Draft shared assertion reconciliation and suite consumer lifecycle
Co-Authored-By: Codex <codex@openai.com>
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. |