// 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 } // String redacts Key, so a %v or %+v of a Region — a test failure // message, a debugger, anything that did not mean to log a key — never // prints one. func (r Region) String() string { return fmt.Sprintf("{Region:%s URLs:%v Key:[%d bytes redacted] UsernameSuffix:%s}", r.Region, r.URLs, len(r.Key), r.UsernameSuffix) } // 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 has no urls", i) } for pos, u := range e.URLs { if u == "" { return nil, fmt.Errorf("relay: element %d has an empty url at position %d", i, pos) } } key, err := base64.StdEncoding.DecodeString(e.Key) if err != nil || len(key) != 32 { return nil, fmt.Errorf("relay: element %d key is not 32 base64 bytes", i) } 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 username_suffix is not account:app:incarnation:kid", i) } out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix}) } // An empty array, a JSON null, or an all-whitespace value: all three // decode without error to zero regions, and all three mean the same // thing as an absent variable — no relay — so they answer the same // nil, not a non-nil empty slice one caller happens to get and // another doesn't. if len(out) == 0 { return nil, nil } 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, and // §5.3's maximum is four; Mint does not enforce either bound — the // caller owns keeping expiry inside the contract, exactly as the // caller already owns picking it. The one thing Mint refuses on its // own is a negative Unix time: that is not a real expiry (TURN's REST // scheme embeds it as a bare decimal, and a leading "-" would name a // username no server was issued a credential for), so Mint mints // nothing rather than a credential built on a nonsense clock. // // Mint only reads regions — it never writes to a Region or its slices // — so many goroutines may call Mint with the same regions // concurrently; that is exactly the shape a.relayRegions() hands // concurrent /ice requests. func Mint(regions []Region, expiry time.Time) []ICEServer { out := []ICEServer{} if expiry.Unix() < 0 { return out } 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]) }