@strav/kernel
Advanced tools
+1
-1
| { | ||
| "name": "@strav/kernel", | ||
| "version": "1.0.0-alpha.28", | ||
| "version": "1.0.0-alpha.29", | ||
| "description": "Strav kernel — IoC container, service providers, lifecycle, config, events, helpers", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+100
-12
@@ -16,6 +16,22 @@ /** | ||
| * | ||
| * **Rotation.** The cipher accepts an optional `previousKeys` ring. | ||
| * Encryption always uses the current key; decryption tries the current | ||
| * key first, then each previous key in order, returning the first | ||
| * successful decrypt. Old ciphertext keeps decrypting after a key swap | ||
| * without re-encrypting every row — production apps add the old key to | ||
| * `previousKeys`, rotate the env, and (optionally) re-encrypt-on-read | ||
| * to migrate forward over time. The envelope shape didn't change; this | ||
| * is a pure rotation policy, not a format version bump. | ||
| * | ||
| * **Blind index.** Encrypted columns can't be queried by equality (the | ||
| * IV makes every ciphertext different). `cipher.blindIndex(plaintext)` | ||
| * returns a deterministic HMAC-SHA256 (hex) of the plaintext under the | ||
| * current key — apps store it in a sidecar column (e.g. `email_index`) | ||
| * and query `WHERE email_index = ?` after hashing the lookup value the | ||
| * same way. | ||
| * | ||
| * @see docs/kernel/guides/encryption.md | ||
| */ | ||
| import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' | ||
| import { createCipheriv, createDecipheriv, createHmac, randomBytes } from 'node:crypto' | ||
| import { ConfigError } from '../exceptions/config_error.ts' | ||
@@ -31,4 +47,5 @@ | ||
| /** | ||
| * Base contract. Concrete subclasses override `encrypt` / `decrypt`. The | ||
| * default impl throws — see file header for the rationale. | ||
| * Base contract. Concrete subclasses override `encrypt` / `decrypt` / | ||
| * `blindIndex`. The default impls throw — see file header for the | ||
| * rationale. | ||
| */ | ||
@@ -50,4 +67,33 @@ export class Cipher { | ||
| } | ||
| /** | ||
| * Deterministic searchable hash of a plaintext value under the current | ||
| * key. Pair with an `_index` sidecar column so encrypted fields stay | ||
| * queryable by equality. The same plaintext always returns the same | ||
| * hash; rotating the key invalidates every previous index value, so | ||
| * applications that rotate must rehash on next write (the rotation | ||
| * design intentionally does NOT auto-rehash blind indexes — silently | ||
| * touching every row at boot is the worse outcome). | ||
| */ | ||
| blindIndex(_plaintext: string): string { | ||
| throw new ConfigError( | ||
| 'Cipher.blindIndex called but no encryption key is configured. ' + | ||
| 'Register EncryptionProvider with `config.encryption.key` set.', | ||
| { code: 'encryption.not-configured' }, | ||
| ) | ||
| } | ||
| } | ||
| export interface AesGcm256CipherOptions { | ||
| /** Current key. Used for every encrypt + blind-index call. */ | ||
| key: Uint8Array | ||
| /** | ||
| * Older keys to try (in order) when the current key fails to decrypt. | ||
| * Use during rotation: add the old key here, swap `key` to the new | ||
| * one. Old ciphertext keeps decrypting; new writes go out under the | ||
| * new key. Empty / omitted → no fallback, decryption with the wrong | ||
| * key throws as before. | ||
| */ | ||
| previousKeys?: readonly Uint8Array[] | ||
| } | ||
| /** | ||
@@ -63,9 +109,20 @@ * AES-256-GCM cipher. The 32-byte key is supplied at construction; see | ||
| export class AesGcm256Cipher extends Cipher { | ||
| constructor(private readonly key: Uint8Array) { | ||
| private readonly currentKey: Uint8Array | ||
| private readonly keyRing: readonly Uint8Array[] | ||
| /** | ||
| * Two-arg form `new AesGcm256Cipher(keyBytes)` is preserved for | ||
| * backward compatibility. Pass `{ key, previousKeys }` to enable | ||
| * rotation. | ||
| */ | ||
| constructor(keyOrOptions: Uint8Array | AesGcm256CipherOptions) { | ||
| super() | ||
| if (key.length !== KEY_LEN) { | ||
| throw new ConfigError(`AesGcm256Cipher: key must be ${KEY_LEN} bytes; got ${key.length}.`, { | ||
| code: 'encryption.bad-key', | ||
| }) | ||
| const opts: AesGcm256CipherOptions = | ||
| keyOrOptions instanceof Uint8Array ? { key: keyOrOptions } : keyOrOptions | ||
| assertKeyLength(opts.key, 'key') | ||
| for (const [i, k] of (opts.previousKeys ?? []).entries()) { | ||
| assertKeyLength(k, `previousKeys[${i}]`) | ||
| } | ||
| this.currentKey = opts.key | ||
| this.keyRing = [opts.key, ...(opts.previousKeys ?? [])] | ||
| } | ||
@@ -75,3 +132,3 @@ | ||
| const iv = randomBytes(IV_LEN) | ||
| const cipher = createCipheriv('aes-256-gcm', this.key, iv) | ||
| const cipher = createCipheriv('aes-256-gcm', this.currentKey, iv) | ||
| const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) | ||
@@ -93,8 +150,39 @@ const tag = cipher.getAuthTag() | ||
| const ct = buf.subarray(IV_LEN + TAG_LEN) | ||
| const decipher = createDecipheriv('aes-256-gcm', this.key, iv) | ||
| decipher.setAuthTag(tag) | ||
| return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') | ||
| // Try the current key first; fall through to each previous key on | ||
| // auth-tag failure. Node's `decipher.final()` throws when the tag | ||
| // doesn't verify — that's the signal we're using. | ||
| let lastErr: unknown | ||
| for (const key of this.keyRing) { | ||
| try { | ||
| const decipher = createDecipheriv('aes-256-gcm', key, iv) | ||
| decipher.setAuthTag(tag) | ||
| return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') | ||
| } catch (err) { | ||
| lastErr = err | ||
| } | ||
| } | ||
| // None of the keys decrypted — surface the last error with context. | ||
| throw new ConfigError( | ||
| `AesGcm256Cipher.decrypt: ciphertext did not decrypt under any of the ${this.keyRing.length} configured key(s). Underlying error: ${(lastErr as Error)?.message ?? String(lastErr)}`, | ||
| { code: 'encryption.decrypt-failed' }, | ||
| ) | ||
| } | ||
| override blindIndex(plaintext: string): string { | ||
| // HMAC-SHA256(currentKey, plaintext) → 64-char hex. Deterministic | ||
| // and keyed — observers without the key can't compute the index. | ||
| return createHmac('sha256', this.currentKey).update(plaintext, 'utf8').digest('hex') | ||
| } | ||
| } | ||
| function assertKeyLength(key: Uint8Array, label: string): void { | ||
| if (key.length !== KEY_LEN) { | ||
| throw new ConfigError( | ||
| `AesGcm256Cipher: ${label} must be ${KEY_LEN} bytes; got ${key.length}.`, | ||
| { code: 'encryption.bad-key' }, | ||
| ) | ||
| } | ||
| } | ||
| /** | ||
@@ -101,0 +189,0 @@ * Normalize a key value to a 32-byte `Uint8Array`. Accepts: |
@@ -32,2 +32,10 @@ /** | ||
| key: string | Uint8Array | ||
| /** | ||
| * Optional ring of previous keys. Encryption always uses `key`; | ||
| * decryption falls through `key` → `previousKeys[0]` → `previousKeys[1]` | ||
| * → ... until one verifies. Use during rotation: keep the old key | ||
| * here for as long as legacy ciphertext (or blind-index columns | ||
| * computed under the old key) still lives in the database. | ||
| */ | ||
| previousKeys?: ReadonlyArray<string | Uint8Array> | ||
| } | ||
@@ -51,3 +59,4 @@ | ||
| const key = parseEncryptionKey(cfg.key) | ||
| return new AesGcm256Cipher(key) | ||
| const previousKeys = (cfg.previousKeys ?? []).map((k) => parseEncryptionKey(k)) | ||
| return new AesGcm256Cipher({ key, previousKeys }) | ||
| }) | ||
@@ -54,0 +63,0 @@ } |
@@ -5,13 +5,21 @@ /** | ||
| * | ||
| * Typical usage from `bootstrap/providers.ts`: | ||
| * Two construction modes: | ||
| * | ||
| * ```ts | ||
| * import appConfig from '../config/app.ts' | ||
| * import dbConfig from '../config/database.ts' | ||
| * 1. **Auto-discovery** (recommended): | ||
| * ```ts | ||
| * const providers = [ | ||
| * await ConfigProvider.fromDirectory('config'), | ||
| * // ... other providers | ||
| * ] | ||
| * ``` | ||
| * Scans `<cwd>/config/*.{ts,js,mts,mjs}`, dynamic-imports each | ||
| * one, and keys the merged map by file basename. `config/app.ts` | ||
| * → `config.app.*`, `config/database.ts` → `config.database.*`, | ||
| * and so on. Files starting with `_` or `.` are skipped. | ||
| * | ||
| * export default [ | ||
| * new ConfigProvider({ app: appConfig, database: dbConfig }), | ||
| * // ... other providers | ||
| * ] | ||
| * ``` | ||
| * 2. **Explicit map** (back-compat, useful in tests): | ||
| * ```ts | ||
| * new ConfigProvider({ app: appConfig, database: dbConfig }) | ||
| * ``` | ||
| * Same shape as before — pass a pre-built `ConfigData` map. | ||
| * | ||
@@ -23,5 +31,28 @@ * `ConfigProvider` is the first provider to register (no deps), so other | ||
| import { readdir } from 'node:fs/promises' | ||
| import { isAbsolute, join, parse as parsePath, resolve } from 'node:path' | ||
| import { pathToFileURL } from 'node:url' | ||
| import { type ConfigData, ConfigRepository } from '../config/configuration.ts' | ||
| import { type Application, ServiceProvider } from '../core/index.ts' | ||
| const CONFIG_FILE_RE = /\.(?:ts|js|mts|mjs|cts|cjs)$/ | ||
| export interface FromDirectoryOptions { | ||
| /** | ||
| * Directory absolute or relative to `cwd`. Default `'config'`. | ||
| */ | ||
| directory?: string | ||
| /** | ||
| * Working directory for resolving a relative `directory`. Defaults | ||
| * to `process.cwd()`. Useful for tests. | ||
| */ | ||
| cwd?: string | ||
| /** | ||
| * Extra config to merge on top of the discovered files. Lets apps | ||
| * overlay environment-specific values (e.g. test fixtures) without | ||
| * touching the on-disk `config/` tree. | ||
| */ | ||
| overrides?: ConfigData | ||
| } | ||
| export class ConfigProvider extends ServiceProvider { | ||
@@ -35,2 +66,85 @@ override readonly name = 'config' | ||
| /** | ||
| * Scan a directory of config files and return a ready-to-use | ||
| * `ConfigProvider`. Each file's default export becomes one | ||
| * top-level config section keyed by the file's basename. | ||
| * | ||
| * Discovery rules: | ||
| * | ||
| * - Files matching `*.{ts,js,mts,mjs,cts,cjs}` are imported. | ||
| * - The default export is read; sections without one are skipped | ||
| * with a warning written to stderr. | ||
| * - Files / sub-directories whose name starts with `.` or `_` | ||
| * are ignored (handy for `_local.ts`, `.draft.ts`, etc.). | ||
| * - Sub-directories are NOT recursed — keep `config/` flat. Apps | ||
| * that want nested config compose objects inside one file. | ||
| * | ||
| * Discovery happens in parallel. Errors thrown by a config file's | ||
| * top-level code propagate — config files are expected to be pure | ||
| * (read `env(...)`, return an object) and not throw under normal | ||
| * load. | ||
| */ | ||
| static async fromDirectory( | ||
| directoryOrOptions: string | FromDirectoryOptions = 'config', | ||
| ): Promise<ConfigProvider> { | ||
| const opts: FromDirectoryOptions = | ||
| typeof directoryOrOptions === 'string' | ||
| ? { directory: directoryOrOptions } | ||
| : directoryOrOptions | ||
| const cwd = opts.cwd ?? process.cwd() | ||
| const directory = opts.directory ?? 'config' | ||
| const absDir = isAbsolute(directory) ? directory : resolve(cwd, directory) | ||
| let entries: Array<{ name: string; isFile(): boolean; isDirectory(): boolean }> | ||
| try { | ||
| entries = await readdir(absDir, { withFileTypes: true }) | ||
| } catch (cause) { | ||
| throw new Error( | ||
| `ConfigProvider.fromDirectory: could not read '${absDir}'. ${(cause as Error).message}`, | ||
| { cause }, | ||
| ) | ||
| } | ||
| const candidates = entries.filter( | ||
| (e) => | ||
| e.isFile() && | ||
| !e.name.startsWith('.') && | ||
| !e.name.startsWith('_') && | ||
| CONFIG_FILE_RE.test(e.name), | ||
| ) | ||
| const data: ConfigData = {} | ||
| await Promise.all( | ||
| candidates.map(async (entry) => { | ||
| const path = join(absDir, entry.name) | ||
| const key = parsePath(entry.name).name | ||
| try { | ||
| const mod = (await import(pathToFileURL(path).href)) as { | ||
| default?: unknown | ||
| } | ||
| if (mod.default === undefined) { | ||
| // Skip silently? No — a config file with no default export | ||
| // is almost always a typo. Loud miss with the path. | ||
| process.stderr.write( | ||
| `[ConfigProvider] '${path}' has no default export — skipped.\n`, | ||
| ) | ||
| return | ||
| } | ||
| data[key] = mod.default | ||
| } catch (cause) { | ||
| throw new Error( | ||
| `ConfigProvider.fromDirectory: failed to load '${path}'. ${(cause as Error).message}`, | ||
| { cause }, | ||
| ) | ||
| } | ||
| }), | ||
| ) | ||
| if (opts.overrides !== undefined) { | ||
| Object.assign(data, opts.overrides) | ||
| } | ||
| return new ConfigProvider(data) | ||
| } | ||
| override register(app: Application): void { | ||
@@ -37,0 +151,0 @@ const repository = new ConfigRepository(this.data) |
141893
6.37%3469
6.05%