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.
Files changed (143) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +80 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. 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
- return { authChallenge, authIssuedAt, authExpiresAt, workerOrigin };
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
- let publicJwk: JsonWebKey;
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
- const expectedKeyId = await publicKeyId(publicJwk);
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
- 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
- }
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(socket: WebSocket, challenge: DaemonChallenge, connectedAt = new Date().toISOString()): void {
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
+ }
@@ -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 match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
106
- return match?.[1]?.trim() ?? "";
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 readBoundedText(request: Request, limit: number): Promise<string> {
137
- const length = Number(request.headers.get("content-length") ?? "0");
138
- if (Number.isFinite(length) && length > limit) {
139
- throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
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 total = 0;
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
- total += value.byteLength;
151
- if (total > limit) {
152
- await reader.cancel();
153
- throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
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
- const combined = new Uint8Array(total);
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);