// Package roster verifies directory snapshots without granting document access. package roster import ( "crypto/elliptic" "crypto/sha256" "encoding/base64" "errors" "net/url" "strings" "unicode/utf8" family "amadan.net/rastrillo/rastrillo/crypto" "github.com/go-json-experiment/json" ) const ( ProvisioningContext = "oficina/roster/provisioning/v1" PageContext = "oficina/roster/page/v1" PageLimit = 100 MemberLimit = 10000 BodyLimit = 64 << 10 EvidenceLimit = 1 << 20 CursorLimit = 2048 ) var ( ErrRefused = errors.New("roster: refused") ErrUnavailable = errors.New("roster: unavailable") ErrNoEvidence = errors.New("roster: no evidence") ) // Bindings are deployment pins, never request input. type Bindings struct { Issuer string `json:"issuer"` Directory string `json:"directory"` Consumer string `json:"consumer"` App string `json:"app"` Team string `json:"team"` Audience string `json:"audience"` } type Key struct { ID string `json:"id"` Public string `json:"public"` } type Provisioning struct { Version int `json:"version"` Operation string `json:"operation"` Sequence int64 `json:"sequence"` Bindings Bindings `json:"bindings"` Generation string `json:"generation"` MinEpoch int64 `json:"min_epoch"` Keys []Key `json:"keys"` // OwnerConfirmation names the audited operation required for a generation change. OwnerConfirmation string `json:"owner_confirmation"` } // VerifiedRecord can only be built by VerifyProvisioning. After total durable // state loss, old signed records cannot be distinguished locally: recovery must // deliver the latest audited operator record before serving traffic. type VerifiedRecord struct { record Provisioning digest string valid bool } func (r VerifiedRecord) Bindings() Bindings { return r.record.Bindings } type envelope struct { Version int `json:"v"` KeyID string `json:"kid"` Payload string `json:"payload"` Signature string `json:"signature"` } func canonical(s string, n int) ([]byte, bool) { if len(s) > BodyLimit { return nil, false } b, e := base64.RawURLEncoding.DecodeString(s) return b, e == nil && (n < 0 || len(b) == n) && base64.RawURLEncoding.EncodeToString(b) == s } func atom(s string, max int) bool { if len(s) == 0 || len(s) > max { return false } for _, c := range s { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { return false } } return true } func team(s string) bool { if !atom(s, 64) { return false } return !strings.ContainsAny(s, "_-") } func origin(s string) bool { if len(s) > 253 { return false } u, e := url.Parse(s) if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.RawFragment != "" || u.ForceQuery { return false } return u.String() == s && strings.ToLower(u.Host) == u.Host && !strings.ContainsAny(u.Host, "%\\") } func (b Bindings) valid() bool { return origin(b.Issuer) && origin(b.Directory) && origin(b.Audience) && atom(b.Consumer, 128) && atom(b.App, 64) && team(b.Team) } func publicKeys(keys []Key) (map[string][]byte, error) { if len(keys) == 0 || len(keys) > 8 { return nil, ErrRefused } out := map[string][]byte{} for _, k := range keys { b, ok := canonical(k.Public, 65) if !atom(k.ID, 64) || !ok { return nil, ErrRefused } x, _ := elliptic.Unmarshal(elliptic.P256(), b) if x == nil || out[k.ID] != nil { return nil, ErrRefused } out[k.ID] = b } return out, nil } func strict(data []byte, dst any) error { if !utf8.Valid(data) || json.Unmarshal(data, dst, json.RejectUnknownMembers(true)) != nil { return ErrRefused } return nil } func signedPayload(data []byte, keys map[string][]byte, purpose string) ([]byte, error) { if len(data) == 0 || len(data) > BodyLimit { return nil, ErrRefused } var e envelope if strict(data, &e) != nil || e.Version != 1 || !atom(e.KeyID, 64) { return nil, ErrRefused } key, known := keys[e.KeyID] if !known { return nil, ErrRefused } p, ok := canonical(e.Payload, -1) sig, ok2 := canonical(e.Signature, 64) // The signature covers decoded UTF-8 payload bytes, never base64url text. if !ok || !ok2 || !family.Verify(key, purpose, p, sig) { return nil, ErrRefused } return p, nil } func encode(kp *family.Keypair, kid, context string, value any) ([]byte, error) { if kp == nil || kp.SignPriv == nil || !atom(kid, 64) { return nil, ErrRefused } p, e := json.Marshal(value) if e != nil { return nil, ErrRefused } sig, e := family.Sign(kp, context, p) if e != nil { return nil, ErrUnavailable } out, e := json.Marshal(envelope{1, kid, base64.RawURLEncoding.EncodeToString(p), base64.RawURLEncoding.EncodeToString(sig)}) if e != nil || len(out) > BodyLimit { return nil, ErrRefused } return out, nil } func SignProvisioning(kp *family.Keypair, kid string, p Provisioning) ([]byte, error) { return encode(kp, kid, ProvisioningContext, p) } func VerifyProvisioning(data []byte, operatorKeys []Key, expected Bindings) (VerifiedRecord, error) { keys, e := publicKeys(operatorKeys) if e != nil || !expected.valid() { return VerifiedRecord{}, ErrRefused } raw, e := signedPayload(data, keys, ProvisioningContext) if e != nil { return VerifiedRecord{}, ErrRefused } var p Provisioning if strict(raw, &p) != nil || p.Version != 1 || p.Bindings != expected || p.Sequence < 1 || p.MinEpoch < 1 || !atom(p.Operation, 128) { return VerifiedRecord{}, ErrRefused } if _, ok := canonical(p.Generation, 32); !ok { return VerifiedRecord{}, ErrRefused } if p.OwnerConfirmation != "" && !atom(p.OwnerConfirmation, 128) { return VerifiedRecord{}, ErrRefused } if _, e = publicKeys(p.Keys); e != nil { return VerifiedRecord{}, ErrRefused } // Hash the parsed canonical record so insignificant JSON whitespace cannot // create another operation payload. Closed decoding still rejects ambiguity. normalized, _ := json.Marshal(p) return VerifiedRecord{p, hash(normalized), true}, nil } func hash(b []byte) string { s := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(s[:]) } // CursorHash always returns SHA256 of the exact cursor bytes. For page zero it // returns SHA256(empty), encoded as 43 canonical base64url characters. func CursorHash(cursor string) string { return hash([]byte(cursor)) } func ValidChallenge(s string) bool { _, ok := canonical(s, 32); return ok } type MemberEntry struct { Subject string `json:"person_ref"` Role string `json:"role"` } func validMember(m MemberEntry) bool { if len(m.Subject) != 29 || !strings.HasPrefix(m.Subject, "pr_") || (m.Role != "owner" && m.Role != "member") { return false } for _, c := range m.Subject[3:] { if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') { return false } } return true } // Page has a closed wire shape; a conflict omits all page-only fields. type Page struct { Version int `json:"version"` Type string `json:"type"` Directory string `json:"directory"` Consumer string `json:"consumer"` Team string `json:"team"` Audience string `json:"audience"` Fetch string `json:"fetch"` RequestCursorHash string `json:"request_cursor_hash"` Generation string `json:"generation"` Epoch int64 `json:"epoch"` Index *int `json:"page_index,omitzero"` Members *[]MemberEntry `json:"members,omitzero"` Complete *bool `json:"complete,omitzero"` Cursor *string `json:"cursor,omitzero"` } func SignPage(kp *family.Keypair, kid string, p Page) ([]byte, error) { return encode(kp, kid, PageContext, p) } func verifyPage(raw []byte, r VerifiedRecord, fetch, cursor string, status int) (Page, error) { keys, _ := publicKeys(r.record.Keys) p, e := signedPayload(raw, keys, PageContext) var v Page if e != nil || strict(p, &v) != nil { return v, ErrRefused } b := r.record.Bindings 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 { return v, ErrRefused } if _, ok := canonical(v.Generation, 32); !ok { return v, ErrRefused } if status == 409 && v.Type == "stale-cursor" && v.Index == nil && v.Members == nil && v.Complete == nil && v.Cursor == nil { return v, nil } 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 { return v, ErrRefused } if *v.Complete { if v.Cursor != nil { return v, ErrRefused } } else { if len(*v.Members) != PageLimit || v.Cursor == nil || len(*v.Cursor) > CursorLimit || *v.Cursor == "" { return v, ErrRefused } if _, ok := canonical(*v.Cursor, -1); !ok { return v, ErrRefused } } for i, m := range *v.Members { if !validMember(m) || (i > 0 && (*v.Members)[i-1].Subject >= m.Subject) { return v, ErrRefused } } return v, nil }