@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1
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 +288 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/server.mjs +74 -11
package/src/agent/p2p.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
REFUSE_REASON,
|
|
77
77
|
respondHandshake,
|
|
78
78
|
startHandshake,
|
|
79
|
+
verifyRefuseMac,
|
|
79
80
|
} from './crypto.mjs';
|
|
80
81
|
|
|
81
82
|
/** Frame kinds. Exported so tests and callers can name them instead of using magic numbers. */
|
|
@@ -270,9 +271,17 @@ export function reverseDialSession({ originMachineId, taskId, machineId } = {})
|
|
|
270
271
|
* * `plaintext` — no handshake was possible (no key for the peer, or the peer sent none), and the
|
|
271
272
|
* reason says which. This is the v0.5.x compatibility path, and it is recorded, never hidden.
|
|
272
273
|
* * `refused` — a handshake was attempted and did not complete. `reason` is a
|
|
273
|
-
* `crypto.mjs` reason code. **
|
|
274
|
-
* downgrading to plaintext" is enforced where the channel is created rather than left to
|
|
275
|
-
* to remember.
|
|
274
|
+
* `crypto.mjs` reason code. **No plaintext channel is handed back on this path**: §4's "never
|
|
275
|
+
* silently downgrading to plaintext" is enforced where the channel is created rather than left to
|
|
276
|
+
* a caller to remember.
|
|
277
|
+
*
|
|
278
|
+
* That last sentence used to be written as "there is no plaintext channel in this case", which is a
|
|
279
|
+
* stronger claim than the code makes and was falsified by the v0.5.9 review: a `REFUSE(NO_PEER_KEY)`
|
|
280
|
+
* that arrives from nowhere still *chose* the `plaintext` outcome (the branch below the refusal check),
|
|
281
|
+
* so a punch ended as `ok: true` with a plaintext channel instead of `refused`. Which is why the
|
|
282
|
+
* refusal is now guarded where it is read — see {@link REFUSE_GUARD} — and why `plaintext` carries
|
|
283
|
+
* `refusal_authenticated` when a refusal is what chose it: the honest wording is "a *failed* handshake
|
|
284
|
+
* never falls back into the clear", not "no datagram can make this a plaintext punch".
|
|
276
285
|
*
|
|
277
286
|
* @param {'encrypted'|'plaintext'|'refused'} status
|
|
278
287
|
* @param {string|null} reason
|
|
@@ -301,17 +310,134 @@ function channelCrypto(outcome, tuning = {}) {
|
|
|
301
310
|
return { session: outcome.session, isInitiator: outcome.isInitiator === true, tuning };
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
/**
|
|
314
|
+
* What a `REFUSE` datagram must satisfy before it may change a punch's outcome — as a value, so the
|
|
315
|
+
* contract cannot drift away from the code the way a comment can.
|
|
316
|
+
*
|
|
317
|
+
* `crypto.mjs` sends `REFUSE` with no proof in the one case where it has none to give: it is answering
|
|
318
|
+
* a handshake it cannot verify, because it holds no key for that machine. That is honest, and it is
|
|
319
|
+
* also a lever: any datagram of 13 bytes that reaches the dialling socket can claim to be that peer
|
|
320
|
+
* refusing, and the naive reading of it turns "the handshake did not complete" (a named failure, the
|
|
321
|
+
* relay fallback) into "the peer cannot take part" (a **plaintext channel**, and a ledger that says
|
|
322
|
+
* the peer was the one who could not). MEDIUM-4 of the v0.5.9 review reproduced exactly that with a
|
|
323
|
+
* third socket on loopback.
|
|
324
|
+
*
|
|
325
|
+
* So the refusal is honoured only when all of these hold, and the fourth narrows the one that
|
|
326
|
+
* matters most:
|
|
327
|
+
*
|
|
328
|
+
* 1. **It names the session this punch agreed** — the frame header *and* the refusal body. A
|
|
329
|
+
* refusal for a punch we are not making cannot be about us.
|
|
330
|
+
* 2. **It comes from an address this punch punched**: one of the candidates we sent `HELLO` to, or
|
|
331
|
+
* the address that has already answered. A refusal from a third address is not this peer's.
|
|
332
|
+
* 3. **A seal never buys a downgrade it cannot prove.** A refusal that would choose a plaintext
|
|
333
|
+
* channel (`NO_KEY`, `NO_PEER_KEY`) is honoured when its `k_ref` seal verifies, or — for a peer too
|
|
334
|
+
* old to seal one — when it carries no seal at all *and* comes from the endpoint that answered the
|
|
335
|
+
* punch (condition 4). A **sealed** downgrade whose MAC does not verify is dropped: an honest peer
|
|
336
|
+
* either seals correctly or sends no MAC at all, so that datagram was assembled by somebody else.
|
|
337
|
+
* 4. **An unsealed downgrade must be corroborated** by the same peer having acknowledged the punch.
|
|
338
|
+
* That branch does not fail the punch, it *downgrades* it, so it is the one place where a claim
|
|
339
|
+
* that cannot be checked has to come from the endpoint that actually answered. A *sealed*
|
|
340
|
+
* downgrade needs no corroboration: it is the peer.
|
|
341
|
+
*
|
|
342
|
+
* Every other (**terminal**) refusal is honoured from a dialled address whether or not it is sealed, and
|
|
343
|
+
* `crypto.refusal_authenticated` says which it was: `true` (a seal that verified — proof), `null` (no
|
|
344
|
+
* seal: the form an older build sends), `false` (a seal that did not verify). Dropping a terminal
|
|
345
|
+
* refusal over a bad seal would buy nothing — an attacker forging one simply omits the seal — and would
|
|
346
|
+
* cost the honest case its name: a peer whose record of *our* key is stale computes a `k_ref` we cannot
|
|
347
|
+
* reproduce, and "your proof did not verify" is exactly what it is telling us.
|
|
348
|
+
*
|
|
349
|
+
* A datagram that fails 1, 2 or 3 is **dropped**, and the drop is recorded on the result in
|
|
350
|
+
* `crypto.ignored_refusals` with a `reason_code` from `CRYPTO_REASON`. Dropped rather than reported as
|
|
351
|
+
* a failure on purpose: if a forged refusal could fail the punch, injecting one would be a cheaper
|
|
352
|
+
* denial of service than suppressing the real answer, and the honest peer would be failed for it.
|
|
353
|
+
*
|
|
354
|
+
* RESIDUAL, stated because the documents above quote it: an attacker who can put a datagram on the
|
|
355
|
+
* wire *from the punched peer's address and port* (spoofing the source, so on-path or on the same
|
|
356
|
+
* host as the punch) can still satisfy 1, 2 and 4 with an **unsealed** `NO_PEER_KEY` — the one form a
|
|
357
|
+
* peer running an older build sends, and therefore the one form that cannot be required to carry a
|
|
358
|
+
* seal. That attacker can force the recorded plaintext outcome; if the real peer answers first, an
|
|
359
|
+
* injected terminal refusal can also force the relay fallback. What no longer works is a datagram
|
|
360
|
+
* from anywhere else, one that names another session, a sealed downgrade that does not verify, or a
|
|
361
|
+
* forced downgrade against a peer new enough to seal one — for those, `k_ref` cannot be forged by
|
|
362
|
+
* anyone who does not hold the peer's static private key.
|
|
363
|
+
*/
|
|
364
|
+
export const REFUSE_GUARD = Object.freeze({
|
|
365
|
+
requires: [
|
|
366
|
+
'names the session this punch agreed (frame header and body)',
|
|
367
|
+
'comes from an address this punch dialled, or from the peer that answered it',
|
|
368
|
+
'a plaintext-choosing refusal is sealed with a k_ref MAC that verifies, or carries no seal at all',
|
|
369
|
+
'an unsealed plaintext-choosing refusal (NO_KEY, NO_PEER_KEY) comes from the address that answered the punch',
|
|
370
|
+
],
|
|
371
|
+
on_failure: 'the datagram is dropped and recorded in crypto.ignored_refusals; the punch outcome is unchanged',
|
|
372
|
+
terminal_refusals:
|
|
373
|
+
'honoured from a dialled address sealed or not; crypto.refusal_authenticated records true (seal verified), '
|
|
374
|
+
+ 'null (no seal) or false (a seal that did not verify), so a claim is never mistaken for proof',
|
|
375
|
+
residual:
|
|
376
|
+
'an attacker who can inject an unsealed refusal from the punched peer\'s own address and port can still '
|
|
377
|
+
+ 'force the recorded plaintext outcome, or the relay fallback with a terminal refusal; a sealed downgrade '
|
|
378
|
+
+ 'cannot be forged without the peer\'s static private key',
|
|
379
|
+
});
|
|
380
|
+
|
|
304
381
|
/**
|
|
305
382
|
* A refusal frame, if this datagram is one. Extracted because three branches ask the same question.
|
|
306
383
|
*
|
|
384
|
+
* `key` is `k_ref` — the key this end derives from the handshake ephemeral (see `crypto.mjs`) — when a
|
|
385
|
+
* handshake is in flight. It decides one thing only: whether the refusal's optional seal can be
|
|
386
|
+
* checked. `authenticated` is deliberately three-valued — `true` (a seal was present and verified),
|
|
387
|
+
* `false` (a seal was present and did *not* verify), `null` (no seal at all, which is what a peer too
|
|
388
|
+
* old to seal one sends and which proves nothing).
|
|
389
|
+
*
|
|
307
390
|
* @param {{kind:number, session:number, body:Buffer}} parsed
|
|
308
|
-
* @
|
|
391
|
+
* @param {Buffer|null} [key] `k_ref` for this exchange, or null when no handshake is in flight.
|
|
392
|
+
* @returns {{code:number, reason:string, session:number, machineId:string|null,
|
|
393
|
+
* authenticated:boolean|null}|null} The named reason, or null when this is not a refusal.
|
|
309
394
|
*/
|
|
310
|
-
function cryptoRefusalOf(parsed) {
|
|
395
|
+
function cryptoRefusalOf(parsed, key = null) {
|
|
311
396
|
if (parsed.kind !== KIND.HELLO_ACK && parsed.kind !== KIND.HELLO) return null;
|
|
312
397
|
const refuse = decodeRefuse(parsed.body);
|
|
313
398
|
if (refuse === null) return null;
|
|
314
|
-
|
|
399
|
+
let authenticated = null;
|
|
400
|
+
if (refuse.mac !== null) authenticated = key === null ? false : verifyRefuseMac(key, parsed.body) === true;
|
|
401
|
+
return {
|
|
402
|
+
code: refuse.code,
|
|
403
|
+
reason: REFUSE_REASON[refuse.code] ?? CRYPTO_REASON.REFUSED_BY_PEER,
|
|
404
|
+
session: refuse.session,
|
|
405
|
+
machineId: refuse.machineId,
|
|
406
|
+
authenticated,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Why a refusal datagram was **ignored**, in the house `{reason_code, detail}` shape.
|
|
412
|
+
*
|
|
413
|
+
* Every one of these four is a refusal we declined to act on rather than a refusal that decided the
|
|
414
|
+
* punch, which is why they are collected on the result (`crypto.ignored_refusals`) instead of being
|
|
415
|
+
* raised as a failure: a datagram an attacker can forge must not be able to fail a punch either.
|
|
416
|
+
* `detail` says what *would* have changed it, because "why did my punch still time out?" is the
|
|
417
|
+
* question each of these exists to answer.
|
|
418
|
+
*
|
|
419
|
+
* @param {string} reasonCode One of `CRYPTO_REASON`'s four `REFUSE_*` codes.
|
|
420
|
+
* @param {string} detail
|
|
421
|
+
* @param {object} context
|
|
422
|
+
* @param {{kind:number, session:number, body:Buffer}} context.parsed
|
|
423
|
+
* @param {{address:string, port:number}} context.rinfo
|
|
424
|
+
* @param {number} context.agreed The session this punch agreed.
|
|
425
|
+
* @returns {{reason_code:string, detail:string, session:number, from:string, datagram_bytes:number}}
|
|
426
|
+
*/
|
|
427
|
+
function ignoredRefusal(reasonCode, detail, { parsed, rinfo, agreed }) {
|
|
428
|
+
return {
|
|
429
|
+
reason_code: reasonCode,
|
|
430
|
+
detail,
|
|
431
|
+
session: (parsed.session >>> 0),
|
|
432
|
+
agreed_session: (agreed >>> 0),
|
|
433
|
+
from: `${rinfo.address}:${rinfo.port}`,
|
|
434
|
+
datagram_bytes: parsed.body.length + 5,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Same remote endpoint? `null` on either side is "not the same". */
|
|
439
|
+
function sameEndpoint(a, b) {
|
|
440
|
+
return a !== null && b !== null && a.address === b.address && a.port === b.port;
|
|
315
441
|
}
|
|
316
442
|
|
|
317
443
|
/**
|
|
@@ -347,9 +473,11 @@ function finishFromInitiatorMessage(handshake, body) {
|
|
|
347
473
|
* * This end holds no key for the peer (a v0.5.x peer, or one that never announced): a plain
|
|
348
474
|
* `HELLO` is sent and the channel is plaintext with `P2P_CRYPTO_NO_KEY` as the reason.
|
|
349
475
|
* * This end holds a key and the handshake fails: the punch reports `ok: false` with the peer's
|
|
350
|
-
* named reason, and
|
|
351
|
-
*
|
|
352
|
-
*
|
|
476
|
+
* named reason, and no plaintext channel is handed back for that peer — but see
|
|
477
|
+
* {@link REFUSE_GUARD}: "the peer's named reason" is only acted on when the datagram carrying it
|
|
478
|
+
* can plausibly be the peer's, because `REFUSE` is unauthenticated by design. The caller falls
|
|
479
|
+
* back to the relay — §4's fallback clause — and the ledger records `p2p.encrypted: false` plus
|
|
480
|
+
* the reason.
|
|
353
481
|
*
|
|
354
482
|
* @param {object} options
|
|
355
483
|
* @param {import('node:dgram').Socket} options.socket Already bound, and the same socket STUN used.
|
|
@@ -366,7 +494,14 @@ function finishFromInitiatorMessage(handshake, body) {
|
|
|
366
494
|
* 2^20 messages.
|
|
367
495
|
* @returns {Promise<{ok:boolean, peer:{address:string,port:number}|null, session:number,
|
|
368
496
|
* attempts:number, rttMs:number|null, error:string|null,
|
|
369
|
-
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean
|
|
497
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean,
|
|
498
|
+
* refusal_authenticated?:boolean|null, ignored_refusals?:object[]}}>}
|
|
499
|
+
* `crypto.ignored_refusals` is present only when at least one `REFUSE` datagram was dropped by
|
|
500
|
+
* {@link REFUSE_GUARD}; each entry is `{reason_code, detail, session, agreed_session, from,
|
|
501
|
+
* datagram_bytes}` (`datagram_bytes` is null for the one drop decided at outcome time, when the
|
|
502
|
+
* datagram is no longer at hand). `crypto.refusal_authenticated` is present only when a refusal
|
|
503
|
+
* decided the outcome: `true` for a seal that verified, `null` for the unsealed form an older build
|
|
504
|
+
* sends, `false` for a seal that did not verify.
|
|
370
505
|
*/
|
|
371
506
|
export async function punch(options) {
|
|
372
507
|
const { socket } = options;
|
|
@@ -416,6 +551,13 @@ export async function punch(options) {
|
|
|
416
551
|
session: ownSession,
|
|
417
552
|
})
|
|
418
553
|
: null;
|
|
554
|
+
/**
|
|
555
|
+
* `k_ref` for the peer being dialled, when a handshake is in flight: what a refusal MAC is checked
|
|
556
|
+
* with. It comes from the handshake's own ephemeral (`ECDH(e_i, s_r)`), so it exists for every peer
|
|
557
|
+
* whose `HELLO` reached us — including one that holds no key for us, which is the case that sends
|
|
558
|
+
* the refusal MEDIUM-4 was about.
|
|
559
|
+
*/
|
|
560
|
+
const refusalKey = handshake === null ? null : handshake.refusalMacKey;
|
|
419
561
|
// Why there is no handshake, when there is none. `null` means one is in flight.
|
|
420
562
|
const noCryptoReason =
|
|
421
563
|
handshake !== null
|
|
@@ -437,21 +579,42 @@ export async function punch(options) {
|
|
|
437
579
|
let punchAttempts = 0;
|
|
438
580
|
/** Set when the handshake has definitively failed, so the punch says why instead of timing out. */
|
|
439
581
|
let refused = null;
|
|
582
|
+
/** Whether the refusal that set `refused` was `k0`-verified. See {@link REFUSE_GUARD}. */
|
|
583
|
+
let refusedAuthenticated = null;
|
|
440
584
|
/**
|
|
441
585
|
* Set when the peer has told us it can only do plaintext (`REFUSE.NO_KEY`).
|
|
442
586
|
*
|
|
443
587
|
* Separate from `refused` on purpose, and the distinction is the whole of §4's fallback clause: a
|
|
444
588
|
* refusal means "do not talk to me in the clear", a plaintext-only answer means "I cannot talk to
|
|
445
589
|
* you any other way, and here is why". The first is a failure, the second is a recorded outcome.
|
|
590
|
+
*
|
|
591
|
+
* Carries the source it arrived from and whether it was authenticated, because a plaintext channel
|
|
592
|
+
* is only handed back when the *same* endpoint that answered the punch is the one that asked for
|
|
593
|
+
* it — see {@link REFUSE_GUARD} condition 4.
|
|
446
594
|
*/
|
|
447
595
|
let plainOnly = null;
|
|
596
|
+
/** Every `REFUSE` datagram dropped by {@link REFUSE_GUARD}, in `{reason_code, detail}` form. */
|
|
597
|
+
const ignoredRefusals = [];
|
|
598
|
+
/** One drop per un-corroborated plaintext refusal, however many times the outcome is asked for. */
|
|
599
|
+
let plainOnlyDropRecorded = false;
|
|
600
|
+
|
|
601
|
+
/** Is this datagram from an address this punch actually punched, or from the peer that answered it? */
|
|
602
|
+
const isPunchedSource = (rinfo) =>
|
|
603
|
+
sameEndpoint(punchedPeer, rinfo) ||
|
|
604
|
+
remote.some((candidate) => candidate.address === rinfo.address && candidate.port === rinfo.port);
|
|
448
605
|
|
|
449
606
|
const finish = (result) => {
|
|
450
607
|
if (settled) return;
|
|
451
608
|
settled = true;
|
|
452
609
|
clearTimeout(timer);
|
|
453
610
|
socket.removeListener('message', onMessage);
|
|
454
|
-
|
|
611
|
+
// The drops are attached here rather than threaded through every outcome, and only when there
|
|
612
|
+
// were any: a punch nobody interfered with returns exactly the object it always did.
|
|
613
|
+
resolve(
|
|
614
|
+
ignoredRefusals.length === 0
|
|
615
|
+
? result
|
|
616
|
+
: { ...result, crypto: { ...result.crypto, ignored_refusals: ignoredRefusals } },
|
|
617
|
+
);
|
|
455
618
|
};
|
|
456
619
|
|
|
457
620
|
/** The path is open: report it with whatever the handshake managed to do. */
|
|
@@ -510,22 +673,76 @@ export async function punch(options) {
|
|
|
510
673
|
}
|
|
511
674
|
|
|
512
675
|
if (parsed.kind === KIND.HELLO_ACK) {
|
|
513
|
-
const refusal = cryptoRefusalOf(parsed);
|
|
676
|
+
const refusal = cryptoRefusalOf(parsed, refusalKey);
|
|
514
677
|
if (refusal !== null) {
|
|
515
|
-
|
|
678
|
+
// ---- REFUSE_GUARD, conditions 1 and 2: this refusal has to be about *this* punch, from an
|
|
679
|
+
// address this punch actually dialled. Both checks come before anything else reads the
|
|
680
|
+
// datagram, and both are recorded rather than raised: see REFUSE_GUARD's `on_failure`.
|
|
681
|
+
if (parsed.session !== agreed || refusal.session !== agreed) {
|
|
682
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
683
|
+
CRYPTO_REASON.REFUSE_SESSION_MISMATCH,
|
|
684
|
+
`the refusal named session ${refusal.session} in a frame for session ${parsed.session}; a refusal `
|
|
685
|
+
+ `that names the agreed session ${agreed} is honoured`,
|
|
686
|
+
{ parsed, rinfo, agreed },
|
|
687
|
+
));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (!isPunchedSource(rinfo)) {
|
|
691
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
692
|
+
CRYPTO_REASON.REFUSE_UNPUNCHED_SOURCE,
|
|
693
|
+
`it arrived from ${rinfo.address}:${rinfo.port}, which is neither one of the ${remote.length} `
|
|
694
|
+
+ 'address(es) this punch dialled nor the peer that answered it; a refusal from one of those is '
|
|
695
|
+
+ 'honoured',
|
|
696
|
+
{ parsed, rinfo, agreed },
|
|
697
|
+
));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
// ---- condition 3, first half: a MAC that was present and *wrong* buys nothing.
|
|
701
|
+
//
|
|
702
|
+
// It cannot be proof, and — for the one refusal that would choose a plaintext channel — it
|
|
703
|
+
// must not even be a claim: `NO_KEY`/`NO_PEER_KEY` are the refusals a *legitimate* peer seals
|
|
704
|
+
// with `k_ref` (it holds our `e_i` and its own `s_r`), so a sealed one that does not verify is
|
|
705
|
+
// not a peer whose records disagree with ours; it is a datagram somebody assembled. Dropping
|
|
706
|
+
// it costs an honest peer nothing, because an honest peer that cannot derive `k_ref` sends no
|
|
707
|
+
// MAC at all, which is the claim path below.
|
|
708
|
+
const wantsPlaintext = refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY;
|
|
709
|
+
if (wantsPlaintext && refusal.authenticated === false) {
|
|
710
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
711
|
+
CRYPTO_REASON.REFUSE_BAD_MAC,
|
|
712
|
+
'it asked for a plaintext channel and carried a refusal MAC that did not verify; a refusal sealed '
|
|
713
|
+
+ 'with the key derived from the handshake ephemeral is honoured, and an older peer that sends no '
|
|
714
|
+
+ 'MAC at all is honoured from the address that answered the punch',
|
|
715
|
+
{ parsed, rinfo, agreed },
|
|
716
|
+
));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (wantsPlaintext) {
|
|
516
720
|
// The peer has a keypair of its own and none *for us*, and it said so instead of answering
|
|
517
721
|
// in a way we would have to guess about. That is the plaintext-only case, not a failure:
|
|
518
722
|
// §4's fallback clause is explicitly about not losing a machine to a security upgrade, and
|
|
519
723
|
// this is the peer naming the upgrade it could not take part in. The plain acknowledgement
|
|
520
724
|
// follows from the same responder, so the punch settles on that.
|
|
521
|
-
|
|
725
|
+
//
|
|
726
|
+
// This is also the one refusal that *chooses a plaintext channel* rather than failing the
|
|
727
|
+
// punch, so it is the one held to REFUSE_GUARD's fourth condition, and that condition can
|
|
728
|
+
// only be checked at outcome time: the refusal and the plain acknowledgement are two
|
|
729
|
+
// datagrams, either may arrive first, and only once both have is there something to
|
|
730
|
+
// corroborate. A refusal sealed with `k_ref` needs no corroboration — it is the peer.
|
|
731
|
+
plainOnly = {
|
|
732
|
+
reason: CRYPTO_REASON.NO_KEY,
|
|
733
|
+
source: { address: rinfo.address, port: rinfo.port },
|
|
734
|
+
authenticated: refusal.authenticated,
|
|
735
|
+
};
|
|
522
736
|
return;
|
|
523
737
|
}
|
|
524
738
|
// Any other refusal is final — `BAD_MAC` means the peer is known and its proof did not
|
|
525
739
|
// verify, which is a stale key or an impersonation attempt, and neither is a reason to fall
|
|
526
740
|
// back into the clear. Named, and never downgraded: this is the path where a channel is
|
|
527
|
-
// deliberately not handed back at all.
|
|
741
|
+
// deliberately not handed back at all. A failed MAC does not disqualify it, because it can
|
|
742
|
+
// only ever fail a punch that was already failing, and an honest peer with a stale record of
|
|
743
|
+
// *our* key is exactly the peer that sends one; `refusal_authenticated` says which it was.
|
|
528
744
|
refused = refusal.reason;
|
|
745
|
+
refusedAuthenticated = refusal.authenticated;
|
|
529
746
|
finish({
|
|
530
747
|
ok: false,
|
|
531
748
|
peer: punchedPeer,
|
|
@@ -533,10 +750,14 @@ export async function punch(options) {
|
|
|
533
750
|
attempts: punchAttempts,
|
|
534
751
|
rttMs: punchedRtt,
|
|
535
752
|
error: `${refusal.reason}: the peer refused the handshake`,
|
|
536
|
-
crypto:
|
|
753
|
+
crypto: {
|
|
754
|
+
...cryptoOutcome('refused', refusal.reason),
|
|
755
|
+
refusal_authenticated: refusal.authenticated,
|
|
756
|
+
},
|
|
537
757
|
});
|
|
538
758
|
return;
|
|
539
|
-
}
|
|
759
|
+
}
|
|
760
|
+
if (parsed.session !== agreed) return;
|
|
540
761
|
notePeer(rinfo);
|
|
541
762
|
const ack = decodeHello(parsed.body);
|
|
542
763
|
if (ack === null) {
|
|
@@ -550,8 +771,9 @@ export async function punch(options) {
|
|
|
550
771
|
// answers plainly, and settling on that answer would turn "your key did not verify" into a
|
|
551
772
|
// silent plaintext channel — the exact outcome §4 forbids. Deferring costs one interval and
|
|
552
773
|
// only happens on the punch where there was something to wait for.
|
|
553
|
-
if (!wantCrypto)
|
|
554
|
-
|
|
774
|
+
if (!wantCrypto) {
|
|
775
|
+
finishPunched(plaintextOutcome() ?? cryptoOutcome('plaintext', noCryptoReason));
|
|
776
|
+
} else deferPlain();
|
|
555
777
|
return;
|
|
556
778
|
}
|
|
557
779
|
if (handshake === null) {
|
|
@@ -581,8 +803,43 @@ export async function punch(options) {
|
|
|
581
803
|
* lose the machine. A peer we hold a key for and never hear a handshake from is a different fact —
|
|
582
804
|
* `P2P_CRYPTO_HANDSHAKE_TIMEOUT` — and it falls back to the relay, which is what the contract says
|
|
583
805
|
* a failed handshake does.
|
|
806
|
+
*
|
|
807
|
+
* It is also where REFUSE_GUARD's fourth condition is enforced, and it has to be *here* rather than
|
|
808
|
+
* at the datagram: the refusal and the plain acknowledgement are two datagrams from the same
|
|
809
|
+
* responder, either may arrive first, and only once both have is there something to corroborate.
|
|
810
|
+
* A refusal that no acknowledgement from the same endpoint ever backs is not honoured — and, like
|
|
811
|
+
* every other drop, it does not fail the punch; the punch keeps waiting and then reports the
|
|
812
|
+
* timeout it honestly has.
|
|
813
|
+
*
|
|
814
|
+
* A refusal that *is* sealed with `k_ref` skips that condition entirely: it proves the sender holds
|
|
815
|
+
* the static key we dialled, which is more than an acknowledgement from the same address could.
|
|
584
816
|
*/
|
|
585
|
-
const plaintextOutcome = () =>
|
|
817
|
+
const plaintextOutcome = () => {
|
|
818
|
+
if (plainOnly === null) return null;
|
|
819
|
+
const corroborated = plainOnly.authenticated === true || sameEndpoint(punchedPeer, plainOnly.source);
|
|
820
|
+
if (!corroborated) {
|
|
821
|
+
if (!plainOnlyDropRecorded) {
|
|
822
|
+
plainOnlyDropRecorded = true;
|
|
823
|
+
ignoredRefusals.push({
|
|
824
|
+
reason_code: CRYPTO_REASON.REFUSE_UNCORROBORATED,
|
|
825
|
+
detail:
|
|
826
|
+
`it asked for a plaintext channel from ${plainOnly.source.address}:${plainOnly.source.port}, and the `
|
|
827
|
+
+ 'peer that answered this punch is '
|
|
828
|
+
+ (punchedPeer === null ? 'nobody yet' : `${punchedPeer.address}:${punchedPeer.port}`)
|
|
829
|
+
+ '; the same refusal from the address that answered the punch is honoured',
|
|
830
|
+
session: agreed >>> 0,
|
|
831
|
+
agreed_session: agreed >>> 0,
|
|
832
|
+
from: `${plainOnly.source.address}:${plainOnly.source.port}`,
|
|
833
|
+
datagram_bytes: null,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
...cryptoOutcome('plaintext', plainOnly.reason),
|
|
840
|
+
refusal_authenticated: plainOnly.authenticated,
|
|
841
|
+
};
|
|
842
|
+
};
|
|
586
843
|
/** Keep the handshake alive after a plain answer, rather than settling into the clear. */
|
|
587
844
|
const deferPlain = () => {
|
|
588
845
|
tick();
|
|
@@ -616,6 +873,10 @@ export async function punch(options) {
|
|
|
616
873
|
// The handshake failed and said why — from the peer's own refusal frame, or from a MAC that
|
|
617
874
|
// did not verify. Reported at once and named, never downgraded to a plaintext channel: a
|
|
618
875
|
// downgrade that a mismatched key can trigger is not encryption.
|
|
876
|
+
//
|
|
877
|
+
// `refusal_authenticated` travels with it because "the peer refused and proved it holds its
|
|
878
|
+
// static key" and "somebody told us the peer refused" are different facts, and the ledger is
|
|
879
|
+
// where that difference has to survive.
|
|
619
880
|
finish({
|
|
620
881
|
ok: false,
|
|
621
882
|
peer: punchedPeer,
|
|
@@ -623,7 +884,7 @@ export async function punch(options) {
|
|
|
623
884
|
attempts: punchAttempts,
|
|
624
885
|
rttMs: punchedRtt,
|
|
625
886
|
error: `${refused}: the handshake did not complete`,
|
|
626
|
-
crypto: cryptoOutcome('refused', refused),
|
|
887
|
+
crypto: { ...cryptoOutcome('refused', refused), refusal_authenticated: refusedAuthenticated },
|
|
627
888
|
});
|
|
628
889
|
return;
|
|
629
890
|
}
|
|
@@ -1065,6 +1326,10 @@ export class P2PChannel extends EventEmitter {
|
|
|
1065
1326
|
* @param {number} [options.timeoutMs] How long to wait before giving up.
|
|
1066
1327
|
* @param {Set<number>|Map<number, unknown>} [options.claimed] Sessions that already have
|
|
1067
1328
|
* a live channel. Read, never written.
|
|
1329
|
+
* @param {boolean} [options.unrefWait] Do not let the wait itself hold the process open. Off by
|
|
1330
|
+
* default: an awaited operation is normally exactly the thing that should keep a process
|
|
1331
|
+
* alive. On for a node that was asked to be unreferenced (`P2PNode.unref()`), where this
|
|
1332
|
+
* timer would otherwise be the last referenced handle and the process could never exit.
|
|
1068
1333
|
* @param {number} [options.intervalMs] Floor between re-acknowledgements of the same
|
|
1069
1334
|
* claimed session within one call, so a peer that sprays HELLOs cannot make the
|
|
1070
1335
|
* responder answer every single one. The default matches `punch()`'s send
|
|
@@ -1074,9 +1339,12 @@ export class P2PChannel extends EventEmitter {
|
|
|
1074
1339
|
* @param {object} [options.cryptoTuning] Rekey thresholds and retention; see `crypto.mjs`.
|
|
1075
1340
|
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
|
|
1076
1341
|
* session:number, rttMs:number|null, error:string|null, machineId:string|null,
|
|
1077
|
-
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean
|
|
1342
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean,
|
|
1343
|
+
* refusal_authenticated?:boolean}}>}
|
|
1078
1344
|
* `machineId` is the handshake's *verified* claim about who dialled, or null for a plain punch
|
|
1079
1345
|
* (which carries no identity at all — see `P2PNode`'s note on why it refuses to guess one).
|
|
1346
|
+
* `crypto.refusal_authenticated` is present only on a refused outcome, and is the *sender's* view of
|
|
1347
|
+
* {@link REFUSE_GUARD}'s third condition: whether the refusal this end sent carried a `k_ref` seal.
|
|
1080
1348
|
*/
|
|
1081
1349
|
export async function accept(options) {
|
|
1082
1350
|
const { socket } = options;
|
|
@@ -1086,6 +1354,7 @@ export async function accept(options) {
|
|
|
1086
1354
|
const intervalMs = options.intervalMs ?? 250;
|
|
1087
1355
|
const claimed = options.claimed ?? null;
|
|
1088
1356
|
const crypto = options.crypto ?? null;
|
|
1357
|
+
const unrefWait = options.unrefWait === true;
|
|
1089
1358
|
|
|
1090
1359
|
return new Promise((resolve) => {
|
|
1091
1360
|
let settled = false;
|
|
@@ -1114,6 +1383,11 @@ export async function accept(options) {
|
|
|
1114
1383
|
});
|
|
1115
1384
|
}, timeoutMs);
|
|
1116
1385
|
|
|
1386
|
+
// See `options.unrefWait`: a node that was asked not to hold the process open must not hold it
|
|
1387
|
+
// open through the accept loop's own timeout, which is the handle that is pending almost all of
|
|
1388
|
+
// the time in a node that is simply waiting for a punch.
|
|
1389
|
+
if (unrefWait) timer.unref?.();
|
|
1390
|
+
|
|
1117
1391
|
// A wait on a socket that goes away must end now, not at the end of the timeout:
|
|
1118
1392
|
// this is what makes a node's `close()` a barrier instead of a five-second stall.
|
|
1119
1393
|
const onSocketClose = () => {
|
|
@@ -1203,6 +1477,18 @@ export async function accept(options) {
|
|
|
1203
1477
|
let machineId = null;
|
|
1204
1478
|
/** Set when the handshake cannot be verified, so the peer is told why in a named refusal. */
|
|
1205
1479
|
let refusal = null;
|
|
1480
|
+
/**
|
|
1481
|
+
* The exact refusal bytes the crypto layer produced, when it produced any.
|
|
1482
|
+
*
|
|
1483
|
+
* They are forwarded rather than rebuilt, because rebuilding them here silently threw away the
|
|
1484
|
+
* responder's machine id and — since v0.6.0 — the `k0` MAC that makes `BAD_MAC` a statement only
|
|
1485
|
+
* the holder of `s_r` can make. The initiator treats a MAC'd refusal as proof and a bare one as a
|
|
1486
|
+
* claim (`REFUSE_GUARD`), so losing the MAC here would quietly downgrade every terminal refusal
|
|
1487
|
+
* back to "somebody said so".
|
|
1488
|
+
*/
|
|
1489
|
+
let refusalBytes = null;
|
|
1490
|
+
/** Whether the refusal this end sent carried a `k_ref` seal. False when it has no key material. */
|
|
1491
|
+
let refusalSealed = false;
|
|
1206
1492
|
|
|
1207
1493
|
if (handshakeMessage !== null) {
|
|
1208
1494
|
if (crypto === null) {
|
|
@@ -1211,6 +1497,9 @@ export async function accept(options) {
|
|
|
1211
1497
|
// the fleet-losing outcome §4 forbids — but the peer is told why, in a named refusal frame
|
|
1212
1498
|
// that travels alongside the plain acknowledgement.
|
|
1213
1499
|
//
|
|
1500
|
+
// A machine with no key material cannot MAC anything, so this refusal is unauthenticated by
|
|
1501
|
+
// construction, like `NO_PEER_KEY` below and for the same reason.
|
|
1502
|
+
//
|
|
1214
1503
|
// This is what keeps the two ends from disagreeing about whether the session is encrypted: an
|
|
1215
1504
|
// initiator that asked for a handshake and received only a plain acknowledgement would
|
|
1216
1505
|
// otherwise have to guess, and guessing wrong means every frame fails its tag. A v0.5.x peer
|
|
@@ -1225,7 +1514,8 @@ export async function accept(options) {
|
|
|
1225
1514
|
ackBody = answered.body;
|
|
1226
1515
|
machineId = answered.machineId;
|
|
1227
1516
|
} else if (answered.refuse !== null) {
|
|
1228
|
-
const
|
|
1517
|
+
const decoded = decodeRefuse(answered.refuse);
|
|
1518
|
+
const code = decoded?.code ?? REFUSE.UNKNOWN_PEER;
|
|
1229
1519
|
// `NO_PEER_KEY` is the recoverable one, and the distinction is what makes §4's fallback
|
|
1230
1520
|
// clause implementable without a silent downgrade. "I have a keypair and none for you"
|
|
1231
1521
|
// means the handshake is impossible for a stated reason, and the peer may use the plaintext
|
|
@@ -1240,17 +1530,17 @@ export async function accept(options) {
|
|
|
1240
1530
|
};
|
|
1241
1531
|
if (refusal.terminal) outcome = cryptoOutcome('refused', answered.reason);
|
|
1242
1532
|
else outcome = cryptoOutcome('plaintext', CRYPTO_REASON.NO_KEY);
|
|
1533
|
+
// `decoded === null` means these bytes are not a refusal at all: the REPLAY path re-sends a
|
|
1534
|
+
// cached *answer*, and for that case the named refusal below is built here as it always was.
|
|
1535
|
+
if (decoded !== null) refusalBytes = answered.refuse;
|
|
1243
1536
|
}
|
|
1244
1537
|
}
|
|
1245
1538
|
}
|
|
1246
1539
|
|
|
1247
1540
|
if (refusal !== null) {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
rinfo.address,
|
|
1252
|
-
() => {},
|
|
1253
|
-
);
|
|
1541
|
+
const bytes = refusalBytes ?? encodeRefuse(session, refusal.code, null);
|
|
1542
|
+
refusalSealed = decodeRefuse(bytes)?.mac !== null;
|
|
1543
|
+
socket.send(frame(KIND.HELLO_ACK, session, bytes), rinfo.port, rinfo.address, () => {});
|
|
1254
1544
|
}
|
|
1255
1545
|
if (refusal !== null && refusal.terminal) {
|
|
1256
1546
|
// The punch is *not* acknowledged: a handshake that failed is not a path this build will use in
|
|
@@ -1263,7 +1553,10 @@ export async function accept(options) {
|
|
|
1263
1553
|
rttMs: null,
|
|
1264
1554
|
error: `${refusal.reason}: the handshake was refused`,
|
|
1265
1555
|
machineId: null,
|
|
1266
|
-
|
|
1556
|
+
// `refusal_authenticated` here is the responder's view of the same field: whether the refusal
|
|
1557
|
+
// it just sent carried a seal, so "I told it why, with proof" and "I told it why, bare" are
|
|
1558
|
+
// distinguishable without reading the wire.
|
|
1559
|
+
crypto: { ...cryptoOutcome('refused', refusal.reason), refusal_authenticated: refusalSealed },
|
|
1267
1560
|
});
|
|
1268
1561
|
return;
|
|
1269
1562
|
}
|
package/src/agent/resolve.mjs
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* unchanged, so the caller still gets the real ENOENT instead of a fake path.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { accessSync, constants, statSync } from 'node:fs';
|
|
19
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
|
-
import { delimiter, join } from 'node:path';
|
|
21
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
22
22
|
|
|
23
23
|
/** The DSH runtime directory layout we fall back to (task-8, measured). */
|
|
24
24
|
export const DSH_RUNTIME_RELATIVE = join('dsh-runtimes', 'dsh-primary-runtime', 'dependencies');
|
|
@@ -141,8 +141,73 @@ export function bundledBinDirs(env = process.env) {
|
|
|
141
141
|
export function resolveCommand(exe, options = {}) {
|
|
142
142
|
if (typeof exe !== 'string' || exe === '') return exe;
|
|
143
143
|
// Anything with a separator is a path the caller chose, not a name to look up.
|
|
144
|
-
if (
|
|
144
|
+
if (hasPathSeparator(exe)) return exe;
|
|
145
145
|
const env = options.env ?? process.env;
|
|
146
146
|
const extraDirs = options.extraDirs ?? bundledBinDirs(env);
|
|
147
147
|
return findExecutable(exe, { env, extraDirs }) ?? exe;
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Does `value` name a path, rather than a bare command name to look up?
|
|
152
|
+
*
|
|
153
|
+
* Separators are platform-specific, and deliberately so. On Windows both `/` and `\` separate, so
|
|
154
|
+
* `C:\x\node.exe` is a path; on POSIX only `/` does, and the same string is an ordinary *name* that
|
|
155
|
+
* `spawn` would fail to find -- treating it as a path there would let a matcher compare two strings
|
|
156
|
+
* that name no file at all and report an identity it never established.
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} value
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function hasPathSeparator(value) {
|
|
162
|
+
if (typeof value !== 'string') return false;
|
|
163
|
+
return process.platform === 'win32'
|
|
164
|
+
? value.includes('/') || value.includes('\\')
|
|
165
|
+
: value.includes('/');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Is `value` a *relative* path (`./node.exe`, `bin\node.exe`)?
|
|
170
|
+
*
|
|
171
|
+
* A relative executable is one whose meaning is chosen by whoever picks the working directory, which
|
|
172
|
+
* is exactly the party an allow-list does not control -- so the matcher refuses these outright rather
|
|
173
|
+
* than comparing them against anything. `C:node.exe` on Windows (drive-relative) is relative by this
|
|
174
|
+
* definition too, which is why the test is `!isAbsolute` and not "does not start with a separator".
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} value
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function isRelativePath(value) {
|
|
180
|
+
return hasPathSeparator(value) && !isAbsolute(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `realpath` that answers `null` instead of throwing for a path that names no file. */
|
|
184
|
+
function realPathOf(value) {
|
|
185
|
+
const realpath = realpathSync.native ?? realpathSync;
|
|
186
|
+
try {
|
|
187
|
+
return realpath(value);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Do two paths name the **same file** on this machine?
|
|
195
|
+
*
|
|
196
|
+
* `realpath` rather than a string comparison, because the point is the file that would be executed:
|
|
197
|
+
* it resolves symlinks and junctions, Windows 8.3 short names, and the on-disk letter case. Two
|
|
198
|
+
* different copies of one program are two different files and answer `false`, which is what makes
|
|
199
|
+
* this usable as an identity check rather than a spelling check.
|
|
200
|
+
*
|
|
201
|
+
* @param {unknown} a
|
|
202
|
+
* @param {unknown} b
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
export function sameFile(a, b) {
|
|
206
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a === '' || b === '') return false;
|
|
207
|
+
const realA = realPathOf(a);
|
|
208
|
+
const realB = realPathOf(b);
|
|
209
|
+
if (realA === null || realB === null) return false;
|
|
210
|
+
return process.platform === 'win32'
|
|
211
|
+
? realA.toLowerCase() === realB.toLowerCase()
|
|
212
|
+
: realA === realB;
|
|
213
|
+
}
|