127 lines
4.5 KiB
TypeScript
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
|
|
};
|
|
}
|