@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.
- package/LICENSE +195 -0
- package/README.md +185 -0
- package/bin/synoi-gateway-lite.js +22 -0
- package/dist/cdro-mirror.js +280 -0
- package/dist/daemon.js +194 -0
- package/dist/gap/cited-oracle-inputs.js +283 -0
- package/dist/gap/lite-dashboard.html +502 -0
- package/dist/gap/lite-keystore.js +351 -0
- package/dist/gap/lite-mode.js +31 -0
- package/dist/gap/lite-signing-key.js +362 -0
- package/dist/gap/local-ingest-router.js +1642 -0
- package/dist/gap/operator-enrollment.js +466 -0
- package/dist/gap/store.js +1063 -0
- package/dist/gap/types.js +15 -0
- package/dist/key-provider.js +275 -0
- package/dist/keys.js +250 -0
- package/dist/native-mldsa.js +82 -0
- package/dist/receipt-store.js +801 -0
- package/dist/shadow-mode.js +85 -0
- package/dist/state-drift/quantize.js +107 -0
- package/dist/tenant-store.js +366 -0
- package/dist/verify-router.js +1755 -0
- package/npm-shrinkwrap.json +1400 -0
- package/package.json +46 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* gap/types.ts — TypeScript types for the SynOI Governed Action Protocol.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the shapes in synoi-brain/libraries/v1/GAP_SPEC.md §2-§8. This is
|
|
6
|
+
* the canonical TS surface for GAP within synoi-gateway. The published
|
|
7
|
+
* @synoi/gap-types npm package will export a subset.
|
|
8
|
+
*
|
|
9
|
+
* Object types use the prefix `gap:` per spec §2.1 (signed type-prefix migrated per ADR_007)
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.isGapFailure = isGapFailure;
|
|
13
|
+
function isGapFailure(r) {
|
|
14
|
+
return typeof r === 'object' && r !== null && 'reason' in r;
|
|
15
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* key-provider.ts
|
|
4
|
+
*
|
|
5
|
+
* KeyProvider abstraction for persistent signing keys used in Decision Receipt
|
|
6
|
+
* signing (Ed25519 + ML-DSA-65 hybrid). Replaces the ephemeral-key pattern
|
|
7
|
+
* that was previously used on the live signing path.
|
|
8
|
+
*
|
|
9
|
+
* Design rules:
|
|
10
|
+
* 1. FAIL CLOSED in production: if NODE_ENV=production and no provider is
|
|
11
|
+
* configured, startup throws — never fall through to ephemeral generation.
|
|
12
|
+
* 2. Key history: every loaded key is appended to an in-memory history ring
|
|
13
|
+
* so that receipts signed with a prior key can still be verified after
|
|
14
|
+
* rotation (without ever re-signing old receipts).
|
|
15
|
+
* 3. Providers are pluggable via the KeyProvider interface. Two implementations
|
|
16
|
+
* exist:
|
|
17
|
+
* a. LocalEncryptedFileKeyProvider — for self-hosted / dev-prod scenarios.
|
|
18
|
+
* Reads PEM + seed from files that the operator places on disk. Simple,
|
|
19
|
+
* auditable, no cloud dependency. Defined IN THIS FILE.
|
|
20
|
+
* b. AwsKmsKeyProvider — a complete, working AWS Secrets Manager client
|
|
21
|
+
* (not a stub). Defined in key-provider-kms.ts, a SEPARATE file,
|
|
22
|
+
* reached from resolveKeyProvider() below ONLY via a runtime
|
|
23
|
+
* require() (Auditor B1, 2026-07-12): this file is part of the free
|
|
24
|
+
* @synoi/gateway-lite package's static reachable graph, and a static
|
|
25
|
+
* import of AwsKmsKeyProvider here would compile a literal require
|
|
26
|
+
* string naming the AWS Secrets Manager SDK package into this
|
|
27
|
+
* file's own output, shipping cloud-SDK bytes inside a package
|
|
28
|
+
* whose README claims zero cloud code. See key-provider-kms.ts's
|
|
29
|
+
* header for the full rationale and the enforcing tests. (The exact
|
|
30
|
+
* package name is deliberately not spelled out here, in this file:
|
|
31
|
+
* it is the one string test/daemon-package-dep-graph.test.ts's
|
|
32
|
+
* byte-level content scan forbids anywhere in the packed tarball,
|
|
33
|
+
* comments included, and this file ships in that tarball.)
|
|
34
|
+
*
|
|
35
|
+
* AWS KMS/Secrets Manager is the locked decision for production key custody
|
|
36
|
+
* (AWS-native stack). Selection today is an env-var opt-in
|
|
37
|
+
* (SYNOI_KEY_PROVIDER=kms), not a license-tier decision; license-gating KMS
|
|
38
|
+
* selection is separate, PAPER, net-new work, not part of this file.
|
|
39
|
+
*
|
|
40
|
+
* NOTE: this file does NOT touch the canonicalizer (canonicalPayload in
|
|
41
|
+
* verify-router.ts) — that is a separate task.
|
|
42
|
+
*/
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.LocalEncryptedFileKeyProvider = void 0;
|
|
45
|
+
exports.appendKeyHistory = appendKeyHistory;
|
|
46
|
+
exports.getKeyHistory = getKeyHistory;
|
|
47
|
+
exports.findKeyById = findKeyById;
|
|
48
|
+
exports._testClearKeyHistory = _testClearKeyHistory;
|
|
49
|
+
exports.auditKeyLoad = auditKeyLoad;
|
|
50
|
+
exports.getPublicKeyBase64 = getPublicKeyBase64;
|
|
51
|
+
exports.resolveKeyProvider = resolveKeyProvider;
|
|
52
|
+
const crypto_1 = require("crypto");
|
|
53
|
+
const fs_1 = require("fs");
|
|
54
|
+
const path_1 = require("path");
|
|
55
|
+
// ── Key history ───────────────────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// Historical receipts must remain verifiable after key rotation. Instead of
|
|
58
|
+
// re-signing old receipts (which would invalidate the audit trail), the gateway
|
|
59
|
+
// keeps an in-memory append-only ring of every KeyBundle that was loaded during
|
|
60
|
+
// this process lifetime. Verification code iterates the history until it finds
|
|
61
|
+
// a key-id match.
|
|
62
|
+
//
|
|
63
|
+
// The ring is bounded at MAX_HISTORY_SIZE entries to prevent unbounded memory
|
|
64
|
+
// growth if many keys are rotated in a single process lifetime.
|
|
65
|
+
const MAX_HISTORY_SIZE = 32;
|
|
66
|
+
const _keyHistory = [];
|
|
67
|
+
/** Append a bundle to the key history. No-op if the key-id is already present. */
|
|
68
|
+
function appendKeyHistory(bundle) {
|
|
69
|
+
if (_keyHistory.some(b => b.keyId === bundle.keyId))
|
|
70
|
+
return;
|
|
71
|
+
if (_keyHistory.length >= MAX_HISTORY_SIZE) {
|
|
72
|
+
// Drop the oldest entry (index 0) to make room.
|
|
73
|
+
_keyHistory.shift();
|
|
74
|
+
}
|
|
75
|
+
_keyHistory.push(bundle);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Return all key bundles that have been loaded in this process lifetime,
|
|
79
|
+
* newest first. Used by verification to try the current key, then fall back
|
|
80
|
+
* through historical keys when a stored receipt carries an older key-id.
|
|
81
|
+
*/
|
|
82
|
+
function getKeyHistory() {
|
|
83
|
+
return [..._keyHistory].reverse();
|
|
84
|
+
}
|
|
85
|
+
/** Look up a bundle by key-id in the history. Returns undefined if not found. */
|
|
86
|
+
function findKeyById(keyId) {
|
|
87
|
+
return _keyHistory.find(b => b.keyId === keyId);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* TEST ONLY: empty the in-memory key history. Used to simulate a process
|
|
91
|
+
* restart (a fresh boot starts with an empty history, populated only by
|
|
92
|
+
* whatever initSigningKeys() loads THIS time) without actually spawning a
|
|
93
|
+
* new process. Hard-gated the same way __setSigningBundleForTest is
|
|
94
|
+
* (verify-router.ts): throws in production regardless of any other flag.
|
|
95
|
+
*/
|
|
96
|
+
function _testClearKeyHistory() {
|
|
97
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
98
|
+
throw new Error('[key-provider] _testClearKeyHistory is TEST-ONLY and must not be ' +
|
|
99
|
+
"called in production (NODE_ENV === 'production').");
|
|
100
|
+
}
|
|
101
|
+
_keyHistory.length = 0;
|
|
102
|
+
}
|
|
103
|
+
// ── LocalEncryptedFileKeyProvider ─────────────────────────────────────────────
|
|
104
|
+
//
|
|
105
|
+
// Reads key material from files on disk. Intended for:
|
|
106
|
+
// - Self-hosted production deployments (operator owns the files)
|
|
107
|
+
// - Developer workstations with a "test-persistent" key
|
|
108
|
+
// - CI pipelines (ephemeral but process-stable keys in temp files)
|
|
109
|
+
//
|
|
110
|
+
// Required env vars / files:
|
|
111
|
+
// SYNOI_ED25519_PRIVATE_KEY_FILE — path to PKCS#8 PEM file
|
|
112
|
+
// SYNOI_ED25519_PUBLIC_KEY_FILE — path to SPKI PEM file
|
|
113
|
+
// SYNOI_MLDSA_SEED_FILE — path to file containing 64 hex chars (32 bytes)
|
|
114
|
+
// SYNOI_SIGNING_KEY_ID — key identifier string (e.g. "synoi-v2-2026-06")
|
|
115
|
+
//
|
|
116
|
+
// Alternatively, the PEM material may be supplied directly via env vars:
|
|
117
|
+
// SYNOI_SIGNING_KEY_PRIVATE — PKCS#8 PEM (existing variable, backward-compat)
|
|
118
|
+
// SYNOI_SIGNING_KEY_PUBLIC — SPKI PEM (existing variable, backward-compat)
|
|
119
|
+
// SYNOI_MLDSA_SEED — 64 hex chars (existing variable, backward-compat)
|
|
120
|
+
//
|
|
121
|
+
// If all three env vars are set (or all three files exist), the provider loads
|
|
122
|
+
// successfully. Otherwise it throws a descriptive error.
|
|
123
|
+
class LocalEncryptedFileKeyProvider {
|
|
124
|
+
async loadCurrentKey() {
|
|
125
|
+
const keyId = process.env['SYNOI_SIGNING_KEY_ID'] ?? 'synoi-v1';
|
|
126
|
+
// Ed25519 — try inline env first (backward compat), then file.
|
|
127
|
+
let privateKeyPem;
|
|
128
|
+
let publicKeyPem;
|
|
129
|
+
const privInline = process.env['SYNOI_SIGNING_KEY_PRIVATE'];
|
|
130
|
+
const pubInline = process.env['SYNOI_SIGNING_KEY_PUBLIC'];
|
|
131
|
+
const privFile = process.env['SYNOI_ED25519_PRIVATE_KEY_FILE'];
|
|
132
|
+
const pubFile = process.env['SYNOI_ED25519_PUBLIC_KEY_FILE'];
|
|
133
|
+
if (privInline && pubInline) {
|
|
134
|
+
privateKeyPem = privInline;
|
|
135
|
+
publicKeyPem = pubInline;
|
|
136
|
+
}
|
|
137
|
+
else if (privFile && pubFile) {
|
|
138
|
+
if (!(0, fs_1.existsSync)(privFile))
|
|
139
|
+
throw new Error(`[key-provider] Ed25519 private key file not found: ${privFile}`);
|
|
140
|
+
if (!(0, fs_1.existsSync)(pubFile))
|
|
141
|
+
throw new Error(`[key-provider] Ed25519 public key file not found: ${pubFile}`);
|
|
142
|
+
privateKeyPem = (0, fs_1.readFileSync)(privFile, 'utf8');
|
|
143
|
+
publicKeyPem = (0, fs_1.readFileSync)(pubFile, 'utf8');
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
throw new Error('[key-provider] Ed25519 key not configured. ' +
|
|
147
|
+
'Set SYNOI_SIGNING_KEY_PRIVATE + SYNOI_SIGNING_KEY_PUBLIC (PEM) ' +
|
|
148
|
+
'or SYNOI_ED25519_PRIVATE_KEY_FILE + SYNOI_ED25519_PUBLIC_KEY_FILE.');
|
|
149
|
+
}
|
|
150
|
+
// Validate the PEM material loads correctly (catches file corruption early).
|
|
151
|
+
try {
|
|
152
|
+
(0, crypto_1.createPrivateKey)({ key: privateKeyPem, format: 'pem' });
|
|
153
|
+
(0, crypto_1.createPublicKey)({ key: publicKeyPem, format: 'pem' });
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw new Error(`[key-provider] Ed25519 PEM validation failed: ${err.message}`);
|
|
157
|
+
}
|
|
158
|
+
// ML-DSA seed — try inline env first, then file.
|
|
159
|
+
let seed;
|
|
160
|
+
const seedInline = process.env['SYNOI_MLDSA_SEED'];
|
|
161
|
+
const seedFile = process.env['SYNOI_MLDSA_SEED_FILE'];
|
|
162
|
+
if (seedInline) {
|
|
163
|
+
const buf = Buffer.from(seedInline, 'hex');
|
|
164
|
+
if (buf.length !== 32)
|
|
165
|
+
throw new Error(`[key-provider] SYNOI_MLDSA_SEED must be 64 hex chars (32 bytes), got ${buf.length * 2}`);
|
|
166
|
+
seed = new Uint8Array(buf);
|
|
167
|
+
}
|
|
168
|
+
else if (seedFile) {
|
|
169
|
+
if (!(0, fs_1.existsSync)(seedFile))
|
|
170
|
+
throw new Error(`[key-provider] ML-DSA seed file not found: ${seedFile}`);
|
|
171
|
+
const raw = (0, fs_1.readFileSync)(seedFile, 'utf8').trim();
|
|
172
|
+
const buf = Buffer.from(raw, 'hex');
|
|
173
|
+
if (buf.length !== 32)
|
|
174
|
+
throw new Error(`[key-provider] ML-DSA seed file must contain 64 hex chars, got ${buf.length * 2}`);
|
|
175
|
+
seed = new Uint8Array(buf);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
throw new Error('[key-provider] ML-DSA seed not configured. ' +
|
|
179
|
+
'Set SYNOI_MLDSA_SEED (64 hex chars) or SYNOI_MLDSA_SEED_FILE.');
|
|
180
|
+
}
|
|
181
|
+
const bundle = {
|
|
182
|
+
keyId,
|
|
183
|
+
ed25519: { privateKeyPem, publicKeyPem },
|
|
184
|
+
mlDsa: { seed },
|
|
185
|
+
valid_from_ms: Date.now(),
|
|
186
|
+
};
|
|
187
|
+
appendKeyHistory(bundle);
|
|
188
|
+
return bundle;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
exports.LocalEncryptedFileKeyProvider = LocalEncryptedFileKeyProvider;
|
|
192
|
+
// ── AwsKmsKeyProvider ───────────────────────────────────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// Moved to key-provider-kms.ts (Auditor B1, 2026-07-12) so this file's
|
|
195
|
+
// compiled output never carries a require string naming the AWS Secrets
|
|
196
|
+
// Manager SDK package. See key-provider-kms.ts for the full class and
|
|
197
|
+
// rationale, and the dynamic require() in resolveKeyProvider() below for
|
|
198
|
+
// how it is reached.
|
|
199
|
+
// ── Key audit log ─────────────────────────────────────────────────────────────
|
|
200
|
+
//
|
|
201
|
+
// Optional: when SYNOI_KEY_AUDIT_LOG is set to a file path, each key-load
|
|
202
|
+
// event is appended (key-id + ISO timestamp, no key material). Supports
|
|
203
|
+
// compliance evidence that the same persistent key was reloaded after restart.
|
|
204
|
+
function auditKeyLoad(keyId) {
|
|
205
|
+
const logPath = process.env['SYNOI_KEY_AUDIT_LOG'];
|
|
206
|
+
if (!logPath)
|
|
207
|
+
return;
|
|
208
|
+
try {
|
|
209
|
+
const dir = (0, path_1.join)(logPath, '..');
|
|
210
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
211
|
+
(0, fs_1.appendFileSync)(logPath, JSON.stringify({ event: 'key_loaded', key_id: keyId, ts: new Date().toISOString() }) + '\n', 'utf8');
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Audit log failure is non-fatal; the key is still loaded.
|
|
215
|
+
console.warn(`[key-provider] Failed to write audit log to ${logPath}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ── Public key extraction helper ──────────────────────────────────────────────
|
|
219
|
+
/**
|
|
220
|
+
* Extract the raw 32-byte Ed25519 public key from a KeyBundle and return it
|
|
221
|
+
* as standard base64. Used by the GET /v1/gap/keys endpoints.
|
|
222
|
+
*/
|
|
223
|
+
function getPublicKeyBase64(bundle) {
|
|
224
|
+
const keyObj = (0, crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
|
|
225
|
+
const der = keyObj.export({ format: 'der', type: 'spki' });
|
|
226
|
+
return der.subarray(der.length - 32).toString('base64');
|
|
227
|
+
}
|
|
228
|
+
// ── Provider factory ──────────────────────────────────────────────────────────
|
|
229
|
+
//
|
|
230
|
+
// Resolves which KeyProvider to use based on environment configuration.
|
|
231
|
+
// Called once at startup by verify-router.ts.
|
|
232
|
+
//
|
|
233
|
+
// Selection logic:
|
|
234
|
+
// 1. SYNOI_KEY_PROVIDER=kms -> AwsKmsKeyProvider (AWS Secrets Manager)
|
|
235
|
+
// 2. SYNOI_KEY_PROVIDER=file -> LocalEncryptedFileKeyProvider (dev/self-hosted)
|
|
236
|
+
// 3. (unset in production) -> throws: production requires explicit KMS selection
|
|
237
|
+
// 4. (unset in non-production) -> defaults to 'file'
|
|
238
|
+
//
|
|
239
|
+
// Production enforcement: NODE_ENV=production requires SYNOI_KEY_PROVIDER=kms.
|
|
240
|
+
// The Ed25519 private key must not live in an environment variable or a file
|
|
241
|
+
// accessible to the process in production — it must be in AWS Secrets Manager.
|
|
242
|
+
function resolveKeyProvider() {
|
|
243
|
+
const isProduction = process.env['NODE_ENV'] === 'production';
|
|
244
|
+
const providerName = process.env['SYNOI_KEY_PROVIDER'] ?? (isProduction ? '' : 'file');
|
|
245
|
+
if (isProduction && providerName !== 'kms') {
|
|
246
|
+
throw new Error('[key-provider] Production requires SYNOI_KEY_PROVIDER=kms. ' +
|
|
247
|
+
'The Ed25519 private key must be stored in AWS Secrets Manager, not in environment variables or on disk. ' +
|
|
248
|
+
'Set SYNOI_KMS_ED25519_SECRET_ARN, SYNOI_KMS_MLDSA_SECRET_ARN, SYNOI_SIGNING_KEY_ID, and AWS_REGION.');
|
|
249
|
+
}
|
|
250
|
+
switch (providerName) {
|
|
251
|
+
case 'kms': {
|
|
252
|
+
// Dynamic require (Auditor B1, 2026-07-12), mirroring the existing
|
|
253
|
+
// precedent at verify-router.ts's tryVerifyAgpReceipt (dynamic require
|
|
254
|
+
// of gap/receipt-sign / gap/state/absence-verify): a STATIC import of
|
|
255
|
+
// AwsKmsKeyProvider here would pull key-provider-kms.ts, and through
|
|
256
|
+
// it the AWS Secrets Manager SDK require string, into this
|
|
257
|
+
// file's own compiled output -- and this file IS in the free
|
|
258
|
+
// @synoi/gateway-lite package's reachable graph. A minimal LOCAL type
|
|
259
|
+
// (not `typeof import('./key-provider-kms')`) is used for the same
|
|
260
|
+
// reason a whole-module type reference was avoided at verify-router.ts
|
|
261
|
+
// in the earlier lite-daemon-carveout work: `typeof import(...)`
|
|
262
|
+
// still pulls the target module into the TypeScript program's type
|
|
263
|
+
// graph even though the runtime require() is lazy, defeating the
|
|
264
|
+
// point. Enforced by test/daemon-dep-graph.test.ts (source-level:
|
|
265
|
+
// key-provider-kms.ts must be absent from the reachable set) and
|
|
266
|
+
// test/daemon-package-dep-graph.test.ts (byte-level content scan of
|
|
267
|
+
// the actual packed tarball).
|
|
268
|
+
const { AwsKmsKeyProvider } = require('./key-provider-kms');
|
|
269
|
+
return new AwsKmsKeyProvider();
|
|
270
|
+
}
|
|
271
|
+
case 'file': return new LocalEncryptedFileKeyProvider();
|
|
272
|
+
default:
|
|
273
|
+
throw new Error(`[key-provider] Unknown SYNOI_KEY_PROVIDER value: "${providerName}". Supported: "file", "kms".`);
|
|
274
|
+
}
|
|
275
|
+
}
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.keyStore = exports.KeyStore = exports.PLAN_RPM = exports.PLAN_CALL_LIMITS = void 0;
|
|
7
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const crypto_1 = require("crypto");
|
|
12
|
+
// Monthly call quotas per plan (matches synoi/domain PLAN_LIMITS)
|
|
13
|
+
exports.PLAN_CALL_LIMITS = {
|
|
14
|
+
FREE: 10_000,
|
|
15
|
+
GROWTH: 100_000,
|
|
16
|
+
SCALE: 1_000_000,
|
|
17
|
+
ENTERPRISE: 10_000_000,
|
|
18
|
+
};
|
|
19
|
+
// RPM limits per plan
|
|
20
|
+
exports.PLAN_RPM = {
|
|
21
|
+
FREE: 20,
|
|
22
|
+
GROWTH: 200,
|
|
23
|
+
SCALE: 2_000,
|
|
24
|
+
ENTERPRISE: 20_000,
|
|
25
|
+
};
|
|
26
|
+
// ─── KeyStore ─────────────────────────────────────────────────────────────────
|
|
27
|
+
class KeyStore {
|
|
28
|
+
db;
|
|
29
|
+
constructor(dbPath) {
|
|
30
|
+
fs_1.default.mkdirSync(path_1.default.dirname(dbPath), { recursive: true });
|
|
31
|
+
this.db = new better_sqlite3_1.default(dbPath);
|
|
32
|
+
this.db.pragma('journal_mode = WAL');
|
|
33
|
+
this._migrate();
|
|
34
|
+
}
|
|
35
|
+
_migrate() {
|
|
36
|
+
this.db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS tenant_keys (
|
|
38
|
+
key_hash TEXT PRIMARY KEY,
|
|
39
|
+
tenant_id TEXT NOT NULL,
|
|
40
|
+
label TEXT NOT NULL DEFAULT '',
|
|
41
|
+
plan TEXT NOT NULL DEFAULT 'FREE',
|
|
42
|
+
created_at INTEGER NOT NULL,
|
|
43
|
+
last_used INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
revoked INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
profile_id TEXT
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_tenant_id ON tenant_keys(tenant_id);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS key_usage (
|
|
51
|
+
tenant_id TEXT PRIMARY KEY,
|
|
52
|
+
total_calls INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
cache_hits INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
cache_misses INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
tokens_saved INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
savings_usd REAL NOT NULL DEFAULT 0,
|
|
57
|
+
updated_at INTEGER NOT NULL DEFAULT 0
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
CREATE TABLE IF NOT EXISTS monthly_calls (
|
|
61
|
+
tenant_id TEXT NOT NULL,
|
|
62
|
+
month TEXT NOT NULL, -- YYYY-MM
|
|
63
|
+
call_count INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
PRIMARY KEY (tenant_id, month)
|
|
65
|
+
);
|
|
66
|
+
`);
|
|
67
|
+
// Add plan column to existing databases (idempotent)
|
|
68
|
+
try {
|
|
69
|
+
this.db.exec(`ALTER TABLE tenant_keys ADD COLUMN plan TEXT NOT NULL DEFAULT 'FREE'`);
|
|
70
|
+
}
|
|
71
|
+
catch { /* column already exists */ }
|
|
72
|
+
// profile_id: nullable, references profiles.id. NULL = use tenant's active
|
|
73
|
+
// profile. Per-token binding lands in PR 3 (device-pairing flow).
|
|
74
|
+
try {
|
|
75
|
+
this.db.exec(`ALTER TABLE tenant_keys ADD COLUMN profile_id TEXT`);
|
|
76
|
+
}
|
|
77
|
+
catch { /* column already exists */ }
|
|
78
|
+
}
|
|
79
|
+
// ── Issue ──────────────────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Mint a new tenant license key.
|
|
82
|
+
*
|
|
83
|
+
* `opts.tenant_id` lets the caller bind the key to a pre-existing tenant
|
|
84
|
+
* (e.g. 'founder' for the operator's own gateway, or a tenant resolved from
|
|
85
|
+
* an admin-approved device-flow). Default behavior — when omitted — derives
|
|
86
|
+
* a fresh tenant_id from the key hash so every issued key is its own tenant.
|
|
87
|
+
*
|
|
88
|
+
* `opts.profile_id` binds the key to a specific gateway profile at issuance
|
|
89
|
+
* (PR 4 — edge device pairing). When set, the auth layer surfaces this on
|
|
90
|
+
* req.auth.profile_id and the credential cascade uses that profile directly
|
|
91
|
+
* instead of falling back to tenant_active_profile.
|
|
92
|
+
*/
|
|
93
|
+
issueKey(label, plan = 'FREE', opts = {}) {
|
|
94
|
+
const raw = (0, crypto_1.randomBytes)(32).toString('hex');
|
|
95
|
+
const key = `synoi-sk-${raw}`;
|
|
96
|
+
const key_hash = hashKey(key);
|
|
97
|
+
const tenant_id = opts.tenant_id?.trim() || `t_${key_hash.slice(0, 16)}`;
|
|
98
|
+
const profile_id = opts.profile_id?.trim() || null;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
this.db.prepare(`
|
|
101
|
+
INSERT INTO tenant_keys (key_hash, tenant_id, label, plan, created_at, last_used, profile_id)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
103
|
+
`).run(key_hash, tenant_id, label, plan, now, now, profile_id);
|
|
104
|
+
this.db.prepare(`
|
|
105
|
+
INSERT OR IGNORE INTO key_usage (tenant_id, updated_at) VALUES (?, ?)
|
|
106
|
+
`).run(tenant_id, now);
|
|
107
|
+
return { key, tenant_id, profile_id };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Hydrate a tenant_keys row from a remote (control-plane) validation. Idempotent
|
|
111
|
+
* by key_hash — first call INSERTs, subsequent calls UPDATE the bookkeeping
|
|
112
|
+
* fields. Used by the auth middleware to cache CP-issued licenses on first
|
|
113
|
+
* sight so subsequent requests don't pay the network roundtrip.
|
|
114
|
+
*
|
|
115
|
+
* `revoked` is NOT cleared on upsert — once a key is locally revoked the only
|
|
116
|
+
* way back is an explicit issuance. CP-side revocations propagate via short
|
|
117
|
+
* cache TTLs + periodic re-validation; see [license-client.ts] for the
|
|
118
|
+
* eviction cadence.
|
|
119
|
+
*/
|
|
120
|
+
upsertFromRemote(key, fields) {
|
|
121
|
+
// Accept a gateway tenant key (synoi-sk-) or a control-plane license
|
|
122
|
+
// (synoi-lk-). The key is stored hashed and only used as an opaque
|
|
123
|
+
// tenant-resolution + cache-scope handle, so either prefix is valid here.
|
|
124
|
+
if (!key.startsWith('synoi-sk-') && !key.startsWith('synoi-lk-')) {
|
|
125
|
+
throw new Error('upsertFromRemote: not a synoi-sk-* or synoi-lk-* key');
|
|
126
|
+
}
|
|
127
|
+
const key_hash = hashKey(key);
|
|
128
|
+
const plan = fields.plan ?? 'FREE';
|
|
129
|
+
const profile_id = fields.profile_id ?? null;
|
|
130
|
+
const label = fields.label ?? 'remote-sync';
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
this.db.prepare(`
|
|
133
|
+
INSERT INTO tenant_keys (key_hash, tenant_id, label, plan, created_at, last_used, profile_id)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
135
|
+
ON CONFLICT(key_hash) DO UPDATE SET
|
|
136
|
+
tenant_id = excluded.tenant_id,
|
|
137
|
+
plan = excluded.plan,
|
|
138
|
+
profile_id = excluded.profile_id,
|
|
139
|
+
last_used = excluded.last_used
|
|
140
|
+
`).run(key_hash, fields.tenant_id, label, plan, now, now, profile_id);
|
|
141
|
+
this.db.prepare(`
|
|
142
|
+
INSERT OR IGNORE INTO key_usage (tenant_id, updated_at) VALUES (?, ?)
|
|
143
|
+
`).run(fields.tenant_id, now);
|
|
144
|
+
}
|
|
145
|
+
// ── Validate ───────────────────────────────────────────────────────────────
|
|
146
|
+
validate(key) {
|
|
147
|
+
if (!key.startsWith('synoi-sk-') && !key.startsWith('synoi-lk-'))
|
|
148
|
+
return null;
|
|
149
|
+
const key_hash = hashKey(key);
|
|
150
|
+
const row = this.db.prepare(`
|
|
151
|
+
SELECT tenant_id, label, plan, revoked, profile_id FROM tenant_keys WHERE key_hash = ?
|
|
152
|
+
`).get(key_hash);
|
|
153
|
+
if (!row || row.revoked)
|
|
154
|
+
return null;
|
|
155
|
+
this.db.prepare(`
|
|
156
|
+
UPDATE tenant_keys SET last_used = ? WHERE key_hash = ?
|
|
157
|
+
`).run(Date.now(), key_hash);
|
|
158
|
+
return { tenant_id: row.tenant_id, label: row.label, plan: row.plan, profile_id: row.profile_id ?? null };
|
|
159
|
+
}
|
|
160
|
+
// ── Revoke ─────────────────────────────────────────────────────────────────
|
|
161
|
+
revoke(key) {
|
|
162
|
+
const key_hash = hashKey(key);
|
|
163
|
+
const result = this.db.prepare(`
|
|
164
|
+
UPDATE tenant_keys SET revoked = 1 WHERE key_hash = ? AND revoked = 0
|
|
165
|
+
`).run(key_hash);
|
|
166
|
+
return result.changes > 0;
|
|
167
|
+
}
|
|
168
|
+
// ── Usage ──────────────────────────────────────────────────────────────────
|
|
169
|
+
recordCall(tenant_id, hit, tokens_saved = 0, savings_usd = 0) {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
this.db.prepare(`
|
|
172
|
+
INSERT INTO key_usage (tenant_id, total_calls, cache_hits, cache_misses, tokens_saved, savings_usd, updated_at)
|
|
173
|
+
VALUES (?, 1, ?, ?, ?, ?, ?)
|
|
174
|
+
ON CONFLICT(tenant_id) DO UPDATE SET
|
|
175
|
+
total_calls = total_calls + 1,
|
|
176
|
+
cache_hits = cache_hits + excluded.cache_hits,
|
|
177
|
+
cache_misses = cache_misses + excluded.cache_misses,
|
|
178
|
+
tokens_saved = tokens_saved + excluded.tokens_saved,
|
|
179
|
+
savings_usd = savings_usd + excluded.savings_usd,
|
|
180
|
+
updated_at = excluded.updated_at
|
|
181
|
+
`).run(tenant_id, hit ? 1 : 0, hit ? 0 : 1, tokens_saved, savings_usd, now);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Attribute token usage to a tenant WITHOUT incrementing the call counter.
|
|
185
|
+
* Used by the streaming path (M1): the request was already counted by
|
|
186
|
+
* recordCall at cache-miss time, but the token counts are only known once the
|
|
187
|
+
* stream ends. This folds those tokens into the metering totals without
|
|
188
|
+
* double-counting the call.
|
|
189
|
+
*/
|
|
190
|
+
recordStreamUsage(tenant_id, tokens, savings_usd = 0) {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
this.db.prepare(`
|
|
193
|
+
INSERT INTO key_usage (tenant_id, total_calls, cache_hits, cache_misses, tokens_saved, savings_usd, updated_at)
|
|
194
|
+
VALUES (?, 0, 0, 0, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(tenant_id) DO UPDATE SET
|
|
196
|
+
tokens_saved = tokens_saved + excluded.tokens_saved,
|
|
197
|
+
savings_usd = savings_usd + excluded.savings_usd,
|
|
198
|
+
updated_at = excluded.updated_at
|
|
199
|
+
`).run(tenant_id, Math.max(0, tokens), savings_usd, now);
|
|
200
|
+
}
|
|
201
|
+
getUsage(tenant_id) {
|
|
202
|
+
return this.db.prepare(`
|
|
203
|
+
SELECT * FROM key_usage WHERE tenant_id = ?
|
|
204
|
+
`).get(tenant_id);
|
|
205
|
+
}
|
|
206
|
+
// ── Plan management ────────────────────────────────────────────────────────
|
|
207
|
+
setPlan(tenant_id, plan) {
|
|
208
|
+
this.db.prepare(`UPDATE tenant_keys SET plan = ? WHERE tenant_id = ?`).run(plan, tenant_id);
|
|
209
|
+
}
|
|
210
|
+
getPlan(tenant_id) {
|
|
211
|
+
const row = this.db.prepare(`SELECT plan FROM tenant_keys WHERE tenant_id = ? LIMIT 1`).get(tenant_id);
|
|
212
|
+
return row?.plan ?? 'FREE';
|
|
213
|
+
}
|
|
214
|
+
// ── Monthly quota ──────────────────────────────────────────────────────────
|
|
215
|
+
_currentMonth() {
|
|
216
|
+
return new Date().toISOString().slice(0, 7); // YYYY-MM
|
|
217
|
+
}
|
|
218
|
+
getMonthlyCallCount(tenant_id) {
|
|
219
|
+
const month = this._currentMonth();
|
|
220
|
+
const row = this.db.prepare(`SELECT call_count FROM monthly_calls WHERE tenant_id = ? AND month = ?`).get(tenant_id, month);
|
|
221
|
+
return row?.call_count ?? 0;
|
|
222
|
+
}
|
|
223
|
+
incrementMonthlyCallCount(tenant_id) {
|
|
224
|
+
const month = this._currentMonth();
|
|
225
|
+
this.db.prepare(`
|
|
226
|
+
INSERT INTO monthly_calls (tenant_id, month, call_count) VALUES (?, ?, 1)
|
|
227
|
+
ON CONFLICT(tenant_id, month) DO UPDATE SET call_count = call_count + 1
|
|
228
|
+
`).run(tenant_id, month);
|
|
229
|
+
return this.getMonthlyCallCount(tenant_id);
|
|
230
|
+
}
|
|
231
|
+
isMonthlyQuotaExceeded(tenant_id, plan) {
|
|
232
|
+
const limit = exports.PLAN_CALL_LIMITS[plan];
|
|
233
|
+
return this.getMonthlyCallCount(tenant_id) >= limit;
|
|
234
|
+
}
|
|
235
|
+
listKeys(tenant_id) {
|
|
236
|
+
return this.db.prepare(`
|
|
237
|
+
SELECT key_hash, tenant_id, label, created_at, last_used, revoked
|
|
238
|
+
FROM tenant_keys WHERE tenant_id = ?
|
|
239
|
+
`).all(tenant_id);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
exports.KeyStore = KeyStore;
|
|
243
|
+
// ─── Singleton ────────────────────────────────────────────────────────────────
|
|
244
|
+
const DATA_DIR = process.env.SYNOI_DATA_DIR ?? path_1.default.join(os_1.default.homedir(), '.synoi', 'gateway');
|
|
245
|
+
const DB_PATH = path_1.default.join(DATA_DIR, '_keys', 'keys.db');
|
|
246
|
+
exports.keyStore = new KeyStore(DB_PATH);
|
|
247
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
248
|
+
function hashKey(key) {
|
|
249
|
+
return (0, crypto_1.createHash)('sha256').update(key).digest('hex');
|
|
250
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* native-mldsa.ts — ML-DSA-65 verification with a native node:crypto fast path.
|
|
4
|
+
*
|
|
5
|
+
* When the runtime ships OpenSSL 3.5+ (Node 24+), node:crypto exposes a native
|
|
6
|
+
* `ml-dsa-65` key type whose verify is ~7.8x faster than the pure-JS
|
|
7
|
+
* @noble/post-quantum implementation (~207us vs ~1.6ms; measured on Node
|
|
8
|
+
* 24.16.0 / OpenSSL 3.5.6). This helper feature-detects native support ONCE and
|
|
9
|
+
* routes the gateway's verify-bound paths (verify-router, skill-signature
|
|
10
|
+
* verify) accordingly, with a transparent @noble fallback on older runtimes.
|
|
11
|
+
*
|
|
12
|
+
* Both paths are byte-for-byte interoperable: a signature minted by either
|
|
13
|
+
* implementation verifies identically under the other (cross-impl KAT lives in
|
|
14
|
+
* @synoi/sraid test/mldsa-kat.test.ts). The native path is a transparent
|
|
15
|
+
* performance swap, NOT a behavior change. Node < 24 keeps working unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Verify-only: signing stays on @noble so the existing seed-derived key
|
|
18
|
+
* lifecycle is untouched. Takes a RAW 1952-byte ML-DSA-65 public key and a RAW
|
|
19
|
+
* signature, matching the @noble surface.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.isNativeMlDsaAvailable = isNativeMlDsaAvailable;
|
|
23
|
+
exports.verifyMlDsa65 = verifyMlDsa65;
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const ml_dsa_js_1 = require("@noble/post-quantum/ml-dsa.js");
|
|
26
|
+
// Fixed SubjectPublicKeyInfo DER prefix for ML-DSA-65 (id-ml-dsa-65 =
|
|
27
|
+
// 2.16.840.1.101.3.4.3.18); the only variable part is the 1952-byte key.
|
|
28
|
+
const MLDSA65_SPKI_PREFIX = Buffer.from('308207b2300b0609608648016503040312038207a100', 'hex');
|
|
29
|
+
const MLDSA65_PUBKEY_LEN = 1952;
|
|
30
|
+
let nativeAvailable;
|
|
31
|
+
function detectNative() {
|
|
32
|
+
if (nativeAvailable !== undefined)
|
|
33
|
+
return nativeAvailable;
|
|
34
|
+
try {
|
|
35
|
+
const dummy = Buffer.concat([MLDSA65_SPKI_PREFIX, Buffer.alloc(MLDSA65_PUBKEY_LEN)]);
|
|
36
|
+
const ko = (0, node_crypto_1.createPublicKey)({ key: dummy, format: 'der', type: 'spki' });
|
|
37
|
+
nativeAvailable = ko.asymmetricKeyType === 'ml-dsa-65';
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
nativeAvailable = false;
|
|
41
|
+
}
|
|
42
|
+
return nativeAvailable;
|
|
43
|
+
}
|
|
44
|
+
/** True when ML-DSA-65 verify runs on native node:crypto, false on @noble. */
|
|
45
|
+
function isNativeMlDsaAvailable() {
|
|
46
|
+
return detectNative();
|
|
47
|
+
}
|
|
48
|
+
const keyCache = new Map();
|
|
49
|
+
function keyObjectFromRaw(raw) {
|
|
50
|
+
if (raw.length !== MLDSA65_PUBKEY_LEN) {
|
|
51
|
+
throw new Error(`ml-dsa-65 public key must be ${MLDSA65_PUBKEY_LEN} bytes`);
|
|
52
|
+
}
|
|
53
|
+
const hex = Buffer.from(raw).toString('hex');
|
|
54
|
+
let ko = keyCache.get(hex);
|
|
55
|
+
if (!ko) {
|
|
56
|
+
const der = Buffer.concat([MLDSA65_SPKI_PREFIX, Buffer.from(raw)]);
|
|
57
|
+
ko = (0, node_crypto_1.createPublicKey)({ key: der, format: 'der', type: 'spki' });
|
|
58
|
+
keyCache.set(hex, ko);
|
|
59
|
+
}
|
|
60
|
+
return ko;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Verify a raw ML-DSA-65 signature over `message` against a raw 1952-byte
|
|
64
|
+
* public key. Native node:crypto when available, otherwise @noble. Returns
|
|
65
|
+
* false (never throws) on any malformed input or verification failure.
|
|
66
|
+
*/
|
|
67
|
+
function verifyMlDsa65(signature, message, publicKeyRaw) {
|
|
68
|
+
if (detectNative()) {
|
|
69
|
+
try {
|
|
70
|
+
return (0, node_crypto_1.verify)(null, message, keyObjectFromRaw(publicKeyRaw), signature);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
return ml_dsa_js_1.ml_dsa65.verify(signature, message, publicKeyRaw);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|