@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.5
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 +1 -1
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/db.ts +70 -0
- package/src/index.ts +57 -2
- 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/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/tools.ts +479 -0
- package/src/update-provider.test.ts +169 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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
|
+
}
|