@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.
- package/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -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/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3508 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2312 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- 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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|