Draft the §13 step 3 plan: assertions, the redirect protocol, gate 3
DRAFT, unreviewed, and labelled as such at the top. The step-2 plan took seven review passes and every one found something; this has had none. Three things it establishes that a builder would otherwise discover late: 1. **rastrillo cannot sign this assertion.** §5.3 says Ed25519. rastrillo/crypto is ECDSA P-256 over SHA-256(context ‖ 0x00 ‖ msg), returning a raw r‖s — a different curve and a bespoke scheme, not a JWS. rastrillo/keyring is E2EE key-wrapping. There is no JWT in the module. So it is stdlib crypto/ed25519 plus a hand-rolled compact JWS, or a new dependency in the home AND in every consumer. Filed as Ruling A with a recommendation: hand-rolled, but with the VERIFIER shipped once as a package the suite imports, because a verifier written three times is where alg:none and unchecked aud live. 2. **A fifth secret, and it is the only rotatable one.** Losing HOME_DEK makes sealed links unreadable and losing HOME_PEPPER orphans every person row; losing the assertion key costs a rotation and a window of failed exchanges, because verifiers cache by kid and refetch once on an unknown one. Worth saying, since it changes what "one-way door" means for this one. 3. **The origin split is not wired.** Checked 2026-09-07: oficina.works and id.oficina.works both answer 200 from build 0d0262a, the same skeleton. Step 2 already hard-codes the credential origin as the CSRF origin, the cookie origin and the RP ID, and asserts it — so the app is written as though the split exists. The cookie half is genuinely true (__Host-, host-only, asserted); the routing half is not. Two more rulings filed rather than guessed: what a "consumer" is concretely, since §5.6's allowlist is what stops the credential origin being an open redirector; and whether the directory exchanges an assertion against itself, which §4 implies and §10's "the directory origin's own store" is evidence for. Also flags something the plan cannot settle: §5.3 wants delivery by auto-POST, which needs script, and ruling 21's carve-out does not cover it. A scriptless auto-POST is a form with a button and a sentence asking the person to press it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+287
−0
docs/superpowers/plans/2026-09-07-home-assertion.md+287 −0
CI passed — run details
diff --git a/docs/superpowers/plans/2026-09-07-home-assertion.md b/docs/superpowers/plans/2026-09-07-home-assertion.md| new file mode 100644 |
| index 0000000..01535e1 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-home-assertion.md |
| @@ -0,0 +1,287 @@ |
| +# 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 |
| + |
| +### Ruling A: 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: |
| + |
| +1. **Hand-rolled compact JWS** over stdlib `crypto/ed25519`: base64url |
| + header, base64url claims, `EdDSA` alg, one signature. Perhaps 80 lines |
| + for the minter and 60 for the verifier. |
| +2. **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 small package the suite imports — so there is one |
| +verifier, not one per app — and with the `alg` allowlisted to `EdDSA` by |
| +whitelist rather than by rejecting `none`. This is a §1 row. |
| + |
| +### 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 `directoryOrigin` beside `Origin`, both constants, both derived |
| + from one `suiteDomain` so 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 `TestRPIDIsExactlyTheCredentialHost` to 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_KEY` joins `LoadSecrets`, base64, exactly 32 bytes |
| + (an Ed25519 seed), refused when all-zero like the rest. |
| +- [ ] `kid` is derived from the public key, not configured — a |
| + configured `kid` is 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". |
| +- [ ] `prefs` carries 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 `TakeLink` is — an |
| + `INSERT ... ON CONFLICT DO NOTHING RETURNING` or a `DELETE ... |
| + RETURNING`, so two concurrent exchanges of one assertion cannot |
| + both succeed. Step 2's `TestConcurrentTakeLinkYieldsOneWinner` is |
| + 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 |
| + |
| +1. Get Rulings A, B and C from Paul. |
| +2. 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. |
| +3. Confirm the two prerequisites above are real and unblocked. |