@mmmbuto/nexuscrew 0.9.20 → 0.9.21
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/CHANGELOG.md +18 -0
- package/frontend/dist/assets/{index-VdGL6XJ8.js → index-D5snoCdN.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/audio/routes.js +27 -0
- package/lib/cells/routes.js +30 -2
- package/lib/cli/commands.js +11 -1
- package/lib/files/routes.js +31 -4
- package/lib/fleet/builtin.js +26 -4
- package/lib/fleet/cell-exec.js +251 -7
- package/lib/fleet/cell-lease-server.js +161 -6
- package/lib/fleet/identity-authority.js +367 -0
- package/lib/fleet/identity-transport.js +68 -0
- package/lib/fleet/launch-broker.js +26 -1
- package/lib/fleet/lease-client.js +168 -1
- package/lib/fleet/lease-routes.js +96 -24
- package/lib/fleet/managed.js +13 -0
- package/lib/fleet/runtime.js +24 -1
- package/lib/identity/binding-guard.js +128 -0
- package/lib/live-host/bridge.js +69 -0
- package/lib/live-host/routes.js +9 -5
- package/lib/mcp/identity-provider.js +132 -0
- package/lib/mcp/identity-schema.js +168 -0
- package/lib/mcp/server.js +123 -6
- package/lib/mcp/tools.js +38 -6
- package/lib/notify/asks.js +1 -0
- package/lib/notify/routes.js +29 -1
- package/lib/server.js +24 -4
- package/package.json +1 -1
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// Side effect isolati e iniettabili (seams) per testabilita', come altrove nel
|
|
31
31
|
// fleet (cell-exec.js, launch-broker.js). Protocollo sul socket lease:
|
|
32
32
|
// line-oriented JSON, un messaggio per riga.
|
|
33
|
+
// supervisor -> server: {"type":"generation","generation":..} (R1)
|
|
33
34
|
// supervisor -> server: {"type":"refresh"}
|
|
34
35
|
// supervisor -> server: {"type":"reconnect","generation":..,"proof":{..}}
|
|
35
36
|
// server -> supervisor: {"type":"lease","leaseId":..,"proof":{..}}
|
|
@@ -42,6 +43,7 @@ const crypto = require('node:crypto');
|
|
|
42
43
|
const L = require('./cell-lease.js');
|
|
43
44
|
const { runtimeDir, ensureRuntimeDir } = require('./launch-broker.js');
|
|
44
45
|
const { loadOrCreateVerifier, signProof, verifyProof, PROOF_TTL_MS } = require('./lease-verifier.js');
|
|
46
|
+
const { validDaemonChallenge } = require('./lease-client.js');
|
|
45
47
|
|
|
46
48
|
function sanitizeCell(cellId) {
|
|
47
49
|
return String(cellId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
|
|
@@ -61,6 +63,7 @@ const EPOCH_RE = /^[a-f0-9]{16}$/;
|
|
|
61
63
|
// ancora scaduto puo' ripresentarsi (la firma e l'expiry restano il gate).
|
|
62
64
|
// Garanzia piu' forte (single-use cross-restart) NON promessa dal contratto.
|
|
63
65
|
const JTI_CAP = 4096;
|
|
66
|
+
const CHALLENGE_FRAME_LIMIT = 8 * 1024;
|
|
64
67
|
|
|
65
68
|
function createLeaseManager(cfg = {}, seams = {}) {
|
|
66
69
|
const dir = runtimeDir(cfg);
|
|
@@ -71,6 +74,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
71
74
|
const netImpl = seams.net || net;
|
|
72
75
|
const fsImpl = seams.fs || fs;
|
|
73
76
|
const log = typeof cfg.log === 'function' ? cfg.log : () => {};
|
|
77
|
+
const identityAuthority = cfg.identityAuthority || null;
|
|
74
78
|
|
|
75
79
|
// cellId -> entry:
|
|
76
80
|
// { launchEpoch, stablePath, stableServer, lease, socket, graceTimer, graceDeadline, lastCommit }
|
|
@@ -261,6 +265,14 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
261
265
|
const onLine = (line) => {
|
|
262
266
|
let msg; try { msg = JSON.parse(line); } catch (_) { return; }
|
|
263
267
|
if (!msg || typeof msg !== 'object') return;
|
|
268
|
+
if (msg.type === 'challengeProof') {
|
|
269
|
+
handleChallengeProof(cellId, entry, socket, msg);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (msg.type === 'generation') {
|
|
273
|
+
handleGeneration(cellId, entry, socket, msg);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
264
276
|
if (msg.type === 'refresh') {
|
|
265
277
|
// Heartbeat lato supervisore (R3.2) + IC1 (rev28/rev29): il refresh
|
|
266
278
|
// committa LA deadline D — monotona, ed è l'expiry del proof che sta
|
|
@@ -288,6 +300,10 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
288
300
|
};
|
|
289
301
|
const onData = (chunk) => {
|
|
290
302
|
buf += chunk.toString();
|
|
303
|
+
if (Buffer.byteLength(buf, 'utf8') > CHALLENGE_FRAME_LIMIT) {
|
|
304
|
+
socket.destroy();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
291
307
|
let nl;
|
|
292
308
|
while ((nl = buf.indexOf('\n')) !== -1) { onLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
|
|
293
309
|
};
|
|
@@ -330,6 +346,105 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
330
346
|
try { socket.write(`${JSON.stringify(obj)}\n`); } catch (_) {}
|
|
331
347
|
}
|
|
332
348
|
|
|
349
|
+
function validLaunchSubject(subject, cellId, entry) {
|
|
350
|
+
if (!subject || typeof subject !== 'object' || Array.isArray(subject)) return false;
|
|
351
|
+
const keys = Object.keys(subject);
|
|
352
|
+
if (keys.length !== 4
|
|
353
|
+
|| !['ownerInstanceId', 'cellId', 'incarnationId', 'launchEpoch'].every((key) => Object.hasOwn(subject, key))) return false;
|
|
354
|
+
return ['ownerInstanceId', 'cellId', 'incarnationId', 'launchEpoch']
|
|
355
|
+
.every((key) => typeof subject[key] === 'string' && subject[key].length > 0 && subject[key].length <= 128)
|
|
356
|
+
&& subject.cellId === cellId && subject.launchEpoch === entry.launchEpoch;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function setLaunchSubject(cellId, subject) {
|
|
360
|
+
if (!validCellId(cellId)) return false;
|
|
361
|
+
const entry = cells.get(cellId);
|
|
362
|
+
if (!entry || entry.subject || entry.lease || entry.socket) return false;
|
|
363
|
+
if (!validLaunchSubject(subject, cellId, entry)) return false;
|
|
364
|
+
entry.subject = { ...subject };
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function identityRelayReason(reason) {
|
|
369
|
+
if (reason === 'expired') return 'expired';
|
|
370
|
+
if (reason === 'replay' || reason === 'challenge-replay') return 'replay';
|
|
371
|
+
if (['audience', 'daemonBootId', 'connectionId'].includes(reason)) return 'audience';
|
|
372
|
+
return 'identity-unverified';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function validChallengeProofRequest(msg) {
|
|
376
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return false;
|
|
377
|
+
const keys = Object.keys(msg);
|
|
378
|
+
if (keys.length !== 4 || !['type', 'requestId', 'generation', 'challenge'].every((key) => Object.hasOwn(msg, key))) return false;
|
|
379
|
+
const requestIdOk = (typeof msg.requestId === 'string' && msg.requestId.length > 0 && msg.requestId.length <= 128)
|
|
380
|
+
|| Number.isSafeInteger(msg.requestId);
|
|
381
|
+
return msg.type === 'challengeProof' && requestIdOk
|
|
382
|
+
&& Number.isSafeInteger(msg.generation) && msg.generation >= 0
|
|
383
|
+
&& validDaemonChallenge(msg.challenge);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// R1: transizione di generazione della connessione viva. Solo stessa
|
|
387
|
+
// generazione (idempotente) o +1 (restart del supervisore); generazioni
|
|
388
|
+
// arbitrarie restano deny -> il relay fail-closed (revoked) non cambia.
|
|
389
|
+
function validGenerationRequest(msg) {
|
|
390
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return false;
|
|
391
|
+
const keys = Object.keys(msg);
|
|
392
|
+
return keys.length === 2 && msg.type === 'generation'
|
|
393
|
+
&& Number.isSafeInteger(msg.generation) && msg.generation >= 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handleGeneration(cellId, entry, socket, msg) {
|
|
397
|
+
if (!validGenerationRequest(msg)) {
|
|
398
|
+
writeSafe(socket, { type: 'generationDeny', generation: msg && Number.isSafeInteger(msg.generation) ? msg.generation : -1 });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const current = cells.get(cellId);
|
|
402
|
+
if (!current || current !== entry || entry.socket !== socket
|
|
403
|
+
|| !entry.lease || !L.isLive(entry.lease)) {
|
|
404
|
+
writeSafe(socket, { type: 'generationDeny', generation: msg.generation });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (msg.generation !== entry.lease.generation
|
|
408
|
+
&& msg.generation !== entry.lease.generation + 1) {
|
|
409
|
+
writeSafe(socket, { type: 'generationDeny', generation: msg.generation });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
// R1b: l'avanzamento PRIMA dell'ACK e' voluto (at-least-once): se l'ACK
|
|
413
|
+
// si perde, il client ritenta con la STESSA generazione e qui e'
|
|
414
|
+
// idempotente (== corrente -> ACK). L'ordine inverso lascerebbe il server
|
|
415
|
+
// stantio con un client che crede di aver transizionato.
|
|
416
|
+
entry.lease.generation = msg.generation;
|
|
417
|
+
writeSafe(socket, { type: 'generationAck', generation: msg.generation });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function handleChallengeProof(cellId, entry, socket, msg) {
|
|
421
|
+
if (!validChallengeProofRequest(msg)) {
|
|
422
|
+
writeSafe(socket, { type: 'challengeProofResult', requestId: msg && msg.requestId, ok: false, reason: 'identity-unverified' });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const current = cells.get(cellId);
|
|
426
|
+
if (!current || current !== entry || entry.socket !== socket
|
|
427
|
+
|| !entry.lease || !L.isLive(entry.lease)
|
|
428
|
+
|| entry.lease.generation !== msg.generation || !entry.subject) {
|
|
429
|
+
writeSafe(socket, { type: 'challengeProofResult', requestId: msg.requestId, ok: false, reason: 'revoked' });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (!identityAuthority || typeof identityAuthority.issueConnectionProof !== 'function') {
|
|
433
|
+
writeSafe(socket, { type: 'challengeProofResult', requestId: msg.requestId, ok: false, reason: 'authority-unavailable' });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const issued = identityAuthority.issueConnectionProof({
|
|
437
|
+
subject: { ...entry.subject },
|
|
438
|
+
challenge: msg.challenge,
|
|
439
|
+
});
|
|
440
|
+
if (!issued || issued.ok !== true) {
|
|
441
|
+
const reason = identityRelayReason(issued && issued.reason);
|
|
442
|
+
writeSafe(socket, { type: 'challengeProofResult', requestId: msg.requestId, ok: false, reason });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
writeSafe(socket, { type: 'challengeProofResult', requestId: msg.requestId, ok: true, proof: issued.proof });
|
|
446
|
+
}
|
|
447
|
+
|
|
333
448
|
function onStableConnection(cellId, socket) {
|
|
334
449
|
// Endpoint stabile: reconnect (R3.3.2-4). Legge identita' + proof, valida.
|
|
335
450
|
let buf = '';
|
|
@@ -509,7 +624,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
509
624
|
launchEpoch: existing.launchEpoch,
|
|
510
625
|
stablePath: existing.stablePath, stableServer: existing.stableServer,
|
|
511
626
|
lease: existing.lease, socket: existing.socket, graceTimer: existing.graceTimer,
|
|
512
|
-
graceDeadline: existing.graceDeadline,
|
|
627
|
+
graceDeadline: existing.graceDeadline, subject: existing.subject,
|
|
513
628
|
} : null;
|
|
514
629
|
// openEndpoint puo' aver creato un nuovo stableServer se l'entry non ne aveva uno.
|
|
515
630
|
const serverBeforeOpen = existing ? existing.stableServer : null;
|
|
@@ -550,6 +665,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
550
665
|
entry.socket = existingSnapshot.socket;
|
|
551
666
|
entry.graceTimer = existingSnapshot.graceTimer;
|
|
552
667
|
entry.graceDeadline = existingSnapshot.graceDeadline;
|
|
668
|
+
entry.subject = existingSnapshot.subject;
|
|
553
669
|
if (entry.stableServer && entry.stableServer !== serverBeforeOpen) {
|
|
554
670
|
try { entry.stableServer.close(); } catch (_) {}
|
|
555
671
|
entry.stableServer = serverBeforeOpen;
|
|
@@ -692,21 +808,32 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
692
808
|
}, { now });
|
|
693
809
|
}
|
|
694
810
|
|
|
695
|
-
function childRegister(cellId) {
|
|
811
|
+
function childRegister(cellId, { authority = false } = {}) {
|
|
696
812
|
if (!validCellId(cellId)) return { status: 'denied', reason: 'cellId' };
|
|
697
813
|
if (!cells.has(cellId)) {
|
|
698
814
|
// La cella non e' (ancora) tracciata dal lease del supervisore: il join e'
|
|
699
815
|
// pendente. Solo register puo' rispondere cosi' (B5).
|
|
700
816
|
return { status: 'pending', retryAfterMs: L.REFRESH_MS };
|
|
701
817
|
}
|
|
818
|
+
const launchSubject = cells.get(cellId) && cells.get(cellId).subject;
|
|
702
819
|
const reg = {
|
|
703
|
-
|
|
820
|
+
// In authority mode the launch subject is the single source of this value;
|
|
821
|
+
// legacy registrations keep their per-registration incarnation.
|
|
822
|
+
incarnationId: (authority === true && launchSubject && launchSubject.incarnationId)
|
|
823
|
+
|| crypto.randomBytes(8).toString('hex'),
|
|
704
824
|
createdAt: now(),
|
|
705
825
|
lastAt: now(),
|
|
706
826
|
recoveryAttempts: 0,
|
|
827
|
+
// La provenienza della registration decide se il proof child puo mai
|
|
828
|
+
// autorizzare il percorso identity shared: una registration nata dal
|
|
829
|
+
// percorso legacy resta legacy anche con firma valida.
|
|
830
|
+
authority: authority === true,
|
|
707
831
|
};
|
|
708
832
|
childRegs.set(cellId, reg);
|
|
709
|
-
return {
|
|
833
|
+
return {
|
|
834
|
+
status: 'registered', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg),
|
|
835
|
+
...(reg.authority ? { identityMode: 'authority' } : {}),
|
|
836
|
+
};
|
|
710
837
|
}
|
|
711
838
|
|
|
712
839
|
function childRefresh(cellId, proof) {
|
|
@@ -719,7 +846,35 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
719
846
|
if (!out.ok) return { status: 'denied', reason: out.reason };
|
|
720
847
|
if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
|
|
721
848
|
reg.lastAt = now();
|
|
722
|
-
return {
|
|
849
|
+
return {
|
|
850
|
+
status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg),
|
|
851
|
+
...(reg.authority ? { identityMode: 'authority' } : {}),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Introspezione READ-ONLY del proof child: verifica firma, expiry e stato
|
|
856
|
+
// della registration SENZA consumare nulla e senza toccare lastAt. Serve al
|
|
857
|
+
// bridge MCP per risolvere il contesto shared a ogni tools/call; un proof
|
|
858
|
+
// consumato qui resterebbe presentabile (e viceversa), perche questo gate
|
|
859
|
+
// non e' l'authorizer one-shot ma la consulta dello stato vivo.
|
|
860
|
+
function childIntrospect(proof) {
|
|
861
|
+
const out = verifyProof(liveKeys(), proof, { now, expect: { kind: 'child' } });
|
|
862
|
+
if (!out.ok) {
|
|
863
|
+
// Stessa semantica di refresh: un proof scaduto e' «expired», non un deny.
|
|
864
|
+
return out.reason === 'expired' ? { status: 'expired' } : { status: 'denied', reason: out.reason };
|
|
865
|
+
}
|
|
866
|
+
const cellId = proof && proof.cellId;
|
|
867
|
+
const reg = childRegs.get(cellId);
|
|
868
|
+
if (!reg) return { status: 'denied', reason: 'no-registration' };
|
|
869
|
+
if (reg.incarnationId !== (proof && proof.incarnationId)) {
|
|
870
|
+
return { status: 'denied', reason: 'incarnation' };
|
|
871
|
+
}
|
|
872
|
+
if (reg.authority !== true) return { status: 'denied', reason: 'legacy-registration' };
|
|
873
|
+
if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
|
|
874
|
+
return {
|
|
875
|
+
status: 'live', cellId, incarnationId: reg.incarnationId,
|
|
876
|
+
issuedAt: proof.issuedAt, expiresAt: proof.expiresAt, identityMode: 'authority',
|
|
877
|
+
};
|
|
723
878
|
}
|
|
724
879
|
|
|
725
880
|
function childRecovery(cellId, proof) {
|
|
@@ -774,7 +929,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
|
|
|
774
929
|
|
|
775
930
|
return {
|
|
776
931
|
boot, track, attachInitial, loadPersisted, status, close, _cells: cells,
|
|
777
|
-
childRegister, childRefresh, childRecovery,
|
|
932
|
+
setLaunchSubject, childRegister, childRefresh, childRecovery, childIntrospect,
|
|
778
933
|
};
|
|
779
934
|
}
|
|
780
935
|
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Server-only authority for the identity.v1 launch handshake. The daemon may
|
|
4
|
+
// register a challenge and the TUI may redeem a one-shot launch grant, but the
|
|
5
|
+
// HMAC key never leaves this module. This is deliberately separate from the
|
|
6
|
+
// Live lease proof: a lease says that a cell is alive, while this proof binds a
|
|
7
|
+
// particular daemon connection to an audience and a challenge.
|
|
8
|
+
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const os = require('node:os');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { loadOrCreateVerifier } = require('./lease-verifier.js');
|
|
13
|
+
|
|
14
|
+
const CHALLENGE_TTL_MS = 15_000;
|
|
15
|
+
const GRANT_TTL_MS = 60_000;
|
|
16
|
+
const NONCE_BYTES = 32;
|
|
17
|
+
const DEFAULT_REPLAY_LIMIT = 4096;
|
|
18
|
+
const DEFAULT_CHALLENGE_LIMIT = 4096;
|
|
19
|
+
const DEFAULT_REVOKE_LIMIT = 4096;
|
|
20
|
+
|
|
21
|
+
const GRANT_FIELDS = Object.freeze([
|
|
22
|
+
'kind', 'ownerInstanceId', 'cellId', 'audience', 'incarnationId',
|
|
23
|
+
'launchEpoch', 'daemonBootId', 'connectionId', 'challenge', 'nonce',
|
|
24
|
+
'jti', 'issuedAt', 'expiresAt',
|
|
25
|
+
]);
|
|
26
|
+
const PROOF_FIELDS = Object.freeze([
|
|
27
|
+
...GRANT_FIELDS.slice(0, 9), 'nonce', 'parentJti', 'jti', 'issuedAt', 'expiresAt', 'generation',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function nonEmpty(value) { return typeof value === 'string' && value.length > 0; }
|
|
31
|
+
|
|
32
|
+
function present(value) {
|
|
33
|
+
return value !== undefined && value !== null && String(value).length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hexNonce(randomBytes) {
|
|
37
|
+
return randomBytes(NONCE_BYTES).toString('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonical(fields, claims) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const field of fields) {
|
|
43
|
+
const value = claims[field];
|
|
44
|
+
if (value === undefined || value === null || String(value).length === 0) {
|
|
45
|
+
throw new Error(`claim mancante: ${field}`);
|
|
46
|
+
}
|
|
47
|
+
const bytes = Buffer.from(String(value), 'utf8');
|
|
48
|
+
const length = Buffer.alloc(4);
|
|
49
|
+
length.writeUInt32BE(bytes.length, 0);
|
|
50
|
+
parts.push(length, bytes);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(parts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sign(verifier, fields, claims) {
|
|
56
|
+
const proof = crypto.createHmac('sha256', verifier.secret)
|
|
57
|
+
.update(canonical(fields, claims)).digest('hex');
|
|
58
|
+
return { ...claims, proof };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeEqual(left, right) {
|
|
62
|
+
if (!nonEmpty(left) || !nonEmpty(right)) return false;
|
|
63
|
+
const a = Buffer.from(left, 'utf8');
|
|
64
|
+
const b = Buffer.from(right, 'utf8');
|
|
65
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function verify(verifier, fields, candidate, { now, expected = {} } = {}) {
|
|
69
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
70
|
+
return { ok: false, reason: 'malformed' };
|
|
71
|
+
}
|
|
72
|
+
for (const field of fields) if (!present(candidate[field])) return { ok: false, reason: 'malformed' };
|
|
73
|
+
if (!/^[a-f0-9]{64}$/.test(candidate.proof)) return { ok: false, reason: 'malformed' };
|
|
74
|
+
const issuedAt = Number(candidate.issuedAt);
|
|
75
|
+
const expiresAt = Number(candidate.expiresAt);
|
|
76
|
+
if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt) || expiresAt <= issuedAt) {
|
|
77
|
+
return { ok: false, reason: 'malformed' };
|
|
78
|
+
}
|
|
79
|
+
if (Number(now()) >= expiresAt) return { ok: false, reason: 'expired' };
|
|
80
|
+
for (const [field, value] of Object.entries(expected)) {
|
|
81
|
+
if (value !== undefined && candidate[field] !== value) return { ok: false, reason: field };
|
|
82
|
+
}
|
|
83
|
+
const expectedProof = crypto.createHmac('sha256', verifier.secret)
|
|
84
|
+
.update(canonical(fields, candidate)).digest('hex');
|
|
85
|
+
if (!safeEqual(expectedProof, candidate.proof)) return { ok: false, reason: 'bad-proof' };
|
|
86
|
+
return { ok: true, claims: { ...candidate } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createIdentityAuthority({
|
|
90
|
+
dir = path.join(os.homedir(), '.nexuscrew', 'identity-authority'),
|
|
91
|
+
fsImpl,
|
|
92
|
+
serviceCredential,
|
|
93
|
+
daemonCredential,
|
|
94
|
+
launcherCredential,
|
|
95
|
+
subjectResolver = () => true,
|
|
96
|
+
now = Date.now,
|
|
97
|
+
randomBytes = crypto.randomBytes,
|
|
98
|
+
maxReplayEntries = DEFAULT_REPLAY_LIMIT,
|
|
99
|
+
maxChallenges = DEFAULT_CHALLENGE_LIMIT,
|
|
100
|
+
maxRevokeEntries = DEFAULT_REVOKE_LIMIT,
|
|
101
|
+
log = () => {},
|
|
102
|
+
} = {}) {
|
|
103
|
+
const legacyCredential = nonEmpty(serviceCredential) ? serviceCredential : null;
|
|
104
|
+
const daemonSecret = daemonCredential || legacyCredential;
|
|
105
|
+
const launcherSecret = launcherCredential || legacyCredential;
|
|
106
|
+
if (!nonEmpty(daemonSecret) || !nonEmpty(launcherSecret)) {
|
|
107
|
+
throw new Error('identity authority: daemon e launcher credential obbligatorie');
|
|
108
|
+
}
|
|
109
|
+
if ((daemonCredential || launcherCredential) && safeEqual(daemonSecret, launcherSecret)) {
|
|
110
|
+
throw new Error('identity authority: daemon e launcher credential devono essere distinte');
|
|
111
|
+
}
|
|
112
|
+
if (typeof subjectResolver !== 'function') throw new Error('identity authority: subject resolver non valido');
|
|
113
|
+
if (!Number.isSafeInteger(maxReplayEntries) || maxReplayEntries < 1) {
|
|
114
|
+
throw new Error('identity authority: replay limit non valido');
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isSafeInteger(maxChallenges) || maxChallenges < 1) {
|
|
117
|
+
throw new Error('identity authority: challenge limit non valido');
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isSafeInteger(maxRevokeEntries) || maxRevokeEntries < 1) {
|
|
120
|
+
throw new Error('identity authority: revoke limit non valido');
|
|
121
|
+
}
|
|
122
|
+
// `fsImpl` is injectable only for tests; production always uses the real
|
|
123
|
+
// filesystem and the dedicated 0600 key managed by lease-verifier.js.
|
|
124
|
+
const verifier = loadOrCreateVerifier({ dir, ...(fsImpl ? { fsImpl } : {}), log });
|
|
125
|
+
// Fresh per authority incarnation and never persisted: a restart of the
|
|
126
|
+
// authority changes it, so every proof issued before the restart fails
|
|
127
|
+
// closed instead of surviving with the persisted HMAC key.
|
|
128
|
+
const generation = hexNonce(randomBytes);
|
|
129
|
+
const challenges = new Map();
|
|
130
|
+
// Bounded stores: every entry carries its own expiry, saturated stores
|
|
131
|
+
// reject instead of evicting valid entries, and only expired entries are
|
|
132
|
+
// collected on the next operation that touches the store.
|
|
133
|
+
const replay = new Map();
|
|
134
|
+
const revoked = new Map();
|
|
135
|
+
|
|
136
|
+
function sweepExpired() {
|
|
137
|
+
const tick = Number(now());
|
|
138
|
+
for (const [challenge, record] of challenges) {
|
|
139
|
+
if (tick >= record.expiresAt) challenges.delete(challenge);
|
|
140
|
+
}
|
|
141
|
+
for (const [value, expiresAt] of replay) {
|
|
142
|
+
if (tick >= expiresAt) replay.delete(value);
|
|
143
|
+
}
|
|
144
|
+
for (const [jti, expiresAt] of revoked) {
|
|
145
|
+
if (tick >= expiresAt) revoked.delete(jti);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function credentialMatches(candidate, expected) { return safeEqual(expected, candidate); }
|
|
150
|
+
|
|
151
|
+
function validConnection(args) {
|
|
152
|
+
return ['audience', 'daemonBootId', 'connectionId']
|
|
153
|
+
.every((field) => nonEmpty(args && args[field]));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validSubject(args) {
|
|
157
|
+
return ['ownerInstanceId', 'cellId', 'incarnationId', 'launchEpoch']
|
|
158
|
+
.every((field) => nonEmpty(args && args[field]));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const strictCredentials = !!(daemonCredential || launcherCredential);
|
|
162
|
+
|
|
163
|
+
function reserveReplay(value, expiresAt) {
|
|
164
|
+
sweepExpired();
|
|
165
|
+
if (replay.has(value)) return { ok: false, reason: 'replay' };
|
|
166
|
+
if (replay.size >= maxReplayEntries) return { ok: false, reason: 'replay-store-full' };
|
|
167
|
+
replay.set(value, expiresAt);
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function registerDaemonChallenge(args = {}) {
|
|
172
|
+
const credential = args.daemonCredential || args.serviceCredential;
|
|
173
|
+
if (!credentialMatches(credential, daemonSecret)) {
|
|
174
|
+
return { ok: false, reason: strictCredentials ? 'daemon-credential' : 'service-credential' };
|
|
175
|
+
}
|
|
176
|
+
if (!validConnection(args)) return { ok: false, reason: 'claims' };
|
|
177
|
+
sweepExpired();
|
|
178
|
+
if (challenges.has(args.challenge)) return { ok: false, reason: 'challenge-replay' };
|
|
179
|
+
if (challenges.size >= maxChallenges) return { ok: false, reason: 'challenge-store-full' };
|
|
180
|
+
const issuedAt = Number(now());
|
|
181
|
+
const challenge = (typeof args.challenge === 'string' && args.challenge) || hexNonce(randomBytes);
|
|
182
|
+
const expiresAt = Number.isSafeInteger(Number(args.expiresAt))
|
|
183
|
+
? Number(args.expiresAt)
|
|
184
|
+
: (issuedAt + CHALLENGE_TTL_MS);
|
|
185
|
+
const record = {
|
|
186
|
+
challenge,
|
|
187
|
+
audience: args.audience,
|
|
188
|
+
daemonBootId: args.daemonBootId,
|
|
189
|
+
connectionId: args.connectionId,
|
|
190
|
+
issuedAt,
|
|
191
|
+
expiresAt,
|
|
192
|
+
used: false,
|
|
193
|
+
};
|
|
194
|
+
challenges.set(challenge, record);
|
|
195
|
+
return { ok: true, challenge, issuedAt, expiresAt: record.expiresAt };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function issueLaunchGrant(args = {}) {
|
|
199
|
+
const credential = args.launcherCredential || args.serviceCredential || args.daemonCredential;
|
|
200
|
+
if (!credentialMatches(credential, launcherSecret)
|
|
201
|
+
|| (strictCredentials && !args.launcherCredential)) return { ok: false, reason: 'launcher-credential' };
|
|
202
|
+
const record = challenges.get(args.challenge);
|
|
203
|
+
if (!record) return { ok: false, reason: 'challenge' };
|
|
204
|
+
if (record.used) return { ok: false, reason: 'challenge-replay' };
|
|
205
|
+
if (Number(now()) >= record.expiresAt) return { ok: false, reason: 'challenge' };
|
|
206
|
+
const subject = args.subject || (strictCredentials ? null : args);
|
|
207
|
+
if (!validSubject(subject) || (strictCredentials && subjectResolver(subject) !== true)) {
|
|
208
|
+
return { ok: false, reason: 'subject' };
|
|
209
|
+
}
|
|
210
|
+
if (!validConnection(record)) return { ok: false, reason: 'claims' };
|
|
211
|
+
for (const field of ['audience', 'daemonBootId', 'connectionId']) {
|
|
212
|
+
if (args[field] !== undefined && args[field] !== record[field]) return { ok: false, reason: field };
|
|
213
|
+
}
|
|
214
|
+
const issuedAt = Number(now());
|
|
215
|
+
const claims = {
|
|
216
|
+
kind: 'launch-grant', ...Object.fromEntries([
|
|
217
|
+
'ownerInstanceId', 'cellId', 'incarnationId', 'launchEpoch',
|
|
218
|
+
].map((field) => [field, subject[field]])
|
|
219
|
+
.concat(['audience', 'daemonBootId', 'connectionId']
|
|
220
|
+
.map((field) => [field, record[field]]))),
|
|
221
|
+
challenge: record.challenge,
|
|
222
|
+
nonce: (typeof args.nonce === 'string' && args.nonce) || hexNonce(randomBytes),
|
|
223
|
+
jti: hexNonce(randomBytes),
|
|
224
|
+
issuedAt,
|
|
225
|
+
expiresAt: Math.min(record.expiresAt, issuedAt + GRANT_TTL_MS),
|
|
226
|
+
};
|
|
227
|
+
if (claims.expiresAt <= issuedAt) return { ok: false, reason: 'challenge' };
|
|
228
|
+
return { ok: true, grant: sign(verifier, GRANT_FIELDS, claims) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function issueChallengeProof({ launchGrant, challenge, nonce: candidateNonce, expiresAt: candidateExpiresAt } = {}) {
|
|
232
|
+
const checked = verify(verifier, GRANT_FIELDS, launchGrant, { now, expected: { kind: 'launch-grant', challenge } });
|
|
233
|
+
if (!checked.ok) return checked;
|
|
234
|
+
const record = challenges.get(challenge);
|
|
235
|
+
if (!record) return { ok: false, reason: 'challenge' };
|
|
236
|
+
if (record.used) return { ok: false, reason: 'challenge-replay' };
|
|
237
|
+
if (Number(now()) >= record.expiresAt) return { ok: false, reason: 'challenge' };
|
|
238
|
+
for (const field of ['audience', 'daemonBootId', 'connectionId']) {
|
|
239
|
+
if (checked.claims[field] !== record[field]) return { ok: false, reason: field };
|
|
240
|
+
}
|
|
241
|
+
if (candidateNonce !== undefined && candidateNonce !== record.challenge) {
|
|
242
|
+
return { ok: false, reason: 'nonce' };
|
|
243
|
+
}
|
|
244
|
+
const reserved = reserveReplay(checked.claims.nonce, Number(checked.claims.expiresAt));
|
|
245
|
+
if (!reserved.ok) return { ok: false, reason: reserved.reason };
|
|
246
|
+
record.used = true;
|
|
247
|
+
const issuedAt = Number(now());
|
|
248
|
+
const nonce = record.challenge;
|
|
249
|
+
const expiresAt = Number.isSafeInteger(Number(candidateExpiresAt))
|
|
250
|
+
? Math.min(Number(candidateExpiresAt), Number(checked.claims.expiresAt))
|
|
251
|
+
: Math.min(Number(checked.claims.expiresAt), issuedAt + GRANT_TTL_MS);
|
|
252
|
+
if (expiresAt <= issuedAt) return { ok: false, reason: 'expired' };
|
|
253
|
+
const claims = {
|
|
254
|
+
kind: 'identity-proof', ...Object.fromEntries([
|
|
255
|
+
'ownerInstanceId', 'cellId', 'audience', 'incarnationId',
|
|
256
|
+
'launchEpoch', 'daemonBootId', 'connectionId', 'challenge',
|
|
257
|
+
].map((field) => [field, checked.claims[field]])),
|
|
258
|
+
nonce,
|
|
259
|
+
parentJti: checked.claims.jti,
|
|
260
|
+
jti: hexNonce(randomBytes),
|
|
261
|
+
issuedAt,
|
|
262
|
+
expiresAt,
|
|
263
|
+
generation,
|
|
264
|
+
};
|
|
265
|
+
return { ok: true, proof: sign(verifier, PROOF_FIELDS, claims) };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function issueConnectionProof({
|
|
269
|
+
subject,
|
|
270
|
+
challenge,
|
|
271
|
+
daemonCredential,
|
|
272
|
+
launcherCredential,
|
|
273
|
+
serviceCredential,
|
|
274
|
+
expiresAt: externalExpiresAt,
|
|
275
|
+
} = {}) {
|
|
276
|
+
if (!challenge || typeof challenge !== 'object') return { ok: false, reason: 'challenge' };
|
|
277
|
+
if (!nonEmpty(challenge.nonce)) return { ok: false, reason: 'challenge' };
|
|
278
|
+
if (!Number.isSafeInteger(challenge.expiresAt)) return { ok: false, reason: 'challenge' };
|
|
279
|
+
if (externalExpiresAt !== undefined && !Number.isSafeInteger(externalExpiresAt)) {
|
|
280
|
+
return { ok: false, reason: 'challenge' };
|
|
281
|
+
}
|
|
282
|
+
const currentTick = Number(now());
|
|
283
|
+
// R2: il TTL della challenge e' SERVER-OWNED (15s dall'orologio
|
|
284
|
+
// dell'authority), non del chiamante: oltre il cap viene capito, e una
|
|
285
|
+
// challenge con issuedAt nel futuro e' rifiutata (documentato in referto).
|
|
286
|
+
if (Number.isSafeInteger(challenge.issuedAt) && challenge.issuedAt > currentTick) {
|
|
287
|
+
return { ok: false, reason: 'challenge' };
|
|
288
|
+
}
|
|
289
|
+
const effectiveExpiresAt = externalExpiresAt === undefined
|
|
290
|
+
? Math.min(challenge.expiresAt, currentTick + CHALLENGE_TTL_MS)
|
|
291
|
+
: Math.min(challenge.expiresAt, currentTick + CHALLENGE_TTL_MS, externalExpiresAt);
|
|
292
|
+
if (currentTick >= effectiveExpiresAt) return { ok: false, reason: 'expired' };
|
|
293
|
+
const dCred = daemonCredential || serviceCredential || daemonSecret;
|
|
294
|
+
const lCred = launcherCredential || serviceCredential || launcherSecret;
|
|
295
|
+
const challengeKey = challenge.nonce;
|
|
296
|
+
const reg = registerDaemonChallenge({
|
|
297
|
+
daemonCredential: dCred,
|
|
298
|
+
audience: challenge.audience,
|
|
299
|
+
daemonBootId: challenge.daemonBootId,
|
|
300
|
+
connectionId: challenge.connectionId,
|
|
301
|
+
challenge: challengeKey,
|
|
302
|
+
expiresAt: effectiveExpiresAt,
|
|
303
|
+
});
|
|
304
|
+
if (!reg.ok) return reg;
|
|
305
|
+
const grant = issueLaunchGrant({
|
|
306
|
+
launcherCredential: lCred,
|
|
307
|
+
challenge: challengeKey,
|
|
308
|
+
subject,
|
|
309
|
+
audience: challenge.audience,
|
|
310
|
+
daemonBootId: challenge.daemonBootId,
|
|
311
|
+
connectionId: challenge.connectionId,
|
|
312
|
+
});
|
|
313
|
+
if (!grant.ok) return grant;
|
|
314
|
+
const proof = issueChallengeProof({
|
|
315
|
+
launchGrant: grant.grant,
|
|
316
|
+
challenge: challengeKey,
|
|
317
|
+
nonce: challenge.nonce,
|
|
318
|
+
expiresAt: effectiveExpiresAt,
|
|
319
|
+
});
|
|
320
|
+
return proof;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function verifyChallengeProof(proof, expected = {}) {
|
|
324
|
+
const checked = verify(verifier, PROOF_FIELDS, proof, { now, expected: { kind: 'identity-proof', ...expected } });
|
|
325
|
+
if (!checked.ok) return checked;
|
|
326
|
+
if (!safeEqual(checked.claims.generation, generation)) return { ok: false, reason: 'generation' };
|
|
327
|
+
if (revoked.has(checked.claims.jti) || revoked.has(checked.claims.parentJti)) {
|
|
328
|
+
return { ok: false, reason: 'revoked' };
|
|
329
|
+
}
|
|
330
|
+
const reserved = reserveReplay(checked.claims.nonce, Number(checked.claims.expiresAt));
|
|
331
|
+
if (!reserved.ok) return { ok: false, reason: reserved.reason };
|
|
332
|
+
return checked;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function revoke(jti, expiresAt) {
|
|
336
|
+
if (!nonEmpty(jti)) return false;
|
|
337
|
+
sweepExpired();
|
|
338
|
+
const tick = Number(now());
|
|
339
|
+
// Without an explicit horizon a revocation covers the widest proof it
|
|
340
|
+
// can still affect, then it expires like the proofs it invalidates.
|
|
341
|
+
const horizon = Number.isSafeInteger(expiresAt)
|
|
342
|
+
? expiresAt
|
|
343
|
+
: tick + CHALLENGE_TTL_MS + GRANT_TTL_MS;
|
|
344
|
+
if (tick >= horizon) return false;
|
|
345
|
+
if (!revoked.has(jti) && revoked.size >= maxRevokeEntries) return false;
|
|
346
|
+
revoked.set(jti, Math.max(horizon, revoked.get(jti) || 0));
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
registerDaemonChallenge,
|
|
352
|
+
issueLaunchGrant,
|
|
353
|
+
issueChallengeProof,
|
|
354
|
+
issueConnectionProof,
|
|
355
|
+
verifyChallengeProof,
|
|
356
|
+
reserveReplay,
|
|
357
|
+
revoke,
|
|
358
|
+
constants: Object.freeze({ CHALLENGE_TTL_MS, GRANT_TTL_MS, NONCE_BYTES }),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
module.exports = {
|
|
363
|
+
createIdentityAuthority,
|
|
364
|
+
CHALLENGE_TTL_MS,
|
|
365
|
+
GRANT_TTL_MS,
|
|
366
|
+
NONCE_BYTES,
|
|
367
|
+
};
|