| index ce60a34..d18a306 100644 |
| --- a/internal/meet/relay/relay_test.go |
| +++ b/internal/meet/relay/relay_test.go |
| @@ -3,6 +3,7 @@ package relay |
| import ( |
| "encoding/base64" |
| "encoding/json" |
| + "reflect" |
| "strings" |
| "testing" |
| "time" |
| @@ -36,13 +37,35 @@ func TestThePlatformVectorMints(t *testing.T) { |
| } |
| } |
| |
| +// 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 || regions[0].Region != "eu-west-1" || len(regions[0].Key) != 32 || regions[0].UsernameSuffix != "abc:meet:0123456789abcdef:k1" || len(regions[0].URLs) != 3 { |
| - t.Fatalf("parsed %+v", regions) |
| + 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)) |
| } |
| } |
| |
| @@ -53,26 +76,121 @@ func TestParseOfNothingIsNoRelay(t *testing.T) { |
| } |
| } |
| |
| +// 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]string{ |
| - "not base64": "%%%", |
| - "not json": base64.StdEncoding.EncodeToString([]byte("nope")), |
| - "not an array": base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)), |
| - "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"}}), |
| - "suffix wrong fields": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}), |
| - "suffix bad app": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:Meet!:0123456789abcdef:k1"}}), |
| - "no urls": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), |
| - "empty region": encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), |
| - } |
| - for name, raw := range cases { |
| + 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": { |
| + 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:Meet!: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}, |
| + }, |
| + } |
| + for name, tc := range cases { |
| t.Run(name, func(t *testing.T) { |
| - _, err := Parse(raw) |
| + _, 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(), raw) { |
| + 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) |
| + } |
| + } |
| }) |
| } |
| } |
| @@ -85,7 +203,7 @@ func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) { |
| 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 %+v", servers) |
| + 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 != "" { |
| @@ -112,8 +230,102 @@ func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) { |
| } |
| } |
| |
| +// 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)) |
| + } |
| + for k := range raw[0] { |
| + if k != "urls" { |
| + t.Fatalf("bare entry carries an extra key %q", k) |
| + } |
| + } |
| + for k := range raw[1] { |
| + if k != "urls" && k != "username" && k != "credential" { |
| + t.Fatalf("turn entry carries an extra key %q", k) |
| + } |
| + } |
| +} |