Specify directory roster HTTP and signing integration
1 file changed,
+216
−0
implementation/roster-http-signing.md+216 −0
CI failed — run details
diff --git a/implementation/roster-http-signing.md b/implementation/roster-http-signing.md| new file mode 100644 |
| index 0000000..9c572bb |
| --- /dev/null |
| +++ b/implementation/roster-http-signing.md |
| @@ -0,0 +1,216 @@ |
| +# 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, 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 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/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. |