import { base64, base64Decode, base64Url, base64UrlDecode, bytesToArrayBuffer, concatBytes, encoder } from "./util"; const PBKDF2_ITERATIONS = 100_000; export async function hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(16)); const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); return `pbkdf2$${PBKDF2_ITERATIONS}$${base64Url(salt)}$${base64Url(hash)}`; } export async function verifyPassword(password: string, stored: string): Promise { if (stored.startsWith("pbkdf2$")) { const [, itersText, saltPart, hashPart] = stored.split("$"); const iters = Number(itersText); if (!Number.isFinite(iters) || iters <= 0) return false; const salt = base64UrlDecode(saltPart); const hash = await pbkdf2(password, salt, iters); return constantTimeEqual(base64Url(hash), hashPart); } const [saltPart, hashPart] = stored.split("."); if (!saltPart || !hashPart) return false; const salt = base64UrlDecode(saltPart); const digest = await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(concatBytes(salt, encoder.encode(password)))); return constantTimeEqual(base64Url(new Uint8Array(digest)), hashPart); } export function passwordNeedsUpgrade(stored: string): boolean { return !stored.startsWith("pbkdf2$"); } async function pbkdf2(password: string, salt: Uint8Array, iters: number): Promise { const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: bytesToArrayBuffer(salt), iterations: iters }, key, 32 * 8); return new Uint8Array(bits); } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } export async function digestBase64(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); return base64(new Uint8Array(digest)); } export async function signString(input: string, jwk: JsonWebKey): Promise { const key = await crypto.subtle.importKey( "jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"] ); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(input)); return base64(new Uint8Array(signature)); } export async function verifyWithPem(pem: string, data: string, signatureBase64: string): Promise { const key = await importSpkiPem(pem); return await crypto.subtle.verify( { name: "RSASSA-PKCS1-v1_5" }, key, bytesToArrayBuffer(base64Decode(signatureBase64)), encoder.encode(data) ); } export async function exportSpkiPem(jwk: JsonWebKey): Promise { const key = await crypto.subtle.importKey( "jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"] ); const spki = await crypto.subtle.exportKey("spki", key); return toPem("PUBLIC KEY", spki); } export async function importSpkiPem(pem: string): Promise { return await crypto.subtle.importKey( "spki", pemToArrayBuffer(pem), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, ["verify"] ); } function toPem(label: string, key: ArrayBuffer): string { const b64 = base64(new Uint8Array(key)); const lines = b64.match(/.{1,64}/g)?.join("\n") ?? b64; return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`; } function pemToArrayBuffer(pem: string): ArrayBuffer { const b64 = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); return bytesToArrayBuffer(base64Decode(b64)); } export type ParsedSignature = { keyId: string; algorithm: string | null; headers: string[]; signature: string; }; export function parseSignatureHeader(value: string): ParsedSignature | null { const fields = new Map(); const re = /(\w+)="([^"]*)"/g; let match: RegExpExecArray | null; while ((match = re.exec(value)) !== null) fields.set(match[1].toLowerCase(), match[2]); const keyId = fields.get("keyid"); const headers = fields.get("headers"); const signature = fields.get("signature"); if (!keyId || !signature) return null; return { keyId, algorithm: fields.get("algorithm") ?? null, headers: (headers ?? "(created)").split(/\s+/).map((item) => item.toLowerCase()).filter(Boolean), signature }; }