oficina / home Public

Specify directory roster HTTP and signing integration

Paul Campbell pushed by paul@keymail.dev cb1fa4943d0a45b7646dbc36ce4f5b480684bf1d parent 12ea169
1 file changed, +216 −0
  • implementation/roster-http-signing.md +216 −0
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.