| 1 | // Package roster verifies directory snapshots without granting document access. |
| 2 | package roster |
| 3 | |
| 4 | import ( |
| 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 | |
| 17 | const ( |
| 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 | |
| 27 | var ( |
| 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. |
| 34 | type 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 | |
| 43 | type Key struct { |
| 44 | ID string `json:"id"` |
| 45 | Public string `json:"public"` |
| 46 | } |
| 47 | |
| 48 | type 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. |
| 63 | type VerifiedRecord struct { |
| 64 | record Provisioning |
| 65 | digest string |
| 66 | valid bool |
| 67 | } |
| 68 | |
| 69 | func (r VerifiedRecord) Bindings() Bindings { return r.record.Bindings } |
| 70 | |
| 71 | type envelope struct { |
| 72 | Version int `json:"v"` |
| 73 | KeyID string `json:"kid"` |
| 74 | Payload string `json:"payload"` |
| 75 | Signature string `json:"signature"` |
| 76 | } |
| 77 | |
| 78 | func 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 | } |
| 85 | func 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 | } |
| 96 | func team(s string) bool { |
| 97 | if !atom(s, 64) { |
| 98 | return false |
| 99 | } |
| 100 | return !strings.ContainsAny(s, "_-") |
| 101 | } |
| 102 | func 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 | } |
| 112 | func (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 | } |
| 115 | func 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 | } |
| 133 | func 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 | } |
| 139 | func 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 | } |
| 159 | func 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 | } |
| 177 | func SignProvisioning(kp *family.Keypair, kid string, p Provisioning) ([]byte, error) { |
| 178 | return encode(kp, kid, ProvisioningContext, p) |
| 179 | } |
| 180 | func 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 | |
| 208 | func 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. |
| 212 | func CursorHash(cursor string) string { return hash([]byte(cursor)) } |
| 213 | func ValidChallenge(s string) bool { _, ok := canonical(s, 32); return ok } |
| 214 | |
| 215 | type MemberEntry struct { |
| 216 | Subject string `json:"person_ref"` |
| 217 | Role string `json:"role"` |
| 218 | } |
| 219 | |
| 220 | func 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. |
| 233 | type 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 | |
| 250 | func SignPage(kp *family.Keypair, kid string, p Page) ([]byte, error) { |
| 251 | return encode(kp, kid, PageContext, p) |
| 252 | } |
| 253 | func 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 | |