@twinsearth/w2m-dsh-plugin 0.5.2 → 0.5.4

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.
@@ -45,6 +45,7 @@
45
45
  import { EventEmitter } from 'node:events';
46
46
  import { networkInterfaces } from 'node:os';
47
47
 
48
+ import { NonceCache, PeerKeyStore, CRYPTO_REASON } from './crypto.mjs';
48
49
  import { accept, connect, deriveSession } from './p2p.mjs';
49
50
  import {
50
51
  bindUdpSocket as bindUdpSocketDefault,
@@ -424,6 +425,15 @@ export class P2PNode extends EventEmitter {
424
425
  * falls back to the relay. Must be an integer in `0..65535`; `0` is the only
425
426
  * spelling that means "let the OS choose". Note that a port already in use makes
426
427
  * `start()` return a `P2P_BIND_FAILED`, which is reported and never fatal.
428
+ * @param {object} [options.deviceKey] This machine's static keypair (v0.6.0). A `DeviceKey`
429
+ * instance. Omitted or `null` means the direct path is plaintext, exactly as v0.5.x — which is
430
+ * the supported way to run a fleet that has not upgraded, and is *recorded* rather than hidden
431
+ * (`status.encryption`).
432
+ * @param {object} [options.peerKeys] A `PeerKeyStore`, adopted rather than copied so the caller and
433
+ * the node see the same keys. Defaults to a fresh empty store: a node with no keys yet is a node
434
+ * that will send plain punches and say so.
435
+ * @param {object} [options.cryptoTuning] `CryptoSession` options — rekey thresholds and retention —
436
+ * for a caller (a test) that cannot send 2^20 messages.
427
437
  */
428
438
  constructor(options = {}) {
429
439
  super();
@@ -441,6 +451,9 @@ export class P2PNode extends EventEmitter {
441
451
  bindUdpSocket = bindUdpSocketDefault,
442
452
  bindAddress = '0.0.0.0',
443
453
  bindPort = 0,
454
+ deviceKey = null,
455
+ peerKeys = null,
456
+ cryptoTuning = null,
444
457
  } = options;
445
458
 
446
459
  const normalized = normalizeP2PMode(mode);
@@ -488,6 +501,30 @@ export class P2PNode extends EventEmitter {
488
501
  /** @type {number} Local port passed to the binder; 0 means an ephemeral one. */
489
502
  this.bindPort = bindPort;
490
503
 
504
+ // ---- v0.6.0 identity and peer keys ------------------------------------
505
+ // Held on the node rather than on the agent because the node is what creates channels: the key a
506
+ // handshake uses has to be reachable from the exact place the channel is built, or the two could
507
+ // disagree about which peers are encryptable.
508
+ /** @type {object|null} This machine's static keypair, or null for a plaintext node. */
509
+ this.deviceKey = deviceKey ?? null;
510
+ /** @type {PeerKeyStore} Peer static keys, learned from offers and from the relay. */
511
+ this.peerKeys = peerKeys instanceof PeerKeyStore ? peerKeys : new PeerKeyStore();
512
+ /** @type {object|null} `CryptoSession` overrides; null means the documented thresholds. */
513
+ this.cryptoTuning = cryptoTuning ?? null;
514
+ /** @type {NonceCache} Replay tracking for inbound handshakes. Outlives one `accept()`. */
515
+ this.seenHandshakes = new NonceCache();
516
+ /**
517
+ * Refusals this node has issued or received, newest last, bounded.
518
+ *
519
+ * A refusal is the one crypto outcome that is *never* silent, so it is kept where `status` can
520
+ * report it rather than only written to a log line somebody has to be watching.
521
+ *
522
+ * @type {Array<{at:string, reason:string, machine_id:string|null, direction:string}>}
523
+ */
524
+ this.cryptoRefusals = [];
525
+ /** @type {{handshakes:number, encrypted_channels:number, plaintext_channels:number, refused:number}} */
526
+ this.cryptoCounts = { handshakes: 0, encrypted_channels: 0, plaintext_channels: 0, refused: 0 };
527
+
491
528
  const unknownTuning = Object.keys(tuning).filter((key) => !TUNING_KEYS.includes(key));
492
529
  if (unknownTuning.length > 0) {
493
530
  throw new TypeError(
@@ -580,7 +617,10 @@ export class P2PNode extends EventEmitter {
580
617
  * reflexive:{address:string,port:number}|null, mapping:string|null,
581
618
  * candidates:Array<{address:string,port:number}>, announced_at:string|null,
582
619
  * announce_ok:boolean, announce_failures:number, last_announce_error:string|null,
583
- * punches_out:number, punches_in:number, dial_failures:number, last_error:string|null}}
620
+ * punches_out:number, punches_in:number, dial_failures:number, last_error:string|null,
621
+ * encryption:{enabled:boolean, public_key:string|null, peers_known:number,
622
+ * handshakes:number, encrypted_channels:number, plaintext_channels:number, refused:number,
623
+ * refusals:Array<object>}}}
584
624
  */
585
625
  get status() {
586
626
  return {
@@ -601,9 +641,43 @@ export class P2PNode extends EventEmitter {
601
641
  punches_in: this.punchesIn,
602
642
  dial_failures: this.dialFailures,
603
643
  last_error: this.lastError,
644
+ // v0.6.0. `enabled` is about this machine's own key, not about any peer: a node with a key
645
+ // still creates plaintext channels for peers it has no key for, and each channel says so
646
+ // itself (`channel.encryption`).
647
+ encryption: {
648
+ enabled: this.deviceKey !== null,
649
+ public_key: this.deviceKey === null ? null : (this.deviceKey.publicKeyBase64 ?? null),
650
+ peers_known: this.peerKeys.size,
651
+ handshakes: this.cryptoCounts.handshakes,
652
+ encrypted_channels: this.cryptoCounts.encrypted_channels,
653
+ plaintext_channels: this.cryptoCounts.plaintext_channels,
654
+ refused: this.cryptoCounts.refused,
655
+ refusals: this.cryptoRefusals.map((entry) => ({ ...entry })),
656
+ },
604
657
  };
605
658
  }
606
659
 
660
+ /**
661
+ * Record a crypto refusal or a plaintext fallback, once per (peer, direction, reason).
662
+ *
663
+ * Deduplicated because a punch repeats: the same refusal would otherwise be appended on every
664
+ * retransmission and a bounded list would hold nothing but copies of one fact.
665
+ *
666
+ * @param {string} reason
667
+ * @param {string|null} machineId
668
+ * @param {'in'|'out'} direction
669
+ */
670
+ noteCrypto(reason, machineId, direction) {
671
+ this.cryptoRefusals.push({
672
+ at: new Date(this.nowMs()).toISOString(),
673
+ reason,
674
+ machine_id: machineId,
675
+ direction,
676
+ });
677
+ while (this.cryptoRefusals.length > 16) this.cryptoRefusals.shift();
678
+ this.log(`p2p: ${direction === 'in' ? 'inbound' : 'outbound'} crypto ${reason}${machineId ? ` (${machineId})` : ''}`);
679
+ }
680
+
607
681
  /**
608
682
  * Record an error: `status.last_error` always, the `'error'` event only if somebody
609
683
  * is listening. See the class comment for why the emit is guarded.
@@ -824,6 +898,11 @@ export class P2PNode extends EventEmitter {
824
898
  candidates: candidates.map((candidate) => ({ ...candidate })),
825
899
  nat: { mapping },
826
900
  ttl_ms: this.tuning.announceTtlMs,
901
+ // v0.6.0: the static public key rides with the candidates, which is what makes it available to
902
+ // a dispatcher that has to name it in an offer. Omitted entirely when this machine has no key,
903
+ // so the wire stays byte-identical to v0.5.x for a plaintext node — an additive change has to
904
+ // be absent, not null, to be additive.
905
+ ...(this.deviceKey === null ? {} : { static_key: this.deviceKey.publicKeyBase64 }),
827
906
  };
828
907
 
829
908
  let response = null;
@@ -1080,6 +1159,33 @@ export class P2PNode extends EventEmitter {
1080
1159
  remote,
1081
1160
  session,
1082
1161
  timeoutMs: options.timeoutMs ?? this.tuning.punchTimeoutMs,
1162
+ // v0.6.0: arm the handshake only when this machine holds a key for *that* peer. An empty
1163
+ // store is the v0.5.x behaviour exactly — a plain punch — and `channel.encryption.reason`
1164
+ // then says `P2P_CRYPTO_NO_KEY` rather than leaving "plaintext" unexplained.
1165
+ //
1166
+ // Letting `connect()` arm a handshake with a null peer key would be worse than useless: the
1167
+ // initiator would compute a MAC nobody could verify and the punch would report
1168
+ // `P2P_CRYPTO_HANDSHAKE_TIMEOUT` against a peer that never had a key to offer. The peer *is*
1169
+ // told what happened — the plain punch it answers carries an empty body, which is exactly how
1170
+ // a v0.5.x build asks to be talked to in the clear.
1171
+ //
1172
+ // A **self-dial** belongs on that same plaintext path, and the reason is not squeamishness
1173
+ // about encrypting a loopback: a machine holds no peer key for itself, so the handshake it
1174
+ // sends would be refused by its own `respondHandshake` (`P2P_CRYPTO_UNKNOWN_PEER`) and the
1175
+ // task it dispatched to itself would be lost. `machineId !== this.machineId` is what makes a
1176
+ // 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
+ : {
1182
+ crypto: {
1183
+ deviceKey: this.deviceKey,
1184
+ peerKey: this.peerKeys.get(machineId),
1185
+ peerMachineId: machineId,
1186
+ },
1187
+ }),
1188
+ ...(this.cryptoTuning === null ? {} : { cryptoTuning: this.cryptoTuning }),
1083
1189
  });
1084
1190
  } catch (error) {
1085
1191
  // `punch()` sends synchronously, and `dgram` throws from `send()` on a socket that
@@ -1092,6 +1198,13 @@ export class P2PNode extends EventEmitter {
1092
1198
  });
1093
1199
  }
1094
1200
  if (!outcome.ok) {
1201
+ // A refused handshake is recorded here, once, so `status.encryption.refusals` can answer "why
1202
+ // is this peer never encrypted?" without anyone reading a log. §4's rule is that a refusal is
1203
+ // named, never silent — and this is the place the name is kept.
1204
+ if (outcome.crypto?.status === 'refused') {
1205
+ this.cryptoCounts.refused += 1;
1206
+ this.noteCrypto(outcome.crypto.reason ?? CRYPTO_REASON.REFUSED_BY_PEER, machineId, 'out');
1207
+ }
1095
1208
  return fail(outcome.error ?? 'P2P_PUNCH_FAILED', {
1096
1209
  attempts: outcome.punch?.attempts ?? 0,
1097
1210
  rttMs: outcome.punch?.rttMs ?? null,
@@ -1100,6 +1213,41 @@ export class P2PNode extends EventEmitter {
1100
1213
  }
1101
1214
 
1102
1215
  this.punchesOut += 1;
1216
+ // v0.6.0: an encrypted channel already live for this session outranks anything this punch just
1217
+ // produced. The simultaneous punch makes the ordering arbitrary — the accept loop can complete a
1218
+ // handshake for session S while S's own punch is still in flight — and a plaintext channel that
1219
+ // *replaced* an encrypted one would leave both ends believing they were encrypted, so every frame
1220
+ // would fail its tag and the task would fall back to the relay for a reason nothing recorded. The
1221
+ // punch's own channel is therefore closed and the encrypted one is returned instead.
1222
+ const liveHere = this.channels.get(session);
1223
+ if (
1224
+ liveHere !== undefined &&
1225
+ liveHere !== outcome.channel &&
1226
+ liveHere.encryption?.encrypted === true &&
1227
+ outcome.channel.encryption?.encrypted !== true
1228
+ ) {
1229
+ try {
1230
+ outcome.channel.close('session-encrypted', { silent: true });
1231
+ } catch {
1232
+ /* the path is already gone */
1233
+ }
1234
+ this.cryptoCounts.refused += 1;
1235
+ this.noteCrypto(CRYPTO_REASON.PLAINTEXT_PEER, machineId, 'out');
1236
+ return {
1237
+ ok: true,
1238
+ channel: liveHere,
1239
+ peer: { ...liveHere.peer },
1240
+ session,
1241
+ error: null,
1242
+ attempts: outcome.punch.attempts,
1243
+ rttMs: outcome.punch.rttMs,
1244
+ peersAnnounced: announced.length,
1245
+ reused: true,
1246
+ encrypted: true,
1247
+ encryption_reason: null,
1248
+ };
1249
+ }
1250
+ this.noteChannelCrypto(outcome.channel, machineId, 'out');
1103
1251
  // The accept loop may have claimed this session while the punch was in flight — the far end
1104
1252
  // dialled us at the same moment we dialled it, which is the whole point of a simultaneous
1105
1253
  // punch. Its channel is the better one: its peer is the address the far end's packet actually
@@ -1123,6 +1271,8 @@ export class P2PNode extends EventEmitter {
1123
1271
  rttMs: outcome.punch.rttMs,
1124
1272
  peersAnnounced: announced.length,
1125
1273
  reused: true,
1274
+ encrypted: claimedMeanwhile.encryption?.encrypted === true,
1275
+ encryption_reason: claimedMeanwhile.encryption?.reason ?? null,
1126
1276
  };
1127
1277
  }
1128
1278
  this.adoptChannel(outcome.channel, { from: machineId, session: outcome.channel.session });
@@ -1136,9 +1286,73 @@ export class P2PNode extends EventEmitter {
1136
1286
  rttMs: outcome.punch.rttMs,
1137
1287
  peersAnnounced: announced.length,
1138
1288
  reused: false,
1289
+ encrypted: outcome.channel.encryption?.encrypted === true,
1290
+ encryption_reason: outcome.channel.encryption?.reason ?? null,
1139
1291
  };
1140
1292
  }
1141
1293
 
1294
+ /**
1295
+ * Attach a completed handshake to the channel that is already live for its session.
1296
+ *
1297
+ * The simultaneous punch makes this the ordinary case rather than a corner case: both ends dial, one
1298
+ * of them finishes the punch first and builds a plaintext channel, and the handshake HELLO for that
1299
+ * same session arrives a moment later. Without this, the two ends would disagree about whether the
1300
+ * session is encrypted and every frame would fail its tag — an outage whose cause is invisible from
1301
+ * either end's ledger.
1302
+ *
1303
+ * It is deliberately a **mutation of an existing channel** and not a replacement: the channel is
1304
+ * already wired into `'message'` listeners, already claimed, and probably already has frames on the
1305
+ * wire. Attaching keys to it changes what the next frame is sealed with and nothing else.
1306
+ *
1307
+ * Not applied when the channel is already encrypted — the first handshake wins, and re-keying a live
1308
+ * session because a duplicate HELLO arrived would break the frames already in flight. Not applied to
1309
+ * a channel that never existed: the caller is the accept loop, which only gets here for a session
1310
+ * `this.channels` already holds.
1311
+ *
1312
+ * @param {{session:number, session_crypto:object, isInitiator:boolean, machineId:string|null}} upgrade
1313
+ * @returns {boolean} Whether a channel was upgraded.
1314
+ */
1315
+ applyCryptoUpgrade(upgrade) {
1316
+ const channel = this.channels.get(upgrade.session);
1317
+ if (channel === undefined) return false;
1318
+ if (channel.encryption?.encrypted === true) return false;
1319
+ if (typeof channel.attachCrypto !== 'function') return false;
1320
+ channel.attachCrypto(upgrade.session_crypto, upgrade.isInitiator === true);
1321
+ this.cryptoCounts.handshakes += 1;
1322
+ this.cryptoCounts.encrypted_channels += 1;
1323
+ this.log(
1324
+ `p2p: session ${upgrade.session} upgraded to an encrypted channel` +
1325
+ `${upgrade.machineId === null ? '' : ` (${upgrade.machineId})`}`,
1326
+ );
1327
+ return true;
1328
+ }
1329
+
1330
+ /**
1331
+ * Count one channel's crypto outcome and tell the world about it.
1332
+ *
1333
+ * Called wherever a channel is created, so the counters cannot drift from the channels they count.
1334
+ * A *refusal* is not counted here — a refused handshake produces no channel at all, which is the
1335
+ * whole point of refusing — and that asymmetry is what makes `refused` mean "no channel was made"
1336
+ * rather than "a channel was made and then complained about".
1337
+ *
1338
+ * @param {import('./p2p.mjs').P2PChannel} channel
1339
+ * @param {string|null} machineId
1340
+ * @param {'in'|'out'} direction
1341
+ */
1342
+ noteChannelCrypto(channel, machineId, direction) {
1343
+ const encryption = channel?.encryption ?? null;
1344
+ if (encryption === null) return;
1345
+ if (encryption.encrypted) {
1346
+ this.cryptoCounts.handshakes += 1;
1347
+ this.cryptoCounts.encrypted_channels += 1;
1348
+ return;
1349
+ }
1350
+ this.cryptoCounts.plaintext_channels += 1;
1351
+ // Recorded once per channel rather than per frame: "this channel is plaintext" is the fact, and
1352
+ // the reason is what makes it actionable.
1353
+ this.noteCrypto(encryption.reason ?? CRYPTO_REASON.PLAINTEXT_PEER, machineId, direction);
1354
+ }
1355
+
1142
1356
  /**
1143
1357
  * Wire one channel into the node: deliver its payloads as `'message'` events, claim
1144
1358
  * its session, and drop the claim when it closes.
@@ -1196,6 +1410,13 @@ export class P2PNode extends EventEmitter {
1196
1410
  channel.on('error', (error) => {
1197
1411
  this.reportError(`P2P_CHANNEL_ERROR: ${messageOf(error)}`);
1198
1412
  });
1413
+ // v0.6.0: a frame that failed its authentication tag. Not an `'error'` — the channel is fine and
1414
+ // the *frame* was not — but not silent either: the reason goes into `status.last_error` so a
1415
+ // machine under a forgery attempt is visible without a packet capture.
1416
+ channel.on('crypto', (event) => {
1417
+ this.lastError = `P2P_CRYPTO_DROPPED: ${event.reason} (${event.dropped} frame(s) on session ${details.session})`;
1418
+ this.log(`p2p: ${this.lastError}`);
1419
+ });
1199
1420
  }
1200
1421
 
1201
1422
  /**
@@ -1217,6 +1438,19 @@ export class P2PNode extends EventEmitter {
1217
1438
  socket: this.socket,
1218
1439
  timeoutMs: this.tuning.acceptTimeoutMs,
1219
1440
  claimed: this.claimed,
1441
+ // v0.6.0: handshakes are answered only when this machine holds a key of its own. Without
1442
+ // one, `accept()` is byte-for-byte v0.5.x — every HELLO answered plainly — which is what
1443
+ // keeps a mixed fleet working rather than half-encrypted and confused.
1444
+ ...(this.deviceKey === null
1445
+ ? {}
1446
+ : {
1447
+ crypto: {
1448
+ deviceKey: this.deviceKey,
1449
+ peerKeys: this.peerKeys,
1450
+ seen: this.seenHandshakes,
1451
+ },
1452
+ }),
1453
+ ...(this.cryptoTuning === null ? {} : { cryptoTuning: this.cryptoTuning }),
1220
1454
  });
1221
1455
  } catch (error) {
1222
1456
  // `accept()` throws only for a socket it was not given, which the loop condition
@@ -1227,6 +1461,20 @@ export class P2PNode extends EventEmitter {
1227
1461
  }
1228
1462
  if (!result.ok) {
1229
1463
  if (result.error?.startsWith('P2P_SOCKET_CLOSED')) return;
1464
+ // A handshake that completed for a session this node already holds a channel for: the
1465
+ // simultaneous punch, seen from the accepting side. The upgrade is applied to the live channel
1466
+ // rather than a second one being made, because two channels on one session would each deliver
1467
+ // every payload and the peer would see each message twice.
1468
+ if (result.upgrade !== undefined && result.upgrade !== null) {
1469
+ this.applyCryptoUpgrade(result.upgrade);
1470
+ continue;
1471
+ }
1472
+ // A refused handshake ends the wait without a channel. Recorded, never fatal: the peer may be
1473
+ // a machine whose key is stale, and the relay path carries its task either way.
1474
+ if (result.crypto?.status === 'refused') {
1475
+ this.cryptoCounts.refused += 1;
1476
+ this.noteCrypto(result.crypto.reason ?? CRYPTO_REASON.REFUSED_BY_PEER, null, 'in');
1477
+ }
1230
1478
  continue;
1231
1479
  }
1232
1480
  if (!this.started || this.runToken !== token) {
@@ -1236,10 +1484,33 @@ export class P2PNode extends EventEmitter {
1236
1484
  return;
1237
1485
  }
1238
1486
  this.punchesIn += 1;
1239
- // `from` is null: a HELLO carries a session, not a machine id, and inventing an
1240
- // identity from a source address would be exactly the kind of unverified claim
1241
- // this transport refuses elsewhere. The caller correlates by session if it needs to.
1242
- this.adoptChannel(result.channel, { from: null, session: result.session });
1487
+ // v0.6.0, and this check is load-bearing rather than defensive. A simultaneous punch means
1488
+ // `dial()` and this loop can both produce a channel for one session, and `adoptChannel` keeps
1489
+ // the *last* one. When `dial()` won the race with an encrypted channel and this loop then saw
1490
+ // the peer's plain punch HELLO, that replacement silently downgraded a live encrypted session to
1491
+ // a plaintext one — with both ends believing they were encrypted, so every frame failed its tag
1492
+ // and the task fell back to the relay for a reason no ledger recorded. An encrypted channel is
1493
+ // therefore never replaced by a plaintext one for the same session; the punch is re-acknowledged
1494
+ // instead, which is what the peer's retransmitted HELLO actually needs.
1495
+ const existing = this.channels.get(result.session);
1496
+ if (
1497
+ existing !== undefined &&
1498
+ existing.encryption?.encrypted === true &&
1499
+ result.channel.encryption?.encrypted !== true
1500
+ ) {
1501
+ this.noteCrypto(CRYPTO_REASON.PLAINTEXT_PEER, result.machineId ?? null, 'in');
1502
+ this.log(
1503
+ `p2p: kept the encrypted channel for session ${result.session}; a plaintext one would have replaced it`,
1504
+ );
1505
+ return;
1506
+ }
1507
+ this.noteChannelCrypto(result.channel, result.machineId ?? null, 'in');
1508
+ // `from` is the handshake's *verified* machine id when there was a handshake, and null
1509
+ // otherwise: a plain HELLO carries a session, not an identity, and inventing one from a source
1510
+ // address would be exactly the kind of unverified claim this transport refuses elsewhere. The
1511
+ // difference is worth stating — `from` is evidence now, not a guess — and a caller that needs
1512
+ // it for a plaintext channel still has to correlate by session.
1513
+ this.adoptChannel(result.channel, { from: result.machineId ?? null, session: result.session });
1243
1514
  }
1244
1515
  }
1245
1516