61 lines
2.0 KiB
TypeScript
61 lines
2.0 KiB
TypeScript
|
|
import { randomBytes, scrypt as scryptCb, timingSafeEqual, createHash } from 'node:crypto';
|
||
|
|
import { promisify } from 'node:util';
|
||
|
|
|
||
|
|
const scrypt = promisify(scryptCb) as (
|
||
|
|
password: string,
|
||
|
|
salt: Buffer,
|
||
|
|
keyLen: number,
|
||
|
|
) => Promise<Buffer>;
|
||
|
|
|
||
|
|
const KEY_LENGTH = 64;
|
||
|
|
const SALT_LENGTH = 16;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hash a password with a fresh random salt. Stored format is
|
||
|
|
* `salt:keyHex` (both as hex strings) so verification can re-derive without
|
||
|
|
* a separate salt column.
|
||
|
|
*/
|
||
|
|
export async function hashPassword(password: string): Promise<string> {
|
||
|
|
const salt = randomBytes(SALT_LENGTH);
|
||
|
|
const key = await scrypt(password, salt, KEY_LENGTH);
|
||
|
|
return `${salt.toString('hex')}:${key.toString('hex')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Constant-time check of a candidate password against a stored
|
||
|
|
* `salt:keyHex` hash. Returns false on any malformed input rather than
|
||
|
|
* throwing — callers should treat false uniformly.
|
||
|
|
*/
|
||
|
|
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||
|
|
const parts = stored.split(':');
|
||
|
|
if (parts.length !== 2) return false;
|
||
|
|
const [saltHex, keyHex] = parts;
|
||
|
|
if (!saltHex || !keyHex) return false;
|
||
|
|
let salt: Buffer;
|
||
|
|
let expected: Buffer;
|
||
|
|
try {
|
||
|
|
salt = Buffer.from(saltHex, 'hex');
|
||
|
|
expected = Buffer.from(keyHex, 'hex');
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (expected.length !== KEY_LENGTH) return false;
|
||
|
|
const candidate = await scrypt(password, salt, KEY_LENGTH);
|
||
|
|
return timingSafeEqual(candidate, expected);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Mint a fresh raw token (returned to the caller) and its SHA-256 hash
|
||
|
|
* (stored in the DB). The raw token is meant to be embedded in a one-shot
|
||
|
|
* URL; only the hash persists.
|
||
|
|
*/
|
||
|
|
export function mintToken(byteLength = 32): { raw: string; hash: string } {
|
||
|
|
const raw = randomBytes(byteLength).toString('base64url');
|
||
|
|
const hash = createHash('sha256').update(raw).digest('hex');
|
||
|
|
return { raw, hash };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function hashToken(raw: string): string {
|
||
|
|
return createHash('sha256').update(raw).digest('hex');
|
||
|
|
}
|