REDACT is a four-stage pipeline: a notarized TLS session, a commitment to the response, selective disclosure of one field, and a zero-knowledge proof of a predicate over that field. This page describes each stage, the trust assumptions, and where the design deliberately gives up generality for safety.
A conventional oracle asks a trusted party to observe the world and report it. REDACT inverts this. The user already observes the world every time they load an authenticated page. The only thing missing is a way to prove what they saw without handing over the session that let them see it.
TLS already provides integrity and origin authentication for the response. What it does not provide is transferability: the session key that proves the response came from the server is the same key that would let the user forge one. REDACT resolves this by splitting the session key between the client and a notary using multi-party computation, so that neither can forge alone, and then proving statements about the transcript in zero knowledge.
Any origin. No cooperation from the origin.
Cannot see plaintext. Cannot forge alone. Replaceable.
Roughly 300 bytes. Fixed circuit. Chain-agnostic.
Origin, predicate, timestamp. Nothing else.
The client initiates a TLS 1.3 handshake with the origin. Key derivation for the session is performed jointly with a notary using two-party computation. The client holds one share of the encryption key and the notary holds the other. The origin sees an ordinary TLS client.
Because the client never holds the full session key, it cannot produce a ciphertext that would decrypt to an arbitrary plaintext under that key. Because the notary never sees the plaintext, it learns nothing about the request, the response, or the user's credentials.
client_share, notary_share = mpc_derive(ecdhe_secret) request = encrypt(client_share, notary_share, GET /api/v1/users/self) response = recv() // ciphertext, held by client
Latency overhead of the MPC is currently 300 to 900 ms depending on response size. The origin's own rate limits and anti-bot controls apply unchanged, since the request is indistinguishable from a normal browser request.
After the session closes, the notary signs a commitment to the full response ciphertext together with the session metadata: origin hostname, server certificate fingerprint, and a timestamp. The notary signs the commitment blind. It attests that this ciphertext was produced under the jointly derived key, which is equivalent to attesting that the origin sent it.
commitment = H(ciphertext || origin || cert_fingerprint || ts) notary_sig = sign(notary_key, commitment)
The commitment is the anchor for everything downstream. A proof is only meaningful relative to a commitment, and a commitment is only meaningful relative to a notary key that verifiers recognize.
The client decrypts the response locally (it now has both key shares, since the session is over and the notary releases its share after signing). The client then identifies exactly one field by a JSON path, or for HTML responses, by a CSS selector resolved against a canonical DOM serialization.
Only the byte range of that field enters the proof as a private witness. Everything else in the response is committed to but never opened. This is what makes the disclosure boundary structural rather than a matter of policy: the circuit has no input for the rest of the response.
The client generates a Groth16 proof over a fixed circuit. The public inputs are the commitment, the origin hostname, the field path hash, the predicate, and the holder address. The private witnesses are the session key shares, the plaintext byte range of the field, and the Merkle path from that range to the commitment.
The circuit asserts three things: that the private range decrypts correctly under the committed key, that the range sits at the declared path in the response, and that the parsed value satisfies the predicate.
public: commitment, origin_hash, path_hash, predicate, holder private: key_shares, field_bytes, merkle_path assert decrypt(key_shares, ciphertext[range]) == field_bytes assert merkle_verify(field_bytes, merkle_path, commitment) assert eval(predicate, parse(field_bytes)) == true
Supported predicates in v0.2: >=, <=, = over integers and fixed-point decimals, and = over hashed strings. Range proofs and set membership are scheduled for v0.3.
Proving time on a 2024 laptop is 1.5 to 4 seconds. On a recent phone, 6 to 12 seconds. Proof size is constant at roughly 300 bytes regardless of response size.
The proof, the public inputs, and the notary signature are submitted to the REDACT registry contract on the target chain. The contract verifies the proof, checks the notary key against its allowlist, and mints a non-transferable credential to the holder address. The credential stores only the public inputs and a nullifier derived from the commitment, which prevents the same session from producing more than one credential.
Verifiers can read the credential directly or verify the proof offchain against the registry's public inputs. See Verify for both paths.