Cryptographic proof that an HTTP response came from an unmodified, approved upstream running inside an Intel TDX TEE.
rpc-attest-sidecar is a Rust service that sits in front of any HTTP upstream inside an Intel TDX confidential VM (via Phala dstack) and signs every response with a hardware-attested key. Clients verify the signature against a TDX quote and gain a trust-minimised guarantee that the response came from a specific, approved upstream image — not a compromised or mis-routed one.
- Rust 1.75+ toolchain to build (latest stable recommended).
- Linux host with access to a dstack-guest-agent Unix socket:
- Production: Intel TDX-capable hardware running Phala dstack.
- Local development: the Phala dstack simulator exposing the same socket interface.
- One HTTP or HTTPS upstream reachable from the sidecar. The inbound listener is plain HTTP only — TLS terminates outside the enclave for incoming traffic.
The sidecar listens on --listen-addr (default 0.0.0.0:8545). Send the same HTTP request you would send to the upstream directly — method, headers and body are forwarded byte-for-byte, and the request's path and query are appended to the configured --upstream-url base (e.g. base http://127.0.0.1:43677 + inbound GET /getConsensusBlock?limit=1 → http://127.0.0.1:43677/getConsensusBlock?limit=1). This makes path-based REST upstreams (TON HTTP API, Stellar Horizon) work through the sidecar, not just single-endpoint JSON-RPC. The sidecar appends three response headers (see below).
The --upstream-url base is used verbatim: a base path prefix like /api/v2 is preserved, and a single trailing / is collapsed against the request's leading /. Point it at the upstream's base origin, not at a full endpoint path.
The sidecar forces Accept-Encoding: identity on the upstream request, so the node returns an uncompressed (plaintext) body, and the vRPC-Signature covers that content-decoded body. The client-facing response is then re-encoded per your Accept-Encoding: a client that accepts gzip receives Content-Encoding: gzip and MUST decode the body before rebuilding the pre-image; everyone else (including brotli/zstd-only clients) receives identity (documented fallback — only gzip + identity are supported).
curl -sS \
-X POST http://sidecar:8545/ \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
-D headers.txtheaders.txt then contains the three vRPC-* headers along with whatever headers the upstream returned.
Every proxied response — any path other than /attestation and /info, which are always served locally by the sidecar and never forwarded upstream — carries:
| Header | Meaning |
|---|---|
vRPC-Pubkey |
0x-prefixed 32-byte hex — the Ed25519 verifying key. Must match the pubkey in /attestation. |
vRPC-Timestamp |
Unix milliseconds (u64) when the sidecar signed this response. Clients enforce their own freshness window (e.g. 60 s). |
vRPC-Signature |
0x-prefixed 64-byte Ed25519 signature over the 104-byte canonical pre-image: sha256(utf8(chain_id)) (32B) ‖ sha256(request_body) (32B) ‖ sha256(response_body) (32B) ‖ timestamp_ms (8B LE). The chain id is an opaque string — 42161, 0x89, TON's -239 are all hashed as UTF-8 bytes, never parsed numerically. |
The pre-image hashes the request body bytes the client sent (verbatim) and the content-decoded response body (the upstream's plaintext, before any client-facing compression). To verify:
- Fetch and validate
/attestation; extractpubkey. - For each response: if
Content-Encoding: gzipis set, decode the body first. Rebuild the pre-image from the request body you sent, the content-decoded response body, thevRPC-Timestampvalue, andsha256(utf8(chain_id))at[0..32]. The verifier learns the chain id string out-of-band (its own config); no new wire headers. - Ed25519-verify
vRPC-Signatureagainst the pre-image andpubkey.
Version gate (breaking change): this contract replaces the previous 80-byte pre-image (chain_id u64 LE at [0..8]). There is no dual-accept — verifiers must build the matching 104-byte pre-image (SDK ≥ next minor). Deployed nodes keep the old image until their compose is updated; upgrade the node's sidecar image and the verifier SDK together.
Standard HTTP clients (fetch/browsers) auto-decode Content-Encoding before exposing the body, so they hash the decoded plaintext and verification just works. (Compression-oracle attacks like CRIME/BREACH are not a concern here: RPC responses are not secret and there is no attacker-controlled secret reflected into the body; the signed bytes are deterministic plaintext.)
/attestation does not emit these headers.
GET /attestation?nonce=<hex> returns a TDX quote bound to REPORTDATA = signing_pubkey || user_nonce. The caller MUST supply a 32-byte nonce as a freshness challenge; the enclave produces a fresh quote against it on every call (no caching).
Missing or malformed nonce returns 400 Bad Request.
Nonce freshness (security-critical): callers MUST sample a fresh CSPRNG-generated 32-byte nonce per request; reused nonces enable replay of captured quotes. The sidecar does not police this — it honours whatever nonce the caller sends and returns a fresh quote bound to it. If you reuse a static nonce, a man-in-the-middle who captured a (quote, pubkey, nonce) tuple from an earlier session can replay it against you.
curl -sS "http://sidecar:8545/attestation?nonce=0x$(openssl rand -hex 32)"Nonce format: 32 raw bytes, hex-encoded, with or without the 0x prefix.
Response:
{
"quote": {
"quote": "…",
"event_log": "…",
"report_data": "…",
"vm_config": ""
},
"pubkey": "0x…",
"composeHash": "…",
"app_compose": "{…}"
}| Field | Meaning |
|---|---|
quote |
Raw GetQuote response from dstack-guest-agent, nested verbatim. See sub-fields below. |
quote.quote |
Hex-encoded TDX quote (bare hex, no 0x prefix). Validate against Intel's PCK chain to verify the enclave identity and that REPORTDATA contains the sidecar's signing pubkey and the nonce you supplied. |
quote.event_log |
Hex-encoded RTMR event log (bare hex). Reconstructs the launch measurement that the quote attests over. |
quote.report_data |
Echo of REPORTDATA bound into the quote (bare hex). |
quote.vm_config |
Hex-encoded VM configuration. Empty unless the agent supplies it. |
pubkey |
Sidecar Ed25519 signing pubkey (32 raw bytes, 0x-prefixed hex). Identical to the vRPC-Pubkey value on every signed response. |
composeHash |
app-compose.json hash reported by the dstack-guest-agent. Anchors the deployed image to a known, auditable compose file. |
app_compose |
Raw app-compose.json text, verbatim from dstack info (tcb_info.app_compose) — the preimage of composeHash: sha256(utf8(app_compose)) == composeHash, with no canonicalization (dstack hashes the raw bytes). Lets a verifier recompute the compose hash and replay it into RTMR3 from a single /attestation fetch, without a separate /info call. Empty when no compose is bound (e.g. the simulator with --allow-empty-compose-hash). |
The inner quote.* fields are bare hex matching the dstack-guest-agent wire format. Add the 0x prefix yourself if your hex parser requires it.
/attestation itself is not signed — verification happens against the TDX quote.
| Flag / env | Default | What it sets |
|---|---|---|
--listen-addr / SIDECAR_LISTEN_ADDR |
0.0.0.0:8545 |
Plain-HTTP listener |
--upstream-url / SIDECAR_UPSTREAM_URL |
required | Upstream base URL — http:// or https:// (Mozilla webpki roots). The inbound request's path+query is appended to it |
--chain-id / SIDECAR_CHAIN_ID |
required | Chain id bound into the signing pre-image as sha256(utf8(chain_id)). Opaque string, never parsed numerically: non-empty, ≤ 64 bytes, printable ASCII, no whitespace (e.g. TON's global id -239, Stellar's network id = sha256 of the passphrase (64-char hex); numeric-looking ids like 42161 are fine too) |
--dstack-endpoint / DSTACK_SIMULATOR_ENDPOINT |
/var/run/dstack.sock |
dstack-guest-agent Unix socket |
--key-path / SIDECAR_KEY_PATH |
rpc-sign/v1 |
Key derivation path (the /v1 segment prevents key reuse across versions/chains) |
--key-purpose / SIDECAR_KEY_PURPOSE |
unset | Optional purpose argument to get_key |
--max-body-bytes / SIDECAR_MAX_BODY_BYTES |
unset (unbounded) | Per-request body byte cap applied to both inbound request and upstream response. Unset → no cap (large eth_getLogs / debug_traceTransaction allowed through). Recommended explicit value: 8388608 (8 MiB) when the upstream is not fully trusted — removing the cap removes one of the two memory-exhaustion guards on the CVM. |
--allow-empty-compose-hash / SIDECAR_ALLOW_EMPTY_COMPOSE_HASH |
false |
Allow boot to continue when dstack info reports no compose hash. Dev/simulator only — production deployments MUST bind a real compose hash so /attestation returns a non-empty composeHash to verifiers. |
git clone https://github.com/Dstack-TEE/dstack.git
cd dstack/sdk/simulator
./build.sh
./dstack-simulatorbuild.sh requires a Rust toolchain. The simulator creates dstack.sock in its working directory; leave the process running.
In another shell, point the sidecar at the simulator's socket and at any HTTP upstream you want to wrap:
export DSTACK_SIMULATOR_ENDPOINT=/absolute/path/to/dstack/sdk/simulator/dstack.sock
cargo run -- \
--upstream-url http://127.0.0.1:8546 \
--chain-id 1The sidecar will log signing_pubkey = 0x… on startup once the simulator answers get_key. Then curl it as in the Calling the upstream and Getting an attestation sections.
dstack simulator docs: https://docs.phala.com/dstack/local-development.
The integration suite (tests/integration_harness.rs + tests/integration_blackbox.rs, with shared helpers in tests/common/mod.rs) spawns the actual sidecar binary against a fresh dstack simulator and a tiny in-process mock upstream, then drives end-to-end checks: byte-identical body forwarding, signature verification over the canonical pre-image, attestation freshness, batch JSON-RPC, HTTPS upstream, optional live upstream node call.
-
Build the dstack simulator (only once):
git clone https://github.com/Dstack-TEE/dstack.git cd dstack/sdk/simulator ./build.sh -
Export the simulator binary path and fixtures directory:
export DSTACK_SIMULATOR_BIN=/abs/path/to/dstack/sdk/simulator/dstack-simulator export DSTACK_SIMULATOR_FIXTURES_DIR=/abs/path/to/dstack/sdk/simulator
-
(Optional) For the live upstream node test, also export:
export NODE_RPC_URL=https://your-node/eth # full URL to an upstream chain endpoint export NODE_API_KEY=<your-api-key> # forwarded as `x-api-key` to upstream
When either is missing the live-upstream test skips cleanly (no failure).
Three test binaries:
# 14 tests — spawn sidecar + simulator + mock upstream per test
cargo test --test integration_harness -- --test-threads=1
# 10 black-box tests — run against any sidecar (local spawn by default,
# or an externally-deployed sidecar via SIDECAR_URL — see below)
cargo test --test integration_blackbox -- --test-threads=1
# 2 tests — dstack SDK baseline
cargo test --test dstack_baseline -- --test-threads=1Each harness test gets its own simulator (own temp dir) and own sidecar on an ephemeral port. Tests are #[serial] so they don't fight over resources.
To point the black-box suite at an already-running sidecar (e.g. a real TDX CVM deploy or a shared dev box), set:
export SIDECAR_URL=https://verified.example.com # base URL of the running sidecar
export SIDECAR_CHAIN_ID=1 # string matching the sidecar's --chain-id
# Optional: forwarded as an upstream-auth header on the method-POST tests
export SIDECAR_AUTH_HEADER_KEY=x-api-key
export SIDECAR_AUTH_HEADER_VAL=$NODE_API_KEY # or hard-coded
# Optional: body to POST `/` (default = eth_blockNumber JSON-RPC)
# export SIDECAR_TEST_BODY='{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}'
cargo test --test integration_blackbox -- --test-threads=1The harness bootstraps the signing pubkey from /attestation once at startup, then verifies every method response's vRPC-Signature against it. No simulator is spawned in this mode — DSTACK_SIMULATOR_* env vars are ignored.
The harness suite (integration_harness) always spawns locally and is not affected by SIDECAR_URL.
Copyright (c) 2026 Web3 Technologies, Inc.
rpc-attest-sidecar is free software licensed under the GNU Affero General
Public License v3.0 only (AGPL-3.0-only). See LICENSE for the
full text. Every source file carries an SPDX-License-Identifier: AGPL-3.0-only
header.
Because this is AGPL-3.0 software, if you run a modified version of the sidecar and make it available to users over a network, AGPL section 13 (Remote Network Interaction) requires you to offer those users the corresponding source of your modified version. The complete corresponding source of this program is published at https://github.com/w3tech/verifiable-rpc-sidecar.
Dependency licenses are verified for AGPL compatibility in CI via
cargo deny check licenses (see deny.toml).