Ankr Confidential RPC cryptography
This page describes the encryption in Ankr Confidential RPC: the key, how the request and the response are encapsulated, how the vRPC signature works on top, and which properties the design does and does not give. For the attestation and the trust in the TEE, see the Trust model.
In one paragraph. The client takes the node's attested Ed25519 signing key, converts it to its X25519 form and HPKE-seals a Binary HTTP request to it with a fresh ephemeral key per call. Only attested enclaves hold the private key: the sidecar inside the node's TEE and the key service that derives it. The sidecar decrypts, calls the node, and encrypts the answer with a key exported from the same HPKE context, so only the client that sent that request can open it. The sidecar signs the encrypted request and response bytes with the usual vRPC signature; the client checks the signature and that the signer is the attested node before it decrypts.
What is protected, and from whom
| Item | Visible to the Ankr gateway and network | Visible inside the TEE (sidecar, node) |
|---|---|---|
| HTTP method, path and query of the inner request | No | Yes |
| Inner headers (content type, accept) | No | Yes |
| Request body (JSON-RPC method, params, batch) | No | Yes |
| Response status | Yes (the outer status mirrors the inner one) | Yes |
| Response body and its content type | No | Yes |
| API key, chain, pinned node, sizes, timing, client IP | Yes | No; outer headers never reach the node |
| Number of calls in a batch | Yes, used for rate limits and accounting | Yes |
Building blocks
| Standard | Used for |
|---|---|
| RFC 9180 HPKE, base mode | Sealing the request to the node key with an ephemeral client key; exporting the response secret |
| RFC 9458 Oblivious HTTP, sections 4.3 and 4.4 | Byte formats of the encapsulated request and response only. No relay or gateway roles, no key configuration resource |
| RFC 9292 Binary HTTP, known-length | The inner HTTP request and response (method, path and query, fields, body, status) |
| RFC 9457 Problem Details | Error bodies generated by the sidecar |
One fixed suite, no negotiation:
KEM 0x0020 DHKEM(X25519, HKDF-SHA256)
KDF 0x0001 HKDF-SHA256
AEAD 0x0002 AES-256-GCM
key_id = 0
Standard formats instead of a custom framing: the suite ids and both public keys are bound into the key derivation, HPKE rejects an all-zero DH result, the response key is bound to its request, and there are public test vectors and maintained libraries on both sides.
The node key
There is no separate encryption key. The encryption key is the node's vRPC signing key, in its X25519 form.
- Origin. At boot the sidecar asks the dstack guest agent for its signing seed: 32 bytes derived by the key management service from its root key and the app id, released only to a VM whose TDX quote verifies and whose compose hash is approved. Each node normally has its own key, stable across restarts on the same host; see the exception under What the design does not provide.
- Private X25519 key.
skR = clamp(SHA-512(seed)[0:32]), the Ed25519 secret scalar, as libsodiumcrypto_sign_ed25519_sk_to_curve25519. - Public X25519 key.
pkR = Montgomery(vRPC-Pubkey), the birational mapu = (1 + y) / (1 - y), as libsodiumcrypto_sign_ed25519_pk_to_curve25519. - Self-check. The sidecar refuses to start when the X25519 public key of
skRdiffers fromMontgomery(pubkey). - Binding to the hardware. The attestation returns a fresh TDX quote with
REPORTDATA = pubkey (32 B) ‖ nonce (32 B), so the quote covers the encryption key too. The attestation route is the same as in open mode. - Client-side checks. Before anything is sealed, the SDK verifies the quote as in open mode, decodes the Ed25519 key strictly (non-canonical encodings are rejected) and rejects small-order points and points outside the prime-order subgroup. A rejected key is a typed attestation error, and no request is sent.
One key serves both Ed25519 signatures and X25519 key agreement. The joint security of this combination for an HPKE-shaped KEM is proven in Thormarker 2021; libsodium recommends separate keys. This is an accepted trade-off: no new key and no change to attestation.
Request encapsulation
POST <chain>_vrpc/.../confidential?node_id=<vRPC-NodeId>
Content-Type: message/ohttp-req
hdr = key_id 0x00 ‖ kem 0x0020 ‖ kdf 0x0001 ‖ aead 0x0002 (7 bytes)
enc, ctx = HPKE SetupBaseS(pkR, info = "message/bhttp request" ‖ 0x00 ‖ hdr)
ct = ctx.Seal(aad = "", pt = BHTTP request)
body = hdr ‖ enc (32 B) ‖ ct
| Offset | Length | Field |
|---|---|---|
| 0 | 1 | key_id = 0x00 |
| 1 | 2 | KEM id 0x0020 |
| 3 | 2 | KDF id 0x0001 |
| 5 | 2 | AEAD id 0x0002 |
| 7 | 32 | enc: the client's ephemeral X25519 public key |
| 39 | n + 16 | ct: AES-256-GCM ciphertext of the BHTTP request with its 16-byte tag |
- A fresh ephemeral key pair per request.
enctravels in the body, never in the URL, so it never lands in access logs. - Each request is one HPKE message (sequence number 0), so the AEAD nonce is the base nonce of a context that is never reused.
- Clients send the outer
Content-Typeand the gateway forwards it; the sidecar does not check it.
Inner request (Binary HTTP)
- Known-length framing only; the framing indicator is the single byte
0x00. Indeterminate-length messages are rejected. - Method: any valid token except
CONNECT. - Path: path and query in origin form, starting with
/(not//, no fragment). The sidecar never parses it as an authority: the upstream URL is the node's configured base plus this path and query. :scheme,:authorityand padding are ignored.- Fields kept:
content-type(required with a body, one value) andaccept(every value, in order). Every other field and all trailers are dropped; outer headers never reach the node. - The header section and the trailer section are at most 64 KiB each, checked before decoding.
The SDK validates the same rules before sealing, so nothing the sidecar would refuse leaves the client.
Response encapsulation
response_nonce = random(32) fresh per response
secret = ctx.Export("message/bhttp response", 32)
prk = HKDF-Extract(salt = enc ‖ response_nonce, IKM = secret)
key = HKDF-Expand(prk, "key", 32)
nonce = HKDF-Expand(prk, "nonce", 12)
ct = AES-256-GCM(key, nonce, aad = "", pt = BHTTP response)
body = response_nonce (32 B) ‖ ct
Content-Type: message/ohttp-res
status = inner status (inner 204, 205, 304 travel as outer 200)
- The response key is exported from the request's HPKE context. Only the holder of that context, the client that created
enc, can derive it. A response produced for another request does not open. response_nonceis fresh for every response. If a request is replayed, the node answers again under a new key and nonce; a key and nonce pair never repeats.- The inner response is known-length Binary HTTP (framing indicator
0x01): final status, every upstreamcontent-typevalue in order, body. Informational (1xx) responses are not allowed. - The HTTP status stays in clear. Inner 204, 205 and 304 travel as outer 200, because HTTP stacks drop the body of those statuses, and the ciphertext with it.
- The sidecar never compresses encrypted responses. The gateway may gzip them; clients decode the transport encoding before verifying.
The vRPC signature over ciphertext
Every encrypted response carries the usual vRPC-Signature, vRPC-Timestamp and vRPC-Pubkey. The 104-byte fingerprint is the same as in open mode, computed over the outer bytes:
[ 0.. 32] sha256(utf8(chain_id))
[ 32.. 64] sha256(outer request body) = sha256(hdr ‖ enc ‖ ct)
[ 64.. 96] sha256(outer response body) = sha256(response_nonce ‖ ct)
[ 96..104] timestamp_ms, u64 little-endian
- Signing ciphertext means no hash of the plaintext request or response appears on the wire.
- The request hash in the fingerprint binds the answer to the exact encrypted request; with the exported response key this is a double binding.
vRPC-Timestamplets the client tell a replayed execution of the same request apart.
Client verification in this mode:
- The answer must be
message/ohttp-res; anything else is an availability error and is never decrypted. - The signature verifies over the fingerprint built from the exact request bytes the client sent and the content-decoded response bytes.
- The signer (
vRPC-Pubkey) equals the pinned, attested node key. - The timestamp is inside the replay window (60 seconds by default).
- Only then does the client derive the response key, open the AEAD and decode the inner Binary HTTP response.
Attacks and outcomes
| Attack or fault | What happens | SDK result |
|---|---|---|
| The gateway modifies the encrypted request | Decapsulation or AEAD fails in the sidecar; the node is not called | Plaintext 400 ohttp-key, RequestRejected |
| The request is routed to a different node | That node's key cannot decapsulate it; the node is not called | 400 ohttp-key, RequestRejected; the pin is dropped and the next call attests again |
| The gateway modifies the encrypted response | The signature over the response bytes fails | BadSignature |
| A response from another node is substituted | The signer differs from the pinned key | ConfidentialSignerMismatch |
| A response to another request is substituted | The request hash in the fingerprint differs, and the response key would not derive either | BadSignature |
| An old response is replayed | The timestamp is outside the window, or the request hash differs | StaleTimestamp or BadSignature |
| The gateway replays the encrypted request to the node | The node executes it again; the answer is sealed for the original client, so the gateway cannot read it | None: there is no replay cache by design, so a state-changing call can run more than once |
| Probing with a wrong suite, key id or garbage | One plaintext answer for every failure before decryption, so there is no oracle | RequestRejected |
| A malformed inner request after decryption | Encrypted, signed 400 urn:vrpc:error:bad-request; the node is not called | Returned as the inner status |
Errors without oracles
- Before decryption the sidecar answers in plaintext, unsigned, and never calls the node. A wrong key id or suite, a short body, a decapsulation or AEAD failure all give the same
400withContent-Type: application/problem+jsonand the exact body{"type":"https://iana.org/assignments/http-problem-types#ohttp-key","title":"Oblivious HTTP key configuration not acceptable"}. A body over the cap gives an empty413; a method other than POST gives405. - After decryption every error is an encrypted, signed response. Errors generated by the sidecar are
application/problem+jsonwith a constant{type, title}per cause (urn:vrpc:error:bad-request,unsupported-media-type,batch-too-large,upstream,upstream-timeout,internal) and no detail. Errors from the node are relayed as the node sent them. - The one exception: if the response cannot be sealed or signed (a random number generator or clock failure), the sidecar returns a plaintext, unsigned
500with an empty body. It checks the clock before calling the node.
What the design does not provide
- No forward secrecy. The encryption key is the long-term signing key derived by the key service. Whoever later obtains it can decrypt recorded traffic and answer future calls. Any configuration approved for the node's app id on that host's key service can derive it, and two VMs created from an identical initial configuration on one host share the app id and the key.
- No hiding of metadata. Size, timing, status, chain, node, API key and client IP address are visible to the gateway. There is no padding.
- No replay protection at the node. There is no replay cache: a replay yields only a response the replaying party cannot read, but the call runs again. The Ankr edge can resend a failed request to the same pinned node.
- The node sees plaintext. That is the point: the node must execute the call. Confidentiality towards the host operator rests on Intel TDX and on the node's logs staying inside the VM.
- No client authentication by the node. The node does not know who sent the request; authentication and billing stay with the API key at the gateway.
Differences from RFC 9458
- No key configuration resource: the key comes from the attestation, with one fixed suite and
key_id0, and it is derived from the signing key. - The outer status mirrors the inner status (inner 204, 205 and 304 become 200).
- Errors after decryption are encrypted, signed responses.
- The request
Content-Typeis not checked. - Inner request rules: a single-byte framing indicator, 400 on invalid bytes in
content-typeoraccept, everyacceptvalue forwarded, every upstreamcontent-typereturned, 64 KiB per field section.
Test vectors
- Golden vectors:
tests/vectors/confidential.jsonin the sidecar repository. They cover key conversion, an encapsulated request and response, one encrypted inner error, and the exact answers before decryption. The cryptographic part is generated independently with the Go standard librarycrypto/hpke; both the sidecar's Rust code and the SDK's CI verify it, and the SDK pins the file to a sidecar commit and its SHA-256. - Public vectors in the SDK's CI: the CFRG HPKE vector for this suite, RFC 9458 Appendix A through the same seal and open code path, the RFC 9292 section 5 examples, and the libsodium Ed25519 to X25519 conversion vectors. They run on the lowest supported Node, the current Node, and a browser runtime without WebCrypto X25519.
References
- RFC 9180 (HPKE), RFC 9458 (Oblivious HTTP), RFC 9292 (Binary HTTP), RFC 9457 (Problem Details).
- Thormarker, On using the same key pair for Ed25519 and an X25519 based KEM, 2021: eprint.iacr.org/2021/509.
- Sidecar README, section on Ankr Confidential RPC: github.com/w3tech/verifiable-rpc-sidecar.
- SDK core README, section "Ankr Confidential RPC mode": github.com/w3tech/verifiable-rpc-sdk.