machine-bridge-mcp 2.0.0 → 3.0.0-beta.9
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 +101 -0
- package/CONTRIBUTING.md +20 -25
- package/README.md +21 -14
- package/SECURITY.md +147 -123
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/service-worker.js +31 -12
- package/docs/ARCHITECTURE.md +27 -13
- package/docs/AUDIT.md +85 -2
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +9 -3
- package/docs/GETTING_STARTED.md +10 -9
- package/docs/LOCAL_AUTHORIZATION.md +115 -72
- package/docs/LOGGING.md +12 -3
- package/docs/MANAGED_JOBS.md +24 -29
- package/docs/MULTI_ACCOUNT.md +98 -37
- package/docs/OPERATIONS.md +33 -22
- package/docs/OVERVIEW.md +7 -7
- package/docs/PROJECT_STANDARDS.md +9 -5
- package/docs/RELEASING.md +171 -64
- package/docs/TESTING.md +19 -7
- package/docs/THREAT_MODEL.md +138 -67
- package/docs/TOOL_REFERENCE.md +2 -2
- package/docs/UPGRADING.md +102 -18
- package/native/macos/MachineBridgeTrustBroker.swift +233 -0
- package/package.json +36 -5
- package/scripts/candidate-runtime-store.mjs +38 -0
- package/scripts/check-plan.mjs +19 -0
- package/scripts/check-runner.mjs +4 -1
- package/scripts/commit-message-check.mjs +5 -2
- package/scripts/coverage-check.mjs +30 -2
- package/scripts/github-push.mjs +11 -0
- package/scripts/github-release.mjs +38 -7
- package/scripts/install-published-prerelease.mjs +167 -0
- package/scripts/local-release-acceptance.mjs +23 -12
- package/scripts/npm-publication-policy.mjs +16 -0
- package/scripts/prerelease-activation.mjs +79 -0
- package/scripts/privacy-check.mjs +7 -5
- package/scripts/promotion-digest.mjs +121 -0
- package/scripts/publish-npm.mjs +51 -0
- package/scripts/published-release.mjs +80 -0
- package/scripts/release-acceptance.mjs +30 -1
- package/scripts/release-candidate-manifest.mjs +40 -0
- package/scripts/release-channel.mjs +106 -0
- package/scripts/release-impact-check.mjs +12 -22
- package/scripts/release-soak.mjs +237 -0
- package/scripts/start-release-candidate.mjs +113 -7
- package/src/local/account-access.mjs +6 -0
- package/src/local/account-admin.mjs +34 -16
- package/src/local/agent-context.mjs +45 -42
- package/src/local/app-automation.mjs +9 -4
- package/src/local/authority-context.mjs +106 -0
- package/src/local/browser-bridge-http.mjs +4 -1
- package/src/local/browser-bridge.mjs +14 -9
- package/src/local/browser-broker-routes.mjs +4 -0
- package/src/local/browser-broker-server.mjs +5 -4
- package/src/local/browser-extension-identity.mjs +50 -0
- package/src/local/browser-extension-protocol.mjs +9 -4
- package/src/local/browser-operation-service.mjs +7 -0
- package/src/local/browser-pairing-http.mjs +36 -0
- package/src/local/browser-pairing-store.mjs +56 -47
- package/src/local/cli-account-admin.mjs +41 -7
- package/src/local/cli-activate.mjs +90 -0
- package/src/local/cli-approval.mjs +14 -58
- package/src/local/cli-local-admin.mjs +5 -15
- package/src/local/cli-options.mjs +11 -7
- package/src/local/cli-service.mjs +35 -5
- package/src/local/cli.mjs +112 -58
- package/src/local/daemon-process.mjs +6 -0
- package/src/local/delegated-process-sandbox.mjs +147 -0
- package/src/local/device-identity.mjs +157 -48
- package/src/local/device-root-provider.mjs +87 -0
- package/src/local/errors.mjs +25 -3
- package/src/local/git-service.mjs +16 -15
- package/src/local/job-runner.mjs +35 -27
- package/src/local/log.mjs +14 -1
- package/src/local/macos-trust-broker.mjs +320 -0
- package/src/local/managed-job-lock.mjs +18 -0
- package/src/local/managed-job-projection.mjs +9 -1
- package/src/local/managed-job-runner.mjs +13 -1
- package/src/local/managed-job-storage.mjs +2 -1
- package/src/local/managed-job-terminal.mjs +138 -0
- package/src/local/managed-jobs.mjs +88 -32
- package/src/local/operation-authorization.mjs +75 -239
- package/src/local/operation-risk.mjs +28 -0
- package/src/local/operation-state-lock.mjs +7 -86
- package/src/local/owner-state-lock.mjs +104 -0
- package/src/local/policy.mjs +19 -0
- package/src/local/process-execution.mjs +21 -9
- package/src/local/process-sessions.mjs +27 -17
- package/src/local/process-tracker.mjs +30 -6
- package/src/local/process-tree-ownership.mjs +59 -0
- package/src/local/process-tree.mjs +22 -18
- package/src/local/relay-connection.mjs +47 -35
- package/src/local/relay-diagnostics.mjs +65 -0
- package/src/local/remote-configuration.mjs +48 -0
- package/src/local/runtime-activation.mjs +141 -0
- package/src/local/runtime-diagnostics.mjs +21 -0
- package/src/local/runtime-relay.mjs +9 -8
- package/src/local/runtime-reporting.mjs +8 -3
- package/src/local/runtime-tool-handlers.mjs +8 -8
- package/src/local/runtime.mjs +73 -39
- package/src/local/secure-file.mjs +5 -1
- package/src/local/security-audit-log.mjs +204 -0
- package/src/local/service-convergence.mjs +17 -1
- package/src/local/service-ownership.mjs +17 -0
- package/src/local/service-restart-handoff.mjs +50 -0
- package/src/local/service-restart-scheduler.mjs +49 -0
- package/src/local/service-status.mjs +47 -0
- package/src/local/service.mjs +142 -43
- package/src/local/state-inventory.mjs +21 -3
- package/src/local/state.mjs +155 -17
- package/src/local/stdio.mjs +5 -0
- package/src/local/tool-executor.mjs +39 -6
- package/src/local/tool-result-boundary.mjs +51 -0
- package/src/local/tools.mjs +1 -0
- package/src/local/trusted-executable.mjs +53 -0
- package/src/local/trusted-git-executable.mjs +34 -0
- package/src/local/trusted-github-cli.mjs +24 -0
- package/src/local/windows-service.mjs +20 -0
- package/src/local/worker-deployment.mjs +3 -4
- package/src/local/worker-secret-file.mjs +2 -2
- package/src/local/workspace-file-service.mjs +34 -31
- package/src/shared/admin-auth.d.mts +2 -1
- package/src/shared/admin-auth.mjs +3 -3
- package/src/shared/device-session-auth.d.mts +20 -0
- package/src/shared/device-session-auth.mjs +51 -0
- package/src/shared/server-metadata.json +1 -1
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/account-admin.ts +23 -22
- package/src/worker/authority.ts +9 -2
- package/src/worker/daemon-auth.ts +45 -67
- package/src/worker/daemon-sockets.ts +12 -2
- package/src/worker/device-session-verifier.ts +129 -0
- package/src/worker/dpop.ts +151 -0
- package/src/worker/http.ts +47 -16
- package/src/worker/index.ts +53 -16
- package/src/worker/nonce-store.ts +1 -5
- package/src/worker/oauth-client-admin.ts +45 -0
- package/src/worker/oauth-controller.ts +55 -6
- package/src/worker/oauth-refresh-families.ts +21 -7
- package/src/worker/oauth-state.ts +6 -0
- package/src/worker/oauth-tokens.ts +28 -8
- package/src/worker/websocket-protocol.ts +8 -2
|
@@ -1,6 +1,15 @@
|
|
|
1
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
2
|
import { safeEqual } from "./oauth-state.ts";
|
|
3
3
|
import { consumeBoundedNonce } from "./nonce-store.ts";
|
|
4
|
+
import {
|
|
5
|
+
decodeBase64Url,
|
|
6
|
+
parsePublicJwk,
|
|
7
|
+
publicKeyId,
|
|
8
|
+
verifyDeviceSessionCertificate,
|
|
9
|
+
verifyP256Signature,
|
|
10
|
+
} from "./device-session-verifier.ts";
|
|
11
|
+
|
|
12
|
+
const SESSION_CERTIFICATE_HEADER = "X-Bridge-Device-Certificate";
|
|
4
13
|
|
|
5
14
|
export interface DaemonChallenge {
|
|
6
15
|
scheme: typeof DAEMON_AUTH_SCHEME;
|
|
@@ -22,26 +31,37 @@ export function createDaemonChallenge(workerOrigin: string, now = Math.floor(Dat
|
|
|
22
31
|
};
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
|
|
26
34
|
export function sanitizeDaemonChallengeAttachment(value: Record<string, unknown>): Partial<{
|
|
27
35
|
authChallenge: string;
|
|
28
36
|
authIssuedAt: number;
|
|
29
37
|
authExpiresAt: number;
|
|
30
38
|
workerOrigin: string;
|
|
39
|
+
authSessionPublicKeyJson: string;
|
|
40
|
+
authSessionKeyId: string;
|
|
41
|
+
authCertificateExpiresAt: number;
|
|
31
42
|
}> {
|
|
32
43
|
const authChallenge = typeof value.authChallenge === "string" && /^daemon_challenge_[A-Za-z0-9_-]{40,96}$/.test(value.authChallenge)
|
|
33
44
|
? value.authChallenge
|
|
34
45
|
: undefined;
|
|
35
46
|
const authIssuedAt = positiveSafeInteger(value.authIssuedAt);
|
|
36
47
|
const authExpiresAt = positiveSafeInteger(value.authExpiresAt);
|
|
48
|
+
const authCertificateExpiresAt = positiveSafeInteger(value.authCertificateExpiresAt);
|
|
49
|
+
const authSessionKeyId = typeof value.authSessionKeyId === "string" && /^device_[A-Za-z0-9_-]{32}$/.test(value.authSessionKeyId)
|
|
50
|
+
? value.authSessionKeyId
|
|
51
|
+
: undefined;
|
|
37
52
|
let workerOrigin: string | undefined;
|
|
38
53
|
try { workerOrigin = normalizeWorkerOrigin(String(value.workerOrigin || "")); } catch {}
|
|
39
|
-
|
|
54
|
+
let authSessionPublicKeyJson: string | undefined;
|
|
55
|
+
try { authSessionPublicKeyJson = JSON.stringify(parsePublicJwk(String(value.authSessionPublicKeyJson || ""))); } catch {}
|
|
56
|
+
return { authChallenge, authIssuedAt, authExpiresAt, workerOrigin, authSessionPublicKeyJson, authSessionKeyId, authCertificateExpiresAt };
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
export interface DaemonPreflightAuthorization {
|
|
43
60
|
nonce: string;
|
|
44
61
|
expiresAt: number;
|
|
62
|
+
sessionPublicKeyJson: string;
|
|
63
|
+
sessionKeyId: string;
|
|
64
|
+
certificateExpiresAt: number;
|
|
45
65
|
}
|
|
46
66
|
|
|
47
67
|
export async function verifyDaemonPreflight(input: {
|
|
@@ -53,6 +73,15 @@ export async function verifyDaemonPreflight(input: {
|
|
|
53
73
|
now?: number;
|
|
54
74
|
}): Promise<DaemonPreflightAuthorization | null> {
|
|
55
75
|
const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
|
|
76
|
+
const certificate = await verifyDeviceSessionCertificate({
|
|
77
|
+
encodedCertificate: input.headers.get(SESSION_CERTIFICATE_HEADER) || "",
|
|
78
|
+
rootPublicKeyJson: input.publicKeyJson,
|
|
79
|
+
workerOrigin: input.workerOrigin,
|
|
80
|
+
server: input.server,
|
|
81
|
+
version: input.version,
|
|
82
|
+
now,
|
|
83
|
+
});
|
|
84
|
+
if (!certificate) return null;
|
|
56
85
|
const scheme = input.headers.get("X-Bridge-Device-Scheme") || "";
|
|
57
86
|
const keyId = input.headers.get("X-Bridge-Device-Key") || "";
|
|
58
87
|
const nonce = input.headers.get("X-Bridge-Device-Nonce") || "";
|
|
@@ -61,36 +90,23 @@ export async function verifyDaemonPreflight(input: {
|
|
|
61
90
|
if (scheme !== DAEMON_PREFLIGHT_SCHEME || !signature) return null;
|
|
62
91
|
if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > DAEMON_PREFLIGHT_TTL_SECONDS) return null;
|
|
63
92
|
if (!/^[A-Za-z0-9_-]{24,128}$/.test(nonce)) return null;
|
|
64
|
-
|
|
65
|
-
try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return null; }
|
|
66
|
-
if (!(await safeEqual(keyId, await publicKeyId(publicJwk)))) return null;
|
|
93
|
+
if (!(await safeEqual(keyId, certificate.sessionKeyId))) return null;
|
|
67
94
|
let transcript: string;
|
|
68
95
|
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;
|
|
96
|
+
transcript = daemonPreflightTranscript({ workerOrigin: input.workerOrigin, server: input.server, version: input.version, nonce, issuedAt });
|
|
88
97
|
} catch {
|
|
89
98
|
return null;
|
|
90
99
|
}
|
|
100
|
+
if (!(await verifyP256Signature(certificate.sessionPublicJwk, transcript, signature))) return null;
|
|
101
|
+
return {
|
|
102
|
+
nonce,
|
|
103
|
+
expiresAt: issuedAt + DAEMON_PREFLIGHT_TTL_SECONDS,
|
|
104
|
+
sessionPublicKeyJson: certificate.sessionPublicKeyJson,
|
|
105
|
+
sessionKeyId: certificate.sessionKeyId,
|
|
106
|
+
certificateExpiresAt: certificate.expiresAt,
|
|
107
|
+
};
|
|
91
108
|
}
|
|
92
109
|
|
|
93
|
-
|
|
94
110
|
export async function consumeDaemonPreflightNonce(
|
|
95
111
|
storage: DurableObjectStorage,
|
|
96
112
|
authorization: DaemonPreflightAuthorization,
|
|
@@ -113,10 +129,12 @@ export async function verifyDaemonAuthentication(input: {
|
|
|
113
129
|
server: string;
|
|
114
130
|
version: string;
|
|
115
131
|
instanceId: string;
|
|
132
|
+
certificateExpiresAt?: number;
|
|
116
133
|
now?: number;
|
|
117
134
|
}): Promise<boolean> {
|
|
118
135
|
const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
|
|
119
136
|
if (now < input.challenge.issuedAt - 5 || now > input.challenge.expiresAt) return false;
|
|
137
|
+
if (input.certificateExpiresAt !== undefined && (!Number.isSafeInteger(input.certificateExpiresAt) || now > input.certificateExpiresAt)) return false;
|
|
120
138
|
if (!input.authentication || typeof input.authentication !== "object" || Array.isArray(input.authentication)) return false;
|
|
121
139
|
const auth = input.authentication as Record<string, unknown>;
|
|
122
140
|
if (auth.scheme !== DAEMON_AUTH_SCHEME) return false;
|
|
@@ -126,8 +144,7 @@ export async function verifyDaemonAuthentication(input: {
|
|
|
126
144
|
if (!signature) return false;
|
|
127
145
|
let publicJwk: JsonWebKey;
|
|
128
146
|
try { publicJwk = parsePublicJwk(input.publicKeyJson); } catch { return false; }
|
|
129
|
-
|
|
130
|
-
if (!(await safeEqual(String(auth.key_id || ""), expectedKeyId))) return false;
|
|
147
|
+
if (!(await safeEqual(String(auth.key_id || ""), await publicKeyId(publicJwk)))) return false;
|
|
131
148
|
let transcript: string;
|
|
132
149
|
try {
|
|
133
150
|
transcript = daemonAuthTranscript({
|
|
@@ -141,45 +158,7 @@ export async function verifyDaemonAuthentication(input: {
|
|
|
141
158
|
} catch {
|
|
142
159
|
return false;
|
|
143
160
|
}
|
|
144
|
-
|
|
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
|
-
}
|
|
161
|
+
return verifyP256Signature(publicJwk, transcript, signature);
|
|
183
162
|
}
|
|
184
163
|
|
|
185
164
|
function base64Url(bytes: Uint8Array): string {
|
|
@@ -188,7 +167,6 @@ function base64Url(bytes: Uint8Array): string {
|
|
|
188
167
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
189
168
|
}
|
|
190
169
|
|
|
191
|
-
|
|
192
170
|
function positiveSafeInteger(value: unknown): number | undefined {
|
|
193
171
|
const number = Number(value);
|
|
194
172
|
return Number.isSafeInteger(number) && number > 0 ? number : undefined;
|
|
@@ -2,7 +2,6 @@ import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from ".
|
|
|
2
2
|
import { sanitizeMetadataText } from "./http.ts";
|
|
3
3
|
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
|
|
4
4
|
import { sanitizeDaemonChallengeAttachment, type DaemonChallenge } from "./daemon-auth.ts";
|
|
5
|
-
|
|
6
5
|
export interface DaemonAttachment {
|
|
7
6
|
role: DaemonRole;
|
|
8
7
|
connectedAt: string;
|
|
@@ -15,6 +14,9 @@ export interface DaemonAttachment {
|
|
|
15
14
|
authIssuedAt?: number;
|
|
16
15
|
authExpiresAt?: number;
|
|
17
16
|
workerOrigin?: string;
|
|
17
|
+
authSessionPublicKeyJson?: string;
|
|
18
|
+
authSessionKeyId?: string;
|
|
19
|
+
authCertificateExpiresAt?: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
interface WebSocketContext {
|
|
@@ -58,7 +60,12 @@ export class DaemonSocketRegistry {
|
|
|
58
60
|
return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
beginCandidate(
|
|
63
|
+
beginCandidate(
|
|
64
|
+
socket: WebSocket,
|
|
65
|
+
challenge: DaemonChallenge,
|
|
66
|
+
preflight: { sessionPublicKeyJson: string; sessionKeyId: string; certificateExpiresAt: number },
|
|
67
|
+
connectedAt = new Date().toISOString(),
|
|
68
|
+
): void {
|
|
62
69
|
socket.serializeAttachment({
|
|
63
70
|
role: "candidate",
|
|
64
71
|
connectedAt,
|
|
@@ -66,6 +73,9 @@ export class DaemonSocketRegistry {
|
|
|
66
73
|
authIssuedAt: challenge.issuedAt,
|
|
67
74
|
authExpiresAt: challenge.expiresAt,
|
|
68
75
|
workerOrigin: challenge.workerOrigin,
|
|
76
|
+
authSessionPublicKeyJson: preflight.sessionPublicKeyJson,
|
|
77
|
+
authSessionKeyId: preflight.sessionKeyId,
|
|
78
|
+
authCertificateExpiresAt: preflight.certificateExpiresAt,
|
|
69
79
|
} satisfies DaemonAttachment);
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEVICE_SESSION_CERTIFICATE_SCHEME,
|
|
3
|
+
DEVICE_SESSION_MAX_LIFETIME_SECONDS,
|
|
4
|
+
canonicalPublicJwk,
|
|
5
|
+
deviceSessionCertificateTranscript,
|
|
6
|
+
} from "../shared/device-session-auth.mjs";
|
|
7
|
+
import { safeEqual } from "./oauth-state.ts";
|
|
8
|
+
|
|
9
|
+
const SESSION_CERTIFICATE_MAX_BYTES = 16 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface VerifiedDeviceSessionCertificate {
|
|
12
|
+
sessionPublicJwk: JsonWebKey;
|
|
13
|
+
sessionPublicKeyJson: string;
|
|
14
|
+
sessionKeyId: string;
|
|
15
|
+
rootKeyId: string;
|
|
16
|
+
issuedAt: number;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function verifyDeviceSessionCertificate(input: {
|
|
21
|
+
encodedCertificate: string;
|
|
22
|
+
rootPublicKeyJson: string;
|
|
23
|
+
workerOrigin: string;
|
|
24
|
+
server: string;
|
|
25
|
+
version: string;
|
|
26
|
+
now: number;
|
|
27
|
+
}): Promise<VerifiedDeviceSessionCertificate | null> {
|
|
28
|
+
const bytes = decodeBase64Url(input.encodedCertificate);
|
|
29
|
+
if (!bytes || bytes.byteLength > SESSION_CERTIFICATE_MAX_BYTES) return null;
|
|
30
|
+
let value: Record<string, unknown>;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
33
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
34
|
+
value = parsed as Record<string, unknown>;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (value.scheme !== DEVICE_SESSION_CERTIFICATE_SCHEME) return null;
|
|
39
|
+
const issuedAt = Number(value.issued_at);
|
|
40
|
+
const expiresAt = Number(value.expires_at);
|
|
41
|
+
const nonce = String(value.nonce || "");
|
|
42
|
+
const rootKeyId = String(value.root_key_id || "");
|
|
43
|
+
const signature = decodeBase64Url(String(value.signature || ""), 64);
|
|
44
|
+
if (!signature || !Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt)) return null;
|
|
45
|
+
if (issuedAt > input.now + 5 * 60 || expiresAt <= input.now) return null;
|
|
46
|
+
if (expiresAt <= issuedAt || expiresAt - issuedAt > DEVICE_SESSION_MAX_LIFETIME_SECONDS) return null;
|
|
47
|
+
if (!/^[A-Za-z0-9_-]{24,128}$/.test(nonce)) return null;
|
|
48
|
+
let rootPublicJwk: JsonWebKey;
|
|
49
|
+
let sessionPublicJwk: JsonWebKey;
|
|
50
|
+
try {
|
|
51
|
+
rootPublicJwk = parsePublicJwk(input.rootPublicKeyJson);
|
|
52
|
+
sessionPublicJwk = parsePublicJwk(JSON.stringify(value.public_jwk));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const expectedRootKeyId = await publicKeyId(rootPublicJwk);
|
|
57
|
+
if (!(await safeEqual(rootKeyId, expectedRootKeyId))) return null;
|
|
58
|
+
let transcript: string;
|
|
59
|
+
try {
|
|
60
|
+
transcript = deviceSessionCertificateTranscript({
|
|
61
|
+
workerOrigin: input.workerOrigin,
|
|
62
|
+
server: input.server,
|
|
63
|
+
version: input.version,
|
|
64
|
+
rootKeyId,
|
|
65
|
+
publicJwk: sessionPublicJwk,
|
|
66
|
+
issuedAt,
|
|
67
|
+
expiresAt,
|
|
68
|
+
nonce,
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (!(await verifyP256Signature(rootPublicJwk, transcript, signature))) return null;
|
|
74
|
+
return {
|
|
75
|
+
sessionPublicJwk,
|
|
76
|
+
sessionPublicKeyJson: JSON.stringify(canonicalPublicJwk(sessionPublicJwk)),
|
|
77
|
+
sessionKeyId: await publicKeyId(sessionPublicJwk),
|
|
78
|
+
rootKeyId,
|
|
79
|
+
issuedAt,
|
|
80
|
+
expiresAt,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function verifyP256Signature(publicJwk: JsonWebKey, transcript: string, signature: Uint8Array<ArrayBuffer>): Promise<boolean> {
|
|
85
|
+
try {
|
|
86
|
+
const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
87
|
+
return crypto.subtle.verify(
|
|
88
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
89
|
+
key,
|
|
90
|
+
signature,
|
|
91
|
+
new TextEncoder().encode(transcript),
|
|
92
|
+
);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parsePublicJwk(value: string): JsonWebKey {
|
|
99
|
+
const parsed = JSON.parse(value) as JsonWebKey;
|
|
100
|
+
const canonical = canonicalPublicJwk(parsed);
|
|
101
|
+
return { ...canonical, ext: true, key_ops: ["verify"] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function publicKeyId(jwk: JsonWebKey): Promise<string> {
|
|
105
|
+
const canonical = JSON.stringify(canonicalPublicJwk(jwk));
|
|
106
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
107
|
+
return `device_${base64Url(new Uint8Array(digest)).slice(0, 32)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function decodeBase64Url(value: string, expectedBytes?: number): Uint8Array<ArrayBuffer> | null {
|
|
111
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
112
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
113
|
+
const padding = "=".repeat((4 - (normalized.length % 4)) % 4);
|
|
114
|
+
try {
|
|
115
|
+
const binary = atob(normalized + padding);
|
|
116
|
+
if (expectedBytes !== undefined && binary.length !== expectedBytes) return null;
|
|
117
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
118
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
119
|
+
return bytes;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function base64Url(bytes: Uint8Array): string {
|
|
126
|
+
let binary = "";
|
|
127
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
128
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
129
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { consumeBoundedNonce } from "./nonce-store.ts";
|
|
2
|
+
|
|
3
|
+
const DPOP_WINDOW_SECONDS = 5 * 60;
|
|
4
|
+
const MAX_DPOP_JTIS = 4096;
|
|
5
|
+
const JKT_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
6
|
+
|
|
7
|
+
export interface VerifiedDpopProof {
|
|
8
|
+
jkt: string;
|
|
9
|
+
jti: string;
|
|
10
|
+
issuedAt: number;
|
|
11
|
+
replayKey: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function verifyDpopProof(input: {
|
|
16
|
+
request: Request;
|
|
17
|
+
expectedMethod?: string;
|
|
18
|
+
expectedUrl?: string;
|
|
19
|
+
accessToken?: string;
|
|
20
|
+
expectedJkt?: string;
|
|
21
|
+
now?: number;
|
|
22
|
+
}): Promise<VerifiedDpopProof | null> {
|
|
23
|
+
const encoded = input.request.headers.get("DPoP") || "";
|
|
24
|
+
if (!encoded) return null;
|
|
25
|
+
const parts = encoded.split(".");
|
|
26
|
+
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
|
|
27
|
+
const header = decodeJson(parts[0]);
|
|
28
|
+
const payload = decodeJson(parts[1]);
|
|
29
|
+
const signature = decodeBase64Url(parts[2], 64);
|
|
30
|
+
if (!header || !payload || !signature) return null;
|
|
31
|
+
if (header.typ !== "dpop+jwt" || header.alg !== "ES256" || !plainRecord(header.jwk)) return null;
|
|
32
|
+
if (header.crit !== undefined || header.b64 !== undefined) return null;
|
|
33
|
+
const publicJwk = publicP256Jwk(header.jwk);
|
|
34
|
+
if (!publicJwk) return null;
|
|
35
|
+
const now = Number.isSafeInteger(input.now) ? Number(input.now) : Math.floor(Date.now() / 1000);
|
|
36
|
+
const issuedAt = Number(payload.iat);
|
|
37
|
+
const jti = String(payload.jti || "");
|
|
38
|
+
if (!Number.isSafeInteger(issuedAt) || Math.abs(now - issuedAt) > DPOP_WINDOW_SECONDS) return null;
|
|
39
|
+
if (!/^[A-Za-z0-9._~-]{16,200}$/.test(jti)) return null;
|
|
40
|
+
const expectedMethod = String(input.expectedMethod || input.request.method).toUpperCase();
|
|
41
|
+
const expectedUrl = normalizedHtu(input.expectedUrl || input.request.url);
|
|
42
|
+
if (String(payload.htm || "").toUpperCase() !== expectedMethod || normalizedHtu(String(payload.htu || "")) !== expectedUrl) return null;
|
|
43
|
+
const jkt = await jwkThumbprint(publicJwk);
|
|
44
|
+
if (input.expectedJkt && (!JKT_PATTERN.test(input.expectedJkt) || input.expectedJkt !== jkt)) return null;
|
|
45
|
+
if (input.accessToken) {
|
|
46
|
+
const ath = String(payload.ath || "");
|
|
47
|
+
if (ath !== await sha256Base64Url(input.accessToken)) return null;
|
|
48
|
+
} else if (payload.ath !== undefined) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const key = await crypto.subtle.importKey("jwk", publicJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
53
|
+
const verified = await crypto.subtle.verify(
|
|
54
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
55
|
+
key,
|
|
56
|
+
signature as unknown as BufferSource,
|
|
57
|
+
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
|
|
58
|
+
);
|
|
59
|
+
if (!verified) return null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const replayKey = await sha256Base64Url(`${jkt}\0${jti}`);
|
|
64
|
+
return {
|
|
65
|
+
jkt,
|
|
66
|
+
jti,
|
|
67
|
+
issuedAt,
|
|
68
|
+
replayKey,
|
|
69
|
+
expiresAt: Math.max(now + 1, issuedAt + DPOP_WINDOW_SECONDS),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function consumeDpopProof(
|
|
74
|
+
storage: DurableObjectStorage,
|
|
75
|
+
proof: VerifiedDpopProof,
|
|
76
|
+
now = Math.floor(Date.now() / 1000),
|
|
77
|
+
): Promise<boolean> {
|
|
78
|
+
if (!proof || !JKT_PATTERN.test(proof.replayKey) || !Number.isSafeInteger(proof.expiresAt) || proof.expiresAt <= now) return false;
|
|
79
|
+
return consumeBoundedNonce(storage, {
|
|
80
|
+
key: "dpop-proof-jtis",
|
|
81
|
+
nonce: proof.replayKey,
|
|
82
|
+
expiresAt: proof.expiresAt,
|
|
83
|
+
now,
|
|
84
|
+
noncePattern: JKT_PATTERN,
|
|
85
|
+
maximum: MAX_DPOP_JTIS,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function jwkThumbprint(jwk: JsonWebKey): Promise<string> {
|
|
90
|
+
const publicJwk = publicP256Jwk(jwk);
|
|
91
|
+
if (!publicJwk) throw new Error("DPoP public key is invalid");
|
|
92
|
+
const canonical = JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y });
|
|
93
|
+
return sha256Base64Url(canonical);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function normalizedHtu(value: string): string {
|
|
97
|
+
const url = new URL(value);
|
|
98
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) {
|
|
99
|
+
throw new Error("DPoP target URI is invalid");
|
|
100
|
+
}
|
|
101
|
+
if (url.username || url.password || url.hash) throw new Error("DPoP target URI is invalid");
|
|
102
|
+
return `${url.origin}${url.pathname}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function publicP256Jwk(value: unknown): JsonWebKey | null {
|
|
106
|
+
if (!plainRecord(value) || value.kty !== "EC" || value.crv !== "P-256" || value.d !== undefined) return null;
|
|
107
|
+
if (typeof value.x !== "string" || typeof value.y !== "string") return null;
|
|
108
|
+
if (!/^[A-Za-z0-9_-]{42,44}$/.test(value.x) || !/^[A-Za-z0-9_-]{42,44}$/.test(value.y)) return null;
|
|
109
|
+
return { kty: "EC", crv: "P-256", x: value.x, y: value.y, ext: true, key_ops: ["verify"] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function decodeJson(value: string): Record<string, unknown> | null {
|
|
113
|
+
const bytes = decodeBase64Url(value);
|
|
114
|
+
if (!bytes || bytes.byteLength > 16 * 1024) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
117
|
+
return plainRecord(parsed) ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function decodeBase64Url(value: string, expectedBytes?: number): Uint8Array | null {
|
|
124
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
125
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
126
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
127
|
+
try {
|
|
128
|
+
const binary = atob(padded);
|
|
129
|
+
if (expectedBytes !== undefined && binary.length !== expectedBytes) return null;
|
|
130
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
131
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
132
|
+
return bytes;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function sha256Base64Url(value: string): Promise<string> {
|
|
139
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
140
|
+
return base64Url(new Uint8Array(digest));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function base64Url(bytes: Uint8Array): string {
|
|
144
|
+
let binary = "";
|
|
145
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
146
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function plainRecord(value: unknown): value is Record<string, any> {
|
|
150
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
151
|
+
}
|
package/src/worker/http.ts
CHANGED
|
@@ -101,11 +101,18 @@ export function baseUrl(request: Request): string {
|
|
|
101
101
|
return new URL(request.url).origin;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
export function oauthAccessToken(request: Request): { scheme: "bearer" | "dpop" | ""; token: string } {
|
|
105
|
+
const match = (request.headers.get("Authorization") ?? "").match(/^(Bearer|DPoP)\s+(.+)$/i);
|
|
106
|
+
if (!match) return { scheme: "", token: "" };
|
|
107
|
+
return { scheme: match[1].toLowerCase() as "bearer" | "dpop", token: match[2].trim() };
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
export function bearerToken(request: Request): string {
|
|
105
|
-
const
|
|
106
|
-
return
|
|
111
|
+
const access = oauthAccessToken(request);
|
|
112
|
+
return access.scheme === "bearer" ? access.token : "";
|
|
107
113
|
}
|
|
108
114
|
|
|
115
|
+
|
|
109
116
|
export async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
|
|
110
117
|
const text = await readBoundedText(request, limit);
|
|
111
118
|
try {
|
|
@@ -133,31 +140,50 @@ export async function parseRequestBody(request: Request, limit: number): Promise
|
|
|
133
140
|
return searchParamsObject(new URLSearchParams(text));
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
export async function
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!request.body) return
|
|
143
|
+
export async function discardRequestBody(request: Pick<Request, "body" | "headers">, limit: number): Promise<{ bytes_read: number; exceeded: boolean }> {
|
|
144
|
+
const boundedLimit = normalizeBodyLimit(limit);
|
|
145
|
+
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
|
146
|
+
let exceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
|
|
147
|
+
let observed = 0;
|
|
148
|
+
if (!request.body) return { bytes_read: 0, exceeded };
|
|
142
149
|
const reader = request.body.getReader();
|
|
150
|
+
try {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { done, value } = await reader.read();
|
|
153
|
+
if (done) break;
|
|
154
|
+
if (!value) continue;
|
|
155
|
+
observed = Math.min(boundedLimit + 1, observed + value.byteLength);
|
|
156
|
+
if (observed > boundedLimit) exceeded = true;
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
reader.releaseLock();
|
|
160
|
+
}
|
|
161
|
+
return { bytes_read: observed, exceeded };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
165
|
+
const boundedLimit = normalizeBodyLimit(limit);
|
|
166
|
+
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
|
167
|
+
let exceeded = Number.isFinite(declaredLength) && declaredLength > boundedLimit;
|
|
168
|
+
const reader = request.body?.getReader();
|
|
169
|
+
if (!reader) return "";
|
|
143
170
|
const chunks: Uint8Array[] = [];
|
|
144
|
-
let
|
|
171
|
+
let observed = 0;
|
|
145
172
|
try {
|
|
146
173
|
for (;;) {
|
|
147
174
|
const { done, value } = await reader.read();
|
|
148
175
|
if (done) break;
|
|
149
176
|
if (!value) continue;
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
chunks.push(value);
|
|
177
|
+
const nextObserved = Math.min(boundedLimit + 1, observed + value.byteLength);
|
|
178
|
+
if (!exceeded && observed + value.byteLength <= boundedLimit) chunks.push(value);
|
|
179
|
+
else exceeded = true;
|
|
180
|
+
observed = nextObserved;
|
|
156
181
|
}
|
|
157
182
|
} finally {
|
|
158
183
|
reader.releaseLock();
|
|
159
184
|
}
|
|
160
|
-
|
|
185
|
+
if (exceeded) throw new HttpError(413, "request_body_too_large", `request body exceeds ${boundedLimit} bytes`);
|
|
186
|
+
const combined = new Uint8Array(observed);
|
|
161
187
|
let offset = 0;
|
|
162
188
|
for (const chunk of chunks) {
|
|
163
189
|
combined.set(chunk, offset);
|
|
@@ -170,6 +196,11 @@ export async function readBoundedText(request: Request, limit: number): Promise<
|
|
|
170
196
|
}
|
|
171
197
|
}
|
|
172
198
|
|
|
199
|
+
function normalizeBodyLimit(value: number): number {
|
|
200
|
+
const parsed = Number(value);
|
|
201
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
173
204
|
export function normalizeRedirectUri(value: string): string | null {
|
|
174
205
|
try {
|
|
175
206
|
const url = new URL(value);
|