@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/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 including the 13-byte header, which passes a
73
- * 1280-byte IPv6 minimum MTU and the 1500-byte Ethernet MTU with room for a tunnel
74
- * (WireGuard adds 60). A larger fragment is faster right up until it is silently
75
- * dropped by a path that will not fragment.
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 { ok: false, peer: null, session: explicitSession ?? 0, attempts: 0, rttMs: null, error: 'P2P_NO_CANDIDATES' };
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
- const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
308
- finish({
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
- if (parsed.session !== agreed) return;
321
- const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
322
- finish({
323
- ok: true,
324
- peer: { address: rinfo.address, port: rinfo.port },
325
- session: agreed,
326
- attempts,
327
- rttMs: Math.round(rttMs * 100) / 100,
328
- error: null,
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
- for (const candidate of remote) {
348
- socket.send(hello, candidate.port, candidate.address, () => {});
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
- if (parsed.kind === KIND.DATA) this.onData(parsed.body);
407
- else if (parsed.kind === KIND.ACK) this.onAck(parsed.body);
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
- /** @param {number} kind @param {Buffer} body */
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
- this.socket.send(frame(kind, this.session, body), this.peer.port, this.peer.address, () => {});
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
- if (now - last >= intervalMs) {
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
- socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
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
- socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
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
- * @returns {Promise<{ok:boolean, channel:P2PChannel|null, punch:object, error:string|null}>}
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
- return { ok: true, channel, punch: result, error: null };
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
  }