Review by anthropic/claude-opus-5
commit: 67eb6e6f672501be1c9f67cde1b979efebdcf981
usage: {'prompt_tokens': 140311, 'completion_tokens': 41074, 'total_tokens': 181385, 'cost': 1.728405, 'is_byok': False, 'prompt_tokens_details': {'cached_tokens': 0, 'cache_write_tokens': 0, 'audio_tokens': 0, 'video_tokens': 0}, 'cost_details': {'upstream_inference_cost': 1.728405, 'upstream_inference_prompt_cost': 0.701555, 'upstream_inference_completions_cost': 1.02685}, 'completion_tokens_details': {'reasoning_tokens': 31674, 'image_tokens': 0, 'audio_tokens': 0}}
time: 578s
Security review: timelock-proxy @ 67eb6e6
I read the README as the spec, then the enclave, host, common and CLI crates against it. Below, findings are grouped by your priority areas. crates/enclave/static/index.html is referenced by proxy.rs but not present in the tree I was given, so the landing page is not reviewed.
1. Can anyone other than the recipient read request/response content before the lock?
Nothing at critical or high severity in the enclave's data path itself. I traced every place bytes cross the enclave boundary and the structure holds:
- Sender TLS terminates on a key generated in
tls::ServerIdentity::generateand used only throughrustls::ServerConfig; there is no path that serialises the private key (onlyserialize_der()intoPrivateKeyDerin-process). - Upstream TLS is built in
net::Dialer::{new, ech_config}fromwebpki_roots::TLS_SERVER_ROOTSwithServerName::try_from(host), so the parent's CONNECT tunnel carries only ciphertext it cannot alter undetected. - DoH in
dns.rs::queryconnects by IP and verifies withServerName::IpAddress, so the parent cannot MITM name resolution either.main.rsrefuses to boot with an emptydoh_resolvers, so there is no silent fallback to parent-side resolution. - WireGuard peer keys are pinned in
config/enclave.toml; a parent that redirectsUDP <ip>:<port>gets a failed handshake, not a readable tunnel (wg.rs::run,mullvad::bring_up). - Records are sealed in
RecordGuard::drop→tlproxy_common::sealbeforeSink::submit; the framing layer carries only(name, ciphertext). HostConfig(the only untrusted structured input from the parent besides beacons) is used exclusively as the Mullvad account number.
The one thing that does break this property in practice is on the verification side, below.
2. What a sender can verify (priority 3) — the most serious finding
F1. High (potentially critical): the attestation ↔ TLS-key binding is never checked in-tree, and the enclave uses a field the validator library probably does not look at
File/function: crates/cli/src/attested.rs::connect; crates/enclave/src/attest.rs::Attester::attest; crates/enclave/src/proxy.rs::attestation.
What the code does. The enclave puts the DER SubjectPublicKeyInfo in the NSM request's user_data and explicitly leaves the NSM's dedicated public_key field None:
let req = Request::Attestation {
user_data: Some(serde_bytes::ByteBuf::from(user_data.to_vec())),
nonce: nonce.map(...),
public_key: None,
};
The CLI never compares anything to doc.user_data. It delegates the entire binding to one third-party call:
let doc = attestation_doc_validation::validate_attestation_doc_against_cert(&cert, &doc_bytes)
and then only checks the nonce and PCRs itself. The README claims verify "checks that user_data equals the captured certificate's public key" — no code in this repository does that.
Why this is dangerous. attestation-doc-validation is Evervault's crate, written for a convention where the enclave places the certificate's public key in the attestation document's public_key field. There are three possible behaviours and two of them are bad:
- The crate compares
cert.public_key()todoc.public_key, and errors when it isNone→tlproxy verifynever succeeds against this enclave (functional break, fails closed). - The crate compares only when the field is present (
if let Some(pk) = doc.public_key { … }) — a very common fail-open pattern → the binding is not checked at all. Then the operator mounts a trivial, undetectable MITM: terminate TLS on the parent with a key of their own choosing, take the verifier's nonce, forward it to a genuine enclave built from this exact commit (PCRs match, nonce matches), and return that document.verifyprints green, prints "PCR match", and the operator reads every request in cleartext. This defeats the headline property of the whole system. - The crate happens to compare against
user_datain exactly the DER SPKI encodingrcgen::PublicKeyData::subject_public_key_infoproduces → everything works, by luck.
There is no test anywhere in the repo that exercises verify against a captured attestation document, so which branch you are in is currently unknown to the operator and to the reviewer. Note also that even in case 3 you are relying on an undocumented field choice and encoding convention in an external crate for the single check the entire threat model rests on.
Fix. Do the comparison in attested.rs yourself, after signature validation, and do not rely on the library for it:
let ud = doc.user_data.as_ref().ok_or_else(|| anyhow!("no user_data"))?;
let spki = spki_der_from_cert(&cert_der)?; // reuse the walk in tls.rs, or use x509-parser
if ud.as_slice() != spki.as_slice() { bail!("attestation does not bind this TLS key"); }
Also assert doc.public_key.is_none() (or set both fields in the enclave and check both), and add a unit test with a checked-in real attestation document plus its certificate, including a negative test where the certificate is swapped. Until that test exists, I would not accept "the attestation binds the TLS key" as established.
F2. Low: x-timelock-proxy-unlock-round under-reports the actual lock
File/function: proxy.rs::forward_entry sets the header from initial_round, but RecordGuard::drop seals to p.initial_round.max(lock_round_at_ms(finished_at_ms, …)). The advertised round is a lower bound on the real one, so the security direction is safe (the record unlocks later than advertised), but a sender who checks time_of_round(header) gets a number that does not match the file name the operator ends up with. Emit the final round in a trailer, or document the header as "no earlier than".
F3. Info: hand-rolled DER walk for the ACME key check
tls.rs::x509_leaf_spki is a manual TLV walk used by install_chain_pem to confirm the CA-issued leaf is for the enclave key. Exploiting a mis-parse buys an attacker nothing (the SigningKey handed to rustls is always the enclave key, so a mismatched cert only breaks handshakes), but a real parser (x509-parser is already in the lock file transitively) removes the question entirely — and the same helper is what you want for F1, where correctness does matter.
3. Time sources, rounding, round selection, sealing
I could not find a way to shorten the lock. Specifically checked:
DrandChain::round_afteris correct:ceil((t-genesis)/period)+1is the first round withtime_of_round(r) ≥ t, andClock::lock_round_at_msconverts ms→s withdiv_ceil, so the unlock time is never even fractionally early.lock_round_at_mstakesmax(trusted_now_ms, verified_beacon_time);Clock::observeusesfetch_max, so withheld or replayed beacons only raise the floor. Forging a future beacon requires the League of Entropy key.verify_beaconuses the pinned chain public key from the measured config, never a fetched chain info.RecordGuard::droptakesinitial_round.max(...)andt.max(monotonic_finished_at_ms), so neither a backwards wall-clock jump nor an NSM failure at completion can shorten the lock.unix_now_secs/ms(the untrusted in-enclave wall clock) is used only for record ids, log timestamps and the informationalcurrent_round— never forlock_round_at_ms.MIN_LOCK_SECONDSis enforced inEnclaveConfig::parse; theTLPROXY_DEV_LOCK_SECONDSoverride inmainis gated ondev, and dev mode is refused byverifyunless--allow-dev.- Sealing is
age(recipient)insidetlock(round)(common::seal), andsealed_header/decrypt_onecheck the chain hash in the tlock header against the pinned chain. Both layers are genuinely required.
Nothing at medium or above here. One note:
F4. Info: NSM timestamp is read from the COSE payload without verifying the COSE signature
attest::attestation_timestamp_ms parses cose_payload(signed)[2] and trusts it. That is fine because the bytes come straight from the /dev/nsm ioctl and the parent is not in that path, but the code would silently accept an unsigned document if this function were ever reused on a document received over the wire. Worth a #[doc(hidden)]-style comment or an assertion that the caller is the local NSM.
4. Leaks via diagnostics, errors, side channels
The log / log_record / log_infra split is real and, as far as I can tell, correctly applied on request paths: classify_upstream_error collapses upstream failures to fixed labels, dns.rs and net.rs only emit static strings on sender-driven paths, and no logger is installed so log/tracing output from rustls, hyper, boringtun and hickory is discarded. Bodies/URLs appear only in error responses to the sender, which is correct.
Two leaks that do escape:
F5. Medium: relay/Mullvad API error text is served publicly through the attestation endpoint and landing page
Files/functions: net::Dialer::json (bail!("{method} {host}{path}: status {status}: {}", String::from_utf8_lossy(&body[..300]))) → mullvad::run (net.set_mode(Mode::Down(format!("registration failed: {e:#}")))) → relay::Net::status().state → proxy::attestation (relay: state.net.status()) and proxy::landing.
Any anonymous client can GET /.well-known/attestation and read up to 300 bytes of the Mullvad API's response body, plus the exact API path and status. Mullvad account numbers are bearer credentials; an error body that echoes request context, or a future code path that formats the account into an error, becomes a public credential disclosure. It also gives an attacker a live view of the enclave's registration/handshake state.
Fix: reduce RelayStatus::state to a fixed enum (connecting / up / down) and keep the detailed text in log_infra only. Never format an HTTP response body into anything served to clients. As a secondary hardening, redact the account number from any error constructed in mullvad::register.
Related: proxy::landing string-substitutes {{relay}} into HTML without escaping, so this is also a stored-XSS vector on the proxy origin fed by a third-party API response. Given that the proxy already serves arbitrary upstream HTML on its own origin (see F13), the origin is worthless anyway, but escape it.
F6. Info: hostname exposure to Cloudflare/Google, and the "nobody but the upstream sees plaintext" framing
For Purpose::Upstream, dns.rs resolution goes through Net::connect_ip(…, Upstream), which requires the tunnel — so DoH queries exit from a Mullvad IP and the resolver cannot link them to the proxy. That is a good design and worth keeping. But the resolver still learns every upstream hostname a sender asks for, timestamped, and the README only says the parent doesn't. Say so explicitly in the threat model: DoH resolvers are a third party that learns request-derived metadata (hostnames), not just infrastructure.
F7. Info: production diagnostics go nowhere
Every claim of the form "the enclave logs it" (dropped records, sealing failures, WireGuard errors) is unobservable in production, because without --debug-mode the console is not attached and there is no other log sink. The operator cannot distinguish "no records because no traffic" from "records dropped for six hours". Consider a counters-only channel over vsock (monotonic counts, no request-derived data) so the operator has some signal without weakening the log-hygiene rule.
5. Correctness bugs that could drop/corrupt records or crash the enclave
panic = "abort" in [profile.release] makes every reachable panic a total loss of all in-flight records, which raises the stakes on everything in this section.
F8. High: memory exhaustion aborts the enclave; the documented bounds do not add up
Files: config/enclave.toml, crates/enclave/src/records.rs, crates/enclave/src/wg.rs::run/service_sockets, crates/enclave/src/main.rs accept loop, deploy/run-enclave.sh (--memory 1024).
The README says resource use is bounded so a sender cannot crash the enclave. Four separate accounting gaps mean it isn't:
- Sealing amplifies each record 3–4×, and the budget doesn't know.
max_capture_bytes_total = 256 MiBbounds captured bytes. ThenRecordGuard::dropdecrementscapture_bytesbefore sealing starts, and sealing allocatesserde_json::to_vec(&record)(base64 inflates bodies 4/3 → ~340 MiB for a full budget), thenageoutput, thentlockoutput. Two large exchanges finishing together comfortably exceed 1024 MiB. Decrement the budget only after the sealed bytes are handed to the sink, and count the sealing allocations against it. - The record queue is bounded by count, not bytes.
records::QUEUE_CAPACITY = 4096records × up to ~180 MiB each. Withmax_body_bytes = 64 MiB, a handful of large queued records is fatal. Bound the queue in bytes. - Per-connection tunnel buffers are large and multiply. Each
wg::Cmd::Connectallocates2 × TCP_BUF(128 KiB) plus aUSER_QUEUEof 32 × up to 16 KiB (512 KiB).dns.rs::resolveopens two fresh DoH connections per uncached name (A and HTTPS, intokio::join!, no request coalescing, no connection reuse), so one proxied request can hold three tunnel sockets ≈ 2 MiB. Atmax_in_flight = 256that is ~500 MiB of WireGuard buffers alone. Coalesce concurrent lookups for the same name, reuse resolver connections, and shrinkUSER_QUEUE/TCP_BUF. - Accepted connections are unbounded.
main's loop spawns a task per accepted vsock stream with no cap and no idle timeout; only exchanges are limited. A client can open tens of thousands of TLS connections that never send a request (they pass the 20 s handshake timeout, then sit in keep-alive forever), each holding rustls buffers. Add a semaphore on accepted connections and an idle/keep-alive timeout.
Any one of these ends in abort() and the loss of every in-flight record — precisely the outcome the availability section says it is protecting against.
F9. Medium: one undeliverable record permanently stops all record delivery
File/function: records::worker.
loop { match deliver(...) { Ok(()) => break, Err(_) => { sleep(delay); delay = (delay*2).min(30s) } } }
The retry loop for the current record is unbounded and the worker is single-threaded, so any permanent failure (host disk full, ERR from handle_record, a data_len the host rejects) blocks the queue forever. The queue then fills and Sink::submit silently drops everything afterwards — with no observable log (F7). Fix: cap retries per record (e.g. 20 attempts / 10 minutes), then move on and increment a dropped counter; consider requeueing at the tail rather than head-of-line blocking.
F10. Medium: ECH rejection is not handled, and stale ECH configs break upstreams for up to an hour
Files/functions: net::Dialer::connect_tls, dns::Resolver::resolve.
connect_tls builds an EchMode::Enable config and never handles rejection. rustls surfaces server rejection of ECH as a handshake error (with retry configs attached), not as EchStatus::Rejected, so the "rejected" value in ech_status_str is effectively unreachable and the request fails with a 502 instead. Cloudflare rotates ECH keys routinely; combined with MAX_TTL = 3600 on the cached ech_config, a rotation can make an upstream unreachable through the proxy for up to an hour, and every failure is recorded as an errored exchange. Fix: on ECH rejection, retry once using the retry configs rustls returns (and refresh the cache entry), and fall back to non-ECH after that; drop the ECH config TTL cap to something short (60 s).
F11. Medium: parent-controlled beacon JSON is parsed in-process under panic = "abort"
File/function: beacon::fetch_from → DrandChain::verify_beacon → drand_core::beacon::ApiBeacon::verify.
The bytes are fetched through the parent (https_get to api.drand.sh; TLS-verified, so the parent can't inject — but the API host can, and a hostile relay/DNS answer path is exactly what this defends against). drand_core + ark-* deserialization of a crafted signature/randomness field is the one place untrusted bytes reach BLS deserialisation code that has not been hardened for adversarial input. A panic there aborts the enclave and loses in-flight records. Mitigate cheaply: validate the JSON shape and hex field lengths yourself before calling verify, and/or perform beacon verification in a spawn_blocking on a build without panic=abort — the latter isn't possible with the current profile, so pre-validation is the practical fix. More generally, reconsider panic = "abort" for the enclave: unwind plus a catch_unwind boundary per connection would turn "lose 256 records" into "lose one".
F12. Medium: trivial anonymous denial of service, with no possibility of rate limiting
Files/functions: proxy::handle (UPSTREAM_HEADERS_TIMEOUT = 600s), proxy::forward_entry (max_in_flight = 256), proxy::attestation, attest::Attester::trusted_time_ms.
- 256 concurrent requests pointed at an attacker-controlled slow upstream occupy every in-flight slot for 10 minutes each, and everyone else gets 503. There is no overall request deadline.
- The enclave cannot see client addresses at all:
host::forwarderdoescopy_bidirectionalwith no PROXY-protocol header, so no per-source limiting is even implementable. /.well-known/attestationis outside the in-flight limiter and performs one NSM attestation per request. NSM requests are serialised ioctls, andforward_entryfail-closes ontrusted_time_ms(); so flooding the attestation endpoint starves the forwarding path and produces "trusted time unavailable" 503s for legitimate senders. The same ioctl is also called synchronously from an async task (and fromRecordGuard::drop, possibly on a runtime worker), blocking tokio workers on a 2-vCPU enclave.
Fixes: have the parent prepend a source address line on the vsock connection (it's untrusted, but useful for coarse limiting and cannot weaken confidentiality); add a total per-request deadline and lower the header timeout; move NSM calls onto a dedicated blocking task with a small cache (a timestamp reused for ≤1 s only raises the lock, so caching is safe in the conservative direction); rate-limit the attestation endpoint.
F13. Low: WireGuard/TCP stack correctness details
File: wg.rs::service_sockets.
if c.user_gone { sock.abort(); … continue; }runs before the user→socket flush, so anything still inc.pendingis discarded on stream drop. In the current flow hyper holds the stream until the response completes, so this manifests (if at all) as a truncated upstream request and an errored record rather than a corrupted one — but it is an unnecessary data-loss path. Flushpendingandclose()before aborting.- In the socket→user loop,
sock.recv_slicehas already dequeued the bytes whento_user.try_send(...)fails; the data is dropped. This is currently only reachable when the receiver is gone (capacity was checked immediately before, and there is a single sender), but it is fragile — hold the chunk and retry rather thanbreak. next_portis a plain monotonic counter over 40 000 ports with no check against live sockets; a collision with a lingering TIME_WAIT socket to the same peer would mix two TLS streams (both would then fail to decrypt — no plaintext leak, but a confusing 502). Track in-use local ports.out_tx.send(...).awaitis awaited from inside the select arms; a parent that stops reading the UDP relay stalls the whole netstack loop (bounded by thewriter.is_finished()check, so it degrades rather than hangs, but tunnel liveness depends on the parent draining).
F14. Low: record fidelity when the response finishes before the request body
TeeBody::poll_frame finalises the record (this.guard.take()) as soon as the response body ends. If the upstream answered early while the sender was still uploading, Pending has already been taken, so subsequent append calls are no-ops: request.body_len under-reports and body_truncated stays false even though bytes are missing (only body_complete: false hints at it). Also in_flight is decremented while the request body is still streaming, so the concurrency limit under-counts. Set body_truncated = true when finalising with request_complete == false, and hold the in-flight permit until both bodies are done.
F15. Info: attestation nonce and TLS-ALPN handling
attestation()parses the query withq.split('&').find_map(|kv| kv.strip_prefix("nonce=")); a request with?x=1&nonce=yields an empty nonce → rejected correctly. Fine, but it accepts only the firstnonce=occurrence — harmless.tls.rsputsacme-tls/1first inalpn_protocols, and rustls selects by server preference. Any client that includesacme-tls/1anywhere in its ALPN list — even alongsidehttp/1.1— is routed into the challenge branch ofCertResolver::resolveand gets either the throwaway challenge certificate or a handshake failure. Harmless today; puthttp/1.1first and keep the ALPN check in the resolver.acme::obtaincreates a brand-new ACME account on every attempt (credentials are discarded), so a crash-loop burns Let's Encrypt new-account rate limits as well as new-order limits. Cache the account key in enclave memory across retries.
6. Design points I disagree with
The browser-valid certificate is a net negative. The README is honest that it "adds no assurance", but it does add: an entire ACME stack (instant-acme, an extra HTTP client, a mutable certificate resolver, a challenge-certificate path) inside the enclave's TCB; a second reason for install_chain_pem's hand-rolled DER walk to exist; and — most importantly — an affordance that invites exactly the unverified use the design is supposed to make impossible. A padlock on proxy.girl.surgery plus a / landing page describing timelock guarantees is a very effective way to get people to send secrets through an endpoint they never attested. Combined with the fact that /<host>/<path> serves arbitrary attacker-controlled content on the proxy's own origin, I would drop [acme] and serve only the self-signed certificate, so that using the proxy at all requires a client that pinned the attested key.
panic = "abort" is the wrong choice for this enclave. The threat model explicitly values "never lose a record", and the code goes to some trouble (the RecordGuard, the fail-closed-at-start rule) to honour it. abort converts any single-request panic — in rustls, hyper, hickory, drand_core, smoltcp — into loss of up to 256 records. Unwind plus a per-connection catch_unwind costs a little binary size and preserves the property you actually care about. (Note the dead code this creates today: Err(_) => crate::log("record sealing task panicked") in RecordGuard::drop can never run.)
Verdict
The architecture is sound and the details are unusually careful: the two-layer seal genuinely requires both the operator's key and a published round; the time logic is conservative in every direction I could probe (signed NSM timestamps, monotonic floor, beacon floor, upward rounding, max at both start and finish); in-enclave DoH and the fail-closed Mullvad path really do keep upstream identities away from the parent; and the diagnostics discipline is a real, enforced boundary rather than a slogan. Against the parent instance and the network, I believe the confidentiality claim.
I would not yet trust this deployment to enforce its stated properties, for two reasons. First, F1: the single check on which the entire sender-facing guarantee rests — that the attestation document binds this TLS key — is not performed by any code in this repository, is delegated to a third-party function whose field convention (public_key) does not match what the enclave populates (user_data), and is covered by zero tests. Until that check is explicit, tested against a captured document, and negative-tested with a swapped certificate, an operator-run MITM that relays a genuine enclave's attestation is a live possibility and verify cannot distinguish it. Second, F8 and F11: the availability bounds the README advertises do not close (sealing amplification, byte-unbounded record queue, unbounded accepted connections, half a gigabyte of per-socket WireGuard buffers at the configured concurrency, all inside 1024 MiB with panic = "abort"), so a sender can very likely abort the enclave and destroy in-flight records — which is the operator's guarantee, not the sender's, but it is stated as a goal.
Before I used it: fix F1 with an in-CLI user_data == SPKI(cert) assertion plus a fixture test; fix the memory accounting in F8 (decrement the capture budget after sealing, bound the record queue in bytes, cap accepted connections, shrink tunnel buffers) and re-measure peak RSS under a 256-way load test with 64 MiB bodies; bound the retry loop in F9; stop serving API error text in RelayStatus::state (F5); handle ECH rejection (F10); pre-validate beacon JSON (F11); and switch the release profile to unwind with a per-connection panic boundary. I would also want the reproducible build actually reproduced by a second party at the claimed commit, since every property above is conditional on the PCRs meaning what the README says they mean.