timelock-proxy
An HTTPS forwarding proxy that runs inside an AWS Nitro Enclave and keeps a sealed log of everything that passes through it. Each request/response pair is encrypted so that
- nobody can read it now, including the operator who owns the AWS account and the parent instance: the enclave's TLS key never leaves the enclave, and the record is encrypted before it leaves the enclave;
- only the operator can read it later, and only after a fixed delay (7 days by default), enforced by drand timelock encryption, not by policy.
A sender can verify all of this before sending anything, by checking the enclave's Nitro attestation document against a reproducible build of this repository.
The reference deployment runs at https://proxy.girl.surgery (DNS points straight at the parent instance; it is deliberately not behind Cloudflare's proxy, because the TLS session has to terminate inside the enclave).
sender ──TLS──▶ [ parent instance ]──vsock──▶ [ enclave: TLS terminates here ] ──TLS──▶ upstream
(untrusted, sees forwards live,
only ciphertext) seals a record: age(operator key) ∘ tlock(round now + 7d)
│
▼ (ciphertext only)
parent writes records/*.age
How it works
- At boot the enclave generates a fresh P-256 TLS key pair. It is never written anywhere; a restart means a new key.
GET /.well-known/attestation?nonce=<hex>returns an attestation document signed by the Nitro Security Module whoseuser_datais the DER SubjectPublicKeyInfo of that TLS certificate, plus the certificate itself, the operator's age recipient, the lock duration and the drand chain.- Any other request
https://<proxy>/<upstream-host>[:port]/<path>?<query>is forwarded live ashttps://<upstream-host>/<path>?<query>, method, headers and body intact (hop-by-hop headers stripped,Hostrewritten). The upstream certificate is verified inside the enclave against the Mozilla root store, so the parent instance, which carries the bytes, cannot read or alter upstream traffic either. - While the exchange streams through, the enclave copies both bodies. When the
response ends it serialises a JSON record (method, URL, headers, bodies,
timing, errors) and seals it:
- inner layer: age to the operator's X25519 recipient baked into the image;
- outer layer: tlock (
tlock_age) to the drand quicknet round that will be publishedlock_secondsfrom now. Either layer alone is insufficient: the world can strip tlock after the round is published but cannot open age; the operator can open age but cannot strip tlock before the round exists.
- The sealed record is handed to the parent instance over vsock and written
to
/var/lib/timelock-proxy/records/<id>-r<round>.age. - After the round is published the operator runs
tlproxy decrypt, which fetches and verifies the round signature and opens both layers.
What "now" means to the enclave
The lock period uses fresh, signed Nitro attestation timestamps obtained when the request begins and when the exchange finishes. The finish time is the later of the second trusted timestamp and the start timestamp plus elapsed monotonic time, covering both wall-clock changes and enclave pauses. The target time is rounded upward to the first drand round at or after seven full days; the code refuses any configuration shorter than that.
As a second lower bound, the enclave uses the newest drand beacon it has fetched
(through the parent) and verified against the chain public key pinned in
config/enclave.toml. A verified beacon for round R proves the time is at least
R's publication time. Withholding or replaying beacons can only make a lock
longer, never shorter. Dev mode uses the local wall clock and proves nothing.
What exactly is guaranteed about time. Every record is locked to at least
seven days after the trusted start timestamp of its request; that bound is
fixed when the request arrives and is what x-timelock-proxy-unlock-round
announces to the sender. The lock is additionally pushed out to seven days
after the trusted finish timestamp, so long exchanges do not unlock early
relative to their end. If the NSM cannot provide a finish timestamp, the
enclave retries for 30 seconds; only then does it fall back to the trusted
start timestamp plus monotonic elapsed time. That fallback is a lower bound on
the finish time, so in that rare case the finish-based extension may be
shorter than the true elapsed time (never shorter than the start-based
guarantee). This is a deliberate choice: the alternative, discarding the
record, would trade the operator's property (every exchange is recorded) for
a marginal gain in an already-covered edge case.
Fail closed at the start. If a fresh Nitro timestamp is unavailable when a request arrives, or no drand beacon verified against the pinned chain key is newer than 15 minutes (by that timestamp), the enclave refuses the request with 503 and nothing is forwarded. So the lock never rests on the NSM clock alone for more than 15 minutes: a parent that withholds beacons gets a refusing proxy, not a shorter lock.
Browser-valid certificate (cosmetic)
With [acme] enabled the enclave obtains a Let's Encrypt certificate for its
own boot-time key via ACME TLS-ALPN-01, so browsers show no warning. This adds
no assurance: whoever controls DNS and the parent could get a CA certificate
for some other key, so a CA padlock never proves you are talking to the
enclave. Only the attestation does, and it binds the same key whichever
certificate is presented. Until issuance succeeds (or if Let's Encrypt rate
limits kick in after many restarts, since each boot means a new key and
certificate) the self-signed certificate is served; the attestation response
reports which (tls_certificate_issuer).
Panics do not take the enclave down
The release profile unwinds on panic rather than aborting: a panic inside one request's task is caught by the runtime and only that request fails, instead of every in-flight record being lost.
Diagnostics never carry request data
Request-derived values (hosts, URLs, headers, bodies, upstream error text)
never reach the enclave's diagnostics. On request paths the logging functions
accept only compile-time strings, or an enclave-generated record id plus a
compile-time string; upstream failures are reduced to a fixed category (dns,
tls, timeout, connect, relay down, http). Dynamic text is logged only from
infrastructure paths whose inputs come from the measured config or from the
relay and beacon services, never from a sender. rustls protocol logging is
disabled. Full request and response data leaves the enclave only after it has
been sealed. Because every line is scrubbed at the source, the enclave also
ships its diagnostics to the parent over vsock (best effort), where the host
daemon prints them to its journal; that is the operator's only view into a
production enclave, since without --debug-mode the console is not attached
(and with it the PCRs are all zero and tlproxy verify refuses the enclave).
The relay status served to clients is a fixed word (connecting, up,
down); reasons, which may echo relay API responses, stay in the journal.
Availability limits
A sender cannot read anything by exhausting the enclave, but crashing it would
lose in-flight records, so resource use is bounded (values in
config/enclave.toml, hence measured):
- at most
max_connectionsaccepted sender connections (TLS handshakes and idle keep-alive included), each closed aftermax_connection_secondsand after 15 s without request headers; - at most
max_in_flightexchanges at once; beyond that, 503. An exchange stays counted until its record has been sealed and queued; - at most
max_body_bytescaptured per body,max_header_bytesof headers per side, andmax_capture_bytes_totalacross all in-flight exchanges; bodies beyond the budget are forwarded but the record is marked truncated; - at most 2 records being sealed at once (sealing inflates a record about 2.7x, which is why the capture budget is 96 MiB in a 2 GiB enclave); the in-enclave DNS cache holds at most 4096 names; each relay TCP connection queues at most 256 KiB beyond its socket buffer before the sender is made to wait;
- sealed records waiting for the parent are capped at 4096 records and
max_queued_record_bytes. Records can be lost if the parent stays unreachable long enough to exhaust that: they are dropped and the enclave logs it. Sender traffic is never blocked on record delivery. If you need "no record is ever lost" instead, run the parent daemon reliably; the enclave cannot store anything itself.
What is and is not proven
The attestation proves that the TLS key you are talking to was generated inside
an enclave running exactly the code measured by PCR0/1/2. Combined with a
reproducible build of this repository at a known commit, that pins every
behaviour described above, including the age recipient and lock duration,
because config/enclave.toml is compiled into the binary.
Trust assumptions that remain:
- AWS Nitro hardware and the Nitro attestation PKI.
- The drand League of Entropy network not colluding to release a round early.
- The operator not publishing their age identity (that would only ever weaken the "only me" property, never the "not before a week" property).
- The Rust toolchain, the crates in
Cargo.lockand the Debian snapshot used by the build (all pinned, all public). - The DNS-over-HTTPS resolvers (Cloudflare, Google) learn every upstream host name, though through the relay they cannot tell which proxy asked.
- If the parent withholds drand beacons for more than 15 minutes the proxy refuses requests rather than relying on the NSM clock alone.
Things the proxy does not hide: that a request happened, when, roughly how large it was, and the upstream's IP address. Traffic analysis on the parent is possible; contents are not.
Hiding the upstream host name from the parent
The parent carries the enclave's outbound bytes, so by default it would learn
which hosts the enclave talks to. Two measures, both baked into the
measurement via config/enclave.toml:
- DNS-over-HTTPS inside the enclave. Names are resolved by the enclave
itself, over HTTPS to resolvers that are contacted by IP and whose
certificates are verified (Cloudflare and Google by default). The parent only
ever sees
CONNECT <ip>:<port>. - Encrypted Client Hello. When the upstream publishes an ECH config in its
HTTPS DNS record (Cloudflare-fronted sites do), the TLS ClientHello's server
name is encrypted and the parent sees only the ECH public name. Responses
carry
x-timelock-proxy-ech(accepted,rejected,not-offered, or rustls'soffered/greasestates) so the sender can tell. For upstreams without ECH the SNI is visible to the parent, and for any upstream the IP is; a dedicated IP identifies a service, a CDN IP does not. - A WireGuard relay (Mullvad). With
[privacy.mullvad]enabled, every upstream connection, including DNS-over-HTTPS, goes through a WireGuard tunnel that the enclave runs entirely in userspace (boringtun for WireGuard, smoltcp for TCP/IP). The parent forwards opaque UDP datagrams to one relay address and learns nothing about destinations. The enclave generates its WireGuard key at boot and registers it with Mullvad's API; the account number is handed in by the parent (/etc/timelock-proxy/mullvad-account) and is never part of the image. Mullvad caps accounts at five devices. The enclave leavesreserved_device_slots(default 1) free for the account owner and uses at most the rest. Device names listed in/etc/timelock-proxy/mullvad-protected-devices(from~/.config/timelock-proxy/mullvad-protected-devicesat deploy time) are never deleted; if room cannot be made without touching them, registration fails and the relay stays down. Among the rest it deletes the most recently created first, which are its own earlier keys. Mullvad assigns device names, so add your devices' names to that file. The relays it may use are pinned in the config, so they are part of the measurement. If the tunnel is down, upstream requests fail with 502 rather than being sent directly. Mullvad sees the upstream IP and, without ECH, the SNI; it does not see content, which stays end to end between the enclave and the upstream.
Together: the parent sees WireGuard ciphertext to a Mullvad relay; Mullvad sees connections from its own relay to upstream IPs; upstreams see Mullvad's exit IP. Nobody but the upstream sees plaintext.
The enclave must run without --debug-mode. In debug mode Nitro reports
all-zero PCRs and tlproxy verify rejects the proxy.
Verifying a proxy before you use it
You need the expected PCR values. Get them either from the operator's
published build/out/pcrs.json, or better, reproduce them yourself (see
Reproducible builds).
cargo build --release -p tlproxy-cli
./target/release/tlproxy verify --proxy https://proxy.girl.surgery --pcrs build/out/pcrs.json \
--expect-recipient age1... # the recipient in config/enclave.toml at that commit
verify connects over TLS, captures the certificate the server proved
possession of during the handshake, fetches the attestation document over the
same connection with a fresh nonce, validates the COSE signature chain up to
the AWS Nitro root, checks the nonce, checks in its own code that user_data
equals the captured certificate's SubjectPublicKeyInfo (and that the NSM
public_key field is unused), checks PCR0/1/2 against the file, and prints
what the enclave enforces. The key-binding check has fixture tests against a
captured real attestation document, including a negative test with a
different certificate. --pcrs is mandatory: without it a genuine but
arbitrary enclave would pass, so the only way to skip it is the explicitly
named --insecure-skip-measurement. The measurements files in
measurements/ also carry the policy (age_recipient, lock_seconds) the
measured config enforces, and verify checks the enclave reports the same.
Values printed as "(measured)" are backed by the PCR match; anything else is
unverified.
To send a request through a proxy after verifying it in one step:
tlproxy request --proxy https://proxy.girl.surgery --pcrs build/out/pcrs.json \
-X POST -H 'content-type: application/json' -d '{"hello":"world"}' \
https://httpbin.org/post
Plain curl also works once you have verified the certificate fingerprint
verify prints (--pinnedpubkey or -k plus your own check), because the
proxy is just HTTPS:
curl -k https://proxy.girl.surgery/api.example.com/v1/things -H 'authorization: Bearer ...'
Responses carry x-timelock-proxy-record (the record id) and
x-timelock-proxy-unlock-round, a guaranteed minimum: the operator cannot
open the record before that drand round (time = genesis_time + (round-1) * 3s); long exchanges are sealed to a later round. Requests whose upstream is
this proxy itself, or that carry the proxy's own forwarding marker (a request
looping back through any name that resolves here), are refused.
Operating it
One-time: create your identity
cargo run -p tlproxy-cli -- keygen # writes ~/.config/timelock-proxy/identity.txt
Put the printed age_recipient into config/enclave.toml. That file is part of
the enclave measurement, so the recipient is what verifiers check. Keep the
identity file offline if you can; nothing on the server needs it.
Deploy
REGION=us-east-2 INSTANCE_TYPE=c6a.xlarge ./deploy/deploy.sh
If ~/.config/timelock-proxy/mullvad-account exists (or MULLVAD_ACCOUNT_FILE
points at a file) it is installed on the instance as
/etc/timelock-proxy/mullvad-account, readable only by the host daemon's user.
This creates a key pair, security group (22 and 443 open) and a Nitro-enabled
Amazon Linux 2023 instance in the default VPC, installs docker and nitro-cli,
syncs the repository, runs the reproducible build on the instance, installs
two systemd units (timelock-proxy-enclave, timelock-proxy-host) and copies
pcrs.json and build-info.json back into build/out/. Re-running it
rebuilds and redeploys to the same instance.
Records accumulate in /var/lib/timelock-proxy/records/ on the instance. Copy
them off however you like (rsync, an aws s3 sync cron job); they are
ciphertext.
Decrypt, a week later
tlproxy decrypt --identity ~/.config/timelock-proxy/identity.txt --out decrypted/ records/*.age
Each record becomes decrypted/<id>.json with the request and response
(headers, base64 body, and body_text/body_json when it decodes). Records
whose round is not yet published are reported with the time they unlock.
Reproducible builds
build/build-eif.sh needs Linux with docker and
nitro-cli (no Nitro hardware
required). It builds a static musl binary in a pinned container and turns it
into an enclave image:
rust:1.97.1-slim-bookwormpinned by digest;- Debian packages (a C compiler for
ring) from a dated snapshot.debian.org; - crates exactly as in
Cargo.lock(--locked); - paths remapped,
SOURCE_DATE_EPOCH=0, image layers with rewritten timestamps; nitro-cli build-enclave, whose kernel and init blobs are part of PCR0/PCR1; the build script refuses to run with any version other than the pinned one (Nitro CLI 1.5.0, from the Amazon Linux 2023 package), so a verifier on a different version gets an error rather than a silently different PCR0.- one vendored, patched dependency:
vendor/i18n-embed-fl(pulled in byage), whose upstream 0.9.x release emits generated code inHashMaporder and made roughly half of all builds differ by a few bytes. Seevendor/i18n-embed-fl/PATCHED.md.
git clone https://github.com/sophiawisdom/timelock-proxy && cd timelock-proxy
git checkout <commit the operator claims to run>
./build/build-eif.sh
cat build/out/pcrs.json # compare PCR0/1/2 with what `tlproxy verify` shows
Layout
config/enclave.toml baked-in policy: recipient, lock, drand chain (measured)
crates/common record format, sealing, drand chain maths, framing
crates/enclave the enclave binary: TLS, attestation, proxy, sealing
crates/host untrusted parent daemon: TCP<->vsock, CONNECT tunnel, record sink
crates/cli tlproxy: keygen, verify, request, decrypt
build/ Dockerfile + build-eif.sh (reproducible EIF)
deploy/ deploy.sh, bootstrap, systemd units
Local development
Everything runs on a laptop with --dev, which swaps vsock for loopback TCP
and disables attestation (and proves nothing):
cargo build
./target/debug/tlproxy-host --dev --allow-private --no-forwarder --records-dir /tmp/records &
TLPROXY_DEV_LOCK_SECONDS=30 ./target/debug/tlproxy-enclave --dev &
./target/debug/tlproxy request --proxy https://127.0.0.1:8443 --allow-dev https://httpbin.org/get
sleep 40
./target/debug/tlproxy decrypt --identity ~/.config/timelock-proxy/identity.txt /tmp/records/*.age
Limitations
- HTTP/1.1 only, on both sides. WebSockets and
CONNECTare not supported. - Bodies are captured up to
max_body_bytes(64 MiB); larger bodies are still forwarded in full but the record is marked truncated. - Records are held in enclave memory until the parent acknowledges them; if the parent is down for long enough to fill the queue (4096 records), records are dropped and the enclave logs it. Sender traffic is never blocked on logging.
- A fresh TLS key per boot means senders must re-verify after any restart.
- IPv4 only for upstreams (the parent instance has no IPv6).
- As with any live proxy, the sender and intended upstream see the request and response immediately. The parent/network can observe traffic timing and byte counts, and the on-disk ciphertext exposes its size and creation time. The seven-day guarantee covers request/response content, not traffic-analysis metadata.
License
MIT.