From 770b9b3dfdf671706af79730c2b917ad5695141d Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 12:33:02 +0200 Subject: [PATCH 1/6] docs: prepare privacy layer for public release --- .github/workflows/ci.yml | 42 +++++++ CONTRIBUTING.md | 32 ++++++ LICENSE | 157 ++++++++++++++++++++++++++ README.md | 230 +++++++++------------------------------ SECURITY.md | 18 +++ 5 files changed, 303 insertions(+), 176 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bad9d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.4" + + - name: Download dependencies + run: go mod download + + - name: Check for secrets + run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --no-git --source . --redact --verbose + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build + run: go build -trimpath -o bin/nexus-proxy ./cmd/nexus-proxy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..53c671b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +Thanks for helping improve the Nexus Privacy Layer. + +## Development setup + +Install Go 1.26.4 or newer, clone the repository, and run the standard checks: + +```sh +make test +make vet +make build +``` + +The compiled binary is written to `bin/nexus-proxy`. + +## Project layout + +- `cmd/nexus-proxy` contains the executable. +- `internal/attestation` verifies the confidential Nexus service. +- `internal/confidential` protects request and response bodies. +- `internal/proxy` provides the local OpenAI-compatible API. +- `internal/ledger` powers the local verification history. + +## Pull requests + +Keep changes focused, add or update tests for behavior changes, and make sure +`make test`, `make vet`, and `make build` pass before opening a pull request. + +Operational policies, release measurements, and deployment runbooks are +maintained privately by the DAppNode team and are intentionally not part of +this public repository. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64c817c --- /dev/null +++ b/LICENSE @@ -0,0 +1,157 @@ +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. + +"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. diff --git a/README.md b/README.md index 8d1eb84..3914ded 100644 --- a/README.md +++ b/README.md @@ -1,197 +1,75 @@ -# DAppNode Nexus SDK +# Nexus Privacy Layer -`nexus-proxy` is an OpenAI-compatible proxy for the confidential Nexus -Gateway endpoint. It verifies fresh AWS Nitro attestation against a policy you -pin, takes the Gateway's X25519 key only from that signed attestation, and then -uses EHBP to encrypt request and response bodies through intermediaries such as -Cloudflare. +Private, OpenAI-compatible access to Nexus for applications running on +DAppNode. -The proxy does not use a Tinfoil service or EHBP's network key-discovery -endpoint. EHBP is linked as an ordinary MIT-licensed Go library. +The Nexus Privacy Layer runs locally on your DAppNode. It verifies the Nexus +confidential service before accepting traffic, then protects prompt and +response bodies on their way to and from Nexus. Applications keep using the +standard OpenAI API format. -## Build +This repository contains the privacy component used by the **Nexus Local +Proxy** DAppNode package. Most users should install the package rather than +build this repository directly. -Go 1.26.4 or newer is required. +## Connect an application -```sh -make test -make vet -make build -``` +1. Install **Nexus Local Proxy** on your DAppNode. +2. Create an API key at [nexus.dappnode.com](https://nexus.dappnode.com). +3. Configure an application on the same DAppNode with: -## Trust policy - -Obtain the source revision and PCR0/PCR1/PCR2 measurements from a Nexus Gateway -release through a channel you trust. Do not copy measurements from the Gateway -being verified. - -```json -{ - "schema_version": 2, - "manifest_schema_version": 4, - "workload": "dappnode-nexus-gateway", - "profile": "nexus-gateway-v2", - "releases": [ - { - "source_revision": "REPLACE_WITH_40_LOWERCASE_HEX_CHARACTERS", - "pcr0": "REPLACE_WITH_96_LOWERCASE_HEX_CHARACTERS", - "pcr1": "REPLACE_WITH_96_LOWERCASE_HEX_CHARACTERS", - "pcr2": "REPLACE_WITH_96_LOWERCASE_HEX_CHARACTERS" - } - ], - "e2ee": { - "protocol": "ehbp-v1", - "suite": "DHKEM-X25519-HKDF-SHA256/HKDF-SHA256/AES-256-GCM", - "endpoint": "/v1/confidential/chat/completions", - "request_encrypted": true, - "response_encrypted": true - } -} +```text +Base URL: http://nexus-local-proxy.dappnode.private:3301/v1 +API key: your Nexus API key +API: OpenAI Chat Completions ``` -The parser rejects unknown fields, all-zero debug PCRs, non-canonical values, -and any unsupported E2EE contract. +For example: -### Pinning more than one release +```sh +export NEXUS_API_KEY="your-api-key" +export NEXUS_BASE_URL="http://nexus-local-proxy.dappnode.private:3301/v1" -`releases` accepts up to four entries so a Gateway can be rolled out without -every client failing closed in the interval before it is updated. Publish a -policy listing both the outgoing and the incoming release, let clients pick it -up, deploy the new Gateway, then publish a policy listing only the new release. +curl "$NEXUS_BASE_URL/chat/completions" \ + -H "Authorization: Bearer $NEXUS_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"MODEL_ID","messages":[{"role":"user","content":"Hello"}]}' +``` -Each entry is matched as a whole. The verifier selects the single entry whose -`source_revision` the signed manifest claims, and accepts only that entry's -measurements; it never accepts a measurement from one entry alongside a -revision from another. Evidence naming a revision that is not pinned is -rejected outright. +Use `GET /v1/models` to find the model IDs available to your account. Both +regular responses and streaming responses are supported. -Keep the list short. Every entry is a build you are asserting is trustworthy, -so remove a release as soon as nothing is running it. +## Check your privacy connection -## Run +Open the local verification page after installing the package: -```sh -./bin/nexus-proxy \ - --gateway-url https://nexus-api-tee.dappnode.com \ - --trust-policy ./nexus-gateway-policy.json \ - --listen 127.0.0.1:3301 +```text +http://nexus-local-proxy.dappnode.private:3301/verification ``` -The listener must be a literal loopback IP. Startup fails unless the Gateway -produces fresh, nonce-bound evidence that passes the AWS certificate-chain, -COSE signature, timestamp, exact PCR, signed-manifest digest, pinned manifest -claims, and public-key checks. +It shows whether the Nexus service passed verification and which protected +connection handled each recent request. The page contains verification +evidence and request metadata, never prompts or responses. -For a shared Nexus Proxy package on a trusted DAppNode, use the explicit -DAppNode listener scope: +## What is protected -```sh -./bin/nexus-proxy \ - --gateway-url https://nexus-api-tee.dappnode.com \ - --trust-policy ./nexus-gateway-policy.json \ - --listen 0.0.0.0:3301 \ - --listen-scope dappnode -``` +- Prompt and response bodies are encrypted between this local privacy layer + and the verified Nexus confidential service. +- The service refuses to start if it cannot verify the confidential service. +- Prompt and response content is not written to the verification history or + application logs. -The DAppNode package must not publish this port outside the trusted DAppNode -environment. `GET /healthz` reports local process readiness; because the HTTP -listener is created only after startup attestation succeeds, an available -health endpoint also means startup verification completed successfully. - -## Privacy verification page - -`GET /verification` serves a local page that reports whether the Gateway is -currently verified, which checks passed, and which attested key each request -was encrypted to. `GET /v1/verification` returns the same data as JSON, and -`GET /v1/verification/document?id=ID` returns the raw COSE_Sign1 attestation -document (add `&part=manifest` for the signed manifest) so the evidence can be -re-checked with an independent AWS Nitro verifier. - -The page is rendered by the same process that performs the verification, so it -cannot prove itself; its purpose is to surface the signed evidence and hand it -back for independent checking. - -The ledger behind it holds verification evidence, which is public by -construction, plus per request identifiers, timing and sizes. It never receives -prompt or completion content, and nothing is written to disk: history is kept in -memory unless `--state-file` is given. Pass `--verification-ui=false` to -remove the page and its API entirely. - -`--state-file PATH` persists verification history so it survives a restart. -Only evidence and metadata are written — the signed attestation documents, the -manifests they commit to, and per request identifiers, timing, sizes and -outcome. The on-disk format has no field that could hold a prompt or a -completion. The file is written `0600`, replaced atomically, and flushed off -the request path; a missing or unreadable file starts an empty history rather -than blocking startup. Without the flag the ledger stays memory-only, exactly -as before. - -Point an OpenAI-compatible client at `http://127.0.0.1:3301/v1` and keep using -its normal API-key setting. For example: +The DAppNode host and its internal network remain trusted. Request metadata, +including headers, sizes, and timing, is outside the body-encryption boundary. +The protection also does not extend beyond Nexus to a downstream model +provider. Do not expose the local service port to the public Internet. -```sh -curl http://127.0.0.1:3301/v1/chat/completions \ - -H 'Authorization: Bearer YOUR_NEXUS_API_KEY' \ - -H 'Content-Type: application/json' \ - -d '{"model":"MODEL","messages":[{"role":"user","content":"hello"}]}' -``` +## For contributors + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow. Please +report security issues as described in [SECURITY.md](SECURITY.md). + +## License -`GET /v1/models` returns the Gateway's model catalog, so a client that -enumerates models against its base URL works without extra configuration. - -Both ordinary JSON responses and `stream: true` SSE responses are supported. -The proxy never automatically retries an inference request. After an explicit -stale-key failure it discards the cached key; a new caller request triggers new -attestation. Deployments therefore require one active enclave per Gateway -origin or key-affine routing. - -## Model catalog - -`GET /v1/models` passes the Gateway's public model catalog through unchanged. -This is **not** a confidential path, and it is the one route on this proxy that -is not covered by the guarantee above: - -- The catalog is public, unauthenticated, cacheable data. It carries no prompt, - no completion and no credential, so there is nothing to encrypt. -- It is fetched over ordinary TLS, not over EHBP, and the Gateway serves it at - the standard `/v1/models` path rather than the confidential endpoint. -- The caller's `Authorization` header is **not** forwarded. The Gateway ignores - it, and forwarding it would put a Nexus API key on a path that terminates at - Cloudflare for no benefit. -- The response is released only when it is one complete JSON object, so an HTML - error page from an intermediary cannot reach a client expecting JSON. -- These requests are not recorded in the verification ledger. Counting them - would overstate how many bodies actually crossed the attested channel. - -Pass `--model-catalog=false` to remove the route entirely and leave the -confidential inference endpoint as the only upstream this proxy talks to. - -## Security boundary - -The verified claim is deliberately narrow: the JSON request body, including -the prompt, is encrypted and integrity-protected from this proxy to the -attested Nexus Gateway enclave; authenticated response frames are decrypted -only by this proxy. Normal JSON is fully read and validated before release. -Streaming frames are released after individual authentication, and the local -connection is aborted on decryption failure or if the authenticated stream -ends without `data: [DONE]`. - -EHBP does **not** protect the HTTP method, URL, status, headers, body length, -frame sizes, or timing. In particular, `Authorization: Bearer ...` remains -visible to Cloudflare and is not cryptographically bound to the encrypted -body. Intermediaries can still drop traffic or alter visible metadata, but -cannot read or alter this proxy's encrypted prompt undetected, or make this -proxy accept an altered completion body. Because the EHBP public key and bearer -credential are visible, a TLS terminator can copy the API key and submit an -independent encrypted request; preventing that impersonation is outside v1. -The client machine and local proxy are trusted, and this claim does not cover -downstream inference providers. In DAppNode listener mode, callers also trust -the DAppNode host and its internal network: the caller-to-proxy hop is ordinary -HTTP and is outside the EHBP boundary. - -The proxy rejects redirects, plaintext confidential responses, malformed or -oversized frames, oversized non-streaming bodies, stale evidence, key -substitution, and attestation downgrades. It logs neither API keys nor -request/response bodies. - -See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for dependency licenses. +Licensed under the [Apache License 2.0](LICENSE). Dependency notices are in +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2b6ba42 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported versions + +Security fixes are made on the `main` branch and included in the next Nexus +Local Proxy package release. Users should keep that package up to date. + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. + +Use **Report a vulnerability** in this repository's Security tab to send the +maintainers a private report. If private reporting is unavailable, email +`admin@dappnode.io` with `[SECURITY] Nexus Privacy Layer` in the subject. + +Include the affected version, reproduction steps, impact, and any suggested +mitigation. Do not include real API keys, prompts, responses, or other user +data in the report. From d02247176da7f848cc9a3ff4350a5307e007f58b Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 12:36:39 +0200 Subject: [PATCH 2/6] chore: update crypto dependency --- .github/workflows/ci.yml | 3 +++ go.mod | 4 ++-- go.sum | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bad9d3..5439a26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: Check for secrets run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --no-git --source . --redact --verbose + - name: Check for known vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... + - name: Vet run: go vet ./... diff --git a/go.mod b/go.mod index 56c67cb..c9b7f96 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,6 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 4a28b11..576a9ea 100644 --- a/go.sum +++ b/go.sum @@ -14,9 +14,9 @@ github.com/tinfoilsh/encrypted-http-body-protocol v0.2.6 h1:Mznvqi4iqL8ZTSkj0lne github.com/tinfoilsh/encrypted-http-body-protocol v0.2.6/go.mod h1:THDK0GFNny7Pcc+nO3AQi4f6Wf1cDkfLatmHAUlIn5s= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 1525f9699c03ba8eb987182f9dbe4f2af9f48cd9 Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 12:39:32 +0200 Subject: [PATCH 3/6] chore: require patched Go toolchain --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 2 +- go.mod | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5439a26..d8063a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.26.4" + go-version: "1.26.8" - name: Download dependencies run: go mod download diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53c671b..1bebcd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for helping improve the Nexus Privacy Layer. ## Development setup -Install Go 1.26.4 or newer, clone the repository, and run the standard checks: +Install Go 1.26.8 or newer, clone the repository, and run the standard checks: ```sh make test diff --git a/go.mod b/go.mod index c9b7f96..c3c43c3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dappnode/dappnode-nexus-sdk -go 1.26.4 +go 1.26.8 require ( github.com/anchorageoss/awsnitroverifier v0.6.0 From a0de2b58bf3663ed0f7c0abd565de58fcde2e86e Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 12:42:49 +0200 Subject: [PATCH 4/6] ci: update GitHub Actions runtimes --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8063a5..ad12df9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v7 with: go-version: "1.26.8" From ea54fd7e6b57bf0d4920280db9d3014eff50fba9 Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 12:58:39 +0200 Subject: [PATCH 5/6] docs: make SDK standalone-first --- .github/workflows/ci.yml | 45 --------------- CONTRIBUTING.md | 10 ++-- README.md | 117 ++++++++++++++++++++++++++------------ SECURITY.md | 6 +- go.mod | 6 +- go.sum | 8 +-- nexus-gateway-policy.json | 27 +++++++++ 7 files changed, 124 insertions(+), 95 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 nexus-gateway-policy.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index ad12df9..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Build and test - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version: "1.26.8" - - - name: Download dependencies - run: go mod download - - - name: Check for secrets - run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --no-git --source . --redact --verbose - - - name: Check for known vulnerabilities - run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... - - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... - - - name: Build - run: go build -trimpath -o bin/nexus-proxy ./cmd/nexus-proxy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1bebcd2..3c5e4a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for helping improve the Nexus Privacy Layer. +Thanks for helping improve the DAppNode Nexus SDK. ## Development setup @@ -17,7 +17,7 @@ The compiled binary is written to `bin/nexus-proxy`. ## Project layout - `cmd/nexus-proxy` contains the executable. -- `internal/attestation` verifies the confidential Nexus service. +- `internal/attestation` verifies the Nexus Gateway. - `internal/confidential` protects request and response bodies. - `internal/proxy` provides the local OpenAI-compatible API. - `internal/ledger` powers the local verification history. @@ -27,6 +27,6 @@ The compiled binary is written to `bin/nexus-proxy`. Keep changes focused, add or update tests for behavior changes, and make sure `make test`, `make vet`, and `make build` pass before opening a pull request. -Operational policies, release measurements, and deployment runbooks are -maintained privately by the DAppNode team and are intentionally not part of -this public repository. +The policy in `nexus-gateway-policy.json` is updated only from verified Gateway +release measurements. Deployment and release runbooks are maintained privately +by the DAppNode team and are intentionally not part of this public repository. diff --git a/README.md b/README.md index 3914ded..47e8d28 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,64 @@ -# Nexus Privacy Layer +# DAppNode Nexus SDK -Private, OpenAI-compatible access to Nexus for applications running on -DAppNode. +A local, OpenAI-compatible client that verifies the Nexus Gateway before any +prompt is sent. -The Nexus Privacy Layer runs locally on your DAppNode. It verifies the Nexus -confidential service before accepting traffic, then protects prompt and -response bodies on their way to and from Nexus. Applications keep using the -standard OpenAI API format. +The SDK runs on your computer or server. At startup it checks a fresh +attestation from the Nexus Gateway running in a trusted execution environment +against a policy published by DAppNode. It accepts requests only after that +verification succeeds, then protects prompt and response bodies between the +local SDK and the verified Gateway. -This repository contains the privacy component used by the **Nexus Local -Proxy** DAppNode package. Most users should install the package rather than -build this repository directly. +You do not need a DAppNode to use it. + +## Install + +Go 1.26.8 or newer is required. + +```sh +go install github.com/dappnode/dappnode-nexus-sdk/cmd/nexus-proxy@latest +``` + +Alternatively, build it from source: + +```sh +git clone https://github.com/dappnode/dappnode-nexus-sdk.git +cd dappnode-nexus-sdk +make build +``` + +## Download the Nexus trust policy + +The maintained policy in this repository identifies the Nexus Gateway +releases the SDK is allowed to trust. Download it through the DAppNode GitHub +organization rather than from the Gateway being verified: + +```sh +curl -fsSLo nexus-gateway-policy.json \ + https://raw.githubusercontent.com/dappnode/dappnode-nexus-sdk/main/nexus-gateway-policy.json +``` + +Keep this file updated when DAppNode publishes support for a new Gateway +release. + +## Start the SDK + +```sh +nexus-proxy \ + --gateway-url https://nexus-api-tee.dappnode.com \ + --trust-policy ./nexus-gateway-policy.json +``` + +The SDK verifies the Gateway before opening its local listener. If verification +fails, it exits without accepting prompts. ## Connect an application -1. Install **Nexus Local Proxy** on your DAppNode. -2. Create an API key at [nexus.dappnode.com](https://nexus.dappnode.com). -3. Configure an application on the same DAppNode with: +Create an API key at [nexus.dappnode.com](https://nexus.dappnode.com), then +configure any OpenAI-compatible application with: ```text -Base URL: http://nexus-local-proxy.dappnode.private:3301/v1 +Base URL: http://127.0.0.1:3301/v1 API key: your Nexus API key API: OpenAI Chat Completions ``` @@ -28,7 +67,7 @@ For example: ```sh export NEXUS_API_KEY="your-api-key" -export NEXUS_BASE_URL="http://nexus-local-proxy.dappnode.private:3301/v1" +export NEXUS_BASE_URL="http://127.0.0.1:3301/v1" curl "$NEXUS_BASE_URL/chat/completions" \ -H "Authorization: Bearer $NEXUS_API_KEY" \ @@ -36,38 +75,46 @@ curl "$NEXUS_BASE_URL/chat/completions" \ -d '{"model":"MODEL_ID","messages":[{"role":"user","content":"Hello"}]}' ``` -Use `GET /v1/models` to find the model IDs available to your account. Both -regular responses and streaming responses are supported. +Use `GET /v1/models` to list available model IDs. Regular and streaming chat +responses are supported. -## Check your privacy connection +## Verify the connection -Open the local verification page after installing the package: +Open the local verification page after starting the SDK: ```text -http://nexus-local-proxy.dappnode.private:3301/verification +http://127.0.0.1:3301/verification ``` -It shows whether the Nexus service passed verification and which protected -connection handled each recent request. The page contains verification -evidence and request metadata, never prompts or responses. +It shows whether the Gateway passed verification and which verified connection +handled each recent request. The page contains verification evidence and +request metadata, never prompts or responses. + +## Using it on DAppNode + +DAppNode users can install **Nexus Local Proxy** instead of running the binary +manually. Applications on the same DAppNode then use: + +```text +http://nexus-local-proxy.dappnode.private:3301/v1 +``` ## What is protected -- Prompt and response bodies are encrypted between this local privacy layer - and the verified Nexus confidential service. -- The service refuses to start if it cannot verify the confidential service. -- Prompt and response content is not written to the verification history or - application logs. +- The SDK verifies the Gateway before accepting prompts. +- Prompt and response bodies are encrypted between the SDK and the verified + Gateway. +- Prompt and response content is not written to verification history or logs. -The DAppNode host and its internal network remain trusted. Request metadata, -including headers, sizes, and timing, is outside the body-encryption boundary. -The protection also does not extend beyond Nexus to a downstream model -provider. Do not expose the local service port to the public Internet. +The machine running the SDK remains trusted. Request metadata, including +headers, sizes, and timing, is outside the body-encryption boundary. Protection +also does not extend beyond Nexus to a downstream model provider. Keep the +local listener private to your machine or trusted network. -## For contributors +## Contributing and security -See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow. Please -report security issues as described in [SECURITY.md](SECURITY.md). +See [CONTRIBUTING.md](CONTRIBUTING.md) for development instructions. Report +security issues as described in [SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md index 2b6ba42..27ce106 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported versions -Security fixes are made on the `main` branch and included in the next Nexus -Local Proxy package release. Users should keep that package up to date. +Security fixes are made on the `main` branch. Standalone users should update +the SDK regularly; DAppNode users should keep Nexus Local Proxy up to date. ## Reporting a vulnerability @@ -11,7 +11,7 @@ Please do not open a public issue for a suspected vulnerability. Use **Report a vulnerability** in this repository's Security tab to send the maintainers a private report. If private reporting is unavailable, email -`admin@dappnode.io` with `[SECURITY] Nexus Privacy Layer` in the subject. +`admin@dappnode.io` with `[SECURITY] Nexus SDK` in the subject. Include the affected version, reproduction steps, impact, and any suggested mitigation. Do not include real API keys, prompts, responses, or other user diff --git a/go.mod b/go.mod index c3c43c3..56c67cb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dappnode/dappnode-nexus-sdk -go 1.26.8 +go 1.26.4 require ( github.com/anchorageoss/awsnitroverifier v0.6.0 @@ -11,6 +11,6 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 576a9ea..4a28b11 100644 --- a/go.sum +++ b/go.sum @@ -14,9 +14,9 @@ github.com/tinfoilsh/encrypted-http-body-protocol v0.2.6 h1:Mznvqi4iqL8ZTSkj0lne github.com/tinfoilsh/encrypted-http-body-protocol v0.2.6/go.mod h1:THDK0GFNny7Pcc+nO3AQi4f6Wf1cDkfLatmHAUlIn5s= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nexus-gateway-policy.json b/nexus-gateway-policy.json new file mode 100644 index 0000000..f2a83cb --- /dev/null +++ b/nexus-gateway-policy.json @@ -0,0 +1,27 @@ +{ + "schema_version": 2, + "manifest_schema_version": 4, + "workload": "dappnode-nexus-gateway", + "profile": "nexus-gateway-v2", + "releases": [ + { + "source_revision": "7207defa97d8e5256ed7ff1ce925a4903752946d", + "pcr0": "a61dc828dffcba5cc6a6dc68b1a3af646b9d8db047d84c3348e52fa0e45cb01675cd01ad1931ed447620b5995e8d7f71", + "pcr1": "4b4d5b3661b3efc12920900c80e126e4ce783c522de6c02a2a5bf7af3a2b9327b86776f188e4be1c1c404a129dbda493", + "pcr2": "b676515e4460e61be1f860cdb05a5852571b2c73320eff1228b70184422d3d44612c6ee405ea3511623be4a2c600b9fa" + }, + { + "source_revision": "bda15a3549b7a9fbb37004281852079e9013f73b", + "pcr0": "f9dc7325b6bfc916a1c29cbd9ba8d802d9c8270dd33db60671834a47c40451658633b573aac718ed8896694611b3ac21", + "pcr1": "4b4d5b3661b3efc12920900c80e126e4ce783c522de6c02a2a5bf7af3a2b9327b86776f188e4be1c1c404a129dbda493", + "pcr2": "9f60b8d4aa783dd8ecb7329a1fd30f597090e76615f4b11c7097fbf85a025920a65623efad80d56c410334dbabdc343a" + } + ], + "e2ee": { + "protocol": "ehbp-v1", + "suite": "DHKEM-X25519-HKDF-SHA256/HKDF-SHA256/AES-256-GCM", + "endpoint": "/v1/confidential/chat/completions", + "request_encrypted": true, + "response_encrypted": true + } +} From d49c902f480ae0db34e2c0621b0ef13384e992f0 Mon Sep 17 00:00:00 2001 From: Marketen Date: Wed, 2 Sep 2026 17:02:05 +0200 Subject: [PATCH 6/6] feat: expose embeddable Nexus SDK --- CONTRIBUTING.md | 2 + README.md | 52 +++ client.go | 488 +++++++++++++++++++++++++++ client_test.go | 206 +++++++++++ cmd/nexus-proxy/main.go | 118 ++----- doc.go | 7 + examples/embed/main.go | 41 +++ internal/attestation/policy.go | 11 + internal/attestation/policy_test.go | 20 ++ internal/confidential/client.go | 10 + internal/confidential/client_test.go | 46 +++ internal/proxy/handler.go | 17 +- internal/proxy/verification.go | 2 +- internal/proxy/verification_test.go | 15 + live_test.go | 148 ++++++++ public_api_test.go | 22 ++ verification.go | 137 ++++++++ 17 files changed, 1240 insertions(+), 102 deletions(-) create mode 100644 client.go create mode 100644 client_test.go create mode 100644 doc.go create mode 100644 examples/embed/main.go create mode 100644 live_test.go create mode 100644 public_api_test.go create mode 100644 verification.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c5e4a3..f54109c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,8 @@ The compiled binary is written to `bin/nexus-proxy`. ## Project layout +- The root `nexus` package is the stable API for applications embedding the + SDK. - `cmd/nexus-proxy` contains the executable. - `internal/attestation` verifies the Nexus Gateway. - `internal/confidential` protects request and response bodies. diff --git a/README.md b/README.md index 47e8d28..d116de0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,58 @@ It shows whether the Gateway passed verification and which verified connection handled each recent request. The page contains verification evidence and request metadata, never prompts or responses. +## Embed the SDK in Go + +Go applications can use the same verification and encrypted transport without +starting a separate process or opening a local port. For example, with the +[official OpenAI Go client](https://github.com/openai/openai-go): + +```go +verifyCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + +sdk, err := nexus.New(verifyCtx, nexus.Config{ + GatewayURL: "https://nexus-api-tee.dappnode.com", + TrustPolicyFile: "./nexus-gateway-policy.json", +}) +cancel() +if err != nil { + log.Fatal(err) +} +defer sdk.Close() + +client := openai.NewClient( + option.WithAPIKey(os.Getenv("NEXUS_API_KEY")), + option.WithBaseURL(nexus.InProcessBaseURL), + option.WithHTTPClient(sdk.HTTPClient()), +) +completion, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("Hello"), + }, + Model: "MODEL_ID", +}) +if err != nil { + log.Fatal(err) +} +fmt.Println(completion.Choices[0].Message.Content) +``` + +`New` returns only after the Gateway passes verification. Developers can then: + +- Mount `Handler()` in an existing Go HTTP server. +- Give `HTTPClient()` and `nexus.InProcessBaseURL` to another Go SDK without + opening a local port. +- Call `ChatCompletions()` or `Models()` directly. +- Call `Verify()`, `Verification()`, and `Evidence()` to build their own + verification experience or independently inspect the signed evidence. +- Call `Close()` before exit when using a persistent `StateFile`. + +`TrustPolicyJSON` can be used with Go's `embed` package when the application +should carry its pinned policy inside the binary. + +See the complete [embedding example](examples/embed/main.go) and the +[package documentation](https://pkg.go.dev/github.com/dappnode/dappnode-nexus-sdk). + ## Using it on DAppNode DAppNode users can install **Nexus Local Proxy** instead of running the binary diff --git a/client.go b/client.go new file mode 100644 index 0000000..fc16541 --- /dev/null +++ b/client.go @@ -0,0 +1,488 @@ +package nexus + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/dappnode/dappnode-nexus-sdk/internal/attestation" + "github.com/dappnode/dappnode-nexus-sdk/internal/catalog" + "github.com/dappnode/dappnode-nexus-sdk/internal/confidential" + "github.com/dappnode/dappnode-nexus-sdk/internal/ledger" + "github.com/dappnode/dappnode-nexus-sdk/internal/proxy" +) + +const ( + // InProcessBaseURL is the OpenAI-compatible base URL to use with an HTTP + // client returned by Client.HTTPClient. Requests never leave the process at + // this URL; the custom transport sends them directly to Client.Handler. + InProcessBaseURL = "http://nexus.local/v1" + + ChatCompletionsPath = "/v1/chat/completions" + ModelsPath = "/v1/models" + HealthPath = "/healthz" + VerificationPath = "/verification" + + StatusStarting = "starting" + OutcomeVerified = "verified" + OutcomeRejected = "rejected" + OutcomeEncrypted = "encrypted" + OutcomeFailed = "failed" + + defaultAttestationTimeout = 15 * time.Second +) + +// ErrEvidenceNotFound is returned when verification evidence is no longer in +// the bounded local history or the supplied identifier is unknown. +var ErrEvidenceNotFound = errors.New("verification evidence not found") + +// Config describes a Nexus SDK client. GatewayURL and exactly one of +// TrustPolicyFile or TrustPolicyJSON are required. The SDK constructs its own +// hardened network transports so callers cannot accidentally weaken redirect +// or encrypted-frame checks. +type Config struct { + GatewayURL string + TrustPolicyFile string + TrustPolicyJSON []byte + AttestationTimeout time.Duration + + // StateFile enables persistence of verification evidence and request + // metadata. Prompt and response content is never stored. Empty keeps history + // in memory only. Call Close or Flush to write pending changes. + StateFile string + + // These switches affect routes exposed by Handler and HTTPClient. Direct + // verification remains available through Verify and Verification. + DisableVerificationUI bool + DisableModelCatalog bool + + // Logger receives operational errors and never receives API keys, prompts, + // or responses. Nil discards library logs. + Logger *log.Logger +} + +// Client is an attestation-verified Nexus client. It is safe for concurrent +// use. New returns only after the Gateway has passed initial verification. +type Client struct { + gatewayURL string + timeout time.Duration + confidential *confidential.Client + handler http.Handler + ledger *ledger.Ledger +} + +// New constructs a Nexus client and verifies the Gateway before returning. +// No prompt can be sent through the returned client before this succeeds. +func New(ctx context.Context, config Config) (*Client, error) { + if ctx == nil { + return nil, errors.New("context is required") + } + gatewayURL, timeout, err := validateConfig(config) + if err != nil { + return nil, err + } + + var policy *attestation.Policy + if len(config.TrustPolicyJSON) > 0 { + policy, err = attestation.ParsePolicy(config.TrustPolicyJSON) + } else { + policy, err = attestation.LoadPolicy(config.TrustPolicyFile) + } + if err != nil { + return nil, fmt.Errorf("load trust policy: %w", err) + } + + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, errors.New("default HTTP transport is not configurable") + } + transport := defaultTransport.Clone() + transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + wireClient := &http.Client{ + Transport: confidential.GuardEHBPResponses(transport), + CheckRedirect: rejectRedirects, + } + plainClient := &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: rejectRedirects, + } + + verifier, err := attestation.NewVerifier( + gatewayURL+"/v1/attestation", + policy, + plainClient, + ) + if err != nil { + return nil, fmt.Errorf("configure attestation verifier: %w", err) + } + confidentialClient, err := confidential.NewClient( + gatewayURL+attestation.ConfidentialEndpoint, + verifier, + wireClient, + ) + if err != nil { + return nil, fmt.Errorf("configure confidential Gateway client: %w", err) + } + + verificationLedger := ledger.New() + if config.StateFile != "" { + verificationLedger, err = ledger.Open(config.StateFile) + if err != nil { + return nil, fmt.Errorf("open verification state file: %w", err) + } + } + confidentialClient = confidentialClient.WithLedger(verificationLedger) + + verifyContext, cancelVerify := context.WithTimeout(ctx, timeout) + err = confidentialClient.WarmUp(verifyContext) + cancelVerify() + if err != nil { + return nil, fmt.Errorf("initial Gateway attestation failed: %w", err) + } + + logger := config.Logger + if logger == nil { + logger = log.New(io.Discard, "", 0) + } + handler, err := proxy.NewHandler(confidentialClient, logger) + if err != nil { + return nil, fmt.Errorf("configure OpenAI-compatible handler: %w", err) + } + handler = handler.WithLedger(verificationLedger) + if !config.DisableVerificationUI { + handler = handler.WithVerification(verificationLedger, gatewayURL) + } + if !config.DisableModelCatalog { + catalogClient, err := catalog.NewClient(gatewayURL+catalog.ModelsEndpoint, plainClient) + if err != nil { + return nil, fmt.Errorf("configure model catalog client: %w", err) + } + handler = handler.WithModelCatalog(catalogClient) + } + + return &Client{ + gatewayURL: gatewayURL, + timeout: timeout, + confidential: confidentialClient, + handler: handler, + ledger: verificationLedger, + }, nil +} + +// GatewayURL returns the normalized HTTPS origin this client verifies. +func (c *Client) GatewayURL() string { + if c == nil { + return "" + } + return c.gatewayURL +} + +// Handler returns the OpenAI-compatible HTTP handler. It includes chat +// completions, health, model catalog, and verification routes according to the +// Config used by New. The application controls where and whether it listens. +func (c *Client) Handler() http.Handler { + if c == nil { + return nil + } + return c.handler +} + +// HTTPClient returns an in-process HTTP client backed by Handler. Use +// InProcessBaseURL as the base URL when passing this client to another Go SDK. +// No local TCP listener is created. +func (c *Client) HTTPClient() *http.Client { + return &http.Client{ + Transport: handlerTransport{handler: c.Handler()}, + CheckRedirect: rejectRedirects, + } +} + +// ChatCompletions sends one OpenAI-compatible chat completion through the +// verified encrypted channel. The response body may be a normal JSON response +// or an event stream and must be closed by the caller. +func (c *Client) ChatCompletions(ctx context.Context, apiKey string, body []byte) (*http.Response, error) { + if c == nil || c.handler == nil { + return nil, errors.New("Nexus client is nil") + } + if ctx == nil { + return nil, errors.New("context is required") + } + if strings.TrimSpace(apiKey) == "" { + return nil, errors.New("Nexus API key is required") + } + if len(bytes.TrimSpace(body)) == 0 || !json.Valid(body) { + return nil, errors.New("chat completion body must be valid JSON") + } + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + InProcessBaseURL+"/chat/completions", + bytes.NewReader(body), + ) + if err != nil { + return nil, fmt.Errorf("create chat completion request: %w", err) + } + request.Header.Set("Authorization", "Bearer "+apiKey) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + return c.HTTPClient().Do(request) +} + +// Models retrieves the Gateway's public OpenAI-compatible model catalog. The +// response body must be closed by the caller. +func (c *Client) Models(ctx context.Context) (*http.Response, error) { + if c == nil || c.handler == nil { + return nil, errors.New("Nexus client is nil") + } + if ctx == nil { + return nil, errors.New("context is required") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, InProcessBaseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("create model catalog request: %w", err) + } + request.Header.Set("Accept", "application/json") + return c.HTTPClient().Do(request) +} + +// Verify discards the cached verified session, obtains fresh attestation +// evidence, and returns the successful verification record. Concurrent +// requests remain safe and will use a verified session. +func (c *Client) Verify(ctx context.Context) (*Attestation, error) { + if c == nil || c.confidential == nil { + return nil, errors.New("Nexus client is nil") + } + if ctx == nil { + return nil, errors.New("context is required") + } + verifyContext, cancelVerify := context.WithTimeout(ctx, c.timeout) + err := c.confidential.Reverify(verifyContext) + cancelVerify() + if err != nil { + return nil, fmt.Errorf("verify Gateway: %w", err) + } + snapshot := c.Verification() + if snapshot.Current == nil { + return nil, errors.New("verification succeeded without a current record") + } + return snapshot.Current, nil +} + +// Verification returns a copy of the current verification and request +// history. It never contains prompts, responses, or API keys. +func (c *Client) Verification() Snapshot { + if c == nil || c.ledger == nil { + return Snapshot{Status: StatusStarting, GeneratedAt: time.Now().UTC()} + } + return snapshotFromLedger(c.ledger.Snapshot()) +} + +// Evidence returns the signed attestation document and manifest for a +// verification record. The returned byte slices are independent copies. +func (c *Client) Evidence(id string) (*Evidence, error) { + if c == nil || c.ledger == nil { + return nil, errors.New("Nexus client is nil") + } + document, manifest, ok := c.ledger.Document(id) + if !ok { + return nil, ErrEvidenceNotFound + } + return &Evidence{ + AttestationID: id, + Document: append([]byte(nil), document...), + Manifest: append(json.RawMessage(nil), manifest...), + }, nil +} + +// Flush persists verification history when Config.StateFile is set. It is a +// no-op for an in-memory client. +func (c *Client) Flush() error { + if c == nil || c.ledger == nil { + return nil + } + return c.ledger.Flush() +} + +// Close flushes persistent verification history. The Client owns no listener; +// an application embedding Handler remains responsible for its HTTP server. +func (c *Client) Close() error { + return c.Flush() +} + +func validateConfig(config Config) (string, time.Duration, error) { + gatewayURL, err := normalizeGatewayURL(config.GatewayURL) + if err != nil { + return "", 0, err + } + hasPolicyFile := strings.TrimSpace(config.TrustPolicyFile) != "" + hasPolicyJSON := len(config.TrustPolicyJSON) > 0 + if hasPolicyFile == hasPolicyJSON { + return "", 0, errors.New("exactly one of trust policy file or JSON is required") + } + timeout := config.AttestationTimeout + if timeout == 0 { + timeout = defaultAttestationTimeout + } + if timeout < 0 { + return "", 0, errors.New("attestation timeout must be positive") + } + return gatewayURL, timeout, nil +} + +func normalizeGatewayURL(raw string) (string, error) { + if raw == "" { + return "", errors.New("Gateway URL is required") + } + parsed, err := url.ParseRequestURI(raw) + if err != nil { + return "", fmt.Errorf("invalid Gateway URL: %w", err) + } + if parsed.Scheme != "https" || parsed.Host == "" || parsed.Opaque != "" { + return "", errors.New("Gateway URL must be an absolute HTTPS origin") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.RawPath != "" { + return "", errors.New("Gateway URL must not contain credentials, a query, or a fragment") + } + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("Gateway URL must be an origin without a path") + } + return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), nil +} + +func rejectRedirects(_ *http.Request, _ []*http.Request) error { + return errors.New("redirects are not allowed") +} + +type handlerTransport struct { + handler http.Handler +} + +func (transport handlerTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if transport.handler == nil { + return nil, errors.New("Nexus handler is nil") + } + if request == nil || request.URL == nil { + return nil, errors.New("HTTP request and URL are required") + } + if request.URL.Scheme != "http" || request.URL.Host != "nexus.local" { + return nil, fmt.Errorf("in-process Nexus requests must use %s", InProcessBaseURL) + } + + reader, writer := io.Pipe() + responseWriter := newInProcessResponseWriter(writer) + go func() { + if request.Body != nil { + defer request.Body.Close() + } + defer func() { + if recovered := recover(); recovered != nil { + if recovered == http.ErrAbortHandler { + responseWriter.finish(io.ErrUnexpectedEOF) + return + } + responseWriter.finish(fmt.Errorf("embedded Nexus handler panic: %v", recovered)) + return + } + responseWriter.finish(nil) + }() + transport.handler.ServeHTTP(responseWriter, request) + }() + + select { + case <-responseWriter.ready: + if err := request.Context().Err(); err != nil { + reader.Close() + return nil, err + } + if responseWriter.terminalErr != nil { + reader.Close() + return nil, responseWriter.terminalErr + } + if responseWriter.status == 0 { + reader.Close() + return nil, errors.New("embedded Nexus handler returned no status") + } + return &http.Response{ + Status: fmt.Sprintf("%d %s", responseWriter.status, http.StatusText(responseWriter.status)), + StatusCode: responseWriter.status, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: responseWriter.responseHeader.Clone(), + Body: reader, + ContentLength: -1, + Request: request, + }, nil + case <-request.Context().Done(): + responseWriter.finish(request.Context().Err()) + reader.Close() + return nil, request.Context().Err() + } +} + +type inProcessResponseWriter struct { + header http.Header + responseHeader http.Header + status int + terminalErr error + pipe *io.PipeWriter + ready chan struct{} + readyOnce sync.Once + finishOnce sync.Once +} + +func newInProcessResponseWriter(pipe *io.PipeWriter) *inProcessResponseWriter { + return &inProcessResponseWriter{ + header: make(http.Header), + pipe: pipe, + ready: make(chan struct{}), + } +} + +func (writer *inProcessResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *inProcessResponseWriter) WriteHeader(status int) { + writer.readyOnce.Do(func() { + writer.status = status + writer.responseHeader = writer.header.Clone() + close(writer.ready) + }) +} + +func (writer *inProcessResponseWriter) Write(data []byte) (int, error) { + writer.WriteHeader(http.StatusOK) + return writer.pipe.Write(data) +} + +func (writer *inProcessResponseWriter) Flush() { + writer.WriteHeader(http.StatusOK) +} + +func (writer *inProcessResponseWriter) finish(err error) { + writer.finishOnce.Do(func() { + if err != nil { + writer.readyOnce.Do(func() { + writer.terminalErr = err + close(writer.ready) + }) + _ = writer.pipe.CloseWithError(err) + return + } + writer.WriteHeader(http.StatusOK) + _ = writer.pipe.Close() + }) +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..f381689 --- /dev/null +++ b/client_test.go @@ -0,0 +1,206 @@ +package nexus + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/dappnode/dappnode-nexus-sdk/internal/ledger" +) + +func TestValidateConfig(t *testing.T) { + gatewayURL, timeout, err := validateConfig(Config{ + GatewayURL: "https://gateway.example/", + TrustPolicyFile: "/tmp/policy.json", + }) + if err != nil { + t.Fatal(err) + } + if gatewayURL != "https://gateway.example" { + t.Fatalf("Gateway URL = %q", gatewayURL) + } + if timeout != defaultAttestationTimeout { + t.Fatalf("timeout = %v", timeout) + } + + tests := []Config{ + {TrustPolicyFile: "/tmp/policy.json"}, + {GatewayURL: "http://gateway.example", TrustPolicyFile: "/tmp/policy.json"}, + {GatewayURL: "https://gateway.example/path", TrustPolicyFile: "/tmp/policy.json"}, + {GatewayURL: "https://gateway.example"}, + {GatewayURL: "https://gateway.example", TrustPolicyFile: "/tmp/policy.json", TrustPolicyJSON: []byte(`{}`)}, + {GatewayURL: "https://gateway.example", TrustPolicyFile: "/tmp/policy.json", AttestationTimeout: -time.Second}, + } + for _, config := range tests { + if _, _, err := validateConfig(config); err == nil { + t.Fatalf("validateConfig(%+v) succeeded", config) + } + } +} + +func TestHTTPClientUsesHandlerWithoutNetwork(t *testing.T) { + sdk := &Client{handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.URL.Path != ModelsPath { + t.Errorf("path = %q", request.URL.Path) + } + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(response, `{"object":"list"}`) + })} + + response, err := sdk.HTTPClient().Get(InProcessBaseURL + "/models") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusAccepted || string(body) != `{"object":"list"}` { + t.Fatalf("response = %d %q", response.StatusCode, body) + } +} + +func TestHTTPClientRejectsOtherOrigins(t *testing.T) { + sdk := &Client{handler: http.NotFoundHandler()} + _, err := sdk.HTTPClient().Get("https://example.com/v1/models") + if err == nil || !strings.Contains(err.Error(), InProcessBaseURL) { + t.Fatalf("HTTPClient error = %v", err) + } +} + +func TestHTTPClientReturnsHandlerPanic(t *testing.T) { + sdk := &Client{handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("boom") + })} + _, err := sdk.HTTPClient().Get(InProcessBaseURL + "/models") + if err == nil || !strings.Contains(err.Error(), "embedded Nexus handler panic") { + t.Fatalf("HTTPClient error = %v", err) + } +} + +func TestChatCompletionsBuildsOpenAIRequest(t *testing.T) { + type observation struct { + method string + path string + authorization string + contentType string + body []byte + } + observed := make(chan observation, 1) + sdk := &Client{handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + observed <- observation{ + method: request.Method, + path: request.URL.Path, + authorization: request.Header.Get("Authorization"), + contentType: request.Header.Get("Content-Type"), + body: body, + } + response.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(response, `{"choices":[]}`) + })} + + payload := []byte(`{"model":"test","messages":[]}`) + response, err := sdk.ChatCompletions(context.Background(), "secret", payload) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if _, err := io.Copy(io.Discard, response.Body); err != nil { + t.Fatal(err) + } + got := <-observed + if got.method != http.MethodPost || got.path != ChatCompletionsPath || + got.authorization != "Bearer secret" || got.contentType != "application/json" || + !bytes.Equal(got.body, payload) { + t.Fatalf("request = %+v", got) + } +} + +func TestHTTPClientStreamsHandlerResponse(t *testing.T) { + sdk := &Client{handler: http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/event-stream") + response.WriteHeader(http.StatusOK) + _, _ = io.WriteString(response, "data: first\n\n") + response.(http.Flusher).Flush() + _, _ = io.WriteString(response, "data: [DONE]\n\n") + })} + + response, err := sdk.HTTPClient().Get(InProcessBaseURL + "/stream") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if string(body) != "data: first\n\ndata: [DONE]\n\n" { + t.Fatalf("body = %q", body) + } +} + +func TestVerificationAndEvidenceArePublicCopies(t *testing.T) { + record := ledger.Attestation{ + ID: "attestation-id", + VerifiedAt: time.Now().UTC(), + Checks: []ledger.Check{{Name: "signature", Passed: true, Detail: "valid"}}, + } + document := []byte("signed-document") + manifest := json.RawMessage(`{"schema_version":4}`) + history := ledger.New() + history.RecordVerified(record, document, manifest) + sdk := &Client{ledger: history} + + snapshot := sdk.Verification() + if snapshot.Current == nil || snapshot.Current.ID != record.ID || len(snapshot.Current.Checks) != 1 { + t.Fatalf("snapshot = %+v", snapshot) + } + evidence, err := sdk.Evidence(record.ID) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(evidence.Document, document) || !bytes.Equal(evidence.Manifest, manifest) { + t.Fatalf("evidence = %+v", evidence) + } + evidence.Document[0] = 'X' + again, err := sdk.Evidence(record.ID) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(evidence.Document, again.Document) { + t.Fatal("Evidence returned shared document storage") + } + if _, err := sdk.Evidence("missing"); !errors.Is(err, ErrEvidenceNotFound) { + t.Fatalf("missing evidence error = %v", err) + } +} + +func TestChatCompletionsRejectsInvalidInputs(t *testing.T) { + sdk := &Client{handler: http.NotFoundHandler()} + for name, test := range map[string]struct { + key string + body []byte + }{ + "missing key": {body: []byte(`{}`)}, + "invalid JSON": {key: "secret-value", body: []byte(`{`)}, + } { + t.Run(name, func(t *testing.T) { + _, err := sdk.ChatCompletions(context.Background(), test.key, test.body) + if err == nil { + t.Fatal("ChatCompletions succeeded with invalid input") + } + if strings.Contains(err.Error(), "secret-value") { + t.Fatalf("error = %v", err) + } + }) + } +} diff --git a/cmd/nexus-proxy/main.go b/cmd/nexus-proxy/main.go index 0e0cb89..7ed129e 100644 --- a/cmd/nexus-proxy/main.go +++ b/cmd/nexus-proxy/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "crypto/tls" "errors" "flag" "fmt" @@ -18,11 +17,7 @@ import ( "syscall" "time" - "github.com/dappnode/dappnode-nexus-sdk/internal/attestation" - "github.com/dappnode/dappnode-nexus-sdk/internal/catalog" - "github.com/dappnode/dappnode-nexus-sdk/internal/confidential" - "github.com/dappnode/dappnode-nexus-sdk/internal/ledger" - "github.com/dappnode/dappnode-nexus-sdk/internal/proxy" + nexus "github.com/dappnode/dappnode-nexus-sdk" ) const ( @@ -60,97 +55,26 @@ func run(args []string, stdout, stderr io.Writer) int { } logger := log.New(stderr, "nexus-proxy: ", log.LstdFlags|log.LUTC) - policy, err := attestation.LoadPolicy(configuration.trustPolicyPath) - if err != nil { - logger.Printf("load trust policy: %v", err) - return 1 - } - - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} - wireClient := &http.Client{ - Transport: confidential.GuardEHBPResponses(transport), - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return errors.New("redirects are not allowed") - }, - } - attestationClient := &http.Client{ - Transport: transport, - Timeout: configuration.attestationTimeout, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return errors.New("redirects are not allowed") - }, - } - - verifier, err := attestation.NewVerifier( - configuration.gatewayOrigin+"/v1/attestation", - policy, - attestationClient, - ) - if err != nil { - logger.Printf("configure attestation verifier: %v", err) - return 1 - } - confidentialClient, err := confidential.NewClient( - configuration.gatewayOrigin+attestation.ConfidentialEndpoint, - verifier, - wireClient, - ) - if err != nil { - logger.Printf("configure confidential Gateway client: %v", err) - return 1 - } - var verificationLedger *ledger.Ledger - if configuration.verificationUI { - if configuration.stateFile != "" { - verificationLedger, err = ledger.Open(configuration.stateFile) - if err != nil { - logger.Printf("open verification state file: %v", err) - return 1 - } - } else { - verificationLedger = ledger.New() - } - confidentialClient = confidentialClient.WithLedger(verificationLedger) - } - warmupContext, cancelWarmup := context.WithTimeout(context.Background(), configuration.attestationTimeout) - err = confidentialClient.WarmUp(warmupContext) + sdk, err := nexus.New(warmupContext, nexus.Config{ + GatewayURL: configuration.gatewayOrigin, + TrustPolicyFile: configuration.trustPolicyPath, + AttestationTimeout: configuration.attestationTimeout, + StateFile: configuration.stateFile, + DisableVerificationUI: !configuration.verificationUI, + DisableModelCatalog: !configuration.modelCatalog, + Logger: logger, + }) cancelWarmup() if err != nil { - logger.Printf("initial Gateway attestation failed: %v", err) - return 1 - } - - handler, err := proxy.NewHandler(confidentialClient, logger) - if err != nil { - logger.Printf("configure local proxy: %v", err) + logger.Printf("initialize Nexus SDK: %v", err) return 1 } - if verificationLedger != nil { - handler = handler.WithVerification(verificationLedger, configuration.gatewayOrigin) - } - if configuration.modelCatalog { - // The catalog is public, unauthenticated data, so it uses the plain - // transport rather than the EHBP-guarded one: GuardEHBPResponses - // would reject an ordinary response, and there is nothing here to - // encrypt. - catalogClient, err := catalog.NewClient( - configuration.gatewayOrigin+catalog.ModelsEndpoint, - &http.Client{ - Transport: transport, - Timeout: configuration.attestationTimeout, - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return errors.New("redirects are not allowed") - }, - }, - ) - if err != nil { - logger.Printf("configure model catalog client: %v", err) - return 1 + defer func() { + if err := sdk.Close(); err != nil { + logger.Printf("persist verification history on exit: %v", err) } - handler = handler.WithModelCatalog(catalogClient) - } + }() if configuration.listenScope == listenScopeDAppNode { logger.Printf("DAppNode network listener enabled on %s; do not publish this port outside the trusted DAppNode environment", configuration.listenAddress) } @@ -162,7 +86,7 @@ func run(args []string, stdout, stderr io.Writer) int { defer listener.Close() server := &http.Server{ - Handler: handler, + Handler: sdk.Handler(), ErrorLog: logger, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, @@ -174,11 +98,11 @@ func run(args []string, stdout, stderr io.Writer) int { serverErrors <- server.Serve(listener) }() logger.Printf("verified Gateway and listening on http://%s", listener.Addr()) - if verificationLedger != nil { - logger.Printf("privacy verification UI at http://%s%s", listener.Addr(), proxy.LocalVerificationUI) + if configuration.verificationUI { + logger.Printf("privacy verification UI at http://%s%s", listener.Addr(), nexus.VerificationPath) } if configuration.modelCatalog { - logger.Printf("public model catalog at http://%s%s; it is served over ordinary TLS, not the attested channel", listener.Addr(), proxy.LocalModelsEndpoint) + logger.Printf("public model catalog at http://%s%s; it is served over ordinary TLS, not the attested channel", listener.Addr(), nexus.ModelsPath) } // Verification history is written behind the request path, never on it: a @@ -193,7 +117,7 @@ func run(args []string, stdout, stderr io.Writer) int { for { select { case <-ticker.C: - if err := verificationLedger.Flush(); err != nil { + if err := sdk.Flush(); err != nil { logger.Printf("persist verification history: %v", err) } case <-stopFlushing: @@ -219,7 +143,7 @@ func run(args []string, stdout, stderr io.Writer) int { } close(stopFlushing) - if err := verificationLedger.Flush(); err != nil { + if err := sdk.Flush(); err != nil { logger.Printf("persist verification history on shutdown: %v", err) } diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b12ca58 --- /dev/null +++ b/doc.go @@ -0,0 +1,7 @@ +// Package nexus provides attestation-verified, OpenAI-compatible access to the +// DAppNode Nexus Gateway. +// +// New verifies the Gateway before it returns a Client. Applications can mount +// Client.Handler in an existing HTTP server, give Client.HTTPClient to another +// Go SDK, or call Client.ChatCompletions directly. +package nexus diff --git a/examples/embed/main.go b/examples/embed/main.go new file mode 100644 index 0000000..d1eae39 --- /dev/null +++ b/examples/embed/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "flag" + "log" + "net/http" + "time" + + nexus "github.com/dappnode/dappnode-nexus-sdk" +) + +func main() { + gatewayURL := flag.String("gateway-url", "https://nexus-api-tee.dappnode.com", "Nexus Gateway HTTPS origin") + policyFile := flag.String("trust-policy", "nexus-gateway-policy.json", "DAppNode-published trust policy") + listen := flag.String("listen", "127.0.0.1:3301", "local listen address") + flag.Parse() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + sdk, err := nexus.New(ctx, nexus.Config{ + GatewayURL: *gatewayURL, + TrustPolicyFile: *policyFile, + Logger: log.Default(), + }) + cancel() + if err != nil { + log.Fatal(err) + } + defer sdk.Close() + + server := &http.Server{ + Addr: *listen, + Handler: sdk.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, + MaxHeaderBytes: 64 << 10, + } + log.Printf("verified Gateway and listening on http://%s", *listen) + log.Fatal(server.ListenAndServe()) +} diff --git a/internal/attestation/policy.go b/internal/attestation/policy.go index 332dcf5..fe40be5 100644 --- a/internal/attestation/policy.go +++ b/internal/attestation/policy.go @@ -82,6 +82,17 @@ func LoadPolicy(path string) (*Policy, error) { if err != nil { return nil, fmt.Errorf("read trust policy: %w", err) } + return ParsePolicy(data) +} + +// ParsePolicy strictly parses and validates a pinned trust policy from JSON. +func ParsePolicy(data []byte) (*Policy, error) { + if len(data) == 0 { + return nil, errors.New("trust policy JSON is required") + } + if len(data) > maxPolicyBytes { + return nil, fmt.Errorf("trust policy exceeds %d bytes", maxPolicyBytes) + } var policy Policy if err := jsonutil.DecodeStrict(data, &policy); err != nil { return nil, fmt.Errorf("decode trust policy: %w", err) diff --git a/internal/attestation/policy_test.go b/internal/attestation/policy_test.go index 5f21609..543f0cf 100644 --- a/internal/attestation/policy_test.go +++ b/internal/attestation/policy_test.go @@ -57,6 +57,26 @@ func TestLoadPolicy(t *testing.T) { } } +func TestParsePolicy(t *testing.T) { + encoded, err := json.Marshal(validPolicy()) + if err != nil { + t.Fatal(err) + } + parsed, err := ParsePolicy(encoded) + if err != nil { + t.Fatal(err) + } + if len(parsed.Releases) != 1 || len(parsed.Releases[0].expectedPCRs()) != 3 { + t.Fatalf("parsed policy = %+v", parsed) + } + if _, err := ParsePolicy(nil); err == nil { + t.Fatal("ParsePolicy(nil) succeeded") + } + if _, err := ParsePolicy(make([]byte, maxPolicyBytes+1)); err == nil { + t.Fatal("ParsePolicy accepted an oversized policy") + } +} + func TestPolicyRejectsUnsafeOrAmbiguousValues(t *testing.T) { tests := []struct { name string diff --git a/internal/confidential/client.go b/internal/confidential/client.go index 255e1ea..e1eceb4 100644 --- a/internal/confidential/client.go +++ b/internal/confidential/client.go @@ -92,6 +92,16 @@ func (c *Client) WarmUp(ctx context.Context) error { return err } +// Reverify discards the cached session and requires fresh attestation evidence +// before another session is used. Requests already holding the previous +// verified session can finish safely. +func (c *Client) Reverify(ctx context.Context) error { + c.mu.Lock() + c.session = nil + c.mu.Unlock() + return c.WarmUp(ctx) +} + // Do sends one JSON envelope. The Authorization header remains outside EHBP, // matching the existing Gateway API. No request is automatically retried. The // returned identifier names the attested key the body was encrypted to. diff --git a/internal/confidential/client_test.go b/internal/confidential/client_test.go index da43bc2..0b21af7 100644 --- a/internal/confidential/client_test.go +++ b/internal/confidential/client_test.go @@ -23,6 +23,16 @@ func (v *fixedEvidenceVerifier) Verify(context.Context) (*attestation.Evidence, return &attestation.Evidence{PublicKey: v.key, ExpiresAt: time.Now().Add(time.Minute)}, nil } +type countingEvidenceVerifier struct { + key []byte + calls int +} + +func (v *countingEvidenceVerifier) Verify(context.Context) (*attestation.Evidence, error) { + v.calls++ + return &attestation.Evidence{PublicKey: v.key, ExpiresAt: time.Now().Add(time.Minute)}, nil +} + type inspectingRoundTripper struct { t *testing.T calls int @@ -122,3 +132,39 @@ func TestNewClientRequiresRedirectPolicy(t *testing.T) { t.Fatalf("NewClient() error = %v, want redirect policy error", err) } } + +func TestReverifyReplacesCachedSession(t *testing.T) { + serverIdentity, err := identity.NewIdentity() + if err != nil { + t.Fatal(err) + } + verifier := &countingEvidenceVerifier{key: serverIdentity.MarshalPublicKey()} + client, err := NewClient( + "https://gateway.example/v1/confidential/chat/completions", + verifier, + &http.Client{ + Transport: http.DefaultTransport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("redirects are not allowed") + }, + }, + ) + if err != nil { + t.Fatal(err) + } + if err := client.WarmUp(context.Background()); err != nil { + t.Fatal(err) + } + if err := client.WarmUp(context.Background()); err != nil { + t.Fatal(err) + } + if verifier.calls != 1 { + t.Fatalf("cached verifier calls = %d, want 1", verifier.calls) + } + if err := client.Reverify(context.Background()); err != nil { + t.Fatal(err) + } + if verifier.calls != 2 { + t.Fatalf("reverify calls = %d, want 2", verifier.calls) + } +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index 8d2af06..bccd965 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -69,9 +69,10 @@ type Handler struct { now func() time.Time newID func() (string, error) - ledger *ledger.Ledger - gatewayOrigin string - catalog catalogFetcher + ledger *ledger.Ledger + gatewayOrigin string + verificationUI bool + catalog catalogFetcher } func NewHandler(upstream sender, logger *log.Logger) (*Handler, error) { @@ -94,8 +95,16 @@ func NewHandler(upstream sender, logger *log.Logger) (*Handler, error) { // stored. gatewayOrigin is shown in the UI so an operator can see which // Gateway the evidence belongs to. func (h *Handler) WithVerification(record *ledger.Ledger, gatewayOrigin string) *Handler { - h.ledger = record + h.WithLedger(record) h.gatewayOrigin = gatewayOrigin + h.verificationUI = true + return h +} + +// WithLedger records request metadata without exposing the verification HTTP +// routes. WithVerification enables both recording and those routes. +func (h *Handler) WithLedger(record *ledger.Ledger) *Handler { + h.ledger = record return h } diff --git a/internal/proxy/verification.go b/internal/proxy/verification.go index a10bba6..660191b 100644 --- a/internal/proxy/verification.go +++ b/internal/proxy/verification.go @@ -68,7 +68,7 @@ func (h *Handler) serveVerificationDocument(w http.ResponseWriter, r *http.Reque } func (h *Handler) allowVerification(w http.ResponseWriter, r *http.Request) bool { - if h.ledger == nil { + if !h.verificationUI || h.ledger == nil { http.NotFound(w, r) return false } diff --git a/internal/proxy/verification_test.go b/internal/proxy/verification_test.go index 219d11a..6468252 100644 --- a/internal/proxy/verification_test.go +++ b/internal/proxy/verification_test.go @@ -228,6 +228,21 @@ func TestVerificationSurfaceAbsentWithoutLedger(t *testing.T) { } } +func TestVerificationSurfaceAbsentWithPrivateLedger(t *testing.T) { + handler, err := NewHandler(&healthTestSender{}, nil) + if err != nil { + t.Fatal(err) + } + local := httptest.NewServer(handler.WithLedger(ledger.New())) + t.Cleanup(local.Close) + + for _, path := range []string{LocalVerificationUI, LocalVerificationAPI, LocalVerificationDocument} { + if status, _ := fetchBody(t, local.URL+path); status != http.StatusNotFound { + t.Fatalf("%s status = %d, want 404", path, status) + } + } +} + func TestRejectedAttestationIsRecordedAndBlocksRequests(t *testing.T) { record := ledger.New() record.RecordRejected("attestation PCR0 does not match the pinned measurement") diff --git a/live_test.go b/live_test.go new file mode 100644 index 0000000..cda2ae3 --- /dev/null +++ b/live_test.go @@ -0,0 +1,148 @@ +package nexus_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "testing" + "time" + + nexus "github.com/dappnode/dappnode-nexus-sdk" +) + +func TestLiveAttestation(t *testing.T) { + if os.Getenv("NEXUS_LIVE_TEST") != "1" { + t.Skip("set NEXUS_LIVE_TEST=1 to run against the live Gateway") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + sdk := newLiveClient(t, ctx) + + verification := sdk.Verification() + if verification.Current == nil || verification.Current.Outcome != nexus.OutcomeVerified { + t.Fatalf("initial verification status = %q", verification.Status) + } + evidence, err := sdk.Evidence(verification.Current.ID) + if err != nil { + t.Fatal(err) + } + if len(evidence.Document) == 0 || !json.Valid(evidence.Manifest) { + t.Fatal("live verification did not retain valid signed evidence") + } + refreshed, err := sdk.Verify(ctx) + if err != nil { + t.Fatal(err) + } + if refreshed.Outcome != nexus.OutcomeVerified || sdk.Verification().VerifiedTotal < 2 { + t.Fatal("explicit re-verification did not record fresh evidence") + } +} + +func TestLiveGatewayCompletion(t *testing.T) { + if os.Getenv("NEXUS_LIVE_TEST") != "1" { + t.Skip("set NEXUS_LIVE_TEST=1 to run against the live Gateway") + } + apiKey := os.Getenv("NEXUS_API_KEY") + if apiKey == "" { + t.Fatal("NEXUS_API_KEY is required for the live completion test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + sdk := newLiveClient(t, ctx) + + modelsResponse, err := sdk.Models(ctx) + if err != nil { + t.Fatal(err) + } + defer modelsResponse.Body.Close() + modelsBody, err := io.ReadAll(io.LimitReader(modelsResponse.Body, 4<<20)) + if err != nil { + t.Fatal(err) + } + model := os.Getenv("NEXUS_MODEL") + if modelsResponse.StatusCode == http.StatusOK { + var catalog struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(modelsBody, &catalog); err != nil || len(catalog.Data) == 0 || catalog.Data[0].ID == "" { + t.Fatalf("model catalog was not a non-empty OpenAI model list: %v", err) + } + if model == "" { + model = catalog.Data[0].ID + } + } else { + if model == "" { + model = "nexus/auto" + } + t.Logf("model catalog returned HTTP %d; trying %q", modelsResponse.StatusCode, model) + } + + payload, err := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]string{{ + "role": "user", + "content": "Reply only with OK.", + }}, + "max_tokens": 8, + }) + if err != nil { + t.Fatal(err) + } + completionResponse, err := sdk.ChatCompletions(ctx, apiKey, payload) + if err != nil { + t.Fatal(err) + } + defer completionResponse.Body.Close() + completionBody, err := io.ReadAll(io.LimitReader(completionResponse.Body, 4<<20)) + if err != nil { + t.Fatal(err) + } + if completionResponse.StatusCode != http.StatusOK { + var gatewayError struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + _ = json.Unmarshal(completionBody, &gatewayError) + t.Fatalf("chat completion returned HTTP %d (%s: %s)", completionResponse.StatusCode, gatewayError.Error.Type, gatewayError.Error.Message) + } + var completion struct { + Choices []json.RawMessage `json:"choices"` + } + if err := json.Unmarshal(completionBody, &completion); err != nil || len(completion.Choices) == 0 { + t.Fatalf("chat completion was not a valid OpenAI response: %v", err) + } +} + +func newLiveClient(t *testing.T, ctx context.Context) *nexus.Client { + t.Helper() + policyFile := os.Getenv("NEXUS_POLICY_FILE") + if policyFile == "" { + policyFile = "nexus-gateway-policy.json" + } + gatewayURL := os.Getenv("NEXUS_GATEWAY_URL") + if gatewayURL == "" { + gatewayURL = "https://nexus-api-tee.dappnode.com" + } + + sdk, err := nexus.New(ctx, nexus.Config{ + GatewayURL: gatewayURL, + TrustPolicyFile: policyFile, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sdk.Close(); err != nil { + t.Errorf("close SDK: %v", err) + } + }) + return sdk +} diff --git a/public_api_test.go b/public_api_test.go new file mode 100644 index 0000000..8860432 --- /dev/null +++ b/public_api_test.go @@ -0,0 +1,22 @@ +package nexus_test + +import ( + "context" + "net/http" + + nexus "github.com/dappnode/dappnode-nexus-sdk" +) + +// These assignments compile from an external package and guard the embeddable +// API from accidentally depending on an internal Go package. +var ( + _ func(context.Context, nexus.Config) (*nexus.Client, error) = nexus.New + _ func(*nexus.Client) http.Handler = (*nexus.Client).Handler + _ func(*nexus.Client) *http.Client = (*nexus.Client).HTTPClient + _ func(*nexus.Client, context.Context, string, []byte) (*http.Response, error) = (*nexus.Client).ChatCompletions + _ func(*nexus.Client, context.Context) (*http.Response, error) = (*nexus.Client).Models + _ func(*nexus.Client, context.Context) (*nexus.Attestation, error) = (*nexus.Client).Verify + _ func(*nexus.Client) nexus.Snapshot = (*nexus.Client).Verification + _ func(*nexus.Client, string) (*nexus.Evidence, error) = (*nexus.Client).Evidence + _ func(*nexus.Client) error = (*nexus.Client).Close +) diff --git a/verification.go b/verification.go new file mode 100644 index 0000000..82718de --- /dev/null +++ b/verification.go @@ -0,0 +1,137 @@ +package nexus + +import ( + "encoding/json" + "time" + + "github.com/dappnode/dappnode-nexus-sdk/internal/ledger" +) + +// Check is one condition that passed or failed during Gateway verification. +type Check struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Detail string `json:"detail"` +} + +// Attestation is one verification attempt against the pinned trust policy. +// It contains no secret values. +type Attestation struct { + ID string `json:"id"` + Outcome string `json:"outcome"` + VerifiedAt time.Time `json:"verified_at"` + Failure string `json:"failure,omitempty"` + + AttestedAt time.Time `json:"attested_at,omitzero"` + ExpiresAt time.Time `json:"expires_at,omitzero"` + ModuleID string `json:"module_id,omitempty"` + HPKEPublicKey string `json:"hpke_public_key,omitempty"` + Nonce string `json:"nonce,omitempty"` + PCR0 string `json:"pcr0,omitempty"` + PCR1 string `json:"pcr1,omitempty"` + PCR2 string `json:"pcr2,omitempty"` + SourceRevision string `json:"source_revision,omitempty"` + Workload string `json:"workload,omitempty"` + Profile string `json:"profile,omitempty"` + RootFingerprint string `json:"root_fingerprint,omitempty"` + Checks []Check `json:"checks,omitempty"` + DocumentBytes int `json:"document_bytes,omitempty"` +} + +// Request is metadata for one inference request. It never contains an API key, +// prompt, or response. +type Request struct { + ID string `json:"id"` + StartedAt time.Time `json:"started_at"` + DurationMS int64 `json:"duration_ms"` + AttestationID string `json:"attestation_id,omitempty"` + Outcome string `json:"outcome"` + Failure string `json:"failure,omitempty"` + StatusCode int `json:"status_code,omitempty"` + RequestBytes int `json:"request_bytes"` + ResponseBytes int64 `json:"response_bytes"` + Streaming bool `json:"streaming"` +} + +// Snapshot is the current verification state and bounded local history. +type Snapshot struct { + Status string `json:"status"` + GeneratedAt time.Time `json:"generated_at"` + VerifiedTotal uint64 `json:"verified_total"` + RejectedTotal uint64 `json:"rejected_total"` + EncryptedTotal uint64 `json:"encrypted_total"` + FailedTotal uint64 `json:"failed_total"` + Current *Attestation `json:"current,omitempty"` + Attestations []Attestation `json:"attestations"` + Requests []Request `json:"requests"` +} + +// Evidence contains the signed material needed to re-check one verification +// independently. +type Evidence struct { + AttestationID string `json:"attestation_id"` + Document []byte `json:"document"` + Manifest json.RawMessage `json:"manifest"` +} + +func snapshotFromLedger(source ledger.Snapshot) Snapshot { + result := Snapshot{ + Status: source.Status, + GeneratedAt: source.GeneratedAt, + VerifiedTotal: source.VerifiedTotal, + RejectedTotal: source.RejectedTotal, + EncryptedTotal: source.EncryptedTotal, + FailedTotal: source.FailedTotal, + Attestations: make([]Attestation, len(source.Attestations)), + Requests: make([]Request, len(source.Requests)), + } + for index, record := range source.Attestations { + result.Attestations[index] = attestationFromLedger(record) + } + for index, record := range source.Requests { + result.Requests[index] = Request{ + ID: record.ID, + StartedAt: record.StartedAt, + DurationMS: record.DurationMS, + AttestationID: record.AttestationID, + Outcome: record.Outcome, + Failure: record.Failure, + StatusCode: record.StatusCode, + RequestBytes: record.RequestBytes, + ResponseBytes: record.ResponseBytes, + Streaming: record.Streaming, + } + } + if source.Current != nil { + current := attestationFromLedger(*source.Current) + result.Current = ¤t + } + return result +} + +func attestationFromLedger(source ledger.Attestation) Attestation { + checks := make([]Check, len(source.Checks)) + for index, check := range source.Checks { + checks[index] = Check(check) + } + return Attestation{ + ID: source.ID, + Outcome: source.Outcome, + VerifiedAt: source.VerifiedAt, + Failure: source.Failure, + AttestedAt: source.AttestedAt, + ExpiresAt: source.ExpiresAt, + ModuleID: source.ModuleID, + HPKEPublicKey: source.HPKEPublicKey, + Nonce: source.Nonce, + PCR0: source.PCR0, + PCR1: source.PCR1, + PCR2: source.PCR2, + SourceRevision: source.SourceRevision, + Workload: source.Workload, + Profile: source.Profile, + RootFingerprint: source.RootFingerprint, + Checks: checks, + DocumentBytes: source.DocumentBytes, + } +}