@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,332 @@
1
+ /**
2
+ * The settings endpoint, and the two ways it leaked (v0.7.0).
3
+ *
4
+ * ## The defects this file exists to make impossible
5
+ *
6
+ * `dsh-chatops` 0.1.0 mounted seven loopback routes. Two of them were a matched pair of
7
+ * serious findings:
8
+ *
9
+ * **H1 — the read served every credential in cleartext.** `GET /chatops/api/config` returned
10
+ * the raw merged config object, which includes `feishu.appSecret`, `dingtalk.clientSecret`
11
+ * and `wecom.secret`. The only gate was a `remoteAddress` loopback test: no token, no
12
+ * `Origin` check, no `Host` check. Any local process — or a hostile page reaching loopback
13
+ * through DNS rebinding or a forwarded proxy — could read four platforms' credentials. The
14
+ * settings page then re-fetched that payload **every three seconds**.
15
+ *
16
+ * **H2 — the write accepted arbitrary keys and wrote them to the profile.** `POST` to the
17
+ * same path merged the body with `{ ...current, ...next }` and wrote the result to
18
+ * `cordis.patch.yml`. Because nothing validated the keys, a caller could set `profilePatch`
19
+ * to redirect the write, and `puppet` — a value later handed to `await import()`. That is
20
+ * arbitrary module load reachable from a loopback request, and the profile's own patch layer
21
+ * permits `!!js` expressions, so a YAML-write primitive there is a code-execution primitive
22
+ * at the next reload.
23
+ *
24
+ * ## What is different here
25
+ *
26
+ * * **Secrets are never returned.** Not masked with a fixed string, which still tells an
27
+ * attacker the field exists and is set — returned as a `{ set: boolean }` marker, so the
28
+ * form can show "configured" without the value leaving the process. A write that omits a
29
+ * secret keeps the existing one, which is what makes "never returned" workable.
30
+ * * **Unknown keys are refused, not dropped.** 0.1.0's spread accepted anything. Dropping
31
+ * silently would leave the caller believing a setting took effect; refusing names it.
32
+ * * **Every route needs a token**, generated per install, and the token is compared in
33
+ * constant time. Loopback is necessary and not sufficient — it was the whole of 0.1.0's
34
+ * defence.
35
+ * * **`Origin` and `Host` are checked.** This is the rebinding defence: a page on
36
+ * `evil.example` resolving to `127.0.0.1` sends `Origin: https://evil.example` and a
37
+ * matching `Host`, and both are refused.
38
+ * * **Reads are not polled into a write path, and writes need a body limit.** The 3-second
39
+ * re-fetch of a secret-bearing payload is gone because the payload no longer has secrets.
40
+ *
41
+ * The module is plain request-in / response-out with no server attached, so every refusal
42
+ * above is tested directly rather than through a live socket.
43
+ */
44
+
45
+ /** What the route answers. */
46
+ export const CONFIG_ROUTE = Object.freeze({
47
+ GET: 'get',
48
+ POST: 'post',
49
+ });
50
+
51
+ /** Why a request was refused. Returned as a code so a test can assert on it. */
52
+ export const CONFIG_REFUSAL = Object.freeze({
53
+ /** Not a loopback peer. Necessary, and nowhere near sufficient. */
54
+ NOT_LOOPBACK: 'not_loopback',
55
+ /** No `Authorization` header. */
56
+ NO_TOKEN: 'no_token',
57
+ /** The header was present and wrong. */
58
+ BAD_TOKEN: 'bad_token',
59
+ /** `Origin` or `Host` is not one this install answers to. */
60
+ BAD_ORIGIN: 'bad_origin',
61
+ /** A verb this route does not answer. */
62
+ METHOD: 'method',
63
+ /** A body that is not a JSON object. */
64
+ BAD_BODY: 'bad_body',
65
+ /** A key that is not part of the schema. Named, because dropping it silently lies. */
66
+ UNKNOWN_KEY: 'unknown_key',
67
+ /** A value the schema rejects. */
68
+ BAD_VALUE: 'bad_value',
69
+ /** Larger than the route accepts. */
70
+ TOO_LARGE: 'too_large',
71
+ });
72
+
73
+ /** Keys a config write may carry, with a validator each. Everything else is refused. */
74
+ export const CONFIG_SCHEMA = Object.freeze({
75
+ channel: (v) => (typeof v === 'string' && v !== '' ? null : 'must be a non-empty string'),
76
+ channels: (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? null : 'must be an array of strings'),
77
+ storagePath: (v) => (typeof v === 'string' ? null : 'must be a string'),
78
+ security: (v) => {
79
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return 'must be an object';
80
+ const allowed = new Set(['listenFilehelper', 'listenSelf', 'allowContacts', 'allowRooms']);
81
+ for (const key of Object.keys(v)) {
82
+ if (!allowed.has(key)) return `security.${key} is not a setting`;
83
+ }
84
+ if (v.allowContacts !== undefined && !Array.isArray(v.allowContacts)) return 'security.allowContacts must be an array';
85
+ if (v.allowRooms !== undefined && !Array.isArray(v.allowRooms)) return 'security.allowRooms must be an array';
86
+ return null;
87
+ },
88
+ push: (v) => {
89
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return 'must be an object';
90
+ const allowed = new Set(['onSessionComplete', 'onApproval', 'approvalTimeoutSec', 'longOutputAsFile']);
91
+ for (const key of Object.keys(v)) {
92
+ if (!allowed.has(key)) return `push.${key} is not a setting`;
93
+ }
94
+ return null;
95
+ },
96
+ reply: (v) => {
97
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return 'must be an object';
98
+ const allowed = new Set(['maxChunkBytes', 'rateLimitMs', 'maxFileMB']);
99
+ for (const key of Object.keys(v)) {
100
+ if (!allowed.has(key)) return `reply.${key} is not a setting`;
101
+ }
102
+ return null;
103
+ },
104
+ feishu: (v) => (v === null || typeof v === 'object' ? null : 'must be an object'),
105
+ dingtalk: (v) => (v === null || typeof v === 'object' ? null : 'must be an object'),
106
+ wecom: (v) => (v === null || typeof v === 'object' ? null : 'must be an object'),
107
+ });
108
+
109
+ /**
110
+ * Keys that are refused even if a validator is added later.
111
+ *
112
+ * `profilePatch` and `puppet` are the two 0.1.0 accepted and should not have: the first
113
+ * redirects where the config is written, the second names a module to import. Both are
114
+ * named individually so the refusal says which one and why, rather than "unknown key".
115
+ */
116
+ export const FORBIDDEN_KEYS = Object.freeze({
117
+ profilePatch: 'the write target is this plugin\'s own row and cannot be redirected from a request',
118
+ puppet: 'a module specifier cannot be supplied by a request; it would be imported into the host process',
119
+ });
120
+
121
+ /** Secret-bearing paths, by dotted key. Never returned; a write that omits one keeps it. */
122
+ const SECRET_PATHS = Object.freeze([
123
+ ['feishu', 'appSecret'],
124
+ ['dingtalk', 'clientSecret'],
125
+ ['wecom', 'secret'],
126
+ ['ilink', 'token'],
127
+ ]);
128
+
129
+ /** Constant-time string compare, so a wrong token cannot be found one byte at a time. */
130
+ function timingSafeEqual(a, b) {
131
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
132
+ if (a.length !== b.length) return false;
133
+ let diff = 0;
134
+ for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
135
+ return diff === 0;
136
+ }
137
+
138
+ function isLoopback(remoteAddress) {
139
+ if (typeof remoteAddress !== 'string') return false;
140
+ return (
141
+ remoteAddress === '127.0.0.1' ||
142
+ remoteAddress === '::1' ||
143
+ remoteAddress === '::ffff:127.0.0.1' ||
144
+ remoteAddress.startsWith('127.')
145
+ );
146
+ }
147
+
148
+ /** `Host` without its port, lower-cased. */
149
+ function hostOnly(value) {
150
+ if (typeof value !== 'string') return null;
151
+ const v = value.trim().toLowerCase();
152
+ if (v.startsWith('[')) return v.slice(1, v.indexOf(']'));
153
+ const cut = v.indexOf(':');
154
+ return cut === -1 ? v : v.slice(0, cut);
155
+ }
156
+
157
+ /** Build the read view: every secret replaced by whether it is set. */
158
+ export function redactConfig(config) {
159
+ const view = {};
160
+ for (const [key, value] of Object.entries(config ?? {})) {
161
+ if (key === 'operatorToken') continue;
162
+ view[key] = value === null || typeof value !== 'object' || Array.isArray(value) ? value : { ...value };
163
+ }
164
+ for (const path of SECRET_PATHS) {
165
+ const [section, field] = path;
166
+ if (view[section] && typeof view[section] === 'object') {
167
+ const had = typeof view[section][field] === 'string' && view[section][field] !== '';
168
+ delete view[section][field];
169
+ // `configured` rather than the value, and rather than a fixed mask: a mask still tells
170
+ // a reader the field exists and is set, and the form only needs to know that much.
171
+ view[section][`${field}Configured`] = had;
172
+ }
173
+ }
174
+ return view;
175
+ }
176
+
177
+ /**
178
+ * Merge a validated write into the current config.
179
+ *
180
+ * Secrets the body omits are **kept**. That is what makes "never returned" workable: the
181
+ * settings form sends back what it was shown, and the credential it was never shown survives.
182
+ */
183
+ export function mergeConfig(current, patch) {
184
+ const next = { ...(current ?? {}) };
185
+ for (const [key, value] of Object.entries(patch)) {
186
+ if (value !== null && typeof value === 'object' && !Array.isArray(value) && typeof next[key] === 'object' && next[key] !== null) {
187
+ const merged = { ...next[key], ...value };
188
+ // A secret sent as an empty string means "leave it": an operator clearing a visible
189
+ // password field should not wipe a working credential.
190
+ for (const [section, field] of SECRET_PATHS) {
191
+ if (section === key && value[field] === '' && typeof next[key]?.[field] === 'string') {
192
+ merged[field] = next[key][field];
193
+ }
194
+ }
195
+ next[key] = merged;
196
+ } else {
197
+ next[key] = value;
198
+ }
199
+ }
200
+ return next;
201
+ }
202
+
203
+ /** Validate a write body against the schema. Returns the first problem, or null. */
204
+ export function validateConfigPatch(patch) {
205
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
206
+ return { code: CONFIG_REFUSAL.BAD_BODY, detail: 'body must be a JSON object' };
207
+ }
208
+ for (const key of Object.keys(patch)) {
209
+ if (FORBIDDEN_KEYS[key]) return { code: CONFIG_REFUSAL.UNKNOWN_KEY, detail: `${key}: ${FORBIDDEN_KEYS[key]}` };
210
+ const validator = CONFIG_SCHEMA[key];
211
+ // Refused, not dropped. A dropped key leaves the caller believing the setting took
212
+ // effect, which is the failure this whole file is written against.
213
+ if (!validator) return { code: CONFIG_REFUSAL.UNKNOWN_KEY, detail: `${key} is not a setting` };
214
+ const problem = validator(patch[key]);
215
+ if (problem) return { code: CONFIG_REFUSAL.BAD_VALUE, detail: `${key}: ${problem}` };
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Create the handler.
222
+ *
223
+ * @param {object} options
224
+ * @param {string} options.token Per-install token. Required; there is no unauthenticated mode.
225
+ * @param {() => object} options.read Current config.
226
+ * @param {(next: object) => Promise<void>|void} options.write Persist a validated config.
227
+ * @param {number} [options.maxBodyBytes]
228
+ * @param {string[]} [options.allowedHosts] Hostnames this install answers to.
229
+ * @param {(event: string, detail?: object) => void} [options.audit]
230
+ */
231
+ export function createConfigRoute(options) {
232
+ if (typeof options?.token !== 'string' || options.token === '') {
233
+ throw new TypeError('createConfigRoute requires a token; there is no unauthenticated mode');
234
+ }
235
+ if (typeof options.read !== 'function' || typeof options.write !== 'function') {
236
+ throw new TypeError('createConfigRoute requires read() and write()');
237
+ }
238
+ const token = options.token;
239
+ const audit = options.audit ?? (() => {});
240
+ const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
241
+ // The default set is the loopback names a browser will send. Anything else — including a
242
+ // public name that happens to resolve here — is refused.
243
+ const allowedHosts = new Set(options.allowedHosts ?? ['127.0.0.1', 'localhost', '::1']);
244
+
245
+ /**
246
+ * @param {object} request
247
+ * @param {string} request.method
248
+ * @param {string} [request.remoteAddress]
249
+ * @param {Record<string,string>} [request.headers]
250
+ * @param {string} [request.body]
251
+ * @returns {Promise<{status: number, body: object}>}
252
+ */
253
+ async function handle(request) {
254
+ const headers = request?.headers ?? {};
255
+ const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
256
+
257
+ // 1. Loopback. First, because it is cheap and because a non-loopback peer should not
258
+ // learn whether the token was right.
259
+ if (!isLoopback(request?.remoteAddress)) {
260
+ audit('config/refused', { reason: CONFIG_REFUSAL.NOT_LOOPBACK, from: request?.remoteAddress ?? null });
261
+ return { status: 403, body: { reason_code: CONFIG_REFUSAL.NOT_LOOPBACK, detail: 'this route answers loopback peers only' } };
262
+ }
263
+
264
+ // 2. Origin and Host. The rebinding defence: a page on another origin reaching 127.0.0.1
265
+ // sends its own Origin and a matching Host, and both are refused here.
266
+ const origin = lower.origin;
267
+ if (origin !== undefined) {
268
+ let originHost;
269
+ try {
270
+ originHost = hostOnly(new URL(origin).host);
271
+ } catch {
272
+ originHost = null;
273
+ }
274
+ if (originHost === null || !allowedHosts.has(originHost)) {
275
+ audit('config/refused', { reason: CONFIG_REFUSAL.BAD_ORIGIN, origin });
276
+ return { status: 403, body: { reason_code: CONFIG_REFUSAL.BAD_ORIGIN, detail: `origin ${origin} is not this install` } };
277
+ }
278
+ }
279
+ const host = hostOnly(lower.host);
280
+ if (host !== null && !allowedHosts.has(host)) {
281
+ audit('config/refused', { reason: CONFIG_REFUSAL.BAD_ORIGIN, host: lower.host });
282
+ return { status: 403, body: { reason_code: CONFIG_REFUSAL.BAD_ORIGIN, detail: `host ${lower.host} is not this install` } };
283
+ }
284
+
285
+ // 3. The token. Loopback is necessary and was the whole of 0.1.0's defence.
286
+ const presented = typeof lower.authorization === 'string' && lower.authorization.startsWith('Bearer ')
287
+ ? lower.authorization.slice('Bearer '.length)
288
+ : '';
289
+ if (presented === '') {
290
+ audit('config/refused', { reason: CONFIG_REFUSAL.NO_TOKEN });
291
+ return { status: 401, body: { reason_code: CONFIG_REFUSAL.NO_TOKEN, detail: 'this route needs the install token' } };
292
+ }
293
+ if (!timingSafeEqual(presented, token)) {
294
+ audit('config/refused', { reason: CONFIG_REFUSAL.BAD_TOKEN });
295
+ return { status: 401, body: { reason_code: CONFIG_REFUSAL.BAD_TOKEN, detail: 'the token was not accepted' } };
296
+ }
297
+
298
+ const method = String(request?.method ?? '').toUpperCase();
299
+ if (method === 'GET') {
300
+ return { status: 200, body: { config: redactConfig(options.read()) } };
301
+ }
302
+ if (method !== 'POST') {
303
+ return { status: 405, body: { reason_code: CONFIG_REFUSAL.METHOD, detail: `${method} is not a method this route answers` } };
304
+ }
305
+
306
+ const body = typeof request.body === 'string' ? request.body : '';
307
+ if (body.length > maxBodyBytes) {
308
+ // Refused before parsing. Accepting it and then rejecting would still have buffered it.
309
+ return { status: 413, body: { reason_code: CONFIG_REFUSAL.TOO_LARGE, detail: `body exceeds ${maxBodyBytes} bytes` } };
310
+ }
311
+ let patch;
312
+ try {
313
+ patch = JSON.parse(body === '' ? '{}' : body);
314
+ } catch {
315
+ return { status: 400, body: { reason_code: CONFIG_REFUSAL.BAD_BODY, detail: 'body is not valid JSON' } };
316
+ }
317
+ const problem = validateConfigPatch(patch);
318
+ if (problem) {
319
+ audit('config/refused', { reason: problem.code, detail: problem.detail });
320
+ return { status: 400, body: { reason_code: problem.code, detail: problem.detail } };
321
+ }
322
+
323
+ const next = mergeConfig(options.read(), patch);
324
+ await options.write(next);
325
+ audit('config/written', { keys: Object.keys(patch) });
326
+ // The write answers with the redacted view, so a form can refresh from the response and
327
+ // never needs a second read of anything secret-bearing.
328
+ return { status: 200, body: { ok: true, config: redactConfig(next) } };
329
+ }
330
+
331
+ return { handle };
332
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Per-device Ed25519 request signing (v0.7.10).
3
+ *
4
+ * ## Why a second signing mechanism
5
+ *
6
+ * The relay already has request signing (`src/signing.mjs`), and it is **HMAC over a shared
7
+ * secret**: everyone who can sign can also verify, so the relay holds a credential that every
8
+ * device holds. That is fine for a fleet where one operator owns every machine and wrong for the
9
+ * thing this workstream is about — proving that *this specific device* is a member of *this
10
+ * account's* fleet. A shared secret proves only that the sender knows the secret, which is every
11
+ * device.
12
+ *
13
+ * This module signs with a **device-held Ed25519 private key**, so the relay verifies with a public
14
+ * key it stores and never holds anything that could impersonate the device.
15
+ *
16
+ * ## Compatibility with the existing mechanism, deliberately
17
+ *
18
+ * The signed bytes are **the relay's own canonical string**, reproduced here byte for byte:
19
+ *
20
+ * v1\n<METHOD>\n<path>\n<timestamp>\n<nonce>\n<sha256(body) hex>
21
+ *
22
+ * Two reasons, and the second is the important one. Reusing the format means one verified string
23
+ * rather than two that must be kept in step; and it means the relay can tell the two apart by
24
+ * **whether the device has a public key**, without a protocol flag, a version negotiation, or a
25
+ * second header that a client could forget to send.
26
+ *
27
+ * ## What this does not do yet
28
+ *
29
+ * The relay half — accept a public key at pairing, store it, and verify with it — is the next
30
+ * step and is **not in this file**. A device that signs today is signing into a relay that does
31
+ * not check it, which is precisely the "not enforced" state recorded in the release notes.
32
+ */
33
+
34
+ import {
35
+ createHmac,
36
+ createPrivateKey,
37
+ createPublicKey,
38
+ generateKeyPairSync,
39
+ randomBytes,
40
+ sign as edSign,
41
+ verify as edVerify,
42
+ } from 'node:crypto';
43
+
44
+ /** Matches `src/signing.mjs`: `v1=<hex>`, and Ed25519 signatures are hex too. */
45
+ export const SIGNATURE_VERSION = 'v1';
46
+
47
+ /** The same three header names the HMAC mechanism uses, so a relay reads one set. */
48
+ export const SIGNATURE_HEADER = 'x-w2m-signature';
49
+ export const TIMESTAMP_HEADER = 'x-w2m-timestamp';
50
+ export const NONCE_HEADER = 'x-w2m-nonce';
51
+
52
+ /**
53
+ * The canonical string, byte-identical to the relay's.
54
+ *
55
+ * **Not reimplemented from the documentation — copied from the implementation**, because a
56
+ * one-character difference produces a signature that verifies nowhere and gives no clue why. The
57
+ * body is hashed with a zero-length HMAC key, which is `sha256` in all but name and is what the
58
+ * relay does.
59
+ */
60
+ export function canonicalString({ method, path, timestamp, nonce, body = '' }) {
61
+ const bodyBytes = Buffer.isBuffer(body) ? body : Buffer.from(body ?? '', 'utf8');
62
+ /**
63
+ * `createHmac` with a **zero-length key**, not `createHash`.
64
+ *
65
+ * This is the relay's exact call, and it is not the same thing as a plain sha256: HMAC wraps the
66
+ * key with its own padding, so `createHash('sha256').update('').digest('hex')` is
67
+ * `e3b0c442…` while `createHmac('sha256', Buffer.alloc(0)).update('').digest('hex')` is
68
+ * `b613679a…`. A hash that differs by construction produces a signature that verifies nowhere
69
+ * and gives no clue why.
70
+ *
71
+ * The probe that caught it signed three requests with both implementations and compared the
72
+ * strings. That comparison is the only reason this is right, which is why it is a test.
73
+ */
74
+ const bodyHash = createHmac('sha256', Buffer.alloc(0)).update(bodyBytes).digest('hex');
75
+ return [SIGNATURE_VERSION, String(method ?? '').toUpperCase(), String(path ?? ''), String(timestamp), String(nonce), bodyHash].join('\n');
76
+ }
77
+
78
+ /** The format the relay parses: `v1=<hex>`. */
79
+ export function formatSignature(hex) {
80
+ return `${SIGNATURE_VERSION}=${hex}`;
81
+ }
82
+
83
+ /** Split `v1=<hex>` back into its parts, or null if it is not that shape. */
84
+ export function parseSignature(presented) {
85
+ if (typeof presented !== 'string') return null;
86
+ const match = /^([a-z0-9]+)=([0-9a-f]+)$/i.exec(presented.trim());
87
+ if (match === null) return null;
88
+ return { version: match[1].toLowerCase(), hex: match[2].toLowerCase() };
89
+ }
90
+
91
+ /**
92
+ * Create a device signing key.
93
+ *
94
+ * @param {object} [options]
95
+ * @param {string} [options.privateKeyPem] An existing key, so a restart keeps its identity.
96
+ * @param {() => string} [options.makeNonce]
97
+ */
98
+ export function createDeviceSigner(options = {}) {
99
+ let privateKey;
100
+ let generated = false;
101
+ if (typeof options.privateKeyPem === 'string' && options.privateKeyPem !== '') {
102
+ privateKey = createPrivateKey(options.privateKeyPem);
103
+ } else {
104
+ privateKey = generateKeyPairSync('ed25519').privateKey;
105
+ generated = true;
106
+ }
107
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
108
+ throw new TypeError(`the device signing key must be ed25519, not ${privateKey.asymmetricKeyType}`);
109
+ }
110
+ const publicKeyPem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
111
+
112
+ return {
113
+ generated,
114
+ publicKeyPem,
115
+ /** The private key, for persistence. Callers must write it 0600 and never log it. */
116
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
117
+
118
+ /**
119
+ * Sign one request. Returns the three headers to send.
120
+ *
121
+ * `timestamp` is **Unix seconds**, matching the relay's `x-w2m-timestamp` contract — not
122
+ * milliseconds. Sending milliseconds produces a timestamp the relay reads as far in the future
123
+ * and refuses for skew, which looks like a clock problem and is not one.
124
+ */
125
+ signRequest({ method, path, body = '', timestamp = Math.floor(Date.now() / 1000), nonce = options.makeNonce?.() ?? defaultNonce() }) {
126
+ const canonical = canonicalString({ method, path, timestamp, nonce, body });
127
+ const hex = edSign(null, Buffer.from(canonical, 'utf8'), privateKey).toString('hex');
128
+ return {
129
+ headers: {
130
+ [SIGNATURE_HEADER]: formatSignature(hex),
131
+ [TIMESTAMP_HEADER]: String(timestamp),
132
+ [NONCE_HEADER]: nonce,
133
+ },
134
+ canonical,
135
+ };
136
+ },
137
+ };
138
+ }
139
+
140
+ /** 16 random bytes as hex, the same size `signing.mjs` uses. */
141
+ function defaultNonce() {
142
+ return randomBytes(16).toString('hex');
143
+ }
144
+
145
+ /**
146
+ * Verify one signed request against a device's public key.
147
+ *
148
+ * @param {object} input
149
+ * @param {Record<string,string>} input.headers
150
+ * @param {string} input.publicKeyPem
151
+ * @param {string} input.method
152
+ * @param {string} input.path
153
+ * @param {string|Buffer} [input.body]
154
+ * @param {number} [input.nowMs]
155
+ * @param {number} [input.skewSeconds]
156
+ * @param {(nonce: string, at: number) => boolean} [input.nonces]
157
+ */
158
+ export function verifyDeviceSignature({
159
+ headers,
160
+ publicKeyPem,
161
+ method,
162
+ path,
163
+ body = '',
164
+ nowMs = Date.now(),
165
+ skewSeconds = 120,
166
+ nonces = null,
167
+ }) {
168
+ const read = (name) => {
169
+ if (typeof headers?.get === 'function') return headers.get(name);
170
+ const found = Object.keys(headers ?? {}).find((k) => k.toLowerCase() === name);
171
+ return found === undefined ? null : headers[found];
172
+ };
173
+
174
+ const presented = read(SIGNATURE_HEADER);
175
+ const timestampRaw = read(TIMESTAMP_HEADER);
176
+ const nonce = read(NONCE_HEADER);
177
+
178
+ if (!presented || !timestampRaw || !nonce) {
179
+ return { ok: false, code: 'SIGNATURE_INCOMPLETE', error: 'all three of signature, timestamp and nonce are required' };
180
+ }
181
+ const parsed = parseSignature(presented);
182
+ if (parsed === null) return { ok: false, code: 'SIGNATURE_MALFORMED', error: 'the signature is not v1=<hex>' };
183
+ if (parsed.version !== SIGNATURE_VERSION) {
184
+ return { ok: false, code: 'SIGNATURE_VERSION', error: `signature version ${parsed.version} is not ${SIGNATURE_VERSION}` };
185
+ }
186
+
187
+ const timestamp = Number(timestampRaw);
188
+ if (!Number.isInteger(timestamp)) {
189
+ return { ok: false, code: 'SIGNATURE_BAD_TIMESTAMP', error: 'x-w2m-timestamp must be whole unix seconds' };
190
+ }
191
+ if (typeof nonce !== 'string' || nonce.length < 8) {
192
+ return { ok: false, code: 'SIGNATURE_BAD_NONCE', error: 'x-w2m-nonce must be at least 8 characters' };
193
+ }
194
+ // Skew is checked **before** the signature, so a captured request replayed much later is refused
195
+ // for the cheap reason rather than spending a verification on it.
196
+ const skew = Math.abs(nowMs / 1000 - timestamp);
197
+ if (skew > skewSeconds) {
198
+ return { ok: false, code: 'SIGNATURE_SKEW', error: `timestamp is ${Math.round(skew)}s from now, beyond ${skewSeconds}s` };
199
+ }
200
+
201
+ const canonical = canonicalString({ method, path, timestamp, nonce, body });
202
+ let verified = false;
203
+ try {
204
+ verified = edVerify(null, Buffer.from(canonical, 'utf8'), createPublicKey(publicKeyPem), Buffer.from(parsed.hex, 'hex'));
205
+ } catch {
206
+ // An unparseable key or a malformed hex signature is a failed verification, not a crash: this
207
+ // runs on the relay's hot path and an exception there is a denial-of-service shape.
208
+ return { ok: false, code: 'SIGNATURE_BAD', error: 'the signature did not verify with this device key' };
209
+ }
210
+ if (!verified) return { ok: false, code: 'SIGNATURE_BAD', error: 'the signature did not verify with this device key' };
211
+
212
+ // Recorded only after the signature checks out, so an attacker cannot fill the replay table with
213
+ // garbage nonces.
214
+ if (typeof nonces === 'function' && nonces(nonce, nowMs) === false) {
215
+ return { ok: false, code: 'SIGNATURE_REPLAY', error: 'this nonce was already used' };
216
+ }
217
+ return { ok: true, signed: true, keyIndex: null, device: true };
218
+ }