@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/hashicorp.ts CHANGED
@@ -1,466 +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>
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>