Files
2026-05-14 09:59:58 +08:00

127 lines
4.5 KiB
TypeScript

import { base64, base64Decode, base64Url, base64UrlDecode, bytesToArrayBuffer, concatBytes, encoder } from "./util";
const PBKDF2_ITERATIONS = 100_000;
export async function hashPassword(password: string): Promise<string> {
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<boolean> {
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<Uint8Array> {
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<string> {
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<string> {
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<boolean> {
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<string> {
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<CryptoKey> {
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<string, string>();
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
};
}