oficina / home Public

Draft shared assertion reconciliation and suite consumer lifecycle

Co-Authored-By: Codex <codex@openai.com>
Paul Campbell pushed by paul@keymail.dev 40726215102f32c4b2b963cbb84bb916574b0ce5 parent 7d68e87
1 file changed, +196 −0
  • docs/superpowers/plans/2026-09-07-suite-assertion-contract.md +196 −0
diff --git a/docs/superpowers/plans/2026-09-07-suite-assertion-contract.md b/docs/superpowers/plans/2026-09-07-suite-assertion-contract.md
new file mode 100644
index 0000000..342b039
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-suite-assertion-contract.md
@@ -0,0 +1,196 @@
+# Home step 3: shared assertion and consumer contract
+
+Status: DRAFT for independent design review; no implementation authorized by this document.
+LLM implementation plan, 2026-09-07. Home discussion 10. Parent/root owns SMTP,
+canary and deployment. Original Home owner remains informed in discussions 3/9.
+
+## Provenance and scope
+
+Home origin/main 4a442a1 supersedes the old Ed25519/JWS implementation
+recommendation. Home's binding design §5.3 still says Ed25519 and fetched keys.
+Rastrillo origin/main 2c3ae782 includes the merged 2026-08-31 assertion design,
+PR 128, discussion 1: its status is explicitly NOT RULED. Its primitive is
+implemented, but the assertion package is not. Merged text is not an approved
+wire protocol. This plan proposes one reconciliation, never a competing verifier.
+
+Keep the existing blind index, person refs, all existing secrets and fresh-session
+passkey enrolment. Directory self-exchange is already settled by Home §4/§10.
+Home supplies identity; instance grants and the separately pulled roster decide
+access. No Mail/Files/external relying parties are added. No UI strings or script
+exemptions are introduced by this design.
+
+## Proposed shared boundary
+
+Put signing and pure verification together in Rastrillo's `assertion` package,
+with independent golden vectors. No browser crypto twin, local RP, or local
+second-factor hook. Both Home and suite consumers import that implementation.
+Oficina-specific preference/roster decoding and issuer policy form one suite
+profile; consumers do not duplicate cryptographic parsing or replay logic.
+
+Adopt the framework's two-part wire: unpadded strict base64url(payload JSON),
+a dot, then unpadded strict base64url(signature). Sign the original decoded
+payload bytes with existing crypto.Sign and context `rastrillo/assertion/v1`.
+ECDSA P-256, SHA-256 and raw 64-byte r||s are fixed; no alg field, JWT header,
+token-supplied key, key URL, negotiation or alternative algorithm. This proposes
+a correction to Home §5.3, not permission to accept both Ed25519 and P-256.
+
+Require bounded UTF-8 JSON objects, no duplicate keys, aliases differing by case,
+trailing data, null required fields, fractional timestamps or unknown top-level
+fields. Wire maximum 4096 bytes; core string bounds: issuer/audience 255 bytes,
+subject 128, kid 64; jti exactly 16 random bytes encoded unpadded base64url.
+Signatures cover received bytes; verification never reserializes. Noncanonical
+base64 encodings are rejected. ECDSA signature malleability cannot bypass replay:
+replay is keyed by issuer+jti, never signature or whole token hash.
+
+Core required fields are v=1, iss, aud, sub, kid, jti, iat, exp. Core optionally
+carries `auth_time` for origins with a verified authentication timestamp, and
+`ext` as one bounded object interpreted by a configured profile after signature
+verification. The Oficina profile admits only prefs.theme and roster_epoch in
+ext; no names, address, role, capabilities or arbitrary metadata. A closed theme
+enum rejects unknown values by treating the preference as absent. Missing or
+malformed roster_epoch is an absent invalidation hint; it never grants access.
+Initial directory-only exchange omits roster_epoch because it names no team.
+Consumers requiring team standing must use roster pull and its TTL, regardless
+of the hint. No caller may treat successful signature verification as membership.
+
+Verify in this order: enforce byte/structure bounds; read only v/kid to select a
+configured key; verify signature; validate issuer, audience, subject and time;
+validate profile; return a typed verified result. Pure verification has no network,
+store writes or session side effects. No required security check is delegated to
+an optional app callback. Return-path state, replay consumption and session mint
+are handled by one exchange adapter, not a raw Verify result handed to sessions.
+
+For Home, iss is exactly the configured credential origin. aud is exactly the
+configured consumer origin including https scheme. The consumer registry binds
+that origin to precisely one compiled suite app, so the canonical host encodes
+the app+instance pair required by Home §5.3 without an ambiguous string join.
+Directory audience is its exact configured origin. No wildcard/suffix/multi-aud,
+Host-header-derived audience, or arbitrary caller-selected issuer. Canary origins
+come only from the reviewed paired configuration and isolated canary registry;
+production and canary issuer/key/consumer sets cannot mix.
+
+Home mints exp=iat+120 seconds. Framework hard ceiling can remain 300 seconds;
+Oficina verifier enforces 0<exp-iat<=120, iat<=now+30, and now<exp with no late
+expiry grace. Check integer bounds before subtraction/addition. Replay rows can
+expire at exp because acceptance stops exactly there. Clock rollback/restore of
+consumed replay state must invalidate outstanding handoffs before serving again.
+
+Do not use iat as session AuthTime: a new assertion from an old credential session
+is not fresh authentication. Signed auth_time is the original completed ceremony
+time, no later than iat, and not rewritten by minting. Missing auth_time cannot
+satisfy freshness. Home's directory step-up requests reauthentication at the
+credential origin and verifies auth_time against its pending transaction's
+max_age bound. This implements the already triggered roster-write step-up;
+no amr or role claim is added. Framework's proposed AuthTime=iat must be corrected
+before the adapter is used for fresh-session checks.
+
+## Key selection and distribution
+
+Recommend fixed key input in the shared verifier and a Home-specific fetch adapter,
+preserving Home's existing published-key model. The framework's never-speak
+argument comes from Eleven vault; Oficina already has explicit authenticated
+instance/Home calls including roster pull. That doctrine is not automatically
+Oficina's. Do not silently replace fetched keys with fleet-wide pin deployment.
+
+The adapter's only endpoint is configured-issuer +
+`/.well-known/oficina-assertion-keys`; its version-1 JSON contains at most two
+entries, each kid plus unpadded-base64url public key. kid is unpadded-base64url
+SHA-256 of the 65 public-key bytes. There are no
+redirects, token URL, DNS-derived issuer or arbitrary callback URL. HTTPS, bounded
+response (16KiB, two-second timeout), validate every 65-byte P-256 point, reject duplicate
+kids, and replace caches atomically. Unknown kid gets one coalesced rate-limited
+refresh then rejects; a known key in an unexpired cache never waits on network.
+Cache TTL is 300 seconds; expired caches require a successful bounded refresh or
+fail closed. Coalesce refresh per issuer and permit at most one attempt per 30
+seconds per process, including failures. Publish the next key before switching;
+keep the previous key published at least 150 seconds after its final mint. Test
+key-retirement delay up to the cache TTL; no immediate revocation promise. Home private
+signing material is a new dedicated P-256 key in platform config, never derived
+from or replacing existing DEK/pepper/signin keys. Overlap current/previous public
+keys, then retire with a bounded schedule. No secret provisioning in this slice.
+
+Alternative to resolve explicitly before implementation: pinned key sets, with
+three deploy phases (add new everywhere, switch signing, remove old everywhere).
+This changes Home's operational contract and needs a concrete fleet rotation
+proof. Neither mode may accept keys named by an untrusted token URL. The shared
+wire and pure verifier work with either distribution mode.
+
+## Suite-only consumer registry
+
+Keep two distinct records. The compiled app registry is Home §11's closed list of
+slug/provisionable/integrated; it is not editable through runtime rows. Consumer
+rows are §10's concrete origin credentials and lifecycle. An app existing in the
+compiled list never by itself authorizes a redirect to any host.
+
+For an instance consumer, require an integrated compiled app, a Home-owned
+team_instances row for that app/team, and exact origin
+https://<reserved-team-label>.<app>.oficina.works. A row is created only by the
+provisioning drain after it has installed the instance's configuration; directory
+upserts, bearer-token callers, invitations and browser parameters cannot create
+or rename consumers. Team membership is not required to mint identity: guests
+still authenticate and the destination checks its own grants.
+
+Registration writes the team_instances binding and consumer record atomically.
+Origin and (team,app) are unique. A partial failure leaves no enabled consumer.
+The directory is an explicit singleton consumer bound to the configured directory
+origin, with no team, no service bearer token and its own replay/session store.
+This is sufficient for step 3 before provisioned team records exist. Do not seed
+existing arbitrary app hosts as team instances just to demonstrate SSO.
+
+Eligibility is a query joining a live consumer row to its expected instance/app
+binding. Decommission deletes the consumer and revokes its service credential in
+the same local transaction; no stale directory row, DNS record or retired cursor
+can keep it eligible. Provisioning compensations mark/remove failed bindings
+before exposure. Re-registration requires the drain to verify a fresh live
+instance; a stale backup alone cannot resurrect admission. Assertions already
+minted before revocation remain valid at that audience for at most 120 seconds;
+existing app sessions follow their own policy. No fake immediate revocation claim.
+
+Use a fixed callback path per integrated app, stored as compiled protocol config,
+not a caller-supplied URL. Home accepts an exact registered consumer identifier,
+looks up its canonical callback, and rechecks eligibility immediately before mint.
+The destination stores the original relative path+query locally in its bounded
+pending login record; only an opaque random state travels to Home. This preserves
+?team= without turning Home into a return-path parser or open redirector. The
+consumer validates its local return target (no absolute/scheme-relative URL,
+backslash or control bytes) and emits a same-origin Location after exchange.
+
+Browser binding must survive cross-site form POST without weakening __Host cookie
+scope. Design the shared adapter around a consumer GET finalization hop: callback
+first verifies the assertion cryptographically without admitting anyone, then
+stores the bounded opaque token/state in a short-lived server pending record,
+303s to a same-origin GET, then matches the initiating browser's HttpOnly Secure
+__Host cookie and transaction before any subject is used or session minted.
+Consume browser state and (iss,jti) atomically before session issuance; restart,
+concurrency and database errors fail closed. Rejected/expired pending payloads
+are pruned; assertion payloads persisted before exchange must be sealed.
+No accepted browser nonce means no admission even for a valid unused assertion.
+
+## Decisions and implementation gates
+
+Under the overnight authorization, package placement/both halves/no JS twin,
+canonical single audience, fixed callback, explicit joined consumer lifecycle,
+and directory self-exchange are bounded implementation choices for review.
+There is no reason to ask again whether the directory should exchange an assertion.
+
+Actual cross-spec choices are the P-256 correction, fetched versus pinned Home
+keys, and the auth_time/ext amendments needed by Home's settled behavior. This
+plan recommends P-256, a fixed-key pure verifier plus constrained Home fetch
+adapter, and explicit authenticated freshness. Fable reviews those recommendations;
+root coordinates the recorded corrections with the framework/Home owner before
+code. New external consumer trust, local RPs, weakened enrolment freshness or
+identity-key migration are outside scope and are not inferred as authorized.
+
+Delivery remains a separate implementation design gate: Home §5.3 says auto-POST,
+but ruling21 grants script only to passkey screens. Do not quietly add auto-submit
+JS. A scriptless submit form can satisfy POST transport, but requires reviewed UI
+copy and a concrete interaction decision. No new copy is written in this plan.
+
+Tests before release: golden vectors crossing signer/verifier; every required
+claim missing/null/duplicate; wrong key/context/issuer/audience; signature mutation;
+TTL boundaries and overflow; unknown-kid refresh abuse; key rollover; replay races
+and restart; stale credential session cannot become fresh on mint; wrong-browser
+login CSRF; callback/query preservation; inactive/spoofed/unintegrated consumer;
+registration rollback and decommission race; directory self-exchange; production
+versus canary issuer isolation. Mutation-prove those trust checks. Review code and
+actual green CI, then root's isolated two-origin browser canary before integration.