@twinsearth/w2m-dsh-plugin 0.5.1 → 0.5.3
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 +62 -0
- package/PROTOCOL.md +122 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +117 -2
- package/docs/AUDIT.md +130 -0
- package/docs/P2P-CRYPTO.md +225 -0
- package/package.json +1 -1
- package/src/agent/agent.mjs +199 -5
- package/src/agent/crypto.mjs +1618 -0
- package/src/agent/p2p-node.mjs +276 -5
- package/src/agent/p2p.mjs +667 -40
- package/src/plugin/tools.mjs +200 -14
- package/src/relay/peers.mjs +56 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Encrypting the peer-to-peer data plane
|
|
2
|
+
|
|
3
|
+
Status: **stage 1 implemented and verified** (v0.5.3); stages 2 and 3 are not. This document is the
|
|
4
|
+
contract the implementation follows, so that a wire-format change is agreed before it is written
|
|
5
|
+
rather than discovered in a diff. What was built, what deviated, and what is still open are in §9
|
|
6
|
+
below rather than edited into the sections above — the contract should read as it was agreed.
|
|
7
|
+
|
|
8
|
+
## 1. Why not simply ship WireGuard
|
|
9
|
+
|
|
10
|
+
The obvious request is "make the fleet use WireGuard, installed automatically, with nothing for the
|
|
11
|
+
user to do". That is not achievable on any desktop platform, and the reason is a security property
|
|
12
|
+
rather than an engineering gap: a VPN intercepts *all* traffic, so every operating system requires an
|
|
13
|
+
explicit elevation or consent once.
|
|
14
|
+
|
|
15
|
+
| Platform | Official implementation | What makes "no install, no operation" impossible |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| Windows | [wireguard-windows](https://github.com/WireGuard/wireguard-windows) | Needs the **Wintun driver** (userspace mode included) or the WireGuardNT kernel driver → **administrator** to load a driver; service mode installs a system service |
|
|
18
|
+
| macOS | [wireguard-apple](https://github.com/WireGuard/wireguard-apple) | Uses a **Network Extension** (packet tunnel provider) → a signed, entitled app, and the system's **"allow VPN configuration" prompt cannot be bypassed by any app** |
|
|
19
|
+
| Linux | [wireguard-linux](https://github.com/WireGuard/wireguard-linux) | Loading the module and creating an interface needs **root / CAP_NET_ADMIN** |
|
|
20
|
+
| Android | [wireguard-android](https://github.com/WireGuard/wireguard-android) | Uses `VpnService` → the **consent dialog is mandatory** |
|
|
21
|
+
| iOS | wireguard-apple | Same Network Extension + consent rule |
|
|
22
|
+
|
|
23
|
+
So the fleet's own transport does not go through WireGuard. What we take from WireGuard is the part
|
|
24
|
+
that is actually valuable here and needs no privileges: **its cryptography and its handshake shape.**
|
|
25
|
+
|
|
26
|
+
## 2. What this protects, and what it does not
|
|
27
|
+
|
|
28
|
+
**Protects**
|
|
29
|
+
|
|
30
|
+
* A passive observer anywhere on the path between two machines: task offers, results and file
|
|
31
|
+
fingerprints on the direct UDP channel become unreadable.
|
|
32
|
+
* An off-path attacker who knows a peer's address: without the peer's static private key a forged
|
|
33
|
+
`HELLO` cannot complete the handshake.
|
|
34
|
+
* Replay and reordering of data frames: counters plus a replay window, not hope.
|
|
35
|
+
* A *curious relay operator* reading task data **that travels over the direct channel**. Note the
|
|
36
|
+
relay still sees the control plane — see below.
|
|
37
|
+
|
|
38
|
+
**Does not protect**
|
|
39
|
+
|
|
40
|
+
* A compromised endpoint. If the machine is owned, the plaintext is there.
|
|
41
|
+
* The control plane: the relay still learns which machines exist, which task was dispatched to whom,
|
|
42
|
+
when, and the aggregate verdict. Metadata is not hidden by this work, and pretending otherwise would
|
|
43
|
+
be worse than saying so.
|
|
44
|
+
* Traffic analysis, or an attacker who can drop packets (availability is the relay's job).
|
|
45
|
+
* Any traffic that falls back to the relay, **unless** the relay leg is separately protected. Stage 1
|
|
46
|
+
leaves the relay leg as it is and says so in the ledger (`encrypted: false` for that machine).
|
|
47
|
+
|
|
48
|
+
## 3. Cryptographic mapping, and the one gap
|
|
49
|
+
|
|
50
|
+
Everything needed is already in `node:crypto`; the zero-dependency rule survives.
|
|
51
|
+
|
|
52
|
+
| Purpose | Primitive | Node API |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Static and ephemeral key agreement | X25519 | `generateKeyPairSync('x25519')`, `diffieHellman({privateKey, publicKey})` |
|
|
55
|
+
| Key derivation | HKDF-SHA256 | `hkdfSync('sha256', ikm, salt, info, len)` |
|
|
56
|
+
| Handshake hash | BLAKE2s-256 | `createHash('blake2s256')` |
|
|
57
|
+
| AEAD | ChaCha20-Poly1305 | `createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 })` |
|
|
58
|
+
| Random | CSPRNG | `randomBytes` |
|
|
59
|
+
|
|
60
|
+
**The gap:** WireGuard's optional `mac1`/`mac2` fields use **keyed** BLAKE2s, which Node's crypto does
|
|
61
|
+
not expose (OpenSSL has it; the binding does not). Stage 1 therefore implements **neither** field and
|
|
62
|
+
records that as an accepted limitation: they mitigate CPU-exhaustion denial of service, not
|
|
63
|
+
confidentiality or authenticity. Stage 2 either implements BLAKE2s' compression function in plain
|
|
64
|
+
JavaScript (it is short and well specified) or argues explicitly for leaving it out. It is not silently
|
|
65
|
+
dropped.
|
|
66
|
+
|
|
67
|
+
**Licence:** `wireguard-windows`, `wireguard-apple` and `wireguard-go` are MIT; `wireguard-linux` is
|
|
68
|
+
GPL-2.0. This project is MIT, so the kernel module's code must not enter it. Nothing here is copied
|
|
69
|
+
from any of them: the construction is from the published protocol description, and the implementation
|
|
70
|
+
is ours.
|
|
71
|
+
|
|
72
|
+
## 4. Stage 1 — what gets implemented
|
|
73
|
+
|
|
74
|
+
**Identity.** Each machine generates an X25519 keypair the first time it pairs and stores it beside
|
|
75
|
+
its device identity (`…/xclient/device.key`, mode 0600 where the platform honours it). The public key
|
|
76
|
+
is registered with the relay as part of the device record.
|
|
77
|
+
|
|
78
|
+
**Distribution, with no `[Peer]` block for anyone to fill in.** When a task is dispatched, the relay
|
|
79
|
+
already tells each machine who the peers are; it now also includes each peer's *static public key* in
|
|
80
|
+
that same offer. A machine therefore learns the keys of exactly the peers it has been asked to talk
|
|
81
|
+
to, and no configuration is written by hand. This is the part that is genuinely better than
|
|
82
|
+
WireGuard's manual peer configuration, and it is the whole reason the key exchange belongs in the
|
|
83
|
+
relay we already have.
|
|
84
|
+
|
|
85
|
+
**Handshake, two messages, modelled on Noise IK.** Written out so the implementation cannot drift:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
initiator → responder HELLO e_i, mac mac = AEAD(k0, nonce=0, hash(prologue || s_i.pub || s_r.pub))
|
|
89
|
+
responder → initiator HELLO_ACK e_r, mac' mac' = AEAD(k0', nonce=0, hash(HELLO))
|
|
90
|
+
session key k = HKDF( ECDH(e_i, e_r) ‖ ECDH(s_i, e_r) ‖ ECDH(e_i, s_r) )
|
|
91
|
+
k0 = HKDF( ECDH(s_i, s_r) ) -- proves possession of the static key
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
* The triple DH gives **mutual authentication and forward secrecy**: a later compromise of both static
|
|
95
|
+
keys does not decrypt recorded sessions.
|
|
96
|
+
* A replayed `HELLO` cannot produce a session key, because the responder's fresh `e_r` never existed
|
|
97
|
+
at the time the original was captured.
|
|
98
|
+
* A machine that does not know the initiator's static key cannot verify `mac` and refuses, naming the
|
|
99
|
+
reason — never silently downgrading to plaintext.
|
|
100
|
+
|
|
101
|
+
**Transport.** ChaCha20-Poly1305 per frame; nonce = four zero bytes followed by a 64-bit
|
|
102
|
+
little-endian counter, per direction (WireGuard's layout). A 64-message sliding replay window. Rekey
|
|
103
|
+
after 2^20 messages or 120 seconds, whichever comes first, by running the handshake again — the old
|
|
104
|
+
key is retained briefly so frames in flight are not lost.
|
|
105
|
+
|
|
106
|
+
**What does not change.** Frames, fragmentation and the reliable-channel logic keep their shape; the
|
|
107
|
+
payload of each frame gains an authentication tag. Anchors, allow-list gating, leases and verdicts are
|
|
108
|
+
untouched.
|
|
109
|
+
|
|
110
|
+
**Fallback, and it is not optional.** If the handshake fails — an older peer, an unknown key, a
|
|
111
|
+
tampered packet — the task proceeds **exactly as today**, over the relay, and the ledger records
|
|
112
|
+
`p2p.encrypted: false` together with the reason. A fleet must never lose a machine because a security
|
|
113
|
+
upgrade could not complete; it must only *say* that it did not.
|
|
114
|
+
|
|
115
|
+
**Compatibility.** Additive: an older peer ignores the unknown offer fields (that property is already
|
|
116
|
+
pinned by tests) and its frames are plaintext, which the newer peer accepts while recording
|
|
117
|
+
`encrypted: false`. `PROTOCOL.md` gains a section; `envelope_version` stays `1.0` because the
|
|
118
|
+
extension rides inside the existing optional `p2p` object.
|
|
119
|
+
|
|
120
|
+
## 5. Stage 2 — the full handshake
|
|
121
|
+
|
|
122
|
+
`Noise_IKpsk2` with its actual message layout, a `psk` mixed in from the pairing secret, `mac1`/`mac2`
|
|
123
|
+
(either implemented in JS or consciously omitted, per §3), cookie reply under load, rekey timers
|
|
124
|
+
aligned with WireGuard's, and a fuzz test for the framing. Stage 1's transport encryption and replay
|
|
125
|
+
window carry over unchanged.
|
|
126
|
+
|
|
127
|
+
## 6. Stage 3 — WireGuard as an optional reachability path
|
|
128
|
+
|
|
129
|
+
Unchanged from `deploy/networking/`: the hub makes machines reachable regardless of NAT, and it does
|
|
130
|
+
**not** make them peers — traffic between two clients is forwarded by the hub. It stays a fallback for
|
|
131
|
+
reachability, and the ledger must never label tunnelled traffic as direct.
|
|
132
|
+
|
|
133
|
+
## 7. How any of this is believed
|
|
134
|
+
|
|
135
|
+
Tests that must exist before stage 1 is called done:
|
|
136
|
+
|
|
137
|
+
1. two peers with keys complete a handshake and exchange a frame;
|
|
138
|
+
2. a peer with the **wrong** static key is refused, with the reason recorded;
|
|
139
|
+
3. a replayed `HELLO` is refused;
|
|
140
|
+
4. a data frame with one bit flipped fails authentication and is dropped, not processed;
|
|
141
|
+
5. a frame whose counter was already seen is dropped (replay window);
|
|
142
|
+
6. an older peer that never learned a key still completes its task over the relay, with
|
|
143
|
+
`encrypted: false`;
|
|
144
|
+
7. a capture of the direct channel contains no plaintext marker (a string planted in the command);
|
|
145
|
+
8. rekey does not lose a frame that was in flight.
|
|
146
|
+
|
|
147
|
+
## 8. Open questions for the implementation
|
|
148
|
+
|
|
149
|
+
* Where the session key cache lives, and its bound — one live session per (peer, punch) today.
|
|
150
|
+
* Whether the relay should refuse to introduce a peer whose key has changed since the last dispatch
|
|
151
|
+
(a key-change notice is cheap and catches a silent substitution).
|
|
152
|
+
* Windows has no 0600: what the key file's protection actually is there, stated honestly in the
|
|
153
|
+
deployment notes rather than assumed.
|
|
154
|
+
|
|
155
|
+
### Raised by the v0.4.0 agent workstream, and still open
|
|
156
|
+
|
|
157
|
+
* **`envelope_sha256` currently covers the transport facts, and that is why the direct result frame is
|
|
158
|
+
not the envelope.** The direct frame carries only `{type:'task.result', task_id}`: `result_path`
|
|
159
|
+
cannot be known until the bounded `result.ack` wait finishes, the envelope is hashed when it is
|
|
160
|
+
built, and stamping `result_path` afterwards would produce a hash that does not cover its own bytes —
|
|
161
|
+
`verifyEnvelope` would then reject an envelope nobody tampered with. The forced choice was an
|
|
162
|
+
accurate `result_path` on the ledger copy at the price of posting that copy up to the bound late
|
|
163
|
+
(`p2pAckTimeoutMs`, default 2000 ms). Consequence: **the fast path saves the dispatcher a wait, not
|
|
164
|
+
the bytes**, because the dispatcher still reads the envelope from the relay. Fixing it means
|
|
165
|
+
splitting the transport facts out of the hashed region (or hashing them in a second, separate
|
|
166
|
+
digest) — a deliberate protocol decision, not a patch, and stage 1 must settle it before claiming
|
|
167
|
+
that the direct channel carries the result.
|
|
168
|
+
* **Stage 1's `encrypted` flag lands in the same `p2p` block**, so whatever is decided above decides
|
|
169
|
+
where that flag is trustworthy: inside the authenticated envelope (readable only by the dispatcher)
|
|
170
|
+
or beside it (readable by the relay too). The second is simpler; the first is the one that cannot be
|
|
171
|
+
forged by the relay.
|
|
172
|
+
* **`result.ack` has only ever been sent by a test dispatcher**, never by the production plugin when
|
|
173
|
+
that work was done — so the ack contract is proven against a faithful stand-in, not against the real
|
|
174
|
+
caller. Stage 1's rekey path makes the same assumption.
|
|
175
|
+
* `direct` mode refusing *every* relay-arrived offer is a contract interpretation, not a measurement.
|
|
176
|
+
|
|
177
|
+
## 9. Stage 1 as delivered (v0.5.3)
|
|
178
|
+
|
|
179
|
+
Eight acceptance cases from §7 exist as `test/p2p-crypto.test.mjs` (33 tests), and the gated suites went
|
|
180
|
+
from 430 tests / 427 passing to **464 / 461**, the single failure being the pre-existing bundled-python
|
|
181
|
+
case. eslint: 0 errors. **These tests earned their keep: they caught seven real defects during
|
|
182
|
+
implementation**, three of which are worth naming because each was a silent failure of exactly the kind
|
|
183
|
+
this project keeps finding —
|
|
184
|
+
|
|
185
|
+
* a replay window that **accepted replays of frame 2** (the bit shift slid the wrong way);
|
|
186
|
+
* a lookahead bound that capped our **own** send epoch;
|
|
187
|
+
* and a plain-ACK race that could **downgrade a live encrypted session to plaintext** — the one outcome
|
|
188
|
+
§4 forbids. It is now structurally impossible: an encrypted channel is never replaced by a plaintext
|
|
189
|
+
one for the same session.
|
|
190
|
+
|
|
191
|
+
### The three deviations from §4, and why each is an improvement rather than a shortcut
|
|
192
|
+
|
|
193
|
+
1. **The MACs are additionally bound to the session id and the handshake version.** As §4 was written,
|
|
194
|
+
every input to `mac` is identical for every punch between one pair of machines, so a `HELLO` captured
|
|
195
|
+
from one punch would verify in the next. No field §4 names changed; the binding was added.
|
|
196
|
+
2. **The rekey is an HKDF ratchet, not a second handshake.** §4 said "run the handshake again", which
|
|
197
|
+
costs a round trip inside the data path and a state machine to survive it. The epoch instead rides in
|
|
198
|
+
one cleartext byte and both ends derive epoch n+1 from n. Same 2^20-message / 120-second thresholds,
|
|
199
|
+
same 15-second retention of the previous key, and a test that a frame in flight across the boundary
|
|
200
|
+
still decrypts. The security property is unchanged; only the round trip is gone.
|
|
201
|
+
3. **`mac1`/`mac2` remain unimplemented**, as §3 required, and the omission is a named value in the code
|
|
202
|
+
with its consequence written down: they mitigate CPU-exhaustion denial of service on the handshake
|
|
203
|
+
path, never confidentiality or authenticity. The interim bound is a TTL- and count-capped nonce cache.
|
|
204
|
+
|
|
205
|
+
### The interface question this document raised, now decided
|
|
206
|
+
|
|
207
|
+
§8 asked where the `encrypted` flag belongs, and the answer is **inside the `p2p` block, therefore
|
|
208
|
+
inside `envelope_sha256`** — so the relay cannot rewrite it without breaking a hash the dispatcher
|
|
209
|
+
verifies. That is the stricter of the two options, and it means the flag is not in `COMPARABLE_FIELDS`
|
|
210
|
+
(it describes the transport, not the result). The consequence from §8 stands and is accepted: the direct
|
|
211
|
+
`task.result` frame still carries `{type, task_id}` rather than the envelope, because `result_path` — and
|
|
212
|
+
now `encrypted` — are only known after the acknowledgement, and an envelope hashed afterwards would not
|
|
213
|
+
cover its own bytes. **The fast path saves the dispatcher a wait, not the bytes.** Making it carry the
|
|
214
|
+
envelope means moving the transport facts out of the hashed region, which remains a deliberate protocol
|
|
215
|
+
decision nobody has needed yet.
|
|
216
|
+
|
|
217
|
+
### Honest limits, unchanged by this work
|
|
218
|
+
|
|
219
|
+
* Every socket in the new suite is **loopback**: it crosses no NAT and is not evidence that a real
|
|
220
|
+
translation can be crossed. The suite says so in its header.
|
|
221
|
+
* **Self-dial is plaintext by design**: a machine holds no peer key for itself, so a handshake would be
|
|
222
|
+
refused by its own responder and the task lost. Documented at the branch rather than left to be
|
|
223
|
+
rediscovered.
|
|
224
|
+
* A v0.4.x/v0.5.x peer is unaffected: it gets the plain channel it always had, and our side records
|
|
225
|
+
`encrypted: false` with a reason. Never silently downgraded, never dropped from the fleet.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/src/agent/agent.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from './git.mjs';
|
|
50
50
|
import { createSpool } from './spool.mjs';
|
|
51
51
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
52
|
+
import { CRYPTO_REASON, DeviceKey, PeerKeyStore } from './crypto.mjs';
|
|
52
53
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
53
54
|
import { punchSession, reverseDialSession } from './p2p.mjs';
|
|
54
55
|
import { createSigner } from '../signing.mjs';
|
|
@@ -1018,6 +1019,39 @@ export function createAgent(options) {
|
|
|
1018
1019
|
/** @type {object|null} Last snapshot handed to `onP2PStatus`, so a no-op change is not pushed. */
|
|
1019
1020
|
let lastP2PStatusJson = null;
|
|
1020
1021
|
|
|
1022
|
+
// -- v0.6.0: the machine's identity on the direct path --------------------
|
|
1023
|
+
//
|
|
1024
|
+
// Loaded here, at construction, rather than lazily at `start()`: the key file is also what a
|
|
1025
|
+
// *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
|
|
1026
|
+
// whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
|
|
1027
|
+
// ("generated the first time a machine pairs"), and a failure is never fatal — see `deviceKeyError`.
|
|
1028
|
+
const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id);
|
|
1029
|
+
/** @type {object|null} This machine's static keypair, or null when there is none. */
|
|
1030
|
+
const deviceKey = deviceKeyOutcome.key;
|
|
1031
|
+
/** @type {string|null} Why there is no key, when there is none. Reported, never thrown. */
|
|
1032
|
+
const deviceKeyError = deviceKeyOutcome.error;
|
|
1033
|
+
/**
|
|
1034
|
+
* Peer static keys, learned from offers.
|
|
1035
|
+
*
|
|
1036
|
+
* One store for the whole agent, handed to the node rather than copied: the agent learns a key when
|
|
1037
|
+
* an offer arrives (which is the only place it is ever told one) and the node needs it moments later
|
|
1038
|
+
* when it dials. Two stores would be two chances to have the key in the wrong one.
|
|
1039
|
+
*
|
|
1040
|
+
* @type {PeerKeyStore}
|
|
1041
|
+
*/
|
|
1042
|
+
const peerKeys = new PeerKeyStore();
|
|
1043
|
+
/**
|
|
1044
|
+
* What each task's direct channel actually was, for the result envelope.
|
|
1045
|
+
*
|
|
1046
|
+
* Keyed by `task_id`, alongside the channel map rather than on the channel itself, because the
|
|
1047
|
+
* envelope is built *after* `planDirectDelivery` may have released the channel — and
|
|
1048
|
+
* `p2p.encrypted` has to describe the channel that carried this attempt, not whichever channel
|
|
1049
|
+
* happens to be live when the envelope is written.
|
|
1050
|
+
*
|
|
1051
|
+
* @type {Map<string, {encrypted: boolean, reason: string|null, session: number|null, epoch: number}>}
|
|
1052
|
+
*/
|
|
1053
|
+
const p2pEncryption = new Map();
|
|
1054
|
+
|
|
1021
1055
|
/**
|
|
1022
1056
|
* Result envelopes awaiting a `result.ack` over a direct channel.
|
|
1023
1057
|
*
|
|
@@ -1105,6 +1139,17 @@ export function createAgent(options) {
|
|
|
1105
1139
|
punches_out: nodeStatus?.punches_out ?? 0,
|
|
1106
1140
|
punches_in: nodeStatus?.punches_in ?? 0,
|
|
1107
1141
|
dial_failures: nodeStatus?.dial_failures ?? 0,
|
|
1142
|
+
encryption: {
|
|
1143
|
+
enabled: deviceKey !== null,
|
|
1144
|
+
public_key: deviceKey === null ? null : deviceKey.publicKeyBase64,
|
|
1145
|
+
error: deviceKeyError,
|
|
1146
|
+
peers_known: peerKeys.size,
|
|
1147
|
+
encrypted_channels: nodeStatus?.encryption?.encrypted_channels ?? 0,
|
|
1148
|
+
plaintext_channels: nodeStatus?.encryption?.plaintext_channels ?? 0,
|
|
1149
|
+
handshakes: nodeStatus?.encryption?.handshakes ?? 0,
|
|
1150
|
+
refused: nodeStatus?.encryption?.refused ?? 0,
|
|
1151
|
+
refusals: nodeStatus?.encryption?.refusals ?? [],
|
|
1152
|
+
},
|
|
1108
1153
|
channels: p2pChannels.size,
|
|
1109
1154
|
start_error: startError,
|
|
1110
1155
|
last_error: startError ?? nodeStatus?.last_error ?? null,
|
|
@@ -1144,8 +1189,7 @@ export function createAgent(options) {
|
|
|
1144
1189
|
* the one that would actually bind a socket, so it is the one that decides, and a caller
|
|
1145
1190
|
* comparing the two configuration surfaces deserves to know they are not the same thing.
|
|
1146
1191
|
*/
|
|
1147
|
-
function configureP2P() {
|
|
1148
|
-
if (p2pMode === 'relay') return;
|
|
1192
|
+
function configureP2P() { if (p2pMode === 'relay') return;
|
|
1149
1193
|
if (p2pNode !== null) return;
|
|
1150
1194
|
if (p2pPortError !== null) {
|
|
1151
1195
|
// Loud, and before anything is built. `relay` mode above is deliberately checked first: an
|
|
@@ -1173,6 +1217,12 @@ export function createAgent(options) {
|
|
|
1173
1217
|
// an escape hatch for the node's other constructor options, and letting an unvalidated
|
|
1174
1218
|
// `bindPort` there silently win would route around the check above.
|
|
1175
1219
|
...(p2pPort === null ? {} : { bindPort: p2pPort }),
|
|
1220
|
+
// Also after `extra`, and for the same reason: the device key and the peer-key store are this
|
|
1221
|
+
// agent's identity and its view of its peers, and an escape hatch that could substitute either
|
|
1222
|
+
// one would be a way to run an agent that cannot be authenticated by the machine that dialled
|
|
1223
|
+
// it. Omitted entirely when there is no key, so the node is exactly a v0.5.x node.
|
|
1224
|
+
...(deviceKey === null ? {} : { deviceKey }),
|
|
1225
|
+
peerKeys,
|
|
1176
1226
|
};
|
|
1177
1227
|
|
|
1178
1228
|
try {
|
|
@@ -1293,11 +1343,77 @@ export function createAgent(options) {
|
|
|
1293
1343
|
};
|
|
1294
1344
|
p2pChannels.set(taskId, { channel, onClose });
|
|
1295
1345
|
channel.on('close', onClose);
|
|
1346
|
+
// v0.6.0: the encryption facts go with the channel, recorded here so every caller that wires a
|
|
1347
|
+
// channel into a task records them without having to remember to.
|
|
1348
|
+
rememberChannelEncryption(taskId, channel);
|
|
1296
1349
|
}
|
|
1297
1350
|
|
|
1298
1351
|
/** The channel a task's result can be acknowledged over, or null when there is none. */
|
|
1299
1352
|
const channelFor = (taskId) => p2pChannels.get(taskId)?.channel ?? null;
|
|
1300
1353
|
|
|
1354
|
+
/**
|
|
1355
|
+
* Remember what a task's direct channel was, encryption included (v0.6.0).
|
|
1356
|
+
*
|
|
1357
|
+
* `encrypted` is read from the channel itself, never from configuration: a machine with a keypair
|
|
1358
|
+
* still creates plaintext channels for peers it has no key for, and the ledger's job is to say what
|
|
1359
|
+
* happened rather than what was possible. `reason` is the named code from that same channel, so
|
|
1360
|
+
* `p2p.encrypted: false` always arrives with a *why*.
|
|
1361
|
+
*
|
|
1362
|
+
* It is called from both places a channel becomes a task's channel — the offer that arrived on one
|
|
1363
|
+
* and the dial that opened one — and the most recent call wins, which is right: a task's result
|
|
1364
|
+
* travels over the channel that is live *now*.
|
|
1365
|
+
*
|
|
1366
|
+
* @param {string} taskId
|
|
1367
|
+
* @param {object|null} channel
|
|
1368
|
+
*/
|
|
1369
|
+
function rememberChannelEncryption(taskId, channel) {
|
|
1370
|
+
if (typeof taskId !== 'string' || taskId === '') return;
|
|
1371
|
+
const encryption = channel?.encryption ?? null;
|
|
1372
|
+
if (encryption === null) return;
|
|
1373
|
+
p2pEncryption.set(taskId, {
|
|
1374
|
+
encrypted: encryption.encrypted === true,
|
|
1375
|
+
reason: encryption.encrypted === true ? null : (encryption.reason ?? CRYPTO_REASON.PLAINTEXT_PEER),
|
|
1376
|
+
session: Number.isInteger(channel?.session) ? channel.session : null,
|
|
1377
|
+
epoch: Number.isInteger(encryption.epoch) ? encryption.epoch : 0,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* The encryption facts of one task, or null when no channel ever existed for it.
|
|
1383
|
+
*
|
|
1384
|
+
* @param {string} taskId
|
|
1385
|
+
* @returns {object|null}
|
|
1386
|
+
*/
|
|
1387
|
+
function encryptionFor(taskId) {
|
|
1388
|
+
const record = p2pEncryption.get(taskId);
|
|
1389
|
+
return record === undefined ? null : { ...record };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Record that a task had no direct channel at all, and why (v0.6.0).
|
|
1394
|
+
*
|
|
1395
|
+
* Without this, a task executed entirely over the relay reports `encrypted: false` with no
|
|
1396
|
+
* explanation, and a reader cannot tell "this machine has no crypto" from "no direct channel was the
|
|
1397
|
+
* answer for this task" — entirely different operational facts. The reason is the *same* string
|
|
1398
|
+
* `offerTransportOf` puts in `p2p.reason`, deliberately: one sentence, decided in one place.
|
|
1399
|
+
*
|
|
1400
|
+
* It never overwrites a channel's own record: once a channel carried something for this task, which
|
|
1401
|
+
* channel it was and whether it was sealed is the more specific answer.
|
|
1402
|
+
*
|
|
1403
|
+
* @param {string} taskId
|
|
1404
|
+
* @param {string|null} reason
|
|
1405
|
+
*/
|
|
1406
|
+
function rememberNoChannel(taskId, reason) {
|
|
1407
|
+
if (typeof taskId !== 'string' || taskId === '') return;
|
|
1408
|
+
if (p2pEncryption.has(taskId)) return;
|
|
1409
|
+
p2pEncryption.set(taskId, {
|
|
1410
|
+
encrypted: false,
|
|
1411
|
+
reason: typeof reason === 'string' && reason !== '' ? reason : CRYPTO_REASON.PLAINTEXT_PEER,
|
|
1412
|
+
session: null,
|
|
1413
|
+
epoch: 0,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1301
1417
|
/**
|
|
1302
1418
|
* What a reverse dial would need, or the named reason there is nothing to dial.
|
|
1303
1419
|
*
|
|
@@ -1535,6 +1651,7 @@ export function createAgent(options) {
|
|
|
1535
1651
|
function releaseChannel(taskId) {
|
|
1536
1652
|
p2pReverseDials.delete(taskId);
|
|
1537
1653
|
p2pPeers.delete(taskId);
|
|
1654
|
+
p2pEncryption.delete(taskId);
|
|
1538
1655
|
const entry = p2pChannels.get(taskId);
|
|
1539
1656
|
if (!entry) return;
|
|
1540
1657
|
p2pChannels.delete(taskId);
|
|
@@ -2181,6 +2298,16 @@ export function createAgent(options) {
|
|
|
2181
2298
|
const record = context.p2pRecord ?? null;
|
|
2182
2299
|
const reverse = context.p2pReverse ?? null;
|
|
2183
2300
|
const dispatcherMode = offer?.p2p?.mode;
|
|
2301
|
+
/**
|
|
2302
|
+
* Was this task's direct channel sealed?
|
|
2303
|
+
*
|
|
2304
|
+
* Read from what the agent *recorded* when it held the channel, not from a live object: by the
|
|
2305
|
+
* time the envelope is built the channel may have been released, and `p2p.encrypted` has to
|
|
2306
|
+
* describe the channel that carried this attempt. `null` means no channel was ever involved —
|
|
2307
|
+
* a task executed entirely over the relay — which reports `encrypted: false` because that is the
|
|
2308
|
+
* truth about this task's data plane, whatever this machine's keypair could have done.
|
|
2309
|
+
*/
|
|
2310
|
+
const encryption = encryptionFor(offer?.task_id);
|
|
2184
2311
|
|
|
2185
2312
|
/**
|
|
2186
2313
|
* Which mechanism opened the channel that carried the offer or the result.
|
|
@@ -2206,6 +2333,17 @@ export function createAgent(options) {
|
|
|
2206
2333
|
return {
|
|
2207
2334
|
mode: typeof dispatcherMode === 'string' && dispatcherMode !== '' ? dispatcherMode : p2pMode,
|
|
2208
2335
|
offer_path: transport.offer_path,
|
|
2336
|
+
// v0.6.0, and it is the field the whole of stage 1 exists to be believed about: `true` only
|
|
2337
|
+
// when a handshake completed and the frames on this task's direct channel were sealed. There is
|
|
2338
|
+
// deliberately no "probably": a machine with a keypair that could not complete a handshake with
|
|
2339
|
+
// this peer records `false` *and* the named reason, so "the relay carried it" and "the relay
|
|
2340
|
+
// carried it because the crypto did not land" are different, readable facts.
|
|
2341
|
+
//
|
|
2342
|
+
// `encrypted` is always present, like `mode`, because a reader has to be able to tell "no
|
|
2343
|
+
// encryption" from "this build does not say" without comparing two machines' envelopes.
|
|
2344
|
+
encrypted: encryption?.encrypted === true,
|
|
2345
|
+
...(encryption === null || encryption.encrypted === true ? {} : { encryption_reason: encryption.reason }),
|
|
2346
|
+
...(encryption === null || encryption.session === null ? {} : { encryption_session: encryption.session }),
|
|
2209
2347
|
...(delivery.path === null ? {} : { result_path: delivery.path }),
|
|
2210
2348
|
...(openedBy === null ? {} : { opened_by: openedBy }),
|
|
2211
2349
|
...(reverse === null || reverse.attempted !== true
|
|
@@ -2372,8 +2510,7 @@ export function createAgent(options) {
|
|
|
2372
2510
|
// -- Task handling -------------------------------------------------------
|
|
2373
2511
|
|
|
2374
2512
|
/** @param {object} offer */
|
|
2375
|
-
function validateOffer(offer) {
|
|
2376
|
-
if (!offer || typeof offer !== 'object') return 'NOT_AN_OBJECT';
|
|
2513
|
+
function validateOffer(offer) { if (!offer || typeof offer !== 'object') return 'NOT_AN_OBJECT';
|
|
2377
2514
|
if (typeof offer.task_id !== 'string' || offer.task_id === '') return 'MISSING_TASK_ID';
|
|
2378
2515
|
if (!Array.isArray(offer.command_argv) || offer.command_argv.length === 0) return 'MISSING_COMMAND_ARGV';
|
|
2379
2516
|
if (!offer.command_argv.every((part) => typeof part === 'string')) return 'COMMAND_ARGV_NOT_STRINGS';
|
|
@@ -2870,7 +3007,12 @@ export function createAgent(options) {
|
|
|
2870
3007
|
}
|
|
2871
3008
|
}
|
|
2872
3009
|
|
|
2873
|
-
if (channelFor(offer?.task_id) === null)
|
|
3010
|
+
if (channelFor(offer?.task_id) === null) {
|
|
3011
|
+
// No channel at all, so nothing about this task's data plane was ever sealed — and the reason
|
|
3012
|
+
// recorded here is the one `offerTransportOf` already decided for `p2p.reason`.
|
|
3013
|
+
rememberNoChannel(offer?.task_id, offerTransportOf(offer, p2pStatusSnapshot()).reason);
|
|
3014
|
+
return p2pDeliveryMark();
|
|
3015
|
+
}
|
|
2874
3016
|
const direct = await sendResultDirect(offer.task_id);
|
|
2875
3017
|
if (direct.delivered) {
|
|
2876
3018
|
log('info', `result acknowledged over the direct channel for ${offer.task_id}`, {
|
|
@@ -3214,6 +3356,18 @@ export function createAgent(options) {
|
|
|
3214
3356
|
// peer tried to put on the wire is overwritten, so a relay frame can never claim the
|
|
3215
3357
|
// direct path -- the only thing that sets `'p2p'` here is the channel having delivered it.
|
|
3216
3358
|
const offer = { ...payload, p2p_transport: source.transport };
|
|
3359
|
+
// v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism — a machine
|
|
3360
|
+
// learns the keys of exactly the peers it was asked to talk to, from the offer that asked it,
|
|
3361
|
+
// and never from a config file. Learned here, before the offer is queued, because the reverse
|
|
3362
|
+
// dial starts inside `handleOffer` and a key merged any later would miss the handshake this
|
|
3363
|
+
// offer exists to enable. A malformed entry is skipped rather than fatal (`merge` says so):
|
|
3364
|
+
// one broken key must not cost this machine its ability to punch at all.
|
|
3365
|
+
if (offer.p2p !== null && typeof offer.p2p === 'object') {
|
|
3366
|
+
const learned = peerKeys.merge(offer.p2p.static_keys, source.transport === 'p2p' ? 'offer' : 'relay');
|
|
3367
|
+
if (learned > 0) {
|
|
3368
|
+
log('info', `learned ${learned} peer static key(s) from the offer for ${offer.task_id ?? '?'}`);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3217
3371
|
if (source.transport === 'p2p') {
|
|
3218
3372
|
if (typeof offer.task_id !== 'string' || offer.task_id === '') {
|
|
3219
3373
|
log('warn', 'ignoring a direct offer with no task_id');
|
|
@@ -3223,6 +3377,10 @@ export function createAgent(options) {
|
|
|
3223
3377
|
// result is produced, and a channel lost between the two would leave `result_path` null
|
|
3224
3378
|
// with no way to tell why.
|
|
3225
3379
|
rememberChannel(offer.task_id, source.channel);
|
|
3380
|
+
// ...and the *encryption* of that channel, remembered with it for the same reason. The
|
|
3381
|
+
// envelope is written after the channel may have been released, so "was this task's direct
|
|
3382
|
+
// channel sealed" has to be a fact recorded here rather than read off a live object later.
|
|
3383
|
+
rememberChannelEncryption(offer.task_id, source.channel);
|
|
3226
3384
|
}
|
|
3227
3385
|
enqueue(offer);
|
|
3228
3386
|
break;
|
|
@@ -3754,6 +3912,42 @@ export function createAgent(options) {
|
|
|
3754
3912
|
};
|
|
3755
3913
|
}
|
|
3756
3914
|
|
|
3915
|
+
/**
|
|
3916
|
+
* Load this machine's static keypair, or explain why there is none (v0.6.0).
|
|
3917
|
+
*
|
|
3918
|
+
* Never throws. A machine whose key file is unreadable is still a working W2M machine — every task
|
|
3919
|
+
* still travels over the relay — and `agent.state.p2p.encryption.error` names the file that could not
|
|
3920
|
+
* be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
|
|
3921
|
+
* complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
|
|
3922
|
+
*
|
|
3923
|
+
* The key lives beside the spool and `agent-state.json` (the `--state` directory), which is where
|
|
3924
|
+
* `identity.mjs` puts `device.json` when the agent is run with `--state`. A caller that injected a
|
|
3925
|
+
* `DeviceKey` gets that one, which is the test seam and the way a caller supplies a key from
|
|
3926
|
+
* somewhere else entirely.
|
|
3927
|
+
*
|
|
3928
|
+
* @param {object|null} injected A `DeviceKey`, or null to load or generate one from the state dir.
|
|
3929
|
+
* @param {string} stateDir
|
|
3930
|
+
* @param {string} machineId
|
|
3931
|
+
* @returns {{key: object|null, error: string|null}}
|
|
3932
|
+
*/
|
|
3933
|
+
function loadDeviceKey(injected, stateDir, machineId) {
|
|
3934
|
+
if (injected !== null && injected !== undefined) {
|
|
3935
|
+
if (typeof injected.sharedSecret !== 'function' || typeof injected.publicKeyBase64 !== 'string') {
|
|
3936
|
+
return { key: null, error: 'P2P_CRYPTO_NO_KEY: the injected deviceKey is not a DeviceKey' };
|
|
3937
|
+
}
|
|
3938
|
+
return { key: injected, error: null };
|
|
3939
|
+
}
|
|
3940
|
+
try {
|
|
3941
|
+
const loaded = DeviceKey.load({ dir: stateDir, machineId });
|
|
3942
|
+
return { key: loaded.key, error: null };
|
|
3943
|
+
} catch (error) {
|
|
3944
|
+
return {
|
|
3945
|
+
key: null,
|
|
3946
|
+
error: `P2P_CRYPTO_KEY_UNREADABLE: ${error instanceof Error ? error.message : String(error)}`,
|
|
3947
|
+
};
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3757
3951
|
/**
|
|
3758
3952
|
* `true` when the project directory can be written to.
|
|
3759
3953
|
*
|