@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
package/src/plugin/tools.mjs
CHANGED
|
@@ -74,6 +74,10 @@ import {
|
|
|
74
74
|
// another's, so a second implementation of the formula that drifted by one input would make every
|
|
75
75
|
// reverse dial time out while both ends insisted they were sending.
|
|
76
76
|
import { mintPunchId, punchSession } from '../agent/p2p.mjs';
|
|
77
|
+
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
78
|
+
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
79
|
+
// implementations of a handshake are two chances to disagree about it.
|
|
80
|
+
import { DeviceKey } from '../agent/crypto.mjs';
|
|
77
81
|
import { parseServer } from '../agent/stun.mjs';
|
|
78
82
|
// The result-deduplication identity the relay mints. Imported rather than re-implemented: the value
|
|
79
83
|
// is compared byte for byte by the executor, so a second copy of the formula that drifted by one
|
|
@@ -115,7 +119,7 @@ const MAX_STAGES = 16;
|
|
|
115
119
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
116
120
|
* overrides it at runtime for tests and packagers.
|
|
117
121
|
*/
|
|
118
|
-
const PLUGIN_VERSION = '0.5.
|
|
122
|
+
const PLUGIN_VERSION = '0.5.3';
|
|
119
123
|
|
|
120
124
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
121
125
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -1077,9 +1081,27 @@ function createP2PTransport(cfg) {
|
|
|
1077
1081
|
* relay and a v0.4.x agent ignore keys they do not read. `relay` mode publishes `{mode}` alone: there
|
|
1078
1082
|
* is no punch, and saying so is better than publishing a stale one.
|
|
1079
1083
|
*
|
|
1084
|
+
* v0.6.0 adds one more key to the same block, and it is the whole of the key distribution problem:
|
|
1085
|
+
*
|
|
1086
|
+
* `static_keys: { [machine_id]: base64(x25519 public key) }`
|
|
1087
|
+
*
|
|
1088
|
+
* WHY A MAP RATHER THAN ONE KEY
|
|
1089
|
+
*
|
|
1090
|
+
* The executor needs the *dispatcher's* key before it can dial back, and the dispatcher needs the
|
|
1091
|
+
* *executor's* key before it can push — and at dispatch time the relay has not yet said which
|
|
1092
|
+
* machines the task went to (`leases` come back in the same response that creates it). So the block
|
|
1093
|
+
* carries this machine's own key, which is known before the request is written, and
|
|
1094
|
+
* {@link offerP2PFor} adds the target's once the lease names it. Reading the field is therefore
|
|
1095
|
+
* "look up the machine you are about to talk to", never "assume the only entry is the peer" — a
|
|
1096
|
+
* machine that receives an offer for a fleet of three has three entries and uses one.
|
|
1097
|
+
*
|
|
1098
|
+
* What that buys, in the document's own terms: no `[Peer]` block for anyone to fill in. A machine
|
|
1099
|
+
* learns the keys of exactly the peers it has been asked to talk to, from the offer that asked it.
|
|
1100
|
+
*
|
|
1080
1101
|
* @param {ReturnType<typeof readConfig>} cfg
|
|
1081
1102
|
* @param {object} runtime The P2P runtime handle from {@link createP2PRuntime}.
|
|
1082
|
-
* @returns {{mode: string, punch?: string, candidates?: Array<{address:string, port:number}
|
|
1103
|
+
* @returns {{mode: string, punch?: string, candidates?: Array<{address:string, port:number}>,
|
|
1104
|
+
* static_keys?: Record<string, string>}}
|
|
1083
1105
|
*/
|
|
1084
1106
|
function buildP2POfferBlock(cfg, runtime) {
|
|
1085
1107
|
const block = { mode: cfg.p2pMode };
|
|
@@ -1096,6 +1118,14 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1096
1118
|
if (Array.isArray(candidates) && candidates.length > 0) {
|
|
1097
1119
|
block.candidates = candidates.map((candidate) => ({ address: candidate.address, port: candidate.port }));
|
|
1098
1120
|
}
|
|
1121
|
+
// This machine's own static public key. Absent (not null) when there is none, so the wire stays
|
|
1122
|
+
// byte-identical to v0.5.x for a machine that has no key — an additive change has to be absent to
|
|
1123
|
+
// be additive.
|
|
1124
|
+
const own = runtime?.encryption?.public_key;
|
|
1125
|
+
const machineId = runtime?.machineId;
|
|
1126
|
+
if (typeof own === 'string' && own !== '' && typeof machineId === 'string' && machineId !== '') {
|
|
1127
|
+
block.static_keys = { [machineId]: own };
|
|
1128
|
+
}
|
|
1099
1129
|
return block;
|
|
1100
1130
|
}
|
|
1101
1131
|
|
|
@@ -1108,16 +1138,25 @@ function buildP2POfferBlock(cfg, runtime) {
|
|
|
1108
1138
|
* dispatcher is not filtering for would be answered by its `accept()` at best — a second session,
|
|
1109
1139
|
* a second channel, and two live sessions for one task where one would do.
|
|
1110
1140
|
*
|
|
1141
|
+
* v0.6.0 adds the *target's* key to `static_keys` when the caller supplies one, which is the half of
|
|
1142
|
+
* the distribution the published block could not carry: the dispatcher learns the executor's key from
|
|
1143
|
+
* the relay's peer record and hands it over in the frame addressed to that machine.
|
|
1144
|
+
*
|
|
1111
1145
|
* @param {unknown} published The block from {@link buildP2POfferBlock}, or whatever the caller sent.
|
|
1112
|
-
* @param {{taskId: string, machineId: string}} identity
|
|
1146
|
+
* @param {{taskId: string, machineId: string, peerKey?: string|null}} identity
|
|
1113
1147
|
* @returns {object|null}
|
|
1114
1148
|
*/
|
|
1115
|
-
function offerP2PFor(published, { taskId, machineId }) {
|
|
1149
|
+
function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
|
|
1116
1150
|
if (published === null || published === undefined) return null;
|
|
1117
1151
|
if (typeof published !== 'object' || Array.isArray(published)) return null;
|
|
1118
1152
|
const block = { ...published };
|
|
1119
1153
|
const session = punchSession({ punch: block.punch, taskId, machineId });
|
|
1120
1154
|
if (session !== null) block.session = session;
|
|
1155
|
+
if (typeof peerKey === 'string' && peerKey !== '') {
|
|
1156
|
+
const keys = block.static_keys !== null && typeof block.static_keys === 'object' ? { ...block.static_keys } : {};
|
|
1157
|
+
keys[machineId] = peerKey;
|
|
1158
|
+
block.static_keys = keys;
|
|
1159
|
+
}
|
|
1121
1160
|
return block;
|
|
1122
1161
|
}
|
|
1123
1162
|
|
|
@@ -1142,9 +1181,11 @@ function offerP2PFor(published, { taskId, machineId }) {
|
|
|
1142
1181
|
* @param {object} options.lease
|
|
1143
1182
|
* @param {object} options.payload The validated `POST /v1/task` body this plugin sent.
|
|
1144
1183
|
* @param {string|null} options.originMachineId
|
|
1184
|
+
* @param {string|null} [options.peerKey] The target's static public key (v0.6.0), added to the
|
|
1185
|
+
* frame's `p2p.static_keys` under that machine's id.
|
|
1145
1186
|
* @returns {{frame: object|null, skipped: string|null}}
|
|
1146
1187
|
*/
|
|
1147
|
-
function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
1188
|
+
function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = null }) {
|
|
1148
1189
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1149
1190
|
if (machineId === null) return { frame: null, skipped: 'the relay returned a lease with no machine_id' };
|
|
1150
1191
|
|
|
@@ -1183,7 +1224,7 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
|
1183
1224
|
requirements: payload.requirements,
|
|
1184
1225
|
compare_policy: payload.compare_policy,
|
|
1185
1226
|
origin_machine_id: originMachineId,
|
|
1186
|
-
p2p: offerP2PFor(payload.p2p, { taskId, machineId }),
|
|
1227
|
+
p2p: offerP2PFor(payload.p2p, { taskId, machineId, peerKey }),
|
|
1187
1228
|
},
|
|
1188
1229
|
skipped: null,
|
|
1189
1230
|
};
|
|
@@ -1201,19 +1242,55 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
|
|
|
1201
1242
|
*
|
|
1202
1243
|
* @param {P2PNode} node
|
|
1203
1244
|
* @param {object} frame
|
|
1204
|
-
* @
|
|
1245
|
+
* @param {{peerKey?: string|null, peerKeys?: object|null}} [crypto]
|
|
1246
|
+
* `peerKey` is the target's static public key, published in this very frame
|
|
1247
|
+
* (`frame.p2p.static_keys[machine_id]`) so the executor can dial back; it is handed to `dial()` as
|
|
1248
|
+
* well because the *dispatcher* is the initiator here and needs the executor's key to handshake.
|
|
1249
|
+
* `peerKeys` is the node's own store, taught the same key so the accept side can verify the
|
|
1250
|
+
* executor's handshake later without a second relay lookup.
|
|
1251
|
+
* @returns {Promise<{ok: boolean, error: string|null, session: number|null, reused: boolean,
|
|
1252
|
+
* encrypted: boolean, encryption_reason: string|null}>}
|
|
1205
1253
|
*/
|
|
1206
|
-
async function pushOffer(node, frame) {
|
|
1254
|
+
async function pushOffer(node, frame, crypto = {}) {
|
|
1207
1255
|
const session = Number.isInteger(frame?.p2p?.session) && frame.p2p.session > 0 ? frame.p2p.session : undefined;
|
|
1256
|
+
const peerKey = typeof crypto.peerKey === 'string' && crypto.peerKey !== '' ? crypto.peerKey : null;
|
|
1257
|
+
if (peerKey !== null && crypto.peerKeys !== null && crypto.peerKeys !== undefined) {
|
|
1258
|
+
// Recorded before the dial: the executor's handshake answer arrives on the same socket, and the
|
|
1259
|
+
// `accept()` loop that has to verify it runs on this node. A key learned only from the relay's
|
|
1260
|
+
// answer would be a second lookup racing that datagram.
|
|
1261
|
+
crypto.peerKeys.set(frame.machine_id, peerKey, 'relay');
|
|
1262
|
+
}
|
|
1263
|
+
// The dial's own options, built here rather than inline because the *presence* of `crypto` is the
|
|
1264
|
+
// decision: `P2PNode.dial` arms a handshake only when this dispatcher holds a key for that peer, and
|
|
1265
|
+
// a peer with no published key is dialled exactly as v0.5.x dialled it — plaintext, recorded, and
|
|
1266
|
+
// never a failure. That is §4's fallback clause applied to the push: a fleet must never lose a
|
|
1267
|
+
// machine to a security upgrade. It is not a silent downgrade, because the peer never offered a key
|
|
1268
|
+
// to downgrade *from*; a peer whose key we hold and whose handshake fails produces `ok: false` and
|
|
1269
|
+
// no channel at all.
|
|
1270
|
+
const dialOptions = session === undefined ? {} : { session };
|
|
1208
1271
|
try {
|
|
1209
|
-
const dialled = await node.dial(frame.machine_id,
|
|
1272
|
+
const dialled = await node.dial(frame.machine_id, dialOptions);
|
|
1210
1273
|
if (!dialled.ok) {
|
|
1211
|
-
return {
|
|
1274
|
+
return {
|
|
1275
|
+
ok: false,
|
|
1276
|
+
error: dialled.error ?? 'P2P_DIAL_FAILED',
|
|
1277
|
+
session: dialled.session ?? null,
|
|
1278
|
+
reused: false,
|
|
1279
|
+
encrypted: false,
|
|
1280
|
+
encryption_reason: dialled.encryption_reason ?? null,
|
|
1281
|
+
};
|
|
1212
1282
|
}
|
|
1213
1283
|
const channel = dialled.channel;
|
|
1214
1284
|
try {
|
|
1215
1285
|
await channel.send(JSON.stringify(frame));
|
|
1216
|
-
return {
|
|
1286
|
+
return {
|
|
1287
|
+
ok: true,
|
|
1288
|
+
error: null,
|
|
1289
|
+
session: dialled.session ?? null,
|
|
1290
|
+
reused: dialled.reused === true,
|
|
1291
|
+
encrypted: channel?.encryption?.encrypted === true,
|
|
1292
|
+
encryption_reason: channel?.encryption?.reason ?? null,
|
|
1293
|
+
};
|
|
1217
1294
|
} finally {
|
|
1218
1295
|
// The offer is one frame over a reliable channel: holding the channel open would keep a NAT
|
|
1219
1296
|
// mapping alive for a conversation that is over, and the result comes back the responder's way.
|
|
@@ -1225,6 +1302,8 @@ async function pushOffer(node, frame) {
|
|
|
1225
1302
|
error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}`,
|
|
1226
1303
|
session: session ?? null,
|
|
1227
1304
|
reused: false,
|
|
1305
|
+
encrypted: false,
|
|
1306
|
+
encryption_reason: null,
|
|
1228
1307
|
};
|
|
1229
1308
|
}
|
|
1230
1309
|
}
|
|
@@ -1238,11 +1317,19 @@ async function pushOffer(node, frame) {
|
|
|
1238
1317
|
* were never handed the command, and sending them an offer would be asking them to run work the
|
|
1239
1318
|
* relay decided they must not run.
|
|
1240
1319
|
*
|
|
1320
|
+
* v0.6.0: each target's static key is fetched from the relay's peer record before the push, which is
|
|
1321
|
+
* the one moment the dispatcher learns the key of a machine it did not already know. It is fetched
|
|
1322
|
+
* **here** rather than sourced from the stored `p2p` block because the block is written *before* the
|
|
1323
|
+
* relay decides which machines the task goes to — the offer that carries the key is the one addressed
|
|
1324
|
+
* to the machine that key belongs to. A missing key is not an error: the push proceeds and the
|
|
1325
|
+
* executor records `P2P_CRYPTO_NO_KEY` for its plaintext channel, which is the documented outcome for
|
|
1326
|
+
* a peer that never announced.
|
|
1327
|
+
*
|
|
1241
1328
|
* @param {P2PNode} node
|
|
1242
1329
|
* @param {object} options
|
|
1243
1330
|
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order.
|
|
1244
1331
|
*/
|
|
1245
|
-
async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
1332
|
+
async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
|
|
1246
1333
|
const out = [];
|
|
1247
1334
|
for (const lease of leases) {
|
|
1248
1335
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
@@ -1259,12 +1346,13 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
|
1259
1346
|
});
|
|
1260
1347
|
continue;
|
|
1261
1348
|
}
|
|
1262
|
-
const
|
|
1349
|
+
const peerKey = await readPeerStaticKey(getJson, machineId);
|
|
1350
|
+
const built = buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey });
|
|
1263
1351
|
if (built.frame === null) {
|
|
1264
1352
|
out.push({ machine_id: machineId, ok: false, error: built.skipped });
|
|
1265
1353
|
continue;
|
|
1266
1354
|
}
|
|
1267
|
-
const result = await pushOffer(node, built.frame);
|
|
1355
|
+
const result = await pushOffer(node, built.frame, { peerKey, peerKeys: node?.peerKeys ?? null });
|
|
1268
1356
|
out.push({
|
|
1269
1357
|
machine_id: machineId,
|
|
1270
1358
|
ok: result.ok,
|
|
@@ -1277,11 +1365,42 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
|
|
|
1277
1365
|
// True when the executor's dial got there first and this push travelled over the channel that
|
|
1278
1366
|
// dial opened — a two-way punch that worked, reported as such rather than as a plain push.
|
|
1279
1367
|
reused: result.reused === true,
|
|
1368
|
+
// v0.6.0: whether the offer actually travelled sealed, and why not when it did not. Reported
|
|
1369
|
+
// per machine because it is a per-machine fact: one peer can be encrypted and the next one a
|
|
1370
|
+
// v0.5.x build in the same dispatch.
|
|
1371
|
+
encrypted: result.encrypted === true,
|
|
1372
|
+
...(result.encryption_reason === null || result.encryption_reason === undefined
|
|
1373
|
+
? {}
|
|
1374
|
+
: { encryption_reason: result.encryption_reason }),
|
|
1375
|
+
...(peerKey === null ? {} : { static_key: 'published' }),
|
|
1280
1376
|
});
|
|
1281
1377
|
}
|
|
1282
1378
|
return out;
|
|
1283
1379
|
}
|
|
1284
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* Read one machine's static public key from the relay's peer record, or explain why not.
|
|
1383
|
+
*
|
|
1384
|
+
* Never throws and never fails a dispatch: a relay that answers 404 (the peer never announced, or its
|
|
1385
|
+
* announcement expired between the task and the push) leaves this machine unencrypted for that peer
|
|
1386
|
+
* and says so, which is precisely §4's "a fleet must never lose a machine because a security upgrade
|
|
1387
|
+
* could not complete".
|
|
1388
|
+
*
|
|
1389
|
+
* @param {((path: string) => Promise<object>)|null} getJson
|
|
1390
|
+
* @param {string} machineId
|
|
1391
|
+
* @returns {Promise<string|null>}
|
|
1392
|
+
*/
|
|
1393
|
+
async function readPeerStaticKey(getJson, machineId) {
|
|
1394
|
+
if (typeof getJson !== 'function') return null;
|
|
1395
|
+
try {
|
|
1396
|
+
const answer = await getJson(`/v1/peer/${encodeURIComponent(machineId)}`);
|
|
1397
|
+
const key = answer?.json?.peer?.static_key;
|
|
1398
|
+
return typeof key === 'string' && key !== '' ? key : null;
|
|
1399
|
+
} catch {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1285
1404
|
/**
|
|
1286
1405
|
* Build the object the tools reach the direct path through, and register its effect disposer.
|
|
1287
1406
|
*
|
|
@@ -1381,6 +1500,40 @@ function createP2PRuntime(ctx, cfg, block = {}) {
|
|
|
1381
1500
|
return runtime;
|
|
1382
1501
|
}
|
|
1383
1502
|
|
|
1503
|
+
/**
|
|
1504
|
+
* Read the dispatcher's own static keypair, or explain why there is none (v0.6.0).
|
|
1505
|
+
*
|
|
1506
|
+
* The key lives beside `device.json`, which is the one directory this plugin already knows how to
|
|
1507
|
+
* find (§ the note on `readDevice`): a `stateDir` when the operator set one, otherwise the
|
|
1508
|
+
* `DSH_HOME/xclient` default. Generating it here is the same "generated the first time a machine
|
|
1509
|
+
* pairs" rule the agent follows, and the two processes read the *same file*, so a machine that both
|
|
1510
|
+
* dispatches and executes has one identity on the direct path rather than two.
|
|
1511
|
+
*
|
|
1512
|
+
* Never throws. A caller cannot fix an unreadable key file from a tool call, and a dispatch that
|
|
1513
|
+
* refused because a *security upgrade* could not complete would be exactly the fleet-losing failure
|
|
1514
|
+
* §4 forbids — so the failure becomes a reason string, the node runs without a key, and every offer
|
|
1515
|
+
* this machine publishes is plainly labelled as having none.
|
|
1516
|
+
*
|
|
1517
|
+
* @param {object|null} injected Test seam: a `DeviceKey`, or null to use the file.
|
|
1518
|
+
* @param {string|null} deviceFile Absolute path of `device.json`, or null when unknown.
|
|
1519
|
+
* @returns {{key: object|null, error: string|null}}
|
|
1520
|
+
*/
|
|
1521
|
+
function loadDeviceKey(injected, deviceFile) {
|
|
1522
|
+
if (injected !== null && injected !== undefined) return { key: injected, error: null };
|
|
1523
|
+
if (typeof deviceFile !== 'string' || deviceFile === '') {
|
|
1524
|
+
return { key: null, error: 'P2P_CRYPTO_NO_KEY: device.json was not located, so device.key has no directory' };
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
const loaded = DeviceKey.load({ dir: path.dirname(deviceFile) });
|
|
1528
|
+
return { key: loaded.key, error: null };
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
return {
|
|
1531
|
+
key: null,
|
|
1532
|
+
error: `P2P_CRYPTO_KEY_UNREADABLE: ${error instanceof Error ? error.message : String(error)}`,
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1384
1537
|
/**
|
|
1385
1538
|
* Read the identity, build the node, and start it.
|
|
1386
1539
|
*
|
|
@@ -1406,6 +1559,19 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1406
1559
|
return runtime.start;
|
|
1407
1560
|
}
|
|
1408
1561
|
|
|
1562
|
+
// v0.6.0: the dispatcher's own static keypair, beside device.json. A failure here is *not* fatal
|
|
1563
|
+
// and must not stop the node: a machine with an unreadable key file is still a working W2M machine
|
|
1564
|
+
// whose tasks travel over the relay, which is the same rule every other crypto failure follows.
|
|
1565
|
+
// What it must not do is pretend: the key stays null, `runtime.encryption.reason` names the
|
|
1566
|
+
// failure, and every offer this machine publishes says no keys.
|
|
1567
|
+
const keyLoad = loadDeviceKey(block.deviceKey ?? null, identity.path);
|
|
1568
|
+
runtime.deviceKey = keyLoad.key;
|
|
1569
|
+
runtime.encryption = {
|
|
1570
|
+
enabled: keyLoad.key !== null,
|
|
1571
|
+
public_key: keyLoad.key === null ? null : keyLoad.key.publicKeyBase64,
|
|
1572
|
+
error: keyLoad.error,
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1409
1575
|
const transport = createP2PTransport(cfg);
|
|
1410
1576
|
// `ctx.effect` owns the node when the host provides it, and the fallback is the same ownership
|
|
1411
1577
|
// with a shorter lifetime: a host that provides no effect (an older Cordis, or a bare test
|
|
@@ -1418,6 +1584,7 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1418
1584
|
getJson: transport.getJson,
|
|
1419
1585
|
stunServers: cfg.stunServers,
|
|
1420
1586
|
log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
|
|
1587
|
+
...(keyLoad.key === null ? {} : { deviceKey: keyLoad.key }),
|
|
1421
1588
|
...block,
|
|
1422
1589
|
});
|
|
1423
1590
|
|
|
@@ -1604,6 +1771,22 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
|
|
|
1604
1771
|
punches_in: status?.punches_in ?? 0,
|
|
1605
1772
|
dial_failures: status?.dial_failures ?? 0,
|
|
1606
1773
|
transport: p2pTransportFacts(runtime.node),
|
|
1774
|
+
// v0.6.0: the crypto facts, named one by one rather than mirrored wholesale. `enabled` is about
|
|
1775
|
+
// *this* machine's key; `encrypted_channels` and `plaintext_channels` are about the channels it
|
|
1776
|
+
// has actually made, and they can both be non-zero on a mixed fleet. `error` is the reason there
|
|
1777
|
+
// is no key when there is none — a reader has to be able to tell "this build has no crypto" from
|
|
1778
|
+
// "the key file is unreadable" without reading a log.
|
|
1779
|
+
encryption: {
|
|
1780
|
+
enabled: runtime.encryption?.enabled === true,
|
|
1781
|
+
public_key: runtime.encryption?.public_key ?? null,
|
|
1782
|
+
error: runtime.encryption?.error ?? null,
|
|
1783
|
+
peers_known: status?.encryption?.peers_known ?? 0,
|
|
1784
|
+
handshakes: status?.encryption?.handshakes ?? 0,
|
|
1785
|
+
encrypted_channels: status?.encryption?.encrypted_channels ?? 0,
|
|
1786
|
+
plaintext_channels: status?.encryption?.plaintext_channels ?? 0,
|
|
1787
|
+
refused: status?.encryption?.refused ?? 0,
|
|
1788
|
+
refusals: Array.isArray(status?.encryption?.refusals) ? status.encryption.refusals : [],
|
|
1789
|
+
},
|
|
1607
1790
|
inbox: {
|
|
1608
1791
|
dir: runtime.inboxDir,
|
|
1609
1792
|
depth: inboxDepth,
|
|
@@ -3140,6 +3323,9 @@ export async function apply(ctx, config = {}) {
|
|
|
3140
3323
|
leases: Array.isArray(result.json?.leases) ? result.json.leases : [],
|
|
3141
3324
|
payload,
|
|
3142
3325
|
originMachineId,
|
|
3326
|
+
// v0.6.0: where each target's static public key is read from, so the offer addressed to a
|
|
3327
|
+
// machine can carry the key that machine needs to authenticate the dispatcher's handshake.
|
|
3328
|
+
getJson: p2p.node?.getJson ?? null,
|
|
3143
3329
|
});
|
|
3144
3330
|
directPushes = {
|
|
3145
3331
|
...directPushes,
|
package/src/relay/peers.mjs
CHANGED
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
* * **Bounded.** Candidate count, address shape and TTL are all validated, because
|
|
21
21
|
* this is the one place where a peer supplies an address another peer will send
|
|
22
22
|
* UDP datagrams to.
|
|
23
|
+
* * **The static public key is a *distribution* channel, not an authority (v0.6.0).** A machine
|
|
24
|
+
* announces its own X25519 public key with its candidates, and a peer reads it back — which is
|
|
25
|
+
* what removes the `[Peer]` block nobody wants to fill in by hand. What the relay can and cannot
|
|
26
|
+
* do here is worth stating plainly: it can substitute a key, because a peer learns the key from
|
|
27
|
+
* the relay's answer (or from the dispatcher's offer, which the relay also carries verbatim). It
|
|
28
|
+
* cannot *use* a substituted key, because a handshake needs the matching private half, so a
|
|
29
|
+
* substituted key produces a refusal rather than a silent interception. §8 of
|
|
30
|
+
* `docs/P2P-CRYPTO.md` names the cheap improvement — a key-change notice — and leaves it for
|
|
31
|
+
* stage 2; until then a changed key is visible as `P2P_CRYPTO_BAD_MAC` rather than as silence.
|
|
23
32
|
*/
|
|
24
33
|
|
|
25
34
|
/** Most candidates a single machine may announce. Enough for several interfaces, not for a scan. */
|
|
@@ -31,6 +40,33 @@ export const DEFAULT_TTL_MS = 60_000;
|
|
|
31
40
|
/** Upper bound a caller may request, so an announcement cannot be pinned indefinitely. */
|
|
32
41
|
export const MAX_TTL_MS = 10 * 60_000;
|
|
33
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Bytes in an X25519 public key (v0.6.0).
|
|
45
|
+
*
|
|
46
|
+
* The key itself is base64, so this is what the validator decodes to. Kept as a literal rather than
|
|
47
|
+
* imported from `src/agent/crypto.mjs` for the same reason `P2P_MODES` is duplicated in
|
|
48
|
+
* `state.mjs`: the relay is a standalone process that must not depend on an agent module, and the
|
|
49
|
+
* value is a wire fact two independently deployed ends have to agree on.
|
|
50
|
+
*/
|
|
51
|
+
export const STATIC_KEY_BYTES = 32;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Is `value` a usable static public key?
|
|
55
|
+
*
|
|
56
|
+
* Strict about the encoding, because a key that "almost" decodes is the input to a MAC that fails for
|
|
57
|
+
* a reason indistinguishable from an attack. `Buffer.from(x, 'base64')` ignores characters it does
|
|
58
|
+
* not recognise, so the decode is only trusted after re-encoding it and getting the same string back.
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} value
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isStaticKey(value) {
|
|
64
|
+
if (typeof value !== 'string' || value === '') return false;
|
|
65
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
|
|
66
|
+
const decoded = Buffer.from(value, 'base64');
|
|
67
|
+
return decoded.length === STATIC_KEY_BYTES && decoded.toString('base64') === value;
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
35
71
|
|
|
36
72
|
/**
|
|
@@ -112,10 +148,26 @@ export class PeerRegistry {
|
|
|
112
148
|
const ttlMs = Math.max(1000, Math.min(requestedTtl, MAX_TTL_MS));
|
|
113
149
|
const now = this.nowMs();
|
|
114
150
|
|
|
151
|
+
// v0.6.0: the machine's static public key, when it sent one. Validated here because this is the
|
|
152
|
+
// one place a peer supplies a value another peer will use to authenticate it — a malformed key
|
|
153
|
+
// stored and served would make a handshake fail with a MAC error that looks exactly like an
|
|
154
|
+
// attack. Absent (a v0.5.x machine, or one with no key) stays absent rather than becoming `null`:
|
|
155
|
+
// "this machine has no key" and "this machine's key is unknown" must not be the same value.
|
|
156
|
+
let staticKey = null;
|
|
157
|
+
if (body.static_key !== undefined && body.static_key !== null) {
|
|
158
|
+
if (!isStaticKey(body.static_key)) {
|
|
159
|
+
throw Object.assign(new Error('static_key is not a 32-byte base64 X25519 public key'), {
|
|
160
|
+
code: 'BAD_STATIC_KEY',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
staticKey = body.static_key;
|
|
164
|
+
}
|
|
165
|
+
|
|
115
166
|
const entry = {
|
|
116
167
|
machine_id: machineId,
|
|
117
168
|
candidates,
|
|
118
169
|
nat: { mapping: typeof body.nat?.mapping === 'string' ? body.nat.mapping : null },
|
|
170
|
+
static_key: staticKey,
|
|
119
171
|
announced_at_ms: now,
|
|
120
172
|
expires_at_ms: now + ttlMs,
|
|
121
173
|
};
|
|
@@ -124,6 +176,7 @@ export class PeerRegistry {
|
|
|
124
176
|
return {
|
|
125
177
|
machine_id: machineId,
|
|
126
178
|
candidates,
|
|
179
|
+
...(staticKey === null ? {} : { static_key: staticKey }),
|
|
127
180
|
expires_at: new Date(entry.expires_at_ms).toISOString(),
|
|
128
181
|
ttl_ms: ttlMs,
|
|
129
182
|
};
|
|
@@ -148,6 +201,9 @@ export class PeerRegistry {
|
|
|
148
201
|
machine_id: entry.machine_id,
|
|
149
202
|
candidates: entry.candidates.map((c) => ({ ...c })),
|
|
150
203
|
nat: { ...entry.nat },
|
|
204
|
+
...(entry.static_key === null || entry.static_key === undefined
|
|
205
|
+
? {}
|
|
206
|
+
: { static_key: entry.static_key }),
|
|
151
207
|
announced_at: new Date(entry.announced_at_ms).toISOString(),
|
|
152
208
|
expires_at: new Date(entry.expires_at_ms).toISOString(),
|
|
153
209
|
};
|