machine-bridge-mcp 1.2.11 → 2.0.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 (50) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +10 -5
  3. package/browser-extension/manifest.json +2 -2
  4. package/docs/ARCHITECTURE.md +18 -10
  5. package/docs/AUDIT.md +25 -3
  6. package/docs/CLIENTS.md +2 -2
  7. package/docs/ENGINEERING.md +2 -2
  8. package/docs/GETTING_STARTED.md +1 -1
  9. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  10. package/docs/LOGGING.md +4 -4
  11. package/docs/MULTI_ACCOUNT.md +5 -5
  12. package/docs/OPERATIONS.md +30 -5
  13. package/docs/OVERVIEW.md +12 -8
  14. package/docs/PROJECT_STANDARDS.md +1 -1
  15. package/docs/RELEASING.md +3 -3
  16. package/docs/TESTING.md +5 -2
  17. package/docs/THREAT_MODEL.md +7 -6
  18. package/docs/UPGRADING.md +10 -6
  19. package/package.json +4 -2
  20. package/scripts/check-plan.mjs +2 -0
  21. package/scripts/local-release-acceptance.mjs +2 -2
  22. package/scripts/release-acceptance.mjs +7 -4
  23. package/src/local/account-admin.mjs +28 -3
  24. package/src/local/cli-approval.mjs +117 -0
  25. package/src/local/cli-options.mjs +8 -2
  26. package/src/local/cli.mjs +13 -2
  27. package/src/local/device-identity.mjs +108 -0
  28. package/src/local/operation-authorization.mjs +366 -0
  29. package/src/local/operation-risk.mjs +221 -0
  30. package/src/local/operation-state-lock.mjs +92 -0
  31. package/src/local/relay-connection.mjs +12 -5
  32. package/src/local/runtime-relay.mjs +13 -5
  33. package/src/local/runtime.mjs +13 -7
  34. package/src/local/state.mjs +9 -3
  35. package/src/local/tool-executor.mjs +4 -2
  36. package/src/local/worker-deployment.mjs +4 -3
  37. package/src/local/worker-secret-file.mjs +2 -1
  38. package/src/shared/admin-auth.d.mts +10 -0
  39. package/src/shared/admin-auth.mjs +36 -0
  40. package/src/shared/daemon-auth.d.mts +20 -0
  41. package/src/shared/daemon-auth.mjs +55 -0
  42. package/src/worker/account-admin.ts +73 -4
  43. package/src/worker/daemon-auth.ts +205 -0
  44. package/src/worker/daemon-sockets.ts +15 -2
  45. package/src/worker/index.ts +59 -10
  46. package/src/worker/nonce-store.ts +79 -0
  47. package/src/worker/oauth-controller.ts +11 -6
  48. package/src/worker/oauth-refresh-families.ts +172 -0
  49. package/src/worker/oauth-state.ts +48 -3
  50. package/src/worker/oauth-tokens.ts +49 -40
@@ -0,0 +1,20 @@
1
+ export const DAEMON_AUTH_SCHEME: "device-signature-v1";
2
+ export const DAEMON_PREFLIGHT_SCHEME: "device-preflight-v1";
3
+ export const DAEMON_AUTH_CHALLENGE_TTL_SECONDS: number;
4
+ export const DAEMON_PREFLIGHT_TTL_SECONDS: number;
5
+ export function daemonAuthTranscript(input: {
6
+ challenge: unknown;
7
+ workerOrigin: unknown;
8
+ server: unknown;
9
+ version: unknown;
10
+ instanceId: unknown;
11
+ issuedAt: unknown;
12
+ }): string;
13
+
14
+ export function daemonPreflightTranscript(input: {
15
+ workerOrigin: unknown;
16
+ server: unknown;
17
+ version: unknown;
18
+ nonce: unknown;
19
+ issuedAt: unknown;
20
+ }): string;
@@ -0,0 +1,55 @@
1
+ export const DAEMON_AUTH_SCHEME = "device-signature-v1";
2
+ export const DAEMON_PREFLIGHT_SCHEME = "device-preflight-v1";
3
+ export const DAEMON_AUTH_CHALLENGE_TTL_SECONDS = 30;
4
+ export const DAEMON_PREFLIGHT_TTL_SECONDS = 5 * 60;
5
+
6
+
7
+ export function daemonPreflightTranscript(input = {}) {
8
+ const values = [
9
+ DAEMON_PREFLIGHT_SCHEME,
10
+ requiredOrigin(input.workerOrigin),
11
+ requiredText(input.server, "server", 1, 128),
12
+ requiredText(input.version, "version", 1, 64),
13
+ requiredText(input.nonce, "preflight nonce", 24, 128),
14
+ requiredInteger(input.issuedAt, "preflight issued at"),
15
+ ];
16
+ return values.join("\0");
17
+ }
18
+
19
+ export function daemonAuthTranscript(input = {}) {
20
+ const values = [
21
+ DAEMON_AUTH_SCHEME,
22
+ requiredText(input.challenge, "challenge", 16, 256),
23
+ requiredOrigin(input.workerOrigin),
24
+ requiredText(input.server, "server", 1, 128),
25
+ requiredText(input.version, "version", 1, 64),
26
+ requiredText(input.instanceId, "instance id", 16, 128),
27
+ requiredInteger(input.issuedAt, "issued at"),
28
+ ];
29
+ return values.join("\0");
30
+ }
31
+
32
+ function requiredText(value, label, minimum, maximum) {
33
+ const text = String(value || "");
34
+ if (text.length < minimum || text.length > maximum || /[\0\r\n]/.test(text)) {
35
+ throw new Error(`daemon authentication ${label} is invalid`);
36
+ }
37
+ return text;
38
+ }
39
+
40
+ function requiredOrigin(value) {
41
+ let url;
42
+ try { url = new URL(String(value || "")); } catch { throw new Error("daemon authentication Worker origin is invalid"); }
43
+ const secure = url.protocol === "https:";
44
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
45
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
46
+ throw new Error("daemon authentication Worker origin is invalid");
47
+ }
48
+ return url.origin;
49
+ }
50
+
51
+ function requiredInteger(value, label) {
52
+ const number = Number(value);
53
+ if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`daemon authentication ${label} is invalid`);
54
+ return String(number);
55
+ }
@@ -1,4 +1,6 @@
1
+ import { ADMIN_AUTH_SCHEME, ADMIN_AUTH_TTL_SECONDS, adminAuthTranscript } from "../shared/admin-auth.mjs";
1
2
  import { json, methodNotAllowed, parseRequestBody } from "./http.ts";
3
+ import { consumeBoundedNonce } from "./nonce-store.ts";
2
4
  import {
3
5
  accountByName, createAccount, publicAccount, replaceAccountPassword, revokeAccountCredentials,
4
6
  safeEqual, updateAccount, type AccountRecord, type OAuthStore,
@@ -7,10 +9,67 @@ import {
7
9
  const BODY_LIMIT_BYTES = 64 * 1024;
8
10
  const MAX_ACCOUNTS = 64;
9
11
 
10
- export async function accountAdminAuthorized(request: Request, expected: string): Promise<boolean> {
11
- const header = request.headers.get("authorization") ?? "";
12
- const supplied = /^Bearer\s+(.+)$/i.exec(header)?.[1] ?? "";
13
- return Boolean(expected && await safeEqual(supplied, expected));
12
+ export interface AccountAdminAuthorization {
13
+ nonce: string;
14
+ expiresAt: number;
15
+ }
16
+
17
+ export async function accountAdminAuthorized(
18
+ request: Request,
19
+ expected: string,
20
+ now = Math.floor(Date.now() / 1000),
21
+ ): Promise<AccountAdminAuthorization | null> {
22
+ if (!expected) return null;
23
+ const scheme = request.headers.get("X-Bridge-Admin-Scheme") || "";
24
+ const issuedAt = Number(request.headers.get("X-Bridge-Admin-Time"));
25
+ const nonce = request.headers.get("X-Bridge-Admin-Nonce") || "";
26
+ const bodyHash = request.headers.get("X-Bridge-Admin-Body-SHA256") || "";
27
+ const suppliedSignature = request.headers.get("X-Bridge-Admin-Signature") || "";
28
+ if (scheme !== ADMIN_AUTH_SCHEME) return null;
29
+ if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > ADMIN_AUTH_TTL_SECONDS) return null;
30
+ if (!/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || !/^[a-f0-9]{64}$/.test(bodyHash) || !/^[A-Za-z0-9_-]{43}$/.test(suppliedSignature)) return null;
31
+ let transcript: string;
32
+ try {
33
+ transcript = adminAuthTranscript({
34
+ origin: new URL(request.url).origin,
35
+ method: request.method.toUpperCase(),
36
+ pathname: new URL(request.url).pathname,
37
+ bodyHash,
38
+ issuedAt,
39
+ nonce,
40
+ });
41
+ } catch {
42
+ return null;
43
+ }
44
+ const key = await crypto.subtle.importKey(
45
+ "raw",
46
+ new TextEncoder().encode(expected),
47
+ { name: "HMAC", hash: "SHA-256" },
48
+ false,
49
+ ["sign"],
50
+ );
51
+ const expectedSignature = base64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(transcript))));
52
+ if (!(await safeEqual(suppliedSignature, expectedSignature))) return null;
53
+ const body = new Uint8Array(await request.clone().arrayBuffer());
54
+ if (body.byteLength > BODY_LIMIT_BYTES) return null;
55
+ const actualBodyHash = hex(new Uint8Array(await crypto.subtle.digest("SHA-256", body)));
56
+ if (!(await safeEqual(bodyHash, actualBodyHash))) return null;
57
+ return { nonce, expiresAt: Math.max(now, issuedAt) + ADMIN_AUTH_TTL_SECONDS };
58
+ }
59
+
60
+ export async function consumeAccountAdminNonce(
61
+ storage: DurableObjectStorage,
62
+ authorization: AccountAdminAuthorization,
63
+ now = Math.floor(Date.now() / 1000),
64
+ ): Promise<boolean> {
65
+ return consumeBoundedNonce(storage, {
66
+ key: "account-admin-nonces",
67
+ nonce: authorization.nonce,
68
+ expiresAt: authorization.expiresAt,
69
+ now,
70
+ noncePattern: /^[A-Za-z0-9_-]{32,128}$/,
71
+ maximum: 256,
72
+ });
14
73
  }
15
74
 
16
75
  export async function handleAccountAdminOperation(options: {
@@ -101,3 +160,13 @@ async function rotatePassword(request: Request, store: OAuthStore, save: () => P
101
160
  function activeOwnerCount(store: OAuthStore): number {
102
161
  return Object.values(store.accounts).filter((account) => account.active && account.role === "owner").length;
103
162
  }
163
+
164
+ function base64Url(bytes: Uint8Array): string {
165
+ let binary = "";
166
+ for (const byte of bytes) binary += String.fromCharCode(byte);
167
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
168
+ }
169
+
170
+ function hex(bytes: Uint8Array): string {
171
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
172
+ }
@@ -0,0 +1,205 @@
1
+ import { DAEMON_AUTH_CHALLENGE_TTL_SECONDS, DAEMON_AUTH_SCHEME, DAEMON_PREFLIGHT_SCHEME, DAEMON_PREFLIGHT_TTL_SECONDS, daemonAuthTranscript, daemonPreflightTranscript } from "../shared/daemon-auth.mjs";
2
+ import { safeEqual } from "./oauth-state.ts";
3
+ import { consumeBoundedNonce } from "./nonce-store.ts";
4
+
5
+ export interface DaemonChallenge {
6
+ scheme: typeof DAEMON_AUTH_SCHEME;
7
+ challenge: string;
8
+ issuedAt: number;
9
+ expiresAt: number;
10
+ workerOrigin: string;
11
+ }
12
+
13
+ export function createDaemonChallenge(workerOrigin: string, now = Math.floor(Date.now() / 1000)): DaemonChallenge {
14
+ const bytes = new Uint8Array(32);
15
+ crypto.getRandomValues(bytes);
16
+ return {
17
+ scheme: DAEMON_AUTH_SCHEME,
18
+ challenge: `daemon_challenge_${base64Url(bytes)}`,
19
+ issuedAt: now,
20
+ expiresAt: now + DAEMON_AUTH_CHALLENGE_TTL_SECONDS,
21
+ workerOrigin: normalizeWorkerOrigin(workerOrigin),
22
+ };
23
+ }
24
+
25
+
26
+ export function sanitizeDaemonChallengeAttachment(value: Record<string, unknown>): Partial<{
27
+ authChallenge: string;
28
+ authIssuedAt: number;
29
+ authExpiresAt: number;
30
+ workerOrigin: string;
31
+ }> {
32
+ const authChallenge = typeof value.authChallenge === "string" && /^daemon_challenge_[A-Za-z0-9_-]{40,96}$/.test(value.authChallenge)
33
+ ? value.authChallenge
34
+ : undefined;
35
+ const authIssuedAt = positiveSafeInteger(value.authIssuedAt);
36
+ const authExpiresAt = positiveSafeInteger(value.authExpiresAt);
37
+ let workerOrigin: string | undefined;
38
+ try { workerOrigin = normalizeWorkerOrigin(String(value.workerOrigin || "")); } catch {}
39
+ return { authChallenge, authIssuedAt, authExpiresAt, workerOrigin };
40
+ }
41
+
42
+ export interface DaemonPreflightAuthorization {
43
+ nonce: string;
44
+ expiresAt: number;
45
+ }
46
+
47
+ export async function verifyDaemonPreflight(input: {
48
+ publicKeyJson: string;
49
+ headers: Headers;
50
+ workerOrigin: string;
51
+ server: string;
52
+ version: string;
53
+ now?: number;
54
+ }): Promise<DaemonPreflightAuthorization | null> {
55
+ const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
56
+ const scheme = input.headers.get("X-Bridge-Device-Scheme") || "";
57
+ const keyId = input.headers.get("X-Bridge-Device-Key") || "";
58
+ const nonce = input.headers.get("X-Bridge-Device-Nonce") || "";
59
+ const issuedAt = Number(input.headers.get("X-Bridge-Device-Time"));
60
+ const signature = decodeBase64Url(input.headers.get("X-Bridge-Device-Signature") || "", 64);
61
+ if (scheme !== DAEMON_PREFLIGHT_SCHEME || !signature) return null;
62
+ if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > DAEMON_PREFLIGHT_TTL_SECONDS) return null;
63
+ if (!/^[A-Za-z0-9_-]{24,128}$/.test(nonce)) return null;
64
+ let publicJwk: JsonWebKey;
65
+ try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return null; }
66
+ if (!(await safeEqual(keyId, await publicKeyId(publicJwk)))) return null;
67
+ let transcript: string;
68
+ try {
69
+ transcript = daemonPreflightTranscript({
70
+ workerOrigin: input.workerOrigin,
71
+ server: input.server,
72
+ version: input.version,
73
+ nonce,
74
+ issuedAt,
75
+ });
76
+ } catch {
77
+ return null;
78
+ }
79
+ try {
80
+ const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
81
+ const valid = await crypto.subtle.verify(
82
+ { name: "ECDSA", hash: "SHA-256" },
83
+ key,
84
+ signature,
85
+ new TextEncoder().encode(transcript),
86
+ );
87
+ return valid ? { nonce, expiresAt: issuedAt + DAEMON_PREFLIGHT_TTL_SECONDS } : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+
94
+ export async function consumeDaemonPreflightNonce(
95
+ storage: DurableObjectStorage,
96
+ authorization: DaemonPreflightAuthorization,
97
+ now = Math.floor(Date.now() / 1000),
98
+ ): Promise<boolean> {
99
+ return consumeBoundedNonce(storage, {
100
+ key: "daemon-preflight-nonces",
101
+ nonce: authorization.nonce,
102
+ expiresAt: authorization.expiresAt,
103
+ now,
104
+ noncePattern: /^[A-Za-z0-9_-]{24,128}$/,
105
+ maximum: 128,
106
+ });
107
+ }
108
+
109
+ export async function verifyDaemonAuthentication(input: {
110
+ publicKeyJson: string;
111
+ authentication: unknown;
112
+ challenge: DaemonChallenge;
113
+ server: string;
114
+ version: string;
115
+ instanceId: string;
116
+ now?: number;
117
+ }): Promise<boolean> {
118
+ const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
119
+ if (now < input.challenge.issuedAt - 5 || now > input.challenge.expiresAt) return false;
120
+ if (!input.authentication || typeof input.authentication !== "object" || Array.isArray(input.authentication)) return false;
121
+ const auth = input.authentication as Record<string, unknown>;
122
+ if (auth.scheme !== DAEMON_AUTH_SCHEME) return false;
123
+ if (!(await safeEqual(String(auth.challenge || ""), input.challenge.challenge))) return false;
124
+ if (Number(auth.issued_at) !== input.challenge.issuedAt) return false;
125
+ const signature = decodeBase64Url(String(auth.signature || ""), 64);
126
+ if (!signature) return false;
127
+ let publicJwk: JsonWebKey;
128
+ try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return false; }
129
+ const expectedKeyId = await publicKeyId(publicJwk);
130
+ if (!(await safeEqual(String(auth.key_id || ""), expectedKeyId))) return false;
131
+ let transcript: string;
132
+ try {
133
+ transcript = daemonAuthTranscript({
134
+ challenge: input.challenge.challenge,
135
+ workerOrigin: input.challenge.workerOrigin,
136
+ server: input.server,
137
+ version: input.version,
138
+ instanceId: input.instanceId,
139
+ issuedAt: input.challenge.issuedAt,
140
+ });
141
+ } catch {
142
+ return false;
143
+ }
144
+ try {
145
+ const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
146
+ return crypto.subtle.verify(
147
+ { name: "ECDSA", hash: "SHA-256" },
148
+ key,
149
+ signature,
150
+ new TextEncoder().encode(transcript),
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ function parsePublicJwk(value: string): JsonWebKey {
158
+ const parsed = JSON.parse(value) as JsonWebKey;
159
+ if (!parsed || parsed.kty !== "EC" || parsed.crv !== "P-256") throw new Error("invalid device public key");
160
+ if (typeof parsed.x !== "string" || typeof parsed.y !== "string" || parsed.d !== undefined) throw new Error("invalid device public key");
161
+ return { kty: "EC", crv: "P-256", x: parsed.x, y: parsed.y, ext: true, key_ops: ["verify"] };
162
+ }
163
+
164
+ async function publicKeyId(jwk: JsonWebKey): Promise<string> {
165
+ const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
166
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
167
+ return `device_${base64Url(new Uint8Array(digest)).slice(0, 32)}`;
168
+ }
169
+
170
+ function decodeBase64Url(value: string, expectedBytes: number): Uint8Array<ArrayBuffer> | null {
171
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
172
+ const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
173
+ const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
174
+ try {
175
+ const binary = atob(normalized + padding);
176
+ if (binary.length !== expectedBytes) return null;
177
+ const bytes = new Uint8Array(expectedBytes);
178
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
179
+ return bytes;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function base64Url(bytes: Uint8Array): string {
186
+ let binary = "";
187
+ for (const byte of bytes) binary += String.fromCharCode(byte);
188
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
189
+ }
190
+
191
+
192
+ function positiveSafeInteger(value: unknown): number | undefined {
193
+ const number = Number(value);
194
+ return Number.isSafeInteger(number) && number > 0 ? number : undefined;
195
+ }
196
+
197
+ function normalizeWorkerOrigin(value: string): string {
198
+ const url = new URL(value);
199
+ const secure = url.protocol === "https:";
200
+ const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
201
+ if ((!secure && !loopback) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
202
+ throw new Error("invalid Worker origin");
203
+ }
204
+ return url.origin;
205
+ }
@@ -1,6 +1,7 @@
1
1
  import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
2
2
  import { sanitizeMetadataText } from "./http.ts";
3
3
  import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
4
+ import { sanitizeDaemonChallengeAttachment, type DaemonChallenge } from "./daemon-auth.ts";
4
5
 
5
6
  export interface DaemonAttachment {
6
7
  role: DaemonRole;
@@ -10,6 +11,10 @@ export interface DaemonAttachment {
10
11
  instanceId?: string;
11
12
  policy?: DaemonPolicy;
12
13
  tools?: string[];
14
+ authChallenge?: string;
15
+ authIssuedAt?: number;
16
+ authExpiresAt?: number;
17
+ workerOrigin?: string;
13
18
  }
14
19
 
15
20
  interface WebSocketContext {
@@ -33,6 +38,7 @@ export class DaemonSocketRegistry {
33
38
  instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
34
39
  policy,
35
40
  tools: sanitizeDaemonTools(candidate.tools, policy),
41
+ ...sanitizeDaemonChallengeAttachment(candidate as Record<string, unknown>),
36
42
  };
37
43
  }
38
44
 
@@ -52,8 +58,15 @@ export class DaemonSocketRegistry {
52
58
  return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
53
59
  }
54
60
 
55
- beginCandidate(socket: WebSocket, connectedAt = new Date().toISOString()): void {
56
- socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
61
+ beginCandidate(socket: WebSocket, challenge: DaemonChallenge, connectedAt = new Date().toISOString()): void {
62
+ socket.serializeAttachment({
63
+ role: "candidate",
64
+ connectedAt,
65
+ authChallenge: challenge.challenge,
66
+ authIssuedAt: challenge.issuedAt,
67
+ authExpiresAt: challenge.expiresAt,
68
+ workerOrigin: challenge.workerOrigin,
69
+ } satisfies DaemonAttachment);
57
70
  }
58
71
 
59
72
  beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
@@ -11,6 +11,7 @@ import {
11
11
  isFreshDaemonCandidate,
12
12
  } from "./daemon-liveness.ts";
13
13
  import { DaemonSocketRegistry } from "./daemon-sockets.ts";
14
+ import { consumeDaemonPreflightNonce, createDaemonChallenge, verifyDaemonAuthentication, verifyDaemonPreflight } from "./daemon-auth.ts";
14
15
  import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
15
16
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
16
17
  import { WorkerObservability } from "./observability.ts";
@@ -20,7 +21,7 @@ import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from ".
20
21
  import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
21
22
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
22
23
  import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
23
- import { OFFLINE_ACCESS_SCOPE, randomToken, safeEqual } from "./oauth-state.ts";
24
+ import { OFFLINE_ACCESS_SCOPE, randomToken } from "./oauth-state.ts";
24
25
  import {
25
26
  HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
26
27
  parseJsonRequest, workerErrorClass,
@@ -34,7 +35,7 @@ import {
34
35
  } from "./websocket-protocol.ts";
35
36
 
36
37
  const SERVER_NAME = String(serverMetadata.name);
37
- const SERVER_VERSION = "1.2.11";
38
+ const SERVER_VERSION = "2.0.0";
38
39
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
39
40
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
40
41
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
@@ -46,7 +47,7 @@ const DAEMON_RECONNECT_GRACE_MS = 30_000;
46
47
  interface BridgeEnv extends OAuthControllerEnv {
47
48
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
48
49
  ACCOUNT_ADMIN_SECRET: string;
49
- DAEMON_SHARED_SECRET: string;
50
+ DAEMON_DEVICE_PUBLIC_KEY: string;
50
51
  OAUTH_TOKEN_VERSION: string;
51
52
  MBM_WORKER_MAX_BODY_BYTES?: string;
52
53
  MBM_ALLOWED_ORIGINS?: string;
@@ -179,12 +180,34 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
179
180
  await this.scheduleSocketAlarms();
180
181
  return;
181
182
  }
182
- const daemonPolicy = sanitizeDaemonPolicy(body.policy);
183
183
  const instanceId = daemonInstanceId(body.instance_id);
184
184
  if (!instanceId) {
185
185
  rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
186
186
  return;
187
187
  }
188
+ if (!socketAttachment.authChallenge || !socketAttachment.authIssuedAt || !socketAttachment.authExpiresAt || !socketAttachment.workerOrigin) {
189
+ rejectDaemonMessage(ws, "missing_daemon_challenge", 1008, "daemon challenge missing");
190
+ return;
191
+ }
192
+ const authenticated = await verifyDaemonAuthentication({
193
+ publicKeyJson: this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
194
+ authentication: body.authentication,
195
+ challenge: {
196
+ scheme: "device-signature-v1",
197
+ challenge: socketAttachment.authChallenge,
198
+ issuedAt: socketAttachment.authIssuedAt,
199
+ expiresAt: socketAttachment.authExpiresAt,
200
+ workerOrigin: socketAttachment.workerOrigin,
201
+ },
202
+ server: SERVER_NAME,
203
+ version: SERVER_VERSION,
204
+ instanceId,
205
+ });
206
+ if (!authenticated) {
207
+ rejectDaemonMessage(ws, "daemon_authentication_failed", 1008, "daemon authentication failed");
208
+ return;
209
+ }
210
+ const daemonPolicy = sanitizeDaemonPolicy(body.policy);
188
211
  const authenticatedAt = new Date().toISOString();
189
212
  const probeId = randomToken("probe");
190
213
  this.daemonRegistry.beginProbe(ws, {
@@ -482,7 +505,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
482
505
  try {
483
506
  socket.send(JSON.stringify({
484
507
  type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
485
- authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
508
+ authorization: {
509
+ account_id: authorized.accountId,
510
+ account_version: authorized.accountVersion,
511
+ client_id: authorized.clientId,
512
+ role: authorized.role,
513
+ },
486
514
  }));
487
515
  } catch {
488
516
  this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
@@ -508,9 +536,19 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
508
536
 
509
537
  private async acceptDaemonWebSocket(request: Request): Promise<Response> {
510
538
  if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
511
- const expected = this.env.DAEMON_SHARED_SECRET ?? "";
512
- const supplied = request.headers.get("X-Bridge-Token") ?? "";
513
- if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
539
+ if (!this.env.DAEMON_DEVICE_PUBLIC_KEY) return new Response("Daemon device identity is not configured", { status: 503 });
540
+ const workerOrigin = new URL(request.url).origin;
541
+ const preflight = await verifyDaemonPreflight({
542
+ publicKeyJson: this.env.DAEMON_DEVICE_PUBLIC_KEY,
543
+ headers: request.headers,
544
+ workerOrigin,
545
+ server: SERVER_NAME,
546
+ version: SERVER_VERSION,
547
+ });
548
+ if (!preflight || !(await consumeDaemonPreflightNonce(this.ctx.storage, preflight))) {
549
+ return new Response("Unauthorized daemon device", { status: 401 });
550
+ }
551
+ const challenge = createDaemonChallenge(workerOrigin);
514
552
 
515
553
  for (const socket of this.daemonRegistry.nonReadySockets()) {
516
554
  closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
@@ -520,9 +558,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
520
558
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
521
559
  this.ctx.acceptWebSocket(server);
522
560
  this.observability.socketCandidate();
523
- this.daemonRegistry.beginCandidate(server);
561
+ this.daemonRegistry.beginCandidate(server, challenge);
524
562
  await this.scheduleSocketAlarms();
525
- server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
563
+ server.send(JSON.stringify({
564
+ type: "welcome",
565
+ server: SERVER_NAME,
566
+ version: SERVER_VERSION,
567
+ worker_origin: workerOrigin,
568
+ authentication: {
569
+ scheme: challenge.scheme,
570
+ challenge: challenge.challenge,
571
+ issued_at: challenge.issuedAt,
572
+ expires_at: challenge.expiresAt,
573
+ },
574
+ }));
526
575
  return new Response(null, { status: 101, webSocket: client });
527
576
  }
528
577
 
@@ -0,0 +1,79 @@
1
+ interface NonceStorage {
2
+ get<T = unknown>(key: string): Promise<T | undefined>;
3
+ put(key: string, value: unknown): Promise<void>;
4
+ }
5
+
6
+ interface TransactionalNonceStorage extends NonceStorage {
7
+ transaction<T>(callback: (transaction: NonceStorage) => Promise<T>): Promise<T>;
8
+ }
9
+
10
+ export async function consumeBoundedNonce(
11
+ storage: DurableObjectStorage | NonceStorage,
12
+ options: {
13
+ key: string;
14
+ nonce: string;
15
+ expiresAt: number;
16
+ now: number;
17
+ noncePattern: RegExp;
18
+ maximum: number;
19
+ },
20
+ ): Promise<boolean> {
21
+ validateOptions(options);
22
+ const consume = (target: NonceStorage) => consumeInStorage(target, options);
23
+ if (hasTransactions(storage)) return storage.transaction(consume);
24
+ return consume(storage);
25
+ }
26
+
27
+ async function consumeInStorage(
28
+ storage: NonceStorage,
29
+ options: {
30
+ key: string;
31
+ nonce: string;
32
+ expiresAt: number;
33
+ now: number;
34
+ noncePattern: RegExp;
35
+ maximum: number;
36
+ },
37
+ ): Promise<boolean> {
38
+ const raw = await storage.get<unknown>(options.key);
39
+ if (raw !== undefined && !validNonceRecord(raw, options.noncePattern)) return false;
40
+ const nonces: Record<string, number> = raw ? { ...raw as Record<string, number> } : {};
41
+ for (const [nonce, expiresAt] of Object.entries(nonces)) {
42
+ if (expiresAt <= options.now) delete nonces[nonce];
43
+ }
44
+ if (nonces[options.nonce]) return false;
45
+ nonces[options.nonce] = options.expiresAt;
46
+ const entries = Object.entries(nonces).sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]));
47
+ while (entries.length > options.maximum) {
48
+ const [nonce] = entries.shift()!;
49
+ delete nonces[nonce];
50
+ }
51
+ await storage.put(options.key, nonces);
52
+ return true;
53
+ }
54
+
55
+ function validateOptions(options: {
56
+ key: string;
57
+ nonce: string;
58
+ expiresAt: number;
59
+ now: number;
60
+ noncePattern: RegExp;
61
+ maximum: number;
62
+ }): void {
63
+ if (!/^[a-z][a-z0-9-]{1,127}$/.test(options.key)) throw new Error("nonce-store key is invalid");
64
+ if (!options.noncePattern.test(options.nonce)) throw new Error("nonce-store nonce is invalid");
65
+ if (!Number.isSafeInteger(options.now) || options.now <= 0) throw new Error("nonce-store current time is invalid");
66
+ if (!Number.isSafeInteger(options.expiresAt) || options.expiresAt <= options.now) throw new Error("nonce-store expiration is invalid");
67
+ if (!Number.isSafeInteger(options.maximum) || options.maximum < 1 || options.maximum > 4096) throw new Error("nonce-store maximum is invalid");
68
+ }
69
+
70
+ function validNonceRecord(value: unknown, noncePattern: RegExp): value is Record<string, number> {
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
72
+ return Object.entries(value).every(([nonce, expiresAt]) => (
73
+ noncePattern.test(nonce) && Number.isSafeInteger(expiresAt) && expiresAt > 0
74
+ ));
75
+ }
76
+
77
+ function hasTransactions(storage: DurableObjectStorage | NonceStorage): storage is TransactionalNonceStorage {
78
+ return typeof (storage as Partial<TransactionalNonceStorage>).transaction === "function";
79
+ }