@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1288 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +13 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
package/src/signing.mjs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request signing for the W2M relay link (v0.3.0 §9).
|
|
3
|
+
*
|
|
4
|
+
* ## What this defends against, and what it does not
|
|
5
|
+
*
|
|
6
|
+
* A bearer token is a bearer token: anyone who can read it once can replay it. Over a link that
|
|
7
|
+
* crosses the public internet — which is the whole point of v0.1.2 onward — three concrete things
|
|
8
|
+
* become reachable that a private LAN made theoretical:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Replay.** A captured `POST /v1/task` re-sent later dispatches the same work again. The
|
|
11
|
+
* idempotency key makes a *duplicate* harmless, but a replay with a fresh task id is a new task.
|
|
12
|
+
* 2. **Tampering by a middlebox.** Anything that terminates the connection can rewrite a body. TLS
|
|
13
|
+
* prevents this where TLS exists; the Tailscale and tunnel deployments are exactly where it may
|
|
14
|
+
* not be end-to-end.
|
|
15
|
+
* 3. **A leaked log.** A signature that covers the body plus a monotonic timestamp turns "we have
|
|
16
|
+
* your token" into "you have at most one window to use it".
|
|
17
|
+
*
|
|
18
|
+
* It is deliberately **not** end-to-end encryption and not a replacement for TLS. The body is still
|
|
19
|
+
* readable by anyone who can see the bytes; this proves *who sent it* and *that it is unchanged*,
|
|
20
|
+
* and it bounds replay. Saying otherwise would be the kind of security claim that gets people hurt.
|
|
21
|
+
*
|
|
22
|
+
* ## Why a shared secret and not asymmetric keys
|
|
23
|
+
*
|
|
24
|
+
* Every machine already holds a per-device bearer token issued at pairing, and the relay already
|
|
25
|
+
* holds a copy. Deriving the signing key from that means **no new secret to distribute, store or
|
|
26
|
+
* rotate separately** — the existing pairing ceremony is the whole key exchange. The cost is that
|
|
27
|
+
* the relay can forge a device's signature, which is acceptable here because the relay is already
|
|
28
|
+
* trusted to route that device's work.
|
|
29
|
+
*
|
|
30
|
+
* ## Wire format
|
|
31
|
+
*
|
|
32
|
+
* ```
|
|
33
|
+
* X-W2M-Signature: v1=<hex hmac>
|
|
34
|
+
* X-W2M-Timestamp: <unix seconds>
|
|
35
|
+
* X-W2M-Nonce: <opaque, 16+ chars>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* The signed string is newline-separated and covers the method, path, timestamp, nonce and the exact
|
|
39
|
+
* body bytes, so a signature cannot be moved to another endpoint, another method, another moment, or
|
|
40
|
+
* another payload.
|
|
41
|
+
*
|
|
42
|
+
* ## Compatibility
|
|
43
|
+
*
|
|
44
|
+
* Signing is **optional on both sides**. An unsigned request is accepted exactly as before, so a
|
|
45
|
+
* v0.2.3 machine keeps working against a v0.3.0 relay and vice versa. What changes is that a relay
|
|
46
|
+
* with a secret configured can *require* signatures; that is opt-in, and the failure is loud.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
50
|
+
|
|
51
|
+
/** Header carrying the signature. */
|
|
52
|
+
export const SIGNATURE_HEADER = 'x-w2m-signature';
|
|
53
|
+
|
|
54
|
+
/** Header carrying the signing timestamp, in whole seconds. */
|
|
55
|
+
export const TIMESTAMP_HEADER = 'x-w2m-timestamp';
|
|
56
|
+
|
|
57
|
+
/** Header carrying the replay nonce. */
|
|
58
|
+
export const NONCE_HEADER = 'x-w2m-nonce';
|
|
59
|
+
|
|
60
|
+
/** Signature format version, so the scheme can change without ambiguity. */
|
|
61
|
+
export const SIGNATURE_VERSION = 'v1';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Accepted skew between the signer's clock and the verifier's, in seconds.
|
|
65
|
+
*
|
|
66
|
+
* Two minutes is chosen against the constraint that matters here: the machines may sit in different
|
|
67
|
+
* time zones and one of them may be behind a tunnelled, jittery link. A window tighter than the
|
|
68
|
+
* worst observed clock drift produces intermittent authentication failures, which people then
|
|
69
|
+
* "fix" by turning signing off — the outcome this module exists to prevent. Nothing in the protocol
|
|
70
|
+
* needs a tighter bound, because the nonce cache is what actually stops replay.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_SKEW_SECONDS = 120;
|
|
73
|
+
|
|
74
|
+
/** Errors carry a code so a caller can distinguish "unsigned" from "forged". */
|
|
75
|
+
export class SignatureError extends Error {
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} code - Machine-readable reason.
|
|
78
|
+
* @param {string} message - Human-readable explanation.
|
|
79
|
+
* @param {object} [detail] - Extra context for a log.
|
|
80
|
+
*/
|
|
81
|
+
constructor(code, message, detail = {}) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'SignatureError';
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.detail = detail;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize a secret into a key buffer.
|
|
91
|
+
*
|
|
92
|
+
* Rejects anything that is not a non-empty string: a `undefined` secret silently disabling signing
|
|
93
|
+
* is the failure mode this whole module is written to avoid, so callers get an error instead.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} secret - Shared secret.
|
|
96
|
+
* @returns {Buffer} Key material.
|
|
97
|
+
* @throws {SignatureError} `BAD_SECRET` when the secret is unusable.
|
|
98
|
+
*/
|
|
99
|
+
export function normalizeSecret(secret) {
|
|
100
|
+
if (typeof secret !== 'string') {
|
|
101
|
+
throw new SignatureError('BAD_SECRET', `signing secret must be a string, got ${typeof secret}`);
|
|
102
|
+
}
|
|
103
|
+
if (secret.length === 0) {
|
|
104
|
+
throw new SignatureError('BAD_SECRET', 'signing secret must not be empty');
|
|
105
|
+
}
|
|
106
|
+
return Buffer.from(secret, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build the canonical string that gets signed.
|
|
111
|
+
*
|
|
112
|
+
* Exported because a signature bug is nearly impossible to find from the outside: both sides must
|
|
113
|
+
* agree byte-for-byte, and a test that can print this is worth more than a test that only asserts
|
|
114
|
+
* "it failed".
|
|
115
|
+
*
|
|
116
|
+
* @param {object} input - Request facts.
|
|
117
|
+
* @param {string} input.method - HTTP method, any case.
|
|
118
|
+
* @param {string} input.path - Request path *including* the query string, if any.
|
|
119
|
+
* @param {number} input.timestamp - Unix seconds.
|
|
120
|
+
* @param {string} input.nonce - Replay nonce.
|
|
121
|
+
* @param {string|Buffer} [input.body] - Exact body bytes; empty for no body.
|
|
122
|
+
* @returns {string} The canonical string.
|
|
123
|
+
*/
|
|
124
|
+
export function canonicalString({ method, path, timestamp, nonce, body = '' }) {
|
|
125
|
+
const bodyBytes = Buffer.isBuffer(body) ? body : Buffer.from(body ?? '', 'utf8');
|
|
126
|
+
// The body is hashed rather than embedded so the canonical form stays small and log-safe; the
|
|
127
|
+
// hash still binds every byte, which is the property that matters.
|
|
128
|
+
const bodyHash = createHmac('sha256', Buffer.alloc(0)).update(bodyBytes).digest('hex');
|
|
129
|
+
return [
|
|
130
|
+
SIGNATURE_VERSION,
|
|
131
|
+
String(method ?? '').toUpperCase(),
|
|
132
|
+
String(path ?? ''),
|
|
133
|
+
String(timestamp),
|
|
134
|
+
String(nonce),
|
|
135
|
+
bodyHash,
|
|
136
|
+
].join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compute a signature.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} input - Signing input plus `secret`.
|
|
143
|
+
* @param {string} input.secret - Shared secret.
|
|
144
|
+
* @param {string} input.method - HTTP method.
|
|
145
|
+
* @param {string} input.path - Path including query.
|
|
146
|
+
* @param {number} input.timestamp - Unix seconds.
|
|
147
|
+
* @param {string} input.nonce - Replay nonce.
|
|
148
|
+
* @param {string|Buffer} [input.body] - Exact body bytes.
|
|
149
|
+
* @returns {string} `v1=<hex>`.
|
|
150
|
+
*/
|
|
151
|
+
export function signRequest({ secret, method, path, timestamp, nonce, body = '' }) {
|
|
152
|
+
const key = normalizeSecret(secret);
|
|
153
|
+
const canonical = canonicalString({ method, path, timestamp, nonce, body });
|
|
154
|
+
const mac = createHmac('sha256', key).update(canonical).digest('hex');
|
|
155
|
+
return `${SIGNATURE_VERSION}=${mac}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create an independent signer bound to one secret.
|
|
160
|
+
*
|
|
161
|
+
* The returned `headers()` is what a client calls per request. Keeping the nonce generator injectable
|
|
162
|
+
* is what makes replay tests deterministic instead of flaky.
|
|
163
|
+
*
|
|
164
|
+
* @param {object} input - Signer options.
|
|
165
|
+
* @param {string} input.secret - Shared secret.
|
|
166
|
+
* @param {() => number} [input.now] - Clock in *milliseconds*; defaults to `Date.now`.
|
|
167
|
+
* @param {() => string} [input.makeNonce] - Nonce source.
|
|
168
|
+
* @returns {{headers: (req: {method: string, path: string, body?: string|Buffer}) => Record<string,string>}}
|
|
169
|
+
*/
|
|
170
|
+
export function createSigner({ secret, now = Date.now, makeNonce = () => randomBytes(16).toString('hex') }) {
|
|
171
|
+
normalizeSecret(secret);
|
|
172
|
+
return {
|
|
173
|
+
headers({ method, path, body = '' }) {
|
|
174
|
+
const timestamp = Math.floor(now() / 1000);
|
|
175
|
+
const nonce = makeNonce();
|
|
176
|
+
return {
|
|
177
|
+
[SIGNATURE_HEADER]: signRequest({ secret, method, path, timestamp, nonce, body }),
|
|
178
|
+
[TIMESTAMP_HEADER]: String(timestamp),
|
|
179
|
+
[NONCE_HEADER]: nonce,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Bounded nonce cache that refuses a nonce it has already accepted.
|
|
187
|
+
*
|
|
188
|
+
* Replay defence has to be *stateful*, or a captured request is valid for the whole skew window --
|
|
189
|
+
* which would make the window the actual security boundary and the timestamp decorative. The cache
|
|
190
|
+
* is bounded so a long-running relay cannot be grown into memory exhaustion by a flood of requests,
|
|
191
|
+
* and it evicts oldest-first because the oldest entries are the ones about to fall outside the skew
|
|
192
|
+
* window anyway.
|
|
193
|
+
*/
|
|
194
|
+
export class NonceCache {
|
|
195
|
+
/**
|
|
196
|
+
* @param {object} [options] - Cache options.
|
|
197
|
+
* @param {number} [options.max] - Maximum retained nonces.
|
|
198
|
+
* @param {number} [options.retentionMs] - How long a nonce is remembered.
|
|
199
|
+
*/
|
|
200
|
+
constructor({ max = 20_000, retentionMs = 10 * 60_000 } = {}) {
|
|
201
|
+
/** @type {Map<string, number>} nonce -> accepted-at milliseconds */
|
|
202
|
+
this.seen = new Map();
|
|
203
|
+
this.max = max;
|
|
204
|
+
this.retentionMs = retentionMs;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Record a nonce, reporting whether it had already been used.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} nonce - The nonce.
|
|
211
|
+
* @param {number} nowMs - Current time in milliseconds.
|
|
212
|
+
* @returns {boolean} True when this nonce is new, false when it is a replay.
|
|
213
|
+
*/
|
|
214
|
+
accept(nonce, nowMs) {
|
|
215
|
+
if (this.seen.has(nonce)) return false;
|
|
216
|
+
this.seen.set(nonce, nowMs);
|
|
217
|
+
// Expire by age first, then by count. Age alone is not enough: a flood inside the retention
|
|
218
|
+
// window would grow the map without bound, which is a memory-exhaustion vector.
|
|
219
|
+
this.prune(this.retentionMs, nowMs);
|
|
220
|
+
while (this.seen.size > this.max) {
|
|
221
|
+
const oldest = this.seen.keys().next();
|
|
222
|
+
if (oldest.done) break;
|
|
223
|
+
this.seen.delete(oldest.value);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Drop entries older than `olderThanMs`, relying on insertion order being chronological.
|
|
230
|
+
*
|
|
231
|
+
* @param {number} olderThanMs - Age beyond which a nonce is forgotten.
|
|
232
|
+
* @param {number} nowMs - Current time in milliseconds.
|
|
233
|
+
*/
|
|
234
|
+
prune(olderThanMs, nowMs) {
|
|
235
|
+
for (const [nonce, at] of this.seen) {
|
|
236
|
+
if (nowMs - at > olderThanMs) this.seen.delete(nonce);
|
|
237
|
+
else break; // insertion order is chronological
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Current size, for diagnostics. */
|
|
242
|
+
get size() {
|
|
243
|
+
return this.seen.size;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Verify a signed request.
|
|
249
|
+
*
|
|
250
|
+
* Returns a decision rather than throwing, because the caller needs to log *why* and because a
|
|
251
|
+
* rejection here is an expected event on a public link, not a programming error. It throws only for
|
|
252
|
+
* a caller mistake (an unusable key list).
|
|
253
|
+
*
|
|
254
|
+
* @param {object} input - Verification input.
|
|
255
|
+
* @param {Headers|Record<string,string>} input.headers - Incoming headers, case-insensitively read.
|
|
256
|
+
* @param {string} input.method - HTTP method.
|
|
257
|
+
* @param {string} input.path - Path including query.
|
|
258
|
+
* @param {string|Buffer} [input.body] - Exact received body bytes.
|
|
259
|
+
* @param {string|string[]} input.secrets - Accepted secrets, newest first (rotation support).
|
|
260
|
+
* @param {NonceCache} input.nonces - Replay cache.
|
|
261
|
+
* @param {number} [input.nowMs] - Current time in milliseconds.
|
|
262
|
+
* @param {number} [input.skewSeconds] - Accepted clock skew.
|
|
263
|
+
* @param {boolean} [input.required] - When true, an unsigned request is rejected.
|
|
264
|
+
* @returns {{ok: true, signed: boolean, keyIndex: number|null}|{ok: false, code: string, error: string}}
|
|
265
|
+
*/
|
|
266
|
+
export function verifyRequest({
|
|
267
|
+
headers,
|
|
268
|
+
method,
|
|
269
|
+
path,
|
|
270
|
+
body = '',
|
|
271
|
+
secrets,
|
|
272
|
+
nonces,
|
|
273
|
+
nowMs = Date.now(),
|
|
274
|
+
skewSeconds = DEFAULT_SKEW_SECONDS,
|
|
275
|
+
required = false,
|
|
276
|
+
}) {
|
|
277
|
+
const list = (Array.isArray(secrets) ? secrets : [secrets]).filter(
|
|
278
|
+
(s) => typeof s === 'string' && s.length > 0,
|
|
279
|
+
);
|
|
280
|
+
if (list.length === 0) {
|
|
281
|
+
// No configured secret means this relay cannot verify anything. If it also requires signatures,
|
|
282
|
+
// that is a configuration error on the relay, and it must not be reported as a client failure.
|
|
283
|
+
if (required) {
|
|
284
|
+
return { ok: false, code: 'SIGNING_NOT_CONFIGURED', error: 'signatures are required but no signing secret is configured' };
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, signed: false, keyIndex: null };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const read = (name) => {
|
|
290
|
+
if (typeof headers?.get === 'function') return headers.get(name);
|
|
291
|
+
const found = Object.keys(headers ?? {}).find((k) => k.toLowerCase() === name);
|
|
292
|
+
return found === undefined ? null : headers[found];
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const presented = read(SIGNATURE_HEADER);
|
|
296
|
+
const timestampRaw = read(TIMESTAMP_HEADER);
|
|
297
|
+
const nonce = read(NONCE_HEADER);
|
|
298
|
+
|
|
299
|
+
if (!presented && !timestampRaw && !nonce) {
|
|
300
|
+
// Unsigned but structurally fine: this is the v0.2.3 client, and it keeps working unless the
|
|
301
|
+
// relay was explicitly told to insist.
|
|
302
|
+
if (required) {
|
|
303
|
+
return { ok: false, code: 'SIGNATURE_REQUIRED', error: 'this relay requires a signed request' };
|
|
304
|
+
}
|
|
305
|
+
return { ok: true, signed: false, keyIndex: null };
|
|
306
|
+
}
|
|
307
|
+
if (!presented || !timestampRaw || !nonce) {
|
|
308
|
+
// A partially-signed request is never a legitimate client: it is either a broken proxy or an
|
|
309
|
+
// attacker probing which header is checked. Refuse rather than fall back to "unsigned".
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
code: 'SIGNATURE_INCOMPLETE',
|
|
313
|
+
error: `a signed request must carry all of ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER} and ${NONCE_HEADER}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const timestamp = Number(timestampRaw);
|
|
318
|
+
if (!Number.isFinite(timestamp)) {
|
|
319
|
+
return { ok: false, code: 'SIGNATURE_BAD_TIMESTAMP', error: `timestamp is not a number: ${JSON.stringify(timestampRaw)}` };
|
|
320
|
+
}
|
|
321
|
+
const skew = Math.abs(Math.floor(nowMs / 1000) - timestamp);
|
|
322
|
+
if (skew > skewSeconds) {
|
|
323
|
+
// Clock skew and replay look the same here; the message says which bound was exceeded so an
|
|
324
|
+
// operator can tell a drifting clock from an attack.
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
code: 'SIGNATURE_EXPIRED',
|
|
328
|
+
error: `timestamp is ${skew}s from local time, outside the ±${skewSeconds}s window`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (nonce.length < 8) {
|
|
332
|
+
return { ok: false, code: 'SIGNATURE_BAD_NONCE', error: 'nonce is too short to be unique' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Check the nonce *after* the cheap structural checks but before the HMAC compare, and only claim
|
|
336
|
+
// it once a signature has actually matched -- otherwise an attacker could burn nonces to make a
|
|
337
|
+
// legitimate request look like a replay.
|
|
338
|
+
const expected = list.map((secret) =>
|
|
339
|
+
signRequest({ secret, method, path, timestamp, nonce, body }),
|
|
340
|
+
);
|
|
341
|
+
let matchedIndex = -1;
|
|
342
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
343
|
+
const a = Buffer.from(expected[i], 'utf8');
|
|
344
|
+
const b = Buffer.from(String(presented), 'utf8');
|
|
345
|
+
// timingSafeEqual throws on a length mismatch, which would leak length as an oracle; guard first.
|
|
346
|
+
if (a.length === b.length && timingSafeEqual(a, b)) {
|
|
347
|
+
matchedIndex = i;
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (matchedIndex === -1) {
|
|
352
|
+
return { ok: false, code: 'SIGNATURE_MISMATCH', error: 'signature does not match any configured secret' };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!nonces.accept(nonce, nowMs)) {
|
|
356
|
+
return { ok: false, code: 'SIGNATURE_REPLAY', error: 'this nonce has already been used' };
|
|
357
|
+
}
|
|
358
|
+
return { ok: true, signed: true, keyIndex: matchedIndex };
|
|
359
|
+
}
|
package/src/util/cli.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the W2M command-line entry points.
|
|
3
|
+
*
|
|
4
|
+
* Kept deliberately small and dependency-free: the interesting decisions here
|
|
5
|
+
* are only "where does state live" and "how do we print an error", and both
|
|
6
|
+
* have to behave identically on Windows and macOS.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the directory a W2M process keeps its durable state in.
|
|
14
|
+
*
|
|
15
|
+
* Precedence, highest first:
|
|
16
|
+
* 1. an explicit `--state` value;
|
|
17
|
+
* 2. `<DSH_HOME>/xclient/<kind>`;
|
|
18
|
+
* 3. `~/.dsh/xclient/<kind>` when `DSH_HOME` is not set.
|
|
19
|
+
*
|
|
20
|
+
* `DSH_HOME` is honoured rather than assumed so that a machine running two
|
|
21
|
+
* throwaway profiles for testing does not share one device identity with the
|
|
22
|
+
* profile the user actually works in -- sharing it would silently make two
|
|
23
|
+
* "machines" claim the same `machine_id`.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|undefined} explicit Value of `--state`, if given.
|
|
26
|
+
* @param {string} kind Sub-directory name, e.g. `'rabbit'` or `'localside'`.
|
|
27
|
+
* @returns {string} Absolute path.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveStateDir(explicit, kind) {
|
|
30
|
+
if (explicit && explicit.trim() !== '') {
|
|
31
|
+
return isAbsolute(explicit) ? explicit : resolve(explicit);
|
|
32
|
+
}
|
|
33
|
+
const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
|
|
34
|
+
? process.env.DSH_HOME
|
|
35
|
+
: join(homedir(), '.dsh');
|
|
36
|
+
return join(home, 'xclient', kind);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format anything throwable as a single human-readable line.
|
|
41
|
+
*
|
|
42
|
+
* `String(error)` on a rejected fetch prints `TypeError: fetch failed`, which
|
|
43
|
+
* hides the actual cause in `error.cause`; the CLI's whole job when it fails is
|
|
44
|
+
* to say what went wrong, so the cause is unwrapped here.
|
|
45
|
+
*
|
|
46
|
+
* @param {unknown} error
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function formatError(error) {
|
|
50
|
+
if (error === null || error === undefined) return 'unknown error';
|
|
51
|
+
if (typeof error === 'string') return error;
|
|
52
|
+
if (!(error instanceof Error)) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.stringify(error);
|
|
55
|
+
} catch {
|
|
56
|
+
return String(error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const parts = [`${error.name}: ${error.message}`];
|
|
60
|
+
let cause = error.cause;
|
|
61
|
+
const seen = new Set([error]);
|
|
62
|
+
while (cause instanceof Error && !seen.has(cause)) {
|
|
63
|
+
seen.add(cause);
|
|
64
|
+
parts.push(`caused by ${cause.name}: ${cause.message}`);
|
|
65
|
+
if (typeof cause.code === 'string') parts.push(`(${cause.code})`);
|
|
66
|
+
cause = cause.cause;
|
|
67
|
+
}
|
|
68
|
+
if (typeof error.code === 'string') parts.push(`[${error.code}]`);
|
|
69
|
+
return parts.join(' ');
|
|
70
|
+
}
|