@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1
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 +288 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- 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 +152 -5
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- 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/server.mjs +74 -11
package/src/agent/crypto.mjs
CHANGED
|
@@ -119,6 +119,15 @@ export const SESSION_KEY_LABEL = 'w2m-p2p-crypto v1 session';
|
|
|
119
119
|
/** `k0 = HKDF(ECDH(s_i,s_r))` of §4: proves possession of the static key. */
|
|
120
120
|
export const STATIC_KEY_LABEL = 'w2m-p2p-crypto v1 static';
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Domain separator of a `REFUSE`'s optional MAC — a *different* label from every handshake MAC, and a
|
|
124
|
+
* different *input*: `ECDH(e_i, s_r)` rather than `ECDH(s_i, s_r)` (see the note above `REFUSE`).
|
|
125
|
+
*
|
|
126
|
+
* Without the label, a refusal MAC and `mac'` of §4 would both be `sealMac(·, …)` over 32-byte hashes
|
|
127
|
+
* a hostile peer could try to make collide. One extra hash input makes the two families disjoint.
|
|
128
|
+
*/
|
|
129
|
+
export const REFUSE_MAC_LABEL = 'w2m-p2p-crypto v1 refuse';
|
|
130
|
+
|
|
122
131
|
/** The literal string §4's `hash(prologue ——` starts with. */
|
|
123
132
|
export const PROLOGUE = 'w2m-p2p-crypto v1 prologue';
|
|
124
133
|
|
|
@@ -200,6 +209,29 @@ export const CRYPTO_REASON = Object.freeze({
|
|
|
200
209
|
MALFORMED: 'P2P_CRYPTO_MALFORMED',
|
|
201
210
|
/** The peer never learned a key (an older build), so a direct channel with it is plaintext. */
|
|
202
211
|
PLAINTEXT_PEER: 'P2P_CRYPTO_PLAINTEXT_PEER',
|
|
212
|
+
/**
|
|
213
|
+
* A `REFUSE` datagram was **dropped**, not honoured: it named a session this punch did not agree.
|
|
214
|
+
*
|
|
215
|
+
* The four `REFUSE_*` codes below name an *ignored datagram* rather than a failed punch, which is
|
|
216
|
+
* deliberate and is the whole of the v0.6.0 hardening: an unauthenticated refusal that cannot be
|
|
217
|
+
* tied to this punch must not be able to change its outcome, and it must not be able to fail it
|
|
218
|
+
* either — an attacker who could turn "the peer never answered" into "the peer refused" would have
|
|
219
|
+
* been handed a denial of service in exchange for closing a downgrade.
|
|
220
|
+
*/
|
|
221
|
+
REFUSE_SESSION_MISMATCH: 'P2P_CRYPTO_REFUSE_SESSION_MISMATCH',
|
|
222
|
+
/** A `REFUSE` datagram was dropped: it came from an address this punch never dialled. */
|
|
223
|
+
REFUSE_UNPUNCHED_SOURCE: 'P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE',
|
|
224
|
+
/** A `REFUSE` datagram was dropped: it carried a `k0` MAC that did not verify. */
|
|
225
|
+
REFUSE_BAD_MAC: 'P2P_CRYPTO_REFUSE_BAD_MAC',
|
|
226
|
+
/**
|
|
227
|
+
* A downgrade-inducing `REFUSE` was dropped: it did not come from the peer that answered the punch.
|
|
228
|
+
*
|
|
229
|
+
* `NO_KEY`/`NO_PEER_KEY` say "I cannot take part, use the plaintext channel" — the one refusal that
|
|
230
|
+
* changes the outcome into something weaker than a failure. Requiring it to come from the address
|
|
231
|
+
* that actually answered the punch is what stops a datagram from a third address from choosing a
|
|
232
|
+
* plaintext channel on the peer's behalf.
|
|
233
|
+
*/
|
|
234
|
+
REFUSE_UNCORROBORATED: 'P2P_CRYPTO_REFUSE_UNCORROBORATED',
|
|
203
235
|
});
|
|
204
236
|
|
|
205
237
|
/* -------------------------------------------------------------------------- */
|
|
@@ -788,12 +820,35 @@ export function bytesEqual(a, b) {
|
|
|
788
820
|
*
|
|
789
821
|
* HELLO magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
790
822
|
* HELLO_ACK magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
791
|
-
* REFUSE magic|version|session u32|code u8|idLen u8|machineId
|
|
823
|
+
* REFUSE magic|version|session u32|code u8|idLen u8|machineId[|mac 48]
|
|
824
|
+
*
|
|
825
|
+
* `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC).
|
|
792
826
|
*
|
|
793
|
-
* `
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
827
|
+
* `REFUSE` **may** be unauthenticated, and that is not an oversight: it is sent precisely when we
|
|
828
|
+
* cannot authenticate the peer (we hold no key for it), so it can carry no proof — and it is what
|
|
829
|
+
* turns "the handshake did not complete" into a named reason on *both* ends instead of a timeout on
|
|
830
|
+
* one of them. When the responder received a handshake `HELLO` it holds the initiator's ephemeral
|
|
831
|
+
* `e_i`, and it *always* holds its own static private key `s_r`, so it can always derive
|
|
832
|
+
* `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` — a key the initiator derives just as easily from
|
|
833
|
+
* `e_i`'s private half and `s_r`'s public key, and that nobody else can derive at all. The trailing 48
|
|
834
|
+
* bytes of a refusal are then `sealMac(k_ref, hash(REFUSE_MAC_LABEL ‖ the refusal without its MAC))`.
|
|
835
|
+
*
|
|
836
|
+
* That is the asymmetry this file relies on, and it is worth stating plainly because the naive
|
|
837
|
+
* version of it is wrong: `k0 = ECDH(s_i, s_r)` — the handshake MAC's key — is *not* usable here. A
|
|
838
|
+
* responder that has no key for us (or a stale one) cannot compute it, and those are exactly the two
|
|
839
|
+
* cases that send a refusal. `k_ref` needs neither end to hold the other's static key: the initiator
|
|
840
|
+
* sends `e_i` in the clear, and the responder's `s_r` is public. What it proves is what a refusal
|
|
841
|
+
* needs to prove — *this datagram came from the machine that holds `s_r`* — and it is therefore
|
|
842
|
+
* available to `BAD_MAC` (our proof did not verify), `NO_PEER_KEY` (we hold no key for you) and
|
|
843
|
+
* `UNKNOWN_PEER` alike.
|
|
844
|
+
*
|
|
845
|
+
* `VERSION` is the one refusal that stays bare, and deliberately: the peer that needs it speaks a
|
|
846
|
+
* handshake version whose body layout this build cannot rely on, so a trailing field it does not know
|
|
847
|
+
* about would be worse than useless.
|
|
848
|
+
*
|
|
849
|
+
* The unauthenticated form still exists and is still honoured by `p2p.mjs` — a peer running an older
|
|
850
|
+
* build cannot produce `k_ref` and must not lose its plaintext channel for it. What `p2p.mjs` will not
|
|
851
|
+
* do is let a datagram it cannot tie to the punch (see `REFUSE_GUARD` there) rewrite a ledger.
|
|
797
852
|
*/
|
|
798
853
|
|
|
799
854
|
/**
|
|
@@ -808,6 +863,14 @@ export function bytesEqual(a, b) {
|
|
|
808
863
|
* it is not a downgrade: the peer never offered a key to downgrade from.
|
|
809
864
|
* * `BAD_MAC` — the peer *is* known and its MAC over its static key did not verify. That is either a
|
|
810
865
|
* stale key or an impersonation attempt, and the initiator treats it as terminal.
|
|
866
|
+
*
|
|
867
|
+
* The two known-peer codes (`BAD_MAC`, `REPLAY`) can be sealed with the ephemeral-static key of
|
|
868
|
+
* {@link refusalMacKey}, and so can the two unknown-peer codes — `k_ref` needs no static key on file.
|
|
869
|
+
* `VERSION` is the exception and stays bare. What `p2p.mjs` does with that asymmetry (`REFUSE_GUARD`
|
|
870
|
+
* there) is the rule: a *plaintext-choosing* refusal (`NO_KEY`, `NO_PEER_KEY`) must either verify its
|
|
871
|
+
* seal or arrive, unsealed, from the endpoint that acknowledged the punch; a terminal refusal is
|
|
872
|
+
* honoured from a dialled address sealed or not, with `refusal_authenticated` recording whether the
|
|
873
|
+
* seal was proof. Nothing dropped ever fails a punch.
|
|
811
874
|
*/
|
|
812
875
|
export const REFUSE = Object.freeze({
|
|
813
876
|
UNKNOWN_PEER: 1,
|
|
@@ -894,32 +957,136 @@ export function decodeHello(body) {
|
|
|
894
957
|
};
|
|
895
958
|
}
|
|
896
959
|
|
|
897
|
-
/**
|
|
898
|
-
|
|
960
|
+
/**
|
|
961
|
+
* Build a `REFUSE` body, optionally with the trailing `k0` MAC of {@link sealRefuse}.
|
|
962
|
+
*
|
|
963
|
+
* @param {number} session
|
|
964
|
+
* @param {number} code
|
|
965
|
+
* @param {string|null} [machineId]
|
|
966
|
+
* @param {Buffer|null} [mac] 48 bytes, or null for the unauthenticated form.
|
|
967
|
+
* @returns {Buffer}
|
|
968
|
+
*/
|
|
969
|
+
export function encodeRefuse(session, code, machineId = null, mac = null) {
|
|
899
970
|
const id = (machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8')).subarray(
|
|
900
971
|
0,
|
|
901
972
|
MAX_HANDSHAKE_MACHINE_ID,
|
|
902
973
|
);
|
|
903
|
-
const
|
|
974
|
+
const trailing = mac === null ? Buffer.alloc(0) : mac;
|
|
975
|
+
if (trailing.length !== 0 && trailing.length !== MAC_BYTES) {
|
|
976
|
+
throw new TypeError(`encodeRefuse: a refusal MAC must be ${MAC_BYTES} bytes`);
|
|
977
|
+
}
|
|
978
|
+
const out = Buffer.alloc(8 + id.length + trailing.length);
|
|
904
979
|
out.writeUInt8(FRAME_MAGIC, 0);
|
|
905
980
|
out.writeUInt8(CRYPTO_VERSION, 1);
|
|
906
981
|
out.writeUInt32BE(session >>> 0, 2);
|
|
907
982
|
out.writeUInt8(code, 6);
|
|
908
983
|
out.writeUInt8(id.length, 7);
|
|
909
984
|
id.copy(out, 8);
|
|
985
|
+
trailing.copy(out, 8 + id.length);
|
|
910
986
|
return out;
|
|
911
987
|
}
|
|
912
988
|
|
|
913
|
-
/**
|
|
989
|
+
/**
|
|
990
|
+
* Parse a `REFUSE` body — both the bare form and the `k0`-MAC'd one — or null.
|
|
991
|
+
*
|
|
992
|
+
* Strict about the length, in both forms, and that strictness is load-bearing rather than tidy: the
|
|
993
|
+
* body being parsed here arrives as the body of a **`HELLO_ACK`**, and a handshake body is also
|
|
994
|
+
* `magic|version|session|…` with a byte that can look like an id length. An earlier version accepted
|
|
995
|
+
* exactly one length and validated nothing else, so a genuine `HELLO_ACK` could be read as a refusal
|
|
996
|
+
* — `body[6]` (the first byte of the responder's ephemeral, which is random) became the refusal code
|
|
997
|
+
* and `body[7]` the id length — roughly one handshake in 256 refusing itself. The machine-id check
|
|
998
|
+
* below is what makes that collision impossible rather than unlikely: a handshake body's bytes from
|
|
999
|
+
* offset 8 on are key material, and key material is not a printable machine id.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {Buffer} body
|
|
1002
|
+
* @returns {{session:number, code:number, machineId:string|null, mac:Buffer|null, headerBytes:number,
|
|
1003
|
+
* bytes:Buffer}|null}
|
|
1004
|
+
*/
|
|
914
1005
|
export function decodeRefuse(body) {
|
|
915
1006
|
if (!Buffer.isBuffer(body) || body.length < 8) return null;
|
|
916
1007
|
if (body.readUInt8(0) !== FRAME_MAGIC) return null;
|
|
1008
|
+
if (body.readUInt8(1) !== CRYPTO_VERSION) return null;
|
|
917
1009
|
const session = body.readUInt32BE(2);
|
|
918
1010
|
const code = body.readUInt8(6);
|
|
919
1011
|
const idLength = body.readUInt8(7);
|
|
920
|
-
if (
|
|
921
|
-
const
|
|
922
|
-
|
|
1012
|
+
if (idLength > MAX_HANDSHAKE_MACHINE_ID) return null;
|
|
1013
|
+
const headerBytes = 8 + idLength;
|
|
1014
|
+
if (body.length !== headerBytes && body.length !== headerBytes + MAC_BYTES) return null;
|
|
1015
|
+
let machineId = null;
|
|
1016
|
+
if (idLength > 0) {
|
|
1017
|
+
machineId = body.subarray(8, headerBytes).toString('utf8');
|
|
1018
|
+
// Same rule, and the same reason, as `decodeHello`: no build produces a control byte in a machine
|
|
1019
|
+
// id, and one that arrives would put a delimiter into a log line.
|
|
1020
|
+
// eslint-disable-next-line no-control-regex -- rejecting control bytes is the point
|
|
1021
|
+
if (/[\u0000-\u001f\u007f]/.test(machineId)) return null;
|
|
1022
|
+
}
|
|
1023
|
+
const mac = body.length === headerBytes + MAC_BYTES
|
|
1024
|
+
? Buffer.from(body.subarray(headerBytes, headerBytes + MAC_BYTES))
|
|
1025
|
+
: null;
|
|
1026
|
+
return { session, code, machineId, mac, headerBytes, bytes: Buffer.from(body) };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The transcript a refusal MAC is taken over: the label, then the refusal exactly as it goes on the
|
|
1031
|
+
* wire *without* its MAC. Recomputed by the receiver from the bytes it holds, never sent separately.
|
|
1032
|
+
*
|
|
1033
|
+
* @param {Buffer} body The refusal body without the trailing MAC.
|
|
1034
|
+
* @returns {Buffer} 32 bytes.
|
|
1035
|
+
*/
|
|
1036
|
+
export function refuseTranscript(body) {
|
|
1037
|
+
return handshakeHash(REFUSE_MAC_LABEL, body);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* A refusal with proof attached, for every responder that received a parseable `HELLO`.
|
|
1042
|
+
*
|
|
1043
|
+
* @param {number} session
|
|
1044
|
+
* @param {number} code
|
|
1045
|
+
* @param {string|null} machineId
|
|
1046
|
+
* @param {Buffer} macKey `k_ref`, from {@link refusalMacKey}.
|
|
1047
|
+
* @returns {Buffer}
|
|
1048
|
+
*/
|
|
1049
|
+
export function sealRefuse(session, code, machineId, macKey) {
|
|
1050
|
+
const body = encodeRefuse(session, code, machineId);
|
|
1051
|
+
return encodeRefuse(session, code, machineId, sealMac(macKey, refuseTranscript(body)));
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* The MAC key a refusal is sealed with: `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)`.
|
|
1056
|
+
*
|
|
1057
|
+
* Both ends derive it from material the *other* end published, and neither needs the other's static
|
|
1058
|
+
* public key to be on file: the initiator passes its own ephemeral private key and the responder's
|
|
1059
|
+
* static public key, the responder passes its own static private key and the initiator's ephemeral
|
|
1060
|
+
* public key from the `HELLO`. An attacker who has neither private key cannot derive it, which is
|
|
1061
|
+
* what makes a sealed refusal evidence rather than a claim.
|
|
1062
|
+
*
|
|
1063
|
+
* @param {object} input
|
|
1064
|
+
* @param {import('node:crypto').KeyObject} input.privateKey `e_i`'s private half (initiator) or `s_r`
|
|
1065
|
+
* (responder).
|
|
1066
|
+
* @param {Buffer} input.peerPublicRaw 32 raw bytes of `s_r` (initiator) or `e_i` (responder).
|
|
1067
|
+
* @returns {Buffer} 32 bytes.
|
|
1068
|
+
*/
|
|
1069
|
+
export function refusalMacKey({ privateKey, peerPublicRaw }) {
|
|
1070
|
+
if (!Buffer.isBuffer(peerPublicRaw) || peerPublicRaw.length !== KEY_BYTES) {
|
|
1071
|
+
throw new TypeError(`refusalMacKey: the peer public key must be ${KEY_BYTES} raw bytes`);
|
|
1072
|
+
}
|
|
1073
|
+
return derive(diffieHellman({ privateKey, publicKey: publicKeyFromRaw(peerPublicRaw) }), REFUSE_MAC_LABEL);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Verify the optional MAC on a received refusal.
|
|
1078
|
+
*
|
|
1079
|
+
* @param {Buffer|null} macKey `k_ref`, or null when this end has nothing to check with.
|
|
1080
|
+
* @param {Buffer} body The whole refusal body as received.
|
|
1081
|
+
* @returns {boolean|null} `null` when the body carries no MAC (or is not a refusal at all), otherwise
|
|
1082
|
+
* whether it verifies. `false` is never "unknown" — it is a MAC that was present and wrong.
|
|
1083
|
+
*/
|
|
1084
|
+
export function verifyRefuseMac(macKey, body) {
|
|
1085
|
+
const decoded = decodeRefuse(body);
|
|
1086
|
+
if (decoded === null || decoded.mac === null) return null;
|
|
1087
|
+
if (macKey === null) return false;
|
|
1088
|
+
const expected = sealMac(macKey, refuseTranscript(body.subarray(0, decoded.headerBytes)));
|
|
1089
|
+
return bytesEqual(expected, decoded.mac);
|
|
923
1090
|
}
|
|
924
1091
|
|
|
925
1092
|
/* -------------------------------------------------------------------------- */
|
|
@@ -1341,7 +1508,9 @@ export function ackTranscript(helloBytes, responderEphemeralRaw) {
|
|
|
1341
1508
|
* @param {string} options.peerMachineId The machine being dialled.
|
|
1342
1509
|
* @param {string} options.peerPublicKey Its static public key, from the offer or from the relay.
|
|
1343
1510
|
* @param {number} options.session The punch session.
|
|
1344
|
-
* @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
|
|
1511
|
+
* @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
|
|
1512
|
+
* refusalMacKey: Buffer, finish: Function}} `refusalMacKey` is `k_ref` of {@link refusalMacKey}: what
|
|
1513
|
+
* this end checks a `REFUSE` against, and the reason a refusal can be evidence rather than a claim.
|
|
1345
1514
|
*/
|
|
1346
1515
|
export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, session }) {
|
|
1347
1516
|
const peerRaw = decodeKey(peerPublicKey);
|
|
@@ -1364,6 +1533,10 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
|
|
|
1364
1533
|
ephemeral: encodeKey(ephemeralPublic),
|
|
1365
1534
|
session,
|
|
1366
1535
|
peerMachineId,
|
|
1536
|
+
// Derived once, here, from the ephemeral this handshake is about to send: `ECDH(e_i, s_r)`. The
|
|
1537
|
+
// responder derives the same value from that very `e_i` and its own static key, which is what lets
|
|
1538
|
+
// it answer a refusal with proof even when it holds no key for us at all.
|
|
1539
|
+
refusalMacKey: refusalMacKey({ privateKey: ephemeral.privateKey, peerPublicRaw: peerRaw }),
|
|
1367
1540
|
/**
|
|
1368
1541
|
* Verify `HELLO_ACK` and hand back the transport session.
|
|
1369
1542
|
*
|
|
@@ -1433,7 +1606,42 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
|
|
|
1433
1606
|
export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
1434
1607
|
const hello = decodeHello(body);
|
|
1435
1608
|
if (hello === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED, refuse: null };
|
|
1609
|
+
|
|
1610
|
+
/** This peer's static public key as filed under its claimed machine id, or null. */
|
|
1611
|
+
const keyFor = (machineId) => {
|
|
1612
|
+
if (machineId === null) return null;
|
|
1613
|
+
return peerKeys instanceof PeerKeyStore
|
|
1614
|
+
? peerKeys.get(machineId)
|
|
1615
|
+
: (peerKeys.get(machineId) ?? null);
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* `k_ref` for this exchange, from the initiator's `e_i` and our own static key.
|
|
1620
|
+
*
|
|
1621
|
+
* Present whenever the `HELLO` parsed at all, which is every refusal below except `MALFORMED` (which
|
|
1622
|
+
* sends nothing). It is deliberately *not* `k0`: a responder with no key for the initiator — the
|
|
1623
|
+
* sender of `NO_PEER_KEY` — cannot compute `k0`, and a responder with a stale key for it (the sender
|
|
1624
|
+
* of `BAD_MAC`) would produce a `k0` the initiator cannot reproduce either. `k_ref` needs neither.
|
|
1625
|
+
*/
|
|
1626
|
+
const initiatorEphemeralRaw = decodeKey(hello.ephemeral);
|
|
1627
|
+
const refusalKey = initiatorEphemeralRaw === null
|
|
1628
|
+
? null
|
|
1629
|
+
: refusalMacKey({ privateKey: deviceKey.privateKey, peerPublicRaw: initiatorEphemeralRaw });
|
|
1630
|
+
/**
|
|
1631
|
+
* A refusal body: sealed when we can prove it, bare when we cannot.
|
|
1632
|
+
*
|
|
1633
|
+
* @param {number} code
|
|
1634
|
+
* @returns {Buffer}
|
|
1635
|
+
*/
|
|
1636
|
+
const refuse = (code) => (refusalKey === null
|
|
1637
|
+
? encodeRefuse(hello.session, code, deviceKey.machineId)
|
|
1638
|
+
: sealRefuse(hello.session, code, deviceKey.machineId, refusalKey));
|
|
1639
|
+
|
|
1436
1640
|
if (hello.version !== CRYPTO_VERSION) {
|
|
1641
|
+
// The version is decided before any key is looked at — a peer speaking a different handshake
|
|
1642
|
+
// version must be told *that*, not "unknown peer" — and this refusal stays bare on purpose: the
|
|
1643
|
+
// peer receiving it speaks a body layout this build cannot vouch for, so a trailing field it does
|
|
1644
|
+
// not know about would be worse than useless.
|
|
1437
1645
|
return {
|
|
1438
1646
|
ok: false,
|
|
1439
1647
|
reason: CRYPTO_REASON.VERSION_MISMATCH,
|
|
@@ -1441,19 +1649,19 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1441
1649
|
};
|
|
1442
1650
|
}
|
|
1443
1651
|
|
|
1444
|
-
const initiatorKey =
|
|
1445
|
-
hello.machineId === null
|
|
1446
|
-
? null
|
|
1447
|
-
: peerKeys instanceof PeerKeyStore
|
|
1448
|
-
? peerKeys.get(hello.machineId)
|
|
1449
|
-
: (peerKeys.get(hello.machineId) ?? null);
|
|
1652
|
+
const initiatorKey = keyFor(hello.machineId);
|
|
1450
1653
|
|
|
1451
1654
|
if (initiatorKey === null) {
|
|
1452
|
-
// Nothing this peer says can be verified, and we will not guess. The refusal is named and
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1655
|
+
// Nothing this peer says can be verified, and we will not guess. The refusal is named — and it is
|
|
1656
|
+
// *sealed*, because `k_ref` is exactly the proof this case can carry: it says "this datagram came
|
|
1657
|
+
// from the machine that holds the static key you dialled", which is precisely what a peer with no
|
|
1658
|
+
// key on file for us can still prove. `p2p.mjs` turns that into the rule that a refusal which
|
|
1659
|
+
// would choose a plaintext channel needs either this proof or the corroboration of the same
|
|
1660
|
+
// endpoint having acknowledged the punch.
|
|
1661
|
+
//
|
|
1662
|
+
// It says `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to
|
|
1663
|
+
// know: a handshake is impossible here, so it may use the plaintext channel it would have had
|
|
1664
|
+
// before this work rather than waiting out a timeout.
|
|
1457
1665
|
//
|
|
1458
1666
|
// This branch has a second, ordinary caller: a handshake from *our own* machine id, which is what
|
|
1459
1667
|
// a task the relay leased back to the dispatching machine produces. We hold no peer key for
|
|
@@ -1461,19 +1669,11 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1461
1669
|
// self-dial has always used. That is the right answer rather than an oversight: a self-connection
|
|
1462
1670
|
// has no path to protect and must not fail, or a machine would lose the task it dispatched to
|
|
1463
1671
|
// itself.
|
|
1464
|
-
return {
|
|
1465
|
-
ok: false,
|
|
1466
|
-
reason: CRYPTO_REASON.UNKNOWN_PEER,
|
|
1467
|
-
refuse: encodeRefuse(hello.session, REFUSE.NO_PEER_KEY, deviceKey.machineId),
|
|
1468
|
-
};
|
|
1672
|
+
return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.NO_PEER_KEY) };
|
|
1469
1673
|
}
|
|
1470
1674
|
const initiatorRaw = decodeKey(initiatorKey);
|
|
1471
1675
|
if (initiatorRaw === null) {
|
|
1472
|
-
return {
|
|
1473
|
-
ok: false,
|
|
1474
|
-
reason: CRYPTO_REASON.UNKNOWN_PEER,
|
|
1475
|
-
refuse: encodeRefuse(hello.session, REFUSE.UNKNOWN_PEER, deviceKey.machineId),
|
|
1476
|
-
};
|
|
1676
|
+
return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.UNKNOWN_PEER) };
|
|
1477
1677
|
}
|
|
1478
1678
|
|
|
1479
1679
|
const k0 = derive(deviceKey.sharedSecret(initiatorRaw), STATIC_KEY_LABEL);
|
|
@@ -1484,12 +1684,10 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1484
1684
|
if (!bytesEqual(hello.mac, expectedMac)) {
|
|
1485
1685
|
// The peer is known, and its mac is wrong: it does not hold the private key we have on file for
|
|
1486
1686
|
// it. A distinct code from UNKNOWN_PEER because the responses differ —one means "refresh that
|
|
1487
|
-
// machine's key", the other means "somebody is guessing at names".
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
refuse: encodeRefuse(hello.session, REFUSE.BAD_MAC, deviceKey.machineId),
|
|
1492
|
-
};
|
|
1687
|
+
// machine's key", the other means "somebody is guessing at names". Sealed with `k_ref` and not
|
|
1688
|
+
// `k0`, because when our record of *its* key is the stale one, `k0` is a value neither end can
|
|
1689
|
+
// agree on — and this is the refusal whose whole content is "our records disagree".
|
|
1690
|
+
return { ok: false, reason: CRYPTO_REASON.BAD_MAC, refuse: refuse(REFUSE.BAD_MAC) };
|
|
1493
1691
|
}
|
|
1494
1692
|
|
|
1495
1693
|
const nonceKey = `${hello.machineId}#${hello.session}#${hello.ephemeral}`;
|
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -590,6 +590,12 @@ export class P2PNode extends EventEmitter {
|
|
|
590
590
|
this.announcing = null;
|
|
591
591
|
/** @type {NodeJS.Timeout|null} */
|
|
592
592
|
this.refreshTimer = null;
|
|
593
|
+
/**
|
|
594
|
+
* Asked not to hold the process open (see `unref()`). Remembered rather than applied once:
|
|
595
|
+
* `close()` followed by `start()` binds a *second* socket, and a promise that covered only the
|
|
596
|
+
* first one would not be a promise.
|
|
597
|
+
*/
|
|
598
|
+
this.unreferenced = false;
|
|
593
599
|
/** @type {symbol|null} Identifies the current run; a stale loop/announce is ignored. */
|
|
594
600
|
this.runToken = null;
|
|
595
601
|
/** @type {Promise<void>|null} */
|
|
@@ -690,6 +696,33 @@ export class P2PNode extends EventEmitter {
|
|
|
690
696
|
if (this.listenerCount('error') > 0) this.emit('error', new Error(message));
|
|
691
697
|
}
|
|
692
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Take the node off the process's liveness budget.
|
|
701
|
+
*
|
|
702
|
+
* A running node holds three things that would otherwise keep a Node.js process alive on their
|
|
703
|
+
* own, and all three are handled here: the bound UDP socket (a referenced handle), the announce
|
|
704
|
+
* refresh timer, and the accept loop's own wait timeout — a node that is simply waiting for a
|
|
705
|
+
* punch has that timer pending essentially all of the time. An embedder that has no disposer to
|
|
706
|
+
* call — a bare context with no `ctx.effect`, or an older host — has no other way to say so, and
|
|
707
|
+
* the alternative is a process that can never exit.
|
|
708
|
+
*
|
|
709
|
+
* The flag is remembered rather than applied once (`unreferenced`), because `close()` followed by
|
|
710
|
+
* `start()` binds a *second* socket and arms a *second* accept wait; the socket is unref'd as it
|
|
711
|
+
* binds, and the accept loop reads the flag on every round. A call made *after* `start()` therefore
|
|
712
|
+
* takes effect on the next round, which is bounded by the accept timeout. `close()` still releases
|
|
713
|
+
* everything; this only changes whether those handles hold the loop open.
|
|
714
|
+
*
|
|
715
|
+
* Idempotent, and safe before `start()` (and on a `relay` node, which never binds at all).
|
|
716
|
+
*
|
|
717
|
+
* @returns {this}
|
|
718
|
+
*/
|
|
719
|
+
unref() {
|
|
720
|
+
this.unreferenced = true;
|
|
721
|
+
this.socket?.unref?.();
|
|
722
|
+
this.refreshTimer?.unref?.();
|
|
723
|
+
return this;
|
|
724
|
+
}
|
|
725
|
+
|
|
693
726
|
/**
|
|
694
727
|
* Bring the node up: bind the socket, start the accept loop, and start announcing.
|
|
695
728
|
*
|
|
@@ -771,6 +804,9 @@ export class P2PNode extends EventEmitter {
|
|
|
771
804
|
});
|
|
772
805
|
|
|
773
806
|
this.socket = socket;
|
|
807
|
+
// `unref()` is remembered across binds: a caller that asked not to hold the process open must
|
|
808
|
+
// not start holding it open again just because this is the second socket the node has owned.
|
|
809
|
+
if (this.unreferenced) socket.unref?.();
|
|
774
810
|
this.local = local;
|
|
775
811
|
this.started = true;
|
|
776
812
|
this.runToken = Symbol('p2p-run');
|
|
@@ -1438,6 +1474,10 @@ export class P2PNode extends EventEmitter {
|
|
|
1438
1474
|
socket: this.socket,
|
|
1439
1475
|
timeoutMs: this.tuning.acceptTimeoutMs,
|
|
1440
1476
|
claimed: this.claimed,
|
|
1477
|
+
// Read on every round, not once: `unref()` can be called after `start()`, and the wait
|
|
1478
|
+
// this loop is about to arm is then the only handle that could still hold the process
|
|
1479
|
+
// open. See `unref()`.
|
|
1480
|
+
unrefWait: this.unreferenced,
|
|
1441
1481
|
// v0.6.0: handshakes are answered only when this machine holds a key of its own. Without
|
|
1442
1482
|
// one, `accept()` is byte-for-byte v0.5.x — every HELLO answered plainly — which is what
|
|
1443
1483
|
// keeps a mixed fleet working rather than half-encrypted and confused.
|