@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/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,43 @@ 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
|
+
* The relay is not serving peer keys, so no handshake can be armed by anyone (v0.6.2).
|
|
214
|
+
*
|
|
215
|
+
* Distinct from {@link PLAINTEXT_PEER} on purpose, and the distinction is the whole reason this code
|
|
216
|
+
* exists. A relay older than v0.6.0 has never heard of `static_key`: it accepts an announcement,
|
|
217
|
+
* silently drops the field, and serves an entry without it. Every peer then looks exactly like a
|
|
218
|
+
* machine that has no key — which is how a stale *relay* came to be reported for hours as "the peer
|
|
219
|
+
* is an older build", pointing the investigation at the one component that was up to date.
|
|
220
|
+
*
|
|
221
|
+
* Detected from this node's own announcement: we POST a key, so the entry the relay echoes back says
|
|
222
|
+
* whether it kept it. No relay cooperation and no version negotiation required, which is what makes
|
|
223
|
+
* it work against the relay that is actually deployed.
|
|
224
|
+
*/
|
|
225
|
+
RELAY_NO_KEYS: 'P2P_CRYPTO_RELAY_NO_KEYS',
|
|
226
|
+
/**
|
|
227
|
+
* A `REFUSE` datagram was **dropped**, not honoured: it named a session this punch did not agree.
|
|
228
|
+
*
|
|
229
|
+
* The four `REFUSE_*` codes below name an *ignored datagram* rather than a failed punch, which is
|
|
230
|
+
* deliberate and is the whole of the v0.6.0 hardening: an unauthenticated refusal that cannot be
|
|
231
|
+
* tied to this punch must not be able to change its outcome, and it must not be able to fail it
|
|
232
|
+
* either — an attacker who could turn "the peer never answered" into "the peer refused" would have
|
|
233
|
+
* been handed a denial of service in exchange for closing a downgrade.
|
|
234
|
+
*/
|
|
235
|
+
REFUSE_SESSION_MISMATCH: 'P2P_CRYPTO_REFUSE_SESSION_MISMATCH',
|
|
236
|
+
/** A `REFUSE` datagram was dropped: it came from an address this punch never dialled. */
|
|
237
|
+
REFUSE_UNPUNCHED_SOURCE: 'P2P_CRYPTO_REFUSE_UNPUNCHED_SOURCE',
|
|
238
|
+
/** A `REFUSE` datagram was dropped: it carried a `k0` MAC that did not verify. */
|
|
239
|
+
REFUSE_BAD_MAC: 'P2P_CRYPTO_REFUSE_BAD_MAC',
|
|
240
|
+
/**
|
|
241
|
+
* A downgrade-inducing `REFUSE` was dropped: it did not come from the peer that answered the punch.
|
|
242
|
+
*
|
|
243
|
+
* `NO_KEY`/`NO_PEER_KEY` say "I cannot take part, use the plaintext channel" — the one refusal that
|
|
244
|
+
* changes the outcome into something weaker than a failure. Requiring it to come from the address
|
|
245
|
+
* that actually answered the punch is what stops a datagram from a third address from choosing a
|
|
246
|
+
* plaintext channel on the peer's behalf.
|
|
247
|
+
*/
|
|
248
|
+
REFUSE_UNCORROBORATED: 'P2P_CRYPTO_REFUSE_UNCORROBORATED',
|
|
203
249
|
});
|
|
204
250
|
|
|
205
251
|
/* -------------------------------------------------------------------------- */
|
|
@@ -788,12 +834,35 @@ export function bytesEqual(a, b) {
|
|
|
788
834
|
*
|
|
789
835
|
* HELLO magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
790
836
|
* HELLO_ACK magic|version|session u32|e[32]|idLen u8|machineId|mac[48]
|
|
791
|
-
* REFUSE magic|version|session u32|code u8|idLen u8|machineId
|
|
837
|
+
* REFUSE magic|version|session u32|code u8|idLen u8|machineId[|mac 48]
|
|
838
|
+
*
|
|
839
|
+
* `mac` is 32 bytes of ciphertext plus 16 of tag (§4's AEAD-produced MAC).
|
|
840
|
+
*
|
|
841
|
+
* `REFUSE` **may** be unauthenticated, and that is not an oversight: it is sent precisely when we
|
|
842
|
+
* cannot authenticate the peer (we hold no key for it), so it can carry no proof — and it is what
|
|
843
|
+
* turns "the handshake did not complete" into a named reason on *both* ends instead of a timeout on
|
|
844
|
+
* one of them. When the responder received a handshake `HELLO` it holds the initiator's ephemeral
|
|
845
|
+
* `e_i`, and it *always* holds its own static private key `s_r`, so it can always derive
|
|
846
|
+
* `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)` — a key the initiator derives just as easily from
|
|
847
|
+
* `e_i`'s private half and `s_r`'s public key, and that nobody else can derive at all. The trailing 48
|
|
848
|
+
* bytes of a refusal are then `sealMac(k_ref, hash(REFUSE_MAC_LABEL ‖ the refusal without its MAC))`.
|
|
849
|
+
*
|
|
850
|
+
* That is the asymmetry this file relies on, and it is worth stating plainly because the naive
|
|
851
|
+
* version of it is wrong: `k0 = ECDH(s_i, s_r)` — the handshake MAC's key — is *not* usable here. A
|
|
852
|
+
* responder that has no key for us (or a stale one) cannot compute it, and those are exactly the two
|
|
853
|
+
* cases that send a refusal. `k_ref` needs neither end to hold the other's static key: the initiator
|
|
854
|
+
* sends `e_i` in the clear, and the responder's `s_r` is public. What it proves is what a refusal
|
|
855
|
+
* needs to prove — *this datagram came from the machine that holds `s_r`* — and it is therefore
|
|
856
|
+
* available to `BAD_MAC` (our proof did not verify), `NO_PEER_KEY` (we hold no key for you) and
|
|
857
|
+
* `UNKNOWN_PEER` alike.
|
|
792
858
|
*
|
|
793
|
-
* `
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
859
|
+
* `VERSION` is the one refusal that stays bare, and deliberately: the peer that needs it speaks a
|
|
860
|
+
* handshake version whose body layout this build cannot rely on, so a trailing field it does not know
|
|
861
|
+
* about would be worse than useless.
|
|
862
|
+
*
|
|
863
|
+
* The unauthenticated form still exists and is still honoured by `p2p.mjs` — a peer running an older
|
|
864
|
+
* build cannot produce `k_ref` and must not lose its plaintext channel for it. What `p2p.mjs` will not
|
|
865
|
+
* do is let a datagram it cannot tie to the punch (see `REFUSE_GUARD` there) rewrite a ledger.
|
|
797
866
|
*/
|
|
798
867
|
|
|
799
868
|
/**
|
|
@@ -808,6 +877,14 @@ export function bytesEqual(a, b) {
|
|
|
808
877
|
* it is not a downgrade: the peer never offered a key to downgrade from.
|
|
809
878
|
* * `BAD_MAC` — the peer *is* known and its MAC over its static key did not verify. That is either a
|
|
810
879
|
* stale key or an impersonation attempt, and the initiator treats it as terminal.
|
|
880
|
+
*
|
|
881
|
+
* The two known-peer codes (`BAD_MAC`, `REPLAY`) can be sealed with the ephemeral-static key of
|
|
882
|
+
* {@link refusalMacKey}, and so can the two unknown-peer codes — `k_ref` needs no static key on file.
|
|
883
|
+
* `VERSION` is the exception and stays bare. What `p2p.mjs` does with that asymmetry (`REFUSE_GUARD`
|
|
884
|
+
* there) is the rule: a *plaintext-choosing* refusal (`NO_KEY`, `NO_PEER_KEY`) must either verify its
|
|
885
|
+
* seal or arrive, unsealed, from the endpoint that acknowledged the punch; a terminal refusal is
|
|
886
|
+
* honoured from a dialled address sealed or not, with `refusal_authenticated` recording whether the
|
|
887
|
+
* seal was proof. Nothing dropped ever fails a punch.
|
|
811
888
|
*/
|
|
812
889
|
export const REFUSE = Object.freeze({
|
|
813
890
|
UNKNOWN_PEER: 1,
|
|
@@ -894,32 +971,136 @@ export function decodeHello(body) {
|
|
|
894
971
|
};
|
|
895
972
|
}
|
|
896
973
|
|
|
897
|
-
/**
|
|
898
|
-
|
|
974
|
+
/**
|
|
975
|
+
* Build a `REFUSE` body, optionally with the trailing `k0` MAC of {@link sealRefuse}.
|
|
976
|
+
*
|
|
977
|
+
* @param {number} session
|
|
978
|
+
* @param {number} code
|
|
979
|
+
* @param {string|null} [machineId]
|
|
980
|
+
* @param {Buffer|null} [mac] 48 bytes, or null for the unauthenticated form.
|
|
981
|
+
* @returns {Buffer}
|
|
982
|
+
*/
|
|
983
|
+
export function encodeRefuse(session, code, machineId = null, mac = null) {
|
|
899
984
|
const id = (machineId === null ? Buffer.alloc(0) : Buffer.from(machineId, 'utf8')).subarray(
|
|
900
985
|
0,
|
|
901
986
|
MAX_HANDSHAKE_MACHINE_ID,
|
|
902
987
|
);
|
|
903
|
-
const
|
|
988
|
+
const trailing = mac === null ? Buffer.alloc(0) : mac;
|
|
989
|
+
if (trailing.length !== 0 && trailing.length !== MAC_BYTES) {
|
|
990
|
+
throw new TypeError(`encodeRefuse: a refusal MAC must be ${MAC_BYTES} bytes`);
|
|
991
|
+
}
|
|
992
|
+
const out = Buffer.alloc(8 + id.length + trailing.length);
|
|
904
993
|
out.writeUInt8(FRAME_MAGIC, 0);
|
|
905
994
|
out.writeUInt8(CRYPTO_VERSION, 1);
|
|
906
995
|
out.writeUInt32BE(session >>> 0, 2);
|
|
907
996
|
out.writeUInt8(code, 6);
|
|
908
997
|
out.writeUInt8(id.length, 7);
|
|
909
998
|
id.copy(out, 8);
|
|
999
|
+
trailing.copy(out, 8 + id.length);
|
|
910
1000
|
return out;
|
|
911
1001
|
}
|
|
912
1002
|
|
|
913
|
-
/**
|
|
1003
|
+
/**
|
|
1004
|
+
* Parse a `REFUSE` body — both the bare form and the `k0`-MAC'd one — or null.
|
|
1005
|
+
*
|
|
1006
|
+
* Strict about the length, in both forms, and that strictness is load-bearing rather than tidy: the
|
|
1007
|
+
* body being parsed here arrives as the body of a **`HELLO_ACK`**, and a handshake body is also
|
|
1008
|
+
* `magic|version|session|…` with a byte that can look like an id length. An earlier version accepted
|
|
1009
|
+
* exactly one length and validated nothing else, so a genuine `HELLO_ACK` could be read as a refusal
|
|
1010
|
+
* — `body[6]` (the first byte of the responder's ephemeral, which is random) became the refusal code
|
|
1011
|
+
* and `body[7]` the id length — roughly one handshake in 256 refusing itself. The machine-id check
|
|
1012
|
+
* below is what makes that collision impossible rather than unlikely: a handshake body's bytes from
|
|
1013
|
+
* offset 8 on are key material, and key material is not a printable machine id.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {Buffer} body
|
|
1016
|
+
* @returns {{session:number, code:number, machineId:string|null, mac:Buffer|null, headerBytes:number,
|
|
1017
|
+
* bytes:Buffer}|null}
|
|
1018
|
+
*/
|
|
914
1019
|
export function decodeRefuse(body) {
|
|
915
1020
|
if (!Buffer.isBuffer(body) || body.length < 8) return null;
|
|
916
1021
|
if (body.readUInt8(0) !== FRAME_MAGIC) return null;
|
|
1022
|
+
if (body.readUInt8(1) !== CRYPTO_VERSION) return null;
|
|
917
1023
|
const session = body.readUInt32BE(2);
|
|
918
1024
|
const code = body.readUInt8(6);
|
|
919
1025
|
const idLength = body.readUInt8(7);
|
|
920
|
-
if (
|
|
921
|
-
const
|
|
922
|
-
|
|
1026
|
+
if (idLength > MAX_HANDSHAKE_MACHINE_ID) return null;
|
|
1027
|
+
const headerBytes = 8 + idLength;
|
|
1028
|
+
if (body.length !== headerBytes && body.length !== headerBytes + MAC_BYTES) return null;
|
|
1029
|
+
let machineId = null;
|
|
1030
|
+
if (idLength > 0) {
|
|
1031
|
+
machineId = body.subarray(8, headerBytes).toString('utf8');
|
|
1032
|
+
// Same rule, and the same reason, as `decodeHello`: no build produces a control byte in a machine
|
|
1033
|
+
// id, and one that arrives would put a delimiter into a log line.
|
|
1034
|
+
// eslint-disable-next-line no-control-regex -- rejecting control bytes is the point
|
|
1035
|
+
if (/[\u0000-\u001f\u007f]/.test(machineId)) return null;
|
|
1036
|
+
}
|
|
1037
|
+
const mac = body.length === headerBytes + MAC_BYTES
|
|
1038
|
+
? Buffer.from(body.subarray(headerBytes, headerBytes + MAC_BYTES))
|
|
1039
|
+
: null;
|
|
1040
|
+
return { session, code, machineId, mac, headerBytes, bytes: Buffer.from(body) };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The transcript a refusal MAC is taken over: the label, then the refusal exactly as it goes on the
|
|
1045
|
+
* wire *without* its MAC. Recomputed by the receiver from the bytes it holds, never sent separately.
|
|
1046
|
+
*
|
|
1047
|
+
* @param {Buffer} body The refusal body without the trailing MAC.
|
|
1048
|
+
* @returns {Buffer} 32 bytes.
|
|
1049
|
+
*/
|
|
1050
|
+
export function refuseTranscript(body) {
|
|
1051
|
+
return handshakeHash(REFUSE_MAC_LABEL, body);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* A refusal with proof attached, for every responder that received a parseable `HELLO`.
|
|
1056
|
+
*
|
|
1057
|
+
* @param {number} session
|
|
1058
|
+
* @param {number} code
|
|
1059
|
+
* @param {string|null} machineId
|
|
1060
|
+
* @param {Buffer} macKey `k_ref`, from {@link refusalMacKey}.
|
|
1061
|
+
* @returns {Buffer}
|
|
1062
|
+
*/
|
|
1063
|
+
export function sealRefuse(session, code, machineId, macKey) {
|
|
1064
|
+
const body = encodeRefuse(session, code, machineId);
|
|
1065
|
+
return encodeRefuse(session, code, machineId, sealMac(macKey, refuseTranscript(body)));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The MAC key a refusal is sealed with: `k_ref = HKDF(ECDH(e_i, s_r), REFUSE_MAC_LABEL)`.
|
|
1070
|
+
*
|
|
1071
|
+
* Both ends derive it from material the *other* end published, and neither needs the other's static
|
|
1072
|
+
* public key to be on file: the initiator passes its own ephemeral private key and the responder's
|
|
1073
|
+
* static public key, the responder passes its own static private key and the initiator's ephemeral
|
|
1074
|
+
* public key from the `HELLO`. An attacker who has neither private key cannot derive it, which is
|
|
1075
|
+
* what makes a sealed refusal evidence rather than a claim.
|
|
1076
|
+
*
|
|
1077
|
+
* @param {object} input
|
|
1078
|
+
* @param {import('node:crypto').KeyObject} input.privateKey `e_i`'s private half (initiator) or `s_r`
|
|
1079
|
+
* (responder).
|
|
1080
|
+
* @param {Buffer} input.peerPublicRaw 32 raw bytes of `s_r` (initiator) or `e_i` (responder).
|
|
1081
|
+
* @returns {Buffer} 32 bytes.
|
|
1082
|
+
*/
|
|
1083
|
+
export function refusalMacKey({ privateKey, peerPublicRaw }) {
|
|
1084
|
+
if (!Buffer.isBuffer(peerPublicRaw) || peerPublicRaw.length !== KEY_BYTES) {
|
|
1085
|
+
throw new TypeError(`refusalMacKey: the peer public key must be ${KEY_BYTES} raw bytes`);
|
|
1086
|
+
}
|
|
1087
|
+
return derive(diffieHellman({ privateKey, publicKey: publicKeyFromRaw(peerPublicRaw) }), REFUSE_MAC_LABEL);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Verify the optional MAC on a received refusal.
|
|
1092
|
+
*
|
|
1093
|
+
* @param {Buffer|null} macKey `k_ref`, or null when this end has nothing to check with.
|
|
1094
|
+
* @param {Buffer} body The whole refusal body as received.
|
|
1095
|
+
* @returns {boolean|null} `null` when the body carries no MAC (or is not a refusal at all), otherwise
|
|
1096
|
+
* whether it verifies. `false` is never "unknown" — it is a MAC that was present and wrong.
|
|
1097
|
+
*/
|
|
1098
|
+
export function verifyRefuseMac(macKey, body) {
|
|
1099
|
+
const decoded = decodeRefuse(body);
|
|
1100
|
+
if (decoded === null || decoded.mac === null) return null;
|
|
1101
|
+
if (macKey === null) return false;
|
|
1102
|
+
const expected = sealMac(macKey, refuseTranscript(body.subarray(0, decoded.headerBytes)));
|
|
1103
|
+
return bytesEqual(expected, decoded.mac);
|
|
923
1104
|
}
|
|
924
1105
|
|
|
925
1106
|
/* -------------------------------------------------------------------------- */
|
|
@@ -1341,7 +1522,9 @@ export function ackTranscript(helloBytes, responderEphemeralRaw) {
|
|
|
1341
1522
|
* @param {string} options.peerMachineId The machine being dialled.
|
|
1342
1523
|
* @param {string} options.peerPublicKey Its static public key, from the offer or from the relay.
|
|
1343
1524
|
* @param {number} options.session The punch session.
|
|
1344
|
-
* @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
|
|
1525
|
+
* @returns {{body: Buffer, ephemeral: string, session: number, peerMachineId: string,
|
|
1526
|
+
* refusalMacKey: Buffer, finish: Function}} `refusalMacKey` is `k_ref` of {@link refusalMacKey}: what
|
|
1527
|
+
* this end checks a `REFUSE` against, and the reason a refusal can be evidence rather than a claim.
|
|
1345
1528
|
*/
|
|
1346
1529
|
export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, session }) {
|
|
1347
1530
|
const peerRaw = decodeKey(peerPublicKey);
|
|
@@ -1364,6 +1547,10 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
|
|
|
1364
1547
|
ephemeral: encodeKey(ephemeralPublic),
|
|
1365
1548
|
session,
|
|
1366
1549
|
peerMachineId,
|
|
1550
|
+
// Derived once, here, from the ephemeral this handshake is about to send: `ECDH(e_i, s_r)`. The
|
|
1551
|
+
// responder derives the same value from that very `e_i` and its own static key, which is what lets
|
|
1552
|
+
// it answer a refusal with proof even when it holds no key for us at all.
|
|
1553
|
+
refusalMacKey: refusalMacKey({ privateKey: ephemeral.privateKey, peerPublicRaw: peerRaw }),
|
|
1367
1554
|
/**
|
|
1368
1555
|
* Verify `HELLO_ACK` and hand back the transport session.
|
|
1369
1556
|
*
|
|
@@ -1433,7 +1620,42 @@ export function startHandshake({ deviceKey, peerMachineId, peerPublicKey, sessio
|
|
|
1433
1620
|
export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
1434
1621
|
const hello = decodeHello(body);
|
|
1435
1622
|
if (hello === null) return { ok: false, reason: CRYPTO_REASON.MALFORMED, refuse: null };
|
|
1623
|
+
|
|
1624
|
+
/** This peer's static public key as filed under its claimed machine id, or null. */
|
|
1625
|
+
const keyFor = (machineId) => {
|
|
1626
|
+
if (machineId === null) return null;
|
|
1627
|
+
return peerKeys instanceof PeerKeyStore
|
|
1628
|
+
? peerKeys.get(machineId)
|
|
1629
|
+
: (peerKeys.get(machineId) ?? null);
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* `k_ref` for this exchange, from the initiator's `e_i` and our own static key.
|
|
1634
|
+
*
|
|
1635
|
+
* Present whenever the `HELLO` parsed at all, which is every refusal below except `MALFORMED` (which
|
|
1636
|
+
* sends nothing). It is deliberately *not* `k0`: a responder with no key for the initiator — the
|
|
1637
|
+
* sender of `NO_PEER_KEY` — cannot compute `k0`, and a responder with a stale key for it (the sender
|
|
1638
|
+
* of `BAD_MAC`) would produce a `k0` the initiator cannot reproduce either. `k_ref` needs neither.
|
|
1639
|
+
*/
|
|
1640
|
+
const initiatorEphemeralRaw = decodeKey(hello.ephemeral);
|
|
1641
|
+
const refusalKey = initiatorEphemeralRaw === null
|
|
1642
|
+
? null
|
|
1643
|
+
: refusalMacKey({ privateKey: deviceKey.privateKey, peerPublicRaw: initiatorEphemeralRaw });
|
|
1644
|
+
/**
|
|
1645
|
+
* A refusal body: sealed when we can prove it, bare when we cannot.
|
|
1646
|
+
*
|
|
1647
|
+
* @param {number} code
|
|
1648
|
+
* @returns {Buffer}
|
|
1649
|
+
*/
|
|
1650
|
+
const refuse = (code) => (refusalKey === null
|
|
1651
|
+
? encodeRefuse(hello.session, code, deviceKey.machineId)
|
|
1652
|
+
: sealRefuse(hello.session, code, deviceKey.machineId, refusalKey));
|
|
1653
|
+
|
|
1436
1654
|
if (hello.version !== CRYPTO_VERSION) {
|
|
1655
|
+
// The version is decided before any key is looked at — a peer speaking a different handshake
|
|
1656
|
+
// version must be told *that*, not "unknown peer" — and this refusal stays bare on purpose: the
|
|
1657
|
+
// peer receiving it speaks a body layout this build cannot vouch for, so a trailing field it does
|
|
1658
|
+
// not know about would be worse than useless.
|
|
1437
1659
|
return {
|
|
1438
1660
|
ok: false,
|
|
1439
1661
|
reason: CRYPTO_REASON.VERSION_MISMATCH,
|
|
@@ -1441,19 +1663,19 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1441
1663
|
};
|
|
1442
1664
|
}
|
|
1443
1665
|
|
|
1444
|
-
const initiatorKey =
|
|
1445
|
-
hello.machineId === null
|
|
1446
|
-
? null
|
|
1447
|
-
: peerKeys instanceof PeerKeyStore
|
|
1448
|
-
? peerKeys.get(hello.machineId)
|
|
1449
|
-
: (peerKeys.get(hello.machineId) ?? null);
|
|
1666
|
+
const initiatorKey = keyFor(hello.machineId);
|
|
1450
1667
|
|
|
1451
1668
|
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
|
-
//
|
|
1669
|
+
// Nothing this peer says can be verified, and we will not guess. The refusal is named — and it is
|
|
1670
|
+
// *sealed*, because `k_ref` is exactly the proof this case can carry: it says "this datagram came
|
|
1671
|
+
// from the machine that holds the static key you dialled", which is precisely what a peer with no
|
|
1672
|
+
// key on file for us can still prove. `p2p.mjs` turns that into the rule that a refusal which
|
|
1673
|
+
// would choose a plaintext channel needs either this proof or the corroboration of the same
|
|
1674
|
+
// endpoint having acknowledged the punch.
|
|
1675
|
+
//
|
|
1676
|
+
// It says `NO_PEER_KEY` rather than `UNKNOWN_PEER` because that is what the initiator needs to
|
|
1677
|
+
// know: a handshake is impossible here, so it may use the plaintext channel it would have had
|
|
1678
|
+
// before this work rather than waiting out a timeout.
|
|
1457
1679
|
//
|
|
1458
1680
|
// This branch has a second, ordinary caller: a handshake from *our own* machine id, which is what
|
|
1459
1681
|
// a task the relay leased back to the dispatching machine produces. We hold no peer key for
|
|
@@ -1461,19 +1683,11 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1461
1683
|
// self-dial has always used. That is the right answer rather than an oversight: a self-connection
|
|
1462
1684
|
// has no path to protect and must not fail, or a machine would lose the task it dispatched to
|
|
1463
1685
|
// itself.
|
|
1464
|
-
return {
|
|
1465
|
-
ok: false,
|
|
1466
|
-
reason: CRYPTO_REASON.UNKNOWN_PEER,
|
|
1467
|
-
refuse: encodeRefuse(hello.session, REFUSE.NO_PEER_KEY, deviceKey.machineId),
|
|
1468
|
-
};
|
|
1686
|
+
return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.NO_PEER_KEY) };
|
|
1469
1687
|
}
|
|
1470
1688
|
const initiatorRaw = decodeKey(initiatorKey);
|
|
1471
1689
|
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
|
-
};
|
|
1690
|
+
return { ok: false, reason: CRYPTO_REASON.UNKNOWN_PEER, refuse: refuse(REFUSE.UNKNOWN_PEER) };
|
|
1477
1691
|
}
|
|
1478
1692
|
|
|
1479
1693
|
const k0 = derive(deviceKey.sharedSecret(initiatorRaw), STATIC_KEY_LABEL);
|
|
@@ -1484,12 +1698,10 @@ export function respondHandshake({ deviceKey, body, peerKeys, seen = null }) {
|
|
|
1484
1698
|
if (!bytesEqual(hello.mac, expectedMac)) {
|
|
1485
1699
|
// The peer is known, and its mac is wrong: it does not hold the private key we have on file for
|
|
1486
1700
|
// 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
|
-
};
|
|
1701
|
+
// machine's key", the other means "somebody is guessing at names". Sealed with `k_ref` and not
|
|
1702
|
+
// `k0`, because when our record of *its* key is the stale one, `k0` is a value neither end can
|
|
1703
|
+
// agree on — and this is the refusal whose whole content is "our records disagree".
|
|
1704
|
+
return { ok: false, reason: CRYPTO_REASON.BAD_MAC, refuse: refuse(REFUSE.BAD_MAC) };
|
|
1493
1705
|
}
|
|
1494
1706
|
|
|
1495
1707
|
const nonceKey = `${hello.machineId}#${hello.session}#${hello.ephemeral}`;
|
package/src/agent/p2p-node.mjs
CHANGED
|
@@ -509,6 +509,14 @@ export class P2PNode extends EventEmitter {
|
|
|
509
509
|
this.deviceKey = deviceKey ?? null;
|
|
510
510
|
/** @type {PeerKeyStore} Peer static keys, learned from offers and from the relay. */
|
|
511
511
|
this.peerKeys = peerKeys instanceof PeerKeyStore ? peerKeys : new PeerKeyStore();
|
|
512
|
+
/**
|
|
513
|
+
* Does the relay keep the static key this machine announces? `null` until an announcement proves
|
|
514
|
+
* it either way. See the note in `performAnnounce` — a relay older than v0.6.0 drops the field
|
|
515
|
+
* silently, and that is otherwise indistinguishable from every peer having no key.
|
|
516
|
+
*
|
|
517
|
+
* @type {boolean|null}
|
|
518
|
+
*/
|
|
519
|
+
this.relayServesKeys = null;
|
|
512
520
|
/** @type {object|null} `CryptoSession` overrides; null means the documented thresholds. */
|
|
513
521
|
this.cryptoTuning = cryptoTuning ?? null;
|
|
514
522
|
/** @type {NonceCache} Replay tracking for inbound handshakes. Outlives one `accept()`. */
|
|
@@ -590,6 +598,12 @@ export class P2PNode extends EventEmitter {
|
|
|
590
598
|
this.announcing = null;
|
|
591
599
|
/** @type {NodeJS.Timeout|null} */
|
|
592
600
|
this.refreshTimer = null;
|
|
601
|
+
/**
|
|
602
|
+
* Asked not to hold the process open (see `unref()`). Remembered rather than applied once:
|
|
603
|
+
* `close()` followed by `start()` binds a *second* socket, and a promise that covered only the
|
|
604
|
+
* first one would not be a promise.
|
|
605
|
+
*/
|
|
606
|
+
this.unreferenced = false;
|
|
593
607
|
/** @type {symbol|null} Identifies the current run; a stale loop/announce is ignored. */
|
|
594
608
|
this.runToken = null;
|
|
595
609
|
/** @type {Promise<void>|null} */
|
|
@@ -648,6 +662,12 @@ export class P2PNode extends EventEmitter {
|
|
|
648
662
|
enabled: this.deviceKey !== null,
|
|
649
663
|
public_key: this.deviceKey === null ? null : (this.deviceKey.publicKeyBase64 ?? null),
|
|
650
664
|
peers_known: this.peerKeys.size,
|
|
665
|
+
/**
|
|
666
|
+
* `null` until this node's own announcement proves it either way; `false` means the relay
|
|
667
|
+
* dropped the key we sent, which is the fact that explains an otherwise mysterious
|
|
668
|
+
* `peers_known: 0` on a fleet whose machines all hold keys (v0.6.2).
|
|
669
|
+
*/
|
|
670
|
+
relay_serves_keys: this.relayServesKeys,
|
|
651
671
|
handshakes: this.cryptoCounts.handshakes,
|
|
652
672
|
encrypted_channels: this.cryptoCounts.encrypted_channels,
|
|
653
673
|
plaintext_channels: this.cryptoCounts.plaintext_channels,
|
|
@@ -690,6 +710,33 @@ export class P2PNode extends EventEmitter {
|
|
|
690
710
|
if (this.listenerCount('error') > 0) this.emit('error', new Error(message));
|
|
691
711
|
}
|
|
692
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Take the node off the process's liveness budget.
|
|
715
|
+
*
|
|
716
|
+
* A running node holds three things that would otherwise keep a Node.js process alive on their
|
|
717
|
+
* own, and all three are handled here: the bound UDP socket (a referenced handle), the announce
|
|
718
|
+
* refresh timer, and the accept loop's own wait timeout — a node that is simply waiting for a
|
|
719
|
+
* punch has that timer pending essentially all of the time. An embedder that has no disposer to
|
|
720
|
+
* call — a bare context with no `ctx.effect`, or an older host — has no other way to say so, and
|
|
721
|
+
* the alternative is a process that can never exit.
|
|
722
|
+
*
|
|
723
|
+
* The flag is remembered rather than applied once (`unreferenced`), because `close()` followed by
|
|
724
|
+
* `start()` binds a *second* socket and arms a *second* accept wait; the socket is unref'd as it
|
|
725
|
+
* binds, and the accept loop reads the flag on every round. A call made *after* `start()` therefore
|
|
726
|
+
* takes effect on the next round, which is bounded by the accept timeout. `close()` still releases
|
|
727
|
+
* everything; this only changes whether those handles hold the loop open.
|
|
728
|
+
*
|
|
729
|
+
* Idempotent, and safe before `start()` (and on a `relay` node, which never binds at all).
|
|
730
|
+
*
|
|
731
|
+
* @returns {this}
|
|
732
|
+
*/
|
|
733
|
+
unref() {
|
|
734
|
+
this.unreferenced = true;
|
|
735
|
+
this.socket?.unref?.();
|
|
736
|
+
this.refreshTimer?.unref?.();
|
|
737
|
+
return this;
|
|
738
|
+
}
|
|
739
|
+
|
|
693
740
|
/**
|
|
694
741
|
* Bring the node up: bind the socket, start the accept loop, and start announcing.
|
|
695
742
|
*
|
|
@@ -771,6 +818,9 @@ export class P2PNode extends EventEmitter {
|
|
|
771
818
|
});
|
|
772
819
|
|
|
773
820
|
this.socket = socket;
|
|
821
|
+
// `unref()` is remembered across binds: a caller that asked not to hold the process open must
|
|
822
|
+
// not start holding it open again just because this is the second socket the node has owned.
|
|
823
|
+
if (this.unreferenced) socket.unref?.();
|
|
774
824
|
this.local = local;
|
|
775
825
|
this.started = true;
|
|
776
826
|
this.runToken = Symbol('p2p-run');
|
|
@@ -915,6 +965,28 @@ export class P2PNode extends EventEmitter {
|
|
|
915
965
|
const posted = response?.ok === true;
|
|
916
966
|
if (!posted && postError === null) postError = describeAnnounceFailure(response);
|
|
917
967
|
|
|
968
|
+
/**
|
|
969
|
+
* Does the relay keep the static key we send it? (v0.6.2)
|
|
970
|
+
*
|
|
971
|
+
* This is knowable from our own announcement, and only from our own announcement: we POST
|
|
972
|
+
* `static_key`, and the relay echoes the entry it stored. A relay that predates v0.6.0 has never
|
|
973
|
+
* heard of the field, drops it silently, and echoes an entry without it — indistinguishable, from
|
|
974
|
+
* any peer's point of view, from a machine that has no key at all.
|
|
975
|
+
*
|
|
976
|
+
* That ambiguity is what turned a stale relay into hours of "the peer is an older build". The
|
|
977
|
+
* deployed relay in this fleet started 2026-10-10T18:01:40Z; v0.6.0 and v0.6.1 shipped on
|
|
978
|
+
* 2026-10-11. Every announcement carried a valid key, every stored entry lost it, no dispatcher
|
|
979
|
+
* could ever arm a handshake, and `PLAINTEXT_PEER` blamed the one component that was up to date.
|
|
980
|
+
*
|
|
981
|
+
* Recorded rather than reported here: one announce proves the relay's behaviour, and `dial()`
|
|
982
|
+
* turns it into a named reason only where it explains something. `null` means "not yet known" —
|
|
983
|
+
* a machine with no key of its own cannot ask the question at all, and must not conclude "no".
|
|
984
|
+
*/
|
|
985
|
+
if (posted && this.deviceKey !== null) {
|
|
986
|
+
const echoed = response?.json?.static_key;
|
|
987
|
+
this.relayServesKeys = typeof echoed === 'string' && echoed !== '' ? true : false;
|
|
988
|
+
}
|
|
989
|
+
|
|
918
990
|
// A POST failure is the headline when there is one: the announcement is not live at
|
|
919
991
|
// all. Otherwise a discovery failure is what makes this announcement incomplete.
|
|
920
992
|
const error = !posted ? postError : discoveryOk ? null : discoveryError;
|
|
@@ -1106,6 +1178,9 @@ export class P2PNode extends EventEmitter {
|
|
|
1106
1178
|
const fromOffer = Array.isArray(options.candidates) && options.candidates.length > 0;
|
|
1107
1179
|
|
|
1108
1180
|
let announced = [];
|
|
1181
|
+
// v0.6.2: the peer's static key as published in the same record the candidates came from. Lifted
|
|
1182
|
+
// out of the lookup block because `lookup` is scoped to it, and the key is needed further down.
|
|
1183
|
+
let announcedStaticKey = null;
|
|
1109
1184
|
if (fromOffer) {
|
|
1110
1185
|
announced = options.candidates;
|
|
1111
1186
|
} else {
|
|
@@ -1130,6 +1205,8 @@ export class P2PNode extends EventEmitter {
|
|
|
1130
1205
|
return fail(`P2P_LOOKUP_FAILED: ${describeLookupFailure(lookup)}`);
|
|
1131
1206
|
}
|
|
1132
1207
|
announced = Array.isArray(lookup.json?.peer?.candidates) ? lookup.json.peer.candidates : [];
|
|
1208
|
+
const key = lookup.json?.peer?.static_key;
|
|
1209
|
+
if (typeof key === 'string' && key !== '') announcedStaticKey = key;
|
|
1133
1210
|
}
|
|
1134
1211
|
|
|
1135
1212
|
const remote = [];
|
|
@@ -1140,6 +1217,52 @@ export class P2PNode extends EventEmitter {
|
|
|
1140
1217
|
remote.push(usable);
|
|
1141
1218
|
if (remote.length >= this.tuning.maxCandidates) break;
|
|
1142
1219
|
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* The peer's static key, learned from its own announcement when this machine does not hold one.
|
|
1223
|
+
*
|
|
1224
|
+
* v0.6.2. This is the other half of the plaintext-downgrade fix, and without it the reason code
|
|
1225
|
+
* alone would have been a better-worded lie: the dispatcher that reported
|
|
1226
|
+
* `P2P_CRYPTO_PLAINTEXT_PEER` against a same-version peer *could* have encrypted, it simply had an
|
|
1227
|
+
* empty key store and never asked. The relay has carried the peer's `static_key` in this very
|
|
1228
|
+
* record since v0.6.0 (`relay/peers.mjs`), the plugin already read it, and the agent's own `dial()`
|
|
1229
|
+
* was fetching the same record for the candidate list and then throwing the key away.
|
|
1230
|
+
*
|
|
1231
|
+
* Adopting it here is what makes the local/remote distinction possible as well as useful: a peer
|
|
1232
|
+
* that publishes no key is genuinely an older build and `PLAINTEXT_PEER` is honest; a peer that
|
|
1233
|
+
* publishes one we had not learned is *our* gap, and after this it is not a gap at all.
|
|
1234
|
+
*/
|
|
1235
|
+
let peerKey = this.peerKeys.get(machineId);
|
|
1236
|
+
if (peerKey === null && announcedStaticKey !== null) {
|
|
1237
|
+
this.peerKeys.set(machineId, announcedStaticKey, 'relay');
|
|
1238
|
+
peerKey = announcedStaticKey;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Why this dial cannot be encrypted, when it cannot. Named here because this is where the reason
|
|
1243
|
+
* is actually known: `punch()` sees only "no crypto request" and used to guess, blaming the peer
|
|
1244
|
+
* for what is usually a local condition.
|
|
1245
|
+
*
|
|
1246
|
+
* `null` means a handshake will be armed. `PLAINTEXT_PEER` is reserved for the one case that is
|
|
1247
|
+
* about the peer -- it publishes no key, so there is nothing to encrypt with. Everything else is
|
|
1248
|
+
* `NO_KEY`: this machine has no device key of its own, holds no key for that peer, or is dialling
|
|
1249
|
+
* itself (a machine keeps no key for itself, so its own `respondHandshake` would refuse it with
|
|
1250
|
+
* `P2P_CRYPTO_UNKNOWN_PEER`).
|
|
1251
|
+
*/
|
|
1252
|
+
const cryptoBlockedReason =
|
|
1253
|
+
this.deviceKey === null
|
|
1254
|
+
? CRYPTO_REASON.NO_KEY
|
|
1255
|
+
: machineId === this.machineId
|
|
1256
|
+
? CRYPTO_REASON.NO_KEY
|
|
1257
|
+
: peerKey === null
|
|
1258
|
+
? this.relayServesKeys === false
|
|
1259
|
+
// The peer announced, we hold a key of our own, and no key came back for it. If this
|
|
1260
|
+
// node has already proved the relay drops keys, the relay is the cause and saying
|
|
1261
|
+
// otherwise sends the operator to the wrong machine.
|
|
1262
|
+
? CRYPTO_REASON.RELAY_NO_KEYS
|
|
1263
|
+
: CRYPTO_REASON.PLAINTEXT_PEER
|
|
1264
|
+
: null;
|
|
1265
|
+
|
|
1143
1266
|
if (remote.length === 0) {
|
|
1144
1267
|
// `peersAnnounced` still counts what was offered, not what survived validation: a caller
|
|
1145
1268
|
// reading "the punch failed" needs to see that there *was* a list, and that every entry in it
|
|
@@ -1174,17 +1297,19 @@ export class P2PNode extends EventEmitter {
|
|
|
1174
1297
|
// sends would be refused by its own `respondHandshake` (`P2P_CRYPTO_UNKNOWN_PEER`) and the
|
|
1175
1298
|
// task it dispatched to itself would be lost. `machineId !== this.machineId` is what makes a
|
|
1176
1299
|
// self-lease survive a security upgrade, which is the same rule as every other fallback here.
|
|
1177
|
-
...(
|
|
1178
|
-
|
|
1179
|
-
this.peerKeys.get(machineId) === null
|
|
1180
|
-
? {}
|
|
1181
|
-
: {
|
|
1300
|
+
...(cryptoBlockedReason === null && peerKey !== null
|
|
1301
|
+
? {
|
|
1182
1302
|
crypto: {
|
|
1183
1303
|
deviceKey: this.deviceKey,
|
|
1184
|
-
peerKey
|
|
1304
|
+
peerKey,
|
|
1185
1305
|
peerMachineId: machineId,
|
|
1186
1306
|
},
|
|
1187
|
-
}
|
|
1307
|
+
}
|
|
1308
|
+
: {}),
|
|
1309
|
+
// v0.6.2: the reason travels with the call instead of being guessed downstream. `punch()`
|
|
1310
|
+
// cannot tell a local "we hold no key" from the peer being an older build, and it used to
|
|
1311
|
+
// report the peer-blaming one for both.
|
|
1312
|
+
cryptoBlockedReason,
|
|
1188
1313
|
...(this.cryptoTuning === null ? {} : { cryptoTuning: this.cryptoTuning }),
|
|
1189
1314
|
});
|
|
1190
1315
|
} catch (error) {
|
|
@@ -1438,6 +1563,10 @@ export class P2PNode extends EventEmitter {
|
|
|
1438
1563
|
socket: this.socket,
|
|
1439
1564
|
timeoutMs: this.tuning.acceptTimeoutMs,
|
|
1440
1565
|
claimed: this.claimed,
|
|
1566
|
+
// Read on every round, not once: `unref()` can be called after `start()`, and the wait
|
|
1567
|
+
// this loop is about to arm is then the only handle that could still hold the process
|
|
1568
|
+
// open. See `unref()`.
|
|
1569
|
+
unrefWait: this.unreferenced,
|
|
1441
1570
|
// v0.6.0: handshakes are answered only when this machine holds a key of its own. Without
|
|
1442
1571
|
// one, `accept()` is byte-for-byte v0.5.x — every HELLO answered plainly — which is what
|
|
1443
1572
|
// keeps a mixed fleet working rather than half-encrypted and confused.
|