oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

Oficina home — §13 step 2: sign-in, second factor, recovery codes

> 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.

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.

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.

Tech Stack: Go 1.25.0, rastrillo v0.22.0, chi v5, modernc.org/sqlite (cgo-free), zero JavaScript.

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.

Global Constraints

Every task's requirements implicitly include this section. Values are copied verbatim from the spec or AGENTS.md.

  • No address column exists. people holds person ref, blind index, created_at — "No address column." (§10)
  • The session Subject is an opaque person ref, never an address. Sessions, credentials and recovery codes all key on it. (§5.1)
  • 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)
  • PRAGMA secure_delete is on. (§5.1)
  • Migrations are additive-only. (§10)
  • 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)
  • 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)
  • 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)
  • Write the attribute spelling — rst-btn, not class="rst-btn" (ruling 20, 2026-09-05). §11 ruled the class vocabulary and both its reasons died: the attribute grammar shipped in v0.21.0, and at v0.22.0 — the current pin — rastrillo's own partials flipped, class-spelling occurrences in ui/partials/ falling from 124 to 6. rastrillo's design system rules "One spelling, not two. No permanent class fallback" and drops the class selectors before 1.0. This app has no markup yet, so it starts on the surviving spelling. day theme, rastrillo server shape.
  • 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)
  • 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.
  • 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)
  • Never put the value in the error. §3.4's "failed to hash alice@example.com" is its counter-example — the sentence naming what must not reach a log — not a prescribed error string. An earlier draft of this plan read it as the latter and would have shipped exactly the leak the rule forbids. Errors carry person refs and codes. (§3.4)
  • One binary, CGO_ENABLED=0, cgo-free SQLite. (AGENTS.md)
  • 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)

The gate escalation this step triggers

§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:

  • Gate 1, plaintext grep — "raw SQLite file and WAL plus this app's store objects, against a seeded corpus of known addresses." (§12)
  • 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)

Sheets 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.

Change its canaries when porting. Sheets' are shaped like CANARY-CELL-VALUE-8f3a and matched with bytes.Contains — case-sensitive and not address-shaped. Both matter here: the blind index (ruling 3) and signin's rate limiter both key on strings.ToLower(address), so the lowercased copy is the one likeliest to reach a store or an error string, and an upper-case canary cannot see it. Use lowercase, address-shaped canaries — canary-8f3a@example.invalid — or scan case-insensitively. Gate 9's own logtest package already learned this and its encodings helper folds case for the same reason.

Three things to settle before Task 5 — not the executor's to decide

  1. 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.
  2. ~~Passkey needs JavaScript, and §11 forbids it.~~ RULED 2026-09-05 by Paul: §11 gains one named carve-out. The passkey enrolment and ceremony screens may load rastrillo's own webauthn.mjs. Everything else stays scriptless — magic-link sign-in, recovery-code redemption and the whole springboard — so a person with script off keeps a working path and only loses the optional second factor. Task 7's no-<script> sweep gets an explicit allowlist of exactly those screens, never a blanket 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.

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.

  1. ~~No main on this remote.~~ Closed 2026-09-05: main exists at 462df84 and the cadence applies as written — branch from it, squash-merge to it.
  1. ~~Merge order: gate 9 must land first.~~ Closed 2026-09-05: gate 9 merged to main at 9162ee5, so internal/guardstest/repo_test.go and internal/home/logtest are both there. Rulings 17-21 are on main too, at 5a42f2d.

A spec-vs-reality note the executor must not "fix" silently

§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.

Land the correction, do not merely file it. AGENTS.md's own rule is that where it disagrees with the spec the spec wins "and this file is wrong — fix it in the same commit". Both §5.1 and AGENTS.md's Data section say classification goes through rastrillo/auth; both are wrong for the reason below, and both are on main. Fix them in this branch, so the plan and the binding files stop disagreeing the moment it lands.

The 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.

---

Helpers and wiring this plan assumes

Named here because an earlier draft used all of them without ever saying to build them, which would have left the executor re-deriving decisions mid-task.

Test helpers — build these in Task 1 and 2, before anything calls them:

| Helper | What it does | |---|---| | 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. | | capturedLink(t, app) | Pulls the magic link out of the captured mail. | | 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. | | passkeyConfig(t) | Returns the passkey.Config the app builds, for Task 7's guard. | | 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. |

loadSecrets is not in this table: it is the production loader cmd/home/main.go calls (Task 1 step 5b), not a test helper.

Build these in Task 1 (newTestApp) and Task 5 (signIn, capturedLink), each with its own step and its own test. An earlier draft listed them in this table and then had no step create any of them.

newTestApp must wire a non-nil Flow.Keymail. Begin skips classification entirely when it is nil, so TestFailedClassificationStillSendsAMagicLink would pass vacuously — green, and proving nothing.

RegisterConnectionHook is process-global, so guard it with a sync.Once; newTestApp is called by every test.

signIn must send an Origin header, or csrf.Protect refuses its POSTs and every test fails for the wrong reason.

There is a fourth secret: Flow.Key. It seals the Keymail pending blob, sealPending refuses a zero key, and §5.1's "must come from config, never an instance data key" applies — sign-in runs before any session exists. So the list Paul provisions is HOME_DEK, HOME_PEPPER, HOME_SIGNIN_KEY and CARLOS_SMTP_PASS.

sessions wiring, which no step created:

sessions.New(sessions.Config{
	DB:         d.Writer(),
	Origin:     "https://id.oficina.works",
	TTL:        30 * 24 * time.Hour, // the package default, named rather than defaulted
	SigninPath: "/signin",
})

Copy Method and AuthTime from the signin.Identity, as auth.admit does — do not invent either. Correcting an earlier draft of this section, which was false at source: it said "a zero AuthTime is never fresh". sessions.Fresh falls back to sess.At when AuthTime is zero, and its doc says why — "plugins that never set AuthTime (the magic link) still converge instead of redirect-looping". CompleteMagicLink sets only At by construction, so a magic-link session legitimately has a zero AuthTime and RequireFresh still works.

The mailed link's base URL is https://id.oficina.works + Flow.LinkPath. It comes from config, never from the request Host: a Host-derived link is an open redirect with an extra step.

Refuse to boot rather than degrade. Two silent failures, both of which sheets-core already guards:

  • No mailer. mail.Logged's Send logs "to", to — an address into platform-held logs on every single sign-in, which is precisely §3.4's leak, arriving through the fallback nobody chose. Sheets' LoadAuthEnv has the shape: "no mailer means magic links get written to platform-held logs … hard exit". Do the same.
  • Empty DBPath. Resolve sets it only from -db, and SQLite treats an empty filename as a private temporary database — so the app comes up, works, and loses every person on restart.

TDD is not optional and not compressible. An earlier draft collapsed a task into "Run, fail, implement, run" with no code. Every task writes the test, runs it, watches it fail for the stated reason, implements, and runs it again. Task 4's step 6 — deliberately break the seal and watch the gate catch it — is the pattern for every gate here, and it exists because gate 9's review found the same thing twice: a guard never seen to fail is a guard written to pass.

---

File Structure

cmd/home/main.go                  Resolve -> db.Open -> migrate.Apply -> Serve.
internal/home/
  app.go                          App struct and wiring. Gains db, sealer, sessions, classifier.
  keys.go                         LoadSealer: HOME_DEK from env, base64, exactly 32 bytes, refuse to boot otherwise.
  seal/seal.go                    DEK, HKDF purpose keys, AES-GCM. Ported from sheets-core.
  sealtest/sealtest.go            Gate 1: AssertNoPlaintext over raw file, WAL and SHM. Ported.
  models.go                       Schema. people, signin_links. No address column, anywhere.
  migrations/0001_people.sql
  migrations/0002_signin_links.sql
  personref.go                    Mint and validate the opaque person ref.
  signin.go                       Begin/Verify: sealed link state, one-time redemption, session mint.
  links.go                        signin.LinkStore. Seals on put, one
                                  DELETE ... RETURNING on take.
  render.go                       One parsed tree per page, ui.Funcs plus an asset func.
  templates/                      Scriptless screens, attribute spelling
                                  (ruling 20; the seven utilities keep class).
  logtest/                        Gate 9. Already on main (9162ee5).
  rpid_test.go                    Rulings 13/16: the RP ID is the full host.
                                  Package home, not guardstest: it reads
                                  the app's own passkey.Config.
  noscript_test.go                §11 + ruling 21's allowlist.
internal/guardstest/
  logsinks_test.go                Already landed. Forbids non-slog sinks.
  noaddress_test.go               Gate 2's schema half.

---

Task 1: The seal package, the DEK, and gate 1

The gate goes in with the mechanism, as it did in Sheets. Once this lands, every later task is checked by it.

Files:

  • Create: internal/home/seal/seal.go, internal/home/seal/seal_test.go
  • Create: internal/home/sealtest/sealtest.go
  • Create: internal/home/keys.go, internal/home/keys_test.go

Interfaces:

  • Consumes: nothing.
  • Produces: - seal.New(dek []byte) (*Sealer, error) — DEK must be exactly 32 bytes. - (*Sealer).Seal(purpose string, plaintext []byte) ([]byte, error) - (*Sealer).Open(purpose string, ciphertext []byte) ([]byte, error) - home.LoadSealer() (*seal.Sealer, error) — reads HOME_DEK. - sealtest.Canaries [][]byte, sealtest.AssertNoPlaintext(t *testing.T, dbPath string) - Purposes: "signin.address", "team.name", "queue.payload".

Port 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.

  • [ ] Step 1: Write the failing round-trip test
package seal

import (
	"bytes"
	"testing"
)

func testDEK() []byte {
	d := make([]byte, 32)
	for i := range d {
		d[i] = byte(i)
	}
	return d
}

func TestSealOpenRoundTrip(t *testing.T) {
	s, err := New(testDEK())
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	ct, err := s.Seal("signin.address", []byte("person@example.invalid"))
	if err != nil {
		t.Fatalf("Seal: %v", err)
	}
	if bytes.Contains(ct, []byte("example.invalid")) {
		t.Fatal("ciphertext contains the plaintext")
	}
	pt, err := s.Open("signin.address", ct)
	if err != nil {
		t.Fatalf("Open: %v", err)
	}
	if string(pt) != "person@example.invalid" {
		t.Fatalf("Open = %q", pt)
	}
}

// A value sealed under one purpose must not open under another. These are
// adjacent columns of the same row; a bug that swapped them would be
// invisible without this.
func TestWrongPurposeFails(t *testing.T) {
	s, _ := New(testDEK())
	ct, _ := s.Seal("signin.address", []byte("secret"))
	if _, err := s.Open("team.name", ct); err == nil {
		t.Fatal("opened under the wrong purpose")
	}
}

func TestRejectsShortDEK(t *testing.T) {
	if _, err := New(make([]byte, 31)); err == nil {
		t.Fatal("accepted a 31-byte DEK")
	}
}
  • [ ] Step 2: Run it and watch it fail

Run: go test ./internal/home/seal/ Expected: FAIL, build error — undefined: New.

  • [ ] Step 3: Port seal.go

Copy oficina/sheets-core/internal/sheets/seal/seal.go to internal/home/seal/seal.go. Change only the package doc and the HKDF info string:

r := hkdf.New(sha256.New, s.dek, nil, []byte("oficina/home:"+purpose))

Add golang.org/x/crypto with go get golang.org/x/crypto.

  • [ ] Step 4: Run the tests

Run: go test ./internal/home/seal/ Expected: PASS.

  • [ ] Step 5: Write LoadSealer and its test
// LoadSealer reads HOME_DEK and returns the process Sealer.
//
// It refuses to boot rather than generating a key. A generated key would
// let the app start with every previously sealed link unreadable, which
// looks like data loss and is discovered late.
func LoadSealer() (*seal.Sealer, error) {
	raw := os.Getenv("HOME_DEK")
	if raw == "" {
		return nil, errors.New("HOME_DEK is not set")
	}
	dek, err := base64.StdEncoding.DecodeString(raw)
	if err != nil {
		return nil, fmt.Errorf("HOME_DEK is not valid base64: %w", err)
	}
	return seal.New(dek)
}

Test that unset, malformed and 31-byte values each error, and a valid one returns a Sealer. Use t.Setenv.

  • [ ] Step 5a: Port sealtest itself

The plan commits internal/home/sealtest and no step ever created it. Port sealtest.go from sheets-core with its own TestLeakedDetectsCanary and WAL tests, and extend the containment guard (now TestTestOnlyPackagesNotImportedByProductionCode) to cover sealtest too — on main it checks a single const logtestImport, so this turns that constant into a slice of two. Like logtest, it imports testing from a non-test file, so importing it from production code would drag the testing package into the binary. — a detector nobody has watched detect anything is the thing gate 9's review found twice.

Export CanaryAddress — a lowercase, address-shaped canary-8f3a@example.invalid — and include it in Canaries, or gate 2 signs in with a value AssertNoPlaintext never scans for and passes while proving nothing. (Task 5 step 6's deliberate break would expose it, which is what that step is for; say it anyway.) Sheets' Canaries is [][]byte and shaped like CANARY-CELL-VALUE-8f3a; both matter here. Case, because the blind index and signin's limiter key on strings.ToLower(address), so the lowercased copy is the one likeliest to reach a store. Shape, because gate 2 signs in with the canary, so it has to parse as an address — SplitAddress rejects a value with two @.

  • [ ] Step 5b: loadSecrets, and the boot refusals

All four secrets, in one place, refusing rather than degrading. Three of them had no loader at all in an earlier draft, and the app would have booted without them.

// loadSecrets reads the four, and refuses to boot on any of them.
//   HOME_DEK         seals link state and queue payloads (Task 1)
//   HOME_PEPPER      the blind index (ruling 3, Task 5)
//   HOME_SIGNIN_KEY  seals signin.Flow's Keymail pending blob
//   CARLOS_SMTP_PASS the mailer

Two refusals that are not about secrets, and both are silent failures sheets already guards:

  • No mailer. mail.Logged's Send logs "to", to — an address into platform-held logs on every sign-in, §3.4's leak arriving through a fallback nobody chose. Sheets' LoadAuthEnv hard-exits; do the same.
  • Empty DBPath. Resolve sets it only from -db, and SQLite treats an empty filename as a private temporary database — the app comes up, works, and loses every person on restart.

Test each refusal with t.Setenv.

  • [ ] Step 5c: Wire main.go, and build newTestApp

cmd/home/main.go must call loadSecrets and hand the Sealer to the app — sheets' main.go calls LoadSealer() and an earlier draft of this plan edited main.go only to wire the database, so three of four secrets were never loaded on the boot path.

newTestApp(t, dbPath) is the helper every later task's tests use: temp DB, migrations applied, capturing mail.Sender, Sealer from a test DEK, test pepper, and a signin.Flow with a stub LookupTXT and a non-nil Keymail — Begin skips classification entirely when Keymail is nil, so the fail-open test would pass vacuously without it. Register the secure_delete connection hook here too, under a sync.Once, and before db.Open (Task 2 step 5).

It starts small and grows. BootSchema is Task 2's, the signin dependency is Task 4's and App.flow is Task 5's, so build it here with the database and the Sealer only, and let Tasks 2, 4 and 5 extend it as each lands. An earlier draft described the finished helper in the first task that needs one, which does not compile.

The stub LookupTXT must return a real error, not nil, nil. An empty TXT result makes signin fall through to delegate, which returns the domain itself, and domainInfo then makes a live HTTPS GET to https://<domain>/.well-known/keymail. Every signIn in the suite would touch the network.

  • [ ] Step 6: Commit
git 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
git commit -m "Seal values at rest, load the four secrets, and the plaintext gate that proves it"

---

Task 2: The database, the schema, and gate 2's schema half

Files:

  • Modify: cmd/home/main.go
  • Create: internal/home/models.go, internal/home/migrations/0001_people.sql
  • Create: internal/guardstest/noaddress_test.go
  • Modify: internal/guardstest/repo_test.go (add migrationFiles, readFile)

Interfaces:

  • Produces: home.Schema *migrate.Set, home.BootSchema *migrate.Set, and an App holding *db.DB.

Follow 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:

opts, err := rastrillo.Resolve(rastrillo.Options{Logger: logger})
d, err := rdb.Open(opts.DBPath, logger)
_, err = migrate.Apply(context.Background(), d, home.BootSchema)
opts.Mux = mux
opts.DBPath = ""
rastrillo.Serve(opts)
//go:embed migrations/*.sql
var migrationsFS embed.FS

var Schema = migrate.MustFromFS(migrationsFS, "home")
var BootSchema = migrate.Merge(sessions.Schema, Schema)

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.

  • [ ] Step 1: Write gate 2's schema half
package guards

import (
	"regexp"
	"strings"
	"testing"
)

// addressish matches a column name that would hold an email address.
// §10: "people — person ref, blind index, created_at. No address column."
var addressish = regexp.MustCompile(`(?i)\b(address|email|e_mail|mail_to|recipient)\b`)

// TestNoAddressColumn reads every migration and fails on a column whose
// name says it holds an address. This is the cheap half of gate 2; the
// expensive half — a full sign-in leaves no readable address anywhere,
// WAL included — is Task 4 and needs a running sign-in.
func TestNoAddressColumn(t *testing.T) {
	root := repoRoot(t)
	for _, path := range migrationFiles(t, root) {
		rel := relPath(t, root, path)
		for i, line := range strings.Split(readFile(t, path), "\n") {
			// A sealed blob is not an address, and says so in its name.
			if strings.Contains(strings.ToLower(line), "sealed") {
				continue
			}
			if addressish.MatchString(line) {
				t.Errorf("%s:%d: %q names an address column; §10 says there is none",
					rel, i+1, strings.TrimSpace(line))
			}
		}
	}
}

Add migrationFiles and readFile beside the existing helpers in repo_test.go, following their shape (t.Helper(), t.Fatalf on error).

  • [ ] Step 2: Run it

Run: go test ./internal/guardstest/ -run TestNoAddressColumn Expected: PASS trivially — no migrations yet. That is correct; it is a tripwire, like gate 9.

  • [ ] Step 3: Write the first migration

internal/home/migrations/0001_people.sql:

CREATE TABLE people (
  person_ref  TEXT PRIMARY KEY,
  blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index
  created_at  TEXT NOT NULL
);

No 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.

  • [ ] Step 4: Verify

Run: go test ./... Expected: PASS.

  • [ ] Step 5: Turn on secure_delete

PRAGMA secure_delete must be on, because "deleting a row does not remove bytes from a file that is continuously replicated" (§5.1).

Resolved at source, 2026-09-05 — it was the plan's own riskiest assumption and it does not hold. rastrillo/db.Open builds the DSN path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" and passes nothing else through: no secure_delete, and no way to add one via the DSN. It also opens two pools — a writer capped at 1 and readers of 4 or more.

That matters because secure_delete is per-connection. An Exec after open sets it on whichever pooled connection served that call and on none of the others, and on none opened later. The earlier draft's test — read the pragma back and assert 1 — runs on that same connection and so passes while proving nothing, which is the worst kind of green.

Use sqlite.RegisterConnectionHook (modernc.org/sqlite — present at the version go.mod actually pins) to run PRAGMA secure_delete=ON on every new connection.

Register the hook before db.Open, in main and in newTestApp. db.Open calls Ping, which opens the writer pool's single long-lived connection there and then. A hook registered afterwards misses that connection forever — and it is the only one that performs DELETEs, so it is the one that matters. A test asserting on "a fresh connection" would pass while the writer never had the pragma set, which is the worst kind of green.

Assert on d.Writer() and one reader. db.DB exposes no reader accessor, so reach one through d.G.Clauses(dbresolver.Read). migrate.Apply takes a *db.DB, so bypassing db.Open is not an escape route.

  • [ ] Step 6: Commit
git add internal/home/models.go internal/home/migrations internal/home/app.go internal/home/testapp_test.go cmd/home/main.go internal/guardstest
git commit -m "Open the database, and the schema that has no address column"

---

Task 3: The person ref

Files:

  • Create: internal/home/personref.go, internal/home/personref_test.go

Interfaces:

  • Produces: home.NewPersonRef() (string, error), home.ValidPersonRef(s string) bool

The 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.

  • [ ] Step 1: Write the failing tests
func TestPersonRefIsOpaqueAndUnique(t *testing.T) {
	seen := map[string]bool{}
	for i := 0; i < 1000; i++ {
		ref, err := NewPersonRef()
		if err != nil {
			t.Fatalf("NewPersonRef: %v", err)
		}
		if seen[ref] {
			t.Fatalf("duplicate person ref %q after %d draws", ref, i)
		}
		seen[ref] = true
		if !ValidPersonRef(ref) {
			t.Fatalf("NewPersonRef produced %q, which ValidPersonRef rejects", ref)
		}
	}
}

func TestPersonRefDoesNotEmbedInput(t *testing.T) {
	ref, _ := NewPersonRef()
	if strings.Contains(ref, "@") {
		t.Fatalf("person ref %q contains an @", ref)
	}
}

func TestValidPersonRefRejectsRubbish(t *testing.T) {
	for _, bad := range []string{"", "person@example.invalid", "../etc", strings.Repeat("a", 500)} {
		if ValidPersonRef(bad) {
			t.Errorf("ValidPersonRef(%q) = true", bad)
		}
	}
}
  • [ ] Step 2: Run, watch it fail

Run: go test ./internal/home/ -run PersonRef Expected: FAIL — undefined: NewPersonRef.

  • [ ] Step 3: Implement
// personRefBytes is 16 bytes of crypto/rand, base32 without padding.
// Long enough that collisions are not a concern at any plausible number
// of people; short enough to read in a log line, which it is allowed to
// appear in — §3.4: "Errors carry person refs and codes."
const personRefBytes = 16

func NewPersonRef() (string, error) {
	b := make([]byte, personRefBytes)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	enc := base32.StdEncoding.WithPadding(base32.NoPadding)
	return "pr_" + strings.ToLower(enc.EncodeToString(b)), nil
}

ValidPersonRef checks the pr_ prefix, the exact expected length, and the alphabet.

  • [ ] Step 4: Run

Run: go test ./internal/home/ -run PersonRef Expected: PASS.

  • [ ] Step 5: Commit
git add internal/home/personref.go internal/home/personref_test.go
git commit -m "The person ref: opaque, minted, never derived from an address"

---

Task 4: The schema and the sealed LinkStore

Task 4 builds the store; Task 5 builds the endpoint. An earlier draft had Task 4 hand-roll Begin and Verify and Task 5 then replace them with signin.Flow — so the executor would have shipped a POST /signin with no rate limit and no CRLF guard, and then rewritten the security-critical path. Nothing here routes a handler.

Where the blindness claim is either kept or quietly broken.

Files:

  • Create: internal/home/links.go, internal/home/links_test.go
  • Create: internal/home/migrations/0002_signin_links.sql
  • Modify: go.mod — add github.com/keymaildev/signin (the LinkStore interface is defined there, so the dependency arrives with the store, not with the handlers)

Interfaces:

  • Consumes: seal.Sealer (Task 1) and the database (Task 2) — and nothing else.
  • 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.

0002_signin_links.sql:

CREATE TABLE signin_links (
  token_hash     TEXT PRIMARY KEY,
  sealed_address BLOB NOT NULL,
  expires_at     TEXT NOT NULL
);
CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at);

There is no redeemed_at column, and its absence is the design. An earlier draft had one and redeemed with SELECT-then-UPDATE. Two things were wrong with that. It is a read-then-write race: two clicks that both read a NULL both mint a session, and rastrillo's own linkStore.TakeLink carries a comment naming this exact race and fixing it with a single statement. And a row that survives redemption lets the handler tell "already used" apart from "never existed", which is an oracle. Taking the link is therefore one statement:

DELETE FROM signin_links
 WHERE token_hash = ? AND expires_at > ?
RETURNING sealed_address

Exactly one concurrent caller gets a row. Unknown, expired and already-used are then indistinguishable by construction rather than by remembering to make them so — all three are "no row". Deleting is safe here where it would not be elsewhere: the column is sealed, so the bytes Litestream keeps are bytes nobody can read (§3.2).

Take first, mint second. A crash between them costs one link, which the person retries. The reverse order costs a session minted from a link still live for a second click.

The column holds sealer.Seal("signin.address", []byte(addr)). Seal before the insert, never "insert then clean up" — Litestream ships WAL frames continuously, so a plaintext write reaches the bucket before any delete could remove it (§5.1, §3.2).

Use 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.

The blind index is built in this task, not deferred. An earlier draft deferred it to step 4 and said "until then, mint a person row keyed on the ref alone". That is not sign-in: with blind_index TEXT NOT NULL UNIQUE and no lookup key, every visit mints a fresh person ref, and sessions, credentials, recovery codes and the assertion's sub all attach to an identity nobody can ever return to. Ruling 3 is in §1's settled table and supplies the mechanism:

// blindIndex is ruling 3: HMAC(pepper, lowercased address). The pepper
// never leaves the home (ruling 4) — one on every instance would make a
// single instance compromise an offline dictionary attack against the
// whole suite directory.
func blindIndex(pepper []byte, address string) string {
	m := hmac.New(sha256.New, pepper)
	m.Write([]byte(strings.ToLower(address)))
	return hex.EncodeToString(m.Sum(nil))
}

HOME_PEPPER joins HOME_DEK in the list only Paul can provision, and for a sharper reason: lose the DEK and sealed links become unreadable; lose the pepper and every person row is orphaned, because the index is the only route from an address back to a person. §3.1 prefers an expensive KDF over a bare HMAC; that is a mitigation note, not a ruling, and ruling 3 is the ruling. File the tension as a spec note rather than resolving it here.

Finding-or-minting the person is also one statement, so a returning person and a first-time person take the same path:

INSERT INTO people (person_ref, blind_index, created_at) VALUES (?, ?, ?)
ON CONFLICT(blind_index) DO UPDATE SET blind_index = excluded.blind_index
RETURNING person_ref

created_at is written coarsened to the day. (Cite §3.3's reasoning, not its rule: on the spec branch §3.3's coarsening is explicitly "a rule for publication … never a storage format". Coarsening here is still right — it is strictly less information and people.created_at has no freshness role — but it is this plan's choice, not §3.3's requirement.)

  • [ ] Step 1: Write the LinkStore's failing tests

Four properties, driven directly against the store rather than through a handler:

func TestPutLinkSealsTheAddress(t *testing.T)        // raw file holds no canary
func TestTakeLinkReturnsTheAddressOnce(t *testing.T) // second take: ok == false
func TestTakeLinkRefusesAWrongPurpose(t *testing.T)
func TestConcurrentTakeLinkYieldsOneWinner(t *testing.T) // N goroutines, one ok

Run the concurrency one against both pools — db.Open gives a writer capped at 1 and four or more readers, and a race that only appears across pools is still a race.

  • [ ] Step 2: Run them

Run: go test ./internal/home/ -run TestPutLink Expected: FAIL — no store.

  • [ ] Step 3: Implement PutLink and TakeLink

PutLink seals then inserts. TakeLink is the single DELETE … RETURNING above.

Pin the timestamp format. expires_at > ? is a string comparison, so both sides must be written the same way — expires.UTC().Format(time.RFC3339), as auth/store.go does. A value carrying a zone offset sorts differently and silently accepts expired links.

purpose needs somewhere to live. The signin.LinkStore interface passes one to both methods, and the schema above has no column for it. Add purpose TEXT NOT NULL to 0002_signin_links.sql and put it in the DELETE predicate. (Hard-coding a check against signin.PurposeSignin also works, but the column costs nothing and the interface clearly expects more than one purpose to exist.)

  • [ ] Step 4: Prove the seal bites

Temporarily store the address unsealed. sealtest.AssertNoPlaintext must FAIL naming the canary. Revert. Do not skip this. A gate never seen to fail is a gate written to pass — the finding that came back twice on gate 9.

  • [ ] Step 5: Sweep expired links

Sealed rows are not a leak, but unbounded growth is a bug. Delete WHERE expires_at < ? at boot and on each PutLink, the shape passkey.Sweep suggests.

And sweep the other two while building the habit, because nobody else does: sessions.Sweep (rows live 30 days) and passkey.Sweep (passkey_pending, passkey_challenges). Same boot-plus-on-write shape. None of these leak — they are keyed on person refs — but a table nothing ever deletes from is a bug waiting for a big enough database. (Not "the wake-driven schedule §6 uses" — that sentence is about instances, and AGENTS.md says the home does not hibernate, so there are no wakes to hang it on. A ticker would work here and is simply not worth a goroutine for this.)

  • [ ] Step 6: Commit
git 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
git commit -m "A sign-in link store that seals before it writes and takes exactly once"

---

Task 5: signin.Flow, the sign-in handlers, and the Keymail upgrade

Files:

  • Create: internal/home/signin.go, internal/home/signin_test.go, internal/home/render.go
  • Create: internal/home/templates/signin.html, check_your_mail.html, signin_confirm.html
  • Modify: internal/home/app.go

Interfaces:

  • Produces: (*App).flow a *signin.Flow, and the four routes. Consumes (*App).links from Task 4.

*`(App).render is built HERE, minimally, and Task 7 extends it.** These three templates are its first callers, and Tasks 5 and 6 cannot execute without it — so the earlier draft's "either land Task 7 first or build it here, but say which" is answered: build it here. Minimal means one parsed tree per page over ui.Templates()` plus the app's layout, no asset pipeline. Task 7 adds the vendored assets, the scriptless sweep and the RP-ID guard on top.

An earlier draft used signin.Classifier alone and dropped the Keymail upgrade on the floor. It computed a class no code path consumed, and did it after the mail had already gone. §5.1 requires "auto-upgrading to Keymail when the address has a claimed inbox", so the seam is one level up.

signin.Flow is that seam, and the reason to take it is that it is the same seam rastrillo/auth uses — minus the address-bearing tables ruling 14 forbids. Flow.Links is an interface (verified at v0.1.1):

PutLink(ctx, hash, address, purpose string, expires time.Time) error
TakeLink(ctx, hash, purpose string) (address string, ok bool, err error)

So the app supplies the store, and sealing happens inside it. PutLink seals before the insert; TakeLink runs Task 4's single DELETE … RETURNING and opens the result. The package's own comment on TakeLink says it "must delete the row it returns", which is the same single-use rule Task 4 arrived at independently.

Wiring Flow then delivers, for free, four things the earlier draft either lost or would have had to write:

  • The Keymail upgrade (§5.1). Begin classifies, and on a claimed inbox takes beginKeymail — an HMAC'd pending blob in a cookie, and CompleteKeymail with an address-mismatch check — instead of mailing.
  • Rate limits on POST /signin, which the earlier draft had none of, leaving a mail-bomb vector. Defaults are 20 per IP and 5 per address per 15-minute window, and a nil Limiter builds a MemoryLimiter rather than being silently unlimited.
  • CRLF-safe address parsing via SplitAddress.
  • LinkTTL, defaulting to 15 minutes — the link lifetime the earlier draft never named.

Configuration: Origin: "https://id.oficina.works", LinkPath: "/signin/verify", a non-zero Key from config (it seals the pending blob and must not come from an instance data key — sign-in runs before any session exists), Mailer (rastrillo's mail.Sender and signin.Mailer have identical method sets, so the capturing test sender plugs straight in with no adapter), Classifier, and Keymail with RedirectPath: "/signin/callback" — its default is /auth/callback, so mounting the route without setting this leaves the two disagreeing.

Mount csrf.Protect(origin) router-wide, not just on the verify POST. POST /signin needs it too: rastrillo/auth checks same-origin on its own Begin and its comment says why — "an unauthenticated endpoint that sends mail and makes outbound DNS/HTTPS calls decided by the submitted address (a blind-SSRF surface by design)". csrf.Protect is pure origin evidence (Sec-Fetch-Site, then Origin, then Referer) and never consults a session, so it works before anyone is signed in.

The per-IP budget collapses into a suite-wide sign-in DoS, and this is settled here rather than left to be found. An earlier draft of this paragraph said RemoteAddr is "" behind the unix socket and the budget is therefore skipped. Measured, and it is worse than that: over a Linux unix socket r.RemoteAddr is "@". net.SplitHostPort("@") fails, auth's clientIP returns "@" unchanged, and Flow.Begin skips the budget only when the key is exactly "". So every request in the world shares the key ip:@, with a budget of 20 per 15 minutes — twenty POSTs to /signin lock out sign-in for the entire suite for a quarter of an hour. rastrillo's own comment admits the shape ("collapsing the per-IP budget into a global one"). §2.1 already names this service the suite's single point of failure; this would make it a trivially reachable one.

Take the client IP from X-Forwarded-For. The carlos edge sets it on every proxied request (SetXForwarded) and strips any inbound Forwarded/X-Forwarded-* on unverified hops (stripForwardedHeaders), so the header is trustworthy at the app and a client cannot forge it. Use the last element; fall back to RemoteAddr only when the header is absent, and never pass "@" as a limiter key. Test both paths — a request with the header gets its own budget, one without does not silently share a global one.

Classification fails open (§5.1). The package already guarantees it — "every failure mode — DNS, transport, malformed body, non-200 — answers not keymail" — so the app must not add a failure path on top. Still bound the Classifier.HTTP client with a timeout: it makes outbound DNS and HTTPS requests to whatever domain the person typed.

Add GET /signin/callback mirroring auth.Callback for the Keymail round trip. Redirect to it, never render it: the return host is address-chosen, and rendering an account-influenced string on the credential origin is what §4 forbids and gate 5 asserts.

  • [ ] Step 1: Write gate 2 in full, as a failing test
// TestFullSigninLeavesNoReadableAddress is §12 gate 2: "a full sign-in
// plus directory upsert plus rekey leaves no readable address anywhere,
// WAL included." The directory and rekey halves arrive in step 4; this
// asserts the sign-in half and must be EXTENDED, not replaced, then.
func TestFullSigninLeavesNoReadableAddress(t *testing.T) {
	dbPath := filepath.Join(t.TempDir(), "home.db")
	app := newTestApp(t, dbPath)

	// Lowercase and address-shaped: the blind index and signin's rate
	// limiter both key on strings.ToLower(address), so a canary that is
	// only ever upper-case cannot detect a leak of the lowercased copy.
	// sealtest.CanaryAddress, not a composed string: the ported Canaries
	// is [][]byte, and the ported canaries are already address-shaped
	// (canary-8f3a@example.invalid), so concatenating an "@example.invalid"
	// onto one gives a two-@ value that SplitAddress rejects outright.
	signIn(t, app, sealtest.CanaryAddress)
	sealtest.AssertNoPlaintext(t, dbPath)
}

Plus the classification test, asserting on the flow's decision rather than on a constant. signin.ClassKeymail does not exist — an earlier draft invented it and flagged it as the one identifier not verified at source, which was the right instinct and the wrong response. Class is a struct {Keymail bool; Server string; Reauth bool}:

func TestFailedClassificationStillSendsAMagicLink(t *testing.T) {
	app := newTestApp(t, filepath.Join(t.TempDir(), "home.db"))
	app.flow.Classifier.LookupTXT = func(context.Context, string) ([]string, error) {
		return nil, errors.New("servfail")
	}
	// force is signin.Method, a string type — not 0.
	next, err := app.flow.Begin(context.Background(), "someone@example.invalid", "", signin.Method(""))
	if err != nil {
		t.Fatalf("Begin: %v", err)
	}
	if next.Kind != signin.NextMagicLink {
		t.Fatalf("Kind = %v, want NextMagicLink: classification must fail open", next.Kind)
	}
}
  • [ ] Step 2: Run them

Expected: FAIL — no /signin route, no flow field.

  • [ ] Step 3: Wire the Flow and implement Begin

POST /signin calls Flow.Begin and then branches on next.Kind — an earlier draft said "303 to a check-your-mail page, always", which drops the Keymail upgrade this task exists to deliver:

  • NextMagicLink — the mail has already been sent by the time Begin returns. 303 to the check-your-mail page.
  • NextKeymail — no mail was sent. Begin returns Redirect and Pending; set the pending cookie and 303 to next.Redirect.

Both branches must be indistinguishable to an attacker probing for known addresses. The Keymail redirect necessarily differs, but it is decided by the address's domain, which the submitter already knows — it discloses nothing about whether that person has an account here.

The pending cookie's attributes are load-bearing and auth keeps them unexported, so they must be replicated deliberately: __Host- prefix, SameSite=Lax, HttpOnly, Secure, and cleared on read. They are exactly what the "callback may mint on GET" argument below rests on; get them wrong and that argument stops being true.

Carry the force escape hatch. auth.Callback redirects to ?force=1&err=keymail when the Keymail round trip fails, and without it a failed approval is a dead end — against §5.1's "every address has a working path". Flow.Begin's last parameter is that override.

  • [ ] Step 4: Verify as GET-renders, POST-redeems

The magic link must not sign anyone in on GET. An earlier draft had GET /signin/verify look up the token and mint. Two things break:

  • Mail security scanners prefetch links. The scanner burns the single-use token and the person's own click fails. Routine, not hypothetical.
  • It is login CSRF. Any page the victim loads can embed <img src="https://id.oficina.works/signin/verify?token=…"> and silently sign them into an account the attacker controls, after which everything they do happens in the attacker's session. §5.6 names login CSRF for the assertion; this is the same attack one hop earlier.

rastrillo's own auth.Verify redeems on GET, so this is inherited rather than invented — and the fix is free under zero-JS:

  • GET /signin/verify?token=… touches no state. It renders a scriptless confirmation page: one form, the token in a hidden field, one button. It must not look the token up — that reintroduces the oracle and lets a prefetch probe validity. The parameter is token, which is what sendLink builds; an earlier draft said t.
  • POST /signin/verify behind csrf.Protect does the work: Flow.CompleteMagicLink (which calls TakeLink), then find-or-mint the person with the blind index, then sessions.SignIn with Subject: personRef and Method/AuthTime copied from the Identity.

Cross-device still works (§5.5): the token rides the URL, not a cookie.

GET /signin/callback may mint on GET, and the difference is not arbitrary. The Keymail return is bound to the browser that began the flow by the __Host- pending cookie plus state, so an attacker cannot make a victim's browser complete a flow it never started. The magic link has no such binding — anyone holding the URL can replay it — which is exactly why it needs the form. Redirect to the callback, never render it: the return host is address-chosen, and rendering an account-influenced string on the credential origin is what §4 forbids and gate 5 asserts.

  • [ ] Step 4a: Build blindIndex, find-or-mint, and the test helpers

The blind index and the find-or-mint upsert were prose in Task 4 and in nobody's git add. They belong here, with the person lookup that uses them: blindIndex(pepper, address), the INSERT … ON CONFLICT(blind_index) DO UPDATE … RETURNING person_ref, and people.created_at written coarsened to the day.

signIn(t, app, addr) and capturedLink(t, app) are built here too — every later test goes through them. signIn must send an Origin header, or csrf.Protect refuses its POSTs and every test fails for the wrong reason.

  • [ ] Step 5: Prove the three properties that were wrong
// A returning person gets the SAME person ref. Without the blind index
// every sign-in minted a new one, so nobody could ever return.
func TestSecondSigninFindsTheSamePerson(t *testing.T)

// Two concurrent redemptions of one link mint exactly one session.
func TestConcurrentRedemptionMintsOneSession(t *testing.T)

// A GET does not redeem: a mail scanner's prefetch must leave the link
// usable, and must not sign anybody in.
func TestGetDoesNotRedeemTheLink(t *testing.T)

And one that keeps the oracle shut: unknown, expired and already-used tokens must produce byte-identical responses.

  • [ ] Step 6: Run the gate, then prove it bites

Run gate 2. Then break the seal deliberately and watch it fail, as Task 4 step 4 did for the store.

  • [ ] Step 7: Commit
git 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
git commit -m "Sign-in through signin.Flow, with the link redeemed on POST"

---

Task 6: Passkeys and recovery codes

Unblocked 2026-09-05 by ruling 21: §11 has its carve-out, so the enrolment and ceremony screens may load script and nothing else may.

Files:

  • Create: internal/home/passkey.go, internal/home/passkey_test.go
  • Create: internal/home/templates/passkey_confirm.html, passkey_enrol.html, recovery_codes.html
  • Create: internal/home/static/passkey.js — the app's own glue (under internal/home/ so go:embed can reach it)
  • Modify: internal/home/app.go, internal/home/models.go (where Task 2 put Schema/BootSchema — there is no db.go)

Interfaces:

  • Produces: (*App).passkeys *passkey.Handlers, mounted at the seven routes below.

Verified at v0.22.0 so the executor need not re-derive it:

  • recoveryCodesPerSet = 10 — matches §5.2's "ten" exactly.
  • 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."
  • 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.
  • 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.
  • Losing passkey and codes together is a permanent lockout: §5.2's disclosed outcome with no operator reset (§14). Not a gap to fill.

Struck from an earlier draft: "If passkeys leave slice one, this task becomes 'recovery codes as a second sign-in path' and stays scriptless." That is a spec substitution, not a fallback. The Gate never signs anybody in from nothing, and §5.2 defines codes as recovery for the second factor; codes alone are a first-factor bypass the spec never authorised.

  • [ ] Step 1: Merge passkey.Schema

Task 2's migrate.Merge(sessions.Schema, Schema) omits it, and Gate INSERTs into passkey_pending, so without this the first enrolled sign-in fails at the database. All four passkey tables key on subject, which is already the person ref — so the spec's worry about inheriting auth's address-bearing subject dissolves. Say so; do not leave it to be rediscovered.

  • [ ] Step 2: Write the failing test that one factor is not enough
// An enrolled person must NOT obtain a full session from the magic link
// alone. This is the whole point of the Gate, and the test that would
// have caught the callback bypass below.
func TestEnrolledPersonCannotSignInWithOneFactor(t *testing.T)

Enrol with webauthn/authtest, which is what rastrillo's own passkey_test.go uses — do not hand-roll a credential.

  • [ ] Step 3: Put Gate on BOTH sign-in paths

At the point each handler would call sessions.SignIn, call passkey.Handlers.Gate(w, r, sess) — exactly as auth.admit does, and note that in rastrillo both Verify and Callback funnel through that one admit. This plan has two entry points, so it needs the call twice:

  • POST /signin/verify — the magic-link path.
  • GET /signin/callback — the Keymail path. An earlier draft named only the first, which left a Keymail-upgraded account with a passkey enrolled signing in with a single factor. That is the bug this task exists to prevent, arriving through the door the task forgot.

An account with no passkey passes the Gate untouched, so the unenrolled path is unchanged.

  • [ ] Step 4: Mount the seven routes and write the glue
POST /passkey/register/begin | /finish
POST /passkey/stepup/begin   | /finish
POST /passkey/signin/begin   | /finish
POST /passkey/signin/recovery

All behind csrf.Protect. Plus the GET pages and assets those seven imply, which an earlier draft left unrouted:

GET /passkey/confirm        ConfirmPath — the ceremony + the recovery form
GET /passkey/enrol          enrolment
GET /passkey/codes          the show-once page, behind RequireFresh
GET /static/webauthn.mjs    webauthn.JS(), text/javascript
GET /static/passkey.js      the app glue

Both on stable, unfingerprinted paths. Everything else may go through rastrillo's fingerprinting asset helper; these two may not, because Task 7's allowlist has to name them, and a rule that cannot name its subject is not a rule.

ConfirmPath is /passkey/confirm (its default, named rather than defaulted). RequireFresh's maxAge for the codes page is 5 minutes — it must be short, because the page shows ten codes once, and it must exceed the ceremony round trip.

Correcting this plan's own earlier claim that "nobody has to write it": webauthn.JS() embeds js/webauthn.mjs, which only exports register()/authenticate() and makes no network call. The app writes the glue that imports it, calls it, POSTs to /passkey/* and follows the to that SignInFinish returns. Both files are served as static same-origin assets — rastrillo's default CSP forbids inline script, and ruling 21's carve-out is for static files, not for inline blocks. Go's mime already maps .mjs to text/javascript; charset=utf-8, so serving it from the embedded FS needs nothing special — set the header explicitly anyway, since a wrong MIME type blocks a module silently.

register() needs rpId, userId and userName, while /begin returns only challenge. userId is the person ref. userName must not be the address — the credential origin never renders an account-authored string (§4), and the home does not hold the address anyway. A person ref reads poorly in a password manager; that is the cost of §0 and it is accepted.

  • [ ] Step 5: The confirm page carries the recovery form

The same screen hosts the ceremony and SignInRecovery's plain form. Script-strip it and the code form must still work — that is gate 6's requirement under ruling 21, and the reason the allowlist exempts the ceremony button rather than the page.

  • [ ] Step 6: Show the codes once, behind RequireFresh

RegenerateRecoveryCodes mints ten, shown once. The package requires that page to sit behind sessions.RequireFresh.

  • [ ] Step 7: Commit
git add internal/home/passkey.go internal/home/passkey_test.go internal/home/templates internal/home/static internal/home/app.go internal/home/models.go
git commit -m "Passkeys as an optional second factor, gated on both sign-in paths"

---

Task 7: The scriptless screens, and the RP-ID guard

Files:

  • Modify: internal/home/render.go (Task 5 created it); create the remaining internal/home/templates/*.html
  • 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.
  • Create: internal/home/noscript_test.go
  • 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.

Interfaces:

  • Produces: (*App).render(w http.ResponseWriter, name string, data any)

Follow 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.

Write the attribute spelling — rst-btn, not class="rst-btn" (ruling 20). An earlier draft of this plan said to write classes "because that is what the partials you render alongside write". That was true at v0.21.0 and false at v0.22.0, the version pinned: the partials flipped, and the class spelling in ui/partials/ fell from 124 occurrences to 6. rastrillo's design system rules one spelling with the class selectors dropped before 1.0, and this app has no markup yet, so it starts on the survivor.

Vendor static/tokens.css and static/theme.css (the day theme), then run rastrillo doctor --fix on each upgrade — it landed in v0.21.0, so the earlier draft's "until rastrillo doctor lands" was a trigger that had already fired. Add the TestVendoredAssetsMatchTheLibrary that rastrillo new scaffolds; this repo was hand-written, so it has no copy.

  • [ ] Step 1: The RP-ID guard

The earlier draft's version asserted nothing: it called an undefined configuredOrigins, counted dots (strings.Count(host, ".") < 2, which is not "the RP ID is the full host" and passes for any three-label host), and ignored LegacyRPID entirely.

// Rulings 2, 13 and 16: "The RP ID is the full host, asserted by a test."
//
// Two doors, both accepted silently:
//
//   - passkey.Config has NO RPID field. The RP ID is always
//     url.Parse(Origin).Hostname(), so widening it means widening Origin,
//     or bypassing passkey and building a webauthn.Config directly.
//   - passkey.Config.LegacyRPID is passed straight through as a SECOND
//     accepted RP ID — for assertions only; registration never takes it
//     (webauthn.go rpMatches, allowLegacy). Set to "oficina.works" it
//     collapses the split §4 exists to make.
// This test lives in package home, NOT internal/guardstest: it reads the
// config the APP builds, via a helper over the unexported App, and a
// guards-package test cannot call one.
func TestRPIDIsExactlyTheCredentialHost(t *testing.T) {
	cfg := passkeyConfig(t) // what the app configures, never a literal

	u, err := url.Parse(cfg.Origin)
	if err != nil {
		t.Fatalf("parse Origin %q: %v", cfg.Origin, err)
	}
	if u.Hostname() != "id.oficina.works" {
		t.Errorf("RP ID is %q, want id.oficina.works: passkey derives the RP ID "+
			"from Origin's hostname, so widening Origin widens it (rulings 13, 16)",
			u.Hostname())
	}
	// LegacyRPID must be empty, or a full host, with a comment naming the
	// §5.7 drill that is running. HasSuffix, not Contains: Contains accepts
	// "x.oficina.works.attacker.example".
	if cfg.LegacyRPID != "" && !strings.HasSuffix(cfg.LegacyRPID, ".oficina.works") {
		t.Errorf("LegacyRPID %q widens the RP ID (§4, ruling 2)", cfg.LegacyRPID)
	}
}

Add ruling 2's other half while here: the sessions cookie on the credential origin is __Host- and host-only (verified in sessions.setCookie). Assert no cookie set there carries a Domain.

  • [ ] Step 2: Every screen works with scripts off

§11 is enforceable mechanically and should be. Ruling 21's carve-out is an allowlist, and an allowlist of screens is not enough — it would admit any script at all on those screens. Constrain it:

  • The only <script> on an allowlisted template is a same-origin src from a named list. No inline bodies.
  • Assert the served CSP is still rastrillo's default (Options.CSP untouched), so the carve-out cannot quietly widen into unsafe-inline.
  • Script-strip the confirm page and assert the recovery code form still posts to /passkey/signin/recovery — gate 6's shape, and the reason the exemption is the ceremony button rather than the screen.
// scriptAllowed is ruling 21's allowlist, by TEMPLATE and by SRC. Naming
// the screens is not enough — that would admit any script at all on them.
// Paths, not fingerprints. rastrillo's NewAssets.Path fingerprints
// (/static/passkey.<hash>.js), so if these templates used the asset func
// the allowlist would never match and the carve-out screens would fail
// their own sweep. Task 6 routes these two on STABLE paths for exactly
// that reason — they are the only two assets that need to be nameable in
// a security rule.
var scriptAllowed = map[string][]string{
	"passkey_confirm.html": {"/static/webauthn.mjs", "/static/passkey.js"},
	"passkey_enrol.html":   {"/static/webauthn.mjs", "/static/passkey.js"},
}

func TestNoScriptTagOutsideTheAllowlist(t *testing.T) {
	for _, name := range templateNames(t) {
		var buf bytes.Buffer
		renderTo(t, &buf, name, sampleData(name))
		srcs := scriptSrcs(t, buf.Bytes()) // parses, so an inline body has no src
		if len(srcs) == 0 {
			continue // no script at all: the common case
		}
		permitted, ok := scriptAllowed[name]
		if !ok {
			t.Errorf("%s renders a <script>; §11 says every screen works with scripts off "+
				"and ruling 21 carves out only the passkey screens", name)
			continue
		}
		for _, src := range srcs {
			if src == "" {
				t.Errorf("%s has an INLINE <script>; the carve-out is for static "+
					"same-origin files, and rastrillo's default CSP forbids inline", name)
			} else if !slices.Contains(permitted, src) {
				t.Errorf("%s loads %q, which is not in ruling 21's allowlist", name, src)
			}
		}
	}
}

// The carve-out must not widen into unsafe-inline behind our backs.
//
// Do NOT reference rastrillo's defaultCSP — it is an unexported const in
// serve.go. Assert that the app leaves Options.CSP empty and that the
// SERVED header's default-src and script-src carry no 'unsafe-inline'.
// (§11 says the credential origin moves to script-src 'self'; rastrillo's
// default already delivers that via default-src 'self', so there is
// nothing to override — which is the point of asserting it stays empty.)
func TestServedCSPIsRastrillosDefault(t *testing.T)

// And the one screen that carries script must still work without it:
// passkey.SignInRecovery is a plain form POST "because recovery is exactly
// the moment WebAuthn isn't working".
func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T)
  • [ ] Step 2a: Build the test helpers this task's guards need

templateNames(t), renderTo(t, w, name, data), sampleData(name), scriptSrcs(t, b) (parse the HTML and return each <script>'s src, empty string for an inline one) and passkeyConfig(t) — all five are used by the two guards above and created by no step in an earlier draft. passkeyConfig must return the config the app builds, not one the test declares, or the RP-ID guard asserts a constant against itself. passkey.Handlers keeps its config unexported, so the App must retain the passkey.Config it passed to passkey.New for the helper to hand back — one field, and the executor otherwise loses an hour to it.

  • [ ] Step 3: Commit
git add internal/home/render.go internal/home/templates internal/home/static internal/home/rpid_test.go internal/home/noscript_test.go
git commit -m "Scriptless sign-in screens, and the guard that the RP ID is a full host"

---

Deferred, with triggers

| Thing | Trigger | |---|---| | ~~Passkey enrollment and step-up~~ | Ruled 2026-09-05 (ruling 21): carve-out granted, Task 6 builds it. | | ~~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 | | Assertion minting, jti, the redirect protocol | §13 step 3 | | The directory, calls 2 and 3 | §13 step 4 | | Operator reset | §5.2: "the first real person locked out with passkey and codes both lost" |

Self-review notes

  • 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.
  • Deliberately not covered: prefs, directory, teams, provisioning_queue, rekey_journal, consumers, assertion_jti — later steps.
  • ~~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.
  • ~~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.
  • ~~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.