@twinsearth/w2m-dsh-plugin 0.5.8 → 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/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. **There is no plaintext channel in this case**: §4's "never silently
274
- * downgrading to plaintext" is enforced where the channel is created rather than left to a caller
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
- * @returns {{code:number, reason:string}|null} The named reason, or null when this is not a refusal.
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
- return { code: refuse.code, reason: REFUSE_REASON[refuse.code] ?? CRYPTO_REASON.REFUSED_BY_PEER };
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 **no plaintext channel is returned for that peer**. The caller falls back to
351
- * the relay — §4's fallback clause — and the ledger records `p2p.encrypted: false` plus the
352
- * reason.
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
- resolve(result);
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
- if (refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY) {
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
- plainOnly = CRYPTO_REASON.NO_KEY;
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: cryptoOutcome('refused', refusal.reason),
753
+ crypto: {
754
+ ...cryptoOutcome('refused', refusal.reason),
755
+ refusal_authenticated: refusal.authenticated,
756
+ },
537
757
  });
538
758
  return;
539
- } if (parsed.session !== agreed) return;
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) finishPunched(cryptoOutcome('plaintext', plainOnly ?? noCryptoReason));
554
- else deferPlain();
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 = () => (plainOnly === null ? null : cryptoOutcome('plaintext', plainOnly));
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 code = decodeRefuse(answered.refuse)?.code ?? REFUSE.UNKNOWN_PEER;
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
- socket.send(
1249
- frame(KIND.HELLO_ACK, session, encodeRefuse(session, refusal.code, null)),
1250
- rinfo.port,
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
- crypto: cryptoOutcome('refused', refusal.reason),
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
  }
@@ -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 (exe.includes('/') || exe.includes('\\')) return exe;
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
+ }