release: v3.0.0 lockstep major + Cloud Connect#29
Merged
Conversation
…ch-code, bind, revoke Implements the vault half of Cloud Agent Connect (paste-a-URL + on-device approval). New @dcprotocol/core connect-link codec (signed dcp_connect_v1_ tokens that PIN the vault HPKE key, carry no permission authority), a cloud_connect_links storage table with single-use atomic redemption, and the vault REST surface: POST /v1/cloud-connect/links issue (owner; $0/no-auto-approve default) POST /v1/cloud-connect/redeem agent redeems -> pending agent + match-code POST /v1/cloud-connect/status agent polls -> mints session token once GET /v1/cloud-connect/pending owner: server-derived facts + match-code GET /v1/cloud-connect/links owner: manage POST /v1/cloud-connect/pending/:id/approve|deny owner (verifies match-code) POST /v1/cloud-connect/agents/:id/revoke owner: instant kill Security rules baked in from the start: #1 HPKE key-pin (verified against the vault's own key), #4 single-use + 10-min TTL + constant-time secret compare, #5 auto-approve OFF/$0 default, #6 server-derived approval facts + match-code, #7 instant revoke clears token, #8 inert until human-approved (redeem only creates a PENDING agent; no token issued until owner approves). Tests: core connect-link.test.ts (11) + vault cloud-connect.test.ts (7), all green. Full monorepo build, publish guard, and 12/12 security regression pass.
Adversarial multi-agent review (35 agents, 30 findings) of the P1 vault code. The real, exploitable-in-P1 issues are fixed here; sender-binding/DPoP and rate-limiting remain P2 (relay OAuth) by design. Fixed: - Match-code is now MANDATORY server-side on approve (was only checked if the client sent it -> defeated Rule #6). Constant-time compare. (5 lenses flagged this.) - Redeemed-but-unapproved links now expire after a 15-min approval grace window; expireStaleCloudConnectLinks() is wired in lazily on /status, /pending, approve (was defined but never called -> a match-code stayed approvable indefinitely). - source_host no longer taken from the attacker-controllable Host header; only request.ip (socket-derived) is surfaced as an on-device approval fact (Rule #6). - approveCloudConnectLink is now transactional with the guarded status UPDATE first, so a lost race never marks the agent paired. - Cap owner-supplied name (80) and scopes (64 x 128) on issue (anti storage bloat). Added regression test: approve with omitted/blank match_code -> 400, agent stays unbound, and source_host is absent from on-device facts. All suites green (core 7, vault 7 test files), monorepo build, guard, 12/12 security.
…PoP, tokens, metadata) First P2 increment: standalone, fully-tested OAuth building blocks for the relay's per-vault MCP authorization. No relay.ts wiring yet (endpoints next). - keys.ts: ES256 token-signing key (load from DCP_RELAY_OAUTH_PRIVATE_JWK or ephemeral-with-warning) + public-only JWKS. Never touches vault key material. - pkce.ts: RFC 7636 S256 verification only (no plain downgrade), constant-time. - dpop.ts: RFC 9449 proof verification — typ/alg checks, embedded public-jwk (rejects private material), htm/htu (query/fragment-insensitive), iat freshness, jti single-use replay guard, optional ath token-binding + nonce. Returns jkt. - tokens.ts: short-lived access tokens bound to ONE vault via aud (RFC 8707) and sender-bound via cnf.jkt (RFC 9449); verify enforces both. - metadata.ts: RFC 8414 AS metadata + RFC 9728 protected-resource metadata + WWW-Authenticate challenge. Advertises only OAuth 2.1-safe options (code+S256+DPoP). Adds jose ^5.9.6. 17 tests (PKCE, keys/JWKS, DPoP incl. replay/ath/stale/private-jwk, access-token audience-isolation + jkt-binding + cross-key, metadata). All green; relay builds; existing relay suite unaffected; guard passes.
… refresh w/ reuse-detection Second P2 increment (state machine, still standalone + tested): - ClientStore: RFC 7591 DCR; public clients (auth_method=none), display strings clamped + treated as untrusted (real gate = on-device approval, Rule #8). - AuthSessionStore: connect-link-driven authorization sessions (device-flow-like) bound to the agent DPoP jkt + PKCE challenge; pending_approval->approved->issued; auto-expiry. - RefreshTokenStore: rotating refresh tokens with REUSE DETECTION — replaying a spent token revokes the entire chain (Rule #3); per-agent revoke (Rule #7); chains independent; TTL/revoked sweep. 10 tests (rotation, reuse->chain-revoke, agent-revoke, chain isolation, session lifecycle, DCR clamping). All green; relay builds; guard passes.
…elayServer Third P2 increment — endpoints that don't need the vault bridge: - GET /.well-known/oauth-authorization-server (RFC 8414) - GET /.well-known/oauth-protected-resource/v/:vaultId/mcp (RFC 9728) - GET /oauth/jwks (public verification keys) - POST /oauth/register (RFC 7591 DCR; public clients, untrusted display strings) - POST /oauth/revoke (RFC 7009; revokes refresh chain; always 200) RelayServer now memoizes its ES256 OAuth keys (lazy async init from DCP_RELAY_OAUTH_PRIVATE_JWK or ephemeral) and holds the client/session/refresh stores + DPoP jti guard. Added RelayConfig.publicUrl (issuer/resource origin; derives from request Host when unset). 5 integration tests (real server + fetch); all relay suites green; build + guard pass. Next (P2 core): /oauth/connect (connect-link grant -> auth session + vault redeem over WS), /oauth/token (device-flow poll -> DPoP+aud access token + rotating refresh), and the /v/:vaultId/mcp facade — these need the vault-side WS bridge.
…oken The device-flow-style authorization grant for cloud agents, against a pluggable VaultConnectBridge (WS impl + vault side come next; tests use a fake bridge). - bridge.ts: VaultConnectBridge interface (redeem + approvalStatus) — the control plane; carries no vault secrets. UnavailableVaultBridge fails closed. - connect-link-lite.ts: minimal UNTRUSTED routing parse (vault_id/link_id) so the relay needs no @dcprotocol/core dep; the vault does full verification. - grant.ts: processConnect (verify DPoP -> jkt, require PKCE S256, redeem via bridge, open auth session, return device_code + match_code) and processToken (device_code: PKCE + same-DPoP-key + gate on vault approval -> issue audience- bound, DPoP-bound access token + rotating refresh; refresh_token: rotate with reuse-detection + DPoP-key match). - relay.ts: wired POST /oauth/connect + /oauth/token (DPoP header), vaultBridge constructor option, grantDeps builder. Security baked in: #2 DPoP sender-binding (cnf.jkt), #3 rotating refresh + reuse revoke, #4 jti single-use + PKCE, #6 match-code surfaced, #8 inert until on-device approval (token gated on bridge.approvalStatus). 7 grant tests (happy path, pending, device_code reuse, PKCE fail, cross-key, bridge failures, refresh reuse->chain revoke). All relay suites green; build + guard pass.
…control bridge Connects the relay's OAuth AS to the real vault that issued a connect-link, so /oauth/connect + /oauth/token now work end-to-end against a live vault. - relay types: add cloud_connect_redeem / cloud_connect_status / cloud_connect_result WS message types (control plane; no vault secrets). - relay: WS-backed VaultConnectBridge (default) — pushes a control message to the vault's /ws socket and awaits a correlated reply (pendingControl map + timeout + cleanup on stop); handleWsMessage resolves cloud_connect_result. - relay-client: emit 'cloudConnectControl' for inbound redeem/status; add sendCloudConnectResult(controlId, result). - vault: register the handler — redeemCloudConnectOverRelay (full connect-link verification + key-pin, but accepts the agent's DPoP jkt as the redeemer identity instead of Ed25519; the relay enforces DPoP/audience) and cloudConnectStatusOverRelay (maps agent status -> pending/approved/revoked + authoritative scope/budget). New test: oauth-ws-bridge.test.ts — a simulated vault registers over /ws and answers control messages; full connect -> pending -> approved -> DPoP/audience-bound token flow, plus vault-offline -> 503. All suites green (relay 6, relay-client, vault 7); monorepo build + guard pass.
POST /v/:vaultId/mcp — the resource the cloud agent calls. Verifies, in order: 1. Authorization: DPoP <access_token> present 2. access token signature/issuer + audience == this vault's resource (RFC 8707) 3. token.vault_id == path vaultId 4. DPoP proof for THIS request: htm/htu + ath==hash(token) + jkt==token.cnf.jkt + jti single-use 5. instant-revoke denylist (Rule #7) then forwards to a pluggable McpDataBridge (data-plane HPKE forwarding is P2e-2; default UnavailableMcpBridge fails closed). 401 + WWW-Authenticate (resource_metadata pointer) on any failure. Added RelayServer.revokeAgentAccess (denylist + kill refresh chains). Tests (oauth-mcp-facade.test.ts, full grant -> facade with fake bridges): authorized forward, no-auth 401, AUDIENCE ISOLATION (token for vault A -> 401 at vault B), DPoP REPLAY blocked, ath-required, INSTANT REVOKE effective. Relay: 7 test files green; monorepo build + guard pass.
…ol channel
The relay now forwards an AUTHORIZED MCP request to the vault and returns its
response, completing the relay side of the facade.
- types: add cloud_connect_mcp WS message type.
- relay: default McpDataBridge is now WS-backed (wsMcpForward) — sends the MCP
JSON-RPC + authenticated agent_id/scope/jkt to the vault over /ws and awaits the
correlated result (30s). Consistent with the 'relay sees vanilla MCP metadata'
decision; DCP-aware agents wanting full blindness use the encrypted /relay/request
path instead.
- relay-client: emit cloudConnectControl{kind:'mcp'} for inbound MCP forwards.
Test oauth-mcp-dataplane.test.ts: a simulated vault answers redeem/status/mcp over
real WS; full connect -> token -> /v/:vaultId/mcp -> forwarded -> echoed JSON-RPC
response, with the vault receiving the authenticated agent_id + original MCP body.
REMAINING (final P2 piece): the vault-side MCP handler — process initialize/
tools/list/tools/call for the authenticated agent with scope checks + on-device
consent for sensitive ops (reuse @dcprotocol/agent tool schemas). Relay fully done.
Relay 8 test files green; monorepo build + guard pass.
The final P2 piece: the vault answers MCP for relay-fronted cloud agents.
handleCloudConnectMcp(server, agentId, body) — a thin MCP JSON-RPC shell that
DELEGATES to the vault's own REST endpoints (server.inject), so scope, budget, and
on-device CONSENT are enforced identically to every other agent path:
- initialize / ping / notifications/initialized / tools/list (8 vault tools)
- tools/call -> map tool -> REST endpoint, bound to the authenticated agent's
identity (agent_name from the relay-validated agent_id). Sensitive ops create a
pending consent and return a gate result — never a signature without an on-device tap.
- requires the agent to be active (defense-in-depth alongside the relay denylist).
Wired into the vault's cloudConnectControl{kind:'mcp'} handler over the WS bridge.
Test cloud-connect-mcp.test.ts (real connect flow -> active scoped agent):
initialize + tools/list, vault_get_address (read-only forwards), REVOKED agent
rejected, and — critically — vault_sign_tx returns a consent/scope GATE, never a
signature (#8 doorbell-not-key proven end-to-end through the whole stack).
=== Cloud-Connect P2 COMPLETE ===
Full path works + tested: paste connect-link -> OAuth (PKCE/DPoP/DCR) -> on-device
match-code approval -> audience+DPoP-bound token + rotating refresh -> /v/:vaultId/mcp
facade -> WS forward -> vault MCP -> scope/budget/consent. All 8 security rules
implemented + tested. relay 8 + vault 8 + core 7 + relay-client suites green;
monorepo build, publish guard, 12/12 security regression pass.
…mplete) When the owner revokes a cloud agent, the vault now pushes a cloud_connect_revoke control message to the relay, which denylists the agent + kills its refresh chains (RelayServer.revokeAgentAccess). Previously the vault was authoritative (rejected revoked agents on the data plane) but the relay wasn't notified; now revoke fails fast at the relay edge too. - relay types: cloud_connect_revoke message type. - relay-client: sendCloudConnectRevoke(agentId). - relay: handleWsMessage routes it to revokeAgentAccess. - vault: the /v1/cloud-connect/agents/:id/revoke handler pushes after revoking. Test (oauth-mcp-dataplane): token works -> vault pushes revoke over WS -> next MCP call 401s at the relay. All suites green; build + guard pass.
…-17) Static guards over the security-critical source so a regression fails the gate: 13. approve enforces a mandatory, constant-time match code (#6) 14. relay OAuth PKCE is S256-only, no plain downgrade 15. access tokens bind cnf.jkt (DPoP) + audience (RFC 8707) (#2) 16. DPoP proofs have a jti single-use replay guard (#4) 17. refresh tokens detect reuse -> whole-chain revoke (#3) 17/17 security tests pass.
…nal (OSS standalone) A self-hoster can now run the whole flow without the desktop: dcp cloud-connect link <name> [--scopes ...] [--relay <url>] # issue a connect-link dcp cloud-connect pending # who's waiting (+ match codes) dcp cloud-connect approve <agentId> <matchCode> # verify + approve dcp cloud-connect deny|revoke <agentId> dcp cloud-connect list Storage-direct (same machine as the vault). 'link' loads/creates the vault relay identity from config the same way the server does (vault_id + HPKE + signing keys), signs the connect-link via @dcprotocol/core createConnectLink, and embeds the derived https /v/<vault>/mcp resource URL. Management ops wrap the same storage methods the REST endpoints + tests use. approve enforces the match-code locally. Smoke-tested: link issues a valid dcp_connect_v1_ token + persists; list/pending reflect state; clean exits. Guard passes; vault cloud-connect tests still green.
…e.ai/ChatGPT) Completes the 'paste any URL' surface: browser clients use the standard authorization_code + PKCE flow; headless agents keep using the device-flow. - grant.ts: processAuthorize (redeem connect-link -> open session, jkt unbound) + authorizeStatus (poll approval). authorization_code grant in processToken reuses the tested device-code path (the code IS the approved session id). Browser sessions bind the DPoP key at /token on first use (TOFU) via AuthSessionStore.bindJkt. - relay: GET /oauth/authorize (HTML — paste connect-link, then show the match code + poll approval + redirect to redirect_uri?code&state, with escaped/validated redirect_uri to prevent reflection/XSS) + GET /oauth/authorize/status (JSON poll). /oauth/token accepts grant_type=authorization_code. Tests (oauth-authorize.test.ts): paste-form shown, bad redirect_uri 400, full authorize -> redeem -> match-code -> status approved -> authorization_code token -> facade accepts (not 401), wrong-PKCE rejected. Relay 9 test files green; build + guard pass. (Real Claude.ai/ChatGPT connector handshake validated at P5.)
scripts/demo-cloud-agent.mjs — plays a cloud agent (pure Node crypto, no deps) so you can watch the whole Cloud-Connect flow locally without OpenClaw/Claude.ai: device-flow OAuth (PKCE + DPoP, ES256 via ieee-p1363 + JWK) against a local relay, poll for on-device approval, then real MCP calls (tools/list, vault_get_address) through the per-vault facade. Header documents the 3-terminal prereqs. Smoke-tested: against a running relay with no vault connected, /oauth/connect returns 503 vault_unreachable (NOT 400) — proving the DPoP/PKCE/link-parsing are all valid and accepted by the relay.
Spins up a throwaway vault + relay + vault-server (connected over WS), issues a connect-link, runs the demo agent, auto-approves, shows the agent read the vault's real address, then revokes — and cleans everything up. Verified working end-to-end across real processes (not just in-process tests).
Lets the relay advertise its public https origin (OAuth issuer / MCP resource audience) when run behind a tunnel or proxy, instead of deriving localhost from the Host header. Needed for tunnel-based remote testing and prod deploy. Relay 9 test files green; build + guard pass.
… guard) The CLI used require.main===module to decide whether to autostart on port 8422, but esbuild evaluates that to true inside a bundle — so the relay server fired *inside* the desktop's bundled vault and fought for 8422. Switch to a path-based guard: autostart only when the process entry is a dcp-relay binary or the relay's own dist/src/@dcprotocol/relay path. Verified: 'node dist/index.js' still autostarts; importing under a bundle-like argv does not. Also: demo-cloud-agent sends ngrok-skip-browser-warning so tunnelled API calls return JSON. Relay 9 test files green; guard passes.
…ient
Make the Cloud-Connect MOAT actually "one link": a standard OAuth MCP client
(Hermes/Claude.ai/ChatGPT) connects with ONLY the per-vault MCP URL
(https://relay/v/<vaultId>/mcp --auth oauth) — no connect-link secret. The relay
derives the vault from the RFC 8707 `resource`, asks the vault to open an on-device
approval directly, and the owner approves with a match code. Proven end-to-end
against a real Hermes agent over the internet.
Real-client interop fixes discovered by testing with Hermes:
- Parse application/x-www-form-urlencoded on the relay (RFC 6749 token requests);
previously only JSON → real clients got 415 at /oauth/token.
- DPoP is now PREFERRED-but-OPTIONAL with a Bearer fallback: no current MCP client
implements DPoP. Tokens stay audience-bound, short-lived, scoped, and revocable;
sign/write still require on-device consent (verified: vault_sign_tx returns
requires_consent, never auto-signs). DPoP-bound tokens still require their proof
(no Bearer downgrade).
Security: link-less pairing is gated by an owner-opened "pairing window" (like BT
pairing, default closed) + a per-window rate-limit + mandatory match-code +
inert-until-approved + $0 auto-approve default. The connect-link path (HPKE
key-pin) remains available as optional hardening.
- core: createCloudConnectPairingRequest (link-less pending approval);
cloud_connect_accept_until config.
- relay: requestPairing bridge + cloud_connect_pair control; /oauth/authorize and
/oauth/connect link-less branches; Bearer-or-DPoP facade; form-urlencoded parser.
- vault: pairCloudConnectOverRelay + pairing-window gate/rate-limit;
/v1/cloud-connect/{connect-url,pairing-mode} endpoints; CLI url/open/close.
- tests: +5 (link-less pairing, Bearer fallback, device-flow); 107 relay green.
…reakage) Vault (localhost-only surface): - #1 mandatory service_signature for agent_/vps_ identities (no unsigned bypass) - #2 nonce replay guard for signed local-agent requests - #3 sign_message/sign_x402 now run verifyLocalAgentRequest (identity+scope gate) - #4 desktop/register guarded: unauth owner-replacement blocked (token/passphrase) - #8 CORS tightened to known desktop origins (no arbitrary-localhost wildcard) Relay (public surface — deploy blockers): - #6 vault_id bound to its first signing key (TOFU); different key claiming an existing vault_id is rejected (anti-hijack of routing) - #5 HTTP-fallback /relay/poll + /relay/respond require a signed proof of vault ownership; /respond also verifies the request targets that vault - #7 /v1/invites/register requires the vault to hold a live authenticated WS conn; /v1/mobile/.../online requires a signed ownership proof Legit flows preserved: dcp-client always signs local requests; desktop origins allowlisted (tauri + 1420); vault is WS-connected when it registers invites. Regression tests added (relay-security.test.ts) + security script Tests 18-22. All 582 tests pass; publish-guard + 22/22 security checks green.
…ntrol binding - BUG #1: owner re-registration corrupted the in-memory master key. unlock() returns the SAME buffer it caches as this.masterKey, so zeroize(mk) zeroed the live key (vault reported unlocked but decrypt/sign would fail). Add storage.verifyPassphrase() — verifies on a throwaway copy, never caches/returns the live key, never changes lock state. Owner-register uses it instead of unlock. - #2: relay WS control messages are now bound to the socket's registered vault. cloud_connect_result only resolves a control sent to THAT vault; cloud_connect_revoke only revokes agents owned by that vault (agent->vault map); pairing_result requires the socket vault to match the claim's vault. - #3: removed the unauthenticated HTTP /v1/pairing-claims/:claimId/resolve route (and handler) — approvals come over the authenticated WS pairing_result; the HTTP route was unused and a spoofing vector. - #4: registerVpsInviteWithRelay now awaits the relay WS connection and retries on 401, so invite registration can't flakily fail when the WS is (re)connecting. Regression tests: core verifyPassphrase (no corruption / no lock-state change), relay resolve-route-gone. Security script Tests 23-25. All 584 tests pass; 25/25 security checks; publish-guard green.
…d revoke - WS `response` now verifies the original request's target vault before storing/ notifying; a response from the wrong vault gets RELAY_UNAUTHORIZED (closes the cross-vault response injection on the WS path, mirroring the HTTP /relay/respond fix). - cloud_connect_revoke + revokeAgentAccess are vault-scoped: the instant-revoke denylist and refresh-chain revoke key on (vault_id, agent_id). A vault can revoke its OWN agent even if the relay lost the fresh agentVault cache, but cannot revoke another vault's agent. isAgentRevoked keeps a global back-compat path for internal/test callers. - RefreshTokenStore.revokeByAgent(agentId, vaultId?) supports scoped revocation. - Regressions: cross-vault WS response injection rejected; revoke-with-missing-cache still works scoped to the owning vault. Security script updated for new revoke logic. All 586 tests pass; 25/25 security checks; publish-guard green. Reviewed + verified.
…+ pair-spam Found via my own systematic relay-surface audit (every HTTP route + WS case): - #6 WS `unregister` now only unregisters the socket's OWN vault (getVaultId match). Previously any socket could evict any vault by id — a cross-tenant takedown of an online vault. (Same class as response/pairing_result/revoke; this one was missed.) - #1 /v1/devices/push-token now requires a signed proof of vault ownership, so a stranger who knows a vault_id can't redirect the owner's approval pushes to their device (approval phishing + notification DoS). Matches the /online route. - #5 relay-side per-vault rate-limit on link-less pair forwarding, so a known vault_id can't be spammed with on-device approval prompts (the vault also gates on its pairing window + own limit). Regressions: cross-tenant unregister rejected; push-token rejects unsigned. Security script Tests 26-27. All 588 tests pass; 27/27 security checks; publish-guard green. Still OPEN (parked mobile flow — needs a decision, not in this commit): OSS relay mobile-pairing /approve key-substitution + /deny DoS + invite flooding. These parallel the authenticated premium-cloud mobile backend; fixing them depends on the mobile signing contract.
Realign published npm with source and document Cloud Connect. Packages: bump core, vault, agent, client, relay, relay-client to 3.0.0 with ^3.0.0 interdependency ranges. Earlier 2.0.x npm artifacts had drifted from source (same numbers, different content); the lockstep republish fixes that. No API removals — 2.x callers keep working. Core: - keychain master-key store now deletes any existing entry before writing, so a vault recreated at the same path cannot inherit a stale key (prevents a class of recovery-phrase mismatches) - owner-mode reads (the desktop reading its own data) are no longer logged as agent activity - agent connection display-name update support Docs: - document Cloud Connect (paste-a-link) in README and ARCHITECTURE, and the relay MCP facade / OAuth bridge in the relay README - per-package LICENSE files (Apache-2.0) - CHANGELOG 3.0.0 entry with migration notes
| }); | ||
|
|
||
| function resourceFor(issuer: string, vaultId: string): string { | ||
| return `${issuer.replace(/\/+$/, '')}/v/${vaultId}/mcp`; |
| } | ||
|
|
||
| export function authorizationServerMetadata(opts: AsMetadataOptions): Record<string, unknown> { | ||
| const base = opts.issuer.replace(/\/+$/, ''); |
| * publicUrl; otherwise derives from the request (dev/local). No trailing slash. | ||
| */ | ||
| private baseUrl(request: FastifyRequest): string { | ||
| if (this.config.publicUrl) return this.config.publicUrl.replace(/\/+$/, ''); |
Comment on lines
+561
to
+564
| this.server.get('/.well-known/oauth-authorization-server', async (request, reply) => { | ||
| reply.header('cache-control', 'public, max-age=300'); | ||
| return authorizationServerMetadata({ issuer: this.baseUrl(request) }); | ||
| }); |
Comment on lines
+768
to
+780
| return shell( | ||
| `<h2>Approve on your DCP device</h2>` + | ||
| `<p class="muted">Confirm this match code matches the one shown on your device, then approve there.</p>` + | ||
| `<div class="code">${esc(result.matchCode)}</div>` + | ||
| `<p class="muted" id="s">Waiting for approval…</p>` + | ||
| `<script>(function(){var c=${JSON.stringify(cfg)};` + | ||
| `function poll(){fetch('/oauth/authorize/status?session='+encodeURIComponent(c.sessionId))` + | ||
| `.then(function(r){return r.json()}).then(function(d){` + | ||
| `if(d.status==='approved'){var u=new URL(c.redirectUri);u.searchParams.set('code',d.code);` + | ||
| `if(c.state)u.searchParams.set('state',c.state);location.href=u.toString();return;}` + | ||
| `if(d.status==='denied'||d.status==='expired'){document.getElementById('s').textContent='Connection '+d.status+'.';return;}` + | ||
| `setTimeout(poll,2000);}).catch(function(){setTimeout(poll,3000)})}poll();})();</script>` | ||
| ); |
Comment on lines
+786
to
+791
| async (request, reply) => { | ||
| reply.header('cache-control', 'no-store'); | ||
| if (!request.query.session) return { status: 'unknown' }; | ||
| const deps = await this.grantDeps(request); | ||
| return authorizeStatus(deps, request.query.session); | ||
| } |
Comment on lines
+798
to
+875
| async (request, reply) => { | ||
| const vaultId = request.params.vaultId; | ||
| const base = this.baseUrl(request); | ||
| const resource = `${base}/v/${vaultId}/mcp`; | ||
| const metadataUrl = `${base}/.well-known/oauth-protected-resource/v/${vaultId}/mcp`; | ||
| const unauthorized = (error: string, desc: string) => { | ||
| reply | ||
| .status(401) | ||
| .header( | ||
| 'WWW-Authenticate', | ||
| wwwAuthenticateChallenge({ resourceMetadataUrl: metadataUrl, error, errorDescription: desc }) | ||
| ); | ||
| return { error, error_description: desc }; | ||
| }; | ||
|
|
||
| // 1. Accept either a DPoP-scheme or Bearer-scheme access token. DPoP is | ||
| // preferred (sender-constrained); Bearer is accepted for clients that | ||
| // don't implement DPoP (the current MCP-client reality). | ||
| const authz = (request.headers['authorization'] as string) || ''; | ||
| const m = /^(DPoP|Bearer) (.+)$/.exec(authz); | ||
| if (!m) return unauthorized('invalid_token', 'A DPoP or Bearer access token is required'); | ||
| const accessToken = m[2].trim(); | ||
| const proof = (request.headers['dpop'] as string) || ''; | ||
|
|
||
| // 2. If a DPoP proof is present, verify it (bound to this request + token) | ||
| // to derive the key thumbprint. A DPoP-BOUND token (cnf.jkt) without a | ||
| // proof is rejected inside verifyAccessToken (no Bearer downgrade). | ||
| let jkt: string | undefined; | ||
| if (proof) { | ||
| try { | ||
| const d = await verifyDpopProof(proof, { | ||
| method: 'POST', | ||
| url: resource, | ||
| jtiGuard: this.oauthJti, | ||
| accessToken, | ||
| }); | ||
| jkt = d.jkt; | ||
| } catch { | ||
| return unauthorized('invalid_token', 'DPoP proof invalid'); | ||
| } | ||
| } | ||
|
|
||
| // 3. Verify the access token (signature, issuer, audience == this vault). | ||
| // When the token is DPoP-bound, this also enforces cnf.jkt === proof key. | ||
| const keys = await this.getOAuthKeys(); | ||
| let claims; | ||
| try { | ||
| claims = await verifyAccessToken({ | ||
| keys, | ||
| token: accessToken, | ||
| issuer: base, | ||
| expectedAudience: resource, | ||
| expectedJkt: jkt, | ||
| }); | ||
| } catch { | ||
| return unauthorized('invalid_token', 'Access token invalid or for a different resource'); | ||
| } | ||
| if (claims.vault_id !== vaultId) { | ||
| return unauthorized('invalid_token', 'Token is not valid for this vault'); | ||
| } | ||
|
|
||
| // 4. Instant-revoke denylist (Rule #7). | ||
| if (this.isAgentRevoked(vaultId, claims.sub)) { | ||
| return unauthorized('invalid_token', 'Access has been revoked'); | ||
| } | ||
|
|
||
| // 5. Authorized — bridge to the vault. The vault still enforces per-action | ||
| // on-device consent for anything sensitive. | ||
| const result = await this.mcpBridge.forward({ | ||
| vaultId, | ||
| agentId: claims.sub, | ||
| scope: claims.scope, | ||
| jkt: jkt || '', | ||
| body: request.body, | ||
| }); | ||
| reply.status(result.status); | ||
| return result.body as unknown; | ||
| } |
Comment on lines
+3293
to
+3372
| const payload = verifyConnectLinkWithKey(token, identity.signingKeyPair.publicKey); | ||
| if (!payload) { | ||
| return reply.status(400).send({ | ||
| error: { | ||
| code: 'INVALID_CONNECT_LINK', | ||
| message: 'Connect link is invalid, tampered, or expired', | ||
| }, | ||
| }); | ||
| } | ||
| if (payload.vault_id !== identity.vaultId) { | ||
| return reply.status(400).send({ | ||
| error: { code: 'WRONG_VAULT', message: 'Connect link is for a different vault' }, | ||
| }); | ||
| } | ||
|
|
||
| // Rule #1: the pinned HPKE key in the link must match this vault's real key. | ||
| const actualHpke = identity.hpkeKeyPair.publicKey.toString('base64'); | ||
| if ( | ||
| payload.vault_hpke_public_key !== actualHpke || | ||
| payload.vault_hpke_fingerprint !== hpkeFingerprint(actualHpke) | ||
| ) { | ||
| return reply.status(400).send({ | ||
| error: { code: 'KEY_PIN_MISMATCH', message: 'Pinned vault key does not match' }, | ||
| }); | ||
| } | ||
|
|
||
| const matchCode = generateMatchCode(); | ||
| const { source_ip, source_host } = deriveSourceFacts(request); | ||
|
|
||
| const result = storage.redeemCloudConnectLink({ | ||
| link_id: payload.link_id, | ||
| secret: payload.secret, | ||
| redeemer_public_key: agentPublicKey, | ||
| match_code: matchCode, | ||
| mode: 'mcp', | ||
| source_ip, | ||
| source_host, | ||
| }); | ||
|
|
||
| if (!result.ok) { | ||
| const code = result.reason || 'REDEEM_FAILED'; | ||
| const status = code === 'LINK_NOT_FOUND' ? 404 : 400; | ||
| return reply.status(status).send({ | ||
| error: { code, message: `Connect link could not be redeemed (${code})` }, | ||
| }); | ||
| } | ||
|
|
||
| return { | ||
| status: 'pending_approval', | ||
| agent_id: result.agent_id, | ||
| match_code: result.match_code, | ||
| vault_id: identity.vaultId, | ||
| vault_hpke_public_key: actualHpke, | ||
| vault_hpke_fingerprint: hpkeFingerprint(actualHpke), | ||
| message: | ||
| 'Approve this connection on your DCP device. Confirm the match code shown here matches the one on your device.', | ||
| }; | ||
| } | ||
| ); | ||
|
|
||
| /** | ||
| * Agent status poll (authed by the connect-link secret). Returns the current | ||
| * state; once approved, mints the session token EXACTLY ONCE (never stored as | ||
| * plaintext) and returns the vault keys + scopes/budget for the agent. | ||
| */ | ||
| server.post<{ Body: { connect_link?: string } }>( | ||
| '/v1/cloud-connect/status', | ||
| async (request, reply) => { | ||
| const token = (request.body?.connect_link || '').trim(); | ||
| if (!token || !isConnectLink(token)) { | ||
| return reply.status(400).send({ | ||
| error: { code: 'INVALID_CONNECT_LINK', message: 'A valid connect_link is required' }, | ||
| }); | ||
| } | ||
|
|
||
| // Decode (no expiry gate — the agent may legitimately poll post-approval). | ||
| const decoded = decodeConnectLink(token); | ||
| if (!decoded) { | ||
| return reply.status(400).send({ | ||
| error: { code: 'INVALID_CONNECT_LINK', message: 'Connect link is malformed' }, |
Comment on lines
+3382
to
+3454
|
|
||
| const linkId = decoded.payload.link_id; | ||
| // Authenticate the poller via the single-use secret (constant-time compare). | ||
| if (!storage.verifyCloudConnectSecret(linkId, decoded.payload.secret)) { | ||
| return reply.status(403).send({ | ||
| error: { code: 'UNAUTHORIZED', message: 'Connect link secret does not match' }, | ||
| }); | ||
| } | ||
|
|
||
| // Lazily expire stale links (bootstrap TTL + approval grace) before acting. | ||
| storage.expireStaleCloudConnectLinks(); | ||
| const link = storage.getCloudConnectLink(linkId); | ||
| if (!link) { | ||
| return reply.status(404).send({ | ||
| error: { code: 'LINK_NOT_FOUND', message: 'Connect link not found' }, | ||
| }); | ||
| } | ||
|
|
||
| if (link.status === 'revoked') return { status: 'revoked' }; | ||
| if (link.status === 'expired') return { status: 'expired' }; | ||
| if (link.status === 'pending') return { status: 'pending_redeem' }; | ||
| if (link.status === 'redeemed') { | ||
| return { status: 'pending_approval', match_code: link.match_code }; | ||
| } | ||
|
|
||
| // status === 'consumed' (owner approved) -> mint token once. | ||
| const agent = storage.getAgentConnection(link.agent_id); | ||
| if (!agent || agent.status !== 'active') { | ||
| return { status: 'revoked' }; | ||
| } | ||
| if (agent.token_hash) { | ||
| return { status: 'approved', token_already_issued: true, agent_id: link.agent_id }; | ||
| } | ||
|
|
||
| const sessionToken = crypto.randomBytes(32).toString('base64url'); | ||
| const sessionTokenHash = crypto.createHash('sha256').update(sessionToken).digest('hex'); | ||
| storage.markAgentPaired(link.agent_id, sessionTokenHash, link.redeemer_public_key); | ||
|
|
||
| const relayUrl = identity.relayUrl || DEFAULT_RELAY_URL || CORE_DEFAULT_RELAY_URL || ''; | ||
| return { | ||
| status: 'approved', | ||
| agent_id: link.agent_id, | ||
| session_token: sessionToken, | ||
| vault_id: identity.vaultId, | ||
| vault_hpke_public_key: identity.hpkeKeyPair.publicKey.toString('base64'), | ||
| vault_signing_public_key: identity.signingKeyPair.publicKey.toString('base64'), | ||
| relay_url: relayUrl, | ||
| scopes: link.permission_scopes, | ||
| budget: link.budget, | ||
| }; | ||
| } | ||
| ); | ||
|
|
||
| /** List connect-links awaiting on-device approval (owner only). Rule #6 facts. */ | ||
| /** | ||
| * The universal connect URL for the paste-URL flow: the per-vault MCP endpoint a | ||
| * user pastes into any OAuth MCP client (Hermes/Claude.ai/ChatGPT). Derived from | ||
| * the relay's base URL (ws[s] → http[s]); the vault id routes to this vault. | ||
| */ | ||
| server.get('/v1/cloud-connect/connect-url', async (request, reply) => { | ||
| if (!isOwnerRequest(request)) { | ||
| return reply.status(403).send({ | ||
| error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, | ||
| }); | ||
| } | ||
| const identity = await ensureRelayIdentity(); | ||
| const wsUrl = identity.relayUrl || DEFAULT_RELAY_URL || CORE_DEFAULT_RELAY_URL || ''; | ||
| const httpsBase = wsUrl | ||
| .replace(/^wss:/i, 'https:') | ||
| .replace(/^ws:/i, 'http:') | ||
| .replace(/\/+$/, '') | ||
| .replace(/\/ws$/i, ''); | ||
| return { |
- core/budget: reject prototype-polluting currency keys (__proto__, constructor, prototype) in setLimit (CWE-915) - telegram: escapeMarkdown now also escapes backslash, so a literal '\' in input cannot smuggle MarkdownV2 control sequences (incomplete sanitization)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lockstep 3.0.0 across all @dcprotocol/* packages, realigning published npm with source. Adds Cloud Connect docs (README + ARCHITECTURE + relay), per-package LICENSE files, keychain stale-key fix, and CHANGELOG migration notes. No API removals; 2.x callers keep working.