|
1 | 1 | /** |
2 | | - * Legacy laddr password verification. |
| 2 | + * Legacy password verification + rehash-on-login. |
3 | 3 | * |
4 | | - * Dispatches by hash format prefix so we can support whatever the laddr import |
5 | | - * lands on. v1 supports bcrypt (`$2a$`, `$2b$`, `$2y$`). Other formats throw |
6 | | - * UnknownHashFormatError so callers can surface a uniform "credentials invalid" |
7 | | - * response (rather than leaking algorithm details) while still logging the |
8 | | - * mismatch internally. |
| 4 | + * Per specs/behaviors/password-hash-rotation.md. Dispatches by hash |
| 5 | + * format (SHA-1 unsalted, bcrypt, or argon2id), verifies in constant |
| 6 | + * time, and reports `needsRehash` so callers can rotate the |
| 7 | + * credential to argon2id on successful login. The corpus drifts |
| 8 | + * toward argon2id without forcing a password reset. |
| 9 | + * |
| 10 | + * Failure modes — wrong password, missing credential, unknown |
| 11 | + * format, internal error — collapse to `{ valid: false }` so callers |
| 12 | + * can return a uniform `invalid_credentials` response without |
| 13 | + * leaking algorithm or user-existence signal. |
9 | 14 | */ |
| 15 | +import { createHash, timingSafeEqual } from 'node:crypto'; |
| 16 | + |
| 17 | +import argon2 from 'argon2'; |
10 | 18 | import bcrypt from 'bcryptjs'; |
11 | 19 |
|
12 | | -export class UnknownHashFormatError extends Error { |
13 | | - constructor(prefix: string) { |
14 | | - super(`Unknown legacy password hash format: ${prefix}`); |
15 | | - this.name = 'UnknownHashFormatError'; |
| 20 | +import { ARGON2_PARAMS } from './argon2-params.js'; |
| 21 | + |
| 22 | +const BCRYPT_PREFIXES = ['$2a$', '$2b$', '$2y$']; |
| 23 | +const ARGON2ID_PREFIX = '$argon2id$'; |
| 24 | +const SHA1_HEX_RE = /^[0-9a-f]{40}$/; |
| 25 | + |
| 26 | +export type VerifyResult = |
| 27 | + | { valid: true; needsRehash: boolean } |
| 28 | + | { valid: false }; |
| 29 | + |
| 30 | +/** |
| 31 | + * Verify a plaintext password against a stored hash. |
| 32 | + * |
| 33 | + * Returns `{ valid: true, needsRehash }` on a successful match; the |
| 34 | + * caller is responsible for rotating to argon2id when `needsRehash` |
| 35 | + * is true. |
| 36 | + * |
| 37 | + * Any failure path — wrong password, unrecognized format, internal |
| 38 | + * error during verify — returns `{ valid: false }` without leaking |
| 39 | + * the cause. Internal errors are not re-thrown. |
| 40 | + */ |
| 41 | +export async function verifyLegacyPassword( |
| 42 | + password: string, |
| 43 | + hash: string, |
| 44 | +): Promise<VerifyResult> { |
| 45 | + try { |
| 46 | + if (hash.startsWith(ARGON2ID_PREFIX)) { |
| 47 | + const ok = await argon2.verify(hash, password); |
| 48 | + if (!ok) return { valid: false }; |
| 49 | + // Argon2's encoded format embeds the params; if they don't match |
| 50 | + // the current floor, the credential is correct but stale — |
| 51 | + // rotate. |
| 52 | + return { valid: true, needsRehash: argonNeedsRehash(hash) }; |
| 53 | + } |
| 54 | + |
| 55 | + if (BCRYPT_PREFIXES.some((p) => hash.startsWith(p))) { |
| 56 | + const ok = await bcrypt.compare(password, hash); |
| 57 | + if (!ok) return { valid: false }; |
| 58 | + // Spec unifies on argon2id; every bcrypt source rotates on login. |
| 59 | + // (Laddr is SHA-1, not bcrypt; the bcrypt branch is defensive |
| 60 | + // fallback for any future bcrypt-imported credentials.) |
| 61 | + return { valid: true, needsRehash: true }; |
| 62 | + } |
| 63 | + |
| 64 | + if (SHA1_HEX_RE.test(hash)) { |
| 65 | + const computedHex = createHash('sha1').update(password).digest('hex'); |
| 66 | + // Length-check before timingSafeEqual — equal lengths are |
| 67 | + // guaranteed by the regex on `hash` plus sha1's fixed 40-char |
| 68 | + // output, but defense in depth: timingSafeEqual throws |
| 69 | + // synchronously on length mismatch, which would itself be a |
| 70 | + // timing oracle if the throw vs. compare paths differ. |
| 71 | + if (computedHex.length !== hash.length) return { valid: false }; |
| 72 | + const ok = timingSafeEqual( |
| 73 | + Buffer.from(computedHex, 'hex'), |
| 74 | + Buffer.from(hash, 'hex'), |
| 75 | + ); |
| 76 | + if (!ok) return { valid: false }; |
| 77 | + return { valid: true, needsRehash: true }; |
| 78 | + } |
| 79 | + |
| 80 | + // Unknown format. No throw — uniform invalid response. |
| 81 | + return { valid: false }; |
| 82 | + } catch { |
| 83 | + // Library error, malformed hash that slipped past the regex, |
| 84 | + // anything else — collapse to invalid. Never leak via different |
| 85 | + // outcomes. |
| 86 | + return { valid: false }; |
16 | 87 | } |
17 | 88 | } |
18 | 89 |
|
19 | | -const BCRYPT_PREFIXES = ['$2a$', '$2b$', '$2y$']; |
| 90 | +/** |
| 91 | + * Hash a plaintext password to argon2id with current params. Used |
| 92 | + * after a successful verify when `needsRehash` is true, and by the |
| 93 | + * password-reset confirm flow (phase C). |
| 94 | + */ |
| 95 | +export async function rehashPassword(password: string): Promise<string> { |
| 96 | + return argon2.hash(password, ARGON2_PARAMS); |
| 97 | +} |
| 98 | + |
| 99 | +/** |
| 100 | + * Returns true when the supplied argon2id-encoded hash uses |
| 101 | + * parameters different from the current floor (`ARGON2_PARAMS`). |
| 102 | + */ |
| 103 | +function argonNeedsRehash(hash: string): boolean { |
| 104 | + try { |
| 105 | + return argon2.needsRehash(hash, ARGON2_PARAMS); |
| 106 | + } catch { |
| 107 | + // If parsing the encoded hash fails, conservatively rotate. |
| 108 | + return true; |
| 109 | + } |
| 110 | +} |
20 | 111 |
|
21 | | -export async function verifyLaddrPassword(password: string, hash: string): Promise<boolean> { |
22 | | - if (BCRYPT_PREFIXES.some((p) => hash.startsWith(p))) { |
23 | | - return bcrypt.compare(password, hash); |
| 112 | +/** |
| 113 | + * Pre-computed argon2id hash of a fixed sentinel plaintext. Computed |
| 114 | + * lazily on first `dummyVerify` so we don't block module load. |
| 115 | + */ |
| 116 | +let dummyHashPromise: Promise<string> | null = null; |
| 117 | +function ensureDummyHash(): Promise<string> { |
| 118 | + if (!dummyHashPromise) { |
| 119 | + dummyHashPromise = rehashPassword('this-string-is-never-a-real-password'); |
| 120 | + } |
| 121 | + return dummyHashPromise; |
| 122 | +} |
| 123 | + |
| 124 | +/** |
| 125 | + * Run an argon2 verify against a fixed sentinel hash. Always returns |
| 126 | + * `{ valid: false }`. Callers invoke this when the user or credential |
| 127 | + * lookup misses so the overall response timing matches the success |
| 128 | + * path — per specs/behaviors/password-hash-rotation.md |
| 129 | + * § anti-enumeration timing. |
| 130 | + */ |
| 131 | +export async function dummyVerify(): Promise<VerifyResult> { |
| 132 | + try { |
| 133 | + const dummy = await ensureDummyHash(); |
| 134 | + await argon2.verify(dummy, 'this-string-also-never-a-real-password'); |
| 135 | + } catch { |
| 136 | + // Outcome doesn't matter — purpose is timing, not correctness. |
24 | 137 | } |
25 | | - throw new UnknownHashFormatError(hash.slice(0, Math.min(4, hash.length))); |
| 138 | + return { valid: false }; |
26 | 139 | } |
0 commit comments