113 lines
4.2 KiB
TypeScript
113 lines
4.2 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";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
const passwordHasherLogger = createScopedLogger("auth.password-hasher");
|
|
|
|
// 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]\$/;
|
|
const BCRYPT_MAX_PASSWORD_BYTES = 72;
|
|
|
|
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 (error: unknown) {
|
|
passwordHasherLogger.warn(
|
|
{ op: "verify.argon2.failed", error: toSafeVerificationError(error) },
|
|
"Argon2 password verification raised; treating the credential as invalid."
|
|
);
|
|
return { valid: false };
|
|
}
|
|
}
|
|
if (BCRYPT_PREFIX_RE.test(hash)) {
|
|
// bcrypt silently truncates after 72 bytes. Reject an overlong legacy
|
|
// credential so a different suffix cannot authenticate as the same key.
|
|
if (Buffer.byteLength(plain, "utf8") > BCRYPT_MAX_PASSWORD_BYTES) {
|
|
return { valid: false };
|
|
}
|
|
let valid = false;
|
|
try {
|
|
valid = await bcryptjs.compare(plain, hash);
|
|
} catch (error: unknown) {
|
|
passwordHasherLogger.warn(
|
|
{ op: "verify.bcrypt.failed", error: toSafeVerificationError(error) },
|
|
"Bcrypt password verification raised; treating the credential as invalid."
|
|
);
|
|
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";
|
|
}
|
|
|
|
function toSafeVerificationError(error: unknown): { name: string; code?: string } {
|
|
const candidate = error as { name?: unknown; code?: unknown } | null;
|
|
return {
|
|
name: typeof candidate?.name === "string" ? candidate.name : "Error",
|
|
...(typeof candidate?.code === "string" ? { code: candidate.code } : {}),
|
|
};
|
|
}
|