UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

286 lines (266 loc) 8.71 kB
import { AlephaError } from "alepha"; export class BrowserCryptoProvider { protected static readonly AES_ALGORITHM = "AES-GCM"; protected static readonly AES_IV_LENGTH = 12; public hashPassword(): never { throw new AlephaError("hashPassword is not supported in the browser"); } public verifyPassword(): never { throw new AlephaError("verifyPassword is not supported in the browser"); } public async hash(data: string, algorithm = "SHA-256"): Promise<string> { const encoded = new TextEncoder().encode(data); const digest = await crypto.subtle.digest(algorithm, encoded); return this.toHex(digest); } public async hmac( data: string, secret: string, algorithm = "SHA-256", ): Promise<string> { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: algorithm }, false, ["sign"], ); const signature = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(data), ); return this.toHex(signature); } public async verifyHmac( data: string, signature: string, secret: string, algorithm = "SHA-256", ): Promise<boolean> { const expected = await this.hmac(data, secret, algorithm); return this.equals(expected, signature); } public async encrypt(plaintext: string, key: string): Promise<string> { const iv = crypto.getRandomValues( new Uint8Array(BrowserCryptoProvider.AES_IV_LENGTH), ); const cryptoKey = await this.deriveAesKey(key); const encoded = new TextEncoder().encode(plaintext); const encrypted = await crypto.subtle.encrypt( { name: BrowserCryptoProvider.AES_ALGORITHM, iv: iv.buffer as ArrayBuffer, }, cryptoKey, encoded, ); // Web Crypto appends the auth tag to the ciphertext const encryptedBytes = new Uint8Array(encrypted); const ciphertext = encryptedBytes.slice(0, -16); const tag = encryptedBytes.slice(-16); return `${this.toHex(iv)}:${this.toHex(tag)}:${this.toHex(ciphertext)}`; } public async decrypt(ciphertext: string, key: string): Promise<string> { const parts = ciphertext.split(":"); if (parts.length !== 3) { throw new AlephaError("Invalid ciphertext format"); } const [ivHex, tagHex, encryptedHex] = parts; const iv = this.fromHex(ivHex!); const tag = this.fromHex(tagHex!); const encrypted = this.fromHex(encryptedHex!); // Web Crypto expects tag appended to ciphertext const combined = new Uint8Array(encrypted.length + tag.length); combined.set(encrypted); combined.set(tag, encrypted.length); const cryptoKey = await this.deriveAesKey(key); const decrypted = await crypto.subtle.decrypt( { name: BrowserCryptoProvider.AES_ALGORITHM, iv: iv.buffer as ArrayBuffer, }, cryptoKey, combined.buffer as ArrayBuffer, ); return new TextDecoder().decode(decrypted); } public equals(a: string, b: string): boolean { if (a.length !== b.length) { return false; } let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } public randomUUID(): string { return crypto.randomUUID(); } public randomText(length: number): string { const bytes = crypto.getRandomValues(new Uint8Array(length)); return this.toBase64Url(bytes.buffer as ArrayBuffer).slice(0, length); } public randomCode(length: number): string { const max = 10 ** length; const array = new Uint32Array(1); // Rejection sampling to avoid modulo bias const limit = Math.floor(0x100000000 / max) * max; let value: number; do { crypto.getRandomValues(array); value = array[0]!; } while (value >= limit); const code = value % max; return String(code).padStart(length, "0"); } protected toHex(buffer: ArrayBuffer | Uint8Array): string { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } protected fromHex(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16); } return bytes; } protected toBase64Url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } protected async deriveAesKey(key: string): Promise<CryptoKey> { const encoded = new TextEncoder().encode(key); const hash = await crypto.subtle.digest("SHA-256", encoded); return crypto.subtle.importKey( "raw", hash, { name: BrowserCryptoProvider.AES_ALGORITHM }, false, ["encrypt", "decrypt"], ); } /** * Derive an AES-GCM key from a low-entropy user passphrase using * PBKDF2-SHA-256. Use this in place of {@link deriveAesKey} whenever the * key material is a human-chosen string — a single SHA-256 over a * passphrase is brute-forceable by anyone with the ciphertext. * * OWASP 2023 recommends 600k iterations of PBKDF2-SHA-256 for password * storage; we use the same budget for client-side at-rest content keys. * Each protected blob carries its own random salt so the same passphrase * derives a different key per blob. */ public async deriveKeyFromPassphrase( passphrase: string, saltHex: string, iterations = 600_000, ): Promise<CryptoKey> { const baseKey = await crypto.subtle.importKey( "raw", new TextEncoder().encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"], ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt: this.fromHex(saltHex).buffer as ArrayBuffer, iterations, hash: "SHA-256", }, baseKey, { name: BrowserCryptoProvider.AES_ALGORITHM, length: 256 }, false, ["encrypt", "decrypt"], ); } /** * Encrypt `plaintext` with a pre-derived passphrase key, returning a * self-contained envelope (versioned for forward compatibility): * * { v: 1, salt, iv, ciphertext, kdf } * * `salt` + `kdf` are echoed back so {@link decryptWithPassphrase} can * reproduce the exact derivation. Each call generates a fresh IV — pass * a stable salt across saves of the same protected blob so the * passphrase only has to be derived once per session. */ public async encryptWithPassphrase( plaintext: string, key: CryptoKey, saltHex: string, iterations = 600_000, ): Promise<string> { const iv = crypto.getRandomValues( new Uint8Array(BrowserCryptoProvider.AES_IV_LENGTH), ); const encrypted = await crypto.subtle.encrypt( { name: BrowserCryptoProvider.AES_ALGORITHM, iv: iv.buffer as ArrayBuffer, }, key, new TextEncoder().encode(plaintext), ); return JSON.stringify({ v: 1, salt: saltHex, iv: this.toHex(iv), ciphertext: this.toHex(new Uint8Array(encrypted)), kdf: { name: "PBKDF2", iterations, hash: "SHA-256" }, }); } /** * Reverse of {@link encryptWithPassphrase}. Throws when the passphrase * is wrong or the envelope is corrupt — both surface as the same * `OperationError` from Web Crypto, which the UI presents as a generic * "wrong passphrase" without revealing which. */ public async decryptWithPassphrase( envelope: string, passphrase: string, ): Promise<string> { let parsed: { v?: number; salt?: string; iv?: string; ciphertext?: string; kdf?: { iterations?: number }; }; try { parsed = JSON.parse(envelope); } catch { throw new AlephaError("Invalid protected folio envelope"); } if (!parsed.salt || !parsed.iv || !parsed.ciphertext) { throw new AlephaError("Invalid protected folio envelope"); } const key = await this.deriveKeyFromPassphrase( passphrase, parsed.salt, parsed.kdf?.iterations ?? 600_000, ); const decrypted = await crypto.subtle.decrypt( { name: BrowserCryptoProvider.AES_ALGORITHM, iv: this.fromHex(parsed.iv).buffer as ArrayBuffer, }, key, this.fromHex(parsed.ciphertext).buffer as ArrayBuffer, ); return new TextDecoder().decode(decrypted); } }