Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/passwordHasher.server.ts
T

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";
}