oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package roster verifies directory snapshots without granting document access.
2package roster
3
4import (
5 "crypto/elliptic"
6 "crypto/sha256"
7 "encoding/base64"
8 "errors"
9 "net/url"
10 "strings"
11 "unicode/utf8"
12
13 family "amadan.net/rastrillo/rastrillo/crypto"
14 "github.com/go-json-experiment/json"
15)
16
17const (
18 ProvisioningContext = "oficina/roster/provisioning/v1"
19 PageContext = "oficina/roster/page/v1"
20 PageLimit = 100
21 MemberLimit = 10000
22 BodyLimit = 64 << 10
23 EvidenceLimit = 1 << 20
24 CursorLimit = 2048
25)
26
27var (
28 ErrRefused = errors.New("roster: refused")
29 ErrUnavailable = errors.New("roster: unavailable")
30 ErrNoEvidence = errors.New("roster: no evidence")
31)
32
33// Bindings are deployment pins, never request input.
34type Bindings struct {
35 Issuer string `json:"issuer"`
36 Directory string `json:"directory"`
37 Consumer string `json:"consumer"`
38 App string `json:"app"`
39 Team string `json:"team"`
40 Audience string `json:"audience"`
41}
42
43type Key struct {
44 ID string `json:"id"`
45 Public string `json:"public"`
46}
47
48type Provisioning struct {
49 Version int `json:"version"`
50 Operation string `json:"operation"`
51 Sequence int64 `json:"sequence"`
52 Bindings Bindings `json:"bindings"`
53 Generation string `json:"generation"`
54 MinEpoch int64 `json:"min_epoch"`
55 Keys []Key `json:"keys"`
56 // OwnerConfirmation names the audited operation required for a generation change.
57 OwnerConfirmation string `json:"owner_confirmation"`
58}
59
60// VerifiedRecord can only be built by VerifyProvisioning. After total durable
61// state loss, old signed records cannot be distinguished locally: recovery must
62// deliver the latest audited operator record before serving traffic.
63type VerifiedRecord struct {
64 record Provisioning
65 digest string
66 valid bool
67}
68
69func (r VerifiedRecord) Bindings() Bindings { return r.record.Bindings }
70
71type envelope struct {
72 Version int `json:"v"`
73 KeyID string `json:"kid"`
74 Payload string `json:"payload"`
75 Signature string `json:"signature"`
76}
77
78func canonical(s string, n int) ([]byte, bool) {
79 if len(s) > BodyLimit {
80 return nil, false
81 }
82 b, e := base64.RawURLEncoding.DecodeString(s)
83 return b, e == nil && (n < 0 || len(b) == n) && base64.RawURLEncoding.EncodeToString(b) == s
84}
85func atom(s string, max int) bool {
86 if len(s) == 0 || len(s) > max {
87 return false
88 }
89 for _, c := range s {
90 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
91 return false
92 }
93 }
94 return true
95}
96func team(s string) bool {
97 if !atom(s, 64) {
98 return false
99 }
100 return !strings.ContainsAny(s, "_-")
101}
102func origin(s string) bool {
103 if len(s) > 253 {
104 return false
105 }
106 u, e := url.Parse(s)
107 if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.RawFragment != "" || u.ForceQuery {
108 return false
109 }
110 return u.String() == s && strings.ToLower(u.Host) == u.Host && !strings.ContainsAny(u.Host, "%\\")
111}
112func (b Bindings) valid() bool {
113 return origin(b.Issuer) && origin(b.Directory) && origin(b.Audience) && atom(b.Consumer, 128) && atom(b.App, 64) && team(b.Team)
114}
115func publicKeys(keys []Key) (map[string][]byte, error) {
116 if len(keys) == 0 || len(keys) > 8 {
117 return nil, ErrRefused
118 }
119 out := map[string][]byte{}
120 for _, k := range keys {
121 b, ok := canonical(k.Public, 65)
122 if !atom(k.ID, 64) || !ok {
123 return nil, ErrRefused
124 }
125 x, _ := elliptic.Unmarshal(elliptic.P256(), b)
126 if x == nil || out[k.ID] != nil {
127 return nil, ErrRefused
128 }
129 out[k.ID] = b
130 }
131 return out, nil
132}
133func strict(data []byte, dst any) error {
134 if !utf8.Valid(data) || json.Unmarshal(data, dst, json.RejectUnknownMembers(true)) != nil {
135 return ErrRefused
136 }
137 return nil
138}
139func signedPayload(data []byte, keys map[string][]byte, purpose string) ([]byte, error) {
140 if len(data) == 0 || len(data) > BodyLimit {
141 return nil, ErrRefused
142 }
143 var e envelope
144 if strict(data, &e) != nil || e.Version != 1 || !atom(e.KeyID, 64) {
145 return nil, ErrRefused
146 }
147 key, known := keys[e.KeyID]
148 if !known {
149 return nil, ErrRefused
150 }
151 p, ok := canonical(e.Payload, -1)
152 sig, ok2 := canonical(e.Signature, 64)
153 // The signature covers decoded UTF-8 payload bytes, never base64url text.
154 if !ok || !ok2 || !family.Verify(key, purpose, p, sig) {
155 return nil, ErrRefused
156 }
157 return p, nil
158}
159func encode(kp *family.Keypair, kid, context string, value any) ([]byte, error) {
160 if kp == nil || kp.SignPriv == nil || !atom(kid, 64) {
161 return nil, ErrRefused
162 }
163 p, e := json.Marshal(value)
164 if e != nil {
165 return nil, ErrRefused
166 }
167 sig, e := family.Sign(kp, context, p)
168 if e != nil {
169 return nil, ErrUnavailable
170 }
171 out, e := json.Marshal(envelope{1, kid, base64.RawURLEncoding.EncodeToString(p), base64.RawURLEncoding.EncodeToString(sig)})
172 if e != nil || len(out) > BodyLimit {
173 return nil, ErrRefused
174 }
175 return out, nil
176}
177func SignProvisioning(kp *family.Keypair, kid string, p Provisioning) ([]byte, error) {
178 return encode(kp, kid, ProvisioningContext, p)
179}
180func VerifyProvisioning(data []byte, operatorKeys []Key, expected Bindings) (VerifiedRecord, error) {
181 keys, e := publicKeys(operatorKeys)
182 if e != nil || !expected.valid() {
183 return VerifiedRecord{}, ErrRefused
184 }
185 raw, e := signedPayload(data, keys, ProvisioningContext)
186 if e != nil {
187 return VerifiedRecord{}, ErrRefused
188 }
189 var p Provisioning
190 if strict(raw, &p) != nil || p.Version != 1 || p.Bindings != expected || p.Sequence < 1 || p.MinEpoch < 1 || !atom(p.Operation, 128) {
191 return VerifiedRecord{}, ErrRefused
192 }
193 if _, ok := canonical(p.Generation, 32); !ok {
194 return VerifiedRecord{}, ErrRefused
195 }
196 if p.OwnerConfirmation != "" && !atom(p.OwnerConfirmation, 128) {
197 return VerifiedRecord{}, ErrRefused
198 }
199 if _, e = publicKeys(p.Keys); e != nil {
200 return VerifiedRecord{}, ErrRefused
201 }
202 // Hash the parsed canonical record so insignificant JSON whitespace cannot
203 // create another operation payload. Closed decoding still rejects ambiguity.
204 normalized, _ := json.Marshal(p)
205 return VerifiedRecord{p, hash(normalized), true}, nil
206}
207
208func hash(b []byte) string { s := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(s[:]) }
209
210// CursorHash always returns SHA256 of the exact cursor bytes. For page zero it
211// returns SHA256(empty), encoded as 43 canonical base64url characters.
212func CursorHash(cursor string) string { return hash([]byte(cursor)) }
213func ValidChallenge(s string) bool { _, ok := canonical(s, 32); return ok }
214
215type MemberEntry struct {
216 Subject string `json:"person_ref"`
217 Role string `json:"role"`
218}
219
220func validMember(m MemberEntry) bool {
221 if len(m.Subject) != 29 || !strings.HasPrefix(m.Subject, "pr_") || (m.Role != "owner" && m.Role != "member") {
222 return false
223 }
224 for _, c := range m.Subject[3:] {
225 if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') {
226 return false
227 }
228 }
229 return true
230}
231
232// Page has a closed wire shape; a conflict omits all page-only fields.
233type Page struct {
234 Version int `json:"version"`
235 Type string `json:"type"`
236 Directory string `json:"directory"`
237 Consumer string `json:"consumer"`
238 Team string `json:"team"`
239 Audience string `json:"audience"`
240 Fetch string `json:"fetch"`
241 RequestCursorHash string `json:"request_cursor_hash"`
242 Generation string `json:"generation"`
243 Epoch int64 `json:"epoch"`
244 Index *int `json:"page_index,omitzero"`
245 Members *[]MemberEntry `json:"members,omitzero"`
246 Complete *bool `json:"complete,omitzero"`
247 Cursor *string `json:"cursor,omitzero"`
248}
249
250func SignPage(kp *family.Keypair, kid string, p Page) ([]byte, error) {
251 return encode(kp, kid, PageContext, p)
252}
253func verifyPage(raw []byte, r VerifiedRecord, fetch, cursor string, status int) (Page, error) {
254 keys, _ := publicKeys(r.record.Keys)
255 p, e := signedPayload(raw, keys, PageContext)
256 var v Page
257 if e != nil || strict(p, &v) != nil {
258 return v, ErrRefused
259 }
260 b := r.record.Bindings
261 if v.Version != 1 || v.Directory != b.Directory || v.Consumer != b.Consumer || v.Team != b.Team || v.Audience != b.Audience || v.Fetch != fetch || !ValidChallenge(v.Fetch) || v.RequestCursorHash != CursorHash(cursor) || v.Epoch < 1 {
262 return v, ErrRefused
263 }
264 if _, ok := canonical(v.Generation, 32); !ok {
265 return v, ErrRefused
266 }
267 if status == 409 && v.Type == "stale-cursor" && v.Index == nil && v.Members == nil && v.Complete == nil && v.Cursor == nil {
268 return v, nil
269 }
270 if status != 200 || v.Type != "page" || v.Index == nil || v.Members == nil || v.Complete == nil || *v.Index < 0 || *v.Index >= 100 || len(*v.Members) > PageLimit {
271 return v, ErrRefused
272 }
273 if *v.Complete {
274 if v.Cursor != nil {
275 return v, ErrRefused
276 }
277 } else {
278 if len(*v.Members) != PageLimit || v.Cursor == nil || len(*v.Cursor) > CursorLimit || *v.Cursor == "" {
279 return v, ErrRefused
280 }
281 if _, ok := canonical(*v.Cursor, -1); !ok {
282 return v, ErrRefused
283 }
284 }
285 for i, m := range *v.Members {
286 if !validMember(m) || (i > 0 && (*v.Members)[i-1].Subject >= m.Subject) {
287 return v, ErrRefused
288 }
289 }
290 return v, nil
291}
292