Skip to content

release: v3.0.0 lockstep major + Cloud Connect#29

Merged
1lystore merged 25 commits into
mainfrom
feat/cloud-connect
Jun 15, 2026
Merged

release: v3.0.0 lockstep major + Cloud Connect#29
1lystore merged 25 commits into
mainfrom
feat/cloud-connect

Conversation

@1lystore

Copy link
Copy Markdown
Owner

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.

1lystore added 24 commits June 13, 2026 06:52
…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(/\/+$/, '');
Comment thread packages/dcp-relay/src/oauth/store.ts Dismissed
* 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)
@1lystore 1lystore merged commit 90eaaf0 into main Jun 15, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants