oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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.
5package relay
6
7import (
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.
24type 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.
34func (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.
40type ICEServer struct {
41 URLs []string `json:"urls"`
42 Username string `json:"username,omitempty"`
43 Credential string `json:"credential,omitempty"`
44}
45
46type 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
53var (
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.
63func 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.
112func 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.
135func 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
162func scheme(u string) string {
163 i := strings.IndexByte(u, ':')
164 if i < 0 {
165 return ""
166 }
167 return strings.ToLower(u[:i])
168}
169