DKIM rotation broke the domain it was run on. It retired the live key and
inserted a placeholder as ACTIVE - private_key_enc was the literal string
"dummy-encrypted-key" - so FindActiveKeys then failed for the whole
domain and outbound mail stopped being signed, with the real key already
gone. Reproduced against a database before changing anything.
The root cause runs deeper: the auth service derives its encryption key
with a plain SHA-256 of the master key while the protocols service uses
HKDF with its own info string, so nothing the auth service seals can ever
be opened by the signer. Rotation therefore moves to the protocols
service, which owns that vault and the real key generator. It retires the
previous key with a seven-day overlap in the same transaction rather than
deleting it, because mail already sent under it is still being verified.
Domain onboarding had three defects in one function. It built the alias
destination by interpolating the domain name into the SQL text, which was
injectable; it reused one placeholder for a CITEXT and a VARCHAR column,
which made the statement fail and happened to hide the injection; and it
ignored its scope argument entirely, so any DOMAIN_ADMIN could create
domains. It also provisioned postmaster, abuse and security, missing the
dmarc and tlsrpt aliases that the published records point at - PLAN.md
section 7.4b is explicit that reports bounce without them.
The TLS panel returned fixed values: acme_status "VALID" and 72 days to
expiry, whatever the process actually held. It now reads the certificate
watcher, and a compile-time assertion keeps the wiring from silently
regressing into "disabled". The audit log and the readiness scorecard were
fixtures too, and their field names did not match the API they now read.
Two tests failed and a third asserted a fabricated constant: one depended
on test order through a shared fixture that had a second factor enrolled,
one used a fixed domain name and so passed once per database, and one
expected "records: 13" from a function that never touches DNS. All three
are fixed, and the suite now passes twice in a row against one database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sessions and password changes were the two things PLAN.md section 14.2
asks of the account area that were still missing, and the admin console
had no audit trail at all despite section 14.3 requiring one for every
mutation.
Session revocation is enforced on every request, not just recorded. A JWT
carries its own validity, so revoking a row without checking it would have
made "sign out this device" a button that did nothing until the token
expired eight hours later. Changing a password revokes every other session
for the same reason: a password change that leaves the attacker's session
alive has not locked anyone out.
Admin queries are scoped by role in the SQL rather than in the UI, as
section 14.3 requires: a DOMAIN_ADMIN calling the API directly sees only
its own domain, and a mailbox outside that scope answers 404 rather than
403 so the scope cannot be used to enumerate what exists elsewhere.
The two mutations that exist so far - enabling or disabling a mailbox, and
retrying or cancelling a queued delivery - write actor, IP and target to
audit_log, and there is an endpoint to read it back.
Covered by tests against a real database: a revoked token stops working
mid-session, a password change needs the current password and takes the
other sessions down with it, and the minimum length is enforced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The F4 auth service issued a valid session for any email and any password,
and accepted any non-empty string as a second factor. Reproduced against
the router before touching it: an invented password plus the code "000000"
returned a SUPER_ADMIN token that opened /api/v1/admin/dashboard.
Passwords. Hashing moves from PBKDF2 to Argon2id via hash-wasm, which is
what PLAN.md section 9 and the Go service already use - the previous
scheme meant a password set through the mail stack could never verify
here. Interoperability was checked in both directions before adopting the
library: a hash written by alexedwards/argon2id verifies in Node and one
written here verifies in Go. hash-wasm is pure WASM, so the multi-arch
Alpine images need no native build.
verifyPassword no longer falls back to comparing the password against the
stored hash. That fallback turned every stored hash into a working
credential: anyone with a database dump could authenticate by sending the
hash back. Anything that is not an argon2id PHC string is now refused.
Logins are decided against the database, with the failure counter and the
temporary lockout of PLAN.md section 5.2. An unknown address and a wrong
password give the same answer, and the unknown case still pays for a hash
so response time does not enumerate valid addresses.
Second factor. The TOTP secret is generated server-side, stored encrypted
and never accepted back from the client - the confirm step used to take
whichever secret the caller sent, so anyone could confirm with their own.
Recovery codes are Argon2id, single-use, and consumed under a guard so two
concurrent requests cannot spend the same one. App passwords are issued
and revoked through real endpoints instead of browser state.
Sessions gain a "purpose" claim. Without it the challenge token - handed
out after the password but before the second factor - satisfied every
check a real session did, which skipped MFA entirely.
The dev seed carried a hand-written password_hash whose digest was 24
bytes instead of 32, so it matched no password at all. Nothing noticed
while passwords went unverified; it is a real hash now.
Verified with 30 tests, 13 of them against a real Postgres: wrong password,
unknown account, pass-the-hash, wrong TOTP code, challenge token replay,
lockout, single-use recovery codes, and the surface isolation that F4's
acceptance criteria require.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>