Rust workspaces for the embedding verifier host and secure enclave.
Two workloads over the same host/enclave shape — the DeepFace verifier and the
DeepIdentifier migration. Each owns a top-level directory; what both would duplicate
lives in shared/. Crate names compose from the path: deepface/host is deepface-host.
embedding-verifier/
├── Cargo.toml # Host-side workspace -> Cargo.lock
├── shared/
│ ├── attested-channel/ # Client↔enclave channel and the attestation it rests on; destined for pontifex
│ └── enclave-types/ # The vsock contract both workloads share: health, errors, key attestation
├── deepface/
│ ├── host/ # Axum HTTP API — the untrusted side of the boundary
│ ├── enclave/ # Nitro enclave workload — the trusted side. Own workspace -> own Cargo.lock
│ ├── types/ # The match's own vsock contract
│ ├── protocol/ # Match inputs and outputs; travels sealed, the host links none of it. Will likely move to `world-id-protocol`.
│ ├── client/ # Attestation-verifying client
│ └── e2e/ # End-to-end harness driving host and enclave together
└── di/ # Skeleton — dirs and crates only, no behaviour yet
├── host/
├── enclave/ # Own workspace -> own Cargo.lock
└── types/
Splitting the types is what keeps one workload out of the other's enclave image. di-host
and di-enclave log and exit non-zero — a skeleton that idled would read as healthy. See
Spec: DeepIdentifier Migration TEE Setup v1
for what goes in them.
Each enclave is its own cargo workspace. One lockfile for the whole repository meant a
deepface-host dependency bump re-resolved the enclave graph and moved PCR0, which clients
pin. Now an EIF's inputs are its own Cargo.toml, the Cargo.lock beside it, and the path
crates they name.
attested-channel, enclave-types, deepface-protocol, deepface-types and di-types are
in both an enclave graph and the host-side one. They are members of the root workspace but
inherit nothing from it — not [workspace.dependencies], not [workspace.package] — so the
root manifest cannot reach an enclave graph either. Treat them as standalone crates: write the
version, and the edition, in their own manifest.
Package metadata matters as much as the dependency versions here. An inherited edition would
change how an enclave compiles when the root workspace moved, and bumping the root
[workspace.package].version would leave both enclave lockfiles stale against --locked.
face-engine now lives only in deepface/enclave, so it is the one build that needs a token
for worldcoin/biometric-engines. CI runs the other lanes without one, which is what keeps
that true.
Every command takes a --manifest-path, because there are three workspaces:
# From the repository root — cargo-deny reads deny.toml from the working directory.
for ws in . deepface/enclave di/enclave; do
cargo fmt --manifest-path "$ws/Cargo.toml" --all -- --check
cargo clippy --manifest-path "$ws/Cargo.toml" --all-targets --all-features --
cargo test --manifest-path "$ws/Cargo.toml" --all
cargo deny --manifest-path "$ws/Cargo.toml" --all-features check
done# Run the host on http://localhost:8000
# ENCLAVE_CID and ENCLAVE_PORT are required; the process panics without them.
RUST_LOG=info ENCLAVE_CID=16 ENCLAVE_PORT=1000 cargo run --bin deepface-host
curl http://localhost:8000/health
# Run the secure enclave placeholder
RUST_LOG=info cargo run --manifest-path deepface/enclave/Cargo.toml --bin deepface-enclaveEach workload has a host image and an enclave image. build-docker.yml builds all four
on every PR but publishes only the hosts — an enclave image is an input to the EIF, and
it is the EIF's PCRs that clients attest.
# EIF + PCRs. Linux x86_64 + Docker; Nitro hardware only needed to run, not to build.
scripts/build-eif.sh --workload deepface # -> target/eif/deepface-enclave.eif, deepface-pcrs.json
scripts/build-eif.sh --workload di # -> target/eif/di-enclave.eif, di-pcrs.json
# Carrier image that launches an EIF on a Nitro node
docker build -f scripts/Dockerfile.carrier --build-arg EIF_FILE=di-enclave.eif target/eifGIT_HUB_TOKEN and HUGGING_FACE_TOKEN are both deepface-only. A build now resolves one
enclave's workspace rather than the whole repository, and nothing in di's graph is private.
di-enclave exits non-zero on start, so its EIF builds and measures but will not stay
running, until the boot sequence lands.
POST /v1/enclave-assignment returns the enclave's encryption-key attestation and nothing
else:
{ "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.
Documents are served from an in-enclave cache. After boot starts background refresh, a task
re-attests every 10 minutes (MAX_CACHED_AGE); requests always receive the last successful
document immediately, including past MAX_CACHED_AGE if a refresh is in flight or has failed.
Attest errors do not block serving until the document is older than MAX_SERVABLE_AGE (1 hour),
at which point the refresh task exits and the enclave process exits. The cache is boot-scoped,
so a restart takes it along and there is nothing for the host to invalidate.
deepface-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:
{
"host_url": "http://localhost:8000",
"allowed_pcr_configs": [
[{ "index": 0, "value": "<PCR0 hex from deepface-pcrs.json>" }]
],
"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.
deepface-e2e reads that file from VERIFIER_CONFIG and fetches its encryption key
through the host, exercising the assignment route and the client together:
VERIFIER_CONFIG=./client.json cargo run --bin deepface-e2e -- <credential> <live> <challenge>POST /v1/matches compares a credential image against a live frame and the RP's challenge
frame. The host relays but cannot read either input:
{ "challenge_image_url": "https://…", "ciphertext": "<base64 enc || ciphertext>" }ciphertext is the match inputs sealed to the enclave's attested encryption key — both
images, hashes.json, and the AES-256-GCM key and IV for the challenge image. The challenge
image itself never travels: the RP uploads it encrypted, and the host fetches that blob from
challenge_image_url holding no key for it. A substituted URL or swapped object therefore
fails inside the enclave rather than changing the result.
{ "response_ciphertext": "<base64 nonce || ciphertext>", "key_attestation": "<base64 COSE_Sign1>" }The sealed response carries either a COSE_Sign1 match statement or the reason no statement was
issued; key_attestation is the signing key's attestation, so a client can verify the statement it
just received. Only the requester can open it — a second channel to the same enclave key cannot.
The host learns only that the enclave answered. Once a request has been opened there is a sealed
channel to reply on, so everything the enclave discovers from that point — a malformed payload, an
unusable hashes.json, an image refused on quality grounds, a below-threshold score, a challenge
blob that would not decrypt — travels inside response_ciphertext. None of it reaches the status
code.
| Status | Meaning |
|---|---|
200 |
The enclave answered; the sealed payload holds the outcome |
409 reassign_required |
The request did not open, so there was no channel to reply on; re-assign and re-seal, once |
400 invalid_challenge_url |
The URL was rejected before any request was made |
502 challenge_fetch_failed |
The challenge image could not be fetched |
500 internal_error |
Enclave fault |
409 is the only input failure with a status of its own, because with no channel open there is
nothing to seal a reply into. Everything else the host might want — how often matches fail, how often
the RP's objects are stale — has to come from enclave-side metrics rather than from status codes.
No SSRF destination control.
challenge_image_urlis only constrained in shape — HTTPS, a domain rather than an IP literal, no credentials, no redirects, a 5s timeout and a 4 MiB cap. Nothing pins where a fetch may go, so this endpoint must not take untrusted callers as-is. SeeTODO(SSRF)indeepface/host/src/challenge_fetcher.rs.
Use an Amazon Linux 2023 EC2 instance type that supports Nitro Enclaves and launch it with Nitro Enclaves enabled. Limit inbound SSH access to your current public IP.
On the instance, install the development tools and Nitro Enclaves runtime:
sudo dnf install -y \
git wget jq tmux tree unzip tar gzip \
gcc gcc-c++ make cmake clang pkgconf-pkg-config openssl-devel \
bubblewrap docker aws-nitro-enclaves-cli aws-nitro-enclaves-cli-devel
sudo usermod -aG ne "$USER"
sudo usermod -aG docker "$USER"
sudo systemctl enable --now nitro-enclaves-allocator.service
sudo systemctl enable --now dockerThese commands follow the AWS Nitro Enclaves setup for Amazon Linux 2023.
The allocator defaults to 2 vCPUs and 512 MiB. Adjust
/etc/nitro_enclaves/allocator.yaml before starting the service when the enclave needs more.
Log out and reconnect after changing group membership, then verify the host:
nitro-cli --version
nitro-cli describe-enclaves
docker versionInstall Rust and the components these workspaces use:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
rustup component add rustfmt clippy
for ws in . deepface/enclave di/enclave; do cargo test --manifest-path "$ws/Cargo.toml" --all; doneFor private repository access, install and authenticate the GitHub CLI using its official RPM instructions.
Optionally install Codex for development on the remote host:
curl -fsSL https://chatgpt.com/codex/install.sh | sh
exec "$SHELL" -l
codex login --device-auth
codex doctorTo use the Codex desktop app, add a concrete host alias to your local ~/.ssh/config, confirm
ssh <alias> works, then select the host and repository path under Settings > Connections.
See the Codex remote connection guide.