§13 step 2: sign-in, the second factor, and recovery codes
The home can now sign a person in. Seven tasks from
docs/superpowers/plans/2026-09-03-home-signin.md, executed across 23
commits on step2/seal-and-secrets, and six adversarial review rounds.
## What is here
- internal/home/seal: values sealed at rest, ported from sheets-core
unchanged apart from the HKDF info string, with vectors pinning the
derivation.
- Four secrets loaded in one place, refusing rather than degrading,
including all-zero keys — the canonical placeholder, well-formed
enough to pass every other check.
- The schema: people (person ref, blind index, created_at — no address
column) and signin_links (sealed address, no redeemed_at, taken by a
single DELETE ... RETURNING so unknown, expired and already-used are
one answer by construction).
- The person ref: opaque, minted, never derived from an address.
- Sign-in through keymaildev/signin, with the Keymail upgrade, driven
end to end against a federation stub.
- The magic link redeemed on POST, never GET: a mail scanner's prefetch
must not burn it, and an <img src=".../verify?token=…"> must not sign
a victim into an attacker's account.
- Passkeys as an optional second factor, gated on BOTH sign-in paths,
with ten single-use recovery codes redeemed by a plain form POST that
needs no script — because recovery is exactly the moment WebAuthn is
not working.
- Scriptless screens with ruling 21's carve-out named by template AND by
src, the RP-ID guard, and the vendored stylesheets.
- Gates 1 and 2, and every gate on this branch watched failing on the
thing it guards.
## What review caught that would otherwise have shipped
- A boot path that could not have started on the platform at all: it
required a --db flag that carlos-app@.service never passes.
- The second factor removable from the keymail door by a one-line
spelling change, because its only guard was a text grep.
- A magic-link expiry predicate with no test that could fail — the
expired row was deleted by its own PutLink sweep before TakeLink saw
it.
- A schema guard whose "sealed" exemption let unsealed_address through.
- A scriptless sweep blind to any <script> inside an {{if}} its fixture
did not satisfy, and to </script > with a space.
- An oracle test that normalised away the difference it was looking for.
- An all-zero key accepted by every check.
- A stolen session cookie able to enrol its own passkey and, with no
operator reset by design, lock the owner out permanently.
- A freshness window that bounced people to re-authenticate at the exact
moment they held a passkey and zero recovery codes.
## Two things filed for Paul, not decided here
- §3.1 says the blind index "uses an expensive KDF rather than a bare
HMAC"; ruling 3 says HMAC, and the code implements the ruling — so
§3.1 is currently false about the code. Filed as a marked OPEN note in
the spec with both sides argued.
- Enrolment now requires a fresh session. §5.2 says a passkey is
"optional per person" and says nothing about how recently you must
have proved yourself to add one; I took the safer side because the
failure it prevents is unrecoverable.
## Not deployable yet, and deliberately so
cmd/home refuses to start without HOME_DEK, HOME_PEPPER,
HOME_SIGNIN_KEY, CARLOS_SMTP_PASS and the SMTP host and from address.
None is provisioned. Nothing in .amadan/ci.d deploys on merge, so this
is safe to land; deploying is what needs the secrets first, and the unit
is Restart=always, so deploying without them would 502 the site until a
rollback rather than leaving the old build serving.
Reviewed by Fable (rounds 1-2) and Opus (rounds 3-6, after Fable hit a
session rate limit); mutation testing throughout, which is what found
most of the above. Second opinion from gpt-6-astra via codex on the
step-3 signing decision.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
52 files changed,
+7513
−78
.amadan/ci.d/50-race+8 −0AGENTS.md+9 −5Makefile+19 −8cmd/home/main.go+80 −22docs/superpowers/plans/2026-09-03-home-signin.md+2 −1docs/superpowers/plans/2026-09-07-home-assertion.md+355 −0docs/superpowers/specs/2026-08-30-home-design.md+21 −0go.mod+9 −2go.sum+30 −8internal/guardstest/logtest_containment_test.go+22 −11internal/guardstest/noaddress_test.go+164 −0internal/guardstest/repo_test.go+39 −0internal/home/app.go+169 −18internal/home/app_test.go+2 −2internal/home/db.go+64 −0internal/home/db_test.go+170 −0internal/home/keymail_test.go+195 −0internal/home/keymailstub_test.go+185 −0internal/home/keys.go+197 −0internal/home/keys_test.go+215 −0internal/home/links.go+163 −0internal/home/links_test.go+327 −0internal/home/logs_test.go+24 −1internal/home/migrations/0001_people.sql+13 −0internal/home/migrations/0002_signin_links.sql+24 −0internal/home/models.go+39 −0internal/home/noscript_test.go+449 −0internal/home/passkey.go+231 −0internal/home/passkey_test.go+412 −0internal/home/personref.go+74 −0internal/home/personref_test.go+80 −0internal/home/render.go+93 −0internal/home/rpid_test.go+89 −0internal/home/seal/seal.go+106 −0internal/home/seal/seal_test.go+53 −0internal/home/seal/vectors_test.go+79 −0internal/home/sealtest/sealtest.go+107 −0internal/home/sealtest/sealtest_test.go+136 −0internal/home/signin.go+385 −0internal/home/signin_test.go+535 −0internal/home/static/passkey.js+83 −0internal/home/static/theme.css+174 −0internal/home/static/tokens.css+1550 −0internal/home/templates/check_your_mail.html+6 −0internal/home/templates/layout.html+17 −0internal/home/templates/passkey_confirm.html+20 −0internal/home/templates/passkey_enrol.html+11 −0internal/home/templates/recovery_codes.html+17 −0internal/home/templates/signin.html+10 −0internal/home/templates/signin_confirm.html+9 −0internal/home/templates/signin_failed.html+6 −0internal/home/testapp_test.go+236 −0
CI passed — run details
.amadan/ci.d/50-raceLoading diff…
AGENTS.mdLoading diff…
MakefileLoading diff…
cmd/home/main.goLoading diff…
docs/superpowers/plans/2026-09-03-home-signin.mdLoading diff…
docs/superpowers/plans/2026-09-07-home-assertion.mdLoading diff…
docs/superpowers/specs/2026-08-30-home-design.mdLoading diff…
go.modLoading diff…
go.sumLoading diff…
internal/guardstest/logtest_containment_test.goLoading diff…
internal/guardstest/noaddress_test.goLoading diff…
internal/guardstest/repo_test.goLoading diff…
internal/home/app.goLoading diff…
internal/home/app_test.goLoading diff…
internal/home/db.goLoading diff…
internal/home/db_test.goLoading diff…
internal/home/keymail_test.goLoading diff…
internal/home/keymailstub_test.goLoading diff…
internal/home/keys.goLoading diff…
internal/home/keys_test.goLoading diff…
internal/home/links.goLoading diff…
internal/home/links_test.goLoading diff…
internal/home/logs_test.goLoading diff…
internal/home/migrations/0001_people.sqlLoading diff…
internal/home/migrations/0002_signin_links.sqlLoading diff…
internal/home/models.goLoading diff…
internal/home/noscript_test.goLoading diff…
internal/home/passkey.goLoading diff…
internal/home/passkey_test.goLoading diff…
internal/home/personref.goLoading diff…
internal/home/personref_test.goLoading diff…
internal/home/render.goLoading diff…
internal/home/rpid_test.goLoading diff…
internal/home/seal/seal.goLoading diff…
internal/home/seal/seal_test.goLoading diff…
internal/home/seal/vectors_test.goLoading diff…
internal/home/sealtest/sealtest.goLoading diff…
internal/home/sealtest/sealtest_test.goLoading diff…
internal/home/signin.goLoading diff…
internal/home/signin_test.goLoading diff…
internal/home/static/passkey.jsLoading diff…
internal/home/static/theme.cssLoading diff…
internal/home/static/tokens.cssLoading diff…
internal/home/templates/check_your_mail.htmlLoading diff…
internal/home/templates/layout.htmlLoading diff…
internal/home/templates/passkey_confirm.htmlLoading diff…
internal/home/templates/passkey_enrol.htmlLoading diff…
internal/home/templates/recovery_codes.htmlLoading diff…
internal/home/templates/signin.htmlLoading diff…
internal/home/templates/signin_confirm.htmlLoading diff…
internal/home/templates/signin_failed.htmlLoading diff…
internal/home/testapp_test.goLoading diff…