@openparachute/vault 0.4.5 → 0.4.6
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/README.md +70 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.test.ts +235 -0
- package/src/auth.ts +135 -3
- package/src/cli.ts +420 -22
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -0
- package/src/routing.test.ts +85 -1
- package/src/server.ts +23 -4
- package/src/vault-name.test.ts +100 -4
- package/src/vault-name.ts +61 -3
package/src/auth.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { VaultConfig, StoredKey } from "./config.ts";
|
|
|
20
20
|
import { resolveToken } from "./token-store.ts";
|
|
21
21
|
import type { TokenPermission } from "./token-store.ts";
|
|
22
22
|
import type { Database } from "bun:sqlite";
|
|
23
|
+
import crypto from "node:crypto";
|
|
23
24
|
import { getVaultStore } from "./vault-store.ts";
|
|
24
25
|
import {
|
|
25
26
|
findBroadVaultScopes,
|
|
@@ -30,8 +31,69 @@ import {
|
|
|
30
31
|
SCOPE_READ,
|
|
31
32
|
SCOPE_WRITE,
|
|
32
33
|
} from "./scopes.ts";
|
|
34
|
+
import { enforceVaultScope } from "@openparachute/scope-guard";
|
|
33
35
|
import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Server-wide operator bearer token, sourced from the `VAULT_AUTH_TOKEN`
|
|
39
|
+
* environment variable.
|
|
40
|
+
*
|
|
41
|
+
* Read dynamically per request (not cached at import time) so test seams
|
|
42
|
+
* that mutate `process.env.VAULT_AUTH_TOKEN` work without re-importing.
|
|
43
|
+
* In production the env var is set at container start and doesn't change.
|
|
44
|
+
*
|
|
45
|
+
* Empty / whitespace-only values are treated as unset — the operator
|
|
46
|
+
* either commits to bearer auth or doesn't, no degraded "empty token
|
|
47
|
+
* always matches" failure mode.
|
|
48
|
+
*/
|
|
49
|
+
function getServerWideAuthToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
50
|
+
const raw = env.VAULT_AUTH_TOKEN;
|
|
51
|
+
if (typeof raw !== "string") return null;
|
|
52
|
+
const trimmed = raw.trim();
|
|
53
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Constant-time string equality. Returns false when lengths differ
|
|
58
|
+
* (timingSafeEqual throws on length mismatch; we want a quiet false).
|
|
59
|
+
*/
|
|
60
|
+
function constantTimeEquals(a: string, b: string): boolean {
|
|
61
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
62
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
63
|
+
if (aBuf.length !== bBuf.length) return false;
|
|
64
|
+
return crypto.timingSafeEqual(aBuf, bBuf);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* If `VAULT_AUTH_TOKEN` is set and the provided bearer matches it
|
|
69
|
+
* (constant-time), return a full-admin AuthResult that's accepted by
|
|
70
|
+
* every vault on the server.
|
|
71
|
+
*
|
|
72
|
+
* The operator-channel auth shape for non-loopback deploys (Render,
|
|
73
|
+
* sibling-container setups, vault#339). Hub uses this to call vault
|
|
74
|
+
* across a container boundary; end-user OAuth tokens still take the
|
|
75
|
+
* per-vault hub-JWT / pvt_* paths below. See `docs/auth-model.md` §2.
|
|
76
|
+
*
|
|
77
|
+
* Scope set is broad (`vault:admin`) — the env-var bearer is an
|
|
78
|
+
* operator credential, not a user credential. Tag-scoping doesn't
|
|
79
|
+
* apply; we represent it as unscoped (`scoped_tags: null`).
|
|
80
|
+
*/
|
|
81
|
+
function tryServerWideAuth(
|
|
82
|
+
providedKey: string,
|
|
83
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
84
|
+
): AuthResult | null {
|
|
85
|
+
const configured = getServerWideAuthToken(env);
|
|
86
|
+
if (configured === null) return null;
|
|
87
|
+
if (!constantTimeEquals(providedKey, configured)) return null;
|
|
88
|
+
return {
|
|
89
|
+
permission: "full",
|
|
90
|
+
scopes: [SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ],
|
|
91
|
+
legacyDerived: false,
|
|
92
|
+
scoped_tags: null,
|
|
93
|
+
vault_name: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
35
97
|
/** Result of a successful auth check. */
|
|
36
98
|
export interface AuthResult {
|
|
37
99
|
permission: TokenPermission;
|
|
@@ -168,12 +230,27 @@ export async function authenticateVaultRequest(
|
|
|
168
230
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
169
231
|
}
|
|
170
232
|
|
|
233
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
234
|
+
// a matching bearer authenticates as full/admin against any vault. This
|
|
235
|
+
// is the cross-container path for Render / sibling-service deployments
|
|
236
|
+
// where hub talks to vault over HTTP. Checked first so it short-circuits
|
|
237
|
+
// both JWT validation and per-vault DB lookups — the operator token is
|
|
238
|
+
// a credential the operator opts into, not one we'd ever fall through.
|
|
239
|
+
const serverWide = tryServerWideAuth(key);
|
|
240
|
+
if (serverWide !== null) return serverWide;
|
|
241
|
+
|
|
171
242
|
// JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
|
|
172
243
|
// verification inside validateHubJwt; signature checked against hub's JWKS.
|
|
173
244
|
// Audience strict-checked against `vault.<name>` so a token stamped for
|
|
174
|
-
// one vault can't reach another.
|
|
245
|
+
// one vault can't reach another. `vault_scope` claim additionally checked
|
|
246
|
+
// against `vaultConfig.name` so a per-user vault pin refuses cross-vault
|
|
247
|
+
// access even if a scope-string somehow named the wrong vault (multi-user
|
|
248
|
+
// Phase 1 defense-in-depth — see hub#283 + scope-guard 0.3.0).
|
|
175
249
|
if (looksLikeJwt(key)) {
|
|
176
|
-
return await authenticateHubJwt(key, {
|
|
250
|
+
return await authenticateHubJwt(key, {
|
|
251
|
+
expectedAudience: `vault.${vaultConfig.name}`,
|
|
252
|
+
vaultName: vaultConfig.name,
|
|
253
|
+
});
|
|
177
254
|
}
|
|
178
255
|
|
|
179
256
|
// Try vault's token DB first
|
|
@@ -249,10 +326,20 @@ export async function authenticateVaultRequest(
|
|
|
249
326
|
* here — Phase B2 settled that hub tokens always name the resource. Per-
|
|
250
327
|
* vault audience enforcement happens inside `validateHubJwt` via
|
|
251
328
|
* `opts.expectedAudience`.
|
|
329
|
+
*
|
|
330
|
+
* Per-user vault-pin enforcement (multi-user Phase 1 PR 5): when
|
|
331
|
+
* `opts.vaultName` is set, the token's `vault_scope` claim is checked
|
|
332
|
+
* against it via scope-guard's `enforceVaultScope`. Non-admin tokens
|
|
333
|
+
* (`vault_scope: [<assigned_vault>]`) refuse cross-vault access with a
|
|
334
|
+
* 403 + `vault_scope_mismatch` error code. Admin tokens (`vault_scope:
|
|
335
|
+
* []`) and pre-PR-4 tokens (claim absent → surfaced as `[]`) pass
|
|
336
|
+
* unchanged. The 403 (not 401) signals "your credential is valid but
|
|
337
|
+
* doesn't grant access to this vault" — distinct from authentication
|
|
338
|
+
* failures upstream. See hub#283 + scope-guard 0.3.0 for the mint side.
|
|
252
339
|
*/
|
|
253
340
|
async function authenticateHubJwt(
|
|
254
341
|
token: string,
|
|
255
|
-
opts: { expectedAudience: string | null },
|
|
342
|
+
opts: { expectedAudience: string | null; vaultName?: string },
|
|
256
343
|
): Promise<{ error: Response } | AuthResult> {
|
|
257
344
|
try {
|
|
258
345
|
const claims = await validateHubJwt(token, { expectedAudience: opts.expectedAudience });
|
|
@@ -268,6 +355,43 @@ async function authenticateHubJwt(
|
|
|
268
355
|
),
|
|
269
356
|
};
|
|
270
357
|
}
|
|
358
|
+
// Defense-in-depth vault-pin check (multi-user Phase 1 PR 5). Runs
|
|
359
|
+
// AFTER the broad-scope check — a malformed token gets the more-
|
|
360
|
+
// descriptive scope-shape error rather than a generic pin-mismatch.
|
|
361
|
+
// When `vaultName` is unset (no per-vault binding known at this
|
|
362
|
+
// call site), the check is skipped — the audience strict-check
|
|
363
|
+
// inside validateHubJwt is the primary pin in that case.
|
|
364
|
+
if (opts.vaultName !== undefined && !enforceVaultScope(claims, opts.vaultName)) {
|
|
365
|
+
// Invariant: by the contract of `enforceVaultScope`, the false
|
|
366
|
+
// branch requires `claims.vaultScope.length > 0` — an empty
|
|
367
|
+
// `vaultScope` always returns true. The defensive assertion
|
|
368
|
+
// pins that so a future refactor can't slip an empty-scope
|
|
369
|
+
// request into this branch (which would render an empty
|
|
370
|
+
// parenthesised list in the message and break the diagnostic).
|
|
371
|
+
// Body shape: client gets `required_vault` + a message naming
|
|
372
|
+
// the conflict. The token's pinned vault is intentionally NOT
|
|
373
|
+
// echoed back — the attacker holds the token (and can decode
|
|
374
|
+
// claims locally), so the message would only leak the pin to a
|
|
375
|
+
// legitimate operator looking at a 403 log line. They already
|
|
376
|
+
// know which user they assigned where; the required_vault is
|
|
377
|
+
// the actionable piece.
|
|
378
|
+
if (claims.vaultScope.length === 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
"unreachable: enforceVaultScope returned false on an empty vaultScope",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
error: Response.json(
|
|
385
|
+
{
|
|
386
|
+
error: "Forbidden",
|
|
387
|
+
error_type: "vault_scope_mismatch",
|
|
388
|
+
message: `token's vault_scope (${claims.vaultScope.join(", ")}) does not include the requested vault '${opts.vaultName}'`,
|
|
389
|
+
required_vault: opts.vaultName,
|
|
390
|
+
},
|
|
391
|
+
{ status: 403 },
|
|
392
|
+
),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
271
395
|
const permission: TokenPermission =
|
|
272
396
|
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
273
397
|
? "full"
|
|
@@ -326,6 +450,14 @@ export async function authenticateGlobalRequest(
|
|
|
326
450
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
327
451
|
}
|
|
328
452
|
|
|
453
|
+
// Server-wide operator token (vault#339). When VAULT_AUTH_TOKEN is set,
|
|
454
|
+
// a matching bearer authenticates as full/admin on cross-vault routes
|
|
455
|
+
// (/vaults metadata listing, /health detail). Checked first so a
|
|
456
|
+
// container-host operator-channel call doesn't depend on any per-vault
|
|
457
|
+
// DB lookup.
|
|
458
|
+
const serverWide = tryServerWideAuth(key);
|
|
459
|
+
if (serverWide !== null) return serverWide;
|
|
460
|
+
|
|
329
461
|
// Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
|
|
330
462
|
// /vaults / /health surface spans every vault and has no single audience to
|
|
331
463
|
// strict-check against, so JWTs aren't accepted here. Cross-vault listing
|