import { createHash, createHmac, randomBytes, scrypt as scryptCallback, timingSafeEqual, } from 'node:crypto'; import { promisify } from 'node:util'; const scrypt = promisify(scryptCallback) as ( password: string, salt: string, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }, ) => Promise; // 使用较高成本的 scrypt 参数,控制离线破解密码哈希时的时间与内存成本。 const scryptOptions = { N: 16_384, r: 8, p: 1, maxmem: 32 * 1024 * 1024 }; export async function hashPassword(password: string): Promise { // 每个密码使用独立随机盐,数据库泄露时不能直接复用预计算结果。 const salt = randomBytes(16).toString('base64url'); const derived = (await scrypt(password, salt, 64, scryptOptions)) as Buffer; return `scrypt$${salt}$${derived.toString('base64url')}`; } export async function verifyPassword( password: string, encoded: string, ): Promise { const [algorithm, salt, expected] = encoded.split('$'); if (algorithm !== 'scrypt' || !salt || !expected) return false; const actual = (await scrypt(password, salt, 64, scryptOptions)) as Buffer; const expectedBuffer = Buffer.from(expected, 'base64url'); return ( expectedBuffer.length === actual.length && timingSafeEqual(expectedBuffer, actual) ); } export function createOpaqueToken(): string { return randomBytes(48).toString('base64url'); } export function tokenHash(token: string): string { // refresh token 只以摘要形式存库;服务端无需保存可直接用于登录的原文。 return createHash('sha256').update(token).digest('base64url'); } export type AccessTokenClaims = { sub: string; sid: string; exp: number; iat: number; }; export type ExtensionAccessTokenClaims = AccessTokenClaims & { typ: 'extension'; wid: string }; export function signAccessToken( claims: AccessTokenClaims, secret: string, ): string { // access token 是短期自包含凭证;真正可撤销的会话状态仍以 PostgreSQL 为准。 const header = Buffer.from( JSON.stringify({ alg: 'HS256', typ: 'JWT' }), ).toString('base64url'); const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); const signature = createHmac('sha256', secret) .update(`${header}.${payload}`) .digest('base64url'); return `${header}.${payload}.${signature}`; } export function verifyAccessToken( token: string, secret: string, ): AccessTokenClaims | null { const [header, payload, signature] = token.split('.'); if (!header || !payload || !signature) return null; const expected = createHmac('sha256', secret) .update(`${header}.${payload}`) .digest(); const actual = Buffer.from(signature, 'base64url'); // 恒定时间比较可避免根据签名比对耗时推测正确前缀。 if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null; try { const claims = JSON.parse( Buffer.from(payload, 'base64url').toString('utf8'), ) as AccessTokenClaims; return typeof claims.sub === 'string' && typeof claims.sid === 'string' && typeof claims.exp === 'number' && claims.exp > Math.floor(Date.now() / 1000) ? claims : null; } catch { return null; } } /** Extension tokens have an explicit audience so they can never be accepted by * the normal Web/API guard, even though both are signed by the same key. */ export function signExtensionAccessToken( claims: ExtensionAccessTokenClaims, secret: string, ): string { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url'); return `${header}.${payload}.${signature}`; } export function verifyExtensionAccessToken(token: string, secret: string): ExtensionAccessTokenClaims | null { const claims = verifyAccessToken(token, secret) as Partial | null; return claims?.typ === 'extension' ? claims as ExtensionAccessTokenClaims : null; }