@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2

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,13 +551,32 @@ export async function punch(options) {
416
551
  session: ownSession,
417
552
  })
418
553
  : null;
419
- // Why there is no handshake, when there is none. `null` means one is in flight.
420
- const noCryptoReason =
421
- handshake !== null
422
- ? null
423
- : request === null
424
- ? CRYPTO_REASON.PLAINTEXT_PEER
425
- : CRYPTO_REASON.NO_KEY;
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;
561
+ /**
562
+ * Why there is no handshake, when there is none. `null` means one is in flight.
563
+ *
564
+ * v0.6.2: `request === null` is *this end's* decision, not a fact about the peer. `P2PNode.dial`
565
+ * sends no crypto request in three situations and every one of them is local — this machine has no
566
+ * device key, it holds no key for that peer, or it is dialling itself (a machine keeps no key for
567
+ * itself, so its own `respondHandshake` would refuse it with `P2P_CRYPTO_UNKNOWN_PEER`).
568
+ *
569
+ * It used to report `PLAINTEXT_PEER` here, documented as "the peer never learned a key (an older
570
+ * build)". Measured consequence: a dispatcher with an empty peer-key store reported every direct
571
+ * channel as the peer being out of date, when both ends were the same build on the same host — an
572
+ * encryption downgrade recorded as normal compatibility. `dial()`'s own comment has always claimed
573
+ * the reason would be `NO_KEY`; this is that behaviour, and it is what the objective asks for: a
574
+ * punch that did not encrypt says whether the cause was ours or theirs.
575
+ *
576
+ * `PLAINTEXT_PEER` keeps its real meaning and its real sites: a handshake *was* armed and the peer
577
+ * answered in the clear, which is the only case where the peer is genuinely the reason.
578
+ */
579
+ const noCryptoReason = handshake !== null ? null : (options.cryptoBlockedReason ?? CRYPTO_REASON.NO_KEY);
426
580
  /** Whether this punch is trying to encrypt at all. */
427
581
  const wantCrypto = handshake !== null;
428
582
 
@@ -437,21 +591,42 @@ export async function punch(options) {
437
591
  let punchAttempts = 0;
438
592
  /** Set when the handshake has definitively failed, so the punch says why instead of timing out. */
439
593
  let refused = null;
594
+ /** Whether the refusal that set `refused` was `k0`-verified. See {@link REFUSE_GUARD}. */
595
+ let refusedAuthenticated = null;
440
596
  /**
441
597
  * Set when the peer has told us it can only do plaintext (`REFUSE.NO_KEY`).
442
598
  *
443
599
  * Separate from `refused` on purpose, and the distinction is the whole of §4's fallback clause: a
444
600
  * refusal means "do not talk to me in the clear", a plaintext-only answer means "I cannot talk to
445
601
  * you any other way, and here is why". The first is a failure, the second is a recorded outcome.
602
+ *
603
+ * Carries the source it arrived from and whether it was authenticated, because a plaintext channel
604
+ * is only handed back when the *same* endpoint that answered the punch is the one that asked for
605
+ * it — see {@link REFUSE_GUARD} condition 4.
446
606
  */
447
607
  let plainOnly = null;
608
+ /** Every `REFUSE` datagram dropped by {@link REFUSE_GUARD}, in `{reason_code, detail}` form. */
609
+ const ignoredRefusals = [];
610
+ /** One drop per un-corroborated plaintext refusal, however many times the outcome is asked for. */
611
+ let plainOnlyDropRecorded = false;
612
+
613
+ /** Is this datagram from an address this punch actually punched, or from the peer that answered it? */
614
+ const isPunchedSource = (rinfo) =>
615
+ sameEndpoint(punchedPeer, rinfo) ||
616
+ remote.some((candidate) => candidate.address === rinfo.address && candidate.port === rinfo.port);
448
617
 
449
618
  const finish = (result) => {
450
619
  if (settled) return;
451
620
  settled = true;
452
621
  clearTimeout(timer);
453
622
  socket.removeListener('message', onMessage);
454
- resolve(result);
623
+ // The drops are attached here rather than threaded through every outcome, and only when there
624
+ // were any: a punch nobody interfered with returns exactly the object it always did.
625
+ resolve(
626
+ ignoredRefusals.length === 0
627
+ ? result
628
+ : { ...result, crypto: { ...result.crypto, ignored_refusals: ignoredRefusals } },
629
+ );
455
630
  };
456
631
 
457
632
  /** The path is open: report it with whatever the handshake managed to do. */
@@ -510,22 +685,76 @@ export async function punch(options) {
510
685
  }
511
686
 
512
687
  if (parsed.kind === KIND.HELLO_ACK) {
513
- const refusal = cryptoRefusalOf(parsed);
688
+ const refusal = cryptoRefusalOf(parsed, refusalKey);
514
689
  if (refusal !== null) {
515
- if (refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY) {
690
+ // ---- REFUSE_GUARD, conditions 1 and 2: this refusal has to be about *this* punch, from an
691
+ // address this punch actually dialled. Both checks come before anything else reads the
692
+ // datagram, and both are recorded rather than raised: see REFUSE_GUARD's `on_failure`.
693
+ if (parsed.session !== agreed || refusal.session !== agreed) {
694
+ ignoredRefusals.push(ignoredRefusal(
695
+ CRYPTO_REASON.REFUSE_SESSION_MISMATCH,
696
+ `the refusal named session ${refusal.session} in a frame for session ${parsed.session}; a refusal `
697
+ + `that names the agreed session ${agreed} is honoured`,
698
+ { parsed, rinfo, agreed },
699
+ ));
700
+ return;
701
+ }
702
+ if (!isPunchedSource(rinfo)) {
703
+ ignoredRefusals.push(ignoredRefusal(
704
+ CRYPTO_REASON.REFUSE_UNPUNCHED_SOURCE,
705
+ `it arrived from ${rinfo.address}:${rinfo.port}, which is neither one of the ${remote.length} `
706
+ + 'address(es) this punch dialled nor the peer that answered it; a refusal from one of those is '
707
+ + 'honoured',
708
+ { parsed, rinfo, agreed },
709
+ ));
710
+ return;
711
+ }
712
+ // ---- condition 3, first half: a MAC that was present and *wrong* buys nothing.
713
+ //
714
+ // It cannot be proof, and — for the one refusal that would choose a plaintext channel — it
715
+ // must not even be a claim: `NO_KEY`/`NO_PEER_KEY` are the refusals a *legitimate* peer seals
716
+ // with `k_ref` (it holds our `e_i` and its own `s_r`), so a sealed one that does not verify is
717
+ // not a peer whose records disagree with ours; it is a datagram somebody assembled. Dropping
718
+ // it costs an honest peer nothing, because an honest peer that cannot derive `k_ref` sends no
719
+ // MAC at all, which is the claim path below.
720
+ const wantsPlaintext = refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY;
721
+ if (wantsPlaintext && refusal.authenticated === false) {
722
+ ignoredRefusals.push(ignoredRefusal(
723
+ CRYPTO_REASON.REFUSE_BAD_MAC,
724
+ 'it asked for a plaintext channel and carried a refusal MAC that did not verify; a refusal sealed '
725
+ + 'with the key derived from the handshake ephemeral is honoured, and an older peer that sends no '
726
+ + 'MAC at all is honoured from the address that answered the punch',
727
+ { parsed, rinfo, agreed },
728
+ ));
729
+ return;
730
+ }
731
+ if (wantsPlaintext) {
516
732
  // The peer has a keypair of its own and none *for us*, and it said so instead of answering
517
733
  // in a way we would have to guess about. That is the plaintext-only case, not a failure:
518
734
  // §4's fallback clause is explicitly about not losing a machine to a security upgrade, and
519
735
  // this is the peer naming the upgrade it could not take part in. The plain acknowledgement
520
736
  // follows from the same responder, so the punch settles on that.
521
- plainOnly = CRYPTO_REASON.NO_KEY;
737
+ //
738
+ // This is also the one refusal that *chooses a plaintext channel* rather than failing the
739
+ // punch, so it is the one held to REFUSE_GUARD's fourth condition, and that condition can
740
+ // only be checked at outcome time: the refusal and the plain acknowledgement are two
741
+ // datagrams, either may arrive first, and only once both have is there something to
742
+ // corroborate. A refusal sealed with `k_ref` needs no corroboration — it is the peer.
743
+ plainOnly = {
744
+ reason: CRYPTO_REASON.NO_KEY,
745
+ source: { address: rinfo.address, port: rinfo.port },
746
+ authenticated: refusal.authenticated,
747
+ };
522
748
  return;
523
749
  }
524
750
  // Any other refusal is final — `BAD_MAC` means the peer is known and its proof did not
525
751
  // verify, which is a stale key or an impersonation attempt, and neither is a reason to fall
526
752
  // back into the clear. Named, and never downgraded: this is the path where a channel is
527
- // deliberately not handed back at all.
753
+ // deliberately not handed back at all. A failed MAC does not disqualify it, because it can
754
+ // only ever fail a punch that was already failing, and an honest peer with a stale record of
755
+ // *our* key is exactly the peer that sends one; `refusal_authenticated` says which it was.
528
756
  refused = refusal.reason;
757
+ refusedAuthenticated = refusal.authenticated;
529
758
  finish({
530
759
  ok: false,
531
760
  peer: punchedPeer,
@@ -533,10 +762,14 @@ export async function punch(options) {
533
762
  attempts: punchAttempts,
534
763
  rttMs: punchedRtt,
535
764
  error: `${refusal.reason}: the peer refused the handshake`,
536
- crypto: cryptoOutcome('refused', refusal.reason),
765
+ crypto: {
766
+ ...cryptoOutcome('refused', refusal.reason),
767
+ refusal_authenticated: refusal.authenticated,
768
+ },
537
769
  });
538
770
  return;
539
- } if (parsed.session !== agreed) return;
771
+ }
772
+ if (parsed.session !== agreed) return;
540
773
  notePeer(rinfo);
541
774
  const ack = decodeHello(parsed.body);
542
775
  if (ack === null) {
@@ -550,8 +783,9 @@ export async function punch(options) {
550
783
  // answers plainly, and settling on that answer would turn "your key did not verify" into a
551
784
  // silent plaintext channel — the exact outcome §4 forbids. Deferring costs one interval and
552
785
  // only happens on the punch where there was something to wait for.
553
- if (!wantCrypto) finishPunched(cryptoOutcome('plaintext', plainOnly ?? noCryptoReason));
554
- else deferPlain();
786
+ if (!wantCrypto) {
787
+ finishPunched(plaintextOutcome() ?? cryptoOutcome('plaintext', noCryptoReason));
788
+ } else deferPlain();
555
789
  return;
556
790
  }
557
791
  if (handshake === null) {
@@ -581,8 +815,43 @@ export async function punch(options) {
581
815
  * lose the machine. A peer we hold a key for and never hear a handshake from is a different fact —
582
816
  * `P2P_CRYPTO_HANDSHAKE_TIMEOUT` — and it falls back to the relay, which is what the contract says
583
817
  * a failed handshake does.
818
+ *
819
+ * It is also where REFUSE_GUARD's fourth condition is enforced, and it has to be *here* rather than
820
+ * at the datagram: the refusal and the plain acknowledgement are two datagrams from the same
821
+ * responder, either may arrive first, and only once both have is there something to corroborate.
822
+ * A refusal that no acknowledgement from the same endpoint ever backs is not honoured — and, like
823
+ * every other drop, it does not fail the punch; the punch keeps waiting and then reports the
824
+ * timeout it honestly has.
825
+ *
826
+ * A refusal that *is* sealed with `k_ref` skips that condition entirely: it proves the sender holds
827
+ * the static key we dialled, which is more than an acknowledgement from the same address could.
584
828
  */
585
- const plaintextOutcome = () => (plainOnly === null ? null : cryptoOutcome('plaintext', plainOnly));
829
+ const plaintextOutcome = () => {
830
+ if (plainOnly === null) return null;
831
+ const corroborated = plainOnly.authenticated === true || sameEndpoint(punchedPeer, plainOnly.source);
832
+ if (!corroborated) {
833
+ if (!plainOnlyDropRecorded) {
834
+ plainOnlyDropRecorded = true;
835
+ ignoredRefusals.push({
836
+ reason_code: CRYPTO_REASON.REFUSE_UNCORROBORATED,
837
+ detail:
838
+ `it asked for a plaintext channel from ${plainOnly.source.address}:${plainOnly.source.port}, and the `
839
+ + 'peer that answered this punch is '
840
+ + (punchedPeer === null ? 'nobody yet' : `${punchedPeer.address}:${punchedPeer.port}`)
841
+ + '; the same refusal from the address that answered the punch is honoured',
842
+ session: agreed >>> 0,
843
+ agreed_session: agreed >>> 0,
844
+ from: `${plainOnly.source.address}:${plainOnly.source.port}`,
845
+ datagram_bytes: null,
846
+ });
847
+ }
848
+ return null;
849
+ }
850
+ return {
851
+ ...cryptoOutcome('plaintext', plainOnly.reason),
852
+ refusal_authenticated: plainOnly.authenticated,
853
+ };
854
+ };
586
855
  /** Keep the handshake alive after a plain answer, rather than settling into the clear. */
587
856
  const deferPlain = () => {
588
857
  tick();
@@ -616,6 +885,10 @@ export async function punch(options) {
616
885
  // The handshake failed and said why — from the peer's own refusal frame, or from a MAC that
617
886
  // did not verify. Reported at once and named, never downgraded to a plaintext channel: a
618
887
  // downgrade that a mismatched key can trigger is not encryption.
888
+ //
889
+ // `refusal_authenticated` travels with it because "the peer refused and proved it holds its
890
+ // static key" and "somebody told us the peer refused" are different facts, and the ledger is
891
+ // where that difference has to survive.
619
892
  finish({
620
893
  ok: false,
621
894
  peer: punchedPeer,
@@ -623,7 +896,7 @@ export async function punch(options) {
623
896
  attempts: punchAttempts,
624
897
  rttMs: punchedRtt,
625
898
  error: `${refused}: the handshake did not complete`,
626
- crypto: cryptoOutcome('refused', refused),
899
+ crypto: { ...cryptoOutcome('refused', refused), refusal_authenticated: refusedAuthenticated },
627
900
  });
628
901
  return;
629
902
  }
@@ -1065,6 +1338,10 @@ export class P2PChannel extends EventEmitter {
1065
1338
  * @param {number} [options.timeoutMs] How long to wait before giving up.
1066
1339
  * @param {Set<number>|Map<number, unknown>} [options.claimed] Sessions that already have
1067
1340
  * a live channel. Read, never written.
1341
+ * @param {boolean} [options.unrefWait] Do not let the wait itself hold the process open. Off by
1342
+ * default: an awaited operation is normally exactly the thing that should keep a process
1343
+ * alive. On for a node that was asked to be unreferenced (`P2PNode.unref()`), where this
1344
+ * timer would otherwise be the last referenced handle and the process could never exit.
1068
1345
  * @param {number} [options.intervalMs] Floor between re-acknowledgements of the same
1069
1346
  * claimed session within one call, so a peer that sprays HELLOs cannot make the
1070
1347
  * responder answer every single one. The default matches `punch()`'s send
@@ -1074,9 +1351,12 @@ export class P2PChannel extends EventEmitter {
1074
1351
  * @param {object} [options.cryptoTuning] Rekey thresholds and retention; see `crypto.mjs`.
1075
1352
  * @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
1076
1353
  * session:number, rttMs:number|null, error:string|null, machineId:string|null,
1077
- * crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean}}>}
1354
+ * crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean,
1355
+ * refusal_authenticated?:boolean}}>}
1078
1356
  * `machineId` is the handshake's *verified* claim about who dialled, or null for a plain punch
1079
1357
  * (which carries no identity at all — see `P2PNode`'s note on why it refuses to guess one).
1358
+ * `crypto.refusal_authenticated` is present only on a refused outcome, and is the *sender's* view of
1359
+ * {@link REFUSE_GUARD}'s third condition: whether the refusal this end sent carried a `k_ref` seal.
1080
1360
  */
1081
1361
  export async function accept(options) {
1082
1362
  const { socket } = options;
@@ -1086,6 +1366,7 @@ export async function accept(options) {
1086
1366
  const intervalMs = options.intervalMs ?? 250;
1087
1367
  const claimed = options.claimed ?? null;
1088
1368
  const crypto = options.crypto ?? null;
1369
+ const unrefWait = options.unrefWait === true;
1089
1370
 
1090
1371
  return new Promise((resolve) => {
1091
1372
  let settled = false;
@@ -1114,6 +1395,11 @@ export async function accept(options) {
1114
1395
  });
1115
1396
  }, timeoutMs);
1116
1397
 
1398
+ // See `options.unrefWait`: a node that was asked not to hold the process open must not hold it
1399
+ // open through the accept loop's own timeout, which is the handle that is pending almost all of
1400
+ // the time in a node that is simply waiting for a punch.
1401
+ if (unrefWait) timer.unref?.();
1402
+
1117
1403
  // A wait on a socket that goes away must end now, not at the end of the timeout:
1118
1404
  // this is what makes a node's `close()` a barrier instead of a five-second stall.
1119
1405
  const onSocketClose = () => {
@@ -1203,6 +1489,18 @@ export async function accept(options) {
1203
1489
  let machineId = null;
1204
1490
  /** Set when the handshake cannot be verified, so the peer is told why in a named refusal. */
1205
1491
  let refusal = null;
1492
+ /**
1493
+ * The exact refusal bytes the crypto layer produced, when it produced any.
1494
+ *
1495
+ * They are forwarded rather than rebuilt, because rebuilding them here silently threw away the
1496
+ * responder's machine id and — since v0.6.0 — the `k0` MAC that makes `BAD_MAC` a statement only
1497
+ * the holder of `s_r` can make. The initiator treats a MAC'd refusal as proof and a bare one as a
1498
+ * claim (`REFUSE_GUARD`), so losing the MAC here would quietly downgrade every terminal refusal
1499
+ * back to "somebody said so".
1500
+ */
1501
+ let refusalBytes = null;
1502
+ /** Whether the refusal this end sent carried a `k_ref` seal. False when it has no key material. */
1503
+ let refusalSealed = false;
1206
1504
 
1207
1505
  if (handshakeMessage !== null) {
1208
1506
  if (crypto === null) {
@@ -1211,6 +1509,9 @@ export async function accept(options) {
1211
1509
  // the fleet-losing outcome §4 forbids — but the peer is told why, in a named refusal frame
1212
1510
  // that travels alongside the plain acknowledgement.
1213
1511
  //
1512
+ // A machine with no key material cannot MAC anything, so this refusal is unauthenticated by
1513
+ // construction, like `NO_PEER_KEY` below and for the same reason.
1514
+ //
1214
1515
  // This is what keeps the two ends from disagreeing about whether the session is encrypted: an
1215
1516
  // initiator that asked for a handshake and received only a plain acknowledgement would
1216
1517
  // otherwise have to guess, and guessing wrong means every frame fails its tag. A v0.5.x peer
@@ -1225,7 +1526,8 @@ export async function accept(options) {
1225
1526
  ackBody = answered.body;
1226
1527
  machineId = answered.machineId;
1227
1528
  } else if (answered.refuse !== null) {
1228
- const code = decodeRefuse(answered.refuse)?.code ?? REFUSE.UNKNOWN_PEER;
1529
+ const decoded = decodeRefuse(answered.refuse);
1530
+ const code = decoded?.code ?? REFUSE.UNKNOWN_PEER;
1229
1531
  // `NO_PEER_KEY` is the recoverable one, and the distinction is what makes §4's fallback
1230
1532
  // clause implementable without a silent downgrade. "I have a keypair and none for you"
1231
1533
  // means the handshake is impossible for a stated reason, and the peer may use the plaintext
@@ -1240,17 +1542,17 @@ export async function accept(options) {
1240
1542
  };
1241
1543
  if (refusal.terminal) outcome = cryptoOutcome('refused', answered.reason);
1242
1544
  else outcome = cryptoOutcome('plaintext', CRYPTO_REASON.NO_KEY);
1545
+ // `decoded === null` means these bytes are not a refusal at all: the REPLAY path re-sends a
1546
+ // cached *answer*, and for that case the named refusal below is built here as it always was.
1547
+ if (decoded !== null) refusalBytes = answered.refuse;
1243
1548
  }
1244
1549
  }
1245
1550
  }
1246
1551
 
1247
1552
  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
- );
1553
+ const bytes = refusalBytes ?? encodeRefuse(session, refusal.code, null);
1554
+ refusalSealed = decodeRefuse(bytes)?.mac !== null;
1555
+ socket.send(frame(KIND.HELLO_ACK, session, bytes), rinfo.port, rinfo.address, () => {});
1254
1556
  }
1255
1557
  if (refusal !== null && refusal.terminal) {
1256
1558
  // The punch is *not* acknowledged: a handshake that failed is not a path this build will use in
@@ -1263,7 +1565,10 @@ export async function accept(options) {
1263
1565
  rttMs: null,
1264
1566
  error: `${refusal.reason}: the handshake was refused`,
1265
1567
  machineId: null,
1266
- crypto: cryptoOutcome('refused', refusal.reason),
1568
+ // `refusal_authenticated` here is the responder's view of the same field: whether the refusal
1569
+ // it just sent carried a seal, so "I told it why, with proof" and "I told it why, bare" are
1570
+ // distinguishable without reading the wire.
1571
+ crypto: { ...cryptoOutcome('refused', refusal.reason), refusal_authenticated: refusalSealed },
1267
1572
  });
1268
1573
  return;
1269
1574
  }
@@ -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
+ }