oficina / home Public

Merge branch 'feat-roster-http' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev cdfc1c536712a4c3e6b5f1b499f8df2c90b98a3a parent 88e09a4parent 9e9613d
25 files changed, +4017 −14
  • Makefile +4 −0
  • go.mod +1 −1
  • go.sum +4 −6
  • implementation/roster-contract.md +79 −0
  • implementation/roster-http-signing.md +219 −0
  • implementation/roster-producer-storage.md +76 −0
  • internal/home/directory.go +8 −2
  • internal/home/directory_runtime.go +15 −3
  • internal/home/directorydata/roster.go +194 −0
  • internal/home/directorydata/roster_migrations/migrations/0001_roster.sql +43 −0
  • internal/home/directorydata/roster_test.go +383 −0
  • internal/home/roster_http.go +162 −0
  • internal/home/roster_http_test.go +174 −0
  • internal/home/roster_integration_test.go +553 −0
  • internal/home/roster_runtime.go +105 −0
  • internal/home/roster_runtime_test.go +128 −0
  • internal/home/runtime.go +15 −1
  • internal/home/runtime_test.go +6 −1
  • roster/cache.go +501 −0
  • roster/cache_test.go +475 −0
  • roster/go.mod +22 −0
  • roster/go.sum +57 −0
  • roster/lease_test.go +207 −0
  • roster/protocol.go +291 −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/go.mod b/go.mod
index 476aac2..7747d06 100644
--- a/go.mod
+++ b/go.mod
@@ -5,12 +5,12 @@ go 1.25.0
require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
+ amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/keymaildev/signin v0.1.1
- golang.org/x/crypto v0.55.0
gorm.io/plugin/dbresolver v1.6.2
modernc.org/sqlite v1.55.0
)
diff --git a/go.sum b/go.sum
index dc6b1f1..0242734 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,11 @@
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db h1:3aN3BlNC03KJuFEk/C7EFEprt0Csto/LFjdwYeDv0FM=
-amadan.net/oficina/home/handoff v0.0.0-20260908081549-3c550c3491db/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
-amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA=
-amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
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/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5 h1:MQElRkuUPK46BWA3V3CVgJC9robI8X7LR0N67Jb3EZI=
+amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5/go.mod h1:nhhPCCPaiXfZfQheJEOzKL8beyHkJjrag8vfmu/DcWU=
+amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo=
+amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg=
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/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
@@ -50,8 +50,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
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/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
-golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
diff --git a/implementation/roster-contract.md b/implementation/roster-contract.md
new file mode 100644
index 0000000..acceebc
--- /dev/null
+++ b/implementation/roster-contract.md
@@ -0,0 +1,79 @@
+# Authenticated roster snapshots and bounded team standing
+
+Status: proposed implementation design by Codex, 2026-09-08; requires independent Fable design review before code. Source bases: approved team-directoryfa64e286990762aecc6913bb9076a3cb75b088c3, directorydata4c0d84811a018c53a47b604a0de5885baec0771e, chooser07043915daebae10512a710915263093ed793b5b, Home spec call8 and restore ruling23, suite doctrine5.3, Docs1e862f80752004d16db98693c25d4c693dc19510. This is an internal implementation contract, not an operator runbook or a deployed feature.
+
+## Existing boundaries
+
+Directorydata currently has teams.roster_epoch, memberships and team_instances. It has no consumer credential table, installation generation, roster HTTP handler or operator transport. The chooser reads membership for navigation; it neither authenticates an instance nor authorizes documents. The directory alone owns team records. Credential Home owns person refs and browser assertions; neither role receives Docs content, and the instance receives no Home pepper.
+
+Docs currently uses the linked-identity adapter but still uses a local email allowlist for membership/admission in its account-link preview. It has no authoritative roster. Its readable/writable scopes include OwnerID automatically, and stream.go checks access only at connection establishment. These are named integration obligations below, not evidence that adding one front-door middleware completes revocation.
+
+Use a separate small nested module amadan.net/oficina/home/roster for wire, verification, producer handler interface and consumer cache. Reuse the already published canonical Rastrillo crypto.Sign/Verify, which sign SHA256(context NUL message) using raw64-byte P256 signatures; do not place rosters into browser assertion claims or copy signing arithmetic. Use the shared handoff/seal interface for cursor/cache envelopes. Home directorydata supplies the authoritative read transaction; Docs supplies item permissions. Keep the package independent of GORM, app sessions and user-visible copy.
+
+## Provisioning and generation authority
+
+A consumer starts only with an explicitly verified provisioning record containing version, operation ID, strictly increasing record sequence, credential issuer origin, directory origin, consumer ID, app slug, team sqid, app origin, installation generation, minimum epoch, roster verification key set and its own bearer credential delivered separately. Origins are exact canonical HTTPS origins; registered app/team/origin must match the compiled production policy or separately approved immutable pilot manifest. No request supplies these pins. No trust-on-first-response path exists.
+
+The provisioning record is signed with a separately pinned directory operator verification key using context oficina/roster/provisioning/v1. That trust key is outside call8 and outside the record it verifies. The verifier returns an opaque validated record to the cache constructor; unsigned configuration or a call8 field cannot construct it. Initial records come from the audited seed/provisioning operation. A new-generation record additionally refers to the audited fresh-owner confirmation operation for that exact consumer/team/generation. Record replay, lower sequence, changed payload under an existing operation/sequence, wrong consumer/team/origin and unknown signing key refuse. Any accepted record change fences the previous cache and discards in-flight staging before refetching; a new generation resets the epoch floor only as authorized by the new record.
+
+Directory generation is random32-byte canonical base64url, persisted once by audited installation bootstrap and changed only by the supported restore procedure before traffic resumes. Ordinary startup never guesses that a restore happened and never generates a new value. The schema migration creates an empty generation table, not a valid generation. Serving call8 refuses until audited provisioning fills it. A signed call8 response with an unfamiliar generation immediately fences standing but cannot approve the new generation. Only the reviewed operator record update can do that.
+
+This module may verify supplied records and test producer transactions with injected verified operator authority. It adds no public bootstrap route and no runtime operator mutation transport. Live provisioning/restore remains gated on supported fenced maintenance (platform discussion117) or a separately reviewed genuinely fresh first-boot path. Neither a browser assertion nor the first visitor nor a sibling bearer token can seed an owner, generation or consumer. Existing-db remote SQL and manifest-wide first-boot assumptions are excluded.
+
+## Producer authority and token lifecycle
+
+Add directory-only migrations for generation, consumers and successful operator receipts. A consumer row binds its opaque ID to exactly one team/app/origin and stores SHA256 hashes of random32-byte bearer tokens, never plaintext tokens. Current and previous token hash slots permit an explicit rotation overlap of at most five minutes; expired previous tokens and disabled consumers fail. Rotation and disabling are audited operator mutations, not call8 writes. No browser session substitutes for a token.
+
+GET /roster accepts exactly one Authorization: Bearer header with canonical43-character base64url token. First requests contain exactly team and fetch query fields; subsequent requests additionally contain cursor. fetch is a newly generated random32-byte challenge for this refresh, not an identity or secret. Limit URL/query to4096 bytes, team to64 alphanumeric bytes and cursor to2048 encoded bytes. Refuse duplicates/unknown fields, alternate encodings, malformed tokens and methods before database work. Error bodies are fixed and contain none of the supplied values.
+
+Resolve the consumer from the token, then compare the requested team to that row. A foreign team and an unknown team both return identical404. Invalid/disabled/expired token returns the same401. The handler's configured Host must match the exact directory origin. Every response including failures has Cache-Control: no-store; logs/metrics contain only fixed operation/status labels, never tokens, person refs, labels, cursors or request URLs.
+
+Within one SQLite read transaction, resolve the token and its enabled/rotation state, live team-instance binding, current generation, team epoch and the ordered membership page. All membership writes increment that same team's epoch atomically and refuse overflow. Other teams' epochs never enter the response. A producer transaction cannot mix authorization at one state with members at another. Return at most100 members, fetched with LIMIT101 to determine whether more remain, sorted bytewise by canonical person_ref. Person refs use directorydata's exact pr_ plus26 lowercase base32 characters; roles are owner/member. Reject invalid stored rows, not merely the bad suffix. A complete empty roster is a successful page with members=[], complete=true and no next cursor.
+
+The cursor is a purpose-bound AEAD envelope containing version, consumer ID, team, generation, epoch, fetch challenge, last person ref, next page index and expiry. Bind its purpose to the directory origin; expiry is no later than ten seconds after issuance. Validate all fields and current generation/epoch on every next-page request. A changed membership epoch invalidates the cursor; no server-side retained roster snapshot is needed. A concurrent change yields authenticated409 stale-cursor with current generation/epoch, never a mixed page. This also means a busy changing roster can refuse refresh; the consumer's maximum age still expires.
+
+## Signed page envelope
+
+A roster-only directory P256 key signs responses, separately provisioned from credential/browser assertion signing keys. Verification keys come only from the operator record, maximum8. An exact response envelope has v, kid, payload and signature. payload is canonical base64url of bounded UTF8 JSON; signature is canonical base64url of raw64 bytes. Verify crypto.Verify(publicKey, "oficina/roster/page/v1", payloadBytes, signature) before using any payload field. Parse envelope and payload with strict closed fields, duplicate-name rejection, integer-only numeric fields and bounded strings. Limit complete HTTP body to64KiB before decoding or crypto; no HTTP compression expansion, redirects or content-type alternatives.
+
+Success payload fields are version, type=page, directory, consumer, team, audience, fetch, request_cursor_hash, generation, epoch, page_index, members, complete and cursor. Bind directory/consumer/team/audience to the provisioning record, fetch to this refresh, and request_cursor_hash to SHA256 of the exact incoming cursor string (empty on page0). page_index starts0 and increases once per cursor. Nonfinal pages have exactly100 members and a nonempty cursor; final pages have0..100 and no cursor. Epoch is a positive signed64-bit integer. The client rejects duplicate/unsorted person refs within or across pages, nonprogressing cursors, unknown roles, more than100 pages or10000 members, and any changed generation/epoch/scope inside one refresh.
+
+The signed409 payload has the same scope/challenge/request-cursor binding and only type=stale-cursor, generation and current epoch instead of membership fields. It cannot publish an empty roster. An unfamiliar generation in an otherwise valid signed response fences standing immediately. An invalid signature, transport error, unsigned status or malformed body cannot update any pin or epoch; it is a failed fetch. HTTP status must agree with signed payload type. No304/unchanged shortcut exists: only a full authenticated complete refetch can renew the same epoch's age.
+
+The client dials only the pinned directory origin with the consumer token in Authorization, standard verified TLS and a no-redirect transport. One refresh has a five-second total context deadline across every page, bounded body reads, and at most one in-flight refresh per instance. A fresh per-refresh challenge prevents replayed signed pages from becoming a fresh observation. Cursors do not select a destination; the next request always uses the same pinned /roster URL. No page/redirect can cause a cross-team request or carry the bearer to a different host.
+
+## Atomic cache and expiry
+
+A client starts with no usable roster, including after process restart. It stages verified pages privately, then atomically publishes a complete immutable snapshot and its lease under a mutex. Failure leaves the previous bytes intact without changing their lease. No accessor can see staging rows. Persist an authenticated complete snapshot plus accepted record sequence, generation, epoch and digest as bounded restore evidence before publication; do not persist a timestamp that becomes a fresh lease at startup. At most1MiB of canonical member payload and10000 rows are retained. If persistence fails, do not publish or advance age. Lower epoch within the pinned generation refuses. Same epoch with a different member digest refuses. A provisioned minimum epoch and any newer durable floor apply even after restart.
+
+The initial engineering policy, inherited from fa64e28, is refresh due30 seconds after the start of the last successful complete fetch and a hard expiry60 seconds after that start. The five-second fetch time is included in the60 seconds, not added afterwards. Use Go monotonic time for in-process deadlines; never refresh age from page completion, hint, partial response, failed fetch or process startup. Compare wall-clock observations too: a detected backward adjustment fences current standing and requires a fresh complete challenged fetch before restoring it; an old durable timestamp never restores standing. Existing in-flight leases from before that fence become invalid. Monotonic time protects the new lease from the wall-clock adjustment itself.
+
+Check is tri-state for the exact configured issuer/team/subject: Member(role, lease), NotMember, or Unknown. Missing/invalid provisioning, unapproved generation, cold/expired cache and failed cold refresh are Unknown. A complete usable snapshot lacking the subject is NotMember. On requests at or after30 seconds, one refresh starts; other callers may use the old snapshot only before its hard deadline. At or after60 seconds they wait within the shared five-second budget or receive Unknown. Retry/backoff never changes the deadline; cap retries to one start per five seconds and one flight. Refresh is demand-driven on wake/requests and long-lived stream use, not an independent cron keeping the instance alive.
+
+A lease includes snapshot/record version and monotonic deadline. Its validity is rechecked immediately before each protected side effect or emitted stream event; a generation/record/clock fence invalidates old leases immediately. Deadline passed means no team standing, including already-signed-in sessions and background/sibling work. A removal just after a successful snapshot therefore takes effect no later than that snapshot's60-second ceiling even when every refresh fails. The implementation must measure this deadline in tests and canary; it is not a production latency claim yet.
+
+Do not add roster_epoch to current assertion wire version1 in this change: it has no such field. Future call2/call5/assertion hints can request one bounded refresh only; they cannot update membership, advance lease age, select a generation or directly deny a person. Call7 never invalidates from a hint. Signed complete call8 data, not a claim, determines standing.
+
+## Docs integration and principal allocation
+
+Introduce explicit provisioned TeamMode alongside the existing standalone and limited account-link preview modes. Standalone remains local and never dials Home. The existing preview remains explicitly a compatibility preview, not authoritative team mode. TeamMode requires the verified operator record, token, exact Home identity issuer and roster client; partial config refuses startup. The Home chooser must not mark an instance live for general team entry until this configuration and its acceptance gates are satisfied.
+
+The principalstore callback agreed with the Home-principal author receives exact issuer/team/subject. Map a complete fresh nonmember to ErrRefused and cold/stale/unavailable/unapproved-generation to ErrUnavailable. Invoke Check before opening its SQL allocation transaction; do not perform network work under a writer lock. Allocation grants no permission and stores no role, timestamp or lasting standing receipt. Existing principal mappings survive removal; every actual document access rechecks the current cache or its separate grant. The callback result is one bounded observation, not permanent authorization.
+
+For Home sessions, use the already verified opaque subject and exact credential issuer. For linked local sessions, resolve the existing person's unique identity link in reverse to obtain the Home subject; an email allowlist cannot substitute for that mapping or for membership. A local person without a link can still use the explicit fresh local account-link settings path under its existing local admission policy, but that path must be mounted separately from document/team admission so linking does not require the standing it establishes. Local delivery allowlists remain delivery controls, never TeamMode member lists.
+
+Per-request authorization carries the typed Actor plus tri-state team standing. Full member powers (create, share/revoke/publish administration) require a currently valid member lease; the roster owner role does not make someone the owner of every document. Existing OwnerIDs and item grants are preserved and remain item-level checks. Fresh membership never makes another person's private document readable.
+
+When standing is NotMember or Unknown, only independently verified individual grants may reach documents. Add a grant-only query mode to readable, writable, GetFor, ListFor, Search, CanWrite and update transactions: the ordinary OwnerID shortcut is disabled in that mode. Otherwise a former member with one unrelated guest grant could pass global admission and reopen all their owned documents despite stale standing. This changes no stored OwnerID or grant. Unknown without an applicable grant is a retryable503/UNKNOWN, not a fabricated membership denial or permanent save failure. Known nonmembership without an applicable grant keeps the existing non-disclosing404/403 behavior. Global admission cannot merely ask whether any grant exists and then restore the full actor scope.
+
+Recheck the individual grant for the requested document on every access. Existing address grants use the linked verified local address, never the Home subject; a Home-only principal with no local address does not inherit email grants. New Home-only guest identity allocation or new grant kinds are outside this change. Public read-only capability links keep their own independent token checks and confer no membership. No team-visible document rung is added merely by shipping roster admission.
+
+Long-lived SSE must use the same authorization decision before every event/heartbeat and terminate on expired/unknown standing unless that exact document remains independently granted. A timer at the current lease deadline closes an otherwise silent stream; it is not enough to check only when the HTTP handler opens. Request-body decoding can cross an expiry: recheck the lease immediately before update/create/grant commits. Keep network refresh outside SQL write transactions; local lease validity is an in-memory check, and failure aborts the transaction before commit.
+
+## Bounded implementation and acceptance
+
+Implement in four reviewable pieces: shared protocol/record verification and cache with deterministic clock/transport tests; directory producer and token/epoch/cursor store under unmounted audited-configuration seams; Docs TeamMode admission/query/SSE adapter with the principalstore callback; only then provisioned runtime wiring and two isolated-team canary. Every piece gets full relevant tests, actual CI and independent Fable review; producer/store tests do not authorize a live seed.
+
+Required failures: missing/unsigned/foreign/replayed operator record; changed generation on first/later page; wrong key/context/token/scope/challenge/cursor; consumer rotation/disable; unknown and foreign team identical404; mixed epochs, same-epoch changed digest, lower epoch/restart, reordered/duplicated/truncated pages, explicit empty roster and the10000-member bound. Assert no token/ref/label/path leakage to logs or unexpected outbound hosts. Verify no partial publish, no age extension after any failure/304/hint, hard60-second removal under all-failure refresh, restart and backward-clock fencing, one flight under concurrency, and staging cancellation on provisioning-record update.
+
+Docs tests must keep an existing session open through removal/expiry, distinguish grant-only access from OwnerID bypass, preserve owned/private and explicitly shared item boundaries, enforce create/share/update expiry, close SSE at the lease deadline, retain legacy owner/grant/content bytes, and show unknown membership refuses Home-only allocation without creating a principal. Two teams and two tabs remain isolated. Mutations must expose missing signature/challenge, early staging publish, timestamp renewal on failure, unchecked OwnerID fallback, and stream/commit checks omitted. Authenticated-generation restore and the supported operator delivery path remain production gates; a synthetic test fixture is not an owner or provisioning authority.
diff --git a/implementation/roster-http-signing.md b/implementation/roster-http-signing.md
new file mode 100644
index 0000000..f9ac698
--- /dev/null
+++ b/implementation/roster-http-signing.md
@@ -0,0 +1,219 @@
+# Directory roster HTTP and signing integration
+
+Status: design-only; review record in Home discussion 34. Explicit Fable design
+approval is required before implementation.
+Scope: Home discussion 34, stacked on directory runtime 171f49a, shared roster
+667dc82 and producer 32fb49c. The approved roster contract (discussion 28) and
+producer storage contract (discussion 33) remain authoritative. This design adds
+runtime wiring and an HTTP adapter; it adds no operator mutation or provisioning
+transport and makes no deployment/readiness claim.
+
+## Source audit and boundaries
+
+`runtime.go` currently merges sessions, handoff and directorydata.Schema only for
+the directory role. The credential role alone owns `Runtime.signer` and
+HOME_ASSERTION_KEY. Directory configuration holds credential public verification
+keys and its own sealer; `newDirectory` rejects a credential private signer.
+`directoryRouter` puts sessions middleware around the browser routes.
+`directoryFeatures` already supplies the compiled app registry and validated
+team-origin policy. Reuse that registry and policy when constructing the producer;
+do not duplicate per-team rows in environment configuration.
+
+The published client builds GET directory+/roster with `url.Values.Encode`, a
+Bearer token, Accept application/json and Accept-Encoding identity. It refuses
+redirects, compression, bodies over 64 KiB and Content-Type values other than
+exact application/json. It checks signatures before using page fields. Page-zero
+CursorHash is SHA256(empty), not an empty string. There is no epoch query or 304
+shortcut. Wire members use person_ref. The pending Docs cache lease amendment
+changes no Page/SignPage contract; this adapter remains compatible with 667dc82.
+
+## Runtime and schema
+
+Merge directorydata.RosterSchema immediately after directorydata.Schema in the
+directory-only schema. Credential and legacy schemas must not gain these tables
+or membership triggers. Applying this migration leaves generation, consumers,
+tokens and receipts empty. Repeated startup never fills them.
+
+Introduce HOME_ROSTER_MODE with absent/disabled and serve values. In directory
+mode, absent/disabled plus an absent HOME_ROSTER_KEY means a disabled endpoint.
+The directory browser routes and boot remain available; every request that passes
+the roster host/method guards receives the same fixed 503 before inspecting any
+token, query or database. There is no standalone roster fallback.
+
+Serve requires HOME_ROSTER_KEY. Unknown mode, key supplied while disabled, missing
+serve key or invalid key refuses startup with one fixed configuration error.
+Credential and legacy roles reject either roster environment variable when set,
+including an explicit disabled value. Direct Runtime constructors enforce the
+same mode/key invariants; LoadRuntime is not the only guard.
+
+Represent roster configuration separately, for example a private rosterRuntime
+field with mode, signer and derived key ID. Keep Runtime.signer credential-only
+and preserve newDirectory's existing rejection of it. Directory now deliberately
+owns one new roster-only private key; update role comments, validation and tests
+that currently equate all private signing keys with credential authority.
+
+HOME_ROSTER_KEY is canonical standard-base64 of the existing family.MarshalKeypair
+format, bounded to 4096 encoded bytes, with no alternate file or second encoding
+input. Decode strictly and verify the encoding round trip. Validate the closed
+keypair JSON before calling family.UnmarshalKeypair: exactly sign_priv_d and
+box_priv_d, canonical 64-character lowercase hexadecimal strings, no duplicate or
+unknown fields. Require P256 signing scalar 0<D<N and valid box scalar; derived
+public keys come from the existing library. The box half is unused by roster.
+Derive roster key ID as lowercase hex SHA256(SignPub()), matching the shared
+64-byte atom bound. Do not take the key ID from request input.
+
+Reject a roster signing public key equal to any configured credential assertion
+verification key. The independently audited operator provisioning key must also
+be distinct; it is not loaded by this producer and its separation is a writer/
+provisioning gate. Never derive a roster private key from HOME_DEK, pepper, token,
+credential key or startup randomness. Startup does not publish a verification key
+or manufacture a signed operator record. Key rotation requires the separately
+approved operator record/key rollout before changing the active signing key.
+
+## Handler seam and dispatch
+
+Use an unexported rosterHandler with the concrete validated roster runtime,
+configured directory origin, clock seam and a small read-source interface:
+Read(context.Context, directorydata.RosterRequest, time.Time)
+(roster.Page, int, error). Production injects NewRosterSource using the existing
+store's DB/sealer/app policy. Tests may inject a counting source or real store.
+Disabled mode does not construct or invoke a source or signer.
+
+Dispatch decoded URL.Path == /roster to this adapter before directory browser
+session/chooser middleware. The adapter owns all methods for that exact route,
+so chi's default method response cannot bypass its headers. Cookies neither
+authorize the request nor cause Set-Cookie, sign-in redirects or session writes.
+All other routes retain the existing directory router and guards. The roster
+adapter does not reuse browser CSRF or assertion-consumer middleware.
+
+At adapter entry set Cache-Control: no-store, no-transform, Content-Type: application/json and
+X-Content-Type-Options: nosniff. No CORS permission, compression, ETag, Last-Modified,
+Location or browser cookie is emitted. Refusal precedence is:
+
+1. r.Host must equal the configured directory host, including any configured
+ port, exactly. No trailing-dot, alternate-host or forwarded-header alias.
+ Wrong host returns fixed 404, before source access.
+2. Method must be GET; HEAD, POST, OPTIONS and all others return fixed 405 with
+ Allow: GET. Nothing follows a redirect to turn a POST into a roster read.
+3. Disabled runtime returns fixed 503 for every token/query variation.
+4. Validate request syntax below; malformed query/path/body returns fixed 400.
+5. Validate one exact bearer header; malformed/missing/duplicate returns fixed401.
+6. Call the source once with a current server time. Nonpositive server time
+ returns fixed503 here, before the source's own input check.
+
+Require URL.EscapedPath() exactly /roster, no absolute request-target, no fragment,
+and no nonempty GET body: ContentLength must be zero and TransferEncoding empty.
+Do not read an attacker-sized body. The configured directory origin plus the
+request path/query must fit 4096 bytes; bound RequestURI too. A valid first query
+contains exactly team and fetch. A continuation additionally contains one
+nonempty cursor. Query field order is unrestricted. Reject duplicate/unknown or
+empty fields, extra separators, semicolons, percent encodings, plus encodings and
+padding. Parse literal bounded name=value pairs; do not use a helper that silently
+discards malformed parameters. Team is 1..64 ASCII alphanumerics, fetch passes
+roster.ValidChallenge, and cursor is strict canonical raw-base64url at most
+roster.CursorLimit (2048 bytes), checked by decoding and round trip. The producer
+validates the sealed cursor contents again. Neither team nor cursor selects a
+network destination.
+
+Count Authorization values across case-insensitive header names; require exactly
+one value equal to Bearer + one ASCII space + a canonical43-character token.
+Reject comma lists, repeated lines, tabs, extra spaces, alternate schemes and
+padded/noncanonical values. Use roster.ValidChallenge before calling the source.
+No browser session, Origin, Referer or query token substitutes for this header.
+Accept and Accept-Encoding need no special value from the caller: the adapter
+always returns the one JSON representation without compression. The no-transform
+directive is required by the CARLOS edge compressor as well as the app adapter:
+without it, application/json responses can be gzipped for Accept-Encoding: gzip,
+including small error responses when the upstream length is unknown.
+
+The application adapter never redirects. The existing outer Go ServeMux can
+canonicalize unrelated malformed paths before the application sees them; the
+shared client never follows these redirects. Tests through the real outer mux
+must show no alternate path produces a trusted page, and canonical /roster never
+redirects for any method/query/error. No change to global router normalization is
+needed to serve the fixed canonical endpoint.
+
+## Response and error boundary
+
+Give source.Read a request-derived context capped at five seconds, and obtain
+now immediately before it. No network call or operator write occurs under that
+read transaction. On nil error, accept only status200 with type page or status409
+with type stale-cursor. Sign with roster.SignPage using the validated roster key
+and derived ID, and buffer the complete returned bytes before writing headers.
+Check len <= roster.BodyLimit as a final boundary. Sign/marshal/status failure
+returns the fixed unsigned503; never emit an unsigned Page or a partial signed
+response. Do not duplicate the shared envelope/signature or JSON-pointer logic.
+
+A source failure maps only its fixed401/404/400/503 error/status pairs. Unexpected
+pairs become fixed503. Keep the approved source's exact mapping, including401
+for a missing token-to-registry join; do not infer a more revealing cause.
+Authenticated epoch/generation change is the signed409 shape, with current scope,
+fetch, request-cursor hash, generation and epoch and all page-only fields absent.
+It is never treated as an empty successful roster or a new generation approval.
+
+Unsigned errors contain only a fixed machine code: bad_request, unauthorized,
+not_found, method_not_allowed or unavailable in an error JSON member. No supplied
+value or underlying error is serialized. No response includes a team name, email,
+origin selected from request input or token diagnostic. All errors keep no-store
+and exact application/json, although the client still refuses unsigned errors.
+No raw URLs, bodies, cursor, token, label, subject or key enters logs, traces,
+metrics or status messages. The handler itself needs no logging. Transport and
+platform access-log behavior must be checked before any live enablement; the
+application's no-log claim does not attest to an unaudited proxy configuration.
+
+## Authority and deployment gates
+
+In serve mode an otherwise authenticated, scoped request with absent generation
+returns503 through the source;
+it must not mint a generation, owner, membership, token or receipt. Invalid or
+unknown tokens remain401 and foreign/unknown teams remain identical404. Merely
+installing the key does not initialize or approve authority. Runtime health may
+report process health as before; it is not a claim that roster provisioning is
+complete or that Docs TeamMode is ready.
+
+The future writer must authenticate independent operator authority before its
+transaction, verify the exact scope/expected generation/record sequence, assert
+foreign_keys ON, and atomically write state and a successful receipt. It must
+follow the producer storage contract's token-slot and epoch-trigger rules.
+This slice adds no such writer, route, command, migration seed or environment
+import. No first visitor or roster bearer can supply it. Existing-db bootstrap
+and restore remain behind the supported fenced maintenance path (platform117);
+a genuinely fresh first-boot path needs separate audit. Without either approved
+path there is no live generation/consumer initialization and no general team
+roster launch. Test SQL and test operator signatures prove behavior only.
+
+## Implementation acceptance
+
+Run the complete Home gate, focused producer/handler race tests, actual CI and
+Fable whole-code review on a frozen candidate before parent integration. Tests
+must use actual runtime/schema/router paths as well as the injected handler:
+
+- Directory schema contains roster tables/triggers; credential/legacy schemas do
+ not. Empty and repeated startup leave authority tables empty. Disabled runtime
+ returns identical503 across token/query inputs without DB/signing/session work.
+- Mode/key matrix covers both environment loader and direct constructors, role
+ separation, malformed/private-key scalar/JSON cases, credential key reuse and
+ no startup generation. Existing Home handoff/chooser browser gates still pass.
+- Exact Host, all wrong methods, duplicate/mixed-case Authorization maps, comma/
+ whitespace forms, absent or noncanonical challenges, hostile team/cursor,
+ duplicate/unknown/escaped query keys, empty cursor, oversized target and GET
+ body all refuse before any source call. Errors leak none of the test corpus.
+- Use real directory fixture transactions and the actual shared Cache client
+ against the handler through an injected same-origin TLS test transport. Signed
+ 200 with zero, one and more than100 members verifies; exact empty array stays
+ explicit. Rotation, disable and foreign/unknown team failures remain bounded.
+- Between pages mutate membership or generation; verify signed409 and no cache
+ publication/lease extension. Wrong key/context/scope/signature cannot become
+ standing. A missing generation never signs a successful page. No epoch/304
+ response path exists. Two consumers and two teams stay isolated.
+- Buffer/sign failures return unsigned503 with no partial Page. Every adapter
+ result has no-store, no-transform/exact JSON and no Location, encoding, validator or cookie.
+ The outer mux/real server retains these for canonical /roster, and no redirect
+ is followed by the shared client.
+- Seed names, tokens and cursor plaintext in fixtures and scan DB/WAL plus
+ captured application logs. Negative mutations removing bearer/mode/Host
+ checks, bypassing SignPage, or dropping signed409 expose failing tests.
+
+No browser UI is introduced by this endpoint. A deployed two-team transport
+canary is a later root-owned gate once an audited provisioning path exists; this
+design and local fixtures do not authorize live seeding or mark the suite ready.
diff --git a/implementation/roster-producer-storage.md b/implementation/roster-producer-storage.md
new file mode 100644
index 0000000..31d442e
--- /dev/null
+++ b/implementation/roster-producer-storage.md
@@ -0,0 +1,76 @@
+# Roster producer storage integration contract
+
+Scope: discussion 33, implementing the approved roster contract in discussion 28.
+This branch adds no mounted schema, route, operator command or production data.
+
+`directorydata.RosterSchema` follows `directorydata.Schema` in a future directory
+schema merge. It creates empty generation, consumer, token and receipt tables.
+`NewRosterSource(db, sealer, directoryConfig, directoryOrigin)` copies the existing
+application and route policy. `Read(ctx, RosterRequest, now)` returns the shared
+`roster.Page`, HTTP status and a fixed error. A successful 409 has a nil error and
+no member fields. The HTTP caller must sign both 200 and 409 with shared
+`roster.SignPage`; no raw Page is a trusted wire response.
+
+The HTTP integration still owns exact Host enforcement, method/query/header
+shape and length checks, no-store on every response, fixed response bodies,
+no request-URL logging, and roster-only signing keys. `Read` has no outbound
+transport. All scope comes from the token's consumer and its matching live
+registry row. A requested team only compares against that scope.
+
+## Future audited writer transaction
+
+There is deliberately no public mutation API. The following are preconditions
+for the root-owned operator integration, not authority conferred by these tables.
+Before opening its write transaction, authenticate and authorize the operation
+using the separately reviewed operator authority. Verify the operation ID,
+monotonic record sequence within its consumer scope, exact directory, consumer, team, app, audience and
+expected current generation. Refuse replay, changed payload under an old ID,
+and unknown authority. Never accept browser identity or bearer possession as
+operator authority. Recheck expected stored state under the transaction lock.
+
+Within the same transaction, write the intended registry/consumer/token or
+generation change and its successful receipt. The receipt retains only opaque
+operation ID, sequence, fixed operation kind, optional consumer ID, generation,
+SHA256 record digest and completion time. It is not a signed provisioning record
+and cannot approve a client generation. The operator path must deliver the
+separately signed record through the approved transport. No token or team name
+belongs in the receipt. Name sealing remains the existing ID-bound helper.
+
+Token slots are normalized rows with a globally unique SHA256 hash of the exact
+canonical 43-character bearer string. This prevents cross-consumer and cross-slot
+collisions. To rotate, delete an older previous slot, change the current row to
+previous with issued_at equal to the rotation time and expires_at at most 300
+seconds later, and insert the new current hash. Here issued_at starts the slot's
+validity window; it is not the original token creation timestamp. Current slots
+have no expiry. Future-dated slots fail. Disable by setting enabled=0 in the
+same audited transaction; no read changes slots or extends an overlap.
+
+Ordinary boot never fills generation. Bootstrap needs the independently approved
+fresh-install path. Restore needs the platform's fenced maintenance path and a
+new random generation plus newly approved records before traffic resumes.
+Directory storage alone does not supply either path. Generation and receipt
+values must be canonical and exact when written; the reader also rejects a
+malformed generation. Consumers bind to a matching registry consumer/team/app/
+origin and the injected app/route policy; pending or disabled instances do not
+serve rosters.
+
+Membership INSERT, UPDATE and DELETE triggers advance affected teams' epochs in
+the writing transaction and abort on signed 64-bit overflow. The future writer
+must not add a second manual increment. Moving membership bumps both teams once;
+changing a role bumps one. Even a no-op UPDATE bumps its team. Do not lower an
+epoch or bypass triggers. Restore is the separate generation-fenced procedure.
+
+## Snapshot and cursor
+
+One read transaction resolves the token, enabled/overlap state, live binding,
+generation, epoch, count and ordered page. It does not keep a server snapshot
+between requests. A membership write after that transaction begins may appear
+on the next call; the consumer's standing lease accounts for this bound.
+
+The cursor is sealed with purpose `directory.roster-cursor/<directory origin>`.
+Its closed JSON binds consumer, team, generation, epoch, fetch challenge,
+last opaque subject, next index, issuance and expiry. It expires at ten seconds,
+including at the exact boundary. Wrong scope/challenge, malformed, expired or
+unauthentic cursors return fixed 400. A scoped valid cursor with changed
+generation/epoch returns the signed 409 shape with current values. Rotation and
+disable are checked again before cursor processing. No cursor grants authority.
diff --git a/internal/home/directory.go b/internal/home/directory.go
index 2e3c8dc..fe25f09 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -17,7 +17,7 @@ import (
)
func newDirectory(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) {
- if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil {
+ if cfg.role != "directory" || cfg.origins.validate() != nil || cfg.secrets.Sealer == nil || d == nil || cfg.signer != nil || !cfg.roster.valid(cfg.role, cfg.keys) {
return nil, errExchangeConfig
}
if logger == nil {
@@ -93,5 +93,11 @@ func (a *App) directoryRouter() http.Handler {
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
})
- return r
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == rosterPath {
+ a.directory.roster.ServeHTTP(w, req)
+ return
+ }
+ r.ServeHTTP(w, req)
+ })
}
diff --git a/internal/home/directory_runtime.go b/internal/home/directory_runtime.go
index 3d15c65..a829720 100644
--- a/internal/home/directory_runtime.go
+++ b/internal/home/directory_runtime.go
@@ -27,8 +27,9 @@ func directoryRouteAllowed(route directorydata.Route) bool {
}
type directoryFeatures struct {
- store *directorydata.Store
- docs *docsChooser
+ roster *rosterHandler
+ store *directorydata.Store
+ docs *docsChooser
}
func (a *App) newDirectoryFeatures() (*directoryFeatures, error) {
@@ -46,7 +47,18 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) {
if err != nil {
return nil, errExchangeConfig
}
- return &directoryFeatures{store: store, docs: chooser}, nil
+ var source rosterReadSource
+ if a.runtime.roster.mode == "serve" {
+ source, err = directorydata.NewRosterSource(a.db.Writer(), a.secrets.Sealer, directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}, a.origins.Directory)
+ if err != nil {
+ return nil, errExchangeConfig
+ }
+ }
+ handler, err := newRosterHandler(*a.runtime, source)
+ if err != nil {
+ return nil, err
+ }
+ return &directoryFeatures{store: store, docs: chooser, roster: handler}, nil
}
func chooserPath(r *http.Request) bool {
diff --git a/internal/home/directorydata/roster.go b/internal/home/directorydata/roster.go
new file mode 100644
index 0000000..2023d7f
--- /dev/null
+++ b/internal/home/directorydata/roster.go
@@ -0,0 +1,194 @@
+package directorydata
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "errors"
+ "io/fs"
+ "time"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+)
+
+//go:embed roster_migrations/migrations/*.sql
+var rosterMigrations embed.FS
+
+// RosterSchema is directory-only and deliberately unmounted. Applying it leaves
+// generation and consumer authority empty. Apply it after Schema.
+var RosterSchema = func() *migrate.Set {
+ f, err := fs.Sub(rosterMigrations, "roster_migrations")
+ if err != nil {
+ panic(err)
+ }
+ return migrate.MustFromFS(f, "oficina_directory_roster")
+}()
+
+var (
+ ErrRosterUnauthorized = errors.New("roster unauthorized")
+ ErrRosterNotFound = errors.New("roster not found")
+ ErrRosterBadRequest = errors.New("invalid roster request")
+)
+
+type RosterSource struct {
+ store *Store
+ directory string
+}
+
+// NewRosterSource creates a read-only source. The caller authenticates operator
+// provisioning separately; this constructor never initializes authority.
+func NewRosterSource(db *sql.DB, sealer Sealer, cfg Config, directory string) (*RosterSource, error) {
+ if !ValidOrigin(directory) {
+ return nil, ErrInvalid
+ }
+ s, err := New(db, sealer, cfg)
+ if err != nil {
+ return nil, err
+ }
+ return &RosterSource{store: s, directory: directory}, nil
+}
+
+type RosterRequest struct{ Token, Team, Fetch, Cursor string }
+
+type rosterCursor struct {
+ Version int `json:"version"`
+ Consumer string `json:"consumer"`
+ Team string `json:"team"`
+ Generation string `json:"generation"`
+ Epoch int64 `json:"epoch"`
+ Fetch string `json:"fetch"`
+ Last string `json:"last"`
+ Next int `json:"next"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+func (s *RosterSource) cursorPurpose() string { return "directory.roster-cursor/" + s.directory }
+
+// Read observes authorization, generation, epoch and members in one transaction.
+// Its Page must be signed by roster.SignPage at the HTTP boundary. A 409 result
+// has no membership fields. No returned mapping grants item access.
+func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Time) (roster.Page, int, error) {
+ fail := func(status int, err error) (roster.Page, int, error) { return roster.Page{}, status, err }
+ if !roster.ValidChallenge(req.Token) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ if !alnum(req.Team, 64) || !roster.ValidChallenge(req.Fetch) || len(req.Cursor) > roster.CursorLimit || now.Unix() <= 0 {
+ return fail(400, ErrRosterBadRequest)
+ }
+ tx, err := s.store.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ defer tx.Rollback()
+ hash := sha256.Sum256([]byte(req.Token))
+ var route Route
+ var enabled int
+ var state, slot string
+ var issued int64
+ var expires sql.NullInt64
+ err = tx.QueryRowContext(ctx, `SELECT c.consumer_id,c.team_id,c.app,c.origin,c.enabled,k.slot,k.issued_at,k.expires_at,t.sqid,t.label,i.state
+ FROM roster_tokens k JOIN roster_consumers c ON c.consumer_id=k.consumer_id
+ JOIN teams t ON t.id=c.team_id JOIN team_instances i ON i.team_id=c.team_id AND i.app=c.app AND i.consumer_id=c.consumer_id AND i.origin=c.origin
+ WHERE k.token_hash=?`, hash[:]).Scan(&route.ConsumerID, &route.TeamID, &route.App, &route.Origin, &enabled, &slot, &issued, &expires, &route.TeamSQID, &route.Label, &state)
+ if errors.Is(err, sql.ErrNoRows) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ if enabled != 1 || issued > now.Unix() || issued <= 0 || (slot != "current" && slot != "previous") || slot == "current" && expires.Valid || slot == "previous" && (!expires.Valid || expires.Int64 <= now.Unix() || expires.Int64 <= issued || expires.Int64-issued > 300) {
+ return fail(401, ErrRosterUnauthorized)
+ }
+ if req.Team != route.TeamSQID {
+ return fail(404, ErrRosterNotFound)
+ }
+ if state != "live" || !s.store.apps[route.App] || !ValidOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
+ return fail(503, ErrUnavailable)
+ }
+ p := roster.Page{Version: 1, Type: "page", Directory: s.directory, Consumer: route.ConsumerID, Team: route.TeamSQID, Audience: route.Origin, Fetch: req.Fetch, RequestCursorHash: roster.CursorHash(req.Cursor)}
+ err = tx.QueryRowContext(ctx, `SELECT g.generation,t.roster_epoch FROM roster_generation g JOIN teams t ON t.id=? WHERE g.singleton=1`, route.TeamID).Scan(&p.Generation, &p.Epoch)
+ if err != nil || !roster.ValidChallenge(p.Generation) || p.Epoch <= 0 {
+ return fail(503, ErrUnavailable)
+ }
+ index := 0
+ last := ""
+ if req.Cursor != "" {
+ raw, e := base64.RawURLEncoding.Strict().DecodeString(req.Cursor)
+ if e != nil || base64.RawURLEncoding.EncodeToString(raw) != req.Cursor {
+ return fail(400, ErrRosterBadRequest)
+ }
+ plain, e := s.store.sealer.Open(s.cursorPurpose(), raw)
+ if e != nil {
+ return fail(400, ErrRosterBadRequest)
+ }
+ var c rosterCursor
+ if json.Unmarshal(plain, &c, json.RejectUnknownMembers(true)) != nil || c.Version != 1 || c.Consumer != route.ConsumerID || c.Team != route.TeamSQID || c.Fetch != req.Fetch || !validSubject(c.Last) || !roster.ValidChallenge(c.Generation) || c.Epoch <= 0 || c.Next < 1 || c.Next >= 100 || c.Issued <= 0 || c.Issued > now.Unix() || c.Expires <= now.Unix() || c.Expires <= c.Issued || c.Expires-c.Issued > 10 {
+ return fail(400, ErrRosterBadRequest)
+ }
+ if c.Generation != p.Generation || c.Epoch != p.Epoch {
+ p.Type = "stale-cursor"
+ if tx.Commit() != nil {
+ return fail(503, ErrUnavailable)
+ }
+ return p, 409, nil
+ }
+ index = c.Next
+ last = c.Last
+ }
+ var count int
+ if tx.QueryRowContext(ctx, `SELECT count(*) FROM (SELECT 1 FROM memberships WHERE team_id=? LIMIT 10001)`, route.TeamID).Scan(&count) != nil || count > roster.MemberLimit {
+ return fail(503, ErrUnavailable)
+ }
+ rows, err := tx.QueryContext(ctx, `SELECT person_ref,role FROM memberships WHERE team_id=? AND person_ref COLLATE BINARY > ? ORDER BY person_ref COLLATE BINARY LIMIT 101`, route.TeamID, last)
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ members := make([]roster.MemberEntry, 0, 101)
+ for rows.Next() {
+ var m roster.MemberEntry
+ if rows.Scan(&m.Subject, &m.Role) != nil || !validSubject(m.Subject) || (m.Role != "owner" && m.Role != "member") || m.Subject <= last {
+ rows.Close()
+ return fail(503, ErrUnavailable)
+ }
+ last = m.Subject
+ members = append(members, m)
+ }
+ err = rows.Err()
+ rows.Close()
+ if err != nil {
+ return fail(503, ErrUnavailable)
+ }
+ complete := len(members) <= roster.PageLimit
+ if !complete {
+ if index >= 99 {
+ return fail(503, ErrUnavailable)
+ }
+ members = members[:roster.PageLimit]
+ c := rosterCursor{Version: 1, Consumer: route.ConsumerID, Team: route.TeamSQID, Generation: p.Generation, Epoch: p.Epoch, Fetch: req.Fetch, Last: members[len(members)-1].Subject, Next: index + 1, Issued: now.Unix(), Expires: now.Unix() + 10}
+ b, e := json.Marshal(c)
+ if e != nil {
+ return fail(503, ErrUnavailable)
+ }
+ sealed, e := s.store.sealer.Seal(s.cursorPurpose(), b)
+ if e != nil {
+ return fail(503, ErrUnavailable)
+ }
+ cursor := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(cursor) > roster.CursorLimit {
+ return fail(503, ErrUnavailable)
+ }
+ p.Cursor = &cursor
+ }
+ p.Index = &index
+ p.Members = &members
+ p.Complete = &complete
+ if tx.Commit() != nil {
+ return fail(503, ErrUnavailable)
+ }
+ return p, 200, nil
+}
diff --git a/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql b/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql
new file mode 100644
index 0000000..955f890
--- /dev/null
+++ b/internal/home/directorydata/roster_migrations/migrations/0001_roster.sql
@@ -0,0 +1,43 @@
+CREATE TABLE roster_generation (
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
+ generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')
+);
+CREATE TABLE roster_consumers (
+ consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,
+ team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,
+ app TEXT NOT NULL,
+ origin TEXT NOT NULL,
+ enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),
+ FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT
+);
+CREATE TABLE roster_tokens (
+ token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),
+ consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,
+ slot TEXT NOT NULL CHECK(slot IN ('current','previous')),
+ issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at > 0),
+ expires_at INTEGER,
+ UNIQUE(consumer_id,slot),
+ CHECK((slot = 'current' AND expires_at IS NULL) OR
+ (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at > issued_at AND expires_at - issued_at <= 300))
+);
+CREATE TABLE roster_operator_receipts (
+ operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),
+ sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence > 0),
+ operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),
+ consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),
+ generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),
+ record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),
+ completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at > 0)
+);
+CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN
+ SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;
+END;
+CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN
+ SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;
+END;
+CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN
+ SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;
+ UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);
+END;
diff --git a/internal/home/directorydata/roster_test.go b/internal/home/directorydata/roster_test.go
new file mode 100644
index 0000000..cd57d2e
--- /dev/null
+++ b/internal/home/directorydata/roster_test.go
@@ -0,0 +1,383 @@
+package directorydata
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base32"
+ "encoding/base64"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+)
+
+var rosterNow = time.Unix(2000000000, 0)
+
+func challenge(b byte) string {
+ return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32))
+}
+func rosterFixture(t *testing.T) (*RosterSource, RosterRequest, string, int64) {
+ t.Helper()
+ s, d, path := fixture(t)
+ if _, err := migrate.Apply(context.Background(), d, RosterSchema); err != nil {
+ t.Fatal(err)
+ }
+ a := team(t, s, "A1", "alpha", privateName)
+ instance(t, s, a, "alpha", "docs", "live")
+ b := team(t, s, "B2", "beta", "Other")
+ instance(t, s, b, "beta", "docs", "live")
+ exec(t, s.db, `INSERT INTO roster_generation VALUES(1,?)`, challenge(3))
+ exec(t, s.db, `INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, a)
+ token := challenge(9)
+ h := sha256.Sum256([]byte(token))
+ exec(t, s.db, `INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()-1)
+ src, err := NewRosterSource(s.db, s.sealer, Config{Apps: []string{"docs"}, ValidateRoute: s.validateRoute}, "https://home.oficina.works")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return src, RosterRequest{Token: token, Team: "A1", Fetch: challenge(8)}, path, a
+}
+func addRoster(t *testing.T, s *RosterSource, team int64, n int) {
+ t.Helper()
+ tx, e := s.store.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ for i := 0; i < n; i++ {
+ raw := make([]byte, 16)
+ raw[0] = byte(i >> 8)
+ raw[1] = byte(i)
+ ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
+ if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, team, ref); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+}
+func TestRosterEmptyScopeRemovalPrivacy(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ ctx := context.Background()
+ p, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || p.Members == nil || len(*p.Members) != 0 || p.Complete == nil || !*p.Complete || p.Cursor != nil || p.Consumer != "alpha-docs" || p.Team != "A1" || p.Audience != "https://alpha.docs.oficina.works" {
+ t.Fatal(p, status, e)
+ }
+ member(t, s.store, a, personA, "owner")
+ p, _, e = s.Read(ctx, r, rosterNow)
+ if e != nil || len(*p.Members) != 1 || (*p.Members)[0].Subject != personA {
+ t.Fatal(p, e)
+ }
+ epoch := p.Epoch
+ exec(t, s.store.db, `DELETE FROM memberships WHERE team_id=?`, a)
+ p, _, e = s.Read(ctx, r, rosterNow)
+ if e != nil || len(*p.Members) != 0 || p.Epoch != epoch+1 {
+ t.Fatal(p, e)
+ }
+ for _, team := range []string{"B2", "Missing"} {
+ bad := r
+ bad.Team = team
+ p, status, e = s.Read(ctx, bad, rosterNow)
+ if status != 404 || !errors.Is(e, ErrRosterNotFound) || p.Team != "" {
+ t.Fatal(p, status, e)
+ }
+ }
+ sealtest.AssertNoPlaintext(t, path)
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ b, e := os.ReadFile(path + suffix)
+ if e != nil && os.IsNotExist(e) {
+ continue
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ if bytes.Contains(b, []byte(r.Token)) {
+ t.Fatal("plaintext token at rest")
+ }
+ }
+}
+func TestRosterRotationDisableAndMissingGeneration(t *testing.T) {
+ s, r, _, _ := rosterFixture(t)
+ ctx := context.Background()
+ old := r
+ tx, e := s.store.db.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=?`, rosterNow.Unix(), rosterNow.Unix()+300); e != nil {
+ t.Fatal(e)
+ }
+ r.Token = challenge(10)
+ h := sha256.Sum256([]byte(r.Token))
+ if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()); e != nil {
+ t.Fatal(e)
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ for _, req := range []RosterRequest{old, r} {
+ if _, status, e := s.Read(ctx, req, rosterNow); e != nil || status != 200 {
+ t.Fatal(status, e)
+ }
+ }
+ if _, status, e := s.Read(ctx, old, rosterNow.Add(300*time.Second)); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+ if _, e = s.store.db.Exec(`UPDATE roster_tokens SET expires_at=issued_at+301 WHERE slot='previous'`); e == nil {
+ t.Fatal("unbounded overlap accepted")
+ }
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=0`)
+ if _, status, e := s.Read(ctx, r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `UPDATE roster_consumers SET enabled=1`)
+ exec(t, s.store.db, `DELETE FROM roster_generation`)
+ if _, status, e := s.Read(ctx, r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterPagingSealedCursorAndEpoch(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ addRoster(t, s, a, 201)
+ ctx := context.Background()
+ first, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || len(*first.Members) != 100 || *first.Complete || first.Cursor == nil {
+ t.Fatal(first, status, e)
+ }
+ cursor := *first.Cursor
+ raw, err := base64.RawURLEncoding.DecodeString(cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := s.store.sealer.Open(s.cursorPurpose(), raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ b, e := os.ReadFile(path + suffix)
+ if os.IsNotExist(e) {
+ continue
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, secret := range [][]byte{[]byte(r.Token), []byte(cursor), plain} {
+ if bytes.Contains(b, secret) {
+ t.Fatal("roster request or cursor persisted")
+ }
+ }
+ }
+ sealtest.AssertNoPlaintext(t, path)
+ r.Cursor = cursor
+ second, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || *second.Index != 1 || len(*second.Members) != 100 || (*second.Members)[0].Subject <= (*first.Members)[99].Subject {
+ t.Fatal(second, status, e)
+ }
+ r.Cursor = *second.Cursor
+ third, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 200 || len(*third.Members) != 1 || !*third.Complete || third.Cursor != nil {
+ t.Fatal(third, status, e)
+ }
+ r.Cursor = cursor
+ for _, change := range []func(*RosterRequest){func(r *RosterRequest) { r.Fetch = challenge(99) }, func(r *RosterRequest) { r.Cursor = r.Cursor[:len(r.Cursor)-1] + "!" }, func(r *RosterRequest) { r.Cursor += "=" }} {
+ bad := r
+ change(&bad)
+ if _, status, e = s.Read(ctx, bad, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ }
+ if _, status, e = s.Read(ctx, r, rosterNow.Add(10*time.Second)); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, a, personA)
+ p, status, e := s.Read(ctx, r, rosterNow)
+ if e != nil || status != 409 || p.Type != "stale-cursor" || p.Index != nil || p.Members != nil || p.Complete != nil || p.Cursor != nil || p.Epoch != first.Epoch+1 {
+ t.Fatal(p, status, e)
+ }
+ exec(t, s.store.db, `UPDATE roster_generation SET generation=?`, challenge(4))
+ p, status, e = s.Read(ctx, r, rosterNow)
+ if e != nil || status != 409 || p.Generation != challenge(4) {
+ t.Fatal(p, status, e)
+ }
+}
+func TestRosterCursorStrictFieldsAndPurpose(t *testing.T) {
+ s, r, _, a := rosterFixture(t)
+ addRoster(t, s, a, 101)
+ p, _, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ b, _ := base64.RawURLEncoding.DecodeString(*p.Cursor)
+ plain, e := s.store.sealer.Open(s.cursorPurpose(), b)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, body := range []string{strings.TrimSuffix(string(plain), "}") + `,"unknown":1}`, strings.TrimSuffix(string(plain), "}") + `,"version":1}`} {
+ sealed, e := s.store.sealer.Seal(s.cursorPurpose(), []byte(body))
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+ }
+ var c rosterCursor
+ if json.Unmarshal(plain, &c) != nil {
+ t.Fatal("cursor decode")
+ }
+ if c.Expires-c.Issued != 10 {
+ t.Fatal(c)
+ }
+ sealed, e := s.store.sealer.Seal("directory.roster-cursor/https://other.oficina.works", plain)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterEpochOverflowAtomicAndOtherTeamIsolation(t *testing.T) {
+ s, _, _, a := rosterFixture(t)
+ member(t, s.store, a, personA, "owner")
+ var before int64
+ if e := s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&before); e != nil {
+ t.Fatal(e)
+ }
+ exec(t, s.store.db, `UPDATE memberships SET role='member' WHERE team_id=?`, a)
+ var after int64
+ s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&after)
+ if after != before {
+ t.Fatal("foreign epoch changed")
+ }
+ exec(t, s.store.db, `UPDATE teams SET roster_epoch=9223372036854775807 WHERE id=?`, a)
+ for _, q := range []string{`DELETE FROM memberships WHERE team_id=?`, `UPDATE memberships SET role='owner' WHERE team_id=?`, `INSERT INTO memberships(team_id,person_ref,role) VALUES(?,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')`} {
+ if _, e := s.store.db.Exec(q, a); e == nil {
+ t.Fatal("overflow accepted")
+ }
+ }
+ var count int
+ s.store.db.QueryRow(`SELECT count(*) FROM memberships WHERE team_id=?`, a).Scan(&count)
+ if count != 1 {
+ t.Fatal(count)
+ }
+}
+func TestRosterBoundAndInvalidLookahead(t *testing.T) {
+ s, r, _, a := rosterFixture(t)
+ addRoster(t, s, a, 10001)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+ exec(t, s.store.db, `DELETE FROM memberships WHERE person_ref=(SELECT max(person_ref) FROM memberships WHERE team_id=?) AND team_id=?`, a, a)
+ p, status, e := s.Read(context.Background(), r, rosterNow)
+ if status != 200 || e != nil || len(*p.Members) != 100 {
+ t.Fatal(p, status, e)
+ }
+ exec(t, s.store.db, `DELETE FROM memberships`)
+ addRoster(t, s, a, 100)
+ exec(t, s.store.db, `PRAGMA ignore_check_constraints=ON`)
+ exec(t, s.store.db, `INSERT INTO memberships VALUES(?,'zz-invalid','owner')`, a)
+ exec(t, s.store.db, `PRAGMA ignore_check_constraints=OFF`)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
+ t.Fatal(status, e)
+ }
+}
+
+type onOpenSealer struct {
+ Sealer
+ once func()
+}
+
+func (s *onOpenSealer) Open(p string, b []byte) ([]byte, error) {
+ if s.once != nil {
+ f := s.once
+ s.once = nil
+ f()
+ }
+ return s.Sealer.Open(p, b)
+}
+func TestRosterOneReadSnapshotUnderConcurrentRevocation(t *testing.T) {
+ s, r, path, a := rosterFixture(t)
+ addRoster(t, s, a, 201)
+ p, _, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Cursor = *p.Cursor
+ writer, e := sql.Open("sqlite", path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer writer.Close()
+ s.store.sealer = &onOpenSealer{Sealer: s.store.sealer, once: func() {
+ tx, e := writer.Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, a); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = tx.Exec(`UPDATE roster_consumers SET enabled=0`); e != nil {
+ t.Fatal(e)
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ }}
+ second, status, e := s.Read(context.Background(), r, rosterNow)
+ if e != nil || status != 200 || second.Epoch != p.Epoch || len(*second.Members) != 100 {
+ t.Fatal(second, status, e)
+ }
+ if _, status, e := s.Read(context.Background(), r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
+ t.Fatal(status, e)
+ }
+}
+func TestRosterMigrationLeavesAuthorityEmpty(t *testing.T) {
+ _, d, _ := fixture(t)
+ if _, e := migrate.Apply(context.Background(), d, RosterSchema); e != nil {
+ t.Fatal(e)
+ }
+ for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} {
+ var n int
+ if e := d.Writer().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); e != nil || n != 0 {
+ t.Fatal(table, n, e)
+ }
+ }
+}
+
+func TestRosterInputAndConfigurationRefusal(t *testing.T) {
+ s, r, _, _ := rosterFixture(t)
+ for _, tc := range []struct {
+ change func(*RosterRequest)
+ status int
+ }{
+ {func(r *RosterRequest) { r.Token = "secret@example.invalid" }, 401},
+ {func(r *RosterRequest) { r.Token = challenge(55) }, 401},
+ {func(r *RosterRequest) { r.Team = "../B2" }, 400},
+ {func(r *RosterRequest) { r.Fetch = "canary@example.invalid" }, 400},
+ {func(r *RosterRequest) { r.Cursor = strings.Repeat("a", 2049) }, 400},
+ } {
+ bad := r
+ tc.change(&bad)
+ p, status, e := s.Read(context.Background(), bad, rosterNow)
+ if status != tc.status || e == nil || p.Team != "" || strings.Contains(e.Error(), "@") || strings.Contains(e.Error(), bad.Token) {
+ t.Fatal(p, status, e)
+ }
+ }
+ for _, q := range []string{`UPDATE team_instances SET state='pending'`, `UPDATE team_instances SET state='disabled'`, `UPDATE roster_consumers SET origin='https://foreign.oficina.works'`} {
+ exec(t, s.store.db, q)
+ if _, status, e := s.Read(context.Background(), r, rosterNow); e == nil || status != 503 && status != 401 {
+ t.Fatal(status, e)
+ }
+ }
+}
diff --git a/internal/home/roster_http.go b/internal/home/roster_http.go
new file mode 100644
index 0000000..299bee3
--- /dev/null
+++ b/internal/home/roster_http.go
@@ -0,0 +1,162 @@
+package home
+
+import (
+ "context"
+ "encoding/base64"
+ "net/http"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/roster"
+)
+
+const rosterPath = "/roster"
+
+type rosterReadSource interface {
+ Read(context.Context, directorydata.RosterRequest, time.Time) (roster.Page, int, error)
+}
+type rosterHandler struct {
+ runtime rosterRuntime
+ directory string
+ source rosterReadSource
+ now func() time.Time
+}
+
+func newRosterHandler(cfg Runtime, source rosterReadSource) (*rosterHandler, error) {
+ if cfg.role != "directory" || !cfg.roster.valid(cfg.role, cfg.keys) || !directorydata.ValidOrigin(cfg.origins.Directory) || (cfg.roster.mode == "serve") != (source != nil) {
+ return nil, errExchangeConfig
+ }
+ return &rosterHandler{runtime: cfg.roster, directory: cfg.origins.Directory, source: source, now: time.Now}, nil
+}
+func rosterFailure(w http.ResponseWriter, status int) {
+ code := "unavailable"
+ switch status {
+ case 400:
+ code = "bad_request"
+ case 401:
+ code = "unauthorized"
+ case 404:
+ code = "not_found"
+ case 405:
+ code = "method_not_allowed"
+ default:
+ status = 503
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(`{"error":"` + code + `"}`))
+}
+func rosterQuery(r *http.Request, directory string) (directorydata.RosterRequest, bool) {
+ var req directorydata.RosterRequest
+ if r.URL.EscapedPath() != rosterPath || r.URL.IsAbs() || r.URL.Fragment != "" || r.ContentLength != 0 || len(r.TransferEncoding) != 0 || len(r.RequestURI) > 4096 || len(directory)+len(rosterPath)+1+len(r.URL.RawQuery) > 4096 {
+ return req, false
+ }
+ fields := strings.Split(r.URL.RawQuery, "&")
+ if len(fields) != 2 && len(fields) != 3 {
+ return req, false
+ }
+ seen := map[string]bool{}
+ for _, field := range fields {
+ name, value, ok := strings.Cut(field, "=")
+ if !ok || value == "" || seen[name] {
+ return req, false
+ }
+ seen[name] = true
+ switch name {
+ case "team":
+ if len(value) > 64 {
+ return req, false
+ }
+ for _, c := range value {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
+ return req, false
+ }
+ }
+ req.Team = value
+ case "fetch":
+ if !roster.ValidChallenge(value) {
+ return req, false
+ }
+ req.Fetch = value
+ case "cursor":
+ if len(value) > roster.CursorLimit {
+ return req, false
+ }
+ b, e := base64.RawURLEncoding.Strict().DecodeString(value)
+ if e != nil || base64.RawURLEncoding.EncodeToString(b) != value {
+ return req, false
+ }
+ req.Cursor = value
+ default:
+ return req, false
+ }
+ }
+ return req, seen["team"] && seen["fetch"]
+}
+func rosterBearer(headers http.Header) (string, bool) {
+ var values []string
+ for name, v := range headers {
+ if strings.EqualFold(name, "Authorization") {
+ values = append(values, v...)
+ }
+ }
+ if len(values) != 1 || !strings.HasPrefix(values[0], "Bearer ") {
+ return "", false
+ }
+ token := strings.TrimPrefix(values[0], "Bearer ")
+ return token, roster.ValidChallenge(token)
+}
+func (h *rosterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store, no-transform")
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Host != strings.TrimPrefix(h.directory, "https://") {
+ rosterFailure(w, 404)
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.Header().Set("Allow", "GET")
+ rosterFailure(w, 405)
+ return
+ }
+ if h.runtime.mode != "serve" {
+ rosterFailure(w, 503)
+ return
+ }
+ req, ok := rosterQuery(r, h.directory)
+ if !ok {
+ rosterFailure(w, 400)
+ return
+ }
+ req.Token, ok = rosterBearer(r.Header)
+ if !ok {
+ rosterFailure(w, 401)
+ return
+ }
+ now := h.now()
+ if now.Unix() <= 0 {
+ rosterFailure(w, 503)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+ page, status, err := h.source.Read(ctx, req, now)
+ if err != nil {
+ if !(status == 400 && err == directorydata.ErrRosterBadRequest || status == 401 && err == directorydata.ErrRosterUnauthorized || status == 404 && err == directorydata.ErrRosterNotFound || status == 503 && err == directorydata.ErrUnavailable) {
+ status = 503
+ }
+ rosterFailure(w, status)
+ return
+ }
+ if !(status == 200 && page.Type == "page" || status == 409 && page.Type == "stale-cursor") {
+ rosterFailure(w, 503)
+ return
+ }
+ wire, err := roster.SignPage(h.runtime.signer, h.runtime.keyID, page)
+ if err != nil || len(wire) > roster.BodyLimit {
+ rosterFailure(w, 503)
+ return
+ }
+ w.WriteHeader(status)
+ _, _ = w.Write(wire)
+}
diff --git a/internal/home/roster_http_test.go b/internal/home/roster_http_test.go
new file mode 100644
index 0000000..54d03f8
--- /dev/null
+++ b/internal/home/roster_http_test.go
@@ -0,0 +1,174 @@
+package home
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/roster"
+)
+
+type rosterSpy struct {
+ calls int
+ req directorydata.RosterRequest
+ page roster.Page
+ status int
+ err error
+}
+
+func (s *rosterSpy) Read(ctx context.Context, r directorydata.RosterRequest, now time.Time) (roster.Page, int, error) {
+ s.calls++
+ s.req = r
+ if d, ok := ctx.Deadline(); !ok || time.Until(d) > 5*time.Second {
+ panic("missing bounded context")
+ }
+ return s.page, s.status, s.err
+}
+func rosterHandlerFixture(t *testing.T) (*rosterHandler, *rosterSpy) {
+ t.Helper()
+ p := newExchangePair(t)
+ cfg := *p.directory.runtime
+ k, _ := rosterPrivate(t)
+ cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)}
+ s := &rosterSpy{status: 503, err: directorydata.ErrUnavailable}
+ h, e := newRosterHandler(cfg, s)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return h, s
+}
+
+const rosterTestChallenge = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
+func rosterRequest() *http.Request {
+ r := httptest.NewRequest("GET", "/roster?team=A1&fetch="+rosterTestChallenge, nil)
+ r.Host = strings.TrimPrefix(directoryOrigin, "https://")
+ r.Header.Set("Authorization", "Bearer "+rosterTestChallenge)
+ return r
+}
+func rosterHeaders(t *testing.T, r *httptest.ResponseRecorder) {
+ t.Helper()
+ if r.Header().Get("Cache-Control") != "no-store, no-transform" || r.Header().Get("Content-Type") != "application/json" || r.Header().Get("X-Content-Type-Options") != "nosniff" {
+ t.Fatal(r.Header())
+ }
+ for _, key := range []string{"Location", "Content-Encoding", "Set-Cookie", "ETag", "Last-Modified", "Access-Control-Allow-Origin"} {
+ if r.Header().Get(key) != "" {
+ t.Fatal("unexpected header", key)
+ }
+ }
+}
+func TestRosterHTTPGuardOrderAndNoPreauthSource(t *testing.T) {
+ h, s := rosterHandlerFixture(t)
+ cases := []struct {
+ name string
+ change func(*http.Request)
+ want int
+ }{
+ {"foreign host", func(r *http.Request) { r.Host = "evil.example" }, 404}, {"host case", func(r *http.Request) { r.Host = strings.ToUpper(r.Host) }, 404}, {"host port", func(r *http.Request) { r.Host += ":443" }, 404}, {"host trailing dot", func(r *http.Request) { r.Host += "." }, 404},
+ {"post", func(r *http.Request) { r.Method = "POST" }, 405}, {"head", func(r *http.Request) { r.Method = "HEAD" }, 405}, {"options", func(r *http.Request) { r.Method = "OPTIONS" }, 405},
+ {"missing token", func(r *http.Request) { r.Header.Del("Authorization") }, 401}, {"duplicate token", func(r *http.Request) { r.Header.Add("Authorization", r.Header.Get("Authorization")) }, 401}, {"case duplicate", func(r *http.Request) { r.Header["authorization"] = []string{r.Header.Get("Authorization")} }, 401}, {"comma", func(r *http.Request) {
+ r.Header.Set("Authorization", r.Header.Get("Authorization")+","+r.Header.Get("Authorization"))
+ }, 401}, {"token space", func(r *http.Request) { r.Header.Set("Authorization", r.Header.Get("Authorization")+" ") }, 401}, {"token tab", func(r *http.Request) { r.Header.Set("Authorization", "Bearer\t"+rosterTestChallenge) }, 401}, {"scheme", func(r *http.Request) { r.Header.Set("Authorization", "bearer "+rosterTestChallenge) }, 401}, {"padding", func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+rosterTestChallenge+"=") }, 401},
+ {"no fetch", func(r *http.Request) { r.URL.RawQuery = "team=A1" }, 400}, {"duplicate fetch", func(r *http.Request) { r.URL.RawQuery += "&fetch=" + rosterTestChallenge }, 400}, {"unknown", func(r *http.Request) { r.URL.RawQuery += "&epoch=1" }, 400}, {"email challenge", func(r *http.Request) { r.URL.RawQuery = "team=A1&fetch=secret@example.invalid" }, 400}, {"team path", func(r *http.Request) { r.URL.RawQuery = "team=../B2&fetch=" + rosterTestChallenge }, 400}, {"escaped key", func(r *http.Request) { r.URL.RawQuery = "%74eam=A1&fetch=" + rosterTestChallenge }, 400}, {"escaped value", func(r *http.Request) { r.URL.RawQuery = "team=%41&fetch=" + rosterTestChallenge }, 400}, {"extra separator", func(r *http.Request) { r.URL.RawQuery += "&" }, 400}, {"empty cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" }, 400}, {"big cursor", func(r *http.Request) { r.URL.RawQuery += "&cursor=" + strings.Repeat("A", 2049) }, 400}, {"big target", func(r *http.Request) { r.RequestURI = strings.Repeat("A", 4097) }, 400}, {"body", func(r *http.Request) { r.ContentLength = 1 }, 400}, {"chunked", func(r *http.Request) { r.TransferEncoding = []string{"chunked"} }, 400}, {"absolute target", func(r *http.Request) { r.URL.Scheme = "https"; r.URL.Host = r.Host }, 400}, {"escaped path", func(r *http.Request) { r.URL.RawPath = "/r%6fster" }, 400},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := rosterRequest()
+ tc.change(req)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != tc.want || s.calls != 0 {
+ t.Fatal(rec.Code, s.calls)
+ }
+ rosterHeaders(t, rec)
+ for _, value := range []string{rosterTestChallenge, "secret@example.invalid", "evil.example"} {
+ if strings.Contains(rec.Body.String(), value) {
+ t.Fatal("error input disclosure")
+ }
+ }
+ if tc.want == 405 && rec.Header().Get("Allow") != "GET" {
+ t.Fatal("missing Allow")
+ }
+ })
+ }
+ req := rosterRequest()
+ req.Header["Authorization"] = []string{"Bearer " + rosterTestChallenge}
+ req.Header.Set("Cookie", "session=anything")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 503 || s.calls != 1 || s.req.Team != "A1" || s.req.Fetch != rosterTestChallenge {
+ t.Fatal(rec.Code, s.calls, s.req)
+ }
+}
+func TestRosterHTTPDisabledIgnoresTokenQueryAndSessions(t *testing.T) {
+ p := newExchangePair(t)
+ h := p.directory.Router()
+ var body []byte
+ for _, query := range []string{"?team=A1&fetch=" + rosterTestChallenge, "?token=secret@example.invalid", "?" + strings.Repeat("x", 5000)} {
+ req := rosterRequest()
+ req.URL.RawQuery = strings.TrimPrefix(query, "?")
+ req.Header.Set("Authorization", "canary-token")
+ req.Header.Set("Cookie", p.directory.sessions.CookieName()+"=hostile; "+p.directory.sessions.CookieName()+"=duplicate")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 503 {
+ t.Fatal(rec.Code)
+ }
+ rosterHeaders(t, rec)
+ if body != nil && !bytes.Equal(body, rec.Body.Bytes()) {
+ t.Fatal("disabled details differed")
+ }
+ body = append([]byte{}, rec.Body.Bytes()...)
+ }
+ var n int
+ if e := p.directory.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&n); e != nil || n != 0 {
+ t.Fatal("roster touched sessions", n, e)
+ }
+}
+func TestRosterHTTPResponseFailuresAreBufferedAndFixed(t *testing.T) {
+ h, s := rosterHandlerFixture(t)
+ for _, tc := range []struct {
+ status int
+ err error
+ typ string
+ }{{401, directorydata.ErrRosterUnauthorized, ""}, {404, directorydata.ErrRosterNotFound, ""}, {400, directorydata.ErrRosterBadRequest, ""}, {503, directorydata.ErrUnavailable, ""}, {200, errors.New("secret@example.invalid"), "page"}, {404, nil, "page"}, {304, nil, "page"}, {200, nil, "wrong"}} {
+ s.status = tc.status
+ s.err = tc.err
+ s.page = roster.Page{Type: tc.typ}
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, rosterRequest())
+ want := tc.status
+ if tc.err == nil || tc.status == 200 {
+ want = 503
+ }
+ if rec.Code != want {
+ t.Fatal(rec.Code, want)
+ }
+ rosterHeaders(t, rec)
+ if strings.Contains(rec.Body.String(), "secret@example.invalid") {
+ t.Fatal("error leak")
+ }
+ }
+ huge := strings.Repeat("x", roster.BodyLimit)
+ s.page = roster.Page{Type: "page", Cursor: &huge}
+ s.status = 200
+ s.err = nil
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, rosterRequest())
+ if rec.Code != 503 || rec.Body.String() != `{"error":"unavailable"}` {
+ t.Fatal("partial signed response", rec.Code)
+ }
+ s.calls = 0
+ h.now = func() time.Time { return time.Unix(0, 0) }
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, rosterRequest())
+ if rec.Code != 503 || s.calls != 0 {
+ t.Fatal("clock fault reached DB")
+ }
+}
diff --git a/internal/home/roster_integration_test.go b/internal/home/roster_integration_test.go
new file mode 100644
index 0000000..e4711d2
--- /dev/null
+++ b/internal/home/roster_integration_test.go
@@ -0,0 +1,553 @@
+package home
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base32"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/sealtest"
+ "amadan.net/oficina/home/roster"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/rastrillo"
+ "github.com/go-json-experiment/json"
+)
+
+type rosterTransport func(*http.Request) (*http.Response, error)
+
+func (f rosterTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+type rosterMemory struct {
+ mu sync.Mutex
+ b []byte
+ saves int
+}
+
+func (m *rosterMemory) Load(context.Context) ([]byte, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.b == nil {
+ return nil, roster.ErrNoEvidence
+ }
+ return append([]byte{}, m.b...), nil
+}
+func (m *rosterMemory) Save(_ context.Context, b []byte) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.b = append([]byte{}, b...)
+ m.saves++
+ return nil
+}
+
+type rosterClock struct {
+ mu sync.Mutex
+ at time.Time
+ elapsed time.Duration
+}
+
+func (c *rosterClock) Now() (time.Time, time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.at, c.elapsed
+}
+func (c *rosterClock) advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.at = c.at.Add(d)
+ c.elapsed += d
+}
+
+type rosterIntegration struct {
+ f chooserFixture
+ key *family.Keypair
+ team int64
+ token, generation string
+ server *httptest.Server
+ transport http.RoundTripper
+}
+
+func rosterAtom(b byte) string {
+ return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32))
+}
+func newRosterIntegration(t *testing.T, n int) *rosterIntegration {
+ t.Helper()
+ f := newChooserFixture(t)
+ key, _ := rosterPrivate(t)
+ cfg := *f.p.directory.runtime
+ cfg.roster = rosterRuntime{mode: "serve", signer: key, keyID: rosterKeyID(key)}
+ app, e := NewRuntime(cfg, f.p.directory.db, f.p.directory.logger)
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.p.directory = app
+ id := f.team(t, "A1", "alpha", string(sealtest.Canaries[1]), chooserPerson)
+ tx, e := app.db.Writer().Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, id); e != nil {
+ t.Fatal(e)
+ }
+ for i := 0; i < n; i++ {
+ raw := make([]byte, 16)
+ raw[0] = byte(i >> 8)
+ raw[1] = byte(i)
+ ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
+ if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, id, ref); e != nil {
+ t.Fatal(e)
+ }
+ }
+ token, generation := rosterAtom(11), rosterAtom(12)
+ hash := sha256.Sum256([]byte(token))
+ for _, q := range []struct {
+ sql string
+ args []any
+ }{
+ {`INSERT INTO roster_generation VALUES(1,?)`, []any{generation}},
+ {`INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, []any{id}},
+ {`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}},
+ } {
+ if _, e = tx.Exec(q.sql, q.args...); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ mux := http.NewServeMux()
+ mux.Handle("/", app.Router())
+ handler, closeHandler, e := rastrillo.Handler(rastrillo.Options{Mux: mux, CSP: app.SecurityPolicy(), Logger: f.p.directory.logger})
+ if e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() { closeHandler() })
+ server := httptest.NewTLSServer(handler)
+ t.Cleanup(server.Close)
+ target, _ := url.Parse(server.URL)
+ transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Scheme != "https" || r.URL.Host != strings.TrimPrefix(directoryOrigin, "https://") {
+ return nil, errors.New("unexpected test destination")
+ }
+ c := r.Clone(r.Context())
+ u := *r.URL
+ c.URL = &u
+ c.URL.Host = target.Host
+ c.Host = r.URL.Host
+ return server.Client().Transport.RoundTrip(c)
+ })
+ return &rosterIntegration{f: f, key: key, team: id, token: token, generation: generation, server: server, transport: transport}
+}
+func (f *rosterIntegration) record(t *testing.T, team, consumer, audience, kid string) roster.VerifiedRecord {
+ t.Helper()
+ op, _ := rosterPrivate(t)
+ binding := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: consumer, App: "docs", Team: team, Audience: audience}
+ record := roster.Provisioning{Version: 1, Operation: "test-operation", Sequence: 1, Bindings: binding, Generation: f.generation, MinEpoch: 1, Keys: []roster.Key{{ID: kid, Public: base64.RawURLEncoding.EncodeToString(f.key.SignPub())}}}
+ wire, e := roster.SignProvisioning(op, "test-operator", record)
+ if e != nil {
+ t.Fatal(e)
+ }
+ verified, e := roster.VerifyProvisioning(wire, []roster.Key{{ID: "test-operator", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, binding)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return verified
+}
+func (f *rosterIntegration) cache(t *testing.T, transport http.RoundTripper, kid string) (*roster.Cache, *rosterMemory, *rosterClock) {
+ t.Helper()
+ memory := &rosterMemory{}
+ clock := &rosterClock{at: time.Now()}
+ c, e := roster.New(context.Background(), roster.Config{Record: f.record(t, "A1", "alpha-docs", "https://alpha.docs.oficina.works", kid), Token: f.token, Transport: transport, Durable: memory, Sealer: f.f.p.directory.secrets.Sealer, Clock: clock})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return c, memory, clock
+}
+func (f *rosterIntegration) get(t *testing.T, query, token string) (int, http.Header, []byte) {
+ t.Helper()
+ r, e := http.NewRequest("GET", directoryOrigin+"/roster?"+query, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Header.Set("Authorization", "Bearer "+token)
+ res, e := f.transport.RoundTrip(r)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer res.Body.Close()
+ b, e := io.ReadAll(io.LimitReader(res.Body, roster.BodyLimit+1))
+ if e != nil {
+ t.Fatal(e)
+ }
+ if res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" {
+ t.Fatal(res.Header)
+ }
+ for _, k := range []string{"Content-Encoding", "Location", "Set-Cookie", "ETag", "Last-Modified"} {
+ if res.Header.Get(k) != "" {
+ t.Fatal("framework changed roster", k)
+ }
+ }
+ return res.StatusCode, res.Header, b
+}
+func decodeRosterPage(t *testing.T, b []byte) roster.Page {
+ t.Helper()
+ var env struct {
+ Payload string `json:"payload"`
+ }
+ if json.Unmarshal(b, &env) != nil {
+ t.Fatal("missing envelope")
+ }
+ plain, e := base64.RawURLEncoding.DecodeString(env.Payload)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var p roster.Page
+ if json.Unmarshal(plain, &p) != nil {
+ t.Fatal("missing page")
+ }
+ return p
+}
+func TestRosterFrameworkAndActualClientZeroOneMany(t *testing.T) {
+ for _, n := range []int{0, 1, 201} {
+ t.Run(strconv.Itoa(n), func(t *testing.T) {
+ f := newRosterIntegration(t, n)
+ cache, _, _ := f.cache(t, f.transport, rosterKeyID(f.key))
+ d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
+ want := roster.Member
+ if n == 0 {
+ want = roster.NotMember
+ }
+ if d.State != want {
+ t.Fatal("actual signed client", n, d.State)
+ }
+ if n > 0 && !d.Lease.Valid() {
+ t.Fatal("missing lease")
+ }
+ status, _, wire := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
+ if status != 200 {
+ t.Fatal(status)
+ }
+ p := decodeRosterPage(t, wire)
+ if p.Members == nil || p.Complete == nil || p.Index == nil || p.RequestCursorHash != roster.CursorHash("") {
+ t.Fatal("page shape")
+ }
+ if n == 0 && (len(*p.Members) != 0 || !*p.Complete || p.Cursor != nil) {
+ t.Fatal("empty roster encoding")
+ }
+ sealtest.AssertNoPlaintext(t, f.f.p.directoryPath)
+ corpus := [][]byte{[]byte(f.token), []byte(string(sealtest.Canaries[1]))}
+ if p.Cursor != nil {
+ sealed, e := base64.RawURLEncoding.DecodeString(*p.Cursor)
+ if e != nil {
+ t.Fatal(e)
+ }
+ plain, e := f.f.p.directory.secrets.Sealer.Open("directory.roster-cursor/"+directoryOrigin, sealed)
+ if e != nil {
+ t.Fatal(e)
+ }
+ corpus = append(corpus, []byte(*p.Cursor), plain)
+ }
+ for _, suffix := range []string{"", "-wal", "-shm"} {
+ b, e := os.ReadFile(f.f.p.directoryPath + suffix)
+ if os.IsNotExist(e) {
+ continue
+ }
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, secret := range corpus {
+ if bytes.Contains(b, secret) {
+ t.Fatal("request secret at rest")
+ }
+ }
+ }
+ for _, secret := range append(corpus, []byte(chooserPerson), []byte("/roster?")) {
+ if bytes.Contains(f.f.p.logs.Bytes(), secret) {
+ t.Fatal("roster request logged")
+ }
+ }
+ })
+ }
+}
+func TestRosterHTTPConflictDoesNotPublishAndUnknownKidRefuses(t *testing.T) {
+ for _, generation := range []bool{false, true} {
+ t.Run(map[bool]string{false: "epoch", true: "generation"}[generation], func(t *testing.T) {
+ f := newRosterIntegration(t, 201)
+ changed := false
+ statuses := []int{}
+ transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Query().Get("cursor") != "" && !changed {
+ changed = true
+ var e error
+ if generation {
+ _, e = f.f.p.directory.db.Writer().Exec(`UPDATE roster_generation SET generation=?`, rosterAtom(13))
+ } else {
+ _, e = f.f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=?`, f.team)
+ }
+ if e != nil {
+ return nil, e
+ }
+ }
+ res, e := f.transport.RoundTrip(r)
+ if e == nil {
+ statuses = append(statuses, res.StatusCode)
+ }
+ return res, e
+ })
+ cache, memory, _ := f.cache(t, transport, rosterKeyID(f.key))
+ d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
+ if d.State != roster.Unknown || !changed || len(statuses) != 2 || statuses[0] != 200 || statuses[1] != 409 {
+ t.Fatal(d.State, statuses)
+ }
+ memory.mu.Lock()
+ evidence := append([]byte{}, memory.b...)
+ memory.mu.Unlock()
+ if len(evidence) > 0 {
+ bindings := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: "alpha-docs", App: "docs", Team: "A1", Audience: "https://alpha.docs.oficina.works"}
+ raw, e := json.Marshal(bindings)
+ if e != nil {
+ t.Fatal(e)
+ }
+ digest := sha256.Sum256(raw)
+ plain, e := f.f.p.directory.secrets.Sealer.Open("oficina/roster/evidence/v1/"+base64.RawURLEncoding.EncodeToString(digest[:]), evidence)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var state struct {
+ Snapshot bool `json:"snapshot"`
+ Blocked bool `json:"blocked"`
+ Members []roster.MemberEntry `json:"members"`
+ }
+ if json.Unmarshal(plain, &state) != nil || state.Snapshot || len(state.Members) != 0 || state.Blocked != generation {
+ t.Fatal("partial roster persisted as complete")
+ }
+ }
+ if d.Lease.Valid() {
+ t.Fatal("conflict granted lease")
+ }
+ })
+ }
+ f := newRosterIntegration(t, 1)
+ cache, _, _ := f.cache(t, f.transport, "unrecognized-key")
+ if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown {
+ t.Fatal("unknown key accepted", d.State)
+ }
+}
+func TestRosterDisableExpiresStandingAndMissingGeneration(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ cache, _, clock := f.cache(t, f.transport, rosterKeyID(f.key))
+ d := cache.Check(context.Background(), Origin, "A1", chooserPerson)
+ if d.State != roster.Member {
+ t.Fatal(d.State)
+ }
+ if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=0`); e != nil {
+ t.Fatal(e)
+ }
+ clock.advance(60 * time.Second)
+ if next := cache.Check(context.Background(), Origin, "A1", chooserPerson); next.State != roster.Unknown || d.Lease.Valid() {
+ t.Fatal("disabled token extended standing", next.State)
+ }
+ if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=1`); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := f.f.p.directory.db.Writer().Exec(`DELETE FROM roster_generation`); e != nil {
+ t.Fatal(e)
+ }
+ status, _, b := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
+ if status != 503 || string(b) != `{"error":"unavailable"}` {
+ t.Fatal("missing generation", status)
+ }
+ var count int
+ if e := f.f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM roster_generation`).Scan(&count); e != nil || count != 0 {
+ t.Fatal("generation minted", count, e)
+ }
+}
+func TestRosterTwoConsumersCannotCrossTeams(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ id := f.f.team(t, "B2", "beta", "Other team", chooserOther)
+ token := rosterAtom(14)
+ hash := sha256.Sum256([]byte(token))
+ tx, e := f.f.p.directory.db.Writer().Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ for _, q := range []struct {
+ sql string
+ args []any
+ }{{`INSERT INTO roster_consumers VALUES('beta-docs',?,'docs','https://beta.docs.oficina.works',1)`, []any{id}}, {`INSERT INTO roster_tokens VALUES(?,'beta-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}}} {
+ if _, e = tx.Exec(q.sql, q.args...); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ for _, pair := range []struct{ token, team string }{{f.token, "B2"}, {token, "A1"}, {f.token, "Missing"}, {token, "Missing"}} {
+ status, _, b := f.get(t, "team="+pair.team+"&fetch="+rosterTestChallenge, pair.token)
+ if status != 404 || string(b) != `{"error":"not_found"}` {
+ t.Fatal(status, string(b))
+ }
+ }
+ status, _, b := f.get(t, "team=B2&fetch="+rosterTestChallenge, token)
+ p := decodeRosterPage(t, b)
+ if status != 200 || p.Team != "B2" || p.Consumer != "beta-docs" || p.Audience != "https://beta.docs.oficina.works" || len(*p.Members) != 1 || (*p.Members)[0].Subject != chooserOther {
+ t.Fatal("foreign roster")
+ }
+}
+
+func TestRosterRealClientRefusesSignedWrongScopeAndContext(t *testing.T) {
+ for _, kind := range []string{"scope", "context", "signature"} {
+ t.Run(kind, func(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ transport := rosterTransport(func(r *http.Request) (*http.Response, error) {
+ res, e := f.transport.RoundTrip(r)
+ if e != nil {
+ return nil, e
+ }
+ raw, e := io.ReadAll(res.Body)
+ res.Body.Close()
+ if e != nil {
+ return nil, e
+ }
+ var env struct {
+ Version int `json:"v"`
+ KeyID string `json:"kid"`
+ Payload string `json:"payload"`
+ Signature string `json:"signature"`
+ }
+ if e = json.Unmarshal(raw, &env); e != nil {
+ return nil, e
+ }
+ payload, e := base64.RawURLEncoding.DecodeString(env.Payload)
+ if e != nil {
+ return nil, e
+ }
+ switch kind {
+ case "scope":
+ var p roster.Page
+ if e = json.Unmarshal(payload, &p); e != nil {
+ return nil, e
+ }
+ p.Team = "B2"
+ raw, e = roster.SignPage(f.key, rosterKeyID(f.key), p)
+ case "context":
+ var sig []byte
+ sig, e = family.Sign(f.key, roster.ProvisioningContext, payload)
+ if e == nil {
+ env.Signature = base64.RawURLEncoding.EncodeToString(sig)
+ raw, e = json.Marshal(env)
+ }
+ case "signature":
+ var sig []byte
+ sig, e = base64.RawURLEncoding.DecodeString(env.Signature)
+ if e == nil {
+ sig[0] ^= 1
+ env.Signature = base64.RawURLEncoding.EncodeToString(sig)
+ raw, e = json.Marshal(env)
+ }
+ }
+ if e != nil {
+ return nil, e
+ }
+ res.Body = io.NopCloser(bytes.NewReader(raw))
+ res.ContentLength = int64(len(raw))
+ return res, nil
+ })
+ cache, _, _ := f.cache(t, transport, rosterKeyID(f.key))
+ if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || d.Lease.Valid() {
+ t.Fatal("invalid signed page granted standing", kind)
+ }
+ })
+ }
+}
+func TestRosterFrameworkMethodAndPathRefusalsNeverYieldPage(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ for _, method := range []string{"HEAD", "POST", "OPTIONS", "DELETE"} {
+ r, e := http.NewRequest(method, directoryOrigin+"/roster?team=A1&fetch="+rosterTestChallenge, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Header.Set("Authorization", "Bearer "+f.token)
+ res, e := f.transport.RoundTrip(r)
+ if e != nil {
+ t.Fatal(e)
+ }
+ res.Body.Close()
+ if res.StatusCode != 405 || res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" || res.Header.Get("Allow") != "GET" || res.Header.Get("Location") != "" {
+ t.Fatal(method, res.StatusCode, res.Header)
+ }
+ }
+ r, e := http.NewRequest("GET", directoryOrigin+"//roster?team=A1&fetch="+rosterTestChallenge, nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r.Header.Set("Authorization", "Bearer "+f.token)
+ res, e := f.transport.RoundTrip(r)
+ if e != nil {
+ t.Fatal(e)
+ }
+ res.Body.Close()
+ if res.StatusCode == 200 {
+ t.Fatal("alternate path yielded roster")
+ }
+ calls := 0
+ redirect := rosterTransport(func(r *http.Request) (*http.Response, error) {
+ calls++
+ return &http.Response{StatusCode: 301, Header: http.Header{"Location": {directoryOrigin + "/roster"}, "Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: r}, nil
+ })
+ cache, _, _ := f.cache(t, redirect, rosterKeyID(f.key))
+ if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || calls != 1 {
+ t.Fatal("redirect followed", calls)
+ }
+}
+
+func TestRosterHTTPTokenRotationOverlapBoundary(t *testing.T) {
+ f := newRosterIntegration(t, 1)
+ rotated := rosterAtom(15)
+ hash := sha256.Sum256([]byte(rotated))
+ at := time.Now()
+ tx, e := f.f.p.directory.db.Writer().Begin()
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=? WHERE consumer_id='alpha-docs'`, at.Unix(), at.Unix()+300); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, hash[:], at.Unix()); e != nil {
+ t.Fatal(e)
+ }
+ if e = tx.Commit(); e != nil {
+ t.Fatal(e)
+ }
+ clock := &rosterClock{at: at}
+ f.f.p.directory.directory.roster.now = func() time.Time { now, _ := clock.Now(); return now }
+ for _, token := range []string{f.token, rotated} {
+ status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, token)
+ if status != 200 {
+ t.Fatal("rotation overlap refused", status)
+ }
+ }
+ clock.advance(300 * time.Second)
+ status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token)
+ if status != 401 {
+ t.Fatal("expired previous token accepted", status)
+ }
+ status, _, _ = f.get(t, "team=A1&fetch="+rosterTestChallenge, rotated)
+ if status != 200 {
+ t.Fatal("current token refused", status)
+ }
+}
diff --git a/internal/home/roster_runtime.go b/internal/home/roster_runtime.go
new file mode 100644
index 0000000..87a1b8a
--- /dev/null
+++ b/internal/home/roster_runtime.go
@@ -0,0 +1,105 @@
+package home
+
+import (
+ "bytes"
+ "crypto/ecdh"
+ "crypto/elliptic"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "math/big"
+ "os"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/go-json-experiment/json"
+)
+
+// The directory's roster key signs call-8 pages only. Runtime.signer remains
+// credential-only; this key never signs identity or operator provisioning.
+type rosterRuntime struct {
+ mode string
+ signer *family.Keypair
+ keyID string
+}
+
+func rosterKeyID(k *family.Keypair) string {
+ sum := sha256.Sum256(k.SignPub())
+ return hex.EncodeToString(sum[:])
+}
+func validRosterKey(k *family.Keypair) bool {
+ if k == nil || k.SignPriv == nil || k.SignPriv.D == nil || k.SignPriv.Curve != elliptic.P256() || k.SignPriv.D.Sign() <= 0 || k.SignPriv.D.Cmp(elliptic.P256().Params().N) >= 0 || k.SignPriv.X == nil || k.SignPriv.Y == nil || k.BoxPriv == nil || k.BoxPriv.Curve() != ecdh.P256() {
+ return false
+ }
+ x, y := elliptic.P256().ScalarBaseMult(k.SignPriv.D.Bytes())
+ return x.Cmp(k.SignPriv.X) == 0 && y.Cmp(k.SignPriv.Y) == 0
+}
+func (r rosterRuntime) valid(role string, credentialKeys map[string][]byte) bool {
+ if role != "directory" {
+ return r.mode == "" && r.signer == nil && r.keyID == ""
+ }
+ if r.mode == "" || r.mode == "disabled" {
+ return r.signer == nil && r.keyID == ""
+ }
+ if r.mode != "serve" || !validRosterKey(r.signer) || r.keyID != rosterKeyID(r.signer) {
+ return false
+ }
+ for _, key := range credentialKeys {
+ if bytes.Equal(key, r.signer.SignPub()) {
+ return false
+ }
+ }
+ return true
+}
+
+func loadRosterRuntime(role string, credentialKeys map[string][]byte) (rosterRuntime, error) {
+ mode, modeSet := os.LookupEnv("HOME_ROSTER_MODE")
+ encoded, keySet := os.LookupEnv("HOME_ROSTER_KEY")
+ if role != "directory" {
+ if modeSet || keySet {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ return rosterRuntime{}, nil
+ }
+ if !modeSet {
+ mode = "disabled"
+ }
+ if mode == "disabled" {
+ if keySet {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ return rosterRuntime{mode: mode}, nil
+ }
+ if mode != "serve" || !keySet || len(encoded) > 4096 {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ raw, err := base64.StdEncoding.Strict().DecodeString(encoded)
+ if err != nil || base64.StdEncoding.EncodeToString(raw) != encoded {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ var fields struct {
+ Sign string `json:"sign_priv_d"`
+ Box string `json:"box_priv_d"`
+ }
+ if json.Unmarshal(raw, &fields, json.RejectUnknownMembers(true)) != nil {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ for _, s := range []string{fields.Sign, fields.Box} {
+ b, e := hex.DecodeString(s)
+ if e != nil || len(b) != 32 || hex.EncodeToString(b) != s {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ d := new(big.Int).SetBytes(b)
+ if d.Sign() <= 0 || d.Cmp(elliptic.P256().Params().N) >= 0 {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ }
+ key, err := family.UnmarshalKeypair(raw)
+ if err != nil || !validRosterKey(key) {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ cfg := rosterRuntime{mode: mode, signer: key, keyID: rosterKeyID(key)}
+ if !cfg.valid(role, credentialKeys) {
+ return rosterRuntime{}, errExchangeConfig
+ }
+ return cfg, nil
+}
diff --git a/internal/home/roster_runtime_test.go b/internal/home/roster_runtime_test.go
new file mode 100644
index 0000000..682d20a
--- /dev/null
+++ b/internal/home/roster_runtime_test.go
@@ -0,0 +1,128 @@
+package home
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/hex"
+ "os"
+ "strings"
+ "testing"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+func rosterPrivate(t *testing.T) (*family.Keypair, string) {
+ t.Helper()
+ k, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ b, e := family.MarshalKeypair(k)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return k, base64.StdEncoding.EncodeToString(b)
+}
+func TestRosterRuntimeModeKeyAndRoleMatrix(t *testing.T) {
+ credential, keys := runtimeEnv(t)
+ _, private := rosterPrivate(t)
+ t.Setenv("HOME_ROLE", "directory")
+ t.Setenv("HOME_ASSERTION_KEYS", keys)
+ cfg, e := LoadRuntime("home.db", quietLogger())
+ if e != nil || cfg.roster.mode != "disabled" || cfg.roster.signer != nil {
+ t.Fatal("default roster not disabled", e)
+ }
+ for _, tc := range []struct {
+ mode, key string
+ ok bool
+ }{{"disabled", "", false}, {"serve", "", false}, {"serve", private, true}, {"serve", credential, false}, {"typo", private, false}, {"", private, false}} {
+ t.Setenv("HOME_ROSTER_MODE", tc.mode)
+ t.Setenv("HOME_ROSTER_KEY", tc.key)
+ cfg, e = LoadRuntime("home.db", quietLogger())
+ if (e == nil) != tc.ok {
+ t.Fatal(tc.mode, "key matrix", e)
+ }
+ if e == nil && (cfg.signer != nil || !cfg.roster.valid("directory", cfg.keys)) {
+ t.Fatal("role/key boundary")
+ }
+ }
+ os.Unsetenv("HOME_ROSTER_KEY")
+ t.Setenv("HOME_ROSTER_MODE", "disabled")
+ if _, e = LoadRuntime("home.db", quietLogger()); e != nil {
+ t.Fatal(e)
+ }
+ for _, role := range []string{"", "credential"} {
+ t.Setenv("HOME_ROLE", role)
+ if _, e = LoadRuntime("home.db", quietLogger()); e != errExchangeConfig {
+ t.Fatal("roster config crossed role", role, e)
+ }
+ }
+}
+func TestRosterRuntimeStrictPrivateEncoding(t *testing.T) {
+ _, keys := runtimeEnv(t)
+ _, private := rosterPrivate(t)
+ t.Setenv("HOME_ROLE", "directory")
+ t.Setenv("HOME_ASSERTION_KEYS", keys)
+ t.Setenv("HOME_ROSTER_MODE", "serve")
+ raw, _ := base64.StdEncoding.DecodeString(private)
+ invalid := []string{"", "not-base64", private + "\n", strings.Repeat("A", 4097)}
+ for _, s := range []string{strings.TrimSuffix(string(raw), "}") + `,"unknown":1}`, strings.TrimSuffix(string(raw), "}") + `,"sign_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("0", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("f", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"` + strings.Repeat("A", 64) + `","box_priv_d":"` + strings.Repeat("1", 64) + `"}`, `{"sign_priv_d":"01","box_priv_d":"01"}`} {
+ invalid = append(invalid, base64.StdEncoding.EncodeToString([]byte(s)))
+ }
+ for _, bad := range invalid {
+ t.Setenv("HOME_ROSTER_KEY", bad)
+ if _, e := LoadRuntime("home.db", quietLogger()); e != errExchangeConfig {
+ t.Fatal("bad private encoding accepted", e)
+ }
+ }
+}
+func TestRosterDirectConstructorsAndEmptySchema(t *testing.T) {
+ p := newExchangePair(t)
+ base := *p.directory.runtime
+ k, _ := rosterPrivate(t)
+ for _, r := range []rosterRuntime{{mode: "serve"}, {mode: "disabled", signer: k}, {mode: "serve", signer: k, keyID: "wrong"}, {mode: "typo"}} {
+ cfg := base
+ cfg.roster = r
+ if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != errExchangeConfig {
+ t.Fatal("unchecked runtime", e)
+ }
+ if _, e := newDirectory(cfg, p.directory.db, quietLogger()); e != errExchangeConfig {
+ t.Fatal("unchecked directory", e)
+ }
+ }
+ cfg := base
+ cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)}
+ if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != nil {
+ t.Fatal(e)
+ }
+ cfg.keys = map[string][]byte{hex.EncodeToString([]byte("key")): k.SignPub()}
+ if _, e := NewRuntime(cfg, p.directory.db, quietLogger()); e != errExchangeConfig {
+ t.Fatal("credential key reuse", e)
+ }
+ cfg = *p.credential.runtime
+ cfg.roster = rosterRuntime{mode: "serve", signer: k, keyID: rosterKeyID(k)}
+ if _, e := NewRuntime(cfg, p.credential.db, quietLogger()); e != errExchangeConfig {
+ t.Fatal("private roster crossed role", e)
+ }
+ for i := 0; i < 2; i++ {
+ if _, e := migrate.Apply(context.Background(), p.directory.db, directorySchema); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := NewRuntime(base, p.directory.db, quietLogger()); e != nil {
+ t.Fatal(e)
+ }
+ }
+ for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} {
+ var n int
+ if e := p.directory.db.Writer().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); e != nil || n != 0 {
+ t.Fatal("authority initialized", table, n, e)
+ }
+ }
+ for _, app := range []*App{p.credential.App, newTestApp(t).App} {
+ var n int
+ if e := app.db.Writer().QueryRow(`SELECT count(*) FROM sqlite_master WHERE name LIKE 'roster_%' OR name LIKE 'memberships_epoch_%'`).Scan(&n); e != nil || n != 0 {
+ t.Fatal("roster schema crossed role", n, e)
+ }
+ }
+}
diff --git a/internal/home/runtime.go b/internal/home/runtime.go
index db6c6a0..1da805b 100644
--- a/internal/home/runtime.go
+++ b/internal/home/runtime.go
@@ -20,9 +20,10 @@ import (
)
var errExchangeConfig = errors.New("home: invalid assertion configuration")
-var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema)
+var directorySchema = migrate.Merge(sessions.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), directorydata.Schema, directorydata.RosterSchema)
type Runtime struct {
+ roster rosterRuntime
role string
origins Origins
secrets Secrets
@@ -42,6 +43,12 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) {
return Runtime{}, err
}
cfg.role = os.Getenv("HOME_ROLE")
+ if cfg.role != "directory" {
+ cfg.roster, err = loadRosterRuntime(cfg.role, nil)
+ if err != nil {
+ return Runtime{}, err
+ }
+ }
switch cfg.role {
case "":
cfg.secrets, err = LoadSecrets(dbPath, logger)
@@ -117,6 +124,10 @@ func LoadRuntime(dbPath string, logger *slog.Logger) (Runtime, error) {
if _, err = assertion.NewVerifier(cfg.origins.Credential, cfg.origins.Directory, cfg.keys); err != nil {
return Runtime{}, errExchangeConfig
}
+ cfg.roster, err = loadRosterRuntime(cfg.role, cfg.keys)
+ if err != nil {
+ return Runtime{}, err
+ }
default:
return Runtime{}, errExchangeConfig
}
@@ -140,6 +151,9 @@ func (cfg Runtime) Schema() *migrate.Set {
return BootSchema
}
func NewRuntime(cfg Runtime, d *db.DB, logger *slog.Logger) (*App, error) {
+ if !cfg.roster.valid(cfg.role, cfg.keys) {
+ return nil, errExchangeConfig
+ }
if cfg.role == "directory" {
return newDirectory(cfg, d, logger)
}
diff --git a/internal/home/runtime_test.go b/internal/home/runtime_test.go
index 84036af..05894fe 100644
--- a/internal/home/runtime_test.go
+++ b/internal/home/runtime_test.go
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "os"
"testing"
assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
@@ -13,6 +14,10 @@ import (
func runtimeEnv(t *testing.T) (string, string) {
t.Helper()
setSecretEnv(t, nil)
+ for _, k := range []string{"HOME_ROSTER_MODE", "HOME_ROSTER_KEY"} {
+ t.Setenv(k, "")
+ os.Unsetenv(k)
+ }
for _, k := range []string{"HOME_CONSUMERS_BASE64", "HOME_ASSERTION_KEYS_BASE64", "HOME_ROLE", "HOME_ASSERTION_KEY", "HOME_ASSERTION_KEYS", "HOME_CONSUMERS", "HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} {
t.Setenv(k, "")
}
@@ -57,7 +62,7 @@ func TestDirectoryRuntimeNeedsNoCredentialMaterial(t *testing.T) {
}
t.Setenv("HOME_ASSERTION_KEY", "present")
if _, err = LoadRuntime("directory.db", quietLogger()); err != errExchangeConfig {
- t.Fatal("directory accepted private signing material")
+ t.Fatal("directory accepted credential private signing material")
}
}
func TestCredentialRuntimeValidatesManifestAndSigningKey(t *testing.T) {
diff --git a/roster/cache.go b/roster/cache.go
new file mode 100644
index 0000000..e521bc4
--- /dev/null
+++ b/roster/cache.go
@@ -0,0 +1,501 @@
+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.
+// Both accessors refuse immediately on cache contention: a caller may hold the
+// database writer that an in-flight durable publication needs to acquire.
+type Lease struct {
+ cache *Cache
+ version uint64
+ deadline time.Duration
+}
+
+func (l Lease) Valid() bool {
+ if l.cache == nil {
+ return false
+ }
+ c := l.cache
+ if !c.mu.TryLock() {
+ return false
+ }
+ 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
+ if !c.mu.TryLock() {
+ return 0
+ }
+ 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..aa4ddb0
--- /dev/null
+++ b/roster/go.mod
@@ -0,0 +1,22 @@
+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
+ modernc.org/sqlite v1.55.0
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/roster/go.sum b/roster/go.sum
new file mode 100644
index 0000000..4cab028
--- /dev/null
+++ b/roster/go.sum
@@ -0,0 +1,57 @@
+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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+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/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+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=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/roster/lease_test.go b/roster/lease_test.go
new file mode 100644
index 0000000..ff742b4
--- /dev/null
+++ b/roster/lease_test.go
@@ -0,0 +1,207 @@
+package roster
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+type sqliteEvidence struct {
+ db *sql.DB
+ mu sync.Mutex
+ entering chan struct{}
+}
+
+func (s *sqliteEvidence) Load(ctx context.Context) ([]byte, error) {
+ var b []byte
+ e := s.db.QueryRowContext(ctx, "SELECT value FROM evidence WHERE id=1").Scan(&b)
+ if e == sql.ErrNoRows {
+ return nil, ErrNoEvidence
+ }
+ return b, e
+}
+func (s *sqliteEvidence) Save(ctx context.Context, b []byte) error {
+ s.mu.Lock()
+ entered := s.entering
+ s.entering = nil
+ s.mu.Unlock()
+ if entered != nil {
+ close(entered)
+ }
+ _, e := s.db.ExecContext(ctx, "INSERT INTO evidence(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value", b)
+ return e
+}
+
+func TestLeaseDoesNotWaitForSQLiteWriterPublication(t *testing.T) {
+ f := setup(t)
+ db, e := sql.Open("sqlite", filepath.Join(t.TempDir(), "roster.db"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+ if _, e = db.Exec("CREATE TABLE evidence(id INTEGER PRIMARY KEY,value BLOB NOT NULL)"); e != nil {
+ t.Fatal(e)
+ }
+ disk := &sqliteEvidence{db: db}
+ f.cfg.Durable = disk
+ c := f.cache()
+ d := f.check(c, 1)
+ state(t, d, Member)
+ tx, e := db.BeginTx(context.Background(), nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer tx.Rollback()
+ if _, e = tx.Exec("UPDATE evidence SET value=value WHERE id=1"); e != nil {
+ t.Fatal(e)
+ }
+ before := db.Stats().WaitCount
+ entered := make(chan struct{})
+ disk.mu.Lock()
+ disk.entering = entered
+ disk.mu.Unlock()
+ f.clock.step(30 * time.Second)
+ refreshed := make(chan error, 1)
+ go func() { refreshed <- c.Refresh(context.Background()) }()
+ <-entered
+ // WaitCount proves Save is waiting on the real one-connection writer pool,
+ // not merely scheduled to enter a fake callback. Publication owns Cache.mu.
+ deadline := time.Now().Add(time.Second)
+ for db.Stats().WaitCount == before && time.Now().Before(deadline) {
+ time.Sleep(time.Millisecond)
+ }
+ if db.Stats().WaitCount == before {
+ _ = tx.Rollback()
+ t.Fatal("publication never waited for writer")
+ }
+ valid := make(chan bool, 1)
+ remaining := make(chan time.Duration, 1)
+ go func() { valid <- d.Lease.Valid() }()
+ go func() { remaining <- d.Lease.Remaining() }()
+ var v bool
+ var left time.Duration
+ timedOut := false
+ select {
+ case v = <-valid:
+ case <-time.After(250 * time.Millisecond):
+ timedOut = true
+ }
+ select {
+ case left = <-remaining:
+ case <-time.After(250 * time.Millisecond):
+ timedOut = true
+ }
+ // Always release the writer before reporting a mutation failure, so neither
+ // the refresh goroutine nor the blocked accessor can leak into another test.
+ _ = tx.Rollback()
+ if e = <-refreshed; e != nil {
+ t.Fatal(e)
+ }
+ if timedOut {
+ t.Fatal("lease accessor blocked behind SQLite writer publication")
+ }
+ if v || left != 0 {
+ t.Fatal("contended lease granted standing")
+ }
+ next := f.check(c, 1)
+ state(t, next, Member)
+ if !next.Lease.Valid() {
+ t.Fatal("retry could not use published lease")
+ }
+ if d.Lease.Valid() {
+ t.Fatal("old lease survived publication version")
+ }
+}
+
+func TestLeaseContentionRefusesWithoutChangingAgeOrStanding(t *testing.T) {
+ f := setup(t)
+ c := f.cache()
+ d := f.check(c, 1)
+ f.clock.step(10 * time.Second)
+ before := d.Lease.Remaining()
+ c.mu.Lock()
+ valid := make(chan bool, 1)
+ left := make(chan time.Duration, 1)
+ go func() { valid <- d.Lease.Valid() }()
+ go func() { left <- d.Lease.Remaining() }()
+ timedOut := false
+ var v bool
+ var n time.Duration
+ select {
+ case v = <-valid:
+ case <-time.After(250 * time.Millisecond):
+ timedOut = true
+ }
+ select {
+ case n = <-left:
+ case <-time.After(250 * time.Millisecond):
+ timedOut = true
+ }
+ c.mu.Unlock()
+ if timedOut {
+ t.Fatal("contended accessor waited")
+ }
+ if v || n != 0 {
+ t.Fatal("contention granted standing")
+ }
+ if !d.Lease.Valid() || d.Lease.Remaining() != before {
+ t.Fatal("contention changed the snapshot or deadline")
+ }
+ f.clock.step(50 * time.Second)
+ if d.Lease.Valid() || d.Lease.Remaining() != 0 {
+ t.Fatal("contention extended age")
+ }
+}
+
+func TestLeaseContentionDoesNotSkipLaterClockFence(t *testing.T) {
+ f := setup(t)
+ c := f.cache()
+ d := f.check(c, 1)
+ f.clock.wallStep(-ClockTolerance - time.Nanosecond)
+ c.mu.Lock()
+ done := make(chan bool, 1)
+ go func() { done <- d.Lease.Valid() }()
+ select {
+ case v := <-done:
+ if v {
+ c.mu.Unlock()
+ t.Fatal("contention granted")
+ }
+ case <-time.After(250 * time.Millisecond):
+ c.mu.Unlock()
+ t.Fatal("contention blocked")
+ }
+ c.mu.Unlock()
+ if d.Lease.Valid() || d.Lease.Remaining() != 0 {
+ t.Fatal("successful lock skipped backward-clock fence")
+ }
+}
+
+func TestUnknownSigningKeyRefused(t *testing.T) {
+ f := setup(t)
+ raw, e := SignProvisioning(f.op, "unprovisioned", f.record)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = VerifyProvisioning(raw, []Key{{"operator", f.record.Keys[0].Public}}, f.record.Bindings); e != ErrRefused {
+ t.Fatalf("unknown operator key result %v", e)
+ }
+ keys, _ := publicKeys(f.record.Keys)
+ // A valid signature under a real key still refuses when its identifier was
+ // not provisioned. The map guard is explicit rather than verifier-dependent.
+ b, e := encode(f.key, "unprovisioned", PageContext, struct {
+ Version int `json:"version"`
+ }{1})
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = signedPayload(b, keys, PageContext); e != ErrRefused {
+ t.Fatalf("unknown roster key result %v", e)
+ }
+}
diff --git a/roster/protocol.go b/roster/protocol.go
new file mode 100644
index 0000000..bcdb434
--- /dev/null
+++ b/roster/protocol.go
@@ -0,0 +1,291 @@
+// Package roster verifies directory snapshots without granting document access.
+package roster
+
+import (
+ "crypto/elliptic"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "net/url"
+ "strings"
+ "unicode/utf8"
+
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/go-json-experiment/json"
+)
+
+const (
+ ProvisioningContext = "oficina/roster/provisioning/v1"
+ PageContext = "oficina/roster/page/v1"
+ PageLimit = 100
+ MemberLimit = 10000
+ BodyLimit = 64 << 10
+ EvidenceLimit = 1 << 20
+ CursorLimit = 2048
+)
+
+var (
+ ErrRefused = errors.New("roster: refused")
+ ErrUnavailable = errors.New("roster: unavailable")
+ ErrNoEvidence = errors.New("roster: no evidence")
+)
+
+// Bindings are deployment pins, never request input.
+type Bindings struct {
+ Issuer string `json:"issuer"`
+ Directory string `json:"directory"`
+ Consumer string `json:"consumer"`
+ App string `json:"app"`
+ Team string `json:"team"`
+ Audience string `json:"audience"`
+}
+
+type Key struct {
+ ID string `json:"id"`
+ Public string `json:"public"`
+}
+
+type Provisioning struct {
+ Version int `json:"version"`
+ Operation string `json:"operation"`
+ Sequence int64 `json:"sequence"`
+ Bindings Bindings `json:"bindings"`
+ Generation string `json:"generation"`
+ MinEpoch int64 `json:"min_epoch"`
+ Keys []Key `json:"keys"`
+ // OwnerConfirmation names the audited operation required for a generation change.
+ OwnerConfirmation string `json:"owner_confirmation"`
+}
+
+// VerifiedRecord can only be built by VerifyProvisioning. After total durable
+// state loss, old signed records cannot be distinguished locally: recovery must
+// deliver the latest audited operator record before serving traffic.
+type VerifiedRecord struct {
+ record Provisioning
+ digest string
+ valid bool
+}
+
+func (r VerifiedRecord) Bindings() Bindings { return r.record.Bindings }
+
+type envelope struct {
+ Version int `json:"v"`
+ KeyID string `json:"kid"`
+ Payload string `json:"payload"`
+ Signature string `json:"signature"`
+}
+
+func canonical(s string, n int) ([]byte, bool) {
+ if len(s) > BodyLimit {
+ return nil, false
+ }
+ b, e := base64.RawURLEncoding.DecodeString(s)
+ return b, e == nil && (n < 0 || len(b) == n) && base64.RawURLEncoding.EncodeToString(b) == s
+}
+func atom(s string, max int) bool {
+ if len(s) == 0 || len(s) > max {
+ return false
+ }
+ for _, c := range s {
+ if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') {
+ return false
+ }
+ }
+ return true
+}
+func team(s string) bool {
+ if !atom(s, 64) {
+ return false
+ }
+ return !strings.ContainsAny(s, "_-")
+}
+func origin(s string) bool {
+ if len(s) > 253 {
+ return false
+ }
+ u, e := url.Parse(s)
+ if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.RawFragment != "" || u.ForceQuery {
+ return false
+ }
+ return u.String() == s && strings.ToLower(u.Host) == u.Host && !strings.ContainsAny(u.Host, "%\\")
+}
+func (b Bindings) valid() bool {
+ return origin(b.Issuer) && origin(b.Directory) && origin(b.Audience) && atom(b.Consumer, 128) && atom(b.App, 64) && team(b.Team)
+}
+func publicKeys(keys []Key) (map[string][]byte, error) {
+ if len(keys) == 0 || len(keys) > 8 {
+ return nil, ErrRefused
+ }
+ out := map[string][]byte{}
+ for _, k := range keys {
+ b, ok := canonical(k.Public, 65)
+ if !atom(k.ID, 64) || !ok {
+ return nil, ErrRefused
+ }
+ x, _ := elliptic.Unmarshal(elliptic.P256(), b)
+ if x == nil || out[k.ID] != nil {
+ return nil, ErrRefused
+ }
+ out[k.ID] = b
+ }
+ return out, nil
+}
+func strict(data []byte, dst any) error {
+ if !utf8.Valid(data) || json.Unmarshal(data, dst, json.RejectUnknownMembers(true)) != nil {
+ return ErrRefused
+ }
+ return nil
+}
+func signedPayload(data []byte, keys map[string][]byte, purpose string) ([]byte, error) {
+ if len(data) == 0 || len(data) > BodyLimit {
+ return nil, ErrRefused
+ }
+ var e envelope
+ if strict(data, &e) != nil || e.Version != 1 || !atom(e.KeyID, 64) {
+ return nil, ErrRefused
+ }
+ key, known := keys[e.KeyID]
+ if !known {
+ return nil, ErrRefused
+ }
+ p, ok := canonical(e.Payload, -1)
+ sig, ok2 := canonical(e.Signature, 64)
+ // The signature covers decoded UTF-8 payload bytes, never base64url text.
+ if !ok || !ok2 || !family.Verify(key, purpose, p, sig) {
+ return nil, ErrRefused
+ }
+ return p, nil
+}
+func encode(kp *family.Keypair, kid, context string, value any) ([]byte, error) {
+ if kp == nil || kp.SignPriv == nil || !atom(kid, 64) {
+ return nil, ErrRefused
+ }
+ p, e := json.Marshal(value)
+ if e != nil {
+ return nil, ErrRefused
+ }
+ sig, e := family.Sign(kp, context, p)
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ out, e := json.Marshal(envelope{1, kid, base64.RawURLEncoding.EncodeToString(p), base64.RawURLEncoding.EncodeToString(sig)})
+ if e != nil || len(out) > BodyLimit {
+ return nil, ErrRefused
+ }
+ return out, nil
+}
+func SignProvisioning(kp *family.Keypair, kid string, p Provisioning) ([]byte, error) {
+ return encode(kp, kid, ProvisioningContext, p)
+}
+func VerifyProvisioning(data []byte, operatorKeys []Key, expected Bindings) (VerifiedRecord, error) {
+ keys, e := publicKeys(operatorKeys)
+ if e != nil || !expected.valid() {
+ return VerifiedRecord{}, ErrRefused
+ }
+ raw, e := signedPayload(data, keys, ProvisioningContext)
+ if e != nil {
+ return VerifiedRecord{}, ErrRefused
+ }
+ var p Provisioning
+ if strict(raw, &p) != nil || p.Version != 1 || p.Bindings != expected || p.Sequence < 1 || p.MinEpoch < 1 || !atom(p.Operation, 128) {
+ return VerifiedRecord{}, ErrRefused
+ }
+ if _, ok := canonical(p.Generation, 32); !ok {
+ return VerifiedRecord{}, ErrRefused
+ }
+ if p.OwnerConfirmation != "" && !atom(p.OwnerConfirmation, 128) {
+ return VerifiedRecord{}, ErrRefused
+ }
+ if _, e = publicKeys(p.Keys); e != nil {
+ return VerifiedRecord{}, ErrRefused
+ }
+ // Hash the parsed canonical record so insignificant JSON whitespace cannot
+ // create another operation payload. Closed decoding still rejects ambiguity.
+ normalized, _ := json.Marshal(p)
+ return VerifiedRecord{p, hash(normalized), true}, nil
+}
+
+func hash(b []byte) string { s := sha256.Sum256(b); return base64.RawURLEncoding.EncodeToString(s[:]) }
+
+// CursorHash always returns SHA256 of the exact cursor bytes. For page zero it
+// returns SHA256(empty), encoded as 43 canonical base64url characters.
+func CursorHash(cursor string) string { return hash([]byte(cursor)) }
+func ValidChallenge(s string) bool { _, ok := canonical(s, 32); return ok }
+
+type MemberEntry struct {
+ Subject string `json:"person_ref"`
+ Role string `json:"role"`
+}
+
+func validMember(m MemberEntry) bool {
+ if len(m.Subject) != 29 || !strings.HasPrefix(m.Subject, "pr_") || (m.Role != "owner" && m.Role != "member") {
+ return false
+ }
+ for _, c := range m.Subject[3:] {
+ if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') {
+ return false
+ }
+ }
+ return true
+}
+
+// Page has a closed wire shape; a conflict omits all page-only fields.
+type Page struct {
+ Version int `json:"version"`
+ Type string `json:"type"`
+ Directory string `json:"directory"`
+ Consumer string `json:"consumer"`
+ Team string `json:"team"`
+ Audience string `json:"audience"`
+ Fetch string `json:"fetch"`
+ RequestCursorHash string `json:"request_cursor_hash"`
+ Generation string `json:"generation"`
+ Epoch int64 `json:"epoch"`
+ Index *int `json:"page_index,omitzero"`
+ Members *[]MemberEntry `json:"members,omitzero"`
+ Complete *bool `json:"complete,omitzero"`
+ Cursor *string `json:"cursor,omitzero"`
+}
+
+func SignPage(kp *family.Keypair, kid string, p Page) ([]byte, error) {
+ return encode(kp, kid, PageContext, p)
+}
+func verifyPage(raw []byte, r VerifiedRecord, fetch, cursor string, status int) (Page, error) {
+ keys, _ := publicKeys(r.record.Keys)
+ p, e := signedPayload(raw, keys, PageContext)
+ var v Page
+ if e != nil || strict(p, &v) != nil {
+ return v, ErrRefused
+ }
+ b := r.record.Bindings
+ if v.Version != 1 || v.Directory != b.Directory || v.Consumer != b.Consumer || v.Team != b.Team || v.Audience != b.Audience || v.Fetch != fetch || !ValidChallenge(v.Fetch) || v.RequestCursorHash != CursorHash(cursor) || v.Epoch < 1 {
+ return v, ErrRefused
+ }
+ if _, ok := canonical(v.Generation, 32); !ok {
+ return v, ErrRefused
+ }
+ if status == 409 && v.Type == "stale-cursor" && v.Index == nil && v.Members == nil && v.Complete == nil && v.Cursor == nil {
+ return v, nil
+ }
+ if status != 200 || v.Type != "page" || v.Index == nil || v.Members == nil || v.Complete == nil || *v.Index < 0 || *v.Index >= 100 || len(*v.Members) > PageLimit {
+ return v, ErrRefused
+ }
+ if *v.Complete {
+ if v.Cursor != nil {
+ return v, ErrRefused
+ }
+ } else {
+ if len(*v.Members) != PageLimit || v.Cursor == nil || len(*v.Cursor) > CursorLimit || *v.Cursor == "" {
+ return v, ErrRefused
+ }
+ if _, ok := canonical(*v.Cursor, -1); !ok {
+ return v, ErrRefused
+ }
+ }
+ for i, m := range *v.Members {
+ if !validMember(m) || (i > 0 && (*v.Members)[i-1].Subject >= m.Subject) {
+ return v, ErrRefused
+ }
+ }
+ return v, nil
+}
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")
+ }
+}