For AI agents: an LLM-friendly Markdown version of every page is available by appending .md to its URL or by sending an Accept: text/markdown request header. The full documentation index is at https://www.ankr.com/docs/llms.txt
Skip to main content

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​

ItemVisible to the Ankr gateway and networkVisible inside the TEE (sidecar, node)
HTTP method, path and query of the inner requestNoYes
Inner headers (content type, accept)NoYes
Request body (JSON-RPC method, params, batch)NoYes
Response statusYes (the outer status mirrors the inner one)Yes
Response body and its content typeNoYes
API key, chain, pinned node, sizes, timing, client IPYesNo; outer headers never reach the node
Number of calls in a batchYes, used for rate limits and accountingYes

Building blocks​

StandardUsed for
RFC 9180 HPKE, base modeSealing the request to the node key with an ephemeral client key; exporting the response secret
RFC 9458 Oblivious HTTP, sections 4.3 and 4.4Byte formats of the encapsulated request and response only. No relay or gateway roles, no key configuration resource
RFC 9292 Binary HTTP, known-lengthThe inner HTTP request and response (method, path and query, fields, body, status)
RFC 9457 Problem DetailsError 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 libsodium crypto_sign_ed25519_sk_to_curve25519.
  • Public X25519 key. pkR = Montgomery(vRPC-Pubkey), the birational map u = (1 + y) / (1 - y), as libsodium crypto_sign_ed25519_pk_to_curve25519.
  • Self-check. The sidecar refuses to start when the X25519 public key of skR differs from Montgomery(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
OffsetLengthField
01key_id = 0x00
12KEM id 0x0020
32KDF id 0x0001
52AEAD id 0x0002
732enc: the client's ephemeral X25519 public key
39n + 16ct: AES-256-GCM ciphertext of the BHTTP request with its 16-byte tag
  • A fresh ephemeral key pair per request. enc travels 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-Type and 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, :authority and padding are ignored.
  • Fields kept: content-type (required with a body, one value) and accept (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_nonce is 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 upstream content-type value 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-Timestamp lets the client tell a replayed execution of the same request apart.

Client verification in this mode:

  1. The answer must be message/ohttp-res; anything else is an availability error and is never decrypted.
  2. The signature verifies over the fingerprint built from the exact request bytes the client sent and the content-decoded response bytes.
  3. The signer (vRPC-Pubkey) equals the pinned, attested node key.
  4. The timestamp is inside the replay window (60 seconds by default).
  5. Only then does the client derive the response key, open the AEAD and decode the inner Binary HTTP response.

Attacks and outcomes​

Attack or faultWhat happensSDK result
The gateway modifies the encrypted requestDecapsulation or AEAD fails in the sidecar; the node is not calledPlaintext 400 ohttp-key, RequestRejected
The request is routed to a different nodeThat node's key cannot decapsulate it; the node is not called400 ohttp-key, RequestRejected; the pin is dropped and the next call attests again
The gateway modifies the encrypted responseThe signature over the response bytes failsBadSignature
A response from another node is substitutedThe signer differs from the pinned keyConfidentialSignerMismatch
A response to another request is substitutedThe request hash in the fingerprint differs, and the response key would not derive eitherBadSignature
An old response is replayedThe timestamp is outside the window, or the request hash differsStaleTimestamp or BadSignature
The gateway replays the encrypted request to the nodeThe node executes it again; the answer is sealed for the original client, so the gateway cannot read itNone: 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 garbageOne plaintext answer for every failure before decryption, so there is no oracleRequestRejected
A malformed inner request after decryptionEncrypted, signed 400 urn:vrpc:error:bad-request; the node is not calledReturned 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 400 with Content-Type: application/problem+json and 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 empty 413; a method other than POST gives 405.
  • After decryption every error is an encrypted, signed response. Errors generated by the sidecar are application/problem+json with 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 500 with 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_id 0, 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-Type is not checked.
  • Inner request rules: a single-byte framing indicator, 400 on invalid bytes in content-type or accept, every accept value forwarded, every upstream content-type returned, 64 KiB per field section.

Test vectors​

  • Golden vectors: tests/vectors/confidential.json in 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 library crypto/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​