Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ea21a9a
feat(api): replace enclave keys route with POST /v1/enclave-assignment
kilianglas Aug 18, 2026
97bbfc9
feat(client): add Nitro attestation verifier ported from bedrock
kilianglas Aug 18, 2026
8e3fbca
feat(client): add the assignment HTTP client and CLI
kilianglas Aug 18, 2026
1e3230d
feat(e2e): verify attestations instead of parsing them
kilianglas Aug 18, 2026
112970f
refactor: tighten comments and drop redundant tests
kilianglas Aug 18, 2026
a0e0d9d
refactor(client): drop the verifier-client binary
kilianglas Aug 18, 2026
cecf4bd
refactor(client): introduce ClientConfig and a reusable request path
kilianglas Aug 19, 2026
ee05046
refactor(client): configure the client the way world-id-protocol does
kilianglas Aug 19, 2026
405db17
refactor: simplify the client and dedup the route error handling
kilianglas Aug 19, 2026
a71b7fa
refactor: cleanup
kilianglas Aug 19, 2026
37d6074
fix(client): reject empty PCR configurations instead of matching them
kilianglas Aug 19, 2026
39d29f3
fix(api): restore the doc comment missing_docs requires
kilianglas Aug 19, 2026
c747baf
docs(client): add the MIT notice the ported code requires
kilianglas Aug 19, 2026
3135e9c
refactor(api): centralize error mapping and test routes through the r…
kilianglas Aug 19, 2026
8da712f
refactor(client): rename Client to FaceVerifierClient
kilianglas Aug 19, 2026
af932d6
docs: state invariants rather than what changed
kilianglas Aug 19, 2026
b7065c8
Merge branch 'main' into kilianglas/enclave-assignment
kilianglas Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
973 changes: 943 additions & 30 deletions Cargo.lock

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
[workspace]
members = ["api", "e2e/enclave-match-e2e", "secure-enclave", "shared/enclave-types"]
members = [
"api",
"client/verifier-client",
"e2e/enclave-match-e2e",
"secure-enclave",
"shared/enclave-types",
]
resolver = "3"

[workspace.package]
Expand All @@ -13,23 +19,34 @@ publish = false
anyhow = "1.0"
ark-babyjubjub = { package = "taceo-ark-babyjubjub", version = "0.5" }
async-trait = "0.1"
# Attestation crates are pinned to bedrock's versions so the two verifiers cannot drift.
aws-nitro-enclaves-nsm-api = { version = "0.4", default-features = false }
axum = "0.8"
base64 = "0.22"
ciborium = "0.2"
coset = "0.4.2"
crypto_box = { version = "0.9.1", default-features = false, features = ["getrandom"] }
eddsa-babyjubjub = { package = "taceo-eddsa-babyjubjub", version = "0.5" }
enclave-types = { path = "shared/enclave-types" }
face-engine = { git = "https://github.com/worldcoin/biometric-engines", rev = "face-engine-v2.16.0", default-features = false, features = ["tract"] }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
hex-literal = "1.1"
image = { version = "=0.25.6", default-features = false, features = ["jpeg", "png", "webp"] }
p384 = { version = "0.13", default-features = false, features = ["ecdsa", "sha384"] }
pontifex = { version = "1.1.2", default-features = false }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1.0"
sha2 = { version = "0.10", default-features = false }
thiserror = "2"
tokio = { version = "1.48", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = { version = "2", features = ["serde"] }
verifier-client = { path = "client/verifier-client" }
webpki = "0.22"
x509-cert = "0.2.5"
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ Rust workspace for the embedding verifier API and secure enclave.

```text
embedding-verifier/
├── api/ # Axum HTTP API
└── secure-enclave/ # Secure enclave process
├── api/ # Axum HTTP API (the untrusted host)
├── client/verifier-client/ # Attestation-verifying client
└── secure-enclave/ # Secure enclave process
```

## Development
Expand All @@ -22,13 +23,54 @@ cargo build
cargo test --all

# Run the API on http://localhost:8000
RUST_LOG=info cargo run --bin api
# ENCLAVE_CID and ENCLAVE_PORT are required; the process panics without them.
RUST_LOG=info ENCLAVE_CID=16 ENCLAVE_PORT=1000 cargo run --bin api
curl http://localhost:8000/health

# Run the secure enclave placeholder
RUST_LOG=info cargo run --bin secure-enclave
```

## Enclave assignment

`POST /v1/enclave-assignment` returns the enclave's encryption-key attestation and nothing
else:

```json
{ "attestation": "<base64 COSE_Sign1>" }
```

The enclave's identity (`module_id`) and expiry (the leaf certificate's `notAfter`) are read
from the document *after* verifying it, never from fields the untrusted host could set.

`verifier-client` verifies the document — the COSE signature, the certificate chain up to the
pinned AWS Nitro root, and the expected measurements. It is configured by a JSON file, in the
shape `world-id-protocol` uses for an authenticator:

```json
{
"host_url": "http://localhost:8000",
"allowed_pcr_configs": [
[{ "index": 0, "value": "<PCR0 hex from scripts/build-eif.sh>" }]
],
"max_attestation_age_millis": 3600000,
"allow_debug_measurements": false
}
```

Only `host_url` and `allowed_pcr_configs` are required; the rest have defaults. A
configuration that pins no measurements is rejected — with nothing pinned, verification only
proves a document came from *some* enclave. A `--debug-mode` enclave reports all-zero PCRs and
its memory is readable from the parent instance, so it is rejected unless
`allow_debug_measurements` is set.

`enclave-match-e2e` reads that file from `VERIFIER_CONFIG` and fetches its encryption key
through the host, exercising the assignment route and the client together:

```bash
VERIFIER_CONFIG=./client.json cargo run --bin enclave-match-e2e -- <credential> <live> <challenge>
```

## Nitro-enabled development host

Use an Amazon Linux 2023 EC2 instance type that supports Nitro Enclaves and launch it with
Expand Down
17 changes: 17 additions & 0 deletions api/src/enclave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ pub trait EnclaveClient: Send + Sync {
async fn run_match(&self, request: MatchRequest) -> Result<MatchResponse, EnclaveClientError>;
}

impl EnclaveClientError {
/// Failure class for telemetry.
#[must_use]
pub const fn failure_class(&self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::Transport(_) => "transport",
Self::Operation(operation) => match operation {
EnclaveError::NotReady => "enclave_not_ready",
EnclaveError::SecureModuleNotInitialized => "nsm_unavailable",
EnclaveError::AttestationFailed => "attestation_failed",
_ => "enclave_operation",
},
}
}
}

/// Pontifex-backed secure-enclave client.
#[derive(Debug, Clone, Copy)]
pub struct PontifexEnclaveClient {
Expand Down
2 changes: 2 additions & 0 deletions api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@
pub mod enclave;
pub mod routes;
pub mod server;
#[cfg(test)]
mod test_support;
pub mod types;
142 changes: 142 additions & 0 deletions api/src/routes/enclave_assignment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use axum::{Json, extract::State, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use enclave_types::EnclaveError;
use serde::Serialize;

use crate::enclave::EnclaveClientError;
use crate::types::AppState;

/// The enclave assigned to a client, as an attestation document.
///
/// The document already carries the enclave's identity and expiry, and the client verifies it
/// before trusting either, so the host relays opaque bytes and adds no fields of its own.
#[derive(Debug, Serialize)]
pub struct EnclaveAssignmentResponse {
attestation: String,
}

/// Assigns this host's enclave by returning its encryption-key attestation.
pub async fn handler(
State(state): State<AppState>,
) -> Result<Json<EnclaveAssignmentResponse>, StatusCode> {
// TODO: Cache the attestation document, invalidating on enclave reconnect, and bound the
// entry's lifetime by the document certificate's validity. Until then every request costs
// an NSM attestation, so this route must not carry production traffic uncapped.
let response = state
Comment thread
kilianglas marked this conversation as resolved.
.enclave_client()
.get_enclave_keys()
.await
.map_err(|error| {
let status = status_for(&error);
tracing::error!(
?error,
%status,
dependency = "secure-enclave",
failure_class = error.failure_class(),
"enclave assignment failed"
);
status
})?;

Ok(Json(EnclaveAssignmentResponse {
attestation: STANDARD.encode(response.encryption_key_attestation),
}))
}

/// Maps an enclave-client failure to an HTTP status.
const fn status_for(error: &EnclaveClientError) -> StatusCode {
Comment thread
kilianglas marked this conversation as resolved.
Outdated
match error {
EnclaveClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
EnclaveClientError::Transport(_) => StatusCode::SERVICE_UNAVAILABLE,
EnclaveClientError::Operation(operation) => match operation {
EnclaveError::NotReady
| EnclaveError::SecureModuleNotInitialized
| EnclaveError::AttestationFailed => StatusCode::SERVICE_UNAVAILABLE,
// Match-path errors cannot arise from an attestation request. Reaching one means
// the enclave answered a request it was not asked, so surface a host bug rather
// than retryable unavailability.
EnclaveError::DecryptFailed
| EnclaveError::MalformedMatchPayload
| EnclaveError::InvalidHashesJson
| EnclaveError::ThumbnailHashMismatch
| EnclaveError::MatchBelowThreshold
| EnclaveError::InvalidImage
| EnclaveError::EmbeddingGenerationFailed
| EnclaveError::EmbeddingComparisonFailed => StatusCode::INTERNAL_SERVER_ERROR,
},
}
}

#[cfg(test)]
mod tests {
use axum::{extract::State, http::StatusCode};
use enclave_types::{EnclaveError, GetEnclaveKeysResponse};

use super::{handler, status_for};
use crate::enclave::EnclaveClientError;
use crate::test_support::{StubEnclaveClient, state_with};

#[tokio::test]
async fn returns_the_encryption_key_attestation_and_nothing_else() {
let state = state_with(StubEnclaveClient {
keys: Some(Ok(GetEnclaveKeysResponse {
encryption_key_attestation: vec![1, 2, 3],
signing_key_attestation: vec![4, 5, 6],
})),
..StubEnclaveClient::default()
});

let response = handler(State(state))
.await
.expect("a reachable enclave should yield an assignment")
.0;

assert_eq!(response.attestation, "AQID");

let json = serde_json::to_value(&response).expect("response should serialize");
assert_eq!(json.as_object().map(serde_json::Map::len), Some(1));
}

#[tokio::test]
async fn maps_enclave_failure_to_its_status() {
let state = state_with(StubEnclaveClient {
keys: Some(Err(EnclaveClientError::Timeout)),
..StubEnclaveClient::default()
});

let status = handler(State(state))
.await
.expect_err("a timed-out enclave should not yield an assignment");

assert_eq!(status, StatusCode::GATEWAY_TIMEOUT);
}

#[test]
fn status_mapping_is_exhaustive_and_classified() {
assert_eq!(
status_for(&EnclaveClientError::Timeout),
StatusCode::GATEWAY_TIMEOUT
);
assert_eq!(
status_for(&EnclaveClientError::Transport("boom".to_string())),
StatusCode::SERVICE_UNAVAILABLE
);

for operation in [
EnclaveError::NotReady,
EnclaveError::SecureModuleNotInitialized,
EnclaveError::AttestationFailed,
] {
assert_eq!(
status_for(&EnclaveClientError::Operation(operation)),
StatusCode::SERVICE_UNAVAILABLE,
"{operation:?} should read as retryable unavailability"
);
}

assert_eq!(
status_for(&EnclaveClientError::Operation(EnclaveError::DecryptFailed)),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
34 changes: 0 additions & 34 deletions api/src/routes/enclave_keys.rs

This file was deleted.

50 changes: 12 additions & 38 deletions api/src/routes/matches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ pub async fn handler(
.await
.map_err(|error| {
let status = status_for(&error);
let failure_class = error.failure_class();
if status.is_server_error() {
tracing::error!(?error, %status, "match request failed");
tracing::error!(?error, %status, dependency = "secure-enclave", failure_class, "match request failed");
} else {
tracing::warn!(?error, %status, "match request rejected");
tracing::warn!(?error, %status, failure_class, "match request rejected");
}
status
})?;
Expand Down Expand Up @@ -108,47 +109,20 @@ const fn status_for(error: &EnclaveClientError) -> StatusCode {

#[cfg(test)]
mod tests {
use std::sync::Arc;
Comment thread
kilianglas marked this conversation as resolved.

use async_trait::async_trait;
use axum::{body::Bytes, extract::State, http::StatusCode};
use enclave_types::{self as enclave, EnclaveError, GetEnclaveKeysResponse};
use enclave_types::{self as enclave, EnclaveError};

use super::{handler, status_for};
use crate::enclave::{EnclaveClient, EnclaveClientError};
use crate::types::{AppState, Environment};

struct StubEnclaveClient {
result: Result<enclave::MatchResponse, EnclaveClientError>,
}

#[async_trait]
impl EnclaveClient for StubEnclaveClient {
async fn health(&self) -> Result<(), EnclaveClientError> {
Ok(())
}

async fn get_enclave_keys(&self) -> Result<GetEnclaveKeysResponse, EnclaveClientError> {
Ok(GetEnclaveKeysResponse {
encryption_key_attestation: Vec::new(),
signing_key_attestation: Vec::new(),
})
}

async fn run_match(
&self,
request: enclave::MatchRequest,
) -> Result<enclave::MatchResponse, EnclaveClientError> {
assert_eq!(request.sealed_payload, b"sealed");
self.result.clone()
}
}
use crate::enclave::EnclaveClientError;
use crate::test_support::{StubEnclaveClient, state_with};
use crate::types::AppState;

fn state_returning(result: Result<enclave::MatchResponse, EnclaveClientError>) -> AppState {
AppState::new(
Environment::Development,
Arc::new(StubEnclaveClient { result }),
)
state_with(StubEnclaveClient {
match_result: Some(result),
expected_sealed_payload: Some(b"sealed".to_vec()),
..StubEnclaveClient::default()
})
}

fn sample_response() -> enclave::MatchResponse {
Expand Down
Loading
Loading