Skip to content

Land auth OTP + Google + mail (A1–A6) onto main, with restored HIGH pre-hijacking fix#305

Merged
oratis merged 11 commits into
mainfrom
feat/mail-capability
Jul 25, 2026
Merged

Land auth OTP + Google + mail (A1–A6) onto main, with restored HIGH pre-hijacking fix#305
oratis merged 11 commits into
mainfrom
feat/mail-capability

Conversation

@oratis

@oratis oratis commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Why this PR exists

The six stacked PRs #289#293, #298 were squash-merged in order — but because they were stacked (each based on the previous branch, not main), squashing #289 made GitHub mark #290#298 as "merged" while only A1's commits actually landed on main. This is the exact stacked-PR trap the runbook warns about (#170).

Worse: a HIGH account-pre-hijacking fix that ultrareview added on the chain (a6be431, 0f651a4) was overwritten when later branches re-touched accounts.ts, so even the branch tips shipped the original vulnerable code.

This PR lands the complete A1–A6 diff on main in one clean merge, with the HIGH fix restored on both adoption paths.

What's here (net diff vs main)

  • A1 sign-in by mailed one-time code (server) — src/web/otp.ts
  • A3 Sign in with Google (server) — src/web/googleAuth.ts, one-address-one-account binding
  • A2 OTP across web / iOS / Mac / CLI
  • A4 Google on web (GIS) + iOS (PKCE, no SDK)
  • A5 runbook + deploy.sh env passthrough (Google client ids + RESEND — verified present)
  • A6 real mail layer on mail.meetlisa.ai (HTML+text, one logged exit, redaction, pre-send deliverability gate)

The restored HIGH fix

/api/auth/register is open, so an attacker can pre-create victim@x.co with a password they know. When the victim later signs in by code or by Google, adoption used to keep that password and not rotate sessionVersion → the attacker's credential still authenticated the account the victim just proved they own.

markVerifiedByOwnershipProof() now drops any pre-set password and rotates sessionVersion on the untrusted unverified→verified transition (only the first verification rotates, so the owner isn't logged out on later sign-ins). Both the OTP and Google-bind paths route through it. Explicit attacker-scenario tests for both.

Known follow-up (lower severity, deliberately not fixed here): the verify-link path can't take the same treatment — its legitimate flow (owner sets own password, clicks own link) is indistinguishable server-side from the attack, so dropping the password there would break normal email+password signup. Needs a design decision; requires the victim to click an unsolicited link and only upgrades the tier of an account the attacker already held at $1.

Verification

  • npm test1197 green, typecheck clean.
  • Mac swift build + iOS swiftc -typecheck clean; iOS redirect-scheme derivation checked against the real OAuth plist.
  • Web login (code + Google), CLI login, and both rendered emails driven end-to-end in earlier PRs; a real test email was delivered to Gmail (delivered, not spam-pending) through the production code path on mail.meetlisa.ai.

Supersedes the un-landed #290#293, #298. 🤖 Generated with Claude Code

oratis and others added 11 commits July 23, 2026 23:57
Passwordless sign-in for LISA accounts: request a 6-digit code, spend it,
and you are in. Reading the mail is the proof of ownership, so one step
both registers and authenticates — and the address comes out verified,
which is what levels the free window from $1 to $5.

- src/web/otp.ts: the code store behind the same file/Firestore seam as
  accounts.ts. One record per address, split into a challenge (hash,
  expiry, wrong-guess count) and a send budget (cooldown, daily cap) —
  the budget deliberately survives a spent code so redeeming one can't
  reset the mail-bombing cap. Codes are stored only as address-salted
  SHA-256 and compared in constant time; a code dies at the first of
  expiry, five wrong guesses, or being spent.
- accounts.ts: ensureOtpAccount() — lookup and insert inside one mutation
  so a Firestore CAS retry can't create an address twice. Password
  material is now explicitly optional; a code-only account fails the
  password path against the decoy params like any other bad credential.
- mailer.ts: the code mail, sharing one transport with the verification
  link. Without RESEND_API_KEY it degrades to a server log, so a real
  key is now a production requirement.
- POST /api/auth/otp/request + /verify, pre-gate like the other
  sign-in surfaces, behind the same per-IP backstop as register/login.
  The request answer is identical whether or not the address has an
  account — no membership oracle.

Password sign-in is untouched: App Review's demo account keeps working,
and a code is an additional way in rather than a replacement.

Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A0/A1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Google ID-token verification, and the account rule that keeps one person
on one balance no matter which door they come through.

- src/web/googleAuth.ts: the sibling of cloudAuth.ts — RS256 over Google's
  JWKS, zero dependencies, JWKS fetch and clock injected so it tests
  offline. Both issuer spellings; audience must be a client id we actually
  run (an empty config rejects rather than vacuously passing); nonce
  verified when present (Google echoes it raw, unlike Apple's hash).
  email_verified is REQUIRED — the address is what binds an identity to an
  existing account, so an unproven one would be a takeover primitive.

- accounts.ts: one address, one account. Email and Google accounts both
  claim their address (EMAIL_OWNER_KINDS), so every lookup spans both
  kinds: signing in by Google onto an address that already has an email
  account BINDS to it (same uid, same balance) rather than forking a
  second one, and a mailed code onto a Google-owned address signs into
  that same account. Apple is excluded on purpose — private-relay
  addresses are per-app aliases, not a claim on an inbox. Resolution is
  sub-first, so changing the address on a Google account keeps the LISA
  one.

- POST /api/auth/google, pre-gate like /api/auth/apple, behind the same
  per-IP backstop. /api/auth/config now advertises the web client id so
  the login page knows whether to draw the button.

Off unless LISA_GOOGLE_WEB_CLIENT_ID / LISA_GOOGLE_IOS_CLIENT_ID are set.

Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four clients for the A1 endpoints. Everywhere the shape is the same:
type an address, get six digits, you're in — and if the address is new
that same step creates the account, because reading the mail is the proof.
Passwords move one layer down ("Use a password instead") rather than away.

- web login page: code-first, with the step revealed only after a code is
  actually sent, the confirmation naming the address, and editing the
  address rewinding a step (an outstanding code belongs to the old one).
  A burned or expired code returns to "send one" instead of leaving a
  dead field. LOGIN_HTML joins the inline-<script> syntax guard.
- iOS: one form serves both Settings and onboarding, so they can't drift.
  SignInCodeError carries the server's typed reason — status alone can't
  tell "a code just went out" from "too many today", and those need
  different words.
- Mac menu-bar app: "Email me a code", then whichever credential is
  filled signs in (code first, else password).
- CLI: `lisa login` mails a code by default; `--password` keeps the old
  path. Wrong digits retry in place while the code is still live.

Two fixes found while testing the CLI end-to-end:
- one readline interface per command instead of one per prompt. A fresh
  interface drops what the previous reader buffered, so every prompt
  after the first read nothing as soon as stdin wasn't a terminal.
- an unreachable server is now a typed `unreachable` result rather than
  an exception caught by a blanket handler that reported every error —
  including bugs — as "could not reach".

Also documents login/logout/billing in `lisa --help`, which never listed
them.

Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- web: Google Identity Services renders its own button, drawn only when
  the instance advertises a web client id. Same nonce goes to Google and
  to our server (Google echoes it raw). The config fetch now feeds both
  providers, and the divider appears only when something sits above it.

- iOS: authorization code + PKCE via ASWebAuthenticationSession, with no
  SDK — the flow is ~100 lines of OAuth, and doing it here matches how
  the server verifies Apple/Google tokens with node:crypto rather than a
  library. The verifier never leaves the device, so intercepting the
  redirect gains nothing; state guards against a redirect we didn't
  start; the session is ephemeral so signing out doesn't leave the next
  person silently signed in. ASWebAuthenticationSession intercepts the
  callback itself, so no URL scheme has to be registered in project.yml.
  The button sits BELOW Sign in with Apple — Guideline 4.8 wants SIWA
  offered with equal prominence wherever a third-party login is.

/api/auth/config now advertises both client ids; each client draws its
button only if its own id is present, so an instance can run Google on
one surface and not the other. Client ids are public by design — they
identify the app, they don't authorize it.

Plan: docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md (A4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eploy them

deploy.sh passes an explicit env allowlist, so the two Google client ids
would have been dropped silently — a deploy that looked fine and left the
button invisible. Added, with the sign-in-by-code dependency on
RESEND_API_KEY spelled out in the header (no key ⇒ codes only reach the
server log ⇒ nobody can sign in).

Runbook gains Phase 8 (verify the Resend sending domain, with a curl
self-check), Phase 9 (GCP OAuth consent + the Web and iOS client ids,
plus the redeploy command with both new env groups) and Phase 10 (ship a
build — deliberately NOT prescribing a version bump, since whether this
folds into 1.1 or becomes 1.2 depends on ASC state the repo can't see).

Plan doc marked implemented with the PR chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ess gate

Sending identity moves to the mail.meetlisa.ai subdomain (verified in
Resend) rather than the apex: the apex carries the domain's human mail
and its reputation, and a bounce storm from transactional mail must not
poison it. Code default and deployed env now name the SAME domain — the
project this is modelled on drifted into a split-brain where the docs
described one sender and production used another.

- src/web/mailer.ts: HTML + plain text for both mails, hand-rolled with
  no template dependency (tables, inline styles, no <style> — Gmail
  strips it). A layout with heading/para/code/cta partials, and
  escapeHtml on everything that didn't come from the file. The two parts
  must say the same thing: multipart/alternative helps deliverability,
  but a text part that diverges from the HTML is itself a spam signal,
  and a test enforces that every link in the HTML is in the text too.

- One exit, `deliver()`, emitting exactly one structured line per send
  with a fixed outcome vocabulary (success / skipped_no_key /
  send_failed). A non-2xx from Resend is a well-formed response, not a
  thrown error — logging it as loudly as a network failure is the whole
  point, since otherwise rate limits and unverified senders vanish.
  Recipients are redacted to ab***@domain.tld: logs aren't a PII-safe
  store, but the domain stays whole because delivery breaks by provider.

- src/web/email-deliverability.ts: sign-in by code has a failure mode
  ordinary sign-in doesn't — a typo'd address doesn't bounce back to
  whoever typed it. They wait for a code that went elsewhere and burn a
  slot of their daily budget. So the address is checked BEFORE the budget
  is touched: deterministic typo lists first (which can suggest the fix,
  and which are careful that .co/.ne are real ccTLDs), then MX with an
  A-record fallback, raced against a timeout. It fails OPEN on every
  uncertainty — locking someone out because a resolver blinked is far
  worse than sending one mail into the void.

All four clients surface the suggestion, so a typo is one tap from fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erge (HIGH)

A HIGH fix that ultrareview landed on the stacked PRs (a6be431, 0f651a4)
was overwritten when later branches in the chain re-touched accounts.ts,
so the final merged code shipped the ORIGINAL vulnerable adoption paths.
This restores it on both.

The hole: /api/auth/register is open and unauthenticated, so an attacker
can pre-create victim@x.co with a password they know. When the victim
later signs in — by mailed code (ensureOtpAccount) or by Google
(upsertGoogleAccount byEmail bind) — adoption flipped verified=true but
KEPT the attacker's scrypt and did NOT rotate sessionVersion, so the
attacker's password still authenticated the account the victim had just
proven they own.

markVerifiedByOwnershipProof(): on the untrusted unverified→verified
transition, drop any pre-set password and rotate sessionVersion (killing
any session minted from it). Only the FIRST verification rotates, so a
later code sign-in doesn't log the owner out. Both adoption paths route
through it.

Tests: flipped the two assertions that pinned the vulnerable "password
still works after adoption" behavior; added explicit attacker-scenario
tests for both the OTP and Google paths, and a no-double-rotation test.

NOTE (follow-up, lower severity): the verify-LINK path
(confirmEmailVerification) can't get the same treatment — its legitimate
flow is the owner setting their own password then clicking their own
link, indistinguishable server-side from the attack, so dropping the
password there would break normal signup. Needs a design decision
(flagged for review), and requires the victim to click an unsolicited
link, upgrading only the tier of an account the attacker already held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md
#	src/cli.ts
#	src/web/accounts.test.ts
#	src/web/accounts.ts
#	src/web/mailer.ts
#	src/web/server.ts
#	src/web/verification.test.ts
CI caught a flake my local run raced past: the 'slow resolver' test used a
never-settling promise, which trips node:test's pending-work detector on a
slower event loop (ERR_TEST_FAILURE: 'Promise resolution is still pending',
cascading cancelledByParent to its sibling).

- checkDeliverable now clears its race timer in a finally and sets a
  flag so the abandoned lookup won't fire a second DNS query after the race is
  over — production hygiene, and it makes the timeout path deterministic.
- the test replaces the never-promise with a releasable gate it drains before
  the assertion returns, so no promise outlives the test.

20× local loop clean; full suite 1355 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eal CI fix)

The previous attempt made the leak worse. The timeout side is
`new Promise((r) => setTimeout(() => r(admit), ms))`; clearing the timeout
stops the callback from ever calling `r`, so that promise then dangles
FOREVER — exactly the "Promise resolution is still pending" node:test flags
on CI's slower event loop, and it happened in every deliverability test
(the fast ones left a 3s timer promise pending after the lookup won).

Fix: keep the resolver external and call it in `finally` with a `cancelled`
sentinel, so the timer promise always settles regardless of who won the
race. The race result is already locked in by then, so the late settle is
inert. `decided` still stops the abandoned lookup from a second DNS query.

Full-glob run (the CI invocation) 5x clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k, real fix)

Root cause finally pinned: CI runs node 22, I run node 24, and node 22's
node:test has a stricter pending-promise detector. The "slow resolver"
test used a promise that never settled; when the timeout won the race the
lookup stayed parked on it forever, and node 22 flagged
"Promise resolution is still pending" — which cascaded (cancelledByParent)
to every sibling in the describe, so the whole "fails open" suite went red.

Reproduced by downloading the node 22.23.1 binary and running the exact CI
glob, then fixed against it: the stub resolver now settles on its OWN timer
shortly AFTER the timeout, and the test outlasts it, so the abandoned
lookup fully unwinds before teardown and nothing is left pending. The
assertion still proves the timeout wins (returns before the resolver).

Verified: node 22, full `src/**/*.test.ts` glob, 3× — 1355 pass, 0 leaks.
(The earlier timer-settle fix in checkDeliverable was still correct and
stays — it fixed the fast-path timer promise; this fixes the timeout path.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant