@better-forge/vault 0.0.0-stage → 0.1.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -3
  3. package/dist/createVault.d.ts +70 -0
  4. package/dist/createVault.d.ts.map +1 -0
  5. package/dist/createVault.js +28 -0
  6. package/dist/createVault.js.map +1 -0
  7. package/dist/hashicorp.d.ts +92 -0
  8. package/dist/hashicorp.d.ts.map +1 -0
  9. package/dist/hashicorp.js +436 -0
  10. package/dist/hashicorp.js.map +1 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/migrations.d.ts +4 -0
  16. package/dist/migrations.d.ts.map +1 -0
  17. package/dist/migrations.js +28 -0
  18. package/dist/migrations.js.map +1 -0
  19. package/dist/module.d.ts +8 -0
  20. package/dist/module.d.ts.map +1 -0
  21. package/dist/module.js +5 -0
  22. package/dist/module.js.map +1 -0
  23. package/dist/resolveSecret.d.ts +17 -0
  24. package/dist/resolveSecret.d.ts.map +1 -0
  25. package/dist/resolveSecret.js +80 -0
  26. package/dist/resolveSecret.js.map +1 -0
  27. package/dist/sealed.d.ts +45 -0
  28. package/dist/sealed.d.ts.map +1 -0
  29. package/dist/sealed.js +20 -0
  30. package/dist/sealed.js.map +1 -0
  31. package/dist/secretBox.d.ts +17 -0
  32. package/dist/secretBox.d.ts.map +1 -0
  33. package/dist/secretBox.js +63 -0
  34. package/dist/secretBox.js.map +1 -0
  35. package/dist/secretFields.d.ts +48 -0
  36. package/dist/secretFields.d.ts.map +1 -0
  37. package/dist/secretFields.js +159 -0
  38. package/dist/secretFields.js.map +1 -0
  39. package/dist/tables.d.ts +267 -0
  40. package/dist/tables.d.ts.map +1 -0
  41. package/dist/tables.js +22 -0
  42. package/dist/tables.js.map +1 -0
  43. package/dist/vaultRoutes.d.ts +42 -0
  44. package/dist/vaultRoutes.d.ts.map +1 -0
  45. package/dist/vaultRoutes.js +408 -0
  46. package/dist/vaultRoutes.js.map +1 -0
  47. package/dist/vaultUse.d.ts +15 -0
  48. package/dist/vaultUse.d.ts.map +1 -0
  49. package/dist/vaultUse.js +39 -0
  50. package/dist/vaultUse.js.map +1 -0
  51. package/package.json +56 -6
  52. package/src/createVault.ts +45 -0
  53. package/src/hashicorp.ts +466 -0
  54. package/src/index.ts +9 -0
  55. package/src/migrations.ts +29 -0
  56. package/src/module.ts +6 -0
  57. package/src/resolveSecret.ts +96 -0
  58. package/src/sealed.ts +38 -0
  59. package/src/secretBox.ts +72 -0
  60. package/src/secretFields.ts +177 -0
  61. package/src/tables.ts +23 -0
  62. package/src/vaultRoutes.ts +422 -0
  63. package/src/vaultUse.ts +41 -0
@@ -0,0 +1,466 @@
1
+ import { Agent, fetch as httpFetch } from 'undici'
2
+ import { and, eq } from 'drizzle-orm'
3
+ import type { SqliteDatabase } from '@better-forge/db'
4
+ import { vaults, vaultSecrets } from './tables.js'
5
+ import type { SecretBox } from './secretBox.js'
6
+
7
+ /**
8
+ * Client for HashiCorp Vault (KV v2). A vault of type `hashicorp` keeps only the
9
+ * connection settings (encrypted); secret values are read live on every use and never stored.
10
+ *
11
+ * The Vault address is private-network by nature, so unlike webhooks no public-IP guard applies.
12
+ * Instead the token is protected by: no redirects, strict path-segment validation, a response size
13
+ * cap and fixed error messages that never echo request headers, bodies or credentials.
14
+ */
15
+
16
+ export type HashicorpAuthMethod = 'token' | 'approle'
17
+
18
+ export interface HashicorpConfig {
19
+ address: string
20
+ namespace?: string
21
+ mount: string
22
+ authMethod: HashicorpAuthMethod
23
+ token?: string | undefined
24
+ roleId?: string
25
+ secretId?: string
26
+ approleMount: string
27
+ caCert?: string
28
+ /**
29
+ * Reads the credentials from a secret in a local vault instead of storing them here: a Secure Value or JSON
30
+ * secret holding the token, or a User / Password (Role ID and Secret ID) or JSON secret for AppRole.
31
+ */
32
+ credentialsRef?: CredentialsRef
33
+ }
34
+
35
+ export interface CredentialsRef {
36
+ vaultId: number
37
+ secretId: number
38
+ /** For a JSON secret: the JSON key holding each credential (`token`, or `roleId` and `secretId`); the key defaults to that name. */
39
+ fieldMapping?: Record<string, string>
40
+ }
41
+
42
+ /** The credentials in effect for one request, whether stored in the connection or read from a local vault. */
43
+ interface Credentials { token?: string | undefined; roleId?: string | undefined; secretId?: string | undefined }
44
+
45
+ /** Message is always safe to return to the browser. */
46
+ export class HashicorpVaultError extends Error {}
47
+
48
+ /** Mutable so tests can shorten the timeout. */
49
+ export const hashicorpLimits = {
50
+ timeoutMs: 10_000,
51
+ maxBodyBytes: 1024 * 1024,
52
+ /** A 403 for a token younger than this is a policy denial, not an expired token: no new login. */
53
+ freshTokenMs: 10_000,
54
+ /** Grace period before a replaced connection pool is closed, so in-flight requests can finish. */
55
+ agentCloseDelayMs: 30_000,
56
+ }
57
+
58
+ const MAX_FIELD = 2048
59
+
60
+ const MAX_CA = 64 * 1024
61
+
62
+ function text(value: unknown, label: string, max = MAX_FIELD): string | undefined {
63
+ if (value === undefined || value === null || value === '') return undefined
64
+ if (typeof value !== 'string') throw new HashicorpVaultError(`${label} must be a string`)
65
+ const trimmed = value.trim()
66
+ if (trimmed.length > max) throw new HashicorpVaultError(`${label} is too long`)
67
+ // eslint-disable-next-line no-control-regex
68
+ if (/[\u0000-\u001f\u007f]/.test(trimmed)) throw new HashicorpVaultError(`${label} contains invalid characters`)
69
+ return trimmed || undefined
70
+ }
71
+
72
+ /** Validates a slash-separated Vault path and returns it URL-encoded per segment. */
73
+ export function encodePath(raw: string, label: string): string {
74
+ const segments = raw.trim().replace(/^\/+|\/+$/g, '').split('/')
75
+ if (segments.length === 0 || segments.some((s) => s === '')) {
76
+ throw new HashicorpVaultError(`${label} is invalid`)
77
+ }
78
+ return segments.map((segment) => {
79
+ let decoded: string
80
+ try { decoded = decodeURIComponent(segment) } catch { throw new HashicorpVaultError(`${label} is invalid`) }
81
+ if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
82
+ throw new HashicorpVaultError(`${label} is invalid`)
83
+ }
84
+ return encodeURIComponent(decoded)
85
+ }).join('/')
86
+ }
87
+
88
+ function requiredText(value: unknown, label: string): string {
89
+ const v = text(value, label)
90
+ if (!v) throw new HashicorpVaultError(`${label} is required`)
91
+ return v
92
+ }
93
+
94
+ function normalizeAddress(raw: string): string {
95
+ let url: URL
96
+ try { url = new URL(raw) } catch { throw new HashicorpVaultError('Address must be a valid URL') }
97
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
98
+ throw new HashicorpVaultError('Address must start with http:// or https://')
99
+ }
100
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
101
+ throw new HashicorpVaultError('Address must be a plain URL such as https://vault.example.com:8200')
102
+ }
103
+ return url.origin
104
+ }
105
+
106
+ /** True when changing these fields would redirect stored credentials somewhere else. */
107
+ const IDENTITY_FIELDS = ['address', 'namespace', 'mount', 'authMethod', 'approleMount', 'caCert'] as const
108
+
109
+ /**
110
+ * Validates connection input from the admin API. With `existing`, blank credentials keep the stored
111
+ * ones, except when the address, namespace, auth method or AppRole mount changes: credentials must
112
+ * then be sent again, so a changed address cannot receive a stored token.
113
+ */
114
+ export function parseConnection(input: unknown, existing?: HashicorpConfig): HashicorpConfig {
115
+ if (!input || typeof input !== 'object') throw new HashicorpVaultError('Connection settings are required')
116
+ const body = input as Record<string, unknown>
117
+
118
+ const authMethod = body['authMethod'] ?? existing?.authMethod
119
+ if (authMethod !== 'token' && authMethod !== 'approle') {
120
+ throw new HashicorpVaultError('Auth method must be token or approle')
121
+ }
122
+ const namespace = text(body['namespace'], 'Namespace')
123
+ const cfg: HashicorpConfig = {
124
+ address: normalizeAddress(requiredText(body['address'], 'Address')),
125
+ mount: requiredText(body['mount'] ?? 'secret', 'KV mount'),
126
+ authMethod,
127
+ approleMount: text(body['approleMount'], 'AppRole mount') ?? 'approle',
128
+ }
129
+ if (namespace) cfg.namespace = namespace
130
+ encodePath(cfg.mount, 'KV mount')
131
+ encodePath(cfg.approleMount, 'AppRole mount')
132
+ if (cfg.namespace) encodePath(cfg.namespace, 'Namespace')
133
+
134
+ const caCert = text(body['caCert'], 'CA certificate', MAX_CA)
135
+ if (caCert) {
136
+ if (!caCert.includes('BEGIN CERTIFICATE')) throw new HashicorpVaultError('CA certificate must be PEM encoded')
137
+ cfg.caCert = caCert
138
+ }
139
+
140
+ const identityChanged = existing ? IDENTITY_FIELDS.some((f) => (existing[f] ?? '') !== (cfg[f] ?? '')) : false
141
+ const keep = existing && !identityChanged ? existing : undefined
142
+ // An object picks a local vault secret; null means direct input; absent keeps what is saved.
143
+ const refInput = body['credentialsRef']
144
+ if (refInput !== undefined && refInput !== null) {
145
+ cfg.credentialsRef = parseCredentialsRef(refInput, authMethod)
146
+ return cfg
147
+ }
148
+ const keptRef = refInput === undefined ? keep?.credentialsRef : undefined
149
+ if (authMethod === 'token') {
150
+ const typed = text(body['token'], 'Token')
151
+ if (!typed && keptRef) { cfg.credentialsRef = keptRef; return cfg }
152
+ const token = typed ?? keep?.token
153
+ if (!token) throw new HashicorpVaultError(identityChanged ? 'Re-enter the token after changing the connection target' : 'Token is required')
154
+ cfg.token = token
155
+ } else {
156
+ const typedSecretId = text(body['secretId'], 'Secret ID')
157
+ const typedRoleId = text(body['roleId'], 'Role ID')
158
+ if (!typedSecretId && !typedRoleId && keptRef) { cfg.credentialsRef = keptRef; return cfg }
159
+ const roleId = typedRoleId ?? keep?.roleId
160
+ const secretId = typedSecretId ?? keep?.secretId
161
+ if (!roleId || !secretId) {
162
+ throw new HashicorpVaultError(identityChanged ? 'Re-enter the Role ID and Secret ID after changing the connection target' : 'Role ID and Secret ID are required')
163
+ }
164
+ cfg.roleId = roleId
165
+ cfg.secretId = secretId
166
+ }
167
+ return cfg
168
+ }
169
+
170
+ function parseCredentialsRef(input: unknown, authMethod: HashicorpAuthMethod): CredentialsRef {
171
+ const { vaultId, secretId, fieldMapping } = (typeof input === 'object' ? input : {}) as Record<string, unknown>
172
+ if (!Number.isInteger(vaultId) || !Number.isInteger(secretId) || (vaultId as number) <= 0 || (secretId as number) <= 0) {
173
+ throw new HashicorpVaultError('Select a secret from a local vault for the credentials')
174
+ }
175
+ const ref: CredentialsRef = { vaultId: vaultId as number, secretId: secretId as number }
176
+ const mapping: Record<string, string> = {}
177
+ for (const field of authMethod === 'token' ? ['token'] : ['roleId', 'secretId']) {
178
+ const key = text((fieldMapping as Record<string, unknown> | undefined)?.[field], 'JSON key', 200)
179
+ if (key) mapping[field] = key
180
+ }
181
+ if (Object.keys(mapping).length) ref.fieldMapping = mapping
182
+ return ref
183
+ }
184
+
185
+ /** Connection settings safe to return to admins: credentials are reported only as present or not. */
186
+ export function publicConnection(cfg: HashicorpConfig) {
187
+ return {
188
+ address: cfg.address,
189
+ namespace: cfg.namespace ?? '',
190
+ mount: cfg.mount,
191
+ authMethod: cfg.authMethod,
192
+ approleMount: cfg.approleMount,
193
+ roleId: cfg.roleId ?? '',
194
+ caCert: cfg.caCert ?? '',
195
+ hasToken: !!cfg.token,
196
+ hasSecretId: !!cfg.secretId,
197
+ credentialsRef: cfg.credentialsRef ?? null,
198
+ }
199
+ }
200
+
201
+ // ── Requests ──────────────────────────────────────────────────────────────────
202
+
203
+ export interface HashicorpVaultRow {
204
+ id: number
205
+ updatedAt: number
206
+ connectionConfig: string | null
207
+ }
208
+
209
+ interface Session {
210
+ fingerprint: number
211
+ cfg: HashicorpConfig
212
+ agent?: Agent | undefined
213
+ token?: string | undefined
214
+ expiresAt: number
215
+ loginAt: number
216
+ login?: Promise<string> | undefined
217
+ /** The credentials last used, kept only to scrub them from error messages. */
218
+ creds?: Credentials | undefined
219
+ }
220
+
221
+ export function createHashicorp(ctx: { db: SqliteDatabase; secretBox: SecretBox }) {
222
+ const { db } = ctx
223
+ const { encrypt, decrypt } = ctx.secretBox
224
+
225
+ function sealConnection(cfg: HashicorpConfig): string {
226
+ return encrypt(JSON.stringify(cfg))
227
+ }
228
+
229
+ function openConnection(sealed: string | null): HashicorpConfig {
230
+ if (!sealed) throw new HashicorpVaultError('Vault connection is not configured')
231
+ try {
232
+ return JSON.parse(decrypt(sealed)) as HashicorpConfig
233
+ } catch {
234
+ throw new HashicorpVaultError('Failed to decrypt vault connection')
235
+ }
236
+ }
237
+
238
+ const sessions = new Map<number, Session>()
239
+
240
+ /** Drops the cached token and closes the connection pool; call after the vault is changed or deleted. */
241
+ function forgetHashicorpVault(vaultId: number): void {
242
+ const session = sessions.get(vaultId)
243
+ sessions.delete(vaultId)
244
+ const agent = session?.agent
245
+ if (agent) setTimeout(() => void agent.close().catch(() => undefined), hashicorpLimits.agentCloseDelayMs).unref()
246
+ }
247
+
248
+ /**
249
+ * Drops the cached sessions of HashiCorp vaults that read their credentials from this local vault (or one
250
+ * of its secrets), so a rotated or deleted secret is used from the next request, also for AppRole.
251
+ */
252
+ function forgetCredentialDependents(vaultId: number, secretId?: number): void {
253
+ for (const [id, session] of sessions) {
254
+ const ref = session.cfg.credentialsRef
255
+ if (ref && ref.vaultId === vaultId && (secretId === undefined || ref.secretId === secretId)) forgetHashicorpVault(id)
256
+ }
257
+ }
258
+
259
+ function sessionFor(vault: HashicorpVaultRow): Session {
260
+ const existing = sessions.get(vault.id)
261
+ if (existing && existing.fingerprint === vault.updatedAt) return existing
262
+ forgetHashicorpVault(vault.id)
263
+ const cfg = openConnection(vault.connectionConfig)
264
+ const session: Session = {
265
+ fingerprint: vault.updatedAt,
266
+ cfg,
267
+ agent: cfg.caCert ? new Agent({ connect: { ca: cfg.caCert } }) : undefined,
268
+ expiresAt: 0,
269
+ loginAt: 0,
270
+ }
271
+ sessions.set(vault.id, session)
272
+ return session
273
+ }
274
+
275
+ function scrub(message: string, creds: Credentials | undefined): string {
276
+ let out = message
277
+ for (const secret of [creds?.token, creds?.secretId]) if (secret) out = out.split(secret).join('[redacted]')
278
+ return out
279
+ }
280
+
281
+ /**
282
+ * Returns the credentials to use now. A referenced local vault secret is read on every call, so a
283
+ * rotation there takes effect on the next request; it must still exist, live in a local vault and
284
+ * have the type the auth method needs.
285
+ */
286
+ async function credentialsFor(session: Session): Promise<Credentials> {
287
+ const { cfg } = session
288
+ const ref = cfg.credentialsRef
289
+ if (!ref) return (session.creds = { token: cfg.token, roleId: cfg.roleId, secretId: cfg.secretId })
290
+ const [row] = await db.select().from(vaultSecrets).where(and(eq(vaultSecrets.id, ref.secretId), eq(vaultSecrets.vaultId, ref.vaultId)))
291
+ const [owner] = row ? await db.select({ type: vaults.type }).from(vaults).where(eq(vaults.id, ref.vaultId)) : []
292
+ if (!row || owner?.type !== 'local') throw new HashicorpVaultError('the credentials secret no longer exists in a local vault')
293
+ let payload: Record<string, unknown>
294
+ try { payload = JSON.parse(decrypt(row.encryptedValue)) as Record<string, unknown> } catch { throw new HashicorpVaultError('the credentials secret cannot be decrypted') }
295
+ // Same trimming and character checks as typed credentials, so a stray newline is a clear error, not a failed request.
296
+ const clean = (value: unknown) => { try { return text(value, 'The credentials secret') } catch { return undefined } }
297
+ // A JSON secret holds an object; the mapping (or the default key names) picks each credential from it.
298
+ let fields: Record<string, unknown> = {}
299
+ if (row.type === 'json') {
300
+ try { fields = JSON.parse(String(payload['value'] ?? '')) as Record<string, unknown> } catch { /* reported below as a missing credential */ }
301
+ }
302
+ const pick = (field: string) => clean(fields[ref.fieldMapping?.[field] ?? field])
303
+ if (cfg.authMethod === 'token') {
304
+ const token = row.type === 'value' ? clean(payload['value']) : row.type === 'json' ? pick('token') : undefined
305
+ if (!token) throw new HashicorpVaultError('the credentials secret must be a Secure Value, or a JSON secret with the token key')
306
+ return (session.creds = { token })
307
+ }
308
+ const roleId = row.type === 'userpass' ? clean(payload['username']) : row.type === 'json' ? pick('roleId') : undefined
309
+ const secretId = row.type === 'userpass' ? clean(payload['password']) : row.type === 'json' ? pick('secretId') : undefined
310
+ if (!roleId || !secretId) {
311
+ throw new HashicorpVaultError('the credentials secret must be a User / Password secret, or a JSON secret with the Role ID and Secret ID keys')
312
+ }
313
+ return (session.creds = { roleId, secretId })
314
+ }
315
+
316
+ function describeNetworkError(err: unknown): string {
317
+ const e = err as { name?: string; message?: string; cause?: { code?: string; message?: string } }
318
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
319
+ return `timed out after ${Math.round(hashicorpLimits.timeoutMs / 1000)} s`
320
+ }
321
+ const code = e?.cause?.code ?? ''
322
+ if (/redirect/i.test(e?.cause?.message ?? e?.message ?? '')) return 'redirects are not followed'
323
+ if (/^(UNABLE_TO|CERT_|DEPTH_ZERO|SELF_SIGNED|ERR_TLS|ERR_SSL|HOSTNAME_MISMATCH)/.test(code)) {
324
+ return `TLS certificate not trusted (${code})`
325
+ }
326
+ return code ? `cannot connect (${code})` : 'cannot connect'
327
+ }
328
+
329
+ async function readCapped(res: Awaited<ReturnType<typeof httpFetch>>): Promise<unknown> {
330
+ const reader = res.body?.getReader()
331
+ if (!reader) return null
332
+ const chunks: Uint8Array[] = []
333
+ let size = 0
334
+ for (;;) {
335
+ const { done, value } = await reader.read()
336
+ if (done) break
337
+ size += value.byteLength
338
+ if (size > hashicorpLimits.maxBodyBytes) {
339
+ void reader.cancel()
340
+ throw new HashicorpVaultError('response from Vault is too large')
341
+ }
342
+ chunks.push(value)
343
+ }
344
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return null }
345
+ }
346
+
347
+ async function call(
348
+ session: Session,
349
+ method: 'GET' | 'POST',
350
+ apiPath: string,
351
+ token: string | undefined,
352
+ body?: unknown,
353
+ ): Promise<{ status: number; json: unknown }> {
354
+ const { cfg } = session
355
+ const headers: Record<string, string> = { 'X-Vault-Request': 'true' }
356
+ if (token) headers['X-Vault-Token'] = token
357
+ if (cfg.namespace) headers['X-Vault-Namespace'] = cfg.namespace
358
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
359
+ try {
360
+ const res = await httpFetch(`${cfg.address}/v1/${apiPath}`, {
361
+ method,
362
+ headers,
363
+ ...(body !== undefined && { body: JSON.stringify(body) }),
364
+ redirect: 'error',
365
+ signal: AbortSignal.timeout(hashicorpLimits.timeoutMs),
366
+ ...(session.agent && { dispatcher: session.agent }),
367
+ })
368
+ return { status: res.status, json: await readCapped(res) }
369
+ } catch (err) {
370
+ if (err instanceof HashicorpVaultError) throw err
371
+ throw new HashicorpVaultError(scrub(describeNetworkError(err), session.creds))
372
+ }
373
+ }
374
+
375
+ function login(session: Session): Promise<string> {
376
+ session.login ??= (async () => {
377
+ const { cfg } = session
378
+ const creds = await credentialsFor(session)
379
+ const { status, json } = await call(session, 'POST', `auth/${encodePath(cfg.approleMount, 'AppRole mount')}/login`, undefined,
380
+ { role_id: creds.roleId, secret_id: creds.secretId })
381
+ const auth = (json as { auth?: { client_token?: string; lease_duration?: number } } | null)?.auth
382
+ if (status !== 200 || !auth?.client_token) {
383
+ throw new HashicorpVaultError(status === 400 || status === 403 ? 'AppRole login was rejected' : `AppRole login failed (HTTP ${status})`)
384
+ }
385
+ const ttlMs = (auth.lease_duration ?? 0) * 1000
386
+ session.token = auth.client_token
387
+ session.loginAt = Date.now()
388
+ // A lease of 0 means the token does not expire; a 403 then decides when to log in again.
389
+ session.expiresAt = ttlMs > 0 ? session.loginAt + ttlMs - Math.min(60_000, ttlMs * 0.1) : Infinity
390
+ return auth.client_token
391
+ })().finally(() => { session.login = undefined })
392
+ return session.login
393
+ }
394
+
395
+ async function tokenFor(session: Session): Promise<string> {
396
+ if (session.cfg.authMethod === 'token') return (await credentialsFor(session)).token!
397
+ if (session.token && Date.now() < session.expiresAt) return session.token
398
+ return login(session)
399
+ }
400
+
401
+ /** Authenticated request; with AppRole a 403 triggers one fresh login and retry. */
402
+ async function authed(session: Session, method: 'GET' | 'POST', apiPath: string) {
403
+ const used = await tokenFor(session)
404
+ let res = await call(session, method, apiPath, used)
405
+ const expired = session.cfg.authMethod === 'approle' && session.token === used
406
+ && Date.now() - session.loginAt >= hashicorpLimits.freshTokenMs
407
+ if (res.status === 403 && expired) {
408
+ session.token = undefined
409
+ res = await call(session, method, apiPath, await tokenFor(session))
410
+ }
411
+ return res
412
+ }
413
+
414
+ /** Reads the data of a KV v2 secret (latest version). */
415
+ async function readKvSecret(vault: HashicorpVaultRow, path: string): Promise<Record<string, unknown>> {
416
+ const session = sessionFor(vault)
417
+ const kvPath = encodePath(path, 'Path')
418
+ const { status, json } = await authed(session, 'GET', `${encodePath(session.cfg.mount, 'KV mount')}/data/${kvPath}`)
419
+ if (status === 404) throw new HashicorpVaultError(`secret not found at ${session.cfg.mount}/${path.replace(/^\/+|\/+$/g, '')}`)
420
+ if (status === 403) throw new HashicorpVaultError('permission denied (HTTP 403)')
421
+ const data = (json as { data?: { data?: unknown } } | null)?.data?.data
422
+ if (status !== 200 || !data || typeof data !== 'object' || Array.isArray(data)) {
423
+ throw new HashicorpVaultError(`unexpected response from Vault (HTTP ${status})`)
424
+ }
425
+ return data as Record<string, unknown>
426
+ }
427
+
428
+ /** Fails unless the connection's credentials secret (if any) exists in a local vault with the type its auth method needs. */
429
+ async function verifyCredentialsRef(cfg: HashicorpConfig): Promise<void> {
430
+ if (cfg.credentialsRef) await credentialsFor({ fingerprint: 0, cfg, expiresAt: 0, loginAt: 0 })
431
+ }
432
+
433
+ /** Verifies the saved credentials. Returns the remaining token TTL when Vault reports one. */
434
+ async function testConnection(vault: HashicorpVaultRow): Promise<{ authMethod: HashicorpAuthMethod; ttlSeconds: number | null }> {
435
+ return verifySession(sessionFor(vault))
436
+ }
437
+
438
+ /** Verifies connection settings that are not saved yet, without touching the session cache. */
439
+ async function testDraftConnection(cfg: HashicorpConfig): Promise<{ authMethod: HashicorpAuthMethod; ttlSeconds: number | null }> {
440
+ const agent = cfg.caCert ? new Agent({ connect: { ca: cfg.caCert } }) : undefined
441
+ try {
442
+ return await verifySession({ fingerprint: 0, cfg, agent, expiresAt: 0, loginAt: 0 })
443
+ } finally {
444
+ void agent?.close()
445
+ }
446
+ }
447
+
448
+ async function verifySession(session: Session): Promise<{ authMethod: HashicorpAuthMethod; ttlSeconds: number | null }> {
449
+ session.token = undefined
450
+ session.loginAt = 0
451
+ const { cfg } = session
452
+ if (cfg.authMethod === 'approle') {
453
+ await login(session)
454
+ return { authMethod: 'approle', ttlSeconds: Math.max(0, Math.round((session.expiresAt - Date.now()) / 1000)) }
455
+ }
456
+ const { status, json } = await call(session, 'GET', 'auth/token/lookup-self', (await credentialsFor(session)).token)
457
+ if (status === 403) throw new HashicorpVaultError('token was rejected (HTTP 403)')
458
+ if (status !== 200) throw new HashicorpVaultError(`unexpected response from Vault (HTTP ${status})`)
459
+ const ttl = (json as { data?: { ttl?: number } } | null)?.data?.ttl
460
+ return { authMethod: 'token', ttlSeconds: typeof ttl === 'number' && ttl > 0 ? ttl : null }
461
+ }
462
+
463
+ return { sealConnection, openConnection, forgetHashicorpVault, forgetCredentialDependents, readKvSecret, verifyCredentialsRef, testConnection, testDraftConnection }
464
+ }
465
+
466
+ export type Hashicorp = ReturnType<typeof createHashicorp>
package/src/index.ts ADDED
@@ -0,0 +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'
@@ -0,0 +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
+ ]
package/src/module.ts ADDED
@@ -0,0 +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
@@ -0,0 +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>
package/src/sealed.ts ADDED
@@ -0,0 +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>