@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.mjs
CHANGED
|
@@ -46,11 +46,38 @@
|
|
|
46
46
|
* PING 6 — keeps the mapping open between messages
|
|
47
47
|
* PONG 7 — the answer to PING, and never a PING
|
|
48
48
|
* BYE 8 — the peer is closing
|
|
49
|
+
*
|
|
50
|
+
* Encryption (v0.6.0, `docs/P2P-CRYPTO.md` stage 1) rides *inside* those kinds rather than adding
|
|
51
|
+
* new ones, which is what keeps a v0.5.x peer able to talk to this build:
|
|
52
|
+
*
|
|
53
|
+
* * A `HELLO`/`HELLO_ACK` whose body begins with `crypto.mjs`'s frame magic is a handshake message;
|
|
54
|
+
* one with an empty body is the plain punch a v0.5.x peer sends. A v0.5.x peer answers a
|
|
55
|
+
* handshake `HELLO` with its own plain `HELLO_ACK` (it never reads the body), and this build
|
|
56
|
+
* answers a v0.5.x `HELLO` with a plain `HELLO_ACK` when it holds no key for that machine.
|
|
57
|
+
* * A frame whose body begins with that same magic is encrypted: the 5-byte header stays in the
|
|
58
|
+
* clear and is authenticated as associated data, and the body carries a 10-byte cipher header
|
|
59
|
+
* (magic, epoch, 64-bit counter), the ciphertext and a 16-byte tag.
|
|
60
|
+
*
|
|
61
|
+
* So the two shapes are told apart by one byte and neither end has to be told which kind of peer it
|
|
62
|
+
* is meeting. What is **not** negotiable is falling back mid-session: a peer that fails the handshake
|
|
63
|
+
* is refused by name and never silently downgraded, because a downgrade an attacker can trigger is
|
|
64
|
+
* not encryption.
|
|
49
65
|
*/
|
|
50
66
|
|
|
51
67
|
import { EventEmitter } from 'node:events';
|
|
52
68
|
import { createHash, randomBytes } from 'node:crypto';
|
|
53
69
|
|
|
70
|
+
import {
|
|
71
|
+
CRYPTO_REASON,
|
|
72
|
+
REFUSE,
|
|
73
|
+
decodeHello,
|
|
74
|
+
decodeRefuse,
|
|
75
|
+
encodeRefuse,
|
|
76
|
+
REFUSE_REASON,
|
|
77
|
+
respondHandshake,
|
|
78
|
+
startHandshake,
|
|
79
|
+
} from './crypto.mjs';
|
|
80
|
+
|
|
54
81
|
/** Frame kinds. Exported so tests and callers can name them instead of using magic numbers. */
|
|
55
82
|
export const KIND = {
|
|
56
83
|
HELLO: 1,
|
|
@@ -69,10 +96,13 @@ export const P2P_VERSION = 1;
|
|
|
69
96
|
/**
|
|
70
97
|
* Payload bytes per fragment.
|
|
71
98
|
*
|
|
72
|
-
* 1160 keeps a frame under 1200 bytes
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
99
|
+
* 1160 keeps a *plaintext* DATA frame under 1200 bytes: 5 header + 12 fields + 1160 = 1177. An
|
|
100
|
+
* encrypted frame adds `crypto.mjs`'s 26 bytes (10 cipher header + 16 tag) and lands at 1203 — 3
|
|
101
|
+
* bytes over that soft budget and still 77 under a 1280-byte IPv6 minimum MTU, which is the number
|
|
102
|
+
* that actually decides whether a path can carry it. The tighter budget is a deliberate acceptance
|
|
103
|
+
* rather than an oversight, and it is asserted against this constant in `test/p2p-crypto.test.mjs`
|
|
104
|
+
* so a future increase is a decision rather than an accident. A larger fragment is faster right up
|
|
105
|
+
* until it is silently dropped by a path that will not fragment.
|
|
76
106
|
*/
|
|
77
107
|
export const MAX_FRAGMENT_PAYLOAD = 1160;
|
|
78
108
|
|
|
@@ -231,6 +261,76 @@ export function reverseDialSession({ originMachineId, taskId, machineId } = {})
|
|
|
231
261
|
return deriveSession(REVERSE_DIAL_SESSION_LABEL, originMachineId, taskId, machineId);
|
|
232
262
|
}
|
|
233
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The result of trying to encrypt a punch, in one shape both `punch()` and `accept()` return.
|
|
266
|
+
*
|
|
267
|
+
* `status` is deliberately three-valued rather than a boolean:
|
|
268
|
+
*
|
|
269
|
+
* * `encrypted` — a handshake completed and every frame on this channel is sealed.
|
|
270
|
+
* * `plaintext` — no handshake was possible (no key for the peer, or the peer sent none), and the
|
|
271
|
+
* reason says which. This is the v0.5.x compatibility path, and it is recorded, never hidden.
|
|
272
|
+
* * `refused` — a handshake was attempted and did not complete. `reason` is a
|
|
273
|
+
* `crypto.mjs` reason code. **There is no plaintext channel in this case**: §4's "never silently
|
|
274
|
+
* downgrading to plaintext" is enforced where the channel is created rather than left to a caller
|
|
275
|
+
* to remember.
|
|
276
|
+
*
|
|
277
|
+
* @param {'encrypted'|'plaintext'|'refused'} status
|
|
278
|
+
* @param {string|null} reason
|
|
279
|
+
* @param {{session: object, isInitiator: boolean}|null} [established]
|
|
280
|
+
* @returns {{status:string, reason:string|null, session:object|null, isInitiator:boolean}}
|
|
281
|
+
*/
|
|
282
|
+
function cryptoOutcome(status, reason, established = null) {
|
|
283
|
+
return {
|
|
284
|
+
status,
|
|
285
|
+
reason,
|
|
286
|
+
session: established === null ? null : established.session,
|
|
287
|
+
isInitiator: established === null ? false : established.isInitiator === true,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The channel's view of a crypto outcome. `null` means plaintext, which is what every existing
|
|
293
|
+
* caller of `P2PChannel` gets.
|
|
294
|
+
*
|
|
295
|
+
* @param {object|null} outcome
|
|
296
|
+
* @param {object} [tuning]
|
|
297
|
+
* @returns {{session:object, isInitiator:boolean, tuning:object}|null}
|
|
298
|
+
*/
|
|
299
|
+
function channelCrypto(outcome, tuning = {}) {
|
|
300
|
+
if (outcome === null || outcome.status !== 'encrypted' || outcome.session === null) return null;
|
|
301
|
+
return { session: outcome.session, isInitiator: outcome.isInitiator === true, tuning };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A refusal frame, if this datagram is one. Extracted because three branches ask the same question.
|
|
306
|
+
*
|
|
307
|
+
* @param {{kind:number, session:number, body:Buffer}} parsed
|
|
308
|
+
* @returns {{code:number, reason:string}|null} The named reason, or null when this is not a refusal.
|
|
309
|
+
*/
|
|
310
|
+
function cryptoRefusalOf(parsed) {
|
|
311
|
+
if (parsed.kind !== KIND.HELLO_ACK && parsed.kind !== KIND.HELLO) return null;
|
|
312
|
+
const refuse = decodeRefuse(parsed.body);
|
|
313
|
+
if (refuse === null) return null;
|
|
314
|
+
return { code: refuse.code, reason: REFUSE_REASON[refuse.code] ?? CRYPTO_REASON.REFUSED_BY_PEER };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Complete a handshake whose answer arrived *as a `HELLO`*: the simultaneous-punch case.
|
|
319
|
+
*
|
|
320
|
+
* When both ends dial at the same moment each one's `HELLO` reaches the other, and a `HELLO` carries
|
|
321
|
+
* a fresh ephemeral exactly as an answer does. Rather than open a second session — two sessions for
|
|
322
|
+
* one punch is the defect `punchSession` exists to prevent — this feeds that message through the
|
|
323
|
+
* ordinary `finish()` verification, where the MAC decides whether it really is the peer.
|
|
324
|
+
*
|
|
325
|
+
* @param {{finish: Function}} handshake
|
|
326
|
+
* @param {Buffer} body The received handshake body.
|
|
327
|
+
* @returns {object|null} The established session, or null when it did not verify.
|
|
328
|
+
*/
|
|
329
|
+
function finishFromInitiatorMessage(handshake, body) {
|
|
330
|
+
const finished = handshake.finish(body);
|
|
331
|
+
return finished.ok ? { session: finished.session, isInitiator: true } : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
234
334
|
/**
|
|
235
335
|
* Try to open a bidirectional path to one of `remote`'s candidate addresses.
|
|
236
336
|
*
|
|
@@ -238,6 +338,19 @@ export function reverseDialSession({ originMachineId, taskId, machineId } = {})
|
|
|
238
338
|
* first peer to get through causes the other's NAT to accept, because the other's
|
|
239
339
|
* outbound HELLO already opened its own mapping in that direction.
|
|
240
340
|
*
|
|
341
|
+
* v0.6.0 folds the handshake of `docs/P2P-CRYPTO.md` §4 into that same exchange rather than adding a
|
|
342
|
+
* round trip: once the path answers, a one-message `HELLO` carrying `e_i` and a MAC over the static
|
|
343
|
+
* keys goes out, the peer's `HELLO_ACK` carries `e_r`, and the session key comes out of the triple
|
|
344
|
+
* DH. Three outcomes, and only the first hands back an encrypted channel:
|
|
345
|
+
*
|
|
346
|
+
* * Both ends hold each other's key: the handshake completes and every frame is sealed.
|
|
347
|
+
* * This end holds no key for the peer (a v0.5.x peer, or one that never announced): a plain
|
|
348
|
+
* `HELLO` is sent and the channel is plaintext with `P2P_CRYPTO_NO_KEY` as the reason.
|
|
349
|
+
* * This end holds a key and the handshake fails: the punch reports `ok: false` with the peer's
|
|
350
|
+
* named reason, and **no plaintext channel is returned for that peer**. The caller falls back to
|
|
351
|
+
* the relay — §4's fallback clause — and the ledger records `p2p.encrypted: false` plus the
|
|
352
|
+
* reason.
|
|
353
|
+
*
|
|
241
354
|
* @param {object} options
|
|
242
355
|
* @param {import('node:dgram').Socket} options.socket Already bound, and the same socket STUN used.
|
|
243
356
|
* @param {Array<{address:string, port:number}>} options.remote
|
|
@@ -247,8 +360,13 @@ export function reverseDialSession({ originMachineId, taskId, machineId } = {})
|
|
|
247
360
|
* responder; two peers that both adopt end up on different ids.
|
|
248
361
|
* @param {number} [options.timeoutMs]
|
|
249
362
|
* @param {number} [options.intervalMs]
|
|
363
|
+
* @param {object} [options.crypto] `{deviceKey, peerKey, peerMachineId}` to attempt a handshake.
|
|
364
|
+
* Omitted, or with any field missing, means plaintext exactly as v0.5.x behaved.
|
|
365
|
+
* @param {object} [options.cryptoTuning] Rekey thresholds and retention, for a test that cannot send
|
|
366
|
+
* 2^20 messages.
|
|
250
367
|
* @returns {Promise<{ok:boolean, peer:{address:string,port:number}|null, session:number,
|
|
251
|
-
* attempts:number, rttMs:number|null, error:string|null
|
|
368
|
+
* attempts:number, rttMs:number|null, error:string|null,
|
|
369
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean}}>}
|
|
252
370
|
*/
|
|
253
371
|
export async function punch(options) {
|
|
254
372
|
const { socket } = options;
|
|
@@ -269,7 +387,15 @@ export async function punch(options) {
|
|
|
269
387
|
(c) => c && typeof c.address === 'string' && Number.isInteger(c.port) && c.port > 0,
|
|
270
388
|
);
|
|
271
389
|
if (remote.length === 0) {
|
|
272
|
-
return {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
peer: null,
|
|
393
|
+
session: explicitSession ?? 0,
|
|
394
|
+
attempts: 0,
|
|
395
|
+
rttMs: null,
|
|
396
|
+
error: 'P2P_NO_CANDIDATES',
|
|
397
|
+
crypto: cryptoOutcome('plaintext', CRYPTO_REASON.PLAINTEXT_PEER),
|
|
398
|
+
};
|
|
273
399
|
}
|
|
274
400
|
|
|
275
401
|
const ownSession = explicitSession ?? randomBytes(4).readUInt32BE(0);
|
|
@@ -279,12 +405,46 @@ export async function punch(options) {
|
|
|
279
405
|
const startedAt = process.hrtime.bigint();
|
|
280
406
|
|
|
281
407
|
const hello = frame(KIND.HELLO, ownSession);
|
|
408
|
+
/** The handshake, armed only when a key for the peer is actually in hand. */
|
|
409
|
+
const request = options.crypto ?? null;
|
|
410
|
+
const handshake =
|
|
411
|
+
request?.deviceKey && request?.peerKey && typeof request?.peerMachineId === 'string'
|
|
412
|
+
? startHandshake({
|
|
413
|
+
deviceKey: request.deviceKey,
|
|
414
|
+
peerMachineId: request.peerMachineId,
|
|
415
|
+
peerPublicKey: request.peerKey,
|
|
416
|
+
session: ownSession,
|
|
417
|
+
})
|
|
418
|
+
: null;
|
|
419
|
+
// Why there is no handshake, when there is none. `null` means one is in flight.
|
|
420
|
+
const noCryptoReason =
|
|
421
|
+
handshake !== null
|
|
422
|
+
? null
|
|
423
|
+
: request === null
|
|
424
|
+
? CRYPTO_REASON.PLAINTEXT_PEER
|
|
425
|
+
: CRYPTO_REASON.NO_KEY;
|
|
426
|
+
/** Whether this punch is trying to encrypt at all. */
|
|
427
|
+
const wantCrypto = handshake !== null;
|
|
282
428
|
|
|
283
429
|
return new Promise((resolve) => {
|
|
284
430
|
let attempts = 0;
|
|
285
431
|
let timer = null;
|
|
286
432
|
let settled = false;
|
|
287
433
|
let agreed = ownSession;
|
|
434
|
+
/** The path is open: a plain HELLO_ACK arrived, or a HELLO did when we are the responder. */
|
|
435
|
+
let punchedPeer = null;
|
|
436
|
+
let punchedRtt = null;
|
|
437
|
+
let punchAttempts = 0;
|
|
438
|
+
/** Set when the handshake has definitively failed, so the punch says why instead of timing out. */
|
|
439
|
+
let refused = null;
|
|
440
|
+
/**
|
|
441
|
+
* Set when the peer has told us it can only do plaintext (`REFUSE.NO_KEY`).
|
|
442
|
+
*
|
|
443
|
+
* Separate from `refused` on purpose, and the distinction is the whole of §4's fallback clause: a
|
|
444
|
+
* refusal means "do not talk to me in the clear", a plaintext-only answer means "I cannot talk to
|
|
445
|
+
* you any other way, and here is why". The first is a failure, the second is a recorded outcome.
|
|
446
|
+
*/
|
|
447
|
+
let plainOnly = null;
|
|
288
448
|
|
|
289
449
|
const finish = (result) => {
|
|
290
450
|
if (settled) return;
|
|
@@ -294,45 +454,183 @@ export async function punch(options) {
|
|
|
294
454
|
resolve(result);
|
|
295
455
|
};
|
|
296
456
|
|
|
457
|
+
/** The path is open: report it with whatever the handshake managed to do. */
|
|
458
|
+
const finishPunched = (outcome) => {
|
|
459
|
+
finish({
|
|
460
|
+
ok: true,
|
|
461
|
+
peer: punchedPeer,
|
|
462
|
+
session: agreed,
|
|
463
|
+
attempts: punchAttempts,
|
|
464
|
+
rttMs: punchedRtt,
|
|
465
|
+
error: null,
|
|
466
|
+
crypto: outcome,
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const notePeer = (rinfo) => {
|
|
471
|
+
punchedPeer = { address: rinfo.address, port: rinfo.port };
|
|
472
|
+
punchedRtt = Math.round((Number(process.hrtime.bigint() - startedAt) / 1e6) * 100) / 100;
|
|
473
|
+
punchAttempts = attempts;
|
|
474
|
+
};
|
|
475
|
+
|
|
297
476
|
const onMessage = (datagram, rinfo) => {
|
|
298
477
|
const parsed = parseFrame(datagram);
|
|
299
478
|
if (!parsed) return;
|
|
300
479
|
|
|
301
480
|
if (parsed.kind === KIND.HELLO) {
|
|
481
|
+
const incoming = decodeHello(parsed.body);
|
|
482
|
+
if (incoming !== null) {
|
|
483
|
+
// A handshake HELLO. It answered the punch *and* asked for a session in one datagram.
|
|
484
|
+
if (explicitSession !== undefined && parsed.session !== explicitSession) return;
|
|
485
|
+
agreed = parsed.session;
|
|
486
|
+
notePeer(rinfo);
|
|
487
|
+
if (handshake !== null && incoming.machineId === request.peerMachineId) {
|
|
488
|
+
const established = finishFromInitiatorMessage(handshake, parsed.body);
|
|
489
|
+
if (established !== null) {
|
|
490
|
+
finishPunched(cryptoOutcome('encrypted', null, established));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
refused = CRYPTO_REASON.BAD_MAC;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// A handshake HELLO from a machine we have no key for. Answering it with a plain HELLO_ACK
|
|
497
|
+
// is the one honest thing available: we cannot verify its MAC and it cannot verify ours.
|
|
498
|
+
socket.send(frame(KIND.HELLO_ACK, agreed), rinfo.port, rinfo.address, () => {});
|
|
499
|
+
if (!wantCrypto) finishPunched(cryptoOutcome('plaintext', noCryptoReason));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
302
502
|
if (explicitSession !== undefined && parsed.session !== explicitSession) return;
|
|
303
503
|
// Adopt the initiator's id when we have none of our own, and echo it back so
|
|
304
504
|
// the initiator sees its own id and accepts the answer.
|
|
305
505
|
agreed = parsed.session;
|
|
306
506
|
socket.send(frame(KIND.HELLO_ACK, agreed), rinfo.port, rinfo.address, () => {});
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
ok: true,
|
|
310
|
-
peer: { address: rinfo.address, port: rinfo.port },
|
|
311
|
-
session: agreed,
|
|
312
|
-
attempts,
|
|
313
|
-
rttMs: Math.round(rttMs * 100) / 100,
|
|
314
|
-
error: null,
|
|
315
|
-
});
|
|
507
|
+
notePeer(rinfo);
|
|
508
|
+
if (!wantCrypto) finishPunched(cryptoOutcome('plaintext', noCryptoReason));
|
|
316
509
|
return;
|
|
317
510
|
}
|
|
318
511
|
|
|
319
512
|
if (parsed.kind === KIND.HELLO_ACK) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
513
|
+
const refusal = cryptoRefusalOf(parsed);
|
|
514
|
+
if (refusal !== null) {
|
|
515
|
+
if (refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY) {
|
|
516
|
+
// The peer has a keypair of its own and none *for us*, and it said so instead of answering
|
|
517
|
+
// in a way we would have to guess about. That is the plaintext-only case, not a failure:
|
|
518
|
+
// §4's fallback clause is explicitly about not losing a machine to a security upgrade, and
|
|
519
|
+
// this is the peer naming the upgrade it could not take part in. The plain acknowledgement
|
|
520
|
+
// follows from the same responder, so the punch settles on that.
|
|
521
|
+
plainOnly = CRYPTO_REASON.NO_KEY;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// Any other refusal is final — `BAD_MAC` means the peer is known and its proof did not
|
|
525
|
+
// verify, which is a stale key or an impersonation attempt, and neither is a reason to fall
|
|
526
|
+
// back into the clear. Named, and never downgraded: this is the path where a channel is
|
|
527
|
+
// deliberately not handed back at all.
|
|
528
|
+
refused = refusal.reason;
|
|
529
|
+
finish({
|
|
530
|
+
ok: false,
|
|
531
|
+
peer: punchedPeer,
|
|
532
|
+
session: agreed,
|
|
533
|
+
attempts: punchAttempts,
|
|
534
|
+
rttMs: punchedRtt,
|
|
535
|
+
error: `${refusal.reason}: the peer refused the handshake`,
|
|
536
|
+
crypto: cryptoOutcome('refused', refusal.reason),
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
} if (parsed.session !== agreed) return;
|
|
540
|
+
notePeer(rinfo);
|
|
541
|
+
const ack = decodeHello(parsed.body);
|
|
542
|
+
if (ack === null) {
|
|
543
|
+
// A plain HELLO_ACK: a v0.5.x peer, or a peer re-acknowledging a session it already holds a
|
|
544
|
+
// channel for. Nothing to authenticate against, so the channel is plaintext by construction
|
|
545
|
+
// and says so — but only once the handshake has had its chance to answer.
|
|
546
|
+
//
|
|
547
|
+
// `deferPlain` is not a grace period for its own sake. A responder answers the *first*
|
|
548
|
+
// datagram of a punch, and a punch sends the plain HELLO (for the mapping, and for a v0.5.x
|
|
549
|
+
// peer) before the handshake one; a responder that has not yet seen the handshake therefore
|
|
550
|
+
// answers plainly, and settling on that answer would turn "your key did not verify" into a
|
|
551
|
+
// silent plaintext channel — the exact outcome §4 forbids. Deferring costs one interval and
|
|
552
|
+
// only happens on the punch where there was something to wait for.
|
|
553
|
+
if (!wantCrypto) finishPunched(cryptoOutcome('plaintext', plainOnly ?? noCryptoReason));
|
|
554
|
+
else deferPlain();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (handshake === null) {
|
|
558
|
+
finishPunched(cryptoOutcome('plaintext', CRYPTO_REASON.PLAINTEXT_PEER));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const finished = handshake.finish(parsed.body);
|
|
562
|
+
if (finished.ok) {
|
|
563
|
+
finishPunched(cryptoOutcome('encrypted', null, { session: finished.session, isInitiator: true }));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// The answer carried an unverifiable MAC. Recorded, and *not* retried: a peer whose mac does
|
|
567
|
+
// not verify will not verify it on the third attempt either, and the deadline below turns this
|
|
568
|
+
// into a named refusal rather than a punch timeout.
|
|
569
|
+
refused = finished.reason;
|
|
570
|
+
tick();
|
|
571
|
+
return;
|
|
330
572
|
}
|
|
331
573
|
};
|
|
332
574
|
|
|
575
|
+
/**
|
|
576
|
+
* The plaintext outcome to settle on, or null when only a handshake will do.
|
|
577
|
+
*
|
|
578
|
+
* `plainOnly` is set when the *peer itself* said it could not take part (`REFUSE.NO_KEY`). That is
|
|
579
|
+
* the one case where a plaintext channel is the documented outcome rather than a downgrade: the
|
|
580
|
+
* peer told us which upgrade it could not make, and §4's fallback clause exists so that it does not
|
|
581
|
+
* lose the machine. A peer we hold a key for and never hear a handshake from is a different fact —
|
|
582
|
+
* `P2P_CRYPTO_HANDSHAKE_TIMEOUT` — and it falls back to the relay, which is what the contract says
|
|
583
|
+
* a failed handshake does.
|
|
584
|
+
*/
|
|
585
|
+
const plaintextOutcome = () => (plainOnly === null ? null : cryptoOutcome('plaintext', plainOnly));
|
|
586
|
+
/** Keep the handshake alive after a plain answer, rather than settling into the clear. */
|
|
587
|
+
const deferPlain = () => {
|
|
588
|
+
tick();
|
|
589
|
+
};
|
|
590
|
+
|
|
333
591
|
const tick = () => {
|
|
334
592
|
if (settled) return;
|
|
335
593
|
if (Date.now() >= deadline) {
|
|
594
|
+
if (punchedPeer !== null && wantCrypto && refused === null) {
|
|
595
|
+
const plaintext = plaintextOutcome();
|
|
596
|
+
if (plaintext !== null) {
|
|
597
|
+
finishPunched(plaintext);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// The path opened and the handshake did not finish: a named failure rather than a punch
|
|
601
|
+
// timeout, because "the peer is unreachable" and "the peer would not authenticate" need
|
|
602
|
+
// different responses from an operator — and because a silent plaintext channel here would be
|
|
603
|
+
// a downgrade that a mismatched key could trigger.
|
|
604
|
+
finish({
|
|
605
|
+
ok: false,
|
|
606
|
+
peer: punchedPeer,
|
|
607
|
+
session: agreed,
|
|
608
|
+
attempts: punchAttempts,
|
|
609
|
+
rttMs: punchedRtt,
|
|
610
|
+
error: `${CRYPTO_REASON.HANDSHAKE_TIMEOUT}: no HELLO_ACK within ${timeoutMs}ms`,
|
|
611
|
+
crypto: cryptoOutcome('refused', CRYPTO_REASON.HANDSHAKE_TIMEOUT),
|
|
612
|
+
});
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (refused !== null) {
|
|
616
|
+
// The handshake failed and said why — from the peer's own refusal frame, or from a MAC that
|
|
617
|
+
// did not verify. Reported at once and named, never downgraded to a plaintext channel: a
|
|
618
|
+
// downgrade that a mismatched key can trigger is not encryption.
|
|
619
|
+
finish({
|
|
620
|
+
ok: false,
|
|
621
|
+
peer: punchedPeer,
|
|
622
|
+
session: agreed,
|
|
623
|
+
attempts: punchAttempts,
|
|
624
|
+
rttMs: punchedRtt,
|
|
625
|
+
error: `${refused}: the handshake did not complete`,
|
|
626
|
+
crypto: cryptoOutcome('refused', refused),
|
|
627
|
+
});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (punchedPeer !== null) {
|
|
631
|
+
finishPunched(cryptoOutcome('plaintext', noCryptoReason));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
336
634
|
finish({
|
|
337
635
|
ok: false,
|
|
338
636
|
peer: null,
|
|
@@ -340,14 +638,32 @@ export async function punch(options) {
|
|
|
340
638
|
attempts,
|
|
341
639
|
rttMs: null,
|
|
342
640
|
error: `P2P_PUNCH_TIMEOUT: no answer from ${remote.length} candidate(s) in ${timeoutMs}ms`,
|
|
641
|
+
crypto: cryptoOutcome('plaintext', noCryptoReason),
|
|
343
642
|
});
|
|
344
643
|
return;
|
|
345
644
|
}
|
|
346
645
|
attempts += 1;
|
|
347
|
-
|
|
348
|
-
|
|
646
|
+
if (punchedPeer !== null && wantCrypto && refused === null) {
|
|
647
|
+
// The path is open and the handshake is not: keep offering it to the address that answered,
|
|
648
|
+
// on a short interval. Repeating the same bytes is what makes this safe — the peer re-sends
|
|
649
|
+
// the identical `HELLO_ACK` for a repeat rather than treating it as a replay
|
|
650
|
+
// (`crypto.mjs`'s `NonceCache`), so a lost answer costs one interval and never a session.
|
|
651
|
+
socket.send(frame(KIND.HELLO, ownSession, handshake.body), punchedPeer.port, punchedPeer.address, () => {});
|
|
652
|
+
} else if (refused === null) {
|
|
653
|
+
// Still punching. **The handshake HELLO goes first**, and the order is load-bearing rather
|
|
654
|
+
// than cosmetic: a responder answers the first datagram it sees, and a responder that holds no
|
|
655
|
+
// key of its own would answer a plain HELLO with a plain acknowledgement — after which the
|
|
656
|
+
// punch has an answer and the handshake arrives too late to be the one that decided. Sending
|
|
657
|
+
// the handshake first means the *first* answer is the handshake's, and the plain HELLO that
|
|
658
|
+
// follows is the mapping keep-alive (and the only thing a v0.5.x peer understands).
|
|
659
|
+
for (const candidate of remote) {
|
|
660
|
+
if (wantCrypto) {
|
|
661
|
+
socket.send(frame(KIND.HELLO, ownSession, handshake.body), candidate.port, candidate.address, () => {});
|
|
662
|
+
}
|
|
663
|
+
socket.send(hello, candidate.port, candidate.address, () => {});
|
|
664
|
+
}
|
|
349
665
|
}
|
|
350
|
-
timer = setTimeout(tick, intervalMs);
|
|
666
|
+
timer = setTimeout(tick, punchedPeer === null ? intervalMs : Math.min(intervalMs, 100));
|
|
351
667
|
};
|
|
352
668
|
|
|
353
669
|
socket.on('message', onMessage);
|
|
@@ -363,6 +679,21 @@ export async function punch(options) {
|
|
|
363
679
|
* A reliable, ordered message channel over a punched UDP path.
|
|
364
680
|
*
|
|
365
681
|
* Emits `message` (Buffer) for each delivered message, `close`, and `error`.
|
|
682
|
+
*
|
|
683
|
+
* v0.6.0: when `options.crypto` is given every frame on the channel is sealed with
|
|
684
|
+
* ChaCha20-Poly1305 (`docs/P2P-CRYPTO.md` §4). The encryption lives in `sendFrame` and
|
|
685
|
+
* `onDatagram` and nowhere else, so the reliable-channel logic above it — fragments, acknowledgements,
|
|
686
|
+
* retransmission, assembly — is byte-for-byte the code it always was. Three properties are worth
|
|
687
|
+
* stating because they are decisions rather than consequences:
|
|
688
|
+
*
|
|
689
|
+
* * **Every kind is encrypted, not just DATA.** ACK, DONE, PING, PONG and BYE too. Encrypting only
|
|
690
|
+
* the payload would leave a plaintext BYE that an off-path attacker could forge to tear a session
|
|
691
|
+
* down, and would leak the message ids and fragment counts of everything else.
|
|
692
|
+
* * **A frame that does not authenticate is dropped, never processed** — and the reason is recorded
|
|
693
|
+
* in `crypto.failures`/`crypto.last_failure`, so "the peer is not who it said" is visible instead
|
|
694
|
+
* of looking like packet loss.
|
|
695
|
+
* * **The old epoch stays decryptable for a bounded window** (`crypto.mjs`), which is what keeps a
|
|
696
|
+
* rekey from losing a frame that was already in flight.
|
|
366
697
|
*/
|
|
367
698
|
export class P2PChannel extends EventEmitter {
|
|
368
699
|
/**
|
|
@@ -371,6 +702,8 @@ export class P2PChannel extends EventEmitter {
|
|
|
371
702
|
* @param {{address:string, port:number}} options.peer
|
|
372
703
|
* @param {number} options.session
|
|
373
704
|
* @param {object} [options.tuning]
|
|
705
|
+
* @param {{session: object, isInitiator: boolean, tuning?: object}|null} [options.crypto]
|
|
706
|
+
* An established session from `punch()`/`accept()`. Omitted means plaintext.
|
|
374
707
|
*/
|
|
375
708
|
constructor(options) {
|
|
376
709
|
super();
|
|
@@ -383,6 +716,31 @@ export class P2PChannel extends EventEmitter {
|
|
|
383
716
|
maxAttempts: options.tuning?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
384
717
|
};
|
|
385
718
|
|
|
719
|
+
const crypto = options.crypto ?? null;
|
|
720
|
+
/** @type {CryptoSession|null} */
|
|
721
|
+
this.cryptoSession = crypto === null ? null : crypto.session;
|
|
722
|
+
/** @type {boolean} Which direction this channel's send counter and key belong to. */
|
|
723
|
+
this.cryptoInitiator = crypto !== null && crypto.isInitiator === true;
|
|
724
|
+
/**
|
|
725
|
+
* What this channel's encryption actually is, for the ledger and for `w2m_status`.
|
|
726
|
+
*
|
|
727
|
+
* `encrypted` is a statement about *this* channel, not about the configuration: a plaintext
|
|
728
|
+
* channel is one where no handshake completed, and `reason` always says why.
|
|
729
|
+
*
|
|
730
|
+
* @type {{encrypted:boolean, reason:string|null, epoch:number, sent:number, received:number,
|
|
731
|
+
* dropped:number, last_failure:string|null, replays_refused:number}}
|
|
732
|
+
*/
|
|
733
|
+
this.encryption = {
|
|
734
|
+
encrypted: this.cryptoSession !== null,
|
|
735
|
+
reason: this.cryptoSession === null ? CRYPTO_REASON.PLAINTEXT_PEER : null,
|
|
736
|
+
epoch: 0,
|
|
737
|
+
sent: 0,
|
|
738
|
+
received: 0,
|
|
739
|
+
dropped: 0,
|
|
740
|
+
replays_refused: 0,
|
|
741
|
+
last_failure: null,
|
|
742
|
+
};
|
|
743
|
+
|
|
386
744
|
/** messageId -> {fragments: Buffer[], received: Set<number>, total: number, timer} */
|
|
387
745
|
this.incoming = new Map();
|
|
388
746
|
/** messageId -> {fragments: Buffer[], acked: Set<number>, resolve, reject, attempts: Map} */
|
|
@@ -403,8 +761,27 @@ export class P2PChannel extends EventEmitter {
|
|
|
403
761
|
// spoofable, which is why the session id is random and the payload is not trusted
|
|
404
762
|
// for anything but the task it is correlated with.)
|
|
405
763
|
|
|
406
|
-
|
|
407
|
-
|
|
764
|
+
let body = parsed.body;
|
|
765
|
+
if (this.cryptoSession !== null) {
|
|
766
|
+
// A handshake message on an encrypted session is not data: it is an attacker (or a confused
|
|
767
|
+
// peer) trying to slip a plaintext frame past the seal. Refused by name.
|
|
768
|
+
if (decodeHello(body) !== null || decodeRefuse(body) !== null) {
|
|
769
|
+
this.noteCryptoFailure(CRYPTO_REASON.AUTH_FAILED);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const opened = this.cryptoSession.decrypt(this.cryptoInitiator, datagram);
|
|
773
|
+
if (!opened.ok) {
|
|
774
|
+
this.noteCryptoFailure(opened.reason);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
body = opened.plaintext;
|
|
778
|
+
this.encryption.received = this.cryptoSession.stats.received;
|
|
779
|
+
this.encryption.epoch = this.cryptoSession.sendEpoch;
|
|
780
|
+
this.encryption.replays_refused = this.cryptoSession.stats.replay_refusals;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (parsed.kind === KIND.DATA) this.onData(body);
|
|
784
|
+
else if (parsed.kind === KIND.ACK) this.onAck(body);
|
|
408
785
|
// A PING must be answered with PONG, never with PING. Echoing the same kind back
|
|
409
786
|
// makes two peers bounce it between them forever, and the loop looks like healthy
|
|
410
787
|
// traffic while it saturates the mapping it was meant to keep alive.
|
|
@@ -413,6 +790,49 @@ export class P2PChannel extends EventEmitter {
|
|
|
413
790
|
else if (parsed.kind === KIND.BYE) this.close('peer-closed');
|
|
414
791
|
}
|
|
415
792
|
|
|
793
|
+
/**
|
|
794
|
+
* Record a frame that did not authenticate, and emit `'crypto'` so a caller can log it.
|
|
795
|
+
*
|
|
796
|
+
* A dropped frame is not an error in the channel's sense — the channel is fine, the *frame* was
|
|
797
|
+
* not — so it deliberately does not go through `'error'` (which `P2PNode` reports as a transport
|
|
798
|
+
* failure). It is not silent either: the counters are in `channel.encryption` and the event names
|
|
799
|
+
* the reason.
|
|
800
|
+
*
|
|
801
|
+
* @param {string} reason
|
|
802
|
+
*/
|
|
803
|
+
noteCryptoFailure(reason) {
|
|
804
|
+
this.encryption.dropped += 1;
|
|
805
|
+
this.encryption.last_failure = reason;
|
|
806
|
+
this.emit('crypto', { reason, dropped: this.encryption.dropped });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Seal this channel with a session that completed *after* the channel was created (v0.6.0).
|
|
811
|
+
*
|
|
812
|
+
* The one caller is `P2PNode.applyCryptoUpgrade`, and the case it serves is the simultaneous punch:
|
|
813
|
+
* the punch resolves first and builds a plaintext channel, then the handshake HELLO for the same
|
|
814
|
+
* session arrives and completes. Replacing the channel instead would tear down a live, claimed
|
|
815
|
+
* channel that listeners are already attached to; attaching keys changes only what the next frame is
|
|
816
|
+
* sealed with, and every frame sent before this point was plaintext at both ends.
|
|
817
|
+
*
|
|
818
|
+
* Refuses once this channel is already encrypted: a second session would mean two key sets for one
|
|
819
|
+
* session, and the safe answer there is to keep the one that is working.
|
|
820
|
+
*
|
|
821
|
+
* @param {object} session A `CryptoSession` from `crypto.mjs`.
|
|
822
|
+
* @param {boolean} isInitiator Which direction this end's keys belong to.
|
|
823
|
+
* @returns {boolean} Whether the channel is encrypted as a result.
|
|
824
|
+
*/
|
|
825
|
+
attachCrypto(session, isInitiator) {
|
|
826
|
+
if (this.closed) return false;
|
|
827
|
+
if (this.cryptoSession !== null) return true;
|
|
828
|
+
if (session === null || typeof session !== 'object') return false;
|
|
829
|
+
this.cryptoSession = session;
|
|
830
|
+
this.cryptoInitiator = isInitiator === true;
|
|
831
|
+
this.encryption.encrypted = true;
|
|
832
|
+
this.encryption.reason = null;
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
|
|
416
836
|
/** @param {Buffer} body */
|
|
417
837
|
onData(body) {
|
|
418
838
|
if (body.length < 12) return;
|
|
@@ -464,10 +884,24 @@ export class P2PChannel extends EventEmitter {
|
|
|
464
884
|
}
|
|
465
885
|
}
|
|
466
886
|
|
|
467
|
-
/**
|
|
887
|
+
/**
|
|
888
|
+
* Send one frame, sealed when the channel has a session.
|
|
889
|
+
*
|
|
890
|
+
* The encryption is here and in `onDatagram`, nowhere else, which is what keeps the reliable
|
|
891
|
+
* channel's logic unchanged by this work: an ACK, a retransmit and a BYE are all just frames.
|
|
892
|
+
*
|
|
893
|
+
* @param {number} kind
|
|
894
|
+
* @param {Buffer} body
|
|
895
|
+
*/
|
|
468
896
|
sendFrame(kind, body) {
|
|
469
897
|
if (this.closed) return;
|
|
470
|
-
|
|
898
|
+
const plain = frame(kind, this.session, body);
|
|
899
|
+
const datagram = this.cryptoSession === null ? plain : this.cryptoSession.encrypt(this.cryptoInitiator, plain);
|
|
900
|
+
if (this.cryptoSession !== null) {
|
|
901
|
+
this.encryption.sent = this.cryptoSession.stats.sent;
|
|
902
|
+
this.encryption.epoch = this.cryptoSession.sendEpoch;
|
|
903
|
+
}
|
|
904
|
+
this.socket.send(datagram, this.peer.port, this.peer.address, () => {});
|
|
471
905
|
}
|
|
472
906
|
|
|
473
907
|
/**
|
|
@@ -617,6 +1051,14 @@ export class P2PChannel extends EventEmitter {
|
|
|
617
1051
|
* so there is no round trip to measure. Reporting the time spent waiting as an RTT
|
|
618
1052
|
* would be a fabricated number in a field named for a measurement.
|
|
619
1053
|
*
|
|
1054
|
+
* v0.6.0 adds the responder's half of the handshake. A `HELLO` whose body is a handshake message is
|
|
1055
|
+
* verified against the initiator's static key, answered with a `HELLO_ACK` carrying `e_r`, and turned
|
|
1056
|
+
* into a session; one whose body is empty is the v0.5.x punch and is answered exactly as before. When
|
|
1057
|
+
* a handshake arrives for a machine we hold no key for, the answer is an unauthenticated named
|
|
1058
|
+
* `REFUSE` — we cannot verify anything it says and we will not guess — and the punch itself is still
|
|
1059
|
+
* acknowledged so a plaintext channel can be formed. A handshake that arrives with a key we *do* hold
|
|
1060
|
+
* and fails its MAC gets a `REFUSE` naming that, and no channel at all.
|
|
1061
|
+
*
|
|
620
1062
|
* @param {object} options
|
|
621
1063
|
* @param {import('node:dgram').Socket} options.socket Already bound; the same socket the
|
|
622
1064
|
* punch was aimed at, and the same one a channel will use.
|
|
@@ -627,8 +1069,14 @@ export class P2PChannel extends EventEmitter {
|
|
|
627
1069
|
* claimed session within one call, so a peer that sprays HELLOs cannot make the
|
|
628
1070
|
* responder answer every single one. The default matches `punch()`'s send
|
|
629
1071
|
* interval, so a retransmission is always answered; `0` disables the floor.
|
|
1072
|
+
* @param {object} [options.crypto] `{deviceKey, peerKeys, seen}` to answer handshakes. Without it
|
|
1073
|
+
* this function is exactly v0.5.x: every `HELLO` is answered plainly.
|
|
1074
|
+
* @param {object} [options.cryptoTuning] Rekey thresholds and retention; see `crypto.mjs`.
|
|
630
1075
|
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
|
|
631
|
-
* session:number, rttMs:number|null, error:string|null
|
|
1076
|
+
* session:number, rttMs:number|null, error:string|null, machineId:string|null,
|
|
1077
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean}}>}
|
|
1078
|
+
* `machineId` is the handshake's *verified* claim about who dialled, or null for a plain punch
|
|
1079
|
+
* (which carries no identity at all — see `P2PNode`'s note on why it refuses to guess one).
|
|
632
1080
|
*/
|
|
633
1081
|
export async function accept(options) {
|
|
634
1082
|
const { socket } = options;
|
|
@@ -637,6 +1085,7 @@ export async function accept(options) {
|
|
|
637
1085
|
const timeoutMs = options.timeoutMs ?? 5000;
|
|
638
1086
|
const intervalMs = options.intervalMs ?? 250;
|
|
639
1087
|
const claimed = options.claimed ?? null;
|
|
1088
|
+
const crypto = options.crypto ?? null;
|
|
640
1089
|
|
|
641
1090
|
return new Promise((resolve) => {
|
|
642
1091
|
let settled = false;
|
|
@@ -660,6 +1109,8 @@ export async function accept(options) {
|
|
|
660
1109
|
session: 0,
|
|
661
1110
|
rttMs: null,
|
|
662
1111
|
error: `P2P_ACCEPT_TIMEOUT: no HELLO within ${timeoutMs}ms`,
|
|
1112
|
+
machineId: null,
|
|
1113
|
+
crypto: cryptoOutcome('plaintext', CRYPTO_REASON.PLAINTEXT_PEER),
|
|
663
1114
|
});
|
|
664
1115
|
}, timeoutMs);
|
|
665
1116
|
|
|
@@ -673,6 +1124,8 @@ export async function accept(options) {
|
|
|
673
1124
|
session: 0,
|
|
674
1125
|
rttMs: null,
|
|
675
1126
|
error: 'P2P_SOCKET_CLOSED: the socket went away while waiting for a HELLO',
|
|
1127
|
+
machineId: null,
|
|
1128
|
+
crypto: cryptoOutcome('plaintext', CRYPTO_REASON.PLAINTEXT_PEER),
|
|
676
1129
|
});
|
|
677
1130
|
};
|
|
678
1131
|
|
|
@@ -683,29 +1136,153 @@ export async function accept(options) {
|
|
|
683
1136
|
// delivery, which is the bug the `claimed` set exists to prevent.
|
|
684
1137
|
if (!parsed || parsed.kind !== KIND.HELLO) return;
|
|
685
1138
|
const session = parsed.session;
|
|
1139
|
+
const handshakeMessage = decodeHello(parsed.body);
|
|
686
1140
|
|
|
687
1141
|
if (claimed !== null && claimed.has(session)) {
|
|
688
1142
|
const now = Date.now();
|
|
689
1143
|
const last = reackedAt.get(session) ?? Number.NEGATIVE_INFINITY;
|
|
690
|
-
|
|
1144
|
+
// The interval floor applies to a *plain* re-acknowledgement only, which is what it was written
|
|
1145
|
+
// for: a peer retransmitting its punch HELLO must not draw an answer to every datagram. A
|
|
1146
|
+
// handshake message is different — it is the initiator asking for a session, it arrives once
|
|
1147
|
+
// per attempt, and suppressing its answer because a plain HELLO for the same session landed
|
|
1148
|
+
// milliseconds earlier is a *stall*, not a saving: the initiator waits out its whole timeout,
|
|
1149
|
+
// and what it reports is a timeout instead of the key it could not verify.
|
|
1150
|
+
const isHandshake = handshakeMessage !== null && crypto !== null;
|
|
1151
|
+
if (isHandshake || now - last >= intervalMs) {
|
|
691
1152
|
reackedAt.set(session, now);
|
|
692
|
-
|
|
1153
|
+
// A plain re-acknowledgement answers with an EMPTY body, and that empty body *is* the answer:
|
|
1154
|
+
// sending nothing because it "has no content" was the bug that made a repeated HELLO look
|
|
1155
|
+
// like an unreachable peer.
|
|
1156
|
+
let reply = Buffer.alloc(0);
|
|
1157
|
+
let upgraded = null;
|
|
1158
|
+
if (isHandshake) {
|
|
1159
|
+
const answer = answerHandshake({ crypto, body: parsed.body });
|
|
1160
|
+
// `refuse` as well as `body`: a handshake that failed for a *claimed* session still gets a
|
|
1161
|
+
// named answer, because the alternative is the initiator waiting out its whole timeout and
|
|
1162
|
+
// reporting `P2P_CRYPTO_HANDSHAKE_TIMEOUT` when the truth is `P2P_CRYPTO_BAD_MAC`. An
|
|
1163
|
+
// earlier version sent only `body`, so every refusal on this path was silent — the one
|
|
1164
|
+
// outcome §4 says must never be silent.
|
|
1165
|
+
reply = answer.body ?? answer.refuse ?? Buffer.alloc(0);
|
|
1166
|
+
if (answer.ok && answer.established !== null) upgraded = answer;
|
|
1167
|
+
}
|
|
1168
|
+
socket.send(frame(KIND.HELLO_ACK, session, reply), rinfo.port, rinfo.address, () => {});
|
|
1169
|
+
// The other half of the simultaneous punch, and the reason `upgrade` exists. A channel for
|
|
1170
|
+
// `session` is already live here — `dial()` opened it, or an earlier `accept()` did — and the
|
|
1171
|
+
// peer has now asked for a handshake on that same session. The handshake is *completed*
|
|
1172
|
+
// above, so the peer gets its answer, but the live channel is still plaintext; returning it
|
|
1173
|
+
// as-is would leave the two ends disagreeing about whether the session is encrypted, so
|
|
1174
|
+
// every frame would fail its tag and the task would fall back to the relay for a reason
|
|
1175
|
+
// nothing recorded. The established session is handed back for the *caller* to attach to the
|
|
1176
|
+
// channel it already owns: `accept()` does not own channels and will not reach into one.
|
|
1177
|
+
if (upgraded !== null) {
|
|
1178
|
+
finish({
|
|
1179
|
+
ok: false,
|
|
1180
|
+
channel: null,
|
|
1181
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
1182
|
+
session,
|
|
1183
|
+
rttMs: null,
|
|
1184
|
+
error: 'P2P_CRYPTO_UPGRADE: the handshake completed for a session that was already claimed',
|
|
1185
|
+
machineId: upgraded.machineId ?? null,
|
|
1186
|
+
crypto: cryptoOutcome('encrypted', null, upgraded.established),
|
|
1187
|
+
upgrade: {
|
|
1188
|
+
session,
|
|
1189
|
+
session_crypto: upgraded.established.session,
|
|
1190
|
+
isInitiator: upgraded.established.isInitiator === true,
|
|
1191
|
+
machineId: upgraded.machineId ?? null,
|
|
1192
|
+
},
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
693
1196
|
}
|
|
694
1197
|
return;
|
|
695
1198
|
}
|
|
696
1199
|
|
|
697
|
-
|
|
1200
|
+
/** A refusal to send *instead of* an acknowledgement: no channel exists in that case. */
|
|
1201
|
+
let outcome = cryptoOutcome('plaintext', CRYPTO_REASON.PLAINTEXT_PEER);
|
|
1202
|
+
let ackBody = Buffer.alloc(0);
|
|
1203
|
+
let machineId = null;
|
|
1204
|
+
/** Set when the handshake cannot be verified, so the peer is told why in a named refusal. */
|
|
1205
|
+
let refusal = null;
|
|
1206
|
+
|
|
1207
|
+
if (handshakeMessage !== null) {
|
|
1208
|
+
if (crypto === null) {
|
|
1209
|
+
// No key material on this machine at all. The punch is still answered — refusing *that*
|
|
1210
|
+
// would turn "this build has no keys" into "this machine is unreachable", which is exactly
|
|
1211
|
+
// the fleet-losing outcome §4 forbids — but the peer is told why, in a named refusal frame
|
|
1212
|
+
// that travels alongside the plain acknowledgement.
|
|
1213
|
+
//
|
|
1214
|
+
// This is what keeps the two ends from disagreeing about whether the session is encrypted: an
|
|
1215
|
+
// initiator that asked for a handshake and received only a plain acknowledgement would
|
|
1216
|
+
// otherwise have to guess, and guessing wrong means every frame fails its tag. A v0.5.x peer
|
|
1217
|
+
// ignores the extra refusal (it never reads a body) and answers plainly, which the initiator
|
|
1218
|
+
// reads as the plaintext-only peer it is.
|
|
1219
|
+
outcome = cryptoOutcome('plaintext', CRYPTO_REASON.NO_KEY);
|
|
1220
|
+
refusal = { code: REFUSE.NO_KEY, reason: CRYPTO_REASON.NO_KEY, terminal: false };
|
|
1221
|
+
} else {
|
|
1222
|
+
const answered = answerHandshake({ crypto, body: parsed.body });
|
|
1223
|
+
if (answered.ok && answered.established !== null) {
|
|
1224
|
+
outcome = cryptoOutcome('encrypted', null, answered.established);
|
|
1225
|
+
ackBody = answered.body;
|
|
1226
|
+
machineId = answered.machineId;
|
|
1227
|
+
} else if (answered.refuse !== null) {
|
|
1228
|
+
const code = decodeRefuse(answered.refuse)?.code ?? REFUSE.UNKNOWN_PEER;
|
|
1229
|
+
// `NO_PEER_KEY` is the recoverable one, and the distinction is what makes §4's fallback
|
|
1230
|
+
// clause implementable without a silent downgrade. "I have a keypair and none for you"
|
|
1231
|
+
// means the handshake is impossible for a stated reason, and the peer may use the plaintext
|
|
1232
|
+
// channel it would have had without this work — a v0.5.x peer never offered a key to
|
|
1233
|
+
// downgrade from. Every other refusal is terminal: the peer is known and its proof did not
|
|
1234
|
+
// verify, which is a stale key or an impersonation attempt, and neither is a reason to fall
|
|
1235
|
+
// back into the clear.
|
|
1236
|
+
refusal = {
|
|
1237
|
+
code,
|
|
1238
|
+
reason: answered.reason,
|
|
1239
|
+
terminal: code !== REFUSE.NO_KEY && code !== REFUSE.NO_PEER_KEY,
|
|
1240
|
+
};
|
|
1241
|
+
if (refusal.terminal) outcome = cryptoOutcome('refused', answered.reason);
|
|
1242
|
+
else outcome = cryptoOutcome('plaintext', CRYPTO_REASON.NO_KEY);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (refusal !== null) {
|
|
1248
|
+
socket.send(
|
|
1249
|
+
frame(KIND.HELLO_ACK, session, encodeRefuse(session, refusal.code, null)),
|
|
1250
|
+
rinfo.port,
|
|
1251
|
+
rinfo.address,
|
|
1252
|
+
() => {},
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
if (refusal !== null && refusal.terminal) {
|
|
1256
|
+
// The punch is *not* acknowledged: a handshake that failed is not a path this build will use in
|
|
1257
|
+
// the clear, and handing back a plaintext channel here would be the downgrade §4 forbids.
|
|
1258
|
+
finish({
|
|
1259
|
+
ok: false,
|
|
1260
|
+
channel: null,
|
|
1261
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
1262
|
+
session,
|
|
1263
|
+
rttMs: null,
|
|
1264
|
+
error: `${refusal.reason}: the handshake was refused`,
|
|
1265
|
+
machineId: null,
|
|
1266
|
+
crypto: cryptoOutcome('refused', refusal.reason),
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
socket.send(frame(KIND.HELLO_ACK, session, ackBody), rinfo.port, rinfo.address, () => {});
|
|
698
1272
|
finish({
|
|
699
1273
|
ok: true,
|
|
700
1274
|
channel: new P2PChannel({
|
|
701
1275
|
socket,
|
|
702
1276
|
peer: { address: rinfo.address, port: rinfo.port },
|
|
703
1277
|
session,
|
|
1278
|
+
crypto: channelCrypto(outcome, options.cryptoTuning),
|
|
704
1279
|
}),
|
|
705
1280
|
peer: { address: rinfo.address, port: rinfo.port },
|
|
706
1281
|
session,
|
|
707
1282
|
rttMs: null,
|
|
708
1283
|
error: null,
|
|
1284
|
+
machineId,
|
|
1285
|
+
crypto: outcome,
|
|
709
1286
|
});
|
|
710
1287
|
};
|
|
711
1288
|
|
|
@@ -714,20 +1291,70 @@ export async function accept(options) {
|
|
|
714
1291
|
});
|
|
715
1292
|
}
|
|
716
1293
|
|
|
1294
|
+
/**
|
|
1295
|
+
* Answer one handshake message, or explain why not.
|
|
1296
|
+
*
|
|
1297
|
+
* Extracted so the ordinary path and the re-acknowledgement of a claimed session do the same thing:
|
|
1298
|
+
* a peer that retransmits its `HELLO` because our `HELLO_ACK` was lost must get the *identical*
|
|
1299
|
+
* answer, not a new one and not a silence.
|
|
1300
|
+
*
|
|
1301
|
+
* @param {object} input
|
|
1302
|
+
* @param {{deviceKey: object, peerKeys: object, seen: object|null}} input.crypto
|
|
1303
|
+
* @param {Buffer} input.body
|
|
1304
|
+
* @returns {{ok:boolean, body:Buffer|null, refuse:Buffer|null, reason:string|null, machineId:string|null,
|
|
1305
|
+
* established:{session:object, isInitiator:boolean}|null}}
|
|
1306
|
+
*/
|
|
1307
|
+
function answerHandshake({ crypto, body }) {
|
|
1308
|
+
const answered = respondHandshake({
|
|
1309
|
+
deviceKey: crypto.deviceKey,
|
|
1310
|
+
body,
|
|
1311
|
+
peerKeys: crypto.peerKeys,
|
|
1312
|
+
seen: crypto.seen ?? null,
|
|
1313
|
+
});
|
|
1314
|
+
if (answered.ok) {
|
|
1315
|
+
return {
|
|
1316
|
+
ok: true,
|
|
1317
|
+
body: answered.body,
|
|
1318
|
+
refuse: null,
|
|
1319
|
+
reason: null,
|
|
1320
|
+
machineId: answered.machineId,
|
|
1321
|
+
established: { session: answered.crypto, isInitiator: false },
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
return {
|
|
1325
|
+
ok: false,
|
|
1326
|
+
body: null,
|
|
1327
|
+
refuse: answered.refuse,
|
|
1328
|
+
reason: answered.reason,
|
|
1329
|
+
machineId: null,
|
|
1330
|
+
established: null,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
717
1334
|
/**
|
|
718
1335
|
* Punch, then return a reliable channel on the opened path.
|
|
719
1336
|
*
|
|
720
|
-
* @param {object} options Same as {@link punch}, plus `tuning` for the channel
|
|
721
|
-
*
|
|
1337
|
+
* @param {object} options Same as {@link punch}, plus `tuning` for the channel and `cryptoTuning`
|
|
1338
|
+
* for the session's rekey thresholds.
|
|
1339
|
+
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, punch:object, error:string|null,
|
|
1340
|
+
* crypto:object|null}>} `crypto` is the punch's own outcome object, repeated here so a caller that
|
|
1341
|
+
* only keeps the channel can still say *why* it is or is not encrypted.
|
|
722
1342
|
*/
|
|
723
1343
|
export async function connect(options) {
|
|
724
1344
|
const result = await punch(options);
|
|
725
|
-
if (!result.ok) return { ok: false, channel: null, punch: result, error: result.error };
|
|
1345
|
+
if (!result.ok) return { ok: false, channel: null, punch: result, error: result.error, crypto: result.crypto };
|
|
1346
|
+
const crypto = channelCrypto(result.crypto, options.cryptoTuning);
|
|
726
1347
|
const channel = new P2PChannel({
|
|
727
1348
|
socket: options.socket,
|
|
728
1349
|
peer: result.peer,
|
|
729
1350
|
session: result.session,
|
|
730
1351
|
tuning: options.tuning,
|
|
1352
|
+
crypto,
|
|
731
1353
|
});
|
|
732
|
-
|
|
1354
|
+
// The punch's own reason survives on the channel even when the handshake did not happen: a
|
|
1355
|
+
// plaintext channel has to be able to say *why* it is one.
|
|
1356
|
+
if (result.crypto !== null && result.crypto.status !== 'encrypted') {
|
|
1357
|
+
channel.encryption.reason = result.crypto.reason;
|
|
1358
|
+
}
|
|
1359
|
+
return { ok: true, channel, punch: result, error: null, crypto: result.crypto };
|
|
733
1360
|
}
|