@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.
- package/LICENSE +21 -0
- package/README.md +5 -3
- package/dist/createVault.d.ts +70 -0
- package/dist/createVault.d.ts.map +1 -0
- package/dist/createVault.js +28 -0
- package/dist/createVault.js.map +1 -0
- package/dist/hashicorp.d.ts +92 -0
- package/dist/hashicorp.d.ts.map +1 -0
- package/dist/hashicorp.js +436 -0
- package/dist/hashicorp.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +4 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +28 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module.d.ts +8 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +5 -0
- package/dist/module.js.map +1 -0
- package/dist/resolveSecret.d.ts +17 -0
- package/dist/resolveSecret.d.ts.map +1 -0
- package/dist/resolveSecret.js +80 -0
- package/dist/resolveSecret.js.map +1 -0
- package/dist/sealed.d.ts +45 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +20 -0
- package/dist/sealed.js.map +1 -0
- package/dist/secretBox.d.ts +17 -0
- package/dist/secretBox.d.ts.map +1 -0
- package/dist/secretBox.js +63 -0
- package/dist/secretBox.js.map +1 -0
- package/dist/secretFields.d.ts +48 -0
- package/dist/secretFields.d.ts.map +1 -0
- package/dist/secretFields.js +159 -0
- package/dist/secretFields.js.map +1 -0
- package/dist/tables.d.ts +267 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +22 -0
- package/dist/tables.js.map +1 -0
- package/dist/vaultRoutes.d.ts +42 -0
- package/dist/vaultRoutes.d.ts.map +1 -0
- package/dist/vaultRoutes.js +408 -0
- package/dist/vaultRoutes.js.map +1 -0
- package/dist/vaultUse.d.ts +15 -0
- package/dist/vaultUse.d.ts.map +1 -0
- package/dist/vaultUse.js +39 -0
- package/dist/vaultUse.js.map +1 -0
- package/package.json +56 -6
- package/src/createVault.ts +45 -0
- package/src/hashicorp.ts +466 -0
- package/src/index.ts +9 -0
- package/src/migrations.ts +29 -0
- package/src/module.ts +6 -0
- package/src/resolveSecret.ts +96 -0
- package/src/sealed.ts +38 -0
- package/src/secretBox.ts +72 -0
- package/src/secretFields.ts +177 -0
- package/src/tables.ts +23 -0
- package/src/vaultRoutes.ts +422 -0
- package/src/vaultUse.ts +41 -0
package/src/hashicorp.ts
ADDED
|
@@ -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>
|