@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,351 @@
1
+ "use strict";
2
+ /**
3
+ * gap/lite-keystore.ts — OS credential-store wrapper for the lite operator
4
+ * signing key (Adversary #1 / Security F3, 2026-07-12 quality gate).
5
+ *
6
+ * PROBLEM: the operator self-sign key (lite-signing-key.ts) was a plaintext
7
+ * JSON file. `mode: 0o600` on the write is a NO-OP on Windows (NTFS ACLs, not
8
+ * POSIX mode bits, govern access there; Node's `fs` mode parameter is
9
+ * silently ignored on Windows), so on the platform most self-hosters
10
+ * actually run this on, the key was readable by ANY process running as the
11
+ * same OS user with no additional barrier at all.
12
+ *
13
+ * CHOICE (founder-directed, "BOTH"): wrap the key at rest with the native OS
14
+ * credential store where available, keep a documented, hardened file
15
+ * fallback where it is not.
16
+ *
17
+ * ABSTRACTION CHOICE: shell out to the OS's OWN already-installed credential
18
+ * CLI (PowerShell + DPAPI on Windows, `security` on macOS, `secret-tool` on
19
+ * Linux) rather than pulling in an npm binding (e.g. keytar, which is
20
+ * unmaintained and requires a native-compiled addon per platform/Node ABI).
21
+ * A native addon dependency would work against the whole point of
22
+ * @synoi/gateway-lite: `npx @synoi/gateway-lite` must install and run with nothing
23
+ * beyond what npm and the OS already provide. Shelling out to the OS's own
24
+ * tool has no compile step, no prebuilt-binary matrix to maintain, and fails
25
+ * CLEANLY (ENOENT / non-zero exit) when the tool is absent, which is exactly
26
+ * the signal isAvailable() below needs to trigger the documented fallback.
27
+ *
28
+ * PLATFORM COVERAGE (tested vs implemented-per-documented-behavior):
29
+ * - Windows (DPAPI via PowerShell `System.Security.Cryptography.
30
+ * ProtectedData`, CurrentUser scope): TESTED in this session (real
31
+ * PowerShell process, real protect/unprotect round-trip). DPAPI ships
32
+ * with every Windows install; no separate tool to be missing.
33
+ * - macOS (`security` CLI, the standard Keychain command-line tool):
34
+ * IMPLEMENTED per documented `security add-generic-password` /
35
+ * `find-generic-password` behavior. NOT executable-tested in this
36
+ * session (no macOS runner available). `security` ships with every
37
+ * macOS install.
38
+ * - Linux (`secret-tool`, part of libsecret-tools / gnome-keyring):
39
+ * IMPLEMENTED per documented `secret-tool store` / `lookup` behavior.
40
+ * NOT executable-tested in this session. NOT guaranteed present on a
41
+ * minimal/headless/server Linux install (no desktop keyring daemon) --
42
+ * this is the platform most likely to hit the file fallback for a
43
+ * legitimate reason, not a bug.
44
+ *
45
+ * FALLBACK: if the platform's keystore is unavailable (tool missing, no
46
+ * desktop session, permission denied, or any other failure), lite-signing-
47
+ * key.ts falls back to the existing file-based store, hardened by this
48
+ * session's Windows ACL fix (icacls owner-only, see hardenFileAcl below),
49
+ * with a LOUD warning surfaced at boot (src/daemon.ts) and disclosed in
50
+ * packages/gateway-lite/README.md. The fallback is not silent.
51
+ *
52
+ * No em dashes. No AI attribution.
53
+ */
54
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
55
+ if (k2 === undefined) k2 = k;
56
+ var desc = Object.getOwnPropertyDescriptor(m, k);
57
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
58
+ desc = { enumerable: true, get: function() { return m[k]; } };
59
+ }
60
+ Object.defineProperty(o, k2, desc);
61
+ }) : (function(o, m, k, k2) {
62
+ if (k2 === undefined) k2 = k;
63
+ o[k2] = m[k];
64
+ }));
65
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
66
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
67
+ }) : function(o, v) {
68
+ o["default"] = v;
69
+ });
70
+ var __importStar = (this && this.__importStar) || (function () {
71
+ var ownKeys = function(o) {
72
+ ownKeys = Object.getOwnPropertyNames || function (o) {
73
+ var ar = [];
74
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
75
+ return ar;
76
+ };
77
+ return ownKeys(o);
78
+ };
79
+ return function (mod) {
80
+ if (mod && mod.__esModule) return mod;
81
+ var result = {};
82
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
83
+ __setModuleDefault(result, mod);
84
+ return result;
85
+ };
86
+ })();
87
+ Object.defineProperty(exports, "__esModule", { value: true });
88
+ exports.detectKeystorePlatform = detectKeystorePlatform;
89
+ exports._setForceUnavailableForTest = _setForceUnavailableForTest;
90
+ exports.createLiteKeystore = createLiteKeystore;
91
+ exports.hardenFileAcl = hardenFileAcl;
92
+ const node_child_process_1 = require("node:child_process");
93
+ const node_util_1 = require("node:util");
94
+ const fs = __importStar(require("node:fs"));
95
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
96
+ const KEYSTORE_SERVICE = 'synoi-gateway-lite-signing-key';
97
+ function detectKeystorePlatform() {
98
+ if (process.platform === 'win32')
99
+ return 'windows-dpapi';
100
+ if (process.platform === 'darwin')
101
+ return 'macos-keychain';
102
+ if (process.platform === 'linux')
103
+ return 'linux-secret-tool';
104
+ return 'unsupported';
105
+ }
106
+ // ── Windows: DPAPI via PowerShell ───────────────────────────────────────────
107
+ //
108
+ // DPAPI (Data Protection API) encrypts to a blob only the SAME Windows user
109
+ // account can decrypt (CurrentUser scope), using OS-managed key material
110
+ // derived from the user's login credentials. Node has no built-in DPAPI
111
+ // binding; PowerShell's full .NET access (System.Security.Cryptography.
112
+ // ProtectedData) is the OS-native tool for this, present on every Windows
113
+ // install with PowerShell (which ships by default since Windows 7 / Server
114
+ // 2008 R2). The secret is passed via an environment variable to the child
115
+ // process, not a command-line argument, so it does not appear in process
116
+ // listings (ps / Get-Process / Task Manager command-line columns).
117
+ //
118
+ // Storage location: since DPAPI protects BYTES, not a keyed store, "store"
119
+ // here just returns the protected blob (base64) for the caller to persist
120
+ // wherever it likes (lite-signing-key.ts writes it to the same file path the
121
+ // plaintext fallback would have used, but the file now holds ciphertext).
122
+ // "account" is accepted for interface symmetry with the other platforms but
123
+ // is not otherwise used by DPAPI itself.
124
+ class WindowsDpapiKeystore {
125
+ platform = 'windows-dpapi';
126
+ filePath;
127
+ constructor(filePath) {
128
+ this.filePath = filePath;
129
+ }
130
+ async isAvailable() {
131
+ if (process.platform !== 'win32')
132
+ return false;
133
+ try {
134
+ const { stdout } = await execFileAsync('powershell', [
135
+ '-NoProfile', '-NonInteractive', '-Command',
136
+ 'Add-Type -AssemblyName System.Security; "ok"',
137
+ ]);
138
+ return stdout.trim() === 'ok';
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ async store(_account, secret) {
145
+ const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-Command', DPAPI_PROTECT_SCRIPT], { env: { ...process.env, SYNOI_DPAPI_INPUT: secret } });
146
+ const protectedBase64 = stdout.trim();
147
+ fs.writeFileSync(this.filePath, protectedBase64, { encoding: 'utf8', mode: 0o600 });
148
+ }
149
+ async retrieve(_account) {
150
+ if (!fs.existsSync(this.filePath))
151
+ return null;
152
+ const protectedBase64 = fs.readFileSync(this.filePath, 'utf8').trim();
153
+ if (protectedBase64 === '')
154
+ return null;
155
+ try {
156
+ const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-NonInteractive', '-Command', DPAPI_UNPROTECT_SCRIPT], { env: { ...process.env, SYNOI_DPAPI_INPUT: protectedBase64 } });
157
+ return stdout.trim();
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ }
164
+ const DPAPI_PROTECT_SCRIPT = [
165
+ 'Add-Type -AssemblyName System.Security;',
166
+ '$bytes = [System.Text.Encoding]::UTF8.GetBytes($env:SYNOI_DPAPI_INPUT);',
167
+ '$protected = [System.Security.Cryptography.ProtectedData]::Protect(',
168
+ ' $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
169
+ '[Convert]::ToBase64String($protected)',
170
+ ].join(' ');
171
+ const DPAPI_UNPROTECT_SCRIPT = [
172
+ 'Add-Type -AssemblyName System.Security;',
173
+ '$bytes = [Convert]::FromBase64String($env:SYNOI_DPAPI_INPUT);',
174
+ '$unprotected = [System.Security.Cryptography.ProtectedData]::Unprotect(',
175
+ ' $bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
176
+ '[System.Text.Encoding]::UTF8.GetString($unprotected)',
177
+ ].join(' ');
178
+ // ── macOS: the `security` CLI (Keychain) ───────────────────────────────────
179
+ //
180
+ // IMPLEMENTED per documented `security` behavior; not executable-tested in
181
+ // this session (no macOS runner available here). `-w <secret>` on the store
182
+ // path is the standard, documented `security add-generic-password` usage.
183
+ //
184
+ // Security re-clear (2026-07-12): checked for a non-argv input path before
185
+ // accepting this. `secret-tool store` (the Linux path below) documents
186
+ // reading its secret from STDIN, so the same was investigated here.
187
+ // `security(1)`'s `add-generic-password` does not document an equivalent:
188
+ // `-w` takes its value as a literal argument or, if omitted entirely,
189
+ // prompts interactively at a TTY (not usable from a non-interactive daemon
190
+ // spawn). There is no documented `-w -` / stdin form, and no separate
191
+ // subcommand that stores a generic password from STDIN. So this remains a
192
+ // known, accepted characteristic of the `security` CLI itself, not a gap in
193
+ // this code: the secret is briefly visible as a process argument during the
194
+ // store call only (a narrow, one-time window at first run; retrieval does
195
+ // not re-expose it). Deleting-then-adding (rather than -U/--update in
196
+ // place) sidesteps a `security` quirk where -U silently no-ops on certain
197
+ // macOS versions when the access-control list differs.
198
+ class MacosKeychainKeystore {
199
+ platform = 'macos-keychain';
200
+ async isAvailable() {
201
+ if (process.platform !== 'darwin')
202
+ return false;
203
+ try {
204
+ await execFileAsync('security', ['-h']);
205
+ return true;
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
211
+ async store(account, secret) {
212
+ // Best-effort delete of any prior entry, then add fresh. Ignore a
213
+ // "could not be found" failure on delete (nothing to remove yet).
214
+ try {
215
+ await execFileAsync('security', ['delete-generic-password', '-a', account, '-s', KEYSTORE_SERVICE]);
216
+ }
217
+ catch {
218
+ /* no prior entry; fine */
219
+ }
220
+ await execFileAsync('security', [
221
+ 'add-generic-password', '-a', account, '-s', KEYSTORE_SERVICE, '-w', secret,
222
+ ]);
223
+ }
224
+ async retrieve(account) {
225
+ try {
226
+ const { stdout } = await execFileAsync('security', [
227
+ 'find-generic-password', '-a', account, '-s', KEYSTORE_SERVICE, '-w',
228
+ ]);
229
+ const value = stdout.trim();
230
+ return value === '' ? null : value;
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
236
+ }
237
+ // ── Linux: `secret-tool` (libsecret) ────────────────────────────────────────
238
+ //
239
+ // IMPLEMENTED per documented `secret-tool` behavior; not executable-tested
240
+ // in this session. Unlike macOS `security -w`, `secret-tool store` reads the
241
+ // secret from STDIN, so it never appears as a process argument at all.
242
+ // `secret-tool` requires a running secret-service provider (gnome-keyring,
243
+ // KWallet via ksecretservice, or similar) -- routinely present on a desktop
244
+ // Linux session, routinely ABSENT on a minimal/headless/server install. That
245
+ // is the expected, legitimate case for isAvailable() to return false and the
246
+ // file fallback to take over, not a defect.
247
+ class LinuxSecretToolKeystore {
248
+ platform = 'linux-secret-tool';
249
+ async isAvailable() {
250
+ if (process.platform !== 'linux')
251
+ return false;
252
+ try {
253
+ await execFileAsync('secret-tool', ['--version']);
254
+ return true;
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ }
260
+ async store(account, secret) {
261
+ await new Promise((resolve, reject) => {
262
+ const child = (0, node_child_process_1.spawn)('secret-tool', [
263
+ 'store', '--label', `SynOI lite daemon operator key (${account})`,
264
+ 'service', KEYSTORE_SERVICE, 'account', account,
265
+ ]);
266
+ let stderr = '';
267
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
268
+ child.on('error', reject);
269
+ child.on('close', (code) => {
270
+ if (code === 0)
271
+ resolve();
272
+ else
273
+ reject(new Error(`secret-tool store exited ${code}: ${stderr}`));
274
+ });
275
+ child.stdin.write(secret);
276
+ child.stdin.end();
277
+ });
278
+ }
279
+ async retrieve(account) {
280
+ try {
281
+ const { stdout } = await execFileAsync('secret-tool', ['lookup', 'service', KEYSTORE_SERVICE, 'account', account]);
282
+ const value = stdout.trim();
283
+ return value === '' ? null : value;
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ }
289
+ }
290
+ // ── Factory ──────────────────────────────────────────────────────────────
291
+ /** TEST ONLY: force createLiteKeystore() to return null (as if no keystore
292
+ * integration exists for this platform), so the file-fallback path can be
293
+ * exercised deterministically without depending on the test runner's own
294
+ * platform having (or lacking) a real keystore tool. */
295
+ let _forceUnavailableForTest = false;
296
+ function _setForceUnavailableForTest(v) {
297
+ _forceUnavailableForTest = v;
298
+ }
299
+ /**
300
+ * Build the keystore for the current platform. `filePath` is only used by
301
+ * the Windows DPAPI implementation (it stores the protected blob at this
302
+ * path rather than in a separate keyed store, since DPAPI has no notion of
303
+ * named entries). macOS/Linux ignore it (they use the OS's own keyed store).
304
+ */
305
+ function createLiteKeystore(filePath) {
306
+ if (_forceUnavailableForTest)
307
+ return null;
308
+ const platform = detectKeystorePlatform();
309
+ if (platform === 'windows-dpapi')
310
+ return new WindowsDpapiKeystore(filePath);
311
+ if (platform === 'macos-keychain')
312
+ return new MacosKeychainKeystore();
313
+ if (platform === 'linux-secret-tool')
314
+ return new LinuxSecretToolKeystore();
315
+ return null;
316
+ }
317
+ // ── File-fallback hardening (Security F3, part b) ──────────────────────────
318
+ //
319
+ // When the OS keystore is unavailable, lite-signing-key.ts falls back to the
320
+ // plaintext file it already used. `fs.writeFileSync(..., { mode: 0o600 })`
321
+ // is a NO-OP on Windows (NTFS ACLs govern access, not POSIX mode bits) --
322
+ // this was the actual gap Adversary #1 flagged. `icacls` is the Windows-
323
+ // native tool for setting a real, enforced ACL: strip inherited permissions
324
+ // and grant Full Control to ONLY the current user (and, implicitly,
325
+ // Administrators via the SYSTEM/built-in Administrators group icacls always
326
+ // preserves unless :r is paired with an explicit deny -- acceptable here
327
+ // since an Administrator already has an unconditional escalation path to
328
+ // any user's files on Windows regardless of this ACL). On macOS/Linux,
329
+ // POSIX mode 0600 (already set by the writeFileSync call site) IS
330
+ // meaningful and enforced by the kernel, so no extra step is needed there.
331
+ async function hardenFileAcl(filePath) {
332
+ if (process.platform !== 'win32') {
333
+ // POSIX mode 0600 on the writeFileSync call already does the job.
334
+ return { hardened: true, detail: 'POSIX file mode 0600 (kernel-enforced)' };
335
+ }
336
+ try {
337
+ // /inheritance:r strips inherited ACEs; /grant:r replaces the grant list
338
+ // for the given principal with exactly what follows (Full Control),
339
+ // removing any broader access the file would otherwise have inherited
340
+ // from its parent directory.
341
+ const user = process.env['USERNAME'] ? `${process.env['USERDOMAIN'] ?? '.'}\\${process.env['USERNAME']}` : undefined;
342
+ if (user === undefined) {
343
+ return { hardened: false, detail: 'could not determine current Windows username for icacls' };
344
+ }
345
+ await execFileAsync('icacls', [filePath, '/inheritance:r', '/grant:r', `${user}:F`]);
346
+ return { hardened: true, detail: `icacls owner-only grant to ${user}` };
347
+ }
348
+ catch (err) {
349
+ return { hardened: false, detail: `icacls failed: ${err instanceof Error ? err.message : String(err)}` };
350
+ }
351
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * gap/lite-mode.ts — the ONE lite-mode flag (ADR_014 Section 10, the public
4
+ * daemon carve-out).
5
+ *
6
+ * Factored into its own leaf module (imports nothing else under gap/) so
7
+ * every module that needs to branch between the full gateway's
8
+ * managed/KMS-hybrid behavior and the lite daemon's operator-owned self-sign
9
+ * behavior can read/set the SAME flag without creating an import cycle.
10
+ * local-ingest-router.ts (receipt signing tier) and operator-enrollment.ts
11
+ * (enrollment-record seal tier, Security F1 fix) both depend on this; neither
12
+ * imports the other for this purpose.
13
+ *
14
+ * src/daemon.ts sets this once at boot. The full gateway (src/index.ts)
15
+ * never sets it, so every module's existing, already-paneled full-gateway
16
+ * behavior is byte-for-byte unchanged when this flag is false (the default).
17
+ *
18
+ * No em dashes. No AI attribution.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.isLiteMode = isLiteMode;
22
+ exports.setLiteMode = setLiteMode;
23
+ let _liteMode = process.env['SYNOI_LITE'] === '1';
24
+ /** Whether the current process is running the lite self-sign tier. */
25
+ function isLiteMode() {
26
+ return _liteMode;
27
+ }
28
+ /** daemon.ts / TEST ONLY: explicitly select lite mode. */
29
+ function setLiteMode(v) {
30
+ _liteMode = v;
31
+ }