@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +379 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +197 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +129 -53
- package/bin/w2m-localside.mjs +5 -0
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +242 -23
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +252 -40
- package/src/agent/p2p-node.mjs +136 -7
- package/src/agent/p2p.mjs +342 -37
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/persistence.mjs +72 -2
- package/src/relay/server.mjs +84 -11
- package/src/relay/state.mjs +9 -2
package/src/agent/p2p.mjs
CHANGED
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
REFUSE_REASON,
|
|
77
77
|
respondHandshake,
|
|
78
78
|
startHandshake,
|
|
79
|
+
verifyRefuseMac,
|
|
79
80
|
} from './crypto.mjs';
|
|
80
81
|
|
|
81
82
|
/** Frame kinds. Exported so tests and callers can name them instead of using magic numbers. */
|
|
@@ -270,9 +271,17 @@ export function reverseDialSession({ originMachineId, taskId, machineId } = {})
|
|
|
270
271
|
* * `plaintext` — no handshake was possible (no key for the peer, or the peer sent none), and the
|
|
271
272
|
* reason says which. This is the v0.5.x compatibility path, and it is recorded, never hidden.
|
|
272
273
|
* * `refused` — a handshake was attempted and did not complete. `reason` is a
|
|
273
|
-
* `crypto.mjs` reason code. **
|
|
274
|
-
* downgrading to plaintext" is enforced where the channel is created rather than left to
|
|
275
|
-
* to remember.
|
|
274
|
+
* `crypto.mjs` reason code. **No plaintext channel is handed back on this path**: §4's "never
|
|
275
|
+
* silently downgrading to plaintext" is enforced where the channel is created rather than left to
|
|
276
|
+
* a caller to remember.
|
|
277
|
+
*
|
|
278
|
+
* That last sentence used to be written as "there is no plaintext channel in this case", which is a
|
|
279
|
+
* stronger claim than the code makes and was falsified by the v0.5.9 review: a `REFUSE(NO_PEER_KEY)`
|
|
280
|
+
* that arrives from nowhere still *chose* the `plaintext` outcome (the branch below the refusal check),
|
|
281
|
+
* so a punch ended as `ok: true` with a plaintext channel instead of `refused`. Which is why the
|
|
282
|
+
* refusal is now guarded where it is read — see {@link REFUSE_GUARD} — and why `plaintext` carries
|
|
283
|
+
* `refusal_authenticated` when a refusal is what chose it: the honest wording is "a *failed* handshake
|
|
284
|
+
* never falls back into the clear", not "no datagram can make this a plaintext punch".
|
|
276
285
|
*
|
|
277
286
|
* @param {'encrypted'|'plaintext'|'refused'} status
|
|
278
287
|
* @param {string|null} reason
|
|
@@ -301,17 +310,134 @@ function channelCrypto(outcome, tuning = {}) {
|
|
|
301
310
|
return { session: outcome.session, isInitiator: outcome.isInitiator === true, tuning };
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
/**
|
|
314
|
+
* What a `REFUSE` datagram must satisfy before it may change a punch's outcome — as a value, so the
|
|
315
|
+
* contract cannot drift away from the code the way a comment can.
|
|
316
|
+
*
|
|
317
|
+
* `crypto.mjs` sends `REFUSE` with no proof in the one case where it has none to give: it is answering
|
|
318
|
+
* a handshake it cannot verify, because it holds no key for that machine. That is honest, and it is
|
|
319
|
+
* also a lever: any datagram of 13 bytes that reaches the dialling socket can claim to be that peer
|
|
320
|
+
* refusing, and the naive reading of it turns "the handshake did not complete" (a named failure, the
|
|
321
|
+
* relay fallback) into "the peer cannot take part" (a **plaintext channel**, and a ledger that says
|
|
322
|
+
* the peer was the one who could not). MEDIUM-4 of the v0.5.9 review reproduced exactly that with a
|
|
323
|
+
* third socket on loopback.
|
|
324
|
+
*
|
|
325
|
+
* So the refusal is honoured only when all of these hold, and the fourth narrows the one that
|
|
326
|
+
* matters most:
|
|
327
|
+
*
|
|
328
|
+
* 1. **It names the session this punch agreed** — the frame header *and* the refusal body. A
|
|
329
|
+
* refusal for a punch we are not making cannot be about us.
|
|
330
|
+
* 2. **It comes from an address this punch punched**: one of the candidates we sent `HELLO` to, or
|
|
331
|
+
* the address that has already answered. A refusal from a third address is not this peer's.
|
|
332
|
+
* 3. **A seal never buys a downgrade it cannot prove.** A refusal that would choose a plaintext
|
|
333
|
+
* channel (`NO_KEY`, `NO_PEER_KEY`) is honoured when its `k_ref` seal verifies, or — for a peer too
|
|
334
|
+
* old to seal one — when it carries no seal at all *and* comes from the endpoint that answered the
|
|
335
|
+
* punch (condition 4). A **sealed** downgrade whose MAC does not verify is dropped: an honest peer
|
|
336
|
+
* either seals correctly or sends no MAC at all, so that datagram was assembled by somebody else.
|
|
337
|
+
* 4. **An unsealed downgrade must be corroborated** by the same peer having acknowledged the punch.
|
|
338
|
+
* That branch does not fail the punch, it *downgrades* it, so it is the one place where a claim
|
|
339
|
+
* that cannot be checked has to come from the endpoint that actually answered. A *sealed*
|
|
340
|
+
* downgrade needs no corroboration: it is the peer.
|
|
341
|
+
*
|
|
342
|
+
* Every other (**terminal**) refusal is honoured from a dialled address whether or not it is sealed, and
|
|
343
|
+
* `crypto.refusal_authenticated` says which it was: `true` (a seal that verified — proof), `null` (no
|
|
344
|
+
* seal: the form an older build sends), `false` (a seal that did not verify). Dropping a terminal
|
|
345
|
+
* refusal over a bad seal would buy nothing — an attacker forging one simply omits the seal — and would
|
|
346
|
+
* cost the honest case its name: a peer whose record of *our* key is stale computes a `k_ref` we cannot
|
|
347
|
+
* reproduce, and "your proof did not verify" is exactly what it is telling us.
|
|
348
|
+
*
|
|
349
|
+
* A datagram that fails 1, 2 or 3 is **dropped**, and the drop is recorded on the result in
|
|
350
|
+
* `crypto.ignored_refusals` with a `reason_code` from `CRYPTO_REASON`. Dropped rather than reported as
|
|
351
|
+
* a failure on purpose: if a forged refusal could fail the punch, injecting one would be a cheaper
|
|
352
|
+
* denial of service than suppressing the real answer, and the honest peer would be failed for it.
|
|
353
|
+
*
|
|
354
|
+
* RESIDUAL, stated because the documents above quote it: an attacker who can put a datagram on the
|
|
355
|
+
* wire *from the punched peer's address and port* (spoofing the source, so on-path or on the same
|
|
356
|
+
* host as the punch) can still satisfy 1, 2 and 4 with an **unsealed** `NO_PEER_KEY` — the one form a
|
|
357
|
+
* peer running an older build sends, and therefore the one form that cannot be required to carry a
|
|
358
|
+
* seal. That attacker can force the recorded plaintext outcome; if the real peer answers first, an
|
|
359
|
+
* injected terminal refusal can also force the relay fallback. What no longer works is a datagram
|
|
360
|
+
* from anywhere else, one that names another session, a sealed downgrade that does not verify, or a
|
|
361
|
+
* forced downgrade against a peer new enough to seal one — for those, `k_ref` cannot be forged by
|
|
362
|
+
* anyone who does not hold the peer's static private key.
|
|
363
|
+
*/
|
|
364
|
+
export const REFUSE_GUARD = Object.freeze({
|
|
365
|
+
requires: [
|
|
366
|
+
'names the session this punch agreed (frame header and body)',
|
|
367
|
+
'comes from an address this punch dialled, or from the peer that answered it',
|
|
368
|
+
'a plaintext-choosing refusal is sealed with a k_ref MAC that verifies, or carries no seal at all',
|
|
369
|
+
'an unsealed plaintext-choosing refusal (NO_KEY, NO_PEER_KEY) comes from the address that answered the punch',
|
|
370
|
+
],
|
|
371
|
+
on_failure: 'the datagram is dropped and recorded in crypto.ignored_refusals; the punch outcome is unchanged',
|
|
372
|
+
terminal_refusals:
|
|
373
|
+
'honoured from a dialled address sealed or not; crypto.refusal_authenticated records true (seal verified), '
|
|
374
|
+
+ 'null (no seal) or false (a seal that did not verify), so a claim is never mistaken for proof',
|
|
375
|
+
residual:
|
|
376
|
+
'an attacker who can inject an unsealed refusal from the punched peer\'s own address and port can still '
|
|
377
|
+
+ 'force the recorded plaintext outcome, or the relay fallback with a terminal refusal; a sealed downgrade '
|
|
378
|
+
+ 'cannot be forged without the peer\'s static private key',
|
|
379
|
+
});
|
|
380
|
+
|
|
304
381
|
/**
|
|
305
382
|
* A refusal frame, if this datagram is one. Extracted because three branches ask the same question.
|
|
306
383
|
*
|
|
384
|
+
* `key` is `k_ref` — the key this end derives from the handshake ephemeral (see `crypto.mjs`) — when a
|
|
385
|
+
* handshake is in flight. It decides one thing only: whether the refusal's optional seal can be
|
|
386
|
+
* checked. `authenticated` is deliberately three-valued — `true` (a seal was present and verified),
|
|
387
|
+
* `false` (a seal was present and did *not* verify), `null` (no seal at all, which is what a peer too
|
|
388
|
+
* old to seal one sends and which proves nothing).
|
|
389
|
+
*
|
|
307
390
|
* @param {{kind:number, session:number, body:Buffer}} parsed
|
|
308
|
-
* @
|
|
391
|
+
* @param {Buffer|null} [key] `k_ref` for this exchange, or null when no handshake is in flight.
|
|
392
|
+
* @returns {{code:number, reason:string, session:number, machineId:string|null,
|
|
393
|
+
* authenticated:boolean|null}|null} The named reason, or null when this is not a refusal.
|
|
309
394
|
*/
|
|
310
|
-
function cryptoRefusalOf(parsed) {
|
|
395
|
+
function cryptoRefusalOf(parsed, key = null) {
|
|
311
396
|
if (parsed.kind !== KIND.HELLO_ACK && parsed.kind !== KIND.HELLO) return null;
|
|
312
397
|
const refuse = decodeRefuse(parsed.body);
|
|
313
398
|
if (refuse === null) return null;
|
|
314
|
-
|
|
399
|
+
let authenticated = null;
|
|
400
|
+
if (refuse.mac !== null) authenticated = key === null ? false : verifyRefuseMac(key, parsed.body) === true;
|
|
401
|
+
return {
|
|
402
|
+
code: refuse.code,
|
|
403
|
+
reason: REFUSE_REASON[refuse.code] ?? CRYPTO_REASON.REFUSED_BY_PEER,
|
|
404
|
+
session: refuse.session,
|
|
405
|
+
machineId: refuse.machineId,
|
|
406
|
+
authenticated,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Why a refusal datagram was **ignored**, in the house `{reason_code, detail}` shape.
|
|
412
|
+
*
|
|
413
|
+
* Every one of these four is a refusal we declined to act on rather than a refusal that decided the
|
|
414
|
+
* punch, which is why they are collected on the result (`crypto.ignored_refusals`) instead of being
|
|
415
|
+
* raised as a failure: a datagram an attacker can forge must not be able to fail a punch either.
|
|
416
|
+
* `detail` says what *would* have changed it, because "why did my punch still time out?" is the
|
|
417
|
+
* question each of these exists to answer.
|
|
418
|
+
*
|
|
419
|
+
* @param {string} reasonCode One of `CRYPTO_REASON`'s four `REFUSE_*` codes.
|
|
420
|
+
* @param {string} detail
|
|
421
|
+
* @param {object} context
|
|
422
|
+
* @param {{kind:number, session:number, body:Buffer}} context.parsed
|
|
423
|
+
* @param {{address:string, port:number}} context.rinfo
|
|
424
|
+
* @param {number} context.agreed The session this punch agreed.
|
|
425
|
+
* @returns {{reason_code:string, detail:string, session:number, from:string, datagram_bytes:number}}
|
|
426
|
+
*/
|
|
427
|
+
function ignoredRefusal(reasonCode, detail, { parsed, rinfo, agreed }) {
|
|
428
|
+
return {
|
|
429
|
+
reason_code: reasonCode,
|
|
430
|
+
detail,
|
|
431
|
+
session: (parsed.session >>> 0),
|
|
432
|
+
agreed_session: (agreed >>> 0),
|
|
433
|
+
from: `${rinfo.address}:${rinfo.port}`,
|
|
434
|
+
datagram_bytes: parsed.body.length + 5,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Same remote endpoint? `null` on either side is "not the same". */
|
|
439
|
+
function sameEndpoint(a, b) {
|
|
440
|
+
return a !== null && b !== null && a.address === b.address && a.port === b.port;
|
|
315
441
|
}
|
|
316
442
|
|
|
317
443
|
/**
|
|
@@ -347,9 +473,11 @@ function finishFromInitiatorMessage(handshake, body) {
|
|
|
347
473
|
* * This end holds no key for the peer (a v0.5.x peer, or one that never announced): a plain
|
|
348
474
|
* `HELLO` is sent and the channel is plaintext with `P2P_CRYPTO_NO_KEY` as the reason.
|
|
349
475
|
* * This end holds a key and the handshake fails: the punch reports `ok: false` with the peer's
|
|
350
|
-
* named reason, and
|
|
351
|
-
*
|
|
352
|
-
*
|
|
476
|
+
* named reason, and no plaintext channel is handed back for that peer — but see
|
|
477
|
+
* {@link REFUSE_GUARD}: "the peer's named reason" is only acted on when the datagram carrying it
|
|
478
|
+
* can plausibly be the peer's, because `REFUSE` is unauthenticated by design. The caller falls
|
|
479
|
+
* back to the relay — §4's fallback clause — and the ledger records `p2p.encrypted: false` plus
|
|
480
|
+
* the reason.
|
|
353
481
|
*
|
|
354
482
|
* @param {object} options
|
|
355
483
|
* @param {import('node:dgram').Socket} options.socket Already bound, and the same socket STUN used.
|
|
@@ -366,7 +494,14 @@ function finishFromInitiatorMessage(handshake, body) {
|
|
|
366
494
|
* 2^20 messages.
|
|
367
495
|
* @returns {Promise<{ok:boolean, peer:{address:string,port:number}|null, session:number,
|
|
368
496
|
* attempts:number, rttMs:number|null, error:string|null,
|
|
369
|
-
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean
|
|
497
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean,
|
|
498
|
+
* refusal_authenticated?:boolean|null, ignored_refusals?:object[]}}>}
|
|
499
|
+
* `crypto.ignored_refusals` is present only when at least one `REFUSE` datagram was dropped by
|
|
500
|
+
* {@link REFUSE_GUARD}; each entry is `{reason_code, detail, session, agreed_session, from,
|
|
501
|
+
* datagram_bytes}` (`datagram_bytes` is null for the one drop decided at outcome time, when the
|
|
502
|
+
* datagram is no longer at hand). `crypto.refusal_authenticated` is present only when a refusal
|
|
503
|
+
* decided the outcome: `true` for a seal that verified, `null` for the unsealed form an older build
|
|
504
|
+
* sends, `false` for a seal that did not verify.
|
|
370
505
|
*/
|
|
371
506
|
export async function punch(options) {
|
|
372
507
|
const { socket } = options;
|
|
@@ -416,13 +551,32 @@ export async function punch(options) {
|
|
|
416
551
|
session: ownSession,
|
|
417
552
|
})
|
|
418
553
|
: null;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
554
|
+
/**
|
|
555
|
+
* `k_ref` for the peer being dialled, when a handshake is in flight: what a refusal MAC is checked
|
|
556
|
+
* with. It comes from the handshake's own ephemeral (`ECDH(e_i, s_r)`), so it exists for every peer
|
|
557
|
+
* whose `HELLO` reached us — including one that holds no key for us, which is the case that sends
|
|
558
|
+
* the refusal MEDIUM-4 was about.
|
|
559
|
+
*/
|
|
560
|
+
const refusalKey = handshake === null ? null : handshake.refusalMacKey;
|
|
561
|
+
/**
|
|
562
|
+
* Why there is no handshake, when there is none. `null` means one is in flight.
|
|
563
|
+
*
|
|
564
|
+
* v0.6.2: `request === null` is *this end's* decision, not a fact about the peer. `P2PNode.dial`
|
|
565
|
+
* sends no crypto request in three situations and every one of them is local — this machine has no
|
|
566
|
+
* device key, it holds no key for that peer, or it is dialling itself (a machine keeps no key for
|
|
567
|
+
* itself, so its own `respondHandshake` would refuse it with `P2P_CRYPTO_UNKNOWN_PEER`).
|
|
568
|
+
*
|
|
569
|
+
* It used to report `PLAINTEXT_PEER` here, documented as "the peer never learned a key (an older
|
|
570
|
+
* build)". Measured consequence: a dispatcher with an empty peer-key store reported every direct
|
|
571
|
+
* channel as the peer being out of date, when both ends were the same build on the same host — an
|
|
572
|
+
* encryption downgrade recorded as normal compatibility. `dial()`'s own comment has always claimed
|
|
573
|
+
* the reason would be `NO_KEY`; this is that behaviour, and it is what the objective asks for: a
|
|
574
|
+
* punch that did not encrypt says whether the cause was ours or theirs.
|
|
575
|
+
*
|
|
576
|
+
* `PLAINTEXT_PEER` keeps its real meaning and its real sites: a handshake *was* armed and the peer
|
|
577
|
+
* answered in the clear, which is the only case where the peer is genuinely the reason.
|
|
578
|
+
*/
|
|
579
|
+
const noCryptoReason = handshake !== null ? null : (options.cryptoBlockedReason ?? CRYPTO_REASON.NO_KEY);
|
|
426
580
|
/** Whether this punch is trying to encrypt at all. */
|
|
427
581
|
const wantCrypto = handshake !== null;
|
|
428
582
|
|
|
@@ -437,21 +591,42 @@ export async function punch(options) {
|
|
|
437
591
|
let punchAttempts = 0;
|
|
438
592
|
/** Set when the handshake has definitively failed, so the punch says why instead of timing out. */
|
|
439
593
|
let refused = null;
|
|
594
|
+
/** Whether the refusal that set `refused` was `k0`-verified. See {@link REFUSE_GUARD}. */
|
|
595
|
+
let refusedAuthenticated = null;
|
|
440
596
|
/**
|
|
441
597
|
* Set when the peer has told us it can only do plaintext (`REFUSE.NO_KEY`).
|
|
442
598
|
*
|
|
443
599
|
* Separate from `refused` on purpose, and the distinction is the whole of §4's fallback clause: a
|
|
444
600
|
* refusal means "do not talk to me in the clear", a plaintext-only answer means "I cannot talk to
|
|
445
601
|
* you any other way, and here is why". The first is a failure, the second is a recorded outcome.
|
|
602
|
+
*
|
|
603
|
+
* Carries the source it arrived from and whether it was authenticated, because a plaintext channel
|
|
604
|
+
* is only handed back when the *same* endpoint that answered the punch is the one that asked for
|
|
605
|
+
* it — see {@link REFUSE_GUARD} condition 4.
|
|
446
606
|
*/
|
|
447
607
|
let plainOnly = null;
|
|
608
|
+
/** Every `REFUSE` datagram dropped by {@link REFUSE_GUARD}, in `{reason_code, detail}` form. */
|
|
609
|
+
const ignoredRefusals = [];
|
|
610
|
+
/** One drop per un-corroborated plaintext refusal, however many times the outcome is asked for. */
|
|
611
|
+
let plainOnlyDropRecorded = false;
|
|
612
|
+
|
|
613
|
+
/** Is this datagram from an address this punch actually punched, or from the peer that answered it? */
|
|
614
|
+
const isPunchedSource = (rinfo) =>
|
|
615
|
+
sameEndpoint(punchedPeer, rinfo) ||
|
|
616
|
+
remote.some((candidate) => candidate.address === rinfo.address && candidate.port === rinfo.port);
|
|
448
617
|
|
|
449
618
|
const finish = (result) => {
|
|
450
619
|
if (settled) return;
|
|
451
620
|
settled = true;
|
|
452
621
|
clearTimeout(timer);
|
|
453
622
|
socket.removeListener('message', onMessage);
|
|
454
|
-
|
|
623
|
+
// The drops are attached here rather than threaded through every outcome, and only when there
|
|
624
|
+
// were any: a punch nobody interfered with returns exactly the object it always did.
|
|
625
|
+
resolve(
|
|
626
|
+
ignoredRefusals.length === 0
|
|
627
|
+
? result
|
|
628
|
+
: { ...result, crypto: { ...result.crypto, ignored_refusals: ignoredRefusals } },
|
|
629
|
+
);
|
|
455
630
|
};
|
|
456
631
|
|
|
457
632
|
/** The path is open: report it with whatever the handshake managed to do. */
|
|
@@ -510,22 +685,76 @@ export async function punch(options) {
|
|
|
510
685
|
}
|
|
511
686
|
|
|
512
687
|
if (parsed.kind === KIND.HELLO_ACK) {
|
|
513
|
-
const refusal = cryptoRefusalOf(parsed);
|
|
688
|
+
const refusal = cryptoRefusalOf(parsed, refusalKey);
|
|
514
689
|
if (refusal !== null) {
|
|
515
|
-
|
|
690
|
+
// ---- REFUSE_GUARD, conditions 1 and 2: this refusal has to be about *this* punch, from an
|
|
691
|
+
// address this punch actually dialled. Both checks come before anything else reads the
|
|
692
|
+
// datagram, and both are recorded rather than raised: see REFUSE_GUARD's `on_failure`.
|
|
693
|
+
if (parsed.session !== agreed || refusal.session !== agreed) {
|
|
694
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
695
|
+
CRYPTO_REASON.REFUSE_SESSION_MISMATCH,
|
|
696
|
+
`the refusal named session ${refusal.session} in a frame for session ${parsed.session}; a refusal `
|
|
697
|
+
+ `that names the agreed session ${agreed} is honoured`,
|
|
698
|
+
{ parsed, rinfo, agreed },
|
|
699
|
+
));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (!isPunchedSource(rinfo)) {
|
|
703
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
704
|
+
CRYPTO_REASON.REFUSE_UNPUNCHED_SOURCE,
|
|
705
|
+
`it arrived from ${rinfo.address}:${rinfo.port}, which is neither one of the ${remote.length} `
|
|
706
|
+
+ 'address(es) this punch dialled nor the peer that answered it; a refusal from one of those is '
|
|
707
|
+
+ 'honoured',
|
|
708
|
+
{ parsed, rinfo, agreed },
|
|
709
|
+
));
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
// ---- condition 3, first half: a MAC that was present and *wrong* buys nothing.
|
|
713
|
+
//
|
|
714
|
+
// It cannot be proof, and — for the one refusal that would choose a plaintext channel — it
|
|
715
|
+
// must not even be a claim: `NO_KEY`/`NO_PEER_KEY` are the refusals a *legitimate* peer seals
|
|
716
|
+
// with `k_ref` (it holds our `e_i` and its own `s_r`), so a sealed one that does not verify is
|
|
717
|
+
// not a peer whose records disagree with ours; it is a datagram somebody assembled. Dropping
|
|
718
|
+
// it costs an honest peer nothing, because an honest peer that cannot derive `k_ref` sends no
|
|
719
|
+
// MAC at all, which is the claim path below.
|
|
720
|
+
const wantsPlaintext = refusal.code === REFUSE.NO_KEY || refusal.code === REFUSE.NO_PEER_KEY;
|
|
721
|
+
if (wantsPlaintext && refusal.authenticated === false) {
|
|
722
|
+
ignoredRefusals.push(ignoredRefusal(
|
|
723
|
+
CRYPTO_REASON.REFUSE_BAD_MAC,
|
|
724
|
+
'it asked for a plaintext channel and carried a refusal MAC that did not verify; a refusal sealed '
|
|
725
|
+
+ 'with the key derived from the handshake ephemeral is honoured, and an older peer that sends no '
|
|
726
|
+
+ 'MAC at all is honoured from the address that answered the punch',
|
|
727
|
+
{ parsed, rinfo, agreed },
|
|
728
|
+
));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (wantsPlaintext) {
|
|
516
732
|
// The peer has a keypair of its own and none *for us*, and it said so instead of answering
|
|
517
733
|
// in a way we would have to guess about. That is the plaintext-only case, not a failure:
|
|
518
734
|
// §4's fallback clause is explicitly about not losing a machine to a security upgrade, and
|
|
519
735
|
// this is the peer naming the upgrade it could not take part in. The plain acknowledgement
|
|
520
736
|
// follows from the same responder, so the punch settles on that.
|
|
521
|
-
|
|
737
|
+
//
|
|
738
|
+
// This is also the one refusal that *chooses a plaintext channel* rather than failing the
|
|
739
|
+
// punch, so it is the one held to REFUSE_GUARD's fourth condition, and that condition can
|
|
740
|
+
// only be checked at outcome time: the refusal and the plain acknowledgement are two
|
|
741
|
+
// datagrams, either may arrive first, and only once both have is there something to
|
|
742
|
+
// corroborate. A refusal sealed with `k_ref` needs no corroboration — it is the peer.
|
|
743
|
+
plainOnly = {
|
|
744
|
+
reason: CRYPTO_REASON.NO_KEY,
|
|
745
|
+
source: { address: rinfo.address, port: rinfo.port },
|
|
746
|
+
authenticated: refusal.authenticated,
|
|
747
|
+
};
|
|
522
748
|
return;
|
|
523
749
|
}
|
|
524
750
|
// Any other refusal is final — `BAD_MAC` means the peer is known and its proof did not
|
|
525
751
|
// verify, which is a stale key or an impersonation attempt, and neither is a reason to fall
|
|
526
752
|
// back into the clear. Named, and never downgraded: this is the path where a channel is
|
|
527
|
-
// deliberately not handed back at all.
|
|
753
|
+
// deliberately not handed back at all. A failed MAC does not disqualify it, because it can
|
|
754
|
+
// only ever fail a punch that was already failing, and an honest peer with a stale record of
|
|
755
|
+
// *our* key is exactly the peer that sends one; `refusal_authenticated` says which it was.
|
|
528
756
|
refused = refusal.reason;
|
|
757
|
+
refusedAuthenticated = refusal.authenticated;
|
|
529
758
|
finish({
|
|
530
759
|
ok: false,
|
|
531
760
|
peer: punchedPeer,
|
|
@@ -533,10 +762,14 @@ export async function punch(options) {
|
|
|
533
762
|
attempts: punchAttempts,
|
|
534
763
|
rttMs: punchedRtt,
|
|
535
764
|
error: `${refusal.reason}: the peer refused the handshake`,
|
|
536
|
-
crypto:
|
|
765
|
+
crypto: {
|
|
766
|
+
...cryptoOutcome('refused', refusal.reason),
|
|
767
|
+
refusal_authenticated: refusal.authenticated,
|
|
768
|
+
},
|
|
537
769
|
});
|
|
538
770
|
return;
|
|
539
|
-
}
|
|
771
|
+
}
|
|
772
|
+
if (parsed.session !== agreed) return;
|
|
540
773
|
notePeer(rinfo);
|
|
541
774
|
const ack = decodeHello(parsed.body);
|
|
542
775
|
if (ack === null) {
|
|
@@ -550,8 +783,9 @@ export async function punch(options) {
|
|
|
550
783
|
// answers plainly, and settling on that answer would turn "your key did not verify" into a
|
|
551
784
|
// silent plaintext channel — the exact outcome §4 forbids. Deferring costs one interval and
|
|
552
785
|
// only happens on the punch where there was something to wait for.
|
|
553
|
-
if (!wantCrypto)
|
|
554
|
-
|
|
786
|
+
if (!wantCrypto) {
|
|
787
|
+
finishPunched(plaintextOutcome() ?? cryptoOutcome('plaintext', noCryptoReason));
|
|
788
|
+
} else deferPlain();
|
|
555
789
|
return;
|
|
556
790
|
}
|
|
557
791
|
if (handshake === null) {
|
|
@@ -581,8 +815,43 @@ export async function punch(options) {
|
|
|
581
815
|
* lose the machine. A peer we hold a key for and never hear a handshake from is a different fact —
|
|
582
816
|
* `P2P_CRYPTO_HANDSHAKE_TIMEOUT` — and it falls back to the relay, which is what the contract says
|
|
583
817
|
* a failed handshake does.
|
|
818
|
+
*
|
|
819
|
+
* It is also where REFUSE_GUARD's fourth condition is enforced, and it has to be *here* rather than
|
|
820
|
+
* at the datagram: the refusal and the plain acknowledgement are two datagrams from the same
|
|
821
|
+
* responder, either may arrive first, and only once both have is there something to corroborate.
|
|
822
|
+
* A refusal that no acknowledgement from the same endpoint ever backs is not honoured — and, like
|
|
823
|
+
* every other drop, it does not fail the punch; the punch keeps waiting and then reports the
|
|
824
|
+
* timeout it honestly has.
|
|
825
|
+
*
|
|
826
|
+
* A refusal that *is* sealed with `k_ref` skips that condition entirely: it proves the sender holds
|
|
827
|
+
* the static key we dialled, which is more than an acknowledgement from the same address could.
|
|
584
828
|
*/
|
|
585
|
-
const plaintextOutcome = () =>
|
|
829
|
+
const plaintextOutcome = () => {
|
|
830
|
+
if (plainOnly === null) return null;
|
|
831
|
+
const corroborated = plainOnly.authenticated === true || sameEndpoint(punchedPeer, plainOnly.source);
|
|
832
|
+
if (!corroborated) {
|
|
833
|
+
if (!plainOnlyDropRecorded) {
|
|
834
|
+
plainOnlyDropRecorded = true;
|
|
835
|
+
ignoredRefusals.push({
|
|
836
|
+
reason_code: CRYPTO_REASON.REFUSE_UNCORROBORATED,
|
|
837
|
+
detail:
|
|
838
|
+
`it asked for a plaintext channel from ${plainOnly.source.address}:${plainOnly.source.port}, and the `
|
|
839
|
+
+ 'peer that answered this punch is '
|
|
840
|
+
+ (punchedPeer === null ? 'nobody yet' : `${punchedPeer.address}:${punchedPeer.port}`)
|
|
841
|
+
+ '; the same refusal from the address that answered the punch is honoured',
|
|
842
|
+
session: agreed >>> 0,
|
|
843
|
+
agreed_session: agreed >>> 0,
|
|
844
|
+
from: `${plainOnly.source.address}:${plainOnly.source.port}`,
|
|
845
|
+
datagram_bytes: null,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
...cryptoOutcome('plaintext', plainOnly.reason),
|
|
852
|
+
refusal_authenticated: plainOnly.authenticated,
|
|
853
|
+
};
|
|
854
|
+
};
|
|
586
855
|
/** Keep the handshake alive after a plain answer, rather than settling into the clear. */
|
|
587
856
|
const deferPlain = () => {
|
|
588
857
|
tick();
|
|
@@ -616,6 +885,10 @@ export async function punch(options) {
|
|
|
616
885
|
// The handshake failed and said why — from the peer's own refusal frame, or from a MAC that
|
|
617
886
|
// did not verify. Reported at once and named, never downgraded to a plaintext channel: a
|
|
618
887
|
// downgrade that a mismatched key can trigger is not encryption.
|
|
888
|
+
//
|
|
889
|
+
// `refusal_authenticated` travels with it because "the peer refused and proved it holds its
|
|
890
|
+
// static key" and "somebody told us the peer refused" are different facts, and the ledger is
|
|
891
|
+
// where that difference has to survive.
|
|
619
892
|
finish({
|
|
620
893
|
ok: false,
|
|
621
894
|
peer: punchedPeer,
|
|
@@ -623,7 +896,7 @@ export async function punch(options) {
|
|
|
623
896
|
attempts: punchAttempts,
|
|
624
897
|
rttMs: punchedRtt,
|
|
625
898
|
error: `${refused}: the handshake did not complete`,
|
|
626
|
-
crypto: cryptoOutcome('refused', refused),
|
|
899
|
+
crypto: { ...cryptoOutcome('refused', refused), refusal_authenticated: refusedAuthenticated },
|
|
627
900
|
});
|
|
628
901
|
return;
|
|
629
902
|
}
|
|
@@ -1065,6 +1338,10 @@ export class P2PChannel extends EventEmitter {
|
|
|
1065
1338
|
* @param {number} [options.timeoutMs] How long to wait before giving up.
|
|
1066
1339
|
* @param {Set<number>|Map<number, unknown>} [options.claimed] Sessions that already have
|
|
1067
1340
|
* a live channel. Read, never written.
|
|
1341
|
+
* @param {boolean} [options.unrefWait] Do not let the wait itself hold the process open. Off by
|
|
1342
|
+
* default: an awaited operation is normally exactly the thing that should keep a process
|
|
1343
|
+
* alive. On for a node that was asked to be unreferenced (`P2PNode.unref()`), where this
|
|
1344
|
+
* timer would otherwise be the last referenced handle and the process could never exit.
|
|
1068
1345
|
* @param {number} [options.intervalMs] Floor between re-acknowledgements of the same
|
|
1069
1346
|
* claimed session within one call, so a peer that sprays HELLOs cannot make the
|
|
1070
1347
|
* responder answer every single one. The default matches `punch()`'s send
|
|
@@ -1074,9 +1351,12 @@ export class P2PChannel extends EventEmitter {
|
|
|
1074
1351
|
* @param {object} [options.cryptoTuning] Rekey thresholds and retention; see `crypto.mjs`.
|
|
1075
1352
|
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
|
|
1076
1353
|
* session:number, rttMs:number|null, error:string|null, machineId:string|null,
|
|
1077
|
-
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean
|
|
1354
|
+
* crypto:{status:string, reason:string|null, session:object|null, isInitiator:boolean,
|
|
1355
|
+
* refusal_authenticated?:boolean}}>}
|
|
1078
1356
|
* `machineId` is the handshake's *verified* claim about who dialled, or null for a plain punch
|
|
1079
1357
|
* (which carries no identity at all — see `P2PNode`'s note on why it refuses to guess one).
|
|
1358
|
+
* `crypto.refusal_authenticated` is present only on a refused outcome, and is the *sender's* view of
|
|
1359
|
+
* {@link REFUSE_GUARD}'s third condition: whether the refusal this end sent carried a `k_ref` seal.
|
|
1080
1360
|
*/
|
|
1081
1361
|
export async function accept(options) {
|
|
1082
1362
|
const { socket } = options;
|
|
@@ -1086,6 +1366,7 @@ export async function accept(options) {
|
|
|
1086
1366
|
const intervalMs = options.intervalMs ?? 250;
|
|
1087
1367
|
const claimed = options.claimed ?? null;
|
|
1088
1368
|
const crypto = options.crypto ?? null;
|
|
1369
|
+
const unrefWait = options.unrefWait === true;
|
|
1089
1370
|
|
|
1090
1371
|
return new Promise((resolve) => {
|
|
1091
1372
|
let settled = false;
|
|
@@ -1114,6 +1395,11 @@ export async function accept(options) {
|
|
|
1114
1395
|
});
|
|
1115
1396
|
}, timeoutMs);
|
|
1116
1397
|
|
|
1398
|
+
// See `options.unrefWait`: a node that was asked not to hold the process open must not hold it
|
|
1399
|
+
// open through the accept loop's own timeout, which is the handle that is pending almost all of
|
|
1400
|
+
// the time in a node that is simply waiting for a punch.
|
|
1401
|
+
if (unrefWait) timer.unref?.();
|
|
1402
|
+
|
|
1117
1403
|
// A wait on a socket that goes away must end now, not at the end of the timeout:
|
|
1118
1404
|
// this is what makes a node's `close()` a barrier instead of a five-second stall.
|
|
1119
1405
|
const onSocketClose = () => {
|
|
@@ -1203,6 +1489,18 @@ export async function accept(options) {
|
|
|
1203
1489
|
let machineId = null;
|
|
1204
1490
|
/** Set when the handshake cannot be verified, so the peer is told why in a named refusal. */
|
|
1205
1491
|
let refusal = null;
|
|
1492
|
+
/**
|
|
1493
|
+
* The exact refusal bytes the crypto layer produced, when it produced any.
|
|
1494
|
+
*
|
|
1495
|
+
* They are forwarded rather than rebuilt, because rebuilding them here silently threw away the
|
|
1496
|
+
* responder's machine id and — since v0.6.0 — the `k0` MAC that makes `BAD_MAC` a statement only
|
|
1497
|
+
* the holder of `s_r` can make. The initiator treats a MAC'd refusal as proof and a bare one as a
|
|
1498
|
+
* claim (`REFUSE_GUARD`), so losing the MAC here would quietly downgrade every terminal refusal
|
|
1499
|
+
* back to "somebody said so".
|
|
1500
|
+
*/
|
|
1501
|
+
let refusalBytes = null;
|
|
1502
|
+
/** Whether the refusal this end sent carried a `k_ref` seal. False when it has no key material. */
|
|
1503
|
+
let refusalSealed = false;
|
|
1206
1504
|
|
|
1207
1505
|
if (handshakeMessage !== null) {
|
|
1208
1506
|
if (crypto === null) {
|
|
@@ -1211,6 +1509,9 @@ export async function accept(options) {
|
|
|
1211
1509
|
// the fleet-losing outcome §4 forbids — but the peer is told why, in a named refusal frame
|
|
1212
1510
|
// that travels alongside the plain acknowledgement.
|
|
1213
1511
|
//
|
|
1512
|
+
// A machine with no key material cannot MAC anything, so this refusal is unauthenticated by
|
|
1513
|
+
// construction, like `NO_PEER_KEY` below and for the same reason.
|
|
1514
|
+
//
|
|
1214
1515
|
// This is what keeps the two ends from disagreeing about whether the session is encrypted: an
|
|
1215
1516
|
// initiator that asked for a handshake and received only a plain acknowledgement would
|
|
1216
1517
|
// otherwise have to guess, and guessing wrong means every frame fails its tag. A v0.5.x peer
|
|
@@ -1225,7 +1526,8 @@ export async function accept(options) {
|
|
|
1225
1526
|
ackBody = answered.body;
|
|
1226
1527
|
machineId = answered.machineId;
|
|
1227
1528
|
} else if (answered.refuse !== null) {
|
|
1228
|
-
const
|
|
1529
|
+
const decoded = decodeRefuse(answered.refuse);
|
|
1530
|
+
const code = decoded?.code ?? REFUSE.UNKNOWN_PEER;
|
|
1229
1531
|
// `NO_PEER_KEY` is the recoverable one, and the distinction is what makes §4's fallback
|
|
1230
1532
|
// clause implementable without a silent downgrade. "I have a keypair and none for you"
|
|
1231
1533
|
// means the handshake is impossible for a stated reason, and the peer may use the plaintext
|
|
@@ -1240,17 +1542,17 @@ export async function accept(options) {
|
|
|
1240
1542
|
};
|
|
1241
1543
|
if (refusal.terminal) outcome = cryptoOutcome('refused', answered.reason);
|
|
1242
1544
|
else outcome = cryptoOutcome('plaintext', CRYPTO_REASON.NO_KEY);
|
|
1545
|
+
// `decoded === null` means these bytes are not a refusal at all: the REPLAY path re-sends a
|
|
1546
|
+
// cached *answer*, and for that case the named refusal below is built here as it always was.
|
|
1547
|
+
if (decoded !== null) refusalBytes = answered.refuse;
|
|
1243
1548
|
}
|
|
1244
1549
|
}
|
|
1245
1550
|
}
|
|
1246
1551
|
|
|
1247
1552
|
if (refusal !== null) {
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
rinfo.address,
|
|
1252
|
-
() => {},
|
|
1253
|
-
);
|
|
1553
|
+
const bytes = refusalBytes ?? encodeRefuse(session, refusal.code, null);
|
|
1554
|
+
refusalSealed = decodeRefuse(bytes)?.mac !== null;
|
|
1555
|
+
socket.send(frame(KIND.HELLO_ACK, session, bytes), rinfo.port, rinfo.address, () => {});
|
|
1254
1556
|
}
|
|
1255
1557
|
if (refusal !== null && refusal.terminal) {
|
|
1256
1558
|
// The punch is *not* acknowledged: a handshake that failed is not a path this build will use in
|
|
@@ -1263,7 +1565,10 @@ export async function accept(options) {
|
|
|
1263
1565
|
rttMs: null,
|
|
1264
1566
|
error: `${refusal.reason}: the handshake was refused`,
|
|
1265
1567
|
machineId: null,
|
|
1266
|
-
|
|
1568
|
+
// `refusal_authenticated` here is the responder's view of the same field: whether the refusal
|
|
1569
|
+
// it just sent carried a seal, so "I told it why, with proof" and "I told it why, bare" are
|
|
1570
|
+
// distinguishable without reading the wire.
|
|
1571
|
+
crypto: { ...cryptoOutcome('refused', refusal.reason), refusal_authenticated: refusalSealed },
|
|
1267
1572
|
});
|
|
1268
1573
|
return;
|
|
1269
1574
|
}
|
package/src/agent/resolve.mjs
CHANGED
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* unchanged, so the caller still gets the real ENOENT instead of a fake path.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { accessSync, constants, statSync } from 'node:fs';
|
|
19
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
|
-
import { delimiter, join } from 'node:path';
|
|
21
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
22
22
|
|
|
23
23
|
/** The DSH runtime directory layout we fall back to (task-8, measured). */
|
|
24
24
|
export const DSH_RUNTIME_RELATIVE = join('dsh-runtimes', 'dsh-primary-runtime', 'dependencies');
|
|
@@ -141,8 +141,73 @@ export function bundledBinDirs(env = process.env) {
|
|
|
141
141
|
export function resolveCommand(exe, options = {}) {
|
|
142
142
|
if (typeof exe !== 'string' || exe === '') return exe;
|
|
143
143
|
// Anything with a separator is a path the caller chose, not a name to look up.
|
|
144
|
-
if (
|
|
144
|
+
if (hasPathSeparator(exe)) return exe;
|
|
145
145
|
const env = options.env ?? process.env;
|
|
146
146
|
const extraDirs = options.extraDirs ?? bundledBinDirs(env);
|
|
147
147
|
return findExecutable(exe, { env, extraDirs }) ?? exe;
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Does `value` name a path, rather than a bare command name to look up?
|
|
152
|
+
*
|
|
153
|
+
* Separators are platform-specific, and deliberately so. On Windows both `/` and `\` separate, so
|
|
154
|
+
* `C:\x\node.exe` is a path; on POSIX only `/` does, and the same string is an ordinary *name* that
|
|
155
|
+
* `spawn` would fail to find -- treating it as a path there would let a matcher compare two strings
|
|
156
|
+
* that name no file at all and report an identity it never established.
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} value
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function hasPathSeparator(value) {
|
|
162
|
+
if (typeof value !== 'string') return false;
|
|
163
|
+
return process.platform === 'win32'
|
|
164
|
+
? value.includes('/') || value.includes('\\')
|
|
165
|
+
: value.includes('/');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Is `value` a *relative* path (`./node.exe`, `bin\node.exe`)?
|
|
170
|
+
*
|
|
171
|
+
* A relative executable is one whose meaning is chosen by whoever picks the working directory, which
|
|
172
|
+
* is exactly the party an allow-list does not control -- so the matcher refuses these outright rather
|
|
173
|
+
* than comparing them against anything. `C:node.exe` on Windows (drive-relative) is relative by this
|
|
174
|
+
* definition too, which is why the test is `!isAbsolute` and not "does not start with a separator".
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} value
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
export function isRelativePath(value) {
|
|
180
|
+
return hasPathSeparator(value) && !isAbsolute(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `realpath` that answers `null` instead of throwing for a path that names no file. */
|
|
184
|
+
function realPathOf(value) {
|
|
185
|
+
const realpath = realpathSync.native ?? realpathSync;
|
|
186
|
+
try {
|
|
187
|
+
return realpath(value);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Do two paths name the **same file** on this machine?
|
|
195
|
+
*
|
|
196
|
+
* `realpath` rather than a string comparison, because the point is the file that would be executed:
|
|
197
|
+
* it resolves symlinks and junctions, Windows 8.3 short names, and the on-disk letter case. Two
|
|
198
|
+
* different copies of one program are two different files and answer `false`, which is what makes
|
|
199
|
+
* this usable as an identity check rather than a spelling check.
|
|
200
|
+
*
|
|
201
|
+
* @param {unknown} a
|
|
202
|
+
* @param {unknown} b
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
export function sameFile(a, b) {
|
|
206
|
+
if (typeof a !== 'string' || typeof b !== 'string' || a === '' || b === '') return false;
|
|
207
|
+
const realA = realPathOf(a);
|
|
208
|
+
const realB = realPathOf(b);
|
|
209
|
+
if (realA === null || realB === null) return false;
|
|
210
|
+
return process.platform === 'win32'
|
|
211
|
+
? realA.toLowerCase() === realB.toLowerCase()
|
|
212
|
+
: realA === realB;
|
|
213
|
+
}
|