UNPKG

alepha

Version:

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

278 lines (251 loc) 7.77 kB
import type { ScryptOptions } from "node:crypto"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, randomUUID, scrypt, timingSafeEqual, } from "node:crypto"; import { AlephaError } from "alepha"; export class CryptoProvider { protected static readonly SCRYPT_OPTIONS: ScryptOptions = { N: 16384, r: 8, p: 1, }; protected static readonly SCRYPT_KEY_LENGTH = 64; protected static readonly SALT_LENGTH = 16; protected static readonly AES_ALGORITHM = "aes-256-gcm"; protected static readonly AES_IV_LENGTH = 12; protected static readonly AES_TAG_LENGTH = 16; protected static readonly AES_KEY_LENGTH = 32; public async hashPassword(password: string): Promise<string> { const salt = randomBytes(CryptoProvider.SALT_LENGTH).toString("hex"); const derivedKey = (await this.scryptAsync( password, salt, CryptoProvider.SCRYPT_KEY_LENGTH, CryptoProvider.SCRYPT_OPTIONS, )) as Buffer; return `${salt}:${derivedKey.toString("hex")}`; } public async verifyPassword( password: string, stored: string, ): Promise<boolean> { if (!stored || typeof stored !== "string") { return false; } const parts = stored.split(":"); if (parts.length !== 2) { return false; } const [salt, originalHex] = parts; if (!salt || !originalHex) { return false; } if (originalHex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(originalHex)) { return false; } try { const derivedKey = (await this.scryptAsync( password, salt, CryptoProvider.SCRYPT_KEY_LENGTH, CryptoProvider.SCRYPT_OPTIONS, )) as Buffer; const originalKey = Buffer.from(originalHex, "hex"); if (derivedKey.length !== originalKey.length) { return false; } return timingSafeEqual(derivedKey, originalKey); } catch { return false; } } public hash(data: string, algorithm = "sha256"): string { return createHash(algorithm).update(data).digest("hex"); } public hmac(data: string, secret: string, algorithm = "sha256"): string { return createHmac(algorithm, secret).update(data).digest("hex"); } public verifyHmac( data: string, signature: string, secret: string, algorithm = "sha256", ): boolean { const expected = this.hmac(data, secret, algorithm); return this.equals(expected, signature); } public encrypt(plaintext: string, key: string): string { const keyBuffer = this.deriveAesKey(key); const iv = randomBytes(CryptoProvider.AES_IV_LENGTH); const cipher = createCipheriv(CryptoProvider.AES_ALGORITHM, keyBuffer, iv, { authTagLength: CryptoProvider.AES_TAG_LENGTH, }); const encrypted = Buffer.concat([ cipher.update(plaintext, "utf8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; } public decrypt(ciphertext: string, key: string): string { const parts = ciphertext.split(":"); if (parts.length !== 3) { throw new AlephaError("Invalid ciphertext format"); } const [ivHex, tagHex, encryptedHex] = parts; const keyBuffer = this.deriveAesKey(key); const decipher = createDecipheriv( CryptoProvider.AES_ALGORITHM, keyBuffer, Buffer.from(ivHex!, "hex"), { authTagLength: CryptoProvider.AES_TAG_LENGTH }, ); decipher.setAuthTag(Buffer.from(tagHex!, "hex")); return ( decipher.update(encryptedHex!, "hex", "utf8") + decipher.final("utf8") ); } public equals(a: string, b: string): boolean { const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) { // Constant-time compare against self to avoid timing leak on length mismatch timingSafeEqual(bufA, bufA); return false; } return timingSafeEqual(bufA, bufB); } public randomUUID(): string { return randomUUID(); } public randomText(length: number): string { return randomBytes(length).toString("base64url").slice(0, length); } public randomCode(length: number): string { const max = 10 ** length; const code = randomInt(max); return String(code).padStart(length, "0"); } protected scryptAsync( password: string, salt: string, keylen: number, options: ScryptOptions, ): Promise<Buffer> { return new Promise((resolve, reject) => { scrypt(password, salt, keylen, options, (err, derivedKey) => { if (err) reject(err); else resolve(derivedKey); }); }); } protected deriveAesKey(key: string): Buffer { return createHash("sha256") .update(key) .digest() .subarray(0, CryptoProvider.AES_KEY_LENGTH); } /** * Web Crypto API parity with `BrowserCryptoProvider`. Node 18+ exposes * `globalThis.crypto.subtle`, so the implementations are the same on * both runtimes. Server-side use is unusual — passphrase-derived keys * are a browser-only flow in Alepha — but these stubs exist so the * type surface matches and shared call sites compile. */ public async deriveKeyFromPassphrase( passphrase: string, saltHex: string, iterations = 600_000, ): Promise<CryptoKey> { const subtle = (globalThis as { crypto: { subtle: SubtleCrypto } }).crypto .subtle; const baseKey = await subtle.importKey( "raw", new TextEncoder().encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"], ); return subtle.deriveKey( { name: "PBKDF2", salt: hexToBytes(saltHex).buffer as ArrayBuffer, iterations, hash: "SHA-256", }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } public async encryptWithPassphrase( plaintext: string, key: CryptoKey, saltHex: string, iterations = 600_000, ): Promise<string> { const subtle = (globalThis as { crypto: { subtle: SubtleCrypto } }).crypto .subtle; const iv = randomBytes(CryptoProvider.AES_IV_LENGTH); const encrypted = await subtle.encrypt( { name: "AES-GCM", iv: iv.buffer as ArrayBuffer }, key, new TextEncoder().encode(plaintext), ); return JSON.stringify({ v: 1, salt: saltHex, iv: iv.toString("hex"), ciphertext: Buffer.from(encrypted).toString("hex"), kdf: { name: "PBKDF2", iterations, hash: "SHA-256" }, }); } public async decryptWithPassphrase( envelope: string, passphrase: string, ): Promise<string> { const subtle = (globalThis as { crypto: { subtle: SubtleCrypto } }).crypto .subtle; let parsed: { salt?: string; iv?: string; ciphertext?: string; kdf?: { iterations?: number }; }; try { parsed = JSON.parse(envelope); } catch { throw new AlephaError("Invalid protected envelope"); } if (!parsed.salt || !parsed.iv || !parsed.ciphertext) { throw new AlephaError("Invalid protected envelope"); } const key = await this.deriveKeyFromPassphrase( passphrase, parsed.salt, parsed.kdf?.iterations ?? 600_000, ); const decrypted = await subtle.decrypt( { name: "AES-GCM", iv: hexToBytes(parsed.iv).buffer as ArrayBuffer }, key, hexToBytes(parsed.ciphertext).buffer as ArrayBuffer, ); return new TextDecoder().decode(decrypted); } } const hexToBytes = (hex: string): Uint8Array => { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { out[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16); } return out; };