Proof of Encryption
A Proof of Encryption allows a prover to demonstrate that they know both the plaintext message and the encryption key corresponding to a given ciphertext, without revealing either of them. This is a zero-knowledge proof of correct symmetric encryption.
In this chapter, we focus on the ChaCha20 stream cipher and show how one can prove, in zero knowledge, that a ciphertext was correctly produced by encrypting a private plaintext under a private key using ChaCha20.
This type of proof is useful whenever a party must convince others that:
- a ciphertext was generated honestly
- and that they possess the underlying secret material
without disclosing the sensitive data itself.
Why Proving Correct Encryption Matters
In many systems, ciphertexts circulate in public (blockchains, logs, databases), but their origin and correctness are critical.
For example, a verifier may want to know:
- that an encrypted credential corresponds to a valid message
- or that a stored ciphertext was produced using a specific algorithm
without ever seeing the plaintext or key.
A Proof of Encryption answers exactly this question.
About ChaCha20
ChaCha20 is a symmetric stream cipher. It generates a pseudorandom keystream from:
- a secret key
- a public nonce
- a counter (0 by default)
which is then XORed with the plaintext to produce the ciphertext.
To avoid keystream reuse attacks, the pair (key, nonce) must never be reused. For this reason, ChaCha20 takes an explicit encryption nonce input.
From the user’s perspective, ChaCha20 is simply an encryption function:
ciphertext = E(key, nonce, plaintext)
Defining Proof of Encryption
The zero-knowledge statement we want to prove is:
Statement: I know a
keyand aplaintextsuch thatciphertext = E(key, nonce, plaintext)
where:
keyandplaintextare private witnessesnonceis a public inputciphertextis a public output
Using the ZK-Toolbox (Developer View)
In practice, ChaCha20 uses:
- a 32-byte key
- a 12-byte nonce
- a plaintext of arbitrary length
However, SNARK circuits must be statically sized. Therefore, we restrict:
- plaintext and ciphertext to exactly 128 bytes
This is a practical tradeoff between generality and circuit size.
Inputs and Outputs
- Private inputs:
plaintext: a 128-byte messageencryptionKey: a 32-byte secret key
- Public inputs:
encryptionNonce: a 12-byte ChaCha20 noncezkNonce: a contextual binding value (for replay protection)
- Public outputs:
ciphertext: the ChaCha20-encrypted messageauthHash: binds this proof instance to the zkNonce
Example
import { ProofOfEncryption, pad, randomBigInt32ModP, poseidon, uint8ArrayToBigInt } from "@prifilabs/zk-toolbox";
// npm install @noble/ciphers
import { chacha20 } from '@noble/ciphers/chacha';
import { utf8ToBytes } from '@noble/ciphers/utils';
import { randomBytes } from '@noble/ciphers/webcrypto';
// step 1: generate the inputs
const privateInputs = {
plaintext: pad(utf8ToBytes('The quick brown fox jumps over the lazy dog!')),
encryptionKey: randomBytes(32),
};
const publicInputs = {
encryptionNonce: randomBytes(12),
zkNonce: randomBigInt32ModP(),
};
// step 2: generate the proof
const proofOfEncryption = new ProofOfEncryption();
const { proof, publicOutputs } = await proofOfEncryption.generate(privateInputs, publicInputs);
// step 3: verify the output (optional)
const ciphertext = chacha20(privateInputs.encryptionKey, publicInputs.encryptionNonce, privateInputs.plaintext);
console.assert(Buffer.from(publicOutputs.ciphertext).toString('hex') === Buffer.from(ciphertext).toString('hex'));
const authHash = poseidon([
uint8ArrayToBigInt(privateInputs.encryptionKey.slice(0, privateInputs.encryptionKey.length/2)),
uint8ArrayToBigInt(privateInputs.encryptionKey.slice(privateInputs.encryptionKey.length/2)),
publicInputs.zkNonce
]);
console.assert(publicOutputs.authHash === authHash);
// step 4: verify the proof
const res = await proofOfEncryption.verify(proof, publicInputs, publicOutputs);
console.assert(res);
Behind the Scenes: The CIRCOM Circuit
Implementing ChaCha20 in Circom
Implementing ChaCha20 inside a SNARK circuit is non-trivial due to:
- word-level operations
- rotations
- modular additions
We reuse the excellent work from the Reclaim Protocol which provides a verified ChaCha20 Circom implementation
Our Circuit
Our Proof of Encryption circuit simply wraps the ChaCha20 component and exposes appropriate inputs and outputs:
pragma circom 2.0.0;
include "../node_modules/circomlib/circuits/poseidon.circom";
include "./lib/ChaCha20/chacha20-bits.circom";
template Key2Num() {
signal input in[8][32];
signal output out[2];
for (var k = 0; k<2; k++) {
var lc=0;
var e = 1;
for (var i = k*4; i<((k+1) * 4); i++) {
for (var j = 31; j>=0; j--) {
lc += in[i][j] * e;
e = e + e;
}
}
lc ==> out[k];
}
}
template ProofOfEncryption(N) {
// private inputs
signal input plaintext[N][32]; // N * 32-bit words => N * 4 byte words
signal input encryptionKey[8][32]; // 8 * 32-bit words = 32 bytes
// public inputs
signal input encryptionNonce[3][32]; // 3 * 32-bit words = 12 bytes
signal input encryptionCounter[32]; // 32-bit word
signal input zkNonce;
// public outputs
signal output ciphertext[N][32]; // N * 32-bit words => N * 4 byte words
signal output authHash;
// compute encryption
component chacha20 = ChaCha20(N, 32);
chacha20.key <== encryptionKey;
chacha20.nonce <== encryptionNonce;
chacha20.counter <== encryptionCounter;
chacha20.in <== plaintext;
ciphertext <== chacha20.out;
// transform key as bytes array into field values
component key2Num = Key2Num();
key2Num.in <== encryptionKey;
// context binding
component authHasher = Poseidon(3);
authHasher.inputs <== [key2Num.out[0], key2Num.out[1], zkNonce];
authHash <== authHasher.out;
}
component main {public [encryptionNonce, encryptionCounter, zkNonce]} = ProofOfEncryption(32);
Context Binding
For the context binding, we need to bind zkNonce to the 256-bits encryptionKey which is byte array. However, Poseidon hashes field elements, not byte arrays so we need to convert the key into a field element first.
Here, we need to be very careful when converting bytes to a field element. As seen in Getting Started with zk-SNARKs, a field element is a positive integer modulo p and such value can be encoded on 254 bits but for instance 2^254 is overflowing. As explain here, converting an byte array of length greater or equal than 254 bits can have some undesired side-effects that might allow an attacker to forge a valid proof.
This is the reason why we split the 256-bits key into two byte arrays of 128 bits each before converting them into two signals using the function Key2Num. The authHash binding hash is obtained by hashing these two pieces of the encryption key and the zkNonce together.
Conclusion and Applications
Proof of Encryption extends zero-knowledge proofs beyond abstract arithmetic relations into concrete cryptographic algorithms.
It enables provable statements about encrypted data while preserving full confidentiality.
Example Applications
-
Encrypted credential systems
Proving possession of a valid encrypted credential without revealing its contents. -
Privacy-preserving authentication
Demonstrating knowledge of a decryption key for an encrypted token without revealing the key or token. -
Verifiable off-chain computation
Proving that a ciphertext was generated honestly by a specific encryption algorithm. -
Auditable encryption in compliance systems
Showing that data was encrypted under a required standard without revealing the data itself.