Connect from a client
There are two ways to talk to a running enclave, and the right one depends on who you trust to verify the attestation.
Embed the SDK in your client (this page). Your code opens a WebSocket directly to wss://<id>.enclaves.beta.enclavia.io, performs the Noise handshake itself, fetches the attestation document, validates the AWS Nitro signing chain, and pins the PCRs. You are the verifier. No third party can hand you tampered bytes without your client detecting it. Use this path when the client is yours to ship: a Rust binary, a WASM build in the browser or Node, a Dart or Flutter app.
Go through the HTTPS proxy at https://<id>.enclaves.beta.enclavia.io/proxy/.... The proxy (we operate one on *.enclaves.beta.enclavia.io, or you can self-host one) does the attestation verification on every request and tunnels plain HTTP/WebSocket to the enclave's workload. The proxy operator is the verifier. Use this path when you can't embed the SDK: an unmodified browser hitting a public URL, a curl pipeline, a client written in a language without a native enclavia SDK. PCR values are surfaced on every response as X-Enclavia-PCR0..2 headers so a curious client can still check them out-of-band, but transport security between client and enclave reduces to "trust the proxy". See Hosted HTTPS proxy for the user-side reference and Self-host the proxy if you want to be the proxy operator yourself.
The rest of this page covers the embed-the-SDK path.
SDK overview
Each running enclave is reachable at wss://<id>.enclaves.beta.enclavia.io, the WebSocket-based proxy that bridges your client to the enclave's vsock channel. The client speaks Noise+CBOR directly to the in-enclave responder; the proxy is protocol-agnostic and never sees plaintext.
The security core is one Rust implementation, shipped in three packagings:
- Rust: the reference
enclaviacrate on crates.io, running natively on Tokio. - TypeScript / JavaScript: the same core compiled to WebAssembly, on npm as
@enclavia/client-wasm. Runs in browsers and any JS runtime with a globalWebSocket(Node 22+, Deno). See Browser and Node specifics. - Dart: UniFFI bindings over the same core, on pub.dev as
enclavia_dart, for Dart and Flutter apps on Android, iOS, Linux, macOS, and Windows. See Dart and Flutter specifics.
All three run the same Noise handshake and the same attestation verifier, so the encrypted channel terminates inside your app in every case.
Add the dependency
# Cargo.toml
[dependencies]
enclavia = "0.2"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }The crate's public surface is small: Client, ClientBuilder, Pcrs, and a request builder. Optional json feature brings in RequestBuilder::json.
Connect and verify
use enclavia::{Client, Pcrs};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Hex strings copied verbatim from `enclavia enclave status`.
let pcrs = Pcrs::from_hex(
"...your pcr0...",
"...your pcr1...",
"...your pcr2...",
)?;
let client = Client::connect(
"wss://<enclave-id>.enclaves.beta.enclavia.io",
pcrs,
).await?;
let resp = client.get("/health").send().await?;
println!("{} — {}", resp.status(), resp.text()?);
Ok(())
}Connecting does three things in one call, in every SDK:
- Opens the WebSocket.
- Performs a Noise NN (
Noise_NN_25519_ChaChaPoly_BLAKE2s) handshake. - Requests an attestation document from the enclave and verifies the COSE_Sign1 envelope, the AWS Nitro signing certificate chain, the handshake-hash binding (so this attestation can't be replayed against a different connection), and the PCR0/1/2 values you pinned.
If any check fails, the call returns an error and no traffic flows.
Get the PCRs you need to pin
enclavia enclave status <enclave-id>The PCRs: block in the output is the source of truth. Pin those exact values; the client will refuse to connect to anything that doesn't measure to the same identity.
PCRs are per-enclave, not per-image — the enclave's UUID is stamped into the rootfs at build time, so two enclaves created from the same Docker image have different PCR2 values. Pinning the PCRs from enclave status therefore binds your client to that specific enclave, not just to its image. If you destroy and re-create an enclave from the same image, you'll get a new set of PCRs to pin.
Sending requests
Every request is encrypted under the same Noise transport and forwarded plaintext to the inner container on the --container-port you specified at create time. The host header is filled in from the URL automatically.
The request builder mirrors reqwest:
let resp = client
.post("/api/run")
.header("Content-Type", "application/json")
.body(r#"{"input": "..."}"#)
.send()
.await?;
println!("status: {}", resp.status());
println!("body: {}", resp.text()?);With the json feature, RequestBuilder::json(&value) serializes a serde::Serialize and sets Content-Type: application/json for you.
Reconnects are automatic and re-attested
A Client holds a single long-lived attested WebSocket channel. Since SDK 0.2.0 the client transparently re-establishes that channel when it drops (most commonly because the enclave restarted or was upgraded, which tears the old connection down): the next request on a dead channel first re-dials the WebSocket, re-runs the full Noise handshake, and re-verifies the attestation against your pinned PCRs before anything is sent. A reconnect can never relax the checks you configured at connect time.
The semantics worth knowing:
- A request that was already in flight when the channel died still fails (native Rust:
Error::ConnectionClosed; TypeScript: a rejected promise; Dart: anEnclaviaErrorwithretryableset). The SDK never silently re-sends a request it already transmitted, because it cannot know whether the enclave acted on it before the channel dropped. If the request is idempotent, retry it at the application level; the retry itself will trigger the reconnect. - Attestation failures on reconnect are terminal, not retried. If the enclave now measures differently (for example after an upgrade), the reconnect fails closed.
- Only transient transport failures are retried internally; protocol and verification errors surface immediately.
- Open streams are not resurrected. A raw stream is stateful on the workload side, so a dropped stream stays dead; opening a replacement stream reconnects and re-attests first.
- Native Rust can opt out with
ClientBuilder::auto_reconnect(false)to restore fail-fast behavior; the TypeScript and Dart bindings always have auto-reconnect enabled.
Expected right after a deploy or restart
A failed in-flight request immediately after you restart, stop-then-start, or upgrade an enclave is expected, not a bug: the old attested channel went away with the old enclave. Retry the request and the client reconnects, re-verifying the new enclave's attestation. If the enclave was upgraded to a new image, its PCRs also changed, so the reconnect fails closed until you either re-pin the new values from enclavia enclave status or connect with trustUpgrades / ClientBuilder::trust_upgrades so the client follows the signed upgrade chain automatically.
Debug-mode enclaves
If you're targeting a debug-mode enclave, the attestation document is a stub that echoes the handshake nonce instead of being COSE-signed. Opt in explicitly:
let client = Client::builder("wss://...local-debug-url...")
.pcrs(Pcrs { pcr0: vec![], pcr1: vec![], pcr2: vec![] })
.debug_mode(true)
.build()
.await?;Debug mode only verifies the nonce binding — never use it against production enclaves.
Browser and Node specifics
The WASM packaging performs the same attestation verification as the native SDK, so the encrypted channel terminates in the user's browser and no proxy has to be trusted. A few things are specific to it:
Loading the module. init() must run once before connect. Bundlers resolve the .wasm asset; in plain Node (no bundler), pass the wasm bytes yourself:
import { readFileSync } from "node:fs";
import init, { connect } from "@enclavia/client-wasm";
await init({
module_or_path: readFileSync(
new URL(import.meta.resolve("@enclavia/client-wasm/wasm")),
),
});Raw streams. Non-HTTP protocols can use client.openStream(firstBytes) for a raw byte pipe over the same attested channel.
Upgrade trust. connect accepts trustUpgrades: { backendUrl, enclaveId }, mirroring the native ClientBuilder::trust_upgrades.
WebSocket-inherent differences. Custom upgrade headers are refused (production routing is by hostname), and TLS for the wss:// hop belongs to the host runtime (the security boundary is the Noise channel inside it, not the TLS hop). See the enclavia-wasm README for the full surface.
Dart and Flutter specifics
The Dart packaging wraps the same Rust core via UniFFI and works on Android, iOS, Linux, macOS, and Windows. A few things are specific to it:
The native library builds on your machine. No prebuilt binaries ship with the package: Dart's Native Assets system compiles the Rust core locally (pinned toolchain, locked dependencies) the first time you dart pub get / dart run / flutter run, so a working rustup install is a hard requirement for development and CI.
Surface. The API is Client.connect and client.fetch only; there is no raw stream API in the Dart bindings yet. Pcrs takes raw bytes rather than hex strings, and FetchResponse.body is a Uint8List.
Errors. Failures surface as EnclaviaError. Transport-level failures carry retryable: true (safe to retry, which also triggers the reconnect); attestation and verification failures do not.
See the enclavia-dart README and its runnable example/main.dart for the full surface.