oficina / home Public

Add signed roster verification and bounded membership cache

Paul Campbell pushed by paul@keymail.dev 667dc8218dd5d04a0de72eea11badfee0eebd8fd parent 4e7b3ff
7 files changed, +1591 −0
  • Makefile +4 −0
  • roster/cache.go +495 −0
  • roster/cache_test.go +475 −0
  • roster/go.mod +9 −0
  • roster/go.sum +26 −0
  • roster/protocol.go +287 −0
  • roster/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")
+ }
+}