@twinsearth/w2m-dsh-plugin 1.2.5 → 1.2.6
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +60 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +4414 -4360
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/tools.mjs +152 -22
- package/src/relay/fleet-messages.mjs +17 -0
- package/src/relay/report.mjs +5 -0
- package/src/relay/server.mjs +21 -0
- package/src/relay/state.mjs +55 -2
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
package/src/agent/crypto.mjs
CHANGED
|
@@ -336,6 +336,37 @@ export function rawPrivateOf(privateKey) {
|
|
|
336
336
|
* stored: the public half is derived, which is what makes a hand-edited file unable to publish a
|
|
337
337
|
* public key that does not match its private key.
|
|
338
338
|
*/
|
|
339
|
+
/**
|
|
340
|
+
* v1.2.6: the Ed25519 key this machine signs envelopes with.
|
|
341
|
+
*
|
|
342
|
+
* ## Why derived rather than a second file
|
|
343
|
+
*
|
|
344
|
+
* `DeviceKey` is X25519: it does key agreement for the p2p handshake and cannot sign (the runtime refuses it outright). A second
|
|
345
|
+
* on-disk key would be a second thing to back up, rotate and lose. The device's 32-byte private scalar is a perfectly good
|
|
346
|
+
* Ed25519 seed, so the signing key is derived from it: one secret to protect, and a machine that re-pairs cannot end up with a
|
|
347
|
+
* signature key nobody can verify.
|
|
348
|
+
*
|
|
349
|
+
* The public half travels INSIDE the signature so a reader can verify the tuple without knowing this machine beforehand. Binding
|
|
350
|
+
* that key to the relay's device record is the remaining step for non-repudiation across machines -- recorded in the roadmap.
|
|
351
|
+
*
|
|
352
|
+
* @param {DeviceKey} deviceKey
|
|
353
|
+
* @returns {{privateKey: import('node:crypto').KeyObject, publicKeyBase64: string}|null}
|
|
354
|
+
*/
|
|
355
|
+
export function deriveSigningKey(deviceKey) {
|
|
356
|
+
if (!(deviceKey instanceof DeviceKey)) return null;
|
|
357
|
+
try {
|
|
358
|
+
const seed = rawPrivateOf(deviceKey.privateKey);
|
|
359
|
+
if (seed.length !== 32) return null;
|
|
360
|
+
const pkcs8 = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]);
|
|
361
|
+
const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
|
362
|
+
const publicKey = createPublicKey(privateKey);
|
|
363
|
+
const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
|
|
364
|
+
return { privateKey, publicKeyBase64: raw.toString('base64') };
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
339
370
|
export class DeviceKey {
|
|
340
371
|
/**
|
|
341
372
|
* @param {object} options
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
2
|
* DSH: Windows2MacO - the DSH Cordis plugin.
|
|
3
3
|
*
|
|
4
4
|
* # What this exposes
|
|
@@ -94,6 +94,7 @@ import { resolveChannels } from './update-channels.mjs';
|
|
|
94
94
|
import { fetchLatestRelease, fetchPrereleaseRelease, requireChecksum, downloadVerified, DEFAULT_REPO } from './update-source.mjs';
|
|
95
95
|
import { installUpdate } from './update-install.mjs';import { createHash, randomUUID } from 'node:crypto';
|
|
96
96
|
import { promises as fs } from 'node:fs';
|
|
97
|
+
import { join } from 'node:path';
|
|
97
98
|
import os from 'node:os';
|
|
98
99
|
import path from 'node:path';
|
|
99
100
|
// The individual memory layer (v0.9.6). Read through the agent's own module rather than re-parsing the
|
|
@@ -186,8 +187,51 @@ import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
|
186
187
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
187
188
|
import { renderMemoryView } from './memory-view.mjs';
|
|
188
189
|
import { buildBoard } from './task-graph.mjs';
|
|
190
|
+
import { verifyEnvelopeSignature } from '../util/envelope-signature.mjs';
|
|
189
191
|
import { createAskStore } from './ask-store.mjs';
|
|
190
192
|
import { deriveTurns } from './turn.mjs';
|
|
193
|
+
import { decryptChatMessage, deriveChatKey, encryptChatText, isEncrypted } from '../util/chat-crypto.mjs';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* v1.2.6 reliability: two small files beside the plugin's state.
|
|
197
|
+
*
|
|
198
|
+
* `chat-cursor.json` remembers how far each conversation has been read, so "list" continues where the last page ended instead of
|
|
199
|
+
* starting over. `chat-spool.jsonl` holds messages that could not be sent because the Rabbit was unreachable; each carries the
|
|
200
|
+
* idempotency key it will be posted with, so a replay that the relay already received does not post the sentence twice.
|
|
201
|
+
*/
|
|
202
|
+
const CHAT_CURSOR_FILE = 'chat-cursor.json';
|
|
203
|
+
const CHAT_SPOOL_FILE = 'chat-spool.jsonl';
|
|
204
|
+
|
|
205
|
+
async function readChatCursors(stateDir) {
|
|
206
|
+
if (typeof stateDir !== 'string' || stateDir === '') return {};
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(await fs.readFile(join(stateDir, CHAT_CURSOR_FILE), 'utf8'));
|
|
209
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
210
|
+
} catch { return {}; }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function writeChatCursors(stateDir, cursors) {
|
|
214
|
+
if (typeof stateDir !== 'string' || stateDir === '') return false;
|
|
215
|
+
try { await fs.writeFile(join(stateDir, CHAT_CURSOR_FILE), JSON.stringify(cursors, null, 2), 'utf8'); return true; }
|
|
216
|
+
catch { return false; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function readChatSpool(stateDir) {
|
|
220
|
+
if (typeof stateDir !== 'string' || stateDir === '') return [];
|
|
221
|
+
try {
|
|
222
|
+
const text = await fs.readFile(join(stateDir, CHAT_SPOOL_FILE), 'utf8');
|
|
223
|
+
return text.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter((entry) => entry !== null && typeof entry === 'object');
|
|
224
|
+
} catch { return []; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function writeChatSpool(stateDir, entries) {
|
|
228
|
+
if (typeof stateDir !== 'string' || stateDir === '') return false;
|
|
229
|
+
try {
|
|
230
|
+
await fs.writeFile(join(stateDir, CHAT_SPOOL_FILE), entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length === 0 ? '' : '\n'), 'utf8');
|
|
231
|
+
return true;
|
|
232
|
+
} catch { return false; }
|
|
233
|
+
}
|
|
234
|
+
|
|
191
235
|
import { buildReducePlan } from '../util/dataflow.mjs';
|
|
192
236
|
import { schemaProblems, strayParameterProblems } from './schema-spec.mjs';
|
|
193
237
|
import { listIntents, resolveIntent } from './intents.mjs';
|
|
@@ -228,7 +272,7 @@ const MAX_STAGES = 16;
|
|
|
228
272
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
229
273
|
* overrides it at runtime for tests and packagers.
|
|
230
274
|
*/
|
|
231
|
-
const PLUGIN_VERSION = '1.2.
|
|
275
|
+
const PLUGIN_VERSION = '1.2.6';
|
|
232
276
|
|
|
233
277
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
234
278
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4423,6 +4467,14 @@ export async function apply(ctx, config = {}) {
|
|
|
4423
4467
|
const machines = Array.isArray(aggregate.detail?.machines)
|
|
4424
4468
|
? aggregate.detail.machines.map((machine) => {
|
|
4425
4469
|
const envelope = machine?.envelope ?? machine?.result ?? null;
|
|
4470
|
+
// v1.2.6: the signature covers the command tuple; a reader is told which of the three states it is in.
|
|
4471
|
+
const relayVerdict = typeof machine?.signature_status === 'string' && machine.signature_status !== '' ? machine.signature_status : null;
|
|
4472
|
+
const signatureStatus = relayVerdict ?? verifyEnvelopeSignature({
|
|
4473
|
+
signature: envelope?.signature ?? null,
|
|
4474
|
+
task_id: envelope?.task_id ?? taskId,
|
|
4475
|
+
argv_hash: envelope?.command_hash,
|
|
4476
|
+
machine_id: envelope?.machine_id ?? machine?.machine_id,
|
|
4477
|
+
});
|
|
4426
4478
|
return {
|
|
4427
4479
|
machine_id: machine?.machine_id ?? null,
|
|
4428
4480
|
machine_name: machine?.machine_name ?? null,
|
|
@@ -4440,6 +4492,7 @@ export async function apply(ctx, config = {}) {
|
|
|
4440
4492
|
* v0.3.9 agent sends, so a missing value must not be dressed up as `'relay'`.
|
|
4441
4493
|
*/
|
|
4442
4494
|
transport: envelope?.transport ?? machine?.transport ?? null,
|
|
4495
|
+
signature_status: signatureStatus,
|
|
4443
4496
|
p2p: envelope?.p2p ?? machine?.p2p ?? null,
|
|
4444
4497
|
reasons: machine?.reasons ?? [],
|
|
4445
4498
|
envelope: compactEnvelope(envelope, COMPARABLE_FIELDS),
|
|
@@ -4447,6 +4500,15 @@ export async function apply(ctx, config = {}) {
|
|
|
4447
4500
|
})
|
|
4448
4501
|
: [];
|
|
4449
4502
|
|
|
4503
|
+
// v1.2.6: a signature state is never silently flattened into success.
|
|
4504
|
+
const signatureCounts = machines.reduce((counts, machine) => {
|
|
4505
|
+
const key = machine.signature_status ?? 'unsigned';
|
|
4506
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
4507
|
+
return counts;
|
|
4508
|
+
}, {});
|
|
4509
|
+
const unsigned = signatureCounts.unsigned ?? 0;
|
|
4510
|
+
const badSignature = signatureCounts.bad_signature ?? 0;
|
|
4511
|
+
|
|
4450
4512
|
// v1.2.4: a task whose machines produced blobs is only complete when EVERY map result names one and a reducer was
|
|
4451
4513
|
// declared. The plan is built from the RAW envelopes: the comparable copy intentionally drops the fields this needs,
|
|
4452
4514
|
// and a reduce over a partial fleet that reports itself complete is the failure this guards.
|
|
@@ -4480,6 +4542,9 @@ export async function apply(ctx, config = {}) {
|
|
|
4480
4542
|
state_known: aggregate.known,
|
|
4481
4543
|
states: aggregate.states,
|
|
4482
4544
|
machines,
|
|
4545
|
+
signatures: signatureCounts,
|
|
4546
|
+
...(badSignature > 0 ? { signature_warning: 'bad_signature' } : {}),
|
|
4547
|
+
...(badSignature === 0 && unsigned > 0 ? { signature_warning: 'unsigned' } : {}),
|
|
4483
4548
|
...(reduce === null ? {} : { reduce }),
|
|
4484
4549
|
...(dataflowDegraded ? { dataflow_degraded: true } : {}),
|
|
4485
4550
|
counts: aggregate.detail?.counts ?? null,
|
|
@@ -6549,6 +6614,10 @@ export async function apply(ctx, config = {}) {
|
|
|
6549
6614
|
// The persisted questions for this conversation, in the states they are in. A read, so it needs no relay.
|
|
6550
6615
|
return JSON.stringify({ ok: true, action, session_id: sessionId, ...askStore.list({ sessionId }) });
|
|
6551
6616
|
}
|
|
6617
|
+
if (action === 'cursor') {
|
|
6618
|
+
const cursors = await readChatCursors(cfg.stateDir);
|
|
6619
|
+
return JSON.stringify({ ok: true, action, session_id: sessionId, cursor: Object.prototype.hasOwnProperty.call(cursors, sessionId) ? cursors[sessionId] : null, spool_count: (await readChatSpool(cfg.stateDir)).length });
|
|
6620
|
+
}
|
|
6552
6621
|
const rabbitUrl = cfg.rabbitUrl ?? null;
|
|
6553
6622
|
if (rabbitUrl === null) throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so chat cannot be reached', { hint: 'set rabbitUrl in the profile patch' });
|
|
6554
6623
|
// resolveToken, not readDevice: readDevice returns an empty identity whenever `stateDir` is
|
|
@@ -6557,6 +6626,22 @@ export async function apply(ctx, config = {}) {
|
|
|
6557
6626
|
// one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
|
|
6558
6627
|
const token = await resolveToken(cfg);
|
|
6559
6628
|
if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
|
|
6629
|
+
const stateDir = typeof cfg.stateDir === 'string' ? cfg.stateDir : '';
|
|
6630
|
+
// v1.2.6 reliability: try to deliver what the last unreachable attempt had to leave behind, before doing anything else.
|
|
6631
|
+
let spool = await readChatSpool(stateDir);
|
|
6632
|
+
let spoolDelivered = 0;
|
|
6633
|
+
if (spool.length > 0) {
|
|
6634
|
+
const remaining = [];
|
|
6635
|
+
for (const entry of spool) {
|
|
6636
|
+
try {
|
|
6637
|
+
const replayed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: entry.body });
|
|
6638
|
+
if (replayed?.ok === true) { spoolDelivered += 1; continue; }
|
|
6639
|
+
} catch { /* still unreachable: keep it for next time */ }
|
|
6640
|
+
remaining.push(entry);
|
|
6641
|
+
}
|
|
6642
|
+
spool = remaining;
|
|
6643
|
+
await writeChatSpool(stateDir, spool);
|
|
6644
|
+
}
|
|
6560
6645
|
// Addressing + correlation travel only when the caller supplied them, so a plain broadcast stays byte-identical.
|
|
6561
6646
|
const addressing = {};
|
|
6562
6647
|
if (typeof args?.to === 'string' && args.to.trim() !== '') {
|
|
@@ -6594,40 +6679,83 @@ export async function apply(ctx, config = {}) {
|
|
|
6594
6679
|
}
|
|
6595
6680
|
if (action === 'send' || action === 'ask') {
|
|
6596
6681
|
const mentions = Array.isArray(args?.mentions) ? args.mentions.filter((id) => typeof id === 'string' && id !== '') : [];
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
6682
|
+
/**
|
|
6683
|
+
* v1.2.6: the chat is end-to-end encrypted when a fleet key is available (configured, or derived from the account join).
|
|
6684
|
+
* Without one the tool keeps sending plaintext rather than refusing: a fleet that has not joined yet is a normal state,
|
|
6685
|
+
* and a silent refusal would read as a broken chat.
|
|
6686
|
+
*/
|
|
6687
|
+
const chatKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
|
|
6688
|
+
const plainText = typeof args?.text === 'string' ? args.text : '';
|
|
6689
|
+
const sealed = chatKey === null ? null : encryptChatText(chatKey, plainText);
|
|
6690
|
+
/**
|
|
6691
|
+
* v1.2.6 reliability: an unreachable Rabbit does not lose the message. The exact body that could not be sent is
|
|
6692
|
+
* spooled to disk and replayed with the SAME idempotency key next time, so a message the relay actually received
|
|
6693
|
+
* before the connection died is not posted twice.
|
|
6694
|
+
*/
|
|
6695
|
+
const messageBody = {
|
|
6696
|
+
session_id: sessionId,
|
|
6697
|
+
text: sealed !== null && sealed.ok === true ? sealed.text : plainText,
|
|
6698
|
+
...(sealed !== null && sealed.ok === true ? { enc: sealed.enc } : {}),
|
|
6699
|
+
...addressing,
|
|
6700
|
+
...(mentions.length === 0 ? {} : { mentions }),
|
|
6701
|
+
...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : { idempotency_key: 'spool-' + Date.now() + '-' + Math.random().toString(36).slice(2) }),
|
|
6702
|
+
};
|
|
6703
|
+
let posted = null;
|
|
6704
|
+
try {
|
|
6705
|
+
posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: messageBody });
|
|
6706
|
+
} catch (error) {
|
|
6707
|
+
if (error?.code === 'W2M_UNREACHABLE') {
|
|
6708
|
+
spool.push({ body: messageBody, created_at: new Date().toISOString() });
|
|
6709
|
+
await writeChatSpool(stateDir, spool);
|
|
6710
|
+
return JSON.stringify({ ok: true, action, spooled: true, spool_count: spool.length, encrypted: sealed !== null && sealed.ok === true });
|
|
6711
|
+
}
|
|
6712
|
+
throw error;
|
|
6713
|
+
}
|
|
6613
6714
|
if (posted?.ok !== true) {
|
|
6614
6715
|
const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
|
|
6615
6716
|
throw new W2MError('W2M_CHAT_SEND', code + ': the message was refused', { reason_code: code, hint: 'quota, size and non-text each need a different answer' });
|
|
6616
6717
|
}
|
|
6617
|
-
return JSON.stringify({
|
|
6718
|
+
return JSON.stringify({
|
|
6719
|
+
ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null,
|
|
6720
|
+
encrypted: sealed !== null && sealed.ok === true,
|
|
6721
|
+
...(posted.json?.deduped === true ? { deduped: true } : {}),
|
|
6722
|
+
});
|
|
6618
6723
|
}
|
|
6724
|
+
const query = { session_id: sessionId };
|
|
6725
|
+
// v1.2.6 reliability: continue where the last page ended, unless the caller asked for an explicit start.
|
|
6726
|
+
const cursors = await readChatCursors(stateDir);
|
|
6727
|
+
const storedCursor = Object.prototype.hasOwnProperty.call(cursors, sessionId) ? cursors[sessionId] : null;
|
|
6728
|
+
if (!Number.isFinite(args?.since_ms) && storedCursor !== null && Number.isFinite(storedCursor.at_ms)) query.since_ms = String(storedCursor.at_ms);
|
|
6619
6729
|
if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') query.thread_id = args.thread_id.trim();
|
|
6620
6730
|
if (typeof args?.to === 'string' && args.to.trim() !== '') query.to = args.to.trim();
|
|
6621
|
-
const query = { session_id: sessionId };
|
|
6622
6731
|
if (Number.isFinite(args?.since_ms)) query.since_ms = String(args.since_ms);
|
|
6623
6732
|
if (Number.isInteger(args?.limit)) query.limit = String(args.limit);
|
|
6624
6733
|
const listed = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query });
|
|
6625
6734
|
if (listed?.ok !== true) throw new W2MError('W2M_CHAT_LIST', 'W2M_CHAT_LIST: the relay refused the read', { hint: 'check the relay and this machine token' });
|
|
6626
|
-
const
|
|
6735
|
+
const rawMessages = listed.json?.messages ?? [];
|
|
6736
|
+
/**
|
|
6737
|
+
* v1.2.6: a reader decrypts what its key covers and marks the rest. An undecryptable message keeps its ciphertext and its
|
|
6738
|
+
* reason instead of failing the page -- the relay is allowed to hold a message this machine cannot open.
|
|
6739
|
+
*/
|
|
6740
|
+
const listKey = deriveChatKey(cfg.fleetKey ?? resolvedFleetKey.value ?? null);
|
|
6741
|
+
const messages = rawMessages.map((message) => {
|
|
6742
|
+
if (!isEncrypted(message)) return message;
|
|
6743
|
+
const opened = decryptChatMessage(listKey, message);
|
|
6744
|
+
if (opened.ok === true) return { ...message, text: opened.text, decrypted: true };
|
|
6745
|
+
return { ...message, text: '', decrypted: false, encrypted: true, decrypt_error: opened.code };
|
|
6746
|
+
});
|
|
6747
|
+
const encryptedCount = messages.filter((message) => message.encrypted === true || message.decrypted === true).length;
|
|
6748
|
+
// The cursor advances only over what was actually read, so a page that failed mid-way does not skip messages.
|
|
6749
|
+
const newestAt = messages.reduce((max, message) => (Number.isFinite(message?.at_ms) && message.at_ms > max ? message.at_ms : max), 0);
|
|
6750
|
+
let cursor = storedCursor;
|
|
6751
|
+
if (newestAt > 0 && (storedCursor === null || newestAt > storedCursor.at_ms)) {
|
|
6752
|
+
cursor = { at_ms: newestAt, message_id: messages.filter((message) => message.at_ms === newestAt)[0]?.message_id ?? null };
|
|
6753
|
+
await writeChatCursors(stateDir, { ...cursors, [sessionId]: cursor });
|
|
6754
|
+
}
|
|
6627
6755
|
// v1.2.4: the page is annotated with turns derived from the relay's own numbering, so a reader does not have to order
|
|
6628
6756
|
// replies by arrival time.
|
|
6629
6757
|
const turns = deriveTurns(messages);
|
|
6630
|
-
return JSON.stringify({ ok: true, action, session_id: sessionId, messages, count: messages.length,
|
|
6758
|
+
return JSON.stringify({ ok: true, action, session_id: sessionId, messages, count: messages.length, encrypted_count: encryptedCount, cursor, spooled: spoolDelivered, turns: turns.threads ?? [], inferred_turns: turns.inferred_turns ?? [] });
|
|
6631
6759
|
},
|
|
6632
6760
|
}));
|
|
6633
6761
|
|
|
@@ -7182,6 +7310,8 @@ async function startLocalStack(ctx, cfg) {
|
|
|
7182
7310
|
caps,
|
|
7183
7311
|
platform,
|
|
7184
7312
|
allowedCommands: cfg.allowedCommands,
|
|
7313
|
+
// v1.2.6: on by default; a profile may turn it off for debugging, and the envelope then says `unredacted: true`.
|
|
7314
|
+
redactOutput: cfg.redactOutput !== false,
|
|
7185
7315
|
});
|
|
7186
7316
|
/**
|
|
7187
7317
|
* v0.8.9: derive the fleet key, RETRYING until the account actually answers.
|
|
@@ -105,6 +105,20 @@ export function createFleetMessages(options = {}) {
|
|
|
105
105
|
if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
|
|
106
106
|
// Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
|
|
107
107
|
// exchange inside one thread. Plain fields, validated rather than trusted.
|
|
108
|
+
/**
|
|
109
|
+
* v1.2.6 end-to-end encryption: the relay carries the ciphertext and its metadata, and never a key. It validates the SHAPE
|
|
110
|
+
* because a malformed envelope would be stored and later fail on every reader; it cannot check the content, and that is the
|
|
111
|
+
* point -- which also means it cannot search messages or know their real length.
|
|
112
|
+
*/
|
|
113
|
+
const rawEnc = input?.enc;
|
|
114
|
+
let enc = null;
|
|
115
|
+
if (rawEnc !== undefined && rawEnc !== null) {
|
|
116
|
+
const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
|
|
117
|
+
const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
|
|
118
|
+
if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
|
|
119
|
+
enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
// v1.2.5 idempotency: replay the first message instead of storing the text twice.
|
|
109
123
|
const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
|
|
110
124
|
? input.idempotency_key.trim().slice(0, 64)
|
|
@@ -161,6 +175,9 @@ export function createFleetMessages(options = {}) {
|
|
|
161
175
|
text,
|
|
162
176
|
// Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
|
|
163
177
|
...(mentions.length === 0 ? {} : { mentions }),
|
|
178
|
+
// v1.2.6: ciphertext metadata travels beside the text so an old client can read it as opaque text and a new one can name
|
|
179
|
+
// the cipher it does not have a key for.
|
|
180
|
+
...(enc === null ? {} : { enc }),
|
|
164
181
|
...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
|
|
165
182
|
at_ms: now(),
|
|
166
183
|
bytes,
|
package/src/relay/report.mjs
CHANGED
|
@@ -148,6 +148,8 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
148
148
|
observing: lease.state === 'observing',
|
|
149
149
|
result: rec,
|
|
150
150
|
envelope: rec?.envelope ?? null,
|
|
151
|
+
// v1.2.6: the relay's own signature verdict (bound to the key registered at pairing), not a client claim.
|
|
152
|
+
signature_status: rec?.validation?.signature_status ?? null,
|
|
151
153
|
outcome: null,
|
|
152
154
|
reasons: [],
|
|
153
155
|
});
|
|
@@ -166,6 +168,7 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
166
168
|
gate_refused: false,
|
|
167
169
|
result: rec,
|
|
168
170
|
envelope: rec.envelope,
|
|
171
|
+
signature_status: rec?.validation?.signature_status ?? null,
|
|
169
172
|
outcome: null,
|
|
170
173
|
reasons: [],
|
|
171
174
|
});
|
|
@@ -487,6 +490,8 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
487
490
|
transport: m.envelope?.transport ?? null,
|
|
488
491
|
p2p: m.envelope?.p2p ?? null,
|
|
489
492
|
reasons: m.reasons,
|
|
493
|
+
// v1.2.6: the relay's verdict, bound to the key registered at pairing.
|
|
494
|
+
signature_status: m.signature_status ?? null,
|
|
490
495
|
})),
|
|
491
496
|
steps,
|
|
492
497
|
differences: step3.differences ?? [],
|
package/src/relay/server.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
rfc3339,
|
|
24
24
|
} from './state.mjs';
|
|
25
25
|
import { createFleetMessages } from './fleet-messages.mjs';
|
|
26
|
+
import { can as mayAct } from '../util/policy-matrix.mjs';
|
|
26
27
|
import { createFleetMemory } from './fleet-memory.mjs';
|
|
27
28
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
28
29
|
import { createAdmission } from './admission.mjs';
|
|
@@ -1114,6 +1115,25 @@ export class RelayServer {
|
|
|
1114
1115
|
this.fleetMemory ??= createFleetMemory({ now: () => this.state.nowMs() });
|
|
1115
1116
|
|
|
1116
1117
|
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1118
|
+
/**
|
|
1119
|
+
* v1.2.6: posting to the group is a dispatch-class action. An observer may read the flow and may not speak into it, and
|
|
1120
|
+
* the refusal names the action and both roles so it is actionable.
|
|
1121
|
+
*/
|
|
1122
|
+
const permitted = mayAct({ role: this.state.roleOf(device.machine_id), action: 'dispatch' });
|
|
1123
|
+
if (permitted.ok !== true) {
|
|
1124
|
+
return sendJson(res, 403, {
|
|
1125
|
+
protocol_version: PROTOCOL_VERSION,
|
|
1126
|
+
ok: false,
|
|
1127
|
+
error: { code: permitted.code, message: 'role may not post to the fleet group' },
|
|
1128
|
+
detail: {
|
|
1129
|
+
action: permitted.action,
|
|
1130
|
+
required_level: permitted.required_level,
|
|
1131
|
+
current_level: permitted.current_level,
|
|
1132
|
+
required_role: permitted.required_role,
|
|
1133
|
+
current_role: permitted.current_role,
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1117
1137
|
const body = takeJson();
|
|
1118
1138
|
const posted = this.fleetMessages.post({
|
|
1119
1139
|
session_id: body.session_id,
|
|
@@ -1124,6 +1144,7 @@ export class RelayServer {
|
|
|
1124
1144
|
reply_to: body.reply_to,
|
|
1125
1145
|
text: body.text,
|
|
1126
1146
|
mentions: body.mentions,
|
|
1147
|
+
enc: body.enc,
|
|
1127
1148
|
idempotency_key: body.idempotency_key,
|
|
1128
1149
|
});
|
|
1129
1150
|
if (posted.ok !== true) {
|
package/src/relay/state.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* there is intentionally no snapshot/serialization hook yet.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
14
|
+
import { createHash, randomBytes , verify as verifyBytes } from 'node:crypto';
|
|
15
15
|
|
|
16
16
|
// v0.7.7: fleet scoping. Imported rather than inlined so the rule has one home with its own
|
|
17
17
|
// exhaustive tests — three lines inside this object would be untestable without a live relay.
|
|
@@ -23,7 +23,8 @@ import { scopeFleetId } from '../chatops/fleet-wiring.mjs';
|
|
|
23
23
|
// v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
|
|
24
24
|
import { planJoin } from '../chatops/fleet-join.mjs';
|
|
25
25
|
import { validateTaskDependencies } from './task-deps.mjs';
|
|
26
|
-
import { buildMembers, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
26
|
+
import { buildMembers, isMemberRole, setMemberRole as applyMemberRole } from './fleet-members.mjs';
|
|
27
|
+
import { canonicalSignedPayload, publicKeyFromBase64 } from '../util/envelope-signature.mjs';
|
|
27
28
|
|
|
28
29
|
export const PROTOCOL_VERSION = 1;
|
|
29
30
|
|
|
@@ -750,6 +751,16 @@ export class RabbitState {
|
|
|
750
751
|
this.tasks.set(task.task_id, task);
|
|
751
752
|
return true;
|
|
752
753
|
}
|
|
754
|
+
case 'member.role': {
|
|
755
|
+
/**
|
|
756
|
+
* v1.2.6: a role survives a restart. The ledger entry already existed (setMemberRole writes it); replaying it here is
|
|
757
|
+
* what stops "the relay restarted" from silently demoting every owner to the default worker.
|
|
758
|
+
*/
|
|
759
|
+
if (typeof entry.machine_id !== 'string' || entry.machine_id === '') return false;
|
|
760
|
+
if (isMemberRole(entry.role) !== true) return false;
|
|
761
|
+
this.roles.set(entry.machine_id, entry.role);
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
753
764
|
case 'lease.changed': {
|
|
754
765
|
const task = this.tasks.get(entry.task_id);
|
|
755
766
|
const lease = task?.leases.get(entry.machine_id);
|
|
@@ -2214,6 +2225,11 @@ export class RabbitState {
|
|
|
2214
2225
|
};
|
|
2215
2226
|
}
|
|
2216
2227
|
|
|
2228
|
+
/** v1.2.6: the role this machine holds, for the permission matrix. Unknown means worker, never owner. */
|
|
2229
|
+
roleOf(machineId) {
|
|
2230
|
+
return typeof machineId === 'string' ? (this.roles.get(machineId) ?? null) : null;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2217
2233
|
/** v1.2.5: the fleet's members, with the role each holds and when it joined. */
|
|
2218
2234
|
listMembers() {
|
|
2219
2235
|
return buildMembers({ devices: this.devices, roles: this.roles });
|
|
@@ -2631,6 +2647,42 @@ export class RabbitState {
|
|
|
2631
2647
|
return { deduped: true, task_id: taskId, dedupe_key: dedupeKey, seq: prior.seq };
|
|
2632
2648
|
}
|
|
2633
2649
|
|
|
2650
|
+
/**
|
|
2651
|
+
* v1.2.6: verify the envelope signature against the key this machine registered WHEN IT PAIRED.
|
|
2652
|
+
*
|
|
2653
|
+
* This is the difference between "the result matches the key it carries" and "the key belongs to this machine". The relay is
|
|
2654
|
+
* the place that can answer the second question, because pairing is the moment the operator (or the account) let the machine
|
|
2655
|
+
* in, and that is the only moment its signing key has a reason to be believed. A signature whose key differs from the
|
|
2656
|
+
* registered one is bad_signature even when it verifies against itself.
|
|
2657
|
+
*/
|
|
2658
|
+
let signatureStatus = 'unsigned';
|
|
2659
|
+
const signature = envelope.signature ?? null;
|
|
2660
|
+
if (signature !== null) {
|
|
2661
|
+
signatureStatus = 'bad_signature';
|
|
2662
|
+
if (signature.alg !== 'ed25519') {
|
|
2663
|
+
signatureStatus = 'unsupported_algorithm';
|
|
2664
|
+
} else {
|
|
2665
|
+
const registeredKey = machineId === null ? null : (typeof this.devices.get(machineId)?.public_key === 'string' ? this.devices.get(machineId).public_key : null);
|
|
2666
|
+
if (registeredKey === null) {
|
|
2667
|
+
signatureStatus = 'unbound';
|
|
2668
|
+
} else if (signature.public_key !== registeredKey) {
|
|
2669
|
+
signatureStatus = 'bad_signature';
|
|
2670
|
+
} else {
|
|
2671
|
+
const key = publicKeyFromBase64(registeredKey);
|
|
2672
|
+
const payload = canonicalSignedPayload({
|
|
2673
|
+
task_id: envelope.task_id,
|
|
2674
|
+
argv_hash: envelope.command_hash,
|
|
2675
|
+
machine_id: envelope.machine_id,
|
|
2676
|
+
ts: signature.ts,
|
|
2677
|
+
});
|
|
2678
|
+
try {
|
|
2679
|
+
signatureStatus = key !== null && verifyBytes(null, Buffer.from(payload, 'utf8'), key, Buffer.from(String(signature.value ?? ''), 'base64'))
|
|
2680
|
+
? 'signed' : 'bad_signature';
|
|
2681
|
+
} catch { signatureStatus = 'bad_signature'; }
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2634
2686
|
const now = this.nowMs();
|
|
2635
2687
|
let envelopeShaOk = null;
|
|
2636
2688
|
if (typeof envelope.envelope_sha256 === 'string') {
|
|
@@ -2649,6 +2701,7 @@ export class RabbitState {
|
|
|
2649
2701
|
missing: [...new Set(missing)],
|
|
2650
2702
|
problems,
|
|
2651
2703
|
envelope_sha256_ok: envelopeShaOk,
|
|
2704
|
+
signature_status: signatureStatus,
|
|
2652
2705
|
},
|
|
2653
2706
|
};
|
|
2654
2707
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet chat's end-to-end encryption.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the relay never sees the key
|
|
5
|
+
*
|
|
6
|
+
* The group key is HKDF over the fleet key, and the fleet key is derived from the account identity on each machine -- it never
|
|
7
|
+
* travels to the relay. So the relay stores ciphertext it cannot open, and the honest consequence is written down rather than
|
|
8
|
+
* discovered later: the relay cannot search message content, and the quota it enforces is measured on ciphertext length.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the metadata is next to the ciphertext, not inside it
|
|
11
|
+
*
|
|
12
|
+
* `{v, alg, iv, tag}` has to be readable by every client of every version, including one that cannot decrypt. Keeping it in a
|
|
13
|
+
* plain field means a reader can say "encrypted with aes-256-gcm, version 1" even when it has no key, instead of guessing from
|
|
14
|
+
* random-looking text.
|
|
15
|
+
*
|
|
16
|
+
* ## Backward compatibility, both directions
|
|
17
|
+
*
|
|
18
|
+
* A message with no `enc` field is plaintext and stays readable. A reader with no key keeps the ciphertext and marks it
|
|
19
|
+
* `encrypted: true` rather than throwing: an unreadable message must not break a whole page.
|
|
20
|
+
*/
|
|
21
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
export const CHAT_CIPHER = Object.freeze({ version: 1, alg: 'aes-256-gcm' });
|
|
24
|
+
const KEY_BYTES = 32;
|
|
25
|
+
const IV_BYTES = 12;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn the fleet key into the chat group key. Deterministic: every machine in the fleet derives the same key from the same
|
|
29
|
+
* fleet key, which is what makes a message encrypted on one machine readable on another.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} fleetKey base64 (the usual shape) or a passphrase
|
|
32
|
+
* @returns {Buffer|null}
|
|
33
|
+
*/
|
|
34
|
+
export function deriveChatKey(fleetKey) {
|
|
35
|
+
if (typeof fleetKey !== 'string' || fleetKey.trim() === '') return null;
|
|
36
|
+
try {
|
|
37
|
+
let material = null;
|
|
38
|
+
try {
|
|
39
|
+
const decoded = Buffer.from(fleetKey.trim(), 'base64');
|
|
40
|
+
material = decoded.length >= 16 ? decoded : Buffer.from(fleetKey.trim(), 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
material = Buffer.from(fleetKey.trim(), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
return Buffer.from(hkdfSync('sha256', material, Buffer.from('w2m-fleet-chat-salt'), Buffer.from('fleet-chat-v1'), KEY_BYTES));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {Buffer|null} key
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {{ok: true, text: string, enc: {v: number, alg: string, iv: string, tag: string}} | {ok: false, code: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function encryptChatText(key, text) {
|
|
56
|
+
if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
|
|
57
|
+
if (typeof text !== 'string' || text === '') return { ok: false, code: 'CHAT_EMPTY_TEXT' };
|
|
58
|
+
const iv = randomBytes(IV_BYTES);
|
|
59
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
60
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(text, 'utf8')), cipher.final()]);
|
|
61
|
+
const tag = cipher.getAuthTag();
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
text: ciphertext.toString('base64'),
|
|
65
|
+
enc: { v: CHAT_CIPHER.version, alg: CHAT_CIPHER.alg, iv: iv.toString('base64'), tag: tag.toString('base64') },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Buffer|null} key
|
|
71
|
+
* @param {{text?: string, enc?: object|null}} message
|
|
72
|
+
* @returns {{ok: true, text: string} | {ok: false, code: string}}
|
|
73
|
+
*/
|
|
74
|
+
export function decryptChatMessage(key, message = {}) {
|
|
75
|
+
const enc = message?.enc ?? null;
|
|
76
|
+
if (enc === null || enc === undefined) {
|
|
77
|
+
// Plaintext: the pre-encryption shape, still readable by everyone.
|
|
78
|
+
return typeof message?.text === 'string' ? { ok: true, text: message.text } : { ok: false, code: 'CHAT_TEXT_MISSING' };
|
|
79
|
+
}
|
|
80
|
+
if (key === null || !Buffer.isBuffer(key) || key.length !== KEY_BYTES) return { ok: false, code: 'CHAT_NO_KEY' };
|
|
81
|
+
if (enc.alg !== CHAT_CIPHER.alg || enc.v !== CHAT_CIPHER.version) return { ok: false, code: 'CHAT_UNSUPPORTED_CIPHER' };
|
|
82
|
+
try {
|
|
83
|
+
const iv = Buffer.from(String(enc.iv ?? ''), 'base64');
|
|
84
|
+
const tag = Buffer.from(String(enc.tag ?? ''), 'base64');
|
|
85
|
+
if (iv.length !== IV_BYTES || tag.length !== 16) return { ok: false, code: 'CHAT_BAD_ENCRYPTION' };
|
|
86
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
87
|
+
decipher.setAuthTag(tag);
|
|
88
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(String(message.text ?? ''), 'base64')), decipher.final()]);
|
|
89
|
+
return { ok: true, text: plain.toString('utf8') };
|
|
90
|
+
} catch {
|
|
91
|
+
return { ok: false, code: 'CHAT_DECRYPT_FAILED' };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when a message carries the encryption metadata, whatever its text looks like. */
|
|
96
|
+
export function isEncrypted(message) {
|
|
97
|
+
return message?.enc !== null && message?.enc !== undefined && typeof message.enc === 'object';
|
|
98
|
+
}
|