# 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:**

1. 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.
2. 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:

1. **Hand-rolled compact JWS** over stdlib `crypto/ed25519`: base64url
   header, base64url claims, `EdDSA` alg, one signature. The minter is
   genuinely small. The verifier is not — see the checklist below, which
   is what "safe" actually costs.
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 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.

1. **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.
2. **Constrain the header.** Require exactly `alg: "EdDSA"`, a bounded
   non-empty `kid`, and a dedicated `typ` for this assertion. Reject
   `crit`, `b64`, and any token-supplied key or key URL.
3. **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 unknown `kid` is not a way to
   make every verifier hammer the home.
4. **Verify over the ORIGINAL bytes.** `ed25519.Verify` against the
   encoded `header.payload` exactly as received. Never decode and
   re-serialise before verifying — that is how a signature ends up
   covering something other than what was read.
5. **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`, `exp` in
   the future, `iat` not beyond a small explicit skew. An unknown enum
   value becomes absent; a malformed integer hint is ignored and never
   grants anything.
6. **Consume before minting.** Atomically insert `(iss, jti)` and reject
   on conflict. Retain through `exp` **plus the accepted skew**, not
   just `exp`. 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 `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.
