@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.
- package/CHANGELOG.md +57 -0
- package/PROTOCOL.md +122 -0
- package/docs/P2P-CRYPTO.md +225 -0
- package/lib/client.js +513 -0
- package/package.json +7 -3
- package/src/agent/agent.mjs +199 -5
- package/src/agent/crypto.mjs +1618 -0
- package/src/agent/p2p-node.mjs +276 -5
- package/src/agent/p2p.mjs +667 -40
- package/src/plugin/fleet-target.mjs +603 -0
- package/src/plugin/tools.mjs +348 -39
- package/src/relay/peers.mjs +56 -0
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -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
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
|
|
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
|
|