UNPKG

alepha

Version:

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

192 lines (191 loc) 7.29 kB
import { $module, AlephaError } from "alepha"; //#region ../../src/crypto/providers/BrowserCryptoProvider.ts var BrowserCryptoProvider = class BrowserCryptoProvider { static AES_ALGORITHM = "AES-GCM"; static AES_IV_LENGTH = 12; hashPassword() { throw new AlephaError("hashPassword is not supported in the browser"); } verifyPassword() { throw new AlephaError("verifyPassword is not supported in the browser"); } async hash(data, algorithm = "SHA-256") { const encoded = new TextEncoder().encode(data); const digest = await crypto.subtle.digest(algorithm, encoded); return this.toHex(digest); } async hmac(data, secret, algorithm = "SHA-256") { 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); } async verifyHmac(data, signature, secret, algorithm = "SHA-256") { const expected = await this.hmac(data, secret, algorithm); return this.equals(expected, signature); } async encrypt(plaintext, key) { 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 }, cryptoKey, encoded); 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)}`; } async decrypt(ciphertext, key) { 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); 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 }, cryptoKey, combined.buffer); return new TextDecoder().decode(decrypted); } equals(a, b) { 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; } randomUUID() { return crypto.randomUUID(); } randomText(length) { const bytes = crypto.getRandomValues(new Uint8Array(length)); return this.toBase64Url(bytes.buffer).slice(0, length); } randomCode(length) { const max = 10 ** length; const array = /* @__PURE__ */ new Uint32Array(1); const limit = Math.floor(4294967296 / max) * max; let value; do { crypto.getRandomValues(array); value = array[0]; } while (value >= limit); const code = value % max; return String(code).padStart(length, "0"); } toHex(buffer) { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } fromHex(hex) { 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; } toBase64Url(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async deriveAesKey(key) { 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. */ async deriveKeyFromPassphrase(passphrase, saltHex, iterations = 6e5) { 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, 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. */ async encryptWithPassphrase(plaintext, key, saltHex, iterations = 6e5) { const iv = crypto.getRandomValues(new Uint8Array(BrowserCryptoProvider.AES_IV_LENGTH)); const encrypted = await crypto.subtle.encrypt({ name: BrowserCryptoProvider.AES_ALGORITHM, iv: iv.buffer }, 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. */ async decryptWithPassphrase(envelope, passphrase) { let parsed; 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 ?? 6e5); const decrypted = await crypto.subtle.decrypt({ name: BrowserCryptoProvider.AES_ALGORITHM, iv: this.fromHex(parsed.iv).buffer }, key, this.fromHex(parsed.ciphertext).buffer); return new TextDecoder().decode(decrypted); } }; //#endregion //#region ../../src/crypto/index.browser.ts /** * Cryptographic utilities: hashing, HMAC, AES-256-GCM encryption, password hashing, and secure random generation. * * @module alepha.crypto */ const AlephaCrypto = $module({ name: "alepha.crypto", services: [BrowserCryptoProvider] }); //#endregion export { AlephaCrypto, BrowserCryptoProvider as CryptoProvider }; //# sourceMappingURL=index.browser.js.map