@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.
@@ -119,6 +119,15 @@ export const SESSION_KEY_LABEL = 'w2m-p2p-crypto v1 session';
119
119
  /** `k0 = HKDF(ECDH(s_i,s_r))` of §4: proves possession of the static key. */
120
120
  export const STATIC_KEY_LABEL = 'w2m-p2p-crypto v1 static';
121
121
 
122
+ /**
123
+ * Domain separator of a `REFUSE`'s optional MAC — a *different* label from every handshake MAC, and a
124
+ * different *input*: `ECDH(e_i, s_r)` rather than `ECDH(s_i, s_r)` (see the note above `REFUSE`).
125
+ *
126
+ * Without the label, a refusal MAC and `mac'` of §4 would both be `sealMac(·, …)` over 32-byte hashes
127
+ * a hostile peer could try to make collide. One extra hash input makes the two families disjoint.
128
+ */
129
+ export const REFUSE_MAC_LABEL = 'w2m-p2p-crypto v1 refuse';
130
+
122
131
  /** The literal string §4's `hash(prologue ——` starts with. */
123
132
  export const PROLOGUE = 'w2m-p2p-crypto v1 prologue';
124
133
 
@@ -200,6 +209,29 @@ export const CRYPTO_REASON = Object.freeze({
200
209
  MALFORMED: 'P2P_CRYPTO_MALFORMED',
201
210
  /** The peer never learned a key (an older build), so a direct channel with it is plaintext. */
202
211
  PLAINTEXT_PEER: 'P2P_CRYPTO_PLAINTEXT_PEER',
212
+ /**
213
+ * A `REFUSE` datagram was **dropped**, not honoured: it named a session this punch did not agree.
214
+ *
215
+ * The four `REFUSE_*` codes below name an *ignored datagram* rather than a failed punch, which is
216
+ * deliberate and is the whole of the v0.6.0 hardening: an unauthenticated refusal that cannot be
217
+ * tied to this punch must not be able to change its outcome, and it must not be able to fail it
218
+ * either — an attacker who could turn "the peer never answered" into "the peer refused" would have
219
+ * been handed a denial of service in exchange for closing a downgrade.
220
+ */
221
+ REFUSE_SESSION_MISMATCH: 'P2P_CRYPTO_REFUSE_SESSION_MISMATCH',
222
+ /** A `REFUSE` datagram was dropped: it came from an address this punch never dialled. */
223
+ REFUSE_UNPUNCHED_SOURCE: 'P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE',
224
+ /** A `REFUSE` datagram was dropped: it carried a `k0` MAC that did not verify. */
225
+ REFUSE_BAD_MAC: 'P2P_CRYPTO_REFUSE_BAD_MAC',
226
+ /**
227
+ * A downgrade-inducing `REFUSE` was dropped: it did not come from the peer that answered the punch.
228
+ *
229
+ * `NO_KEY`/`NO_PEER_KEY` say "I cannot take part, use the plaintext channel" — the one refusal that
230
+ * changes the outcome into something weaker than a failure. Requiring it to come from the address
231
+ * that actually answered the punch is what stops a datagram from a third address from choosing a
232
+ * plaintext channel on the peer's behalf.
233
+ */
234
+ REFUSE_UNCORROBORATED: 'P2P_CRYPTO_REFUSE_UNCORROBORATED',
203
235
  });
204
236
 
205
237
  /* -------------------------------------------------------------------------- */
@@ -788,12 +820,35 @@ export function bytesEqual(a, b) {
788
820
  *
789
821
  * HELLO magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
790
822
  * HELLO_ACK magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
791
- * REFUSE magic|version|session u32|code u8|idLen u8|machineId
823
+ * REFUSE magic|version|session u32|code u8|idLen u8|machineId[|mac 48]
824
+ *
825
+ * `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC).
792
826
  *
793
- * `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC). `REFUSE` is deliberately
794
- * unauthenticated: it is sent precisely when we cannot authenticate the peer, so it can carry no
795
- * proof —and it is what turns "the handshake did not complete" into a named reason on *both* ends
796
- * instead of a timeout on one of them.
827
+ * `REFUSE` **may** be unauthenticated, and that is not an oversight: it is sent precisely when we
828
+ * cannot authenticate the peer (we hold no key for it), so it can carry no proof — and it is what
829
+ * turns "the handshake did not complete" into a named reason on *both* ends instead of a timeout on
830
+ * one of them. When the responder received a handshake `HELLO` it holds the initiator's ephemeral
831
+ * `e_i`, and it *always* holds its own static private key `s_r`, so it can always derive
832
+ * `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` — a key the initiator derives just as easily from
833
+ * `e_i`'s private half and `s_r`'s public key, and that nobody else can derive at all. The trailing 48
834
+ * bytes of a refusal are then `sealMac(k_ref, hash(REFUSE_MAC_LABEL ‖ the refusal without its MAC))`.
835
+ *
836
+ * That is the asymmetry this file relies on, and it is worth stating plainly because the naive
837
+ * version of it is wrong: `k0 = ECDH(s_i, s_r)` — the handshake MAC's key — is *not* usable here. A
838
+ * responder that has no key for us (or a stale one) cannot compute it, and those are exactly the two
839
+ * cases that send a refusal. `k_ref` needs neither end to hold the other's static key: the initiator
840
+ * sends `e_i` in the clear, and the responder's `s_r` is public. What it proves is what a refusal
841
+ * needs to prove — *this datagram came from the machine that holds `s_r`* — and it is therefore
842
+ * available to `BAD_MAC` (our proof did not verify), `NO_PEER_KEY` (we hold no key for you) and
843
+ * `UNKNOWN_PEER` alike.
844
+ *
845
+ * `VERSION` is the one refusal that stays bare, and deliberately: the peer that needs it speaks a
846
+ * handshake version whose body layout this build cannot rely on, so a trailing field it does not know
847
+ * about would be worse than useless.
848
+ *
849
+ * The unauthenticated form still exists and is still honoured by `p2p.mjs` — a peer running an older
850
+ * build cannot produce `k_ref` and must not lose its plaintext channel for it. What `p2p.mjs` will not
851
+ * do is let a datagram it cannot tie to the punch (see `REFUSE_GUARD` there) rewrite a ledger.
797
852
  */
798
853
 
799
854
  /**
@@ -808,6 +863,14 @@ export function bytesEqual(a, b) {
808
863
  * it is not a downgrade: the peer never offered a key to downgrade from.
809
864
  * * `BAD_MAC` — the peer *is* known and its MAC over its static key did not verify. That is either a
810
865
  * stale key or an impersonation attempt, and the initiator treats it as terminal.
866
+ *
867
+ * The two known-peer codes (`BAD_MAC`, `REPLAY`) can be sealed with the ephemeral-static key of
868
+ * {@link refusalMacKey}, and so can the two unknown-peer codes — `k_ref` needs no static key on file.
869
+ * `VERSION` is the exception and stays bare. What `p2p.mjs` does with that asymmetry (`REFUSE_GUARD`
870
+ * there) is the rule: a *plaintext-choosing* refusal (`NO_KEY`, `NO_PEER_KEY`) must either verify its
871
+ * seal or arrive, unsealed, from the endpoint that acknowledged the punch; a terminal refusal is
872
+ * honoured from a dialled address sealed or not, with `refusal_authenticated` recording whether the
873
+ * seal was proof. Nothing dropped ever fails a punch.
811
874
  */
812
875
  export const REFUSE = Object.freeze({
813
876
  UNKNOWN_PEER: 1,
@@ -894,32 +957,136 @@ export function decodeHello(body) {
894
957
  };
895
958
  }
896
959
 
897
- /** @param {number} session @param {number} code @param {string|null} [machineId] @returns {Buffer} */
898
- export function encodeRefuse(session, code, machineId = null) {
960
+ /**
961
+ * Build a `REFUSE` body, optionally with the trailing `k0` MAC of {@link sealRefuse}.
962
+ *
963
+ * @param {number} session
964
+ * @param {number} code
965
+ * @param {string|null} [machineId]
966
+ * @param {Buffer|null} [mac] 48 bytes, or null for the unauthenticated form.
967
+ * @returns {Buffer}
968
+ */
969
+ export function encodeRefuse(session, code, machineId = null, mac = null) {
899
970
  const id = (machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8')).subarray(
900
971
  0,
901
972
  MAX_HANDSHAKE_MACHINE_ID,
902
973
  );
903
- const out = Buffer.alloc(8 + id.length);
974
+ const trailing = mac === null ? Buffer.alloc(0) : mac;
975
+ if (trailing.length !== 0 && trailing.length !== MAC_BYTES) {
976
+ throw new TypeError(`encodeRefuse: a refusal MAC must be ${MAC_BYTES} bytes`);
977
+ }
978
+ const out = Buffer.alloc(8 + id.length + trailing.length);
904
979
  out.writeUInt8(FRAME_MAGIC, 0);
905
980
  out.writeUInt8(CRYPTO_VERSION, 1);
906
981
  out.writeUInt32BE(session >>> 0, 2);
907
982
  out.writeUInt8(code, 6);
908
983
  out.writeUInt8(id.length, 7);
909
984
  id.copy(out, 8);
985
+ trailing.copy(out, 8 + id.length);
910
986
  return out;
911
987
  }
912
988
 
913
- /** @param {Buffer} body @returns {{session:number, code:number, machineId:string|null}|null} */
989
+ /**
990
+ * Parse a `REFUSE` body — both the bare form and the `k0`-MAC'd one — or null.
991
+ *
992
+ * Strict about the length, in both forms, and that strictness is load-bearing rather than tidy: the
993
+ * body being parsed here arrives as the body of a **`HELLO_ACK`**, and a handshake body is also
994
+ * `magic|version|session|…` with a byte that can look like an id length. An earlier version accepted
995
+ * exactly one length and validated nothing else, so a genuine `HELLO_ACK` could be read as a refusal
996
+ * — `body[6]` (the first byte of the responder's ephemeral, which is random) became the refusal code
997
+ * and `body[7]` the id length — roughly one handshake in 256 refusing itself. The machine-id check
998
+ * below is what makes that collision impossible rather than unlikely: a handshake body's bytes from
999
+ * offset 8 on are key material, and key material is not a printable machine id.
1000
+ *
1001
+ * @param {Buffer} body
1002
+ * @returns {{session:number, code:number, machineId:string|null, mac:Buffer|null, headerBytes:number,
1003
+ * bytes:Buffer}|null}
1004
+ */
914
1005
  export function decodeRefuse(body) {
915
1006
  if (!Buffer.isBuffer(body) || body.length < 8) return null;
916
1007
  if (body.readUInt8(0) !== FRAME_MAGIC) return null;
1008
+ if (body.readUInt8(1) !== CRYPTO_VERSION) return null;
917
1009
  const session = body.readUInt32BE(2);
918
1010
  const code = body.readUInt8(6);
919
1011
  const idLength = body.readUInt8(7);
920
- if (body.length !== 8 + idLength) return null;
921
- const machineId = idLength > 0 ? body.subarray(8, 8 + idLength).toString('utf8') : null;
922
- return { session, code, machineId };
1012
+ if (idLength > MAX_HANDSHAKE_MACHINE_ID) return null;
1013
+ const headerBytes = 8 + idLength;
1014
+ if (body.length !== headerBytes && body.length !== headerBytes + MAC_BYTES) return null;
1015
+ let machineId = null;
1016
+ if (idLength > 0) {
1017
+ machineId = body.subarray(8, headerBytes).toString('utf8');
1018
+ // Same rule, and the same reason, as `decodeHello`: no build produces a control byte in a machine
1019
+ // id, and one that arrives would put a delimiter into a log line.
1020
+ // eslint-disable-next-line no-control-regex -- rejecting control bytes is the point
1021
+ if (/[\u0000-\u001f\u007f]/.test(machineId)) return null;
1022
+ }
1023
+ const mac = body.length === headerBytes + MAC_BYTES
1024
+ ? Buffer.from(body.subarray(headerBytes, headerBytes + MAC_BYTES))
1025
+ : null;
1026
+ return { session, code, machineId, mac, headerBytes, bytes: Buffer.from(body) };
1027
+ }
1028
+
1029
+ /**
1030
+ * The transcript a refusal MAC is taken over: the label, then the refusal exactly as it goes on the
1031
+ * wire *without* its MAC. Recomputed by the receiver from the bytes it holds, never sent separately.
1032
+ *
1033
+ * @param {Buffer} body The refusal body without the trailing MAC.
1034
+ * @returns {Buffer} 32 bytes.
1035
+ */
1036
+ export function refuseTranscript(body) {
1037
+ return handshakeHash(REFUSE_MAC_LABEL, body);
1038
+ }
1039
+
1040
+ /**
1041
+ * A refusal with proof attached, for every responder that received a parseable `HELLO`.
1042
+ *
1043
+ * @param {number} session
1044
+ * @param {number} code
1045
+ * @param {string|null} machineId
1046
+ * @param {Buffer} macKey `k_ref`, from {@link refusalMacKey}.
1047
+ * @returns {Buffer}
1048
+ */
1049
+ export function sealRefuse(session, code, machineId, macKey) {
1050
+ const body = encodeRefuse(session, code, machineId);
1051
+ return encodeRefuse(session, code, machineId, sealMac(macKey, refuseTranscript(body)));
1052
+ }
1053
+
1054
+ /**
1055
+ * The MAC key a refusal is sealed with: `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)`.
1056
+ *
1057
+ * Both ends derive it from material the *other* end published, and neither needs the other's static
1058
+ * public key to be on file: the initiator passes its own ephemeral private key and the responder's
1059
+ * static public key, the responder passes its own static private key and the initiator's ephemeral
1060
+ * public key from the `HELLO`. An attacker who has neither private key cannot derive it, which is
1061
+ * what makes a sealed refusal evidence rather than a claim.
1062
+ *
1063
+ * @param {object} input
1064
+ * @param {import('node:crypto').KeyObject} input.privateKey `e_i`'s private half (initiator) or `s_r`
1065
+ * (responder).
1066
+ * @param {Buffer} input.peerPublicRaw 32 raw bytes of `s_r` (initiator) or `e_i` (responder).
1067
+ * @returns {Buffer} 32 bytes.
1068
+ */
1069
+ export function refusalMacKey({ privateKey, peerPublicRaw }) {
1070
+ if (!Buffer.isBuffer(peerPublicRaw) || peerPublicRaw.length !== KEY_BYTES) {
1071
+ throw new TypeError(`refusalMacKey: the peer public key must be ${KEY_BYTES} raw bytes`);
1072
+ }
1073
+ return derive(diffieHellman({ privateKey, publicKey: publicKeyFromRaw(peerPublicRaw) }), REFUSE_MAC_LABEL);
1074
+ }
1075
+
1076
+ /**
1077
+ * Verify the optional MAC on a received refusal.
1078
+ *
1079
+ * @param {Buffer|null} macKey `k_ref`, or null when this end has nothing to check with.
1080
+ * @param {Buffer} body The whole refusal body as received.
1081
+ * @returns {boolean|null} `null` when the body carries no MAC (or is not a refusal at all), otherwise
1082
+ * whether it verifies. `false` is never "unknown" — it is a MAC that was present and wrong.
1083
+ */
1084
+ export function verifyRefuseMac(macKey, body) {
1085
+ const decoded = decodeRefuse(body);
1086
+ if (decoded === null || decoded.mac === null) return null;
1087
+ if (macKey === null) return false;
1088
+ const expected = sealMac(macKey, refuseTranscript(body.subarray(0, decoded.headerBytes)));
1089
+ return bytesEqual(expected, decoded.mac);
923
1090
  }
924
1091
 
925
1092
  /* -------------------------------------------------------------------------- */
@@ -1341,7 +1508,9 @@ export function ackTranscript(helloBytes, responderEphemeralRaw) {
1341
1508
  * @param {string} options.peerMachineId The machine being dialled.
1342
1509
  * @param {string} options.peerPublicKey Its static public key, from the offer or from the relay.
1343
1510
  * @param {number} options.session The punch session.
1344
- * @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string, finish: Function}}
1511
+ * @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
1512
+ * refusalMacKey: Buffer, finish: Function}} `refusalMacKey` is `k_ref` of {@link refusalMacKey}: what
1513
+ * this end checks a `REFUSE` against, and the reason a refusal can be evidence rather than a claim.
1345
1514
  */
1346
1515
  export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, session }) {
1347
1516
  const peerRaw = decodeKey(peerPublicKey);
@@ -1364,6 +1533,10 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
1364
1533
  ephemeral: encodeKey(ephemeralPublic),
1365
1534
  session,
1366
1535
  peerMachineId,
1536
+ // Derived once, here, from the ephemeral this handshake is about to send: `ECDH(e_i, s_r)`. The
1537
+ // responder derives the same value from that very `e_i` and its own static key, which is what lets
1538
+ // it answer a refusal with proof even when it holds no key for us at all.
1539
+ refusalMacKey: refusalMacKey({ privateKey: ephemeral.privateKey, peerPublicRaw: peerRaw }),
1367
1540
  /**
1368
1541
  * Verify `HELLO_ACK` and hand back the transport session.
1369
1542
  *
@@ -1433,7 +1606,42 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
1433
1606
  export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1434
1607
  const hello = decodeHello(body);
1435
1608
  if (hello === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED, refuse: null };
1609
+
1610
+ /** This peer's static public key as filed under its claimed machine id, or null. */
1611
+ const keyFor = (machineId) => {
1612
+ if (machineId === null) return null;
1613
+ return peerKeys instanceof PeerKeyStore
1614
+ ? peerKeys.get(machineId)
1615
+ : (peerKeys.get(machineId) ?? null);
1616
+ };
1617
+
1618
+ /**
1619
+ * `k_ref` for this exchange, from the initiator's `e_i` and our own static key.
1620
+ *
1621
+ * Present whenever the `HELLO` parsed at all, which is every refusal below except `MALFORMED` (which
1622
+ * sends nothing). It is deliberately *not* `k0`: a responder with no key for the initiator — the
1623
+ * sender of `NO_PEER_KEY` — cannot compute `k0`, and a responder with a stale key for it (the sender
1624
+ * of `BAD_MAC`) would produce a `k0` the initiator cannot reproduce either. `k_ref` needs neither.
1625
+ */
1626
+ const initiatorEphemeralRaw = decodeKey(hello.ephemeral);
1627
+ const refusalKey = initiatorEphemeralRaw === null
1628
+ ? null
1629
+ : refusalMacKey({ privateKey: deviceKey.privateKey, peerPublicRaw: initiatorEphemeralRaw });
1630
+ /**
1631
+ * A refusal body: sealed when we can prove it, bare when we cannot.
1632
+ *
1633
+ * @param {number} code
1634
+ * @returns {Buffer}
1635
+ */
1636
+ const refuse = (code) => (refusalKey === null
1637
+ ? encodeRefuse(hello.session, code, deviceKey.machineId)
1638
+ : sealRefuse(hello.session, code, deviceKey.machineId, refusalKey));
1639
+
1436
1640
  if (hello.version !== CRYPTO_VERSION) {
1641
+ // The version is decided before any key is looked at — a peer speaking a different handshake
1642
+ // version must be told *that*, not "unknown peer" — and this refusal stays bare on purpose: the
1643
+ // peer receiving it speaks a body layout this build cannot vouch for, so a trailing field it does
1644
+ // not know about would be worse than useless.
1437
1645
  return {
1438
1646
  ok: false,
1439
1647
  reason: CRYPTO_REASON.VERSION_MISMATCH,
@@ -1441,19 +1649,19 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1441
1649
  };
1442
1650
  }
1443
1651
 
1444
- const initiatorKey =
1445
- hello.machineId === null
1446
- ? null
1447
- : peerKeys instanceof PeerKeyStore
1448
- ? peerKeys.get(hello.machineId)
1449
- : (peerKeys.get(hello.machineId) ?? null);
1652
+ const initiatorKey = keyFor(hello.machineId);
1450
1653
 
1451
1654
  if (initiatorKey === null) {
1452
- // Nothing this peer says can be verified, and we will not guess. The refusal is named and
1453
- // unauthenticated — it proves nothing, and it is the only honest answer available. It says
1454
- // `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to know: a
1455
- // handshake is impossible here, so it may use the plaintext channel it would have had before this
1456
- // work rather than waiting out a timeout.
1655
+ // Nothing this peer says can be verified, and we will not guess. The refusal is named — and it is
1656
+ // *sealed*, because `k_ref` is exactly the proof this case can carry: it says "this datagram came
1657
+ // from the machine that holds the static key you dialled", which is precisely what a peer with no
1658
+ // key on file for us can still prove. `p2p.mjs` turns that into the rule that a refusal which
1659
+ // would choose a plaintext channel needs either this proof or the corroboration of the same
1660
+ // endpoint having acknowledged the punch.
1661
+ //
1662
+ // It says `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to
1663
+ // know: a handshake is impossible here, so it may use the plaintext channel it would have had
1664
+ // before this work rather than waiting out a timeout.
1457
1665
  //
1458
1666
  // This branch has a second, ordinary caller: a handshake from *our own* machine id, which is what
1459
1667
  // a task the relay leased back to the dispatching machine produces. We hold no peer key for
@@ -1461,19 +1669,11 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1461
1669
  // self-dial has always used. That is the right answer rather than an oversight: a self-connection
1462
1670
  // has no path to protect and must not fail, or a machine would lose the task it dispatched to
1463
1671
  // itself.
1464
- return {
1465
- ok: false,
1466
- reason: CRYPTO_REASON.UNKNOWN_PEER,
1467
- refuse: encodeRefuse(hello.session, REFUSE.NO_PEER_KEY, deviceKey.machineId),
1468
- };
1672
+ return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.NO_PEER_KEY) };
1469
1673
  }
1470
1674
  const initiatorRaw = decodeKey(initiatorKey);
1471
1675
  if (initiatorRaw === null) {
1472
- return {
1473
- ok: false,
1474
- reason: CRYPTO_REASON.UNKNOWN_PEER,
1475
- refuse: encodeRefuse(hello.session, REFUSE.UNKNOWN_PEER, deviceKey.machineId),
1476
- };
1676
+ return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.UNKNOWN_PEER) };
1477
1677
  }
1478
1678
 
1479
1679
  const k0 = derive(deviceKey.sharedSecret(initiatorRaw), STATIC_KEY_LABEL);
@@ -1484,12 +1684,10 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1484
1684
  if (!bytesEqual(hello.mac, expectedMac)) {
1485
1685
  // The peer is known, and its mac is wrong: it does not hold the private key we have on file for
1486
1686
  // it. A distinct code from UNKNOWN_PEER because the responses differ —one means "refresh that
1487
- // machine's key", the other means "somebody is guessing at names".
1488
- return {
1489
- ok: false,
1490
- reason: CRYPTO_REASON.BAD_MAC,
1491
- refuse: encodeRefuse(hello.session, REFUSE.BAD_MAC, deviceKey.machineId),
1492
- };
1687
+ // machine's key", the other means "somebody is guessing at names". Sealed with `k_ref` and not
1688
+ // `k0`, because when our record of *its* key is the stale one, `k0` is a value neither end can
1689
+ // agree on — and this is the refusal whose whole content is "our records disagree".
1690
+ return { ok: false, reason: CRYPTO_REASON.BAD_MAC, refuse: refuse(REFUSE.BAD_MAC) };
1493
1691
  }
1494
1692
 
1495
1693
  const nonceKey = `${hello.machineId}#${hello.session}#${hello.ephemeral}`;
@@ -590,6 +590,12 @@ export class P2PNode extends EventEmitter {
590
590
  this.announcing = null;
591
591
  /** @type {NodeJS.Timeout|null} */
592
592
  this.refreshTimer = null;
593
+ /**
594
+ * Asked not to hold the process open (see `unref()`). Remembered rather than applied once:
595
+ * `close()` followed by `start()` binds a *second* socket, and a promise that covered only the
596
+ * first one would not be a promise.
597
+ */
598
+ this.unreferenced = false;
593
599
  /** @type {symbol|null} Identifies the current run; a stale loop/announce is ignored. */
594
600
  this.runToken = null;
595
601
  /** @type {Promise<void>|null} */
@@ -690,6 +696,33 @@ export class P2PNode extends EventEmitter {
690
696
  if (this.listenerCount('error') > 0) this.emit('error', new Error(message));
691
697
  }
692
698
 
699
+ /**
700
+ * Take the node off the process's liveness budget.
701
+ *
702
+ * A running node holds three things that would otherwise keep a Node.js process alive on their
703
+ * own, and all three are handled here: the bound UDP socket (a referenced handle), the announce
704
+ * refresh timer, and the accept loop's own wait timeout — a node that is simply waiting for a
705
+ * punch has that timer pending essentially all of the time. An embedder that has no disposer to
706
+ * call — a bare context with no `ctx.effect`, or an older host — has no other way to say so, and
707
+ * the alternative is a process that can never exit.
708
+ *
709
+ * The flag is remembered rather than applied once (`unreferenced`), because `close()` followed by
710
+ * `start()` binds a *second* socket and arms a *second* accept wait; the socket is unref'd as it
711
+ * binds, and the accept loop reads the flag on every round. A call made *after* `start()` therefore
712
+ * takes effect on the next round, which is bounded by the accept timeout. `close()` still releases
713
+ * everything; this only changes whether those handles hold the loop open.
714
+ *
715
+ * Idempotent, and safe before `start()` (and on a `relay` node, which never binds at all).
716
+ *
717
+ * @returns {this}
718
+ */
719
+ unref() {
720
+ this.unreferenced = true;
721
+ this.socket?.unref?.();
722
+ this.refreshTimer?.unref?.();
723
+ return this;
724
+ }
725
+
693
726
  /**
694
727
  * Bring the node up: bind the socket, start the accept loop, and start announcing.
695
728
  *
@@ -771,6 +804,9 @@ export class P2PNode extends EventEmitter {
771
804
  });
772
805
 
773
806
  this.socket = socket;
807
+ // `unref()` is remembered across binds: a caller that asked not to hold the process open must
808
+ // not start holding it open again just because this is the second socket the node has owned.
809
+ if (this.unreferenced) socket.unref?.();
774
810
  this.local = local;
775
811
  this.started = true;
776
812
  this.runToken = Symbol('p2p-run');
@@ -1438,6 +1474,10 @@ export class P2PNode extends EventEmitter {
1438
1474
  socket: this.socket,
1439
1475
  timeoutMs: this.tuning.acceptTimeoutMs,
1440
1476
  claimed: this.claimed,
1477
+ // Read on every round, not once: `unref()` can be called after `start()`, and the wait
1478
+ // this loop is about to arm is then the only handle that could still hold the process
1479
+ // open. See `unref()`.
1480
+ unrefWait: this.unreferenced,
1441
1481
  // v0.6.0: handshakes are answered only when this machine holds a key of its own. Without
1442
1482
  // one, `accept()` is byte-for-byte v0.5.x — every HELLO answered plainly — which is what
1443
1483
  // keeps a mixed fleet working rather than half-encrypted and confused.