@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,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
+ }