@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.
@@ -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
@@ -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.5';
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
- const posted = await request({
6598
- rabbitUrl,
6599
- token,
6600
- signal: exec?.signal,
6601
- method: 'POST',
6602
- pathname: '/v1/fleet/messages',
6603
- body: {
6604
- session_id: sessionId,
6605
- text: typeof args?.text === 'string' ? args.text : '',
6606
- ...addressing,
6607
- // v1.2.5: a mention is a notification hint; the idempotency key lets a retry after a lost response replay the
6608
- // first message instead of posting the same sentence twice.
6609
- ...(mentions.length === 0 ? {} : { mentions }),
6610
- ...(typeof args?.idempotency_key === 'string' && args.idempotency_key.trim() !== '' ? { idempotency_key: args.idempotency_key.trim() } : {}),
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({ ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null, ...(posted.json?.deduped === true ? { deduped: true } : {}) });
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 messages = listed.json?.messages ?? [];
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, turns: turns.threads, inferred_turns: turns.inferred_turns, count_was: listed.json?.count ?? 0, usage: listed.json?.usage ?? null });
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,
@@ -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 ?? [],
@@ -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) {
@@ -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
+ }