#Crypto and Secrets
alepha/crypto holds the cryptographic helpers every other module uses:
password hashing, hashes and HMAC, AES-256-GCM encryption, and secure random
values. It also owns APP_SECRET, the key that signs tokens and cookies.
#CryptoProvider
Inject CryptoProvider instead of calling node:crypto or Math.random()
directly, so a test can substitute it.
1import { $inject } from "alepha"; 2import { CryptoProvider } from "alepha/crypto"; 3 4class AccountService { 5 crypto = $inject(CryptoProvider); 6 7 async register(password: string) { 8 const hash = await this.crypto.hashPassword(password); // "$scrypt$ln=14,r=8,p=1$...", scrypt 9 const ok = await this.crypto.verifyPassword(password, hash); // true10 11 const code = this.crypto.randomCode(6); // "042917", digits only12 const token = this.crypto.randomText(32); // base64url, 32 chars13 14 return { hash, ok, code, token };15 }16}
| Method | Notes |
|---|---|
hashPassword(password) |
scrypt (16-byte salt, 64-byte key), stored as a PHC string $scrypt$ln=14,r=8,p=1$<salt>$<hash> |
verifyPassword(password, stored) |
constant-time; reads legacy salt:hex hashes too; false on a malformed hash, never a throw |
needsRehash(stored) |
true for a legacy hash or one below the current scrypt parameters (synchronous) |
hash(data, algorithm = "sha256") |
hex digest; "sha256", "sha384" or "sha512", anything else is refused |
hmac(data, secret, algorithm = "sha256") |
hex digest; verifyHmac(data, signature, secret) compares in constant time |
encrypt(plaintext, key) / decrypt(...) |
AES-256-GCM, output v1:iv:tag:ciphertext in hex, key hashed to 32 bytes with SHA-256 |
equals(a, b) |
constant-time string comparison |
randomUUID() / randomUUIDv7(timestampMs) |
v7 takes the timestamp, see below |
randomText(length) |
base64url, cut to length |
randomCode(length) |
zero-padded digits, for one-time codes |
randomInt(maxExclusive) |
uniform integer in [0, maxExclusive), the stand-in for Math.random(); throws on a bound < 1 |
Every transformation (hashPassword, verifyPassword, hash, hmac,
verifyHmac, encrypt, decrypt) returns a promise, on Node as in the
browser, so the same code runs on both. Random values and equals are
synchronous.
1import { $inject } from "alepha"; 2import { CryptoProvider } from "alepha/crypto"; 3 4class PayloadSigner { 5 protected readonly crypto = $inject(CryptoProvider); 6 7 public async inspect(payload: string, secret: string) { 8 const digest = await this.crypto.hash(payload, "sha256"); 9 const signature = await this.crypto.hmac(payload, secret);10 const valid = await this.crypto.verifyHmac(payload, signature, secret);11 return { digest, signature, valid };12 }13}
The scrypt parameters live in the hash, so raising them later does not break
stored passwords. After a successful verifyPassword, needsRehash(stored)
says whether to store a fresh hash; the built-in password login does it on its
own. decrypt also reads the older iv:tag:ciphertext form, without the v1
prefix.
randomUUIDv7 takes the timestamp rather than reading a clock, so pass
DateTimeProvider.nowMillis() and generated ids follow travel() in tests.
Ids generated in the same millisecond still sort in generation order.
1import { $inject } from "alepha"; 2import { CryptoProvider } from "alepha/crypto"; 3import { DateTimeProvider } from "alepha/datetime"; 4 5class IdService { 6 crypto = $inject(CryptoProvider); 7 dateTime = $inject(DateTimeProvider); 8 9 next() {10 return this.crypto.randomUUIDv7(this.dateTime.nowMillis());11 }12}
#In the browser
The browser build of alepha/crypto exports a Web Crypto implementation under
the same CryptoProvider name and the same contract. The one difference:
hashPassword and verifyPassword reject, since password hashing is
server-only.
Both providers also expose deriveKeyFromPassphrase, encryptWithPassphrase
and decryptWithPassphrase (PBKDF2-SHA-256, 600,000 iterations by default),
meant for client-side encryption with a user's passphrase. The envelope records
its version (v: 1, the only one decryption accepts) and its iteration count,
so either side decrypts what the other wrote.
#APP_SECRET
SecretProvider reads the application secret used to sign JWTs, encrypt
cookies, and sign links such as unsubscribe URLs. It is created only when
something injects it, so an app that signs nothing never needs one.
| Variable | Meaning |
|---|---|
APP_SECRET |
the secret itself; an empty value counts as unset |
APP_SECRET_FILE |
a file holding it, read on boot and generated on first boot (mode 0600) when it is absent or empty |
- Production refuses the default. Unset, the secret falls back to a public constant: outside production that logs a warning, in production the app refuses to boot, since anyone could forge tokens.
APP_SECRETwins over the file. The file is read only when no explicit secret is set.- No file on serverless. With no
APP_SECRETset,APP_SECRET_FILEon a serverless runtime (Cloudflare, Vercel, orALEPHA_SERVERLESSset) throws: there is no writable file system, so setAPP_SECRETas a secret. - The generated secret is 64 random base64url characters. An empty file is regenerated with a warning; a directory that is missing or not writable stops the boot with an error saying so.
- Rotating it invalidates every session token, signed cookie and signed link issued with the old value.
APP_SECRET_FILE is what lets a self-hosted image start with a plain
docker run: point it at a mounted volume and each install mints its own
secret.