@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12
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/package.json +3 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import {
|
|
4
|
+
validateBaseUrl,
|
|
5
|
+
ProbeValidationError,
|
|
6
|
+
} from "./probe-validate.js";
|
|
7
|
+
|
|
8
|
+
// RFC-028 P1.5 — update_provider invariants. Tests the building blocks
|
|
9
|
+
// the MCP handler relies on:
|
|
10
|
+
// - SQL-level SEC-1 (provider_id × network_id uniqueness across tenants)
|
|
11
|
+
// - audit_log row written for every update
|
|
12
|
+
// - validateBaseUrl re-runs against vendor from DB (not patch) on
|
|
13
|
+
// base_url change → vendor immutability bypass attempt fails
|
|
14
|
+
// - patch-empty noop path
|
|
15
|
+
// - DELETE FROM provider_models + INSERT replacement is atomic
|
|
16
|
+
//
|
|
17
|
+
// Boundary behaviour (zod-strict patch wrapper, MCP -32602 on extras,
|
|
18
|
+
// admin gate, cross-tenant rejection) is covered by the docker e2e
|
|
19
|
+
// (qa-rfc028-update-provider). The unit layer focuses on the impl
|
|
20
|
+
// guarantees that survive even if the MCP boundary changes.
|
|
21
|
+
|
|
22
|
+
const NET_A = "net_test_pa_alpha";
|
|
23
|
+
const NET_B = "net_test_pa_beta";
|
|
24
|
+
const PROV_A = "prov_test_p15_alpha";
|
|
25
|
+
const PROV_B = "prov_test_p15_beta";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
29
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
30
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
31
|
+
}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
for (const p of [PROV_A, PROV_B]) {
|
|
37
|
+
try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function seedProvider(provider_id: string, network_id: string, opts: Partial<{ name: string; vendor: string; base_url: string; enabled: number }> = {}) {
|
|
44
|
+
db.run(
|
|
45
|
+
`INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
|
|
46
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
|
|
47
|
+
[provider_id, network_id, opts.name ?? "anthro-prod", opts.vendor ?? "anthropic",
|
|
48
|
+
opts.base_url ?? "https://api.anthropic.com", "ANTHROPIC_API_KEY",
|
|
49
|
+
Date.now(), "test", opts.enabled ?? 1],
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
|
|
54
|
+
test("SEC-1: provider_id is unique-per-network — netA query for netB provider_id returns nothing", () => {
|
|
55
|
+
seedProvider(PROV_A, NET_A);
|
|
56
|
+
seedProvider(PROV_B, NET_B);
|
|
57
|
+
// The handler's lookup query: SELECT ... WHERE provider_id = ? AND network_id = ?
|
|
58
|
+
const fromNetA = db.get<any>(
|
|
59
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
60
|
+
PROV_B, NET_A,
|
|
61
|
+
);
|
|
62
|
+
expect(fromNetA == null).toBe(true);
|
|
63
|
+
// Sanity: PROV_B does exist in its own network
|
|
64
|
+
const fromNetB = db.get<any>(
|
|
65
|
+
"SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
|
|
66
|
+
PROV_B, NET_B,
|
|
67
|
+
);
|
|
68
|
+
expect(fromNetB?.provider_id).toBe(PROV_B);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("vendor immutability via validateBaseUrl: changing base_url uses DB vendor, attempt to widen via patched vendor fails", () => {
|
|
72
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
73
|
+
// Handler reads vendor from DB row, NOT from patch. Suppose patch tried
|
|
74
|
+
// to set vendor=evil + base_url=https://attacker.example.com — the
|
|
75
|
+
// handler ignores the vendor from patch (it's not a field in the
|
|
76
|
+
// zod patch schema), and validateBaseUrl runs with vendor='anthropic'
|
|
77
|
+
// from the DB, so the host check is applied.
|
|
78
|
+
const dbVendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
79
|
+
expect(dbVendor).toBe("anthropic");
|
|
80
|
+
// attempting to use base_url not in anthropic allowlist fails
|
|
81
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(ProbeValidationError);
|
|
82
|
+
expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(/probe_target_forbidden/);
|
|
83
|
+
// valid anthropic host passes
|
|
84
|
+
expect(() => validateBaseUrl(dbVendor, "https://api.anthropic.com/v1")).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("patch noop: enabled set to same value → SET clause skipped", () => {
|
|
88
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
89
|
+
const before = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
90
|
+
expect(before).toBe(1);
|
|
91
|
+
// simulate the handler's diff logic: if patch.enabled === row.enabled, skip
|
|
92
|
+
const newEnabled = true ? 1 : 0;
|
|
93
|
+
const shouldUpdate = newEnabled !== before;
|
|
94
|
+
expect(shouldUpdate).toBe(false);
|
|
95
|
+
// verify no SET ran (row unchanged)
|
|
96
|
+
const after = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
|
|
97
|
+
expect(after).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("enabled toggle 1→0 written + audit log row created in same transaction", () => {
|
|
101
|
+
seedProvider(PROV_A, NET_A, { enabled: 1 });
|
|
102
|
+
db.exec("BEGIN");
|
|
103
|
+
db.run("UPDATE providers SET enabled = 0 WHERE provider_id = ?1", [PROV_A]);
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
106
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
107
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { enabled: { before: true, after: false } } }), NET_A],
|
|
108
|
+
);
|
|
109
|
+
db.exec("COMMIT");
|
|
110
|
+
expect(db.get<{enabled:number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)?.enabled).toBe(0);
|
|
111
|
+
const audit = db.get<any>("SELECT action, target_id, detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", PROV_A);
|
|
112
|
+
expect(audit?.action).toBe("update_provider");
|
|
113
|
+
expect(audit?.target_id).toBe(PROV_A);
|
|
114
|
+
expect(JSON.parse(audit!.detail).diff.enabled.after).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("audit detail does NOT contain secret_key_ref VALUE under any patch", () => {
|
|
118
|
+
seedProvider(PROV_A, NET_A);
|
|
119
|
+
// Even if an attacker tried to slip secret into the diff (impossible
|
|
120
|
+
// through the schema, but defense-in-depth), the audit serializer
|
|
121
|
+
// only includes the fields actually in `diff`. Secret isn't a patch
|
|
122
|
+
// field, so it can't reach the diff. Verify the absence:
|
|
123
|
+
const auditPayload = JSON.stringify({ diff: { name: { before: "old", after: "new" } } });
|
|
124
|
+
expect(auditPayload).not.toContain("secret");
|
|
125
|
+
expect(auditPayload).not.toContain("ANTHROPIC_API_KEY");
|
|
126
|
+
expect(auditPayload).not.toContain("sk-");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("provider_models DELETE+INSERT atomicity (transaction wraps both)", () => {
|
|
130
|
+
seedProvider(PROV_A, NET_A);
|
|
131
|
+
db.run(
|
|
132
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
133
|
+
VALUES ('pm_old1', ?1, 'claude-old-1', 1, ?2), ('pm_old2', ?1, 'claude-old-2', 1, ?2)`,
|
|
134
|
+
[PROV_A, Date.now()],
|
|
135
|
+
);
|
|
136
|
+
expect(db.all("SELECT model_id FROM provider_models WHERE provider_id = ?1", PROV_A).length).toBe(2);
|
|
137
|
+
// Replace in one transaction
|
|
138
|
+
db.exec("BEGIN");
|
|
139
|
+
db.run("DELETE FROM provider_models WHERE provider_id = ?1", [PROV_A]);
|
|
140
|
+
db.run(
|
|
141
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
|
|
142
|
+
VALUES ('pm_new1', ?1, 'claude-new-1', 1, ?2)`,
|
|
143
|
+
[PROV_A, Date.now()],
|
|
144
|
+
);
|
|
145
|
+
db.exec("COMMIT");
|
|
146
|
+
const rows = db.all<{model_name: string}>("SELECT model_name FROM provider_models WHERE provider_id = ?1", PROV_A);
|
|
147
|
+
expect(rows.length).toBe(1);
|
|
148
|
+
expect(rows[0].model_name).toBe("claude-new-1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("base_url change rejected via validateBaseUrl when host not in vendor allowlist", () => {
|
|
152
|
+
seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
|
|
153
|
+
const vendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
|
|
154
|
+
// any host other than api.anthropic.com is rejected
|
|
155
|
+
expect(() => validateBaseUrl(vendor, "https://api.openai.com")).toThrow(/probe_target_forbidden/);
|
|
156
|
+
expect(() => validateBaseUrl(vendor, "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
|
|
157
|
+
expect(() => validateBaseUrl(vendor, "http://api.anthropic.com")).toThrow(/probe_base_url_invalid/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("provider_disabled error distinct from provider_not_found in probe lookup", () => {
|
|
161
|
+
seedProvider(PROV_A, NET_A, { enabled: 0 });
|
|
162
|
+
// The new probe handler does the lookup WITHOUT enabled filter, then
|
|
163
|
+
// checks .enabled — distinguishing not_found vs disabled.
|
|
164
|
+
const row = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2", PROV_A, NET_A);
|
|
165
|
+
expect(row).toBeTruthy();
|
|
166
|
+
expect(row!.enabled).toBe(0);
|
|
167
|
+
// dashboard renders different message for these two cases
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("no-op same-value patch: NO audit_log row written (通信牛 nit)", () => {
|
|
171
|
+
// Regression for the post-merge fix: when patch fields all match the
|
|
172
|
+
// existing row (e.g. dashboard saves an unchanged form), the handler
|
|
173
|
+
// must early-return WITHOUT inserting an empty-diff audit row.
|
|
174
|
+
// Empty audit rows pollute the trail and make `forced_*` / sensitive
|
|
175
|
+
// actions harder to find.
|
|
176
|
+
seedProvider(PROV_A, NET_A, { name: "same-name", enabled: 1 });
|
|
177
|
+
const auditBefore = db.get<{n:number}>(
|
|
178
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
179
|
+
PROV_A,
|
|
180
|
+
)!.n;
|
|
181
|
+
|
|
182
|
+
// Mirror the handler's diff-check logic on a same-value patch
|
|
183
|
+
const row = db.get<{name:string, enabled:number, base_url:string}>(
|
|
184
|
+
"SELECT name, enabled, base_url FROM providers WHERE provider_id = ?1", PROV_A,
|
|
185
|
+
)!;
|
|
186
|
+
const patch = { name: "same-name", enabled: true };
|
|
187
|
+
const sets: string[] = [];
|
|
188
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
189
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
190
|
+
const willReplaceModels = false; // no patch.models
|
|
191
|
+
|
|
192
|
+
// sets MUST be empty for a same-value patch
|
|
193
|
+
expect(sets.length).toBe(0);
|
|
194
|
+
expect(willReplaceModels).toBe(false);
|
|
195
|
+
|
|
196
|
+
// Handler returns here WITHOUT writing audit_log → count unchanged
|
|
197
|
+
const auditAfter = db.get<{n:number}>(
|
|
198
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
|
|
199
|
+
PROV_A,
|
|
200
|
+
)!.n;
|
|
201
|
+
expect(auditAfter).toBe(auditBefore);
|
|
202
|
+
expect(auditAfter).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("real change DOES write audit_log row (positive guard for the early-return)", () => {
|
|
206
|
+
// Belt-and-suspenders: no-op early-return MUST NOT trigger when even
|
|
207
|
+
// one field actually changes. Otherwise we'd silently lose audit
|
|
208
|
+
// coverage for real updates.
|
|
209
|
+
seedProvider(PROV_A, NET_A, { name: "old-name", enabled: 1 });
|
|
210
|
+
const row = db.get<{name:string, enabled:number}>(
|
|
211
|
+
"SELECT name, enabled FROM providers WHERE provider_id = ?1", PROV_A,
|
|
212
|
+
)!;
|
|
213
|
+
const patch = { name: "new-name", enabled: true };
|
|
214
|
+
const sets: string[] = [];
|
|
215
|
+
if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
|
|
216
|
+
if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
|
|
217
|
+
expect(sets).toEqual(["name"]);
|
|
218
|
+
|
|
219
|
+
db.exec("BEGIN");
|
|
220
|
+
db.run("UPDATE providers SET name = ?1 WHERE provider_id = ?2", ["new-name", PROV_A]);
|
|
221
|
+
db.run(
|
|
222
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
223
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
224
|
+
["u_test", "test", PROV_A, JSON.stringify({ diff: { name: { before: "old-name", after: "new-name" } }, fields_changed: ["name"] }), NET_A],
|
|
225
|
+
);
|
|
226
|
+
db.exec("COMMIT");
|
|
227
|
+
|
|
228
|
+
const audit = db.get<any>(
|
|
229
|
+
"SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider' ORDER BY id DESC LIMIT 1",
|
|
230
|
+
PROV_A,
|
|
231
|
+
)!;
|
|
232
|
+
expect(JSON.parse(audit.detail).fields_changed).toEqual(["name"]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// RFC-028 P1 §4.1 vault layer tests. F2 lazy gate is the critical
|
|
2
|
+
// invariant — existing prod hub升级 without ANET_HUB_SECRET_VAULT_KEY
|
|
3
|
+
// must NOT boot-fail. Every test runs with deterministic key isolation.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test, beforeEach, afterAll } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
encryptSecret, decryptSecret,
|
|
8
|
+
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
9
|
+
vaultStatusForBoot, vaultMasterKeyFingerprint,
|
|
10
|
+
_resetVaultKeyForTest, VaultError,
|
|
11
|
+
} from "./vault.js";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
|
|
14
|
+
const TEST_KEY_HEX = "0123456789abcdef".repeat(4); // 32 bytes hex = 64 chars
|
|
15
|
+
|
|
16
|
+
function setKey(hex: string | undefined): void {
|
|
17
|
+
if (hex === undefined) delete process.env.ANET_HUB_SECRET_VAULT_KEY;
|
|
18
|
+
else process.env.ANET_HUB_SECRET_VAULT_KEY = hex;
|
|
19
|
+
_resetVaultKeyForTest();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db.run("DELETE FROM network_secrets");
|
|
24
|
+
db.run("DELETE FROM provider_models");
|
|
25
|
+
db.run("DELETE FROM providers");
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => { _resetVaultKeyForTest(); delete process.env.ANET_HUB_SECRET_VAULT_KEY; });
|
|
28
|
+
|
|
29
|
+
describe("RFC-028 P1 §4.1 F2 — LAZY GATE (critical: don't brick prod hub)", () => {
|
|
30
|
+
test("hub boot WITHOUT env + WITHOUT vault data → vaultStatusForBoot says configured=false, needsKeyToOp=false (boots OK)", () => {
|
|
31
|
+
setKey(undefined);
|
|
32
|
+
const s = vaultStatusForBoot();
|
|
33
|
+
expect(s.configured).toBe(false);
|
|
34
|
+
expect(s.tablesHaveData).toBe(false);
|
|
35
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
36
|
+
// Crucially: no throw, no crash, hub keeps running.
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("hub boot WITH env + WITHOUT vault data → configured=true, tablesEmpty (no fingerprint needed)", () => {
|
|
40
|
+
setKey(TEST_KEY_HEX);
|
|
41
|
+
const s = vaultStatusForBoot();
|
|
42
|
+
expect(s.configured).toBe(true);
|
|
43
|
+
expect(s.tablesHaveData).toBe(false);
|
|
44
|
+
expect(s.needsKeyToOp).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("hub WITHOUT env + WITH vault data → needsKeyToOp=true (banner warning, NO throw)", () => {
|
|
48
|
+
setKey(TEST_KEY_HEX);
|
|
49
|
+
vaultUpsert("net_test", "TEST_KEY", "plaintext-value");
|
|
50
|
+
setKey(undefined);
|
|
51
|
+
const s = vaultStatusForBoot();
|
|
52
|
+
expect(s.configured).toBe(false);
|
|
53
|
+
expect(s.tablesHaveData).toBe(true);
|
|
54
|
+
expect(s.needsKeyToOp).toBe(true);
|
|
55
|
+
// Still no throw — banner is informational; throw only happens on
|
|
56
|
+
// actual vault op (test below).
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("vault op WITHOUT env throws vault_master_key_missing (lazy throw, not boot throw)", () => {
|
|
60
|
+
setKey(undefined);
|
|
61
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(VaultError);
|
|
62
|
+
try { vaultUpsert("net_test", "K", "v"); }
|
|
63
|
+
catch (e: any) { expect(e.code).toBe("vault_master_key_missing"); }
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("vault op WITH invalid env (not 32 bytes hex) throws vault_master_key_invalid", () => {
|
|
67
|
+
setKey("not-hex");
|
|
68
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
69
|
+
setKey("aabbcc"); // too short
|
|
70
|
+
expect(() => vaultUpsert("net_test", "K", "v")).toThrow(/vault_master_key_invalid/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("RFC-028 P1 §4.1 — AES-GCM encrypt/decrypt round-trip", () => {
|
|
75
|
+
test("encrypt+decrypt roundtrip preserves plaintext", () => {
|
|
76
|
+
setKey(TEST_KEY_HEX);
|
|
77
|
+
const pt = "sk-ant-api03-abc123xyz";
|
|
78
|
+
const enc = encryptSecret(pt);
|
|
79
|
+
expect(enc.ciphertext.length).toBeGreaterThan(0);
|
|
80
|
+
expect(enc.iv.length).toBe(12);
|
|
81
|
+
expect(enc.tag.length).toBe(16);
|
|
82
|
+
expect(decryptSecret(enc)).toBe(pt);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("ciphertext is unique per call (random IV)", () => {
|
|
86
|
+
setKey(TEST_KEY_HEX);
|
|
87
|
+
const a = encryptSecret("same-secret");
|
|
88
|
+
const b = encryptSecret("same-secret");
|
|
89
|
+
expect(a.iv.equals(b.iv)).toBe(false);
|
|
90
|
+
expect(a.ciphertext.equals(b.ciphertext)).toBe(false);
|
|
91
|
+
// Both decrypt back to same plaintext
|
|
92
|
+
expect(decryptSecret(a)).toBe("same-secret");
|
|
93
|
+
expect(decryptSecret(b)).toBe("same-secret");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("tamper detection — flipping ciphertext bit throws vault_decrypt_failed", () => {
|
|
97
|
+
setKey(TEST_KEY_HEX);
|
|
98
|
+
const enc = encryptSecret("important");
|
|
99
|
+
enc.ciphertext[0] ^= 0x01;
|
|
100
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("tamper detection — flipping tag bit throws vault_decrypt_failed", () => {
|
|
104
|
+
setKey(TEST_KEY_HEX);
|
|
105
|
+
const enc = encryptSecret("important");
|
|
106
|
+
enc.tag[0] ^= 0x01;
|
|
107
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("wrong key cannot decrypt", () => {
|
|
111
|
+
setKey(TEST_KEY_HEX);
|
|
112
|
+
const enc = encryptSecret("important");
|
|
113
|
+
setKey("ff".repeat(32)); // different key
|
|
114
|
+
expect(() => decryptSecret(enc)).toThrow(/vault_decrypt_failed/);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("RFC-028 P1 §4.1 — DB ops + key listing (永不返 value)", () => {
|
|
119
|
+
test("vaultUpsert + vaultGet roundtrip via DB", () => {
|
|
120
|
+
setKey(TEST_KEY_HEX);
|
|
121
|
+
vaultUpsert("net_A", "ANTHROPIC_API_KEY", "sk-ant-prod-1");
|
|
122
|
+
expect(vaultGet("net_A", "ANTHROPIC_API_KEY")).toBe("sk-ant-prod-1");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("vaultUpsert replaces existing key (same network_id + key)", () => {
|
|
126
|
+
setKey(TEST_KEY_HEX);
|
|
127
|
+
vaultUpsert("net_B", "K1", "v1");
|
|
128
|
+
vaultUpsert("net_B", "K1", "v2");
|
|
129
|
+
expect(vaultGet("net_B", "K1")).toBe("v2");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("vaultGet returns null for missing key (does NOT throw)", () => {
|
|
133
|
+
setKey(TEST_KEY_HEX);
|
|
134
|
+
expect(vaultGet("net_X", "MISSING")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("vaultListKeys returns key NAMES only (no values, ever)", () => {
|
|
138
|
+
setKey(TEST_KEY_HEX);
|
|
139
|
+
vaultUpsert("net_L", "KEY_A", "value-a");
|
|
140
|
+
vaultUpsert("net_L", "KEY_B", "value-b");
|
|
141
|
+
const keys = vaultListKeys("net_L");
|
|
142
|
+
expect(keys).toEqual(["KEY_A", "KEY_B"]); // sorted; no values
|
|
143
|
+
expect(JSON.stringify(keys)).not.toContain("value-a");
|
|
144
|
+
expect(JSON.stringify(keys)).not.toContain("value-b");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("network isolation — netA's secret不 visible to netB", () => {
|
|
148
|
+
setKey(TEST_KEY_HEX);
|
|
149
|
+
vaultUpsert("net_A", "SHARED_NAME", "value-from-A");
|
|
150
|
+
vaultUpsert("net_B", "SHARED_NAME", "value-from-B");
|
|
151
|
+
expect(vaultGet("net_A", "SHARED_NAME")).toBe("value-from-A");
|
|
152
|
+
expect(vaultGet("net_B", "SHARED_NAME")).toBe("value-from-B");
|
|
153
|
+
expect(vaultListKeys("net_A")).toEqual(["SHARED_NAME"]);
|
|
154
|
+
expect(vaultListKeys("net_B")).toEqual(["SHARED_NAME"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("vaultDelete removes key + returns true; second delete returns false", () => {
|
|
158
|
+
setKey(TEST_KEY_HEX);
|
|
159
|
+
vaultUpsert("net_D", "K", "v");
|
|
160
|
+
expect(vaultDelete("net_D", "K")).toBe(true);
|
|
161
|
+
expect(vaultGet("net_D", "K")).toBeNull();
|
|
162
|
+
expect(vaultDelete("net_D", "K")).toBe(false); // no-op
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("RFC-028 P1 §4.1 — master key fingerprint", () => {
|
|
167
|
+
test("fingerprint same for same key, different for different key", () => {
|
|
168
|
+
setKey(TEST_KEY_HEX);
|
|
169
|
+
const fp1 = vaultMasterKeyFingerprint();
|
|
170
|
+
expect(fp1.length).toBe(16);
|
|
171
|
+
setKey(TEST_KEY_HEX); // re-set same key
|
|
172
|
+
expect(vaultMasterKeyFingerprint()).toBe(fp1);
|
|
173
|
+
setKey("ff".repeat(32));
|
|
174
|
+
expect(vaultMasterKeyFingerprint()).not.toBe(fp1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("RFC-028 P1 §4.1 — DB ciphertext storage (PRAGMA-style: NO plaintext in DB)", () => {
|
|
179
|
+
test("BLOB stored is ciphertext, not plaintext (raw SELECT verifies)", () => {
|
|
180
|
+
setKey(TEST_KEY_HEX);
|
|
181
|
+
const plaintext = "sk-very-secret-key-do-not-leak";
|
|
182
|
+
vaultUpsert("net_P", "ANTHROPIC_API_KEY", plaintext);
|
|
183
|
+
const raw = db.get<{ ciphertext: Buffer | Uint8Array; iv: Buffer | Uint8Array; tag: Buffer | Uint8Array }>(
|
|
184
|
+
"SELECT ciphertext, iv, tag FROM network_secrets WHERE network_id = ?1 AND key = ?2",
|
|
185
|
+
"net_P", "ANTHROPIC_API_KEY",
|
|
186
|
+
);
|
|
187
|
+
expect(raw).not.toBeNull();
|
|
188
|
+
const ctBuf = Buffer.isBuffer(raw!.ciphertext) ? raw!.ciphertext : Buffer.from(raw!.ciphertext as any);
|
|
189
|
+
// Plaintext substring MUST NOT appear in ciphertext bytes
|
|
190
|
+
expect(ctBuf.toString("binary")).not.toContain(plaintext);
|
|
191
|
+
expect(ctBuf.toString("utf8")).not.toContain(plaintext);
|
|
192
|
+
// Even as hex / base64 / partial — bytes must not match plaintext bytes
|
|
193
|
+
const plainBytes = Buffer.from(plaintext, "utf8");
|
|
194
|
+
let found = false;
|
|
195
|
+
for (let i = 0; i <= ctBuf.length - plainBytes.length; i++) {
|
|
196
|
+
if (ctBuf.subarray(i, i + plainBytes.length).equals(plainBytes)) { found = true; break; }
|
|
197
|
+
}
|
|
198
|
+
expect(found).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// RFC-028 P1 §4.1 — vault layer (AES-GCM at-rest + lazy master key gate).
|
|
2
|
+
//
|
|
3
|
+
// F2 CRITICAL: master key from ANET_HUB_SECRET_VAULT_KEY env is
|
|
4
|
+
// resolved LAZILY — hub boot does NOT require it. Existing prod hubs
|
|
5
|
+
// without the env should boot + run normally. The env is only required
|
|
6
|
+
// when:
|
|
7
|
+
// (a) vault write hits (upsert_network_secret)
|
|
8
|
+
// (b) vault read hits (probe / create-node env_refs)
|
|
9
|
+
// (c) network_secrets / providers tables are non-empty at boot
|
|
10
|
+
// (banner status only — doesn't throw)
|
|
11
|
+
//
|
|
12
|
+
// If the env is missing AND a vault op runs → throw `vault_master_key_missing`
|
|
13
|
+
// with explicit migration message. Existing hub stays up.
|
|
14
|
+
|
|
15
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
|
|
18
|
+
let _masterKey: Buffer | null = null;
|
|
19
|
+
let _bootBannerShown = false;
|
|
20
|
+
|
|
21
|
+
/** Lazy getter — throws on first vault op if env missing. */
|
|
22
|
+
export function getVaultMasterKey(): Buffer {
|
|
23
|
+
if (_masterKey) return _masterKey;
|
|
24
|
+
const hex = process.env.ANET_HUB_SECRET_VAULT_KEY;
|
|
25
|
+
if (!hex) {
|
|
26
|
+
throw new VaultError("vault_master_key_missing",
|
|
27
|
+
"ANET_HUB_SECRET_VAULT_KEY env var not set. Generate one: openssl rand -hex 32. " +
|
|
28
|
+
"Required only when using vault/provider features; this op tried to access vault."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
32
|
+
throw new VaultError("vault_master_key_invalid",
|
|
33
|
+
"ANET_HUB_SECRET_VAULT_KEY must be 32 bytes hex (64 chars). Generate: openssl rand -hex 32"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
_masterKey = Buffer.from(hex, "hex");
|
|
37
|
+
return _masterKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Banner-only check (does not throw). Call on boot for operator visibility. */
|
|
41
|
+
export function vaultStatusForBoot(): { configured: boolean; tablesHaveData: boolean; needsKeyToOp: boolean } {
|
|
42
|
+
const configured = !!process.env.ANET_HUB_SECRET_VAULT_KEY
|
|
43
|
+
&& /^[0-9a-fA-F]{64}$/.test(process.env.ANET_HUB_SECRET_VAULT_KEY);
|
|
44
|
+
let tablesHaveData = false;
|
|
45
|
+
try {
|
|
46
|
+
const r1 = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_secrets");
|
|
47
|
+
const r2 = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM providers");
|
|
48
|
+
tablesHaveData = (r1?.n || 0) > 0 || (r2?.n || 0) > 0;
|
|
49
|
+
} catch { /* tables may not exist yet */ }
|
|
50
|
+
return {
|
|
51
|
+
configured,
|
|
52
|
+
tablesHaveData,
|
|
53
|
+
needsKeyToOp: tablesHaveData && !configured,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reset for unit tests (must be combined with delete process.env.ANET_HUB_SECRET_VAULT_KEY). */
|
|
58
|
+
export function _resetVaultKeyForTest(): void { _masterKey = null; _bootBannerShown = false; }
|
|
59
|
+
|
|
60
|
+
export class VaultError extends Error {
|
|
61
|
+
constructor(public code: string, msg: string) {
|
|
62
|
+
super(`${code}: ${msg}`);
|
|
63
|
+
this.name = "VaultError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── AES-GCM encrypt/decrypt ─────────────────────────────────────────
|
|
68
|
+
// AES-256-GCM. 12-byte IV per RFC 5116. Auth tag is 16 bytes. We
|
|
69
|
+
// store iv + ciphertext + tag separately (DB schema mirrors NIST SP
|
|
70
|
+
// 800-38D structure for forward-compatibility).
|
|
71
|
+
|
|
72
|
+
const ALGO = "aes-256-gcm";
|
|
73
|
+
const IV_BYTES = 12;
|
|
74
|
+
const TAG_BYTES = 16;
|
|
75
|
+
|
|
76
|
+
export interface EncryptedSecret {
|
|
77
|
+
ciphertext: Buffer;
|
|
78
|
+
iv: Buffer;
|
|
79
|
+
tag: Buffer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function encryptSecret(plaintext: string): EncryptedSecret {
|
|
83
|
+
const key = getVaultMasterKey();
|
|
84
|
+
const iv = randomBytes(IV_BYTES);
|
|
85
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
86
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
87
|
+
const tag = cipher.getAuthTag();
|
|
88
|
+
return { ciphertext: ct, iv, tag };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function decryptSecret(enc: EncryptedSecret): string {
|
|
92
|
+
const key = getVaultMasterKey();
|
|
93
|
+
if (enc.tag.length !== TAG_BYTES) {
|
|
94
|
+
throw new VaultError("vault_tag_invalid", `auth tag must be ${TAG_BYTES} bytes, got ${enc.tag.length}`);
|
|
95
|
+
}
|
|
96
|
+
const decipher = createDecipheriv(ALGO, key, enc.iv);
|
|
97
|
+
decipher.setAuthTag(enc.tag);
|
|
98
|
+
try {
|
|
99
|
+
const pt = Buffer.concat([decipher.update(enc.ciphertext), decipher.final()]);
|
|
100
|
+
return pt.toString("utf8");
|
|
101
|
+
} catch (e: any) {
|
|
102
|
+
// GCM auth fail = either wrong key, tampered ciphertext, or
|
|
103
|
+
// tampered iv/tag. All collapse to one error.
|
|
104
|
+
throw new VaultError("vault_decrypt_failed", `AES-GCM auth fail (key/iv/tag/ciphertext mismatch): ${e?.message || e}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── DB ops ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export function vaultUpsert(networkId: string, key: string, plaintext: string): void {
|
|
111
|
+
const enc = encryptSecret(plaintext);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
db.run(
|
|
114
|
+
`INSERT INTO network_secrets (network_id, key, ciphertext, iv, tag, created_at, updated_at)
|
|
115
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
|
116
|
+
ON CONFLICT(network_id, key) DO UPDATE SET
|
|
117
|
+
ciphertext = ?3, iv = ?4, tag = ?5, updated_at = ?6`,
|
|
118
|
+
[networkId, key, enc.ciphertext, enc.iv, enc.tag, now],
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Returns the decrypted plaintext, or null if no row.
|
|
123
|
+
* Throws VaultError on missing key / decrypt failure. */
|
|
124
|
+
export function vaultGet(networkId: string, key: string): string | null {
|
|
125
|
+
const row = db.get<{ ciphertext: Buffer; iv: Buffer; tag: Buffer }>(
|
|
126
|
+
"SELECT ciphertext, iv, tag FROM network_secrets WHERE network_id = ?1 AND key = ?2",
|
|
127
|
+
networkId, key,
|
|
128
|
+
);
|
|
129
|
+
if (!row) return null;
|
|
130
|
+
return decryptSecret({
|
|
131
|
+
ciphertext: Buffer.isBuffer(row.ciphertext) ? row.ciphertext : Buffer.from(row.ciphertext as any),
|
|
132
|
+
iv: Buffer.isBuffer(row.iv) ? row.iv : Buffer.from(row.iv as any),
|
|
133
|
+
tag: Buffer.isBuffer(row.tag) ? row.tag : Buffer.from(row.tag as any),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** List vault key names for a network (NEVER returns values). */
|
|
138
|
+
export function vaultListKeys(networkId: string): string[] {
|
|
139
|
+
const rows = db.all<{ key: string }>(
|
|
140
|
+
"SELECT key FROM network_secrets WHERE network_id = ?1 ORDER BY key",
|
|
141
|
+
networkId,
|
|
142
|
+
);
|
|
143
|
+
return rows.map(r => r.key);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function vaultDelete(networkId: string, key: string): boolean {
|
|
147
|
+
const r: any = db.run("DELETE FROM network_secrets WHERE network_id = ?1 AND key = ?2", [networkId, key]);
|
|
148
|
+
return (r?.changes ?? 0) > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Test/diagnostic: fingerprint of master key (sha256 hex first 8) so
|
|
152
|
+
* ops can verify two hubs share the same vault key without leaking it. */
|
|
153
|
+
export function vaultMasterKeyFingerprint(): string {
|
|
154
|
+
const key = getVaultMasterKey();
|
|
155
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
156
|
+
}
|