oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1# Oficina home — §13 step 3: assertion minting, the redirect protocol, gate 3
2
3**Status: DRAFT, unreviewed.** Written 2026-09-07 immediately after §13
4step 2 landed. It has had no review pass; the step-2 plan took seven, and
5every one of them found something. Do not execute this as though it were
6finished.
7
8**What this step unblocks.** §13's own note: "Sheets and Docs are
9unblocked from step 3, not step 9 — assertions plus a stub directory is
10enough for both." Nothing in the suite can authenticate a person against
11an instance until this exists.
12
13---
14
15## Global constraints
16
17Everything in the step-2 plan's Global Constraints still binds: the
18gates, the cadence, `CGO_ENABLED=0`, the review requirement. Two more
19apply only here.
20
21**Every claim on the assertion faces §5.4's two-sided test.** A claim may
22ride only if (a) the instance makes no trust decision on it, validating
23against a closed enumerated set and treating a failed match as *absent*,
24**and** (b) the home already holds it for its own purposes. There is
25exactly one exception, added 2026-09-05 and scoped by the suite doctrine
26to the call-8 path: `roster_epoch`, which is an invalidation hint and
27never a decision.
28
29**The credential origin renders no account-authored string, ever** (§4).
30That constrains where every page in this step may live, and it is why
31§5.6's return host is validated rather than displayed.
32
33---
34
35## Two prerequisites that are not the executor's
36
37### 1. The origin split is not wired
38
39§4 requires two hostnames with different jobs: `id.oficina.works` (the
40credential origin, the WebAuthn RP) and `oficina.works` (the directory,
41at the apex). **Today both names serve the same deployment** — checked
422026-09-07, both answer 200 from `/api/version` `0d0262a`, which is the
43skeleton build — which means
44§4's guarantee — "an XSS on the directory cannot reach the RP" — is
45currently not true, it is merely unexercised, because nothing renders a
46team name yet.
47
48Step 2 already hard-codes `Origin = "https://id.oficina.works"` as the
49CSRF origin, the session cookie origin and the WebAuthn RP ID, and its
50`TestRPIDIsExactlyTheCredentialHost` asserts it. So the app is already
51written as though the split exists.
52
53**What must be true before this step ships:** a request to
54`oficina.works` and a request to `id.oficina.works` reach code that knows
55which one it is, and the session cookie set on the credential origin is
56not readable by the directory. The cookie half is already true — it is
57`__Host-`, host-only, asserted by
58`TestNoCookieOnTheCredentialOriginCarriesADomain`. The routing half is
59not.
60
61**Verify before planning further, and do not assume:** what
62`oficina.works` and `id.oficina.works` currently resolve to, whether the
63carlos edge routes them to one app or two, and whether one binary serving
64both hostnames is the intended shape or two deployments are. This plan
65assumes ONE binary routing on `Host`, because §4 says the directory
66obtains its session by exchanging an assertion "so there is one assertion
67path rather than two" — which only needs saying if the two halves might
68otherwise have shared a process. That reading should be confirmed.
69
70### 2. A fifth secret
71
72`HOME_ASSERTION_KEY` — the Ed25519 private key. §5.3: "Private key in
73platform config, never in the database."
74
75**Unlike the other four, this one is not a one-way door**, and that is
76worth stating because it changes who may mint it and what losing it
77costs. Verification keys are published and cached by `kid`; a verifier
78refetches once on an unknown `kid` and then fails closed. So a lost
79signing key costs a rotation and a brief window of failed exchanges, not
80unreadable data. Losing `HOME_DEK` makes every sealed link unreadable;
81losing `HOME_PEPPER` orphans every person row. This one is recoverable.
82
83It is still platform config and still Paul's to provision, on the same
84argument as the rest: an executor that mints production secrets is an
85executor that has decided something it should not.
86
87---
88
89## Three things to settle before Task 2 — not the executor's to decide
90
91### Two things to adopt from the SSO plugin work, not re-derive
92
93From the rastrillo SSO session, 2026-09-07, and verified here:
94
95**Never key an enforcement decision on `sessions.Session.Method`.**
96`passkey` rewrites it: `SignInFinish` writes `"<first factor>+passkey"`,
97`SignInRecovery` writes `"<first factor>+recovery"`, and `StepUpFinish`
98writes bare `"passkey"` (verified at the v0.22.0 pin). So a check like
99"was this a magic-link session?" silently changes answer after a step
100up. Their spec adds `sessions.Session.Provider`, written
101`"<method>:<tenant>"` — an assertion plugin would write
102`"assertion:<issuer>"` — precisely so enforcement has something stable
103to read.
104
105**Home has no such bug today**: `grep` shows it only ever WRITES
106`Method` and never reads it for a decision. Step 3 is exactly where one
107would appear, since the design's §6 mints `Method: "assertion"` and the
108obvious next thing anybody writes is "was this a strong sign-in?".
109
110Also worth importing rather than rebuilding: `sessions.Handle/Lookup/
111Revoke`, which is what lets a re-authentication revoke the session that
112started it. Both land on their `sso-saml` branch first, so **this step
113has a sequencing dependency on that branch**, not just on §9.
114
115### Ruling A: SUPERSEDED — rastrillo already has a merged design
116
117**Read this before anything below it.** Checked 2026-09-07:
118`rastrillo/rastrillo` discussion **#1**, "An instance needs to verify a
119signed identity assertion", is open and was filed jointly by the Sheets
120and Docs design sessions, with Paul ruling on the shape for both. The
121design is **written and merged** — PR #128, 2026-08-31,
122`docs/superpowers/specs/2026-08-31-identity-assertions-design.md` in
123rastrillo. What does not exist is the code: there is no Ed25519 and no
124assertion package anywhere in rastrillo's non-test Go.
125
126**It contradicts this plan, and on the evidence rastrillo is right.**
127This section below recommends a hand-rolled compact JWS. rastrillo's
128design says explicitly that the wire format **should not be a JWT even
129in shape**, and builds on `crypto`'s fixed primitive instead — one curve
130(ECDSA P-256), one hash, one encoding, and a domain-separating context
131string pinned to `rastrillo/assertion/v1`. Its reasoning is that
132`crypto` has no algorithm agility at all, so there is no field in which
133to say "none" and no second algorithm to downgrade to: **algorithm
134confusion is designed out rather than defended against.** That is a
135better answer than this plan's on precisely the failure mode the second
136opinion below listed first.
137
138Its wire format is `base64url(payload) "." base64url(signature)`, with
139`kid` INSIDE the signed payload and a strict order — decode, check `v`,
140select key by `kid`, verify the signature, and only then read any other
141claim; nothing before the signature check may have a side effect.
142
143**So home must not hand-roll this.** Doing so would make it the third
144app to hand-roll assertion verification differently, which is the exact
145outcome discussion #1 exists to prevent.
146
147**A GAP IN THE MERGED DESIGN, found by the rastrillo SSO session and
148verified here.** The design has **no browser binding at all** — searched
149for browser, cookie, bound, transaction and CSRF, and its only replay
150controls are `jti` single use plus short expiry (§4). Neither stops this:
151an attacker completes their own handoff, does not use the assertion, and
152hands it to a victim, who is signed in as the attacker. Single use
153prevents a SECOND use, not a first use by the wrong person; short expiry
154only shortens the window.
155
156Home §5.6 already requires "a browser-bound state nonce" and gives this
157exact reason. rastrillo's design does not, and rastrillo's is the one
158that would be implemented — so every consumer would inherit the gap.
159Their fix on the SSO side is a transaction cookie set on the outbound
160leg with its hash on the request row; the shape home already uses for
161the keymail pending cookie.
162
163This is the third time this one attack has surfaced on this project:
164the GET-redeemed magic link, the keymail callback, and codex naming it
165unprompted as the most commonly forgotten thing when asked about
166assertions.
167
168**Two conflicts for Paul, both between specs rather than within one:**
169
1701. Home §5.3 says "Signature: Ed25519". rastrillo's design says ECDSA
171 P-256 via `crypto`. Home's choice carries no stated reasoning;
172 rastrillo's carries the argument above. One of the two specs has to
173 change.
1742. Home §5.3 publishes verification keys on the credential origin
175 (fetched). rastrillo's §9 Q2 recommends **pinned** keys on the
176 never-speak doctrine, and says the rotation counter-argument "is not
177 weak". Home is the signer, so this one is home's problem more than
178 any consumer's.
179
180**What actually unblocks this step** is not a new issue — it is Paul
181answering rastrillo's §9, especially Q1 ("both halves, or verify only?",
182which that design says "is the first thing to rule on, because it sizes
183everything else") and Q2. Then rastrillo implements, and home consumes
184it.
185
186**Meanwhile Tasks 4, 5 and 6 below are independent of the signature
187format** — the redirect protocol, the `jti` store and gate 3 do not care
188how the bytes are signed. That is where to work while #1 is unblocked.
189
190---
191
192### Ruling A (superseded — kept for the reasoning): how the assertion is signed and encoded
193
194**§5.3 says Ed25519. rastrillo cannot supply it.** Verified at the
195v0.22.0 pin: `rastrillo/crypto` is **ECDSA P-256**, signing over
196`SHA-256(context ‖ 0x00 ‖ msg)` and returning a raw 64-byte r‖s — a
197different curve and a bespoke scheme, not a JWS. `rastrillo/keyring` is
198an E2EE key-wrapping package and not this. There is no JWT anywhere in
199the module.
200
201So the choices are:
202
2031. **Hand-rolled compact JWS** over stdlib `crypto/ed25519`: base64url
204 header, base64url claims, `EdDSA` alg, one signature. The minter is
205 genuinely small. The verifier is not — see the checklist below, which
206 is what "safe" actually costs.
2072. **A third-party JWT library**, and a new dependency in the home and in
208 every consumer.
209
210The tradeoff is not "how hard is it to write" — option 1's minter is
211easy. It is that **every consumer app has to VERIFY**, and a verifier is
212where the dangerous mistakes live: accepting `alg: none`, not checking
213`aud`, not checking `exp`, accepting an unknown `kid`. Writing that once
214in a shared package the suite imports is option 1 done properly; writing
215it three times in three apps is option 1 done the way it usually goes.
216
217**Recommendation, for Paul to accept or reject:** option 1, with the
218verifier shipped as a package the suite imports — so there is one
219verifier, not one per app — and with `alg` allowlisted to `EdDSA` by
220whitelist rather than by rejecting `none`. This is a §1 row.
221
222**Second opinion, 2026-09-07, `gpt-6-astra` via codex** (the account's
223default; named models are refused, and the rollout was checked rather
224than assumed). It agreed with option 1 under these constraints, and
225corrected the framing above: an earlier draft of this section put the
226verifier at "60 lines", and its response was that parsing, replay
227protection and key handling are what decide safety, so a line-count
228target is the wrong goal. It also named a third option this plan had not
229considered, and the strongest argument against the recommendation. Both
230are recorded below rather than filtered out.
231
232**The verifier must do all of this, in this order.** This is the list
233that makes option 1 defensible; without it, option 1 is the bad version
234of itself.
235
2361. **Bound and parse.** Cap the token size. Require exactly three
237 non-empty segments and strict unpadded base64url. Require JSON
238 objects with no duplicate keys, no trailing data, no case-insensitive
239 field aliases.
2402. **Constrain the header.** Require exactly `alg: "EdDSA"`, a bounded
241 non-empty `kid`, and a dedicated `typ` for this assertion. Reject
242 `crit`, `b64`, and any token-supplied key or key URL.
2433. **Resolve a trusted key.** Only from the configured home's HTTPS key
244 endpoint, never from the token. Require an Ed25519 public key of the
245 right length. Refetch once on an unknown `kid`, then reject; coalesce
246 and rate-limit those refetches so an unknown `kid` is not a way to
247 make every verifier hammer the home.
2484. **Verify over the ORIGINAL bytes.** `ed25519.Verify` against the
249 encoded `header.payload` exactly as received. Never decode and
250 re-serialise before verifying — that is how a signature ends up
251 covering something other than what was read.
2525. **Validate the claims.** Correct types for `sub`, `iss`, `aud`,
253 `iat`, `exp`, `jti`. Exact match on issuer and on this instance's own
254 app+host audience. Integer timestamps, `0 < exp-iat <= 120`, `exp` in
255 the future, `iat` not beyond a small explicit skew. An unknown enum
256 value becomes absent; a malformed integer hint is ignored and never
257 grants anything.
2586. **Consume before minting.** Atomically insert `(iss, jti)` and reject
259 on conflict. Retain through `exp` **plus the accepted skew**, not
260 just `exp`. Survive restarts. A storage failure is a rejection, never
261 a pass.
262
263**And the thing it says is most commonly forgotten: login CSRF.** A
264valid, single-use assertion does not by itself stop an attacker fixing
265their own assertion onto a victim's browser. The receiving endpoint must
266bind the exchange to the browser's own login transaction. §5.6 already
267requires exactly that ("a browser-bound state nonce"), so this is
268independent confirmation of the spec rather than a new requirement —
269and it is the third time this same attack has come up in this project,
270after the magic link and the keymail callback.
271
272**The third option, recorded because it was not considered:** a shared
273policy package backed by a JWT library. Every app imports one package
274that exposes only this protocol, so configuration stays in one place;
275the dependency is still in every binary, but it is configured once
276rather than four times. Strictly better than option 2 as stated.
277
278**The strongest argument against the recommendation, in its own words:**
279maintenance concentration. One person plus agents owns every parsing
280edge case and every future protocol change, and a single shared verifier
281distributes one mistake across the whole suite. A maintained library
282brings outside scrutiny. Its condition was explicit — **if independent
283security review and adversarial testing will not happen, take the
284library-backed shared package instead.** On this project review does
285happen and has repeatedly caught exactly this class of defect, which is
286the argument for option 1 rather than against it; but the condition is
287the thing to check, not the conclusion.
288
289### Ruling B: what a "consumer" is, concretely
290
291§5.6 requires return hosts validated against a consumers allowlist,
292"otherwise the credential origin is an open redirector". §10 lists a
293`consumers` table. Nothing yet says how a row gets into it.
294
295Two shapes, and the choice decides how provisioning works in step 5:
296the allowlist is **derived** from the team/app/host records the home
297already holds (a host is a valid return target if it is an instance the
298home provisioned), or it is **its own record** written at provisioning.
299
300The derived shape has no second thing to keep in sync and no way to
301forget a row. The explicit shape can express "this host may receive
302assertions" separately from "this host exists", which matters the day an
303instance is decommissioned but its hostname still resolves. **§8.3 and
304the decommission path in step 8 probably decide this**, so it should be
305read before ruling.
306
307### Ruling C: does the directory exchange an assertion against itself?
308
309§4: "The directory obtains its session by exchanging an assertion, the
310same mechanism instances use (§5.3), so there is one assertion path
311rather than two."
312
313If both origins are one process, the directory half of that process is a
314consumer of the credential half. That is implementable and probably
315right — it means the directory's session is a directory-origin cookie
316minted from a verified assertion, and an XSS there reaches a session that
317opens the directory and nothing else. But it means the home holds its own
318`aud`, verifies its own signature, and stores `jti` for itself, which
319reads oddly and should be deliberate rather than incidental. §10 already
320says `assertion_jti` is "the directory origin's own store", which is
321evidence the answer is yes.
322
323---
324
325## Tasks
326
327Task boundaries follow step 2's discipline: each one lands green, with
328its gate, and nothing routes a handler before the store beneath it
329exists.
330
331### Task 1: The origin split, in the app
332
333**Files:** `internal/home/origins.go`, `internal/home/origins_test.go`,
334modify `internal/home/app.go`.
335
336One binary, two hostnames, and a router that knows which is which. The
337credential routes (`/signin*`, `/passkey/*`) answer only on
338`id.oficina.works`; the directory routes answer only on the apex.
339
340- [ ] Add `directoryOrigin` beside `Origin`, both constants, both derived
341 from one `suiteDomain` so they cannot drift.
342- [ ] A `Host`-routing middleware. A request for a hostname the app does
343 not serve gets one answer, not a stack trace.
344- [ ] **The guard, which is the point of the task:** a test that walks
345 the router and asserts every credential route refuses on the
346 directory host and every directory route refuses on the credential
347 host. Written now, while there is exactly one directory route to
348 forget.
349- [ ] Extend `TestRPIDIsExactlyTheCredentialHost` to assert the two
350 origins are different hostnames, and that neither is a suffix of
351 the other in a way that would let a cookie or an RP ID span both.
352
353**Do not** move sign-in behind a host check without checking the test
354helpers: step 2's `signIn`, `redeem` and `postJSON` set `Origin` but
355build requests with `httptest.NewRequest`, whose `Host` is
356`example.com`. Every one of them will need the credential host, and they
357will fail in a way that looks like a routing bug rather than a test-setup
358bug.
359
360### Task 2: The signing key, the key set, and `kid`
361
362**Files:** `internal/home/assertion.go`, `internal/home/assertion_test.go`,
363modify `internal/home/keys.go`.
364
365- [ ] `HOME_ASSERTION_KEY` joins `LoadSecrets`, base64, exactly 32 bytes
366 (an Ed25519 seed), refused when all-zero like the rest.
367- [ ] `kid` is derived from the public key, not configured — a
368 configured `kid` is a second thing to get wrong, and §5.3 only
369 needs it to name a key, not to be chosen.
370- [ ] `GET /.well-known/...` on the credential origin publishes the
371 verification key. **The exact path and document shape are Ruling
372 A's to settle**; do not invent one.
373- [ ] §5.3 says two keys are active during rotation. Build the key set as
374 a slice from the start, even with one member: a publisher that can
375 only express one key is a rotation that requires a deploy.
376
377### Task 3: Minting
378
379**Files:** modify `internal/home/assertion.go`.
380
381Every claim in §5.3's table, and nothing else. `sub` is the person ref.
382`exp` is **120 seconds** — sized for a cold instance wake, and the
383paragraph in §5.3 explains why tightening it makes the first sign-in to
384a cold instance fail and loop. Single-use `jti` is what stops replay, not
385brevity; do not "harden" this by shortening it.
386
387- [ ] Mint, with a test per claim.
388- [ ] A test that the claim set is CLOSED: an assertion carries exactly
389 the claims §5.4 admits and no others. This is the test that stops
390 somebody adding a display name "just for the header".
391- [ ] `prefs` carries theme only, validated against a closed enumerated
392 set, a failed match treated as absent.
393
394### Task 4: The redirect protocol (§5.6)
395
396**Files:** `internal/home/exchange.go`, `internal/home/exchange_test.go`.
397
398All three of §5.6's requirements are in gate 3, so they land together:
399
400- [ ] Return hosts validated against the consumers allowlist (Ruling B).
401- [ ] A browser-bound state nonce. **This is the same attack step 2's
402 POST-redeems design closed one hop earlier** — without it an
403 attacker fixes their own signed assertion onto a victim's browser
404 and the victim works inside the attacker's account. Step 2's
405 `__Host-` pending cookie is the shape to copy.
406- [ ] The return URL preserves its query string, validated host and all.
407 Ruling 22 puts the team in `?team=<sqid>`, so dropping it returns a
408 person to a different team than the one they were sent.
409- [ ] Delivery is an **auto-POST**, not a query string, so assertions
410 stay out of history and referrers. It is a form that submits
411 itself — which needs script, and §11's carve-out does not cover
412 it. **Check ruling 21's wording before building this**: a
413 scriptless auto-POST is a form with a submit button and a sentence
414 telling the person to press it, which is worse UX and needs no
415 exemption. That choice is not settled here.
416
417### Task 5: `jti`, single use
418
419**Files:** `internal/home/migrations/0003_assertion_jti.sql`, modify
420`internal/home/exchange.go`.
421
422Self-pruning, sized by assertion lifetime rather than user count. §10's
423`assertion_jti` is the directory origin's own store, not a shared one.
424
425- [ ] The consume is one statement, the way `TakeLink` is — an
426 `INSERT ... ON CONFLICT DO NOTHING RETURNING` or a `DELETE ...
427 RETURNING`, so two concurrent exchanges of one assertion cannot
428 both succeed. Step 2's `TestConcurrentTakeLinkYieldsOneWinner` is
429 the test to copy, and the mutation that proves it (a
430 SELECT-then-INSERT) is the one to run.
431- [ ] Pruning on write plus at boot, the shape step 2 settled on.
432
433### Task 6: Gate 3
434
435Gate 3 is §5.6's three requirements asserted together. Following step 2's
436hard-won rule: **write the break that proves each one bites**, and record
437the output in the commit. A gate never seen to fail is a gate written to
438pass — that finding came back three times in step 2 and was right every
439time.
440
441---
442
443## What this plan does NOT cover
444
445The directory's own pages, calls 2 and 3, and anything that renders a
446team name: those are step 4. This step builds the mechanism that lets an
447instance trust a person, and a stub directory is explicitly enough for
448Sheets and Docs to start.
449
450## Before executing this
451
4521. Get Rulings A, B and C from Paul.
4532. Have this plan reviewed adversarially, at least twice. Every claim it
454 makes about rastrillo was checked at the v0.22.0 pin on 2026-09-07;
455 every claim it makes about the platform was not.
4563. Confirm the two prerequisites above are real and unblocked.
457