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.
- package/CHANGELOG.md +14 -1
- package/README.md +10 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/AUDIT.md +25 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +2 -2
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +111 -0
- package/docs/LOGGING.md +4 -4
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +30 -5
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +3 -3
- package/docs/TESTING.md +5 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/UPGRADING.md +10 -6
- package/package.json +4 -2
- package/scripts/check-plan.mjs +2 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +7 -4
- package/src/local/account-admin.mjs +28 -3
- package/src/local/cli-approval.mjs +117 -0
- package/src/local/cli-options.mjs +8 -2
- package/src/local/cli.mjs +13 -2
- package/src/local/device-identity.mjs +108 -0
- package/src/local/operation-authorization.mjs +366 -0
- package/src/local/operation-risk.mjs +221 -0
- package/src/local/operation-state-lock.mjs +92 -0
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +13 -7
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/worker-deployment.mjs +4 -3
- package/src/local/worker-secret-file.mjs +2 -1
- package/src/shared/admin-auth.d.mts +10 -0
- package/src/shared/admin-auth.mjs +36 -0
- package/src/shared/daemon-auth.d.mts +20 -0
- package/src/shared/daemon-auth.mjs +55 -0
- package/src/worker/account-admin.ts +73 -4
- package/src/worker/daemon-auth.ts +205 -0
- package/src/worker/daemon-sockets.ts +15 -2
- package/src/worker/index.ts +59 -10
- package/src/worker/nonce-store.ts +79 -0
- package/src/worker/oauth-controller.ts +11 -6
- package/src/worker/oauth-refresh-families.ts +172 -0
- package/src/worker/oauth-state.ts +48 -3
- 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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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({
|
|
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 {
|
package/src/worker/index.ts
CHANGED
|
@@ -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
|
|
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 = "
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
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({
|
|
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
|
+
}
|