Add signed roster verification and bounded membership cache
7 files changed,
+1591
−0
Makefile+4 −0roster/cache.go+495 −0roster/cache_test.go+475 −0roster/go.mod+9 −0roster/go.sum+26 −0roster/protocol.go+287 −0roster/protocol_test.go+295 −0
CI passed — run details
diff --git a/Makefile b/Makefile| index 37690c1..9bb9a7b 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -23,6 +23,7 @@ test: test-vet test-fmt test-build test-go test-race |
| test-vet: |
| CGO_ENABLED=0 go vet ./... |
| cd handoff && CGO_ENABLED=0 go vet ./... |
| + cd roster && CGO_ENABLED=0 go vet ./... |
| # Fails on unformatted files rather than rewriting them. CI must never |
| # silently "fix" a diff out from under a branch — a red test-fmt tells |
| @@ -37,10 +38,12 @@ test-fmt: |
| test-build: |
| CGO_ENABLED=0 go build ./... |
| cd handoff && CGO_ENABLED=0 go build ./... |
| + cd roster && CGO_ENABLED=0 go build ./... |
| test-go: |
| CGO_ENABLED=0 go test -count=1 ./... |
| cd handoff && CGO_ENABLED=0 go test -count=1 ./... |
| + cd roster && CGO_ENABLED=0 go test -count=1 ./... |
| # The one concurrency-bearing package. internal/home's link store is |
| # taken by a single DELETE ... RETURNING precisely so that two clicks on |
| @@ -55,3 +58,4 @@ test-go: |
| test-race: |
| go test -race -count=1 ./internal/home/ |
| cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... |
| + cd roster && CGO_ENABLED=1 go test -race -count=1 ./... |
diff --git a/roster/cache.go b/roster/cache.go| new file mode 100644 |
| index 0000000..1c645ea |
| --- /dev/null |
| +++ b/roster/cache.go |
| @@ -0,0 +1,495 @@ |
| +package roster |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "io" |
| + "net/http" |
| + "net/url" |
| + "sync" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +const ( |
| + RefreshAfter = 30 * time.Second |
| + MaximumAge = 60 * time.Second |
| + FetchTimeout = 5 * time.Second |
| + RetryAfter = 5 * time.Second |
| + // A backward wall-clock discrepancy beyond two seconds fences standing. |
| + // Comparing wall advancement to monotonic advancement tolerates ordinary slew. |
| + ClockTolerance = 2 * time.Second |
| + recordLimit = 1024 |
| +) |
| + |
| +// Clock returns wall time and monotonic elapsed time from one observation. The |
| +// elapsed value must never go backwards. Nil Config.Clock uses Go's monotonic clock. |
| +type Clock interface { |
| + Now() (time.Time, time.Duration) |
| +} |
| +type realClock struct{ start time.Time } |
| + |
| +func (c realClock) Now() (time.Time, time.Duration) { n := time.Now(); return n, n.Sub(c.start) } |
| + |
| +// Durable stores one opaque envelope. Save must atomically replace it, retaining |
| +// the previous value on error. Load reports ErrNoEvidence only for absent data. |
| +// Its implementation must honor context cancellation and use bounded local IO. |
| +type Durable interface { |
| + Load(context.Context) ([]byte, error) |
| + Save(context.Context, []byte) error |
| +} |
| + |
| +type Config struct { |
| + Record VerifiedRecord |
| + Token string |
| + Transport http.RoundTripper |
| + Durable Durable |
| + Sealer handoff.Sealer |
| + Clock Clock |
| +} |
| + |
| +type State uint8 |
| + |
| +const ( |
| + Unknown State = iota |
| + NotMember |
| + Member |
| +) |
| + |
| +type Decision struct { |
| + State State |
| + Role string |
| + Lease Lease |
| +} |
| + |
| +// Lease is an observation, not a stored authorization grant. Call Valid at the |
| +// protected operation boundary; stream users must recheck and re-arm their timer |
| +// from a new decision after renewal, or close when renewal cannot authorize it. |
| +type Lease struct { |
| + cache *Cache |
| + version uint64 |
| + deadline time.Duration |
| +} |
| + |
| +func (l Lease) Valid() bool { |
| + if l.cache == nil { |
| + return false |
| + } |
| + c := l.cache |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + _, n := c.observe() |
| + return c.version == l.version && c.usable && n < l.deadline |
| +} |
| +func (l Lease) Remaining() time.Duration { |
| + if l.cache == nil { |
| + return 0 |
| + } |
| + c := l.cache |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + _, n := c.observe() |
| + if c.version != l.version || !c.usable || n >= l.deadline { |
| + return 0 |
| + } |
| + return l.deadline - n |
| +} |
| + |
| +type receipt struct { |
| + Operation string `json:"operation"` |
| + Digest string `json:"digest"` |
| +} |
| +type evidence struct { |
| + Snapshot bool `json:"snapshot"` |
| + Blocked bool `json:"blocked"` |
| + Version int `json:"version"` |
| + Sequence int64 `json:"sequence"` |
| + RecordDigest string `json:"record_digest"` |
| + Generation string `json:"generation"` |
| + Epoch int64 `json:"epoch"` |
| + Members []MemberEntry `json:"members"` |
| + Digest string `json:"digest"` |
| + Receipts []receipt `json:"receipts"` |
| +} |
| + |
| +type flight struct { |
| + done chan struct{} |
| + err error |
| + cancel context.CancelFunc |
| +} |
| +type Cache struct { |
| + mu sync.Mutex |
| + record VerifiedRecord |
| + token string |
| + client *http.Client |
| + durable Durable |
| + sealer handoff.Sealer |
| + purpose string |
| + clock Clock |
| + seen bool |
| + wall time.Time |
| + mono time.Duration |
| + version uint64 |
| + usable bool |
| + start time.Duration |
| + members map[string]string |
| + evidence evidence |
| + flight *flight |
| + attempted bool |
| + lastAttempt time.Duration |
| +} |
| + |
| +func New(ctx context.Context, cfg Config) (*Cache, error) { |
| + if !cfg.Record.valid || !ValidChallenge(cfg.Token) || cfg.Durable == nil || cfg.Sealer == nil { |
| + return nil, ErrRefused |
| + } |
| + if cfg.Clock == nil { |
| + cfg.Clock = realClock{time.Now()} |
| + } |
| + if cfg.Transport == nil { |
| + tr := http.DefaultTransport.(*http.Transport).Clone() |
| + tr.DisableCompression = true |
| + cfg.Transport = tr |
| + } |
| + b, _ := json.Marshal(cfg.Record.record.Bindings) |
| + c := &Cache{record: cfg.Record, token: cfg.Token, client: &http.Client{Transport: cfg.Transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, durable: cfg.Durable, sealer: cfg.Sealer, purpose: "oficina/roster/evidence/v1/" + hash(b), clock: cfg.Clock, version: 1} |
| + raw, e := cfg.Durable.Load(ctx) |
| + if e != nil && e != ErrNoEvidence { |
| + return nil, ErrUnavailable |
| + } |
| + if e == nil { |
| + if len(raw) > EvidenceLimit+128 { |
| + return nil, ErrRefused |
| + } |
| + plain, err := cfg.Sealer.Open(c.purpose, raw) |
| + if err != nil || len(plain) > EvidenceLimit || strict(plain, &c.evidence) != nil || !validEvidence(c.evidence) { |
| + return nil, ErrRefused |
| + } |
| + } |
| + ev, e := nextEvidence(c.evidence, cfg.Record) |
| + if e != nil { |
| + return nil, e |
| + } |
| + if e = c.save(ctx, ev); e != nil { |
| + return nil, e |
| + } |
| + c.evidence = ev |
| + return c, nil |
| +} |
| +func validEvidence(e evidence) bool { |
| + if e.Version != 1 || e.Sequence < 1 || e.Epoch < 1 || !ValidChallenge(e.RecordDigest) || !ValidChallenge(e.Generation) || len(e.Receipts) == 0 || len(e.Receipts) > recordLimit || len(e.Members) > MemberLimit { |
| + return false |
| + } |
| + seen := map[string]bool{} |
| + for _, r := range e.Receipts { |
| + if !atom(r.Operation, 128) || !ValidChallenge(r.Digest) || seen[r.Operation] { |
| + return false |
| + } |
| + seen[r.Operation] = true |
| + } |
| + for i, m := range e.Members { |
| + if !validMember(m) || (i > 0 && e.Members[i-1].Subject >= m.Subject) { |
| + return false |
| + } |
| + } |
| + return e.Digest == memberDigest(e.Members) |
| +} |
| +func memberDigest(m []MemberEntry) string { |
| + if m == nil { |
| + m = []MemberEntry{} |
| + } |
| + b, _ := json.Marshal(m) |
| + return hash(b) |
| +} |
| +func nextEvidence(old evidence, r VerifiedRecord) (evidence, error) { |
| + p := r.record |
| + if old.Version != 0 { |
| + if p.Sequence < old.Sequence { |
| + return evidence{}, ErrRefused |
| + } |
| + if p.Sequence == old.Sequence { |
| + if r.digest != old.RecordDigest { |
| + return evidence{}, ErrRefused |
| + } |
| + return old, nil |
| + } |
| + if p.Generation != old.Generation && p.OwnerConfirmation == "" { |
| + return evidence{}, ErrRefused |
| + } |
| + } |
| + for _, a := range old.Receipts { |
| + if a.Operation == p.Operation { |
| + return evidence{}, ErrRefused |
| + } |
| + } |
| + if len(old.Receipts) >= recordLimit { |
| + return evidence{}, ErrRefused |
| + } |
| + receipts := append(append([]receipt(nil), old.Receipts...), receipt{p.Operation, r.digest}) |
| + ev := evidence{Version: 1, Sequence: p.Sequence, RecordDigest: r.digest, Generation: p.Generation, Epoch: p.MinEpoch, Members: []MemberEntry{}, Digest: memberDigest(nil), Receipts: receipts} |
| + if old.Generation == p.Generation && old.Epoch >= p.MinEpoch { |
| + ev.Epoch = old.Epoch |
| + ev.Members = old.Members |
| + ev.Digest = old.Digest |
| + ev.Snapshot = old.Snapshot |
| + } |
| + if old.Generation == p.Generation { |
| + ev.Blocked = old.Blocked |
| + } |
| + return ev, nil |
| +} |
| +func (c *Cache) save(ctx context.Context, e evidence) error { |
| + raw, err := json.Marshal(e) |
| + if err != nil || len(raw) > EvidenceLimit { |
| + return ErrUnavailable |
| + } |
| + ct, err := c.sealer.Seal(c.purpose, raw) |
| + if err != nil { |
| + return ErrUnavailable |
| + } |
| + if err = c.durable.Save(ctx, ct); err != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +// UpdateRecord accepts only independently operator-verified records for the same |
| +// bindings. Durable failure changes neither the current floor nor usable lease. |
| +func (c *Cache) UpdateRecord(ctx context.Context, r VerifiedRecord) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if !r.valid || r.record.Bindings != c.record.record.Bindings || r.record.Sequence <= c.record.record.Sequence { |
| + return ErrRefused |
| + } |
| + ev, e := nextEvidence(c.evidence, r) |
| + if e != nil { |
| + return e |
| + } |
| + if e = c.save(ctx, ev); e != nil { |
| + return e |
| + } |
| + c.record = r |
| + c.evidence = ev |
| + c.fence() |
| + c.attempted = false |
| + return nil |
| +} |
| +func (c *Cache) fence() { |
| + c.usable = false |
| + c.version++ |
| + if c.flight != nil { |
| + c.flight.cancel() |
| + } |
| +} |
| +func (c *Cache) observe() (time.Time, time.Duration) { |
| + w, m := c.clock.Now() |
| + if c.seen && (m < c.mono || w.Round(0).Sub(c.wall.Round(0)).Nanoseconds()-(m-c.mono).Nanoseconds() < -ClockTolerance.Nanoseconds()) { |
| + c.fence() |
| + } |
| + c.wall = w |
| + c.mono = m |
| + c.seen = true |
| + return w, m |
| +} |
| + |
| +func (c *Cache) Check(ctx context.Context, issuer, teamID, subject string) Decision { |
| + c.mu.Lock() |
| + b := c.record.record.Bindings |
| + if issuer != b.Issuer || teamID != b.Team || !validMember(MemberEntry{subject, "member"}) { |
| + c.mu.Unlock() |
| + return Decision{State: Unknown} |
| + } |
| + _, now := c.observe() |
| + usable := c.usable && now < c.start+MaximumAge |
| + due := !c.usable || now >= c.start+RefreshAfter |
| + var f *flight |
| + if due { |
| + f = c.startRefresh(now) |
| + } |
| + if usable { |
| + d := c.decision(subject) |
| + c.mu.Unlock() |
| + return d |
| + } |
| + c.mu.Unlock() |
| + if f != nil { |
| + select { |
| + case <-ctx.Done(): |
| + return Decision{State: Unknown} |
| + case <-f.done: |
| + } |
| + } |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + _, now = c.observe() |
| + if !c.usable || now >= c.start+MaximumAge { |
| + return Decision{State: Unknown} |
| + } |
| + return c.decision(subject) |
| +} |
| +func (c *Cache) decision(subject string) Decision { |
| + role, ok := c.members[subject] |
| + if !ok { |
| + return Decision{State: NotMember} |
| + } |
| + return Decision{State: Member, Role: role, Lease: Lease{c, c.version, c.start + MaximumAge}} |
| +} |
| + |
| +// Refresh joins the same bounded flight as Check. Caller cancellation stops |
| +// waiting, not another request's shared refresh. Backoff never changes lease age. |
| +func (c *Cache) Refresh(ctx context.Context) error { |
| + c.mu.Lock() |
| + _, now := c.observe() |
| + f := c.startRefresh(now) |
| + c.mu.Unlock() |
| + if f == nil { |
| + return ErrUnavailable |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ErrUnavailable |
| + case <-f.done: |
| + return f.err |
| + } |
| +} |
| +func (c *Cache) startRefresh(now time.Duration) *flight { |
| + if c.flight != nil { |
| + return c.flight |
| + } |
| + if c.evidence.Blocked { |
| + return nil |
| + } |
| + if c.attempted && now-c.lastAttempt < RetryAfter { |
| + return nil |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), FetchTimeout) |
| + f := &flight{done: make(chan struct{}), cancel: cancel} |
| + c.flight = f |
| + c.attempted = true |
| + c.lastAttempt = now |
| + r := c.record |
| + version := c.version |
| + go func() { |
| + defer cancel() |
| + err := c.fetch(ctx, r, version, now) |
| + c.mu.Lock() |
| + f.err = err |
| + c.flight = nil |
| + close(f.done) |
| + c.mu.Unlock() |
| + }() |
| + return f |
| +} |
| +func (c *Cache) fetch(ctx context.Context, r VerifiedRecord, version uint64, start time.Duration) error { |
| + random := make([]byte, 32) |
| + if _, e := rand.Read(random); e != nil { |
| + return ErrUnavailable |
| + } |
| + challenge := base64.RawURLEncoding.EncodeToString(random) |
| + var members []MemberEntry |
| + cursor := "" |
| + epoch := int64(0) |
| + seen := map[string]bool{} |
| + for index := 0; index < 100; index++ { |
| + q := url.Values{"team": {r.record.Bindings.Team}, "fetch": {challenge}} |
| + if cursor != "" { |
| + q.Set("cursor", cursor) |
| + } |
| + target := r.record.Bindings.Directory + "/roster?" + q.Encode() |
| + if len(target) > 4096 { |
| + return ErrRefused |
| + } |
| + req, e := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + req.Header.Set("Authorization", "Bearer "+c.token) |
| + req.Header.Set("Accept", "application/json") |
| + req.Header.Set("Accept-Encoding", "identity") |
| + response, e := c.client.Do(req) |
| + if e != nil { |
| + return ErrUnavailable |
| + } |
| + raw, readErr := io.ReadAll(io.LimitReader(response.Body, BodyLimit+1)) |
| + response.Body.Close() |
| + if readErr != nil || len(raw) > BodyLimit || response.Header.Get("Content-Type") != "application/json" || response.Header.Get("Content-Encoding") != "" || response.Uncompressed { |
| + return ErrRefused |
| + } |
| + p, e := verifyPage(raw, r, challenge, cursor, response.StatusCode) |
| + if e != nil { |
| + return ErrRefused |
| + } |
| + if p.Generation != r.record.Generation { |
| + c.mu.Lock() |
| + if c.record.digest == r.digest { |
| + ev := c.evidence |
| + ev.Blocked = true |
| + _ = c.save(ctx, ev) |
| + c.evidence.Blocked = true |
| + c.fence() |
| + } |
| + c.mu.Unlock() |
| + return ErrRefused |
| + } |
| + if p.Type != "page" || *p.Index != index || p.Epoch < r.record.MinEpoch || (epoch != 0 && p.Epoch != epoch) { |
| + return ErrRefused |
| + } |
| + epoch = p.Epoch |
| + if len(members) > 0 && len(*p.Members) > 0 && members[len(members)-1].Subject >= (*p.Members)[0].Subject { |
| + return ErrRefused |
| + } |
| + members = append(members, (*p.Members)...) |
| + if len(members) > MemberLimit { |
| + return ErrRefused |
| + } |
| + if *p.Complete { |
| + return c.publish(ctx, r, version, start, epoch, members) |
| + } |
| + cursor = *p.Cursor |
| + if seen[cursor] { |
| + return ErrRefused |
| + } |
| + seen[cursor] = true |
| + } |
| + return ErrRefused |
| +} |
| +func (c *Cache) publish(ctx context.Context, r VerifiedRecord, version uint64, start time.Duration, epoch int64, members []MemberEntry) error { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + _, now := c.observe() |
| + if ctx.Err() != nil || c.version != version || c.record.digest != r.digest || now-start >= FetchTimeout || epoch < c.evidence.Epoch { |
| + return ErrUnavailable |
| + } |
| + digest := memberDigest(members) |
| + // A provisioned floor without a fetched snapshot has no member digest yet. |
| + if epoch == c.evidence.Epoch && c.evidence.Digest != "" && c.evidenceSnapshot() && digest != c.evidence.Digest { |
| + return ErrRefused |
| + } |
| + ev := c.evidence |
| + ev.Epoch = epoch |
| + ev.Members = append([]MemberEntry{}, members...) |
| + ev.Digest = digest |
| + ev.Snapshot = true |
| + if e := c.save(ctx, ev); e != nil { |
| + return e |
| + } |
| + c.evidence = ev |
| + _, now = c.observe() |
| + if ctx.Err() != nil || c.version != version || now-start >= FetchTimeout { |
| + return ErrUnavailable |
| + } |
| + c.members = make(map[string]string, len(members)) |
| + for _, m := range members { |
| + c.members[m.Subject] = m.Role |
| + } |
| + c.start = start |
| + c.usable = true |
| + c.version++ |
| + return nil |
| +} |
| +func (c *Cache) evidenceSnapshot() bool { |
| + return c.evidence.Snapshot |
| +} |
diff --git a/roster/cache_test.go b/roster/cache_test.go| new file mode 100644 |
| index 0000000..166ed00 |
| --- /dev/null |
| +++ b/roster/cache_test.go |
| @@ -0,0 +1,475 @@ |
| +package roster |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff/seal" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +type fakeClock struct { |
| + mu sync.Mutex |
| + wall time.Time |
| + mono time.Duration |
| +} |
| + |
| +func (f *fakeClock) Now() (time.Time, time.Duration) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return f.wall, f.mono |
| +} |
| +func (f *fakeClock) step(d time.Duration) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.wall = f.wall.Add(d) |
| + f.mono += d |
| +} |
| +func (f *fakeClock) wallStep(d time.Duration) { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + f.wall = f.wall.Add(d) |
| +} |
| + |
| +type memory struct { |
| + mu sync.Mutex |
| + raw []byte |
| + fail bool |
| +} |
| + |
| +func (m *memory) Load(context.Context) ([]byte, error) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if m.raw == nil { |
| + return nil, ErrNoEvidence |
| + } |
| + return bytes.Clone(m.raw), nil |
| +} |
| +func (m *memory) Save(_ context.Context, b []byte) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if m.fail { |
| + return errors.New("secret-storage-error") |
| + } |
| + m.raw = bytes.Clone(b) |
| + return nil |
| +} |
| +func (m *memory) failWrites(v bool) { m.mu.Lock(); defer m.mu.Unlock(); m.fail = v } |
| + |
| +type transport func(*http.Request) (*http.Response, error) |
| + |
| +func (f transport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| +func fixed(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) } |
| +func ptr[T any](v T) *T { return &v } |
| +func person(n int) string { |
| + s := []byte(strings.Repeat("a", 26)) |
| + for i := 25; n > 0; i-- { |
| + s[i] = "abcdefghijklmnopqrstuvwxyz"[n%26] |
| + n /= 26 |
| + } |
| + return "pr_" + string(s) |
| +} |
| + |
| +type fixture struct { |
| + t *testing.T |
| + op, key *family.Keypair |
| + record Provisioning |
| + verified VerifiedRecord |
| + cfg Config |
| + clock *fakeClock |
| + disk *memory |
| + mu sync.Mutex |
| + handler transport |
| + calls int |
| +} |
| + |
| +func setup(t *testing.T) *fixture { |
| + t.Helper() |
| + op, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f := &fixture{t: t, op: op, key: key, clock: &fakeClock{wall: time.Unix(1800000000, 0)}, disk: &memory{}} |
| + f.record = Provisioning{Version: 1, Operation: "op1", Sequence: 1, Bindings: Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"}, Generation: fixed(1), MinEpoch: 1, Keys: []Key{{"roster1", base64.RawURLEncoding.EncodeToString(key.SignPub())}}} |
| + f.verified = f.verify(f.record) |
| + s, e := seal.New(bytes.Repeat([]byte{9}, 32), "roster-test") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + f.cfg = Config{Record: f.verified, Token: fixed(7), Clock: f.clock, Durable: f.disk, Sealer: s} |
| + f.handler = func(r *http.Request) (*http.Response, error) { |
| + return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + } |
| + f.cfg.Transport = transport(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Scheme != "https" || r.URL.Host != "home.example" || r.URL.Path != "/roster" || r.Header.Get("Authorization") != "Bearer "+fixed(7) || !ValidChallenge(r.URL.Query().Get("fetch")) { |
| + t.Error("outbound boundary") |
| + } |
| + f.mu.Lock() |
| + f.calls++ |
| + h := f.handler |
| + f.mu.Unlock() |
| + return h(r) |
| + }) |
| + return f |
| +} |
| +func (f *fixture) verify(p Provisioning) VerifiedRecord { |
| + f.t.Helper() |
| + raw, e := SignProvisioning(f.op, "operator", p) |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + v, e := VerifyProvisioning(raw, []Key{{"operator", base64.RawURLEncoding.EncodeToString(f.op.SignPub())}}, f.record.Bindings) |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return v |
| +} |
| +func (f *fixture) cache() *Cache { |
| + f.t.Helper() |
| + c, e := New(context.Background(), f.cfg) |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return c |
| +} |
| +func (f *fixture) set(h transport) { f.mu.Lock(); defer f.mu.Unlock(); f.handler = h } |
| +func (f *fixture) page(r *http.Request, epoch int64, m []MemberEntry, complete bool, cursor string) Page { |
| + q := r.URL.Query() |
| + i := 0 |
| + if q.Get("cursor") != "" { |
| + i = 1 |
| + } |
| + p := Page{Version: 1, Type: "page", Directory: f.record.Bindings.Directory, Consumer: f.record.Bindings.Consumer, Team: f.record.Bindings.Team, Audience: f.record.Bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: CursorHash(q.Get("cursor")), Generation: f.record.Generation, Epoch: epoch, Index: &i, Members: &m, Complete: &complete} |
| + if !complete { |
| + p.Cursor = &cursor |
| + } |
| + return p |
| +} |
| +func (f *fixture) signed(p Page, status int) *http.Response { |
| + f.t.Helper() |
| + b, e := SignPage(f.key, "roster1", p) |
| + if e != nil { |
| + f.t.Fatal(e) |
| + } |
| + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))} |
| +} |
| +func (f *fixture) response(r *http.Request, e int64, m []MemberEntry, complete bool, cursor string) *http.Response { |
| + return f.signed(f.page(r, e, m, complete, cursor), 200) |
| +} |
| +func (f *fixture) check(c *Cache, n int) Decision { |
| + return c.Check(context.Background(), f.record.Bindings.Issuer, f.record.Bindings.Team, person(n)) |
| +} |
| +func state(t *testing.T, d Decision, s State) { |
| + t.Helper() |
| + if d.State != s { |
| + t.Fatalf("state %d want %d", d.State, s) |
| + } |
| +} |
| + |
| +func TestPrivacyAndSixtySecondCeiling(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + state(t, d, Member) |
| + raw, _ := f.disk.Load(context.Background()) |
| + for _, v := range []string{fixed(7), person(1), "member", "secret-storage-error"} { |
| + if bytes.Contains(raw, []byte(v)) { |
| + t.Fatal("readable durable data") |
| + } |
| + } |
| + f.set(func(*http.Request) (*http.Response, error) { return nil, errors.New(fixed(7) + person(1)) }) |
| + f.clock.step(30 * time.Second) |
| + if e := c.Refresh(context.Background()); e != ErrUnavailable { |
| + t.Fatalf("unsafe error %v", e) |
| + } |
| + state(t, f.check(c, 1), Member) |
| + f.clock.step(29 * time.Second) |
| + if !d.Lease.Valid() { |
| + t.Fatal("early expiry") |
| + } |
| + f.clock.step(time.Second) |
| + if d.Lease.Valid() { |
| + t.Fatal("standing survived ceiling") |
| + } |
| + state(t, f.check(c, 1), Unknown) |
| +} |
| +func TestNoPartialPublishOrAgeExtension(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + state(t, f.check(c, 1), Member) |
| + old, _ := f.disk.Load(context.Background()) |
| + batch := make([]MemberEntry, 100) |
| + for i := range batch { |
| + batch[i] = MemberEntry{person(i + 100), "member"} |
| + } |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Query().Get("cursor") != "" { |
| + return nil, errors.New("partial") |
| + } |
| + return f.response(r, 2, batch, false, fixed(4)), nil |
| + }) |
| + f.clock.step(30 * time.Second) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("partial accepted") |
| + } |
| + state(t, f.check(c, 100), NotMember) |
| + state(t, f.check(c, 1), Member) |
| + after, _ := f.disk.Load(context.Background()) |
| + if !bytes.Equal(old, after) { |
| + t.Fatal("partial persisted") |
| + } |
| + f.clock.step(30 * time.Second) |
| + state(t, f.check(c, 1), Unknown) |
| +} |
| +func TestEmptyRosterAndRestartEpochFloor(t *testing.T) { |
| + f := setup(t) |
| + f.set(func(r *http.Request) (*http.Response, error) { return f.response(r, 2, []MemberEntry{}, true, ""), nil }) |
| + c := f.cache() |
| + state(t, f.check(c, 1), NotMember) |
| + f.clock.step(30 * time.Second) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + return f.response(r, 2, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("changed empty epoch accepted") |
| + } |
| + state(t, f.check(c, 1), NotMember) |
| + c = f.cache() |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("restart forgot digest") |
| + } |
| + state(t, f.check(c, 1), Unknown) |
| +} |
| +func TestGenerationRequiresOperatorApproval(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + f.clock.step(30 * time.Second) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + p := f.page(r, 2, []MemberEntry{}, true, "") |
| + p.Generation = fixed(2) |
| + return f.signed(p, 200), nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("generation accepted") |
| + } |
| + if d.Lease.Valid() { |
| + t.Fatal("old lease survived") |
| + } |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + }) |
| + f.clock.step(5 * time.Second) |
| + state(t, f.check(c, 1), Unknown) |
| + c = f.cache() |
| + state(t, f.check(c, 1), Unknown) |
| + n := f.record |
| + n.Sequence = 2 |
| + n.Operation = "op2" |
| + n.Generation = fixed(2) |
| + if c.UpdateRecord(context.Background(), f.verify(n)) == nil { |
| + t.Fatal("missing confirmation accepted") |
| + } |
| + n.OwnerConfirmation = "owner-confirmation" |
| + if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { |
| + t.Fatal(e) |
| + } |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + p := f.page(r, 1, []MemberEntry{{person(1), "owner"}}, true, "") |
| + p.Generation = fixed(2) |
| + return f.signed(p, 200), nil |
| + }) |
| + state(t, f.check(c, 1), Member) |
| +} |
| +func TestClockToleranceAndColdRestart(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + f.clock.wallStep(-ClockTolerance) |
| + if !d.Lease.Valid() { |
| + t.Fatal("tolerated slew fenced") |
| + } |
| + f.clock.wallStep(-ClockTolerance - time.Nanosecond) |
| + if d.Lease.Valid() { |
| + t.Fatal("clock rollback missed") |
| + } |
| + f.clock.step(5 * time.Second) |
| + state(t, f.check(c, 1), Member) |
| + f.set(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") }) |
| + c = f.cache() |
| + state(t, f.check(c, 1), Unknown) |
| +} |
| +func TestRecordUpdateFencesStagingAndWriteFailure(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + old, _ := f.disk.Load(context.Background()) |
| + n := f.record |
| + n.Sequence = 2 |
| + n.Operation = "op2" |
| + f.disk.failWrites(true) |
| + if e := c.UpdateRecord(context.Background(), f.verify(n)); e != ErrUnavailable { |
| + t.Fatalf("storage error %v", e) |
| + } |
| + if !d.Lease.Valid() { |
| + t.Fatal("failed write changed lease") |
| + } |
| + after, _ := f.disk.Load(context.Background()) |
| + if !bytes.Equal(old, after) { |
| + t.Fatal("failed write persisted") |
| + } |
| + f.disk.failWrites(false) |
| + entered := make(chan struct{}) |
| + release := make(chan struct{}) |
| + f.clock.step(30 * time.Second) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + close(entered) |
| + <-release |
| + return f.response(r, 2, []MemberEntry{{person(2), "member"}}, true, ""), nil |
| + }) |
| + done := make(chan error) |
| + go func() { done <- c.Refresh(context.Background()) }() |
| + <-entered |
| + if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { |
| + t.Fatal(e) |
| + } |
| + close(release) |
| + if <-done == nil { |
| + t.Fatal("old staging published") |
| + } |
| + if d.Lease.Valid() { |
| + t.Fatal("old lease survived") |
| + } |
| + if c.UpdateRecord(context.Background(), f.verified) == nil { |
| + t.Fatal("old record replay") |
| + } |
| +} |
| +func TestSingleFlight(t *testing.T) { |
| + f := setup(t) |
| + entered := make(chan struct{}) |
| + release := make(chan struct{}) |
| + var once sync.Once |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + once.Do(func() { close(entered) }) |
| + <-release |
| + return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + }) |
| + c := f.cache() |
| + var wg sync.WaitGroup |
| + for i := 0; i < 24; i++ { |
| + wg.Add(1) |
| + go func() { defer wg.Done(); state(t, f.check(c, 1), Member) }() |
| + } |
| + <-entered |
| + close(release) |
| + wg.Wait() |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if f.calls != 1 { |
| + t.Fatalf("calls %d", f.calls) |
| + } |
| +} |
| +func TestFetchDeadlineAndSaveFailure(t *testing.T) { |
| + for _, slow := range []bool{false, true} { |
| + t.Run(strings.Repeat("slow", map[bool]int{false: 0, true: 1}[slow])+"failure", func(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + if slow { |
| + f.clock.step(FetchTimeout) |
| + } else { |
| + f.disk.failWrites(true) |
| + } |
| + return f.response(r, 2, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("invalid refresh accepted") |
| + } |
| + state(t, f.check(c, 1), Unknown) |
| + }) |
| + } |
| +} |
| +func TestRefusalsNeverRenew(t *testing.T) { |
| + for _, name := range []string{"challenge", "cursor-hash", "team", "directory", "audience", "role", "subject", "index", "extra", "duplicate", "context", "signature", "oversize", "compression", "redirect", "304", "epoch"} { |
| + t.Run(name, func(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + state(t, f.check(c, 1), Member) |
| + f.clock.step(30 * time.Second) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + p := f.page(r, 1, []MemberEntry{{person(1), "member"}}, true, "") |
| + switch name { |
| + case "challenge": |
| + p.Fetch = fixed(9) |
| + case "cursor-hash": |
| + p.RequestCursorHash = "" |
| + case "team": |
| + p.Team = "TeamB" |
| + case "directory": |
| + p.Directory = "https://foreign.example" |
| + case "audience": |
| + p.Audience = "https://foreign.example" |
| + case "role": |
| + (*p.Members)[0].Role = "admin" |
| + case "subject": |
| + (*p.Members)[0].Subject = "someone@example.test" |
| + case "index": |
| + p.Index = ptr(1) |
| + case "epoch": |
| + p.Epoch = 0 |
| + } |
| + res := f.signed(p, 200) |
| + switch name { |
| + case "extra", "duplicate": |
| + raw, _ := json.Marshal(p) |
| + tail := `,"extra":1}` |
| + if name == "duplicate" { |
| + tail = `,"epoch":1}` |
| + } |
| + raw = append(raw[:len(raw)-1], []byte(tail)...) |
| + sig, _ := family.Sign(f.key, PageContext, raw) |
| + b, _ := json.Marshal(envelope{1, "roster1", base64.RawURLEncoding.EncodeToString(raw), base64.RawURLEncoding.EncodeToString(sig)}) |
| + res.Body = io.NopCloser(bytes.NewReader(b)) |
| + case "context": |
| + b, _ := encode(f.key, "roster1", ProvisioningContext, p) |
| + res.Body = io.NopCloser(bytes.NewReader(b)) |
| + case "signature": |
| + b, _ := io.ReadAll(res.Body) |
| + var e envelope |
| + _ = json.Unmarshal(b, &e) |
| + e.Signature = base64.RawURLEncoding.EncodeToString(make([]byte, 64)) |
| + b, _ = json.Marshal(e) |
| + res.Body = io.NopCloser(bytes.NewReader(b)) |
| + case "oversize": |
| + res.Body = io.NopCloser(strings.NewReader(strings.Repeat("a", BodyLimit+1))) |
| + case "compression": |
| + res.Header.Set("Content-Encoding", "gzip") |
| + case "redirect": |
| + res.StatusCode = 302 |
| + res.Header.Set("Location", "https://foreign.example/roster") |
| + case "304": |
| + res.StatusCode = 304 |
| + } |
| + return res, nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("invalid response accepted") |
| + } |
| + f.clock.step(30 * time.Second) |
| + state(t, f.check(c, 1), Unknown) |
| + }) |
| + } |
| +} |
diff --git a/roster/go.mod b/roster/go.mod| new file mode 100644 |
| index 0000000..cdb9ca3 |
| --- /dev/null |
| +++ b/roster/go.mod |
| @@ -0,0 +1,9 @@ |
| +module amadan.net/oficina/home/roster |
| + |
| +go 1.25.0 |
| + |
| +require ( |
| + amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| + amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 |
| +) |
diff --git a/roster/go.sum b/roster/go.sum| new file mode 100644 |
| index 0000000..bfcd60e |
| --- /dev/null |
| +++ b/roster/go.sum |
| @@ -0,0 +1,26 @@ |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| +amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc= |
| +amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
diff --git a/roster/protocol.go b/roster/protocol.go| new file mode 100644 |
| index 0000000..558fb04 |
| --- /dev/null |
| +++ b/roster/protocol.go |
| @@ -0,0 +1,287 @@ |
| +// 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 |
| + } |
| + 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(keys[e.KeyID], 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 |
| +} |
diff --git a/roster/protocol_test.go b/roster/protocol_test.go| new file mode 100644 |
| index 0000000..fe5ae38 |
| --- /dev/null |
| +++ b/roster/protocol_test.go |
| @@ -0,0 +1,295 @@ |
| +package roster |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "net/http" |
| + "strconv" |
| + "testing" |
| + "time" |
| + |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/go-json-experiment/json" |
| +) |
| + |
| +func TestCanonicalWireAndDecodedSignature(t *testing.T) { |
| + f := setup(t) |
| + if CursorHash("") != "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" { |
| + t.Fatal("page zero hash") |
| + } |
| + for _, s := range []string{"", fixed(1) + "=", fixed(1) + "\n", fixed(1)[:42], fixed(1) + "a"} { |
| + if ValidChallenge(s) { |
| + t.Fatal("noncanonical challenge") |
| + } |
| + } |
| + req, _ := http.NewRequest("GET", "https://home.example/roster?fetch="+fixed(1), nil) |
| + p := f.page(req, 1, []MemberEntry{}, true, "") |
| + raw, e := SignPage(f.key, "roster1", p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + var env envelope |
| + if strict(raw, &env) != nil { |
| + t.Fatal("envelope") |
| + } |
| + payload, _ := canonical(env.Payload, -1) |
| + sig, _ := canonical(env.Signature, 64) |
| + if !family.Verify(f.key.SignPub(), PageContext, payload, sig) || family.Verify(f.key.SignPub(), PageContext, []byte(env.Payload), sig) { |
| + t.Fatal("wrong signed bytes") |
| + } |
| + if !bytes.Contains(payload, []byte(`"members":[]`)) || bytes.Contains(payload, []byte(`"cursor"`)) { |
| + t.Fatalf("empty final shape %s", payload) |
| + } |
| +} |
| +func TestProvisioningStrictBindingsAndKeys(t *testing.T) { |
| + for _, name := range []string{"issuer", "audience", "directory", "team", "version", "sequence", "epoch", "generation", "operation", "key", "duplicate-key", "owner", "unknown-field", "duplicate-field", "wrong-context", "unsigned", "case", "number"} { |
| + t.Run(name, func(t *testing.T) { |
| + f := setup(t) |
| + p := f.record |
| + p.Keys = append([]Key(nil), p.Keys...) |
| + switch name { |
| + case "issuer": |
| + p.Bindings.Issuer = "https://other.example" |
| + case "audience": |
| + p.Bindings.Audience = "https://other.example" |
| + case "directory": |
| + p.Bindings.Directory = "https://other.example" |
| + case "team": |
| + p.Bindings.Team = "TeamB" |
| + case "version": |
| + p.Version = 2 |
| + case "sequence": |
| + p.Sequence = 0 |
| + case "epoch": |
| + p.MinEpoch = 0 |
| + case "generation": |
| + p.Generation += "=" |
| + case "operation": |
| + p.Operation = "unsafe/value" |
| + case "key": |
| + p.Keys[0].Public = fixed(1) |
| + case "duplicate-key": |
| + p.Keys = append(p.Keys, p.Keys[0]) |
| + case "owner": |
| + p.OwnerConfirmation = "unsafe/value" |
| + } |
| + raw, _ := SignProvisioning(f.op, "operator", p) |
| + if name == "unknown-field" || name == "duplicate-field" || name == "case" || name == "number" { |
| + b, _ := json.Marshal(p) |
| + switch name { |
| + case "unknown-field": |
| + b = append(b[:len(b)-1], []byte(`,"surprise":true}`)...) |
| + case "duplicate-field": |
| + b = append(b[:len(b)-1], []byte(`,"version":1}`)...) |
| + case "case": |
| + b = bytes.Replace(b, []byte(`"version"`), []byte(`"Version"`), 1) |
| + case "number": |
| + b = bytes.Replace(b, []byte(`"version":1`), []byte(`"version":1.0`), 1) |
| + } |
| + sig, _ := family.Sign(f.op, ProvisioningContext, b) |
| + raw, _ = json.Marshal(envelope{1, "operator", base64.RawURLEncoding.EncodeToString(b), base64.RawURLEncoding.EncodeToString(sig)}) |
| + } |
| + if name == "wrong-context" { |
| + raw, _ = encode(f.op, "operator", PageContext, p) |
| + } |
| + if name == "unsigned" { |
| + raw, _ = json.Marshal(p) |
| + } |
| + if _, e := VerifyProvisioning(raw, []Key{{"operator", base64.RawURLEncoding.EncodeToString(f.op.SignPub())}}, f.record.Bindings); e == nil { |
| + t.Fatal("bad record accepted") |
| + } |
| + }) |
| + } |
| +} |
| +func TestRecordOperationAndRestartRollback(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + n := f.record |
| + n.Sequence = 2 |
| + n.Operation = "op2" |
| + if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e := New(context.Background(), f.cfg); e == nil { |
| + t.Fatal("old signed record on restart accepted") |
| + } |
| + n.Sequence = 3 |
| + n.Operation = "op1" |
| + if c.UpdateRecord(context.Background(), f.verify(n)) == nil { |
| + t.Fatal("operation reused") |
| + } |
| + f.cfg.Record = VerifiedRecord{} |
| + if _, e := New(context.Background(), f.cfg); e == nil { |
| + t.Fatal("zero verified record accepted") |
| + } |
| +} |
| +func TestBoundedCompletePagination(t *testing.T) { |
| + for _, count := range []int{101, 10000, 10001} { |
| + t.Run(strconv.Itoa(count), func(t *testing.T) { |
| + f := setup(t) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + index := 0 |
| + if c := r.URL.Query().Get("cursor"); c != "" { |
| + b, _ := base64.RawURLEncoding.DecodeString(c) |
| + index, _ = strconv.Atoi(string(b)) |
| + } |
| + end := (index + 1) * 100 |
| + if end > count { |
| + end = count |
| + } |
| + m := make([]MemberEntry, end-index*100) |
| + for i := range m { |
| + m[i] = MemberEntry{person(index*100 + i + 1), "member"} |
| + } |
| + cursor := base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(index + 1))) |
| + p := f.page(r, 3, m, end == count, cursor) |
| + p.Index = &index |
| + return f.signed(p, 200), nil |
| + }) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + if count <= MemberLimit { |
| + state(t, d, Member) |
| + state(t, f.check(c, count), Member) |
| + } else { |
| + state(t, d, Unknown) |
| + } |
| + }) |
| + } |
| +} |
| +func TestPaginationRefusals(t *testing.T) { |
| + for _, name := range []string{"epoch", "order", "repeat-cursor", "short-page", "conflict", "generation-later"} { |
| + t.Run(name, func(t *testing.T) { |
| + f := setup(t) |
| + first := make([]MemberEntry, 100) |
| + for i := range first { |
| + first[i] = MemberEntry{person(i + 1), "member"} |
| + } |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + if r.URL.Query().Get("cursor") == "" { |
| + m := first |
| + if name == "short-page" { |
| + m = m[:99] |
| + } |
| + return f.response(r, 2, m, false, fixed(4)), nil |
| + } |
| + p := f.page(r, 2, []MemberEntry{{person(101), "member"}}, true, "") |
| + status := 200 |
| + switch name { |
| + case "epoch": |
| + p.Epoch = 3 |
| + case "order": |
| + (*p.Members)[0].Subject = person(1) |
| + case "repeat-cursor": |
| + m := make([]MemberEntry, 100) |
| + for i := range m { |
| + m[i] = MemberEntry{person(i + 101), "member"} |
| + } |
| + p.Members = &m |
| + p.Complete = ptr(false) |
| + p.Cursor = ptr(fixed(4)) |
| + case "conflict": |
| + p.Type = "stale-cursor" |
| + p.Index = nil |
| + p.Members = nil |
| + p.Complete = nil |
| + status = 409 |
| + case "generation-later": |
| + p.Generation = fixed(2) |
| + } |
| + return f.signed(p, status), nil |
| + }) |
| + c := f.cache() |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("bad pagination accepted") |
| + } |
| + state(t, f.check(c, 1), Unknown) |
| + }) |
| + } |
| +} |
| +func TestSignedConflictGenerationFence(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + f.clock.step(30 * time.Second) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + p := f.page(r, 2, nil, false, "") |
| + p.Type = "stale-cursor" |
| + p.Index = nil |
| + p.Members = nil |
| + p.Complete = nil |
| + p.Cursor = nil |
| + p.Generation = fixed(2) |
| + return f.signed(p, 409), nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("conflict published") |
| + } |
| + if d.Lease.Valid() { |
| + t.Fatal("conflict generation did not fence") |
| + } |
| +} |
| +func TestEvidenceTamperAndPurpose(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + state(t, f.check(c, 1), Member) |
| + raw, _ := f.disk.Load(context.Background()) |
| + raw[len(raw)-1] ^= 1 |
| + _ = f.disk.Save(context.Background(), raw) |
| + if _, e := New(context.Background(), f.cfg); e == nil { |
| + t.Fatal("tampered durable evidence accepted") |
| + } |
| + f = setup(t) |
| + c = f.cache() |
| + state(t, f.check(c, 1), Member) |
| + p := f.record |
| + p.Bindings.Audience = "https://other.example" |
| + f.record.Bindings = p.Bindings |
| + f.cfg.Record = f.verify(p) |
| + if _, e := New(context.Background(), f.cfg); e == nil { |
| + t.Fatal("cross-origin durable evidence accepted") |
| + } |
| +} |
| +func TestElapsedFetchIncludedInLease(t *testing.T) { |
| + f := setup(t) |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + f.clock.step(4 * time.Second) |
| + return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil |
| + }) |
| + c := f.cache() |
| + d := f.check(c, 1) |
| + state(t, d, Member) |
| + if d.Lease.Remaining() != 56*time.Second { |
| + t.Fatalf("remaining %v", d.Lease.Remaining()) |
| + } |
| + f.clock.step(56 * time.Second) |
| + if d.Lease.Valid() { |
| + t.Fatal("transit extended age") |
| + } |
| +} |
| +func TestActualTransportDeadline(t *testing.T) { |
| + f := setup(t) |
| + f.set(func(r *http.Request) (*http.Response, error) { <-r.Context().Done(); return nil, r.Context().Err() }) |
| + c := f.cache() |
| + start := time.Now() |
| + if c.Refresh(context.Background()) != ErrUnavailable { |
| + t.Fatal("deadline error") |
| + } |
| + elapsed := time.Since(start) |
| + if elapsed < FetchTimeout || elapsed > FetchTimeout+time.Second { |
| + t.Fatalf("refresh budget %v", elapsed) |
| + } |
| +} |
| +func TestNoMissingRequiredOrAlternateBody(t *testing.T) { |
| + f := setup(t) |
| + c := f.cache() |
| + f.set(func(r *http.Request) (*http.Response, error) { |
| + res := f.response(r, 1, []MemberEntry{}, true, "") |
| + res.Header.Set("Content-Type", "application/json; charset=utf-8") |
| + return res, nil |
| + }) |
| + if c.Refresh(context.Background()) == nil { |
| + t.Fatal("alternate type accepted") |
| + } |
| +} |