@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +188 -0
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/package-version-consistency.test.ts +25 -0
  44. package/src/peer-reply-atomic.test.ts +455 -0
  45. package/src/peer-reply-capability.test.ts +70 -0
  46. package/src/probe-validate.test.ts +221 -0
  47. package/src/probe-validate.ts +104 -0
  48. package/src/probe.test.ts +80 -0
  49. package/src/probe.ts +200 -0
  50. package/src/push.ts +96 -7
  51. package/src/require-explicit-test-db.ts +9 -0
  52. package/src/resolve-port.test.ts +57 -0
  53. package/src/resolve-port.ts +44 -0
  54. package/src/response-charset.test.ts +130 -0
  55. package/src/rest-explicit-columns-http.test.ts +211 -0
  56. package/src/rest-identity.test.ts +129 -0
  57. package/src/rest-identity.ts +100 -0
  58. package/src/rest-projections.ts +67 -0
  59. package/src/rest-write-network-resolution.test.ts +133 -0
  60. package/src/runtime-label.test.ts +22 -0
  61. package/src/runtime-label.ts +21 -0
  62. package/src/scheduled-run-terminal.test.ts +268 -0
  63. package/src/scheduled-tasks-http.test.ts +479 -0
  64. package/src/scheduled-tasks.ts +549 -0
  65. package/src/send-reply-agent-warning.test.ts +202 -0
  66. package/src/send-reply-attachments.test.ts +437 -0
  67. package/src/server.ts +3508 -0
  68. package/src/shared/external-schedule-contract.ts +60 -0
  69. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  70. package/src/shared/probe-host-allowlist.ts +128 -0
  71. package/src/side-thread-command-transport.test.ts +302 -0
  72. package/src/side-thread-command-transport.ts +318 -0
  73. package/src/side-thread-http-integration.test.ts +229 -0
  74. package/src/side-thread-http.ts +504 -0
  75. package/src/side-thread-production.test.ts +72 -0
  76. package/src/side-thread-production.ts +118 -0
  77. package/src/side-thread.test.ts +1067 -0
  78. package/src/side-thread.ts +2495 -0
  79. package/src/skillhub-http.test.ts +88 -0
  80. package/src/stop-delete-node.test.ts +720 -0
  81. package/src/task-auth-origin-http.test.ts +87 -0
  82. package/src/task-auth-origin.test.ts +24 -0
  83. package/src/task-auth-origin.ts +7 -0
  84. package/src/task-consumption.test.ts +398 -0
  85. package/src/task-diagnostic-http.test.ts +145 -0
  86. package/src/task-diagnostic.test.ts +115 -0
  87. package/src/task-diagnostic.ts +118 -0
  88. package/src/task-idempotency-mcp.test.ts +142 -0
  89. package/src/task-idempotency.test.ts +60 -0
  90. package/src/task-idempotency.ts +75 -0
  91. package/src/task-lifecycle-watcher.test.ts +200 -0
  92. package/src/task-lifecycle-watcher.ts +49 -0
  93. package/src/task-network-resolution-http.test.ts +137 -0
  94. package/src/tasks-pagination-http.test.ts +137 -0
  95. package/src/tools.ts +2312 -169
  96. package/src/update-provider.test.ts +234 -0
  97. package/src/upload-early-reject-guard.test.ts +155 -0
  98. package/src/uploads-http.test.ts +92 -14
  99. package/src/uploads.test.ts +66 -0
  100. package/src/uploads.ts +130 -5
  101. package/src/vault.test.ts +200 -0
  102. package/src/vault.ts +156 -0
package/src/uploads.ts CHANGED
@@ -53,6 +53,17 @@ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
53
53
  // traversal.
54
54
  export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
55
55
 
56
+ // Stored extension tokens have one invariant at every path boundary:
57
+ // either no extension, or a leading dot followed by 1-16 ASCII
58
+ // alphanumeric characters. Keep this separate from sanitizeExt(), which
59
+ // parses a client filename and therefore deliberately has different
60
+ // capture/anchoring semantics.
61
+ const EXT_TOKEN_REGEX = /^\.[A-Za-z0-9]{1,16}$/;
62
+
63
+ function isValidExtToken(ext: unknown): ext is string {
64
+ return typeof ext === "string" && (ext === "" || EXT_TOKEN_REGEX.test(ext));
65
+ }
66
+
56
67
  export type GeneratedFile = {
57
68
  fileId: string;
58
69
  ext: string;
@@ -94,14 +105,51 @@ export function sanitizeExt(filename: string | undefined | null): string {
94
105
  return match ? "." + match[1].toLowerCase() : "";
95
106
  }
96
107
 
108
+ // YYYY-MM-DD bucket string regex — matches what getDateBucket produces
109
+ // and what an index entry's `date_bucket` field is validated against.
110
+ const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
111
+
97
112
  /**
98
- * Build the per-upload storage path. The file_id and ext are validated
99
- * against strict regexes; any path-separator or `..` segment from a
100
- * malicious client gets rejected at the regex layer before any I/O.
113
+ * Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
114
+ * AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
115
+ * "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
116
+ * enough because the regex accepts any digit combination in each
117
+ * position.
118
+ *
119
+ * Used as a pre-check by the download handler before it calls into
120
+ * pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
121
+ * rejected at the boundary), NOT a 500 (uncaught throw from deep in
122
+ * the read path). Distinguishing these matters: 500 says "server
123
+ * failed"; 404 says "we know your request is bad, refused early".
124
+ */
125
+ export function isValidCalendarBucket(bucket: unknown): bucket is string {
126
+ if (typeof bucket !== "string") return false;
127
+ if (!DATE_BUCKET_REGEX.test(bucket)) return false;
128
+ // Round-trip via Date to reject non-calendar shapes like "2026-02-30"
129
+ // or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
130
+ // "2026-03-02") — if the ISO round-trip doesn't equal the input, the
131
+ // caller supplied a non-calendar bucket.
132
+ const [yStr, mStr, dStr] = bucket.split("-");
133
+ const y = Number(yStr), m = Number(mStr), d = Number(dStr);
134
+ if (m < 1 || m > 12) return false;
135
+ if (d < 1 || d > 31) return false;
136
+ const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
137
+ return roundtrip === bucket;
138
+ }
139
+
140
+ /**
141
+ * Build the storage path for a NEW upload. Computes today's date bucket
142
+ * from the runtime clock (or `opts.now` for tests). Use ONLY for creating
143
+ * a fresh blob; for reading an existing blob whose bucket is already
144
+ * recorded in the index, use pathForExistingBlob() instead.
145
+ *
146
+ * The file_id and ext are validated against strict regexes; any
147
+ * path-separator or `..` segment from a malicious client gets rejected
148
+ * at the regex layer before any I/O.
101
149
  */
102
150
  export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
103
151
  if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
104
- if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
152
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
105
153
  const dateBucket = getDateBucket(opts.now);
106
154
  const root = opts.uploadsRoot ?? getUploadsRoot();
107
155
  const relativePath = join(dateBucket, fileId + ext);
@@ -109,6 +157,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
109
157
  return { fileId, ext, dateBucket, relativePath, absolutePath };
110
158
  }
111
159
 
160
+ /**
161
+ * Build the storage path for an EXISTING blob, using the caller-supplied
162
+ * `dateBucket` (typically read from the index entry). Does NOT compute
163
+ * a date from the runtime clock — that is the write-side concern.
164
+ *
165
+ * This split exists because sharing one date-computing function between
166
+ * write and read semantics caused #509: yesterday's file downloaded
167
+ * today would look under today's bucket → blob_missing → 404, even
168
+ * though the blob was still sitting in yesterday's directory.
169
+ *
170
+ * The file_id, ext and dateBucket are validated against strict regexes
171
+ * before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
172
+ * here just as file_id path-escape attempts are.
173
+ */
174
+ export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
175
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
176
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
177
+ if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
178
+ throw new Error(`date_bucket "${dateBucket}" is invalid`);
179
+ }
180
+ const root = opts.uploadsRoot ?? getUploadsRoot();
181
+ const relativePath = join(dateBucket, fileId + ext);
182
+ const absolutePath = join(root, relativePath);
183
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
184
+ }
185
+
112
186
  export type UploadIndexEntry = {
113
187
  file_id: string;
114
188
  date_bucket: string; // YYYY-MM-DD
@@ -117,6 +191,15 @@ export type UploadIndexEntry = {
117
191
  mime: string; // best-effort mime from the multipart Content-Type
118
192
  size: number; // byte length of the binary
119
193
  owner?: string; // upload token id / username, for traceability
194
+ // Written by the upload handler as `authCtx?.userId ?? null`, so `null`
195
+ // is a real on-disk value — the type must say so rather than pretend
196
+ // the field is `string | undefined`.
197
+ owner_id?: string | null;
198
+ // #503 — network the upload is attributed to. The key is OMITTED (not
199
+ // written as null) when there is no attribution, so "absent" and
200
+ // "unattributed" are the same on-disk shape and legacy entries written
201
+ // before #503 are indistinguishable from new unattributed ones.
202
+ network_id?: string;
120
203
  uploaded_at: string; // ISO 8601
121
204
  };
122
205
 
@@ -135,10 +218,16 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
135
218
  export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
136
219
  if (!entry || typeof entry !== "object") return false;
137
220
  const e: any = entry;
221
+ // #503 — if the network_id key is present it must be a non-empty string.
222
+ // An absent key is valid (legacy entries + unattributed uploads); that
223
+ // keeps this in sync with the writer, which omits the key entirely
224
+ // rather than writing null. owner_id is deliberately NOT constrained
225
+ // here — existing entries legitimately carry owner_id=null.
226
+ if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
138
227
  return (
139
228
  typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
140
229
  typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
141
- typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
230
+ isValidExtToken(e.ext) &&
142
231
  typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
143
232
  );
144
233
  }
@@ -241,6 +330,42 @@ export type TaskAttachment = {
241
330
  size?: number;
242
331
  };
243
332
 
333
+ /**
334
+ * #222 cross-host attachment safety — strip `path` from meta.attachments
335
+ * entries that ALSO carry a `file_id`. Reason: the hub's /api/upload
336
+ * returns `{file_id, path}` where `path` is a hub-machine-local
337
+ * absolute path. Senders sometimes echo `path` back into meta.attachments.
338
+ * When the receiving agent runs on a different host (Vincent 2026-06-30
339
+ * toodadev2 case), that path string is meaningless. agent-node's
340
+ * fetch-attachment.ts will fetch via `file_id` regardless, but leaving
341
+ * the stale `path` in the JSON misleads any tooling that reads it.
342
+ *
343
+ * CRITICAL — conditional, not unconditional (通信龙 PR #222 pre-review
344
+ * nit): feishu-bridge currently writes `/work/feishu-attachments/<conn>/
345
+ * <chat>/` directly and ships path-only attachments (NO file_id). If we
346
+ * strip path unconditionally those break too. Only strip when file_id
347
+ * is present (the "safe to drop" signal). Single-host feishu fallback
348
+ * preserved.
349
+ *
350
+ * Shared by tools.ts (MCP send_task) and index.ts (REST /api/task) so
351
+ * both transports apply the same sanitization to meta.
352
+ */
353
+ export function stripHostLocalPathsForCrossHostSafe(meta: any): any {
354
+ if (!meta || typeof meta !== "object") return meta;
355
+ if (!Array.isArray((meta as any).attachments)) return meta;
356
+ let mutated = false;
357
+ const sanitized = (meta as any).attachments.map((a: any) => {
358
+ if (a && typeof a === "object" && typeof a.file_id === "string" && a.file_id && typeof a.path === "string") {
359
+ mutated = true;
360
+ const { path: _stripped, ...rest } = a;
361
+ return rest;
362
+ }
363
+ return a;
364
+ });
365
+ if (!mutated) return meta;
366
+ return { ...meta, attachments: sanitized };
367
+ }
368
+
244
369
  /** Light validator usable from the REST handler. Returns null on success or an error message string. */
245
370
  export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
246
371
  if (input === undefined || input === null) return { ok: true, attachments: [] };
@@ -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
+ }