A CLI for Untangling Privacy Protocols

Debugging privacy-preserving protocols is notoriously difficult. Oblivious HTTP (OHTTP), for example, involves multiple steps distributed across four different parties, relies on binary HTTP encoding, and pulls details from a number of draft RFCs. To address this, Cloudflare is open-sourcing privacy-client (pvcli), a CLI tool released under the Apache-2.0 License. The tool condenses the complexity of these protocols into a single command while exposing every step of the process for inspection.

The motivation is practical. As Cloudflare’s Privacy team expanded its product suite to power services like Apple’s Private Relay, Microsoft’s Edge Secure Network VPN, and Flo Health’s Anonymous Mode, the operational complexity grew. Specialized customer requirements and deep domain knowledge increased friction during both development and incident response.

Why OHTTP Is a Challenge

OHTTP provides a simple privacy guarantee: no single party can know both who made a request and what they requested. This is achieved by splitting the path between a relay and a gateway, operated by two non-colluding parties.

The protocol is a multi-step dance:

  1. The client fetches a public key from the gateway.
  2. The client encrypts the request and forwards it to the relay.
  3. The relay strips identifying client information and sends the encrypted request to the gateway.
  4. The gateway decrypts the request and forwards it to the target server.
  5. The target sends its response back to the gateway.
  6. The gateway encrypts the response and returns it to the relay.
  7. The relay sends the encrypted response to the client.
  8. The client decrypts and reads the plaintext response.

Each of these steps is a potential failure point. Existing debugging workflows were unreliable and tedious. Cloudflare frequently wrote one-off custom clients for specific customer deployments. Determining whether a fault sat in their own system or the customer’s system was time-consuming. And because OHTTP relies on binary HTTP encoding, verifying the raw bits manually was error-prone.

Debugging Without pvcli

To illustrate the problem, consider an operator running an OHTTP relay in front of a customer’s gateway and performing an end-to-end test.

Step one: Fetch the public key. A simple curl command to the customer gateway returns a long hex-encoded binary string. Interpreting that response requires parsing fields manually per RFC 9458 Section 3. The operator must break down each segment to identify the public key ID, the asymmetric encryption method (for example, DHKEM(X25519, HKDF-SHA256)), and the asymmetric and symmetric cryptography IDs, then repeat the process for every key in the string.

Step two: Build the binary HTTP request. The original HTTP request must be converted into its binary HTTP representation per RFC 9292. This is a manual process, assisted by gathering bespoke scripts and verifying each byte: the length indicators, the method (POST), the scheme (https), and the authority field such as target.ohttp.info.

Step three: Encrypt and transmit. The request is encrypted via another one-off script using the earlier public key protocol. The operator concatenates the public key ID, asymmetric encryption method ID, and symmetric encryption method ID to form a header, then appends the encrypted binary HTTP request. The resulting bytes are inserted as the body of a wrapper HTTP request and sent to the relay.

When the response arrives, it is not self-explanatory. The operator frequently must contact the customer to inspect gateway logs. After re-checking the crafted bits, a common bug emerges: binary HTTP is length-prefixed. Specifying a length of 0x0a (10 in decimal) should yield 10 bytes of payload, but sometimes ends up with an extra byte — like the space character (0x20) accidentally added during the build process. Removing that character makes the request work, but finding it required hours of investigation.

Debugging With pvcli

With pvcli, the operations above reduce to a single command:

pvcli -vvv --ohttp \
    --first-hop https://relay-cloudflare.ohttp.info \
    --proxy https://gateway.ohttp.info \
    -X POST \
    --header "content-type: application/json" \
    --data '{"test":1}' \
    https://target.ohttp.info/anything

The tool handles binary parsing and encryption internally, printing logs when a deeper dive is required:

TRCE Full decoded client config: ClientConfig { key_configs: [KeyConfig {
  key_id: 85,
  key: PublicKey { kem_id: X25519HkdfSha256, bytes: b9bb667e...0832540 },
  symmetric_algorithms: [(HkdfSha256, AesGcm128)] },
  ...
] }
...
DEBG Creating POST request to https://target.ohttp.info/anything
DEBG Parsed URI: scheme: Some("https"), host: Some("target.ohttp.info"), port: None, path: /anything
TRCE Building request with headers: ["content-type: application/json", "User-Agent:pvcli/0.1.0"], body: Some(b"{\"test\":1}")
...
TRCE BHTTP encoded request bytes, hex: 0204504f5354056874747073117461726765742e6f687474702e696e666f092f616e797468696e670c636f6e74656e742d74797065106170706c69636174696f6e2f6a736f6e0a757365722d6167656e740b7076636c692f302e312e30000a7b2274657374223a317d00
...
TRCE Encrypted request bytes, hex: 5500200001000164f03cef18f625f1dcd9fb26aa802081196bd6d7ba225bf60d3ba65f4f669d59f22d4d37dfd1e45ac9d5097bd7d8e186576ebff850509b5708fe1dbe4c88a7b93b95d153ee203382979063603fb1759bcc41049ac40950f80564de9d45fe8aa4e24259e313da4768b469bbb03037b9b0c34ae5f1b65dea09b8d25f2d5bb4833b68d516a3b19f0265841d5f23a11e74a8d17a04b616e888dd60f147

What was a fragile process involving bit manipulation, glue-code scripts, and RFC lookups becomes one straightforward command.

What pvcli Offers

pvcli’s interface takes cues from curl; many arguments match curl’s conventions to follow the principle of least surprise. A basic GET request to Cloudflare’s cdn-cgi endpoint is enough to get started:

pvcli https://cloudflare.com/cdn-cgi/trace
pvcli --http3 https://cloudflare.com/cdn-cgi/trace

For detailed logs, the -v flag reveals what is happening under the hood:

# more logs
pvcli -v --http3 https://cloudflare.com/cdn-cgi/trace
# even more logs
pvcli -vvv --http3 https://cloudflare.com/cdn-cgi/trace

For OHTTP requests, use the --ohttp flag. The relay is passed as --first-hop, while the gateway is passed as --proxy. The target can be an echo server to verify exactly what the server sees. The command can be tested against the demo relay, gateway, and target hosted at ohttp.info:

pvcli -vvv --ohttp \
    --first-hop https://relay-cloudflare.ohttp.info \
    --proxy https://gateway.ohttp.info \
    -X POST \
    --header "content-type: application/json" \
    --data '{"test":1}' \
    https://target.ohttp.info/anything
# abridged output below
...
{
  "request": {
    "method": "POST",
    "url": "https://target.ohttp.info/anything",
    "headers": {
      "content-length": "10",
      "content-type": "application/json",
      "user-agent": "pvcli/0.1.0",
      "x-ohttp-gateway": "true"
    },
    "body": {
      "test": 1
    }
  },
  "cf": {
    "ip": "Unknown",
    "country": "Unknown",
    "colo": "Unknown",
    "asn": "Unknown",
    "asOrganization": "Unknown"
  },
  "message": "Hello from Lilo's OHTTP target!",
...

Some scenarios require passing headers to the relay rather than the target. That is handled by --first-hop-header:

pvcli -vvv --ohttp \
    --first-hop https://relay-cloudflare.ohttp.info \
    --first-hop-header "authorization: Bearer relay-token" \
    --proxy https://gateway.ohttp.info \
    -X POST \
    --header "content-type: application/json" \
    --data '{"test":1}' \
    https://target.ohttp.info/anything

Similarly, authenticating to the relay with mutual TLS is supported via --first-hop-client and --first-hop-key options:

pvcli -vvv --ohttp \
    --first-hop https://relay-cloudflare.ohttp.info \
    --first-hop-client ./relay-client.pem \
    --first-hop-key ./relay-client.key \
    --proxy https://gateway.ohttp.info \
    -X POST \
    --header "content-type: application/json" \
    --data '{"test":1}' \
    https://target.ohttp.info/anything

This flexibility means that testing a full OHTTP request with a relay, a gateway, arbitrary headers, and mTLS — or merely inspecting the OHTTP key configuration — are all single-command operations including debugging output.

One Tool Instead of Several

Existing OHTTP implementations, such as Martin Thomson’s Rust work and Chris Wood’s Go implementation, were useful during the original OHTTP buildout. But pvcli is not limited to OHTTP. The roadmap includes broader privacy-preserving protocol coverage, aiming to combine OHTTP, CONNECT proxying, MASQUE, and Privacy Pass (coming soon) within one interface. That consolidation is what sets the tool apart from the current OSS debugging landscape.

How to Contribute

Oblivious HTTP is a strong protocol, and the goal is to help others write and debug their own implementations. Contributions are open via pull request at https://github.com/cloudflareresearch/pvcli.

Areas on the to-do list include MASQUE support for proxying TCP over HTTP/3, and UDP or IP over HTTP/2 and HTTP/3. For OHTTP, planned additions are post-quantum cryptography support, timing and latency metrics, Chunked OHTTP support, and improved logging.

Contact Cloudflare directly if your organization is interested in using their OHTTP Relays and Gateways.