const encoder = new TextEncoder(); const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; const ID_LENGTH = 20; /** Workers' WebCrypto refuses PBKDF2 above 100,000 iterations, so this is the highest cost the runtime allows. */ const PBKDF2_ITERATIONS = 100_000; const PBKDF2_MAX_ITERATIONS = 100_000; const SALT_BYTES = 16; const HASH_BITS = 256; const AES_IV_BYTES = 12; const AES_KEY_BITS = 256; /** Ciphertexts under an HKDF-derived per-purpose key with AAD carry this prefix; bare base64 is legacy. */ const SEALED_V2_PREFIX = "v2:"; export function randomId(length = ID_LENGTH): string { const bytes = crypto.getRandomValues(new Uint8Array(length)); let out = ""; for (const byte of bytes) { out += ID_ALPHABET[byte % ID_ALPHABET.length]; } return out; } export function toHex(bytes: ArrayBuffer | Uint8Array): string { const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let out = ""; for (const byte of view) { out += byte.toString(16).padStart(2, "0"); } return out; } export function fromHex(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let index = 0; index < out.length; index++) { out[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } return out; } export function toBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary); } export function fromBase64(value: string): Uint8Array { const binary = atob(value); const out = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { out[index] = binary.charCodeAt(index); } return out; } export function randomToken(prefix: string): string { return `${prefix}_${toHex(crypto.getRandomValues(new Uint8Array(20)))}`; } export async function sha256Hex(value: string): Promise { return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(value))); } export function timingSafeEqualStrings(left: string, right: string): boolean { const leftBytes = encoder.encode(left); const rightBytes = encoder.encode(right); if (leftBytes.byteLength !== rightBytes.byteLength) return false; let difference = 0; for (let index = 0; index < leftBytes.byteLength; index++) { difference |= leftBytes[index] ^ rightBytes[index]; } return difference === 0; } async function pbkdf2(password: string, salt: Uint8Array, iterations: 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, iterations }, key, HASH_BITS); return new Uint8Array(bits); } export async function hashPassword(password: string): Promise { const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS); return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`; } export async function verifyPassword(password: string, stored: string): Promise { const [scheme, iterations, salt, hash] = stored.split("$"); if (scheme !== "pbkdf2" || !iterations || !salt || !hash) return false; const cost = Number(iterations); if (!Number.isInteger(cost) || cost < 1 || cost > PBKDF2_MAX_ITERATIONS) return false; const computed = await pbkdf2(password, fromBase64(salt), cost); return timingSafeEqualStrings(toBase64(computed), hash); } /** True when a stored hash uses fewer iterations than the current cost and should be re-hashed on next login. */ export function passwordNeedsRehash(stored: string): boolean { const [scheme, iterations] = stored.split("$"); return scheme !== "pbkdf2" || !Number.isFinite(Number(iterations)) || Number(iterations) < PBKDF2_ITERATIONS; } let dummyHash: Promise | null = null; /** * Spends the same PBKDF2 work as a real verification for a login that matched no account, so response * time does not reveal whether a username or email exists (AUTH-A5). The result is always false. */ export async function verifyPasswordAgainstNobody(password: string): Promise { dummyHash ??= hashPassword(randomToken("dummy")); await verifyPassword(password, await dummyHash); return false; } export async function hmacSha256Hex(secret: string, body: string): Promise { const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [ "sign", ]); return toHex(await crypto.subtle.sign("HMAC", key, encoder.encode(body))); } /** Legacy (pre-v2) key: a bare SHA-256 of the master key, shared by every purpose. Decrypt only. */ async function legacySecretsKey(masterKey: string): Promise { assertMasterKey(masterKey); const digest = await crypto.subtle.digest("SHA-256", encoder.encode(masterKey)); return crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["decrypt"]); } /** * What a ciphertext is for. `purpose` selects an HKDF-derived key (so TOTP seeds and CI secrets never * share one), and `aad` binds the ciphertext to its row (user id, repo id) so it cannot be moved to * another row and still decrypt (AUTH-A10). */ export type SealContext = { purpose: string; aad?: string }; const DEFAULT_SEAL_CONTEXT: SealContext = { purpose: "generic" }; /** * An empty or whitespace master key would derive a key anyone can recompute, so sealing refuses it outright. * The deployed-worker boot check (src/server/deployment.ts) additionally requires MIN_SECRETS_KEY_LENGTH. */ function assertMasterKey(masterKey: string | undefined): asserts masterKey is string { if (!masterKey || masterKey.trim() === "") throw new Error("SECRETS_KEY is not configured"); } export const MIN_SECRETS_KEY_LENGTH = 32; async function derivedSecretsKey(masterKey: string, purpose: string): Promise { assertMasterKey(masterKey); const material = await crypto.subtle.importKey("raw", encoder.encode(masterKey), "HKDF", false, ["deriveKey"]); return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: encoder.encode("gitfor-secrets"), info: encoder.encode(purpose) }, material, { name: "AES-GCM", length: AES_KEY_BITS }, false, ["encrypt", "decrypt"], ); } export async function encryptSecret( masterKey: string, plaintext: string, context: SealContext = DEFAULT_SEAL_CONTEXT, ): Promise<{ ciphertext: string; iv: string }> { const iv = crypto.getRandomValues(new Uint8Array(AES_IV_BYTES)); const key = await derivedSecretsKey(masterKey, context.purpose); const encrypted = await crypto.subtle.encrypt( { name: "AES-GCM", iv, additionalData: encoder.encode(context.aad ?? "") }, key, encoder.encode(plaintext), ); return { ciphertext: `${SEALED_V2_PREFIX}${toBase64(new Uint8Array(encrypted))}`, iv: toBase64(iv) }; } /** Whether a ciphertext predates per-purpose keys; callers that can cheaply re-encrypt should. */ export function isLegacySecret(ciphertext: string): boolean { return !ciphertext.startsWith(SEALED_V2_PREFIX); } export async function decryptSecret( masterKey: string, ciphertext: string, iv: string, context: SealContext = DEFAULT_SEAL_CONTEXT, ): Promise { if (isLegacySecret(ciphertext)) { const key = await legacySecretsKey(masterKey); const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(iv) }, key, fromBase64(ciphertext)); return new TextDecoder().decode(decrypted); } const key = await derivedSecretsKey(masterKey, context.purpose); const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: fromBase64(iv), additionalData: encoder.encode(context.aad ?? "") }, key, fromBase64(ciphertext.slice(SEALED_V2_PREFIX.length)), ); return new TextDecoder().decode(decrypted); }