@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/LICENSE +21 -21
- package/README.md +5 -5
- package/dist/migrations.js +21 -21
- package/package.json +56 -56
- package/src/createVault.ts +45 -45
- package/src/hashicorp.ts +466 -466
- package/src/index.ts +9 -9
- package/src/migrations.ts +29 -29
- package/src/module.ts +6 -6
- package/src/resolveSecret.ts +96 -96
- package/src/sealed.ts +38 -38
- package/src/secretBox.ts +72 -72
- package/src/secretFields.ts +177 -177
- package/src/tables.ts +23 -23
- package/src/vaultRoutes.ts +422 -422
- package/src/vaultUse.ts +41 -41
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
|
package/src/resolveSecret.ts
CHANGED
|
@@ -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
|
+
}
|