@mycadet/gateway 0.12.9 → 0.12.11
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/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +8 -8
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/credential-reader.test.ts +1 -1
- package/src/__tests__/credential-watcher.test.ts +1 -1
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +4 -4
- package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
- package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
- package/src/__tests__/remote-web-pairing-token.test.ts +1 -1
- package/src/auth/guardian-bootstrap.ts +1 -1
- package/src/cli/__tests__/pairing.test.ts +131 -0
- package/src/cli/index.ts +12 -1
- package/src/cli/pairing.ts +71 -0
- package/src/credential-reader.ts +1 -1
- package/src/http/routes/guardian-pin.ts +2 -1
- package/src/http/routes/ingress-domains.test.ts +5 -2
- package/src/http/routes/ingress-domains.ts +2 -8
- package/src/http/routes/live-voice-websocket.ts +2 -1
- package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
- package/src/http/routes/plugin-webhook-websocket.ts +2 -1
- package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
- package/src/http/routes/runtime-audio-stream.ts +2 -1
- package/src/http/routes/speech-relay-websocket.ts +4 -3
- package/src/http/utils.ts +3 -0
- package/src/index.ts +4 -3
- package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
- package/src/remote-web/pairing-challenge-store.ts +130 -2
- package/src/remote-web/pairing-mint.ts +94 -0
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { getGatewaySecurityDir } from "../paths.js";
|
|
2
12
|
|
|
3
13
|
import {
|
|
4
14
|
REMOTE_WEB_PAIRING_CODE_TTL_MS,
|
|
@@ -70,6 +80,94 @@ function nowMs(): number {
|
|
|
70
80
|
return nowForTests?.() ?? Date.now();
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The challenge store is FILE-BACKED under GATEWAY_SECURITY_DIR so a mint can
|
|
85
|
+
* run in ANY process: `gateway pairing mint` executes the mint logic directly
|
|
86
|
+
* (deliberately never over the gateway's IPC socket — the assistant daemon
|
|
87
|
+
* shares that socket, and an IPC-exposed mint would let the assistant
|
|
88
|
+
* approve pairings for itself) and persists the challenge here. The running
|
|
89
|
+
* gateway's verification and token-exchange routes re-read the file on every
|
|
90
|
+
* store call, so cross-process writes land. Writes are atomic (tmp + rename)
|
|
91
|
+
* and the single-user local flow makes last-writer-wins races a non-issue.
|
|
92
|
+
*/
|
|
93
|
+
const STORE_FILENAME = "remote-web-pairing-challenges.json";
|
|
94
|
+
|
|
95
|
+
let lastResolvedStorePath: string | null = null;
|
|
96
|
+
|
|
97
|
+
function challengeStoreFilePath(): string {
|
|
98
|
+
const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
|
|
99
|
+
lastResolvedStorePath = resolved;
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isNotExistError(err: unknown): boolean {
|
|
104
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isChallengeLike(
|
|
108
|
+
value: unknown,
|
|
109
|
+
): value is PendingRemoteWebPairingChallenge {
|
|
110
|
+
return (
|
|
111
|
+
typeof value === "object" &&
|
|
112
|
+
value !== null &&
|
|
113
|
+
"userCodeHash" in value &&
|
|
114
|
+
typeof value.userCodeHash === "string" &&
|
|
115
|
+
"deviceCodeHash" in value &&
|
|
116
|
+
typeof value.deviceCodeHash === "string"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Re-sync the in-memory maps from disk at the top of every exported call.
|
|
122
|
+
* A missing file is an empty store; a corrupt one warns and starts empty.
|
|
123
|
+
* Expired challenges are NOT dropped here — the same cleanup/expiry checks
|
|
124
|
+
* that ran before file-backing still classify them ("expired", not
|
|
125
|
+
* "invalid") and delete them on their own paths.
|
|
126
|
+
*/
|
|
127
|
+
function loadFromDisk(): void {
|
|
128
|
+
challengesByUserCodeHash.clear();
|
|
129
|
+
challengesByDeviceCodeHash.clear();
|
|
130
|
+
|
|
131
|
+
let raw: string;
|
|
132
|
+
try {
|
|
133
|
+
raw = readFileSync(challengeStoreFilePath(), "utf-8");
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (isNotExistError(err)) return;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
parsed = null;
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(parsed)) {
|
|
146
|
+
console.warn(
|
|
147
|
+
`[pairing-challenge-store] could not read ${challengeStoreFilePath()} — starting empty.`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (const entry of parsed) {
|
|
152
|
+
if (!isChallengeLike(entry)) continue;
|
|
153
|
+
challengesByUserCodeHash.set(entry.userCodeHash, entry);
|
|
154
|
+
challengesByDeviceCodeHash.set(entry.deviceCodeHash, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Persist the current challenges so other processes can see them. */
|
|
159
|
+
function saveToDisk(): void {
|
|
160
|
+
const filePath = challengeStoreFilePath();
|
|
161
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
162
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
163
|
+
writeFileSync(
|
|
164
|
+
tmpPath,
|
|
165
|
+
JSON.stringify([...challengesByUserCodeHash.values()]),
|
|
166
|
+
{ mode: 0o600 },
|
|
167
|
+
);
|
|
168
|
+
renameSync(tmpPath, filePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
73
171
|
function hashSecret(value: string): string {
|
|
74
172
|
return createHash("sha256").update(value).digest("hex");
|
|
75
173
|
}
|
|
@@ -100,6 +198,7 @@ function cleanupExpiredChallenges(now = nowMs()): void {
|
|
|
100
198
|
}
|
|
101
199
|
|
|
102
200
|
export function checkRemoteWebPairingChallengeCapacity(): RemoteWebPairingChallengeCapacityLimit | null {
|
|
201
|
+
loadFromDisk();
|
|
103
202
|
const now = nowMs();
|
|
104
203
|
cleanupExpiredChallenges(now);
|
|
105
204
|
|
|
@@ -122,6 +221,7 @@ export function createRemoteWebPairingChallenge(
|
|
|
122
221
|
publicBaseUrl: string,
|
|
123
222
|
requester: { ip: string; userAgent: string | null; viaEdgeProxy: boolean },
|
|
124
223
|
): RemoteWebPairingChallengeResponse {
|
|
224
|
+
loadFromDisk();
|
|
125
225
|
cleanupExpiredChallenges();
|
|
126
226
|
|
|
127
227
|
const deviceCode = randomBytes(DEVICE_CODE_BYTES).toString("base64url");
|
|
@@ -152,6 +252,7 @@ export function createRemoteWebPairingChallenge(
|
|
|
152
252
|
};
|
|
153
253
|
challengesByUserCodeHash.set(userCodeHash, challenge);
|
|
154
254
|
challengesByDeviceCodeHash.set(deviceCodeHash, challenge);
|
|
255
|
+
saveToDisk();
|
|
155
256
|
|
|
156
257
|
return {
|
|
157
258
|
deviceCode,
|
|
@@ -188,12 +289,15 @@ function approveChallenge(
|
|
|
188
289
|
export function approveRemoteWebPairingChallenge(
|
|
189
290
|
userCode: string,
|
|
190
291
|
): ApproveRemoteWebPairingChallengeResult {
|
|
292
|
+
loadFromDisk();
|
|
191
293
|
const userCodeHash = hashSecret(normalizeUserCode(userCode));
|
|
192
294
|
const challenge = challengesByUserCodeHash.get(userCodeHash);
|
|
193
295
|
if (!challenge) {
|
|
194
296
|
return { status: "invalid" };
|
|
195
297
|
}
|
|
196
|
-
|
|
298
|
+
const result = approveChallenge(challenge);
|
|
299
|
+
saveToDisk();
|
|
300
|
+
return result;
|
|
197
301
|
}
|
|
198
302
|
|
|
199
303
|
function findChallengeById(
|
|
@@ -208,6 +312,7 @@ function findChallengeById(
|
|
|
208
312
|
}
|
|
209
313
|
|
|
210
314
|
export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequestSummary[] {
|
|
315
|
+
loadFromDisk();
|
|
211
316
|
cleanupExpiredChallenges();
|
|
212
317
|
|
|
213
318
|
const pending = [...challengesByUserCodeHash.values()]
|
|
@@ -229,11 +334,14 @@ export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequest
|
|
|
229
334
|
export function approveRemoteWebPairingChallengeById(
|
|
230
335
|
id: string,
|
|
231
336
|
): ApproveRemoteWebPairingChallengeResult {
|
|
337
|
+
loadFromDisk();
|
|
232
338
|
const challenge = findChallengeById(id);
|
|
233
339
|
if (!challenge) {
|
|
234
340
|
return { status: "invalid" };
|
|
235
341
|
}
|
|
236
|
-
|
|
342
|
+
const result = approveChallenge(challenge);
|
|
343
|
+
saveToDisk();
|
|
344
|
+
return result;
|
|
237
345
|
}
|
|
238
346
|
|
|
239
347
|
export type DenyRemoteWebPairingChallengeResult =
|
|
@@ -244,6 +352,7 @@ export type DenyRemoteWebPairingChallengeResult =
|
|
|
244
352
|
export function denyRemoteWebPairingChallengeById(
|
|
245
353
|
id: string,
|
|
246
354
|
): DenyRemoteWebPairingChallengeResult {
|
|
355
|
+
loadFromDisk();
|
|
247
356
|
const challenge = findChallengeById(id);
|
|
248
357
|
if (!challenge) {
|
|
249
358
|
return { status: "invalid" };
|
|
@@ -256,12 +365,14 @@ export function denyRemoteWebPairingChallengeById(
|
|
|
256
365
|
}
|
|
257
366
|
|
|
258
367
|
deleteChallenge(challenge);
|
|
368
|
+
saveToDisk();
|
|
259
369
|
return { status: "denied" };
|
|
260
370
|
}
|
|
261
371
|
|
|
262
372
|
export function claimRemoteWebPairingChallengeExchange(
|
|
263
373
|
deviceCode: string,
|
|
264
374
|
): ClaimRemoteWebPairingChallengeExchangeResult {
|
|
375
|
+
loadFromDisk();
|
|
265
376
|
const deviceCodeHash = hashSecret(deviceCode);
|
|
266
377
|
const challenge = challengesByDeviceCodeHash.get(deviceCodeHash);
|
|
267
378
|
if (!challenge) return { status: "invalid" };
|
|
@@ -269,6 +380,7 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
269
380
|
const now = nowMs();
|
|
270
381
|
if (challenge.expiresAtMs <= now) {
|
|
271
382
|
deleteChallenge(challenge);
|
|
383
|
+
saveToDisk();
|
|
272
384
|
return { status: "expired" };
|
|
273
385
|
}
|
|
274
386
|
|
|
@@ -290,6 +402,7 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
290
402
|
|
|
291
403
|
challenge.status = "exchanging";
|
|
292
404
|
challenge.exchangeStartedAtMs = now;
|
|
405
|
+
saveToDisk();
|
|
293
406
|
return {
|
|
294
407
|
status: "approved",
|
|
295
408
|
publicBaseUrl: challenge.publicBaseUrl,
|
|
@@ -301,27 +414,41 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
301
414
|
export function completeRemoteWebPairingChallengeExchange(
|
|
302
415
|
deviceCode: string,
|
|
303
416
|
): void {
|
|
417
|
+
loadFromDisk();
|
|
304
418
|
const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
|
|
305
419
|
if (!challenge || challenge.status !== "exchanging") return;
|
|
306
420
|
|
|
307
421
|
challenge.status = "consumed";
|
|
308
422
|
challenge.consumedAtMs = nowMs();
|
|
423
|
+
saveToDisk();
|
|
309
424
|
}
|
|
310
425
|
|
|
311
426
|
export function releaseRemoteWebPairingChallengeExchange(
|
|
312
427
|
deviceCode: string,
|
|
313
428
|
): void {
|
|
429
|
+
loadFromDisk();
|
|
314
430
|
const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
|
|
315
431
|
if (!challenge || challenge.status !== "exchanging") return;
|
|
316
432
|
|
|
317
433
|
challenge.status = "approved";
|
|
318
434
|
challenge.exchangeStartedAtMs = undefined;
|
|
435
|
+
saveToDisk();
|
|
319
436
|
}
|
|
320
437
|
|
|
321
438
|
export function resetRemoteWebPairingChallengesForTests(): void {
|
|
322
439
|
challengesByUserCodeHash.clear();
|
|
323
440
|
challengesByDeviceCodeHash.clear();
|
|
324
441
|
nowForTests = null;
|
|
442
|
+
// Best effort on the LAST-RESOLVED path: test files may delete
|
|
443
|
+
// GATEWAY_SECURITY_DIR before this runs, and re-resolving then would
|
|
444
|
+
// either throw the ephemeral-path guard or point at the wrong dir.
|
|
445
|
+
if (lastResolvedStorePath) {
|
|
446
|
+
try {
|
|
447
|
+
rmSync(lastResolvedStorePath, { force: true });
|
|
448
|
+
} catch {
|
|
449
|
+
/* the maps are cleared either way */
|
|
450
|
+
}
|
|
451
|
+
}
|
|
325
452
|
}
|
|
326
453
|
|
|
327
454
|
export function setRemoteWebPairingChallengeNowForTests(
|
|
@@ -333,5 +460,6 @@ export function setRemoteWebPairingChallengeNowForTests(
|
|
|
333
460
|
export function getRemoteWebPairingChallengeForTests(
|
|
334
461
|
userCode: string,
|
|
335
462
|
): PendingRemoteWebPairingChallenge | undefined {
|
|
463
|
+
loadFromDisk();
|
|
336
464
|
return challengesByUserCodeHash.get(hashSecret(normalizeUserCode(userCode)));
|
|
337
465
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint-and-approve for the remote-web pairing flow — executed DIRECTLY in
|
|
3
|
+
* the calling process (the `gateway pairing mint` CLI), deliberately NOT
|
|
4
|
+
* exposed over the gateway's IPC socket: the assistant daemon shares that
|
|
5
|
+
* socket, and an IPC-exposed mint would let the assistant mint + approve
|
|
6
|
+
* pairings for itself, defeating the local-presence proof. Challenges
|
|
7
|
+
* persist to the file-backed pairing store under GATEWAY_SECURITY_DIR, so
|
|
8
|
+
* the RUNNING gateway's verification and token-exchange routes see what
|
|
9
|
+
* this mints.
|
|
10
|
+
*
|
|
11
|
+
* This is the exact sequence the loopback HTTP mint used to run (parse →
|
|
12
|
+
* rate-limit → capacity → create → approve), with the requester metadata
|
|
13
|
+
* honestly naming the CLI invocation instead of an HTTP peer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RemoteWebPairingChallengeResponse } from "@mycadet/service-contracts/remote-web-pairing";
|
|
17
|
+
|
|
18
|
+
import { parsePublicBaseUrl } from "../http/routes/remote-web-pairing-challenge.js";
|
|
19
|
+
import { recordRemoteWebPairingChallengeCreation } from "./pairing-challenge-rate-limit-store.js";
|
|
20
|
+
import {
|
|
21
|
+
approveRemoteWebPairingChallenge,
|
|
22
|
+
checkRemoteWebPairingChallengeCapacity,
|
|
23
|
+
createRemoteWebPairingChallenge,
|
|
24
|
+
type RemoteWebPairingChallengeCapacityLimit,
|
|
25
|
+
} from "./pairing-challenge-store.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Client-facing failure with a stable code (BAD_REQUEST, RATE_LIMITED,
|
|
29
|
+
* PAIRING_CHALLENGE_CAPACITY_EXCEEDED, MINT_APPROVAL_FAILED).
|
|
30
|
+
*/
|
|
31
|
+
export class PairingMintError extends Error {
|
|
32
|
+
readonly code: string;
|
|
33
|
+
|
|
34
|
+
constructor(message: string, code: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "PairingMintError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rateLimitError(retryAfterSeconds: number): PairingMintError {
|
|
42
|
+
return new PairingMintError(
|
|
43
|
+
`too many remote web pairing challenges; retry in ${retryAfterSeconds}s`,
|
|
44
|
+
"RATE_LIMITED",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capacityLimitError(
|
|
49
|
+
limit: RemoteWebPairingChallengeCapacityLimit,
|
|
50
|
+
): PairingMintError {
|
|
51
|
+
return new PairingMintError(
|
|
52
|
+
`too many pending remote web pairing challenges; retry in ${limit.retryAfterSeconds}s`,
|
|
53
|
+
"PAIRING_CHALLENGE_CAPACITY_EXCEEDED",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mintAndApproveRemoteWebPairingChallenge(
|
|
58
|
+
publicBaseUrlRaw: string,
|
|
59
|
+
): RemoteWebPairingChallengeResponse {
|
|
60
|
+
const publicBaseUrl = parsePublicBaseUrl(publicBaseUrlRaw);
|
|
61
|
+
if (!publicBaseUrl) {
|
|
62
|
+
throw new PairingMintError(
|
|
63
|
+
"publicBaseUrl is required (an http(s) URL with a host)",
|
|
64
|
+
"BAD_REQUEST",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rateLimited = recordRemoteWebPairingChallengeCreation();
|
|
69
|
+
if (rateLimited) throw rateLimitError(rateLimited.retryAfterSeconds);
|
|
70
|
+
|
|
71
|
+
const capacityLimited = checkRemoteWebPairingChallengeCapacity();
|
|
72
|
+
if (capacityLimited) throw capacityLimitError(capacityLimited);
|
|
73
|
+
|
|
74
|
+
// The requester fields back the host approval UI; a direct mint has no
|
|
75
|
+
// HTTP peer to report, so the honest provenance is the CLI invocation
|
|
76
|
+
// itself.
|
|
77
|
+
const challenge = createRemoteWebPairingChallenge(publicBaseUrl, {
|
|
78
|
+
ip: "cli",
|
|
79
|
+
userAgent: null,
|
|
80
|
+
viaEdgeProxy: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const approved = approveRemoteWebPairingChallenge(challenge.userCode);
|
|
84
|
+
if (approved.status !== "approved") {
|
|
85
|
+
// Unreachable for a challenge minted this tick, but fail loudly rather
|
|
86
|
+
// than hand out an unapproved link.
|
|
87
|
+
throw new PairingMintError(
|
|
88
|
+
`minted challenge could not be approved (${approved.status})`,
|
|
89
|
+
"MINT_APPROVAL_FAILED",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return challenge;
|
|
94
|
+
}
|