UNPKG

alepha

Version:

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

193 lines (192 loc) 7.36 kB
import { $env, $hook, $inject, $module, Alepha, AlephaError, z } from "alepha"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, randomUUID, scrypt, timingSafeEqual } from "node:crypto"; import { $logger } from "alepha/logger"; //#region ../../src/crypto/providers/CryptoProvider.ts var CryptoProvider = class CryptoProvider { static SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 }; static SCRYPT_KEY_LENGTH = 64; static SALT_LENGTH = 16; static AES_ALGORITHM = "aes-256-gcm"; static AES_IV_LENGTH = 12; static AES_TAG_LENGTH = 16; static AES_KEY_LENGTH = 32; async hashPassword(password) { const salt = randomBytes(CryptoProvider.SALT_LENGTH).toString("hex"); return `${salt}:${(await this.scryptAsync(password, salt, CryptoProvider.SCRYPT_KEY_LENGTH, CryptoProvider.SCRYPT_OPTIONS)).toString("hex")}`; } async verifyPassword(password, stored) { 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); const originalKey = Buffer.from(originalHex, "hex"); if (derivedKey.length !== originalKey.length) return false; return timingSafeEqual(derivedKey, originalKey); } catch { return false; } } hash(data, algorithm = "sha256") { return createHash(algorithm).update(data).digest("hex"); } hmac(data, secret, algorithm = "sha256") { return createHmac(algorithm, secret).update(data).digest("hex"); } verifyHmac(data, signature, secret, algorithm = "sha256") { const expected = this.hmac(data, secret, algorithm); return this.equals(expected, signature); } encrypt(plaintext, key) { 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")}`; } decrypt(ciphertext, key) { 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"); } equals(a, b) { const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) { timingSafeEqual(bufA, bufA); return false; } return timingSafeEqual(bufA, bufB); } randomUUID() { return randomUUID(); } randomText(length) { return randomBytes(length).toString("base64url").slice(0, length); } randomCode(length) { const code = randomInt(10 ** length); return String(code).padStart(length, "0"); } scryptAsync(password, salt, keylen, options) { return new Promise((resolve, reject) => { scrypt(password, salt, keylen, options, (err, derivedKey) => { if (err) reject(err); else resolve(derivedKey); }); }); } deriveAesKey(key) { 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. */ async deriveKeyFromPassphrase(passphrase, saltHex, iterations = 6e5) { const subtle = globalThis.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, iterations, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } async encryptWithPassphrase(plaintext, key, saltHex, iterations = 6e5) { const subtle = globalThis.crypto.subtle; const iv = randomBytes(CryptoProvider.AES_IV_LENGTH); const encrypted = await subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, 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" } }); } async decryptWithPassphrase(envelope, passphrase) { const subtle = globalThis.crypto.subtle; let parsed; 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 ?? 6e5); const decrypted = await subtle.decrypt({ name: "AES-GCM", iv: hexToBytes(parsed.iv).buffer }, key, hexToBytes(parsed.ciphertext).buffer); return new TextDecoder().decode(decrypted); } }; const hexToBytes = (hex) => { 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; }; //#endregion //#region ../../src/crypto/providers/SecretProvider.ts const DEFAULT_SECRET_KEY_VALUE = "change-me-in-production"; const alephaSecretEnvSchema = z.object({ APP_SECRET: z.text({ default: DEFAULT_SECRET_KEY_VALUE, description: "The secret key used for signing JWTs, encrypting cookies, and other security features." }) }); var SecretProvider = class { log = $logger(); alepha = $inject(Alepha); env = $env(alephaSecretEnvSchema); get secretKey() { return this.env.APP_SECRET; } configure = $hook({ on: "configure", handler: async () => { if (this.secretKey === "change-me-in-production") { if (this.alepha.isProduction()) throw new AlephaError("APP_SECRET is unset in production (using the built-in default). Set a strong, unique APP_SECRET environment variable — the default is public and lets anyone forge authentication tokens."); this.log.warn("Using the default APP_SECRET. This is fine for local development but MUST be set to a strong, unique value before deploying to production."); } } }); }; //#endregion //#region ../../src/crypto/index.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: [CryptoProvider, SecretProvider] }); //#endregion export { AlephaCrypto, CryptoProvider, DEFAULT_SECRET_KEY_VALUE, SecretProvider, alephaSecretEnvSchema }; //# sourceMappingURL=index.js.map