@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.
- package/CHANGELOG.md +100 -0
- package/frontend/dist/assets/{index-BmA9pnC1.js → index-DzCo6cJc.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +33 -1
- package/lib/cli/doctor.js +87 -4
- package/lib/config.js +51 -1
- package/lib/fleet/builtin.js +13 -1
- package/lib/fleet/catalogs/opencode-go.json +200 -16
- package/lib/fleet/cell-exec.js +113 -11
- package/lib/fleet/cell-lease-server.js +104 -2
- package/lib/fleet/identity-authority.js +65 -6
- package/lib/fleet/identity-provision.js +142 -0
- package/lib/fleet/lease-client.js +78 -1
- package/lib/fleet/managed.js +140 -14
- package/lib/fleet/runtime.js +30 -1
- package/lib/mcp/identity-provider.js +133 -5
- package/lib/mcp/identity-schema.js +2 -0
- package/lib/mcp/server.js +50 -9
- package/lib/mcp/tools.js +15 -4
- package/package.json +1 -1
package/lib/fleet/cell-exec.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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',
|
|
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
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
+
};
|