@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.
@@ -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
- incarnationId: crypto.randomBytes(8).toString('hex'), // B2: per-registration
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 { status: 'registered', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
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 { status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
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
+ };