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

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 } : {}),
};
}