103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import { argon2, createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
|
|
export interface PasswordHasher {
|
|
createSalt(): string;
|
|
hash(password: string): Promise<{ hash: string; salt: string }>;
|
|
verify(password: string, hash: string, salt: string): Promise<{ ok: boolean; needsUpgrade: boolean }>;
|
|
}
|
|
|
|
const ARGON2_MEMORY_KIB = 19 * 1024;
|
|
const ARGON2_PASSES = 2;
|
|
const ARGON2_PARALLELISM = 1;
|
|
const ARGON2_TAG_LENGTH = 32;
|
|
const ARGON2_PREFIX = `$argon2id$v=19$m=${ARGON2_MEMORY_KIB},t=${ARGON2_PASSES},p=${ARGON2_PARALLELISM}$`;
|
|
|
|
const deriveArgon2id = (password: string, salt: Buffer): Promise<Buffer> =>
|
|
new Promise((resolve, reject) => {
|
|
argon2(
|
|
'argon2id',
|
|
{
|
|
message: Buffer.from(password, 'utf8'),
|
|
nonce: salt,
|
|
parallelism: ARGON2_PARALLELISM,
|
|
tagLength: ARGON2_TAG_LENGTH,
|
|
memory: ARGON2_MEMORY_KIB,
|
|
passes: ARGON2_PASSES,
|
|
},
|
|
(error, result) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve(result);
|
|
}
|
|
);
|
|
});
|
|
|
|
const safeEqualHex = (left: string, right: string): boolean => {
|
|
if (!/^[a-f0-9]+$/i.test(left) || !/^[a-f0-9]+$/i.test(right) || left.length !== right.length) {
|
|
return false;
|
|
}
|
|
return timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'));
|
|
};
|
|
|
|
const parseArgon2Hash = (hash: string): { salt: Buffer; expected: Buffer } | null => {
|
|
if (!hash.startsWith(ARGON2_PREFIX)) {
|
|
return null;
|
|
}
|
|
const encoded = hash.slice(ARGON2_PREFIX.length).split('$');
|
|
if (encoded.length !== 2 || !encoded[0] || !encoded[1]) {
|
|
return null;
|
|
}
|
|
try {
|
|
const salt = Buffer.from(encoded[0], 'base64url');
|
|
const expected = Buffer.from(encoded[1], 'base64url');
|
|
if (salt.length < 16 || expected.length !== ARGON2_TAG_LENGTH) {
|
|
return null;
|
|
}
|
|
return { salt, expected };
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const createPasswordHasher = (options: { legacyGlobalSalt?: string } = {}): PasswordHasher => ({
|
|
createSalt: () => randomBytes(16).toString('hex'),
|
|
hash: async (password) => {
|
|
const salt = randomBytes(16);
|
|
const derived = await deriveArgon2id(password, salt);
|
|
return {
|
|
hash: `${ARGON2_PREFIX}${salt.toString('base64url')}$${derived.toString('base64url')}`,
|
|
salt: '',
|
|
};
|
|
},
|
|
verify: async (password, hash, salt) => {
|
|
const modern = parseArgon2Hash(hash);
|
|
if (modern) {
|
|
const actual = await deriveArgon2id(password, modern.salt);
|
|
return {
|
|
ok: timingSafeEqual(actual, modern.expected),
|
|
needsUpgrade: false,
|
|
};
|
|
}
|
|
|
|
if (/^[a-f0-9]{64}$/i.test(hash)) {
|
|
const actual = createHash('sha256').update(`${salt}:${password}`).digest('hex');
|
|
return { ok: safeEqualHex(actual, hash), needsUpgrade: true };
|
|
}
|
|
|
|
if (/^[a-f0-9]{128}$/i.test(hash) && options.legacyGlobalSalt) {
|
|
const browserHash = createHash('sha512')
|
|
.update(`${options.legacyGlobalSalt}${password}${options.legacyGlobalSalt}`)
|
|
.digest('hex');
|
|
const actual = createHash('sha512').update(`${salt}${browserHash}${salt}`).digest('hex');
|
|
return { ok: safeEqualHex(actual, hash), needsUpgrade: true };
|
|
}
|
|
|
|
return { ok: false, needsUpgrade: false };
|
|
},
|
|
});
|
|
|
|
// 기존 import 지점을 깨지 않되 새 계정은 Argon2id를 사용한다.
|
|
export const createSimplePasswordHasher = createPasswordHasher;
|