@mmmbuto/nexuscrew 0.9.23 → 0.9.25

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.
@@ -9,7 +9,8 @@ const path = require('node:path');
9
9
  const { spawn } = require('node:child_process');
10
10
  const { MAX_PAYLOAD } = require('./launch-broker.js');
11
11
  const {
12
- validDaemonChallenge, identityErrorCode, IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
12
+ validDaemonChallenge, validVerifyExpected, identityErrorCode,
13
+ IDENTITY_FRAME_LIMIT, IDENTITY_TIMEOUT_MS,
13
14
  } = require('./lease-client.js');
14
15
 
15
16
  const DEFAULT_SUPERVISE = Object.freeze({
@@ -110,9 +111,14 @@ function validLease(value) {
110
111
 
111
112
  function validPayload(payload) {
112
113
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
113
- if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity'].includes(key))) return false;
114
+ if (Object.keys(payload).some((key) => !['command', 'args', 'env', 'supervise', 'restartPrompt', 'lease', 'identity', 'identityChannel'].includes(key))) return false;
114
115
  if (typeof payload.command !== 'string' || !payload.command || !Array.isArray(payload.args)) return false;
115
116
  if (!payload.env || typeof payload.env !== 'object' || Array.isArray(payload.env)) return false;
117
+ // Il launcher decide se il canale identita' esiste (managed.js) e lo dichiara
118
+ // nel payload con `identityChannel`: il supervisore non lo ricalcola. La chiave
119
+ // va quindi ammessa qui, e SOLO come booleano: un valore di altro tipo e' un
120
+ // payload malformato, non un "canale assente" (assente = caller precedente).
121
+ if (payload.identityChannel !== undefined && typeof payload.identityChannel !== 'boolean') return false;
116
122
  return payload.args.every((v) => typeof v === 'string')
117
123
  && Object.entries(payload.env).every(([k, v]) => /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k) && typeof v === 'string')
118
124
  && validSupervise(payload.supervise)
@@ -176,20 +182,44 @@ function identityErrorResponse(id, code, message) {
176
182
  };
177
183
  }
178
184
 
185
+ const IDENTITY_METHODS = new Set([
186
+ 'nexuscrew/identity/challengeProof',
187
+ 'nexuscrew/identity/verify',
188
+ ]);
189
+
179
190
  function validIdentityRequest(msg) {
180
191
  if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return false;
181
192
  const keys = Object.keys(msg);
182
193
  if (keys.length !== 4 || !['jsonrpc', 'id', 'method', 'params'].every((key) => Object.hasOwn(msg, key))) return false;
183
- if (msg.jsonrpc !== '2.0' || msg.method !== 'nexuscrew/identity/challengeProof') return false;
194
+ if (msg.jsonrpc !== '2.0' || !IDENTITY_METHODS.has(msg.method)) return false;
184
195
  if (!Number.isSafeInteger(msg.id) || msg.id < 0) return false;
185
196
  return true;
186
197
  }
187
198
 
199
+ // schema chiuso del metodo verify (v1). Il proof viene VERIFICATO
200
+ // online dall'authority (C2): il daemon non valuta nulla da solo.
201
+ function validVerifyRequest(msg, channelGeneration) {
202
+ if (msg.method !== 'nexuscrew/identity/verify') return false;
203
+ const params = msg.params;
204
+ if (!params || typeof params !== 'object' || Array.isArray(params)) return false;
205
+ // Verify v1.1: la tupla attesa {nonce, connectionId, daemonBootId, audience}
206
+ // e' opzionale (retro-compat v1) ma, se presente, ha schema chiuso.
207
+ if (Object.keys(params).length !== 3 && Object.keys(params).length !== 4) return false;
208
+ if (!Object.hasOwn(params, 'v') || !Object.hasOwn(params, 'generation')
209
+ || !Object.hasOwn(params, 'proof')) return false;
210
+ if (Object.hasOwn(params, 'expected') && !validVerifyExpected(params.expected)) return false;
211
+ if (params.v !== 1) return false;
212
+ if (!Number.isSafeInteger(params.generation) || params.generation !== channelGeneration) return false;
213
+ if (!params.proof || typeof params.proof !== 'object' || Array.isArray(params.proof)) return false;
214
+ return true;
215
+ }
216
+
188
217
  function createIdentityChannel({
189
218
  request,
190
219
  response,
191
220
  generation,
192
221
  relay,
222
+ relayVerify,
193
223
  cancel = () => {},
194
224
  setTimeout: setTimer = (...args) => setTimeout(...args),
195
225
  clearTimeout: clearTimer = (timer) => { if (timer) clearTimeout(timer); },
@@ -264,12 +294,37 @@ function createIdentityChannel({
264
294
  return;
265
295
  }
266
296
  if (!validIdentityRequest(msg)
267
- || !msg.params || typeof msg.params !== 'object' || Array.isArray(msg.params)
268
- || Object.keys(msg.params).length !== 1 || !Object.hasOwn(msg.params, 'challenge')
269
- || !validDaemonChallenge(msg.params.challenge)) {
297
+ || !msg.params || typeof msg.params !== 'object' || Array.isArray(msg.params)) {
270
298
  sendError(validIdentityRequest(msg) ? msg.id : 0, 'IDENTITY_UNVERIFIED', 'invalid request');
271
299
  return;
272
300
  }
301
+ if (msg.method === 'nexuscrew/identity/verify') {
302
+ if (typeof relayVerify !== 'function') {
303
+ sendError(msg.id, 'IDENTITY_UNVERIFIED', 'verify-unsupported');
304
+ return;
305
+ }
306
+ if (!validVerifyRequest(msg, generation)) {
307
+ sendError(msg.id, 'IDENTITY_UNVERIFIED', 'invalid request');
308
+ return;
309
+ }
310
+ const verifyRequestId = `g${generation}-v${nextRequestId}`;
311
+ nextRequestId += 1;
312
+ pending = {
313
+ requestId: verifyRequestId, rpcId: msg.id, generation, done: false,
314
+ timer: null, verify: true,
315
+ };
316
+ pending.timer = setTimer(() => settleVerify({ ok: false, reason: 'timeout' }), IDENTITY_TIMEOUT_MS);
317
+ if (pending.timer && typeof pending.timer.unref === 'function') pending.timer.unref();
318
+ Promise.resolve(relayVerify(verifyRequestId, msg.params.proof, msg.params.expected)).then(settleVerify, (error) => {
319
+ settleVerify({ ok: false, reason: error && error.code ? error.code : 'authority-unavailable' });
320
+ });
321
+ return;
322
+ }
323
+ if (Object.keys(msg.params).length !== 1 || !Object.hasOwn(msg.params, 'challenge')
324
+ || !validDaemonChallenge(msg.params.challenge)) {
325
+ sendError(msg.id, 'IDENTITY_UNVERIFIED', 'invalid request');
326
+ return;
327
+ }
273
328
  const requestId = `g${generation}-r${nextRequestId}`;
274
329
  nextRequestId += 1;
275
330
  pending = {
@@ -282,6 +337,26 @@ function createIdentityChannel({
282
337
  });
283
338
  };
284
339
 
340
+ // esito del verify come RISULTATO JSON-RPC (ok:false e' una risposta
341
+ // valida, non un errore di trasporto). Solo un 'revoked' chiude il canale:
342
+ // il lease/generazione non c'e' piu', il daemon deve ribindare da zero.
343
+ const settleVerify = (outcome) => {
344
+ if (closed) return;
345
+ const current = pending;
346
+ if (!current || current.done) return;
347
+ current.done = true;
348
+ clearTimer(current.timer);
349
+ pending = null;
350
+ const ok = outcome && outcome.ok === true;
351
+ const payload = ok
352
+ ? { ok: true, v: 1, claims: outcome.claims }
353
+ : { ok: false, v: 1, reason: outcome && typeof outcome.reason === 'string' ? outcome.reason : 'identity-unverified' };
354
+ try {
355
+ response.write(`${JSON.stringify({ jsonrpc: '2.0', id: current.rpcId, result: payload })}\n`);
356
+ } catch (_) {}
357
+ if (!ok && payload.reason === 'revoked') close('REVOKED', 'revoked', current.rpcId, false);
358
+ };
359
+
285
360
  request.on('data', (chunk) => {
286
361
  if (closed) return;
287
362
  buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
@@ -452,7 +527,16 @@ async function main(argv = process.argv.slice(2), seams = {}) {
452
527
  const sleep = seams.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
453
528
  const proc = seams.process || process;
454
529
  const writeError = seams.writeError || seams.stderrWrite || ((message) => process.stderr.write(message));
455
- const childEnv = { ...payload.env, NEXUSCREW_IDENTITY_FD: '3:4' };
530
+ // Il canale identita' nasce SOLO se il launcher lo ha deciso: la stessa
531
+ // risoluzione che decide CODEX_APP_SERVER_IDENTITY_REQUIRED in managed.js
532
+ // viaggia nel payload come `identityChannel`. Derivarlo qui (aggiungere
533
+ // sempre fd 3:4) permetteva ai due di divergere: una cella standalone
534
+ // riceveva il canale, avviava l'handshake contro un lease server legacy che
535
+ // rispondeva 'revoked', e moriva. Un payload senza il campo (caller
536
+ // precedenti) conserva il comportamento di prima.
537
+ const identityChannel = payload.identityChannel !== false;
538
+ const childEnv = { ...payload.env };
539
+ if (identityChannel) childEnv.NEXUSCREW_IDENTITY_FD = '3:4';
456
540
  // tmux injects these only after the broker ticket was created. Preserve them
457
541
  // for the actual TUI and bind NexusCrew MCP callbacks to the owning session.
458
542
  if (process.env.TMUX) childEnv.TMUX = process.env.TMUX;
@@ -542,7 +626,11 @@ async function main(argv = process.argv.slice(2), seams = {}) {
542
626
  const promptCtl = startGenerationPrompt(payload.restartPrompt, generation, childState, seams);
543
627
  current = spawnImpl(payload.command, payload.args, {
544
628
  env: childEnv,
545
- stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe'],
629
+ // Senza canale non si creano nemmeno le pipe: fd 3 e 4 chiusi nel
630
+ // child (non pipe aperte e inutilizzate).
631
+ stdio: identityChannel
632
+ ? ['inherit', 'inherit', 'inherit', 'pipe', 'pipe']
633
+ : ['inherit', 'inherit', 'inherit'],
546
634
  });
547
635
  // R1a (fix5): exit/error del figlio contano SUBITO, prima di qualunque
548
636
  // await — se il figlio esce durante l'handshake di generazione, l'evento
@@ -579,7 +667,15 @@ async function main(argv = process.argv.slice(2), seams = {}) {
579
667
  ),
580
668
  childExit.then(() => 'exited'),
581
669
  ]);
582
- if (raced === 'ok' && !childSettled) {
670
+ if (!identityChannel) {
671
+ // Nessun canale da aprire: la transizione di generazione resta
672
+ // annunciata al lease (bookkeeping), ma l'identita' non esiste e
673
+ // non c'e' nessun fd da chiudere. L'uscita del figlio resta
674
+ // diagnostica.
675
+ if (raced === 'exited') {
676
+ writeError(`nexuscrew cell: figlio uscito durante l'handshake di generazione (gen ${generation})\n`);
677
+ }
678
+ } else if (raced === 'ok' && !childSettled) {
583
679
  identityCtl = createIdentityChannel({
584
680
  request: current && current.stdio && current.stdio[3],
585
681
  response: current && current.stdio && current.stdio[4],
@@ -587,6 +683,9 @@ async function main(argv = process.argv.slice(2), seams = {}) {
587
683
  relay: leaseCtl
588
684
  ? (requestId, challenge) => leaseCtl.challengeProof({ requestId, generation, challenge })
589
685
  : async () => ({ ok: false, reason: 'lease-down' }),
686
+ relayVerify: leaseCtl
687
+ ? (requestId, proof, expected) => leaseCtl.verifyProof({ requestId, generation, proof, expected })
688
+ : async () => ({ ok: false, reason: 'lease-down' }),
590
689
  cancel: leaseCtl
591
690
  ? (requestId) => leaseCtl.cancelIdentityChallenge(requestId)
592
691
  : () => {},
@@ -598,7 +697,7 @@ async function main(argv = process.argv.slice(2), seams = {}) {
598
697
  } else {
599
698
  writeError(`nexuscrew cell: generazione ${generation} senza identita' (${raced})\n`);
600
699
  }
601
- } else {
700
+ } else if (identityChannel) {
602
701
  identityCtl = createIdentityChannel({
603
702
  request: current && current.stdio && current.stdio[3],
604
703
  response: current && current.stdio && current.stdio[4],
@@ -606,6 +705,9 @@ async function main(argv = process.argv.slice(2), seams = {}) {
606
705
  relay: leaseCtl
607
706
  ? (requestId, challenge) => leaseCtl.challengeProof({ requestId, generation, challenge })
608
707
  : async () => ({ ok: false, reason: 'lease-down' }),
708
+ relayVerify: leaseCtl
709
+ ? (requestId, proof, expected) => leaseCtl.verifyProof({ requestId, generation, proof, expected })
710
+ : async () => ({ ok: false, reason: 'lease-down' }),
609
711
  cancel: leaseCtl
610
712
  ? (requestId) => leaseCtl.cancelIdentityChallenge(requestId)
611
713
  : () => {},
@@ -676,5 +778,5 @@ if (require.main === module) {
676
778
  module.exports = {
677
779
  DEFAULT_SUPERVISE, LEASE_LOST_KILL_ESCALATION_MS, parseArgs, validSupervise, validRestartPrompt, validPayload, validLease,
678
780
  receivePayload, sanitizeSpawnError, normalizeSupervise, waitChild, startGenerationPrompt,
679
- createIdentityChannel, validIdentityRequest, main,
781
+ createIdentityChannel, validIdentityRequest, validVerifyRequest, main,
680
782
  };
@@ -43,7 +43,7 @@ const crypto = require('node:crypto');
43
43
  const L = require('./cell-lease.js');
44
44
  const { runtimeDir, ensureRuntimeDir } = require('./launch-broker.js');
45
45
  const { loadOrCreateVerifier, signProof, verifyProof, PROOF_TTL_MS } = require('./lease-verifier.js');
46
- const { validDaemonChallenge } = require('./lease-client.js');
46
+ const { validDaemonChallenge, validVerifyExpected } = require('./lease-client.js');
47
47
 
48
48
  function sanitizeCell(cellId) {
49
49
  return String(cellId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
@@ -269,6 +269,10 @@ function createLeaseManager(cfg = {}, seams = {}) {
269
269
  handleChallengeProof(cellId, entry, socket, msg);
270
270
  return;
271
271
  }
272
+ if (msg.type === 'verify') {
273
+ handleVerify(cellId, entry, socket, msg);
274
+ return;
275
+ }
272
276
  if (msg.type === 'generation') {
273
277
  handleGeneration(cellId, entry, socket, msg);
274
278
  return;
@@ -365,6 +369,19 @@ function createLeaseManager(cfg = {}, seams = {}) {
365
369
  return true;
366
370
  }
367
371
 
372
+ // il canale verify espone l'enum COMPLETO del contratto v1
373
+ // (docs/identity/verify-channel-v1.md): a differenza del relay
374
+ // challenge-proof (che collassa i motivi sensibili), qui la diagnostica
375
+ // puntuale e' parte del contratto — il daemon decide il fail-closed.
376
+ function identityVerifyReason(reason) {
377
+ const known = new Set([
378
+ 'malformed', 'expired', 'bad-proof', 'replay', 'challenge-replay',
379
+ 'revoked', 'generation', 'ownerInstanceId', 'cellId',
380
+ 'incarnationId', 'launchEpoch', 'challenge_mismatch',
381
+ ]);
382
+ return known.has(reason) ? reason : 'identity-unverified';
383
+ }
384
+
368
385
  function identityRelayReason(reason) {
369
386
  if (reason === 'expired') return 'expired';
370
387
  if (reason === 'replay' || reason === 'challenge-replay') return 'replay';
@@ -417,7 +434,24 @@ function createLeaseManager(cfg = {}, seams = {}) {
417
434
  writeSafe(socket, { type: 'generationAck', generation: msg.generation });
418
435
  }
419
436
 
420
- function handleChallengeProof(cellId, entry, socket, msg) {
437
+ function validCellRequestId(value) {
438
+ return (typeof value === 'string' && value.length > 0 && value.length <= 128)
439
+ || Number.isSafeInteger(value);
440
+ }
441
+
442
+ function validVerifyRequest(msg) {
443
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return false;
444
+ if (!validCellRequestId(msg.requestId)) return false;
445
+ if (msg.v !== 1) return false;
446
+ if (!Number.isSafeInteger(msg.generation) || msg.generation < 0) return false;
447
+ if (!msg.proof || typeof msg.proof !== 'object' || Array.isArray(msg.proof)) return false;
448
+ // Verify v1.1: la tupla attesa {nonce, connectionId, daemonBootId, audience}
449
+ // e' opzionale (retro-compat v1) ma, se presente, ha schema chiuso.
450
+ if (Object.hasOwn(msg, 'expected') && !validVerifyExpected(msg.expected)) return false;
451
+ return true;
452
+ }
453
+
454
+ function handleChallengeProof(cellId, entry, socket, msg) {
421
455
  if (!validChallengeProofRequest(msg)) {
422
456
  writeSafe(socket, { type: 'challengeProofResult', requestId: msg && msg.requestId, ok: false, reason: 'identity-unverified' });
423
457
  return;
@@ -436,6 +470,7 @@ function createLeaseManager(cfg = {}, seams = {}) {
436
470
  const issued = identityAuthority.issueConnectionProof({
437
471
  subject: { ...entry.subject },
438
472
  challenge: msg.challenge,
473
+ generation: msg.generation,
439
474
  });
440
475
  if (!issued || issued.ok !== true) {
441
476
  const reason = identityRelayReason(issued && issued.reason);
@@ -445,6 +480,73 @@ function createLeaseManager(cfg = {}, seams = {}) {
445
480
  writeSafe(socket, { type: 'challengeProofResult', requestId: msg.requestId, ok: true, proof: issued.proof });
446
481
  }
447
482
 
483
+ // verifica ONLINE di un identity proof presso l'authority. Il proof
484
+ // viaggia, ma la decisione resta qui: l'authority ri-calcola l'HMAC, la
485
+ // finestra temporale, il replay (nonce single-use) e la revoca (jti), e i
486
+ // claims vengono confrontati col SUBJECT AUTENTICATO del lancio — mai con
487
+ // campi del messaggio (C7-bis). Fail-closed: qualunque esito non ok resta
488
+ // una negazione motivata, il canale non apre nulla.
489
+ function handleVerify(cellId, entry, socket, msg) {
490
+ if (!validVerifyRequest(msg)) {
491
+ writeSafe(socket, { type: 'verifyResult', requestId: msg && msg.requestId, ok: false, v: 1, reason: 'identity-unverified' });
492
+ return;
493
+ }
494
+ const current = cells.get(cellId);
495
+ if (!current || current !== entry || entry.socket !== socket
496
+ || !entry.lease || !L.isLive(entry.lease)
497
+ || entry.lease.generation !== msg.generation || !entry.subject) {
498
+ writeSafe(socket, { type: 'verifyResult', requestId: msg.requestId, ok: false, v: 1, reason: 'revoked' });
499
+ return;
500
+ }
501
+ if (!identityAuthority || typeof identityAuthority.verifyChallengeProof !== 'function') {
502
+ writeSafe(socket, { type: 'verifyResult', requestId: msg.requestId, ok: false, v: 1, reason: 'authority-unavailable' });
503
+ return;
504
+ }
505
+ const subject = entry.subject;
506
+ const checked = identityAuthority.verifyChallengeProof(msg.proof, {
507
+ ownerInstanceId: subject.ownerInstanceId,
508
+ cellId: subject.cellId,
509
+ incarnationId: subject.incarnationId,
510
+ launchEpoch: subject.launchEpoch,
511
+ // Verify v1.1: la tupla attesa del daemon viaggia nella richiesta e
512
+ // vincola il proof alla challenge emessa (mai claims del client).
513
+ ...(msg.expected ? msg.expected : {}),
514
+ });
515
+ if (!checked || checked.ok !== true) {
516
+ const reason = identityVerifyReason(checked && checked.reason);
517
+ writeSafe(socket, { type: 'verifyResult', requestId: msg.requestId, ok: false, v: 1, reason });
518
+ return;
519
+ }
520
+ const c = checked.claims;
521
+ writeSafe(socket, {
522
+ type: 'verifyResult', requestId: msg.requestId, ok: true, v: 1,
523
+ // Verify v1.1: TUTTI i campi che entrano nel binding VL, gia'
524
+ // normalizzati (nessun campo puo' arrivare dal client). issuerOwner =
525
+ // ownerInstanceId (mappatura legacy remote.rs:384), notBefore =
526
+ // issuedAt, origin e' il contesto del canale lease locale; tmuxSession,
527
+ // bindingId e scopes arrivano dal proof firmato dell'authority.
528
+ claims: {
529
+ ownerInstanceId: c.ownerInstanceId,
530
+ issuerOwner: c.ownerInstanceId,
531
+ cellId: c.cellId,
532
+ audience: c.audience,
533
+ incarnationId: c.incarnationId,
534
+ launchEpoch: c.launchEpoch,
535
+ daemonBootId: c.daemonBootId,
536
+ connectionId: c.connectionId,
537
+ nonce: c.nonce,
538
+ issuedAt: c.issuedAt,
539
+ notBefore: c.issuedAt,
540
+ expiresAt: c.expiresAt,
541
+ generation: c.generation,
542
+ tmuxSession: c.tmuxSession,
543
+ bindingId: c.bindingId,
544
+ scopes: c.scopes,
545
+ origin: 'local_tui',
546
+ },
547
+ });
548
+ }
549
+
448
550
  function onStableConnection(cellId, socket) {
449
551
  // Endpoint stabile: reconnect (R3.3.2-4). Legge identita' + proof, valida.
450
552
  let buf = '';
@@ -9,6 +9,7 @@
9
9
  const crypto = require('node:crypto');
10
10
  const os = require('node:os');
11
11
  const path = require('node:path');
12
+ const { tmuxSessionForCell } = require('./definitions.js');
12
13
  const { loadOrCreateVerifier } = require('./lease-verifier.js');
13
14
 
14
15
  const CHALLENGE_TTL_MS = 15_000;
@@ -24,8 +25,13 @@ const GRANT_FIELDS = Object.freeze([
24
25
  'jti', 'issuedAt', 'expiresAt',
25
26
  ]);
26
27
  const PROOF_FIELDS = Object.freeze([
27
- ...GRANT_FIELDS.slice(0, 9), 'nonce', 'parentJti', 'jti', 'issuedAt', 'expiresAt', 'generation',
28
+ ...GRANT_FIELDS.slice(0, 9), 'nonce', 'parentJti', 'jti', 'issuedAt', 'expiresAt',
29
+ 'authorityGeneration', 'generation', 'tmuxSession', 'bindingId', 'scopes',
28
30
  ]);
31
+ // Tupla attesa della connessione (verify v1.1): il daemon la dichiara nella
32
+ // richiesta verify; l'authority rifiuta 'challenge_mismatch' se il proof non
33
+ // corrisponde a questa tupla o se il nonce non e' una challenge emessa.
34
+ const TUPLE_EXPECTED_FIELDS = Object.freeze(['nonce', 'connectionId', 'daemonBootId', 'audience']);
29
35
 
30
36
  function nonEmpty(value) { return typeof value === 'string' && value.length > 0; }
31
37
 
@@ -125,7 +131,7 @@ function createIdentityAuthority({
125
131
  // Fresh per authority incarnation and never persisted: a restart of the
126
132
  // authority changes it, so every proof issued before the restart fails
127
133
  // closed instead of surviving with the persisted HMAC key.
128
- const generation = hexNonce(randomBytes);
134
+ const authorityGeneration = hexNonce(randomBytes);
129
135
  const challenges = new Map();
130
136
  // Bounded stores: every entry carries its own expiry, saturated stores
131
137
  // reject instead of evicting valid entries, and only expired entries are
@@ -228,7 +234,8 @@ function createIdentityAuthority({
228
234
  return { ok: true, grant: sign(verifier, GRANT_FIELDS, claims) };
229
235
  }
230
236
 
231
- function issueChallengeProof({ launchGrant, challenge, nonce: candidateNonce, expiresAt: candidateExpiresAt } = {}) {
237
+ function issueChallengeProof({ launchGrant, challenge, nonce: candidateNonce, expiresAt: candidateExpiresAt, generation = 0 } = {}) {
238
+ if (!Number.isSafeInteger(generation) || generation < 0) return { ok: false, reason: 'generation' };
232
239
  const checked = verify(verifier, GRANT_FIELDS, launchGrant, { now, expected: { kind: 'launch-grant', challenge } });
233
240
  if (!checked.ok) return checked;
234
241
  const record = challenges.get(challenge);
@@ -260,8 +267,14 @@ function createIdentityAuthority({
260
267
  jti: hexNonce(randomBytes),
261
268
  issuedAt,
262
269
  expiresAt,
270
+ authorityGeneration,
263
271
  generation,
272
+ tmuxSession: tmuxSessionForCell(checked.claims.cellId),
273
+ bindingId: null,
274
+ scopes: ['thread/start'],
264
275
  };
276
+ claims.bindingId = claims.jti;
277
+ if (!claims.tmuxSession) return { ok: false, reason: 'claims' };
265
278
  return { ok: true, proof: sign(verifier, PROOF_FIELDS, claims) };
266
279
  }
267
280
 
@@ -272,6 +285,7 @@ function createIdentityAuthority({
272
285
  launcherCredential,
273
286
  serviceCredential,
274
287
  expiresAt: externalExpiresAt,
288
+ generation = 0,
275
289
  } = {}) {
276
290
  if (!challenge || typeof challenge !== 'object') return { ok: false, reason: 'challenge' };
277
291
  if (!nonEmpty(challenge.nonce)) return { ok: false, reason: 'challenge' };
@@ -316,17 +330,61 @@ function createIdentityAuthority({
316
330
  challenge: challengeKey,
317
331
  nonce: challenge.nonce,
318
332
  expiresAt: effectiveExpiresAt,
333
+ generation,
319
334
  });
320
335
  return proof;
321
336
  }
322
337
 
338
+ // Verify v1.1: la tupla attesa dal daemon {nonce, connectionId,
339
+ // daemonBootId, audience} lega il proof alla challenge EMESSA da questa
340
+ // authority: il nonce deve essere una challenge registrata e ogni campo
341
+ // della tupla deve coincidere sia con l'emissione sia con il proof.
342
+ // Mismatch sulla tupla -> 'challenge_mismatch' (enum chiusa del contratto
343
+ // verify); i subject field restano rifiuti per nome campo (v1).
323
344
  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' };
345
+ const subjectExpected = {};
346
+ const tupleExpected = {};
347
+ for (const field of ['ownerInstanceId', 'cellId', 'incarnationId', 'launchEpoch']) {
348
+ if (expected[field] !== undefined) subjectExpected[field] = expected[field];
349
+ }
350
+ for (const field of TUPLE_EXPECTED_FIELDS) {
351
+ if (expected[field] !== undefined) tupleExpected[field] = expected[field];
352
+ }
353
+ // v1.1 solo con la tupla COMPLETA (i quattro campi, nonce incluso):
354
+ // i vincoli parziali restano semantics v1 (rifiuto per nome campo),
355
+ // perche' i chiamanti v1 usano expected come vincoli per-campo.
356
+ const tupleRequired = TUPLE_EXPECTED_FIELDS.every((field) => expected[field] !== undefined);
357
+ if (tupleRequired && !TUPLE_EXPECTED_FIELDS.every((field) => nonEmpty(tupleExpected[field]))) {
358
+ return { ok: false, reason: 'malformed' };
359
+ }
360
+ if (tupleRequired) {
361
+ sweepExpired();
362
+ const record = challenges.get(tupleExpected.nonce);
363
+ if (!record
364
+ || record.audience !== tupleExpected.audience
365
+ || record.daemonBootId !== tupleExpected.daemonBootId
366
+ || record.connectionId !== tupleExpected.connectionId) {
367
+ return { ok: false, reason: 'challenge_mismatch' };
368
+ }
369
+ }
370
+ const checked = verify(verifier, PROOF_FIELDS, proof, { now, expected: { kind: 'identity-proof', ...subjectExpected, ...tupleExpected } });
371
+ if (!checked.ok) {
372
+ if (TUPLE_EXPECTED_FIELDS.includes(checked.reason)) return { ok: false, reason: 'challenge_mismatch' };
373
+ return checked;
374
+ }
375
+ if (!safeEqual(checked.claims.authorityGeneration, authorityGeneration)) return { ok: false, reason: 'generation' };
327
376
  if (revoked.has(checked.claims.jti) || revoked.has(checked.claims.parentJti)) {
328
377
  return { ok: false, reason: 'revoked' };
329
378
  }
379
+ // Verify v1.1: il nonce restituito nei claims e' quello del RECORD di
380
+ // emissione (nonce della challenge per cui il proof e' stato emesso),
381
+ // mai un valore di input: se la challenge non e' piu' nel registro si
382
+ // rifiuta (fail-closed) invece di fidarsi del campo del proof.
383
+ sweepExpired();
384
+ const emitted = challenges.get(checked.claims.challenge);
385
+ if (!emitted || emitted.challenge !== checked.claims.nonce) {
386
+ return { ok: false, reason: 'challenge_mismatch' };
387
+ }
330
388
  const reserved = reserveReplay(checked.claims.nonce, Number(checked.claims.expiresAt));
331
389
  if (!reserved.ok) return { ok: false, reason: reserved.reason };
332
390
  return checked;
@@ -364,4 +422,5 @@ module.exports = {
364
422
  CHALLENGE_TTL_MS,
365
423
  GRANT_TTL_MS,
366
424
  NONCE_BYTES,
425
+ TUPLE_EXPECTED_FIELDS,
367
426
  };
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ // Provisioning dell'authority mode. Genera le DUE credenziali distinte
3
+ // (daemon + launcher) nella directory dell'authority, con permessi 0700/0600,
4
+ // e attiva `fleet.identity.mode = "authority"` nella config. Idempotente:
5
+ // credenziali esistenti non vengono MAI sovrascritte senza --force.
6
+ //
7
+ // Nessun valore di credenziale torna nel ritorno e nessun valore finisce nei
8
+ // log: il ritorno descrive solo percorsi e azioni.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const crypto = require('node:crypto');
13
+
14
+ const CREDENTIAL_FILES = Object.freeze({
15
+ identityDaemonCredential: 'daemon.credential',
16
+ identityLauncherCredential: 'launcher.credential',
17
+ });
18
+
19
+ // Le due credenziali devono essere DISTINTE (stessa regola di
20
+ // identity-authority a runtime): confronto a tempo costante sui byte.
21
+ function credentialsDistinct(daemonCredential, launcherCredential) {
22
+ if (typeof daemonCredential !== 'string' || !daemonCredential
23
+ || typeof launcherCredential !== 'string' || !launcherCredential) return false;
24
+ const daemon = Buffer.from(daemonCredential);
25
+ const launcher = Buffer.from(launcherCredential);
26
+ if (daemon.length !== launcher.length) return true;
27
+ return !crypto.timingSafeEqual(daemon, launcher);
28
+ }
29
+
30
+ function defaultAuthorityDir(home) {
31
+ return path.join(home || require('node:os').homedir(), '.nexuscrew', 'identity-authority');
32
+ }
33
+
34
+ function assertSafeMode(filePath, expectedMode) {
35
+ const st = fs.lstatSync(filePath);
36
+ if (!st.isFile()) throw new Error(`identity provision: ${filePath} non e' un file regolare`);
37
+ if ((st.mode & 0o777) !== expectedMode) {
38
+ throw new Error(`identity provision: ${filePath} ha permessi ${(st.mode & 0o777).toString(8)} (attesi ${expectedMode.toString(8)})`);
39
+ }
40
+ }
41
+
42
+ function existingCredentials(dir) {
43
+ const found = [];
44
+ for (const name of Object.values(CREDENTIAL_FILES)) {
45
+ const filePath = path.join(dir, name);
46
+ try {
47
+ const st = fs.lstatSync(filePath);
48
+ if (st.isFile() && st.size > 0) found.push(filePath);
49
+ } catch (_) { /* assente: ok */ }
50
+ }
51
+ return found;
52
+ }
53
+
54
+ function provisionIdentityAuthority({
55
+ home,
56
+ dir,
57
+ force = false,
58
+ writeConfig = true,
59
+ configPath,
60
+ randomBytes = crypto.randomBytes,
61
+ fsImpl = fs,
62
+ now = new Date,
63
+ } = {}) {
64
+ const targetDir = dir || defaultAuthorityDir(home);
65
+ fsImpl.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
66
+
67
+ const preExisting = existingCredentials(targetDir);
68
+ if (preExisting.length && !force) {
69
+ return {
70
+ ok: false, code: 'ALREADY_PROVISIONED', dir: targetDir,
71
+ existing: preExisting,
72
+ message: 'identity authority already provisioned; pass --force to regenerate',
73
+ };
74
+ }
75
+
76
+ const written = [];
77
+ let daemonValue = randomBytes(32).toString('hex');
78
+ let launcherValue = randomBytes(32).toString('hex');
79
+ // Rigenera finché le due credenziali sono distinte (confronto a tempo
80
+ // costante, stessa regola di identity-authority). Con una fonte casuale
81
+ // sana la prima iterazione passa; con randomBytes iniettati degeneri il
82
+ // provisioning viene RIFIUTATO anche con --force, senza scrivere nulla.
83
+ for (let attempt = 0; attempt < 8; attempt += 1) {
84
+ if (credentialsDistinct(daemonValue, launcherValue)) break;
85
+ launcherValue = randomBytes(32).toString('hex');
86
+ if (attempt === 7 && !credentialsDistinct(daemonValue, launcherValue)) {
87
+ return {
88
+ ok: false, code: 'IDENTICAL_CREDENTIALS', dir: targetDir,
89
+ message: 'identity provision: generated credentials are identical; nothing written',
90
+ };
91
+ }
92
+ }
93
+ for (const [name, value] of [
94
+ [CREDENTIAL_FILES.identityDaemonCredential, daemonValue],
95
+ [CREDENTIAL_FILES.identityLauncherCredential, launcherValue],
96
+ ]) {
97
+ const filePath = path.join(targetDir, name);
98
+ fsImpl.writeFileSync(filePath, `${value}\n`, { mode: 0o600 });
99
+ written.push(filePath);
100
+ }
101
+ // I permessi espliciti valgono anche su filesystem che ignorano mode in
102
+ // writeFileSync (umask larghe): rinforza dopo la scrittura.
103
+ fsImpl.chmodSync(targetDir, 0o700);
104
+ for (const filePath of written) fsImpl.chmodSync(filePath, 0o600);
105
+ for (const filePath of written) assertSafeMode(filePath, 0o600);
106
+
107
+ const resolvedConfigPath = configPath
108
+ || (process.env.NEXUSCREW_CONFIG_FILE
109
+ || path.join(home || require('node:os').homedir(), '.nexuscrew', 'config.json'));
110
+
111
+ let configUpdated = false;
112
+ let configInstructions = null;
113
+ if (writeConfig) {
114
+ let config = {};
115
+ try {
116
+ config = JSON.parse(fsImpl.readFileSync(resolvedConfigPath, 'utf8'));
117
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
118
+ } catch (_) { config = {}; }
119
+ config.fleet = { ...(config.fleet || {}), identity: { ...(config.fleet?.identity || {}), mode: 'authority' } };
120
+ fsImpl.mkdirSync(path.dirname(resolvedConfigPath), { recursive: true });
121
+ fsImpl.writeFileSync(resolvedConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
122
+ configUpdated = true;
123
+ } else {
124
+ configInstructions = [
125
+ 'Set the following keys in the NexusCrew config (fleet.identity.mode = "authority")',
126
+ `and keep the two credential files in place under ${targetDir}:`,
127
+ ...Object.values(CREDENTIAL_FILES).map((name) => ` ${path.join(targetDir, name)}`),
128
+ ].join('\n');
129
+ }
130
+
131
+ return {
132
+ ok: true, dir: targetDir, written, configUpdated, configInstructions,
133
+ mode: 'authority',
134
+ };
135
+ }
136
+
137
+ module.exports = {
138
+ provisionIdentityAuthority,
139
+ defaultAuthorityDir,
140
+ credentialsDistinct,
141
+ CREDENTIAL_FILES,
142
+ };