VERDICT: CHANGES-REQUIRED
Reviewer: Astra (GPT-6-Astra via Codex), posting through the configured agent account claude~paul@keymail.dev. Whole-branch review of fix/keymail-signin at a61f0d274d2131dfbb06259d5d90a48c3e787459 against fetched origin/main 728851566dc4ae8e7f635b287c311e305b696bbc (also the merge base). No branch edits, commits, pushes, or merges.
- P1 — Pre-verification allowlist timing oracle remains.
internal/slides/auth.go:208–212(admissionMailer.Send). A denied address returns immediately, whereas a permitted address waits for the real mail sender. Pinned signin.Flow.sendLink calls this synchronously, and neither sameAnswer nor OAuthContinuation sends the HTTP response until Begin returns. An unauthenticated caller can submitforce=1for any candidate, including Keymail addresses, bypass DNS/classification variance, and distinguish admission through SMTP latency. Matching status, headers, cookies, and body does not hide this. A scratch HTTP-handler probe with a controlled 250 ms sender measured alternating denied/allowed requests at 0.351 ms / 252.195 ms / 0.345 ms / 252.194 ms, all returning303 /signin?sent=1; the no-timing-oracle assertion failed. This preserves an existing weakness of main's synchronous permitted-email path, rather than introducing it, but it fails this review's explicit timing requirement and the branch's stated no-oracle guarantee. Fix: remove admission-dependent delivery work from the response path. Enqueue equivalent bounded work for all syntactically valid email attempts, then check admission and deliver only permitted mail in the worker; keep enqueue/error responses admission-independent, retain rate limits, and add a blocking/slow-sender regression proving the response does not wait selectively. A fixed sleep is not sufficient against slow or failed SMTP.
The rest of the security trace:
- Unforced Keymail path: no allowlist branch before verification. Both candidates classify, receive the same continuation, and spend the same budgets. Additional probes compared all nonrandom response headers and cookie attributes and exercised seven attempts per address: both get 200 for attempts 1–5 and the masked rate-limit 303 thereafter. Pending-cookie values are randomized encrypted state and contain the submitted address; byte equality is not expected. Mail fallback is the exception described in finding 1.
- Destination validation: newBegin does invoke the pinned connect.OAuthContinuation/keymailDestination check. Through the app wrapper I confirmed rejection of HTTP, a different path, duplicate clientid, fragments, userinfo, wrong scope, and non-S256 challenges, with 502 and no pending-cookie leak. Valid destinations require HTTPS, /oauth/authorize, app-bound clientid and redirecturi, identify scope, state, and S256 challenge. The page contains the localized Slides door, a meta refresh and a Continue to Keymail link, with no-store and no-referrer. The check does not authenticate or allowlist the hostname: it intentionally permits the federation host chosen by the submitted domain. It is not proof that an arbitrary DNS-selected host is keymail.dev or trustworthy; this is the existing federation trust model, not a free-form returnto redirect. The form still has CSRF protection.
- Refusal wrapper: requires BOTH 403 and the exact trimmed rauth refusal sentence. Direct probes preserved CSRF-style failures, address-mismatch failures, other statuses, and cookies. A real mismatched Keymail callback remained a bare 403 rather than being mislabeled as admission refusal. Outer CSRF and LocalGuard errors are outside this wrapper.
?err=refuseditself is NOT authenticated: anyone can construct a URL showing the generic refusal sentence. It discloses no address, performs no allowlist lookup, grants no access, and is not evidence that verification occurred; I regard this as a presentation limitation rather than another merge blocker. - Home: an additional permitted Keymail recovery round trip verified FreshLocal, replacement/revocation of the previous Home session, navigation through LocalComplete back to /account/home, access to the link screen without creating another user, and offboarding after removal from the allowlist. The existing real Home exchange/link and revocation tests also passed in make ci. FreshLocal still refuses Keymail without a fresh auth_time.
- Stub isolation: keymailtest.Install is called only from the two _test.go TestMain functions, restores the globals on exit, and has no init side effect.
go list -deps ./cmd/slidesexcludes keymailtest; it does not enter the production binary. - Fallback/rates: an actual invalid-code exchange reached /signin?force=1&err=keymail and rendered the hidden force field; the branch's forced-mail test passed. Home's connect.Routes.Signin routes err/force query results to LocalSignin. Both ordinary and forced attempts remain behind the pinned IP/address budgets. Production edge traffic shares the connection-IP budget, as documented by rauth.
Validation on the unmodified reviewed head:
GOTOOLCHAIN=go1.26.5 make ci— PASS (vet, formatting, Go tests, migrations, credits).GOTOOLCHAIN=go1.26.5 RASTRILLO_CHROME="$HOME/Library/Caches/ms-playwright/chromium-1234/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" go test -tags browser ./internal/slidestest -count=1— PASS, 126.159 s. Chromium validates the browser suite; this was not a Safari execution.amadan ci status oficina/slides -branch fix/keymail-signin— exit 0, CI passed at a61f0d2.- Four independent scratch mutations, each restored after execution, all turned a distinct new regression test red at the intended assertion: force every Begin to email → TestKeymailAddressGoesToKeymailWhetherPermittedOrNot; bypass admissionMailer → TestNonPermittedPlainAddressIsAnsweredSentAndNotMailed; disable refusal interception → TestKeymailRefusalIsTheDoor; omit fallback force → TestKeymailFailureFallsBackToAnEmailLink.
- Additional scratch probes passed for Home recovery/revocation, destination checks, exact refusal interception, Keymail header/cookie/rate parity, actual exchange fallback, and mismatched-address refusal. The timing probe intentionally failed and establishes finding 1.
All mutation/probe edits were confined to detached worktrees under /tmp. The existing checkout and branch were left untouched; scratch worktrees are being removed after posting. Review evidence remains in /tmp/slides-astra-*.log on this machine.