diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7dec8a0..3adaaaa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -115,6 +115,38 @@ remote VPS The relay routes encrypted data. It is not supposed to see plaintext secrets, transaction payloads, private keys, or credential values. +## Cloud Connect Path + +For agents you do not host (Claude.ai, ChatGPT, or hosted OpenClaw/Hermes), there is no +sidecar to install. Instead the vault issues a one-time **connect link** that the user pastes +into the cloud agent, and the relay exposes an **MCP facade** the agent speaks to directly. + +```text +cloud agent (Claude.ai / ChatGPT / hosted) +┌────────────────────────┐ +│ paste: dcp_connect_v1_… │ one-time, ≤10 min, key-pinned, no authority +└───────────┬────────────┘ + │ MCP over HTTPS (OAuth) + ▼ +┌────────────────────────┐ encrypted envelopes ┌────────────────────┐ +│ dcp-relay (MCP facade │ ───────────────────────────▶ │ local DCP vault │ +│ + OAuth bridge) │ ◀─────────────────────────── │ Desktop approves │ +└────────────────────────┘ └────────────────────┘ +``` + +Trust model (see `packages/dcp-core/src/connect-link.ts`): + +- The link **pins the vault's HPKE public key + fingerprint** (out-of-band trust anchor), so a + hostile relay cannot MITM the end-to-end channel. +- The link is **single-use and short-lived** — a bootstrap only, never a standing credential. It + is exchanged for a vault-issued session token **only after the user approves on-device**. +- The link carries **no permission authority**. Scopes, budgets, and revocation live only in the + vault policy DB; the embedded `agent_name` is for display. +- The payload is Ed25519-signed by the vault so the pinned key and identifiers are tamper-evident. + +As with the VPS path, the relay routes encrypted envelopes only — it does not see plaintext +secrets, transaction payloads, private keys, or credential values. + ## Telegram Approval Path Telegram is a second approval surface, not a vault. diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e61f7..9070122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ## Unreleased +## 3.0.0 — 2026-06-15 + +Major, lockstep release of all published `@dcprotocol/*` packages (`core`, `vault`, +`agent`, `client`, `relay`, `relay-client`) to a single `3.0.0`. The version jump +realigns published npm content with the current source — earlier `2.0.x` npm +artifacts had drifted from the repo (same numbers, stale content), which could +leave downloaded apps calling symbols that were not in the published build. + +### Added +- `core`: agent connection display-name update support (`updateAgentConnection({ name })`) + and the vault `PATCH` agent endpoint accepts an optional `name` (display-only rename). + +### Fixed +- `core`: keychain master-key storage now deletes any existing entry before writing, + so a vault recreated at the same path can never inherit a stale key (prevents a + class of recovery-phrase mismatches). +- `core`/`vault`: owner-mode reads (the desktop reading its own data) no longer + pollute the audit log as agent activity. + +### Migration notes +- Bump all `@dcprotocol/*` dependencies to `^3.0.0` together; mixing `2.x` and `3.x` + across these packages is unsupported. +- No API removals. Code written against `2.x` continues to work; the major bump + reflects the lockstep republish, not breaking signatures. + ## 2.0.4 — 2026-05-20 ### Fixed diff --git a/README.md b/README.md index 3841aee..d01d401 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,20 @@ For OpenClaw, start a fresh chat/session. For Hermes, run: /reload-mcp ``` +### Cloud Agent (paste a link) + +For agents you do not host — Claude.ai, ChatGPT, or hosted OpenClaw/Hermes — there is nothing to +install. In DCP Desktop, generate a connect link and paste it into the agent: + +```text +dcp_connect_v1_... +``` + +The link is **single-use**, expires in ~10 minutes, and carries **no permissions**. It pins your +vault's public key so the relay cannot impersonate your vault. The agent reaches your on-device +vault through the DCP relay (an end-to-end-encrypted MCP facade), and **you approve the connection +on your device** before anything is granted. Scopes, budgets, and revocation stay in your vault. + ### CLI Use the CLI to create and manage vault data: diff --git a/packages/dcp-agent/LICENSE b/packages/dcp-agent/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-agent/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-agent/package.json b/packages/dcp-agent/package.json index 8e84d19..6df2322 100644 --- a/packages/dcp-agent/package.json +++ b/packages/dcp-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/agent", - "version": "2.0.10", + "version": "3.0.0", "description": "DCP Agent - Lightweight binary for connecting AI agents to vaults via pairing grants", "type": "module", "main": "dist/index.js", @@ -55,8 +55,8 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { - "@dcprotocol/client": "^2.0.1", - "@dcprotocol/core": "^2.0.1", + "@dcprotocol/client": "^3.0.0", + "@dcprotocol/core": "^3.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "@noble/curves": "^1.4.0", "@solana/web3.js": "^1.98.0", diff --git a/packages/dcp-client/LICENSE b/packages/dcp-client/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-client/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-client/package.json b/packages/dcp-client/package.json index a3de4f7..ab3489f 100644 --- a/packages/dcp-client/package.json +++ b/packages/dcp-client/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/client", - "version": "2.0.1", + "version": "3.0.0", "description": "Internal runtime client for DCP agent and vault packages", "main": "dist/index.js", "module": "dist/index.mjs", @@ -47,7 +47,7 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { - "@dcprotocol/core": "^2.0.0", + "@dcprotocol/core": "^3.0.0", "@hpke/core": "^1.6.0", "@hpke/chacha20poly1305": "^1.6.0", "@hpke/dhkem-x25519": "^1.6.0", diff --git a/packages/dcp-core/LICENSE b/packages/dcp-core/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-core/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-core/package.json b/packages/dcp-core/package.json index a9fb445..7e8abdb 100644 --- a/packages/dcp-core/package.json +++ b/packages/dcp-core/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/core", - "version": "2.0.1", + "version": "3.0.0", "description": "Core encryption, wallet, storage, and policy engine for DCP", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/packages/dcp-core/src/budget.ts b/packages/dcp-core/src/budget.ts index c1c5a45..cf3279a 100644 --- a/packages/dcp-core/src/budget.ts +++ b/packages/dcp-core/src/budget.ts @@ -107,6 +107,13 @@ export interface VaultConfig { relay_signing_public_key?: string; relay_signing_private_key?: string; relay_pairing_token?: string; + /** + * Cloud-Connect "pairing window" (paste-URL flow): epoch-ms until which the vault + * will open on-device approvals for link-less connections. 0/absent = closed. + * The owner opens a short window (like Bluetooth pairing); link-less requests + * outside it are refused. + */ + cloud_connect_accept_until?: number; } /** Default vault configuration */ @@ -147,6 +154,7 @@ function deepCloneConfig(config: VaultConfig): VaultConfig { relay_signing_public_key: config.relay_signing_public_key, relay_signing_private_key: config.relay_signing_private_key, relay_pairing_token: config.relay_pairing_token, + cloud_connect_accept_until: config.cloud_connect_accept_until, }; } @@ -251,6 +259,11 @@ export class BudgetEngine { if (amount < 0) { throw new VaultError('INTERNAL_ERROR', 'Budget limit cannot be negative'); } + // Guard against prototype-polluting currency keys (defensive — currency is a + // free-form string). + if (currency === '__proto__' || currency === 'constructor' || currency === 'prototype') { + throw new VaultError('INTERNAL_ERROR', 'Invalid currency'); + } this.config[type][currency] = amount; this.saveConfig(); diff --git a/packages/dcp-core/src/connect-link.ts b/packages/dcp-core/src/connect-link.ts new file mode 100644 index 0000000..9159ef2 --- /dev/null +++ b/packages/dcp-core/src/connect-link.ts @@ -0,0 +1,287 @@ +/** + * Cloud-Connect Link (Cloud Agent Connect feature) + * + * A one-time, key-pinned bootstrap credential that a user pastes into a cloud + * agent (OpenClaw / Hermes / Claude.ai / ChatGPT) to connect it to their + * on-device vault. Encoded as: dcp_connect_v1_ + * + * Security design (see docs/cloud-connect-prd.md §4): + * - The link PINS the vault's HPKE public key + fingerprint (out-of-band trust + * anchor) so a hostile relay cannot MITM the end-to-end channel. (Rule #1) + * - The link is a SINGLE-USE, short-lived (<=10 min) bootstrap only — never a + * standing credential. It is exchanged for a vault-issued session token only + * AFTER the user approves on-device. (Rule #8 — inert until human-approved) + * - Like the pairing grant, the link carries NO permission authority. Scopes + * and budget live ONLY in the vault policy DB; the link's `agent_name` is for + * display. The vault never trusts link-embedded strings as authority. (Rule #6) + * - The payload is signed by the vault's Ed25519 signing key so the pinned key + * and identifiers are tamper-evident. + */ + +import { randomBytes, randomUUID, createHash, randomInt } from 'crypto'; +import { signMessage, verifySignature, canonicalJson } from './crypto.js'; +import { AgentConnectionMode } from './types.js'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** Connect-link token prefix */ +export const CONNECT_LINK_PREFIX = 'dcp_connect_v1_'; + +/** + * Default TTL for connect-links: 10 minutes. + * Connect-links are bootstrap-only and MUST be short-lived (PRD §2, §4). + */ +export const DEFAULT_CONNECT_LINK_TTL_MS = 10 * 60 * 1000; + +/** Number of random bytes in the single-use bootstrap secret. */ +export const CONNECT_LINK_SECRET_BYTES = 32; + +/** + * Match-code alphabet — unambiguous (no 0/O/1/I/L) so the user can reliably + * compare the code shown on the agent side against the code shown on-device. + */ +const MATCH_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'; + +/** Match-code length (characters). */ +export const MATCH_CODE_LENGTH = 6; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * The signed portion of a connect-link. + * + * Carries identifiers + the PINNED vault HPKE key, but NO permission authority + * (scopes/budget live only in the vault policy DB — mirrors SignedPairingGrant). + */ +export interface ConnectLinkPayload { + version: 1; + /** Connect-link id (also the DB row key). */ + link_id: string; + /** Vault this link targets. */ + vault_id: string; + /** Pre-allocated agent id this link will create/bind on redemption. */ + agent_id: string; + /** Human display name for the agent (NOT authority). */ + agent_name: string; + /** Connection mode (cloud agents use 'mcp'). */ + mode: AgentConnectionMode; + /** + * Where the agent connects (the relay per-vault MCP URL). May be '' in + * vault-only flows where the relay facade is not yet configured. + */ + relay_url: string; + /** PINNED vault HPKE public key (base64) — out-of-band trust anchor (Rule #1). */ + vault_hpke_public_key: string; + /** SHA-256 fingerprint (hex) of the pinned HPKE key, for display + pin checks. */ + vault_hpke_fingerprint: string; + /** Vault Ed25519 signing public key (base64) — verifies this payload. */ + vault_signing_public_key: string; + /** The single-use bootstrap secret (high entropy). Stored hashed by the vault. */ + secret: string; + created_at: string; + expires_at: string; +} + +/** The full encoded connect-link envelope. */ +export interface EncodedConnectLink { + payload: ConnectLinkPayload; + /** Base64 Ed25519 signature over canonicalJson(payload). */ + signature: string; +} + +/** Input for issuing a connect-link. */ +export interface CreateConnectLinkInput { + vault_id: string; + agent_id: string; + agent_name: string; + mode: AgentConnectionMode; + relay_url: string; + vault_hpke_public_key: string; + vault_signing_public_key: string; + /** Provide to override the generated id (otherwise auto-generated). */ + link_id?: string; + /** Provide to override the generated secret (otherwise auto-generated). */ + secret?: string; + ttl_ms?: number; +} + +/** Result of issuing a connect-link. */ +export interface CreateConnectLinkResult { + /** The pasteable token: dcp_connect_v1_. */ + token: string; + link_id: string; + agent_id: string; + /** The single-use secret (also embedded in the token; returned for hashing). */ + secret: string; + vault_hpke_fingerprint: string; + created_at: string; + expires_at: string; +} + +// ============================================================================ +// Fingerprint + match-code helpers +// ============================================================================ + +/** + * Compute the SHA-256 fingerprint (hex) of an HPKE public key. + * Hashes the raw key bytes (base64-decoded) so the fingerprint pins the key, + * not its encoding. + */ +export function hpkeFingerprint(hpkePublicKeyBase64: string): string { + const keyBytes = Buffer.from(hpkePublicKeyBase64, 'base64'); + return createHash('sha256').update(keyBytes).digest('hex'); +} + +/** + * Format a fingerprint for human display (grouped, first 16 hex chars). + * e.g. "a1b2 c3d4 e5f6 0718" + */ +export function formatFingerprint(fingerprintHex: string): string { + return (fingerprintHex.slice(0, 16).match(/.{1,4}/g) || []).join(' '); +} + +/** + * Generate a phishing-resistant match-code using a CSPRNG (crypto.randomInt). + * Shown on BOTH the agent side and the on-device approval so the user confirms + * they match before approving (anti cross-device phishing — Rule #6). + */ +export function generateMatchCode(length: number = MATCH_CODE_LENGTH): string { + let code = ''; + for (let i = 0; i < length; i++) { + code += MATCH_CODE_ALPHABET[randomInt(MATCH_CODE_ALPHABET.length)]; + } + return code; +} + +/** Hash the bootstrap secret for at-rest storage (never store plaintext). */ +export function hashConnectLinkSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + +// ============================================================================ +// Connect-link creation +// ============================================================================ + +/** + * Create a signed, single-use connect-link. + * + * @param input - Link parameters (identifiers + pinned keys). NO scopes/budget. + * @param signingPrivateKey - 64-byte Ed25519 vault signing private key. + */ +export function createConnectLink( + input: CreateConnectLinkInput, + signingPrivateKey: Buffer +): CreateConnectLinkResult { + const now = new Date(); + const ttl = input.ttl_ms ?? DEFAULT_CONNECT_LINK_TTL_MS; + const expiresAt = new Date(now.getTime() + ttl); + + const linkId = input.link_id || `cl_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const secret = input.secret || randomBytes(CONNECT_LINK_SECRET_BYTES).toString('base64url'); + const fingerprint = hpkeFingerprint(input.vault_hpke_public_key); + + const payload: ConnectLinkPayload = { + version: 1, + link_id: linkId, + vault_id: input.vault_id, + agent_id: input.agent_id, + agent_name: input.agent_name, + mode: input.mode, + relay_url: input.relay_url, + vault_hpke_public_key: input.vault_hpke_public_key, + vault_hpke_fingerprint: fingerprint, + vault_signing_public_key: input.vault_signing_public_key, + secret, + created_at: now.toISOString(), + expires_at: expiresAt.toISOString(), + }; + + const canonicalPayload = canonicalJson(payload); + const signature = signMessage(Buffer.from(canonicalPayload, 'utf8'), signingPrivateKey); + + const encoded: EncodedConnectLink = { + payload, + signature: signature.toString('base64'), + }; + + const base64url = Buffer.from(JSON.stringify(encoded), 'utf8').toString('base64url'); + + return { + token: CONNECT_LINK_PREFIX + base64url, + link_id: linkId, + agent_id: input.agent_id, + secret, + vault_hpke_fingerprint: fingerprint, + created_at: payload.created_at, + expires_at: payload.expires_at, + }; +} + +// ============================================================================ +// Connect-link decoding + verification +// ============================================================================ + +/** + * Decode a connect-link token WITHOUT verifying the signature. + * @returns The envelope, or null if the format is invalid. + */ +export function decodeConnectLink(token: string): EncodedConnectLink | null { + if (!token.startsWith(CONNECT_LINK_PREFIX)) { + return null; + } + try { + const base64url = token.slice(CONNECT_LINK_PREFIX.length); + const jsonStr = Buffer.from(base64url, 'base64url').toString('utf8'); + const decoded = JSON.parse(jsonStr) as EncodedConnectLink; + + if (!decoded.payload || !decoded.signature) { + return null; + } + if (decoded.payload.version !== 1) { + return null; + } + return decoded; + } catch { + return null; + } +} + +/** + * Verify a connect-link against a KNOWN vault signing public key. + * + * Use the vault's own key (not the embedded one) so a tampered link can't + * self-certify. Checks signature + expiry. + * + * @returns The verified payload, or null if invalid/expired. + */ +export function verifyConnectLinkWithKey( + token: string, + vaultSigningPublicKey: Buffer +): ConnectLinkPayload | null { + const decoded = decodeConnectLink(token); + if (!decoded) { + return null; + } + try { + const signature = Buffer.from(decoded.signature, 'base64'); + const message = Buffer.from(canonicalJson(decoded.payload), 'utf8'); + if (!verifySignature(message, signature, vaultSigningPublicKey)) { + return null; + } + if (new Date(decoded.payload.expires_at) < new Date()) { + return null; + } + return decoded.payload; + } catch { + return null; + } +} + +/** Check whether a token looks like a connect-link. */ +export function isConnectLink(token: string): boolean { + return token.startsWith(CONNECT_LINK_PREFIX); +} diff --git a/packages/dcp-core/src/index.ts b/packages/dcp-core/src/index.ts index f2f0238..9199f04 100644 --- a/packages/dcp-core/src/index.ts +++ b/packages/dcp-core/src/index.ts @@ -138,6 +138,26 @@ export { type CreateSessionTokenInput, } from './pairing.js'; +// Cloud-Connect Links (Cloud Agent Connect — paste-a-URL + OAuth feature) +export { + createConnectLink, + decodeConnectLink, + verifyConnectLinkWithKey, + isConnectLink, + hpkeFingerprint, + formatFingerprint, + generateMatchCode, + hashConnectLinkSecret, + CONNECT_LINK_PREFIX, + DEFAULT_CONNECT_LINK_TTL_MS, + CONNECT_LINK_SECRET_BYTES, + MATCH_CODE_LENGTH, + type ConnectLinkPayload, + type EncodedConnectLink, + type CreateConnectLinkInput, + type CreateConnectLinkResult, +} from './connect-link.js'; + // Re-export Telegram types explicitly for convenience export type { TelegramConfig, diff --git a/packages/dcp-core/src/storage.ts b/packages/dcp-core/src/storage.ts index a6024d1..59996c3 100644 --- a/packages/dcp-core/src/storage.ts +++ b/packages/dcp-core/src/storage.ts @@ -31,7 +31,7 @@ async function getKeytar() { } import * as path from 'path'; import * as os from 'os'; -import { randomBytes, randomUUID, createHash, randomInt } from 'crypto'; +import { randomBytes, randomUUID, createHash, randomInt, timingSafeEqual } from 'crypto'; import { VaultRecord, AgentSession, @@ -52,6 +52,8 @@ import { AgentConnection, AgentConnectionMode, AgentConnectionTier, + CloudConnectLink, + CloudConnectLinkStatus, TelegramConfig, CreateTelegramConfigInput, TelegramPairingCode, @@ -87,6 +89,13 @@ const KEY_FILE_MODE = 0o600; /** Non-secret marker used to distinguish a provisioned vault from bare schema tables */ const INIT_MARKER_FILE = 'vault.initialized'; +/** + * Grace window in which a REDEEMED Cloud-Connect link may still be approved. + * After this, the redeemed-but-unapproved link expires so its match-code can't + * be approved indefinitely (anti stale-approval; Rule #4 short-expiry). + */ +export const CLOUD_CONNECT_APPROVAL_GRACE_MS = 15 * 60 * 1000; + function generateId(): string { if (typeof randomUUID === 'function') { return randomUUID(); @@ -291,6 +300,32 @@ export class VaultStorage { used_at TEXT ); + -- Cloud-Connect links (Cloud Agent Connect: one-time, key-pinned bootstrap) + CREATE TABLE IF NOT EXISTS cloud_connect_links ( + link_id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + permission_scopes TEXT NOT NULL, + budget_daily REAL NOT NULL DEFAULT 0, + budget_currency TEXT NOT NULL DEFAULT 'USDC', + budget_auto_approve_under REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + match_code TEXT, + redeemer_public_key TEXT, + source_ip TEXT, + source_host TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + redeemed_at TEXT, + consumed_at TEXT, + revoked_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_status ON cloud_connect_links(status); + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_agent ON cloud_connect_links(agent_id); + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_expires ON cloud_connect_links(expires_at); + CREATE INDEX IF NOT EXISTS idx_pairing_tokens_expires ON pairing_tokens(expires_at); -- Telegram configuration (protocol spec section 15) @@ -388,6 +423,40 @@ export class VaultStorage { `); } + // Ensure cloud_connect_links table exists (for existing vaults) + const cloudConnectTable = this.db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='cloud_connect_links'" + ).get() as { name?: string } | undefined; + + if (!cloudConnectTable) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS cloud_connect_links ( + link_id TEXT PRIMARY KEY, + secret_hash TEXT NOT NULL, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + permission_scopes TEXT NOT NULL, + budget_daily REAL NOT NULL DEFAULT 0, + budget_currency TEXT NOT NULL DEFAULT 'USDC', + budget_auto_approve_under REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + match_code TEXT, + redeemer_public_key TEXT, + source_ip TEXT, + source_host TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + redeemed_at TEXT, + consumed_at TEXT, + revoked_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_status ON cloud_connect_links(status); + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_agent ON cloud_connect_links(agent_id); + CREATE INDEX IF NOT EXISTS idx_cloud_connect_links_expires ON cloud_connect_links(expires_at); + `); + } + // Ensure agent_connections table exists (for existing vaults) const agentConnectionsTable = this.db.prepare( "SELECT name FROM sqlite_master WHERE type='table' AND name='agent_connections'" @@ -638,6 +707,38 @@ export class VaultStorage { } } + /** + * Verify a passphrase WITHOUT unlocking or caching the key, and WITHOUT changing + * the vault's lock state. Returns true iff the passphrase decrypts the master key. + * Used for re-authentication (e.g. owner re-registration) where we must not mutate + * the cached master key. (Unlike unlock(), this never sets this.masterKey, so the + * decrypted copy can be safely zeroized.) + */ + async verifyPassphrase(passphrase: string): Promise { + let keyData = await this.loadMasterKeyFromKeychain(); + if (!keyData) keyData = this.loadMasterKeyFromFile(); + if (!keyData) return false; + + const attempt = (data: { encryptedKey: Buffer; nonce: Buffer; salt: Buffer }): boolean => { + const wrappingKey = deriveKeyFromPassphrase(passphrase, data.salt); + try { + const mk = decrypt(data.encryptedKey, data.nonce, wrappingKey); + zeroize(mk); // verify only — never cache, never return the live key + return true; + } catch { + return false; + } finally { + zeroize(wrappingKey); + } + }; + + if (attempt(keyData)) return true; + // Stale keychain entry fallback: try the file. + const fileData = this.loadMasterKeyFromFile(); + if (fileData) return attempt(fileData); + return false; + } + /** * Lock vault (zeroize master key from memory) */ @@ -696,7 +797,18 @@ export class VaultStorage { version: '2.0', // Version 2.0 = AEAD encryption }); - await kt.setPassword(KEYCHAIN_SERVICE, this.getKeychainAccount(), data); + // Delete any existing entry first, then write. The keychain account is + // derived from the vault path, so a vault recreated at the same path must + // NOT inherit a previous vault's key. A plain setPassword should overwrite, + // but deleting first guarantees the freshly stored key is the only one — + // otherwise a stale key can silently survive and break recovery. + const account = this.getKeychainAccount(); + try { + await kt.deletePassword(KEYCHAIN_SERVICE, account); + } catch { + // No prior entry (or delete unsupported) — fine, we're about to set it. + } + await kt.setPassword(KEYCHAIN_SERVICE, account, data); return true; } catch { // Keychain not available (CI, headless system, etc.) @@ -2140,6 +2252,7 @@ export class VaultStorage { updateAgentConnection( agentId: string, updates: { + name?: string; permission_scopes?: string[]; budget_daily?: number; budget_currency?: string; @@ -2156,6 +2269,11 @@ export class VaultStorage { const setClauses: string[] = []; const values: (string | number)[] = []; + // Display name is cosmetic only — connections key on agent_id, so renaming is safe. + if (updates.name !== undefined) { + setClauses.push('name = ?'); + values.push(updates.name); + } if (updates.permission_scopes !== undefined) { setClauses.push('permission_scopes = ?'); values.push(JSON.stringify(updates.permission_scopes)); @@ -2303,6 +2421,436 @@ export class VaultStorage { return result.changes > 0; } + // ========================================================================== + // Cloud-Connect Links (Cloud Agent Connect feature) + // ========================================================================== + + private rowToCloudConnectLink(row: { + link_id: string; + agent_id: string; + name: string; + permission_scopes: string; + budget_daily: number; + budget_currency: string; + budget_auto_approve_under: number; + status: string; + match_code: string | null; + redeemer_public_key: string | null; + source_ip: string | null; + source_host: string | null; + created_at: string; + expires_at: string; + redeemed_at: string | null; + consumed_at: string | null; + revoked_at: string | null; + }): CloudConnectLink { + return { + link_id: row.link_id, + agent_id: row.agent_id, + name: row.name, + permission_scopes: JSON.parse(row.permission_scopes), + budget: { + daily: row.budget_daily, + currency: row.budget_currency, + auto_approve_under: row.budget_auto_approve_under, + }, + status: row.status as CloudConnectLinkStatus, + match_code: row.match_code || undefined, + redeemer_public_key: row.redeemer_public_key || undefined, + source_ip: row.source_ip || undefined, + source_host: row.source_host || undefined, + created_at: row.created_at, + expires_at: row.expires_at, + redeemed_at: row.redeemed_at || undefined, + consumed_at: row.consumed_at || undefined, + revoked_at: row.revoked_at || undefined, + }; + } + + /** Constant-time compare of two equal-length hex digests. */ + private hexDigestsEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); + } catch { + return false; + } + } + + /** + * Persist a newly-issued Cloud-Connect link (status='pending'). + * The secret is stored only as a SHA-256 hash; the plaintext lives only in + * the link token handed to the user. + */ + createCloudConnectLink(input: { + link_id: string; + secret: string; + agent_id: string; + name: string; + permission_scopes: string[]; + budget: { daily: number; currency: string; auto_approve_under: number }; + expires_at: string; + }): CloudConnectLink { + const now = new Date().toISOString(); + const secretHash = createHash('sha256').update(input.secret).digest('hex'); + + this.db + .prepare( + `INSERT INTO cloud_connect_links ( + link_id, secret_hash, agent_id, name, permission_scopes, + budget_daily, budget_currency, budget_auto_approve_under, + status, created_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)` + ) + .run( + input.link_id, + secretHash, + input.agent_id, + input.name, + JSON.stringify(input.permission_scopes), + input.budget.daily, + input.budget.currency, + input.budget.auto_approve_under, + now, + input.expires_at + ); + + this.logAudit('CONFIG', 'success', { + operation: 'cloud_connect_link_issued', + details: JSON.stringify({ + link_id: input.link_id, + agent_id: input.agent_id, + name: input.name, + }), + }); + + return this.getCloudConnectLink(input.link_id)!; + } + + /** Get a Cloud-Connect link by id. */ + getCloudConnectLink(linkId: string): CloudConnectLink | null { + const row = this.db + .prepare('SELECT * FROM cloud_connect_links WHERE link_id = ?') + .get(linkId) as Parameters[0] | undefined; + return row ? this.rowToCloudConnectLink(row) : null; + } + + /** Get the most recent Cloud-Connect link for an agent. */ + getCloudConnectLinkByAgent(agentId: string): CloudConnectLink | null { + const row = this.db + .prepare( + 'SELECT * FROM cloud_connect_links WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1' + ) + .get(agentId) as Parameters[0] | undefined; + return row ? this.rowToCloudConnectLink(row) : null; + } + + /** List all Cloud-Connect links (newest first). */ + listCloudConnectLinks(): CloudConnectLink[] { + const rows = this.db + .prepare('SELECT * FROM cloud_connect_links ORDER BY created_at DESC') + .all() as Array[0]>; + return rows.map((row) => this.rowToCloudConnectLink(row)); + } + + /** List links awaiting on-device approval (status='redeemed'). */ + listPendingCloudConnectApprovals(): CloudConnectLink[] { + const rows = this.db + .prepare( + "SELECT * FROM cloud_connect_links WHERE status = 'redeemed' ORDER BY redeemed_at DESC" + ) + .all() as Array[0]>; + return rows.map((row) => this.rowToCloudConnectLink(row)); + } + + /** + * Redeem a Cloud-Connect link (single-use). Validates the link is pending, + * unexpired, and the secret matches; then creates a PENDING agent connection + * (inert until approved — Rule #8) and transitions the link to 'redeemed'. + * Atomic: the whole thing runs in one transaction. + */ + redeemCloudConnectLink(input: { + link_id: string; + secret: string; + redeemer_public_key: string; + match_code: string; + mode?: AgentConnectionMode; + source_ip?: string; + source_host?: string; + }): { ok: boolean; reason?: string; agent_id?: string; match_code?: string } { + const presentedHash = createHash('sha256').update(input.secret).digest('hex'); + + const txn = this.db.transaction(() => { + const link = this.getCloudConnectLink(input.link_id); + if (!link) return { ok: false as const, reason: 'LINK_NOT_FOUND' }; + if (link.status !== 'pending') { + return { ok: false as const, reason: `LINK_${link.status.toUpperCase()}` }; + } + if (new Date(link.expires_at) < new Date()) { + this.db + .prepare( + "UPDATE cloud_connect_links SET status='expired' WHERE link_id=? AND status='pending'" + ) + .run(input.link_id); + return { ok: false as const, reason: 'LINK_EXPIRED' }; + } + + const secretRow = this.db + .prepare('SELECT secret_hash FROM cloud_connect_links WHERE link_id = ?') + .get(input.link_id) as { secret_hash: string } | undefined; + if (!secretRow || !this.hexDigestsEqual(secretRow.secret_hash, presentedHash)) { + return { ok: false as const, reason: 'BAD_SECRET' }; + } + + // Create the PENDING agent connection — no authority until approved. + this.createAgentConnection({ + agent_id: link.agent_id, + name: link.name, + mode: input.mode || 'mcp', + permission_scopes: link.permission_scopes, + budget: link.budget, + }); + + // Single-use transition (guarded on status to defeat double-redeem races). + const now = new Date().toISOString(); + const upd = this.db + .prepare( + `UPDATE cloud_connect_links + SET status='redeemed', match_code=?, redeemer_public_key=?, + source_ip=?, source_host=?, redeemed_at=? + WHERE link_id=? AND status='pending'` + ) + .run( + input.match_code, + input.redeemer_public_key, + input.source_ip || null, + input.source_host || null, + now, + input.link_id + ); + if (upd.changes === 0) return { ok: false as const, reason: 'RACE' }; + + return { ok: true as const, agent_id: link.agent_id, match_code: input.match_code }; + }); + + const result = txn(); + if (result.ok) { + this.logAudit('CONFIG', 'success', { + operation: 'cloud_connect_link_redeemed', + details: JSON.stringify({ link_id: input.link_id, agent_id: result.agent_id }), + }); + } + return result; + } + + /** + * Create a LINK-LESS pairing request (the "paste-URL" Cloud-Connect flow). + * + * Unlike {@link redeemCloudConnectLink}, there is no pre-issued connect-link or + * secret: a standard OAuth MCP client connected with just the vault's MCP URL, + * and the relay forwarded the request here. We materialise the SAME on-device + * approval state a redeem produces — a row in 'redeemed' status plus a PENDING + * (inert) agent connection — so the existing approve/deny/status/revoke paths + * work unchanged. The only difference is provenance: secret_hash is empty (the + * row can never be redeemed as a link) and source_host marks it as URL-paired. + * + * Security note: the connect-link's HPKE key-pin (Rule #1) is intentionally + * absent here; the on-device match-code + human approval (Rules #6/#8) and the + * caller-side pairing window remain the gates. Budget defaults to $0 auto-approve + * (Rule #5). The agent stays inert until the owner approves. + */ + createCloudConnectPairingRequest(input: { + link_id: string; + agent_id: string; + name: string; + permission_scopes: string[]; + budget: { daily: number; currency: string; auto_approve_under: number }; + redeemer_public_key: string; + match_code: string; + source_ip?: string; + source_host?: string; + expires_at: string; + }): { ok: boolean; reason?: string; agent_id: string; match_code: string } { + const txn = this.db.transaction(() => { + const now = new Date().toISOString(); + // Inserted directly as 'redeemed': there is no secret to verify, so the + // pending->redeemed transition that protects link redemption does not apply. + // secret_hash='' ensures verifyCloudConnectSecret / redeem can never match it. + this.db + .prepare( + `INSERT INTO cloud_connect_links ( + link_id, secret_hash, agent_id, name, permission_scopes, + budget_daily, budget_currency, budget_auto_approve_under, + status, match_code, redeemer_public_key, source_ip, source_host, + created_at, expires_at, redeemed_at + ) VALUES (?, '', ?, ?, ?, ?, ?, ?, 'redeemed', ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + input.link_id, + input.agent_id, + input.name, + JSON.stringify(input.permission_scopes), + input.budget.daily, + input.budget.currency, + input.budget.auto_approve_under, + input.match_code, + input.redeemer_public_key, + input.source_ip || null, + input.source_host || 'url', + now, + input.expires_at, + now + ); + + // Create the PENDING agent connection — no authority until approved (Rule #8). + this.createAgentConnection({ + agent_id: input.agent_id, + name: input.name, + mode: 'mcp', + permission_scopes: input.permission_scopes, + budget: input.budget, + }); + + return { ok: true as const, agent_id: input.agent_id, match_code: input.match_code }; + }); + + const result = txn(); + this.logAudit('CONFIG', 'success', { + operation: 'cloud_connect_pairing_requested', + details: JSON.stringify({ + link_id: input.link_id, + agent_id: input.agent_id, + source: input.source_host || 'url', + }), + }); + return result; + } + + /** + * Approve a redeemed Cloud-Connect link: bind the agent's presented public key + * and mark it active. The session token is minted separately when the agent + * authenticates its status poll (so the plaintext token is never stored). + */ + approveCloudConnectLink( + linkId: string + ): { ok: boolean; reason?: string; agent_id?: string; redeemer_public_key?: string } { + // Atomic: bind the agent and consume the link in one transaction, with the + // guarded status UPDATE done FIRST so a lost race never marks the agent paired. + const txn = this.db.transaction(() => { + const link = this.getCloudConnectLink(linkId); + if (!link) return { ok: false as const, reason: 'LINK_NOT_FOUND' }; + if (link.status !== 'redeemed') { + return { ok: false as const, reason: `LINK_${link.status.toUpperCase()}` }; + } + + const now = new Date().toISOString(); + const upd = this.db + .prepare( + "UPDATE cloud_connect_links SET status='consumed', consumed_at=? WHERE link_id=? AND status='redeemed'" + ) + .run(now, linkId); + if (upd.changes === 0) return { ok: false as const, reason: 'RACE' }; + + this.markAgentPaired(link.agent_id, undefined, link.redeemer_public_key); + + return { + ok: true as const, + agent_id: link.agent_id, + redeemer_public_key: link.redeemer_public_key, + }; + }); + + const result = txn(); + if (result.ok) { + this.logAudit('GRANT', 'success', { + operation: 'cloud_connect_link_approved', + details: JSON.stringify({ link_id: linkId, agent_id: result.agent_id }), + }); + } + return result; + } + + /** Deny a pending/redeemed link (revokes any pending agent connection). */ + denyCloudConnectLink( + linkId: string + ): { ok: boolean; reason?: string; agent_id?: string } { + const link = this.getCloudConnectLink(linkId); + if (!link) return { ok: false, reason: 'LINK_NOT_FOUND' }; + if (link.status !== 'redeemed' && link.status !== 'pending') { + return { ok: false, reason: `LINK_${link.status.toUpperCase()}` }; + } + const now = new Date().toISOString(); + this.db + .prepare("UPDATE cloud_connect_links SET status='revoked', revoked_at=? WHERE link_id=?") + .run(now, linkId); + if (link.status === 'redeemed') { + this.revokeAgentConnection(link.agent_id); + } + this.logAudit('REVOKE', 'success', { + operation: 'cloud_connect_link_denied', + details: JSON.stringify({ link_id: linkId, agent_id: link.agent_id }), + }); + return { ok: true, agent_id: link.agent_id }; + } + + /** + * Instantly revoke a Cloud-Connect link and its bound agent (Rule #7). + * Clears the agent token and marks both link + agent revoked so subsequent + * inbound requests are rejected. + */ + revokeCloudConnectLink( + linkId: string + ): { ok: boolean; reason?: string; agent_id?: string } { + const link = this.getCloudConnectLink(linkId); + if (!link) return { ok: false, reason: 'LINK_NOT_FOUND' }; + const now = new Date().toISOString(); + this.db + .prepare("UPDATE cloud_connect_links SET status='revoked', revoked_at=? WHERE link_id=?") + .run(now, linkId); + this.revokeAgentConnection(link.agent_id); + this.logAudit('REVOKE', 'success', { + operation: 'cloud_connect_link_revoked', + details: JSON.stringify({ link_id: linkId, agent_id: link.agent_id }), + }); + return { ok: true, agent_id: link.agent_id }; + } + + /** + * Verify a presented secret against a stored link (constant-time). + * Used to authenticate the agent's status poll without exposing the hash. + */ + verifyCloudConnectSecret(linkId: string, secret: string): boolean { + const row = this.db + .prepare('SELECT secret_hash FROM cloud_connect_links WHERE link_id = ?') + .get(linkId) as { secret_hash: string } | undefined; + if (!row) return false; + const presented = createHash('sha256').update(secret).digest('hex'); + return this.hexDigestsEqual(row.secret_hash, presented); + } + + /** + * Lazily expire stale links. Covers both: + * - unredeemed links past their bootstrap TTL, and + * - redeemed-but-unapproved links past the approval grace window + * so a redeemed link's match-code can't be approved/polled indefinitely. + * Returns the number of links transitioned to 'expired'. + */ + expireStaleCloudConnectLinks(): number { + const now = new Date().toISOString(); + const r1 = this.db + .prepare( + "UPDATE cloud_connect_links SET status='expired' WHERE status='pending' AND expires_at < ?" + ) + .run(now); + const graceCutoff = new Date(Date.now() - CLOUD_CONNECT_APPROVAL_GRACE_MS).toISOString(); + const r2 = this.db + .prepare( + "UPDATE cloud_connect_links SET status='expired' WHERE status='redeemed' AND redeemed_at < ?" + ) + .run(graceCutoff); + return r1.changes + r2.changes; + } + /** * Check if a service is trusted and has permission for a scope */ diff --git a/packages/dcp-core/src/types.ts b/packages/dcp-core/src/types.ts index 7825290..8204388 100644 --- a/packages/dcp-core/src/types.ts +++ b/packages/dcp-core/src/types.ts @@ -334,6 +334,51 @@ export interface AgentConnection { revoked_at?: string; } +/** + * Lifecycle status of a Cloud-Connect link. + * - pending: issued, not yet redeemed by an agent + * - redeemed: an agent redeemed it; awaiting on-device owner approval + * - consumed: owner approved; the agent is bound (link spent) + * - revoked: owner denied/revoked + * - expired: TTL elapsed before redemption + */ +export type CloudConnectLinkStatus = + | 'pending' + | 'redeemed' + | 'consumed' + | 'revoked' + | 'expired'; + +/** + * A Cloud-Connect link record (Cloud Agent Connect feature). + * Permissions/budget here are the AUTHORITATIVE copy (the link token carries + * none). `source_*` are server-derived facts captured at redemption and shown + * in the on-device approval (Rule #6). + */ +export interface CloudConnectLink { + link_id: string; + agent_id: string; + name: string; + permission_scopes: string[]; + budget: { + daily: number; + currency: string; + auto_approve_under: number; + }; + status: CloudConnectLinkStatus; + /** Match-code shown on both sides after redemption (anti cross-device phishing). */ + match_code?: string; + /** Agent's Ed25519 public key presented at redemption (bound on approval). */ + redeemer_public_key?: string; + source_ip?: string; + source_host?: string; + created_at: string; + expires_at: string; + redeemed_at?: string; + consumed_at?: string; + revoked_at?: string; +} + /** * Signed Pairing Grant (protocol spec section 7.1) * diff --git a/packages/dcp-core/tests/connect-link.test.ts b/packages/dcp-core/tests/connect-link.test.ts new file mode 100644 index 0000000..e91e3b9 --- /dev/null +++ b/packages/dcp-core/tests/connect-link.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { randomBytes } from 'crypto'; +import { + createConnectLink, + decodeConnectLink, + verifyConnectLinkWithKey, + isConnectLink, + hpkeFingerprint, + formatFingerprint, + generateMatchCode, + hashConnectLinkSecret, + CONNECT_LINK_PREFIX, + MATCH_CODE_LENGTH, + generateSigningKeyPair, + type CreateConnectLinkInput, +} from '../src/index.js'; + +function makeInput(overrides: Partial = {}): { + input: CreateConnectLinkInput; + signingPublicKey: Buffer; + signingPrivateKey: Buffer; + hpke: string; +} { + const keys = generateSigningKeyPair(); + const hpke = randomBytes(32).toString('base64'); + const input: CreateConnectLinkInput = { + vault_id: 'vault_test', + agent_id: 'agent_test', + agent_name: 'openclaw-aws', + mode: 'mcp', + relay_url: 'wss://relay.example/v/vault_test/mcp', + vault_hpke_public_key: hpke, + vault_signing_public_key: keys.publicKey.toString('base64'), + ...overrides, + }; + return { input, signingPublicKey: keys.publicKey, signingPrivateKey: keys.privateKey, hpke }; +} + +describe('Connect-link codec', () => { + it('creates a prefixed, recognizable token', () => { + const { input, signingPrivateKey } = makeInput(); + const link = createConnectLink(input, signingPrivateKey); + expect(link.token.startsWith(CONNECT_LINK_PREFIX)).toBe(true); + expect(isConnectLink(link.token)).toBe(true); + expect(isConnectLink('dcp_pair_v1_xxx')).toBe(false); + }); + + it('pins the HPKE key and computes a matching fingerprint', () => { + const { input, signingPrivateKey, hpke } = makeInput(); + const link = createConnectLink(input, signingPrivateKey); + const decoded = decodeConnectLink(link.token)!; + expect(decoded.payload.vault_hpke_public_key).toBe(hpke); + expect(decoded.payload.vault_hpke_fingerprint).toBe(hpkeFingerprint(hpke)); + expect(link.vault_hpke_fingerprint).toBe(hpkeFingerprint(hpke)); + }); + + it('verifies with the correct signing key and rejects the wrong key', () => { + const { input, signingPrivateKey, signingPublicKey } = makeInput(); + const link = createConnectLink(input, signingPrivateKey); + const verified = verifyConnectLinkWithKey(link.token, signingPublicKey); + expect(verified).not.toBeNull(); + expect(verified!.link_id).toBe(link.link_id); + expect(verified!.vault_id).toBe('vault_test'); + + const otherKey = generateSigningKeyPair().publicKey; + expect(verifyConnectLinkWithKey(link.token, otherKey)).toBeNull(); + }); + + it('rejects a tampered pinned key (signature no longer matches)', () => { + const { input, signingPrivateKey, signingPublicKey } = makeInput(); + const link = createConnectLink(input, signingPrivateKey); + const decoded = decodeConnectLink(link.token)!; + // Attacker swaps the pinned HPKE key but cannot re-sign. + decoded.payload.vault_hpke_public_key = randomBytes(32).toString('base64'); + const tampered = + CONNECT_LINK_PREFIX + Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url'); + expect(verifyConnectLinkWithKey(tampered, signingPublicKey)).toBeNull(); + }); + + it('rejects an expired link', () => { + const { input, signingPrivateKey, signingPublicKey } = makeInput(); + const link = createConnectLink({ ...input, ttl_ms: -1000 }, signingPrivateKey); + // decode (no expiry gate) still works, but verify rejects it. + expect(decodeConnectLink(link.token)).not.toBeNull(); + expect(verifyConnectLinkWithKey(link.token, signingPublicKey)).toBeNull(); + }); + + it('carries a high-entropy secret that hashes deterministically', () => { + const { input, signingPrivateKey } = makeInput(); + const link = createConnectLink(input, signingPrivateKey); + const decoded = decodeConnectLink(link.token)!; + expect(decoded.payload.secret).toBe(link.secret); + expect(link.secret.length).toBeGreaterThanOrEqual(40); + expect(hashConnectLinkSecret(link.secret)).toBe(hashConnectLinkSecret(decoded.payload.secret)); + }); + + it('returns null for malformed tokens', () => { + expect(decodeConnectLink('not-a-link')).toBeNull(); + expect(decodeConnectLink(CONNECT_LINK_PREFIX + 'garbage')).toBeNull(); + expect(verifyConnectLinkWithKey('not-a-link', randomBytes(32))).toBeNull(); + }); +}); + +describe('hpkeFingerprint', () => { + it('is deterministic and 64 hex chars', () => { + const key = randomBytes(32).toString('base64'); + const fp = hpkeFingerprint(key); + expect(fp).toMatch(/^[0-9a-f]{64}$/); + expect(hpkeFingerprint(key)).toBe(fp); + }); + + it('differs for different keys and formats for display', () => { + const a = hpkeFingerprint(randomBytes(32).toString('base64')); + const b = hpkeFingerprint(randomBytes(32).toString('base64')); + expect(a).not.toBe(b); + expect(formatFingerprint(a)).toMatch(/^[0-9a-f]{4}( [0-9a-f]{4}){3}$/); + }); +}); + +describe('generateMatchCode', () => { + it('has the right length and uses only the unambiguous alphabet', () => { + const code = generateMatchCode(); + expect(code).toHaveLength(MATCH_CODE_LENGTH); + // No 0/O/1/I/L (ambiguous) and uppercase only. + expect(code).toMatch(/^[2-9A-HJ-NP-Z]{6}$/); + }); + + it('is not constant across many draws (CSPRNG)', () => { + const codes = new Set(Array.from({ length: 50 }, () => generateMatchCode())); + // Astronomically unlikely to collide to a single value; expect high variety. + expect(codes.size).toBeGreaterThan(40); + }); +}); diff --git a/packages/dcp-core/tests/storage.test.ts b/packages/dcp-core/tests/storage.test.ts index 95a77d5..b3ace76 100644 --- a/packages/dcp-core/tests/storage.test.ts +++ b/packages/dcp-core/tests/storage.test.ts @@ -526,6 +526,27 @@ describe('Storage Layer', () => { zeroize(correctKey); }); + + it('verifyPassphrase validates without changing lock state or corrupting the key', async () => { + const passphrase = 'verify-pass-123'; + await storage.initializeMasterKey(passphrase); + + // Correct + wrong passphrase results. + expect(await storage.verifyPassphrase(passphrase)).toBe(true); + expect(await storage.verifyPassphrase('nope')).toBe(false); + + // Critical regression: verifyPassphrase must NOT zeroize/replace the cached + // master key (the original bug). The vault stays usable after verification. + expect(storage.isUnlocked()).toBe(true); + const key = storage.getMasterKey(); + expect(key.length).toBe(32); + expect(key.every((b) => b === 0)).toBe(false); // not all-zero (not corrupted) + + // While locked, verifyPassphrase still works and does not unlock the vault. + storage.lock(); + expect(await storage.verifyPassphrase(passphrase)).toBe(true); + expect(storage.isUnlocked()).toBe(false); + }); }); describe('Telegram Configuration', () => { diff --git a/packages/dcp-relay-client/LICENSE b/packages/dcp-relay-client/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-relay-client/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-relay-client/package.json b/packages/dcp-relay-client/package.json index 04f8de7..7d774a0 100644 --- a/packages/dcp-relay-client/package.json +++ b/packages/dcp-relay-client/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/relay-client", - "version": "2.0.1", + "version": "3.0.0", "description": "Internal relay client for DCP vault transport packages", "main": "dist/index.js", "module": "dist/index.mjs", @@ -45,7 +45,7 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { - "@dcprotocol/relay": "^2.0.1", + "@dcprotocol/relay": "^3.0.0", "@hpke/core": "^1.6.0", "@hpke/chacha20poly1305": "^1.6.0", "@hpke/dhkem-x25519": "^1.6.0", diff --git a/packages/dcp-relay-client/src/client.ts b/packages/dcp-relay-client/src/client.ts index 43a6df4..4470ebe 100644 --- a/packages/dcp-relay-client/src/client.ts +++ b/packages/dcp-relay-client/src/client.ts @@ -214,6 +214,27 @@ export class RelayClient extends SimpleEmitter { }); } + /** + * Reply to a Cloud-Connect control message (redeem/status) from the relay. + * `controlId` correlates the reply to the relay's pending request. + */ + sendCloudConnectResult(controlId: string, result: Record): boolean { + return this.sendWsMessage({ + type: 'cloud_connect_result', + payload: { control_id: controlId, vault_id: this.config.vaultId, ...result }, + timestamp: new Date().toISOString(), + }); + } + + /** Tell the relay to instantly revoke a cloud agent (denylist + kill refresh chains). */ + sendCloudConnectRevoke(agentId: string): boolean { + return this.sendWsMessage({ + type: 'cloud_connect_revoke', + payload: { vault_id: this.config.vaultId, agent_id: agentId }, + timestamp: new Date().toISOString(), + }); + } + /** * Cleanup resources */ @@ -444,6 +465,26 @@ export class RelayClient extends SimpleEmitter { this.emit('pairingClaim', msg.payload as import('@dcprotocol/relay').StoredPairingClaim); break; + case 'cloud_connect_redeem': + // Relay asking the vault to redeem a Cloud-Connect link for an agent. + this.emit('cloudConnectControl', { kind: 'redeem', ...(msg.payload as object) }); + break; + + case 'cloud_connect_pair': + // Relay asking the vault to open a link-less (paste-URL) pairing. + this.emit('cloudConnectControl', { kind: 'pair', ...(msg.payload as object) }); + break; + + case 'cloud_connect_status': + // Relay asking the vault for an agent's approval status. + this.emit('cloudConnectControl', { kind: 'status', ...(msg.payload as object) }); + break; + + case 'cloud_connect_mcp': + // Relay forwarding an authorized MCP request to the vault. + this.emit('cloudConnectControl', { kind: 'mcp', ...(msg.payload as object) }); + break; + default: if (this.config.debug) { console.log('Unknown message type:', msg.type); diff --git a/packages/dcp-relay/LICENSE b/packages/dcp-relay/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-relay/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-relay/README.md b/packages/dcp-relay/README.md index d03d61b..27a1621 100644 --- a/packages/dcp-relay/README.md +++ b/packages/dcp-relay/README.md @@ -1,8 +1,13 @@ # @dcprotocol/relay -Encrypted relay for DCP remote agents. +Encrypted relay for DCP remote and cloud agents. -The relay moves encrypted envelopes between a remote sidecar and a local vault. It is transport. Secrets do not belong in relay logs or relay payload metadata. +The relay moves encrypted envelopes between an agent and a local vault. It is transport. Secrets do not belong in relay logs or relay payload metadata. + +It serves two paths: + +- **Remote/VPS sidecar** — a `dcp-agent` sidecar on a server forwards encrypted envelopes through the relay to the on-device vault. +- **Cloud Connect MCP facade** — for agents you do not host (Claude.ai, ChatGPT, hosted OpenClaw/Hermes), the relay exposes an MCP endpoint with an OAuth bridge. The agent connects using a one-time, key-pinned connect link (`dcp_connect_v1_…`) the user pastes in; the relay carries end-to-end-encrypted requests to the vault, which approves on-device. The relay never sees plaintext — see [`ARCHITECTURE.md`](../../ARCHITECTURE.md) → Cloud Connect Path. ## Run diff --git a/packages/dcp-relay/package.json b/packages/dcp-relay/package.json index 5c45b72..f33072e 100644 --- a/packages/dcp-relay/package.json +++ b/packages/dcp-relay/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/relay", - "version": "2.0.1", + "version": "3.0.0", "description": "Encrypted message relay for DCP - enables cloud MCP clients to reach local vaults", "main": "dist/index.js", "module": "dist/index.mjs", @@ -53,7 +53,8 @@ "@fastify/websocket": "^11.0.1", "@fastify/cors": "^10.0.2", "@noble/curves": "^1.4.0", - "pino-pretty": "^13.0.0" + "pino-pretty": "^13.0.0", + "jose": "^5.9.6" }, "devDependencies": { "vite": "^6.0.0", diff --git a/packages/dcp-relay/src/index.ts b/packages/dcp-relay/src/index.ts index 3eec219..f667e4d 100644 --- a/packages/dcp-relay/src/index.ts +++ b/packages/dcp-relay/src/index.ts @@ -86,6 +86,7 @@ interface RelayCliOptions { debug: boolean; rateLimitPerMinute: number; expoPushUrl: string; + publicUrl: string; } function parseArgs(): RelayCliOptions { @@ -97,6 +98,9 @@ function parseArgs(): RelayCliOptions { let debug = process.env.DCP_RELAY_DEBUG === 'true' || false; let rateLimitPerMinute = parseInt(process.env.DCP_RELAY_RATE_LIMIT || '', 10) || DEFAULT_RELAY_CONFIG.rateLimitPerMinute; let expoPushUrl = process.env.DCP_EXPO_PUSH_URL ?? DEFAULT_RELAY_CONFIG.expoPushUrl; + // Public base URL (OAuth issuer / MCP resource origin) — set behind a tunnel/proxy + // so discovery metadata + token audience use the public https host, not localhost. + const publicUrl = process.env.DCP_RELAY_PUBLIC_URL ?? DEFAULT_RELAY_CONFIG.publicUrl; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -141,11 +145,11 @@ Examples: } } - return { port, host, debug, rateLimitPerMinute, expoPushUrl }; + return { port, host, debug, rateLimitPerMinute, expoPushUrl, publicUrl }; } async function main(): Promise { - const { port, host, debug, rateLimitPerMinute, expoPushUrl } = parseArgs(); + const { port, host, debug, rateLimitPerMinute, expoPushUrl, publicUrl } = parseArgs(); const relay = new RelayServer({ port, @@ -154,6 +158,7 @@ async function main(): Promise { enableLongPoll: true, rateLimitPerMinute, expoPushUrl, + publicUrl, }); // Handle shutdown @@ -176,18 +181,21 @@ async function main(): Promise { } } -// Run only when executed directly, never when imported as a library. -const argvEntry = process.argv[1] ? path.basename(process.argv[1]) : ''; +// Run only when this file is the process entry point — NEVER when imported or +// bundled into another package (e.g. the desktop's vault bundle). We deliberately +// do NOT use `require.main === module`: esbuild evaluates that to `true` inside a +// bundle, which would make the relay server autostart and fight for port 8422. +// Instead, key off the actual entry path: a relay binary name, or a path that runs +// the relay's own dist/src directly. +const argv1 = process.argv[1] || ''; +const argvEntry = argv1 ? path.basename(argv1) : ''; const isDirectCliInvocation = argvEntry === 'dcp-relay' || argvEntry === 'dcp-relay.js' || - argvEntry === 'dcp-relay.mjs'; -const isMainModule = - (typeof require !== 'undefined' && - typeof module !== 'undefined' && - require.main === module) || - isDirectCliInvocation; - -if (isMainModule) { + argvEntry === 'dcp-relay.mjs' || + /[\\/]dcp-relay[\\/](dist|src)[\\/]index\.(js|mjs|ts)$/.test(argv1) || + /[\\/]@dcprotocol[\\/]relay[\\/]/.test(argv1); + +if (isDirectCliInvocation) { main().catch(console.error); } diff --git a/packages/dcp-relay/src/oauth/bridge.ts b/packages/dcp-relay/src/oauth/bridge.ts new file mode 100644 index 0000000..a19d59b --- /dev/null +++ b/packages/dcp-relay/src/oauth/bridge.ts @@ -0,0 +1,123 @@ +/** + * Vault connect bridge. + * + * The relay's OAuth AS must ask the VAULT that issued a connect-link to (a) redeem + * it for an agent (producing the on-device match-code) and (b) report whether the + * owner has approved. The vault — not the relay — is the source of truth: it + * verifies the connect-link signature, generates the match-code, and runs the + * on-device approval. The relay only orchestrates and issues tokens. + * + * This is the CONTROL plane (authorization handshake), distinct from the encrypted + * DATA plane (vault operations). It carries no vault secrets and no key material. + * + * Production backs this with the relay↔vault WebSocket control channel; tests + * inject a fake implementation. + */ + +export interface BridgeRedeemInput { + /** Vault that issued the link (routing target). */ + vaultId: string; + /** The opaque connect-link token; the VAULT verifies + decodes it. */ + connectLink: string; + /** Stable identifier for the redeeming agent — its DPoP JWK thumbprint (jkt). */ + redeemerKey: string; + /** Server-derived source IP (for the on-device approval facts). */ + sourceIp?: string; +} + +export interface BridgeRedeemResult { + ok: boolean; + agentId?: string; + /** Match-code generated by the vault, shown on-device and returned to the agent. */ + matchCode?: string; + reason?: string; +} + +/** + * Link-less pairing: a standard OAuth MCP client connected with JUST the vault's + * MCP URL (no connect-link secret). The relay asks the vault to open an on-device + * approval directly. The vault gates this on its "pairing window" + rate-limit and + * returns the same {agentId, matchCode} a redeem would. + */ +export interface BridgePairingInput { + /** Vault addressed by the MCP URL (routing target). */ + vaultId: string; + /** Redeeming agent's DPoP JWK thumbprint (jkt), or a TOFU placeholder for the + * browser auth-code flow where the key is bound later at /token. */ + redeemerKey: string; + /** Server-derived source IP (for the on-device approval facts). */ + sourceIp?: string; + /** Untrusted client display name (from Dynamic Client Registration). */ + clientName?: string; + /** Requested scope (space-delimited); the vault decides what to actually grant. */ + scope?: string; +} + +export interface BridgeApprovalInput { + vaultId: string; + agentId: string; +} + +export interface BridgeApprovalResult { + status: 'pending' | 'approved' | 'denied' | 'revoked' | 'unknown'; + /** Authoritative scope granted by the vault (only meaningful once approved). */ + scope?: string; + /** Authoritative budget (display/audit). */ + budget?: { daily: number; currency: string; auto_approve_under: number }; +} + +export interface VaultConnectBridge { + redeem(input: BridgeRedeemInput): Promise; + approvalStatus(input: BridgeApprovalInput): Promise; + /** + * Open a link-less on-device approval (the paste-URL flow). Optional so existing + * (e.g. test) bridges keep working; when absent, the relay falls back to the + * connect-link form and the URL-only path returns a 'pairing_unsupported' error. + */ + requestPairing?(input: BridgePairingInput): Promise; +} + +/** Default bridge used when no vault control channel is wired — fails closed. */ +export class UnavailableVaultBridge implements VaultConnectBridge { + async redeem(): Promise { + return { ok: false, reason: 'vault_unreachable' }; + } + async approvalStatus(): Promise { + return { status: 'unknown' }; + } + async requestPairing(): Promise { + return { ok: false, reason: 'vault_unreachable' }; + } +} + +/** + * Data-plane bridge: forward an AUTHORIZED MCP request to the vault. The facade + * has already verified the access token (audience) + DPoP proof; this carries the + * authenticated agent identity so the vault can apply the right scopes/budget and + * pop an on-device consent for sensitive actions. + */ +export interface AuthedMcpRequest { + vaultId: string; + /** Authenticated agent id (the access token subject). */ + agentId: string; + /** Granted scope (space-delimited) from the access token. */ + scope: string; + /** DPoP key thumbprint the token is bound to. */ + jkt: string; + /** The MCP JSON-RPC request body. */ + body: unknown; +} + +export interface McpDataBridge { + forward(req: AuthedMcpRequest): Promise<{ status: number; body: unknown }>; +} + +/** Default MCP data bridge — not wired yet; signals the data plane is unavailable. */ +export class UnavailableMcpBridge implements McpDataBridge { + async forward(): Promise<{ status: number; body: unknown }> { + return { + status: 503, + body: { error: 'data_plane_unavailable', error_description: 'MCP bridge not configured' }, + }; + } +} diff --git a/packages/dcp-relay/src/oauth/connect-link-lite.ts b/packages/dcp-relay/src/oauth/connect-link-lite.ts new file mode 100644 index 0000000..28e3f32 --- /dev/null +++ b/packages/dcp-relay/src/oauth/connect-link-lite.ts @@ -0,0 +1,39 @@ +/** + * Minimal, UNTRUSTED connect-link parsing for routing only. + * + * The relay must not depend on the heavy @dcprotocol/core package, and it does + * not need to: it only needs the `vault_id` (and `link_id`) to route the redeem + * to the right vault. The VAULT performs full signature verification + decoding. + * Never trust any field returned here for an authorization decision. + */ + +const CONNECT_LINK_PREFIX = 'dcp_connect_v1_'; + +export interface ConnectLinkRouting { + vault_id: string; + link_id: string; +} + +export function isConnectLinkToken(token: string): boolean { + return typeof token === 'string' && token.startsWith(CONNECT_LINK_PREFIX); +} + +/** + * Extract routing fields from a connect-link token without verifying it. + * Returns null if the token is not a well-formed connect-link envelope. + */ +export function parseConnectLinkRouting(token: string): ConnectLinkRouting | null { + if (!isConnectLinkToken(token)) return null; + try { + const b64 = token.slice(CONNECT_LINK_PREFIX.length); + const json = Buffer.from(b64, 'base64url').toString('utf8'); + const decoded = JSON.parse(json) as { payload?: { vault_id?: unknown; link_id?: unknown } }; + const vault_id = decoded?.payload?.vault_id; + const link_id = decoded?.payload?.link_id; + if (typeof vault_id !== 'string' || !vault_id) return null; + if (typeof link_id !== 'string' || !link_id) return null; + return { vault_id, link_id }; + } catch { + return null; + } +} diff --git a/packages/dcp-relay/src/oauth/dpop.ts b/packages/dcp-relay/src/oauth/dpop.ts new file mode 100644 index 0000000..3bff977 --- /dev/null +++ b/packages/dcp-relay/src/oauth/dpop.ts @@ -0,0 +1,185 @@ +/** + * DPoP (RFC 9449) — sender-constrained proof-of-possession. + * + * Each request carries a `DPoP` header: a JWT signed by the client's private + * key whose PUBLIC key is embedded in the proof header. The access token is + * bound to the JWK thumbprint (`cnf.jkt`), so a stolen bearer token is useless + * without the matching private key. (Security rule #2.) + */ + +import { createHash } from 'crypto'; +import { jwtVerify, importJWK, decodeProtectedHeader, calculateJwkThumbprint, type JWK } from 'jose'; + +/** Allowed DPoP proof signing algorithms (asymmetric only). */ +const DPOP_ALGS = ['ES256', 'ES384', 'EdDSA', 'RS256', 'PS256'] as const; + +export interface DpopVerifyOptions { + /** HTTP method of the request the proof covers (e.g. 'POST'). */ + method: string; + /** Full request URL; query/fragment are ignored per RFC 9449 §4.2. */ + url: string; + /** Max allowed clock skew / proof age in seconds (default 300). */ + maxAgeSec?: number; + /** Replay guard for the `jti` claim (required to stop proof replay). */ + jtiGuard: JtiReplayGuard; + /** When verifying a proof that accompanies an access token, the token to bind via `ath`. */ + accessToken?: string; + /** Expected DPoP-Nonce (when the AS issued one). */ + expectedNonce?: string; +} + +export interface DpopVerifyResult { + /** JWK thumbprint of the proof key — bind the access token to this (`cnf.jkt`). */ + jkt: string; + /** The proof's `jti` (already recorded as used). */ + jti: string; +} + +export class DpopError extends Error { + constructor( + public code: string, + message: string + ) { + super(message); + this.name = 'DpopError'; + } +} + +/** Replay guard: returns true if a jti was already seen (and records it otherwise). */ +export interface JtiReplayGuard { + seen(jti: string): boolean; +} + +/** Simple in-memory jti replay guard with TTL eviction. */ +export function createJtiGuard(ttlMs = 5 * 60 * 1000): JtiReplayGuard & { size(): number } { + const seenMap = new Map(); + let lastSweep = 0; + const sweep = (now: number) => { + if (now - lastSweep < 30_000) return; + lastSweep = now; + for (const [jti, exp] of seenMap) if (exp <= now) seenMap.delete(jti); + }; + return { + seen(jti: string): boolean { + const now = Date.now(); + sweep(now); + if (seenMap.has(jti)) return true; + seenMap.set(jti, now + ttlMs); + return false; + }, + size: () => seenMap.size, + }; +} + +/** Normalize an htu/URL for comparison: scheme+authority+path, no query/fragment. */ +function normalizeHtu(url: string): string { + const u = new URL(url); + return `${u.origin}${u.pathname}`; +} + +/** base64url(SHA-256(input)) — used for the DPoP `ath` access-token hash. */ +function sha256Base64Url(input: string): string { + return createHash('sha256').update(input, 'ascii').digest('base64url'); +} + +/** + * Verify a DPoP proof JWT. Throws DpopError on any failure; returns the bound + * JWK thumbprint (jkt) and jti on success. + */ +export async function verifyDpopProof( + proof: string, + opts: DpopVerifyOptions +): Promise { + if (!proof || typeof proof !== 'string') { + throw new DpopError('invalid_dpop_proof', 'Missing DPoP proof'); + } + + let header: ReturnType; + try { + header = decodeProtectedHeader(proof); + } catch { + throw new DpopError('invalid_dpop_proof', 'Malformed DPoP proof header'); + } + + if (header.typ !== 'dpop+jwt') { + throw new DpopError('invalid_dpop_proof', 'DPoP proof typ must be dpop+jwt'); + } + if (!header.alg || !DPOP_ALGS.includes(header.alg as (typeof DPOP_ALGS)[number])) { + throw new DpopError('invalid_dpop_proof', `Unsupported DPoP alg: ${header.alg}`); + } + const jwk = header.jwk as JWK | undefined; + if (!jwk || typeof jwk !== 'object') { + throw new DpopError('invalid_dpop_proof', 'DPoP proof must embed a public jwk'); + } + // The embedded JWK must be PUBLIC (no private material). + if ('d' in jwk) { + throw new DpopError('invalid_dpop_proof', 'DPoP jwk must not contain private key material'); + } + + const maxAgeSec = opts.maxAgeSec ?? 300; + let key; + try { + key = await importJWK(jwk, header.alg); + } catch { + throw new DpopError('invalid_dpop_proof', 'DPoP jwk could not be imported'); + } + + let payload; + try { + ({ payload } = await jwtVerify(proof, key, { + algorithms: [...DPOP_ALGS], + typ: 'dpop+jwt', + maxTokenAge: `${maxAgeSec}s`, + clockTolerance: `${maxAgeSec}s`, + })); + } catch (e) { + throw new DpopError('invalid_dpop_proof', `DPoP signature/claims invalid: ${(e as Error).message}`); + } + + // htm: HTTP method must match. + if (typeof payload.htm !== 'string' || payload.htm.toUpperCase() !== opts.method.toUpperCase()) { + throw new DpopError('invalid_dpop_proof', 'DPoP htm does not match request method'); + } + // htu: HTTP URI must match (sans query/fragment). + if (typeof payload.htu !== 'string') { + throw new DpopError('invalid_dpop_proof', 'DPoP htu missing'); + } + try { + if (normalizeHtu(payload.htu) !== normalizeHtu(opts.url)) { + throw new DpopError('invalid_dpop_proof', 'DPoP htu does not match request URL'); + } + } catch (e) { + if (e instanceof DpopError) throw e; + throw new DpopError('invalid_dpop_proof', 'DPoP htu is not a valid URL'); + } + // iat is enforced by maxTokenAge above; require it to be present. + if (typeof payload.iat !== 'number') { + throw new DpopError('invalid_dpop_proof', 'DPoP iat missing'); + } + // jti: required + single-use (anti-replay). + if (typeof payload.jti !== 'string' || payload.jti.length < 8) { + throw new DpopError('invalid_dpop_proof', 'DPoP jti missing or too short'); + } + if (opts.jtiGuard.seen(payload.jti)) { + throw new DpopError('dpop_replay', 'DPoP proof replayed (jti already used)'); + } + // Optional nonce binding. + if (opts.expectedNonce && payload.nonce !== opts.expectedNonce) { + throw new DpopError('use_dpop_nonce', 'DPoP nonce mismatch'); + } + // ath: when bound to an access token, the hash must match. + if (opts.accessToken) { + const expectedAth = sha256Base64Url(opts.accessToken); + if (payload.ath !== expectedAth) { + throw new DpopError('invalid_dpop_proof', 'DPoP ath does not match access token'); + } + } + + const jkt = await calculateJwkThumbprint(jwk, 'sha256'); + return { jkt, jti: payload.jti }; +} + +/** Compute the JWK thumbprint (jkt) for a public JWK — the value bound as cnf.jkt. */ +export async function jwkThumbprint(jwk: JWK): Promise { + return calculateJwkThumbprint(jwk, 'sha256'); +} diff --git a/packages/dcp-relay/src/oauth/grant.ts b/packages/dcp-relay/src/oauth/grant.ts new file mode 100644 index 0000000..296c5db --- /dev/null +++ b/packages/dcp-relay/src/oauth/grant.ts @@ -0,0 +1,439 @@ +/** + * OAuth grant processing for Cloud-Connect. + * + * A connect-link-driven, device-flow-style grant (works for headless cloud + * agents) plus refresh-token rotation. All the security rules live here: + * - PKCE S256 binds the connect call to the token call. + * - DPoP binds every call to the agent's key; the access token carries cnf.jkt. + * - The access token is audience-bound to ONE vault (RFC 8707). + * - Refresh tokens rotate with reuse-detection (whole-chain revoke). + * - The agent stays INERT until the owner approves on-device (the bridge gates + * token issuance on the vault's approval status). + * + * Pure logic: returns { status, body }. The HTTP layer just (de)serializes. + */ + +import type { RelayOAuthKeys } from './keys.js'; +import type { AuthSessionStore, RefreshTokenStore } from './store.js'; +import type { JtiReplayGuard } from './dpop.js'; +import type { VaultConnectBridge } from './bridge.js'; +import { verifyDpopProof, DpopError } from './dpop.js'; +import { verifyPkceS256 } from './pkce.js'; +import { issueAccessToken, DEFAULT_ACCESS_TOKEN_TTL_SEC } from './tokens.js'; +import { parseConnectLinkRouting } from './connect-link-lite.js'; + +export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +export interface GrantDeps { + keys: RelayOAuthKeys; + sessions: AuthSessionStore; + refresh: RefreshTokenStore; + jtiGuard: JtiReplayGuard; + bridge: VaultConnectBridge; + /** Public base URL of the relay (issuer + resource origin). No trailing slash. */ + issuer: string; + accessTtlSec?: number; + /** Poll interval hint (seconds) returned to the device-flow client. */ + pollIntervalSec?: number; +} + +export interface GrantResponse { + status: number; + body: Record; +} + +const oauthError = (status: number, error: string, description?: string): GrantResponse => ({ + status, + body: description ? { error, error_description: description } : { error }, +}); + +function resourceFor(issuer: string, vaultId: string): string { + return `${issuer.replace(/\/+$/, '')}/v/${vaultId}/mcp`; +} + +/** Call the optional link-less pairing bridge, normalising "not wired" to an error. */ +async function requestPairingOrError( + deps: GrantDeps, + input: { vaultId: string; redeemerKey: string; sourceIp?: string; clientName?: string; scope?: string } +): Promise { + if (!deps.bridge.requestPairing) return { ok: false, reason: 'pairing_unsupported' }; + return deps.bridge.requestPairing(input); +} + +// ---------------------------------------------------------------------------- +// /oauth/connect — redeem a connect-link, open an authorization session +// ---------------------------------------------------------------------------- + +export interface ConnectRequest { + /** Connect-link token (Model B). Omit for the link-less paste-URL flow (Model A). */ + connectLink?: string; + /** Link-less flow: the vault addressed by the MCP URL (from the `resource` param). */ + vaultId?: string; + /** Untrusted client display name (DCR), surfaced in the on-device approval. */ + clientName?: string; + codeChallenge: string; + codeChallengeMethod?: string; + dpopProof: string; + /** HTTP method + full URL of the /oauth/connect request (for DPoP htm/htu). */ + method: string; + url: string; + sourceIp?: string; + clientId?: string; + scope?: string; +} + +export async function processConnect( + deps: GrantDeps, + req: ConnectRequest +): Promise { + // Model B (connect-link) carries vault routing in the token; Model A (paste-URL) + // carries it in the resource-derived vaultId. Exactly one must be present. + const routing = req.connectLink ? parseConnectLinkRouting(req.connectLink) : null; + const vaultId = routing?.vault_id || req.vaultId; + if (!vaultId) { + return oauthError(400, 'invalid_request', 'connect_link or a vault resource is required'); + } + if (req.connectLink && !routing) { + return oauthError(400, 'invalid_request', 'connect_link is malformed'); + } + if (!req.codeChallenge || (req.codeChallengeMethod ?? 'S256') !== 'S256') { + return oauthError(400, 'invalid_request', 'PKCE code_challenge with method S256 is required'); + } + + let jkt: string; + try { + const dpop = await verifyDpopProof(req.dpopProof, { + method: req.method, + url: req.url, + jtiGuard: deps.jtiGuard, + }); + jkt = dpop.jkt; + } catch (e) { + if (e instanceof DpopError) return oauthError(400, 'invalid_dpop_proof', e.message); + return oauthError(400, 'invalid_dpop_proof', 'DPoP proof invalid'); + } + + const redeem = routing + ? await deps.bridge.redeem({ + vaultId, + connectLink: req.connectLink!, + redeemerKey: jkt, + sourceIp: req.sourceIp, + }) + : await requestPairingOrError(deps, { + vaultId, + redeemerKey: jkt, + sourceIp: req.sourceIp, + clientName: req.clientName, + scope: req.scope, + }); + + if (!redeem.ok) { + if (redeem.reason === 'vault_unreachable') { + return oauthError(503, 'temporarily_unavailable', 'The target vault is offline'); + } + if (redeem.reason === 'pairing_unsupported') { + return oauthError(400, 'invalid_request', 'This vault requires a connect-link'); + } + const what = routing ? 'connect-link could not be redeemed' : 'pairing was refused'; + return oauthError(400, 'invalid_grant', `${what} (${redeem.reason || 'failed'})`); + } + + const session = deps.sessions.create({ + vault_id: vaultId, + link_id: routing?.link_id || `url:${redeem.agentId || ''}`, + agent_id: redeem.agentId, + agent_jkt: jkt, + code_challenge: req.codeChallenge, + scope: req.scope || '', + audience: resourceFor(deps.issuer, vaultId), + match_code: redeem.matchCode, + client_id: req.clientId, + }); + + return { + status: 200, + body: { + // Device-flow shape: poll /oauth/token with this device_code. + device_code: session.session_id, + match_code: redeem.matchCode, + interval: deps.pollIntervalSec ?? 3, + expires_in: Math.max(1, Math.round((session.expires_at - Date.now()) / 1000)), + message: + 'Approve this connection on your DCP device. Confirm the match code shown here matches the one on your device.', + }, + }; +} + +// ---------------------------------------------------------------------------- +// /oauth/authorize — browser auth-code flow (Claude.ai / ChatGPT) +// ---------------------------------------------------------------------------- +// The user pastes a connect-link; we redeem it (triggering on-device approval), +// open an authorization session, and return an authorization "code" (the session +// id). The browser page polls approval, then redirects to redirect_uri with the +// code. The DPoP key is bound later at /token (TOFU). PKCE is mandatory. + +export interface AuthorizeRequest { + /** Connect-link token (Model B). Omit for the link-less paste-URL flow (Model A). */ + connectLink?: string; + /** Link-less flow: the vault addressed by the MCP URL (from the `resource` param). */ + vaultId?: string; + /** Untrusted client display name (DCR), surfaced in the on-device approval. */ + clientName?: string; + codeChallenge: string; + codeChallengeMethod?: string; + sourceIp?: string; + clientId?: string; + scope?: string; +} + +export interface AuthorizeResult { + ok: boolean; + status: number; + error?: string; + sessionId?: string; + matchCode?: string; +} + +export async function processAuthorize( + deps: GrantDeps, + req: AuthorizeRequest +): Promise { + const routing = req.connectLink ? parseConnectLinkRouting(req.connectLink) : null; + const vaultId = routing?.vault_id || req.vaultId; + if (!vaultId) return { ok: false, status: 400, error: 'invalid_connect_link' }; + if (req.connectLink && !routing) return { ok: false, status: 400, error: 'invalid_connect_link' }; + if (!req.codeChallenge || (req.codeChallengeMethod ?? 'S256') !== 'S256') { + return { ok: false, status: 400, error: 'pkce_s256_required' }; + } + + // The browser auth-code flow binds the DPoP key later at /token (TOFU), so we + // pass a placeholder redeemer key here for both models. + const redeem = routing + ? await deps.bridge.redeem({ + vaultId, + connectLink: req.connectLink!, + redeemerKey: `browser:${routing.link_id}`, + sourceIp: req.sourceIp, + }) + : await requestPairingOrError(deps, { + vaultId, + redeemerKey: 'browser-tofu', + sourceIp: req.sourceIp, + clientName: req.clientName, + scope: req.scope, + }); + if (!redeem.ok) { + return { + ok: false, + status: redeem.reason === 'vault_unreachable' ? 503 : 400, + error: redeem.reason || 'redeem_failed', + }; + } + + const session = deps.sessions.create({ + vault_id: vaultId, + link_id: routing?.link_id || `url:${redeem.agentId || ''}`, + agent_id: redeem.agentId, + agent_jkt: '', // bound at /token on first use (TOFU) + code_challenge: req.codeChallenge, + scope: req.scope || '', + audience: resourceFor(deps.issuer, vaultId), + match_code: redeem.matchCode, + client_id: req.clientId, + }); + + return { ok: true, status: 200, sessionId: session.session_id, matchCode: redeem.matchCode }; +} + +/** Poll a browser authorization session's approval status. */ +export async function authorizeStatus( + deps: GrantDeps, + sessionId: string +): Promise<{ status: 'pending' | 'approved' | 'denied' | 'expired' | 'unknown'; code?: string }> { + const session = deps.sessions.get(sessionId); + if (!session) return { status: 'unknown' }; + if (session.status === 'expired') return { status: 'expired' }; + if (!session.agent_id) return { status: 'unknown' }; + const approval = await deps.bridge.approvalStatus({ + vaultId: session.vault_id, + agentId: session.agent_id, + }); + if (approval.status === 'approved') return { status: 'approved', code: session.session_id }; + if (approval.status === 'denied' || approval.status === 'revoked') return { status: 'denied' }; + return { status: 'pending' }; +} + +// ---------------------------------------------------------------------------- +// /oauth/token — device_code poll + refresh_token rotation +// ---------------------------------------------------------------------------- + +export interface TokenRequest { + grantType: string; + /** device_code grant */ + deviceCode?: string; + /** authorization_code grant (the code == the approved session id) */ + code?: string; + codeVerifier?: string; + /** refresh_token grant */ + refreshToken?: string; + dpopProof: string; + method: string; + url: string; +} + +export async function processToken(deps: GrantDeps, req: TokenRequest): Promise { + // DPoP is PREFERRED but OPTIONAL. If the client sends a proof we verify it and + // sender-bind the issued token (cnf.jkt). If it doesn't (Bearer-only clients — + // the current reality for Hermes / Claude.ai / ChatGPT), we proceed and issue a + // plain Bearer token. The token stays audience-bound, short-lived, scoped, and + // revocable, and the vault still gates every sensitive action on on-device consent. + let jkt: string | undefined; + if (req.dpopProof) { + try { + const dpop = await verifyDpopProof(req.dpopProof, { + method: req.method, + url: req.url, + jtiGuard: deps.jtiGuard, + }); + jkt = dpop.jkt; + } catch (e) { + if (e instanceof DpopError) return oauthError(400, 'invalid_dpop_proof', e.message); + return oauthError(400, 'invalid_dpop_proof', 'DPoP proof invalid'); + } + } + + if (req.grantType === DEVICE_CODE_GRANT) { + return processDeviceCodeGrant(deps, req, jkt); + } + if (req.grantType === 'authorization_code') { + // Browser auth-code flow: the `code` IS the authorization session id (minted by + // /oauth/authorize AFTER on-device approval). Exchange reuses the device-code + // path: same PKCE + same-DPoP-key + approval gate. + return processDeviceCodeGrant(deps, { ...req, deviceCode: req.code }, jkt); + } + if (req.grantType === 'refresh_token') { + return processRefreshGrant(deps, req, jkt); + } + return oauthError(400, 'unsupported_grant_type'); +} + +const accessTtl = (deps: GrantDeps) => deps.accessTtlSec ?? DEFAULT_ACCESS_TOKEN_TTL_SEC; + +async function processDeviceCodeGrant( + deps: GrantDeps, + req: TokenRequest, + jkt: string | undefined +): Promise { + if (!req.deviceCode || !req.codeVerifier) { + return oauthError(400, 'invalid_request', 'device_code and code_verifier are required'); + } + const session = deps.sessions.get(req.deviceCode); + if (!session) return oauthError(400, 'invalid_grant', 'unknown device_code'); + if (session.status === 'expired') return oauthError(400, 'expired_token'); + if (session.status === 'issued') return oauthError(400, 'invalid_grant', 'device_code already used'); + + // Bind the DPoP key when the client uses DPoP: device-flow set it at /connect; + // browser auth-code binds it here on first use (TOFU). Subsequent calls must + // match. Bearer-only clients (no proof) skip binding and get a Bearer token. + if (jkt) { + const boundJkt = deps.sessions.bindJkt(session.session_id, jkt); + if (boundJkt !== jkt) { + return oauthError(400, 'invalid_grant', 'DPoP key does not match the authorization session'); + } + } else if (session.agent_jkt) { + // The session was opened expecting a DPoP key (set at /connect) but the token + // request omitted the proof — refuse the downgrade. + return oauthError(400, 'invalid_dpop_proof', 'This authorization requires a DPoP proof'); + } + // PKCE binds connect -> token. + if (!verifyPkceS256(req.codeVerifier, session.code_challenge)) { + return oauthError(400, 'invalid_grant', 'PKCE verification failed'); + } + + if (!session.agent_id) return oauthError(400, 'invalid_grant', 'session has no bound agent'); + + const approval = await deps.bridge.approvalStatus({ + vaultId: session.vault_id, + agentId: session.agent_id, + }); + if (approval.status === 'pending') return oauthError(400, 'authorization_pending'); + if (approval.status === 'denied' || approval.status === 'revoked') { + return oauthError(400, 'access_denied'); + } + if (approval.status !== 'approved') return oauthError(400, 'invalid_grant', 'authorization not granted'); + + const scope = approval.scope ?? session.scope ?? ''; + const access = await issueAccessToken({ + keys: deps.keys, + issuer: deps.issuer, + subject: session.agent_id, + audience: session.audience, + vaultId: session.vault_id, + scope, + jkt: session.agent_jkt, + ttlSec: accessTtl(deps), + }); + const { token: refreshToken } = deps.refresh.issue({ + vault_id: session.vault_id, + agent_id: session.agent_id, + jkt: session.agent_jkt, + scope, + audience: session.audience, + }); + deps.sessions.setStatus(session.session_id, 'issued'); + + return { + status: 200, + body: { + access_token: access, + token_type: session.agent_jkt ? 'DPoP' : 'Bearer', + expires_in: accessTtl(deps), + refresh_token: refreshToken, + scope, + }, + }; +} + +async function processRefreshGrant( + deps: GrantDeps, + req: TokenRequest, + jkt: string | undefined +): Promise { + if (!req.refreshToken) return oauthError(400, 'invalid_request', 'refresh_token is required'); + + const rotated = deps.refresh.rotate(req.refreshToken); + if (!rotated.ok || !rotated.context || !rotated.token) { + return oauthError(400, 'invalid_grant', `refresh failed (${rotated.reason || 'unknown'})`); + } + // A DPoP-bound refresh token (context.jkt set) MUST be presented with the same + // key; presenting it with a different/absent key is reuse → kill the chain. + // Bearer-issued refresh tokens (no bound key) rotate without a proof. + if (rotated.context.jkt && rotated.context.jkt !== jkt) { + const chain = deps.refresh.chainFor(rotated.token); + if (chain) deps.refresh.revokeChain(chain); + return oauthError(400, 'invalid_grant', 'DPoP key does not match the refresh token binding'); + } + + const access = await issueAccessToken({ + keys: deps.keys, + issuer: deps.issuer, + subject: rotated.context.agent_id, + audience: rotated.context.audience, + vaultId: rotated.context.vault_id, + scope: rotated.context.scope, + jkt: rotated.context.jkt, + ttlSec: accessTtl(deps), + }); + + return { + status: 200, + body: { + access_token: access, + token_type: rotated.context.jkt ? 'DPoP' : 'Bearer', + expires_in: accessTtl(deps), + refresh_token: rotated.token, + scope: rotated.context.scope, + }, + }; +} diff --git a/packages/dcp-relay/src/oauth/index.ts b/packages/dcp-relay/src/oauth/index.ts new file mode 100644 index 0000000..cad5c45 --- /dev/null +++ b/packages/dcp-relay/src/oauth/index.ts @@ -0,0 +1,91 @@ +/** + * DCP Relay OAuth 2.1 primitives (Cloud-Connect feature). + * + * Building blocks for the relay's per-vault MCP authorization: + * - keys: ES256 token-signing key + JWKS + * - pkce: RFC 7636 S256 verification + * - dpop: RFC 9449 sender-constraint proof verification + * - tokens: short-lived, audience-bound (RFC 8707), DPoP-bound access tokens + * - metadata: RFC 8414 AS metadata + RFC 9728 protected-resource metadata + */ + +export { + initOAuthKeys, + generateOAuthPrivateJwk, + OAUTH_SIGNING_ALG, + type RelayOAuthKeys, +} from './keys.js'; + +export { isValidCodeVerifier, computeS256Challenge, verifyPkceS256 } from './pkce.js'; + +export { + verifyDpopProof, + createJtiGuard, + jwkThumbprint, + DpopError, + type DpopVerifyOptions, + type DpopVerifyResult, + type JtiReplayGuard, +} from './dpop.js'; + +export { + issueAccessToken, + verifyAccessToken, + AccessTokenError, + DEFAULT_ACCESS_TOKEN_TTL_SEC, + type IssueAccessTokenInput, + type VerifyAccessTokenInput, + type AccessTokenClaims, +} from './tokens.js'; + +export { + authorizationServerMetadata, + protectedResourceMetadata, + wwwAuthenticateChallenge, + type AsMetadataOptions, + type PrmOptions, +} from './metadata.js'; + +export { + ClientStore, + AuthSessionStore, + RefreshTokenStore, + type RegisteredClient, + type AuthSession, + type AuthSessionStatus, + type RefreshContext, + type RotateResult, +} from './store.js'; + +export { + UnavailableVaultBridge, + UnavailableMcpBridge, + type VaultConnectBridge, + type BridgeRedeemInput, + type BridgeRedeemResult, + type BridgeApprovalInput, + type BridgeApprovalResult, + type BridgePairingInput, + type McpDataBridge, + type AuthedMcpRequest, +} from './bridge.js'; + +export { + parseConnectLinkRouting, + isConnectLinkToken, + type ConnectLinkRouting, +} from './connect-link-lite.js'; + +export { + processConnect, + processToken, + processAuthorize, + authorizeStatus, + DEVICE_CODE_GRANT, + type GrantDeps, + type GrantResponse, + type ConnectRequest, + type TokenRequest, + type AuthorizeRequest, + type AuthorizeResult, +} from './grant.js'; diff --git a/packages/dcp-relay/src/oauth/keys.ts b/packages/dcp-relay/src/oauth/keys.ts new file mode 100644 index 0000000..28963d4 --- /dev/null +++ b/packages/dcp-relay/src/oauth/keys.ts @@ -0,0 +1,88 @@ +/** + * Relay OAuth signing keys. + * + * The relay's OAuth 2.1 Authorization Server signs access tokens (JWTs) with an + * ES256 (P-256) key. In production the key MUST be stable across restarts so + * issued tokens stay valid — load it from the `DCP_RELAY_OAUTH_PRIVATE_JWK` + * secret. If absent (dev/test), an ephemeral key is generated and a warning is + * emitted. The public half is published at the JWKS endpoint for verification. + * + * NOTE: this key NEVER touches vault key material. It only authenticates which + * agent/token may submit requests to a vault; the vault itself still enforces + * per-action on-device consent. (Relay = untrusted availability infra.) + */ + +import { + generateKeyPair, + exportJWK, + importJWK, + calculateJwkThumbprint, + type JWK, + type KeyLike, +} from 'jose'; + +export const OAUTH_SIGNING_ALG = 'ES256' as const; + +export interface RelayOAuthKeys { + alg: typeof OAUTH_SIGNING_ALG; + /** Key id (JWK thumbprint) — set in JWT headers and the published JWK. */ + kid: string; + /** Private signing key (never exported). */ + privateKey: KeyLike; + /** Public verification key. */ + publicKey: KeyLike; + /** Public JWK (with kid/alg/use) for the JWKS endpoint. */ + publicJwk: JWK; + /** RFC 7517 JWK Set document. */ + jwks(): { keys: JWK[] }; +} + +/** + * Initialise the relay OAuth signing keys. + * @param opts.privateJwk - JSON string of a private JWK (from env secret) to reuse. + * @param opts.warn - sink for the ephemeral-key warning (defaults to console.warn). + */ +export async function initOAuthKeys(opts?: { + privateJwk?: string; + warn?: (msg: string) => void; +}): Promise { + let privateKey: KeyLike; + let publicKey: KeyLike; + + if (opts?.privateJwk) { + const jwk = JSON.parse(opts.privateJwk) as JWK; + privateKey = (await importJWK(jwk, OAUTH_SIGNING_ALG)) as KeyLike; + // Derive the public key by stripping the private component. + const { d: _omit, ...pub } = jwk; + publicKey = (await importJWK({ ...pub, alg: OAUTH_SIGNING_ALG }, OAUTH_SIGNING_ALG)) as KeyLike; + } else { + const warn = opts?.warn || ((m: string) => console.warn(m)); + warn( + '[relay/oauth] No DCP_RELAY_OAUTH_PRIVATE_JWK set — generating an EPHEMERAL signing key. ' + + 'Access tokens will be invalidated on restart. Set the secret for production.' + ); + const kp = await generateKeyPair(OAUTH_SIGNING_ALG, { extractable: true }); + privateKey = kp.privateKey as KeyLike; + publicKey = kp.publicKey as KeyLike; + } + + const publicJwkRaw = await exportJWK(publicKey); + const kid = await calculateJwkThumbprint(publicJwkRaw, 'sha256'); + const publicJwk: JWK = { ...publicJwkRaw, alg: OAUTH_SIGNING_ALG, use: 'sig', kid }; + + return { + alg: OAUTH_SIGNING_ALG, + kid, + privateKey, + publicKey, + publicJwk, + jwks: () => ({ keys: [publicJwk] }), + }; +} + +/** Export a freshly-generated private JWK string — helper for provisioning the secret. */ +export async function generateOAuthPrivateJwk(): Promise { + const kp = await generateKeyPair(OAUTH_SIGNING_ALG, { extractable: true }); + const jwk = await exportJWK(kp.privateKey); + return JSON.stringify({ ...jwk, alg: OAUTH_SIGNING_ALG, use: 'sig' }); +} diff --git a/packages/dcp-relay/src/oauth/metadata.ts b/packages/dcp-relay/src/oauth/metadata.ts new file mode 100644 index 0000000..7cb4ca3 --- /dev/null +++ b/packages/dcp-relay/src/oauth/metadata.ts @@ -0,0 +1,71 @@ +/** + * OAuth discovery metadata. + * - Authorization Server Metadata (RFC 8414): /.well-known/oauth-authorization-server + * - Protected Resource Metadata (RFC 9728): /.well-known/oauth-protected-resource[/] + * + * MCP clients discover how to authenticate by fetching these. We advertise only + * what we actually enforce: authorization_code + PKCE(S256), refresh_token, DCR, + * DPoP, and bearer methods. No implicit/password/plain — OAuth 2.1 hardening. + */ + +export interface AsMetadataOptions { + /** Public base URL of the relay (the OAuth issuer), e.g. https://relay.example. No trailing slash. */ + issuer: string; + scopesSupported?: string[]; +} + +export function authorizationServerMetadata(opts: AsMetadataOptions): Record { + const base = opts.issuer.replace(/\/+$/, ''); + return { + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/oauth/token`, + registration_endpoint: `${base}/oauth/register`, + revocation_endpoint: `${base}/oauth/revoke`, + jwks_uri: `${base}/oauth/jwks`, + scopes_supported: opts.scopesSupported ?? [], + response_types_supported: ['code'], + response_modes_supported: ['query'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + dpop_signing_alg_values_supported: ['ES256', 'ES384', 'EdDSA', 'RS256', 'PS256'], + revocation_endpoint_auth_methods_supported: ['none'], + // OAuth 2.1: PKCE required, no implicit grant, no plain challenge. + require_pushed_authorization_requests: false, + }; +} + +export interface PrmOptions { + /** The protected resource identifier (the per-vault MCP URL). */ + resource: string; + /** The authorization server issuer(s) that protect this resource. */ + authorizationServers: string[]; + scopesSupported?: string[]; +} + +export function protectedResourceMetadata(opts: PrmOptions): Record { + return { + resource: opts.resource, + authorization_servers: opts.authorizationServers, + bearer_methods_supported: ['header'], + scopes_supported: opts.scopesSupported ?? [], + // Tokens are DPoP-bound; advertise the proof algs the resource accepts. + dpop_signing_alg_values_supported: ['ES256', 'ES384', 'EdDSA', 'RS256', 'PS256'], + }; +} + +/** + * Build the `WWW-Authenticate` header value for a 401 from the MCP resource, + * pointing clients at the Protected Resource Metadata (RFC 9728 §5.1). + */ +export function wwwAuthenticateChallenge(opts: { + resourceMetadataUrl: string; + error?: string; + errorDescription?: string; +}): string { + let header = `DPoP realm="DCP", resource_metadata="${opts.resourceMetadataUrl}"`; + if (opts.error) header += `, error="${opts.error}"`; + if (opts.errorDescription) header += `, error_description="${opts.errorDescription}"`; + return header; +} diff --git a/packages/dcp-relay/src/oauth/pkce.ts b/packages/dcp-relay/src/oauth/pkce.ts new file mode 100644 index 0000000..78072a9 --- /dev/null +++ b/packages/dcp-relay/src/oauth/pkce.ts @@ -0,0 +1,37 @@ +/** + * PKCE (RFC 7636) — S256 only. + * + * OAuth 2.1 requires PKCE. We support ONLY the S256 method (never `plain`), + * so a code challenge cannot be downgraded. + */ + +import { createHash, timingSafeEqual } from 'crypto'; + +const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/; + +/** Validate a code_verifier per RFC 7636 §4.1 (43–128 unreserved chars). */ +export function isValidCodeVerifier(verifier: string): boolean { + return typeof verifier === 'string' && VERIFIER_RE.test(verifier); +} + +/** Compute the S256 code_challenge for a verifier: base64url(SHA-256(verifier)). */ +export function computeS256Challenge(verifier: string): string { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +/** + * Verify a code_verifier against a stored S256 code_challenge (constant-time). + * Returns false for malformed input rather than throwing. + */ +export function verifyPkceS256(codeVerifier: string, codeChallenge: string): boolean { + if (!isValidCodeVerifier(codeVerifier)) return false; + if (typeof codeChallenge !== 'string' || codeChallenge.length === 0) return false; + const expected = Buffer.from(computeS256Challenge(codeVerifier), 'utf8'); + const given = Buffer.from(codeChallenge, 'utf8'); + if (expected.length !== given.length) return false; + try { + return timingSafeEqual(expected, given); + } catch { + return false; + } +} diff --git a/packages/dcp-relay/src/oauth/store.ts b/packages/dcp-relay/src/oauth/store.ts new file mode 100644 index 0000000..4e1a228 --- /dev/null +++ b/packages/dcp-relay/src/oauth/store.ts @@ -0,0 +1,280 @@ +/** + * In-memory state for the relay OAuth Authorization Server. + * + * - ClientStore: Dynamic Client Registration (RFC 7591). Self-registered + * clients are UNTRUSTED display strings; the real gate is + * the on-device approval at the vault (Rule #8). + * - AuthSessionStore: connect-link-driven authorization sessions (device-flow- + * like): pending_approval -> approved -> issued, bound to the + * agent's DPoP key + PKCE challenge. + * - RefreshTokenStore: rotating refresh tokens with REUSE DETECTION — presenting + * an already-rotated token revokes the whole chain (Rule #3). + * + * All records are TTL'd and swept lazily. This is process-local (one relay node); + * a multi-node deployment would back these with shared storage. + */ + +import { randomUUID, randomBytes, createHash } from 'crypto'; + +const sha256Hex = (s: string): string => createHash('sha256').update(s).digest('hex'); + +// ============================================================================ +// Dynamic Client Registration (RFC 7591) +// ============================================================================ + +export interface RegisteredClient { + client_id: string; + client_name?: string; + redirect_uris: string[]; + token_endpoint_auth_method: 'none'; + created_at: number; +} + +export class ClientStore { + private clients = new Map(); + + /** Register a public client. Display strings are stored but treated as untrusted. */ + register(input: { client_name?: string; redirect_uris?: string[] }): RegisteredClient { + const client: RegisteredClient = { + client_id: `dcpc_${randomUUID().replace(/-/g, '')}`, + client_name: typeof input.client_name === 'string' ? input.client_name.slice(0, 200) : undefined, + redirect_uris: Array.isArray(input.redirect_uris) + ? input.redirect_uris.filter((u) => typeof u === 'string').slice(0, 10) + : [], + token_endpoint_auth_method: 'none', + created_at: Date.now(), + }; + this.clients.set(client.client_id, client); + return client; + } + + get(clientId: string): RegisteredClient | undefined { + return this.clients.get(clientId); + } +} + +// ============================================================================ +// Authorization sessions (connect-link-driven) +// ============================================================================ + +export type AuthSessionStatus = + | 'pending_approval' + | 'approved' + | 'issued' + | 'denied' + | 'expired'; + +export interface AuthSession { + session_id: string; + vault_id: string; + link_id: string; + agent_id?: string; + /** DPoP JWK thumbprint the eventual token is bound to. */ + agent_jkt: string; + /** PKCE S256 challenge supplied at connect; verified at token issuance. */ + code_challenge: string; + scope: string; + audience: string; + match_code?: string; + client_id?: string; + status: AuthSessionStatus; + created_at: number; + expires_at: number; +} + +export class AuthSessionStore { + private sessions = new Map(); + + constructor(private ttlMs = 15 * 60 * 1000) {} + + create(input: { + vault_id: string; + link_id: string; + agent_jkt: string; + code_challenge: string; + scope: string; + audience: string; + match_code?: string; + client_id?: string; + agent_id?: string; + }): AuthSession { + this.sweep(); + const now = Date.now(); + const session: AuthSession = { + session_id: `as_${randomUUID().replace(/-/g, '')}`, + vault_id: input.vault_id, + link_id: input.link_id, + agent_id: input.agent_id, + agent_jkt: input.agent_jkt, + code_challenge: input.code_challenge, + scope: input.scope, + audience: input.audience, + match_code: input.match_code, + client_id: input.client_id, + status: 'pending_approval', + created_at: now, + expires_at: now + this.ttlMs, + }; + this.sessions.set(session.session_id, session); + return session; + } + + get(sessionId: string): AuthSession | undefined { + const s = this.sessions.get(sessionId); + if (!s) return undefined; + if (s.expires_at <= Date.now() && s.status !== 'issued') { + s.status = 'expired'; + } + return s; + } + + setStatus(sessionId: string, status: AuthSessionStatus, agentId?: string): boolean { + const s = this.sessions.get(sessionId); + if (!s) return false; + s.status = status; + if (agentId) s.agent_id = agentId; + return true; + } + + /** + * Bind the DPoP key thumbprint on first use (browser auth-code flow, where the + * key is established at /token rather than at /authorize). No-op if already bound. + * Returns the effective jkt (existing or newly bound). + */ + bindJkt(sessionId: string, jkt: string): string | null { + const s = this.sessions.get(sessionId); + if (!s) return null; + if (!s.agent_jkt) s.agent_jkt = jkt; + return s.agent_jkt; + } + + private sweep(): void { + const now = Date.now(); + for (const [id, s] of this.sessions) { + if (s.expires_at + 5 * 60 * 1000 <= now) this.sessions.delete(id); + } + } +} + +// ============================================================================ +// Rotating refresh tokens with reuse detection (Rule #3) +// ============================================================================ + +export interface RefreshContext { + vault_id: string; + agent_id: string; + jkt: string; + scope: string; + audience: string; +} + +interface RefreshRecord extends RefreshContext { + token_hash: string; + chain_id: string; + created_at: number; + expires_at: number; + /** True once this token has been rotated (a fresh one issued from it). */ + used: boolean; + /** True once the chain was revoked (reuse / explicit / agent revoke). */ + revoked: boolean; +} + +export interface RotateResult { + ok: boolean; + reason?: 'not_found' | 'expired' | 'revoked' | 'reuse_detected'; + token?: string; + context?: RefreshContext; +} + +export class RefreshTokenStore { + private byHash = new Map(); + /** chain_id -> revoked. A revoked chain rejects all its tokens. */ + private revokedChains = new Set(); + + constructor(private ttlMs = 30 * 24 * 60 * 60 * 1000) {} + + /** Issue the first refresh token of a NEW chain. */ + issue(ctx: RefreshContext): { token: string; chain_id: string } { + const chain_id = `rc_${randomUUID().replace(/-/g, '')}`; + const token = this.mint(ctx, chain_id); + return { token, chain_id }; + } + + private mint(ctx: RefreshContext, chain_id: string): string { + this.sweep(); + const token = `dcprt_${randomBytes(32).toString('base64url')}`; + const now = Date.now(); + this.byHash.set(sha256Hex(token), { + ...ctx, + token_hash: sha256Hex(token), + chain_id, + created_at: now, + expires_at: now + this.ttlMs, + used: false, + revoked: false, + }); + return token; + } + + private sweep(): void { + const now = Date.now(); + for (const [hash, rec] of this.byHash) { + // Drop expired records, and revoked records after a short grace (so a + // legit client still gets an informative 'revoked' for a while). + if (rec.expires_at <= now) this.byHash.delete(hash); + else if (rec.revoked && now - rec.created_at > 60 * 60 * 1000) this.byHash.delete(hash); + } + } + + /** + * Rotate a refresh token. On success returns a fresh token (same chain) and the + * bound context. If a token that was ALREADY rotated is presented again, that is + * reuse — the ENTIRE chain is revoked and rotation fails (Rule #3). + */ + rotate(presentedToken: string): RotateResult { + const rec = this.byHash.get(sha256Hex(presentedToken)); + if (!rec) return { ok: false, reason: 'not_found' }; + if (rec.revoked || this.revokedChains.has(rec.chain_id)) return { ok: false, reason: 'revoked' }; + if (rec.expires_at <= Date.now()) return { ok: false, reason: 'expired' }; + if (rec.used) { + // Replay of a spent token -> revoke the whole family. + this.revokeChain(rec.chain_id); + return { ok: false, reason: 'reuse_detected' }; + } + rec.used = true; + const ctx: RefreshContext = { + vault_id: rec.vault_id, + agent_id: rec.agent_id, + jkt: rec.jkt, + scope: rec.scope, + audience: rec.audience, + }; + const token = this.mint(ctx, rec.chain_id); + return { ok: true, token, context: ctx }; + } + + /** Revoke an entire refresh-token chain (e.g. on reuse or explicit revoke). */ + revokeChain(chainId: string): void { + this.revokedChains.add(chainId); + for (const rec of this.byHash.values()) { + if (rec.chain_id === chainId) rec.revoked = true; + } + } + + /** Revoke all chains bound to an agent (instant-revoke path, Rule #7). */ + revokeByAgent(agentId: string, vaultId?: string): number { + const chains = new Set(); + for (const rec of this.byHash.values()) { + if (rec.agent_id === agentId && (!vaultId || rec.vault_id === vaultId)) { + chains.add(rec.chain_id); + } + } + for (const c of chains) this.revokeChain(c); + return chains.size; + } + + /** Look up the chain for a live (un-rotated) token — for revocation endpoints. */ + chainFor(token: string): string | undefined { + return this.byHash.get(sha256Hex(token))?.chain_id; + } +} diff --git a/packages/dcp-relay/src/oauth/tokens.ts b/packages/dcp-relay/src/oauth/tokens.ts new file mode 100644 index 0000000..0aea08f --- /dev/null +++ b/packages/dcp-relay/src/oauth/tokens.ts @@ -0,0 +1,128 @@ +/** + * Access tokens — short-lived, DPoP-bound, audience-bound JWTs. + * + * Each access token is: + * - signed by the relay AS (ES256), + * - bound to ONE vault via the `aud` / resource indicator (RFC 8707) so a token + * for vault A is rejected by vault B, + * - sender-constrained via `cnf.jkt` (the DPoP key thumbprint, RFC 9449), + * - short-lived (~10 min) — refresh tokens (rotating, reuse-detected) handle renewal. + */ + +import { SignJWT, jwtVerify } from 'jose'; +import { randomUUID } from 'crypto'; +import type { RelayOAuthKeys } from './keys.js'; + +/** Default access-token lifetime: 10 minutes. */ +export const DEFAULT_ACCESS_TOKEN_TTL_SEC = 10 * 60; + +export interface IssueAccessTokenInput { + keys: RelayOAuthKeys; + issuer: string; + /** Agent id (subject). */ + subject: string; + /** Audience = the per-vault resource (e.g. https://relay/v//mcp). RFC 8707. */ + audience: string; + /** The vault this token may act on. */ + vaultId: string; + /** Granted scope string (space-delimited). */ + scope: string; + /** + * DPoP JWK thumbprint to sender-bind the token (cnf.jkt, RFC 9449). OPTIONAL: + * when the client supports DPoP we bind the token to its key (preferred — + * stops token replay). When omitted (the client is Bearer-only, as most current + * MCP clients are), we issue a plain Bearer token — still audience-bound, short- + * lived, scoped, and revocable, with on-device consent gating every sensitive op. + */ + jkt?: string; + ttlSec?: number; +} + +export interface AccessTokenClaims { + iss: string; + sub: string; + aud: string; + vault_id: string; + scope: string; + /** Present only for DPoP-bound (sender-constrained) tokens. */ + cnf?: { jkt: string }; + jti: string; + iat: number; + exp: number; +} + +/** Issue a signed, audience-bound access token (DPoP-bound when a jkt is given). */ +export async function issueAccessToken(input: IssueAccessTokenInput): Promise { + const ttl = input.ttlSec ?? DEFAULT_ACCESS_TOKEN_TTL_SEC; + const claims: Record = { + scope: input.scope, + vault_id: input.vaultId, + }; + if (input.jkt) claims.cnf = { jkt: input.jkt }; + return new SignJWT(claims) + .setProtectedHeader({ alg: input.keys.alg, kid: input.keys.kid, typ: 'at+jwt' }) + .setIssuer(input.issuer) + .setSubject(input.subject) + .setAudience(input.audience) + .setIssuedAt() + .setJti(randomUUID()) + .setExpirationTime(`${ttl}s`) + .sign(input.keys.privateKey); +} + +export interface VerifyAccessTokenInput { + keys: RelayOAuthKeys; + token: string; + issuer: string; + /** The resource the token is being presented to — must equal `aud` (RFC 8707). */ + expectedAudience: string; + /** DPoP thumbprint from the accompanying proof — must equal `cnf.jkt`. */ + expectedJkt?: string; +} + +export class AccessTokenError extends Error { + constructor( + public code: string, + message: string + ) { + super(message); + this.name = 'AccessTokenError'; + } +} + +/** + * Verify an access token: signature, issuer, audience binding, and (when given) + * the DPoP sender-binding. Throws AccessTokenError on any mismatch. + */ +export async function verifyAccessToken( + input: VerifyAccessTokenInput +): Promise { + let payload; + try { + ({ payload } = await jwtVerify(input.token, input.keys.publicKey, { + issuer: input.issuer, + audience: input.expectedAudience, + algorithms: [input.keys.alg], + typ: 'at+jwt', + })); + } catch (e) { + throw new AccessTokenError('invalid_token', `Access token invalid: ${(e as Error).message}`); + } + + // DPoP-bound tokens carry cnf.jkt and MUST match the presented proof key. Bearer + // tokens (no cnf) are accepted without a proof — the caller decides whether a + // given resource requires sender-binding. A DPoP-bound token presented WITHOUT a + // proof key (expectedJkt undefined) is rejected, so a stolen DPoP token can't be + // downgraded to Bearer use. + const cnf = payload.cnf as { jkt?: string } | undefined; + if (cnf && typeof cnf.jkt === 'string') { + if (input.expectedJkt === undefined) { + throw new AccessTokenError('invalid_token', 'DPoP-bound token requires a DPoP proof'); + } + if (cnf.jkt !== input.expectedJkt) { + throw new AccessTokenError('invalid_token', 'DPoP key does not match token binding (cnf.jkt)'); + } + } + + return payload as unknown as AccessTokenClaims; +} diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index 0c07637..a95bc5b 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -21,6 +21,7 @@ import type { FastifyPluginCallback, FastifyPluginOptions, RouteShorthandOptions import fastifyWebsocket from '@fastify/websocket'; import fastifyCors from '@fastify/cors'; import type { WebSocket } from 'ws'; +import { randomUUID } from 'crypto'; import { ed25519 } from '@noble/curves/ed25519'; import type { RelayConfig, @@ -45,7 +46,39 @@ import { RELAY_VERSION, } from './types.js'; import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore, MobilePairingStore, PushTokenStore } from './store.js'; -import { authenticateRegistration, authenticateRequest, closeAuth, type AuthConfig } from './auth.js'; +import { + authenticateRegistration, + authenticateRequest, + verifyRegistrationSignature, + closeAuth, + type AuthConfig, +} from './auth.js'; +import { + initOAuthKeys, + authorizationServerMetadata, + protectedResourceMetadata, + createJtiGuard, + ClientStore, + AuthSessionStore, + RefreshTokenStore, + processConnect, + processToken, + processAuthorize, + authorizeStatus, + verifyAccessToken, + verifyDpopProof, + wwwAuthenticateChallenge, + type RelayOAuthKeys, + type JtiReplayGuard, + type VaultConnectBridge, + type McpDataBridge, + type GrantDeps, + type BridgeRedeemInput, + type BridgeRedeemResult, + type BridgeApprovalInput, + type BridgeApprovalResult, + type BridgePairingInput, +} from './oauth/index.js'; // ============================================================================ // Relay Server @@ -69,9 +102,61 @@ export class RelayServer { /** invite_id -> vault_id mapping (populated when vault registers) */ private inviteVaultMap: Map = new Map(); - constructor(config: Partial & { authConfig?: AuthConfig } = {}) { + // --- OAuth 2.1 Authorization Server (Cloud-Connect) --- + private oauthKeysPromise: Promise | null = null; + private oauthClients = new ClientStore(); + private oauthSessions = new AuthSessionStore(); + private oauthRefresh = new RefreshTokenStore(); + private oauthJti: JtiReplayGuard = createJtiGuard(); + /** Control-plane bridge to the vault that issued a connect-link (WS-backed in prod). */ + private vaultBridge: VaultConnectBridge; + /** Data-plane bridge that forwards authorized MCP requests to the vault. */ + private mcpBridge: McpDataBridge; + /** Instant-revoke denylist of agent ids (kills live access tokens; Rule #7). */ + private revokedAgents = new Set(); + /** + * Trust-on-first-use binding of vault_id -> signing public key. The first key to + * register a vault_id owns it; a DIFFERENT key claiming that id is rejected. This + * stops an attacker who knows a vault_id from hijacking its routing or draining the + * HTTP-fallback queue. In-memory per relay instance (a restart re-binds on reconnect). + */ + private vaultKeyBindings = new Map(); + private readonly VAULT_BINDING_TTL_MS = 7 * 24 * 60 * 60 * 1000; + /** Pending cloud-connect control requests awaiting a vault reply (control_id -> waiter). + * `vaultId` records which vault the request was sent to, so only THAT vault's socket + * may resolve it (a different vault cannot spoof another's control reply). */ + private pendingControl = new Map< + string, + { vaultId: string; resolve: (v: Record) => void; timer: ReturnType } + >(); + /** agent_id -> owning vault_id, so cloud_connect_revoke can only revoke own agents. */ + private agentVault = new Map(); + /** Per-vault timestamps of forwarded link-less pair requests (anti prompt-spam). */ + private pairRequestTimes = new Map(); + private readonly PAIR_REQ_WINDOW_MS = 60_000; + private readonly PAIR_REQ_MAX_PER_WINDOW = 5; + + constructor( + config: Partial & { + authConfig?: AuthConfig; + vaultBridge?: VaultConnectBridge; + mcpBridge?: McpDataBridge; + } = {} + ) { this.config = { ...DEFAULT_RELAY_CONFIG, ...config }; this.authConfig = config.authConfig ?? { requirePairingToken: false }; + // Default to the WS-backed bridge (talks to the connected vault over /ws); + // tests inject a fake. UnavailableVaultBridge is only a typing fallback. + this.vaultBridge = + config.vaultBridge ?? { + redeem: (input) => this.wsBridgeRedeem(input), + approvalStatus: (input) => this.wsBridgeApprovalStatus(input), + requestPairing: (input) => this.wsBridgeRequestPairing(input), + }; + // Default to the WS-backed MCP forwarder (vanilla clients: relay terminates + // and forwards to the vault over /ws). DCP-aware agents wanting full E2E + // blindness use the existing encrypted /relay/request path instead. + this.mcpBridge = config.mcpBridge ?? { forward: (req) => this.wsMcpForward(req) }; this.messageStore = new MessageStore(this.config); this.connectionStore = new ConnectionStore(); this.rateLimiter = new RateLimiter( @@ -101,6 +186,27 @@ export class RelayServer { // -------------------------------------------------------------------------- async start(): Promise { + // OAuth 2.1 token/registration/revocation requests are sent as + // `application/x-www-form-urlencoded` by standards-compliant clients + // (RFC 6749 §4.1.3 / Hermes / Claude.ai / ChatGPT). Fastify only parses JSON + // out of the box, so without this a real client's /oauth/token POST fails with + // 415 and the whole flow stalls. Parse form bodies into the same plain-object + // shape the JSON handlers expect (JSON parsing stays enabled for our own agents). + this.server.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (_req: FastifyRequest, body: string | Buffer, done: (err: Error | null, value?: unknown) => void) => { + try { + const params = new URLSearchParams(typeof body === 'string' ? body : body.toString('utf8')); + const obj: Record = {}; + for (const [k, v] of params) obj[k] = v; + done(null, obj); + } catch (err) { + done(err as Error); + } + } + ); + await this.server.register( fastifyCors as unknown as FastifyPluginCallback, { origin: true } @@ -139,6 +245,9 @@ export class RelayServer { this.clientRequestMap.clear(); this.clientRequestsBySocket.clear(); + for (const { timer } of this.pendingControl.values()) clearTimeout(timer); + this.pendingControl.clear(); + this.messageStore.close(); this.rateLimiter.close(); this.pairingClaimStore.close(); @@ -147,6 +256,291 @@ export class RelayServer { await this.server.close(); } + // -------------------------------------------------------------------------- + // OAuth helpers (Cloud-Connect) + // -------------------------------------------------------------------------- + + /** Lazily initialise + memoize the OAuth signing keys (async, sync constructor). */ + private getOAuthKeys(): Promise { + if (!this.oauthKeysPromise) { + this.oauthKeysPromise = initOAuthKeys({ + privateJwk: process.env.DCP_RELAY_OAUTH_PRIVATE_JWK, + }); + } + return this.oauthKeysPromise; + } + + // --- Cloud-Connect control channel (relay -> vault over the /ws socket) --- + + /** + * Send a cloud-connect control message to a connected vault and await its + * correlated reply. Rejects if the vault is offline or does not reply in time. + */ + private sendCloudConnectControl( + vaultId: string, + type: 'cloud_connect_redeem' | 'cloud_connect_status' | 'cloud_connect_mcp' | 'cloud_connect_pair', + payload: Record, + timeoutMs = 10_000 + ): Promise> { + const sock = this.wsConnections.get(vaultId); + if (!sock || sock.readyState !== 1) { + return Promise.reject(new Error('vault_offline')); + } + const controlId = randomUUID(); + const promise = new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingControl.delete(controlId); + reject(new Error('control_timeout')); + }, timeoutMs); + this.pendingControl.set(controlId, { vaultId, resolve, timer }); + }); + const msg: WsMessage = { + type, + payload: { control_id: controlId, ...payload }, + timestamp: new Date().toISOString(), + }; + sock.send(JSON.stringify(msg)); + return promise; + } + + /** + * Resolve a pending control request when the vault's reply arrives. Only the vault + * the request was SENT to may resolve it — a reply from any other vault socket is + * ignored (prevents one vault spoofing another's control reply). + */ + private resolveControl( + controlId: string, + payload: Record, + fromVaultId: string | null + ): void { + const waiter = this.pendingControl.get(controlId); + if (!waiter) return; + if (fromVaultId !== waiter.vaultId) return; // not the vault we asked + clearTimeout(waiter.timer); + this.pendingControl.delete(controlId); + waiter.resolve(payload); + } + + private async wsBridgeRedeem(input: BridgeRedeemInput): Promise { + try { + const res = await this.sendCloudConnectControl(input.vaultId, 'cloud_connect_redeem', { + connect_link: input.connectLink, + redeemer_key: input.redeemerKey, + source_ip: input.sourceIp, + }); + const agentId = res.agent_id as string | undefined; + if (res.ok && agentId) this.agentVault.set(agentId, input.vaultId); + return { + ok: Boolean(res.ok), + agentId, + matchCode: res.match_code as string | undefined, + reason: res.reason as string | undefined, + }; + } catch { + return { ok: false, reason: 'vault_unreachable' }; + } + } + + /** Link-less pairing (paste-URL flow): ask the vault to open an on-device approval. */ + private async wsBridgeRequestPairing(input: BridgePairingInput): Promise { + // Rate-limit per target vault so an attacker who knows a vault_id can't spam the + // owner's device with approval prompts. (The vault also gates on its pairing + // window + its own limit; this stops the forwarding flood at the relay.) + const now = Date.now(); + const hits = (this.pairRequestTimes.get(input.vaultId) || []).filter( + (t) => now - t < this.PAIR_REQ_WINDOW_MS + ); + if (hits.length >= this.PAIR_REQ_MAX_PER_WINDOW) { + return { ok: false, reason: 'rate_limited' }; + } + hits.push(now); + this.pairRequestTimes.set(input.vaultId, hits); + try { + const res = await this.sendCloudConnectControl(input.vaultId, 'cloud_connect_pair', { + redeemer_key: input.redeemerKey, + source_ip: input.sourceIp, + client_name: input.clientName, + scope: input.scope, + }); + const agentId = res.agent_id as string | undefined; + if (res.ok && agentId) this.agentVault.set(agentId, input.vaultId); + return { + ok: Boolean(res.ok), + agentId, + matchCode: res.match_code as string | undefined, + reason: res.reason as string | undefined, + }; + } catch { + return { ok: false, reason: 'vault_unreachable' }; + } + } + + private async wsBridgeApprovalStatus(input: BridgeApprovalInput): Promise { + try { + const res = await this.sendCloudConnectControl(input.vaultId, 'cloud_connect_status', { + agent_id: input.agentId, + }); + const status = res.status as BridgeApprovalResult['status']; + return { + status: status || 'unknown', + scope: res.scope as string | undefined, + budget: res.budget as BridgeApprovalResult['budget'], + }; + } catch { + return { status: 'unknown' }; + } + } + + /** Forward an authorized MCP request to the vault over the WS control channel. */ + private async wsMcpForward(req: { + vaultId: string; + agentId: string; + scope: string; + jkt: string; + body: unknown; + }): Promise<{ status: number; body: unknown }> { + try { + const res = await this.sendCloudConnectControl( + req.vaultId, + 'cloud_connect_mcp', + { agent_id: req.agentId, scope: req.scope, jkt: req.jkt, body: req.body }, + 30_000 + ); + const status = typeof res.status === 'number' ? res.status : 200; + return { status, body: res.body ?? res }; + } catch { + return { + status: 503, + body: { error: 'vault_unreachable', error_description: 'The vault did not respond' }, + }; + } + } + + /** + * Bind a vault_id to its signing key on first sight; on later sights require the + * SAME key. Returns false if a different key already owns the id (hijack attempt). + */ + private bindOrVerifyVaultKey(vaultId: string, signingKey: string): boolean { + const now = Date.now(); + for (const [k, v] of this.vaultKeyBindings) { + if (now - v.lastSeen > this.VAULT_BINDING_TTL_MS) this.vaultKeyBindings.delete(k); + } + const existing = this.vaultKeyBindings.get(vaultId); + if (!existing) { + this.vaultKeyBindings.set(vaultId, { key: signingKey, lastSeen: now }); + return true; + } + if (existing.key !== signingKey) return false; + existing.lastSeen = now; + return true; + } + + /** True iff `signingKey` is the bound owner of `vaultId`. */ + private vaultKeyOwner(vaultId: string, signingKey: string): boolean { + const e = this.vaultKeyBindings.get(vaultId); + return !!e && e.key === signingKey; + } + + /** + * Verify a registration-style signed proof that the caller owns `vault_id`, for the + * unauthenticated HTTP-fallback endpoints (poll/respond). Checks the Ed25519 + * signature over vault_id||timestamp||nonce (single-use nonce) AND that the signing + * key is the bound owner of the vault. Fails closed on anything missing/invalid. + */ + private verifyVaultProof( + p: { + vault_id?: string; + signing_public_key?: string; + timestamp?: string; + nonce?: string; + signature?: string; + }, + mode: 'strict' | 'bind' = 'strict' + ): boolean { + if (!p.vault_id || !p.signing_public_key || !p.timestamp || !p.nonce || !p.signature) { + return false; + } + try { + verifyRegistrationSignature({ + vault_id: p.vault_id, + public_key: '', + signing_public_key: p.signing_public_key, + timestamp: p.timestamp, + nonce: p.nonce, + signature: p.signature, + } as RegisterPayload); + } catch { + return false; + } + // 'strict': the vault must already be bound (via WS registration). 'bind': allow + // trust-on-first-use binding (for HTTP-only vaults that register status before WS). + return mode === 'bind' + ? this.bindOrVerifyVaultKey(p.vault_id, p.signing_public_key) + : this.vaultKeyOwner(p.vault_id, p.signing_public_key); + } + + private revokedAgentKey(vaultId: string, agentId: string): string { + return `${vaultId}\0${agentId}`; + } + + private isAgentRevoked(vaultId: string, agentId: string): boolean { + return ( + this.revokedAgents.has(this.revokedAgentKey(vaultId, agentId)) || + // Back-compat for direct test/internal callers that intentionally revoke globally. + this.revokedAgents.has(agentId) + ); + } + + /** + * Instantly revoke a cloud agent's access (Rule #7): denylist its access tokens + * AND kill its refresh-token chains. Called when the vault owner revokes. + */ + revokeAgentAccess(agentId: string, vaultId?: string): void { + if (!agentId) return; + if (vaultId) { + this.revokedAgents.add(this.revokedAgentKey(vaultId, agentId)); + this.oauthRefresh.revokeByAgent(agentId, vaultId); + } else { + this.revokedAgents.add(agentId); + this.oauthRefresh.revokeByAgent(agentId); + } + } + + /** Assemble the per-request OAuth grant dependencies. */ + private async grantDeps(request: FastifyRequest): Promise { + return { + keys: await this.getOAuthKeys(), + sessions: this.oauthSessions, + refresh: this.oauthRefresh, + jtiGuard: this.oauthJti, + bridge: this.vaultBridge, + issuer: this.baseUrl(request), + }; + } + + /** + * Public base URL (OAuth issuer / MCP resource origin). Prefers the configured + * 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(/\/+$/, ''); + const proto = (request.headers['x-forwarded-proto'] as string) || request.protocol || 'http'; + const host = request.headers['host'] || `${this.config.host}:${this.config.port}`; + return `${proto}://${host}`; + } + + /** + * Extract the vault id from an MCP resource URL (`.../v//mcp`), used by + * the link-less paste-URL flow (RFC 8707 `resource` parameter). Returns null if + * the string is not a well-formed per-vault MCP resource. Untrusted: the vault is + * still the authorization authority (it runs the on-device approval). + */ + private vaultIdFromResource(resource: string | undefined): string | null { + if (!resource || typeof resource !== 'string') return null; + const m = /\/v\/([A-Za-z0-9_-]{8,})\/mcp\/?$/.exec(resource.trim()); + return m ? m[1] : null; + } + // -------------------------------------------------------------------------- // HTTP Routes (REST + Long-Poll) // -------------------------------------------------------------------------- @@ -159,6 +553,328 @@ export class RelayServer { timestamp: new Date().toISOString(), })); + // ---------------------------------------------------------------------- + // OAuth 2.1 discovery + client management (Cloud-Connect) + // ---------------------------------------------------------------------- + + // Authorization Server Metadata (RFC 8414). + 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) }); + }); + + // Protected Resource Metadata (RFC 9728) for a per-vault MCP resource. + this.server.get<{ Params: { vaultId: string } }>( + '/.well-known/oauth-protected-resource/v/:vaultId/mcp', + async (request, reply) => { + const base = this.baseUrl(request); + reply.header('cache-control', 'public, max-age=300'); + return protectedResourceMetadata({ + resource: `${base}/v/${request.params.vaultId}/mcp`, + authorizationServers: [base], + }); + } + ); + + // JWKS — public verification keys for issued access tokens. + this.server.get('/oauth/jwks', async (_request, reply) => { + const keys = await this.getOAuthKeys(); + reply.header('cache-control', 'public, max-age=3600'); + return keys.jwks(); + }); + + // Dynamic Client Registration (RFC 7591). Public clients only; the real + // authorization gate is the on-device approval at the vault (Rule #8). + this.server.post<{ + Body: { client_name?: string; redirect_uris?: string[] }; + }>('/oauth/register', async (request, reply) => { + const body = request.body || {}; + const client = this.oauthClients.register({ + client_name: body.client_name, + redirect_uris: body.redirect_uris, + }); + reply.status(201); + return { + client_id: client.client_id, + client_name: client.client_name, + redirect_uris: client.redirect_uris, + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }; + }); + + // Token revocation (RFC 7009). Revokes the refresh-token chain. Always 200. + this.server.post<{ Body: { token?: string } }>( + '/oauth/revoke', + async (request, reply) => { + const token = request.body?.token; + if (token) { + const chain = this.oauthRefresh.chainFor(token); + if (chain) this.oauthRefresh.revokeChain(chain); + } + reply.status(200); + return {}; + } + ); + + // Device-flow grant: redeem a connect-link (Model B) OR open a link-less pairing + // from the MCP `resource` URL (Model A). The agent then polls /oauth/token. + this.server.post<{ + Body: { + connect_link?: string; + resource?: string; + code_challenge?: string; + code_challenge_method?: string; + client_id?: string; + scope?: string; + }; + }>('/oauth/connect', async (request, reply) => { + const body = request.body || {}; + const deps = await this.grantDeps(request); + const resourceVaultId = body.connect_link + ? null + : this.vaultIdFromResource(body.resource); + const clientName = body.client_id + ? this.oauthClients.get(body.client_id)?.client_name + : undefined; + const result = await processConnect(deps, { + connectLink: body.connect_link || undefined, + vaultId: resourceVaultId || undefined, + clientName, + codeChallenge: body.code_challenge || '', + codeChallengeMethod: body.code_challenge_method, + dpopProof: (request.headers['dpop'] as string) || '', + method: 'POST', + url: `${this.baseUrl(request)}/oauth/connect`, + sourceIp: request.ip, + clientId: body.client_id, + scope: body.scope, + }); + reply.status(result.status); + return result.body; + }); + + // Token endpoint: device_code poll + refresh_token rotation. DPoP required. + this.server.post<{ + Body: { + grant_type?: string; + device_code?: string; + code?: string; + code_verifier?: string; + refresh_token?: string; + }; + }>('/oauth/token', async (request, reply) => { + const body = request.body || {}; + const deps = await this.grantDeps(request); + const result = await processToken(deps, { + grantType: body.grant_type || '', + deviceCode: body.device_code, + code: body.code, + codeVerifier: body.code_verifier, + refreshToken: body.refresh_token, + dpopProof: (request.headers['dpop'] as string) || '', + method: 'POST', + url: `${this.baseUrl(request)}/oauth/token`, + }); + reply.status(result.status); + if (result.status === 200) reply.header('cache-control', 'no-store'); + return result.body; + }); + + // Browser authorization (Claude.ai / ChatGPT auth-code + PKCE). The user + // pastes a DCP connect-link; we redeem it, they approve on-device, then we + // redirect to redirect_uri with an authorization code. (DPoP binds at /token.) + this.server.get<{ + Querystring: { + resource?: string; + code_challenge?: string; + code_challenge_method?: string; + redirect_uri?: string; + state?: string; + client_id?: string; + connect_link?: string; + }; + }>('/oauth/authorize', async (request, reply) => { + const q = request.query; + const esc = (s: string | undefined) => + String(s ?? '').replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string + ); + const redirectUri = q.redirect_uri || ''; + // Validate redirect_uri is a well-formed absolute URL before reflecting it. + try { + if (redirectUri) new URL(redirectUri); + } catch { + reply.status(400).header('content-type', 'text/html'); + return '

Invalid redirect_uri

'; + } + reply.header('content-type', 'text/html; charset=utf-8'); + + const shell = (inner: string) => + `Connect to DCP` + + `` + + `` + + `${inner}`; + + // The universal "paste-URL" flow (Model A): a standard MCP client arrives with + // a `resource` pointing at /v//mcp and no connect-link. We derive the + // vault and open an on-device approval directly — no second secret to paste. + const resourceVaultId = this.vaultIdFromResource(q.resource); + + // Step 1 — if there's neither a connect-link nor a resolvable vault resource, + // fall back to the connect-link form (Model B / hardened pairing). + if (!q.connect_link && !resourceVaultId) { + return shell( + `

Connect a cloud agent to your DCP vault

` + + `

Open DCP → Connect → Cloud Agent, generate a connect-link, and paste it here.

` + + `
` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
` + ); + } + + // Step 2 — redeem (connect-link) or request pairing (URL) + open a session; + // the page polls approval, then redirects. + const deps = await this.grantDeps(request); + const clientName = q.client_id ? this.oauthClients.get(q.client_id)?.client_name : undefined; + const result = await processAuthorize(deps, { + connectLink: q.connect_link, + vaultId: q.connect_link ? undefined : resourceVaultId || undefined, + clientName, + codeChallenge: q.code_challenge || '', + codeChallengeMethod: q.code_challenge_method, + sourceIp: request.ip, + clientId: q.client_id, + }); + if (!result.ok) { + return shell(`

Could not connect

Error: ${esc(result.error)}.

`); + } + + const cfg = { + sessionId: result.sessionId, + redirectUri, + state: q.state || '', + }; + return shell( + `

Approve on your DCP device

` + + `

Confirm this match code matches the one shown on your device, then approve there.

` + + `
${esc(result.matchCode)}
` + + `

Waiting for approval…

` + + `` + ); + }); + + // Browser authorization status poll (used by the /oauth/authorize page). + this.server.get<{ Querystring: { session?: string } }>( + '/oauth/authorize/status', + 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); + } + ); + + // Per-vault MCP resource (Streamable HTTP). Verifies the DPoP-bound, + // audience-bound access token, then bridges the MCP request to the vault. + this.server.post<{ Params: { vaultId: string }; Body: unknown }>( + '/v/:vaultId/mcp', + 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; + } + ); + // Stats (for monitoring) this.server.get('/stats', async () => ({ ...this.messageStore.getStats(), @@ -303,22 +1019,15 @@ export class RelayServer { } ); - // Vault resolves a pairing claim (approve/deny) - this.server.post<{ - Params: { claimId: string }; - Body: { action: 'approve' | 'deny'; agent_id?: string; vault_id: string }; - }>( - '/v1/pairing-claims/:claimId/resolve', - async (request, reply) => { - return this.handlePairingResolve( - request.params.claimId, - request.body, - reply - ); - } - ); + // NOTE: the unauthenticated HTTP "/v1/pairing-claims/:claimId/resolve" route was + // removed (#3). Pairing approvals are pushed by the vault over its authenticated + // WS control channel (`pairing_result`, bound to the socket's vault), so an + // unauthenticated HTTP resolve was both unused and a spoofing vector. - // Register an invite_id → vault_id mapping (called by vault on invite creation) + // Register an invite_id → vault_id mapping (called by vault on invite creation). + // Only a vault with a LIVE, authenticated WS connection (vault_id bound to its + // key via #6) may register an invite for itself — otherwise anyone could remap + // a pairing invite. The legit vault is always WS-connected when it does this. this.server.post<{ Body: { invite_id: string; vault_id: string } }>( '/v1/invites/register', async (request, reply) => { @@ -326,6 +1035,11 @@ export class RelayServer { if (!invite_id || !vault_id) { return reply.status(400).send({ error: 'Missing invite_id or vault_id' }); } + if (!this.wsConnections.has(vault_id)) { + return reply + .status(401) + .send({ error: 'vault_id is not connected; invite registration requires an authenticated vault connection' }); + } this.inviteVaultMap.set(invite_id, vault_id); return reply.send({ success: true }); } @@ -413,13 +1127,30 @@ export class RelayServer { this.server.post<{ Params: { vaultId: string }; - Body: { public_key: string; signing_public_key?: string }; + Body: { + public_key: string; + signing_public_key?: string; + timestamp?: string; + nonce?: string; + signature?: string; + }; }>( '/v1/mobile/vaults/:vaultId/online', async (request, reply) => { if (!request.body?.public_key) { return reply.status(400).send({ error: 'Missing public_key' }); } + // Require a signed proof that the caller owns this vault_id (anti-spoof). + // TOFU bind: an HTTP-only (mobile) vault may register status before any WS. + const ok = this.verifyVaultProof( + { vault_id: request.params.vaultId, ...request.body }, + 'bind' + ); + if (!ok) { + return reply + .status(401) + .send({ error: 'A signed proof of vault ownership is required' }); + } this.connectionStore.register(request.params.vaultId, request.body.public_key); return reply.send({ success: true, vault_id: request.params.vaultId }); } @@ -431,6 +1162,10 @@ export class RelayServer { token: string; platform?: 'ios' | 'android' | 'web' | 'unknown'; device_id?: string; + signing_public_key?: string; + timestamp?: string; + nonce?: string; + signature?: string; }; }>( '/v1/devices/push-token', @@ -442,6 +1177,14 @@ export class RelayServer { if (!this.isExpoPushToken(token)) { return reply.status(400).send({ error: 'Unsupported push token format' }); } + // Only the vault owner may (over)write its push token — otherwise an attacker + // who knows a vault_id could redirect the victim's approval pushes to their + // own device (approval phishing + notification DoS). Signed proof required. + if (!this.verifyVaultProof(request.body, 'bind')) { + return reply + .status(401) + .send({ error: 'A signed proof of vault ownership is required' }); + } const record = this.pushTokenStore.register({ vault_id, @@ -604,6 +1347,15 @@ export class RelayServer { req: LongPollRequest, reply: FastifyReply ): Promise { + // The HTTP-fallback poll exposes a vault's queued (encrypted) messages and marks + // them delivered, so it MUST prove ownership of the vault — otherwise anyone who + // knows a vault_id could drain the queue. Same signed scheme as registration. + if (!this.verifyVaultProof(req)) { + return reply + .status(401) + .send(new RelayError('RELAY_UNAUTHORIZED', 'A signed proof of vault ownership is required').toJSON()); + } + const timeoutMs = Math.min(req.timeout_ms ?? 30000, 60000); const startTime = Date.now(); @@ -639,6 +1391,21 @@ export class RelayServer { response: RelayResponseEnvelope, reply: FastifyReply ): Promise { + // Only the vault the request was destined for may post its response — otherwise + // an attacker could race a bogus response for any known request_id. Require a + // signed proof of vault ownership AND that the request actually targets that vault. + if (!this.verifyVaultProof(response)) { + return reply + .status(401) + .send(new RelayError('RELAY_UNAUTHORIZED', 'A signed proof of vault ownership is required').toJSON()); + } + const targetVault = this.messageStore.getVaultIdForRequest(response.request_id); + if (!targetVault || targetVault !== response.vault_id) { + return reply + .status(403) + .send(new RelayError('RELAY_UNAUTHORIZED', 'Not the owner of this request').toJSON()); + } + const stored = this.messageStore.storeResponse(response.request_id, response); if (!stored) { return reply.status(404).send( @@ -786,46 +1553,6 @@ export class RelayServer { } satisfies PairingApprovalStatus); } - /** - * Handle pairing resolution from vault (approve/deny) - */ - private async handlePairingResolve( - claimId: string, - body: { action: 'approve' | 'deny'; agent_id?: string; vault_id: string }, - reply: FastifyReply - ): Promise { - const { action, agent_id, vault_id } = body; - - const claim = this.pairingClaimStore.getClaim(claimId); - if (!claim) { - return reply.status(404).send({ error: 'Claim not found' }); - } - - // Verify vault_id matches - if (claim.vault_id && claim.vault_id !== vault_id) { - return reply.status(403).send({ error: 'Vault ID mismatch' }); - } - - // Update claim status - const status = action === 'approve' ? 'approved' : 'denied'; - const updated = this.pairingClaimStore.updateClaimStatus(claimId, status, agent_id); - - if (!updated) { - return reply.status(500).send({ error: 'Failed to update claim status' }); - } - - if (this.config.debug) { - console.log(`Pairing claim ${claimId} ${status} by vault ${vault_id}`); - } - - return reply.send({ - success: true, - claim_id: claimId, - status, - agent_id, - }); - } - private async handleMobilePairingApprove( inviteId: string, body: MobilePairingApprovalRequest, @@ -895,9 +1622,14 @@ export class RelayServer { ws.on('message', (data: Buffer | string) => { try { const msg: WsMessage = JSON.parse(data.toString()); - this.handleWsMessage(ws, msg, (id) => { - vaultId = id; - }); + this.handleWsMessage( + ws, + msg, + (id) => { + vaultId = id; + }, + () => vaultId + ); } catch (err) { this.sendWsError(ws, 'RELAY_INVALID_ENVELOPE', 'Invalid message format'); } @@ -950,7 +1682,8 @@ export class RelayServer { private handleWsMessage( ws: WebSocket, msg: WsMessage, - setVaultId: (id: string) => void + setVaultId: (id: string) => void, + getVaultId: () => string | null ): void { switch (msg.type) { case 'register': { @@ -978,6 +1711,17 @@ export class RelayServer { return; } + // Bind vault_id to this signing key (TOFU). A different key claiming an + // already-bound vault_id is a hijack attempt and is rejected. + if (!this.bindOrVerifyVaultKey(payload.vault_id, payload.signing_public_key)) { + this.sendWsError( + ws, + 'RELAY_UNAUTHORIZED', + 'vault_id is already registered to a different key' + ); + return; + } + // Register vault (authenticated) this.connectionStore.register(payload.vault_id, payload.public_key, ws); this.wsConnections.set(payload.vault_id, ws); @@ -1046,7 +1790,17 @@ export class RelayServer { return; } - this.messageStore.storeResponse(response.request_id, response); + const targetVault = this.messageStore.getVaultIdForRequest(response.request_id); + if (!targetVault || targetVault !== getVaultId()) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'Not the owner of this request'); + return; + } + + const stored = this.messageStore.storeResponse(response.request_id, response); + if (!stored) { + this.sendWsError(ws, 'RELAY_TIMEOUT', 'Original request not found or expired'); + return; + } this.notifyClientResponse(response); // Send ack @@ -1060,12 +1814,18 @@ export class RelayServer { } case 'unregister': { + // A socket may only unregister ITSELF — not evict another vault's live + // connection. Without this, anyone could open /ws and unregister any vault + // by id (cross-tenant takedown of an online vault). const payload = msg.payload as { vault_id: string }; - if (payload.vault_id) { - this.connectionStore.unregister(payload.vault_id); - this.wsConnections.delete(payload.vault_id); - setVaultId(''); + const me = getVaultId(); + if (!payload.vault_id || !me || payload.vault_id !== me) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'unregister vault_id mismatch'); + return; } + this.connectionStore.unregister(payload.vault_id); + this.wsConnections.delete(payload.vault_id); + setVaultId(''); break; } @@ -1083,6 +1843,18 @@ export class RelayServer { return; } + // The socket must be the registered vault it claims to be, and may only + // resolve a claim that targets ITS vault. + if (getVaultId() !== payload.vault_id) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'pairing_result vault_id mismatch'); + return; + } + const claimVault = this.pairingClaimStore.getClaim(payload.claim_id)?.vault_id; + if (claimVault && claimVault !== payload.vault_id) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'claim does not belong to this vault'); + return; + } + const status = payload.action === 'approve' ? 'approved' : 'denied'; const updated = this.pairingClaimStore.updateClaimStatus( payload.claim_id, @@ -1107,6 +1879,35 @@ export class RelayServer { break; } + case 'cloud_connect_result': { + // Vault's reply to a cloud-connect redeem/status control message. Only the + // vault the request was sent to may resolve it (checked in resolveControl). + const payload = (msg.payload || {}) as { control_id?: string }; + if (payload.control_id) { + this.resolveControl(payload.control_id, msg.payload as Record, getVaultId()); + } + break; + } + + case 'cloud_connect_revoke': { + // Vault instructing the relay to instantly revoke a cloud agent (Rule #7). + // A vault may only revoke agents that belong to IT. + const payload = (msg.payload || {}) as { agent_id?: string; vault_id?: string }; + const me = getVaultId(); + if (!payload.agent_id || !me) return; + if (payload.vault_id && payload.vault_id !== me) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'cloud_connect_revoke vault_id mismatch'); + return; + } + const knownOwner = this.agentVault.get(payload.agent_id); + if (knownOwner && knownOwner !== me) { + this.sendWsError(ws, 'RELAY_UNAUTHORIZED', 'agent does not belong to this vault'); + return; + } + this.revokeAgentAccess(payload.agent_id, me); + break; + } + default: this.sendWsError(ws, 'RELAY_INVALID_ENVELOPE', `Unknown message type: ${msg.type}`); } diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index b62f2fc..e147c6d 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -50,6 +50,7 @@ export class MessageStore { rateLimitPerMinute: config.rateLimitPerMinute ?? 60, rateLimitWindowMs: config.rateLimitWindowMs ?? 60_000, expoPushUrl: config.expoPushUrl ?? 'https://exp.host/--/api/v2/push/send', + publicUrl: config.publicUrl ?? '', }; // Start cleanup interval @@ -156,6 +157,17 @@ export class MessageStore { return true; } + /** + * The vault_id a queued request was destined for. Used to authorize the + * HTTP-fallback /relay/respond (the responder must own the target vault). + */ + getVaultIdForRequest(requestId: string): string | undefined { + const key = this.requestIdIndex.get(requestId); + if (!key) return undefined; + const suffix = `:${requestId}`; + return key.endsWith(suffix) ? key.slice(0, -suffix.length) : undefined; + } + /** * Get response for a request (for idempotent replay) */ diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index d1f1acc..dd22b63 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -62,6 +62,13 @@ export interface RelayResponseEnvelope { encrypted_payload: string; /** Response timestamp */ timestamp: string; + // Signed proof of vault ownership (required for the HTTP-fallback /relay/respond). + // The responder proves it owns `vault_id` (Ed25519 over vault_id||timestamp||nonce) + // and the relay checks that the original request was destined for that vault. + vault_id?: string; + signing_public_key?: string; + nonce?: string; + signature?: string; } // ============================================================================ @@ -77,7 +84,13 @@ export type WsMessageType = | 'ack' // Acknowledgement | 'error' // Error response | 'pairing_claim' // New pairing claim from agent (relay → vault) - | 'pairing_result'; // Pairing approval result from vault (vault → relay) + | 'pairing_result' // Pairing approval result from vault (vault → relay) + | 'cloud_connect_redeem' // Cloud-Connect: relay asks vault to redeem a connect-link (relay → vault) + | 'cloud_connect_pair' // Cloud-Connect: relay asks vault to open a link-less (paste-URL) pairing (relay → vault) + | 'cloud_connect_status' // Cloud-Connect: relay asks vault for an agent's approval status (relay → vault) + | 'cloud_connect_mcp' // Cloud-Connect: relay forwards an authorized MCP request to the vault (relay → vault) + | 'cloud_connect_revoke' // Cloud-Connect: vault tells relay to instantly revoke an agent (vault → relay) + | 'cloud_connect_result'; // Cloud-Connect: vault's reply to a redeem/status/mcp control message (vault → relay) export interface WsMessage { type: WsMessageType; @@ -131,6 +144,12 @@ export interface LongPollRequest { vault_id: string; timeout_ms?: number; last_message_id?: string; + // Signed proof of vault ownership (required for the HTTP-fallback poll). Same + // scheme as registration: Ed25519 over vault_id||timestamp||nonce. + signing_public_key?: string; + timestamp?: string; + nonce?: string; + signature?: string; } export interface LongPollResponse { @@ -229,6 +248,13 @@ export interface RelayConfig { rateLimitWindowMs: number; /** Expo push API endpoint. Empty disables outbound push delivery. */ expoPushUrl: string; + /** + * Public base URL of this relay (the OAuth issuer / MCP resource origin), + * e.g. https://relay.dcp.1ly.store. Used to build OAuth discovery metadata and + * audience-bound token identifiers. Empty = derive from the request's Host + * header (fine for local/dev; set explicitly in production). + */ + publicUrl: string; } export const DEFAULT_RELAY_CONFIG: RelayConfig = { @@ -242,6 +268,7 @@ export const DEFAULT_RELAY_CONFIG: RelayConfig = { rateLimitPerMinute: 60, rateLimitWindowMs: 60_000, expoPushUrl: 'https://exp.host/--/api/v2/push/send', + publicUrl: '', }; // ============================================================================ diff --git a/packages/dcp-relay/tests/oauth-authorize.test.ts b/packages/dcp-relay/tests/oauth-authorize.test.ts new file mode 100644 index 0000000..5b34eb6 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-authorize.test.ts @@ -0,0 +1,151 @@ +/** + * Browser auth-code adapter (/oauth/authorize) for vanilla MCP clients (Claude.ai / + * ChatGPT). Drives the full path through the server with a fake vault bridge: + * GET /oauth/authorize -> redeem + session -> status poll -> authorization_code at /token. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { randomUUID, createHash } from 'crypto'; +import { RelayServer } from '../src/relay.js'; +import type { VaultConnectBridge } from '../src/oauth/index.js'; + +const VAULT = 'vault_authz_1'; +const makeConnectLink = (vault_id = VAULT, link_id = 'cl_1') => + 'dcp_connect_v1_' + + Buffer.from(JSON.stringify({ payload: { version: 1, vault_id, link_id }, signature: '' }), 'utf8').toString('base64url'); +const s256 = (v: string) => createHash('sha256').update(v, 'ascii').digest('base64url'); +const ath = (t: string) => createHash('sha256').update(t, 'ascii').digest('base64url'); + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + return { privateKey, publicJwk: await exportJWK(publicKey) }; +} +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string, + athClaim?: string +): Promise { + const payload: Record = { htm: method, htu: url }; + if (athClaim) payload.ath = athClaim; + return new SignJWT(payload) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('Browser auth-code adapter (/oauth/authorize)', () => { + let server: RelayServer; + let port: number; + const base = () => `http://127.0.0.1:${port}`; + + const fakeVault: VaultConnectBridge = { + async redeem() { + return { ok: true, agentId: 'agent_1', matchCode: 'AB23CD' }; + }, + async approvalStatus() { + return { status: 'approved', scope: 'read:wallet.address' }; + }, + }; + + beforeAll(async () => { + port = 23000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, vaultBridge: fakeVault }); + await server.start(); + }); + afterAll(async () => { + await server.stop(); + }); + + it('shows a paste form when no connect-link is supplied', async () => { + const res = await fetch(`${base()}/oauth/authorize?redirect_uri=https://app/cb&state=xyz`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('name="connect_link"'); + }); + + it('rejects a malformed redirect_uri', async () => { + const res = await fetch(`${base()}/oauth/authorize?redirect_uri=not-a-url`); + expect(res.status).toBe(400); + }); + + it('redeems a connect-link, surfaces the match code, and completes the auth-code flow', async () => { + const verifier = 'v'.repeat(64); + const url = + `${base()}/oauth/authorize?connect_link=${encodeURIComponent(makeConnectLink())}` + + `&code_challenge=${s256(verifier)}&code_challenge_method=S256` + + `&redirect_uri=${encodeURIComponent('https://app.example/cb')}&state=st123`; + const res = await fetch(url); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('AB23CD'); // match code surfaced + const sessionId = (html.match(/"sessionId":"(as_[^"]+)"/) || [])[1]; + expect(sessionId).toBeTruthy(); + + // The page polls this; the (fake) vault reports approved. + const statusRes = await fetch(`${base()}/oauth/authorize/status?session=${sessionId}`); + const status = await statusRes.json(); + expect(status.status).toBe('approved'); + expect(status.code).toBe(sessionId); + + // Exchange the authorization code for a token (DPoP binds here, TOFU). + const agent = await makeAgentKey(); + const tokenRes = await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: status.code, + code_verifier: verifier, + redirect_uri: 'https://app.example/cb', + }), + }); + expect(tokenRes.status).toBe(200); + const tok = await tokenRes.json(); + expect(tok.token_type).toBe('DPoP'); + expect(tok.access_token).toBeTruthy(); + + // The token works at the facade for this vault. + const mcpUrl = `${base()}/v/${VAULT}/mcp`; + const facade = await fetch(mcpUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `DPoP ${tok.access_token}`, + dpop: await dpop(agent, 'POST', mcpUrl, ath(tok.access_token)), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + // 503 = facade auth passed, data plane (no real vault) unavailable — which is + // exactly what proves the token + DPoP were accepted (not a 401). + expect([200, 503]).toContain(facade.status); + expect(facade.status).not.toBe(401); + }); + + it('rejects an auth-code exchange with the wrong PKCE verifier', async () => { + const verifier = 'v'.repeat(64); + const html = await ( + await fetch( + `${base()}/oauth/authorize?connect_link=${encodeURIComponent(makeConnectLink())}` + + `&code_challenge=${s256(verifier)}&code_challenge_method=S256` + + `&redirect_uri=${encodeURIComponent('https://app.example/cb')}` + ) + ).text(); + const sessionId = (html.match(/"sessionId":"(as_[^"]+)"/) || [])[1]; + const agent = await makeAgentKey(); + const tokenRes = await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: sessionId, + code_verifier: 'w'.repeat(64), + }), + }); + expect(tokenRes.status).toBe(400); + expect((await tokenRes.json()).error).toBe('invalid_grant'); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-endpoints.test.ts b/packages/dcp-relay/tests/oauth-endpoints.test.ts new file mode 100644 index 0000000..3b8c9b9 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-endpoints.test.ts @@ -0,0 +1,78 @@ +/** + * Integration tests for the relay's static OAuth 2.1 discovery + client endpoints + * (Cloud-Connect P2c, decision-independent): metadata, JWKS, DCR, revoke. + * Starts a real RelayServer and hits it over HTTP. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { RelayServer } from '../src/relay.js'; + +describe('Relay OAuth endpoints', () => { + let server: RelayServer; + let port: number; + const base = () => `http://127.0.0.1:${port}`; + + beforeAll(async () => { + port = 19000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, debug: false }); + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('serves Authorization Server Metadata (RFC 8414) with OAuth 2.1-safe options', async () => { + const res = await fetch(`${base()}/.well-known/oauth-authorization-server`); + expect(res.status).toBe(200); + const md = await res.json(); + expect(md.issuer).toBe(base()); + expect(md.authorization_endpoint).toBe(`${base()}/oauth/authorize`); + expect(md.token_endpoint).toBe(`${base()}/oauth/token`); + expect(md.jwks_uri).toBe(`${base()}/oauth/jwks`); + expect(md.code_challenge_methods_supported).toEqual(['S256']); + expect(md.grant_types_supported).toContain('refresh_token'); + expect(md.dpop_signing_alg_values_supported).toContain('ES256'); + }); + + it('serves per-vault Protected Resource Metadata (RFC 9728)', async () => { + const res = await fetch(`${base()}/.well-known/oauth-protected-resource/v/vault_abc/mcp`); + expect(res.status).toBe(200); + const md = await res.json(); + expect(md.resource).toBe(`${base()}/v/vault_abc/mcp`); + expect(md.authorization_servers).toEqual([base()]); + }); + + it('serves a public-only JWKS', async () => { + const res = await fetch(`${base()}/oauth/jwks`); + expect(res.status).toBe(200); + const jwks = await res.json(); + expect(Array.isArray(jwks.keys)).toBe(true); + expect(jwks.keys.length).toBe(1); + expect(jwks.keys[0].kty).toBeTruthy(); + expect(jwks.keys[0].use).toBe('sig'); + expect(jwks.keys[0].d).toBeUndefined(); // no private material + }); + + it('registers a public client via DCR (RFC 7591)', async () => { + const res = await fetch(`${base()}/oauth/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ client_name: 'OpenClaw on AWS', redirect_uris: ['https://app/cb'] }), + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.client_id).toMatch(/^dcpc_/); + expect(body.token_endpoint_auth_method).toBe('none'); + expect(body.redirect_uris).toEqual(['https://app/cb']); + }); + + it('accepts revoke requests idempotently (RFC 7009 — always 200)', async () => { + const res = await fetch(`${base()}/oauth/revoke`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token: 'dcprt_does_not_exist' }), + }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-grant.test.ts b/packages/dcp-relay/tests/oauth-grant.test.ts new file mode 100644 index 0000000..fd924b2 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-grant.test.ts @@ -0,0 +1,241 @@ +/** + * Tests for the Cloud-Connect OAuth grant: connect-link redemption -> device-flow + * poll -> DPoP/audience-bound access token + rotating refresh. Uses a FAKE vault + * bridge so the full grant state machine is exercised without the WS plumbing. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { randomUUID } from 'crypto'; +import { + initOAuthKeys, + AuthSessionStore, + RefreshTokenStore, + createJtiGuard, + processConnect, + processToken, + verifyAccessToken, + jwkThumbprint, + computeS256Challenge, + DEVICE_CODE_GRANT, + type GrantDeps, + type VaultConnectBridge, + type BridgeRedeemResult, + type BridgeApprovalResult, +} from '../src/oauth/index.js'; + +const ISSUER = 'https://relay.example'; +const VAULT = 'vault_1'; +const RESOURCE = `${ISSUER}/v/${VAULT}/mcp`; + +function makeConnectLink(vault_id = VAULT, link_id = 'cl_1'): string { + const env = { payload: { version: 1, vault_id, link_id }, signature: '' }; + return 'dcp_connect_v1_' + Buffer.from(JSON.stringify(env), 'utf8').toString('base64url'); +} + +class FakeBridge implements VaultConnectBridge { + redeemResult: BridgeRedeemResult = { ok: true, agentId: 'agent_1', matchCode: 'AB23CD' }; + approval: BridgeApprovalResult = { status: 'pending' }; + async redeem(): Promise { + return this.redeemResult; + } + async approvalStatus(): Promise { + return this.approval; + } +} + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + const publicJwk = await exportJWK(publicKey); + return { privateKey, publicJwk }; +} + +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string +): Promise { + return new SignJWT({ htm: method, htu: url }) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('Cloud-Connect OAuth grant', () => { + let bridge: FakeBridge; + let deps: GrantDeps; + + beforeEach(async () => { + bridge = new FakeBridge(); + deps = { + keys: await initOAuthKeys({ warn: () => {} }), + sessions: new AuthSessionStore(), + refresh: new RefreshTokenStore(), + jtiGuard: createJtiGuard(), + bridge, + issuer: ISSUER, + }; + }); + + async function connect(agent: Awaited>, verifier: string) { + return processConnect(deps, { + connectLink: makeConnectLink(), + codeChallenge: computeS256Challenge(verifier), + dpopProof: await dpop(agent, 'POST', `${ISSUER}/oauth/connect`), + method: 'POST', + url: `${ISSUER}/oauth/connect`, + sourceIp: '203.0.113.7', + }); + } + + async function tokenDeviceCode( + agent: Awaited>, + deviceCode: string, + verifier: string + ) { + return processToken(deps, { + grantType: DEVICE_CODE_GRANT, + deviceCode, + codeVerifier: verifier, + dpopProof: await dpop(agent, 'POST', `${ISSUER}/oauth/token`), + method: 'POST', + url: `${ISSUER}/oauth/token`, + }); + } + + it('runs connect -> pending -> approved -> token (DPoP + audience bound)', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + + const c = await connect(agent, verifier); + expect(c.status).toBe(200); + expect(c.body.device_code).toBeTruthy(); + expect(c.body.match_code).toBe('AB23CD'); + const deviceCode = c.body.device_code as string; + + // Pending until the owner approves on-device. + const pending = await tokenDeviceCode(agent, deviceCode, verifier); + expect(pending.status).toBe(400); + expect(pending.body.error).toBe('authorization_pending'); + + // Owner approves; the vault returns the authoritative scope. + bridge.approval = { status: 'approved', scope: 'read:wallet.address' }; + + const tok = await tokenDeviceCode(agent, deviceCode, verifier); + expect(tok.status).toBe(200); + expect(tok.body.token_type).toBe('DPoP'); + expect(tok.body.refresh_token).toBeTruthy(); + expect(tok.body.scope).toBe('read:wallet.address'); + + // The access token is bound to this vault's resource (audience) AND, because + // the client used DPoP, sender-bound to its key — so verifying requires the jkt. + const agentJkt = await jwkThumbprint(agent.publicJwk); + const claims = await verifyAccessToken({ + keys: deps.keys, + token: tok.body.access_token as string, + issuer: ISSUER, + expectedAudience: RESOURCE, + expectedJkt: agentJkt, + }); + expect(claims.vault_id).toBe(VAULT); + expect(claims.sub).toBe('agent_1'); + expect(claims.cnf?.jkt).toBe(agentJkt); + }); + + it('rejects a reused device_code after issuance', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + const c = await connect(agent, verifier); + const deviceCode = c.body.device_code as string; + bridge.approval = { status: 'approved', scope: 'read:wallet.address' }; + expect((await tokenDeviceCode(agent, deviceCode, verifier)).status).toBe(200); + const again = await tokenDeviceCode(agent, deviceCode, verifier); + expect(again.status).toBe(400); + expect(again.body.error).toBe('invalid_grant'); + }); + + it('rejects a wrong PKCE verifier', async () => { + const agent = await makeAgentKey(); + const c = await connect(agent, 'v'.repeat(64)); + bridge.approval = { status: 'approved' }; + const bad = await tokenDeviceCode(agent, c.body.device_code as string, 'w'.repeat(64)); + expect(bad.status).toBe(400); + expect(bad.body.error).toBe('invalid_grant'); + }); + + it('rejects a token request from a different DPoP key than connect', async () => { + const agent = await makeAgentKey(); + const attacker = await makeAgentKey(); + const verifier = 'v'.repeat(64); + const c = await connect(agent, verifier); + bridge.approval = { status: 'approved' }; + const bad = await tokenDeviceCode(attacker, c.body.device_code as string, verifier); + expect(bad.status).toBe(400); + expect(bad.body.error).toBe('invalid_grant'); + }); + + it('requires PKCE on connect and rejects bad DPoP', async () => { + const agent = await makeAgentKey(); + const noPkce = await processConnect(deps, { + connectLink: makeConnectLink(), + codeChallenge: '', + dpopProof: await dpop(agent, 'POST', `${ISSUER}/oauth/connect`), + method: 'POST', + url: `${ISSUER}/oauth/connect`, + }); + expect(noPkce.status).toBe(400); + expect(noPkce.body.error).toBe('invalid_request'); + + const badDpop = await processConnect(deps, { + connectLink: makeConnectLink(), + codeChallenge: computeS256Challenge('v'.repeat(64)), + dpopProof: 'not-a-proof', + method: 'POST', + url: `${ISSUER}/oauth/connect`, + }); + expect(badDpop.status).toBe(400); + expect(badDpop.body.error).toBe('invalid_dpop_proof'); + }); + + it('maps bridge failures: unreachable -> 503, redeem-fail -> invalid_grant', async () => { + const agent = await makeAgentKey(); + bridge.redeemResult = { ok: false, reason: 'vault_unreachable' }; + const offline = await connect(agent, 'v'.repeat(64)); + expect(offline.status).toBe(503); + + bridge.redeemResult = { ok: false, reason: 'LINK_REDEEMED' }; + const spent = await connect(agent, 'v'.repeat(64)); + expect(spent.status).toBe(400); + expect(spent.body.error).toBe('invalid_grant'); + }); + + it('rotates refresh tokens and detects reuse (revokes the chain)', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + const c = await connect(agent, verifier); + bridge.approval = { status: 'approved', scope: 'read:wallet.address' }; + const first = await tokenDeviceCode(agent, c.body.device_code as string, verifier); + const rt0 = first.body.refresh_token as string; + + const refresh = async (rt: string) => + processToken(deps, { + grantType: 'refresh_token', + refreshToken: rt, + dpopProof: await dpop(agent, 'POST', `${ISSUER}/oauth/token`), + method: 'POST', + url: `${ISSUER}/oauth/token`, + }); + + const r1 = await refresh(rt0); + expect(r1.status).toBe(200); + const rt1 = r1.body.refresh_token as string; + expect(rt1).not.toBe(rt0); + + // Reuse the spent rt0 -> chain revoked. + const reuse = await refresh(rt0); + expect(reuse.status).toBe(400); + // The newer rt1 is now dead too. + expect((await refresh(rt1)).status).toBe(400); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-mcp-dataplane.test.ts b/packages/dcp-relay/tests/oauth-mcp-dataplane.test.ts new file mode 100644 index 0000000..260bd2d --- /dev/null +++ b/packages/dcp-relay/tests/oauth-mcp-dataplane.test.ts @@ -0,0 +1,214 @@ +/** + * Full data-plane E2E (P2e-2): a simulated vault registers over /ws and answers + * ALL Cloud-Connect control messages (redeem, status, mcp). With the relay's + * DEFAULT WS-backed bridges, an agent runs connect -> token -> /v/:vaultId/mcp and + * the authorized MCP request is forwarded over the WS and its response returned. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import WebSocket from 'ws'; +import { ed25519 } from '@noble/curves/ed25519'; +import { randomBytes, randomUUID, createHash } from 'crypto'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { RelayServer } from '../src/relay.js'; + +const VAULT = 'vault_dataplane_1'; +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +const makeConnectLink = (vault_id: string, link_id = 'cl_1') => + 'dcp_connect_v1_' + + Buffer.from(JSON.stringify({ payload: { version: 1, vault_id, link_id }, signature: '' }), 'utf8').toString( + 'base64url' + ); +const s256 = (v: string) => createHash('sha256').update(v, 'ascii').digest('base64url'); +const ath = (t: string) => createHash('sha256').update(t, 'ascii').digest('base64url'); + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + return { privateKey, publicJwk: await exportJWK(publicKey) }; +} +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string, + athClaim?: string +): Promise { + const payload: Record = { htm: method, htu: url }; + if (athClaim) payload.ath = athClaim; + return new SignJWT(payload) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('Cloud-Connect MCP data plane (E2E over WS)', () => { + let server: RelayServer; + let port: number; + let vaultWs: WebSocket; + let lastMcp: { agent_id?: string; body?: unknown } | null = null; + const base = () => `http://127.0.0.1:${port}`; + + beforeAll(async () => { + port = 22000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, debug: false }); + await server.start(); + + const signPriv = ed25519.utils.randomPrivateKey(); + const signPub = ed25519.getPublicKey(signPriv); + vaultWs = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const reply = (controlId: string, extra: Record) => + vaultWs.send( + JSON.stringify({ + type: 'cloud_connect_result', + payload: { control_id: controlId, ...extra }, + timestamp: new Date().toISOString(), + }) + ); + + await new Promise((resolve, reject) => { + vaultWs.on('open', () => { + const timestamp = new Date().toISOString(); + const nonce = randomBytes(32); + const message = Buffer.concat([Buffer.from(VAULT), Buffer.from(timestamp), nonce]); + const signature = ed25519.sign(message, signPriv); + vaultWs.send( + JSON.stringify({ + type: 'register', + payload: { + vault_id: VAULT, + public_key: Buffer.from(randomBytes(32)).toString('base64'), + signing_public_key: Buffer.from(signPub).toString('base64'), + timestamp, + nonce: nonce.toString('base64'), + signature: Buffer.from(signature).toString('base64'), + }, + timestamp, + }) + ); + }); + vaultWs.on('message', (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'ack' && msg.payload?.registered) resolve(); + if (msg.type === 'cloud_connect_redeem') + reply(msg.payload.control_id, { ok: true, agent_id: 'agent_1', match_code: 'AB23CD' }); + if (msg.type === 'cloud_connect_status') + reply(msg.payload.control_id, { status: 'approved', scope: 'read:wallet.address' }); + if (msg.type === 'cloud_connect_mcp') { + lastMcp = { agent_id: msg.payload.agent_id, body: msg.payload.body }; + // Simulated MCP handler: echo a JSON-RPC result. + reply(msg.payload.control_id, { + status: 200, + body: { + jsonrpc: '2.0', + id: (msg.payload.body as { id?: unknown })?.id ?? null, + result: { tools: ['vault_get_address'], handled_for: msg.payload.agent_id }, + }, + }); + } + }); + vaultWs.on('error', reject); + }); + }); + + afterAll(async () => { + vaultWs?.close(); + await server.stop(); + }); + + it('forwards an authorized MCP call to the vault and returns its response', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + + // connect + const c = await ( + await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`) }, + body: JSON.stringify({ connect_link: makeConnectLink(VAULT), code_challenge: s256(verifier) }), + }) + ).json(); + expect(c.match_code).toBe('AB23CD'); + + // token + const t = await ( + await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: c.device_code, code_verifier: verifier }), + }) + ).json(); + const accessToken = t.access_token as string; + expect(accessToken).toBeTruthy(); + + // MCP call through the facade -> forwarded over WS to the vault. + const url = `${base()}/v/${VAULT}/mcp`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `DPoP ${accessToken}`, + dpop: await dpop(agent, 'POST', url, ath(accessToken)), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/list' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.jsonrpc).toBe('2.0'); + expect(body.id).toBe(7); + expect(body.result.handled_for).toBe('agent_1'); + + // The vault received the authenticated agent id + the original MCP body. + expect(lastMcp?.agent_id).toBe('agent_1'); + expect((lastMcp?.body as { method?: string })?.method).toBe('tools/list'); + }); + + it('honors a vault revoke push over WS (Rule #7 fast-fail at the relay)', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + const c = await ( + await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`) }, + body: JSON.stringify({ connect_link: makeConnectLink(VAULT), code_challenge: s256(verifier) }), + }) + ).json(); + const t = await ( + await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: c.device_code, code_verifier: verifier }), + }) + ).json(); + const accessToken = t.access_token as string; + const url = `${base()}/v/${VAULT}/mcp`; + const call = async () => + fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `DPoP ${accessToken}`, + dpop: await dpop(agent, 'POST', url, ath(accessToken)), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + + expect((await call()).status).toBe(200); + + // Simulate a relay restart losing the fresh agent->vault cache. The vault's + // authenticated socket must still be able to revoke its own agent, scoped by vault. + (server as any).agentVault.clear(); + + // The vault pushes a revoke; the relay must denylist the agent. + vaultWs.send( + JSON.stringify({ + type: 'cloud_connect_revoke', + payload: { vault_id: VAULT, agent_id: 'agent_1' }, + timestamp: new Date().toISOString(), + }) + ); + await new Promise((r) => setTimeout(r, 150)); + + expect((await call()).status).toBe(401); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-mcp-facade.test.ts b/packages/dcp-relay/tests/oauth-mcp-facade.test.ts new file mode 100644 index 0000000..b78816e --- /dev/null +++ b/packages/dcp-relay/tests/oauth-mcp-facade.test.ts @@ -0,0 +1,162 @@ +/** + * Tests for the per-vault MCP facade /v/:vaultId/mcp (P2e): + * full grant -> facade auth gate (audience + DPoP ath/jkt + jti replay + revoke), + * with a fake vault bridge (approves) and a fake MCP data bridge (echoes). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { randomUUID, createHash } from 'crypto'; +import { RelayServer } from '../src/relay.js'; +import type { VaultConnectBridge, McpDataBridge, AuthedMcpRequest } from '../src/oauth/index.js'; + +const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +function makeConnectLink(vault_id: string, link_id = 'cl_1'): string { + const env = { payload: { version: 1, vault_id, link_id }, signature: '' }; + return 'dcp_connect_v1_' + Buffer.from(JSON.stringify(env), 'utf8').toString('base64url'); +} +const s256 = (v: string) => createHash('sha256').update(v, 'ascii').digest('base64url'); +const ath = (t: string) => createHash('sha256').update(t, 'ascii').digest('base64url'); + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + return { privateKey, publicJwk: await exportJWK(publicKey) }; +} +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string, + athClaim?: string +): Promise { + const payload: Record = { htm: method, htu: url }; + if (athClaim) payload.ath = athClaim; + return new SignJWT(payload) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('MCP facade /v/:vaultId/mcp', () => { + let server: RelayServer; + let port: number; + let forwarded: AuthedMcpRequest | null = null; + const base = () => `http://127.0.0.1:${port}`; + + const fakeVault: VaultConnectBridge = { + async redeem() { + return { ok: true, agentId: 'agent_1', matchCode: 'AB23CD' }; + }, + async approvalStatus() { + return { status: 'approved', scope: 'read:wallet.address' }; + }, + }; + const fakeMcp: McpDataBridge = { + async forward(req) { + forwarded = req; + return { status: 200, body: { ok: true, agent: req.agentId, echo: req.body } }; + }, + }; + + beforeAll(async () => { + port = 21000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, vaultBridge: fakeVault, mcpBridge: fakeMcp }); + await server.start(); + }); + afterAll(async () => { + await server.stop(); + }); + + /** Run the full grant for a vault and return the agent key + access token. */ + async function getToken(vaultId: string) { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + const c = await ( + await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`) }, + body: JSON.stringify({ connect_link: makeConnectLink(vaultId), code_challenge: s256(verifier) }), + }) + ).json(); + const t = await ( + await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ grant_type: DEVICE_GRANT, device_code: c.device_code, code_verifier: verifier }), + }) + ).json(); + return { agent, accessToken: t.access_token as string }; + } + + async function mcpCall( + vaultId: string, + agent: Awaited>, + accessToken: string, + opts: { proof?: string } = {} + ) { + const url = `${base()}/v/${vaultId}/mcp`; + const proof = opts.proof ?? (await dpop(agent, 'POST', url, ath(accessToken))); + return fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `DPoP ${accessToken}`, dpop: proof }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 }), + }); + } + + it('forwards an authorized MCP request to the vault', async () => { + forwarded = null; + const { agent, accessToken } = await getToken('vault_A'); + const res = await mcpCall('vault_A', agent, accessToken); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.agent).toBe('agent_1'); + expect(forwarded?.vaultId).toBe('vault_A'); + expect(forwarded?.scope).toBe('read:wallet.address'); + }); + + it('401s with no/!DPoP authorization', async () => { + const res = await fetch(`${base()}/v/vault_A/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toContain('resource_metadata='); + }); + + it('enforces audience isolation: a token for vault A is rejected at vault B', async () => { + const { agent, accessToken } = await getToken('vault_A'); + const res = await mcpCall('vault_B', agent, accessToken); + expect(res.status).toBe(401); + }); + + it('blocks DPoP proof replay (same jti twice)', async () => { + const { agent, accessToken } = await getToken('vault_A'); + const url = `${base()}/v/vault_A/mcp`; + const proof = await dpop(agent, 'POST', url, ath(accessToken)); + const first = await mcpCall('vault_A', agent, accessToken, { proof }); + expect(first.status).toBe(200); + const replay = await mcpCall('vault_A', agent, accessToken, { proof }); + expect(replay.status).toBe(401); + }); + + it('rejects a DPoP proof without the access-token hash (ath)', async () => { + const { agent, accessToken } = await getToken('vault_A'); + const url = `${base()}/v/vault_A/mcp`; + const proofNoAth = await dpop(agent, 'POST', url); // no ath + const res = await mcpCall('vault_A', agent, accessToken, { proof: proofNoAth }); + expect(res.status).toBe(401); + }); + + it('honors instant revoke (Rule #7): revoked agent is rejected', async () => { + const { agent, accessToken } = await getToken('vault_A'); + // Works first. + expect((await mcpCall('vault_A', agent, accessToken)).status).toBe(200); + // Owner revokes the agent. + server.revokeAgentAccess('agent_1'); + const res = await mcpCall('vault_A', agent, accessToken); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-pairing-urlonly.test.ts b/packages/dcp-relay/tests/oauth-pairing-urlonly.test.ts new file mode 100644 index 0000000..c10b8f7 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-pairing-urlonly.test.ts @@ -0,0 +1,217 @@ +/** + * Link-less "paste-URL" pairing (Model A): a vanilla OAuth MCP client (Hermes / + * Claude.ai / ChatGPT) connects with ONLY the per-vault MCP URL — no connect-link + * secret. The relay derives the vault from the `resource` and asks the vault to + * open an on-device approval directly. Drives the full path with a fake bridge: + * GET /oauth/authorize?resource=... -> pairing + session -> status poll -> + * authorization_code at /token -> token works at the facade. + * + * Also asserts the device-flow (/oauth/connect) link-less path, and that a bridge + * WITHOUT requestPairing falls back safely (form for browser, error for device). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { randomUUID, createHash } from 'crypto'; +import { RelayServer } from '../src/relay.js'; +import type { VaultConnectBridge, BridgePairingInput } from '../src/oauth/index.js'; + +const VAULT = 'vault_urlonly_123456'; +const s256 = (v: string) => createHash('sha256').update(v, 'ascii').digest('base64url'); +const ath = (t: string) => createHash('sha256').update(t, 'ascii').digest('base64url'); + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + return { privateKey, publicJwk: await exportJWK(publicKey) }; +} +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string, + athClaim?: string +): Promise { + const payload: Record = { htm: method, htu: url }; + if (athClaim) payload.ath = athClaim; + return new SignJWT(payload) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('Link-less paste-URL pairing (/oauth/authorize + /oauth/connect)', () => { + let server: RelayServer; + let port: number; + const base = () => `http://127.0.0.1:${port}`; + const resource = () => `${base()}/v/${VAULT}/mcp`; + + const pairingCalls: BridgePairingInput[] = []; + const fakeVault: VaultConnectBridge = { + async redeem() { + return { ok: true, agentId: 'agent_link', matchCode: 'AB23CD' }; + }, + async approvalStatus() { + return { status: 'approved', scope: 'read:wallet.address' }; + }, + async requestPairing(input) { + pairingCalls.push(input); + return { ok: true, agentId: 'agent_url', matchCode: 'X9MK7P' }; + }, + }; + + beforeAll(async () => { + port = 24000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, vaultBridge: fakeVault }); + await server.start(); + }); + afterAll(async () => { + await server.stop(); + }); + + it('pairs from the URL alone (no connect-link), surfaces a match code, completes auth-code', async () => { + const verifier = 'v'.repeat(64); + const url = + `${base()}/oauth/authorize?resource=${encodeURIComponent(resource())}` + + `&code_challenge=${s256(verifier)}&code_challenge_method=S256` + + `&redirect_uri=${encodeURIComponent('https://app.example/cb')}&state=st1`; + const res = await fetch(url); + expect(res.status).toBe(200); + const html = await res.text(); + // No connect-link form was shown — we went straight to the approval page. + expect(html).not.toContain('name="connect_link"'); + expect(html).toContain('X9MK7P'); // match code surfaced + expect(pairingCalls.length).toBeGreaterThan(0); + expect(pairingCalls[0].vaultId).toBe(VAULT); + + const sessionId = (html.match(/"sessionId":"(as_[^"]+)"/) || [])[1]; + expect(sessionId).toBeTruthy(); + + const status = await (await fetch(`${base()}/oauth/authorize/status?session=${sessionId}`)).json(); + expect(status.status).toBe('approved'); + + const agent = await makeAgentKey(); + const tokenRes = await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/token`) }, + body: JSON.stringify({ grant_type: 'authorization_code', code: status.code, code_verifier: verifier }), + }); + expect(tokenRes.status).toBe(200); + const tok = await tokenRes.json(); + expect(tok.access_token).toBeTruthy(); + + // Token is accepted at the per-vault facade (503 = auth OK, no real data plane). + const facade = await fetch(resource(), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `DPoP ${tok.access_token}`, + dpop: await dpop(agent, 'POST', resource(), ath(tok.access_token)), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(facade.status).not.toBe(401); + expect([200, 503]).toContain(facade.status); + }); + + it('issues a BEARER token to a non-DPoP client and accepts it at the facade', async () => { + const verifier = 'b'.repeat(64); + // Browser-style auth: GET authorize (no DPoP), approve (fake auto-approves). + const html = await ( + await fetch( + `${base()}/oauth/authorize?resource=${encodeURIComponent(resource())}` + + `&code_challenge=${s256(verifier)}&code_challenge_method=S256` + + `&redirect_uri=${encodeURIComponent('https://app.example/cb')}` + ) + ).text(); + const sessionId = (html.match(/"sessionId":"(as_[^"]+)"/) || [])[1]; + expect(sessionId).toBeTruthy(); + + // Token exchange as FORM-ENCODED with NO DPoP header (the Hermes reality). + const tokenRes = await fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: sessionId, + code_verifier: verifier, + }).toString(), + }); + expect(tokenRes.status).toBe(200); + const tok = await tokenRes.json(); + expect(tok.token_type).toBe('Bearer'); // not DPoP + expect(tok.access_token).toBeTruthy(); + + // The Bearer token is accepted at the facade WITHOUT a DPoP proof. + const facade = await fetch(resource(), { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${tok.access_token}` }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }); + expect(facade.status).not.toBe(401); + expect([200, 503]).toContain(facade.status); + }); + + it('device-flow /oauth/connect pairs from a resource (no connect-link)', async () => { + const verifier = 'd'.repeat(64); + const agent = await makeAgentKey(); + const res = await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`) }, + body: JSON.stringify({ resource: resource(), code_challenge: s256(verifier), code_challenge_method: 'S256' }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.device_code).toBeTruthy(); + expect(body.match_code).toBe('X9MK7P'); + }); + + it('falls back to the connect-link form when neither link nor resolvable resource is present', async () => { + const res = await fetch(`${base()}/oauth/authorize?redirect_uri=https://app/cb`); + const html = await res.text(); + expect(html).toContain('name="connect_link"'); + }); +}); + +describe('Link-less pairing when the bridge does not support it', () => { + let server: RelayServer; + let port: number; + const base = () => `http://127.0.0.1:${port}`; + const resource = () => `${base()}/v/${VAULT}/mcp`; + + // No requestPairing implemented. + const noPairing: VaultConnectBridge = { + async redeem() { + return { ok: true, agentId: 'agent_link', matchCode: 'AB23CD' }; + }, + async approvalStatus() { + return { status: 'pending' }; + }, + }; + + beforeAll(async () => { + port = 25000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, vaultBridge: noPairing }); + await server.start(); + }); + afterAll(async () => { + await server.stop(); + }); + + it('device-flow returns an error rather than pairing silently', async () => { + const verifier = 'd'.repeat(64); + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + const publicJwk = await exportJWK(publicKey); + const proof = await new SignJWT({ htm: 'POST', htu: `${base()}/oauth/connect` }) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(privateKey); + const res = await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', dpop: proof }, + body: JSON.stringify({ resource: resource(), code_challenge: s256(verifier), code_challenge_method: 'S256' }), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe('invalid_request'); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-store.test.ts b/packages/dcp-relay/tests/oauth-store.test.ts new file mode 100644 index 0000000..0bcbd76 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-store.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for the relay OAuth AS state: DCR clients, authorization sessions, and + * rotating refresh tokens with reuse detection (Rule #3). + */ + +import { describe, it, expect } from 'vitest'; +import { ClientStore, AuthSessionStore, RefreshTokenStore } from '../src/oauth/index.js'; + +describe('ClientStore (DCR)', () => { + it('issues a client_id and treats display strings as untrusted (clamped)', () => { + const store = new ClientStore(); + const c = store.register({ client_name: 'x'.repeat(500), redirect_uris: ['https://a', 'https://b'] }); + expect(c.client_id).toMatch(/^dcpc_/); + expect(c.token_endpoint_auth_method).toBe('none'); + expect((c.client_name || '').length).toBeLessThanOrEqual(200); + expect(store.get(c.client_id)).toBeTruthy(); + }); +}); + +describe('AuthSessionStore', () => { + const base = { + vault_id: 'vault_1', + link_id: 'cl_1', + agent_jkt: 'jkt_abc', + code_challenge: 'chal', + scope: 'read:wallet.address', + audience: 'https://relay/v/vault_1/mcp', + match_code: 'AB23CD', + }; + + it('creates a pending session and transitions through approval', () => { + const store = new AuthSessionStore(); + const s = store.create(base); + expect(s.status).toBe('pending_approval'); + expect(s.session_id).toMatch(/^as_/); + expect(store.setStatus(s.session_id, 'approved', 'agent_1')).toBe(true); + expect(store.get(s.session_id)!.status).toBe('approved'); + expect(store.get(s.session_id)!.agent_id).toBe('agent_1'); + }); + + it('auto-expires a stale, unissued session', () => { + const store = new AuthSessionStore(-1); // already expired + const s = store.create(base); + expect(store.get(s.session_id)!.status).toBe('expired'); + }); +}); + +describe('RefreshTokenStore (rotation + reuse detection)', () => { + const ctx = { + vault_id: 'vault_1', + agent_id: 'agent_1', + jkt: 'jkt_abc', + scope: 'read:wallet.address', + audience: 'https://relay/v/vault_1/mcp', + }; + + it('rotates a token and returns a fresh one bound to the same context', () => { + const store = new RefreshTokenStore(); + const { token } = store.issue(ctx); + const r = store.rotate(token); + expect(r.ok).toBe(true); + expect(r.token).toBeTruthy(); + expect(r.token).not.toBe(token); + expect(r.context).toMatchObject({ vault_id: 'vault_1', agent_id: 'agent_1', jkt: 'jkt_abc' }); + }); + + it('detects reuse of a spent token and revokes the WHOLE chain (Rule #3)', () => { + const store = new RefreshTokenStore(); + const { token: t0 } = store.issue(ctx); + const r1 = store.rotate(t0); // t0 spent, t1 issued + expect(r1.ok).toBe(true); + const t1 = r1.token!; + + // Attacker replays the already-rotated t0 -> reuse detected. + const replay = store.rotate(t0); + expect(replay.ok).toBe(false); + expect(replay.reason).toBe('reuse_detected'); + + // The legitimate (newer) t1 is now also dead — whole chain revoked. + const afterRevoke = store.rotate(t1); + expect(afterRevoke.ok).toBe(false); + expect(afterRevoke.reason).toBe('revoked'); + }); + + it('rejects unknown and revoked-by-agent tokens', () => { + const store = new RefreshTokenStore(); + expect(store.rotate('dcprt_nope').reason).toBe('not_found'); + + const { token } = store.issue(ctx); + const n = store.revokeByAgent('agent_1'); + expect(n).toBe(1); + expect(store.rotate(token).ok).toBe(false); + }); + + it('keeps separate chains independent', () => { + const store = new RefreshTokenStore(); + const a = store.issue(ctx); + const b = store.issue({ ...ctx, agent_id: 'agent_2' }); + // Reuse on chain A must not affect chain B. + const a1 = store.rotate(a.token); + store.rotate(a.token); // reuse -> revoke chain A + expect(store.rotate(a1.token!).ok).toBe(false); + expect(store.rotate(b.token).ok).toBe(true); + }); +}); diff --git a/packages/dcp-relay/tests/oauth-ws-bridge.test.ts b/packages/dcp-relay/tests/oauth-ws-bridge.test.ts new file mode 100644 index 0000000..d5a8fe4 --- /dev/null +++ b/packages/dcp-relay/tests/oauth-ws-bridge.test.ts @@ -0,0 +1,191 @@ +/** + * End-to-end test of the Cloud-Connect WS control bridge (P2d-2): + * a (simulated) vault registers over /ws and answers cloud_connect_redeem / + * cloud_connect_status control messages; the relay's /oauth/connect + /oauth/token + * drive that bridge to issue a real DPoP/audience-bound token. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import WebSocket from 'ws'; +import { ed25519 } from '@noble/curves/ed25519'; +import { randomBytes, randomUUID } from 'crypto'; +import { SignJWT, generateKeyPair, exportJWK, type JWK } from 'jose'; +import { createHash } from 'crypto'; +import { RelayServer } from '../src/relay.js'; + +const VAULT = 'vault_wsbridge_1'; + +function makeConnectLink(vault_id = VAULT, link_id = 'cl_1'): string { + const env = { payload: { version: 1, vault_id, link_id }, signature: '' }; + return 'dcp_connect_v1_' + Buffer.from(JSON.stringify(env), 'utf8').toString('base64url'); +} + +function s256(verifier: string): string { + return createHash('sha256').update(verifier, 'ascii').digest('base64url'); +} + +async function makeAgentKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + return { privateKey, publicJwk: await exportJWK(publicKey) }; +} + +async function dpop( + key: { privateKey: CryptoKey | Uint8Array; publicJwk: JWK }, + method: string, + url: string +): Promise { + return new SignJWT({ htm: method, htu: url }) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(key.privateKey as Parameters[0]); +} + +describe('Cloud-Connect WS bridge (relay <-> vault)', () => { + let server: RelayServer; + let port: number; + let vaultWs: WebSocket; + let approvalStatus = 'pending'; + const base = () => `http://127.0.0.1:${port}`; + + beforeAll(async () => { + port = 20000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, debug: false }); + await server.start(); + + // Simulate a vault: register over /ws, then answer cloud-connect control msgs. + const signPriv = ed25519.utils.randomPrivateKey(); + const signPub = ed25519.getPublicKey(signPriv); + vaultWs = new WebSocket(`ws://127.0.0.1:${port}/ws`); + + await new Promise((resolve, reject) => { + vaultWs.on('open', () => { + const timestamp = new Date().toISOString(); + const nonce = randomBytes(32); + const message = Buffer.concat([ + Buffer.from(VAULT, 'utf8'), + Buffer.from(timestamp, 'utf8'), + nonce, + ]); + const signature = ed25519.sign(message, signPriv); + vaultWs.send( + JSON.stringify({ + type: 'register', + payload: { + vault_id: VAULT, + public_key: Buffer.from(randomBytes(32)).toString('base64'), + signing_public_key: Buffer.from(signPub).toString('base64'), + timestamp, + nonce: nonce.toString('base64'), + signature: Buffer.from(signature).toString('base64'), + }, + timestamp, + }) + ); + }); + vaultWs.on('message', (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'ack' && msg.payload?.registered) resolve(); + if (msg.type === 'cloud_connect_redeem') { + vaultWs.send( + JSON.stringify({ + type: 'cloud_connect_result', + payload: { + control_id: msg.payload.control_id, + ok: true, + agent_id: 'agent_1', + match_code: 'AB23CD', + }, + timestamp: new Date().toISOString(), + }) + ); + } + if (msg.type === 'cloud_connect_status') { + vaultWs.send( + JSON.stringify({ + type: 'cloud_connect_result', + payload: { + control_id: msg.payload.control_id, + status: approvalStatus, + scope: 'read:wallet.address', + }, + timestamp: new Date().toISOString(), + }) + ); + } + }); + vaultWs.on('error', reject); + }); + }); + + afterAll(async () => { + vaultWs?.close(); + await server.stop(); + }); + + it('drives connect -> (pending) -> approved -> token over the WS bridge', async () => { + const agent = await makeAgentKey(); + const verifier = 'v'.repeat(64); + + // 1. /oauth/connect — relay redeems via the WS bridge, returns the vault's match-code. + const connectRes = await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`), + }, + body: JSON.stringify({ connect_link: makeConnectLink(), code_challenge: s256(verifier) }), + }); + expect(connectRes.status).toBe(200); + const connect = await connectRes.json(); + expect(connect.match_code).toBe('AB23CD'); + const deviceCode = connect.device_code as string; + + const token = async () => + fetch(`${base()}/oauth/token`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + dpop: await dpop(agent, 'POST', `${base()}/oauth/token`), + }, + body: JSON.stringify({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + code_verifier: verifier, + }), + }); + + // 2. Pending until the (simulated) owner approves. + approvalStatus = 'pending'; + const pending = await token(); + expect(pending.status).toBe(400); + expect((await pending.json()).error).toBe('authorization_pending'); + + // 3. Approved -> issues a DPoP + audience-bound access token. + approvalStatus = 'approved'; + const ok = await token(); + expect(ok.status).toBe(200); + const body = await ok.json(); + expect(body.token_type).toBe('DPoP'); + expect(body.access_token).toBeTruthy(); + expect(body.refresh_token).toBeTruthy(); + expect(body.scope).toBe('read:wallet.address'); + }); + + it('returns vault_unreachable (503) when the target vault is not connected', async () => { + const agent = await makeAgentKey(); + const res = await fetch(`${base()}/oauth/connect`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + dpop: await dpop(agent, 'POST', `${base()}/oauth/connect`), + }, + body: JSON.stringify({ + connect_link: makeConnectLink('vault_not_connected'), + code_challenge: s256('v'.repeat(64)), + }), + }); + expect(res.status).toBe(503); + expect((await res.json()).error).toBe('temporarily_unavailable'); + }); +}); diff --git a/packages/dcp-relay/tests/oauth.test.ts b/packages/dcp-relay/tests/oauth.test.ts new file mode 100644 index 0000000..f45f409 --- /dev/null +++ b/packages/dcp-relay/tests/oauth.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for the relay OAuth 2.1 primitives (Cloud-Connect P2 foundation): + * keys/JWKS, PKCE (S256), DPoP (RFC 9449), access tokens (audience + cnf.jkt), + * and discovery metadata. + */ + +import { describe, it, expect } from 'vitest'; +import { SignJWT, generateKeyPair, exportJWK, calculateJwkThumbprint, type JWK } from 'jose'; +import { randomUUID, createHash } from 'crypto'; +import { + initOAuthKeys, + computeS256Challenge, + verifyPkceS256, + isValidCodeVerifier, + verifyDpopProof, + createJtiGuard, + issueAccessToken, + verifyAccessToken, + AccessTokenError, + DpopError, + authorizationServerMetadata, + protectedResourceMetadata, +} from '../src/oauth/index.js'; + +// --- helpers ---------------------------------------------------------------- + +async function makeDpopKey() { + const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); + const publicJwk = await exportJWK(publicKey); + const jkt = await calculateJwkThumbprint(publicJwk, 'sha256'); + return { publicKey, privateKey, publicJwk, jkt }; +} + +async function makeProof( + privateKey: CryptoKey | Uint8Array, + publicJwk: JWK, + method: string, + url: string, + opts: { jti?: string; ath?: string; iat?: number } = {} +): Promise { + const payload: Record = { htm: method, htu: url }; + if (opts.ath) payload.ath = opts.ath; + const jwt = new SignJWT(payload) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: publicJwk }) + .setJti(opts.jti ?? randomUUID()); + if (opts.iat !== undefined) jwt.setIssuedAt(opts.iat); + else jwt.setIssuedAt(); + return jwt.sign(privateKey as Parameters[0]); +} + +// --- PKCE ------------------------------------------------------------------- + +describe('PKCE (S256)', () => { + it('verifies a correct verifier against its challenge', () => { + const verifier = 'a'.repeat(64); + const challenge = computeS256Challenge(verifier); + expect(verifyPkceS256(verifier, challenge)).toBe(true); + }); + + it('rejects a wrong verifier and malformed input', () => { + const challenge = computeS256Challenge('a'.repeat(64)); + expect(verifyPkceS256('b'.repeat(64), challenge)).toBe(false); + expect(verifyPkceS256('too-short', challenge)).toBe(false); + expect(verifyPkceS256('a'.repeat(64), 'not-the-challenge')).toBe(false); + }); + + it('validates verifier charset/length per RFC 7636', () => { + expect(isValidCodeVerifier('a'.repeat(43))).toBe(true); + expect(isValidCodeVerifier('a'.repeat(128))).toBe(true); + expect(isValidCodeVerifier('a'.repeat(42))).toBe(false); + expect(isValidCodeVerifier('a'.repeat(129))).toBe(false); + expect(isValidCodeVerifier('has spaces!!' + 'a'.repeat(40))).toBe(false); + }); +}); + +// --- keys / JWKS ------------------------------------------------------------ + +describe('OAuth keys', () => { + it('generates a usable signing key and a public-only JWKS', async () => { + const keys = await initOAuthKeys({ warn: () => {} }); + expect(keys.alg).toBe('ES256'); + expect(keys.kid).toMatch(/^[A-Za-z0-9_-]{20,}$/); + const jwks = keys.jwks(); + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0].kid).toBe(keys.kid); + expect(jwks.keys[0].use).toBe('sig'); + // The published JWK must NOT contain private material. + expect((jwks.keys[0] as JWK).d).toBeUndefined(); + }); +}); + +// --- DPoP ------------------------------------------------------------------- + +describe('DPoP (RFC 9449)', () => { + const url = 'https://relay.example/v/vault_1/mcp'; + + it('verifies a valid proof and returns the bound jkt', async () => { + const k = await makeDpopKey(); + const proof = await makeProof(k.privateKey, k.publicJwk, 'POST', url); + const res = await verifyDpopProof(proof, { method: 'POST', url, jtiGuard: createJtiGuard() }); + expect(res.jkt).toBe(k.jkt); + }); + + it('rejects method / URL mismatch', async () => { + const k = await makeDpopKey(); + const guard = createJtiGuard(); + const proof = await makeProof(k.privateKey, k.publicJwk, 'POST', url); + await expect( + verifyDpopProof(proof, { method: 'GET', url, jtiGuard: guard }) + ).rejects.toBeInstanceOf(DpopError); + const proof2 = await makeProof(k.privateKey, k.publicJwk, 'POST', url); + await expect( + verifyDpopProof(proof2, { method: 'POST', url: url + '/other', jtiGuard: guard }) + ).rejects.toBeInstanceOf(DpopError); + }); + + it('ignores query/fragment when matching htu', async () => { + const k = await makeDpopKey(); + const proof = await makeProof(k.privateKey, k.publicJwk, 'POST', url); + const res = await verifyDpopProof(proof, { + method: 'POST', + url: url + '?foo=bar#frag', + jtiGuard: createJtiGuard(), + }); + expect(res.jkt).toBe(k.jkt); + }); + + it('blocks replay (same jti twice)', async () => { + const k = await makeDpopKey(); + const guard = createJtiGuard(); + const jti = randomUUID(); + const proof = await makeProof(k.privateKey, k.publicJwk, 'POST', url, { jti }); + await verifyDpopProof(proof, { method: 'POST', url, jtiGuard: guard }); + // Same proof again -> replay. + await expect( + verifyDpopProof(proof, { method: 'POST', url, jtiGuard: guard }) + ).rejects.toMatchObject({ code: 'dpop_replay' }); + }); + + it('rejects a proof whose header jwk contains private material', async () => { + const { privateKey } = await generateKeyPair('ES256', { extractable: true }); + const privJwk = await exportJWK(privateKey); // includes "d" + const proof = await new SignJWT({ htm: 'POST', htu: url }) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: privJwk }) + .setIssuedAt() + .setJti(randomUUID()) + .sign(privateKey); + await expect( + verifyDpopProof(proof, { method: 'POST', url, jtiGuard: createJtiGuard() }) + ).rejects.toBeInstanceOf(DpopError); + }); + + it('enforces ath when bound to an access token', async () => { + const k = await makeDpopKey(); + const token = 'fake.access.token'; + const ath = createHash('sha256').update(token, 'ascii').digest('base64url'); + const good = await makeProof(k.privateKey, k.publicJwk, 'POST', url, { ath }); + const res = await verifyDpopProof(good, { + method: 'POST', + url, + jtiGuard: createJtiGuard(), + accessToken: token, + }); + expect(res.jkt).toBe(k.jkt); + + const bad = await makeProof(k.privateKey, k.publicJwk, 'POST', url, { ath: 'wronghash' }); + await expect( + verifyDpopProof(bad, { method: 'POST', url, jtiGuard: createJtiGuard(), accessToken: token }) + ).rejects.toBeInstanceOf(DpopError); + }); + + it('rejects a stale proof (iat too old)', async () => { + const k = await makeDpopKey(); + const oldIat = Math.floor(Date.now() / 1000) - 10_000; + const proof = await makeProof(k.privateKey, k.publicJwk, 'POST', url, { iat: oldIat }); + await expect( + verifyDpopProof(proof, { method: 'POST', url, jtiGuard: createJtiGuard(), maxAgeSec: 300 }) + ).rejects.toBeInstanceOf(DpopError); + }); +}); + +// --- access tokens ---------------------------------------------------------- + +describe('Access tokens (audience + DPoP binding)', () => { + const issuer = 'https://relay.example'; + const audience = 'https://relay.example/v/vault_1/mcp'; + + it('issues and verifies a token bound to audience + jkt', async () => { + const keys = await initOAuthKeys({ warn: () => {} }); + const token = await issueAccessToken({ + keys, + issuer, + subject: 'agent_1', + audience, + vaultId: 'vault_1', + scope: 'read:wallet.address', + jkt: 'THUMBPRINT_ABC', + }); + const claims = await verifyAccessToken({ + keys, + token, + issuer, + expectedAudience: audience, + expectedJkt: 'THUMBPRINT_ABC', + }); + expect(claims.sub).toBe('agent_1'); + expect(claims.vault_id).toBe('vault_1'); + expect(claims.cnf.jkt).toBe('THUMBPRINT_ABC'); + expect(claims.scope).toBe('read:wallet.address'); + }); + + it('rejects audience isolation violation (token for vault A at vault B)', async () => { + const keys = await initOAuthKeys({ warn: () => {} }); + const token = await issueAccessToken({ + keys, + issuer, + subject: 'agent_1', + audience, + vaultId: 'vault_1', + scope: '', + jkt: 'jkt1', + }); + await expect( + verifyAccessToken({ + keys, + token, + issuer, + expectedAudience: 'https://relay.example/v/vault_2/mcp', + }) + ).rejects.toBeInstanceOf(AccessTokenError); + }); + + it('rejects a DPoP-key mismatch (stolen bearer without the key)', async () => { + const keys = await initOAuthKeys({ warn: () => {} }); + const token = await issueAccessToken({ + keys, + issuer, + subject: 'agent_1', + audience, + vaultId: 'vault_1', + scope: '', + jkt: 'real_jkt', + }); + await expect( + verifyAccessToken({ keys, token, issuer, expectedAudience: audience, expectedJkt: 'attacker_jkt' }) + ).rejects.toBeInstanceOf(AccessTokenError); + }); + + it('rejects a token signed by a different relay key', async () => { + const keysA = await initOAuthKeys({ warn: () => {} }); + const keysB = await initOAuthKeys({ warn: () => {} }); + const token = await issueAccessToken({ + keys: keysA, + issuer, + subject: 'agent_1', + audience, + vaultId: 'vault_1', + scope: '', + jkt: 'jkt1', + }); + await expect( + verifyAccessToken({ keys: keysB, token, issuer, expectedAudience: audience }) + ).rejects.toBeInstanceOf(AccessTokenError); + }); +}); + +// --- metadata --------------------------------------------------------------- + +describe('Discovery metadata', () => { + it('AS metadata advertises only OAuth 2.1-safe options', () => { + const md = authorizationServerMetadata({ issuer: 'https://relay.example/' }); + expect(md.issuer).toBe('https://relay.example'); + expect(md.code_challenge_methods_supported).toEqual(['S256']); + expect(md.response_types_supported).toEqual(['code']); + expect(md.grant_types_supported).toContain('authorization_code'); + expect(md.grant_types_supported).toContain('refresh_token'); + expect(md.dpop_signing_alg_values_supported).toContain('ES256'); + expect(md.token_endpoint).toBe('https://relay.example/oauth/token'); + }); + + it('PRM points at the authorization server for a resource', () => { + const md = protectedResourceMetadata({ + resource: 'https://relay.example/v/vault_1/mcp', + authorizationServers: ['https://relay.example'], + }); + expect(md.resource).toBe('https://relay.example/v/vault_1/mcp'); + expect(md.authorization_servers).toEqual(['https://relay.example']); + expect(md.bearer_methods_supported).toEqual(['header']); + }); +}); diff --git a/packages/dcp-relay/tests/relay-security.test.ts b/packages/dcp-relay/tests/relay-security.test.ts new file mode 100644 index 0000000..ff90f55 --- /dev/null +++ b/packages/dcp-relay/tests/relay-security.test.ts @@ -0,0 +1,296 @@ +/** + * Security regressions for the relay hardening: + * - #6: a vault_id is bound to its first signing key (TOFU). A DIFFERENT key + * claiming an already-registered vault_id is rejected (anti-hijack). + * - #5: the HTTP-fallback /relay/poll and /relay/respond require a signed proof + * of vault ownership — unauthenticated callers cannot drain the queue or + * race responses. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import WebSocket from 'ws'; +import { ed25519 } from '@noble/curves/ed25519'; +import { randomBytes } from 'crypto'; +import { RelayServer } from '../src/relay.js'; +import { RELAY_VERSION, type RelayEnvelope } from '../src/types.js'; + +const VAULT = 'vault_sec_regress_1'; + +function signRegister(vaultId: string, signPriv: Uint8Array) { + const signPub = ed25519.getPublicKey(signPriv); + const timestamp = new Date().toISOString(); + const nonce = randomBytes(32); + const message = Buffer.concat([ + Buffer.from(vaultId, 'utf8'), + Buffer.from(timestamp, 'utf8'), + nonce, + ]); + const signature = ed25519.sign(message, signPriv); + return { + vault_id: vaultId, + public_key: Buffer.from(randomBytes(32)).toString('base64'), + signing_public_key: Buffer.from(signPub).toString('base64'), + timestamp, + nonce: nonce.toString('base64'), + signature: Buffer.from(signature).toString('base64'), + }; +} + +function registerWs(port: number, payload: Record): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + let settled = false; + const done = (r: { ok: boolean; error?: string }) => { + if (settled) return; + settled = true; + try { ws.close(); } catch { /* noop */ } + resolve(r); + }; + ws.on('open', () => ws.send(JSON.stringify({ type: 'register', payload, timestamp: new Date().toISOString() }))); + ws.on('message', (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'ack' && msg.payload?.registered) done({ ok: true }); + if (msg.type === 'error') done({ ok: false, error: msg.payload?.code || 'error' }); + }); + ws.on('error', () => done({ ok: false, error: 'ws_error' })); + setTimeout(() => done({ ok: false, error: 'timeout' }), 3000); + }); +} + +function openRegisteredWs( + port: number, + payload: Record +): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + try { ws.close(); } catch { /* noop */ } + reject(err); + }; + ws.on('open', () => ws.send(JSON.stringify({ type: 'register', payload, timestamp: new Date().toISOString() }))); + ws.on('message', (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'ack' && msg.payload?.registered) { + settled = true; + resolve(ws); + } + if (msg.type === 'error') { + fail(new Error(msg.payload?.code || 'error')); + } + }); + ws.on('error', fail); + setTimeout(() => fail(new Error('timeout')), 3000); + }); +} + +function waitForWsMessage(ws: WebSocket, predicate: (msg: any) => boolean): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.off('message', onMessage); + reject(new Error('timeout')); + }, 3000); + const onMessage = (data: Buffer) => { + const msg = JSON.parse(data.toString()); + if (!predicate(msg)) return; + clearTimeout(timer); + ws.off('message', onMessage); + resolve(msg); + }; + ws.on('message', onMessage); + }); +} + +describe('Relay security regressions (#5 poll/respond auth, #6 vault_id binding)', () => { + let server: RelayServer; + let port: number; + const base = () => `http://127.0.0.1:${port}`; + + beforeAll(async () => { + port = 26000 + Math.floor(Math.random() * 9000); + server = new RelayServer({ port, enableLongPoll: true }); + await server.start(); + }); + afterAll(async () => { + await server.stop(); + }); + + it('#6: rejects a different key claiming an already-bound vault_id', async () => { + const keyA = ed25519.utils.randomPrivateKey(); + const first = await registerWs(port, signRegister(VAULT, keyA)); + expect(first.ok).toBe(true); + + // Same key re-registers — allowed (idempotent reconnect). + const again = await registerWs(port, signRegister(VAULT, keyA)); + expect(again.ok).toBe(true); + + // A DIFFERENT key claims the same vault_id — rejected (hijack). + const keyB = ed25519.utils.randomPrivateKey(); + const hijack = await registerWs(port, signRegister(VAULT, keyB)); + expect(hijack.ok).toBe(false); + expect(hijack.error).toBe('RELAY_UNAUTHORIZED'); + }); + + it('#5: /relay/poll without a signed proof is rejected', async () => { + const res = await fetch(`${base()}/relay/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ vault_id: VAULT, timeout_ms: 100 }), + }); + expect(res.status).toBe(401); + }); + + it('#5: /relay/poll WITH a valid proof from the bound owner is accepted', async () => { + // Bind the vault first. + const key = ed25519.utils.randomPrivateKey(); + const v = 'vault_poll_ok_1'; + expect((await registerWs(port, signRegister(v, key))).ok).toBe(true); + + const proof = signRegister(v, key); + const res = await fetch(`${base()}/relay/poll`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + vault_id: v, + timeout_ms: 100, + signing_public_key: proof.signing_public_key, + timestamp: proof.timestamp, + nonce: proof.nonce, + signature: proof.signature, + }), + }); + expect(res.status).toBe(200); + }); + + it('#5: /relay/respond without a signed proof is rejected', async () => { + const res = await fetch(`${base()}/relay/respond`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + version: '1', + request_id: 'req_whatever', + encrypted_payload: 'x', + timestamp: new Date().toISOString(), + }), + }); + expect(res.status).toBe(401); + }); + + it('#3: the unauthenticated HTTP pairing-claims resolve route is removed', async () => { + const res = await fetch(`${base()}/v1/pairing-claims/claim_anything/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'approve', vault_id: VAULT }), + }); + // Route no longer exists → Fastify 404 (definitely not a 200 success). + expect(res.status).toBe(404); + }); + + it('#5: WS response from the wrong vault cannot race another vault request', async () => { + const ownerKey = ed25519.utils.randomPrivateKey(); + const attackerKey = ed25519.utils.randomPrivateKey(); + const ownerVault = 'vault_ws_response_owner'; + const attackerVault = 'vault_ws_response_attacker'; + const ownerWs = await openRegisteredWs(port, signRegister(ownerVault, ownerKey)); + const attackerWs = await openRegisteredWs(port, signRegister(attackerVault, attackerKey)); + + try { + const envelope: RelayEnvelope = { + version: RELAY_VERSION, + vault_id: ownerVault, + request_id: `req_ws_owner_${Date.now()}`, + action_type: 'sign', + encrypted_payload: 'encrypted_request', + expires_at: new Date(Date.now() + 60_000).toISOString(), + }; + server.getMessageStore().storeMessage(envelope); + + attackerWs.send(JSON.stringify({ + type: 'response', + payload: { + version: RELAY_VERSION, + request_id: envelope.request_id, + encrypted_payload: 'attacker_response', + timestamp: new Date().toISOString(), + }, + timestamp: new Date().toISOString(), + })); + + const err = await waitForWsMessage(attackerWs, (msg) => msg.type === 'error'); + expect(err.payload?.code).toBe('RELAY_UNAUTHORIZED'); + expect(server.getMessageStore().getResponse(envelope.request_id)).toBeUndefined(); + + ownerWs.send(JSON.stringify({ + type: 'response', + payload: { + version: RELAY_VERSION, + request_id: envelope.request_id, + encrypted_payload: 'owner_response', + timestamp: new Date().toISOString(), + }, + timestamp: new Date().toISOString(), + })); + + const ack = await waitForWsMessage(ownerWs, (msg) => msg.type === 'ack' && msg.payload?.stored); + expect(ack.payload?.request_id).toBe(envelope.request_id); + expect(server.getMessageStore().getResponse(envelope.request_id)?.encrypted_payload).toBe('owner_response'); + } finally { + ownerWs.close(); + attackerWs.close(); + } + }); + + it('#7: WS revoke is vault-scoped even when the fresh agent ownership cache is absent', async () => { + const vaultKey = ed25519.utils.randomPrivateKey(); + const otherKey = ed25519.utils.randomPrivateKey(); + const vaultWs = await openRegisteredWs(port, signRegister('vault_revoke_owner', vaultKey)); + const otherWs = await openRegisteredWs(port, signRegister('vault_revoke_other', otherKey)); + + try { + vaultWs.send(JSON.stringify({ + type: 'cloud_connect_revoke', + payload: { vault_id: 'vault_revoke_owner', agent_id: 'agent_after_restart' }, + timestamp: new Date().toISOString(), + })); + await new Promise((r) => setTimeout(r, 100)); + + otherWs.send(JSON.stringify({ + type: 'cloud_connect_revoke', + payload: { vault_id: 'vault_revoke_owner', agent_id: 'agent_after_restart' }, + timestamp: new Date().toISOString(), + })); + const err = await waitForWsMessage(otherWs, (msg) => msg.type === 'error'); + expect(err.payload?.code).toBe('RELAY_UNAUTHORIZED'); + } finally { + vaultWs.close(); + otherWs.close(); + } + }); + + it('a socket cannot unregister ANOTHER vault (cross-tenant eviction)', async () => { + const victimKey = ed25519.utils.randomPrivateKey(); + const attackerKey = ed25519.utils.randomPrivateKey(); + const victimWs = await openRegisteredWs(port, signRegister('vault_evict_victim', victimKey)); + const attackerWs = await openRegisteredWs(port, signRegister('vault_evict_attacker', attackerKey)); + + try { + // Attacker (registered as its own vault) tries to evict the victim's connection. + attackerWs.send(JSON.stringify({ + type: 'unregister', + payload: { vault_id: 'vault_evict_victim' }, + timestamp: new Date().toISOString(), + })); + const err = await waitForWsMessage(attackerWs, (msg) => msg.type === 'error'); + expect(err.payload?.code).toBe('RELAY_UNAUTHORIZED'); + + // The victim is still connected (its id still counts in stats). + const stats = await (await fetch(`${base()}/stats`)).json(); + expect(stats.connectedVaults).toBeGreaterThanOrEqual(2); + } finally { + victimWs.close(); + attackerWs.close(); + } + }); +}); diff --git a/packages/dcp-relay/tests/relay.test.ts b/packages/dcp-relay/tests/relay.test.ts index 51982e7..1118bba 100644 --- a/packages/dcp-relay/tests/relay.test.ts +++ b/packages/dcp-relay/tests/relay.test.ts @@ -637,7 +637,21 @@ describe('RelayServer', () => { }); describe('Request endpoint', () => { - it('registers mobile push tokens without exposing approval details', async () => { + it('registers mobile push tokens (with a signed proof of vault ownership)', async () => { + // Push-token registration now requires the vault to prove ownership of vault_id + // (signed proof, same scheme as registration) so a stranger can't hijack a + // victim's approval pushes. + const signPriv = ed25519.utils.randomPrivateKey(); + const signPub = ed25519.getPublicKey(signPriv); + const timestamp = new Date().toISOString(); + const nonce = randomBytes(32); + const message = Buffer.concat([ + Buffer.from('vault_push', 'utf8'), + Buffer.from(timestamp, 'utf8'), + nonce, + ]); + const signature = ed25519.sign(message, signPriv); + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -646,6 +660,10 @@ describe('RelayServer', () => { token: 'ExpoPushToken[abc123_DEF-456]', platform: 'android', device_id: 'device_test', + signing_public_key: Buffer.from(signPub).toString('base64'), + timestamp, + nonce: nonce.toString('base64'), + signature: Buffer.from(signature).toString('base64'), }), }); const data = await response.json(); @@ -656,6 +674,19 @@ describe('RelayServer', () => { expect(server.getPushTokenStore().get('vault_push')?.token).toBe('ExpoPushToken[abc123_DEF-456]'); }); + it('rejects push token registration without a signed proof', async () => { + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vault_id: 'vault_push_unauth', + token: 'ExpoPushToken[xyz789]', + platform: 'android', + }), + }); + expect(response.status).toBe(401); + }); + it('rejects unsupported push token formats', async () => { const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { method: 'POST', diff --git a/packages/dcp-telegram/src/notification.ts b/packages/dcp-telegram/src/notification.ts index 5ce8f47..beab095 100644 --- a/packages/dcp-telegram/src/notification.ts +++ b/packages/dcp-telegram/src/notification.ts @@ -132,7 +132,9 @@ export function sanitizeAgentName(name: string): string { * Escape Markdown special characters for Telegram */ export function escapeMarkdown(text: string): string { - return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&'); + // Escape backslash FIRST (include it in the class) so a literal '\' in input + // can't smuggle Telegram MarkdownV2 control sequences past the sanitizer. + return text.replace(/[\\_*[\]()~`>#+=|{}.!-]/g, '\\$&'); } /** diff --git a/packages/dcp-vault/LICENSE b/packages/dcp-vault/LICENSE new file mode 100644 index 0000000..c7a7400 --- /dev/null +++ b/packages/dcp-vault/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/dcp-vault/package.json b/packages/dcp-vault/package.json index 3d1b4de..dba153e 100644 --- a/packages/dcp-vault/package.json +++ b/packages/dcp-vault/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/vault", - "version": "2.0.2", + "version": "3.0.0", "description": "DCP - Local vault runtime with CLI and server", "type": "module", "main": "./dist/index.js", @@ -28,9 +28,9 @@ "clean": "rm -rf dist" }, "dependencies": { - "@dcprotocol/client": "^2.0.1", - "@dcprotocol/core": "^2.0.0", - "@dcprotocol/relay-client": "^2.0.0", + "@dcprotocol/client": "^3.0.0", + "@dcprotocol/core": "^3.0.0", + "@dcprotocol/relay-client": "^3.0.0", "@fastify/cors": "^10.0.2", "@fastify/rate-limit": "^10.0.0", "@fastify/websocket": "^11.0.0", diff --git a/packages/dcp-vault/src/cli/commands/cloud-connect.ts b/packages/dcp-vault/src/cli/commands/cloud-connect.ts new file mode 100644 index 0000000..58a7836 --- /dev/null +++ b/packages/dcp-vault/src/cli/commands/cloud-connect.ts @@ -0,0 +1,338 @@ +/** + * dcp cloud-connect — manage Cloud-Connect links for cloud agents (OSS standalone). + * + * Lets a self-hoster drive the whole flow from the terminal without the desktop: + * dcp cloud-connect link [--scopes ...] [--relay ] # issue a connect-link + * dcp cloud-connect pending # see who's waiting + * dcp cloud-connect approve # approve on-device + * dcp cloud-connect deny + * dcp cloud-connect list + * dcp cloud-connect revoke + * + * Storage-direct (same machine as the vault). Issuing needs the vault's relay + * identity (kept in config), which this loads/creates the same way the server does. + */ + +import { Command } from 'commander'; +import { randomUUID } from 'crypto'; +import { + VaultStorage, + getBudgetEngine, + generateSigningKeyPair, + createConnectLink, +} from '@dcprotocol/core'; +import { generateKeyPair } from '@dcprotocol/relay-client'; +import { success, error, info, dim, bold, handleError } from '../utils.js'; + +interface RelayIdentity { + vaultId: string; + relayUrl: string; + hpkePublicKeyB64: string; + signingPublicKeyB64: string; + signingPrivateKey: Buffer; +} + +function openVault(): { storage: VaultStorage; budget: ReturnType } { + const storage = new VaultStorage(); + storage.initializeSchema(); + const budget = getBudgetEngine(storage); + return { storage, budget }; +} + +/** Load (or create) the vault's relay identity from config — mirrors the server. */ +async function loadIdentity(budget: ReturnType): Promise { + const config = budget.getConfig(); + + let vaultId = config.vault_id; + if (!vaultId) { + vaultId = `vault_${randomUUID()}`; + budget.setConfig('vault_id', vaultId); + } + + const relayUrl = config.relay_url || process.env.DCP_RELAY_URL || ''; + + let hpkePublicKeyB64 = config.relay_hpke_public_key || ''; + if (!hpkePublicKeyB64) { + const kp = await generateKeyPair(); + hpkePublicKeyB64 = kp.publicKey.toString('base64'); + budget.setConfig('relay_hpke_public_key', hpkePublicKeyB64); + budget.setConfig('relay_hpke_private_key', kp.privateKey.toString('base64')); + } + + let signingPublicKeyB64 = config.relay_signing_public_key || ''; + let signingPrivateKeyB64 = config.relay_signing_private_key || ''; + if (!signingPublicKeyB64 || !signingPrivateKeyB64) { + const s = generateSigningKeyPair(); + signingPublicKeyB64 = s.publicKey.toString('base64'); + signingPrivateKeyB64 = s.privateKey.toString('base64'); + budget.setConfig('relay_signing_public_key', signingPublicKeyB64); + budget.setConfig('relay_signing_private_key', signingPrivateKeyB64); + } + + return { + vaultId, + relayUrl, + hpkePublicKeyB64, + signingPublicKeyB64, + signingPrivateKey: Buffer.from(signingPrivateKeyB64, 'base64'), + }; +} + +/** Derive the public MCP resource URL the agent connects to from a relay URL. */ +function mcpUrlFor(relayUrl: string, vaultId: string): string { + if (!relayUrl) return ''; + const https = relayUrl.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://').replace(/\/+$/, ''); + return `${https}/v/${vaultId}/mcp`; +} + +// --------------------------------------------------------------------------- + +const linkCommand = new Command('link') + .description('Issue a one-time connect-link for a cloud agent') + .argument('', 'A name for the agent (e.g. openclaw-aws)') + .option('-s, --scopes ', 'Comma-separated scopes (e.g. read:wallet.address,sign:solana)', '') + .option('-r, --relay ', 'Relay base URL (default: DCP_RELAY_URL / saved config)') + .option('--budget-daily ', 'Daily budget (default: 0)', '0') + .option('--currency ', 'Budget currency (default: USD)', 'USD') + .option('--auto-approve ', 'Auto-approve under this amount (default: 0)', '0') + .action(async (name: string, options) => { + try { + const { storage, budget } = openVault(); + const identity = await loadIdentity(budget); + const relayUrl = options.relay || identity.relayUrl; + if (!relayUrl) { + error('No relay configured. Pass --relay or set DCP_RELAY_URL.'); + process.exit(1); + } + + const scopes = String(options.scopes || '') + .split(',') + .map((s: string) => s.trim()) + .filter(Boolean); + const agentId = `agent_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + + const link = createConnectLink( + { + vault_id: identity.vaultId, + agent_id: agentId, + agent_name: name, + mode: 'mcp', + relay_url: mcpUrlFor(relayUrl, identity.vaultId), + vault_hpke_public_key: identity.hpkePublicKeyB64, + vault_signing_public_key: identity.signingPublicKeyB64, + }, + identity.signingPrivateKey + ); + + storage.createCloudConnectLink({ + link_id: link.link_id, + secret: link.secret, + agent_id: agentId, + name, + permission_scopes: scopes, + budget: { + daily: Math.max(0, Number(options.budgetDaily) || 0), + currency: options.currency || 'USD', + auto_approve_under: Math.max(0, Number(options.autoApprove) || 0), + }, + expires_at: link.expires_at, + }); + + console.log(); + success('Connect-link created. Paste it into your cloud agent:'); + console.log(); + console.log(bold(link.token)); + console.log(); + console.log(dim(`Agent id: ${agentId}`)); + console.log(dim(`Key pin: ${link.vault_hpke_fingerprint.slice(0, 24)}…`)); + console.log(dim(`Expires: ${link.expires_at}`)); + info('Then run `dcp cloud-connect pending` and approve, verifying the match code.'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const pendingCommand = new Command('pending') + .description('List cloud agents waiting for your approval') + .action(async () => { + try { + const { storage } = openVault(); + const pending = storage.listPendingCloudConnectApprovals(); + if (pending.length === 0) { + info('No cloud agents waiting for approval.'); + return process.exit(0); + } + for (const p of pending) { + console.log(); + console.log(bold(`${p.name} ${dim(`(${p.agent_id})`)}`)); + console.log(` Match code: ${bold(p.match_code || '—')} ${dim('(confirm it matches the agent)')}`); + console.log(dim(` Source: ${p.source_ip || 'unknown'}`)); + console.log(dim(` Scopes: ${p.permission_scopes.join(', ') || 'none'}`)); + console.log( + dim(` Budget: ${p.budget.daily} ${p.budget.currency}/day, auto-approve under ${p.budget.auto_approve_under}`) + ); + console.log(dim(` Approve: dcp cloud-connect approve ${p.agent_id} ${p.match_code || ''}`)); + } + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const approveCommand = new Command('approve') + .description('Approve a pending cloud agent (verify the match code first)') + .argument('', 'Agent id from `pending`') + .argument('', 'The match code shown on the agent side') + .action(async (agentId: string, matchCode: string) => { + try { + const { storage } = openVault(); + const link = storage.getCloudConnectLinkByAgent(agentId); + if (!link || link.status !== 'redeemed') { + error('No pending approval for this agent.'); + process.exit(1); + } + if (!link.match_code || link.match_code.toUpperCase() !== matchCode.trim().toUpperCase()) { + error('Match code does not match. Aborting (anti-phishing).'); + process.exit(1); + } + const result = storage.approveCloudConnectLink(link.link_id); + if (!result.ok) { + error(`Approval failed: ${result.reason}`); + process.exit(1); + } + success(`Approved “${link.name}”. It can now operate via its own channel.`); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const denyCommand = new Command('deny') + .description('Deny a pending cloud agent') + .argument('', 'Agent id from `pending`') + .action(async (agentId: string) => { + try { + const { storage } = openVault(); + const link = storage.getCloudConnectLinkByAgent(agentId); + if (!link) { + error('No cloud-connect link for this agent.'); + process.exit(1); + } + storage.denyCloudConnectLink(link.link_id); + success('Denied.'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const listCommand = new Command('list') + .description('List all cloud-connect links') + .action(async () => { + try { + const { storage } = openVault(); + const links = storage.listCloudConnectLinks(); + if (links.length === 0) { + info('No cloud-connect links yet.'); + return process.exit(0); + } + for (const l of links) { + console.log(`${bold(l.name)} ${dim(l.agent_id)} [${l.status}] ${dim(l.created_at)}`); + } + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const urlCommand = new Command('url') + .description('Print the universal connect URL to paste into any cloud agent (no link needed)') + .option('-r, --relay ', 'Relay base URL (default: DCP_RELAY_URL / saved config)') + .action(async (options) => { + try { + const { budget } = openVault(); + const identity = await loadIdentity(budget); + const relayUrl = options.relay || identity.relayUrl; + if (!relayUrl) { + error('No relay configured. Pass --relay or set DCP_RELAY_URL.'); + process.exit(1); + } + const url = mcpUrlFor(relayUrl, identity.vaultId); + console.log(); + success('Universal connect URL — paste into any OAuth MCP client:'); + console.log(); + console.log(bold(url)); + console.log(); + console.log(dim('Examples:')); + console.log(dim(` hermes mcp add dcp --url ${url} --auth oauth`)); + console.log(dim(' Claude.ai / ChatGPT: add as a custom connector')); + console.log(); + info('First open the pairing window: `dcp cloud-connect open` — then approve on this device.'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const openCommand = new Command('open') + .description('Open the pairing window so cloud agents can request to connect (paste-URL flow)') + .option('-m, --minutes ', 'Minutes to stay open (1–60, default 10)', '10') + .action(async (options) => { + try { + const { budget } = openVault(); + const raw = Number(options.minutes); + const minutes = Number.isFinite(raw) ? Math.min(60, Math.max(1, raw)) : 10; + budget.setConfig('cloud_connect_accept_until', Date.now() + minutes * 60 * 1000); + success(`Pairing window open for ${minutes} min. New cloud agents can now request to connect.`); + info('Run `dcp cloud-connect pending` when an agent connects, then approve with the match code.'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const closeCommand = new Command('close') + .description('Close the pairing window (no new cloud agents can request to connect)') + .action(async () => { + try { + const { budget } = openVault(); + budget.setConfig('cloud_connect_accept_until', 0); + success('Pairing window closed.'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +const revokeCommand = new Command('revoke') + .description('Instantly revoke a cloud agent') + .argument('', 'Agent id to revoke') + .action(async (agentId: string) => { + try { + const { storage } = openVault(); + const link = storage.getCloudConnectLinkByAgent(agentId); + if (link) { + storage.revokeCloudConnectLink(link.link_id); + } else if (!storage.revokeAgentConnection(agentId)) { + error('Agent not found.'); + process.exit(1); + } + success('Revoked. (If the vault server is running it also tells the relay to drop the agent.)'); + process.exit(0); + } catch (err) { + handleError(err); + } + }); + +export const cloudConnectCommand = new Command('cloud-connect') + .description('Connect cloud agents (OpenClaw/Hermes/Claude.ai) via a one-time link') + .addCommand(urlCommand) + .addCommand(openCommand) + .addCommand(closeCommand) + .addCommand(linkCommand) + .addCommand(pendingCommand) + .addCommand(approveCommand) + .addCommand(denyCommand) + .addCommand(listCommand) + .addCommand(revokeCommand); diff --git a/packages/dcp-vault/src/cli/index.ts b/packages/dcp-vault/src/cli/index.ts index 9c3a4e7..c3ecc10 100644 --- a/packages/dcp-vault/src/cli/index.ts +++ b/packages/dcp-vault/src/cli/index.ts @@ -29,6 +29,7 @@ import { trustCommand } from './commands/trust.js'; import { connectCommand } from './commands/connect.js'; import { proxyCommand } from './commands/proxy.js'; import { pairingCommand } from './commands/pairing.js'; +import { cloudConnectCommand } from './commands/cloud-connect.js'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; @@ -73,6 +74,7 @@ program.addCommand(trustCommand); program.addCommand(connectCommand); program.addCommand(proxyCommand); program.addCommand(pairingCommand); +program.addCommand(cloudConnectCommand); // Parse arguments program.parse(); diff --git a/packages/dcp-vault/src/index.ts b/packages/dcp-vault/src/index.ts index 4b78206..b72d5d9 100644 --- a/packages/dcp-vault/src/index.ts +++ b/packages/dcp-vault/src/index.ts @@ -6,7 +6,7 @@ */ // Server exports -export { buildServer } from './server/index.js'; +export { buildServer, handleCloudConnectMcp } from './server/index.js'; // CLI is available as a binary (dcp, dcp-vault) // Import it directly if needed: diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index cc278f6..45b46b9 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -60,6 +60,12 @@ import { parseVpsPairingInvite, verifyPairingGrantWithKey, decodePairingGrant, + createConnectLink, + verifyConnectLinkWithKey, + decodeConnectLink, + isConnectLink, + generateMatchCode, + hpkeFingerprint, canonicalJson, DEFAULT_RELAY_URL as CORE_DEFAULT_RELAY_URL, } from '@dcprotocol/core'; @@ -1072,27 +1078,29 @@ async function buildServer(): Promise { logger: loggerConfig, }); - // CORS for desktop UI access (restrict to trusted origins) + // CORS for the desktop UI — restrict to the KNOWN desktop origins only. + // We deliberately do NOT allow arbitrary localhost origins: any local web page + // (a dev server, a malicious site's localhost listener) would otherwise be able + // to make cross-origin requests to the vault. Non-browser clients (CLI, agent) + // send no Origin and are unaffected. Extra origins can be added via + // DCP_EXTRA_CORS_ORIGINS (comma-separated) for unusual setups. const allowedOrigins = new Set([ 'tauri://localhost', 'https://tauri.localhost', + 'http://tauri.localhost', // Tauri on Windows/Linux 'http://localhost:1420', 'http://127.0.0.1:1420', + ...(process.env.DCP_EXTRA_CORS_ORIGINS || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean), ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await server.register(cors as any, { origin: (origin: string | undefined, cb: (err: Error | null, allow: boolean) => void) => { - if (!origin) return cb(null, true); // non-browser clients + if (!origin) return cb(null, true); // non-browser clients (CLI, dcp-agent) if (allowedOrigins.has(origin)) return cb(null, true); - try { - const url = new URL(origin); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { - return cb(null, true); - } - } catch { - // fall through - } return cb(new Error('Not allowed by CORS'), false); }, methods: ['GET', 'POST', 'DELETE', 'PATCH'], @@ -1862,9 +1870,10 @@ async function buildServer(): Promise { Body: { desktop_id: string; public_key: string; // base64 encoded Ed25519 public key + passphrase?: string; // required to REPLACE an existing owner without an owner token }; - }>('/v1/desktop/register', async (request) => { - const { desktop_id, public_key } = request.body; + }>('/v1/desktop/register', async (request, reply) => { + const { desktop_id, public_key, passphrase } = request.body; if (!desktop_id || !public_key) { throw new VaultError('INTERNAL_ERROR', 'desktop_id and public_key are required'); @@ -1880,6 +1889,31 @@ async function buildServer(): Promise { throw new VaultError('INTERNAL_ERROR', 'Invalid public key format'); } + // Owner-replacement guard: registration is open ONLY when no owner exists yet + // (first run). Once an owner is set, REPLACING it requires proof of ownership — + // a valid owner token, or the vault passphrase. An idempotent re-register of the + // SAME device + key (normal relaunch) is always allowed. + const existingOwner = budget.getConfig().desktop_owner; + if (existingOwner) { + const sameDevice = + existingOwner.desktop_id === desktop_id && existingOwner.public_key === public_key; + let allowed = sameDevice || isOwnerRequest(request); + if (!allowed && passphrase) { + // Verify the passphrase WITHOUT changing the vault's lock state or touching + // the cached master key (verifyPassphrase decrypts a throwaway copy). + allowed = await storage.verifyPassphrase(passphrase); + } + if (!allowed) { + return reply.status(403).send({ + error: { + code: 'OWNER_AUTH_REQUIRED', + message: + 'An owner is already registered. Replacing it requires the current owner token or the vault passphrase.', + }, + }); + } + } + // Store in config budget.setConfig('desktop_owner', { desktop_id, @@ -2690,6 +2724,7 @@ async function buildServer(): Promise { server.patch<{ Params: { id: string }; Body: { + name?: string; permission_scopes?: string[]; budget?: { daily?: number; @@ -2730,6 +2765,20 @@ async function buildServer(): Promise { }); } + // Validate name if provided (display-only rename; safe since connections key on agent_id) + let trimmedName: string | undefined; + if (body.name !== undefined) { + if (typeof body.name !== 'string' || body.name.trim().length === 0) { + return reply.status(400).send({ + error: { + code: 'INVALID_REQUEST', + message: 'name must be a non-empty string', + }, + }); + } + trimmedName = body.name.trim().slice(0, 80); + } + // Validate permission_scopes if provided if (body.permission_scopes !== undefined) { if (!Array.isArray(body.permission_scopes)) { @@ -2755,12 +2804,16 @@ async function buildServer(): Promise { // Build updates object const updates: { + name?: string; permission_scopes?: string[]; budget_daily?: number; budget_currency?: string; budget_auto_approve_under?: number; } = {}; + if (trimmedName !== undefined) { + updates.name = trimmedName; + } if (body.permission_scopes !== undefined) { // Normalize scopes to ensure they have proper read:/sign: prefix updates.permission_scopes = normalizePermissionScopes( @@ -3099,6 +3152,489 @@ async function buildServer(): Promise { return { denied: true }; }); + // ============================================================================ + // Cloud-Connect (Cloud Agent Connect: paste-a-URL + on-device approval) + // ============================================================================ + // Flow: owner issues a one-time, key-pinned connect-link -> the agent redeems + // it (creating a PENDING agent + match-code) -> owner approves on-device (verifies + // the match-code) -> the agent polls and mints its session token. Permissions and + // budget live ONLY in the vault DB; the link carries no authority. See PRD §6. + + /** + * Derive server-side request facts for the on-device approval (Rule #6). + * Only truly server-derived values count: request.ip comes from the socket. + * The Host header is attacker-controllable, so it is NOT surfaced as a fact + * (a verified host will be provided by the relay in P2). source_host stays + * undefined here by design. + */ + const deriveSourceFacts = ( + request: FastifyRequest + ): { source_ip?: string; source_host?: string } => { + return { source_ip: request.ip || undefined, source_host: undefined }; + }; + + /** Constant-time, length-safe equality for short ASCII codes (Rule #4/#6). */ + const constantTimeStrEqual = (a: string, b: string): boolean => { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + try { + return crypto.timingSafeEqual(ab, bb); + } catch { + return false; + } + }; + + /** + * Issue a one-time, key-pinned connect-link (owner only). + * Auto-approve defaults OFF / $0 (Rule #5). Permissions stored only in vault DB. + */ + server.post<{ + Body: { + name?: string; + scopes?: string[]; + budget?: { daily?: number; currency?: string; auto_approve_under?: number }; + }; + }>('/v1/cloud-connect/links', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + + const body = request.body || {}; + // Bound owner-supplied strings to avoid storage bloat / abuse. + const name = ((body.name || '').trim() || 'Cloud agent').slice(0, 80); + const scopes = Array.isArray(body.scopes) + ? body.scopes + .filter((s) => typeof s === 'string') + .slice(0, 64) + .map((s) => s.slice(0, 128)) + : []; + // Rule #5: cloud agents start with NO standing auto-approve and $0 budget. + const budget = { + daily: Math.max(0, Number(body.budget?.daily ?? 0)) || 0, + currency: body.budget?.currency || 'USD', + auto_approve_under: Math.max(0, Number(body.budget?.auto_approve_under ?? 0)) || 0, + }; + + const identity = await ensureRelayIdentity(); + const agentId = `agent_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const hpkeB64 = identity.hpkeKeyPair.publicKey.toString('base64'); + const signB64 = identity.signingKeyPair.publicKey.toString('base64'); + const relayUrl = identity.relayUrl || DEFAULT_RELAY_URL || CORE_DEFAULT_RELAY_URL || ''; + + const link = createConnectLink( + { + vault_id: identity.vaultId, + agent_id: agentId, + agent_name: name, + mode: 'mcp', + relay_url: relayUrl, + vault_hpke_public_key: hpkeB64, + vault_signing_public_key: signB64, + }, + identity.signingKeyPair.privateKey + ); + + storage.createCloudConnectLink({ + link_id: link.link_id, + secret: link.secret, + agent_id: agentId, + name, + permission_scopes: scopes, + budget, + expires_at: link.expires_at, + }); + + return { + connect_link: link.token, + link_id: link.link_id, + agent_id: agentId, + name, + scopes, + budget, + vault_id: identity.vaultId, + vault_hpke_fingerprint: link.vault_hpke_fingerprint, + relay_url: relayUrl, + created_at: link.created_at, + expires_at: link.expires_at, + }; + }); + + /** + * Redeem a connect-link (called by the agent; authed by the link itself). + * Creates a PENDING agent (inert until approved — Rule #8) + a match-code. + */ + server.post<{ Body: { connect_link?: string; agent_public_key?: string } }>( + '/v1/cloud-connect/redeem', + async (request, reply) => { + const body = request.body || {}; + const token = (body.connect_link || '').trim(); + const agentPublicKey = (body.agent_public_key || '').trim(); + + if (!token || !isConnectLink(token)) { + return reply.status(400).send({ + error: { code: 'INVALID_CONNECT_LINK', message: 'A valid connect_link is required' }, + }); + } + if (!agentPublicKey || !isValidPublicKey(agentPublicKey)) { + return reply.status(400).send({ + error: { + code: 'INVALID_AGENT_KEY', + message: 'A valid agent_public_key (base64 Ed25519) is required', + }, + }); + } + + const identity = await ensureRelayIdentity(); + + // Verify against the vault's OWN signing key (never trust the embedded key). + 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' }, + }); + } + + const identity = await ensureRelayIdentity(); + if (decoded.payload.vault_id !== identity.vaultId) { + return reply.status(400).send({ + error: { code: 'WRONG_VAULT', message: 'Connect link is for a different vault' }, + }); + } + + 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 { + vault_id: identity.vaultId, + mcp_url: httpsBase ? `${httpsBase}/v/${identity.vaultId}/mcp` : '', + relay_base: httpsBase, + }; + }); + + /** + * Open (or close) the link-less pairing window (owner only). The paste-URL flow + * only opens on-device approvals while this window is open — opt-in, like + * Bluetooth pairing. POST { minutes } opens for N minutes (clamped 1..60); + * minutes<=0 closes immediately. + */ + server.post<{ Body: { minutes?: number } }>( + '/v1/cloud-connect/pairing-mode', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + const raw = Number(request.body?.minutes ?? 10); + const minutes = Number.isFinite(raw) ? Math.min(60, Math.max(0, raw)) : 10; + const acceptUntil = minutes > 0 ? Date.now() + minutes * 60 * 1000 : 0; + budget.setConfig('cloud_connect_accept_until', acceptUntil); + // Reset the rate-limit counter when (re)opening so a fresh window is clean. + if (minutes > 0) cloudConnectPairTimes = []; + return { + open: minutes > 0, + accept_until: acceptUntil, + seconds_remaining: minutes > 0 ? minutes * 60 : 0, + }; + } + ); + + /** Report whether the link-less pairing window is currently open (owner only). */ + server.get('/v1/cloud-connect/pairing-mode', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + const acceptUntil = cloudConnectPairingOpenUntil(); + const open = acceptUntil > Date.now(); + return { + open, + accept_until: acceptUntil, + seconds_remaining: open ? Math.round((acceptUntil - Date.now()) / 1000) : 0, + }; + }); + + server.get('/v1/cloud-connect/pending', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + storage.expireStaleCloudConnectLinks(); + const pending = storage.listPendingCloudConnectApprovals(); + return { + pending: pending.map((l) => ({ + agent_id: l.agent_id, + link_id: l.link_id, + name: l.name, + scopes: l.permission_scopes, + budget: l.budget, + match_code: l.match_code, + source_ip: l.source_ip, + source_host: l.source_host, + agent_fingerprint: l.redeemer_public_key + ? hpkeFingerprint(l.redeemer_public_key) + : undefined, + redeemed_at: l.redeemed_at, + created_at: l.created_at, + })), + }; + }); + + /** List all connect-links for management (owner only). */ + server.get('/v1/cloud-connect/links', async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + return { links: storage.listCloudConnectLinks() }; + }); + + /** Approve a pending cloud-connect (owner only; match-code mandatory). */ + server.post<{ Params: { agentId: string }; Body: { match_code?: string } }>( + '/v1/cloud-connect/pending/:agentId/approve', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + // Expire over-age redeemed links first so a stale link can't be approved. + storage.expireStaleCloudConnectLinks(); + const link = storage.getCloudConnectLinkByAgent(request.params.agentId); + if (!link || link.status !== 'redeemed') { + return reply.status(404).send({ + error: { + code: 'PENDING_NOT_FOUND', + message: 'No pending cloud-connect approval for this agent', + }, + }); + } + // Rule #6: the initiation<->approval match-code is MANDATORY. Every redeemed + // link carries a server-generated code, so the owner must echo the code they + // visually verified against the agent side. Constant-time compare. + const presentedCode = (request.body?.match_code || '').trim().toUpperCase(); + const expectedCode = (link.match_code || '').toUpperCase(); + if (!presentedCode || !expectedCode || !constantTimeStrEqual(presentedCode, expectedCode)) { + return reply.status(400).send({ + error: { + code: 'MATCH_CODE_MISMATCH', + message: 'A matching match code is required to approve this connection', + }, + }); + } + const result = storage.approveCloudConnectLink(link.link_id); + if (!result.ok) { + return reply.status(400).send({ + error: { code: result.reason || 'APPROVE_FAILED', message: 'Approval failed' }, + }); + } + return { approved: true, agent_id: result.agent_id, name: link.name }; + } + ); + + /** Deny a pending cloud-connect (owner only). */ + server.post<{ Params: { agentId: string } }>( + '/v1/cloud-connect/pending/:agentId/deny', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + const link = storage.getCloudConnectLinkByAgent(request.params.agentId); + if (!link) { + return reply.status(404).send({ + error: { code: 'PENDING_NOT_FOUND', message: 'No cloud-connect link for this agent' }, + }); + } + const result = storage.denyCloudConnectLink(link.link_id); + if (!result.ok) { + return reply.status(400).send({ + error: { code: result.reason || 'DENY_FAILED', message: 'Deny failed' }, + }); + } + return { denied: true, agent_id: result.agent_id }; + } + ); + + /** Instantly revoke a bound cloud agent + its link (owner only — Rule #7). */ + server.post<{ Params: { agentId: string } }>( + '/v1/cloud-connect/agents/:agentId/revoke', + async (request, reply) => { + if (!isOwnerRequest(request)) { + return reply.status(403).send({ + error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, + }); + } + const link = storage.getCloudConnectLinkByAgent(request.params.agentId); + if (!link) { + // No link record — still revoke the agent connection if it exists. + const ok = storage.revokeAgentConnection(request.params.agentId); + if (!ok) { + return reply.status(404).send({ + error: { code: 'AGENT_NOT_FOUND', message: 'Agent not found' }, + }); + } + // Fast-fail at the relay too (denylist + kill refresh chains) — Rule #7. + relayClient?.sendCloudConnectRevoke(request.params.agentId); + return { revoked: true, agent_id: request.params.agentId }; + } + const result = storage.revokeCloudConnectLink(link.link_id); + relayClient?.sendCloudConnectRevoke(result.agent_id || request.params.agentId); + return { revoked: true, agent_id: result.agent_id }; + } + ); + // ============================================================================ // Trusted Services (Owner Only) // ============================================================================ @@ -3540,11 +4076,15 @@ async function buildServer(): Promise { // Decrypt if it's not a CRITICAL item if (record.sensitivity === 'critical') { // Don't return critical data - return reference only - storage.logAudit('READ', 'success', { - agentName: agent_name, - scope, - operation: 'read_reference', - }); + // Owner-mode reads (the desktop app reading your own data, e.g. for the + // greeting) are not "activity" — don't pollute the audit log with them. + if (!ownerMode) { + storage.logAudit('READ', 'success', { + agentName: agent_name, + scope, + operation: 'read_reference', + }); + } return { scope, @@ -3564,11 +4104,14 @@ async function buildServer(): Promise { const decrypted = envelopeDecrypt(payload, masterKey); const data = JSON.parse(decrypted.toString('utf-8')); - storage.logAudit('READ', 'success', { - agentName: agent_name, - scope, - operation: 'read_data', - }); + // Skip owner-mode reads (the app reading your own data) — not real activity. + if (!ownerMode) { + storage.logAudit('READ', 'success', { + agentName: agent_name, + scope, + operation: 'read_data', + }); + } return { scope, @@ -4057,6 +4600,13 @@ async function buildServer(): Promise { throw new VaultError('VAULT_LOCKED', 'Vault is locked. Please unlock first.'); } + // Enforce agent identity + scope for named agents (mandatory signed request). + // No service_id (desktop / cloud-connect-via-consent) falls through to consent. + const agentAuth = verifyLocalAgentRequest(body as Record, `sign:${chain}`); + if (!agentAuth.authorized && agentAuth.skipConsentFlow) { + throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + } + const walletScope = `crypto.wallet.${chain}`; if (!effectiveSessionId) { @@ -4211,6 +4761,14 @@ async function buildServer(): Promise { } const chain: Chain = 'solana'; + + // Enforce agent identity + scope for named agents (mandatory signed request). + // No service_id (desktop / cloud-connect-via-consent) falls through to consent. + const agentAuth = verifyLocalAgentRequest(request.body as Record, `sign:${network}`); + if (!agentAuth.authorized && agentAuth.skipConsentFlow) { + throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + } + const walletScope = `crypto.wallet.${chain}`; if (!effectiveSessionId) { @@ -5077,6 +5635,23 @@ function verifyRelayServiceIdentity( * @param requestedScope - The scope being requested (e.g., "read:identity.email") * @returns Authorization result with agent info or denial reason */ +/** + * Replay guard for signed local-agent requests. Each (service_id + nonce) is + * single-use within the freshness window; entries older than the window are + * evicted lazily. Prevents replay of a captured valid signed request. + */ +const LOCAL_AGENT_NONCE_TTL_MS = 5 * 60 * 1000; +const localAgentNonces = new Map(); +function markLocalAgentNonce(key: string): boolean { + const now = Date.now(); + for (const [k, t] of localAgentNonces) { + if (now - t > LOCAL_AGENT_NONCE_TTL_MS) localAgentNonces.delete(k); + } + if (localAgentNonces.has(key)) return false; // replay + localAgentNonces.set(key, now); + return true; +} + function verifyLocalAgentRequest( body: Record, requestedScope: string @@ -5128,39 +5703,56 @@ function verifyLocalAgentRequest( return { authorized: false, reason: `Agent '${serviceId}' has no registered public key`, skipConsentFlow: true }; } - // Verify signature if provided - if (serviceSignature) { - // Build the payload that was signed (everything except the signature) - const { service_signature: _, ...payloadWithoutSig } = body; + // A signed request is MANDATORY for agent_/vps_ identities. The legitimate client + // (dcp-client) always signs (service_id + service_signature + timestamp + nonce); + // a named-agent request WITHOUT a valid signature is an impersonation attempt and is + // rejected outright — there is no unsigned fallback for an agent identity. + const nonce = body.nonce as string | undefined; + if (!serviceSignature || !timestamp || !nonce) { + storage.logAudit('DENY', 'denied', { + operation: 'local_agent_request', + details: JSON.stringify({ service_id: serviceId, reason: 'missing_signature' }), + }); + return { + authorized: false, + reason: 'A signed request (service_signature, timestamp, nonce) is required for agent identities', + skipConsentFlow: true, + }; + } - const signatureValid = verifyServiceSignature( - payloadWithoutSig, - serviceId, - serviceSignature, - agent.service_public_key - ); + // Verify the Ed25519 signature over the canonical body (everything except the signature). + const { service_signature: _, ...payloadWithoutSig } = body; + const signatureValid = verifyServiceSignature( + payloadWithoutSig, + serviceId, + serviceSignature, + agent.service_public_key + ); + if (!signatureValid) { + storage.logAudit('DENY', 'denied', { + operation: 'local_agent_request', + details: JSON.stringify({ service_id: serviceId, reason: 'invalid_signature' }), + }); + return { authorized: false, reason: 'Invalid agent signature', skipConsentFlow: true }; + } - if (!signatureValid) { - storage.logAudit('DENY', 'denied', { - operation: 'local_agent_request', - details: JSON.stringify({ service_id: serviceId, reason: 'invalid_signature' }), - }); - return { authorized: false, reason: 'Invalid agent signature', skipConsentFlow: true }; - } - - // Check timestamp for replay protection (5 minute window) - if (timestamp) { - const requestTime = new Date(timestamp).getTime(); - const now = Date.now(); - const fiveMinutes = 5 * 60 * 1000; - if (Math.abs(now - requestTime) > fiveMinutes) { - storage.logAudit('DENY', 'denied', { - operation: 'local_agent_request', - details: JSON.stringify({ service_id: serviceId, reason: 'timestamp_expired' }), - }); - return { authorized: false, reason: 'Request timestamp expired', skipConsentFlow: true }; - } - } + // Timestamp freshness (5-minute window) — mandatory. + const requestTime = new Date(timestamp).getTime(); + if (isNaN(requestTime) || Math.abs(Date.now() - requestTime) > 5 * 60 * 1000) { + storage.logAudit('DENY', 'denied', { + operation: 'local_agent_request', + details: JSON.stringify({ service_id: serviceId, reason: 'timestamp_expired' }), + }); + return { authorized: false, reason: 'Request timestamp expired or invalid', skipConsentFlow: true }; + } + + // Replay protection: each (service_id, nonce) is single-use within the window. + if (!markLocalAgentNonce(`${serviceId}:${nonce}`)) { + storage.logAudit('DENY', 'denied', { + operation: 'local_agent_request', + details: JSON.stringify({ service_id: serviceId, reason: 'nonce_replay' }), + }); + return { authorized: false, reason: 'Duplicate request (nonce already used)', skipConsentFlow: true }; } // Check scope permissions. Empty permissions are request-only: authenticated, @@ -5284,16 +5876,33 @@ async function registerVpsInviteWithRelay( inviteId: string, vaultId: string ): Promise { - const response = await fetch(`${relayHttpUrl(relayUrl)}/v1/invites/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ invite_id: inviteId, vault_id: vaultId }), - }); + // The relay only accepts invite registration from a vault with a LIVE, authenticated + // WS connection (security #6/#7). The vault's WS may still be (re)connecting right + // after unlock, so wait for it to be connected, then retry a few times to ride out + // the registration ack race. This keeps the relay strict while avoiding a flaky fail. + const deadline = Date.now() + 8000; + while (relayClient && !relayClient.isConnected() && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 250)); + } - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`Relay invite registration failed: ${response.status}${body ? ` ${body}` : ''}`); + let lastErr = ''; + for (let attempt = 0; attempt < 5; attempt++) { + try { + const response = await fetch(`${relayHttpUrl(relayUrl)}/v1/invites/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite_id: inviteId, vault_id: vaultId }), + }); + if (response.ok) return; + lastErr = `${response.status} ${(await response.text().catch(() => '')) || ''}`.trim(); + // 401 = relay hasn't registered our WS yet; wait and retry. + if (response.status !== 401) break; + } catch (err) { + lastErr = err instanceof Error ? err.message : 'network error'; + } + await new Promise((r) => setTimeout(r, 400 * (attempt + 1))); } + throw new Error(`Relay invite registration failed: ${lastErr || 'unknown'}`); } async function ensureRelayIdentity(): Promise { @@ -5650,6 +6259,370 @@ async function handleRelayRequest( return { response: response.payload || '', replyPublicKey }; } +/** + * Redeem a Cloud-Connect link on behalf of a cloud agent that connected via the + * relay's OAuth flow. Mirrors the HTTP /v1/cloud-connect/redeem, but the redeemer + * identity is the agent's DPoP key thumbprint (jkt) rather than an Ed25519 key — + * the relay enforces DPoP/audience binding; the vault records who redeemed and + * runs the on-device approval. The vault fully verifies the connect-link. + */ +async function redeemCloudConnectOverRelay(ctrl: { + connect_link?: string; + redeemer_key?: string; + source_ip?: string; +}): Promise> { + const token = (ctrl.connect_link || '').trim(); + if (!token || !isConnectLink(token)) return { ok: false, reason: 'INVALID_CONNECT_LINK' }; + const jkt = (ctrl.redeemer_key || '').trim(); + if (!jkt) return { ok: false, reason: 'INVALID_AGENT_KEY' }; + + const identity = await ensureRelayIdentity(); + const payload = verifyConnectLinkWithKey(token, identity.signingKeyPair.publicKey); + if (!payload) return { ok: false, reason: 'INVALID_CONNECT_LINK' }; + if (payload.vault_id !== identity.vaultId) return { ok: false, reason: 'WRONG_VAULT' }; + + const actualHpke = identity.hpkeKeyPair.publicKey.toString('base64'); + if ( + payload.vault_hpke_public_key !== actualHpke || + payload.vault_hpke_fingerprint !== hpkeFingerprint(actualHpke) + ) { + return { ok: false, reason: 'KEY_PIN_MISMATCH' }; + } + + const matchCode = generateMatchCode(); + const result = storage.redeemCloudConnectLink({ + link_id: payload.link_id, + secret: payload.secret, + redeemer_public_key: jkt, + match_code: matchCode, + mode: 'mcp', + source_ip: ctrl.source_ip, + }); + if (!result.ok) return { ok: false, reason: result.reason || 'REDEEM_FAILED' }; + return { ok: true, agent_id: result.agent_id, match_code: result.match_code }; +} + +// --- Link-less ("paste-URL") pairing --------------------------------------- +// A standard OAuth MCP client connected with JUST the vault's MCP URL (no +// connect-link). The relay forwards the request here; we open the SAME on-device +// approval a redeem would, gated by a short owner-opened "pairing window" + a +// rate-limit (so a stranger who learns the vault id can at worst cause a bounded +// number of approval prompts the owner must still tap — never a key release). + +const CLOUD_CONNECT_PAIR_MAX_PER_WINDOW = 5; +const CLOUD_CONNECT_PAIR_WINDOW_MS = 10 * 60 * 1000; +let cloudConnectPairTimes: number[] = []; + +function cloudConnectPairingOpenUntil(): number { + // Read the window FRESH from the config file (the source of truth — setConfig + // always persists), so it can be opened by the desktop, the CLI, or any owner + // tool in another process while this server runs. Fall back to in-memory config. + try { + const dir = + process.env.DCP_VAULT_DIR || process.env.VAULT_DIR || path.join(os.homedir(), '.dcp'); + const file = path.join(dir, 'config.json'); + if (fs.existsSync(file)) { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + const v = Number(data?.cloud_connect_accept_until || 0); + if (Number.isFinite(v) && v > 0) return v; + } + } catch { + /* fall through to in-memory */ + } + try { + return Number(budget.getConfig().cloud_connect_accept_until || 0) || 0; + } catch { + return 0; + } +} + +async function pairCloudConnectOverRelay(ctrl: { + redeemer_key?: string; + source_ip?: string; + client_name?: string; + scope?: string; +}): Promise> { + // Gate 1 — the owner must have opened the pairing window (opt-in, like BT pairing). + const openUntil = cloudConnectPairingOpenUntil(); + if (!openUntil || Date.now() > openUntil) { + return { ok: false, reason: 'PAIRING_NOT_OPEN' }; + } + // Gate 2 — rate-limit link-less requests within the window. + const now = Date.now(); + cloudConnectPairTimes = cloudConnectPairTimes.filter((t) => now - t < CLOUD_CONNECT_PAIR_WINDOW_MS); + if (cloudConnectPairTimes.length >= CLOUD_CONNECT_PAIR_MAX_PER_WINDOW) { + return { ok: false, reason: 'RATE_LIMITED' }; + } + cloudConnectPairTimes.push(now); + + // The relay must be reachable for routing; ensure our identity exists. + await ensureRelayIdentity(); + + const jkt = (ctrl.redeemer_key || '').trim() || 'browser-tofu'; + const agentId = `agent_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const linkId = `urlpair_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`; + const matchCode = generateMatchCode(); + const name = ((ctrl.client_name || '').trim() || 'Cloud agent').slice(0, 80); + // Minimal default scope (read-only address) + $0 auto-approve (Rule #5). The + // owner can broaden scope/budget after approval; sensitive actions always need + // an explicit on-device consent regardless of scope. + const scopes = ['read:wallet.address']; + const budgetDef = { daily: 0, currency: 'USD', auto_approve_under: 0 }; + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); + + const result = storage.createCloudConnectPairingRequest({ + link_id: linkId, + agent_id: agentId, + name, + permission_scopes: scopes, + budget: budgetDef, + redeemer_public_key: jkt, + match_code: matchCode, + source_ip: ctrl.source_ip, + source_host: 'url', + expires_at: expiresAt, + }); + if (!result.ok) return { ok: false, reason: result.reason || 'PAIR_FAILED' }; + return { ok: true, agent_id: agentId, match_code: matchCode }; +} + +// ============================================================================ +// Cloud-Connect MCP handler (the data plane for relay-fronted cloud agents) +// ============================================================================ +// A thin MCP JSON-RPC shell that DELEGATES tool calls to the vault's own REST +// endpoints (same path as handleRelayRequest -> server.inject), so scope, budget, +// and ON-DEVICE CONSENT are enforced exactly as for every other agent. The cloud +// agent was authenticated at the relay (DPoP + audience); here we bind the call to +// THAT agent's identity so sensitive actions still require an on-device tap. + +const MCP_PROTOCOL_VERSION = '2024-11-05'; + +/** Tool schemas advertised to cloud agents. Mirrors the @dcprotocol/agent set. */ +const CLOUD_CONNECT_MCP_TOOLS = [ + { + name: 'vault_get_address', + description: 'Get the wallet public address for a chain (read-only).', + inputSchema: { + type: 'object', + properties: { chain: { type: 'string', enum: ['solana'], default: 'solana' } }, + }, + }, + { + name: 'vault_budget_check', + description: 'Check whether an amount is within the agent budget (read-only).', + inputSchema: { + type: 'object', + properties: { + amount: { type: 'number' }, + currency: { type: 'string' }, + chain: { type: 'string' }, + }, + required: ['amount', 'currency'], + }, + }, + { + name: 'vault_read', + description: 'Read a credential/data scope (gated by scope + on-device consent).', + inputSchema: { + type: 'object', + properties: { scope: { type: 'string' }, fields: { type: 'array', items: { type: 'string' } } }, + required: ['scope'], + }, + }, + { + name: 'vault_sign_tx', + description: 'Sign a transaction (requires on-device approval; never auto-signs).', + inputSchema: { + type: 'object', + properties: { + chain: { type: 'string', enum: ['solana'] }, + unsigned_tx: { type: 'string' }, + description: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + destination: { type: 'string' }, + idempotency_key: { type: 'string' }, + }, + required: ['chain', 'unsigned_tx'], + }, + }, + { + name: 'vault_sign_message', + description: 'Sign an arbitrary message (requires on-device approval).', + inputSchema: { + type: 'object', + properties: { chain: { type: 'string' }, message: { type: 'string' } }, + required: ['chain', 'message'], + }, + }, + { + name: 'vault_sign_x402', + description: 'Sign an x402 payment payload (requires on-device approval).', + inputSchema: { + type: 'object', + properties: { + network: { type: 'string' }, + payload: { type: 'string' }, + amount: { type: 'number' }, + currency: { type: 'string' }, + }, + required: ['network', 'payload'], + }, + }, + { + name: 'vault_write', + description: 'Write a credential/data scope (gated by scope + on-device consent).', + inputSchema: { + type: 'object', + properties: { scope: { type: 'string' }, value: {} }, + required: ['scope'], + }, + }, +]; + +/** Map an MCP tool call to the vault's REST endpoint (same surface as handleRelayRequest). */ +function mcpToolToRest( + name: string, + args: Record +): { method: 'GET' | 'POST'; url: string; body?: Record } | null { + switch (name) { + case 'vault_get_address': + return { method: 'GET', url: `/address/${(args.chain as string) || 'solana'}` }; + case 'vault_budget_check': + return { + method: 'GET', + url: `/budget/check?amount=${args.amount ?? ''}¤cy=${args.currency ?? ''}&chain=${args.chain ?? ''}`, + }; + case 'vault_read': + return { method: 'POST', url: '/v1/vault/read', body: { ...args } }; + case 'vault_write': + return { method: 'POST', url: '/v1/vault/write', body: { ...args } }; + case 'vault_sign_tx': + return { method: 'POST', url: '/v1/vault/sign', body: { ...args } }; + case 'vault_sign_message': + return { method: 'POST', url: '/v1/vault/sign_message', body: { ...args } }; + case 'vault_sign_x402': + return { method: 'POST', url: '/v1/vault/sign_x402', body: { ...args } }; + default: + return null; + } +} + +interface JsonRpcRequest { + jsonrpc?: string; + id?: unknown; + method?: string; + params?: Record; +} + +/** + * Handle an MCP JSON-RPC request from a relay-fronted cloud agent. + * Returns { status, body } where body is the JSON-RPC response (or null for + * notifications). Tool calls delegate to the vault's REST endpoints bound to the + * authenticated agent's identity (scope + budget + on-device consent enforced). + */ +export async function handleCloudConnectMcp( + server: FastifyInstance, + agentId: string, + rawBody: unknown +): Promise<{ status: number; body: unknown }> { + const req = (rawBody || {}) as JsonRpcRequest; + const id = req.id ?? null; + const rpcError = (code: number, message: string, status = 200) => ({ + status, + body: { jsonrpc: '2.0', id, error: { code, message } }, + }); + const rpcResult = (result: unknown) => ({ status: 200, body: { jsonrpc: '2.0', id, result } }); + + // The cloud agent must be bound + active (defense-in-depth alongside the relay). + const agent = storage.getAgentConnection(agentId); + if (!agent || agent.status !== 'active') { + return rpcError(-32001, 'Agent is not authorized', 200); + } + + const method = req.method; + if (!method) return rpcError(-32600, 'Invalid Request'); + + if (method === 'initialize') { + return rpcResult({ + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'dcp-vault', version: PACKAGE_VERSION }, + }); + } + if (method === 'notifications/initialized') { + return { status: 202, body: null }; // notification: no response + } + if (method === 'ping') { + return rpcResult({}); + } + if (method === 'tools/list') { + return rpcResult({ tools: CLOUD_CONNECT_MCP_TOOLS }); + } + if (method === 'tools/call') { + const params = (req.params || {}) as { name?: string; arguments?: Record }; + const name = params.name || ''; + const args = params.arguments || {}; + + if (name === 'vault_scope_guide') { + return rpcResult({ + content: [ + { + type: 'text', + text: `Your granted scopes: ${agent.permission_scopes.join(', ') || '(none)'}\nSensitive actions require on-device approval.`, + }, + ], + }); + } + + const route = mcpToolToRest(name, args); + if (!route) return rpcError(-32601, `Unknown tool: ${name}`); + + // Bind the call to THIS authenticated cloud agent (scope/budget/consent). + const body = { ...(route.body || {}), agent_name: agent.name }; + const injected = await server.inject({ + method: route.method, + url: route.url, + payload: route.method === 'POST' ? JSON.stringify(body) : undefined, + headers: { 'content-type': 'application/json' }, + }); + + let payload: unknown; + try { + payload = injected.payload ? JSON.parse(injected.payload) : {}; + } catch { + payload = { raw: injected.payload }; + } + // Surface vault errors (incl. "consent required") as a tool result the agent + // can act on, rather than a transport error. The vault never signs without + // an on-device tap — a sensitive call returns a pending-consent result here. + const isError = injected.statusCode >= 400; + return rpcResult({ + content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], + isError, + }); + } + + return rpcError(-32601, `Unsupported method: ${method}`); +} + +/** Report a cloud agent's approval status to the relay (for the device-flow poll). */ +function cloudConnectStatusOverRelay(agentId?: string): Record { + if (!agentId) return { status: 'unknown' }; + const agent = storage.getAgentConnection(agentId); + if (!agent) return { status: 'unknown' }; + if (agent.status === 'revoked') return { status: 'revoked' }; + if (agent.status === 'active') { + return { + status: 'approved', + scope: agent.permission_scopes.join(' '), + budget: agent.budget, + }; + } + // 'pending' (redeemed, awaiting on-device approval) or 'stale'. + return { status: 'pending' }; +} + async function startRelayClient(server: FastifyInstance): Promise { const identity = await ensureRelayIdentity(); @@ -5695,6 +6668,49 @@ async function startRelayClient(server: FastifyInstance): Promise { pendingPairingClaims.set(claim.claim_id, claim); }); + // Handle Cloud-Connect control messages from the relay's OAuth flow + // (relay asks the vault to redeem a connect-link / report approval status). + relayClient.on('cloudConnectControl', async (raw: unknown) => { + const ctrl = raw as { + control_id?: string; + kind?: 'redeem' | 'pair' | 'status' | 'mcp'; + connect_link?: string; + redeemer_key?: string; + source_ip?: string; + client_name?: string; + scope?: string; + agent_id?: string; + body?: unknown; + }; + if (!ctrl?.control_id || !relayClient) return; + try { + if (ctrl.kind === 'redeem') { + const result = await redeemCloudConnectOverRelay(ctrl); + relayClient.sendCloudConnectResult(ctrl.control_id, result); + } else if (ctrl.kind === 'pair') { + const result = await pairCloudConnectOverRelay(ctrl); + relayClient.sendCloudConnectResult(ctrl.control_id, result); + } else if (ctrl.kind === 'status') { + relayClient.sendCloudConnectResult( + ctrl.control_id, + cloudConnectStatusOverRelay(ctrl.agent_id) + ); + } else if (ctrl.kind === 'mcp') { + const { status, body } = await handleCloudConnectMcp(server, ctrl.agent_id || '', ctrl.body); + relayClient.sendCloudConnectResult(ctrl.control_id, { status, body }); + } + } catch (err) { + console.error('[Vault] Cloud-Connect control error:', err); + const fallback: Record = + ctrl.kind === 'redeem' || ctrl.kind === 'pair' + ? { ok: false, reason: 'internal_error' } + : ctrl.kind === 'mcp' + ? { status: 500, body: { jsonrpc: '2.0', id: null, error: { code: -32603, message: 'internal_error' } } } + : { status: 'unknown' }; + relayClient.sendCloudConnectResult(ctrl.control_id, fallback); + } + }); + let pendingEnvelope: { request_id: string; action_type: string; vault_id: string; version: string } | null = null; relayClient.on('request', (rawEnvelope: unknown) => { const envelope = rawEnvelope as { request_id: string; action_type: string; vault_id: string; version: string }; diff --git a/packages/dcp-vault/tests/cloud-connect-mcp.test.ts b/packages/dcp-vault/tests/cloud-connect-mcp.test.ts new file mode 100644 index 0000000..e60d12d --- /dev/null +++ b/packages/dcp-vault/tests/cloud-connect-mcp.test.ts @@ -0,0 +1,181 @@ +/** + * Vault-side Cloud-Connect MCP handler (P2e-2 final piece). + * + * Provisions a vault, binds an active cloud agent via the real connect flow, then + * drives handleCloudConnectMcp directly. The critical assertion: a sensitive call + * (vault_sign_tx) NEVER returns a signature — it yields a pending consent / scope + * gate, proving the "doorbell, not a key" property (#8) end-to-end. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildServer, handleCloudConnectMcp } from '../src/index.js'; +import { + VaultStorage, + resetStorage, + generateRecoveryMnemonic, + deriveKeyFromMnemonic, + zeroize, + createWallet, + generateSigningKeyPair, + signMessage, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +async function mintOwnerToken(server: FastifyInstance): Promise { + const keys = generateSigningKeyPair(); + const desktopId = 'desktop_' + Math.random().toString(36).slice(2); + await server.inject({ + method: 'POST', + url: '/v1/desktop/register', + payload: { desktop_id: desktopId, public_key: keys.publicKey.toString('base64') }, + }); + const ch = await server.inject({ method: 'GET', url: `/v1/desktop/challenge?desktop_id=${desktopId}` }); + const { nonce } = JSON.parse(ch.body); + const signature = signMessage(Buffer.from(nonce, 'base64'), keys.privateKey).toString('base64'); + const vr = await server.inject({ + method: 'POST', + url: '/v1/desktop/verify', + payload: { desktop_id: desktopId, nonce, signature }, + }); + return JSON.parse(vr.body).token; +} + +describe('Cloud-Connect vault MCP handler', () => { + let server: FastifyInstance; + let testVaultDir: string; + let ownerToken: string; + let agentId: string; + const passphrase = 'test-passphrase-123'; + const owner = () => ({ 'x-dcp-owner-token': ownerToken }); + + beforeAll(async () => { + resetStorage(); + testVaultDir = path.join(os.tmpdir(), `dcp-ccmcp-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.VAULT_DIR = testVaultDir; + + const storage = new VaultStorage(testVaultDir); + storage.initializeSchema(); + const mnemonic = generateRecoveryMnemonic(); + const masterKey = deriveKeyFromMnemonic(mnemonic); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const { encrypted, info } = createWallet('solana', masterKey); + storage.createRecord({ + scope: 'crypto.wallet.solana', + item_type: 'WALLET_KEY', + sensitivity: 'critical', + data: encrypted, + chain: 'solana', + public_address: info.public_address, + }); + } finally { + zeroize(masterKey); + } + storage.close(); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + ownerToken = await mintOwnerToken(server); + + // Bind an active cloud agent via the real connect flow. + const issue = JSON.parse( + ( + await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/links', + headers: owner(), + payload: { + name: 'openclaw-aws', + scopes: ['read:wallet.address', 'sign:solana'], + budget: { daily: 10, currency: 'USD', auto_approve_under: 0 }, + }, + }) + ).body + ); + agentId = issue.agent_id; + const agentKeys = generateSigningKeyPair(); + const redeem = JSON.parse( + ( + await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: issue.connect_link, agent_public_key: agentKeys.publicKey.toString('base64') }, + }) + ).body + ); + await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${agentId}/approve`, + headers: owner(), + payload: { match_code: redeem.match_code }, + }); + }); + + afterAll(async () => { + await server.close(); + resetStorage(); + if (testVaultDir && fs.existsSync(testVaultDir)) fs.rmSync(testVaultDir, { recursive: true, force: true }); + delete process.env.VAULT_DIR; + }); + + it('handles initialize + tools/list', async () => { + const init = await handleCloudConnectMcp(server, agentId, { jsonrpc: '2.0', id: 1, method: 'initialize' }); + expect((init.body as any).result.protocolVersion).toBeTruthy(); + expect((init.body as any).result.serverInfo.name).toBe('dcp-vault'); + + const list = await handleCloudConnectMcp(server, agentId, { jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const names = (list.body as any).result.tools.map((t: { name: string }) => t.name); + expect(names).toContain('vault_get_address'); + expect(names).toContain('vault_sign_tx'); + }); + + it('forwards a read-only tool (vault_get_address) to the vault', async () => { + const res = await handleCloudConnectMcp(server, agentId, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'vault_get_address', arguments: { chain: 'solana' } }, + }); + const text = (res.body as any).result.content[0].text as string; + // Returns the wallet's public address (read-only). + expect(text.length).toBeGreaterThan(0); + expect((res.body as any).result.isError).toBeFalsy(); + }); + + it('NEVER signs without on-device approval — vault_sign_tx yields a gate, not a signature (#8)', async () => { + const res = await handleCloudConnectMcp(server, agentId, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'vault_sign_tx', + arguments: { + chain: 'solana', + unsigned_tx: Buffer.from('dummy-unsigned-tx').toString('base64'), + amount: 1, + currency: 'SOL', + description: 'test transfer', + }, + }, + }); + const text = (res.body as any).result.content[0].text as string; + // The result must NOT contain a real signature — it is a consent/scope gate. + expect(text).not.toMatch(/"signature"\s*:\s*"[1-9A-HJ-NP-Za-km-z]{40,}"/); + expect((res.body as any).result.isError).toBe(true); + }); + + it('rejects calls for a revoked agent', async () => { + await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/agents/${agentId}/revoke`, + headers: owner(), + }); + const res = await handleCloudConnectMcp(server, agentId, { jsonrpc: '2.0', id: 5, method: 'tools/list' }); + expect((res.body as any).error).toBeTruthy(); + expect((res.body as any).error.message).toMatch(/not authorized/i); + }); +}); diff --git a/packages/dcp-vault/tests/cloud-connect.test.ts b/packages/dcp-vault/tests/cloud-connect.test.ts new file mode 100644 index 0000000..6ffd425 --- /dev/null +++ b/packages/dcp-vault/tests/cloud-connect.test.ts @@ -0,0 +1,384 @@ +/** + * Cloud-Connect (Cloud Agent Connect) — vault integration tests. + * + * Exercises the full P1 flow end-to-end via the REST server: + * issue connect-link -> redeem (pending agent + match-code) -> on-device + * approve (match-code verified) -> agent mints session token -> revoke. + * Plus the security invariants: single-use, owner-gating, inert-until-approved, + * match-code enforcement, key-pin/signature rejection. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildServer } from '../src/index.js'; +import { + VaultStorage, + resetStorage, + generateRecoveryMnemonic, + deriveKeyFromMnemonic, + zeroize, + createWallet, + generateSigningKeyPair, + signMessage, + createConnectLink, + decodeConnectLink, + isConnectLink, + CONNECT_LINK_PREFIX, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +async function mintOwnerToken(server: FastifyInstance): Promise { + const keys = generateSigningKeyPair(); + const desktopId = 'desktop_test_' + Math.random().toString(36).slice(2); + + await server.inject({ + method: 'POST', + url: '/v1/desktop/register', + payload: { desktop_id: desktopId, public_key: keys.publicKey.toString('base64') }, + }); + const ch = await server.inject({ + method: 'GET', + url: `/v1/desktop/challenge?desktop_id=${desktopId}`, + }); + const { nonce } = JSON.parse(ch.body); + const signature = signMessage(Buffer.from(nonce, 'base64'), keys.privateKey).toString('base64'); + const vr = await server.inject({ + method: 'POST', + url: '/v1/desktop/verify', + payload: { desktop_id: desktopId, nonce, signature }, + }); + const { token } = JSON.parse(vr.body); + if (!token) throw new Error('Failed to mint owner token: ' + vr.body); + return token; +} + +describe('Cloud-Connect', () => { + let server: FastifyInstance; + let testVaultDir: string; + let storage: VaultStorage; + let ownerToken: string; + const passphrase = 'test-passphrase-123'; + const owner = () => ({ 'x-dcp-owner-token': ownerToken }); + + beforeAll(async () => { + resetStorage(); + testVaultDir = path.join( + os.tmpdir(), + `dcp-cc-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + process.env.VAULT_DIR = testVaultDir; + + storage = new VaultStorage(testVaultDir); + storage.initializeSchema(); + const mnemonic = generateRecoveryMnemonic(); + const masterKey = deriveKeyFromMnemonic(mnemonic); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const { encrypted, info } = createWallet('solana', masterKey); + storage.createRecord({ + scope: 'crypto.wallet.solana', + item_type: 'WALLET_KEY', + sensitivity: 'critical', + data: encrypted, + chain: 'solana', + public_address: info.public_address, + }); + } finally { + zeroize(masterKey); + } + storage.close(); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + ownerToken = await mintOwnerToken(server); + }); + + afterAll(async () => { + await server.close(); + resetStorage(); + if (testVaultDir && fs.existsSync(testVaultDir)) { + fs.rmSync(testVaultDir, { recursive: true, force: true }); + } + delete process.env.VAULT_DIR; + }); + + // -- Helpers --------------------------------------------------------------- + + async function issueLink( + body: Record = {} + ): Promise<{ connect_link: string; agent_id: string; link_id: string; vault_hpke_fingerprint: string }> { + const res = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/links', + headers: owner(), + payload: { name: 'openclaw-aws', scopes: ['read:wallet.address'], ...body }, + }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.body); + } + + // -- Happy path ------------------------------------------------------------ + + it('runs the full connect -> approve -> token -> revoke flow', async () => { + // 1. Owner issues a one-time, key-pinned connect-link. + const issued = await issueLink({ + budget: { daily: 5, currency: 'USD', auto_approve_under: 0 }, + }); + expect(isConnectLink(issued.connect_link)).toBe(true); + expect(issued.vault_hpke_fingerprint).toMatch(/^[0-9a-f]{64}$/); + + // 2. Agent redeems it -> pending agent + match-code (inert until approved). + const agentKeys = generateSigningKeyPair(); + const redeemRes = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { + connect_link: issued.connect_link, + agent_public_key: agentKeys.publicKey.toString('base64'), + }, + }); + expect(redeemRes.statusCode).toBe(200); + const redeem = JSON.parse(redeemRes.body); + expect(redeem.status).toBe('pending_approval'); + expect(redeem.agent_id).toBe(issued.agent_id); + expect(redeem.match_code).toMatch(/^[2-9A-HJ-NP-Z]{6}$/); + + // 3. BEFORE approval: a stolen link grants nothing (Rule #5/#8 — inert). + const preApprove = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/status', + payload: { connect_link: issued.connect_link }, + }); + expect(JSON.parse(preApprove.body).status).toBe('pending_approval'); + expect(JSON.parse(preApprove.body).session_token).toBeUndefined(); + + // 4. Owner sees the pending approval with server-derived facts + match-code. + const pendingRes = await server.inject({ + method: 'GET', + url: '/v1/cloud-connect/pending', + headers: owner(), + }); + const pending = JSON.parse(pendingRes.body).pending; + const entry = pending.find((p: { agent_id: string }) => p.agent_id === issued.agent_id); + expect(entry).toBeTruthy(); + expect(entry.match_code).toBe(redeem.match_code); + expect(entry.scopes).toEqual(['read:wallet.address']); + expect(entry.budget).toEqual({ daily: 5, currency: 'USD', auto_approve_under: 0 }); + expect(entry.agent_fingerprint).toMatch(/^[0-9a-f]{64}$/); + + // 5. Wrong match-code is rejected (Rule #6 anti-phishing). + const wrong = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${issued.agent_id}/approve`, + headers: owner(), + payload: { match_code: 'WRONG9' }, + }); + expect(wrong.statusCode).toBe(400); + expect(JSON.parse(wrong.body).error.code).toBe('MATCH_CODE_MISMATCH'); + + // 6. Correct match-code approves and binds the agent. + const approve = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${issued.agent_id}/approve`, + headers: owner(), + payload: { match_code: redeem.match_code }, + }); + expect(approve.statusCode).toBe(200); + expect(JSON.parse(approve.body).approved).toBe(true); + + // 7. Agent polls -> mints its session token exactly once. + const tokenRes = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/status', + payload: { connect_link: issued.connect_link }, + }); + const tokenBody = JSON.parse(tokenRes.body); + expect(tokenBody.status).toBe('approved'); + expect(typeof tokenBody.session_token).toBe('string'); + expect(tokenBody.session_token.length).toBeGreaterThan(20); + expect(tokenBody.scopes).toEqual(['read:wallet.address']); + expect(tokenBody.budget.auto_approve_under).toBe(0); + + // Second poll: token already issued, not re-minted. + const tokenRes2 = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/status', + payload: { connect_link: issued.connect_link }, + }); + const tokenBody2 = JSON.parse(tokenRes2.body); + expect(tokenBody2.status).toBe('approved'); + expect(tokenBody2.token_already_issued).toBe(true); + expect(tokenBody2.session_token).toBeUndefined(); + + // 8. Instant revoke kills the agent; subsequent polls report revoked. + const revoke = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/agents/${issued.agent_id}/revoke`, + headers: owner(), + }); + expect(revoke.statusCode).toBe(200); + expect(JSON.parse(revoke.body).revoked).toBe(true); + + const afterRevoke = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/status', + payload: { connect_link: issued.connect_link }, + }); + expect(JSON.parse(afterRevoke.body).status).toBe('revoked'); + }); + + // -- Security invariants --------------------------------------------------- + + it('requires the match-code to approve — cannot be omitted (Rule #6)', async () => { + const issued = await issueLink(); + const agentKeys = generateSigningKeyPair(); + const redeem = JSON.parse( + ( + await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { + connect_link: issued.connect_link, + agent_public_key: agentKeys.publicKey.toString('base64'), + }, + }) + ).body + ); + expect(redeem.status).toBe('pending_approval'); + + // Approve with NO match_code -> rejected. + const noCode = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${issued.agent_id}/approve`, + headers: owner(), + payload: {}, + }); + expect(noCode.statusCode).toBe(400); + expect(JSON.parse(noCode.body).error.code).toBe('MATCH_CODE_MISMATCH'); + + // Approve with blank match_code -> rejected. + const blank = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${issued.agent_id}/approve`, + headers: owner(), + payload: { match_code: ' ' }, + }); + expect(blank.statusCode).toBe(400); + + // Still pending (not bound), and the on-device facts do NOT include a + // host taken from the attacker-controllable Host header (Rule #6). + const pending = JSON.parse( + (await server.inject({ method: 'GET', url: '/v1/cloud-connect/pending', headers: owner() })) + .body + ).pending; + const entry = pending.find((p: { agent_id: string }) => p.agent_id === issued.agent_id); + expect(entry).toBeTruthy(); + expect(entry.source_host).toBeUndefined(); + + // Correct code -> approves. + const ok = await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/pending/${issued.agent_id}/approve`, + headers: owner(), + payload: { match_code: redeem.match_code }, + }); + expect(ok.statusCode).toBe(200); + }); + + it('defaults to $0 budget / no auto-approve when omitted (Rule #5)', async () => { + const issued = await issueLink({ budget: undefined }); + const pendingBudgetRes = await server.inject({ + method: 'GET', + url: '/v1/cloud-connect/links', + headers: owner(), + }); + const link = JSON.parse(pendingBudgetRes.body).links.find( + (l: { link_id: string }) => l.link_id === issued.link_id + ); + expect(link.budget).toEqual({ daily: 0, currency: 'USD', auto_approve_under: 0 }); + }); + + it('is single-use: a redeemed link cannot be redeemed again', async () => { + const issued = await issueLink(); + const a1 = generateSigningKeyPair(); + const first = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: issued.connect_link, agent_public_key: a1.publicKey.toString('base64') }, + }); + expect(first.statusCode).toBe(200); + + const a2 = generateSigningKeyPair(); + const second = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: issued.connect_link, agent_public_key: a2.publicKey.toString('base64') }, + }); + expect(second.statusCode).toBe(400); + expect(JSON.parse(second.body).error.code).toMatch(/LINK_REDEEMED|LINK_CONSUMED|LINK_REVOKED/); + }); + + it('rejects a connect-link signed by a different (rogue) key — key-pin (Rule #1)', async () => { + const rogue = generateSigningKeyPair(); + const fakeHpke = Buffer.from(generateSigningKeyPair().publicKey).toString('base64'); + const fake = createConnectLink( + { + vault_id: 'vault_attacker', + agent_id: 'agent_x', + agent_name: 'evil', + mode: 'mcp', + relay_url: '', + vault_hpke_public_key: fakeHpke, + vault_signing_public_key: rogue.publicKey.toString('base64'), + }, + rogue.privateKey + ); + const agentKeys = generateSigningKeyPair(); + const res = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: fake.token, agent_public_key: agentKeys.publicKey.toString('base64') }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.code).toMatch(/INVALID_CONNECT_LINK|WRONG_VAULT/); + }); + + it('rejects redeem with an invalid agent public key', async () => { + const issued = await issueLink(); + const res = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: issued.connect_link, agent_public_key: 'not-a-key' }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.code).toBe('INVALID_AGENT_KEY'); + }); + + it('requires an owner token to issue links or view pending/management', async () => { + const issue = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/links', + payload: { name: 'x' }, + }); + expect(issue.statusCode).toBe(403); + + const pending = await server.inject({ method: 'GET', url: '/v1/cloud-connect/pending' }); + expect(pending.statusCode).toBe(403); + + const links = await server.inject({ method: 'GET', url: '/v1/cloud-connect/links' }); + expect(links.statusCode).toBe(403); + }); + + it('rejects a malformed connect-link', async () => { + const res = await server.inject({ + method: 'POST', + url: '/v1/cloud-connect/redeem', + payload: { connect_link: 'garbage', agent_public_key: generateSigningKeyPair().publicKey.toString('base64') }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error.code).toBe('INVALID_CONNECT_LINK'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 997a97e..9d15d9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,10 +11,10 @@ importers: packages/dcp-agent: dependencies: '@dcprotocol/client': - specifier: ^2.0.1 + specifier: ^3.0.0 version: link:../dcp-client '@dcprotocol/core': - specifier: ^2.0.1 + specifier: ^3.0.0 version: link:../dcp-core '@modelcontextprotocol/sdk': specifier: ^1.0.0 @@ -63,7 +63,7 @@ importers: packages/dcp-client: dependencies: '@dcprotocol/core': - specifier: ^2.0.0 + specifier: ^3.0.0 version: link:../dcp-core '@hpke/chacha20poly1305': specifier: ^1.6.0 @@ -169,6 +169,9 @@ importers: fastify: specifier: ^5.2.1 version: 5.8.5 + jose: + specifier: ^5.9.6 + version: 5.10.0 pino-pretty: specifier: ^13.0.0 version: 13.1.3 @@ -201,7 +204,7 @@ importers: packages/dcp-relay-client: dependencies: '@dcprotocol/relay': - specifier: ^2.0.1 + specifier: ^3.0.0 version: link:../dcp-relay '@hpke/chacha20poly1305': specifier: ^1.6.0 @@ -278,13 +281,13 @@ importers: packages/dcp-vault: dependencies: '@dcprotocol/client': - specifier: ^2.0.1 + specifier: ^3.0.0 version: link:../dcp-client '@dcprotocol/core': - specifier: ^2.0.0 + specifier: ^3.0.0 version: link:../dcp-core '@dcprotocol/relay-client': - specifier: ^2.0.0 + specifier: ^3.0.0 version: link:../dcp-relay-client '@fastify/cors': specifier: ^10.0.2 @@ -1498,6 +1501,9 @@ packages: engines: {node: '>=8'} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -3286,6 +3292,8 @@ snapshots: - bufferutil - utf-8-validate + jose@5.10.0: {} + jose@6.2.3: {} joycon@3.1.1: {} diff --git a/scripts/demo-cloud-agent.mjs b/scripts/demo-cloud-agent.mjs new file mode 100644 index 0000000..dd2d1b3 --- /dev/null +++ b/scripts/demo-cloud-agent.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Demo cloud agent — exercises the WHOLE Cloud-Connect flow locally so you can + * watch it end-to-end without OpenClaw/Hermes/Claude.ai. Pure Node crypto (no deps). + * + * It plays the role of a cloud agent: OAuth device-flow (PKCE + DPoP) against your + * local relay, then a real MCP call through the per-vault facade. + * + * PREREQ (3 terminals): + * 1) relay: node packages/dcp-relay/dist/index.js --port 8422 + * 2) vault: DCP_RELAY_URL=ws://127.0.0.1:8422 VAULT_DIR=/tmp/dcp-demo \ + * node packages/dcp-vault/dist/server/index.js + * (init+unlock the vault first; e.g. dcp init / create-wallet, then unlock) + * 3) issue a link (same VAULT_DIR): + * VAULT_DIR=/tmp/dcp-demo DCP_RELAY_URL=ws://127.0.0.1:8422 \ + * node packages/dcp-vault/dist/cli/index.js cloud-connect link demo --scopes read:wallet.address + * + * THEN run this with the printed connect-link: + * node scripts/demo-cloud-agent.mjs dcp_connect_v1_... --relay http://127.0.0.1:8422 + * + * When it prints a MATCH CODE, approve on the vault side (verifying it matches): + * VAULT_DIR=/tmp/dcp-demo node packages/dcp-vault/dist/cli/index.js cloud-connect approve + * ...and watch this script receive a token and make an MCP call. + */ + +import crypto from 'node:crypto'; + +const b64url = (buf) => Buffer.from(buf).toString('base64url'); +const sha256 = (s) => crypto.createHash('sha256').update(s).digest(); + +const args = process.argv.slice(2); +const connectLink = args.find((a) => a.startsWith('dcp_connect_v1_')); +const relayArgIdx = args.indexOf('--relay'); +const RELAY = (relayArgIdx >= 0 ? args[relayArgIdx + 1] : 'http://127.0.0.1:8422').replace(/\/+$/, ''); + +if (!connectLink) { + console.error('Usage: node scripts/demo-cloud-agent.mjs [--relay http://127.0.0.1:8422]'); + process.exit(1); +} + +// Parse vault_id from the (untrusted) connect-link for routing. +let vaultId; +try { + const env = JSON.parse(Buffer.from(connectLink.slice('dcp_connect_v1_'.length), 'base64url').toString()); + vaultId = env.payload.vault_id; +} catch { + console.error('Could not parse the connect-link.'); + process.exit(1); +} + +// --- DPoP key (EC P-256) + proof builder ----------------------------------- +const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); +const jwk = publicKey.export({ format: 'jwk' }); // { kty, crv, x, y } + +function makeDpop(method, url, accessToken) { + const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } }; + const payload = { + htm: method, + htu: url, + iat: Math.floor(Date.now() / 1000), + jti: crypto.randomUUID(), + }; + if (accessToken) payload.ath = b64url(sha256(accessToken)); + const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; + const sig = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + return `${signingInput}.${b64url(sig)}`; +} + +async function jpost(path, body, headers = {}) { + const url = `${RELAY}${path}`; + const res = await fetch(url, { + method: 'POST', + // ngrok-skip-browser-warning bypasses ngrok's free-tier interstitial so we + // always get JSON, not the warning HTML page. + headers: { 'content-type': 'application/json', 'ngrok-skip-browser-warning': 'true', ...headers }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json; + try { + json = JSON.parse(text); + } catch { + json = { raw: text }; + } + return { status: res.status, json }; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + console.log(`\n▶ Demo cloud agent connecting to vault ${vaultId} via ${RELAY}\n`); + + const verifier = b64url(crypto.randomBytes(48)); + const challenge = b64url(sha256(verifier)); + + // 1) /oauth/connect — redeem the connect-link (triggers on-device approval). + const connect = await jpost( + '/oauth/connect', + { connect_link: connectLink, code_challenge: challenge }, + { dpop: makeDpop('POST', `${RELAY}/oauth/connect`) } + ); + if (connect.status !== 200) { + console.error('✗ /oauth/connect failed:', connect.status, JSON.stringify(connect.json)); + process.exit(1); + } + const deviceCode = connect.json.device_code; + console.log(' ✓ connect-link redeemed.'); + console.log(`\n ┌─────────────────────────────────────┐`); + console.log(` │ MATCH CODE: ${String(connect.json.match_code).padEnd(22)}│`); + console.log(` └─────────────────────────────────────┘`); + console.log('\n → Approve on the vault side, e.g.:'); + console.log(` node packages/dcp-vault/dist/cli/index.js cloud-connect approve ${connect.json.match_code}`); + console.log(' (run `cloud-connect pending` to get the agentId)\n'); + + // 2) /oauth/token — poll until the owner approves on-device. + process.stdout.write(' Waiting for approval'); + let accessToken; + for (let i = 0; i < 100; i++) { + const tok = await jpost( + '/oauth/token', + { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCode, code_verifier: verifier }, + { dpop: makeDpop('POST', `${RELAY}/oauth/token`) } + ); + if (tok.status === 200) { + accessToken = tok.json.access_token; + break; + } + if (tok.json.error === 'authorization_pending') { + process.stdout.write('.'); + await sleep(3000); + continue; + } + console.error('\n✗ /oauth/token error:', tok.status, JSON.stringify(tok.json)); + process.exit(1); + } + if (!accessToken) { + console.error('\n✗ Timed out waiting for approval.'); + process.exit(1); + } + console.log('\n ✓ Approved! Got a DPoP + audience-bound access token.\n'); + + // 3) Real MCP calls through the per-vault facade. + const mcpUrl = `${RELAY}/v/${vaultId}/mcp`; + const call = (rpc) => + jpost(`/v/${vaultId}/mcp`, rpc, { + authorization: `DPoP ${accessToken}`, + dpop: makeDpop('POST', mcpUrl, accessToken), + }); + + const list = await call({ jsonrpc: '2.0', id: 1, method: 'tools/list' }); + console.log(' tools/list →', list.status, JSON.stringify(list.json).slice(0, 200)); + + const addr = await call({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'vault_get_address', arguments: { chain: 'solana' } }, + }); + console.log(' vault_get_address →', addr.status, JSON.stringify(addr.json).slice(0, 240)); + + console.log('\n✓ Done. The agent operated the vault — keys never left the device.\n'); + process.exit(0); +} + +main().catch((e) => { + console.error('demo agent error:', e); + process.exit(1); +}); diff --git a/scripts/demo-local.sh b/scripts/demo-local.sh new file mode 100755 index 0000000..f851e9c --- /dev/null +++ b/scripts/demo-local.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# demo-local.sh — one command to watch the WHOLE Cloud-Connect flow on your machine. +# +# Spins up a throwaway vault + relay + vault-server, issues a connect-link, runs a +# demo cloud agent, auto-approves (simulating your on-device tap), and shows the +# agent read the vault — then revokes and cleans everything up. Touches nothing real. +# +# bash scripts/demo-local.sh +# +set -o pipefail +cd "$(dirname "$0")/.." +strip_ansi() { sed $'s/\x1b\\[[0-9;]*m//g'; } + +RELAY_PORT=${RELAY_PORT:-8497} +VAULT_PORT=${VAULT_PORT:-8496} +VAULT_DIR=$(mktemp -d)/demo-vault +export DCP_RELAY_URL="ws://127.0.0.1:${RELAY_PORT}" +mkdir -p "$VAULT_DIR" + +RELAY_PID=""; VAULT_PID="" +cleanup() { + [ -n "$RELAY_PID" ] && kill "$RELAY_PID" 2>/dev/null || true + [ -n "$VAULT_PID" ] && kill "$VAULT_PID" 2>/dev/null || true + rm -rf "$VAULT_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +cli() { VAULT_DIR="$VAULT_DIR" DCP_RELAY_URL="$DCP_RELAY_URL" node packages/dcp-vault/dist/cli/index.js "$@"; } + +echo "==> Building (local, no publish)…" +pnpm -r build >/dev/null 2>&1 || { echo "build failed"; exit 1; } + +echo "==> Provisioning a throwaway vault…" +VAULT_DIR="$VAULT_DIR" pnpm --filter @dcprotocol/vault exec node -e " +(async()=>{const c=await import('@dcprotocol/core');const s=new c.VaultStorage();s.initializeSchema(); +const mk=c.deriveKeyFromMnemonic(c.generateRecoveryMnemonic());await s.storeMasterKeyWithPassphrase(mk,'demo-pass-123'); +const{encrypted,info}=c.createWallet('solana',mk);s.createRecord({scope:'crypto.wallet.solana',item_type:'WALLET_KEY',sensitivity:'critical',data:encrypted,chain:'solana',public_address:info.public_address}); +c.zeroize(mk);s.close();console.log(' wallet:',info.public_address);})()" 2>&1 | grep wallet + +echo "==> Starting relay (:$RELAY_PORT) and vault server (:$VAULT_PORT, connected to relay)…" +node packages/dcp-relay/dist/index.js --port "$RELAY_PORT" >/tmp/demo-relay.log 2>&1 & RELAY_PID=$! +DCP_RELAY_URL="$DCP_RELAY_URL" VAULT_DIR="$VAULT_DIR" VAULT_PORT="$VAULT_PORT" \ + node packages/dcp-vault/dist/server/index.js >/tmp/demo-vault.log 2>&1 & VAULT_PID=$! +sleep 4 + +CONNECTED=$(curl -s "localhost:$RELAY_PORT/stats" | grep -oE '"connectedVaults":[0-9]+' || echo "?") +echo " relay $CONNECTED" +curl -s -X POST "localhost:$VAULT_PORT/v1/vault/unlock" -H 'content-type: application/json' \ + -d '{"passphrase":"demo-pass-123"}' >/dev/null + +echo "==> Issuing a connect-link…" +LINK=$(cli cloud-connect link openclaw-demo --scopes read:wallet.address 2>/dev/null | grep -oE 'dcp_connect_v1_[A-Za-z0-9_-]+') +echo " ${LINK:0:42}…" + +echo "==> Running the demo cloud agent (redeems over the relay)…" +node scripts/demo-cloud-agent.mjs "$LINK" --relay "http://127.0.0.1:$RELAY_PORT" >/tmp/demo-agent.log 2>&1 & AGENT_PID=$! +sleep 4 + +PEND=$(cli cloud-connect pending 2>/dev/null | strip_ansi | tr -cd 'A-Za-z0-9 _:\n') +AID=$(printf '%s' "$PEND" | grep -oE 'agent_[a-f0-9]+' | head -1) +MC=$(printf '%s' "$PEND" | grep -oE 'Match code: [A-HJ-NP-Z2-9]{6}' | grep -oE '[A-HJ-NP-Z2-9]{6}' | head -1) +AID=$(printf '%s' "${AID:-}" | tr -cd 'a-z0-9_'); MC=$(printf '%s' "${MC:-}" | tr -cd 'A-Z0-9') +if [ -z "$AID" ] || [ -z "$MC" ]; then echo "could not find pending agent; see /tmp/demo-agent.log"; exit 1; fi +printf '==> Agent is waiting. On a real run you approve on-device after confirming the match code.\n Auto-approving %s now...\n' "$AID" +cli cloud-connect approve "$AID" "$MC" 2>/dev/null | grep -i approved || true +sleep 6 + +echo "" +echo "==> Agent result:" +grep -E "Approved!|tools/list →|vault_get_address →|Done" /tmp/demo-agent.log | sed 's/^/ /' +kill $AGENT_PID 2>/dev/null || true + +echo "" +echo "==> Revoking the agent (instant)…" +cli cloud-connect revoke "$AID" 2>/dev/null | grep -i revoked || true + +echo "" +echo "✓ Done — full flow worked locally. (Throwaway vault + processes cleaned up.)" diff --git a/scripts/test-security.sh b/scripts/test-security.sh index 169b709..9ba19a7 100755 --- a/scripts/test-security.sh +++ b/scripts/test-security.sh @@ -180,6 +180,162 @@ else fail "Config file permission check not found" fi +# ============================================================================= +# Test 13: Cloud-Connect — match-code is MANDATORY on approve (Rule #6) +# ============================================================================= +echo "Test 13: Cloud-Connect approve requires a match code" +if grep -n "presentedCode\|expectedCode\|constantTimeStrEqual" "${ROOT_DIR}/packages/dcp-vault/src/server/index.ts" > /dev/null 2>&1; then + pass "Cloud-Connect approve enforces a mandatory, constant-time match code" +else + fail "Cloud-Connect approve match-code enforcement not found" +fi + +# ============================================================================= +# Test 14: Cloud-Connect — PKCE is S256-only (no plain downgrade) +# ============================================================================= +echo "Test 14: Relay OAuth PKCE is S256-only" +if grep -rn "code_challenge_methods_supported" "${ROOT_DIR}/packages/dcp-relay/src/oauth/metadata.ts" | grep -q "S256" && \ + ! grep -rn "'plain'" "${ROOT_DIR}/packages/dcp-relay/src/oauth/"*.ts > /dev/null 2>&1; then + pass "Relay advertises + enforces PKCE S256 only (no plain)" +else + fail "Relay PKCE may allow plain or S256 not enforced" +fi + +# ============================================================================= +# Test 15: Cloud-Connect — access tokens are DPoP + audience bound (Rules #2) +# ============================================================================= +echo "Test 15: Relay access tokens are DPoP + audience bound" +if grep -n "cnf" "${ROOT_DIR}/packages/dcp-relay/src/oauth/tokens.ts" | grep -q "jkt" && \ + grep -qn "setAudience" "${ROOT_DIR}/packages/dcp-relay/src/oauth/tokens.ts"; then + pass "Access tokens bind cnf.jkt (DPoP) + audience (RFC 8707)" +else + fail "Access tokens missing DPoP/audience binding" +fi + +# ============================================================================= +# Test 16: Cloud-Connect — DPoP proofs are single-use (jti replay guard) +# ============================================================================= +echo "Test 16: Relay DPoP proofs have a jti replay guard" +if grep -qn "dpop_replay\|jtiGuard\|createJtiGuard" "${ROOT_DIR}/packages/dcp-relay/src/oauth/dpop.ts"; then + pass "DPoP proofs are single-use (jti replay guard)" +else + fail "DPoP jti replay guard not found" +fi + +# ============================================================================= +# Test 17: Cloud-Connect — refresh tokens rotate with reuse detection (Rule #3) +# ============================================================================= +echo "Test 17: Refresh tokens rotate with reuse detection" +if grep -qn "reuse_detected" "${ROOT_DIR}/packages/dcp-relay/src/oauth/store.ts"; then + pass "Refresh-token reuse is detected (whole-chain revoke)" +else + fail "Refresh-token reuse detection not found" +fi + +# ============================================================================= +# Test 18: Local agent requests require a mandatory signature (no unsigned bypass) +# ============================================================================= +echo "Test 18: Local agent identity requires a signature" +if grep -qn "A signed request (service_signature, timestamp, nonce) is required" "${ROOT_DIR}/packages/dcp-vault/src/server/index.ts"; then + pass "agent_/vps_ requests reject missing signatures (no unsigned fallback)" +else + fail "Mandatory local-agent signature check not found" +fi + +# ============================================================================= +# Test 19: Local signed requests have nonce replay protection +# ============================================================================= +echo "Test 19: Local agent nonce replay protection" +if grep -qn "markLocalAgentNonce" "${ROOT_DIR}/packages/dcp-vault/src/server/index.ts"; then + pass "Local signed requests are single-use (nonce replay guard)" +else + fail "Local-agent nonce replay guard not found" +fi + +# ============================================================================= +# Test 20: Owner registration cannot be silently overwritten +# ============================================================================= +echo "Test 20: Owner registration guard" +if grep -qn "Replacing it requires the current owner token or the vault passphrase" "${ROOT_DIR}/packages/dcp-vault/src/server/index.ts"; then + pass "desktop/register guards against unauthenticated owner replacement" +else + fail "Owner-registration guard not found" +fi + +# ============================================================================= +# Test 21: Relay binds vault_id to its signing key (anti-hijack) +# ============================================================================= +echo "Test 21: Relay vault_id <-> key binding" +if grep -qn "bindOrVerifyVaultKey" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts"; then + pass "Relay rejects a different key claiming an existing vault_id" +else + fail "Relay vault_id binding not found" +fi + +# ============================================================================= +# Test 22: Relay HTTP poll/respond require a signed proof of vault ownership +# ============================================================================= +echo "Test 22: Relay poll/respond authentication" +if grep -qn "A signed proof of vault ownership is required" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts"; then + pass "HTTP-fallback poll/respond require signed vault ownership" +else + fail "Relay poll/respond auth not found" +fi + +# ============================================================================= +# Test 23: Relay WS control messages are bound to the socket's vault +# ============================================================================= +echo "Test 23: Relay WS control messages bound to vault" +if grep -qn "fromVaultId !== waiter.vaultId" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts" \ + && grep -qn "knownOwner && knownOwner !== me" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts" \ + && grep -qn "revokeAgentAccess(payload.agent_id, me)" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts"; then + pass "cloud_connect_result/revoke + pairing_result are bound/scoped to the socket's vault" +else + fail "WS control message vault-binding not found" +fi + +# ============================================================================= +# Test 24: Unauthenticated HTTP pairing-claims resolve route removed +# ============================================================================= +echo "Test 24: Unauthenticated pairing resolve route removed" +if ! grep -qn "handlePairingResolve" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts"; then + pass "Unauthenticated /v1/pairing-claims/:claimId/resolve route is gone" +else + fail "Unauthenticated pairing resolve handler still present" +fi + +# ============================================================================= +# Test 25: Passphrase re-auth does not corrupt the cached master key +# ============================================================================= +echo "Test 25: Side-effect-free passphrase verification" +if grep -qn "verifyPassphrase" "${ROOT_DIR}/packages/dcp-core/src/storage.ts" \ + && ! grep -qn "zeroize(mk); // verify only — don't keep the key around" "${ROOT_DIR}/packages/dcp-vault/src/server/index.ts"; then + pass "Owner re-auth uses verifyPassphrase (no master-key corruption)" +else + fail "Side-effect-free passphrase verification not in place" +fi + +# ============================================================================= +# Test 26: WS unregister can only unregister the socket's own vault +# ============================================================================= +echo "Test 26: WS unregister vault binding" +if grep -qn "unregister vault_id mismatch" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts"; then + pass "WS unregister rejects evicting another vault's connection" +else + fail "WS unregister vault-binding not found" +fi + +# ============================================================================= +# Test 27: push-token registration requires signed vault ownership +# ============================================================================= +echo "Test 27: Push-token registration auth" +if grep -qn "A signed proof of vault ownership is required" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts" \ + && grep -B6 "A signed proof of vault ownership is required" "${ROOT_DIR}/packages/dcp-relay/src/relay.ts" | grep -qn "verifyVaultProof(request.body"; then + pass "push-token register requires a signed proof (no cross-tenant push hijack)" +else + fail "push-token registration auth not found" +fi + # ============================================================================= # Summary # =============================================================================