@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/src/agent/agent.mjs
CHANGED
|
@@ -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)
|
|
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
|
*
|