alepha@docs:~/framework/docs/guides/security$
cat 6-crypto.md | pretty
4 min read
Last commit:

#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.

typescript
 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.

typescript
 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.

typescript
 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_SECRET wins over the file. The file is read only when no explicit secret is set.
  • No file on serverless. With no APP_SECRET set, APP_SECRET_FILE on a serverless runtime (Cloudflare, Vercel, or ALEPHA_SERVERLESS set) throws: there is no writable file system, so set APP_SECRET as 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.