package relay import ( "bytes" "encoding/base64" "encoding/json" "fmt" "reflect" "strings" "testing" "time" ) const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" func encode(t *testing.T, v any) string { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return base64.StdEncoding.EncodeToString(b) } func oneRegion() []map[string]any { return []map[string]any{{ "region": "eu-west-1", "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"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", }} } func TestThePlatformVectorMints(t *testing.T) { key, _ := base64.StdEncoding.DecodeString(vectorKey) got := Credential(key, "1900000000:abc:meet:0123456789abcdef:k1") if got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" { t.Fatalf("credential %q does not match the platform vector", got) } } // TestCredentialUsesTheStandardAlphabet pins base64.StdEncoding rather // than base64.URLEncoding: the two agree except on '+'/'-' and '/'/'_', // so a vector whose digest never needs either byte would pass under // either encoding. This username's digest does need one. func TestCredentialUsesTheStandardAlphabet(t *testing.T) { key, _ := base64.StdEncoding.DecodeString(vectorKey) const username = "1900000001:abc:meet:0123456789abcdef:k1" got := Credential(key, username) if !strings.ContainsAny(got, "+/") { t.Fatalf("test vector %q does not exercise the standard alphabet, got %q", username, got) } if got != "XcTS/L4xj87J1gqfQHjq1Z3F5iL4iEXlOAqxAmI8Cow=" { t.Fatalf("credential %q does not match the platform vector", got) } } func TestParseAcceptsTheContractAndDecodesTheKey(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } if len(regions) != 1 { t.Fatalf("parsed %d regions, want 1", len(regions)) } r := regions[0] if r.Region != "eu-west-1" || len(r.Key) != 32 || r.UsernameSuffix != "abc:meet:0123456789abcdef:k1" || len(r.URLs) != 3 { // Region, UsernameSuffix and the URL/key lengths are not // secrets; the key bytes themselves are never printed here. t.Fatalf("parsed region=%q keylen=%d suffix=%q urls=%d", r.Region, len(r.Key), r.UsernameSuffix, len(r.URLs)) } } func TestParseOfNothingIsNoRelay(t *testing.T) { regions, err := Parse("") if err != nil || regions != nil { t.Fatalf("empty must mean no relay: %v %v", regions, err) } } // TestParseOfAnEmptyArrayOrNullIsNoRelay: an empty array and a JSON // null both decode without error to zero regions, and both must mean // the same thing an absent variable does — nil, not a non-nil empty // slice one caller happens to get and another doesn't. func TestParseOfAnEmptyArrayOrNullIsNoRelay(t *testing.T) { for name, body := range map[string]string{"empty array": "[]", "json null": "null"} { t.Run(name, func(t *testing.T) { regions, err := Parse(base64.StdEncoding.EncodeToString([]byte(body))) if err != nil || regions != nil { t.Fatalf("%s must mean no relay: %v %v", name, regions, err) } }) } } // TestParseRefusesWithoutEchoingTheValue proves every refusal names the // element index (and, for a bad url, its position) and never the value // that made it bad. Most cases assert the general "the raw input never // appears" property; the cases with a non-nil unwanted also plant a // specific secret — the platform vector key — into the one field whose // check used to echo it (region, before the fix) or that a careless // change to any other field's check could start echoing (key, // username_suffix), and into a JSON syntax error, so a leak in any of // those paths fails loudly here rather than only in a log line no test // is watching. func TestParseRefusesWithoutEchoingTheValue(t *testing.T) { cases := map[string]struct { raw string unwanted []string }{ "not base64": {"%%%", nil}, "not json": {base64.StdEncoding.EncodeToString([]byte("nope")), nil}, "not an array": {base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)), nil}, "json syntax error": { // Inert by construction, not merely by the fix: a JSON syntax // error from encoding/json ("invalid character '}' looking // for beginning of object key string") never echoes the // input it failed on, with or without this package's own // wrapping. Kept — and its planted needle kept alongside it // — as a tripwire in case that ever stops being true of // encoding/json's own error text. base64.StdEncoding.EncodeToString([]byte(`[{"region":"NEEDLE-SHOULD-NOT-LEAK",}]`)), []string{"NEEDLE-SHOULD-NOT-LEAK"}, }, "short key": { 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"}}), nil, }, "suffix wrong fields": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}), nil, }, "suffix bad account": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "ABC:meet:0123456789abcdef:k1"}}), nil, }, "suffix bad app": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:Calls!:0123456789abcdef:k1"}}), nil, }, "suffix bad incarnation": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:XYZ:k1"}}), nil, }, "suffix bad kid": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:toolongkid"}}), nil, }, "suffix five fields": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1:extra"}}), nil, }, "suffix trailing newline": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1\n"}}), nil, }, "no urls": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), nil, }, "empty url": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1", ""}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), nil, }, "empty region": { encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), nil, }, "region is the vector key": { // Before the fix, the "no urls" and "key" and // "username_suffix" errors all echoed e.Region; this plants // the key there so a regression trips this test, not only a // log line nothing was watching. encode(t, []map[string]any{{"region": vectorKey, "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), []string{vectorKey}, }, "key value carries the vector key": { // Not valid base64 (the trailing junk breaks decoding), but // the vector key is a substring, so a key-check error that // ever echoed e.Key would leak it too. encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey + "!!!", "username_suffix": "abc:meet:0123456789abcdef:k1"}}), []string{vectorKey}, }, "username_suffix is the vector key": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": vectorKey}}), []string{vectorKey}, }, // base64.StdEncoding is strict about padding; encoding/base64's // URL- or raw-encoding variants are not what CARLOS_RELAY nor a // region's key are documented to use, so an unpadded value of // either must be refused rather than silently accepted under a // more permissive decoder. "outer value is not padded": { strings.TrimRight(encode(t, oneRegion()), "="), nil, }, "key is not padded": { encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": strings.TrimRight(vectorKey, "="), "username_suffix": "abc:meet:0123456789abcdef:k1"}}), nil, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { _, err := Parse(tc.raw) if err == nil { t.Fatal("accepted") } if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), tc.raw) { t.Fatalf("the error echoes the value: %v", err) } for _, secret := range tc.unwanted { if strings.Contains(err.Error(), secret) { t.Fatalf("the error echoes the planted secret %q: %v", secret, err) } } }) } } // TestRegionStringRedactsTheKey pins Region's fmt.Stringer against the // three verbs a stray Printf/Errorf/log call is most likely to use: // %v and %+v both route a struct through String() when it implements // fmt.Stringer, and %s does too. None may ever print the key bytes. func TestRegionStringRedactsTheKey(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } r := regions[0] out := fmt.Sprintf("%v %+v %s", r, r, r) if !strings.Contains(out, "redacted") { t.Fatalf("Region's format verbs do not redact the key: %s", out) } key, _ := base64.StdEncoding.DecodeString(vectorKey) if strings.Contains(out, vectorKey) || bytes.Contains([]byte(out), key) { t.Fatalf("Region's format verbs leaked the key: %s", out) } } func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } expiry := time.Unix(1900000000, 0) servers := Mint(regions, expiry) if len(servers) != 2 { t.Fatalf("want a bare stun entry and a credentialed turn entry, got %d entries", len(servers)) } stun, turn := servers[0], servers[1] if len(stun.URLs) != 1 || stun.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || stun.Username != "" || stun.Credential != "" { t.Fatalf("stun entry %+v", stun) } if len(turn.URLs) != 2 || turn.Username != "1900000000:abc:meet:0123456789abcdef:k1" || turn.Credential != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" { t.Fatalf("turn entry %+v", turn) } } func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) { two := append(oneRegion(), map[string]any{ "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"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", }) regions, err := Parse(encode(t, two)) if err != nil { t.Fatal(err) } servers := Mint(regions, time.Unix(1900000000, 0)) // eu-west-1 first (stun, turn), then us-east-1 (bare: stuns+future, credentialed: turns). 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" { t.Fatalf("order or scheme handling wrong: %+v", servers) } } // TestSchemeMatchingIsCaseInsensitive pins scheme()'s case-folding: an // uppercase "TURN:" must still be credentialed, not passed through // bare, since the contract does not promise the platform lowercases // its own URLs. func TestSchemeMatchingIsCaseInsensitive(t *testing.T) { region := []map[string]any{{ "region": "eu-west-1", "urls": []string{"STUN:relay.eu-west-1.oncarlos.com:3478", "TURN:relay.eu-west-1.oncarlos.com:3478?transport=udp"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", }} regions, err := Parse(encode(t, region)) if err != nil { t.Fatal(err) } servers := Mint(regions, time.Unix(1900000000, 0)) if len(servers) != 2 || servers[0].Username != "" || servers[1].Username == "" { t.Fatalf("an uppercase TURN: scheme was not credentialed: %+v", servers) } } func TestMintOfNoRegionsIsAnEmptyListNotNil(t *testing.T) { if got := Mint(nil, time.Now()); got == nil || len(got) != 0 { t.Fatalf("want [] so the JSON is [] not null, got %#v", got) } } // TestMintSkipsANegativeExpiry: a Unix time before the epoch is not a // real expiry, and Mint refuses to build a credential on one rather // than mint a username with a "-" a TURN server never issued a // credential for. func TestMintSkipsANegativeExpiry(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } got := Mint(regions, time.Unix(-1, 0)) if got == nil || len(got) != 0 { t.Fatalf("want [], got %#v", got) } } // TestMintDoesNotMutateRegions: Mint only reads regions, so the same // slice a.relayRegions() hands out can be read by many concurrent /ice // requests without a copy. A deep snapshot taken before Mint, compared // after, is the guard against a future edit that starts writing // through it. func TestMintDoesNotMutateRegions(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } want := make([]Region, len(regions)) for i, r := range regions { want[i] = Region{ Region: r.Region, URLs: append([]string(nil), r.URLs...), Key: append([]byte(nil), r.Key...), UsernameSuffix: r.UsernameSuffix, } } _ = Mint(regions, time.Unix(1900000000, 0)) if !reflect.DeepEqual(regions, want) { t.Fatal("Mint mutated regions") } } // TestICEServerJSONShapeIsExactlyTheWireContract is the golden shape a // native client relies on: a bare entry carries only "urls"; a // credentialed entry carries "urls", "username" and "credential" and // nothing else — omitempty must not leave a stray key on either side. func TestICEServerJSONShapeIsExactlyTheWireContract(t *testing.T) { regions, err := Parse(encode(t, oneRegion())) if err != nil { t.Fatal(err) } servers := Mint(regions, time.Unix(1900000000, 0)) b, err := json.Marshal(servers) if err != nil { t.Fatal(err) } var raw []map[string]json.RawMessage if err := json.Unmarshal(b, &raw); err != nil { t.Fatal(err) } if len(raw) != 2 { t.Fatalf("want a bare entry and a turn entry, got %d", len(raw)) } if _, ok := raw[0]["urls"]; !ok { t.Fatal("bare entry is missing urls") } for k := range raw[0] { if k != "urls" { t.Fatalf("bare entry carries an extra key %q", k) } } for _, want := range []string{"urls", "username", "credential"} { if _, ok := raw[1][want]; !ok { t.Fatalf("turn entry is missing %q", want) } } for k := range raw[1] { if k != "urls" && k != "username" && k != "credential" { t.Fatalf("turn entry carries an extra key %q", k) } } }