88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
// Password hashing + verification. Argon2id is the only path for new hashes;
|
|
// bcrypt remains as a verify-only fallback for legacy rows. A successful
|
|
// bcrypt verify produces an argon2 rehash that the caller is responsible for
|
|
// persisting — that's how we drain the bcrypt corpus without a one-shot
|
|
// bulk-rehash (which isn't possible since bcrypt is one-way).
|
|
|
|
import argon2 from "argon2";
|
|
import bcryptjs from "bcryptjs";
|
|
|
|
// OWASP 2024 recommendation for argon2id at modest server cost.
|
|
// memoryCost is in KiB (19456 = 19 MiB).
|
|
const ARGON2_OPTIONS = {
|
|
type: argon2.argon2id,
|
|
memoryCost: 19456,
|
|
timeCost: 2,
|
|
parallelism: 1,
|
|
} as const;
|
|
|
|
const ARGON2_PREFIX_RE = /^\$argon2(?:i|d|id)\$/;
|
|
const BCRYPT_PREFIX_RE = /^\$2[abxy]\$/;
|
|
|
|
export type VerifyPasswordResult = {
|
|
valid: boolean;
|
|
/**
|
|
* Present only when the input was successfully verified against a bcrypt
|
|
* legacy hash. The caller MUST persist this value back into the user row
|
|
* (or whatever store the hash came from) so the next login takes the
|
|
* argon2 path. Absent for argon2 hits and for any failed verify.
|
|
*/
|
|
rehashedHash?: string;
|
|
};
|
|
|
|
export async function hashPassword(plain: string): Promise<string> {
|
|
return await argon2.hash(plain, ARGON2_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* Verify `plain` against an existing hash. Detects bcrypt vs argon2 by hash
|
|
* prefix; on a successful bcrypt verify, also computes an argon2 rehash for
|
|
* the caller to persist.
|
|
*
|
|
* Always rejects unknown hash formats. Treats decryption errors (e.g.
|
|
* corrupted hashes) as a failed verify rather than surfacing the underlying
|
|
* exception, so the call site can show a generic "invalid credentials"
|
|
* message without leaking format details.
|
|
*/
|
|
export async function verifyPassword(plain: string, hash: string | null | undefined): Promise<VerifyPasswordResult> {
|
|
// Phase 01 / #20 — `User.passwordHash` is nullable for magic-link-only
|
|
// accounts. Callers that compare a submitted password against the row
|
|
// hash now pass `string | null`; treat null/empty as "no comparable
|
|
// hash" → invalid, never throw.
|
|
if (typeof hash !== "string" || hash.length === 0) {
|
|
return { valid: false };
|
|
}
|
|
if (ARGON2_PREFIX_RE.test(hash)) {
|
|
try {
|
|
const valid = await argon2.verify(hash, plain);
|
|
return { valid };
|
|
} catch {
|
|
return { valid: false };
|
|
}
|
|
}
|
|
if (BCRYPT_PREFIX_RE.test(hash)) {
|
|
let valid = false;
|
|
try {
|
|
valid = await bcryptjs.compare(plain, hash);
|
|
} catch {
|
|
return { valid: false };
|
|
}
|
|
if (!valid) {
|
|
return { valid: false };
|
|
}
|
|
const rehashedHash = await hashPassword(plain);
|
|
return { valid: true, rehashedHash };
|
|
}
|
|
return { valid: false };
|
|
}
|
|
|
|
/**
|
|
* True iff the hash format is something verifyPassword understands. Useful
|
|
* for migration scripts that want to identify legacy bcrypt rows.
|
|
*/
|
|
export function isKnownHashFormat(hash: string): "argon2" | "bcrypt" | "unknown" {
|
|
if (ARGON2_PREFIX_RE.test(hash)) return "argon2";
|
|
if (BCRYPT_PREFIX_RE.test(hash)) return "bcrypt";
|
|
return "unknown";
|
|
}
|