oficina / home Public
Oficina home — §13 step 3: assertion minting, the redirect protocol, gate 3
Status: DRAFT, unreviewed. Written 2026-09-07 immediately after §13 step 2 landed. It has had no review pass; the step-2 plan took seven, and every one of them found something. Do not execute this as though it were finished.
What this step unblocks. §13's own note: "Sheets and Docs are unblocked from step 3, not step 9 — assertions plus a stub directory is enough for both." Nothing in the suite can authenticate a person against an instance until this exists.
---
Global constraints
Everything in the step-2 plan's Global Constraints still binds: the gates, the cadence, CGO_ENABLED=0, the review requirement. Two more apply only here.
Every claim on the assertion faces §5.4's two-sided test. A claim may ride only if (a) the instance makes no trust decision on it, validating against a closed enumerated set and treating a failed match as absent, and (b) the home already holds it for its own purposes. There is exactly one exception, added 2026-09-05 and scoped by the suite doctrine to the call-8 path: roster_epoch, which is an invalidation hint and never a decision.
The credential origin renders no account-authored string, ever (§4). That constrains where every page in this step may live, and it is why §5.6's return host is validated rather than displayed.
---
Two prerequisites that are not the executor's
1. The origin split is not wired
§4 requires two hostnames with different jobs: id.oficina.works (the credential origin, the WebAuthn RP) and oficina.works (the directory, at the apex). Today both names serve the same deployment — checked 2026-09-07, both answer 200 from /api/version 0d0262a, which is the skeleton build — which means §4's guarantee — "an XSS on the directory cannot reach the RP" — is currently not true, it is merely unexercised, because nothing renders a team name yet.
Step 2 already hard-codes Origin = "https://id.oficina.works" as the CSRF origin, the session cookie origin and the WebAuthn RP ID, and its TestRPIDIsExactlyTheCredentialHost asserts it. So the app is already written as though the split exists.
What must be true before this step ships: a request to oficina.works and a request to id.oficina.works reach code that knows which one it is, and the session cookie set on the credential origin is not readable by the directory. The cookie half is already true — it is __Host-, host-only, asserted by TestNoCookieOnTheCredentialOriginCarriesADomain. The routing half is not.
Verify before planning further, and do not assume: what oficina.works and id.oficina.works currently resolve to, whether the carlos edge routes them to one app or two, and whether one binary serving both hostnames is the intended shape or two deployments are. This plan assumes ONE binary routing on Host, because §4 says the directory obtains its session by exchanging an assertion "so there is one assertion path rather than two" — which only needs saying if the two halves might otherwise have shared a process. That reading should be confirmed.
2. A fifth secret
HOME_ASSERTION_KEY — the Ed25519 private key. §5.3: "Private key in platform config, never in the database."
Unlike the other four, this one is not a one-way door, and that is worth stating because it changes who may mint it and what losing it costs. Verification keys are published and cached by kid; a verifier refetches once on an unknown kid and then fails closed. So a lost signing key costs a rotation and a brief window of failed exchanges, not unreadable data. Losing HOME_DEK makes every sealed link unreadable; losing HOME_PEPPER orphans every person row. This one is recoverable.
It is still platform config and still Paul's to provision, on the same argument as the rest: an executor that mints production secrets is an executor that has decided something it should not.
---
Three things to settle before Task 2 — not the executor's to decide
Two things to adopt from the SSO plugin work, not re-derive
From the rastrillo SSO session, 2026-09-07, and verified here:
Never key an enforcement decision on sessions.Session.Method. passkey rewrites it: SignInFinish writes "<first factor>+passkey", SignInRecovery writes "<first factor>+recovery", and StepUpFinish writes bare "passkey" (verified at the v0.22.0 pin). So a check like "was this a magic-link session?" silently changes answer after a step up. Their spec adds sessions.Session.Provider, written "<method>:<tenant>" — an assertion plugin would write "assertion:<issuer>" — precisely so enforcement has something stable to read.
Home has no such bug today: grep shows it only ever WRITES Method and never reads it for a decision. Step 3 is exactly where one would appear, since the design's §6 mints Method: "assertion" and the obvious next thing anybody writes is "was this a strong sign-in?".
Also worth importing rather than rebuilding: sessions.Handle/Lookup/ Revoke, which is what lets a re-authentication revoke the session that started it. Both land on their sso-saml branch first, so this step has a sequencing dependency on that branch, not just on §9.
Ruling A: SUPERSEDED — rastrillo already has a merged design
Read this before anything below it. Checked 2026-09-07: rastrillo/rastrillo discussion #1, "An instance needs to verify a signed identity assertion", is open and was filed jointly by the Sheets and Docs design sessions, with Paul ruling on the shape for both. The design is written and merged — PR #128, 2026-08-31, docs/superpowers/specs/2026-08-31-identity-assertions-design.md in rastrillo. What does not exist is the code: there is no Ed25519 and no assertion package anywhere in rastrillo's non-test Go.
It contradicts this plan, and on the evidence rastrillo is right. This section below recommends a hand-rolled compact JWS. rastrillo's design says explicitly that the wire format should not be a JWT even in shape, and builds on crypto's fixed primitive instead — one curve (ECDSA P-256), one hash, one encoding, and a domain-separating context string pinned to rastrillo/assertion/v1. Its reasoning is that crypto has no algorithm agility at all, so there is no field in which to say "none" and no second algorithm to downgrade to: algorithm confusion is designed out rather than defended against. That is a better answer than this plan's on precisely the failure mode the second opinion below listed first.
Its wire format is base64url(payload) "." base64url(signature), with kid INSIDE the signed payload and a strict order — decode, check v, select key by kid, verify the signature, and only then read any other claim; nothing before the signature check may have a side effect.
So home must not hand-roll this. Doing so would make it the third app to hand-roll assertion verification differently, which is the exact outcome discussion #1 exists to prevent.
A GAP IN THE MERGED DESIGN, found by the rastrillo SSO session and verified here. The design has no browser binding at all — searched for browser, cookie, bound, transaction and CSRF, and its only replay controls are jti single use plus short expiry (§4). Neither stops this: an attacker completes their own handoff, does not use the assertion, and hands it to a victim, who is signed in as the attacker. Single use prevents a SECOND use, not a first use by the wrong person; short expiry only shortens the window.
Home §5.6 already requires "a browser-bound state nonce" and gives this exact reason. rastrillo's design does not, and rastrillo's is the one that would be implemented — so every consumer would inherit the gap. Their fix on the SSO side is a transaction cookie set on the outbound leg with its hash on the request row; the shape home already uses for the keymail pending cookie.
This is the third time this one attack has surfaced on this project: the GET-redeemed magic link, the keymail callback, and codex naming it unprompted as the most commonly forgotten thing when asked about assertions.
Two conflicts for Paul, both between specs rather than within one:
- Home §5.3 says "Signature: Ed25519". rastrillo's design says ECDSA P-256 via
crypto. Home's choice carries no stated reasoning; rastrillo's carries the argument above. One of the two specs has to change. - Home §5.3 publishes verification keys on the credential origin (fetched). rastrillo's §9 Q2 recommends pinned keys on the never-speak doctrine, and says the rotation counter-argument "is not weak". Home is the signer, so this one is home's problem more than any consumer's.
What actually unblocks this step is not a new issue — it is Paul answering rastrillo's §9, especially Q1 ("both halves, or verify only?", which that design says "is the first thing to rule on, because it sizes everything else") and Q2. Then rastrillo implements, and home consumes it.
Meanwhile Tasks 4, 5 and 6 below are independent of the signature format — the redirect protocol, the jti store and gate 3 do not care how the bytes are signed. That is where to work while #1 is unblocked.
---
Ruling A (superseded — kept for the reasoning): how the assertion is signed and encoded
§5.3 says Ed25519. rastrillo cannot supply it. Verified at the v0.22.0 pin: rastrillo/crypto is ECDSA P-256, signing over SHA-256(context ‖ 0x00 ‖ msg) and returning a raw 64-byte r‖s — a different curve and a bespoke scheme, not a JWS. rastrillo/keyring is an E2EE key-wrapping package and not this. There is no JWT anywhere in the module.
So the choices are:
- Hand-rolled compact JWS over stdlib
crypto/ed25519: base64url header, base64url claims,EdDSAalg, one signature. The minter is genuinely small. The verifier is not — see the checklist below, which is what "safe" actually costs. - A third-party JWT library, and a new dependency in the home and in every consumer.
The tradeoff is not "how hard is it to write" — option 1's minter is easy. It is that every consumer app has to VERIFY, and a verifier is where the dangerous mistakes live: accepting alg: none, not checking aud, not checking exp, accepting an unknown kid. Writing that once in a shared package the suite imports is option 1 done properly; writing it three times in three apps is option 1 done the way it usually goes.
Recommendation, for Paul to accept or reject: option 1, with the verifier shipped as a package the suite imports — so there is one verifier, not one per app — and with alg allowlisted to EdDSA by whitelist rather than by rejecting none. This is a §1 row.
Second opinion, 2026-09-07, gpt-6-astra via codex (the account's default; named models are refused, and the rollout was checked rather than assumed). It agreed with option 1 under these constraints, and corrected the framing above: an earlier draft of this section put the verifier at "60 lines", and its response was that parsing, replay protection and key handling are what decide safety, so a line-count target is the wrong goal. It also named a third option this plan had not considered, and the strongest argument against the recommendation. Both are recorded below rather than filtered out.
The verifier must do all of this, in this order. This is the list that makes option 1 defensible; without it, option 1 is the bad version of itself.
- Bound and parse. Cap the token size. Require exactly three non-empty segments and strict unpadded base64url. Require JSON objects with no duplicate keys, no trailing data, no case-insensitive field aliases.
- Constrain the header. Require exactly
alg: "EdDSA", a bounded non-emptykid, and a dedicatedtypfor this assertion. Rejectcrit,b64, and any token-supplied key or key URL. - Resolve a trusted key. Only from the configured home's HTTPS key endpoint, never from the token. Require an Ed25519 public key of the right length. Refetch once on an unknown
kid, then reject; coalesce and rate-limit those refetches so an unknownkidis not a way to make every verifier hammer the home. - Verify over the ORIGINAL bytes.
ed25519.Verifyagainst the encodedheader.payloadexactly as received. Never decode and re-serialise before verifying — that is how a signature ends up covering something other than what was read. - Validate the claims. Correct types for
sub,iss,aud,iat,exp,jti. Exact match on issuer and on this instance's own app+host audience. Integer timestamps,0 < exp-iat <= 120,expin the future,iatnot beyond a small explicit skew. An unknown enum value becomes absent; a malformed integer hint is ignored and never grants anything. - Consume before minting. Atomically insert
(iss, jti)and reject on conflict. Retain throughexpplus the accepted skew, not justexp. Survive restarts. A storage failure is a rejection, never a pass.
And the thing it says is most commonly forgotten: login CSRF. A valid, single-use assertion does not by itself stop an attacker fixing their own assertion onto a victim's browser. The receiving endpoint must bind the exchange to the browser's own login transaction. §5.6 already requires exactly that ("a browser-bound state nonce"), so this is independent confirmation of the spec rather than a new requirement — and it is the third time this same attack has come up in this project, after the magic link and the keymail callback.
The third option, recorded because it was not considered: a shared policy package backed by a JWT library. Every app imports one package that exposes only this protocol, so configuration stays in one place; the dependency is still in every binary, but it is configured once rather than four times. Strictly better than option 2 as stated.
The strongest argument against the recommendation, in its own words: maintenance concentration. One person plus agents owns every parsing edge case and every future protocol change, and a single shared verifier distributes one mistake across the whole suite. A maintained library brings outside scrutiny. Its condition was explicit — if independent security review and adversarial testing will not happen, take the library-backed shared package instead. On this project review does happen and has repeatedly caught exactly this class of defect, which is the argument for option 1 rather than against it; but the condition is the thing to check, not the conclusion.
Ruling B: what a "consumer" is, concretely
§5.6 requires return hosts validated against a consumers allowlist, "otherwise the credential origin is an open redirector". §10 lists a consumers table. Nothing yet says how a row gets into it.
Two shapes, and the choice decides how provisioning works in step 5: the allowlist is derived from the team/app/host records the home already holds (a host is a valid return target if it is an instance the home provisioned), or it is its own record written at provisioning.
The derived shape has no second thing to keep in sync and no way to forget a row. The explicit shape can express "this host may receive assertions" separately from "this host exists", which matters the day an instance is decommissioned but its hostname still resolves. §8.3 and the decommission path in step 8 probably decide this, so it should be read before ruling.
Ruling C: does the directory exchange an assertion against itself?
§4: "The directory obtains its session by exchanging an assertion, the same mechanism instances use (§5.3), so there is one assertion path rather than two."
If both origins are one process, the directory half of that process is a consumer of the credential half. That is implementable and probably right — it means the directory's session is a directory-origin cookie minted from a verified assertion, and an XSS there reaches a session that opens the directory and nothing else. But it means the home holds its own aud, verifies its own signature, and stores jti for itself, which reads oddly and should be deliberate rather than incidental. §10 already says assertion_jti is "the directory origin's own store", which is evidence the answer is yes.
---
Tasks
Task boundaries follow step 2's discipline: each one lands green, with its gate, and nothing routes a handler before the store beneath it exists.
Task 1: The origin split, in the app
Files: internal/home/origins.go, internal/home/origins_test.go, modify internal/home/app.go.
One binary, two hostnames, and a router that knows which is which. The credential routes (/signin*, /passkey/*) answer only on id.oficina.works; the directory routes answer only on the apex.
- [ ] Add
directoryOriginbesideOrigin, both constants, both derived from onesuiteDomainso they cannot drift. - [ ] A
Host-routing middleware. A request for a hostname the app does not serve gets one answer, not a stack trace. - [ ] The guard, which is the point of the task: a test that walks the router and asserts every credential route refuses on the directory host and every directory route refuses on the credential host. Written now, while there is exactly one directory route to forget.
- [ ] Extend
TestRPIDIsExactlyTheCredentialHostto assert the two origins are different hostnames, and that neither is a suffix of the other in a way that would let a cookie or an RP ID span both.
Do not move sign-in behind a host check without checking the test helpers: step 2's signIn, redeem and postJSON set Origin but build requests with httptest.NewRequest, whose Host is example.com. Every one of them will need the credential host, and they will fail in a way that looks like a routing bug rather than a test-setup bug.
Task 2: The signing key, the key set, and kid
Files: internal/home/assertion.go, internal/home/assertion_test.go, modify internal/home/keys.go.
- [ ]
HOME_ASSERTION_KEYjoinsLoadSecrets, base64, exactly 32 bytes (an Ed25519 seed), refused when all-zero like the rest. - [ ]
kidis derived from the public key, not configured — a configuredkidis a second thing to get wrong, and §5.3 only needs it to name a key, not to be chosen. - [ ]
GET /.well-known/...on the credential origin publishes the verification key. The exact path and document shape are Ruling A's to settle; do not invent one. - [ ] §5.3 says two keys are active during rotation. Build the key set as a slice from the start, even with one member: a publisher that can only express one key is a rotation that requires a deploy.
Task 3: Minting
Files: modify internal/home/assertion.go.
Every claim in §5.3's table, and nothing else. sub is the person ref. exp is 120 seconds — sized for a cold instance wake, and the paragraph in §5.3 explains why tightening it makes the first sign-in to a cold instance fail and loop. Single-use jti is what stops replay, not brevity; do not "harden" this by shortening it.
- [ ] Mint, with a test per claim.
- [ ] A test that the claim set is CLOSED: an assertion carries exactly the claims §5.4 admits and no others. This is the test that stops somebody adding a display name "just for the header".
- [ ]
prefscarries theme only, validated against a closed enumerated set, a failed match treated as absent.
Task 4: The redirect protocol (§5.6)
Files: internal/home/exchange.go, internal/home/exchange_test.go.
All three of §5.6's requirements are in gate 3, so they land together:
- [ ] Return hosts validated against the consumers allowlist (Ruling B).
- [ ] A browser-bound state nonce. This is the same attack step 2's POST-redeems design closed one hop earlier — without it an attacker fixes their own signed assertion onto a victim's browser and the victim works inside the attacker's account. Step 2's
__Host-pending cookie is the shape to copy. - [ ] The return URL preserves its query string, validated host and all. Ruling 22 puts the team in
?team=<sqid>, so dropping it returns a person to a different team than the one they were sent. - [ ] Delivery is an auto-POST, not a query string, so assertions stay out of history and referrers. It is a form that submits itself — which needs script, and §11's carve-out does not cover it. Check ruling 21's wording before building this: a scriptless auto-POST is a form with a submit button and a sentence telling the person to press it, which is worse UX and needs no exemption. That choice is not settled here.
Task 5: jti, single use
Files: internal/home/migrations/0003_assertion_jti.sql, modify internal/home/exchange.go.
Self-pruning, sized by assertion lifetime rather than user count. §10's assertion_jti is the directory origin's own store, not a shared one.
- [ ] The consume is one statement, the way
TakeLinkis — anINSERT ... ON CONFLICT DO NOTHING RETURNINGor aDELETE ... RETURNING, so two concurrent exchanges of one assertion cannot both succeed. Step 2'sTestConcurrentTakeLinkYieldsOneWinneris the test to copy, and the mutation that proves it (a SELECT-then-INSERT) is the one to run. - [ ] Pruning on write plus at boot, the shape step 2 settled on.
Task 6: Gate 3
Gate 3 is §5.6's three requirements asserted together. Following step 2's hard-won rule: write the break that proves each one bites, and record the output in the commit. A gate never seen to fail is a gate written to pass — that finding came back three times in step 2 and was right every time.
---
What this plan does NOT cover
The directory's own pages, calls 2 and 3, and anything that renders a team name: those are step 4. This step builds the mechanism that lets an instance trust a person, and a stub directory is explicitly enough for Sheets and Docs to start.
Before executing this
- Get Rulings A, B and C from Paul.
- Have this plan reviewed adversarially, at least twice. Every claim it makes about rastrillo was checked at the v0.22.0 pin on 2026-09-07; every claim it makes about the platform was not.
- Confirm the two prerequisites above are real and unblocked.