@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.
@@ -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.1",
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",
@@ -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) return p2pDeliveryMark();
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
  *