@better-forge/vault 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
- export { createVault, type Vault, type VaultOptions } from './createVault.js'
2
- export { vaultDbModule } from './module.js'
3
- export { vaults, vaultSecrets } from './tables.js'
4
- export { createSecretBox, validateEncryptionKey, type SecretBox } from './secretBox.js'
5
- export { createSealer, sealedText, SEALED_PREFIX, type Sealer, type SealedText } from './sealed.js'
6
- export { canonical, createVaultUseGuard } from './vaultUse.js'
7
- export { createSecretFields, SECRET_MASK, SENSITIVE_HEADER, uniqueSecretFields, type LooseningSwitch, type RestoreResult, type SecretField } from './secretFields.js'
8
- export { HashicorpVaultError, hashicorpLimits, type HashicorpAuthMethod, type HashicorpConfig, type CredentialsRef, type HashicorpVaultRow } from './hashicorp.js'
9
- export type { VaultAudit } from './vaultRoutes.js'
1
+ export { createVault, type Vault, type VaultOptions } from './createVault.js'
2
+ export { vaultDbModule } from './module.js'
3
+ export { vaults, vaultSecrets } from './tables.js'
4
+ export { createSecretBox, validateEncryptionKey, type SecretBox } from './secretBox.js'
5
+ export { createSealer, sealedText, SEALED_PREFIX, type Sealer, type SealedText } from './sealed.js'
6
+ export { canonical, createVaultUseGuard } from './vaultUse.js'
7
+ export { createSecretFields, SECRET_MASK, SENSITIVE_HEADER, uniqueSecretFields, type LooseningSwitch, type RestoreResult, type SecretField } from './secretFields.js'
8
+ export { HashicorpVaultError, hashicorpLimits, type HashicorpAuthMethod, type HashicorpConfig, type CredentialsRef, type HashicorpVaultRow } from './hashicorp.js'
9
+ export type { VaultAudit } from './vaultRoutes.js'
package/src/migrations.ts CHANGED
@@ -1,29 +1,29 @@
1
- import type { MigrationStep } from '@better-forge/db'
2
-
3
- /** Vaults and the secrets in them. Steps run in this order. */
4
- export const vaultMigrations: readonly MigrationStep[] = [
5
- { kind: 'exec', sql: `
6
- CREATE TABLE IF NOT EXISTS vaults (
7
- id INTEGER PRIMARY KEY AUTOINCREMENT,
8
- name TEXT NOT NULL,
9
- type TEXT NOT NULL DEFAULT 'local',
10
- description TEXT,
11
- created_at INTEGER NOT NULL,
12
- updated_at INTEGER NOT NULL
13
- );
14
- ` },
15
- { kind: 'exec', sql: `
16
- CREATE TABLE IF NOT EXISTS vault_secrets (
17
- id INTEGER PRIMARY KEY AUTOINCREMENT,
18
- vault_id INTEGER NOT NULL,
19
- name TEXT NOT NULL,
20
- type TEXT NOT NULL,
21
- encrypted_value TEXT NOT NULL,
22
- created_at INTEGER NOT NULL,
23
- updated_at INTEGER NOT NULL,
24
- FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE,
25
- UNIQUE (vault_id, name)
26
- );
27
- ` },
28
- { kind: 'column', sql: `ALTER TABLE vaults ADD COLUMN connection_config TEXT`, desc: 'vaults.connection_config' },
29
- ]
1
+ import type { MigrationStep } from '@better-forge/db'
2
+
3
+ /** Vaults and the secrets in them. Steps run in this order. */
4
+ export const vaultMigrations: readonly MigrationStep[] = [
5
+ { kind: 'exec', sql: `
6
+ CREATE TABLE IF NOT EXISTS vaults (
7
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8
+ name TEXT NOT NULL,
9
+ type TEXT NOT NULL DEFAULT 'local',
10
+ description TEXT,
11
+ created_at INTEGER NOT NULL,
12
+ updated_at INTEGER NOT NULL
13
+ );
14
+ ` },
15
+ { kind: 'exec', sql: `
16
+ CREATE TABLE IF NOT EXISTS vault_secrets (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ vault_id INTEGER NOT NULL,
19
+ name TEXT NOT NULL,
20
+ type TEXT NOT NULL,
21
+ encrypted_value TEXT NOT NULL,
22
+ created_at INTEGER NOT NULL,
23
+ updated_at INTEGER NOT NULL,
24
+ FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE,
25
+ UNIQUE (vault_id, name)
26
+ );
27
+ ` },
28
+ { kind: 'column', sql: `ALTER TABLE vaults ADD COLUMN connection_config TEXT`, desc: 'vaults.connection_config' },
29
+ ]
package/src/module.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { DbModule } from '@better-forge/db'
2
- import * as tables from './tables.js'
3
- import { vaultMigrations } from './migrations.js'
4
-
5
- /** The tables of vaults and their secrets, with their migrations, for the application's list of database modules. */
6
- export const vaultDbModule = { id: 'vault', tables, migrations: vaultMigrations } satisfies DbModule
1
+ import type { DbModule } from '@better-forge/db'
2
+ import * as tables from './tables.js'
3
+ import { vaultMigrations } from './migrations.js'
4
+
5
+ /** The tables of vaults and their secrets, with their migrations, for the application's list of database modules. */
6
+ export const vaultDbModule = { id: 'vault', tables, migrations: vaultMigrations } satisfies DbModule
@@ -1,96 +1,96 @@
1
- import { and, eq } from 'drizzle-orm'
2
- import type { VaultRef } from '@better-forge/contracts'
3
- import type { SqliteDatabase } from '@better-forge/db'
4
- import { vaults, vaultSecrets } from './tables.js'
5
- import { HashicorpVaultError, type Hashicorp } from './hashicorp.js'
6
- import type { SecretBox } from './secretBox.js'
7
-
8
- type VaultRow = typeof vaults.$inferSelect
9
-
10
- type SecretRow = typeof vaultSecrets.$inferSelect
11
-
12
- export function createSecretResolver(ctx: { db: SqliteDatabase; secretBox: SecretBox }, hashicorp: Hashicorp) {
13
- const { db } = ctx
14
- const { decrypt } = ctx.secretBox
15
- const { readKvSecret } = hashicorp
16
-
17
- /**
18
- * Returns the stored payload of a secret in the shape `decrypt` yields for local vaults
19
- * (`{ username, password }`, `{ value }` where json holds a JSON string). HashiCorp vaults keep only a
20
- * reference `{ path, key? }`; the value is read live from Vault and shaped to match.
21
- */
22
- async function loadSecretPayload(secret: SecretRow, vault: VaultRow): Promise<Record<string, unknown>> {
23
- if (vault.type !== 'hashicorp') return JSON.parse(decrypt(secret.encryptedValue)) as Record<string, unknown>
24
- try {
25
- const ref = JSON.parse(decrypt(secret.encryptedValue)) as { path: string; key?: string }
26
- const kv = await readKvSecret(vault, ref.path)
27
- if (secret.type === 'userpass') {
28
- if (typeof kv['username'] !== 'string' || typeof kv['password'] !== 'string') {
29
- throw new HashicorpVaultError('keys "username" and "password" are required at the path')
30
- }
31
- return { username: kv['username'], password: kv['password'] }
32
- }
33
- if (secret.type === 'value') {
34
- const found = ref.key ? kv[ref.key] : undefined
35
- if (found === undefined || found === null) throw new HashicorpVaultError(`key "${ref.key ?? ''}" not found`)
36
- return { value: typeof found === 'string' ? found : JSON.stringify(found) }
37
- }
38
- return { value: JSON.stringify(kv) }
39
- } catch (e) {
40
- if (e instanceof HashicorpVaultError) throw new Error(`HashiCorp Vault "${vault.name}": ${e.message}`, { cause: e })
41
- throw e
42
- }
43
- }
44
-
45
- /**
46
- * Resolves a vault secret reference to a flat string map.
47
- *
48
- * - userpass → { username, password }
49
- * - value → { value }
50
- * - json → all keys as strings, or filtered/renamed via fieldMapping
51
- * e.g. fieldMapping = { clientId: 'client_id' } → { clientId: <json.client_id> }
52
- */
53
- async function resolveVaultSecret(ref: VaultRef): Promise<Record<string, string>> {
54
- const [secret] = await db
55
- .select()
56
- .from(vaultSecrets)
57
- .where(and(eq(vaultSecrets.id, ref.secretId), eq(vaultSecrets.vaultId, ref.vaultId)))
58
-
59
- const [vault] = secret ? await db.select().from(vaults).where(eq(vaults.id, ref.vaultId)) : []
60
- if (!secret || !vault) {
61
- throw new Error(`Vault secret ${ref.secretId} not found in vault ${ref.vaultId}`)
62
- }
63
-
64
- const decrypted = await loadSecretPayload(secret, vault)
65
-
66
- if (secret.type === 'userpass') {
67
- return {
68
- username: String(decrypted['username'] ?? ''),
69
- password: String(decrypted['password'] ?? ''),
70
- }
71
- }
72
-
73
- if (secret.type === 'value') {
74
- return { value: String(decrypted['value'] ?? '') }
75
- }
76
-
77
- if (secret.type === 'json') {
78
- const raw = JSON.parse(String(decrypted['value'] ?? '{}')) as Record<string, unknown>
79
- const { fieldMapping } = ref
80
- if (fieldMapping && Object.keys(fieldMapping).length > 0) {
81
- return Object.fromEntries(
82
- Object.entries(fieldMapping)
83
- .filter(([, jsonKey]) => !!jsonKey)
84
- .map(([ourField, jsonKey]) => [ourField, String(raw[jsonKey] ?? '')]),
85
- )
86
- }
87
- return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, String(v ?? '')]))
88
- }
89
-
90
- throw new Error(`Unknown vault secret type: ${secret.type}`)
91
- }
92
-
93
- return { loadSecretPayload, resolveVaultSecret }
94
- }
95
-
96
- export type SecretResolver = ReturnType<typeof createSecretResolver>
1
+ import { and, eq } from 'drizzle-orm'
2
+ import type { VaultRef } from '@better-forge/contracts'
3
+ import type { SqliteDatabase } from '@better-forge/db'
4
+ import { vaults, vaultSecrets } from './tables.js'
5
+ import { HashicorpVaultError, type Hashicorp } from './hashicorp.js'
6
+ import type { SecretBox } from './secretBox.js'
7
+
8
+ type VaultRow = typeof vaults.$inferSelect
9
+
10
+ type SecretRow = typeof vaultSecrets.$inferSelect
11
+
12
+ export function createSecretResolver(ctx: { db: SqliteDatabase; secretBox: SecretBox }, hashicorp: Hashicorp) {
13
+ const { db } = ctx
14
+ const { decrypt } = ctx.secretBox
15
+ const { readKvSecret } = hashicorp
16
+
17
+ /**
18
+ * Returns the stored payload of a secret in the shape `decrypt` yields for local vaults
19
+ * (`{ username, password }`, `{ value }` where json holds a JSON string). HashiCorp vaults keep only a
20
+ * reference `{ path, key? }`; the value is read live from Vault and shaped to match.
21
+ */
22
+ async function loadSecretPayload(secret: SecretRow, vault: VaultRow): Promise<Record<string, unknown>> {
23
+ if (vault.type !== 'hashicorp') return JSON.parse(decrypt(secret.encryptedValue)) as Record<string, unknown>
24
+ try {
25
+ const ref = JSON.parse(decrypt(secret.encryptedValue)) as { path: string; key?: string }
26
+ const kv = await readKvSecret(vault, ref.path)
27
+ if (secret.type === 'userpass') {
28
+ if (typeof kv['username'] !== 'string' || typeof kv['password'] !== 'string') {
29
+ throw new HashicorpVaultError('keys "username" and "password" are required at the path')
30
+ }
31
+ return { username: kv['username'], password: kv['password'] }
32
+ }
33
+ if (secret.type === 'value') {
34
+ const found = ref.key ? kv[ref.key] : undefined
35
+ if (found === undefined || found === null) throw new HashicorpVaultError(`key "${ref.key ?? ''}" not found`)
36
+ return { value: typeof found === 'string' ? found : JSON.stringify(found) }
37
+ }
38
+ return { value: JSON.stringify(kv) }
39
+ } catch (e) {
40
+ if (e instanceof HashicorpVaultError) throw new Error(`HashiCorp Vault "${vault.name}": ${e.message}`, { cause: e })
41
+ throw e
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolves a vault secret reference to a flat string map.
47
+ *
48
+ * - userpass → { username, password }
49
+ * - value → { value }
50
+ * - json → all keys as strings, or filtered/renamed via fieldMapping
51
+ * e.g. fieldMapping = { clientId: 'client_id' } → { clientId: <json.client_id> }
52
+ */
53
+ async function resolveVaultSecret(ref: VaultRef): Promise<Record<string, string>> {
54
+ const [secret] = await db
55
+ .select()
56
+ .from(vaultSecrets)
57
+ .where(and(eq(vaultSecrets.id, ref.secretId), eq(vaultSecrets.vaultId, ref.vaultId)))
58
+
59
+ const [vault] = secret ? await db.select().from(vaults).where(eq(vaults.id, ref.vaultId)) : []
60
+ if (!secret || !vault) {
61
+ throw new Error(`Vault secret ${ref.secretId} not found in vault ${ref.vaultId}`)
62
+ }
63
+
64
+ const decrypted = await loadSecretPayload(secret, vault)
65
+
66
+ if (secret.type === 'userpass') {
67
+ return {
68
+ username: String(decrypted['username'] ?? ''),
69
+ password: String(decrypted['password'] ?? ''),
70
+ }
71
+ }
72
+
73
+ if (secret.type === 'value') {
74
+ return { value: String(decrypted['value'] ?? '') }
75
+ }
76
+
77
+ if (secret.type === 'json') {
78
+ const raw = JSON.parse(String(decrypted['value'] ?? '{}')) as Record<string, unknown>
79
+ const { fieldMapping } = ref
80
+ if (fieldMapping && Object.keys(fieldMapping).length > 0) {
81
+ return Object.fromEntries(
82
+ Object.entries(fieldMapping)
83
+ .filter(([, jsonKey]) => !!jsonKey)
84
+ .map(([ourField, jsonKey]) => [ourField, String(raw[jsonKey] ?? '')]),
85
+ )
86
+ }
87
+ return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, String(v ?? '')]))
88
+ }
89
+
90
+ throw new Error(`Unknown vault secret type: ${secret.type}`)
91
+ }
92
+
93
+ return { loadSecretPayload, resolveVaultSecret }
94
+ }
95
+
96
+ export type SecretResolver = ReturnType<typeof createSecretResolver>
package/src/sealed.ts CHANGED
@@ -1,38 +1,38 @@
1
- import { customType } from 'drizzle-orm/sqlite-core'
2
- import type { SecretBox } from './secretBox.js'
3
-
4
- /** Marks a stored value as encrypted, so a column can hold rows from before it was sealed. */
5
- export const SEALED_PREFIX = 'enc:v1:'
6
-
7
- /** Turns a secret into what is stored and back. */
8
- export interface Sealer {
9
- /**
10
- * Always encrypts, whatever the value looks like: a value that merely starts with the prefix is somebody's text, or a
11
- * ciphertext offered to be decrypted by the next read, and must never be taken for one already sealed. Empty stays empty.
12
- */
13
- seal(value: string): string
14
- /** Like `seal`, but leaves a value that is already sealed alone. Only for upgrading rows that may hold either. */
15
- sealPlain(value: string): string
16
- /** Opens a sealed value; a value without the prefix is a row from before sealing and is returned as it is. Throws when the key does not fit. */
17
- open(value: string): string
18
- }
19
-
20
- export function createSealer(box: Pick<SecretBox, 'encrypt' | 'decrypt'>): Sealer {
21
- const seal = (value: string) => (value === '' ? value : `${SEALED_PREFIX}${box.encrypt(value)}`)
22
- return {
23
- seal,
24
- sealPlain: (value) => (value.startsWith(SEALED_PREFIX) ? value : seal(value)),
25
- open: (value) => (value.startsWith(SEALED_PREFIX) ? box.decrypt(value.slice(SEALED_PREFIX.length)) : value),
26
- }
27
- }
28
-
29
- /** A text column whose value goes through `codec` on its way to and from the database; the rest of the code sees the open value. */
30
- export function sealedText(codec: Pick<Sealer, 'seal' | 'open'>) {
31
- return customType<{ data: string; driverData: string }>({
32
- dataType: () => 'text',
33
- toDriver: codec.seal,
34
- fromDriver: codec.open,
35
- })
36
- }
37
-
38
- export type SealedText = ReturnType<typeof sealedText>
1
+ import { customType } from 'drizzle-orm/sqlite-core'
2
+ import type { SecretBox } from './secretBox.js'
3
+
4
+ /** Marks a stored value as encrypted, so a column can hold rows from before it was sealed. */
5
+ export const SEALED_PREFIX = 'enc:v1:'
6
+
7
+ /** Turns a secret into what is stored and back. */
8
+ export interface Sealer {
9
+ /**
10
+ * Always encrypts, whatever the value looks like: a value that merely starts with the prefix is somebody's text, or a
11
+ * ciphertext offered to be decrypted by the next read, and must never be taken for one already sealed. Empty stays empty.
12
+ */
13
+ seal(value: string): string
14
+ /** Like `seal`, but leaves a value that is already sealed alone. Only for upgrading rows that may hold either. */
15
+ sealPlain(value: string): string
16
+ /** Opens a sealed value; a value without the prefix is a row from before sealing and is returned as it is. Throws when the key does not fit. */
17
+ open(value: string): string
18
+ }
19
+
20
+ export function createSealer(box: Pick<SecretBox, 'encrypt' | 'decrypt'>): Sealer {
21
+ const seal = (value: string) => (value === '' ? value : `${SEALED_PREFIX}${box.encrypt(value)}`)
22
+ return {
23
+ seal,
24
+ sealPlain: (value) => (value.startsWith(SEALED_PREFIX) ? value : seal(value)),
25
+ open: (value) => (value.startsWith(SEALED_PREFIX) ? box.decrypt(value.slice(SEALED_PREFIX.length)) : value),
26
+ }
27
+ }
28
+
29
+ /** A text column whose value goes through `codec` on its way to and from the database; the rest of the code sees the open value. */
30
+ export function sealedText(codec: Pick<Sealer, 'seal' | 'open'>) {
31
+ return customType<{ data: string; driverData: string }>({
32
+ dataType: () => 'text',
33
+ toDriver: codec.seal,
34
+ fromDriver: codec.open,
35
+ })
36
+ }
37
+
38
+ export type SealedText = ReturnType<typeof sealedText>
package/src/secretBox.ts CHANGED
@@ -1,72 +1,72 @@
1
- import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
-
3
- const ALGORITHM = 'aes-256-gcm'
4
- const IV_BYTES = 12
5
- const TAG_BYTES = 16
6
-
7
- export interface SecretBox {
8
- /** Encrypts plaintext with AES-256-GCM. Returns `iv:authTag:ciphertext` (all hex). */
9
- encrypt(plaintext: string): string
10
- /** Decrypts a value produced by `encrypt`. Throws on tampered data. */
11
- decrypt(ciphertext: string): string
12
- }
13
-
14
- /**
15
- * Encryption of the secrets an application stores, with the key from `VAULT_ENCRYPTION_KEY` (64 hex characters).
16
- * The key is read on first use, so the warning about the development fallback appears once per box.
17
- * `env` is read at that moment, not captured, so a test may change it before first use.
18
- */
19
- export function createSecretBox(options: { env?: NodeJS.ProcessEnv } = {}): SecretBox {
20
- const env = options.env ?? process.env
21
- let cachedKey: Buffer | null = null
22
-
23
- function getKey(): Buffer {
24
- const raw = env['VAULT_ENCRYPTION_KEY']
25
- if (!raw) {
26
- if (env['NODE_ENV'] === 'production') {
27
- throw new Error('VAULT_ENCRYPTION_KEY environment variable must be set in production')
28
- }
29
- // Dev fallback — 32-byte zero key with warning
30
- console.warn('⚠ VAULT_ENCRYPTION_KEY not set — using insecure dev key (development only)')
31
- return Buffer.alloc(32, 0)
32
- }
33
- const buf = Buffer.from(raw, 'hex')
34
- if (buf.length !== 32) {
35
- throw new Error('VAULT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
36
- }
37
- return buf
38
- }
39
-
40
- const key = () => (cachedKey ??= getKey())
41
-
42
- return {
43
- encrypt(plaintext) {
44
- const iv = randomBytes(IV_BYTES)
45
- const cipher = createCipheriv(ALGORITHM, key(), iv)
46
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
47
- const tag = cipher.getAuthTag()
48
- return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
49
- },
50
- decrypt(ciphertext) {
51
- const parts = ciphertext.split(':')
52
- if (parts.length !== 3) throw new Error('Invalid ciphertext format')
53
- const [ivHex, tagHex, encHex] = parts as [string, string, string]
54
- const iv = Buffer.from(ivHex, 'hex')
55
- const tag = Buffer.from(tagHex, 'hex')
56
- const enc = Buffer.from(encHex, 'hex')
57
- if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error('Invalid ciphertext length')
58
- const decipher = createDecipheriv(ALGORITHM, key(), iv)
59
- decipher.setAuthTag(tag)
60
- return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
61
- },
62
- }
63
- }
64
-
65
- /** In production the key must be exactly 64 hexadecimal characters; refuses to start otherwise. Elsewhere anything goes. */
66
- export function validateEncryptionKey(env: NodeJS.ProcessEnv = process.env): void {
67
- if (env['NODE_ENV'] !== 'production') return
68
- const key = env['VAULT_ENCRYPTION_KEY']
69
- if (!key || !/^[0-9a-fA-F]{64}$/.test(key)) {
70
- throw new Error('VAULT_ENCRYPTION_KEY must be set to exactly 64 hexadecimal characters in production')
71
- }
72
- }
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
+
3
+ const ALGORITHM = 'aes-256-gcm'
4
+ const IV_BYTES = 12
5
+ const TAG_BYTES = 16
6
+
7
+ export interface SecretBox {
8
+ /** Encrypts plaintext with AES-256-GCM. Returns `iv:authTag:ciphertext` (all hex). */
9
+ encrypt(plaintext: string): string
10
+ /** Decrypts a value produced by `encrypt`. Throws on tampered data. */
11
+ decrypt(ciphertext: string): string
12
+ }
13
+
14
+ /**
15
+ * Encryption of the secrets an application stores, with the key from `VAULT_ENCRYPTION_KEY` (64 hex characters).
16
+ * The key is read on first use, so the warning about the development fallback appears once per box.
17
+ * `env` is read at that moment, not captured, so a test may change it before first use.
18
+ */
19
+ export function createSecretBox(options: { env?: NodeJS.ProcessEnv } = {}): SecretBox {
20
+ const env = options.env ?? process.env
21
+ let cachedKey: Buffer | null = null
22
+
23
+ function getKey(): Buffer {
24
+ const raw = env['VAULT_ENCRYPTION_KEY']
25
+ if (!raw) {
26
+ if (env['NODE_ENV'] === 'production') {
27
+ throw new Error('VAULT_ENCRYPTION_KEY environment variable must be set in production')
28
+ }
29
+ // Dev fallback — 32-byte zero key with warning
30
+ console.warn('⚠ VAULT_ENCRYPTION_KEY not set — using insecure dev key (development only)')
31
+ return Buffer.alloc(32, 0)
32
+ }
33
+ const buf = Buffer.from(raw, 'hex')
34
+ if (buf.length !== 32) {
35
+ throw new Error('VAULT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
36
+ }
37
+ return buf
38
+ }
39
+
40
+ const key = () => (cachedKey ??= getKey())
41
+
42
+ return {
43
+ encrypt(plaintext) {
44
+ const iv = randomBytes(IV_BYTES)
45
+ const cipher = createCipheriv(ALGORITHM, key(), iv)
46
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
47
+ const tag = cipher.getAuthTag()
48
+ return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`
49
+ },
50
+ decrypt(ciphertext) {
51
+ const parts = ciphertext.split(':')
52
+ if (parts.length !== 3) throw new Error('Invalid ciphertext format')
53
+ const [ivHex, tagHex, encHex] = parts as [string, string, string]
54
+ const iv = Buffer.from(ivHex, 'hex')
55
+ const tag = Buffer.from(tagHex, 'hex')
56
+ const enc = Buffer.from(encHex, 'hex')
57
+ if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error('Invalid ciphertext length')
58
+ const decipher = createDecipheriv(ALGORITHM, key(), iv)
59
+ decipher.setAuthTag(tag)
60
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
61
+ },
62
+ }
63
+ }
64
+
65
+ /** In production the key must be exactly 64 hexadecimal characters; refuses to start otherwise. Elsewhere anything goes. */
66
+ export function validateEncryptionKey(env: NodeJS.ProcessEnv = process.env): void {
67
+ if (env['NODE_ENV'] !== 'production') return
68
+ const key = env['VAULT_ENCRYPTION_KEY']
69
+ if (!key || !/^[0-9a-fA-F]{64}$/.test(key)) {
70
+ throw new Error('VAULT_ENCRYPTION_KEY must be set to exactly 64 hexadecimal characters in production')
71
+ }
72
+ }