🚀. Socket Launch Week Day 2:Introducing Manifest Alerts.Learn more
Sign In

@strav/kernel

Package Overview
Dependencies
Maintainers
1
Versions
110
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@strav/kernel - npm Package Compare versions

Comparing version
1.0.0-alpha.28
to
1.0.0-alpha.29
+1
-1
package.json
{
"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",

@@ -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)