| 1 | // Package relay reads the platform's CARLOS_RELAY contract and mints |
| 2 | // per-call TURN credentials from it (platform relay design §2, frozen |
| 3 | // at revision 6). It is pure: no logging, no clock, no network. The |
| 4 | // decoded keys live only in memory; nothing here ever formats one. |
| 5 | package relay |
| 6 | |
| 7 | import ( |
| 8 | "crypto/hmac" |
| 9 | "crypto/sha256" |
| 10 | "encoding/base64" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "fmt" |
| 14 | "regexp" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "time" |
| 18 | ) |
| 19 | |
| 20 | // Region is one element of the contract: the relay hosts for one AWS |
| 21 | // region, the 32-byte key the platform issued for this app and |
| 22 | // incarnation, and the username suffix that names account, app, |
| 23 | // incarnation and key id. |
| 24 | type Region struct { |
| 25 | Region string |
| 26 | URLs []string |
| 27 | Key []byte |
| 28 | UsernameSuffix string |
| 29 | } |
| 30 | |
| 31 | // String redacts Key, so a %v or %+v of a Region — a test failure |
| 32 | // message, a debugger, anything that did not mean to log a key — never |
| 33 | // prints one. |
| 34 | func (r Region) String() string { |
| 35 | return fmt.Sprintf("{Region:%s URLs:%v Key:[%d bytes redacted] UsernameSuffix:%s}", r.Region, r.URLs, len(r.Key), r.UsernameSuffix) |
| 36 | } |
| 37 | |
| 38 | // ICEServer is one RTCIceServer as the browser wants it. A stun entry |
| 39 | // carries no username; a turn entry carries the minted pair. |
| 40 | type ICEServer struct { |
| 41 | URLs []string `json:"urls"` |
| 42 | Username string `json:"username,omitempty"` |
| 43 | Credential string `json:"credential,omitempty"` |
| 44 | } |
| 45 | |
| 46 | type wire struct { |
| 47 | Region string `json:"region"` |
| 48 | URLs []string `json:"urls"` |
| 49 | Key string `json:"key"` |
| 50 | UsernameSuffix string `json:"username_suffix"` |
| 51 | } |
| 52 | |
| 53 | var ( |
| 54 | accountRe = regexp.MustCompile(`^[a-z0-9]+$`) |
| 55 | appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| 56 | incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| 57 | kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`) |
| 58 | ) |
| 59 | |
| 60 | // Parse decodes $CARLOS_RELAY. An empty value is no relay (nil, nil). |
| 61 | // Errors name the element index and the field, never the value, so a |
| 62 | // startup log line built from one cannot leak a key or a suffix. |
| 63 | func Parse(raw string) ([]Region, error) { |
| 64 | raw = strings.TrimSpace(raw) |
| 65 | if raw == "" { |
| 66 | return nil, nil |
| 67 | } |
| 68 | b, err := base64.StdEncoding.DecodeString(raw) |
| 69 | if err != nil { |
| 70 | return nil, errors.New("relay: CARLOS_RELAY is not standard base64") |
| 71 | } |
| 72 | var elems []wire |
| 73 | if err := json.Unmarshal(b, &elems); err != nil { |
| 74 | return nil, errors.New("relay: CARLOS_RELAY does not decode to a JSON array of regions") |
| 75 | } |
| 76 | out := make([]Region, 0, len(elems)) |
| 77 | for i, e := range elems { |
| 78 | if e.Region == "" { |
| 79 | return nil, fmt.Errorf("relay: element %d has no region", i) |
| 80 | } |
| 81 | if len(e.URLs) == 0 { |
| 82 | return nil, fmt.Errorf("relay: element %d has no urls", i) |
| 83 | } |
| 84 | for pos, u := range e.URLs { |
| 85 | if u == "" { |
| 86 | return nil, fmt.Errorf("relay: element %d has an empty url at position %d", i, pos) |
| 87 | } |
| 88 | } |
| 89 | key, err := base64.StdEncoding.DecodeString(e.Key) |
| 90 | if err != nil || len(key) != 32 { |
| 91 | return nil, fmt.Errorf("relay: element %d key is not 32 base64 bytes", i) |
| 92 | } |
| 93 | fields := strings.Split(e.UsernameSuffix, ":") |
| 94 | if len(fields) != 4 || !accountRe.MatchString(fields[0]) || !appRe.MatchString(fields[1]) || !incarnationRe.MatchString(fields[2]) || !kidRe.MatchString(fields[3]) { |
| 95 | return nil, fmt.Errorf("relay: element %d username_suffix is not account:app:incarnation:kid", i) |
| 96 | } |
| 97 | out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix}) |
| 98 | } |
| 99 | // An empty array, a JSON null, or an all-whitespace value: all three |
| 100 | // decode without error to zero regions, and all three mean the same |
| 101 | // thing as an absent variable — no relay — so they answer the same |
| 102 | // nil, not a non-nil empty slice one caller happens to get and |
| 103 | // another doesn't. |
| 104 | if len(out) == 0 { |
| 105 | return nil, nil |
| 106 | } |
| 107 | return out, nil |
| 108 | } |
| 109 | |
| 110 | // Credential is base64std(HMAC-SHA256(key, username)), the platform's |
| 111 | // TURN REST scheme. |
| 112 | func Credential(key []byte, username string) string { |
| 113 | mac := hmac.New(sha256.New, key) |
| 114 | mac.Write([]byte(username)) |
| 115 | return base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
| 116 | } |
| 117 | |
| 118 | // Mint builds the RTCIceServer list for one call. Per region, in the |
| 119 | // contract's order: one bare entry for every URL whose scheme is not |
| 120 | // turn or turns (stun, stuns, or a scheme the contract adds later), |
| 121 | // then one credentialed entry for the turn and turns URLs. Either may |
| 122 | // be absent. Expiry is the caller's; §5.2 uses three hours ahead, and |
| 123 | // §5.3's maximum is four; Mint does not enforce either bound — the |
| 124 | // caller owns keeping expiry inside the contract, exactly as the |
| 125 | // caller already owns picking it. The one thing Mint refuses on its |
| 126 | // own is a negative Unix time: that is not a real expiry (TURN's REST |
| 127 | // scheme embeds it as a bare decimal, and a leading "-" would name a |
| 128 | // username no server was issued a credential for), so Mint mints |
| 129 | // nothing rather than a credential built on a nonsense clock. |
| 130 | // |
| 131 | // Mint only reads regions — it never writes to a Region or its slices |
| 132 | // — so many goroutines may call Mint with the same regions |
| 133 | // concurrently; that is exactly the shape a.relayRegions() hands |
| 134 | // concurrent /ice requests. |
| 135 | func Mint(regions []Region, expiry time.Time) []ICEServer { |
| 136 | out := []ICEServer{} |
| 137 | if expiry.Unix() < 0 { |
| 138 | return out |
| 139 | } |
| 140 | username := strconv.FormatInt(expiry.Unix(), 10) |
| 141 | for _, r := range regions { |
| 142 | var bare, turn []string |
| 143 | for _, u := range r.URLs { |
| 144 | switch scheme(u) { |
| 145 | case "turn", "turns": |
| 146 | turn = append(turn, u) |
| 147 | default: |
| 148 | bare = append(bare, u) |
| 149 | } |
| 150 | } |
| 151 | if len(bare) > 0 { |
| 152 | out = append(out, ICEServer{URLs: bare}) |
| 153 | } |
| 154 | if len(turn) > 0 { |
| 155 | name := username + ":" + r.UsernameSuffix |
| 156 | out = append(out, ICEServer{URLs: turn, Username: name, Credential: Credential(r.Key, name)}) |
| 157 | } |
| 158 | } |
| 159 | return out |
| 160 | } |
| 161 | |
| 162 | func scheme(u string) string { |
| 163 | i := strings.IndexByte(u, ':') |
| 164 | if i < 0 { |
| 165 | return "" |
| 166 | } |
| 167 | return strings.ToLower(u[:i]) |
| 168 | } |
| 169 | |