relay: parse CARLOS_RELAY and mint TURN credentials (platform vector)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
2 files changed,
+252
−0
internal/meet/relay/relay.go+133 −0internal/meet/relay/relay_test.go+119 −0
CI passed — run details
diff --git a/internal/meet/relay/relay.go b/internal/meet/relay/relay.go| new file mode 100644 |
| index 0000000..75b528d |
| --- /dev/null |
| +++ b/internal/meet/relay/relay.go |
| @@ -0,0 +1,133 @@ |
| +// Package relay reads the platform's CARLOS_RELAY contract and mints |
| +// per-call TURN credentials from it (platform relay design §2, frozen |
| +// at revision 6). It is pure: no logging, no clock, no network. The |
| +// decoded keys live only in memory; nothing here ever formats one. |
| +package relay |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Region is one element of the contract: the relay hosts for one AWS |
| +// region, the 32-byte key the platform issued for this app and |
| +// incarnation, and the username suffix that names account, app, |
| +// incarnation and key id. |
| +type Region struct { |
| + Region string |
| + URLs []string |
| + Key []byte |
| + UsernameSuffix string |
| +} |
| + |
| +// ICEServer is one RTCIceServer as the browser wants it. A stun entry |
| +// carries no username; a turn entry carries the minted pair. |
| +type ICEServer struct { |
| + URLs []string `json:"urls"` |
| + Username string `json:"username,omitempty"` |
| + Credential string `json:"credential,omitempty"` |
| +} |
| + |
| +type wire struct { |
| + Region string `json:"region"` |
| + URLs []string `json:"urls"` |
| + Key string `json:"key"` |
| + UsernameSuffix string `json:"username_suffix"` |
| +} |
| + |
| +var ( |
| + accountRe = regexp.MustCompile(`^[a-z0-9]+$`) |
| + appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`) |
| +) |
| + |
| +// Parse decodes $CARLOS_RELAY. An empty value is no relay (nil, nil). |
| +// Errors name the element index and the field, never the value, so a |
| +// startup log line built from one cannot leak a key or a suffix. |
| +func Parse(raw string) ([]Region, error) { |
| + raw = strings.TrimSpace(raw) |
| + if raw == "" { |
| + return nil, nil |
| + } |
| + b, err := base64.StdEncoding.DecodeString(raw) |
| + if err != nil { |
| + return nil, errors.New("relay: CARLOS_RELAY is not standard base64") |
| + } |
| + var elems []wire |
| + if err := json.Unmarshal(b, &elems); err != nil { |
| + return nil, errors.New("relay: CARLOS_RELAY does not decode to a JSON array of regions") |
| + } |
| + out := make([]Region, 0, len(elems)) |
| + for i, e := range elems { |
| + if e.Region == "" { |
| + return nil, fmt.Errorf("relay: element %d has no region", i) |
| + } |
| + if len(e.URLs) == 0 { |
| + return nil, fmt.Errorf("relay: element %d (%s) has no urls", i, e.Region) |
| + } |
| + key, err := base64.StdEncoding.DecodeString(e.Key) |
| + if err != nil || len(key) != 32 { |
| + return nil, fmt.Errorf("relay: element %d (%s) key is not 32 base64 bytes", i, e.Region) |
| + } |
| + fields := strings.Split(e.UsernameSuffix, ":") |
| + if len(fields) != 4 || !accountRe.MatchString(fields[0]) || !appRe.MatchString(fields[1]) || !incarnationRe.MatchString(fields[2]) || !kidRe.MatchString(fields[3]) { |
| + return nil, fmt.Errorf("relay: element %d (%s) username_suffix is not account:app:incarnation:kid", i, e.Region) |
| + } |
| + out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix}) |
| + } |
| + return out, nil |
| +} |
| + |
| +// Credential is base64std(HMAC-SHA256(key, username)), the platform's |
| +// TURN REST scheme. |
| +func Credential(key []byte, username string) string { |
| + mac := hmac.New(sha256.New, key) |
| + mac.Write([]byte(username)) |
| + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
| +} |
| + |
| +// Mint builds the RTCIceServer list for one call. Per region, in the |
| +// contract's order: one bare entry for every URL whose scheme is not |
| +// turn or turns (stun, stuns, or a scheme the contract adds later), |
| +// then one credentialed entry for the turn and turns URLs. Either may |
| +// be absent. Expiry is the caller's; §5.2 uses three hours ahead. |
| +func Mint(regions []Region, expiry time.Time) []ICEServer { |
| + out := []ICEServer{} |
| + username := strconv.FormatInt(expiry.Unix(), 10) |
| + for _, r := range regions { |
| + var bare, turn []string |
| + for _, u := range r.URLs { |
| + switch scheme(u) { |
| + case "turn", "turns": |
| + turn = append(turn, u) |
| + default: |
| + bare = append(bare, u) |
| + } |
| + } |
| + if len(bare) > 0 { |
| + out = append(out, ICEServer{URLs: bare}) |
| + } |
| + if len(turn) > 0 { |
| + name := username + ":" + r.UsernameSuffix |
| + out = append(out, ICEServer{URLs: turn, Username: name, Credential: Credential(r.Key, name)}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func scheme(u string) string { |
| + i := strings.IndexByte(u, ':') |
| + if i < 0 { |
| + return "" |
| + } |
| + return strings.ToLower(u[:i]) |
| +} |
diff --git a/internal/meet/relay/relay_test.go b/internal/meet/relay/relay_test.go| new file mode 100644 |
| index 0000000..ce60a34 |
| --- /dev/null |
| +++ b/internal/meet/relay/relay_test.go |
| @@ -0,0 +1,119 @@ |
| +package relay |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "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) |
| + } |
| +} |
| + |
| +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) |
| + } |
| +} |
| + |
| +func TestParseOfNothingIsNoRelay(t *testing.T) { |
| + regions, err := Parse("") |
| + if err != nil || regions != nil { |
| + t.Fatalf("empty must mean no relay: %v %v", regions, err) |
| + } |
| +} |
| + |
| +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 { |
| + t.Run(name, func(t *testing.T) { |
| + _, err := Parse(raw) |
| + if err == nil { |
| + t.Fatal("accepted") |
| + } |
| + if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), raw) { |
| + t.Fatalf("the error echoes the value: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +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 %+v", 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) |
| + } |
| +} |
| + |
| +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) |
| + } |
| +} |