oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package relay
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12)
13
14const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="
15
16func encode(t *testing.T, v any) string {
17 t.Helper()
18 b, err := json.Marshal(v)
19 if err != nil {
20 t.Fatal(err)
21 }
22 return base64.StdEncoding.EncodeToString(b)
23}
24
25func oneRegion() []map[string]any {
26 return []map[string]any{{
27 "region": "eu-west-1",
28 "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp", "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp"},
29 "key": vectorKey,
30 "username_suffix": "abc:meet:0123456789abcdef:k1",
31 }}
32}
33
34func TestThePlatformVectorMints(t *testing.T) {
35 key, _ := base64.StdEncoding.DecodeString(vectorKey)
36 got := Credential(key, "1900000000:abc:meet:0123456789abcdef:k1")
37 if got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
38 t.Fatalf("credential %q does not match the platform vector", got)
39 }
40}
41
42// TestCredentialUsesTheStandardAlphabet pins base64.StdEncoding rather
43// than base64.URLEncoding: the two agree except on '+'/'-' and '/'/'_',
44// so a vector whose digest never needs either byte would pass under
45// either encoding. This username's digest does need one.
46func TestCredentialUsesTheStandardAlphabet(t *testing.T) {
47 key, _ := base64.StdEncoding.DecodeString(vectorKey)
48 const username = "1900000001:abc:meet:0123456789abcdef:k1"
49 got := Credential(key, username)
50 if !strings.ContainsAny(got, "+/") {
51 t.Fatalf("test vector %q does not exercise the standard alphabet, got %q", username, got)
52 }
53 if got != "XcTS/L4xj87J1gqfQHjq1Z3F5iL4iEXlOAqxAmI8Cow=" {
54 t.Fatalf("credential %q does not match the platform vector", got)
55 }
56}
57
58func TestParseAcceptsTheContractAndDecodesTheKey(t *testing.T) {
59 regions, err := Parse(encode(t, oneRegion()))
60 if err != nil {
61 t.Fatal(err)
62 }
63 if len(regions) != 1 {
64 t.Fatalf("parsed %d regions, want 1", len(regions))
65 }
66 r := regions[0]
67 if r.Region != "eu-west-1" || len(r.Key) != 32 || r.UsernameSuffix != "abc:meet:0123456789abcdef:k1" || len(r.URLs) != 3 {
68 // Region, UsernameSuffix and the URL/key lengths are not
69 // secrets; the key bytes themselves are never printed here.
70 t.Fatalf("parsed region=%q keylen=%d suffix=%q urls=%d", r.Region, len(r.Key), r.UsernameSuffix, len(r.URLs))
71 }
72}
73
74func TestParseOfNothingIsNoRelay(t *testing.T) {
75 regions, err := Parse("")
76 if err != nil || regions != nil {
77 t.Fatalf("empty must mean no relay: %v %v", regions, err)
78 }
79}
80
81// TestParseOfAnEmptyArrayOrNullIsNoRelay: an empty array and a JSON
82// null both decode without error to zero regions, and both must mean
83// the same thing an absent variable does — nil, not a non-nil empty
84// slice one caller happens to get and another doesn't.
85func TestParseOfAnEmptyArrayOrNullIsNoRelay(t *testing.T) {
86 for name, body := range map[string]string{"empty array": "[]", "json null": "null"} {
87 t.Run(name, func(t *testing.T) {
88 regions, err := Parse(base64.StdEncoding.EncodeToString([]byte(body)))
89 if err != nil || regions != nil {
90 t.Fatalf("%s must mean no relay: %v %v", name, regions, err)
91 }
92 })
93 }
94}
95
96// TestParseRefusesWithoutEchoingTheValue proves every refusal names the
97// element index (and, for a bad url, its position) and never the value
98// that made it bad. Most cases assert the general "the raw input never
99// appears" property; the cases with a non-nil unwanted also plant a
100// specific secret — the platform vector key — into the one field whose
101// check used to echo it (region, before the fix) or that a careless
102// change to any other field's check could start echoing (key,
103// username_suffix), and into a JSON syntax error, so a leak in any of
104// those paths fails loudly here rather than only in a log line no test
105// is watching.
106func TestParseRefusesWithoutEchoingTheValue(t *testing.T) {
107 cases := map[string]struct {
108 raw string
109 unwanted []string
110 }{
111 "not base64": {"%%%", nil},
112 "not json": {base64.StdEncoding.EncodeToString([]byte("nope")), nil},
113 "not an array": {base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)), nil},
114 "json syntax error": {
115 // Inert by construction, not merely by the fix: a JSON syntax
116 // error from encoding/json ("invalid character '}' looking
117 // for beginning of object key string") never echoes the
118 // input it failed on, with or without this package's own
119 // wrapping. Kept — and its planted needle kept alongside it
120 // — as a tripwire in case that ever stops being true of
121 // encoding/json's own error text.
122 base64.StdEncoding.EncodeToString([]byte(`[{"region":"NEEDLE-SHOULD-NOT-LEAK",}]`)),
123 []string{"NEEDLE-SHOULD-NOT-LEAK"},
124 },
125 "short key": {
126 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": base64.StdEncoding.EncodeToString([]byte("short")), "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
127 nil,
128 },
129 "suffix wrong fields": {
130 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}),
131 nil,
132 },
133 "suffix bad account": {
134 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "ABC:meet:0123456789abcdef:k1"}}),
135 nil,
136 },
137 "suffix bad app": {
138 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:Calls!:0123456789abcdef:k1"}}),
139 nil,
140 },
141 "suffix bad incarnation": {
142 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:XYZ:k1"}}),
143 nil,
144 },
145 "suffix bad kid": {
146 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:toolongkid"}}),
147 nil,
148 },
149 "suffix five fields": {
150 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1:extra"}}),
151 nil,
152 },
153 "suffix trailing newline": {
154 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1\n"}}),
155 nil,
156 },
157 "no urls": {
158 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
159 nil,
160 },
161 "empty url": {
162 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1", ""}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
163 nil,
164 },
165 "empty region": {
166 encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
167 nil,
168 },
169 "region is the vector key": {
170 // Before the fix, the "no urls" and "key" and
171 // "username_suffix" errors all echoed e.Region; this plants
172 // the key there so a regression trips this test, not only a
173 // log line nothing was watching.
174 encode(t, []map[string]any{{"region": vectorKey, "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
175 []string{vectorKey},
176 },
177 "key value carries the vector key": {
178 // Not valid base64 (the trailing junk breaks decoding), but
179 // the vector key is a substring, so a key-check error that
180 // ever echoed e.Key would leak it too.
181 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey + "!!!", "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
182 []string{vectorKey},
183 },
184 "username_suffix is the vector key": {
185 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": vectorKey}}),
186 []string{vectorKey},
187 },
188 // base64.StdEncoding is strict about padding; encoding/base64's
189 // URL- or raw-encoding variants are not what CARLOS_RELAY nor a
190 // region's key are documented to use, so an unpadded value of
191 // either must be refused rather than silently accepted under a
192 // more permissive decoder.
193 "outer value is not padded": {
194 strings.TrimRight(encode(t, oneRegion()), "="),
195 nil,
196 },
197 "key is not padded": {
198 encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": strings.TrimRight(vectorKey, "="), "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
199 nil,
200 },
201 }
202 for name, tc := range cases {
203 t.Run(name, func(t *testing.T) {
204 _, err := Parse(tc.raw)
205 if err == nil {
206 t.Fatal("accepted")
207 }
208 if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), tc.raw) {
209 t.Fatalf("the error echoes the value: %v", err)
210 }
211 for _, secret := range tc.unwanted {
212 if strings.Contains(err.Error(), secret) {
213 t.Fatalf("the error echoes the planted secret %q: %v", secret, err)
214 }
215 }
216 })
217 }
218}
219
220// TestRegionStringRedactsTheKey pins Region's fmt.Stringer against the
221// three verbs a stray Printf/Errorf/log call is most likely to use:
222// %v and %+v both route a struct through String() when it implements
223// fmt.Stringer, and %s does too. None may ever print the key bytes.
224func TestRegionStringRedactsTheKey(t *testing.T) {
225 regions, err := Parse(encode(t, oneRegion()))
226 if err != nil {
227 t.Fatal(err)
228 }
229 r := regions[0]
230 out := fmt.Sprintf("%v %+v %s", r, r, r)
231 if !strings.Contains(out, "redacted") {
232 t.Fatalf("Region's format verbs do not redact the key: %s", out)
233 }
234 key, _ := base64.StdEncoding.DecodeString(vectorKey)
235 if strings.Contains(out, vectorKey) || bytes.Contains([]byte(out), key) {
236 t.Fatalf("Region's format verbs leaked the key: %s", out)
237 }
238}
239
240func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) {
241 regions, err := Parse(encode(t, oneRegion()))
242 if err != nil {
243 t.Fatal(err)
244 }
245 expiry := time.Unix(1900000000, 0)
246 servers := Mint(regions, expiry)
247 if len(servers) != 2 {
248 t.Fatalf("want a bare stun entry and a credentialed turn entry, got %d entries", len(servers))
249 }
250 stun, turn := servers[0], servers[1]
251 if len(stun.URLs) != 1 || stun.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || stun.Username != "" || stun.Credential != "" {
252 t.Fatalf("stun entry %+v", stun)
253 }
254 if len(turn.URLs) != 2 || turn.Username != "1900000000:abc:meet:0123456789abcdef:k1" || turn.Credential != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
255 t.Fatalf("turn entry %+v", turn)
256 }
257}
258
259func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) {
260 two := append(oneRegion(), map[string]any{
261 "region": "us-east-1", "urls": []string{"stuns:relay.us-east-1.oncarlos.com:5349", "turns:relay.us-east-1.oncarlos.com:5349", "future:relay.us-east-1.oncarlos.com:1"},
262 "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
263 })
264 regions, err := Parse(encode(t, two))
265 if err != nil {
266 t.Fatal(err)
267 }
268 servers := Mint(regions, time.Unix(1900000000, 0))
269 // eu-west-1 first (stun, turn), then us-east-1 (bare: stuns+future, credentialed: turns).
270 if len(servers) != 4 || servers[0].URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || servers[2].Username != "" || len(servers[2].URLs) != 2 || servers[3].Username == "" || servers[3].URLs[0] != "turns:relay.us-east-1.oncarlos.com:5349" {
271 t.Fatalf("order or scheme handling wrong: %+v", servers)
272 }
273}
274
275// TestSchemeMatchingIsCaseInsensitive pins scheme()'s case-folding: an
276// uppercase "TURN:" must still be credentialed, not passed through
277// bare, since the contract does not promise the platform lowercases
278// its own URLs.
279func TestSchemeMatchingIsCaseInsensitive(t *testing.T) {
280 region := []map[string]any{{
281 "region": "eu-west-1",
282 "urls": []string{"STUN:relay.eu-west-1.oncarlos.com:3478", "TURN:relay.eu-west-1.oncarlos.com:3478?transport=udp"},
283 "key": vectorKey,
284 "username_suffix": "abc:meet:0123456789abcdef:k1",
285 }}
286 regions, err := Parse(encode(t, region))
287 if err != nil {
288 t.Fatal(err)
289 }
290 servers := Mint(regions, time.Unix(1900000000, 0))
291 if len(servers) != 2 || servers[0].Username != "" || servers[1].Username == "" {
292 t.Fatalf("an uppercase TURN: scheme was not credentialed: %+v", servers)
293 }
294}
295
296func TestMintOfNoRegionsIsAnEmptyListNotNil(t *testing.T) {
297 if got := Mint(nil, time.Now()); got == nil || len(got) != 0 {
298 t.Fatalf("want [] so the JSON is [] not null, got %#v", got)
299 }
300}
301
302// TestMintSkipsANegativeExpiry: a Unix time before the epoch is not a
303// real expiry, and Mint refuses to build a credential on one rather
304// than mint a username with a "-" a TURN server never issued a
305// credential for.
306func TestMintSkipsANegativeExpiry(t *testing.T) {
307 regions, err := Parse(encode(t, oneRegion()))
308 if err != nil {
309 t.Fatal(err)
310 }
311 got := Mint(regions, time.Unix(-1, 0))
312 if got == nil || len(got) != 0 {
313 t.Fatalf("want [], got %#v", got)
314 }
315}
316
317// TestMintDoesNotMutateRegions: Mint only reads regions, so the same
318// slice a.relayRegions() hands out can be read by many concurrent /ice
319// requests without a copy. A deep snapshot taken before Mint, compared
320// after, is the guard against a future edit that starts writing
321// through it.
322func TestMintDoesNotMutateRegions(t *testing.T) {
323 regions, err := Parse(encode(t, oneRegion()))
324 if err != nil {
325 t.Fatal(err)
326 }
327 want := make([]Region, len(regions))
328 for i, r := range regions {
329 want[i] = Region{
330 Region: r.Region,
331 URLs: append([]string(nil), r.URLs...),
332 Key: append([]byte(nil), r.Key...),
333 UsernameSuffix: r.UsernameSuffix,
334 }
335 }
336 _ = Mint(regions, time.Unix(1900000000, 0))
337 if !reflect.DeepEqual(regions, want) {
338 t.Fatal("Mint mutated regions")
339 }
340}
341
342// TestICEServerJSONShapeIsExactlyTheWireContract is the golden shape a
343// native client relies on: a bare entry carries only "urls"; a
344// credentialed entry carries "urls", "username" and "credential" and
345// nothing else — omitempty must not leave a stray key on either side.
346func TestICEServerJSONShapeIsExactlyTheWireContract(t *testing.T) {
347 regions, err := Parse(encode(t, oneRegion()))
348 if err != nil {
349 t.Fatal(err)
350 }
351 servers := Mint(regions, time.Unix(1900000000, 0))
352 b, err := json.Marshal(servers)
353 if err != nil {
354 t.Fatal(err)
355 }
356 var raw []map[string]json.RawMessage
357 if err := json.Unmarshal(b, &raw); err != nil {
358 t.Fatal(err)
359 }
360 if len(raw) != 2 {
361 t.Fatalf("want a bare entry and a turn entry, got %d", len(raw))
362 }
363 if _, ok := raw[0]["urls"]; !ok {
364 t.Fatal("bare entry is missing urls")
365 }
366 for k := range raw[0] {
367 if k != "urls" {
368 t.Fatalf("bare entry carries an extra key %q", k)
369 }
370 }
371 for _, want := range []string{"urls", "username", "credential"} {
372 if _, ok := raw[1][want]; !ok {
373 t.Fatalf("turn entry is missing %q", want)
374 }
375 }
376 for k := range raw[1] {
377 if k != "urls" && k != "username" && k != "credential" {
378 t.Fatalf("turn entry carries an extra key %q", k)
379 }
380 }
381}
382