@synoi/gateway-lite 0.1.0

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.
@@ -0,0 +1,362 @@
1
+ "use strict";
2
+ /**
3
+ * gap/lite-signing-key.ts — per-instance operator-owned self-sign key.
4
+ *
5
+ * ADR_014 Section 10.1 (public lite distribution scope): the gate that trips
6
+ * on public self-host distribution is keys, not multi-tenancy. The full
7
+ * gateway's receipt-signing identity is loaded via verify-router's
8
+ * initSigningKeys() -> KeyProvider chain (LocalEncryptedFileKeyProvider /
9
+ * AwsKmsKeyProvider), a managed-custody-oriented, Ed25519+ML-DSA-65 hybrid
10
+ * path. The lite self-host daemon has neither managed custody nor a KMS to
11
+ * reach, and must not ship on a shared Foundation X TEST key (a stranger
12
+ * cannot run on Foundation X test keys, ADR_014 Section 10.1).
13
+ *
14
+ * This module generates a single Ed25519 keypair ONCE, on the operator's own
15
+ * machine, so every receipt the daemon signs after first run uses the SAME
16
+ * key. This is the "operator-owned" tier: the private key never leaves the
17
+ * operator's own machine, distinct from a shared/baked-in TEST key and
18
+ * distinct from the gateway's KMS-hybrid managed-custody tier.
19
+ *
20
+ * KEY-AT-REST (Adversary #1 / Security F3, 2026-07-12 quality gate, founder
21
+ * choice "BOTH"):
22
+ * (a) OS credential store where available -- Windows DPAPI, macOS
23
+ * Keychain, Linux libsecret (gap/lite-keystore.ts). Wrapping the
24
+ * private key so only the same OS user/session can unwrap it, using
25
+ * key material the OS itself manages, not a key SynOI's own code
26
+ * chooses or stores.
27
+ * (b) A hardened file fallback when the keystore is unavailable (tool
28
+ * missing, headless/server install, permission denied): the SAME
29
+ * plaintext-JSON shape this module always used, but now with a REAL
30
+ * Windows ACL (icacls owner-only) rather than the POSIX `mode: 0o600`
31
+ * that was always a silent no-op on Windows NTFS.
32
+ * (c) Loud disclosure either way: daemon.ts's boot banner and
33
+ * packages/gateway-lite/README.md both state plainly that ANY process
34
+ * running as the operator's OS account can sign as the operator, that
35
+ * this key has no managed custody and no recovery, and (on the file
36
+ * fallback path specifically) exactly how it is protected per-OS.
37
+ *
38
+ * BACKWARD COMPATIBILITY: an existing lite-signing-key.json written by a
39
+ * pre-fix daemon (schema_version 'lite-signing-key/1', plaintext
40
+ * private_key_hex directly in the file) is read AS-IS, unchanged -- an
41
+ * operator upgrading must not silently lose their operator identity (every
42
+ * receipt they ever signed, and the enrollment record sealed under that
43
+ * key, would become unverifiable under a new key). Only a FRESH first-run
44
+ * key generation uses the new schema_version 'lite-signing-key/2' shape and
45
+ * attempts the OS keystore.
46
+ *
47
+ * Uses @synoi/gap's own Ed25519 keygen (generateReceiptKeyPair) -- no second
48
+ * key-generation primitive in this codebase.
49
+ *
50
+ * No em dashes. No AI attribution.
51
+ */
52
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
53
+ if (k2 === undefined) k2 = k;
54
+ var desc = Object.getOwnPropertyDescriptor(m, k);
55
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
56
+ desc = { enumerable: true, get: function() { return m[k]; } };
57
+ }
58
+ Object.defineProperty(o, k2, desc);
59
+ }) : (function(o, m, k, k2) {
60
+ if (k2 === undefined) k2 = k;
61
+ o[k2] = m[k];
62
+ }));
63
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
64
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
65
+ }) : function(o, v) {
66
+ o["default"] = v;
67
+ });
68
+ var __importStar = (this && this.__importStar) || (function () {
69
+ var ownKeys = function(o) {
70
+ ownKeys = Object.getOwnPropertyNames || function (o) {
71
+ var ar = [];
72
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
73
+ return ar;
74
+ };
75
+ return ownKeys(o);
76
+ };
77
+ return function (mod) {
78
+ if (mod && mod.__esModule) return mod;
79
+ var result = {};
80
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
81
+ __setModuleDefault(result, mod);
82
+ return result;
83
+ };
84
+ })();
85
+ Object.defineProperty(exports, "__esModule", { value: true });
86
+ exports.getLiteSigningKeyPath = getLiteSigningKeyPath;
87
+ exports._resetLiteSigningKeyCache = _resetLiteSigningKeyCache;
88
+ exports.loadOrCreateLiteSigningKeyAsync = loadOrCreateLiteSigningKeyAsync;
89
+ exports.loadOrCreateLiteSigningKey = loadOrCreateLiteSigningKey;
90
+ const fs = __importStar(require("node:fs"));
91
+ const path = __importStar(require("node:path"));
92
+ const os = __importStar(require("node:os"));
93
+ const gap_1 = require("@synoi/gap");
94
+ const lite_keystore_1 = require("./lite-keystore");
95
+ function getLiteSigningKeyPath() {
96
+ return path.join(process.env['SYNOI_DATA_DIR'] ?? path.join(os.homedir(), '.synoi'), 'lite-signing-key.json');
97
+ }
98
+ /** Where the Windows DPAPI-protected private-key blob lives, when that
99
+ * backend is used. A separate file from getLiteSigningKeyPath() so the
100
+ * metadata file (schema_version 'lite-signing-key/2') never itself carries
101
+ * private key material for the keystore-backed backends. */
102
+ function getDpapiBlobPath() {
103
+ return path.join(process.env['SYNOI_DATA_DIR'] ?? path.join(os.homedir(), '.synoi'), 'lite-signing-key.dpapi');
104
+ }
105
+ const KEYSTORE_ACCOUNT = 'lite-operator';
106
+ let _cached = null;
107
+ let _cachedResult = null;
108
+ /** TEST ONLY: drop the in-process cache so a test pointed at a fresh
109
+ * SYNOI_DATA_DIR re-reads (or re-generates) from that directory. */
110
+ function _resetLiteSigningKeyCache() {
111
+ _cached = null;
112
+ _cachedResult = null;
113
+ }
114
+ function hexToBytes32(hex) {
115
+ const buf = Buffer.from(hex, 'hex');
116
+ if (buf.length !== 32)
117
+ throw new Error(`expected 32 bytes, got ${buf.length}`);
118
+ return new Uint8Array(buf);
119
+ }
120
+ /**
121
+ * Async, keystore-aware loader. Call this ONCE at daemon boot (src/
122
+ * daemon.ts), before any request can arrive, and before the synchronous
123
+ * loadOrCreateLiteSigningKey() below is used by the (synchronous) receipt-
124
+ * signing / enrollment-seal call paths -- it populates the same in-process
125
+ * cache those synchronous paths read from.
126
+ *
127
+ * On an EXISTING v1 (pre-fix) file: loaded as-is, unchanged, backend
128
+ * reported as 'file-fallback' (it always was; this call does not migrate it
129
+ * -- migrating would mean re-writing a file whose whole point is to never
130
+ * silently change under the operator).
131
+ *
132
+ * On an EXISTING v2 file: dispatches by its own `backend` field.
133
+ *
134
+ * On NO file (first run): tries the OS keystore; on any failure (including
135
+ * "no keystore for this platform") falls back to a hardened plaintext file
136
+ * and returns a non-empty fallbackWarning.
137
+ */
138
+ async function loadOrCreateLiteSigningKeyAsync() {
139
+ if (_cachedResult !== null)
140
+ return _cachedResult;
141
+ const metaPath = getLiteSigningKeyPath();
142
+ if (fs.existsSync(metaPath)) {
143
+ const rec = parsePersistedFile(metaPath);
144
+ const result = await loadFromExisting(rec);
145
+ _cached = result.keyPair;
146
+ _cachedResult = result;
147
+ return result;
148
+ }
149
+ // First run.
150
+ const fresh = (0, gap_1.generateReceiptKeyPair)();
151
+ const keyId = fresh.keyId ?? ('key:' + Buffer.from(fresh.publicKey).toString('hex').slice(0, 16));
152
+ const publicKeyHex = Buffer.from(fresh.publicKey).toString('hex');
153
+ const privateKeyHex = Buffer.from(fresh.privateKey).toString('hex');
154
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
155
+ const keystore = (0, lite_keystore_1.createLiteKeystore)(getDpapiBlobPath());
156
+ let usedKeystore = false;
157
+ let fallbackWarning = '';
158
+ if (keystore !== null) {
159
+ try {
160
+ const available = await keystore.isAvailable();
161
+ if (available) {
162
+ await keystore.store(KEYSTORE_ACCOUNT, privateKeyHex);
163
+ usedKeystore = true;
164
+ }
165
+ else {
166
+ fallbackWarning = `OS keystore (${keystore.platform}) not available on this machine; using the hardened file fallback.`;
167
+ }
168
+ }
169
+ catch (err) {
170
+ fallbackWarning = `OS keystore (${keystore.platform}) store failed (${err instanceof Error ? err.message : String(err)}); using the hardened file fallback.`;
171
+ }
172
+ }
173
+ else {
174
+ fallbackWarning = `No OS keystore integration for platform "${(0, lite_keystore_1.detectKeystorePlatform)()}"; using the hardened file fallback.`;
175
+ }
176
+ if (usedKeystore) {
177
+ const record = {
178
+ schema_version: 'lite-signing-key/2',
179
+ backend: 'os-keystore',
180
+ keystore_platform: (0, lite_keystore_1.detectKeystorePlatform)(),
181
+ public_key_hex: publicKeyHex,
182
+ key_id: keyId,
183
+ created_at: new Date().toISOString(),
184
+ };
185
+ writeMetaFile(metaPath, record);
186
+ }
187
+ else {
188
+ const record = {
189
+ schema_version: 'lite-signing-key/2',
190
+ backend: 'file-fallback',
191
+ public_key_hex: publicKeyHex,
192
+ key_id: keyId,
193
+ created_at: new Date().toISOString(),
194
+ private_key_hex: privateKeyHex,
195
+ };
196
+ // Security re-clear (2026-07-12): the ACL used to be hardened AFTER the
197
+ // plaintext-bearing file was already renamed into place at metaPath, so
198
+ // for the duration of the icacls process spawn (non-trivial: an async
199
+ // child-process round-trip, not a syscall) the private key sat on disk
200
+ // under its FINAL, discoverable name with whatever ACL it inherited from
201
+ // the parent directory -- POSIX mode 0600 on the writeFileSync call is a
202
+ // no-op on NTFS, so on Windows that inherited ACL could be broader than
203
+ // owner-only. Fix: write to the temp path, harden ITS ACL, THEN rename
204
+ // into place, so the file never appears at metaPath without the
205
+ // restrictive ACL already applied. On non-Windows this is a cheap no-op
206
+ // check (POSIX mode 0600 already did the job at write time); on Windows
207
+ // it closes the window entirely.
208
+ const tmp = writeMetaFileTmp(metaPath, record);
209
+ const acl = await (0, lite_keystore_1.hardenFileAcl)(tmp);
210
+ if (!acl.hardened) {
211
+ fallbackWarning += ` ADDITIONALLY: file ACL hardening failed (${acl.detail}) -- the key file may be readable by more than the operator's own account.`;
212
+ }
213
+ fs.renameSync(tmp, metaPath);
214
+ }
215
+ const keyPair = { privateKey: fresh.privateKey, publicKey: fresh.publicKey, keyId };
216
+ const result = {
217
+ keyPair,
218
+ backend: usedKeystore ? 'os-keystore' : 'file-fallback',
219
+ keystorePlatform: (0, lite_keystore_1.detectKeystorePlatform)(),
220
+ fallbackWarning,
221
+ };
222
+ _cached = keyPair;
223
+ _cachedResult = result;
224
+ return result;
225
+ }
226
+ async function loadFromExisting(rec) {
227
+ if (rec.schema_version === 'lite-signing-key/1') {
228
+ // Pre-fix file: read as-is, never migrated.
229
+ const privateKey = hexToBytes32(rec.private_key_hex);
230
+ const publicKey = hexToBytes32(rec.public_key_hex);
231
+ return {
232
+ keyPair: { privateKey, publicKey, keyId: rec.key_id },
233
+ backend: 'file-fallback',
234
+ fallbackWarning: '',
235
+ };
236
+ }
237
+ // v2.
238
+ if (rec.backend === 'file-fallback') {
239
+ if (rec.private_key_hex === undefined) {
240
+ throw new Error(`[lite-signing-key] v2 file-fallback record missing private_key_hex`);
241
+ }
242
+ const privateKey = hexToBytes32(rec.private_key_hex);
243
+ const publicKey = hexToBytes32(rec.public_key_hex);
244
+ return {
245
+ keyPair: { privateKey, publicKey, keyId: rec.key_id },
246
+ backend: 'file-fallback',
247
+ fallbackWarning: '',
248
+ };
249
+ }
250
+ // backend === 'os-keystore': retrieve the private key from the OS store.
251
+ const keystore = (0, lite_keystore_1.createLiteKeystore)(getDpapiBlobPath());
252
+ if (keystore === null) {
253
+ throw new Error(`[lite-signing-key] record says backend=os-keystore (platform=${String(rec.keystore_platform)}) but ` +
254
+ `this process's platform (${(0, lite_keystore_1.detectKeystorePlatform)()}) has no keystore integration. ` +
255
+ 'The operator key cannot be recovered on this machine/platform.');
256
+ }
257
+ const privateKeyHex = await keystore.retrieve(KEYSTORE_ACCOUNT);
258
+ if (privateKeyHex === null) {
259
+ throw new Error(`[lite-signing-key] record says backend=os-keystore but the key could not be retrieved from ` +
260
+ `the ${keystore.platform} keystore. It may have been removed, or this process may be running as ` +
261
+ 'a different OS user than the one that created it.');
262
+ }
263
+ const privateKey = hexToBytes32(privateKeyHex);
264
+ const publicKey = hexToBytes32(rec.public_key_hex);
265
+ return {
266
+ keyPair: { privateKey, publicKey, keyId: rec.key_id },
267
+ backend: 'os-keystore',
268
+ keystorePlatform: rec.keystore_platform,
269
+ fallbackWarning: '',
270
+ };
271
+ }
272
+ function parsePersistedFile(metaPath) {
273
+ let rec;
274
+ try {
275
+ rec = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
276
+ }
277
+ catch (err) {
278
+ throw new Error(`[lite-signing-key] failed to parse ${metaPath}: ${err instanceof Error ? err.message : String(err)}`);
279
+ }
280
+ if (rec.schema_version !== 'lite-signing-key/1' && rec.schema_version !== 'lite-signing-key/2') {
281
+ throw new Error(`[lite-signing-key] ${metaPath}: unexpected schema_version "${String(rec.schema_version)}"`);
282
+ }
283
+ return rec;
284
+ }
285
+ /** Write `record` to metaPath's temp path and rename it into place in one
286
+ * step, for callers with nothing to do between write and commit (the
287
+ * os-keystore branch: no private key in the file, so there is no ACL
288
+ * window to close). Callers who DO need to act between write and commit
289
+ * (the file-fallback branch, which hardens the ACL first) use
290
+ * writeMetaFileTmp + fs.renameSync directly instead. */
291
+ function writeMetaFile(metaPath, record) {
292
+ const tmp = writeMetaFileTmp(metaPath, record);
293
+ fs.renameSync(tmp, metaPath);
294
+ }
295
+ /** Write `record` to metaPath's temp path ONLY; does not rename. Returns the
296
+ * temp path so the caller can act on it (e.g. harden its ACL) before
297
+ * committing it into place with fs.renameSync. */
298
+ function writeMetaFileTmp(metaPath, record) {
299
+ const tmp = metaPath + '.tmp';
300
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
301
+ return tmp;
302
+ }
303
+ /**
304
+ * SYNCHRONOUS loader for the many call sites inside a request handler
305
+ * (receipt signing, enrollment sealing) that cannot await a keystore CLI
306
+ * round-trip mid-request. Returns the in-process cache if
307
+ * loadOrCreateLiteSigningKeyAsync() already ran (the normal daemon boot
308
+ * order: async loader first, then the server starts accepting requests).
309
+ *
310
+ * If called before the async loader has ever run (e.g. a test that only
311
+ * exercises this synchronous path directly, or a hypothetical future
312
+ * caller that skips the boot sequence), falls back to the ORIGINAL
313
+ * synchronous, file-only behavior: read an existing v1 OR v2 file-fallback
314
+ * record directly (no keystore attempt, since that would require an
315
+ * async call), or generate-and-persist a fresh v1-shaped plaintext file on
316
+ * first run. This preserves every existing synchronous test's behavior
317
+ * unchanged. A v2 os-keystore record encountered here (impossible in
318
+ * practice without having gone through the async loader first, since that
319
+ * is the only path that ever WRITES an os-keystore record) throws rather
320
+ * than silently failing to produce a key.
321
+ */
322
+ function loadOrCreateLiteSigningKey() {
323
+ if (_cached !== null)
324
+ return _cached;
325
+ const p = getLiteSigningKeyPath();
326
+ if (fs.existsSync(p)) {
327
+ const rec = parsePersistedFile(p);
328
+ if (rec.schema_version === 'lite-signing-key/1') {
329
+ const privateKey = hexToBytes32(rec.private_key_hex);
330
+ const publicKey = hexToBytes32(rec.public_key_hex);
331
+ _cached = { privateKey, publicKey, keyId: rec.key_id };
332
+ return _cached;
333
+ }
334
+ if (rec.backend === 'file-fallback' && rec.private_key_hex !== undefined) {
335
+ const privateKey = hexToBytes32(rec.private_key_hex);
336
+ const publicKey = hexToBytes32(rec.public_key_hex);
337
+ _cached = { privateKey, publicKey, keyId: rec.key_id };
338
+ return _cached;
339
+ }
340
+ throw new Error('[lite-signing-key] existing record is backend=os-keystore, which requires the async loader ' +
341
+ '(loadOrCreateLiteSigningKeyAsync, called once at daemon boot) to retrieve the private key. ' +
342
+ 'The synchronous loadOrCreateLiteSigningKey() cannot do this mid-request.');
343
+ }
344
+ // First run via the SYNCHRONOUS path only: no keystore attempt (would
345
+ // require awaiting a CLI round-trip). Matches the original v1 behavior
346
+ // exactly, for callers/tests that never invoke the async loader.
347
+ const fresh = (0, gap_1.generateReceiptKeyPair)();
348
+ const keyId = fresh.keyId ?? ('key:' + Buffer.from(fresh.publicKey).toString('hex').slice(0, 16));
349
+ fs.mkdirSync(path.dirname(p), { recursive: true });
350
+ const record = {
351
+ schema_version: 'lite-signing-key/1',
352
+ private_key_hex: Buffer.from(fresh.privateKey).toString('hex'),
353
+ public_key_hex: Buffer.from(fresh.publicKey).toString('hex'),
354
+ key_id: keyId,
355
+ created_at: new Date().toISOString(),
356
+ };
357
+ const tmp = p + '.tmp';
358
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: 'utf8', mode: 0o600 });
359
+ fs.renameSync(tmp, p);
360
+ _cached = { privateKey: fresh.privateKey, publicKey: fresh.publicKey, keyId };
361
+ return _cached;
362
+ }