README-v1.md
On this page

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

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

  1. At boot the enclave generates a fresh P-256 TLS key pair. It is never written anywhere; a restart means a new key.
  2. GET /.well-known/attestation?nonce=<hex> returns an attestation document signed by the Nitro Security Module whose user_data is the DER SubjectPublicKeyInfo of that TLS certificate, plus the certificate itself, the operator's age recipient, the lock duration and the drand chain.
  3. Any other request https://<proxy>/<upstream-host>[:port]/<path>?<query> is forwarded live as https://<upstream-host>/<path>?<query>, method, headers and body intact (hop-by-hop headers stripped, Host rewritten). 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.
  4. 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 published lock_seconds from 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.
  5. The sealed record is handed to the parent instance over vsock and written to /var/lib/timelock-proxy/records/<id>-r<round>.age.
  6. 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):

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:

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:

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:

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

License

MIT.