From 787194be707c388f2d022c3f62ca062e3307a74b Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 23 Jul 2026 23:57:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20A1=20=E2=80=94=20sign=20in=20?= =?UTF-8?q?with=20a=20mailed=20one-time=20code=20(server)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md | 95 ++++++++++++ src/web/accounts.test.ts | 42 +++++ src/web/accounts.ts | 57 ++++++- src/web/mailer.ts | 58 +++++-- src/web/otp.test.ts | 176 +++++++++++++++++++++ src/web/otp.ts | 249 ++++++++++++++++++++++++++++++ src/web/server.ts | 86 ++++++++++- src/web/verification.test.ts | 32 +++- 8 files changed, 780 insertions(+), 15 deletions(-) create mode 100644 docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md create mode 100644 src/web/otp.test.ts create mode 100644 src/web/otp.ts diff --git a/docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md b/docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md new file mode 100644 index 0000000..6efbf95 --- /dev/null +++ b/docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md @@ -0,0 +1,95 @@ +# PLAN — 邮箱验证码登录 + Google 登录 / Email-OTP & Google Sign-In (v1.0) + +**Status: DRAFT,待评审。** 承接 [PLAN_ACCOUNTS_BILLING_v1.0.md](PLAN_ACCOUNTS_BILLING_v1.0.md) +(B1 邮箱+密码 / SIWA、B8a 验证链接、B8b SIWA-web)。目标:① 邮箱**验证码(OTP) +免密登录**成为邮箱路径的默认形态;② 新增 **Google 登录**(推翻原 §4 "不加 Google" +的裁决——当时的顾虑是 4.8 连带义务,但 SIWA 已上线且处主位,义务已满足)。 + +## 1. 现状与缺口 + +| 组件 | 位置 | 现状 | +| --- | --- | --- | +| 邮箱+密码账号(scrypt) | [src/web/accounts.ts](../src/web/accounts.ts) | ✅ 保留不动(ASC 审核账号必须 user/pass) | +| 验证**链接**(提额 $1→$5) | accounts.ts `mintVerifyToken` + [mailer.ts](../src/web/mailer.ts) | ⚠️ OTP 上线后此流程被吸收(见 §2.3) | +| SIWA(iOS 原生 + web Services ID) | [cloudAuth.ts](../src/web/cloudAuth.ts) / [login.ts](../src/web/login.ts) | ✅ 线上原生已启用;web 端 `appleWeb:null` 待配 | +| Google 登录 | — | ✗ 全库为零 | +| OTP | — | ✗ 全库为零 | +| 发信 | mailer.ts(Resend) | ⚠️ OTP **硬依赖**线上 RESEND_API_KEY(现在缺失只降级打日志) | +| 登录限流/锁定 | accounts.ts 锁定 + [billing/limits.ts](../src/billing/limits.ts) | ✅ OTP 复用同一套 | + +## 2. 设计 + +### 2.1 OTP 免密登录(与密码并存,OTP 为默认) + +* **端点**: + * `POST /api/auth/otp/request {email}` → 生成 6 位数字码;存 `SHA-256(code+salt)`, + TTL 10 分钟;同邮箱 60s 冷却、每日 ≤10 次;per-IP 限流复用 limits.ts; + 响应恒定(不泄露邮箱是否已注册)。 + * `POST /api/auth/otp/verify {email, code}` → 常量时间比对;单条 OTP 最多试 5 次, + 超限作废;成功 → 签发既有 HMAC session(cookie/JSON token 双通道不变)。 +* **注册=登录合一**:验证码成功即证明邮箱所有权 —— 未注册邮箱直接建号且 + `verified=true`(直接 $5 窗口);已注册未验证账号顺带转正。**独立的 + "注册+验证链接"两步流被吸收**,`/api/auth/verify/*` 保留兼容存量邮件。 +* **存储**:pending-OTP 独立表(未注册邮箱尚无 account record):file 模式 + `/data/otp.json`(复用 fs-utils 原子写+锁,读时惰性清理过期);Firestore 模式 + `otps` 集合 CAS(与 [firestore.ts](../src/cloud/firestore.ts) 现有模式一致)。 +* **密码路径保留**:`/api/auth/login` 原样;UI 上折叠为 "Use password instead"。 + 审核账号 `reviewer@meetlisa.ai` 继续密码登录,ASC 表单不变。 + +### 2.2 Google 登录 + +* **服务端** `src/web/googleAuth.ts`,复刻 cloudAuth.ts 风格(纯 Node crypto, + 零依赖):Google JWKS(`googleapis.com/oauth2/v3/certs`)缓存 + RS256 验签; + 校验 `iss ∈ {accounts.google.com, https://accounts.google.com}`、 + `aud ∈ {LISA_GOOGLE_WEB_CLIENT_ID, LISA_GOOGLE_IOS_CLIENT_ID}`、`exp`、 + `email_verified === true`。端点 `POST /api/auth/google {idToken}`。 +* **账号映射(决策点,推荐如下)**:Google 邮箱命中现有 email-kind 账号 → + 登入该账号并记 `googleSub`(一个邮箱一个 uid,余额不分裂;Google 已验证 + 所有权,风险可控);否则建 `g-` 新号,`verified=true`。SIWA 不参与 + 匹配(private relay 邮箱)。 +* **客户端**: + * **web**:GIS(Google Identity Services)按钮,动态注入(与 SIWA-web 同模式); + `/api/auth/config` 增发 `google.webClientId`。 + * **iOS**:**不引 GoogleSignIn SDK**(沿袭零依赖惯例,先例:拒 RevenueCat、 + cloudAuth 纯 crypto)——用 `ASWebAuthenticationSession` + OAuth **PKCE** + (authorization code → token 端点换 `id_token`,iOS 类 client 无 secret)→ + POST /api/auth/google。project.yml 加反转 client ID 的 URL scheme。 + * **Mac 菜单栏 / CLI**:v1 不做原生 Google(OTP 已覆盖"免密"诉求);后续可 + 复用 ASWebAuthenticationSession(Mac)/ 设备码(CLI)。 +* **按钮次序(4.8/HIG)**:Apple 首位、Google 次之、邮箱第三 —— SIWA 等同 + 显著性义务持续满足。App Privacy 无新增类别(email/uid 已申报)。 + +### 2.3 防滥用 + +OTP 发信是新攻击面:① 请求响应恒定,防枚举;② 冷却+日限+IP 限流(费率: +Resend 免费档 100 封/天,超限告警接既有通道);③ 验证码尝试上限与密码锁定 +共用计数语义;④ OTP 建号获 $5 窗口 —— 一次性邮箱女巫风险与现有"邮箱验证后 +$5"完全同级,不新增暴露(仍受 per-uid 并发=1 + RPM + 全局日上限约束)。 + +## 3. Phasing(堆叠 PR,两线可并行) + +| 阶段 | 内容 | 依赖 | +| --- | --- | --- | +| **A0** | 本方案入库 | — | +| **A1** | OTP 服务端:otp store(file+Firestore)+ 两端点 + 邮件模板 + 限流 + 测试 | — | +| **A2** | OTP 四端:web 登录页 code-first;iOS 表单;Mac AccountWindow;CLI `lisa login` 默认 OTP(`--password` 保留) | A1 | +| **A3** | Google 服务端:googleAuth 验签 + `/api/auth/google` + email 绑定 + config 下发 + 测试 | — | +| **A4** | Google 客户端:web GIS 按钮 + iOS PKCE 流(project.yml URL scheme) | A3 | +| **A5** | 文档:runbook 增补(GCP OAuth、Resend 核查、部署 env);RELEASE 注记;iOS 1.2 tag 发 TestFlight(CI 已配好,`pocket-v1.2.0` 即可) | A2+A4 | + +## 4. 运营前置(人工,代码做不了) + +1. **Resend 发信必须真的在线上生效**(OTP 硬依赖):确认 `RESEND_API_KEY` 已配 + 且 `meetlisa.ai` 发信域在 Resend 验证通过(SPF/DKIM);未配则 OTP 无法送达。 +2. **GCP Console**:OAuth consent screen(External、发布)+ 两个 OAuth Client ID + (Web 应用、iOS 应用 bundle `ai.meetlisa.main`)。 +3. **重部署**:带 `LISA_GOOGLE_WEB_CLIENT_ID` / `LISA_GOOGLE_IOS_CLIENT_ID` + (顺带补 SIWA-web 的 Services ID,把 `appleWeb:null` 一起解决)。 +4. ASC:1.2 送审时 Review Notes 不变(demo 账号仍密码制);4.8 合规自查通过。 + +## 5. 决策点(默认已选推荐项,评审时可推翻) + +1. Google 邮箱与既有邮箱账号**自动绑定**(推荐)vs 各自建号。 +2. iOS 用 **PKCE 零依赖**(推荐)vs 引 GoogleSignIn SPM。 +3. Mac/CLI 的 Google **缓到 v1.1**(推荐)vs 一并做。 +4. OTP 默认位(推荐 OTP-first,密码折叠)vs 密码默认、OTP 备选。 diff --git a/src/web/accounts.test.ts b/src/web/accounts.test.ts index 9d2f63a..9a5fe89 100644 --- a/src/web/accounts.test.ts +++ b/src/web/accounts.test.ts @@ -18,6 +18,7 @@ const { sessionAccountValid, resetLoginThrottles, appleUid, + ensureOtpAccount, AccountError, } = await import("./accounts.js"); @@ -70,6 +71,47 @@ describe("email accounts", () => { }); }); +describe("code-only (OTP) accounts", () => { + test("first code creates a verified, passwordless account", async () => { + const { acct, created } = await ensureOtpAccount("New@Example.com", 1000); + assert.equal(created, true); + assert.equal(acct.kind, "email"); + assert.equal(acct.email, "new@example.com"); + assert.equal(acct.verified, true, "reading the mail proved ownership"); + assert.equal(acct.scrypt, undefined, "no password material"); + }); + + test("a later code signs into the same account rather than making a second", async () => { + const first = await ensureOtpAccount("a@b.co", 1000); + const second = await ensureOtpAccount("a@b.co", 2000); + assert.equal(second.created, false); + assert.equal(second.acct.uid, first.acct.uid); + assert.equal(second.acct.lastLoginAt, 2000); + }); + + test("a passwordless account cannot be logged into with any password", async () => { + const { acct } = await ensureOtpAccount("a@b.co", 1000); + assert.equal(await verifyEmailLogin("a@b.co", "password-123", 2000), null); + assert.equal(await verifyEmailLogin("a@b.co", "", 2000), null); + assert.equal((await getAccount(acct.uid))?.uid, acct.uid, "the account still exists"); + }); + + test("a code verifies an existing unverified password account (levels the free window)", async () => { + const made = await createEmailAccount("a@b.co", "password-123"); + assert.equal(made.verified, false); + const { acct, created } = await ensureOtpAccount("a@b.co", 3000); + assert.equal(created, false); + assert.equal(acct.uid, made.uid); + assert.equal(acct.verified, true); + // The password still works — the code is an additional way in, not a swap. + assert.equal((await verifyEmailLogin("a@b.co", "password-123", 4000))?.uid, made.uid); + }); + + test("a malformed address is refused", async () => { + await assert.rejects(ensureOtpAccount("not-an-email", 1000), isCode("invalid_email")); + }); +}); + describe("apple accounts", () => { test("upsert creates once, then updates lastLoginAt; uid is fs-safe", async () => { const a = await upsertAppleAccount("001234.abcdef.5678", "user@example.com", 1000); diff --git a/src/web/accounts.ts b/src/web/accounts.ts index dbd2d0e..1648389 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -5,9 +5,11 @@ * Two account kinds share one store (`$lisaHome()/accounts.json`, 0600): * - **Apple** (`apple-`): created/updated on every verified Sign in with * Apple. No password material — Apple is the authority. - * - **Email** (`em-`): self-serve email+password, scrypt-hashed. This - * is what App Review's demo account uses (ASC insists on user/pass), and the - * path for desktop/web users without an Apple ID. + * - **Email** (`em-`): self-serve, keyed by the address. Password + * material is OPTIONAL: accounts born from a mailed one-time code + * (src/web/otp.ts) carry no scrypt params at all, and the password path + * rejects them constant-time like any other bad credential. App Review's + * demo account is the password kind (ASC insists on user/pass). * * Every account carries a `sessionVersion`; session tokens embed it and the * gate rejects a mismatch — so deleting an account (App Store 5.1.1(v)) or a @@ -38,7 +40,11 @@ export interface AccountRecord { kind: AccountKind; /** Normalized (trimmed, lowercased). Optional for Apple (user may hide it). */ email?: string; - /** Password material — email accounts only. */ + /** + * Password material — email accounts that chose one. Absent for code-only + * (OTP) accounts; `verifyEmailLogin` then fails against the decoy params, so + * a passwordless account can never be password-authenticated. + */ scrypt?: ScryptParams; createdAt: number; lastLoginAt: number; @@ -260,6 +266,49 @@ export async function createEmailAccount( }); } +/** + * Sign-in by mailed code (A1). The code already proved this person reads the + * address, so one call both registers and authenticates: an existing account + * comes back marked verified (ownership was just demonstrated, which levels the + * free window $1 → $5), a new one is created with no password material. + * + * The lookup and the insert share a single mutation, so two codes redeemed at + * once can't create the address twice under Firestore CAS. + * + * Password lockouts are deliberately NOT cleared here: they guard the password + * credential only, and leaving them in place means someone else's failed + * guessing can never be undone by the victim signing in normally. + */ +export async function ensureOtpAccount( + emailRaw: string, + now: number = Date.now(), +): Promise<{ acct: AccountRecord; created: boolean }> { + const email = normalizeEmail(emailRaw); + if (!validEmail(email)) throw new AccountError("invalid_email"); + const uid = `em-${crypto.randomBytes(9).toString("hex")}`; + return mutateAccounts((list) => { + const existing = list.find((a) => a.kind === "email" && a.email === email); + if (existing) { + existing.lastLoginAt = now; + existing.verified = true; + delete existing.verifyTokenHash; + delete existing.verifyExpiresAt; + return { acct: existing, created: false }; + } + const rec: AccountRecord = { + uid, + kind: "email", + email, + createdAt: now, + lastLoginAt: now, + verified: true, + sessionVersion: 0, + }; + list.push(rec); + return { acct: rec, created: true }; + }); +} + /** * Email+password login. Returns the account or null; throws AccountError * ("throttled") while the email is locked out. A wrong password on an unknown diff --git a/src/web/mailer.ts b/src/web/mailer.ts index b28cc93..f8bde6c 100644 --- a/src/web/mailer.ts +++ b/src/web/mailer.ts @@ -1,12 +1,14 @@ /** - * Outbound mail — email-ownership verification for LISA accounts - * (docs/PLAN_ACCOUNTS_BILLING_v1.0.md B7 follow-up, milestone B8a). + * Outbound mail — account verification and sign-in codes + * (docs/PLAN_ACCOUNTS_BILLING_v1.0.md B8a; docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md A1). * - * One provider, one purpose: Resend's REST API (zero deps) sends the - * verification link that levels an email account's free window from $1 to $5. - * Without RESEND_API_KEY the mailer degrades loudly-but-safely: the link is - * printed to the server log so an operator can forward it by hand (and dev - * setups keep working offline). + * One provider, two messages, both over Resend's REST API (zero deps): the + * verification link that levels an email account's free window from $1 to $5, + * and the one-time code that signs a person in without a password. + * Without RESEND_API_KEY the mailer degrades loudly-but-safely: the link or code + * is printed to the server log so an operator can forward it by hand (and dev + * setups keep working offline). **Sign-in by code therefore needs a real key in + * production** — a code nobody receives is a sign-in nobody completes. * * Env: RESEND_API_KEY, LISA_MAIL_FROM (default "LISA "; * the domain must be verified in Resend before real sends succeed). @@ -42,15 +44,53 @@ export function verificationEmail(link: string): { subject: string; text: string }; } +/** Compose the sign-in code mail (pure — unit-testable). */ +export function signInCodeEmail(code: string, ttlMinutes: number): { subject: string; text: string } { + return { + subject: `${code} is your LISA sign-in code`, + text: + `Your LISA sign-in code is:\n\n` + + ` ${code}\n\n` + + `Enter it in the app or on the sign-in page. It expires in ${ttlMinutes} minutes ` + + `and can be used once.\n\n` + + `If you didn't try to sign in, ignore this mail — nobody can use the code ` + + `without reading this inbox.`, + }; +} + +/** + * Mail the one-time sign-in code. Same degradation as the verification link: + * with no API key the code goes to the server log (dev/offline), which is why + * a production deployment must have RESEND_API_KEY set. + */ +export async function sendSignInCodeEmail( + to: string, + code: string, + ttlMinutes: number, + cfg: MailerConfig = mailerConfig(), + fetchFn: typeof fetch = fetch, +): Promise { + return deliver(to, signInCodeEmail(code, ttlMinutes), `sign-in code for ${to}: ${code}`, cfg, fetchFn); +} + export async function sendVerificationEmail( to: string, link: string, cfg: MailerConfig = mailerConfig(), fetchFn: typeof fetch = fetch, ): Promise { - const mail = verificationEmail(link); + return deliver(to, verificationEmail(link), `verification link for ${to}: ${link}`, cfg, fetchFn); +} + +async function deliver( + to: string, + mail: { subject: string; text: string }, + fallbackLog: string, + cfg: MailerConfig, + fetchFn: typeof fetch, +): Promise { if (!cfg.apiKey) { - console.error(`[mail] RESEND_API_KEY unset — verification link for ${to}: ${link}`); + console.error(`[mail] RESEND_API_KEY unset — ${fallbackLog}`); return { sent: false, detail: "no_api_key" }; } try { diff --git a/src/web/otp.test.ts b/src/web/otp.test.ts new file mode 100644 index 0000000..116823b --- /dev/null +++ b/src/web/otp.test.ts @@ -0,0 +1,176 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lisa-otp-")); +process.env.LISA_HOME = TMP; +const FILE = path.join(TMP, "otp.json"); + +const { + requestEmailOtp, + verifyEmailOtp, + peekOtpRecord, + OTP_TTL_MS, + OTP_COOLDOWN_MS, + OTP_DAILY_MAX_SENDS, + OTP_MAX_ATTEMPTS, + OTP_CODE_LENGTH, +} = await import("./otp.js"); + +/** Mint a code, asserting the request succeeded. */ +async function mint(email: string, at: number): Promise { + const r = await requestEmailOtp(email, at); + assert.equal(r.ok, true, "expected the request to be allowed"); + return (r as { ok: true; code: string }).code; +} + +beforeEach(() => { + fs.rmSync(FILE, { force: true }); +}); + +describe("otp — minting", () => { + test("returns a fixed-length numeric code and stores only its hash", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint("Person@Example.com ", t0); + assert.match(code, new RegExp(`^\\d{${OTP_CODE_LENGTH}}$`)); + + const raw = fs.readFileSync(FILE, "utf8"); + assert.ok(!raw.includes(code), "the raw code must never touch the store"); + const rec = await peekOtpRecord("person@example.com"); + assert.ok(rec?.codeHash, "a challenge should be outstanding"); + assert.equal(rec?.expiresAt, t0 + OTP_TTL_MS); + }); + + test("normalizes the address, so casing/whitespace hit one record", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint(" USER@Example.com", t0); + assert.deepEqual(await verifyEmailOtp("user@example.com", code, t0 + 1000), { ok: true }); + }); + + test("a second request replaces the first code", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const first = await mint("a@b.com", t0); + const second = await mint("a@b.com", t0 + OTP_COOLDOWN_MS); + const at = t0 + OTP_COOLDOWN_MS + 1000; + assert.deepEqual(await verifyEmailOtp("a@b.com", first, at), { ok: false, reason: "bad_code" }); + assert.deepEqual(await verifyEmailOtp("a@b.com", second, at), { ok: true }); + }); +}); + +describe("otp — send budget", () => { + test("a second send inside the cooldown is refused with a retry hint", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + await mint("a@b.com", t0); + const again = await requestEmailOtp("a@b.com", t0 + 20_000); + assert.equal(again.ok, false); + assert.equal((again as { reason: string }).reason, "cooldown"); + assert.equal((again as { retryAfterSec: number }).retryAfterSec, 40); + }); + + test("the daily cap holds, and the refusal does not burn the live code", async () => { + const t0 = Date.UTC(2026, 6, 23, 0, 0, 0); + let at = t0; + for (let i = 0; i < OTP_DAILY_MAX_SENDS; i++) { + await mint("a@b.com", at); + at += OTP_COOLDOWN_MS; + } + const capped = await requestEmailOtp("a@b.com", at); + assert.equal(capped.ok, false); + assert.equal((capped as { reason: string }).reason, "daily_cap"); + // Still holding a usable challenge from the last successful send. + assert.ok((await peekOtpRecord("a@b.com"))?.codeHash); + }); + + test("the budget resets on the next UTC day", async () => { + const t0 = Date.UTC(2026, 6, 23, 0, 0, 0); + let at = t0; + for (let i = 0; i < OTP_DAILY_MAX_SENDS; i++) { + await mint("a@b.com", at); + at += OTP_COOLDOWN_MS; + } + assert.equal((await requestEmailOtp("a@b.com", at)).ok, false); + const nextDay = Date.UTC(2026, 6, 24, 0, 0, 0); + assert.equal((await requestEmailOtp("a@b.com", nextDay)).ok, true); + }); + + test("spending a code does NOT refund the daily budget", async () => { + const t0 = Date.UTC(2026, 6, 23, 0, 0, 0); + let at = t0; + for (let i = 0; i < OTP_DAILY_MAX_SENDS - 1; i++) { + await mint("a@b.com", at); + at += OTP_COOLDOWN_MS; + } + const last = await mint("a@b.com", at); + at += 1000; + assert.deepEqual(await verifyEmailOtp("a@b.com", last, at), { ok: true }); + // A spent code must not hand back a fresh send allowance. + const after = await requestEmailOtp("a@b.com", at + OTP_COOLDOWN_MS); + assert.equal(after.ok, false); + assert.equal((after as { reason: string }).reason, "daily_cap"); + }); +}); + +describe("otp — spending a code", () => { + test("a code works once", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint("a@b.com", t0); + assert.deepEqual(await verifyEmailOtp("a@b.com", code, t0 + 1000), { ok: true }); + assert.deepEqual(await verifyEmailOtp("a@b.com", code, t0 + 2000), { ok: false, reason: "no_pending" }); + }); + + test("expiry is enforced", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint("a@b.com", t0); + assert.deepEqual(await verifyEmailOtp("a@b.com", code, t0 + OTP_TTL_MS + 1), { ok: false, reason: "expired" }); + }); + + test("the code burns after the attempt limit", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint("a@b.com", t0); + const wrong = code === "000000" ? "111111" : "000000"; + for (let i = 0; i < OTP_MAX_ATTEMPTS - 1; i++) { + assert.deepEqual(await verifyEmailOtp("a@b.com", wrong, t0 + 1000), { ok: false, reason: "bad_code" }); + } + assert.deepEqual(await verifyEmailOtp("a@b.com", wrong, t0 + 1000), { + ok: false, + reason: "too_many_attempts", + }); + // Burned: even the right code is gone now. + assert.deepEqual(await verifyEmailOtp("a@b.com", code, t0 + 1000), { ok: false, reason: "no_pending" }); + }); + + test("a code cannot be spent on a different address", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + const code = await mint("a@b.com", t0); + await mint("c@d.com", t0); + assert.deepEqual(await verifyEmailOtp("c@d.com", code, t0 + 1000), { ok: false, reason: "bad_code" }); + }); + + test("an address with no outstanding code reports no_pending", async () => { + assert.deepEqual(await verifyEmailOtp("nobody@example.com", "123456", Date.now()), { + ok: false, + reason: "no_pending", + }); + }); +}); + +describe("otp — store hygiene", () => { + test("the file is private (0600)", async () => { + await mint("a@b.com", Date.UTC(2026, 6, 23, 12, 0, 0)); + assert.equal(fs.statSync(FILE).mode & 0o777, 0o600); + }); + + test("dead records are pruned once their day and challenge are past", async () => { + const t0 = Date.UTC(2026, 6, 23, 12, 0, 0); + await mint("stale@b.com", t0); + // Two days on: challenge expired and the send budget belongs to an old day. + await mint("fresh@b.com", Date.UTC(2026, 6, 25, 12, 0, 0)); + const list = JSON.parse(fs.readFileSync(FILE, "utf8")) as { email: string }[]; + assert.deepEqual( + list.map((r) => r.email), + ["fresh@b.com"], + ); + }); +}); diff --git a/src/web/otp.ts b/src/web/otp.ts new file mode 100644 index 0000000..52be39f --- /dev/null +++ b/src/web/otp.ts @@ -0,0 +1,249 @@ +/** + * Email one-time codes — passwordless sign-in for LISA accounts + * (docs/PLAN_AUTH_OTP_GOOGLE_v1.0.md §2.1, milestone A1). + * + * A 6-digit code mailed to an address proves the person reading that inbox + * asked to sign in. That single proof does three jobs at once: it authenticates + * an existing account, it registers a new one (no password to choose), and it + * marks the address verified — which is what levels the free window from $1 to + * $5 (src/billing/quota.ts). The password path stays untouched beside it: App + * Review's demo account needs user/pass, and existing accounts keep working. + * + * The store lives beside the account directory (`$LISA_HOME/otp.json`, 0600, or + * `lisa-global/otps` under Firestore) and holds ONE record per email address, + * split into two independent halves: + * + * - the **challenge** (code hash, expiry, wrong-guess count) — cleared the + * moment it is spent or burned; + * - the **send budget** (last send, sends today) — deliberately survives a + * successful verify, so spending a code can't reset the mail-bombing cap. + * + * Codes are never stored: we keep SHA-256 of `email:code`, salted by the address + * so the same digits for two people hash differently, and compare in constant + * time. A code dies at the first of: expiry, five wrong guesses, or being spent. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { firestoreEnabled, getDoc, casUpdate } from "../cloud/firestore.js"; +import { normalizeEmail } from "./accounts.js"; + +/** Digits in a mailed code. Six is the usual phone-friendly length. */ +export const OTP_CODE_LENGTH = 6; +/** How long a code stays usable. */ +export const OTP_TTL_MS = 10 * 60 * 1000; +/** Wrong guesses before the code is burned (an attacker gets 5-in-a-million). */ +export const OTP_MAX_ATTEMPTS = 5; +/** Minimum gap between two sends to one address. */ +export const OTP_COOLDOWN_MS = 60 * 1000; +/** Sends per address per UTC day — the mail-bombing cap. */ +export const OTP_DAILY_MAX_SENDS = 10; + +export interface OtpRecord { + /** Normalized email — also the record key. */ + email: string; + /** SHA-256 of `email:code`. Absent when no challenge is outstanding. */ + codeHash?: string; + expiresAt?: number; + attempts?: number; + /** Send budget (survives a spent code). */ + lastSentAt: number; + sentToday: number; + /** UTC day the `sentToday` counter belongs to, "YYYY-MM-DD". */ + day: string; +} + +export type OtpRequestResult = + | { ok: true; code: string; expiresInSec: number } + | { ok: false; reason: "cooldown" | "daily_cap"; retryAfterSec: number }; + +export type OtpVerifyReason = "no_pending" | "expired" | "bad_code" | "too_many_attempts"; +export type OtpVerifyResult = { ok: true } | { ok: false; reason: OtpVerifyReason }; + +function lisaHome(): string { + return process.env.LISA_HOME ?? path.join(os.homedir(), ".lisa"); +} +function otpPath(): string { + return path.join(lisaHome(), "otp.json"); +} + +function utcDay(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +/** Uniform 6-digit code — `randomInt` avoids the modulo bias of `randomBytes % n`. */ +function generateCode(): string { + return String(crypto.randomInt(0, 10 ** OTP_CODE_LENGTH)).padStart(OTP_CODE_LENGTH, "0"); +} + +/** Address-salted digest: the same digits for two people never collide. */ +function codeDigest(email: string, code: string): string { + return crypto.createHash("sha256").update(`${email}:${code}`).digest("hex"); +} + +function validRecords(parsed: unknown): OtpRecord[] { + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (r): r is OtpRecord => !!r && typeof (r as OtpRecord).email === "string" && typeof (r as OtpRecord).day === "string", + ); +} + +function loadFile(): OtpRecord[] { + try { + return validRecords(JSON.parse(fs.readFileSync(otpPath(), "utf8"))); + } catch { + return []; + } +} + +function saveFile(list: OtpRecord[]): void { + const file = otpPath(); + // Code hashes are credentials — same 0600 posture as accounts.json. + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(list, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); + try { + fs.chmodSync(file, 0o600); + } catch { + // best effort + } +} + +// ── storage seam (mirrors accounts.ts): file under min=max=1, Firestore for +// multi-instance. One doc holds the whole table; every mutation is a CAS. +const OTP_DOC = "lisa-global/otps"; + +async function loadRecords(): Promise { + if (firestoreEnabled()) { + const doc = await getDoc(OTP_DOC); + return validRecords((doc?.data.list as unknown) ?? []); + } + return loadFile(); +} + +/** + * Drop records that can no longer do anything: no live challenge AND a send + * budget from an earlier day. Keeps the table from growing with every address + * that ever asked for a code. + */ +function prune(list: OtpRecord[], now: number): OtpRecord[] { + const today = utcDay(now); + return list.filter((r) => { + const liveChallenge = !!r.codeHash && (r.expiresAt ?? 0) > now; + const liveBudget = r.day === today; + return liveChallenge || liveBudget; + }); +} + +async function mutate(fn: (list: OtpRecord[]) => T, now: number): Promise { + if (firestoreEnabled()) { + return casUpdate(OTP_DOC, (current) => { + const list = prune(validRecords((current?.list as unknown) ?? []), now); + const result = fn(list); + return { next: { list: list as unknown as Record[] }, result }; + }); + } + const list = prune(loadFile(), now); + const result = fn(list); + saveFile(list); + return result; +} + +function findOrCreate(list: OtpRecord[], email: string, now: number): OtpRecord { + const existing = list.find((r) => r.email === email); + if (existing) return existing; + const rec: OtpRecord = { email, lastSentAt: 0, sentToday: 0, day: utcDay(now) }; + list.push(rec); + return rec; +} + +/** + * Mint a code for `email`, or refuse when the address is over its send budget. + * Returns the RAW code exactly once — it goes straight into the mail and is + * never recoverable afterwards. Any outstanding challenge is replaced, so the + * newest code is always the only valid one. + */ +export async function requestEmailOtp(emailRaw: string, now: number = Date.now()): Promise { + const email = normalizeEmail(emailRaw); + const code = generateCode(); + const hash = codeDigest(email, code); + return mutate((list) => { + const rec = findOrCreate(list, email, now); + if (rec.day !== utcDay(now)) { + rec.day = utcDay(now); + rec.sentToday = 0; + } + const sinceLast = now - rec.lastSentAt; + if (sinceLast < OTP_COOLDOWN_MS) { + return { + ok: false as const, + reason: "cooldown" as const, + retryAfterSec: Math.ceil((OTP_COOLDOWN_MS - sinceLast) / 1000), + }; + } + if (rec.sentToday >= OTP_DAILY_MAX_SENDS) { + // Until the UTC day rolls over. + const midnight = Date.UTC( + new Date(now).getUTCFullYear(), + new Date(now).getUTCMonth(), + new Date(now).getUTCDate() + 1, + ); + return { ok: false as const, reason: "daily_cap" as const, retryAfterSec: Math.ceil((midnight - now) / 1000) }; + } + rec.codeHash = hash; + rec.expiresAt = now + OTP_TTL_MS; + rec.attempts = 0; + rec.lastSentAt = now; + rec.sentToday += 1; + return { ok: true as const, code, expiresInSec: Math.floor(OTP_TTL_MS / 1000) }; + }, now); +} + +/** + * Spend a code. Success clears the challenge (a code is single-use) but keeps + * the send budget. A wrong guess counts against `OTP_MAX_ATTEMPTS`; the fifth + * burns the challenge outright, so brute force costs a fresh mail each time. + */ +export async function verifyEmailOtp( + emailRaw: string, + codeRaw: string, + now: number = Date.now(), +): Promise { + const email = normalizeEmail(emailRaw); + const code = codeRaw.trim(); + const presented = Buffer.from(codeDigest(email, code), "utf8"); + return mutate((list) => { + const rec = list.find((r) => r.email === email); + if (!rec?.codeHash) return { ok: false as const, reason: "no_pending" as const }; + if ((rec.expiresAt ?? 0) <= now) { + clearChallenge(rec); + return { ok: false as const, reason: "expired" as const }; + } + const stored = Buffer.from(rec.codeHash, "utf8"); + const match = stored.length === presented.length && crypto.timingSafeEqual(stored, presented); + if (!match) { + rec.attempts = (rec.attempts ?? 0) + 1; + if (rec.attempts >= OTP_MAX_ATTEMPTS) { + clearChallenge(rec); + return { ok: false as const, reason: "too_many_attempts" as const }; + } + return { ok: false as const, reason: "bad_code" as const }; + } + clearChallenge(rec); + return { ok: true as const }; + }, now); +} + +function clearChallenge(rec: OtpRecord): void { + delete rec.codeHash; + delete rec.expiresAt; + delete rec.attempts; +} + +/** Test/diagnostic seam: the live record for an address (challenge included). */ +export async function peekOtpRecord(emailRaw: string): Promise { + const email = normalizeEmail(emailRaw); + return (await loadRecords()).find((r) => r.email === email) ?? null; +} diff --git a/src/web/server.ts b/src/web/server.ts index 2a2719f..d2fe7d2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -94,8 +94,12 @@ import { ensureSeededAccount, beginEmailVerification, confirmEmailVerification, + ensureOtpAccount, + validEmail, + normalizeEmail, } from "./accounts.js"; -import { sendVerificationEmail } from "./mailer.js"; +import { requestEmailOtp, verifyEmailOtp, OTP_TTL_MS } from "./otp.js"; +import { sendVerificationEmail, sendSignInCodeEmail } from "./mailer.js"; import { readBalance, creditPurchase } from "../billing/quota.js"; import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs, registerLiveActivity, unregisterLiveActivity, type PushPrefs } from "./push.js"; import { SenseService } from "../sense/service.js"; @@ -1106,6 +1110,86 @@ export async function startWebServer(opts: WebServerOptions): Promise { fs.rmSync(FILE, { force: true }); @@ -86,4 +87,33 @@ describe("mailer", () => { assert.match(calls[0]!.url, /api\.resend\.com/); assert.match(String(calls[0]!.init.body), /a@b\.co/); }); + + test("the sign-in code mail carries the code in subject and body", () => { + const mail = signInCodeEmail("123456", 10); + assert.match(mail.subject, /123456/); // visible in the notification preview + assert.match(mail.text, /123456/); + assert.match(mail.text, /10 minutes/); + }); + + test("no api key → the code degrades to a server log, sent:false", async () => { + const r = await sendSignInCodeEmail("a@b.co", "123456", 10, mailerConfig({})); + assert.deepEqual(r, { sent: false, detail: "no_api_key" }); + }); + + test("the code mail goes out through the same transport", async () => { + const bodies: string[] = []; + const fakeFetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + return new Response(JSON.stringify({ id: "email_456" }), { status: 200 }); + }) as typeof fetch; + const r = await sendSignInCodeEmail( + "a@b.co", + "654321", + 10, + { apiKey: "re_key", from: "LISA " }, + fakeFetch, + ); + assert.deepEqual(r, { sent: true, detail: "email_456" }); + assert.match(bodies[0]!, /654321/); + }); }); From a6be431799c73dd2b80a0ad21fa62999ef4e403d Mon Sep 17 00:00:00 2001 From: oratis Date: Fri, 24 Jul 2026 16:41:48 +0800 Subject: [PATCH 2/2] fix(auth): close account pre-hijacking on OTP adoption (HIGH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureOtpAccount() adopted a pre-existing email account by setting verified=true but KEEPING its scrypt and NOT bumping sessionVersion. Because /api/auth/register is open+unauthenticated, an attacker could pre-register victim@x.com with a known password; when the victim later signed in by code, the attacker's password still authenticated → shared/hijacked account. Add markVerifiedByOwnershipProof(): on the unverified→verified transition (proof of inbox control), drop any pre-set password and rotate sessionVersion, so a credential set before ownership was proven can no longer authenticate and any session minted from it is invalidated. A password the real owner sets AFTER verifying is unaffected. Updated the test that pinned the vulnerable "password still works" behavior; added an explicit attacker-scenario test. (Google byEmail path fixed on #290; the verify-link path + verifyEmailLogin-allows-unverified are flagged for review.) Co-Authored-By: Claude Opus 4.8 --- src/web/accounts.test.ts | 26 +++++++++++++++++++++++--- src/web/accounts.ts | 30 +++++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/web/accounts.test.ts b/src/web/accounts.test.ts index 9a5fe89..75add7b 100644 --- a/src/web/accounts.test.ts +++ b/src/web/accounts.test.ts @@ -96,15 +96,35 @@ describe("code-only (OTP) accounts", () => { assert.equal((await getAccount(acct.uid))?.uid, acct.uid, "the account still exists"); }); - test("a code verifies an existing unverified password account (levels the free window)", async () => { + test("a code verifies an existing unverified password account, dropping the pre-verification password", async () => { const made = await createEmailAccount("a@b.co", "password-123"); assert.equal(made.verified, false); + assert.equal(made.sessionVersion, 0); const { acct, created } = await ensureOtpAccount("a@b.co", 3000); assert.equal(created, false); assert.equal(acct.uid, made.uid); assert.equal(acct.verified, true); - // The password still works — the code is an additional way in, not a swap. - assert.equal((await verifyEmailLogin("a@b.co", "password-123", 4000))?.uid, made.uid); + // /api/auth/register is open, so a password set before ownership was proven + // can't be trusted. Signing in by code drops it and rotates sessionVersion, so + // the pre-set password no longer authenticates (anti account-pre-hijacking). + assert.equal(acct.scrypt, undefined, "pre-verification password dropped"); + assert.equal(acct.sessionVersion, 1, "sessionVersion rotated to kill prior sessions"); + assert.equal(await verifyEmailLogin("a@b.co", "password-123", 4000), null, "the pre-set password no longer works"); + }); + + test("account pre-hijacking is closed: an attacker's pre-set password can't survive the victim's code sign-in", async () => { + // Attacker pre-registers the victim's address with a password they know. + const attacker = await createEmailAccount("victim@x.co", "attacker-knows-this"); + assert.equal(attacker.verified, false); + // Victim signs in by mailed code (proving they, not the attacker, own the inbox). + const { acct } = await ensureOtpAccount("victim@x.co", 5000); + assert.equal(acct.uid, attacker.uid, "same record — the code adopts the existing account"); + // The attacker can no longer authenticate with the password they set. + assert.equal( + await verifyEmailLogin("victim@x.co", "attacker-knows-this", 6000), + null, + "attacker's pre-set password is invalidated on the victim's ownership proof", + ); }); test("a malformed address is refused", async () => { diff --git a/src/web/accounts.ts b/src/web/accounts.ts index 1648389..0ea92f4 100644 --- a/src/web/accounts.ts +++ b/src/web/accounts.ts @@ -266,6 +266,28 @@ export async function createEmailAccount( }); } +/** + * Apply proof-of-ownership (a mailed OTP or a verified OIDC email) to an account. + * The FIRST time an account becomes verified this way, anything set on it before + * is untrusted — `/api/auth/register` is open and unauthenticated, so an attacker + * can pre-create any address with a password of their choosing. So on the + * unverified→verified transition we drop any pre-set password and rotate + * `sessionVersion` (invalidating any session minted from that credential), + * closing the account pre-hijacking path. A password the real owner sets AFTER + * verifying is unaffected. Returns whether this was the first verification. + */ +function markVerifiedByOwnershipProof(acct: AccountRecord): boolean { + const firstVerification = !acct.verified; + acct.verified = true; + delete acct.verifyTokenHash; + delete acct.verifyExpiresAt; + if (firstVerification && acct.scrypt) { + delete acct.scrypt; + acct.sessionVersion += 1; + } + return firstVerification; +} + /** * Sign-in by mailed code (A1). The code already proved this person reads the * address, so one call both registers and authenticates: an existing account @@ -290,9 +312,11 @@ export async function ensureOtpAccount( const existing = list.find((a) => a.kind === "email" && a.email === email); if (existing) { existing.lastLoginAt = now; - existing.verified = true; - delete existing.verifyTokenHash; - delete existing.verifyExpiresAt; + // OTP proves inbox control → apply ownership proof. This drops any password + // set before verification (an attacker can pre-register any address via the + // open /api/auth/register) and rotates sessionVersion, closing the account + // pre-hijacking path where the attacker's password survived the adoption. + markVerifiedByOwnershipProof(existing); return { acct: existing, created: false }; } const rec: AccountRecord = {