@twinsearth/w2m-dsh-plugin 0.6.2 → 0.8.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1057 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The approval broker (v0.7.0).
3
+ *
4
+ * ## The defect this file exists to make impossible
5
+ *
6
+ * A chat bridge that wants to answer approvals from a phone joins the
7
+ * `approval/request` **waterfall**. The contract, as this host documents it, is:
8
+ *
9
+ * 'approval/request'(this, req, next: () => Promise<ApprovalOutcome>)
10
+ * -> "Return an outcome to claim the request or call `next()` to delegate."
11
+ *
12
+ * `dsh-chatops` 0.1.0 registers `(req) => this.onApprovalRequest(req)` and **never
13
+ * calls `next()`** — the identifier does not appear in its source at all. Because a
14
+ * waterfall consults the next listener only when the current one calls `next()`, a
15
+ * listener that returns without delegating removes every answerer registered after
16
+ * it from the decision. In that plugin the listener is also `async`, so the *first*
17
+ * thing it hands back is a Promise, and the harness treats any non-`undefined`
18
+ * value as the final answer. Measured consequence: approvals for sessions that have
19
+ * a bound IM window are held until the plugin's own timer expires, and the host's
20
+ * GUI answerer is never asked. Whether that happens at all depends on listener
21
+ * registration order, which the plugin does nothing to control.
22
+ *
23
+ * Two properties make the correct version look different from the obvious one:
24
+ *
25
+ * 1. **Declining must be synchronous.** If the bridge has nothing to ask (no bound
26
+ * window, push disabled), it must return `undefined` *without* having touched
27
+ * `next`, so the waterfall proceeds normally. Awaiting anything first — even a
28
+ * resolved promise — hands a Promise to the harness and claims the request.
29
+ * 2. **A timeout must delegate, not decide.** A bridge that answers "no decision"
30
+ * after its own deadline has still answered. The host is entitled to a real
31
+ * outcome, so the deadline calls `next()` and lets the answerers behind it
32
+ * decide. This is the difference between "the phone did not answer" and
33
+ * "the request was denied".
34
+ *
35
+ * ## Why the state is keyed the way it is
36
+ *
37
+ * Pending approvals are keyed by **approval id**, and each window keeps a queue of
38
+ * ids. The 0.1.0 code used a single `Map<windowKey, PendingApproval>`, so a second
39
+ * concurrent request in one window overwrote the first: the older request became
40
+ * unanswerable from IM and was reclaimed only by its timer, while `/approve` always
41
+ * resolved the newer one. Keying by id and queueing per window makes that state
42
+ * unrepresentable rather than merely unlikely.
43
+ *
44
+ * Ids carry full entropy. 0.1.0 minted `randomUUID().slice(0, 8)` — 32 bits — which
45
+ * its own core then held for up to five minutes. Nothing here is guessable.
46
+ *
47
+ * ## What this module deliberately is not
48
+ *
49
+ * It is not an answerer and it does not touch Cordis. It is a pure state machine
50
+ * with injected clock, timer and id source, so every property above is testable
51
+ * without a host, a phone, or a five-minute wait. The wiring — one listener, four
52
+ * lines — lives in the transport layer and can be read at a glance.
53
+ *
54
+ * The timer is injectable for a reason that cost a hanging test run to learn: an
55
+ * injected *clock* with a real `setTimeout` is two clocks, and a test that advances
56
+ * the one it controls then waits on the other hangs forever. Anything that reads
57
+ * time here — `now`, `setTimer`, `clearTimer` — comes from the same place.
58
+ */
59
+
60
+ /** Outcomes the host's approval service can accept. Mirrors `ApprovalOutcome`. */
61
+ export const OUTCOMES = Object.freeze(['allowed-once', 'rejected', 'cancelled', 'unavailable']);
62
+
63
+ /**
64
+ * Why a pending request stopped being answerable. Reported, never thrown: an
65
+ * approval that nobody answered is a normal event, not an error.
66
+ */
67
+ export const RESOLUTION = Object.freeze({
68
+ /** The operator answered from IM. */
69
+ OPERATOR: 'operator',
70
+ /** The deadline passed and the request was delegated to the answerers behind us. */
71
+ DELEGATED: 'delegated',
72
+ /** The host withdrew the request (`signal` aborted) before anyone answered. */
73
+ CANCELLED: 'cancelled',
74
+ /** The bridge was disposed, or the broker shut down. */
75
+ DISPOSED: 'disposed',
76
+ });
77
+
78
+ /**
79
+ * A decision is only ever one of these two. The host's `ApprovalOutcome` includes
80
+ * `'cancelled'` and `'unavailable'`, but those describe *why a decision is absent*
81
+ * — they are produced by the host, not claimed by an answerer. Accepting them from
82
+ * an operator would let a bridge report its own failure as the operator's choice.
83
+ */
84
+ const OPERATOR_OUTCOMES = new Set(['allowed-once', 'rejected']);
85
+
86
+ /**
87
+ * Create a broker.
88
+ *
89
+ * @param {object} options
90
+ * @param {() => number} [options.now] Injected clock, for tests and for a host that virtualises time.
91
+ * @param {(fn: () => void, ms: number) => unknown} [options.setTimer] Injected timer. Must pair with `now`.
92
+ * @param {(handle: unknown) => void} [options.clearTimer] Cancels a `setTimer` handle.
93
+ * @param {() => string} [options.mintId] Injected id source. Must be unguessable.
94
+ * @param {(event: string, detail?: object) => void} [options.audit] Called on every state change.
95
+ * @param {number} [options.defaultTimeoutMs] Deadline for a request nobody answers.
96
+ * @param {number} [options.maxPendingPerWindow] Refuse rather than queue without bound.
97
+ */
98
+ export function createApprovalBroker(options = {}) {
99
+ const now = options.now ?? (() => Date.now());
100
+ const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
101
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
102
+ const mintId = options.mintId ?? (() => globalThis.crypto.randomUUID());
103
+ const audit = options.audit ?? (() => {});
104
+ const defaultTimeoutMs = options.defaultTimeoutMs ?? 300_000;
105
+ const maxPendingPerWindow = options.maxPendingPerWindow ?? 4;
106
+
107
+ /** approvalId -> pending record. */
108
+ const pending = new Map();
109
+ /** windowKey -> approvalId[], oldest first. The queue is the fix for the overwrite bug. */
110
+ const byWindow = new Map();
111
+ let disposed = false;
112
+
113
+ function queueFor(windowKey) {
114
+ let q = byWindow.get(windowKey);
115
+ if (!q) {
116
+ q = [];
117
+ byWindow.set(windowKey, q);
118
+ }
119
+ return q;
120
+ }
121
+
122
+ function unlink(windowKey, approvalId) {
123
+ const q = byWindow.get(windowKey);
124
+ if (!q) return;
125
+ const i = q.indexOf(approvalId);
126
+ if (i !== -1) q.splice(i, 1);
127
+ if (q.length === 0) byWindow.delete(windowKey);
128
+ }
129
+
130
+ /** Settle one record exactly once. Every path out of `pending` goes through here. */
131
+ function settle(record, outcome, resolution) {
132
+ if (record.settled) return;
133
+ record.settled = true;
134
+ if (record.timer !== null) clearTimer(record.timer);
135
+ if (record.onAbort && record.signal) record.signal.removeEventListener('abort', record.onAbort);
136
+ pending.delete(record.id);
137
+ unlink(record.windowKey, record.id);
138
+ audit(resolution === RESOLUTION.OPERATOR ? 'approval/decided' : 'approval/delegated', {
139
+ approval_id: record.id,
140
+ window: record.windowKey,
141
+ tool: record.toolName,
142
+ outcome,
143
+ resolution,
144
+ waited_ms: now() - record.openedAt,
145
+ });
146
+ record.resolve(outcome);
147
+ }
148
+
149
+ /**
150
+ * Offer a request to the bridge.
151
+ *
152
+ * Returns a discriminated result rather than an outcome, because "I will not
153
+ * answer this" and "the answer is no" are different things to the caller and the
154
+ * host. `claim: false` means the caller must return `undefined` **without having
155
+ * called `next`**; `claim: true` means the caller owns the outcome and `promise`
156
+ * settles exactly once.
157
+ *
158
+ * @param {object} input
159
+ * @param {{agent?: {id?: string}, toolName?: string, callId?: string, reason?: string, signal?: AbortSignal}} input.request
160
+ * @param {string|null} input.windowKey The IM window bound to this request's session, or null.
161
+ * @param {number} [input.timeoutMs]
162
+ * @param {() => Promise<string>} input.delegate Calls the answerers registered behind us.
163
+ * Called at most once, and only when the bridge declines *after* claiming.
164
+ * @returns {{claim: false, reason: string} | {claim: true, id: string, promise: Promise<string>}}
165
+ */
166
+ function offer({ request, windowKey, timeoutMs, delegate }) {
167
+ if (disposed) return { claim: false, reason: 'disposed' };
168
+ if (typeof delegate !== 'function') {
169
+ throw new TypeError('offer() requires a delegate() that calls the answerers behind this one');
170
+ }
171
+ if (request?.signal?.aborted) return { claim: false, reason: 'aborted' };
172
+ // Nothing to ask. Declining here — synchronously, with `next` untouched — is what
173
+ // keeps this plugin from swallowing approvals it has no business answering.
174
+ if (windowKey === null || windowKey === undefined || windowKey === '') {
175
+ return { claim: false, reason: 'no_window' };
176
+ }
177
+
178
+ const queue = queueFor(windowKey);
179
+ if (queue.length >= maxPendingPerWindow) {
180
+ audit('approval/refused-backlog', { window: windowKey, depth: queue.length });
181
+ return { claim: false, reason: 'backlog' };
182
+ }
183
+
184
+ const id = mintId();
185
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
186
+ let resolveOutcome;
187
+ const promise = new Promise((resolve) => {
188
+ resolveOutcome = resolve;
189
+ });
190
+
191
+ const record = {
192
+ id,
193
+ windowKey,
194
+ toolName: request?.toolName ?? null,
195
+ callId: request?.callId ?? null,
196
+ reason: request?.reason ?? null,
197
+ signal: request?.signal ?? null,
198
+ openedAt: now(),
199
+ deadlineAt: now() + timeout,
200
+ settled: false,
201
+ timer: null,
202
+ onAbort: null,
203
+ resolve: resolveOutcome,
204
+ };
205
+
206
+ // Order matters: the record must be reachable by the time the timer or the
207
+ // abort listener can fire, or a zero-length timeout would settle nothing.
208
+ pending.set(id, record);
209
+ queue.push(id);
210
+ audit('approval/asked', {
211
+ approval_id: id,
212
+ window: windowKey,
213
+ tool: record.toolName,
214
+ timeout_ms: timeout,
215
+ });
216
+
217
+ record.timer = setTimer(() => {
218
+ // Delegating rather than deciding. The answerers behind us are allowed to
219
+ // allow this; reporting our own silence as a denial would be a lie about what
220
+ // the operator chose, and would deny work the host would have permitted.
221
+ void delegate().then(
222
+ (outcome) => settle(record, normaliseDelegated(outcome), RESOLUTION.DELEGATED),
223
+ () => settle(record, 'unavailable', RESOLUTION.DELEGATED),
224
+ );
225
+ }, timeout);
226
+ if (typeof record.timer?.unref === 'function') record.timer.unref();
227
+
228
+ if (record.signal) {
229
+ record.onAbort = () => settle(record, 'cancelled', RESOLUTION.CANCELLED);
230
+ record.signal.addEventListener('abort', record.onAbort, { once: true });
231
+ }
232
+
233
+ return { claim: true, id, promise };
234
+ }
235
+
236
+ /** The host may return any of its four outcomes; keep it, but never invent one. */
237
+ function normaliseDelegated(outcome) {
238
+ return OUTCOMES.includes(outcome) ? outcome : 'unavailable';
239
+ }
240
+
241
+ /**
242
+ * Answer a pending request from IM.
243
+ *
244
+ * @param {string} approvalId
245
+ * @param {'allowed-once'|'rejected'} outcome
246
+ * @returns {{ok: true} | {ok: false, reason: string}}
247
+ */
248
+ function decide(approvalId, outcome) {
249
+ if (!OPERATOR_OUTCOMES.has(outcome)) return { ok: false, reason: 'bad_outcome' };
250
+ const record = pending.get(approvalId);
251
+ // Already decided, already delegated, or never ours. The 0.1.0 code answered
252
+ // "✅ approved" on the strength of a promise resolving; here an unknown id is a
253
+ // refusal with a name.
254
+ if (!record) return { ok: false, reason: 'unknown_or_settled' };
255
+ if (now() >= record.deadlineAt) return { ok: false, reason: 'expired' };
256
+ settle(record, outcome, RESOLUTION.OPERATOR);
257
+ return { ok: true };
258
+ }
259
+
260
+ /** The oldest request still waiting in a window, or null. `/pending` renders this. */
261
+ function oldestFor(windowKey) {
262
+ const q = byWindow.get(windowKey);
263
+ if (!q || q.length === 0) return null;
264
+ const record = pending.get(q[0]);
265
+ return record ? publicView(record) : null;
266
+ }
267
+
268
+ /** Every request still waiting in a window, oldest first. */
269
+ function listFor(windowKey) {
270
+ const q = byWindow.get(windowKey);
271
+ if (!q) return [];
272
+ return q.map((id) => pending.get(id)).filter(Boolean).map(publicView);
273
+ }
274
+
275
+ function publicView(record) {
276
+ return {
277
+ approval_id: record.id,
278
+ window: record.windowKey,
279
+ tool: record.toolName,
280
+ reason: record.reason,
281
+ opened_at: record.openedAt,
282
+ deadline_at: record.deadlineAt,
283
+ remaining_ms: Math.max(0, record.deadlineAt - now()),
284
+ };
285
+ }
286
+
287
+ /**
288
+ * Delegate everything still pending, then refuse new work.
289
+ *
290
+ * Called from the plugin's disposer. Without this, a reload leaves timers alive
291
+ * that will resolve promises belonging to a fiber that no longer exists — the
292
+ * 0.1.0 code had exactly that leak.
293
+ */
294
+ function dispose() {
295
+ if (disposed) return { delegated: 0 };
296
+ disposed = true;
297
+ let delegated = 0;
298
+ for (const record of [...pending.values()]) {
299
+ delegated += 1;
300
+ // On shutdown there is nobody left to ask, so the host's own answerers must
301
+ // take it. `unavailable` would be an answer; this is not an answer.
302
+ settle(record, 'unavailable', RESOLUTION.DISPOSED);
303
+ }
304
+ byWindow.clear();
305
+ return { delegated };
306
+ }
307
+
308
+ return {
309
+ offer,
310
+ decide,
311
+ oldestFor,
312
+ listFor,
313
+ dispose,
314
+ get size() {
315
+ return pending.size;
316
+ },
317
+ /** Exposed for tests and diagnostics only. */
318
+ _internals: { pending, byWindow },
319
+ };
320
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * The device keypair and the authorisation statement (v0.7.6).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * v0.7.5 gave a machine an account code and a serial number. Both are **client-computed**, so
7
+ * neither proves anything: anyone who knows an account id can produce a well-formed SN for
8
+ * hardware they do not own. `ACCOUNT-FLEET-DESIGN.md` §2.5 says so in as many words, and this
9
+ * file is the part that fixes it.
10
+ *
11
+ * The fix is a signature chain with three links:
12
+ *
13
+ * 1. **The device generates a keypair** on first run. The private key never leaves the machine
14
+ * and is never logged.
15
+ * 2. **The relay signs a statement** naming this device, its serial number, its public key and
16
+ * its fleet. The fleet comes from *the pairing code*, not from anything the device said — so a
17
+ * device **cannot choose which fleet it joins**. That is the whole of §3.2.
18
+ * 3. **Every request carries a signature** over the statement plus the request, so membership is
19
+ * proven per request rather than asserted once.
20
+ *
21
+ * ## What this file is careful about
22
+ *
23
+ * **The statement is signed over a canonical encoding.** Two JSON encodings of the same object
24
+ * can sign differently, and a verifier that re-serialises the object it received cannot tell a
25
+ * tampered field from a key-order difference. So the statement travels **as the exact bytes that
26
+ * were signed**, and the verifier signs nothing it reassembled.
27
+ *
28
+ * **Expiry is mandatory.** A statement with no expiry is a permanent credential; a leaked one
29
+ * cannot be aged out. `verifyStatement` refuses a statement without one rather than defaulting to
30
+ * "never expires".
31
+ *
32
+ * **A device cannot widen its own statement.** The fleet id, the machine id and the public key
33
+ * are all inside the signature, so changing any of them invalidates it — which is what makes
34
+ * "this device is in this fleet" a fact rather than a claim.
35
+ */
36
+
37
+ import {
38
+ createPrivateKey,
39
+ createPublicKey,
40
+ generateKeyPairSync,
41
+ createHash,
42
+ randomUUID,
43
+ sign as edSign,
44
+ verify as edVerify,
45
+ createHmac,
46
+ timingSafeEqual,
47
+ } from 'node:crypto';
48
+
49
+ /** Signature algorithms this module will use, and nothing else. */
50
+ export const ALGORITHM = 'ed25519';
51
+
52
+ /** Default statement lifetime: 90 days, per the design. */
53
+ export const DEFAULT_TTL_MS = 90 * 24 * 60 * 60 * 1000;
54
+
55
+ /** How long before expiry a statement may be renewed. */
56
+ export const RENEW_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
57
+
58
+ /** Why a statement or a signature was refused. */
59
+ export const AUTH_REFUSAL = Object.freeze({
60
+ MALFORMED: 'malformed',
61
+ BAD_SIGNATURE: 'bad_signature',
62
+ EXPIRED: 'expired',
63
+ NOT_YET_VALID: 'not_yet_valid',
64
+ NO_EXPIRY: 'no_expiry',
65
+ FLEET_MISMATCH: 'fleet_mismatch',
66
+ MACHINE_MISMATCH: 'machine_mismatch',
67
+ WRONG_ALGORITHM: 'wrong_algorithm',
68
+ CLOCK_SKEW: 'clock_skew',
69
+ });
70
+
71
+ /** How far a device's clock may drift before its request timestamps are refused. */
72
+ export const CLOCK_SKEW_MS = 120_000;
73
+
74
+ /**
75
+ * A canonical, unambiguous encoding of a statement's signed fields.
76
+ *
77
+ * Built by hand rather than with `JSON.stringify` so that **key order is fixed here** and cannot
78
+ * depend on how the object was constructed. A verifier that re-serialised a received object would
79
+ * be verifying its own encoding, which is not the same string the signer signed.
80
+ *
81
+ * Length-prefixing each field removes the other classic ambiguity: without it,
82
+ * `{a:'x', b:'yz'}` and `{a:'xy', b:'z'}` concatenate identically.
83
+ */
84
+ export function canonicalStatement(statement) {
85
+ const fields = ['algorithm', 'fleet_id', 'machine_id', 'sn', 'public_key', 'issued_at_ms', 'expires_at_ms'];
86
+ const parts = [];
87
+ for (const field of fields) {
88
+ const value = statement?.[field];
89
+ if (value === undefined || value === null) throw new TypeError(`statement is missing ${field}`);
90
+ const text = String(value);
91
+ parts.push(`${field}:${Buffer.byteLength(text, 'utf8')}:${text}`);
92
+ }
93
+ return parts.join('\n');
94
+ }
95
+
96
+ /**
97
+ * A canonical encoding of one signed request.
98
+ *
99
+ * `body_hash` is the hash of the request body rather than the body itself: a body can be large,
100
+ * and the signature only needs to commit to it.
101
+ */
102
+ export function canonicalRequest({ method, path, timestampMs, nonce, bodyHash }) {
103
+ const parts = [
104
+ `method:${Buffer.byteLength(String(method), 'utf8')}:${method}`,
105
+ `path:${Buffer.byteLength(String(path), 'utf8')}:${path}`,
106
+ `timestamp_ms:${String(timestampMs).length}:${timestampMs}`,
107
+ `nonce:${Buffer.byteLength(String(nonce), 'utf8')}:${nonce}`,
108
+ `body_hash:${String(bodyHash).length}:${bodyHash}`,
109
+ ];
110
+ return parts.join('\n');
111
+ }
112
+
113
+ /** Hash a request body for signing. Absent body hashes as the empty string's hash, not as null. */
114
+ export function hashBody(body) {
115
+ const text = body === undefined || body === null ? '' : typeof body === 'string' ? body : JSON.stringify(body);
116
+ return createHash('sha256').update(text, 'utf8').digest('hex');
117
+ }
118
+
119
+ /**
120
+ * Create a device identity: a keypair plus a stable machine id.
121
+ *
122
+ * @param {object} [options]
123
+ * @param {string} [options.privateKeyPem] An existing key, so a restart keeps its identity.
124
+ * @param {string} [options.machineId]
125
+ * @param {() => string} [options.mintMachineId]
126
+ */
127
+ export function createDeviceKey(options = {}) {
128
+ const mintMachineId = options.mintMachineId ?? randomUUID;
129
+ let privateKey;
130
+ let generated = false;
131
+
132
+ if (typeof options.privateKeyPem === 'string' && options.privateKeyPem !== '') {
133
+ privateKey = createPrivateKey(options.privateKeyPem);
134
+ } else {
135
+ // Ed25519 needs no parameters and no randomness argument here; the platform CSPRNG supplies it.
136
+ const pair = generateKeyPairSync(ALGORITHM);
137
+ privateKey = pair.privateKey;
138
+ generated = true;
139
+ }
140
+
141
+ if (privateKey.asymmetricKeyType !== ALGORITHM) {
142
+ throw new TypeError(`the device key must be ${ALGORITHM}, not ${privateKey.asymmetricKeyType}`);
143
+ }
144
+
145
+ const publicKey = createPublicKey(privateKey);
146
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
147
+ const machineId = options.machineId ?? mintMachineId();
148
+
149
+ return {
150
+ machineId,
151
+ publicKeyPem,
152
+ generated,
153
+ /** The private key, for persistence. Callers must write it 0600 and never log it. */
154
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
155
+ /** Sign arbitrary bytes with the device key. */
156
+ sign(bytes) {
157
+ return edSign(null, Buffer.from(bytes, 'utf8'), privateKey);
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Verify a detached signature against a public key.
164
+ *
165
+ * Returns a value rather than throwing, because a bad signature is an expected input on the
166
+ * relay's hot path and an exception there is a denial-of-service shape.
167
+ */
168
+ export function verifyWith(publicKeyPem, bytes, signature) {
169
+ try {
170
+ const key = createPublicKey(publicKeyPem);
171
+ return edVerify(null, Buffer.from(bytes, 'utf8'), key, Buffer.from(signature));
172
+ } catch {
173
+ // An unparseable key or signature is a failed verification, not a crash.
174
+ return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Sign a statement into the wire form.
180
+ *
181
+ * The wire form carries **the exact bytes that were signed** alongside the signature, so a
182
+ * verifier never re-serialises. `canonical` is included rather than recomputed for that reason.
183
+ *
184
+ * @param {object} options
185
+ * @param {object} options.statement The fields to commit to.
186
+ * @param {(bytes: string) => Buffer} options.sign The relay's signer.
187
+ */
188
+ export function sealStatement({ statement, sign }) {
189
+ const canonical = canonicalStatement(statement);
190
+ const signature = sign(canonical);
191
+ return {
192
+ statement,
193
+ canonical,
194
+ signature: Buffer.from(signature).toString('base64'),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Verify a sealed statement.
200
+ *
201
+ * @param {object} options
202
+ * @param {object} options.sealed The wire form.
203
+ * @param {(canonical: string, signature: Buffer) => boolean} options.verify The relay's verifier.
204
+ * @param {string} [options.expectFleet] Refuse a statement for another fleet.
205
+ * @param {string} [options.expectMachine] Refuse a statement for another machine.
206
+ * @param {number} [options.now]
207
+ */
208
+ export function verifyStatement({ sealed, verify, expectFleet, expectMachine, now = Date.now() }) {
209
+ if (sealed === null || typeof sealed !== 'object') return { ok: false, reason: AUTH_REFUSAL.MALFORMED };
210
+ const { statement, canonical, signature } = sealed;
211
+ if (statement === null || typeof statement !== 'object' || typeof canonical !== 'string' || typeof signature !== 'string') {
212
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED };
213
+ }
214
+ if (statement.algorithm !== ALGORITHM) {
215
+ return { ok: false, reason: AUTH_REFUSAL.WRONG_ALGORITHM, detail: String(statement.algorithm) };
216
+ }
217
+
218
+ /**
219
+ * Expiry is checked **before** the canonical form is recomputed.
220
+ *
221
+ * Order matters here, and a test is what found it. `canonicalStatement` refuses a statement
222
+ * missing a signed field, so a statement with no expiry reported `malformed` — technically true
223
+ * and useless, because the actionable answer is "this statement has no expiry and must not be
224
+ * treated as permanent". Checking it first names the real problem.
225
+ */
226
+ const expires = Number(statement.expires_at_ms);
227
+ if (!Number.isFinite(expires)) return { ok: false, reason: AUTH_REFUSAL.NO_EXPIRY };
228
+
229
+ // The signed bytes must be the bytes that describe this statement. Recomputing and comparing
230
+ // is what stops a caller swapping in a different statement beside a valid signature — and it is
231
+ // cheap, because the canonical form is already on the wire.
232
+ let recomputed;
233
+ try {
234
+ recomputed = canonicalStatement(statement);
235
+ } catch (error) {
236
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED, detail: error.message };
237
+ }
238
+ if (recomputed !== canonical) {
239
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED, detail: 'the canonical form does not match the statement' };
240
+ }
241
+
242
+ let signatureBytes;
243
+ try {
244
+ signatureBytes = Buffer.from(signature, 'base64');
245
+ } catch {
246
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED, detail: 'signature is not base64' };
247
+ }
248
+ if (!verify(canonical, signatureBytes)) return { ok: false, reason: AUTH_REFUSAL.BAD_SIGNATURE };
249
+
250
+ const issued = Number(statement.issued_at_ms);
251
+ if (!Number.isFinite(issued)) return { ok: false, reason: AUTH_REFUSAL.MALFORMED, detail: 'issued_at_ms is not a number' };
252
+
253
+ if (now > expires) return { ok: false, reason: AUTH_REFUSAL.EXPIRED, detail: new Date(expires).toISOString() };
254
+ // A statement issued in the future means a clock problem, which is worth naming separately from
255
+ // an expiry: the fixes are different.
256
+ if (issued > now + CLOCK_SKEW_MS) return { ok: false, reason: AUTH_REFUSAL.NOT_YET_VALID, detail: new Date(issued).toISOString() };
257
+
258
+ if (typeof expectFleet === 'string' && statement.fleet_id !== expectFleet) {
259
+ return { ok: false, reason: AUTH_REFUSAL.FLEET_MISMATCH, detail: `statement is for ${statement.fleet_id}` };
260
+ }
261
+ if (typeof expectMachine === 'string' && statement.machine_id !== expectMachine) {
262
+ return { ok: false, reason: AUTH_REFUSAL.MACHINE_MISMATCH };
263
+ }
264
+ return { ok: true, statement };
265
+ }
266
+
267
+ /**
268
+ * Sign one request.
269
+ *
270
+ * @param {object} options
271
+ * @param {object} options.device From `createDeviceKey`.
272
+ * @param {string} options.method
273
+ * @param {string} options.path
274
+ * @param {unknown} [options.body]
275
+ * @param {number} [options.now]
276
+ * @param {() => string} [options.mintNonce]
277
+ */
278
+ export function signRequest({ device, method, path, body, now = Date.now(), mintNonce = randomUUID }) {
279
+ const timestampMs = now;
280
+ const nonce = mintNonce();
281
+ const bodyHash = hashBody(body);
282
+ const canonical = canonicalRequest({ method, path, timestampMs, nonce, bodyHash });
283
+ return {
284
+ timestampMs,
285
+ nonce,
286
+ bodyHash,
287
+ signature: device.sign(canonical).toString('base64'),
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Verify one request's signature.
293
+ *
294
+ * @param {object} options
295
+ * @param {object} options.headers The request headers, lower-cased.
296
+ * @param {string} options.publicKeyPem
297
+ * @param {string} options.method
298
+ * @param {string} options.path
299
+ * @param {unknown} [options.body]
300
+ * @param {number} [options.now]
301
+ * @param {(nonce: string, timestampMs: number) => boolean} [options.rememberNonce]
302
+ * Replay defence, injected: without it a captured request is valid forever.
303
+ */
304
+ export function verifyRequest({ headers, publicKeyPem, method, path, body, now = Date.now(), rememberNonce }) {
305
+ const ts = Number(headers['x-w2m-timestamp']);
306
+ const nonce = headers['x-w2m-nonce'];
307
+ const signature = headers['x-w2m-signature'];
308
+ const claimedHash = headers['x-w2m-body-hash'];
309
+ if (!Number.isFinite(ts) || typeof nonce !== 'string' || nonce === '' || typeof signature !== 'string' || signature === '') {
310
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED, detail: 'missing signature headers' };
311
+ }
312
+ if (Math.abs(now - ts) > CLOCK_SKEW_MS) {
313
+ // Outside the window the request is either replayed or from a machine whose clock is wrong;
314
+ // both are refused, and the window is named so the second case is diagnosable.
315
+ return { ok: false, reason: AUTH_REFUSAL.CLOCK_SKEW, detail: `timestamp is ${Math.round((now - ts) / 1000)}s from now` };
316
+ }
317
+
318
+ const bodyHash = hashBody(body);
319
+ // The body must hash to what was signed. Without this check the signature would authorise the
320
+ // *route* while saying nothing about the payload.
321
+ if (typeof claimedHash === 'string' && claimedHash !== '' && claimedHash !== bodyHash) {
322
+ return { ok: false, reason: AUTH_REFUSAL.BAD_SIGNATURE, detail: 'the body does not match the signed hash' };
323
+ }
324
+
325
+ const canonical = canonicalRequest({ method, path, timestampMs: ts, nonce, bodyHash });
326
+ let signatureBytes;
327
+ try {
328
+ signatureBytes = Buffer.from(signature, 'base64');
329
+ } catch {
330
+ return { ok: false, reason: AUTH_REFUSAL.MALFORMED };
331
+ }
332
+ if (!verifyWith(publicKeyPem, canonical, signatureBytes)) {
333
+ return { ok: false, reason: AUTH_REFUSAL.BAD_SIGNATURE };
334
+ }
335
+ // Recorded only after the signature checks out, so an attacker cannot fill the replay table by
336
+ // sending garbage nonces.
337
+ if (typeof rememberNonce === 'function' && rememberNonce(nonce, ts) === false) {
338
+ return { ok: false, reason: AUTH_REFUSAL.BAD_SIGNATURE, detail: 'this nonce was already used' };
339
+ }
340
+ return { ok: true, nonce, timestampMs: ts };
341
+ }
342
+
343
+ /** Constant-time string comparison, for tokens. */
344
+ export function safeEqual(a, b) {
345
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
346
+ const ab = Buffer.from(a, 'utf8');
347
+ const bb = Buffer.from(b, 'utf8');
348
+ if (ab.length !== bb.length) return false;
349
+ return timingSafeEqual(ab, bb);
350
+ }
351
+
352
+ /**
353
+ * A bounded replay window.
354
+ *
355
+ * Bounded on purpose: an unbounded set of seen nonces is a memory leak that a peer can drive, and
356
+ * the timestamp window already rejects anything old enough to have fallen out.
357
+ */
358
+ export function createReplayGuard({ windowMs = CLOCK_SKEW_MS, now = Date.now } = {}) {
359
+ const seen = new Map();
360
+ return function remember(nonce, timestampMs) {
361
+ const current = now();
362
+ for (const [key, at] of seen) {
363
+ if (current - at > windowMs) seen.delete(key);
364
+ }
365
+ if (seen.has(nonce)) return false;
366
+ seen.set(nonce, Number.isFinite(timestampMs) ? timestampMs : current);
367
+ return true;
368
+ };
369
+ }
370
+
371
+ /** Derive a relay's fleet signing key from a master secret, so one secret serves many fleets. */
372
+ export function deriveFleetKey(masterSecret, fleetId) {
373
+ if (typeof masterSecret !== 'string' || masterSecret === '') throw new TypeError('deriveFleetKey needs a master secret');
374
+ if (typeof fleetId !== 'string' || fleetId === '') throw new TypeError('deriveFleetKey needs a fleet id');
375
+ return createHmac('sha256', masterSecret).update(`w2m-fleet-v1:${fleetId}`, 'utf8').digest();
376
+ }