| 1 | package relay |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "reflect" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| 15 | |
| 16 | func 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 | |
| 25 | func 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 | |
| 34 | func 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. |
| 46 | func 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 | |
| 58 | func 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 | |
| 74 | func 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. |
| 85 | func 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. |
| 106 | func 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. |
| 224 | func 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 | |
| 240 | func 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 | |
| 259 | func 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. |
| 279 | func 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 | |
| 296 | func 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. |
| 306 | func 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. |
| 322 | func 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. |
| 346 | func 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 | |