@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,466 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* gap/operator-enrollment.ts - operator identity enrollment record + auth helper.
|
|
4
|
+
*
|
|
5
|
+
* ADR_015 Fix #1: closes Attack A by requiring that the operator OID be
|
|
6
|
+
* enrolled via a signed-and-sealed record before any HITL decision is accepted.
|
|
7
|
+
*
|
|
8
|
+
* No em dashes. No AI attribution.
|
|
9
|
+
*/
|
|
10
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
12
|
+
};
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.EnrollmentIntegrityError = exports.OperatorNotEnrolledForTenantError = exports.OperatorNotEnrolledError = exports.LITE_ENROLLMENT_PAYLOAD_TYPE = exports.ENROLLMENT_PAYLOAD_TYPE = void 0;
|
|
15
|
+
exports.getEnrollmentPath = getEnrollmentPath;
|
|
16
|
+
exports.verifyDsseEnrollment = verifyDsseEnrollment;
|
|
17
|
+
exports._testResetEnrollmentCache = _testResetEnrollmentCache;
|
|
18
|
+
exports.loadEnrollmentRecord = loadEnrollmentRecord;
|
|
19
|
+
exports.saveEnrollmentRecord = saveEnrollmentRecord;
|
|
20
|
+
exports.enrollOperator = enrollOperator;
|
|
21
|
+
exports.assertEnrolledOperatorAuth = assertEnrolledOperatorAuth;
|
|
22
|
+
exports.assertEnrolledOperator = assertEnrolledOperator;
|
|
23
|
+
exports._testClearEnrollment = _testClearEnrollment;
|
|
24
|
+
exports._testEnrollOperatorForTest = _testEnrollOperatorForTest;
|
|
25
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
27
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
29
|
+
const ml_dsa_js_1 = require("@noble/post-quantum/ml-dsa.js");
|
|
30
|
+
const ed25519_1 = require("@noble/curves/ed25519");
|
|
31
|
+
const verify_router_1 = require("../verify-router");
|
|
32
|
+
const key_provider_1 = require("../key-provider");
|
|
33
|
+
const sraid_1 = require("@synoi/sraid");
|
|
34
|
+
const lite_mode_1 = require("./lite-mode");
|
|
35
|
+
const lite_signing_key_1 = require("./lite-signing-key");
|
|
36
|
+
exports.ENROLLMENT_PAYLOAD_TYPE = 'application/vnd.synoi.operator-enrollment+json';
|
|
37
|
+
/**
|
|
38
|
+
* Security F1 (2026-07-12 quality gate, SEVERE, ship-block): the lite
|
|
39
|
+
* enrollment seal tier. The full-gateway tier above (ENROLLMENT_PAYLOAD_TYPE,
|
|
40
|
+
* verifyDsseEnrollment below) seals with the gateway's signing bundle
|
|
41
|
+
* (verify-router.ts currentBundle()), which in production is a persisted,
|
|
42
|
+
* managed key -- correct there. In lite mode that same bundle is, by design,
|
|
43
|
+
* the SYNOI_ALLOW_EPHEMERAL_KEYS fallback: a fresh Ed25519+ML-DSA-65 pair
|
|
44
|
+
* generated in-process on EVERY boot and never written to disk. Sealing the
|
|
45
|
+
* enrollment record with it meant the seal was byte-correct at write time but
|
|
46
|
+
* unverifiable forever after, because the NEXT boot's key history starts
|
|
47
|
+
* empty and gets a DIFFERENT fresh bundle -- never the one that signed the
|
|
48
|
+
* file. loadEnrollmentRecord() then returns null, the operator reads
|
|
49
|
+
* not-enrolled, and a subsequent enrollOperator() call starts
|
|
50
|
+
* enrollment_seq back at 1 (the "already enrolled" guard and the audit
|
|
51
|
+
* trail's monotonic sequence both silently reset).
|
|
52
|
+
*
|
|
53
|
+
* FIX: in lite mode, seal with the PERSISTED operator key
|
|
54
|
+
* (lite-signing-key.ts), Ed25519-only, verified against the persisted lite
|
|
55
|
+
* public key -- the SAME key that survives a restart by construction (it is
|
|
56
|
+
* generated once and read from disk on every later boot). This removes
|
|
57
|
+
* lite's enrollment-seal dependence on SYNOI_ALLOW_EPHEMERAL_KEYS /
|
|
58
|
+
* initSigningKeys() entirely: loadEnrollmentRecord() for a lite-sealed
|
|
59
|
+
* record never touches the gateway key history.
|
|
60
|
+
*
|
|
61
|
+
* Dispatch is by the record's OWN attestation.payloadType, not by the
|
|
62
|
+
* CURRENT process's lite-mode flag: a lite-sealed record verifies correctly
|
|
63
|
+
* via the lite path regardless of what mode happens to be active when it is
|
|
64
|
+
* loaded (there is no legitimate cross-tier record today, but self-describing
|
|
65
|
+
* dispatch is strictly more robust than trusting ambient state).
|
|
66
|
+
*
|
|
67
|
+
* Uses @synoi/sraid's own `pae()` (Pre-Authentication Encoding) rather than
|
|
68
|
+
* hand-rolling a second copy of the DSSE prefix construction the way
|
|
69
|
+
* verifyDsseEnrollment below does (pre-existing code, out of scope for this
|
|
70
|
+
* fix): one canonical PAE implementation for new code.
|
|
71
|
+
*/
|
|
72
|
+
exports.LITE_ENROLLMENT_PAYLOAD_TYPE = 'application/vnd.synoi.operator-enrollment.lite+json';
|
|
73
|
+
function signLiteEnrollmentSeal(payload) {
|
|
74
|
+
const key = (0, lite_signing_key_1.loadOrCreateLiteSigningKey)();
|
|
75
|
+
const paeBytes = (0, sraid_1.pae)(exports.LITE_ENROLLMENT_PAYLOAD_TYPE, payload);
|
|
76
|
+
const sigBytes = ed25519_1.ed25519.sign(paeBytes, key.privateKey);
|
|
77
|
+
return {
|
|
78
|
+
payloadType: exports.LITE_ENROLLMENT_PAYLOAD_TYPE,
|
|
79
|
+
payload,
|
|
80
|
+
signatures: [{ alg: 'ed25519', sig: Buffer.from(sigBytes).toString('base64') }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Verify a lite-tier enrollment seal against the PERSISTED operator key.
|
|
85
|
+
* Unlike verifyDsseEnrollment (which walks the gateway's in-process key
|
|
86
|
+
* history), this checks against exactly one key: whatever
|
|
87
|
+
* loadOrCreateLiteSigningKey() currently loads from disk. That call is
|
|
88
|
+
* itself idempotent and restart-stable (same file, same key, every boot),
|
|
89
|
+
* which is the whole point of this fix. Never throws; returns false on any
|
|
90
|
+
* failure (malformed signature, wrong payloadType, key-load failure).
|
|
91
|
+
*/
|
|
92
|
+
function verifyLiteEnrollmentSeal(attestation, payload) {
|
|
93
|
+
if (attestation.payloadType !== exports.LITE_ENROLLMENT_PAYLOAD_TYPE)
|
|
94
|
+
return false;
|
|
95
|
+
const edSig = attestation.signatures.find(s => s.alg === 'ed25519');
|
|
96
|
+
if (edSig === undefined)
|
|
97
|
+
return false;
|
|
98
|
+
try {
|
|
99
|
+
const key = (0, lite_signing_key_1.loadOrCreateLiteSigningKey)();
|
|
100
|
+
const paeBytes = (0, sraid_1.pae)(attestation.payloadType, payload);
|
|
101
|
+
return ed25519_1.ed25519.verify(Buffer.from(edSig.sig, 'base64'), paeBytes, key.publicKey);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
class OperatorNotEnrolledError extends Error {
|
|
108
|
+
code;
|
|
109
|
+
constructor(code, message) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = 'OperatorNotEnrolledError';
|
|
112
|
+
this.code = code;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.OperatorNotEnrolledError = OperatorNotEnrolledError;
|
|
116
|
+
/**
|
|
117
|
+
* Thrown when the operator IS enrolled but NOT for the requested tenant
|
|
118
|
+
* (ADR_015 Section 10.2.A). Distinct from OperatorNotEnrolledError so the
|
|
119
|
+
* failure mode is observable at the router level.
|
|
120
|
+
*/
|
|
121
|
+
class OperatorNotEnrolledForTenantError extends Error {
|
|
122
|
+
code;
|
|
123
|
+
constructor(message) {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = 'OperatorNotEnrolledForTenantError';
|
|
126
|
+
this.code = 'operator_not_enrolled_for_tenant';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
exports.OperatorNotEnrolledForTenantError = OperatorNotEnrolledForTenantError;
|
|
130
|
+
class EnrollmentIntegrityError extends Error {
|
|
131
|
+
constructor(message) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = 'EnrollmentIntegrityError';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.EnrollmentIntegrityError = EnrollmentIntegrityError;
|
|
137
|
+
function getEnrollmentPath() {
|
|
138
|
+
return node_path_1.default.join(process.env['SYNOI_DATA_DIR'] ?? node_path_1.default.join(node_os_1.default.homedir(), '.synoi'), 'operator-enrollment.json');
|
|
139
|
+
}
|
|
140
|
+
// The enrollment-seal content-core is canonicalized with the SINGLE @synoi/sraid
|
|
141
|
+
// serializer (RFC 8785 JCS). The previous local `canonicalize` copy was
|
|
142
|
+
// byte-identical to @synoi/sraid for the seal content-core (schema_version,
|
|
143
|
+
// enrolled_operators[], enrollment_seq integer, updated_at) across all in-use
|
|
144
|
+
// records; removing it leaves exactly one canonicalization path.
|
|
145
|
+
/**
|
|
146
|
+
* Verify a DSSE enrollment attestation. Reconstructs PAE from the attestation
|
|
147
|
+
* and checks Ed25519 + ML-DSA-65 over those PAE bytes, against every key in history.
|
|
148
|
+
* Returns true only if both algorithms verify with the SAME bundle.
|
|
149
|
+
*/
|
|
150
|
+
function verifyDsseEnrollment(attestation, payload) {
|
|
151
|
+
const enc = new TextEncoder();
|
|
152
|
+
const typeBytes = enc.encode(attestation.payloadType);
|
|
153
|
+
const bodyBytes = enc.encode(payload);
|
|
154
|
+
const prefix = enc.encode(`DSSEv1 ${typeBytes.length} ${attestation.payloadType} ${bodyBytes.length} `);
|
|
155
|
+
const paeBytes = new Uint8Array(prefix.length + bodyBytes.length);
|
|
156
|
+
paeBytes.set(prefix, 0);
|
|
157
|
+
paeBytes.set(bodyBytes, prefix.length);
|
|
158
|
+
const edSig = attestation.signatures.find(s => s.alg === 'ed25519');
|
|
159
|
+
const mlSig = attestation.signatures.find(s => s.alg === 'ml-dsa-65');
|
|
160
|
+
if (!edSig || !mlSig)
|
|
161
|
+
return false;
|
|
162
|
+
const edSigBytes = Buffer.from(edSig.sig, 'base64');
|
|
163
|
+
const mlSigBytes = Buffer.from(mlSig.sig, 'base64');
|
|
164
|
+
for (const bundle of (0, key_provider_1.getKeyHistory)()) {
|
|
165
|
+
let edOk = false;
|
|
166
|
+
let mlOk = false;
|
|
167
|
+
try {
|
|
168
|
+
const keyObj = (0, node_crypto_1.createPublicKey)({ key: bundle.ed25519.publicKeyPem, format: 'pem' });
|
|
169
|
+
edOk = (0, node_crypto_1.verify)(null, Buffer.from(paeBytes), keyObj, edSigBytes);
|
|
170
|
+
}
|
|
171
|
+
catch { /* fail-closed */ }
|
|
172
|
+
try {
|
|
173
|
+
const { publicKey: mlPub } = ml_dsa_js_1.ml_dsa65.keygen(bundle.mlDsa.seed);
|
|
174
|
+
mlOk = ml_dsa_js_1.ml_dsa65.verify(mlSigBytes, paeBytes, mlPub);
|
|
175
|
+
}
|
|
176
|
+
catch { /* fail-closed */ }
|
|
177
|
+
if (edOk && mlOk)
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
let _enrollmentCache = null;
|
|
183
|
+
/** TEST ONLY: drop the verified-enrollment cache. */
|
|
184
|
+
function _testResetEnrollmentCache() {
|
|
185
|
+
_enrollmentCache = null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Load the enrollment record from disk, verify the DSSE seal.
|
|
189
|
+
* Returns null on ANY failure (missing file, parse error, seal failure, schema mismatch).
|
|
190
|
+
* NEVER throws. NEVER fails-open.
|
|
191
|
+
*/
|
|
192
|
+
function loadEnrollmentRecord() {
|
|
193
|
+
try {
|
|
194
|
+
const p = getEnrollmentPath();
|
|
195
|
+
if (!node_fs_1.default.existsSync(p)) {
|
|
196
|
+
_enrollmentCache = null;
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
// [2] mtime+size cache: skip the ML-DSA seal re-verify when the file is unchanged.
|
|
200
|
+
const st = node_fs_1.default.statSync(p);
|
|
201
|
+
if (_enrollmentCache &&
|
|
202
|
+
_enrollmentCache.mtimeMs === st.mtimeMs &&
|
|
203
|
+
_enrollmentCache.size === st.size) {
|
|
204
|
+
return _enrollmentCache.record;
|
|
205
|
+
}
|
|
206
|
+
const raw = node_fs_1.default.readFileSync(p, 'utf8');
|
|
207
|
+
const record = JSON.parse(raw);
|
|
208
|
+
if (record.schema_version !== 'operator-enrollment/1') {
|
|
209
|
+
process.stderr.write('[enrollment] schema_version mismatch\n');
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
// Reconstruct the content core (everything except attestation) and verify.
|
|
213
|
+
const contentCore = {
|
|
214
|
+
schema_version: record.schema_version,
|
|
215
|
+
enrolled_operators: record.enrolled_operators,
|
|
216
|
+
enrollment_seq: record.enrollment_seq,
|
|
217
|
+
updated_at: record.updated_at,
|
|
218
|
+
};
|
|
219
|
+
const payload = (0, sraid_1.canonicalize)(contentCore);
|
|
220
|
+
// Dispatch by the record's OWN payloadType (self-describing), not by the
|
|
221
|
+
// current process's lite-mode flag -- see the LITE_ENROLLMENT_PAYLOAD_TYPE
|
|
222
|
+
// doc comment above (Security F1 fix).
|
|
223
|
+
const verified = record.attestation.payloadType === exports.LITE_ENROLLMENT_PAYLOAD_TYPE
|
|
224
|
+
? verifyLiteEnrollmentSeal(record.attestation, payload)
|
|
225
|
+
: verifyDsseEnrollment(record.attestation, payload);
|
|
226
|
+
if (!verified) {
|
|
227
|
+
process.stderr.write('[enrollment] attestation verification failed\n');
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// Cache only the VERIFIED record, keyed by the exact file identity.
|
|
231
|
+
_enrollmentCache = { mtimeMs: st.mtimeMs, size: st.size, record };
|
|
232
|
+
return record;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
process.stderr.write(`[enrollment] load failed: ${e.message}\n`);
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Build, seal, and atomically write the enrollment record.
|
|
241
|
+
*/
|
|
242
|
+
function saveEnrollmentRecord(operators) {
|
|
243
|
+
const schema_version = 'operator-enrollment/1';
|
|
244
|
+
const enrollment_seq = (loadEnrollmentRecord()?.enrollment_seq ?? 0) + 1;
|
|
245
|
+
const updated_at = new Date().toISOString();
|
|
246
|
+
const contentCore = { schema_version, enrolled_operators: operators, enrollment_seq, updated_at };
|
|
247
|
+
const payload = (0, sraid_1.canonicalize)(contentCore);
|
|
248
|
+
// Security F1 fix: lite mode seals with the persisted operator key (which
|
|
249
|
+
// survives a restart by construction), never the gateway's ephemeral
|
|
250
|
+
// per-boot bundle. The full gateway's existing hybrid-DSSE tier is
|
|
251
|
+
// unchanged.
|
|
252
|
+
const attestation = (0, lite_mode_1.isLiteMode)()
|
|
253
|
+
? signLiteEnrollmentSeal(payload)
|
|
254
|
+
: (0, verify_router_1.signDssePayload)(exports.ENROLLMENT_PAYLOAD_TYPE, payload);
|
|
255
|
+
const record = {
|
|
256
|
+
schema_version,
|
|
257
|
+
enrolled_operators: operators,
|
|
258
|
+
enrollment_seq,
|
|
259
|
+
updated_at,
|
|
260
|
+
attestation,
|
|
261
|
+
};
|
|
262
|
+
const p = getEnrollmentPath();
|
|
263
|
+
const tmp = p + '.tmp';
|
|
264
|
+
node_fs_1.default.writeFileSync(tmp, JSON.stringify(record, null, 2), 'utf8');
|
|
265
|
+
node_fs_1.default.renameSync(tmp, p);
|
|
266
|
+
// [2] Invalidate the verified-record cache explicitly: a fresh write normally
|
|
267
|
+
// bumps mtime/size, but a rename within the same coarse mtime tick with an equal
|
|
268
|
+
// size could otherwise read stale. Force the next load to re-verify.
|
|
269
|
+
_enrollmentCache = null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Enroll a new operator, scoped to a specific tenant (ADR_015 Section 10.2.A).
|
|
273
|
+
*
|
|
274
|
+
* Signature: enrollOperator(tenant_id, pubkeyHex, deviceLabel, opts?)
|
|
275
|
+
* Default source: 'first-run-local'.
|
|
276
|
+
*
|
|
277
|
+
* Guard behavior (per Section 10.4):
|
|
278
|
+
* - For non-demo sources ('first-run-local' | 'magic-link'): refuses if an
|
|
279
|
+
* active non-demo entry already exists FOR THAT tenant_id.
|
|
280
|
+
* - For 'demo-session' source: idempotent -- may (re)enroll the same demo
|
|
281
|
+
* tenant freely without triggering the guard.
|
|
282
|
+
*
|
|
283
|
+
* The demo route must call with source 'demo-session' and the demo tenant_id only.
|
|
284
|
+
* Production-trusted enrollment must only be called over the loopback /local surface.
|
|
285
|
+
*/
|
|
286
|
+
function enrollOperator(tenant_id, pubkeyHex, deviceLabel, opts) {
|
|
287
|
+
const source = opts?.source ?? 'first-run-local';
|
|
288
|
+
const existing = loadEnrollmentRecord();
|
|
289
|
+
const prior = existing?.enrolled_operators ?? [];
|
|
290
|
+
if (source !== 'demo-session') {
|
|
291
|
+
// Production sources: refuse if an active non-demo entry already exists for THIS tenant.
|
|
292
|
+
const conflict = prior.find(e => e.tenant_id === tenant_id &&
|
|
293
|
+
e.status === 'active' &&
|
|
294
|
+
e.enrollment_source !== 'demo-session');
|
|
295
|
+
if (conflict) {
|
|
296
|
+
throw new Error('operator already enrolled; use re-enrollment ceremony to change');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Demo-session: idempotent on the same key -- deduplicate below.
|
|
300
|
+
const pubBytes = Buffer.from(pubkeyHex, 'hex');
|
|
301
|
+
const operator_oid = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
|
|
302
|
+
const entry = {
|
|
303
|
+
operator_oid,
|
|
304
|
+
operator_pubkey: pubkeyHex,
|
|
305
|
+
tenant_id,
|
|
306
|
+
enrollment_source: source,
|
|
307
|
+
device_label: deviceLabel,
|
|
308
|
+
enrolled_at: new Date().toISOString(),
|
|
309
|
+
status: 'active',
|
|
310
|
+
};
|
|
311
|
+
// Append; deduplicate by (tenant_id, operator_oid, enrollment_source) -- last write wins.
|
|
312
|
+
const merged = [
|
|
313
|
+
...prior.filter(e => !(e.tenant_id === tenant_id && e.operator_oid === operator_oid && e.enrollment_source === source)),
|
|
314
|
+
entry,
|
|
315
|
+
];
|
|
316
|
+
saveEnrollmentRecord(merged);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* assertEnrolledOperatorAuth - steps 1-3 only (OID-binding + tenant-scoped
|
|
320
|
+
* enrolled-membership + Ed25519 signature). Does NOT check a nonce (step 4).
|
|
321
|
+
*
|
|
322
|
+
* Factored from assertEnrolledOperator per ADR_016 Section 4.5 so that both
|
|
323
|
+
* GET /local/decide-challenge (steps 1-3) and POST /local/decide (steps 1-4)
|
|
324
|
+
* share the same membership+signature core with no divergent comparator.
|
|
325
|
+
*
|
|
326
|
+
* Throws EnrollmentIntegrityError, OperatorNotEnrolledError, or
|
|
327
|
+
* OperatorNotEnrolledForTenantError on any failure.
|
|
328
|
+
*
|
|
329
|
+
* @param tenant_id The tenant the operator must be enrolled for.
|
|
330
|
+
* @param operator_oid Caller-declared OID; checked against sha256(pubkey).
|
|
331
|
+
* @param operator_pubkey_hex Raw 32-byte Ed25519 public key as 64-char lc hex.
|
|
332
|
+
* @param signature Ed25519 sig over canonical_signed_payload, 128-char lc hex.
|
|
333
|
+
* @param canonical_signed_payload Exact canonical string the caller signed.
|
|
334
|
+
*/
|
|
335
|
+
function assertEnrolledOperatorAuth(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload) {
|
|
336
|
+
// Step 1: OID-binding
|
|
337
|
+
try {
|
|
338
|
+
const pubBytes = Buffer.from(operator_pubkey_hex, 'hex');
|
|
339
|
+
const computed = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
|
|
340
|
+
if (computed !== operator_oid) {
|
|
341
|
+
throw new EnrollmentIntegrityError('oid_binding_mismatch');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
if (e instanceof EnrollmentIntegrityError)
|
|
346
|
+
throw e;
|
|
347
|
+
throw new EnrollmentIntegrityError('oid_binding_mismatch');
|
|
348
|
+
}
|
|
349
|
+
// Step 2: Enrolled-membership (tenant-scoped, source-gated per Section 10.2.A + 10.2.C).
|
|
350
|
+
const record = loadEnrollmentRecord();
|
|
351
|
+
if (record === null) {
|
|
352
|
+
throw new OperatorNotEnrolledError('enrollment_missing', 'no enrollment record found or record is corrupt');
|
|
353
|
+
}
|
|
354
|
+
const anyActiveEntry = record.enrolled_operators.find(e => e.operator_oid === operator_oid && e.status === 'active');
|
|
355
|
+
if (!anyActiveEntry) {
|
|
356
|
+
throw new OperatorNotEnrolledError('not_enrolled', `operator ${operator_oid} is not in the active enrolled set`);
|
|
357
|
+
}
|
|
358
|
+
const isDemoTenant = tenant_id.startsWith('tenant:demo-') && !tenant_id.startsWith('tenant:demo-mem-');
|
|
359
|
+
const tenantEntry = record.enrolled_operators.find(e => e.operator_oid === operator_oid &&
|
|
360
|
+
e.status === 'active' &&
|
|
361
|
+
e.tenant_id === tenant_id &&
|
|
362
|
+
(isDemoTenant || e.enrollment_source !== 'demo-session'));
|
|
363
|
+
if (!tenantEntry) {
|
|
364
|
+
throw new OperatorNotEnrolledForTenantError(`operator ${operator_oid} is not enrolled for tenant ${tenant_id} with a production-trusted source`);
|
|
365
|
+
}
|
|
366
|
+
// Step 3: Signature
|
|
367
|
+
try {
|
|
368
|
+
const sigBytes = Buffer.from(signature, 'hex');
|
|
369
|
+
const msgBytes = Buffer.from(canonical_signed_payload, 'utf8');
|
|
370
|
+
const pubBytes = Buffer.from(operator_pubkey_hex, 'hex');
|
|
371
|
+
const valid = ed25519_1.ed25519.verify(sigBytes, msgBytes, pubBytes);
|
|
372
|
+
if (!valid) {
|
|
373
|
+
throw new EnrollmentIntegrityError('signature_invalid');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
if (e instanceof EnrollmentIntegrityError)
|
|
378
|
+
throw e;
|
|
379
|
+
throw new EnrollmentIntegrityError('signature_invalid');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Validate that an operator is enrolled for the given tenant and their request is authentic.
|
|
384
|
+
* Throws EnrollmentIntegrityError, OperatorNotEnrolledError, or
|
|
385
|
+
* OperatorNotEnrolledForTenantError on any failure.
|
|
386
|
+
*
|
|
387
|
+
* Steps (in order, ADR_015 Sections 3 + 10.2.A):
|
|
388
|
+
* 1. OID-binding: sha256(pubkeyBytes) == operator_oid
|
|
389
|
+
* 2. Enrolled-membership (tenant-scoped): record exists + active entry for
|
|
390
|
+
* (operator_oid, tenant_id) with enrollment_source !== 'demo-session'.
|
|
391
|
+
* Entries lacking tenant_id are treated as not-enrolled-for-any-tenant
|
|
392
|
+
* (fail-CLOSED migration per Section 10.3).
|
|
393
|
+
* 3. Signature: Ed25519 sig over canonical_signed_payload with operator_pubkey_hex
|
|
394
|
+
* 4. Freshness: stored_nonce === challenge_nonce
|
|
395
|
+
*
|
|
396
|
+
* The new first arg tenant_id is the tenant the action authorizes against.
|
|
397
|
+
* Steps 1/3/4 are UNCHANGED from Sections 1-9.
|
|
398
|
+
*
|
|
399
|
+
* Delegates steps 1-3 to assertEnrolledOperatorAuth (ADR_016 Section 4.5).
|
|
400
|
+
*/
|
|
401
|
+
function assertEnrolledOperator(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload, challenge_nonce, stored_nonce) {
|
|
402
|
+
// Steps 1-3: delegate to the shared auth helper (ADR_016 Section 4.5).
|
|
403
|
+
assertEnrolledOperatorAuth(tenant_id, operator_oid, operator_pubkey_hex, signature, canonical_signed_payload);
|
|
404
|
+
// Step 4: Freshness
|
|
405
|
+
if (stored_nonce === undefined) {
|
|
406
|
+
throw new EnrollmentIntegrityError('nonce_missing');
|
|
407
|
+
}
|
|
408
|
+
if (stored_nonce !== challenge_nonce) {
|
|
409
|
+
throw new EnrollmentIntegrityError('stale_or_replayed');
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// -- TEST-ONLY helpers ---------------------------------------------------------
|
|
413
|
+
/** TEST ONLY: delete the enrollment file if it exists. */
|
|
414
|
+
function _testClearEnrollment() {
|
|
415
|
+
// Hard production stop (re-panel F2, 2026-06-22): same pattern as
|
|
416
|
+
// __setSigningBundleForTest in verify-router.ts:179. NODE_ENV=production
|
|
417
|
+
// is a hard wall regardless of SYNOI_ALLOW_EPHEMERAL_KEYS.
|
|
418
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
419
|
+
throw new Error('[operator-enrollment] _testClearEnrollment is TEST-ONLY and must not be ' +
|
|
420
|
+
"called in production (NODE_ENV === 'production').");
|
|
421
|
+
}
|
|
422
|
+
if (process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] !== 'true') {
|
|
423
|
+
throw new Error('_testClearEnrollment is TEST-ONLY; set SYNOI_ALLOW_EPHEMERAL_KEYS=true');
|
|
424
|
+
}
|
|
425
|
+
const p = getEnrollmentPath();
|
|
426
|
+
if (node_fs_1.default.existsSync(p))
|
|
427
|
+
node_fs_1.default.unlinkSync(p);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* TEST ONLY: enroll without the "already enrolled" guard.
|
|
431
|
+
* Now requires tenant_id and accepts optional source (ADR_015 Section 10.4).
|
|
432
|
+
* The NODE_ENV=production hard-wall (re-panel F2, 2026-06-22) is UNCHANGED.
|
|
433
|
+
*
|
|
434
|
+
* Callers that previously passed only pubkeyHex must be updated to supply
|
|
435
|
+
* tenant_id (and optionally source). Default source is 'first-run-local'.
|
|
436
|
+
*/
|
|
437
|
+
function _testEnrollOperatorForTest(pubkeyHex, tenant_id = 'tenant:test', source = 'first-run-local') {
|
|
438
|
+
// Hard production stop (re-panel F2, 2026-06-22).
|
|
439
|
+
if (process.env['NODE_ENV'] === 'production') {
|
|
440
|
+
throw new Error('[operator-enrollment] _testEnrollOperatorForTest is TEST-ONLY and must not be ' +
|
|
441
|
+
"called in production (NODE_ENV === 'production').");
|
|
442
|
+
}
|
|
443
|
+
if (process.env['SYNOI_ALLOW_EPHEMERAL_KEYS'] !== 'true') {
|
|
444
|
+
throw new Error('_testEnrollOperatorForTest is TEST-ONLY; set SYNOI_ALLOW_EPHEMERAL_KEYS=true');
|
|
445
|
+
}
|
|
446
|
+
const pubBytes = Buffer.from(pubkeyHex, 'hex');
|
|
447
|
+
const operator_oid = 'oid-' + (0, node_crypto_1.createHash)('sha256').update(pubBytes).digest('hex');
|
|
448
|
+
const entry = {
|
|
449
|
+
operator_oid,
|
|
450
|
+
operator_pubkey: pubkeyHex,
|
|
451
|
+
tenant_id,
|
|
452
|
+
enrollment_source: source,
|
|
453
|
+
device_label: 'test-device',
|
|
454
|
+
enrolled_at: new Date().toISOString(),
|
|
455
|
+
status: 'active',
|
|
456
|
+
};
|
|
457
|
+
// Append to any existing test entries (do not wipe operators enrolled earlier
|
|
458
|
+
// in the same test run). Deduplicate by (tenant_id, operator_oid, enrollment_source).
|
|
459
|
+
const existing = loadEnrollmentRecord();
|
|
460
|
+
const prior = existing?.enrolled_operators ?? [];
|
|
461
|
+
const merged = [
|
|
462
|
+
...prior.filter(e => !(e.tenant_id === tenant_id && e.operator_oid === operator_oid && e.enrollment_source === source)),
|
|
463
|
+
entry,
|
|
464
|
+
];
|
|
465
|
+
saveEnrollmentRecord(merged);
|
|
466
|
+
}
|