@twinsearth/w2m-dsh-plugin 0.5.2 → 0.5.4

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.
@@ -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
  *