@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.
@@ -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.1';
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
- * @returns {Promise<{ok: boolean, error: string|null, session: number|null, reused: boolean}>}
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, session === undefined ? {} : { session });
1272
+ const dialled = await node.dial(frame.machine_id, dialOptions);
1210
1273
  if (!dialled.ok) {
1211
- return { ok: false, error: dialled.error ?? 'P2P_DIAL_FAILED', session: dialled.session ?? null, reused: false };
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 { ok: true, error: null, session: dialled.session ?? null, reused: dialled.reused === true };
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 built = buildOfferFrame({ taskId, lease, payload, originMachineId });
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,
@@ -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
  };