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 key and a plaintext such that ciphertext = E(key, nonce, plaintext)

where:

  • key and plaintext are private witnesses
  • nonce is a public input
  • ciphertext is 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 message
    • encryptionKey: a 32-byte secret key
  • Public inputs:
    • encryptionNonce: a 12-byte ChaCha20 nonce
    • zkNonce: a contextual binding value (for replay protection)
  • Public outputs:
    • ciphertext: the ChaCha20-encrypted message
    • authHash: 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

  1. Encrypted credential systems
    Proving possession of a valid encrypted credential without revealing its contents.

  2. Privacy-preserving authentication
    Demonstrating knowledge of a decryption key for an encrypted token without revealing the key or token.

  3. Verifiable off-chain computation
    Proving that a ciphertext was generated honestly by a specific encryption algorithm.

  4. Auditable encryption in compliance systems
    Showing that data was encrypted under a required standard without revealing the data itself.