oficina / home Public

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>
Paul Campbell pushed by paul@keymail.dev bd3e7d742b0bd7160fa72904da18148d6ccc5b6f parent 903eb7b
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.