alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
193 lines (192 loc) • 7.36 kB
JavaScript
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