mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
import type { RedeemBundle } from '@/lib/auth/pair-bundle';
|
|
import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff';
|
|
import { createFailureBudget, type BudgetRefund } from '@/lib/auth/verify-budget';
|
|
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
|
import { getStore, isSharedMode } from '@/lib/shared-store';
|
|
import { logger } from '@/lib/logger';
|
|
|
|
// One-time codes for the browser sign-in hand-off to the mobile app
|
|
// (lib/auth/mobile-handoff.ts, bulwarkmail.org/docs/development/mobile-handoff). The app opens the
|
|
// login page with a PKCE `mobile_code_challenge`; once the user has signed in
|
|
// and confirmed, the sign-in (an OAuth bundle, or username + password) is
|
|
// parked here and the browser opens the app's callback link with nothing but
|
|
// a random code. The app redeems that code at /api/auth/handoff/redeem with
|
|
// the verifier behind its challenge. A custom-scheme link can be caught by
|
|
// another app on the device; the code alone is worthless to it.
|
|
//
|
|
// A code can be redeemed for 60 seconds from the moment the user confirms,
|
|
// and only once. Any redeem attempt spends it, including one with the wrong
|
|
// verifier, so a code that leaked cannot be tried twice.
|
|
//
|
|
// The password flow parks on confirmation (the page holds the password), so
|
|
// its code is live at once. The SSO flow parks in /api/auth/sso/complete,
|
|
// before the user has seen the confirmation: that code waits, unusable, for
|
|
// up to five minutes until the page confirms it (/api/auth/handoff/confirm),
|
|
// and its 60 seconds start then.
|
|
//
|
|
// Spent and expired records stay a while without their sign-in, so a late
|
|
// redeem is told "expired" rather than "invalid".
|
|
//
|
|
// One process keeps the codes in a Map pinned on globalThis, like the pairing
|
|
// store (lib/auth/pairing-store.ts). With a shared store (#1066) they live
|
|
// there instead, sealed with the session key and under the code's hash, so
|
|
// the page's confirm and the app's redeem may reach any replica: the SSO
|
|
// hand-off is three requests from two clients. While the store is down every
|
|
// function rejects with StoreUnavailableError; nothing is parked or redeemed.
|
|
|
|
export const HANDOFF_TTL_MS = 60 * 1000;
|
|
/** How long an SSO sign-in waits for the user to confirm the hand-off. */
|
|
export const HANDOFF_CONFIRM_TTL_MS = 5 * 60 * 1000;
|
|
const TOMBSTONE_MS = 10 * 60 * 1000;
|
|
const CODE_BYTES = 32; // 256 bits
|
|
/** Records at most; the oldest gives way when a new one comes in. */
|
|
const MAX_RECORDS = 2000;
|
|
/** The store's TTL only cleans up; every record carries its own expiry. */
|
|
const CLEANUP_MS = 60 * 1000;
|
|
|
|
/** RFC 7636 §4.1: 43 to 128 unreserved characters. */
|
|
const CODE_VERIFIER_RE = /^[A-Za-z0-9._~-]{43,128}$/;
|
|
const CODE_RE = /^[0-9a-f]{64}$/;
|
|
|
|
export const isHandoffCodeChallenge = isMobileCodeChallenge;
|
|
|
|
export function isHandoffCodeVerifier(value: unknown): value is string {
|
|
return typeof value === 'string' && CODE_VERIFIER_RE.test(value);
|
|
}
|
|
|
|
export function isHandoffCode(value: unknown): value is string {
|
|
return typeof value === 'string' && CODE_RE.test(value);
|
|
}
|
|
|
|
interface HandoffRecord {
|
|
/** Null once redeemed (or tried) or expired. */
|
|
bundle: RedeemBundle | null;
|
|
state: string;
|
|
challenge: string;
|
|
/** Whether the user confirmed the hand-off; only confirmed codes redeem. */
|
|
confirmed: boolean;
|
|
issuedAt: number;
|
|
expiresAt: number;
|
|
forgetAt: number;
|
|
}
|
|
|
|
export type HandoffRedeemResult =
|
|
| { ok: true; bundle: RedeemBundle; state: string }
|
|
| { ok: false; reason: 'invalid' | 'expired' | 'verifier_mismatch' };
|
|
|
|
const STORE_KEY = '__bulwarkMobileHandoffStore';
|
|
|
|
function store(): Map<string, HandoffRecord> {
|
|
const g = globalThis as typeof globalThis & { [STORE_KEY]?: Map<string, HandoffRecord> };
|
|
if (!g[STORE_KEY]) g[STORE_KEY] = new Map();
|
|
return g[STORE_KEY];
|
|
}
|
|
|
|
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called
|
|
// on every operation, so memory stays bounded without a timer.
|
|
function sweep(now: number): void {
|
|
const codes = store();
|
|
for (const [code, record] of codes) {
|
|
if (record.expiresAt <= now) record.bundle = null;
|
|
if (record.forgetAt <= now) codes.delete(code);
|
|
}
|
|
}
|
|
|
|
function s256(verifier: string): string {
|
|
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
}
|
|
|
|
function newRecord(
|
|
bundle: RedeemBundle,
|
|
options: { challenge: string; state: string; awaitConfirm?: boolean },
|
|
now: number,
|
|
): HandoffRecord {
|
|
const expiresAt = now + (options.awaitConfirm ? HANDOFF_CONFIRM_TTL_MS : HANDOFF_TTL_MS);
|
|
return {
|
|
bundle,
|
|
state: options.state,
|
|
challenge: options.challenge,
|
|
confirmed: !options.awaitConfirm,
|
|
issuedAt: now,
|
|
expiresAt,
|
|
forgetAt: expiresAt + TOMBSTONE_MS,
|
|
};
|
|
}
|
|
|
|
/** The user confirmed: the code redeems for the next 60 seconds. */
|
|
function confirmed(record: HandoffRecord, now: number): HandoffRecord {
|
|
const expiresAt = now + HANDOFF_TTL_MS;
|
|
return { ...record, confirmed: true, expiresAt, forgetAt: expiresAt + TOMBSTONE_MS };
|
|
}
|
|
|
|
/** What a redeem attempt of `record` (spent by the attempt) gets. */
|
|
function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verifier: string, now: number): HandoffRedeemResult {
|
|
if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' };
|
|
if (!record.confirmed) return { ok: false, reason: 'invalid' };
|
|
const expected = Buffer.from(record.challenge, 'ascii');
|
|
const actual = Buffer.from(s256(verifier), 'ascii');
|
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
return { ok: false, reason: 'verifier_mismatch' };
|
|
}
|
|
if (bundle.flow === 'oauth' && typeof bundle.expires_in === 'number') {
|
|
const elapsed = Math.floor((now - record.issuedAt) / 1000);
|
|
return { ok: true, bundle: { ...bundle, expires_in: Math.max(0, bundle.expires_in - elapsed) }, state: record.state };
|
|
}
|
|
return { ok: true, bundle, state: record.state };
|
|
}
|
|
|
|
// ─── shared store ──────────────────────────────────────────────────────────
|
|
|
|
const sharedKey = (code: string) => `handoff:${createHash('sha256').update(code).digest('hex')}`;
|
|
|
|
function seal(record: HandoffRecord): string {
|
|
return encryptPayload({ r: record }, 'handoff-store');
|
|
}
|
|
|
|
/** The record, or null when missing, past its tombstone or unreadable. */
|
|
function open(sealed: string | null, now: number): HandoffRecord | null {
|
|
if (sealed === null) return null;
|
|
const payload = decryptPayload(sealed, 'handoff-store');
|
|
const record = payload && typeof payload.r === 'object' && payload.r !== null ? (payload.r as HandoffRecord) : null;
|
|
return record && record.forgetAt > now ? record : null;
|
|
}
|
|
|
|
const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - now, 0) + CLEANUP_MS;
|
|
|
|
// ─── API ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Park a sign-in for the app; returns the code the browser hands over. With
|
|
* `awaitConfirm` the code only becomes redeemable through confirmHandoff.
|
|
*/
|
|
export async function stashHandoff(
|
|
bundle: RedeemBundle,
|
|
options: { challenge: string; state: string; awaitConfirm?: boolean },
|
|
now = Date.now(),
|
|
): Promise<string> {
|
|
const code = randomBytes(CODE_BYTES).toString('hex');
|
|
const record = newRecord(bundle, options, now);
|
|
if (isSharedMode()) {
|
|
// Each code is spent by a single redeem and dies on its own; the create
|
|
// budget bounds how many one client parks.
|
|
await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: ttl(record, now) });
|
|
return code;
|
|
}
|
|
sweep(now);
|
|
const codes = store();
|
|
// Map keeps insertion order, so the first key is the oldest.
|
|
while (codes.size >= MAX_RECORDS) {
|
|
const oldest = codes.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
codes.delete(oldest);
|
|
}
|
|
codes.set(code, record);
|
|
return code;
|
|
}
|
|
|
|
/**
|
|
* The user confirmed the hand-off of a code parked with `awaitConfirm`: it
|
|
* can be redeemed for the next 60 seconds. False when there is nothing (left)
|
|
* to confirm.
|
|
*/
|
|
export async function confirmHandoff(code: string, now = Date.now()): Promise<boolean> {
|
|
if (isSharedMode()) {
|
|
const result = { ok: false };
|
|
await getStore().kv.update(sharedKey(code), (current) => {
|
|
result.ok = false;
|
|
const record = open(current, now);
|
|
if (!record || !record.bundle || record.expiresAt <= now || record.confirmed) return undefined;
|
|
result.ok = true;
|
|
return seal(confirmed(record, now));
|
|
}, { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS });
|
|
return result.ok;
|
|
}
|
|
sweep(now);
|
|
const record = store().get(code);
|
|
if (!record || !record.bundle || record.confirmed) return false;
|
|
store().set(code, confirmed(record, now));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Hand out the sign-in behind `code` when `verifier` matches its challenge.
|
|
* The code is spent by the attempt, whatever its outcome. `expires_in` of an
|
|
* OAuth bundle counts down from when it was parked.
|
|
*/
|
|
export async function redeemHandoff(code: string, verifier: string, now = Date.now()): Promise<HandoffRedeemResult> {
|
|
if (isSharedMode()) {
|
|
const kv = getStore().kv;
|
|
// Taken in one step: of two attempts, only one sees the sign-in.
|
|
const record = open(await kv.take(sharedKey(code)), now);
|
|
if (!record) return { ok: false, reason: 'invalid' };
|
|
const bundle = record.expiresAt > now ? record.bundle : null;
|
|
// A tombstone goes back, so a late attempt is told "expired". The code is
|
|
// spent already: if the tombstone can't be written, the answer stands.
|
|
const spent = { ...record, bundle: null };
|
|
try {
|
|
await kv.set(sharedKey(code), seal(spent), { ttlMs: ttl(spent, now), ifAbsent: true });
|
|
} catch (error) {
|
|
logger.warn('Mobile hand-off tombstone not written', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
}
|
|
return redeemOutcome(record, bundle, verifier, now);
|
|
}
|
|
sweep(now);
|
|
const record = store().get(code);
|
|
if (!record) return { ok: false, reason: 'invalid' };
|
|
const bundle = record.bundle;
|
|
record.bundle = null;
|
|
return redeemOutcome(record, bundle, verifier, now);
|
|
}
|
|
|
|
/** Test hook: forget everything. */
|
|
export function resetHandoffStoreForTests(): void {
|
|
store().clear();
|
|
}
|
|
|
|
// Per-client limits on the routes. Codes carry 256 bits and die on the first
|
|
// attempt, so guessing is hopeless either way; these keep one client from
|
|
// filling the store or hammering the redeem route.
|
|
const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity });
|
|
const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity });
|
|
|
|
/** Count one parked sign-in for `client`; false when it is over its limit. */
|
|
export async function reserveHandoffCreate(client: string, now = Date.now()): Promise<boolean> {
|
|
return (await createBudget.reserve(client, now)) !== null;
|
|
}
|
|
|
|
/**
|
|
* Reserve one redeem attempt for `client`: null when it is over its limit,
|
|
* otherwise a refund to call when the redeem succeeded (only failures count).
|
|
*/
|
|
export function reserveHandoffRedeem(client: string, now = Date.now()): Promise<BudgetRefund | null> {
|
|
return redeemBudget.reserve(client, now);
|
|
}
|