@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.
@@ -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,43 @@ 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
+ * The relay is not serving peer keys, so no handshake can be armed by anyone (v0.6.2).
214
+ *
215
+ * Distinct from {@link PLAINTEXT_PEER} on purpose, and the distinction is the whole reason this code
216
+ * exists. A relay older than v0.6.0 has never heard of `static_key`: it accepts an announcement,
217
+ * silently drops the field, and serves an entry without it. Every peer then looks exactly like a
218
+ * machine that has no key — which is how a stale *relay* came to be reported for hours as "the peer
219
+ * is an older build", pointing the investigation at the one component that was up to date.
220
+ *
221
+ * Detected from this node's own announcement: we POST a key, so the entry the relay echoes back says
222
+ * whether it kept it. No relay cooperation and no version negotiation required, which is what makes
223
+ * it work against the relay that is actually deployed.
224
+ */
225
+ RELAY_NO_KEYS: 'P2P_CRYPTO_RELAY_NO_KEYS',
226
+ /**
227
+ * A `REFUSE` datagram was **dropped**, not honoured: it named a session this punch did not agree.
228
+ *
229
+ * The four `REFUSE_*` codes below name an *ignored datagram* rather than a failed punch, which is
230
+ * deliberate and is the whole of the v0.6.0 hardening: an unauthenticated refusal that cannot be
231
+ * tied to this punch must not be able to change its outcome, and it must not be able to fail it
232
+ * either — an attacker who could turn "the peer never answered" into "the peer refused" would have
233
+ * been handed a denial of service in exchange for closing a downgrade.
234
+ */
235
+ REFUSE_SESSION_MISMATCH: 'P2P_CRYPTO_REFUSE_SESSION_MISMATCH',
236
+ /** A `REFUSE` datagram was dropped: it came from an address this punch never dialled. */
237
+ REFUSE_UNPUNCHED_SOURCE: 'P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE',
238
+ /** A `REFUSE` datagram was dropped: it carried a `k0` MAC that did not verify. */
239
+ REFUSE_BAD_MAC: 'P2P_CRYPTO_REFUSE_BAD_MAC',
240
+ /**
241
+ * A downgrade-inducing `REFUSE` was dropped: it did not come from the peer that answered the punch.
242
+ *
243
+ * `NO_KEY`/`NO_PEER_KEY` say "I cannot take part, use the plaintext channel" — the one refusal that
244
+ * changes the outcome into something weaker than a failure. Requiring it to come from the address
245
+ * that actually answered the punch is what stops a datagram from a third address from choosing a
246
+ * plaintext channel on the peer's behalf.
247
+ */
248
+ REFUSE_UNCORROBORATED: 'P2P_CRYPTO_REFUSE_UNCORROBORATED',
203
249
  });
204
250
 
205
251
  /* -------------------------------------------------------------------------- */
@@ -788,12 +834,35 @@ export function bytesEqual(a, b) {
788
834
  *
789
835
  * HELLO magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
790
836
  * HELLO_ACK magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
791
- * REFUSE magic|version|session u32|code u8|idLen u8|machineId
837
+ * REFUSE magic|version|session u32|code u8|idLen u8|machineId[|mac 48]
838
+ *
839
+ * `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC).
840
+ *
841
+ * `REFUSE` **may** be unauthenticated, and that is not an oversight: it is sent precisely when we
842
+ * cannot authenticate the peer (we hold no key for it), so it can carry no proof — and it is what
843
+ * turns "the handshake did not complete" into a named reason on *both* ends instead of a timeout on
844
+ * one of them. When the responder received a handshake `HELLO` it holds the initiator's ephemeral
845
+ * `e_i`, and it *always* holds its own static private key `s_r`, so it can always derive
846
+ * `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` — a key the initiator derives just as easily from
847
+ * `e_i`'s private half and `s_r`'s public key, and that nobody else can derive at all. The trailing 48
848
+ * bytes of a refusal are then `sealMac(k_ref, hash(REFUSE_MAC_LABEL ‖ the refusal without its MAC))`.
849
+ *
850
+ * That is the asymmetry this file relies on, and it is worth stating plainly because the naive
851
+ * version of it is wrong: `k0 = ECDH(s_i, s_r)` — the handshake MAC's key — is *not* usable here. A
852
+ * responder that has no key for us (or a stale one) cannot compute it, and those are exactly the two
853
+ * cases that send a refusal. `k_ref` needs neither end to hold the other's static key: the initiator
854
+ * sends `e_i` in the clear, and the responder's `s_r` is public. What it proves is what a refusal
855
+ * needs to prove — *this datagram came from the machine that holds `s_r`* — and it is therefore
856
+ * available to `BAD_MAC` (our proof did not verify), `NO_PEER_KEY` (we hold no key for you) and
857
+ * `UNKNOWN_PEER` alike.
792
858
  *
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.
859
+ * `VERSION` is the one refusal that stays bare, and deliberately: the peer that needs it speaks a
860
+ * handshake version whose body layout this build cannot rely on, so a trailing field it does not know
861
+ * about would be worse than useless.
862
+ *
863
+ * The unauthenticated form still exists and is still honoured by `p2p.mjs` — a peer running an older
864
+ * build cannot produce `k_ref` and must not lose its plaintext channel for it. What `p2p.mjs` will not
865
+ * do is let a datagram it cannot tie to the punch (see `REFUSE_GUARD` there) rewrite a ledger.
797
866
  */
798
867
 
799
868
  /**
@@ -808,6 +877,14 @@ export function bytesEqual(a, b) {
808
877
  * it is not a downgrade: the peer never offered a key to downgrade from.
809
878
  * * `BAD_MAC` — the peer *is* known and its MAC over its static key did not verify. That is either a
810
879
  * stale key or an impersonation attempt, and the initiator treats it as terminal.
880
+ *
881
+ * The two known-peer codes (`BAD_MAC`, `REPLAY`) can be sealed with the ephemeral-static key of
882
+ * {@link refusalMacKey}, and so can the two unknown-peer codes — `k_ref` needs no static key on file.
883
+ * `VERSION` is the exception and stays bare. What `p2p.mjs` does with that asymmetry (`REFUSE_GUARD`
884
+ * there) is the rule: a *plaintext-choosing* refusal (`NO_KEY`, `NO_PEER_KEY`) must either verify its
885
+ * seal or arrive, unsealed, from the endpoint that acknowledged the punch; a terminal refusal is
886
+ * honoured from a dialled address sealed or not, with `refusal_authenticated` recording whether the
887
+ * seal was proof. Nothing dropped ever fails a punch.
811
888
  */
812
889
  export const REFUSE = Object.freeze({
813
890
  UNKNOWN_PEER: 1,
@@ -894,32 +971,136 @@ export function decodeHello(body) {
894
971
  };
895
972
  }
896
973
 
897
- /** @param {number} session @param {number} code @param {string|null} [machineId] @returns {Buffer} */
898
- export function encodeRefuse(session, code, machineId = null) {
974
+ /**
975
+ * Build a `REFUSE` body, optionally with the trailing `k0` MAC of {@link sealRefuse}.
976
+ *
977
+ * @param {number} session
978
+ * @param {number} code
979
+ * @param {string|null} [machineId]
980
+ * @param {Buffer|null} [mac] 48 bytes, or null for the unauthenticated form.
981
+ * @returns {Buffer}
982
+ */
983
+ export function encodeRefuse(session, code, machineId = null, mac = null) {
899
984
  const id = (machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8')).subarray(
900
985
  0,
901
986
  MAX_HANDSHAKE_MACHINE_ID,
902
987
  );
903
- const out = Buffer.alloc(8 + id.length);
988
+ const trailing = mac === null ? Buffer.alloc(0) : mac;
989
+ if (trailing.length !== 0 && trailing.length !== MAC_BYTES) {
990
+ throw new TypeError(`encodeRefuse: a refusal MAC must be ${MAC_BYTES} bytes`);
991
+ }
992
+ const out = Buffer.alloc(8 + id.length + trailing.length);
904
993
  out.writeUInt8(FRAME_MAGIC, 0);
905
994
  out.writeUInt8(CRYPTO_VERSION, 1);
906
995
  out.writeUInt32BE(session >>> 0, 2);
907
996
  out.writeUInt8(code, 6);
908
997
  out.writeUInt8(id.length, 7);
909
998
  id.copy(out, 8);
999
+ trailing.copy(out, 8 + id.length);
910
1000
  return out;
911
1001
  }
912
1002
 
913
- /** @param {Buffer} body @returns {{session:number, code:number, machineId:string|null}|null} */
1003
+ /**
1004
+ * Parse a `REFUSE` body — both the bare form and the `k0`-MAC'd one — or null.
1005
+ *
1006
+ * Strict about the length, in both forms, and that strictness is load-bearing rather than tidy: the
1007
+ * body being parsed here arrives as the body of a **`HELLO_ACK`**, and a handshake body is also
1008
+ * `magic|version|session|…` with a byte that can look like an id length. An earlier version accepted
1009
+ * exactly one length and validated nothing else, so a genuine `HELLO_ACK` could be read as a refusal
1010
+ * — `body[6]` (the first byte of the responder's ephemeral, which is random) became the refusal code
1011
+ * and `body[7]` the id length — roughly one handshake in 256 refusing itself. The machine-id check
1012
+ * below is what makes that collision impossible rather than unlikely: a handshake body's bytes from
1013
+ * offset 8 on are key material, and key material is not a printable machine id.
1014
+ *
1015
+ * @param {Buffer} body
1016
+ * @returns {{session:number, code:number, machineId:string|null, mac:Buffer|null, headerBytes:number,
1017
+ * bytes:Buffer}|null}
1018
+ */
914
1019
  export function decodeRefuse(body) {
915
1020
  if (!Buffer.isBuffer(body) || body.length < 8) return null;
916
1021
  if (body.readUInt8(0) !== FRAME_MAGIC) return null;
1022
+ if (body.readUInt8(1) !== CRYPTO_VERSION) return null;
917
1023
  const session = body.readUInt32BE(2);
918
1024
  const code = body.readUInt8(6);
919
1025
  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 };
1026
+ if (idLength > MAX_HANDSHAKE_MACHINE_ID) return null;
1027
+ const headerBytes = 8 + idLength;
1028
+ if (body.length !== headerBytes && body.length !== headerBytes + MAC_BYTES) return null;
1029
+ let machineId = null;
1030
+ if (idLength > 0) {
1031
+ machineId = body.subarray(8, headerBytes).toString('utf8');
1032
+ // Same rule, and the same reason, as `decodeHello`: no build produces a control byte in a machine
1033
+ // id, and one that arrives would put a delimiter into a log line.
1034
+ // eslint-disable-next-line no-control-regex -- rejecting control bytes is the point
1035
+ if (/[\u0000-\u001f\u007f]/.test(machineId)) return null;
1036
+ }
1037
+ const mac = body.length === headerBytes + MAC_BYTES
1038
+ ? Buffer.from(body.subarray(headerBytes, headerBytes + MAC_BYTES))
1039
+ : null;
1040
+ return { session, code, machineId, mac, headerBytes, bytes: Buffer.from(body) };
1041
+ }
1042
+
1043
+ /**
1044
+ * The transcript a refusal MAC is taken over: the label, then the refusal exactly as it goes on the
1045
+ * wire *without* its MAC. Recomputed by the receiver from the bytes it holds, never sent separately.
1046
+ *
1047
+ * @param {Buffer} body The refusal body without the trailing MAC.
1048
+ * @returns {Buffer} 32 bytes.
1049
+ */
1050
+ export function refuseTranscript(body) {
1051
+ return handshakeHash(REFUSE_MAC_LABEL, body);
1052
+ }
1053
+
1054
+ /**
1055
+ * A refusal with proof attached, for every responder that received a parseable `HELLO`.
1056
+ *
1057
+ * @param {number} session
1058
+ * @param {number} code
1059
+ * @param {string|null} machineId
1060
+ * @param {Buffer} macKey `k_ref`, from {@link refusalMacKey}.
1061
+ * @returns {Buffer}
1062
+ */
1063
+ export function sealRefuse(session, code, machineId, macKey) {
1064
+ const body = encodeRefuse(session, code, machineId);
1065
+ return encodeRefuse(session, code, machineId, sealMac(macKey, refuseTranscript(body)));
1066
+ }
1067
+
1068
+ /**
1069
+ * The MAC key a refusal is sealed with: `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)`.
1070
+ *
1071
+ * Both ends derive it from material the *other* end published, and neither needs the other's static
1072
+ * public key to be on file: the initiator passes its own ephemeral private key and the responder's
1073
+ * static public key, the responder passes its own static private key and the initiator's ephemeral
1074
+ * public key from the `HELLO`. An attacker who has neither private key cannot derive it, which is
1075
+ * what makes a sealed refusal evidence rather than a claim.
1076
+ *
1077
+ * @param {object} input
1078
+ * @param {import('node:crypto').KeyObject} input.privateKey `e_i`'s private half (initiator) or `s_r`
1079
+ * (responder).
1080
+ * @param {Buffer} input.peerPublicRaw 32 raw bytes of `s_r` (initiator) or `e_i` (responder).
1081
+ * @returns {Buffer} 32 bytes.
1082
+ */
1083
+ export function refusalMacKey({ privateKey, peerPublicRaw }) {
1084
+ if (!Buffer.isBuffer(peerPublicRaw) || peerPublicRaw.length !== KEY_BYTES) {
1085
+ throw new TypeError(`refusalMacKey: the peer public key must be ${KEY_BYTES} raw bytes`);
1086
+ }
1087
+ return derive(diffieHellman({ privateKey, publicKey: publicKeyFromRaw(peerPublicRaw) }), REFUSE_MAC_LABEL);
1088
+ }
1089
+
1090
+ /**
1091
+ * Verify the optional MAC on a received refusal.
1092
+ *
1093
+ * @param {Buffer|null} macKey `k_ref`, or null when this end has nothing to check with.
1094
+ * @param {Buffer} body The whole refusal body as received.
1095
+ * @returns {boolean|null} `null` when the body carries no MAC (or is not a refusal at all), otherwise
1096
+ * whether it verifies. `false` is never "unknown" — it is a MAC that was present and wrong.
1097
+ */
1098
+ export function verifyRefuseMac(macKey, body) {
1099
+ const decoded = decodeRefuse(body);
1100
+ if (decoded === null || decoded.mac === null) return null;
1101
+ if (macKey === null) return false;
1102
+ const expected = sealMac(macKey, refuseTranscript(body.subarray(0, decoded.headerBytes)));
1103
+ return bytesEqual(expected, decoded.mac);
923
1104
  }
924
1105
 
925
1106
  /* -------------------------------------------------------------------------- */
@@ -1341,7 +1522,9 @@ export function ackTranscript(helloBytes, responderEphemeralRaw) {
1341
1522
  * @param {string} options.peerMachineId The machine being dialled.
1342
1523
  * @param {string} options.peerPublicKey Its static public key, from the offer or from the relay.
1343
1524
  * @param {number} options.session The punch session.
1344
- * @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string, finish: Function}}
1525
+ * @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
1526
+ * refusalMacKey: Buffer, finish: Function}} `refusalMacKey` is `k_ref` of {@link refusalMacKey}: what
1527
+ * this end checks a `REFUSE` against, and the reason a refusal can be evidence rather than a claim.
1345
1528
  */
1346
1529
  export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, session }) {
1347
1530
  const peerRaw = decodeKey(peerPublicKey);
@@ -1364,6 +1547,10 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
1364
1547
  ephemeral: encodeKey(ephemeralPublic),
1365
1548
  session,
1366
1549
  peerMachineId,
1550
+ // Derived once, here, from the ephemeral this handshake is about to send: `ECDH(e_i, s_r)`. The
1551
+ // responder derives the same value from that very `e_i` and its own static key, which is what lets
1552
+ // it answer a refusal with proof even when it holds no key for us at all.
1553
+ refusalMacKey: refusalMacKey({ privateKey: ephemeral.privateKey, peerPublicRaw: peerRaw }),
1367
1554
  /**
1368
1555
  * Verify `HELLO_ACK` and hand back the transport session.
1369
1556
  *
@@ -1433,7 +1620,42 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
1433
1620
  export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1434
1621
  const hello = decodeHello(body);
1435
1622
  if (hello === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED, refuse: null };
1623
+
1624
+ /** This peer's static public key as filed under its claimed machine id, or null. */
1625
+ const keyFor = (machineId) => {
1626
+ if (machineId === null) return null;
1627
+ return peerKeys instanceof PeerKeyStore
1628
+ ? peerKeys.get(machineId)
1629
+ : (peerKeys.get(machineId) ?? null);
1630
+ };
1631
+
1632
+ /**
1633
+ * `k_ref` for this exchange, from the initiator's `e_i` and our own static key.
1634
+ *
1635
+ * Present whenever the `HELLO` parsed at all, which is every refusal below except `MALFORMED` (which
1636
+ * sends nothing). It is deliberately *not* `k0`: a responder with no key for the initiator — the
1637
+ * sender of `NO_PEER_KEY` — cannot compute `k0`, and a responder with a stale key for it (the sender
1638
+ * of `BAD_MAC`) would produce a `k0` the initiator cannot reproduce either. `k_ref` needs neither.
1639
+ */
1640
+ const initiatorEphemeralRaw = decodeKey(hello.ephemeral);
1641
+ const refusalKey = initiatorEphemeralRaw === null
1642
+ ? null
1643
+ : refusalMacKey({ privateKey: deviceKey.privateKey, peerPublicRaw: initiatorEphemeralRaw });
1644
+ /**
1645
+ * A refusal body: sealed when we can prove it, bare when we cannot.
1646
+ *
1647
+ * @param {number} code
1648
+ * @returns {Buffer}
1649
+ */
1650
+ const refuse = (code) => (refusalKey === null
1651
+ ? encodeRefuse(hello.session, code, deviceKey.machineId)
1652
+ : sealRefuse(hello.session, code, deviceKey.machineId, refusalKey));
1653
+
1436
1654
  if (hello.version !== CRYPTO_VERSION) {
1655
+ // The version is decided before any key is looked at — a peer speaking a different handshake
1656
+ // version must be told *that*, not "unknown peer" — and this refusal stays bare on purpose: the
1657
+ // peer receiving it speaks a body layout this build cannot vouch for, so a trailing field it does
1658
+ // not know about would be worse than useless.
1437
1659
  return {
1438
1660
  ok: false,
1439
1661
  reason: CRYPTO_REASON.VERSION_MISMATCH,
@@ -1441,19 +1663,19 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1441
1663
  };
1442
1664
  }
1443
1665
 
1444
- const initiatorKey =
1445
- hello.machineId === null
1446
- ? null
1447
- : peerKeys instanceof PeerKeyStore
1448
- ? peerKeys.get(hello.machineId)
1449
- : (peerKeys.get(hello.machineId) ?? null);
1666
+ const initiatorKey = keyFor(hello.machineId);
1450
1667
 
1451
1668
  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.
1669
+ // Nothing this peer says can be verified, and we will not guess. The refusal is named — and it is
1670
+ // *sealed*, because `k_ref` is exactly the proof this case can carry: it says "this datagram came
1671
+ // from the machine that holds the static key you dialled", which is precisely what a peer with no
1672
+ // key on file for us can still prove. `p2p.mjs` turns that into the rule that a refusal which
1673
+ // would choose a plaintext channel needs either this proof or the corroboration of the same
1674
+ // endpoint having acknowledged the punch.
1675
+ //
1676
+ // It says `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to
1677
+ // know: a handshake is impossible here, so it may use the plaintext channel it would have had
1678
+ // before this work rather than waiting out a timeout.
1457
1679
  //
1458
1680
  // This branch has a second, ordinary caller: a handshake from *our own* machine id, which is what
1459
1681
  // a task the relay leased back to the dispatching machine produces. We hold no peer key for
@@ -1461,19 +1683,11 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1461
1683
  // self-dial has always used. That is the right answer rather than an oversight: a self-connection
1462
1684
  // has no path to protect and must not fail, or a machine would lose the task it dispatched to
1463
1685
  // itself.
1464
- return {
1465
- ok: false,
1466
- reason: CRYPTO_REASON.UNKNOWN_PEER,
1467
- refuse: encodeRefuse(hello.session, REFUSE.NO_PEER_KEY, deviceKey.machineId),
1468
- };
1686
+ return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.NO_PEER_KEY) };
1469
1687
  }
1470
1688
  const initiatorRaw = decodeKey(initiatorKey);
1471
1689
  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
- };
1690
+ return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.UNKNOWN_PEER) };
1477
1691
  }
1478
1692
 
1479
1693
  const k0 = derive(deviceKey.sharedSecret(initiatorRaw), STATIC_KEY_LABEL);
@@ -1484,12 +1698,10 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
1484
1698
  if (!bytesEqual(hello.mac, expectedMac)) {
1485
1699
  // The peer is known, and its mac is wrong: it does not hold the private key we have on file for
1486
1700
  // 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
- };
1701
+ // machine's key", the other means "somebody is guessing at names". Sealed with `k_ref` and not
1702
+ // `k0`, because when our record of *its* key is the stale one, `k0` is a value neither end can
1703
+ // agree on — and this is the refusal whose whole content is "our records disagree".
1704
+ return { ok: false, reason: CRYPTO_REASON.BAD_MAC, refuse: refuse(REFUSE.BAD_MAC) };
1493
1705
  }
1494
1706
 
1495
1707
  const nonceKey = `${hello.machineId}#${hello.session}#${hello.ephemeral}`;
@@ -509,6 +509,14 @@ export class P2PNode extends EventEmitter {
509
509
  this.deviceKey = deviceKey ?? null;
510
510
  /** @type {PeerKeyStore} Peer static keys, learned from offers and from the relay. */
511
511
  this.peerKeys = peerKeys instanceof PeerKeyStore ? peerKeys : new PeerKeyStore();
512
+ /**
513
+ * Does the relay keep the static key this machine announces? `null` until an announcement proves
514
+ * it either way. See the note in `performAnnounce` — a relay older than v0.6.0 drops the field
515
+ * silently, and that is otherwise indistinguishable from every peer having no key.
516
+ *
517
+ * @type {boolean|null}
518
+ */
519
+ this.relayServesKeys = null;
512
520
  /** @type {object|null} `CryptoSession` overrides; null means the documented thresholds. */
513
521
  this.cryptoTuning = cryptoTuning ?? null;
514
522
  /** @type {NonceCache} Replay tracking for inbound handshakes. Outlives one `accept()`. */
@@ -590,6 +598,12 @@ export class P2PNode extends EventEmitter {
590
598
  this.announcing = null;
591
599
  /** @type {NodeJS.Timeout|null} */
592
600
  this.refreshTimer = null;
601
+ /**
602
+ * Asked not to hold the process open (see `unref()`). Remembered rather than applied once:
603
+ * `close()` followed by `start()` binds a *second* socket, and a promise that covered only the
604
+ * first one would not be a promise.
605
+ */
606
+ this.unreferenced = false;
593
607
  /** @type {symbol|null} Identifies the current run; a stale loop/announce is ignored. */
594
608
  this.runToken = null;
595
609
  /** @type {Promise<void>|null} */
@@ -648,6 +662,12 @@ export class P2PNode extends EventEmitter {
648
662
  enabled: this.deviceKey !== null,
649
663
  public_key: this.deviceKey === null ? null : (this.deviceKey.publicKeyBase64 ?? null),
650
664
  peers_known: this.peerKeys.size,
665
+ /**
666
+ * `null` until this node's own announcement proves it either way; `false` means the relay
667
+ * dropped the key we sent, which is the fact that explains an otherwise mysterious
668
+ * `peers_known: 0` on a fleet whose machines all hold keys (v0.6.2).
669
+ */
670
+ relay_serves_keys: this.relayServesKeys,
651
671
  handshakes: this.cryptoCounts.handshakes,
652
672
  encrypted_channels: this.cryptoCounts.encrypted_channels,
653
673
  plaintext_channels: this.cryptoCounts.plaintext_channels,
@@ -690,6 +710,33 @@ export class P2PNode extends EventEmitter {
690
710
  if (this.listenerCount('error') > 0) this.emit('error', new Error(message));
691
711
  }
692
712
 
713
+ /**
714
+ * Take the node off the process's liveness budget.
715
+ *
716
+ * A running node holds three things that would otherwise keep a Node.js process alive on their
717
+ * own, and all three are handled here: the bound UDP socket (a referenced handle), the announce
718
+ * refresh timer, and the accept loop's own wait timeout — a node that is simply waiting for a
719
+ * punch has that timer pending essentially all of the time. An embedder that has no disposer to
720
+ * call — a bare context with no `ctx.effect`, or an older host — has no other way to say so, and
721
+ * the alternative is a process that can never exit.
722
+ *
723
+ * The flag is remembered rather than applied once (`unreferenced`), because `close()` followed by
724
+ * `start()` binds a *second* socket and arms a *second* accept wait; the socket is unref'd as it
725
+ * binds, and the accept loop reads the flag on every round. A call made *after* `start()` therefore
726
+ * takes effect on the next round, which is bounded by the accept timeout. `close()` still releases
727
+ * everything; this only changes whether those handles hold the loop open.
728
+ *
729
+ * Idempotent, and safe before `start()` (and on a `relay` node, which never binds at all).
730
+ *
731
+ * @returns {this}
732
+ */
733
+ unref() {
734
+ this.unreferenced = true;
735
+ this.socket?.unref?.();
736
+ this.refreshTimer?.unref?.();
737
+ return this;
738
+ }
739
+
693
740
  /**
694
741
  * Bring the node up: bind the socket, start the accept loop, and start announcing.
695
742
  *
@@ -771,6 +818,9 @@ export class P2PNode extends EventEmitter {
771
818
  });
772
819
 
773
820
  this.socket = socket;
821
+ // `unref()` is remembered across binds: a caller that asked not to hold the process open must
822
+ // not start holding it open again just because this is the second socket the node has owned.
823
+ if (this.unreferenced) socket.unref?.();
774
824
  this.local = local;
775
825
  this.started = true;
776
826
  this.runToken = Symbol('p2p-run');
@@ -915,6 +965,28 @@ export class P2PNode extends EventEmitter {
915
965
  const posted = response?.ok === true;
916
966
  if (!posted && postError === null) postError = describeAnnounceFailure(response);
917
967
 
968
+ /**
969
+ * Does the relay keep the static key we send it? (v0.6.2)
970
+ *
971
+ * This is knowable from our own announcement, and only from our own announcement: we POST
972
+ * `static_key`, and the relay echoes the entry it stored. A relay that predates v0.6.0 has never
973
+ * heard of the field, drops it silently, and echoes an entry without it — indistinguishable, from
974
+ * any peer's point of view, from a machine that has no key at all.
975
+ *
976
+ * That ambiguity is what turned a stale relay into hours of "the peer is an older build". The
977
+ * deployed relay in this fleet started 2026-10-10T18:01:40Z; v0.6.0 and v0.6.1 shipped on
978
+ * 2026-10-11. Every announcement carried a valid key, every stored entry lost it, no dispatcher
979
+ * could ever arm a handshake, and `PLAINTEXT_PEER` blamed the one component that was up to date.
980
+ *
981
+ * Recorded rather than reported here: one announce proves the relay's behaviour, and `dial()`
982
+ * turns it into a named reason only where it explains something. `null` means "not yet known" —
983
+ * a machine with no key of its own cannot ask the question at all, and must not conclude "no".
984
+ */
985
+ if (posted && this.deviceKey !== null) {
986
+ const echoed = response?.json?.static_key;
987
+ this.relayServesKeys = typeof echoed === 'string' && echoed !== '' ? true : false;
988
+ }
989
+
918
990
  // A POST failure is the headline when there is one: the announcement is not live at
919
991
  // all. Otherwise a discovery failure is what makes this announcement incomplete.
920
992
  const error = !posted ? postError : discoveryOk ? null : discoveryError;
@@ -1106,6 +1178,9 @@ export class P2PNode extends EventEmitter {
1106
1178
  const fromOffer = Array.isArray(options.candidates) && options.candidates.length > 0;
1107
1179
 
1108
1180
  let announced = [];
1181
+ // v0.6.2: the peer's static key as published in the same record the candidates came from. Lifted
1182
+ // out of the lookup block because `lookup` is scoped to it, and the key is needed further down.
1183
+ let announcedStaticKey = null;
1109
1184
  if (fromOffer) {
1110
1185
  announced = options.candidates;
1111
1186
  } else {
@@ -1130,6 +1205,8 @@ export class P2PNode extends EventEmitter {
1130
1205
  return fail(`P2P_LOOKUP_FAILED: ${describeLookupFailure(lookup)}`);
1131
1206
  }
1132
1207
  announced = Array.isArray(lookup.json?.peer?.candidates) ? lookup.json.peer.candidates : [];
1208
+ const key = lookup.json?.peer?.static_key;
1209
+ if (typeof key === 'string' && key !== '') announcedStaticKey = key;
1133
1210
  }
1134
1211
 
1135
1212
  const remote = [];
@@ -1140,6 +1217,52 @@ export class P2PNode extends EventEmitter {
1140
1217
  remote.push(usable);
1141
1218
  if (remote.length >= this.tuning.maxCandidates) break;
1142
1219
  }
1220
+
1221
+ /**
1222
+ * The peer's static key, learned from its own announcement when this machine does not hold one.
1223
+ *
1224
+ * v0.6.2. This is the other half of the plaintext-downgrade fix, and without it the reason code
1225
+ * alone would have been a better-worded lie: the dispatcher that reported
1226
+ * `P2P_CRYPTO_PLAINTEXT_PEER` against a same-version peer *could* have encrypted, it simply had an
1227
+ * empty key store and never asked. The relay has carried the peer's `static_key` in this very
1228
+ * record since v0.6.0 (`relay/peers.mjs`), the plugin already read it, and the agent's own `dial()`
1229
+ * was fetching the same record for the candidate list and then throwing the key away.
1230
+ *
1231
+ * Adopting it here is what makes the local/remote distinction possible as well as useful: a peer
1232
+ * that publishes no key is genuinely an older build and `PLAINTEXT_PEER` is honest; a peer that
1233
+ * publishes one we had not learned is *our* gap, and after this it is not a gap at all.
1234
+ */
1235
+ let peerKey = this.peerKeys.get(machineId);
1236
+ if (peerKey === null && announcedStaticKey !== null) {
1237
+ this.peerKeys.set(machineId, announcedStaticKey, 'relay');
1238
+ peerKey = announcedStaticKey;
1239
+ }
1240
+
1241
+ /**
1242
+ * Why this dial cannot be encrypted, when it cannot. Named here because this is where the reason
1243
+ * is actually known: `punch()` sees only "no crypto request" and used to guess, blaming the peer
1244
+ * for what is usually a local condition.
1245
+ *
1246
+ * `null` means a handshake will be armed. `PLAINTEXT_PEER` is reserved for the one case that is
1247
+ * about the peer -- it publishes no key, so there is nothing to encrypt with. Everything else is
1248
+ * `NO_KEY`: this machine has no device key of its own, holds no key for that peer, or is dialling
1249
+ * itself (a machine keeps no key for itself, so its own `respondHandshake` would refuse it with
1250
+ * `P2P_CRYPTO_UNKNOWN_PEER`).
1251
+ */
1252
+ const cryptoBlockedReason =
1253
+ this.deviceKey === null
1254
+ ? CRYPTO_REASON.NO_KEY
1255
+ : machineId === this.machineId
1256
+ ? CRYPTO_REASON.NO_KEY
1257
+ : peerKey === null
1258
+ ? this.relayServesKeys === false
1259
+ // The peer announced, we hold a key of our own, and no key came back for it. If this
1260
+ // node has already proved the relay drops keys, the relay is the cause and saying
1261
+ // otherwise sends the operator to the wrong machine.
1262
+ ? CRYPTO_REASON.RELAY_NO_KEYS
1263
+ : CRYPTO_REASON.PLAINTEXT_PEER
1264
+ : null;
1265
+
1143
1266
  if (remote.length === 0) {
1144
1267
  // `peersAnnounced` still counts what was offered, not what survived validation: a caller
1145
1268
  // reading "the punch failed" needs to see that there *was* a list, and that every entry in it
@@ -1174,17 +1297,19 @@ export class P2PNode extends EventEmitter {
1174
1297
  // sends would be refused by its own `respondHandshake` (`P2P_CRYPTO_UNKNOWN_PEER`) and the
1175
1298
  // task it dispatched to itself would be lost. `machineId !== this.machineId` is what makes a
1176
1299
  // self-lease survive a security upgrade, which is the same rule as every other fallback here.
1177
- ...(this.deviceKey === null ||
1178
- machineId === this.machineId ||
1179
- this.peerKeys.get(machineId) === null
1180
- ? {}
1181
- : {
1300
+ ...(cryptoBlockedReason === null && peerKey !== null
1301
+ ? {
1182
1302
  crypto: {
1183
1303
  deviceKey: this.deviceKey,
1184
- peerKey: this.peerKeys.get(machineId),
1304
+ peerKey,
1185
1305
  peerMachineId: machineId,
1186
1306
  },
1187
- }),
1307
+ }
1308
+ : {}),
1309
+ // v0.6.2: the reason travels with the call instead of being guessed downstream. `punch()`
1310
+ // cannot tell a local "we hold no key" from the peer being an older build, and it used to
1311
+ // report the peer-blaming one for both.
1312
+ cryptoBlockedReason,
1188
1313
  ...(this.cryptoTuning === null ? {} : { cryptoTuning: this.cryptoTuning }),
1189
1314
  });
1190
1315
  } catch (error) {
@@ -1438,6 +1563,10 @@ export class P2PNode extends EventEmitter {
1438
1563
  socket: this.socket,
1439
1564
  timeoutMs: this.tuning.acceptTimeoutMs,
1440
1565
  claimed: this.claimed,
1566
+ // Read on every round, not once: `unref()` can be called after `start()`, and the wait
1567
+ // this loop is about to arm is then the only handle that could still hold the process
1568
+ // open. See `unref()`.
1569
+ unrefWait: this.unreferenced,
1441
1570
  // v0.6.0: handshakes are answered only when this machine holds a key of its own. Without
1442
1571
  // one, `accept()` is byte-for-byte v0.5.x — every HELLO answered plainly — which is what
1443
1572
  // keeps a mixed fleet working rather than half-encrypted and confused.