@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21

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/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
+ }