@twinsearth/w2m-dsh-plugin 0.5.2 → 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 +29 -0
- package/PROTOCOL.md +122 -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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.3] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **The peer-to-peer data plane can now be encrypted.** Stage 1 of `docs/P2P-CRYPTO.md`: each machine
|
|
12
|
+
holds an X25519 keypair beside its device identity, the relay carries each peer's static public key
|
|
13
|
+
inside the offer it already sends — so **nobody fills in a `[Peer]` block by hand** — and the direct
|
|
14
|
+
channel completes a two-message IK-shaped handshake before a single frame is trusted. Triple DH gives
|
|
15
|
+
mutual authentication and forward secrecy; ChaCha20-Poly1305 with WireGuard's nonce layout carries the
|
|
16
|
+
frames; a 64-message replay window and an HKDF epoch ratchet (2^20 messages or 120 s, previous key
|
|
17
|
+
retained 15 s) keep it going without a round trip in the data path.
|
|
18
|
+
* A handshake failure is **terminal for that peer, never a silent downgrade to plaintext**: a peer that
|
|
19
|
+
has no key for us gets the plain channel it would have had and the ledger records
|
|
20
|
+
`p2p.encrypted: false` with a reason. A machine is never lost to a security upgrade — only told that
|
|
21
|
+
the upgrade did not complete.
|
|
22
|
+
* `PROTOCOL.md` §9; `w2m_status` reports `p2p.encryption`.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
* **The tests earned their keep: they caught seven real defects**, including a replay window that
|
|
27
|
+
accepted replays of frame 2, a lookahead bound that capped our own send epoch, and a plain-ACK race
|
|
28
|
+
that could downgrade a live encrypted session to plaintext — the exact failure the design forbids, now
|
|
29
|
+
structurally impossible.
|
|
30
|
+
* `mac1`/`mac2` are **not** implemented: keyed BLAKE2s is not exposed by `node:crypto`. They mitigate
|
|
31
|
+
CPU-exhaustion denial of service, not confidentiality or authenticity, and the omission is a named
|
|
32
|
+
value in the code rather than a silence.
|
|
33
|
+
* Every socket in the new suite is loopback and crosses no NAT; that is stated in the suite header and
|
|
34
|
+
is not evidence that a real translation can be crossed.
|
|
35
|
+
* No protocol version bump: `envelope_version` stays `1.0` and a v0.4.x/v0.5.x peer is unaffected.
|
|
7
36
|
## [0.5.2] — 2026-10-10
|
|
8
37
|
|
|
9
38
|
### Fixed
|
package/PROTOCOL.md
CHANGED
|
@@ -337,3 +337,125 @@ Step 3 比对全部"可比字段":
|
|
|
337
337
|
- 不做写回合并(`write: true` 时仅记录分支名,不做 merge)
|
|
338
338
|
- 不做跨机浏览器看板(那是设计文档的 P6)
|
|
339
339
|
- 不做 Mac 真机验证(本机无 Mac;代码跨平台但需用户在 Mac 上复验)
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## 9. P2P 数据面加密(v0.6.0 新增,**附加式扩展,不升版本**)
|
|
344
|
+
|
|
345
|
+
> **不破坏冻结。** `protocol_version` 仍为 `1`,`envelope_version` 仍为 `1.0`。本节全部内容是
|
|
346
|
+
> **可选对象 `p2p` 内部的新键**,以及对**已存在的可选字段** `p2p.encrypted` 的赋值规则;不知道
|
|
347
|
+
> 这些键的一端(v0.4.x / v0.5.x)会原样忽略它们,行为与本文件冻结时一致。
|
|
348
|
+
> 设计契约见 `docs/P2P-CRYPTO.md`(§4 是规范,§7 是验收清单)。
|
|
349
|
+
|
|
350
|
+
### 9.1 设备静态密钥
|
|
351
|
+
|
|
352
|
+
| 项 | 值 |
|
|
353
|
+
|---|---|
|
|
354
|
+
| 算法 | X25519(`generateKeyPairSync('x25519')`) |
|
|
355
|
+
| 私钥存放 | 与 `device.json` 同目录的 `device.key`,权限 0600(**Windows/NTFS 无 POSIX 位,见下**) |
|
|
356
|
+
| 生成时机 | 首次配对时生成,此后永不更换;文件损坏 → 硬错误,**绝不静默换新**(换新会使所有认识旧公钥的peer拒绝本机) |
|
|
357
|
+
| 公钥编码 | 32 字节 X25519 公钥的标准 base64(44 字符,含 `=` 填充) |
|
|
358
|
+
|
|
359
|
+
`device.key` 内容(JSON,原子写入):
|
|
360
|
+
```json
|
|
361
|
+
{ "format": "w2m-device-key/1", "algo": "x25519", "machine_id": "<uuid>", "private_key": "<base64 32B>" }
|
|
362
|
+
```
|
|
363
|
+
只存私钥标量,公钥由私钥推导——手改文件无法发布与私钥不匹配的公钥。
|
|
364
|
+
|
|
365
|
+
**0600 的诚实表述**:在 POSIX 平台上按 0600 写入。**Windows 无 POSIX 权限位**,`chmod` 是
|
|
366
|
+
尽力而为且不报错,该文件的实际保护来自用户 profile 目录继承的 ACL,而非本实现。能读到该 profile
|
|
367
|
+
目录的本地用户即可读到该私钥。
|
|
368
|
+
|
|
369
|
+
### 9.2 公钥分发(无人手工填 `[Peer]`)
|
|
370
|
+
|
|
371
|
+
公钥随**已有的**两条信令一起走,没有新端点:
|
|
372
|
+
|
|
373
|
+
| 位置 | 字段 | 说明 |
|
|
374
|
+
|---|---|---|
|
|
375
|
+
| `POST /v1/peer/announce` 请求体 | `static_key` | 可选。非 32 字节 base64 X25519 公钥 → `BAD_REQUEST`(`BAD_STATIC_KEY`)。**缺省即缺省**,不写 `null`——"没有密钥"与"密钥未知"不能同值 |
|
|
376
|
+
| `GET /v1/peer/{machine_id}` 响应 `peer` | `static_key` | 与 `candidates` 同级;该机未公告密钥时**该键不存在** |
|
|
377
|
+
| `POST /v1/task` 请求体 `p2p` | `static_keys` | `{ "<machine_id>": "<base64 公钥>" }`。中继按既有规则**原样转发**(`validateTaskRouting` 只校验 `p2p.mode` 并整体复制),不解释该键 |
|
|
378
|
+
| `task.offer` 帧 `p2p` | `static_keys` | 同上;下发帧在此基础上追加**该帧目标机**的公钥 |
|
|
379
|
+
|
|
380
|
+
一台机器由此从"要求它谈话的那份 offer"里学到**恰好那些 peer** 的公钥,无需任何本地配置。
|
|
381
|
+
|
|
382
|
+
### 9.3 握手(两帧,Noise IK 形状)
|
|
383
|
+
|
|
384
|
+
握手消息**寄生在既有帧类型内**:`HELLO`/`HELLO_ACK` 的 **body 首字节为魔数 `0xA5`** 即为握手消息;
|
|
385
|
+
**body 为空**即为 v0.5.x 的普通打洞。v0.5.x 端从不读 body,因此对握手 `HELLO` 会回普通 `HELLO_ACK`;
|
|
386
|
+
本端在无对端密钥时也回普通 `HELLO_ACK`。两端都无需预先知道对方是哪个版本。
|
|
387
|
+
|
|
388
|
+
| 帧 | body 布局(大端) | 长度 |
|
|
389
|
+
|---|---|---|
|
|
390
|
+
| `HELLO` | `A5 \| ver u8 \| session u32 \| e_i[32] \| idLen u8 \| machineId \| mac[48]` | 88 + idLen |
|
|
391
|
+
| `HELLO_ACK` | 同上,`e_r` 替代 `e_i` | 88 + idLen |
|
|
392
|
+
| `REFUSE` | `A5 \| ver u8 \| session u32 \| code u8 \| idLen u8 \| machineId` | 8 + idLen |
|
|
393
|
+
|
|
394
|
+
`mac` = `AEAD(k0, nonce=0, BLAKE2s-256(prologue ‖ ver u8 ‖ session u32 ‖ s_i.pub ‖ s_r.pub))`,48 字节
|
|
395
|
+
(32 密文 + 16 tag)。`mac'` = 同 `k0`,消息为 `BLAKE2s-256(HELLO ‖ e_r)`。
|
|
396
|
+
`k0 = HKDF-SHA256(ECDH(s_i, s_r))`;会话密钥 `k = HKDF-SHA256(ECDH(e_i,e_r) ‖ ECDH(s_i,e_r) ‖ ECDH(e_i,s_r))`
|
|
397
|
+
展开为四个互不相同的 32 字节子密钥(两个握手 MAC、两个方向的数据密钥)。
|
|
398
|
+
|
|
399
|
+
> **相对 `docs/P2P-CRYPTO.md` §4 的两处偏离,均属更强而非更弱**:MAC 额外绑定了 `session` 与握手
|
|
400
|
+
> 版本(否则同一对机器在任何一次打洞中捕获的 `HELLO` 都可在下一次重放);`mac1`/`mac2` **未实现**
|
|
401
|
+
> ——keyed BLAKE2s 未被 `node:crypto` 暴露,它们缓解的是握手路径的 **CPU 耗尽 DoS**,与机密性、
|
|
402
|
+
> 真实性无关。该省略以常量 `HANDSHAKE_MAC_FIELDS_OMITTED` 落在代码里,不是沉默。
|
|
403
|
+
|
|
404
|
+
`REFUSE.code`:`1`=UNKNOWN_PEER、`2`=BAD_MAC、`3`=REPLAY、`4`=NO_KEY、`5`=VERSION、`6`=NO_PEER_KEY。
|
|
405
|
+
`REFUSE` 本身**不认证**(它正是在无法认证对端时发出的),因此不携带任何证明。
|
|
406
|
+
|
|
407
|
+
### 9.4 数据面帧加密
|
|
408
|
+
|
|
409
|
+
启用后**所有帧类型**(`DATA`/`ACK`/`DONE`/`PING`/`PONG`/`BYE`)一律加密,不只 `DATA`:
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
frame = kind u8 | session u32 | A5 | epoch u8 | counter u64le | ciphertext | tag[16]
|
|
413
|
+
```
|
|
414
|
+
- 前 5 字节**明文但参与认证**(AAD):改 kind 或 session 即验证失败。
|
|
415
|
+
- nonce = `00000000 ‖ counter u64le`(12 字节),ChaCha20-Poly1305,tag 16 字节。整帧开销 **26 字节**。
|
|
416
|
+
- `counter` 每方向独立、从 0 起、**跨 epoch 单调不减**(不复位)。
|
|
417
|
+
- 64 条滑动重放窗口;窗口外或已见过的计数器 → 丢弃(`P2P_CRYPTO_REPLAYED_FRAME`)。
|
|
418
|
+
- **重协商(rekey)**:发送满 2^20 帧或满 120 秒即 `epoch+1`,双方各自沿 HKDF 链推导,无需往返;
|
|
419
|
+
旧 epoch 密钥保留 15 秒以接住在途帧。未认证帧**绝不**用其它 epoch 密钥重试。
|
|
420
|
+
|
|
421
|
+
### 9.5 拒绝与回退(**拒绝永远具名,回退永不静默**)
|
|
422
|
+
|
|
423
|
+
| 码 | 含义 |
|
|
424
|
+
|---|---|
|
|
425
|
+
| `P2P_CRYPTO_NO_KEY` | 本机无密钥,或对端无本机密钥 → 明文通道,已记录 |
|
|
426
|
+
| `P2P_CRYPTO_UNKNOWN_PEER` | 握手来自本机未持有公钥的 `machine_id` |
|
|
427
|
+
| `P2P_CRYPTO_BAD_MAC` | 对端已知但证明不成立(密钥过期或有人在冒充)→ **不建通道** |
|
|
428
|
+
| `P2P_CRYPTO_REPLAY` | 同一 `e_i` 二次出现;重传会收到**逐字节相同**的应答 |
|
|
429
|
+
| `P2P_CRYPTO_AUTH_FAILED` | 数据帧 tag 校验失败 → 丢弃,不处理 |
|
|
430
|
+
| `P2P_CRYPTO_REPLAYED_FRAME` | 计数器已在窗口内 |
|
|
431
|
+
| `P2P_CRYPTO_STALE_EPOCH` / `EPOCH_TOO_FAR` | 密钥已过期 / epoch 超出推导上限 |
|
|
432
|
+
| `P2P_CRYPTO_HANDSHAKE_TIMEOUT` | 路径已通但握手未完成 → 不建通道 |
|
|
433
|
+
| `P2P_CRYPTO_VERSION_MISMATCH` / `MALFORMED` | 版本或报文形状不可用 |
|
|
434
|
+
| `P2P_CRYPTO_PLAINTEXT_PEER` | 对端未学得密钥(v0.5.x 情形)→ 明文通道 |
|
|
435
|
+
|
|
436
|
+
**回退规则**:握手失败 → 任务**照旧走中继**,信封记录 `p2p.encrypted: false` 与原因;机器**绝不**
|
|
437
|
+
因为安全升级而丢失。唯一不允许的降级是"已持有对端密钥、握手失败后仍建明文通道"——那是一次攻击者
|
|
438
|
+
可触发的降级。
|
|
439
|
+
|
|
440
|
+
### 9.6 信封字段
|
|
441
|
+
|
|
442
|
+
`p2p` 对象新增(均为可选;`encrypted` 恒存在):
|
|
443
|
+
|
|
444
|
+
| 字段 | 类型 | 含义 |
|
|
445
|
+
|---|---|---|
|
|
446
|
+
| `encrypted` | bool | **仅当**该任务的直连通道确实完成握手并被密封时为 `true` |
|
|
447
|
+
| `encryption_reason` | string | `encrypted: false` 时的具名原因(`P2P_CRYPTO_*` 或直接路径未建立的原因) |
|
|
448
|
+
| `encryption_session` | number | 可选。承载该任务的会话 id |
|
|
449
|
+
|
|
450
|
+
`encrypted` 进入 `p2p` 块,因此**在 `envelope_sha256` 覆盖范围之内**——中继无法在不破坏哈希的前提下
|
|
451
|
+
改写它。它也**不进入** `COMPARABLE_FIELDS`:传输路径不是执行结果。
|
|
452
|
+
|
|
453
|
+
### 9.7 兼容性矩阵
|
|
454
|
+
|
|
455
|
+
| 对端 | 结果 |
|
|
456
|
+
|---|---|
|
|
457
|
+
| v0.4.x / v0.5.x(无 `static_keys`) | 本端发**普通** `HELLO`,明文通道,任务照常完成,`p2p.encrypted: false` + 具名原因 |
|
|
458
|
+
| v0.6.0(`static_keys` 齐备) | 握手完成,通道密封,`p2p.encrypted: true` |
|
|
459
|
+
| v0.6.0,但本端无对端公钥 | 对端回 `REFUSE(6)`+普通 `HELLO_ACK` → 明文通道,原因为 `P2P_CRYPTO_NO_KEY` |
|
|
460
|
+
| 公钥错误 / 有人在冒充 | `REFUSE(2)` → **不建通道**,任务走中继,账本记 `encrypted: false` + `P2P_CRYPTO_BAD_MAC` |
|
|
461
|
+
| 自打洞(relay 把任务租给下发机自己) | 明文通道(自连接无路径可保护,且不得失败)
|
|
@@ -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",
|