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 2: sign-in, second factor, recovery codes
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A person can sign in to the Oficina home with a magic link, optionally add a passkey as a second factor, and recover with a one-time code — with no readable email address anywhere at rest.
6
7**Architecture:** Hand-rolled sign-in on the credential origin. `keymaildev/signin` supplies classification and the Keymail upgrade; `rastrillo/auth` is not used at all (see below), never for its link or session stores, because those hold readable addresses (ruling 14). The session Subject is an opaque person ref minted at first sign-in; link state is sealed before it is written. This is the first step that opens a database, so it is also the step that makes gates 1 and 2 buildable — and therefore mandatory.
8
9**Tech Stack:** Go 1.25.0, rastrillo v0.22.0, chi v5, modernc.org/sqlite (cgo-free), zero JavaScript.
10
11**Spec:** `docs/superpowers/specs/2026-08-30-home-design.md` — §5.1, §5.2, §10, §12, and §13 step 2. Read it alongside this plan; every task argues from it.
12
13## Global Constraints
14
15Every task's requirements implicitly include this section. Values are copied verbatim from the spec or AGENTS.md.
16
17- **No address column exists.** `people` holds person ref, blind index, created_at — "**No address column.**" (§10)
18- **The session Subject is an opaque person ref, never an address.** Sessions, credentials and recovery codes all key on it. (§5.1)
19- **Link state is sealed before it is written** and short-lived — "never cleaned up afterwards", because deleting a row does not erase bytes from a continuously replicated file. (§5.1, §3.2)
20- **`PRAGMA secure_delete` is on.** (§5.1)
21- **Migrations are additive-only.** (§10)
22- **The credential origin never renders an account-authored string, ever.** It is the RP. "a permanent constraint with its own test, not a naming choice." (§4)
23- **The RP ID is the full host** — `id.oficina.works` — "**Never widen the RP ID to a registrable suffix**", asserted by a test. (§4, ruling 16)
24- **Zero JS.** "Every screen works with scripts off. **No surface here has any reason to require script**, and the day one does is the day this section is wrong." (§11)
25- **Write the attribute spelling — `rst-btn`, not `class="rst-btn"`**
26 (ruling 20, 2026-09-05). §11 ruled the class vocabulary and both its
27 reasons died: the attribute grammar shipped in v0.21.0, and at v0.22.0 —
28 the current pin — rastrillo's own partials flipped, class-spelling
29 occurrences in `ui/partials/` falling from 124 to 6. rastrillo's design
30 system rules "One spelling, not two. No permanent class fallback" and
31 drops the class selectors before 1.0. This app has no markup yet, so it
32 starts on the surviving spelling. `day` theme, rastrillo `server` shape.
33- **Recovery is recovery codes**: "ten, shown once, redeemed as a plain form POST with no JS. Sign-in only — step-up still demands a real assertion." (§5.2, ruling 6)
34- **Passkey is optional per person.** Per-team enforcement is **not in slice one** (ruling 15) and no operator reset exists in slice one (§5.2) — do not build either.
35- **No address, team name, or queue payload in any log line, error string, job status message or metric label.** Enforced by gate 9, already in the tree. (AGENTS.md; §3.4)
36- **Never put the value in the error.** §3.4's *"failed to hash
37 alice@example.com"* is its **counter-example** — the sentence naming what
38 must not reach a log — not a prescribed error string. An earlier draft of
39 this plan read it as the latter and would have shipped exactly the leak
40 the rule forbids. Errors carry person refs and codes. (§3.4)
41- **One binary, `CGO_ENABLED=0`, cgo-free SQLite.** (AGENTS.md)
42- **Every branch gets a discussion, and that discussion is the review record.** §Review wants a pass by a model that did not write the code. (AGENTS.md)
43
44### The gate escalation this step triggers
45
46§13 step 1 is "Skeleton, `AGENTS.md`, gates 1, 2 and 9". Gate 9 landed in `feat/gate-9-no-content-in-logs`. Gates 1 and 2 could not be built because the app had no database. **This step opens the first database, so this step owes gates 1 and 2** — they are not optional extras at the end:
47
48- **Gate 1, plaintext grep** — "raw SQLite file and WAL plus this app's store objects, against a seeded corpus of known addresses." (§12)
49- **Gate 2, no-address-at-rest** — "the schema has no address column, and a full sign-in plus directory upsert plus rekey leaves no readable address anywhere, WAL included." (§12)
50
51Sheets already has the helper this needs: `internal/sheets/sealtest` in `oficina/sheets-core`. Port it the way `internal/guardstest` was ported, rather than reinventing it — **and port `sealtest.go` itself along with its own `TestLeakedDetectsCanary` and WAL tests.** Task 1's steps commit `internal/home/sealtest` without ever saying to create it.
52
53**Change its canaries when porting.** Sheets' are shaped like
54`CANARY-CELL-VALUE-8f3a` and matched with `bytes.Contains` — case-sensitive
55and not address-shaped. Both matter here: the blind index (ruling 3) and
56`signin`'s rate limiter both key on `strings.ToLower(address)`, so the
57lowercased copy is the one likeliest to reach a store or an error string,
58and an upper-case canary cannot see it. Use lowercase, address-shaped
59canaries — `canary-8f3a@example.invalid` — or scan case-insensitively.
60Gate 9's own `logtest` package already learned this and its `encodings`
61helper folds case for the same reason.
62
63### Three things to settle before Task 5 — not the executor's to decide
64
651. **`home` has no secrets.** `carlos secrets list --account bds --app home` returns empty; sheets has `SHEETS_DEK`, `SHEETS_INSTANCE_KEY`, `CARLOS_SMTP_PASS`. Task 1 cannot run until `HOME_DEK` exists; Task 4 cannot send a link without SMTP; and **Task 4 also needs `HOME_PEPPER`** for the blind index (ruling 3). All three are one-way doors — lose the DEK and every sealed link is unreadable; lose the pepper and every person row is orphaned, because the index is the only way back from an address to a person. **Paul provisions these, not the executor.**
662. ~~Passkey needs JavaScript, and §11 forbids it.~~ **RULED 2026-09-05 by
67 Paul: §11 gains one named carve-out.** The passkey enrolment and
68 ceremony screens may load rastrillo's own `webauthn.mjs`. Everything
69 else stays scriptless — magic-link sign-in, recovery-code redemption and
70 the whole springboard — so a person with script off keeps a working path
71 and only loses the optional second factor. Task 7's no-`<script>` sweep
72 gets an **explicit allowlist of exactly those screens**, never a blanket
73 exemption. The original problem, kept for the reasoning: §11: "Every screen works with scripts off. **No surface here has any reason to require script**, and the day one does is the day this section is wrong." A WebAuthn ceremony is `navigator.credentials.create/get`, which is script by construction. **The day §11 anticipated has arrived, and the ruling is whether §11 bends or passkeys leave slice one.**
74
75 Correcting this plan's own earlier claim, which was false at source: **rastrillo does ship the client JS.** `webauthn/js.go` embeds `js/webauthn.mjs` and exports `webauthn.JS()`, and the `passkey` package's doc says apps serve it as a static asset. So the question is not who writes the script — nobody has to — it is only whether one screen may require it. That makes the carve-out cheaper than the earlier draft implied, and the ruling no less necessary. Everything else here is scriptless and unaffected.
763. ~~No `main` on this remote.~~ **Closed 2026-09-05:** `main` exists at
77 `462df84` and the cadence applies as written — branch from it,
78 squash-merge to it.
79
804. ~~Merge order: gate 9 must land first.~~ **Closed 2026-09-05:** gate 9
81 merged to `main` at `9162ee5`, so `internal/guardstest/repo_test.go` and
82 `internal/home/logtest` are both there. Rulings 17-21 are on `main` too,
83 at `5a42f2d`.
84
85### A spec-vs-reality note the executor must not "fix" silently
86
87§5.1 says "We still use `rastrillo/auth` for **address classification and Keymail upgrade** — the part that is hard to get right and holds nothing." **`auth` cannot be used that way.** Its classification is reachable only through `auth.Begin`, which writes `auth_links.address TEXT NOT NULL` — verified at source, and the very thing ruling 14 exists to avoid.
88
89**Land the correction, do not merely file it.** AGENTS.md's own rule is
90that where it disagrees with the spec the spec wins "and this file is wrong
91— fix it in the same commit". Both §5.1 and AGENTS.md's Data section say
92classification goes through `rastrillo/auth`; both are wrong for the reason
93below, and both are on `main`. Fix them in **this branch**, so the plan and
94the binding files stop disagreeing the moment it lands.
95
96The usable seam is one level down: `github.com/keymaildev/signin` exports `Classifier` as a plain struct with exported fields (`HTTP`, `LookupTXT`, `Scheme`, `Now`, `TTL`, `NegTTL`) and a `Classify(ctx, address) Class` method. No constructor, no database, no stores. **Depend on `signin` directly** (rastrillo already requires v0.1.1). This plan does that, and **the correction has landed**: spec §5.1 and AGENTS.md both say `keymaildev/signin` as of this branch. Not filed — fixed.
97
98---
99
100## Helpers and wiring this plan assumes
101
102Named here because an earlier draft used all of them without ever saying to
103build them, which would have left the executor re-deriving decisions
104mid-task.
105
106**Test helpers** — build these in Task 1 and 2, before anything calls them:
107
108| Helper | What it does |
109|---|---|
110| `newTestApp(t, dbPath)` | Opens a temp DB, applies migrations, wires a capturing `mail.Sender`, a `Sealer` from a test DEK, a test pepper, and a `signin.Flow` with a stub `LookupTXT`. Returns `*App`. |
111| `capturedLink(t, app)` | Pulls the magic link out of the captured mail. |
112| `signIn(t, app, addr)` | The whole ceremony: POST `/signin`, take the link, GET the confirm page, POST the form. Sends an `Origin` header, or `csrf.Protect` refuses. Task 5's tests all go through it. |
113| `passkeyConfig(t)` | Returns the `passkey.Config` the app builds, for Task 7's guard. |
114| `templateNames(t)`, `renderTo(t, w, name, data)`, `sampleData(name)`, `scriptSrcs(t, b)` | Task 7's scriptless sweep. `scriptSrcs` parses the HTML and returns each `<script>`'s `src`, with the empty string for an inline one. |
115
116
117`loadSecrets` is **not** in this table: it is the production loader
118`cmd/home/main.go` calls (Task 1 step 5b), not a test helper.
119
120**Build these in Task 1 (`newTestApp`) and Task 5 (`signIn`,
121`capturedLink`), each with its own step and its own test.** An earlier draft
122listed them in this table and then had no step create any of them.
123
124**`newTestApp` must wire a non-nil `Flow.Keymail`.** `Begin` skips
125classification entirely when it is nil, so
126`TestFailedClassificationStillSendsAMagicLink` would pass vacuously —
127green, and proving nothing.
128
129**`RegisterConnectionHook` is process-global**, so guard it with a
130`sync.Once`; `newTestApp` is called by every test.
131
132**`signIn` must send an `Origin` header**, or `csrf.Protect` refuses its
133POSTs and every test fails for the wrong reason.
134
135**There is a fourth secret: `Flow.Key`.** It seals the Keymail pending blob,
136`sealPending` refuses a zero key, and §5.1's "must come from config, never
137an instance data key" applies — sign-in runs before any session exists. So
138the list Paul provisions is `HOME_DEK`, `HOME_PEPPER`, `HOME_SIGNIN_KEY`
139and `CARLOS_SMTP_PASS`.
140
141**`sessions` wiring**, which no step created:
142
143```go
144sessions.New(sessions.Config{
145 DB: d.Writer(),
146 Origin: "https://id.oficina.works",
147 TTL: 30 * 24 * time.Hour, // the package default, named rather than defaulted
148 SigninPath: "/signin",
149})
150```
151
152**Copy `Method` and `AuthTime` from the `signin.Identity`**, as
153`auth.admit` does — do not invent either. Correcting an earlier draft of
154this section, which was false at source: it said "a zero `AuthTime` is never
155fresh". `sessions.Fresh` **falls back to `sess.At`** when `AuthTime` is
156zero, and its doc says why — "plugins that never set AuthTime (the magic
157link) still converge instead of redirect-looping". `CompleteMagicLink` sets
158only `At` by construction, so a magic-link session legitimately has a zero
159`AuthTime` and `RequireFresh` still works.
160
161**The mailed link's base URL** is `https://id.oficina.works` +
162`Flow.LinkPath`. It comes from config, never from the request Host: a
163Host-derived link is an open redirect with an extra step.
164
165**Refuse to boot rather than degrade.** Two silent failures, both of which
166sheets-core already guards:
167
168- **No mailer.** `mail.Logged`'s `Send` logs `"to", to` — an address into
169 platform-held logs on every single sign-in, which is precisely §3.4's
170 leak, arriving through the fallback nobody chose. Sheets' `LoadAuthEnv`
171 has the shape: "no mailer means magic links get written to platform-held
172 logs … hard exit". Do the same.
173- **Empty `DBPath`.** `Resolve` sets it only from `-db`, and SQLite treats
174 an empty filename as a **private temporary database** — so the app comes
175 up, works, and loses every person on restart.
176
177**TDD is not optional and not compressible.** An earlier draft collapsed a
178task into "Run, fail, implement, run" with no code. Every task writes the
179test, runs it, watches it fail *for the stated reason*, implements, and
180runs it again. Task 4's step 6 — deliberately break the seal and watch the
181gate catch it — is the pattern for every gate here, and it exists because
182gate 9's review found the same thing twice: a guard never seen to fail is a
183guard written to pass.
184
185---
186
187## File Structure
188
189```
190cmd/home/main.go Resolve -> db.Open -> migrate.Apply -> Serve.
191internal/home/
192 app.go App struct and wiring. Gains db, sealer, sessions, classifier.
193 keys.go LoadSealer: HOME_DEK from env, base64, exactly 32 bytes, refuse to boot otherwise.
194 seal/seal.go DEK, HKDF purpose keys, AES-GCM. Ported from sheets-core.
195 sealtest/sealtest.go Gate 1: AssertNoPlaintext over raw file, WAL and SHM. Ported.
196 models.go Schema. people, signin_links. No address column, anywhere.
197 migrations/0001_people.sql
198 migrations/0002_signin_links.sql
199 personref.go Mint and validate the opaque person ref.
200 signin.go Begin/Verify: sealed link state, one-time redemption, session mint.
201 links.go signin.LinkStore. Seals on put, one
202 DELETE ... RETURNING on take.
203 render.go One parsed tree per page, ui.Funcs plus an asset func.
204 templates/ Scriptless screens, attribute spelling
205 (ruling 20; the seven utilities keep class).
206 logtest/ Gate 9. Already on main (9162ee5).
207 rpid_test.go Rulings 13/16: the RP ID is the full host.
208 Package home, not guardstest: it reads
209 the app's own passkey.Config.
210 noscript_test.go §11 + ruling 21's allowlist.
211internal/guardstest/
212 logsinks_test.go Already landed. Forbids non-slog sinks.
213 noaddress_test.go Gate 2's schema half.
214```
215
216---
217
218### Task 1: The seal package, the DEK, and gate 1
219
220The gate goes in with the mechanism, as it did in Sheets. Once this lands, every later task is checked by it.
221
222**Files:**
223- Create: `internal/home/seal/seal.go`, `internal/home/seal/seal_test.go`
224- Create: `internal/home/sealtest/sealtest.go`
225- Create: `internal/home/keys.go`, `internal/home/keys_test.go`
226
227**Interfaces:**
228- Consumes: nothing.
229- Produces:
230 - `seal.New(dek []byte) (*Sealer, error)` — DEK must be exactly 32 bytes.
231 - `(*Sealer).Seal(purpose string, plaintext []byte) ([]byte, error)`
232 - `(*Sealer).Open(purpose string, ciphertext []byte) ([]byte, error)`
233 - `home.LoadSealer() (*seal.Sealer, error)` — reads `HOME_DEK`.
234 - `sealtest.Canaries [][]byte`, `sealtest.AssertNoPlaintext(t *testing.T, dbPath string)`
235 - Purposes: `"signin.address"`, `"team.name"`, `"queue.payload"`.
236
237Port `seal.go` from `oficina/sheets-core/internal/sheets/seal/seal.go` **unchanged except the HKDF info string**, which becomes `"oficina/home:"+purpose`. Do not redesign it: two apps disagreeing about their envelope format is a bug waiting to differ. Likewise `sealtest.go`, keeping its `leaked`/`findLeak` split — a failing subtest always fails its parent, so the scan must be unit-testable without a live `*testing.T`.
238
239- [ ] **Step 1: Write the failing round-trip test**
240
241```go
242package seal
243
244import (
245 "bytes"
246 "testing"
247)
248
249func testDEK() []byte {
250 d := make([]byte, 32)
251 for i := range d {
252 d[i] = byte(i)
253 }
254 return d
255}
256
257func TestSealOpenRoundTrip(t *testing.T) {
258 s, err := New(testDEK())
259 if err != nil {
260 t.Fatalf("New: %v", err)
261 }
262 ct, err := s.Seal("signin.address", []byte("person@example.invalid"))
263 if err != nil {
264 t.Fatalf("Seal: %v", err)
265 }
266 if bytes.Contains(ct, []byte("example.invalid")) {
267 t.Fatal("ciphertext contains the plaintext")
268 }
269 pt, err := s.Open("signin.address", ct)
270 if err != nil {
271 t.Fatalf("Open: %v", err)
272 }
273 if string(pt) != "person@example.invalid" {
274 t.Fatalf("Open = %q", pt)
275 }
276}
277
278// A value sealed under one purpose must not open under another. These are
279// adjacent columns of the same row; a bug that swapped them would be
280// invisible without this.
281func TestWrongPurposeFails(t *testing.T) {
282 s, _ := New(testDEK())
283 ct, _ := s.Seal("signin.address", []byte("secret"))
284 if _, err := s.Open("team.name", ct); err == nil {
285 t.Fatal("opened under the wrong purpose")
286 }
287}
288
289func TestRejectsShortDEK(t *testing.T) {
290 if _, err := New(make([]byte, 31)); err == nil {
291 t.Fatal("accepted a 31-byte DEK")
292 }
293}
294```
295
296- [ ] **Step 2: Run it and watch it fail**
297
298Run: `go test ./internal/home/seal/`
299Expected: FAIL, build error — `undefined: New`.
300
301- [ ] **Step 3: Port seal.go**
302
303Copy `oficina/sheets-core/internal/sheets/seal/seal.go` to `internal/home/seal/seal.go`. Change only the package doc and the HKDF info string:
304
305```go
306r := hkdf.New(sha256.New, s.dek, nil, []byte("oficina/home:"+purpose))
307```
308
309Add `golang.org/x/crypto` with `go get golang.org/x/crypto`.
310
311- [ ] **Step 4: Run the tests**
312
313Run: `go test ./internal/home/seal/`
314Expected: PASS.
315
316- [ ] **Step 5: Write LoadSealer and its test**
317
318```go
319// LoadSealer reads HOME_DEK and returns the process Sealer.
320//
321// It refuses to boot rather than generating a key. A generated key would
322// let the app start with every previously sealed link unreadable, which
323// looks like data loss and is discovered late.
324func LoadSealer() (*seal.Sealer, error) {
325 raw := os.Getenv("HOME_DEK")
326 if raw == "" {
327 return nil, errors.New("HOME_DEK is not set")
328 }
329 dek, err := base64.StdEncoding.DecodeString(raw)
330 if err != nil {
331 return nil, fmt.Errorf("HOME_DEK is not valid base64: %w", err)
332 }
333 return seal.New(dek)
334}
335```
336
337Test that unset, malformed and 31-byte values each error, and a valid one returns a Sealer. Use `t.Setenv`.
338
339- [ ] **Step 5a: Port `sealtest` itself**
340
341The plan commits `internal/home/sealtest` and no step ever created it.
342Port `sealtest.go` from sheets-core **with its own `TestLeakedDetectsCanary`
343and WAL tests**, and **extend the containment guard (now
344`TestTestOnlyPackagesNotImportedByProductionCode`) to cover
345`sealtest` too** — on `main` it checks a single `const logtestImport`, so this
346turns that constant into a slice of two. Like `logtest`, it imports
347`testing` from a non-test file, so importing it from production code would
348drag the testing package into the binary. — a detector nobody has watched detect anything is the
349thing gate 9's review found twice.
350
351Export **`CanaryAddress`** — a lowercase, address-shaped
352`canary-8f3a@example.invalid` — and **include it in `Canaries`**, or gate 2
353signs in with a value `AssertNoPlaintext` never scans for and passes while
354proving nothing. (Task 5 step 6's deliberate break would expose it, which is
355what that step is for; say it anyway.) Sheets' `Canaries` is
356`[][]byte` and shaped like `CANARY-CELL-VALUE-8f3a`; both matter here.
357Case, because the blind index and `signin`'s limiter key on
358`strings.ToLower(address)`, so the lowercased copy is the one likeliest to
359reach a store. Shape, because gate 2 signs *in* with the canary, so it has
360to parse as an address — `SplitAddress` rejects a value with two `@`.
361
362- [ ] **Step 5b: `loadSecrets`, and the boot refusals**
363
364**All four secrets, in one place, refusing rather than degrading.** Three
365of them had no loader at all in an earlier draft, and the app would have
366booted without them.
367
368```go
369// loadSecrets reads the four, and refuses to boot on any of them.
370// HOME_DEK seals link state and queue payloads (Task 1)
371// HOME_PEPPER the blind index (ruling 3, Task 5)
372// HOME_SIGNIN_KEY seals signin.Flow's Keymail pending blob
373// CARLOS_SMTP_PASS the mailer
374```
375
376Two refusals that are not about secrets, and both are silent failures
377sheets already guards:
378
379- **No mailer.** `mail.Logged`'s `Send` logs `"to", to` — an address into
380 platform-held logs on **every** sign-in, §3.4's leak arriving through a
381 fallback nobody chose. Sheets' `LoadAuthEnv` hard-exits; do the same.
382- **Empty `DBPath`.** `Resolve` sets it only from `-db`, and SQLite treats
383 an empty filename as a **private temporary database** — the app comes up,
384 works, and loses every person on restart.
385
386Test each refusal with `t.Setenv`.
387
388- [ ] **Step 5c: Wire `main.go`, and build `newTestApp`**
389
390`cmd/home/main.go` must call `loadSecrets` and hand the Sealer to the app —
391sheets' `main.go` calls `LoadSealer()` and an earlier draft of this plan
392edited `main.go` only to wire the database, so three of four secrets were
393never loaded on the boot path.
394
395`newTestApp(t, dbPath)` is the helper every later task's tests use: temp DB,
396migrations applied, capturing `mail.Sender`, Sealer from a test DEK, test
397pepper, and a `signin.Flow` with a stub `LookupTXT` **and a non-nil
398`Keymail`** — `Begin` skips classification entirely when `Keymail` is nil,
399so the fail-open test would pass vacuously without it. Register the
400`secure_delete` connection hook here too, under a `sync.Once`, and **before**
401`db.Open` (Task 2 step 5).
402
403**It starts small and grows.** `BootSchema` is Task 2's, the `signin`
404dependency is Task 4's and `App.flow` is Task 5's, so build it here with the
405database and the Sealer only, and let Tasks 2, 4 and 5 extend it as each
406lands. An earlier draft described the finished helper in the first task that
407needs one, which does not compile.
408
409**The stub `LookupTXT` must return a real error, not `nil, nil`.** An empty
410TXT result makes `signin` fall through to `delegate`, which returns the
411domain itself, and `domainInfo` then makes a live HTTPS GET to
412`https://<domain>/.well-known/keymail`. Every `signIn` in the suite would
413touch the network.
414
415- [ ] **Step 6: Commit**
416
417```bash
418git add internal/home/seal internal/home/sealtest internal/home/keys.go internal/home/keys_test.go internal/home/testapp_test.go internal/home/app.go cmd/home/main.go go.mod go.sum
419git commit -m "Seal values at rest, load the four secrets, and the plaintext gate that proves it"
420```
421
422---
423
424### Task 2: The database, the schema, and gate 2's schema half
425
426**Files:**
427- Modify: `cmd/home/main.go`
428- Create: `internal/home/models.go`, `internal/home/migrations/0001_people.sql`
429- Create: `internal/guardstest/noaddress_test.go`
430- Modify: `internal/guardstest/repo_test.go` (add `migrationFiles`, `readFile`)
431
432**Interfaces:**
433- Produces: `home.Schema *migrate.Set`, `home.BootSchema *migrate.Set`, and an `App` holding `*db.DB`.
434
435Follow sheets-core's boot shape exactly — `Resolve`, open the DB yourself, `migrate.Apply`, then blank `DBPath` before `Serve`, because `Serve` would otherwise open a second handle:
436
437```go
438opts, err := rastrillo.Resolve(rastrillo.Options{Logger: logger})
439d, err := rdb.Open(opts.DBPath, logger)
440_, err = migrate.Apply(context.Background(), d, home.BootSchema)
441opts.Mux = mux
442opts.DBPath = ""
443rastrillo.Serve(opts)
444```
445
446```go
447//go:embed migrations/*.sql
448var migrationsFS embed.FS
449
450var Schema = migrate.MustFromFS(migrationsFS, "home")
451var BootSchema = migrate.Merge(sessions.Schema, Schema)
452```
453
454`auth.Schema` is **deliberately absent**: this app does not use `rastrillo/auth`, and its address-bearing tables must never be created. Migration files are `migrations/NNNN_lowercase_name.sql`, forward-only, ledgered by ID and checksum.
455
456- [ ] **Step 1: Write gate 2's schema half**
457
458```go
459package guards
460
461import (
462 "regexp"
463 "strings"
464 "testing"
465)
466
467// addressish matches a column name that would hold an email address.
468// §10: "people — person ref, blind index, created_at. No address column."
469var addressish = regexp.MustCompile(`(?i)\b(address|email|e_mail|mail_to|recipient)\b`)
470
471// TestNoAddressColumn reads every migration and fails on a column whose
472// name says it holds an address. This is the cheap half of gate 2; the
473// expensive half — a full sign-in leaves no readable address anywhere,
474// WAL included — is Task 4 and needs a running sign-in.
475func TestNoAddressColumn(t *testing.T) {
476 root := repoRoot(t)
477 for _, path := range migrationFiles(t, root) {
478 rel := relPath(t, root, path)
479 for i, line := range strings.Split(readFile(t, path), "\n") {
480 // A sealed blob is not an address, and says so in its name.
481 if strings.Contains(strings.ToLower(line), "sealed") {
482 continue
483 }
484 if addressish.MatchString(line) {
485 t.Errorf("%s:%d: %q names an address column; §10 says there is none",
486 rel, i+1, strings.TrimSpace(line))
487 }
488 }
489 }
490}
491```
492
493Add `migrationFiles` and `readFile` beside the existing helpers in `repo_test.go`, following their shape (`t.Helper()`, `t.Fatalf` on error).
494
495- [ ] **Step 2: Run it**
496
497Run: `go test ./internal/guardstest/ -run TestNoAddressColumn`
498Expected: PASS trivially — no migrations yet. That is correct; it is a tripwire, like gate 9.
499
500- [ ] **Step 3: Write the first migration**
501
502`internal/home/migrations/0001_people.sql`:
503
504```sql
505CREATE TABLE people (
506 person_ref TEXT PRIMARY KEY,
507 blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index
508 created_at TEXT NOT NULL
509);
510```
511
512No address column, no email column. The blind index is `HMAC(pepper, lowercased address)` per ruling 3 — **but §3.1 says the index "uses an expensive KDF rather than a bare HMAC", contradicting ruling 3 and §6.** Filed as task 2 on `skeleton`; it **must be settled before the pepper is implemented in step 4**. This task creates the column; nothing writes it yet.
513
514- [ ] **Step 4: Verify**
515
516Run: `go test ./...`
517Expected: PASS.
518
519- [ ] **Step 5: Turn on secure_delete**
520
521`PRAGMA secure_delete` must be on, because "deleting a row does not remove bytes from a file that is continuously replicated" (§5.1).
522
523**Resolved at source, 2026-09-05 — it was the plan's own riskiest
524assumption and it does not hold.** `rastrillo/db.Open` builds the DSN
525`path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"`
526and passes nothing else through: **no `secure_delete`, and no way to add
527one via the DSN.** It also opens **two pools** — a writer capped at 1 and
528readers of 4 or more.
529
530That matters because **`secure_delete` is per-connection.** An `Exec`
531after open sets it on whichever pooled connection served that call and on
532none of the others, and on none opened later. The earlier draft's test —
533read the pragma back and assert 1 — runs on that same connection and so
534passes while proving nothing, which is the worst kind of green.
535
536Use `sqlite.RegisterConnectionHook` (modernc.org/sqlite — present at the
537version `go.mod` actually pins) to run `PRAGMA secure_delete=ON` on
538**every** new connection.
539
540**Register the hook before `db.Open`, in `main` and in `newTestApp`.**
541`db.Open` calls `Ping`, which opens the writer pool's single long-lived
542connection there and then. A hook registered afterwards misses that
543connection **forever** — and it is the only one that performs DELETEs, so
544it is the one that matters. A test asserting on "a fresh connection" would
545pass while the writer never had the pragma set, which is the worst kind of
546green.
547
548Assert on `d.Writer()` **and** one reader. `db.DB` exposes no reader
549accessor, so reach one through `d.G.Clauses(dbresolver.Read)`. `migrate.Apply` takes a
550`*db.DB`, so bypassing `db.Open` is not an escape route.
551
552- [ ] **Step 6: Commit**
553
554```bash
555git add internal/home/models.go internal/home/migrations internal/home/app.go internal/home/testapp_test.go cmd/home/main.go internal/guardstest
556git commit -m "Open the database, and the schema that has no address column"
557```
558
559---
560
561### Task 3: The person ref
562
563**Files:**
564- Create: `internal/home/personref.go`, `internal/home/personref_test.go`
565
566**Interfaces:**
567- Produces: `home.NewPersonRef() (string, error)`, `home.ValidPersonRef(s string) bool`
568
569The person ref is the subject of every session, credential and recovery code, and later the assertion's `sub` and the directory's join key. Opaque, stable across an email change, and **never derived from the address** — a derived ref puts the address back at rest in a reversible form under another name.
570
571- [ ] **Step 1: Write the failing tests**
572
573```go
574func TestPersonRefIsOpaqueAndUnique(t *testing.T) {
575 seen := map[string]bool{}
576 for i := 0; i < 1000; i++ {
577 ref, err := NewPersonRef()
578 if err != nil {
579 t.Fatalf("NewPersonRef: %v", err)
580 }
581 if seen[ref] {
582 t.Fatalf("duplicate person ref %q after %d draws", ref, i)
583 }
584 seen[ref] = true
585 if !ValidPersonRef(ref) {
586 t.Fatalf("NewPersonRef produced %q, which ValidPersonRef rejects", ref)
587 }
588 }
589}
590
591func TestPersonRefDoesNotEmbedInput(t *testing.T) {
592 ref, _ := NewPersonRef()
593 if strings.Contains(ref, "@") {
594 t.Fatalf("person ref %q contains an @", ref)
595 }
596}
597
598func TestValidPersonRefRejectsRubbish(t *testing.T) {
599 for _, bad := range []string{"", "person@example.invalid", "../etc", strings.Repeat("a", 500)} {
600 if ValidPersonRef(bad) {
601 t.Errorf("ValidPersonRef(%q) = true", bad)
602 }
603 }
604}
605```
606
607- [ ] **Step 2: Run, watch it fail**
608
609Run: `go test ./internal/home/ -run PersonRef`
610Expected: FAIL — `undefined: NewPersonRef`.
611
612- [ ] **Step 3: Implement**
613
614```go
615// personRefBytes is 16 bytes of crypto/rand, base32 without padding.
616// Long enough that collisions are not a concern at any plausible number
617// of people; short enough to read in a log line, which it is allowed to
618// appear in — §3.4: "Errors carry person refs and codes."
619const personRefBytes = 16
620
621func NewPersonRef() (string, error) {
622 b := make([]byte, personRefBytes)
623 if _, err := rand.Read(b); err != nil {
624 return "", err
625 }
626 enc := base32.StdEncoding.WithPadding(base32.NoPadding)
627 return "pr_" + strings.ToLower(enc.EncodeToString(b)), nil
628}
629```
630
631`ValidPersonRef` checks the `pr_` prefix, the exact expected length, and the alphabet.
632
633- [ ] **Step 4: Run**
634
635Run: `go test ./internal/home/ -run PersonRef`
636Expected: PASS.
637
638- [ ] **Step 5: Commit**
639
640```bash
641git add internal/home/personref.go internal/home/personref_test.go
642git commit -m "The person ref: opaque, minted, never derived from an address"
643```
644
645---
646
647### Task 4: The schema and the sealed LinkStore
648
649**Task 4 builds the store; Task 5 builds the endpoint.** An earlier draft
650had Task 4 hand-roll `Begin` and `Verify` and Task 5 then replace them with
651`signin.Flow` — so the executor would have shipped a `POST /signin` with no
652rate limit and no CRLF guard, and then rewritten the security-critical path.
653Nothing here routes a handler.
654
655Where the blindness claim is either kept or quietly broken.
656
657**Files:**
658- Create: `internal/home/links.go`, `internal/home/links_test.go`
659- Create: `internal/home/migrations/0002_signin_links.sql`
660- Modify: `go.mod` — add `github.com/keymaildev/signin` (the `LinkStore`
661 interface is defined there, so the dependency arrives with the store,
662 not with the handlers)
663
664**Interfaces:**
665- Consumes: `seal.Sealer` (Task 1) and the database (Task 2) — and nothing else.
666- Produces: `(*App).links`, a `signin.LinkStore`. **Routes nothing, and needs no `sessions` or `mail.Sender`** — the handlers are Task 5's, at `POST /signin`, `GET /signin/verify` (renders), `POST /signin/verify` (redeems) and `GET /signin/callback`.
667
668`0002_signin_links.sql`:
669
670```sql
671CREATE TABLE signin_links (
672 token_hash TEXT PRIMARY KEY,
673 sealed_address BLOB NOT NULL,
674 expires_at TEXT NOT NULL
675);
676CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at);
677```
678
679**There is no `redeemed_at` column, and its absence is the design.** An
680earlier draft had one and redeemed with SELECT-then-UPDATE. Two things were
681wrong with that. It is a read-then-write race: two clicks that both read a
682NULL both mint a session, and rastrillo's own `linkStore.TakeLink` carries a
683comment naming this exact race and fixing it with a single statement. And a
684row that survives redemption lets the handler tell "already used" apart from
685"never existed", which is an oracle. Taking the link is therefore one
686statement:
687
688```sql
689DELETE FROM signin_links
690 WHERE token_hash = ? AND expires_at > ?
691RETURNING sealed_address
692```
693
694Exactly one concurrent caller gets a row. Unknown, expired and already-used
695are then **indistinguishable by construction** rather than by remembering to
696make them so — all three are "no row". Deleting is safe here where it would
697not be elsewhere: the column is sealed, so the bytes Litestream keeps are
698bytes nobody can read (§3.2).
699
700**Take first, mint second.** A crash between them costs one link, which the
701person retries. The reverse order costs a session minted from a link still
702live for a second click.
703
704The column holds `sealer.Seal("signin.address", []byte(addr))`. **Seal
705before the insert, never "insert then clean up"** — Litestream ships WAL
706frames continuously, so a plaintext write reaches the bucket before any
707delete could remove it (§5.1, §3.2).
708
709Use `rastrillo/sessions` directly, **never `rastrillo/auth`**: `sessions.Session.Subject` is an opaque string the app chooses, so it takes the person ref. `auth` sets `Subject: id.Address` and stores `auth_links.address TEXT NOT NULL` — both verified at source, and precisely what ruling 14 forbids.
710
711**The blind index is built in this task, not deferred.** An earlier draft
712deferred it to step 4 and said "until then, mint a person row keyed on the
713ref alone". That is not sign-in: with `blind_index TEXT NOT NULL UNIQUE` and
714no lookup key, every visit mints a *fresh* person ref, and sessions,
715credentials, recovery codes and the assertion's `sub` all attach to an
716identity nobody can ever return to. Ruling 3 is in §1's settled table and
717supplies the mechanism:
718
719```go
720// blindIndex is ruling 3: HMAC(pepper, lowercased address). The pepper
721// never leaves the home (ruling 4) — one on every instance would make a
722// single instance compromise an offline dictionary attack against the
723// whole suite directory.
724func blindIndex(pepper []byte, address string) string {
725 m := hmac.New(sha256.New, pepper)
726 m.Write([]byte(strings.ToLower(address)))
727 return hex.EncodeToString(m.Sum(nil))
728}
729```
730
731`HOME_PEPPER` joins `HOME_DEK` in the list only Paul can provision, and for
732a sharper reason: lose the DEK and sealed links become unreadable; lose the
733pepper and **every person row is orphaned**, because the index is the only
734route from an address back to a person. §3.1 prefers an expensive KDF over a
735bare HMAC; that is a mitigation note, not a ruling, and ruling 3 is the
736ruling. File the tension as a spec note rather than resolving it here.
737
738Finding-or-minting the person is also one statement, so a returning person
739and a first-time person take the same path:
740
741```sql
742INSERT INTO people (person_ref, blind_index, created_at) VALUES (?, ?, ?)
743ON CONFLICT(blind_index) DO UPDATE SET blind_index = excluded.blind_index
744RETURNING person_ref
745```
746
747`created_at` is written **coarsened to the day**. (Cite §3.3's reasoning,
748not its rule: on the spec branch §3.3's coarsening is explicitly "a rule for
749publication … never a storage format". Coarsening here is still right — it
750is strictly less information and `people.created_at` has no freshness role —
751but it is this plan's choice, not §3.3's requirement.)
752
753- [ ] **Step 1: Write the LinkStore's failing tests**
754
755Four properties, driven directly against the store rather than through a
756handler:
757
758```go
759func TestPutLinkSealsTheAddress(t *testing.T) // raw file holds no canary
760func TestTakeLinkReturnsTheAddressOnce(t *testing.T) // second take: ok == false
761func TestTakeLinkRefusesAWrongPurpose(t *testing.T)
762func TestConcurrentTakeLinkYieldsOneWinner(t *testing.T) // N goroutines, one ok
763```
764
765Run the concurrency one against **both pools** — `db.Open` gives a writer
766capped at 1 and four or more readers, and a race that only appears across
767pools is still a race.
768
769- [ ] **Step 2: Run them**
770
771Run: `go test ./internal/home/ -run TestPutLink`
772Expected: FAIL — no store.
773
774- [ ] **Step 3: Implement PutLink and TakeLink**
775
776`PutLink` seals then inserts. `TakeLink` is the single `DELETE … RETURNING`
777above.
778
779**Pin the timestamp format.** `expires_at > ?` is a **string** comparison,
780so both sides must be written the same way — `expires.UTC().Format(time.RFC3339)`,
781as `auth/store.go` does. A value carrying a zone offset sorts differently
782and silently accepts expired links.
783
784**`purpose` needs somewhere to live.** The `signin.LinkStore` interface
785passes one to both methods, and the schema above has no column for it. Add
786`purpose TEXT NOT NULL` to `0002_signin_links.sql` and put it in the DELETE
787predicate. (Hard-coding a check against `signin.PurposeSignin` also works,
788but the column costs nothing and the interface clearly expects more than
789one purpose to exist.)
790
791- [ ] **Step 4: Prove the seal bites**
792
793Temporarily store the address unsealed. `sealtest.AssertNoPlaintext` must
794FAIL naming the canary. Revert. **Do not skip this.** A gate never seen to
795fail is a gate written to pass — the finding that came back twice on gate 9.
796
797- [ ] **Step 5: Sweep expired links**
798
799Sealed rows are not a leak, but unbounded growth is a bug. Delete
800`WHERE expires_at < ?` **at boot and on each `PutLink`**, the shape
801`passkey.Sweep` suggests.
802
803**And sweep the other two while building the habit**, because nobody else
804does: `sessions.Sweep` (rows live 30 days) and `passkey.Sweep`
805(`passkey_pending`, `passkey_challenges`). Same boot-plus-on-write shape.
806None of these leak — they are keyed on person refs — but a table nothing
807ever deletes from is a bug waiting for a big enough database. (Not "the wake-driven schedule §6 uses" — that
808sentence is about *instances*, and AGENTS.md says **the home does not
809hibernate**, so there are no wakes to hang it on. A ticker would work here
810and is simply not worth a goroutine for this.)
811
812- [ ] **Step 6: Commit**
813
814```bash
815git add internal/home/links.go internal/home/links_test.go internal/home/migrations/0002_signin_links.sql internal/home/testapp_test.go go.mod go.sum
816git commit -m "A sign-in link store that seals before it writes and takes exactly once"
817```
818
819---
820
821### Task 5: `signin.Flow`, the sign-in handlers, and the Keymail upgrade
822
823**Files:**
824- Create: `internal/home/signin.go`, `internal/home/signin_test.go`, `internal/home/render.go`
825- Create: `internal/home/templates/signin.html`, `check_your_mail.html`, `signin_confirm.html`
826- Modify: `internal/home/app.go`
827
828**Interfaces:**
829- Produces: `(*App).flow` a `*signin.Flow`, and the four routes. Consumes `(*App).links` from Task 4.
830
831**`(*App).render` is built HERE, minimally, and Task 7 extends it.** These
832three templates are its first callers, and Tasks 5 and 6 cannot execute
833without it — so the earlier draft's "either land Task 7 first or build it
834here, but say which" is answered: build it here. Minimal means one parsed
835tree per page over `ui.Templates()` plus the app's layout, no asset
836pipeline. Task 7 adds the vendored assets, the scriptless sweep and the
837RP-ID guard on top.
838
839**An earlier draft used `signin.Classifier` alone and dropped the Keymail
840upgrade on the floor.** It computed a class no code path consumed, and did
841it *after* the mail had already gone. §5.1 requires "auto-upgrading to
842Keymail when the address has a claimed inbox", so the seam is one level up.
843
844`signin.Flow` is that seam, and the reason to take it is that it is the
845same seam `rastrillo/auth` uses — minus the address-bearing tables ruling
84614 forbids. `Flow.Links` is an interface (verified at v0.1.1):
847
848```go
849PutLink(ctx, hash, address, purpose string, expires time.Time) error
850TakeLink(ctx, hash, purpose string) (address string, ok bool, err error)
851```
852
853**So the app supplies the store, and sealing happens inside it.** `PutLink`
854seals before the insert; `TakeLink` runs Task 4's single
855`DELETE … RETURNING` and opens the result. The package's own comment on
856`TakeLink` says it "must delete the row it returns", which is the same
857single-use rule Task 4 arrived at independently.
858
859Wiring `Flow` then delivers, for free, four things the earlier draft either
860lost or would have had to write:
861
862- **The Keymail upgrade** (§5.1). `Begin` classifies, and on a claimed
863 inbox takes `beginKeymail` — an HMAC'd pending blob in a cookie, and
864 `CompleteKeymail` with an address-mismatch check — instead of mailing.
865- **Rate limits on `POST /signin`**, which the earlier draft had none of,
866 leaving a mail-bomb vector. Defaults are 20 per IP and 5 per address per
867 15-minute window, and a nil `Limiter` builds a `MemoryLimiter` rather
868 than being silently unlimited.
869- **CRLF-safe address parsing** via `SplitAddress`.
870- **`LinkTTL`**, defaulting to 15 minutes — the link lifetime the earlier
871 draft never named.
872
873Configuration: `Origin: "https://id.oficina.works"`, `LinkPath:
874"/signin/verify"`, a non-zero `Key` from config (it seals the pending blob
875and **must not** come from an instance data key — sign-in runs before any
876session exists), `Mailer` (rastrillo's `mail.Sender` and `signin.Mailer` have identical
877method sets, so the capturing test sender plugs straight in with no
878adapter), `Classifier`, and `Keymail` **with
879`RedirectPath: "/signin/callback"`** — its default is `/auth/callback`, so
880mounting the route without setting this leaves the two disagreeing.
881
882**Mount `csrf.Protect(origin)` router-wide, not just on the verify POST.**
883`POST /signin` needs it too: `rastrillo/auth` checks same-origin on its own
884`Begin` and its comment says why — "an unauthenticated endpoint that sends
885mail and makes outbound DNS/HTTPS calls decided by the submitted address (a
886blind-SSRF surface by design)". `csrf.Protect` is pure origin evidence
887(`Sec-Fetch-Site`, then `Origin`, then `Referer`) and never consults a
888session, so it works before anyone is signed in.
889
890**The per-IP budget collapses into a suite-wide sign-in DoS, and this is
891settled here rather than left to be found.** An earlier draft of this
892paragraph said `RemoteAddr` is `""` behind the unix socket and the budget is
893therefore skipped. **Measured, and it is worse than that:** over a Linux
894unix socket `r.RemoteAddr` is `"@"`. `net.SplitHostPort("@")` fails, `auth`'s
895`clientIP` returns `"@"` unchanged, and `Flow.Begin` skips the budget only
896when the key is exactly `""`. So **every request in the world shares the key
897`ip:@`, with a budget of 20 per 15 minutes** — twenty POSTs to `/signin` lock
898out sign-in for the entire suite for a quarter of an hour. rastrillo's own
899comment admits the shape ("collapsing the per-IP budget into a global one").
900§2.1 already names this service the suite's single point of failure; this
901would make it a trivially reachable one.
902
903**Take the client IP from `X-Forwarded-For`.** The carlos edge sets it on
904every proxied request (`SetXForwarded`) and strips any inbound
905`Forwarded`/`X-Forwarded-*` on unverified hops (`stripForwardedHeaders`), so
906the header is trustworthy at the app and a client cannot forge it. Use the
907last element; fall back to `RemoteAddr` only when the header is absent, and
908**never pass `"@"` as a limiter key**. Test both paths — a request with the
909header gets its own budget, one without does not silently share a global
910one.
911
912**Classification fails open** (§5.1). The package already guarantees it —
913"every failure mode — DNS, transport, malformed body, non-200 — answers
914*not keymail*" — so the app must not add a failure path on top. Still bound
915the `Classifier.HTTP` client with a timeout: it makes outbound DNS and
916HTTPS requests to whatever domain the person typed.
917
918Add `GET /signin/callback` mirroring `auth.Callback` for the Keymail round
919trip. **Redirect to it, never render it**: the return host is
920address-chosen, and rendering an account-influenced string on the
921credential origin is what §4 forbids and gate 5 asserts.
922
923- [ ] **Step 1: Write gate 2 in full, as a failing test**
924
925```go
926// TestFullSigninLeavesNoReadableAddress is §12 gate 2: "a full sign-in
927// plus directory upsert plus rekey leaves no readable address anywhere,
928// WAL included." The directory and rekey halves arrive in step 4; this
929// asserts the sign-in half and must be EXTENDED, not replaced, then.
930func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
931 dbPath := filepath.Join(t.TempDir(), "home.db")
932 app := newTestApp(t, dbPath)
933
934 // Lowercase and address-shaped: the blind index and signin's rate
935 // limiter both key on strings.ToLower(address), so a canary that is
936 // only ever upper-case cannot detect a leak of the lowercased copy.
937 // sealtest.CanaryAddress, not a composed string: the ported Canaries
938 // is [][]byte, and the ported canaries are already address-shaped
939 // (canary-8f3a@example.invalid), so concatenating an "@example.invalid"
940 // onto one gives a two-@ value that SplitAddress rejects outright.
941 signIn(t, app, sealtest.CanaryAddress)
942 sealtest.AssertNoPlaintext(t, dbPath)
943}
944```
945
946Plus the classification test, asserting on the flow's decision rather than
947on a constant. **`signin.ClassKeymail` does not exist** — an earlier draft
948invented it and flagged it as the one identifier not verified at source,
949which was the right instinct and the wrong response. `Class` is a struct
950`{Keymail bool; Server string; Reauth bool}`:
951
952```go
953func TestFailedClassificationStillSendsAMagicLink(t *testing.T) {
954 app := newTestApp(t, filepath.Join(t.TempDir(), "home.db"))
955 app.flow.Classifier.LookupTXT = func(context.Context, string) ([]string, error) {
956 return nil, errors.New("servfail")
957 }
958 // force is signin.Method, a string type — not 0.
959 next, err := app.flow.Begin(context.Background(), "someone@example.invalid", "", signin.Method(""))
960 if err != nil {
961 t.Fatalf("Begin: %v", err)
962 }
963 if next.Kind != signin.NextMagicLink {
964 t.Fatalf("Kind = %v, want NextMagicLink: classification must fail open", next.Kind)
965 }
966}
967```
968
969- [ ] **Step 2: Run them**
970
971Expected: FAIL — no `/signin` route, no `flow` field.
972
973- [ ] **Step 3: Wire the Flow and implement Begin**
974
975`POST /signin` calls `Flow.Begin` and then **branches on `next.Kind`** —
976an earlier draft said "303 to a check-your-mail page, always", which drops
977the Keymail upgrade this task exists to deliver:
978
979- `NextMagicLink` — the mail has already been sent by the time `Begin`
980 returns. 303 to the check-your-mail page.
981- `NextKeymail` — **no mail was sent.** `Begin` returns `Redirect` and
982 `Pending`; set the pending cookie and 303 to `next.Redirect`.
983
984**Both branches must be indistinguishable to an attacker probing for known
985addresses.** The Keymail redirect necessarily differs, but it is decided by
986the address's *domain*, which the submitter already knows — it discloses
987nothing about whether that person has an account here.
988
989**The pending cookie's attributes are load-bearing and `auth` keeps them
990unexported, so they must be replicated deliberately:** `__Host-` prefix,
991`SameSite=Lax`, `HttpOnly`, `Secure`, and **cleared on read**. They are
992exactly what the "callback may mint on GET" argument below rests on; get
993them wrong and that argument stops being true.
994
995**Carry the `force` escape hatch.** `auth.Callback` redirects to
996`?force=1&err=keymail` when the Keymail round trip fails, and without it a
997failed approval is a dead end — against §5.1's "every address has a working
998path". `Flow.Begin`'s last parameter is that override.
999
1000- [ ] **Step 4: Verify as GET-renders, POST-redeems**
1001
1002**The magic link must not sign anyone in on GET.** An earlier draft had
1003`GET /signin/verify` look up the token and mint. Two things break:
1004
1005- **Mail security scanners prefetch links.** The scanner burns the
1006 single-use token and the person's own click fails. Routine, not
1007 hypothetical.
1008- **It is login CSRF.** Any page the victim loads can embed
1009 `<img src="https://id.oficina.works/signin/verify?token=…">` and silently
1010 sign them into an account the attacker controls, after which everything
1011 they do happens in the attacker's session. §5.6 names login CSRF for the
1012 assertion; this is the same attack one hop earlier.
1013
1014rastrillo's own `auth.Verify` redeems on GET, so this is inherited rather
1015than invented — and the fix is free under zero-JS:
1016
1017- `GET /signin/verify?token=…` **touches no state.** It renders a
1018 scriptless confirmation page: one form, the token in a hidden field, one
1019 button. It must not look the token up — that reintroduces the oracle and
1020 lets a prefetch probe validity. **The parameter is `token`**, which is
1021 what `sendLink` builds; an earlier draft said `t`.
1022- `POST /signin/verify` behind `csrf.Protect` does the work:
1023 `Flow.CompleteMagicLink` (which calls `TakeLink`), then find-or-mint the
1024 person with the blind index, then `sessions.SignIn` with
1025 `Subject: personRef` and `Method`/`AuthTime` copied from the `Identity`.
1026
1027Cross-device still works (§5.5): the token rides the URL, not a cookie.
1028
1029**`GET /signin/callback` may mint on GET, and the difference is not
1030arbitrary.** The Keymail return is bound to the browser that began the flow
1031by the `__Host-` pending cookie plus `state`, so an attacker cannot make a
1032victim's browser complete a flow it never started. The magic link has no
1033such binding — anyone holding the URL can replay it — which is exactly why
1034it needs the form. Redirect to the callback, **never render it**: the
1035return host is address-chosen, and rendering an account-influenced string on
1036the credential origin is what §4 forbids and gate 5 asserts.
1037
1038- [ ] **Step 4a: Build `blindIndex`, find-or-mint, and the test helpers**
1039
1040The blind index and the find-or-mint upsert were prose in Task 4 and in
1041nobody's `git add`. They belong here, with the person lookup that uses them:
1042`blindIndex(pepper, address)`, the `INSERT … ON CONFLICT(blind_index) DO
1043UPDATE … RETURNING person_ref`, and `people.created_at` written coarsened to
1044the day.
1045
1046`signIn(t, app, addr)` and `capturedLink(t, app)` are built here too — every
1047later test goes through them. **`signIn` must send an `Origin` header**, or
1048`csrf.Protect` refuses its POSTs and every test fails for the wrong reason.
1049
1050- [ ] **Step 5: Prove the three properties that were wrong**
1051
1052```go
1053// A returning person gets the SAME person ref. Without the blind index
1054// every sign-in minted a new one, so nobody could ever return.
1055func TestSecondSigninFindsTheSamePerson(t *testing.T)
1056
1057// Two concurrent redemptions of one link mint exactly one session.
1058func TestConcurrentRedemptionMintsOneSession(t *testing.T)
1059
1060// A GET does not redeem: a mail scanner's prefetch must leave the link
1061// usable, and must not sign anybody in.
1062func TestGetDoesNotRedeemTheLink(t *testing.T)
1063```
1064
1065And one that keeps the oracle shut: unknown, expired and already-used
1066tokens must produce byte-identical responses.
1067
1068- [ ] **Step 6: Run the gate, then prove it bites**
1069
1070Run gate 2. Then break the seal deliberately and watch it fail, as Task 4
1071step 4 did for the store.
1072
1073- [ ] **Step 7: Commit**
1074
1075```bash
1076git add internal/home/signin.go internal/home/signin_test.go internal/home/render.go internal/home/templates internal/home/app.go go.mod go.sum
1077git commit -m "Sign-in through signin.Flow, with the link redeemed on POST"
1078```
1079
1080---
1081
1082### Task 6: Passkeys and recovery codes
1083
1084**Unblocked 2026-09-05** by ruling 21: §11 has its carve-out, so the
1085enrolment and ceremony screens may load script and nothing else may.
1086
1087**Files:**
1088- Create: `internal/home/passkey.go`, `internal/home/passkey_test.go`
1089- Create: `internal/home/templates/passkey_confirm.html`, `passkey_enrol.html`, `recovery_codes.html`
1090- Create: `internal/home/static/passkey.js` — the app's own glue (under `internal/home/` so `go:embed` can reach it)
1091- Modify: `internal/home/app.go`, `internal/home/models.go` (where Task 2 put `Schema`/`BootSchema` — there is no `db.go`)
1092
1093**Interfaces:**
1094- Produces: `(*App).passkeys *passkey.Handlers`, mounted at the seven routes below.
1095
1096Verified at v0.22.0 so the executor need not re-derive it:
1097- `recoveryCodesPerSet = 10` — matches §5.2's "ten" exactly.
1098- `SignInRecovery` is `r.PostFormValue("code")` — **a plain form POST with no JS**, matching §5.2. The package says why: "recovery is exactly the moment WebAuthn isn't working."
1099- Codes are base32, 50 bits each, hashed with `sessions.HashToken`, redeemed with a single `DELETE … RETURNING`. No attempt counter, and that is sound rather than an omission: `SignInRecovery` needs a live `passkey_pending` half-session, so the window is bounded by `pendingTTL` rather than by a counter.
1100- `SignInFinish` (the Gate path) mints `Method` as the first factor plus `"+passkey"` — `"magiclink+passkey"`. `StepUpFinish` is the different one: it mints `"passkey"` alone. Both set a fresh `AuthTime`, which is what `RequireFresh` checks.
1101- Losing passkey and codes together is a permanent lockout: §5.2's disclosed outcome with no operator reset (§14). Not a gap to fill.
1102
1103**Struck from an earlier draft:** *"If passkeys leave slice one, this task
1104becomes 'recovery codes as a second sign-in path' and stays scriptless."*
1105That is a spec substitution, not a fallback. The Gate never signs anybody in
1106from nothing, and §5.2 defines codes as recovery **for the second factor**;
1107codes alone are a first-factor bypass the spec never authorised.
1108
1109- [ ] **Step 1: Merge `passkey.Schema`**
1110
1111Task 2's `migrate.Merge(sessions.Schema, Schema)` omits it, and `Gate`
1112INSERTs into `passkey_pending`, so without this the first enrolled sign-in
1113fails at the database. All four passkey tables key on `subject`, which is
1114already the person ref — so the spec's worry about inheriting `auth`'s
1115address-bearing subject dissolves. Say so; do not leave it to be
1116rediscovered.
1117
1118- [ ] **Step 2: Write the failing test that one factor is not enough**
1119
1120```go
1121// An enrolled person must NOT obtain a full session from the magic link
1122// alone. This is the whole point of the Gate, and the test that would
1123// have caught the callback bypass below.
1124func TestEnrolledPersonCannotSignInWithOneFactor(t *testing.T)
1125```
1126
1127Enrol with `webauthn/authtest`, which is what rastrillo's own
1128`passkey_test.go` uses — do not hand-roll a credential.
1129
1130- [ ] **Step 3: Put `Gate` on BOTH sign-in paths**
1131
1132At the point each handler would call `sessions.SignIn`, call
1133`passkey.Handlers.Gate(w, r, sess)` — exactly as `auth.admit` does, and
1134note that in rastrillo **both `Verify` and `Callback` funnel through that
1135one `admit`.** This plan has two entry points, so it needs the call twice:
1136
1137- `POST /signin/verify` — the magic-link path.
1138- `GET /signin/callback` — **the Keymail path.** An earlier draft named
1139 only the first, which left a Keymail-upgraded account with a passkey
1140 enrolled signing in with a single factor. That is the bug this task
1141 exists to prevent, arriving through the door the task forgot.
1142
1143An account with no passkey passes the Gate untouched, so the unenrolled
1144path is unchanged.
1145
1146- [ ] **Step 4: Mount the seven routes and write the glue**
1147
1148```
1149POST /passkey/register/begin | /finish
1150POST /passkey/stepup/begin | /finish
1151POST /passkey/signin/begin | /finish
1152POST /passkey/signin/recovery
1153```
1154
1155All behind `csrf.Protect`. **Plus the GET pages and assets those seven
1156imply**, which an earlier draft left unrouted:
1157
1158```
1159GET /passkey/confirm ConfirmPath — the ceremony + the recovery form
1160GET /passkey/enrol enrolment
1161GET /passkey/codes the show-once page, behind RequireFresh
1162GET /static/webauthn.mjs webauthn.JS(), text/javascript
1163GET /static/passkey.js the app glue
1164```
1165
1166**Both on stable, unfingerprinted paths.** Everything else may go through
1167rastrillo's fingerprinting asset helper; these two may not, because Task 7's
1168allowlist has to name them, and a rule that cannot name its subject is not a
1169rule.
1170
1171
1172`ConfirmPath` is `/passkey/confirm` (its default, named rather than
1173defaulted). `RequireFresh`'s `maxAge` for the codes page is **5 minutes** —
1174it must be short, because the page shows ten codes once, and it must exceed
1175the ceremony round trip.
1176
1177**Correcting this plan's own earlier claim that "nobody has to write it":**
1178`webauthn.JS()` embeds `js/webauthn.mjs`, which only *exports*
1179`register()`/`authenticate()` and makes no network call. The app writes the
1180glue that imports it, calls it, POSTs to `/passkey/*` and follows the `to`
1181that `SignInFinish` returns. Both files are served as **static same-origin
1182assets** — rastrillo's default CSP forbids inline script, and ruling 21's
1183carve-out is for static files, not for inline blocks. Go's `mime` already maps `.mjs` to `text/javascript; charset=utf-8`, so
1184serving it from the embedded FS needs nothing special — set the header
1185explicitly anyway, since a wrong MIME type blocks a module silently.
1186
1187`register()` needs `rpId`, `userId` and `userName`, while `/begin` returns
1188only `challenge`. **`userId` is the person ref. `userName` must not be the
1189address** — the credential origin never renders an account-authored string
1190(§4), and the home does not hold the address anyway. A person ref reads
1191poorly in a password manager; that is the cost of §0 and it is accepted.
1192
1193- [ ] **Step 5: The confirm page carries the recovery form**
1194
1195The same screen hosts the ceremony *and* `SignInRecovery`'s plain form.
1196**Script-strip it and the `code` form must still work** — that is gate 6's
1197requirement under ruling 21, and the reason the allowlist exempts the
1198ceremony button rather than the page.
1199
1200- [ ] **Step 6: Show the codes once, behind `RequireFresh`**
1201
1202`RegenerateRecoveryCodes` mints ten, shown once. The package requires that
1203page to sit behind `sessions.RequireFresh`.
1204
1205- [ ] **Step 7: Commit**
1206
1207```bash
1208git add internal/home/passkey.go internal/home/passkey_test.go internal/home/templates internal/home/static internal/home/app.go internal/home/models.go
1209git commit -m "Passkeys as an optional second factor, gated on both sign-in paths"
1210```
1211
1212---
1213
1214### Task 7: The scriptless screens, and the RP-ID guard
1215
1216**Files:**
1217- Modify: `internal/home/render.go` (Task 5 created it); create the remaining `internal/home/templates/*.html`
1218- Create: `internal/home/rpid_test.go` — **package `home`, not `guardstest`**: it reads the app's own config through a helper over the unexported `App`, which a `guards` test cannot reach.
1219- Create: `internal/home/noscript_test.go`
1220- Create: `internal/home/static/tokens.css`, `internal/home/static/theme.css` — vendored from rastrillo. **Under `internal/home/`, not the repo root:** `go:embed` cannot reach a parent directory, sheets-core embeds `all:static` from inside its package, and `rastrillo new` scaffolds `internal/<pkg>/static/` with a `TestVendoredAssetsMatchTheLibrary` that reads it there.
1221
1222**Interfaces:**
1223- Produces: `(*App).render(w http.ResponseWriter, name string, data any)`
1224
1225Follow sheets-core's `internal/sheets/render.go`: one parsed tree per page, `ui.Funcs()` plus an `asset` func, `ParseFS(ui.Templates(), "*.html")` then the app's own layout and page.
1226
1227**Write the attribute spelling — `rst-btn`, not `class="rst-btn"`** (ruling
122820). An earlier draft of this plan said to write classes "because that is
1229what the partials you render alongside write". That was true at v0.21.0 and
1230false at v0.22.0, the version pinned: the partials flipped, and the class
1231spelling in `ui/partials/` fell from 124 occurrences to 6. rastrillo's
1232design system rules one spelling with the class selectors dropped before
12331.0, and this app has no markup yet, so it starts on the survivor.
1234
1235Vendor `static/tokens.css` and `static/theme.css` (the `day` theme), then
1236run **`rastrillo doctor --fix`** on each upgrade — it landed in v0.21.0, so
1237the earlier draft's "until `rastrillo doctor` lands" was a trigger that had
1238already fired. Add the `TestVendoredAssetsMatchTheLibrary` that
1239`rastrillo new` scaffolds; this repo was hand-written, so it has no copy.
1240
1241- [ ] **Step 1: The RP-ID guard**
1242
1243The earlier draft's version asserted nothing: it called an undefined
1244`configuredOrigins`, counted dots (`strings.Count(host, ".") < 2`, which is
1245not "the RP ID is the full host" and passes for any three-label host), and
1246ignored `LegacyRPID` entirely.
1247
1248```go
1249// Rulings 2, 13 and 16: "The RP ID is the full host, asserted by a test."
1250//
1251// Two doors, both accepted silently:
1252//
1253// - passkey.Config has NO RPID field. The RP ID is always
1254// url.Parse(Origin).Hostname(), so widening it means widening Origin,
1255// or bypassing passkey and building a webauthn.Config directly.
1256// - passkey.Config.LegacyRPID is passed straight through as a SECOND
1257// accepted RP ID — for assertions only; registration never takes it
1258// (webauthn.go rpMatches, allowLegacy). Set to "oficina.works" it
1259// collapses the split §4 exists to make.
1260// This test lives in package home, NOT internal/guardstest: it reads the
1261// config the APP builds, via a helper over the unexported App, and a
1262// guards-package test cannot call one.
1263func TestRPIDIsExactlyTheCredentialHost(t *testing.T) {
1264 cfg := passkeyConfig(t) // what the app configures, never a literal
1265
1266 u, err := url.Parse(cfg.Origin)
1267 if err != nil {
1268 t.Fatalf("parse Origin %q: %v", cfg.Origin, err)
1269 }
1270 if u.Hostname() != "id.oficina.works" {
1271 t.Errorf("RP ID is %q, want id.oficina.works: passkey derives the RP ID "+
1272 "from Origin's hostname, so widening Origin widens it (rulings 13, 16)",
1273 u.Hostname())
1274 }
1275 // LegacyRPID must be empty, or a full host, with a comment naming the
1276 // §5.7 drill that is running. HasSuffix, not Contains: Contains accepts
1277 // "x.oficina.works.attacker.example".
1278 if cfg.LegacyRPID != "" && !strings.HasSuffix(cfg.LegacyRPID, ".oficina.works") {
1279 t.Errorf("LegacyRPID %q widens the RP ID (§4, ruling 2)", cfg.LegacyRPID)
1280 }
1281}
1282```
1283
1284Add ruling 2's other half while here: the sessions cookie on the credential
1285origin is `__Host-` and host-only (verified in `sessions.setCookie`).
1286Assert no cookie set there carries a `Domain`.
1287
1288- [ ] **Step 2: Every screen works with scripts off**
1289
1290§11 is enforceable mechanically and should be. Ruling 21's carve-out is an
1291allowlist, and **an allowlist of screens is not enough** — it would admit
1292any script at all on those screens. Constrain it:
1293
1294- The only `<script>` on an allowlisted template is a **same-origin `src`**
1295 from a named list. No inline bodies.
1296- Assert the served CSP is still rastrillo's default (`Options.CSP`
1297 untouched), so the carve-out cannot quietly widen into
1298 `unsafe-inline`.
1299- Script-strip the confirm page and assert the recovery `code` form still
1300 posts to `/passkey/signin/recovery` — gate 6's shape, and the reason the
1301 exemption is the ceremony button rather than the screen.
1302
1303```go
1304// scriptAllowed is ruling 21's allowlist, by TEMPLATE and by SRC. Naming
1305// the screens is not enough — that would admit any script at all on them.
1306// Paths, not fingerprints. rastrillo's NewAssets.Path fingerprints
1307// (/static/passkey.<hash>.js), so if these templates used the asset func
1308// the allowlist would never match and the carve-out screens would fail
1309// their own sweep. Task 6 routes these two on STABLE paths for exactly
1310// that reason — they are the only two assets that need to be nameable in
1311// a security rule.
1312var scriptAllowed = map[string][]string{
1313 "passkey_confirm.html": {"/static/webauthn.mjs", "/static/passkey.js"},
1314 "passkey_enrol.html": {"/static/webauthn.mjs", "/static/passkey.js"},
1315}
1316
1317func TestNoScriptTagOutsideTheAllowlist(t *testing.T) {
1318 for _, name := range templateNames(t) {
1319 var buf bytes.Buffer
1320 renderTo(t, &buf, name, sampleData(name))
1321 srcs := scriptSrcs(t, buf.Bytes()) // parses, so an inline body has no src
1322 if len(srcs) == 0 {
1323 continue // no script at all: the common case
1324 }
1325 permitted, ok := scriptAllowed[name]
1326 if !ok {
1327 t.Errorf("%s renders a <script>; §11 says every screen works with scripts off "+
1328 "and ruling 21 carves out only the passkey screens", name)
1329 continue
1330 }
1331 for _, src := range srcs {
1332 if src == "" {
1333 t.Errorf("%s has an INLINE <script>; the carve-out is for static "+
1334 "same-origin files, and rastrillo's default CSP forbids inline", name)
1335 } else if !slices.Contains(permitted, src) {
1336 t.Errorf("%s loads %q, which is not in ruling 21's allowlist", name, src)
1337 }
1338 }
1339 }
1340}
1341
1342// The carve-out must not widen into unsafe-inline behind our backs.
1343//
1344// Do NOT reference rastrillo's defaultCSP — it is an unexported const in
1345// serve.go. Assert that the app leaves Options.CSP empty and that the
1346// SERVED header's default-src and script-src carry no 'unsafe-inline'.
1347// (§11 says the credential origin moves to script-src 'self'; rastrillo's
1348// default already delivers that via default-src 'self', so there is
1349// nothing to override — which is the point of asserting it stays empty.)
1350func TestServedCSPIsRastrillosDefault(t *testing.T)
1351
1352// And the one screen that carries script must still work without it:
1353// passkey.SignInRecovery is a plain form POST "because recovery is exactly
1354// the moment WebAuthn isn't working".
1355func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T)
1356```
1357
1358- [ ] **Step 2a: Build the test helpers this task's guards need**
1359
1360`templateNames(t)`, `renderTo(t, w, name, data)`, `sampleData(name)`,
1361`scriptSrcs(t, b)` (parse the HTML and return each `<script>`'s `src`, empty
1362string for an inline one) and `passkeyConfig(t)` — all five are used by the two guards above and created
1363by no step in an earlier draft. `passkeyConfig` must return the config the
1364**app** builds, not one the test declares, or the RP-ID guard asserts a
1365constant against itself. `passkey.Handlers` keeps its config unexported, so
1366**the `App` must retain the `passkey.Config` it passed to `passkey.New`**
1367for the helper to hand back — one field, and the executor otherwise loses an
1368hour to it.
1369
1370- [ ] **Step 3: Commit**
1371
1372```bash
1373git add internal/home/render.go internal/home/templates internal/home/static internal/home/rpid_test.go internal/home/noscript_test.go
1374git commit -m "Scriptless sign-in screens, and the guard that the RP ID is a full host"
1375```
1376
1377---
1378
1379## Deferred, with triggers
1380
1381| Thing | Trigger |
1382|---|---|
1383| ~~Passkey enrollment and step-up~~ | **Ruled 2026-09-05 (ruling 21): carve-out granted, Task 6 builds it.** |
1384| ~~The pepper and the blind index~~ — **built in Task 5** (ruling 3 is settled) | §3.1's "expensive KDF" vs ruling 3's `HMAC` is settled — task 2 on `skeleton` |
1385| Assertion minting, `jti`, the redirect protocol | §13 step 3 |
1386| The directory, calls 2 and 3 | §13 step 4 |
1387| Operator reset | §5.2: "the first real person locked out with passkey and codes both lost" |
1388
1389## Self-review notes
1390
1391- **Spec coverage:** §5.1 is Tasks 1–5; §5.2 is Task 6, unblocked by ruling 21; §10's `people`, `signin_links` and `sessions` are Tasks 2–4; §12 gates 1 and 2 are Tasks 1 and 5; §11 is Task 7. §5.3–§5.6 are step 3, out of scope and listed above.
1392- **Deliberately not covered:** `prefs`, `directory`, `teams`, `provisioning_queue`, `rekey_journal`, `consumers`, `assertion_jti` — later steps.
1393- ~~Two spec corrections needed~~ — **landed in this branch**, neither made silently:** §5.1's "use `rastrillo/auth` for classification" is not achievable; §3.1 contradicts ruling 3 on HMAC-vs-KDF.
1394- ~~The one unverified identifier: `signin.ClassKeymail` in Task 5.~~ **It did not exist** — `Class` is a struct. Flagging it was the right instinct; writing it anyway was the wrong response. Verify, or do not write. Every other signature in this plan was read at source, and the 2026-09-05 review pass re-read them at the pin.
1395- ~~The riskiest assumption: that `rastrillo/db.Open` either sets `PRAGMA secure_delete` or lets the app set it.~~ **Resolved 2026-09-05: it does neither.** The DSN carries `busy_timeout`, `journal_mode` and `foreign_keys` and passes nothing else through. Task 2 step 5 now uses a connection hook registered before `db.Open`, because the writer pool's one long-lived connection is opened inside it.
1396