Initial toot-worker implementation
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user