Adding an anonymity revoker to Tornado Cash: Implementation

19 minute read

This article continues the work presented in Adding an anonymity revoker to Tornado Cash: Design. Here, we provide a detailed implementation of the approach previously described, based on the Tornado Cash protocol. The work was a collaborative effort between Antonio Mejias Gil (author of the linked Design post), Marcin Górny and César Descalzo (author of the implementation).


0. Introduction

Tornado Cash stands as one of Ethereum’s most significant privacy protocols, enabling users to deposit and withdraw funds from an asset pool while maintaining transaction unlinkability, meaning no observer can connect any specific deposit to its corresponding withdrawal within the protocol’s anonymity set. The protocol is based on the concept of a shielded pool, which is a smart contract that allows users to deposit funds linked to a unique note. The note is a cryptographic value that is used to prove the user’s ownership of the funds. The protocol uses zero-knowledge proofs to ensure that no one can connect a deposit to its corresponding withdrawal, while cryptographically guaranteeing that users can only withdraw funds they previously deposited.

Before November 2024, users whose assets had passed through Tornado Cash found themselves effectively blacklisted from many cryptocurrency exchanges, as OFAC’s sanctions meant that any wallet that had interacted with the protocol was considered “tainted.” However, this landscape changed significantly when a US Fifth Circuit Appeals Court ruled that the OFAC had exceeded its authority in sanctioning Tornado Cash’s smart contracts, effectively invalidating OFAC’s sanctions against them 1.

Despite this legal victory, the controversy surrounding Tornado Cash highlights the need for privacy solutions that can coexist with regulatory framworks. A compliant version of Tornado Cash can bridge the gap between users’ (legitimate) privacy needs and regulators’ concerns about illicit activities. By implementing a mechanism that maintains transaction privacy by default while allowing for selective transparency when required by an authority. Following Aleph’s Zero naming convention, we refer to the authority with this oversight capability as the Anonymity Revoker.

1. Original Tornado Cash Protocol

In our previous post, we described how the Tornado Cash protocol works from a theoretical perspective. Recall that the protocol has two main components: the deposit and the withdrawal. During the deposit, the user samples a secret $k$ and a nullifier $h$ and computes the commitment as the hash of the secret and nullifier $n = H(k || h)$. The commitment is then used to update the Merkle tree in the contract’s storage. At withdrawal time, the user provides a proof of knowledge of two values $k$ and $h$ such that $H(k || h)$ is included in the Merkle tree, without revealing $k, h$ or the commitment.

Before delving into our compliant implementation, we provide a brief overview of the original Tornado Cash protocol from the implementation perspective. Note that the naming conventions and the overall structure of the codebase might differ slightly from the theoretical foundation presented in the previous post. We will be using tornado-cash-rebuilt as our foundation - a simplified fork of the original protocol that includes several improvements.

The repository contains only 5 Solidity contracts (excluding tests) but we are only interested in 3 of them:

  • Tornado.sol: Main deposit and withdrawal contract logic.
  • MerkleTreeWithHistory.sol: Merkle tree logic, initialized with “empty” leaves.
  • Verifier.sol: Groth16 verifier for the circuit withdraw.circom.

In addition, the repository contains several circom circuits in the circuits folder. Namely:

  • withdraw.circom: Computes a proof for the withdrawal relation $\mathcal{R}_w$
  • merkleTree.circom: Computes an opening proof of a leaf in the Merkle tree

Using the notation from the previous post, Tornado Cash implements the following cryptographic primitives:

  • The size of secret and nullifier is fixed to 31 bytes in order to fit into 248-bit used for representing points on the (Baby Jubjub elliptic curve)[https://iden3-docs.readthedocs.io/en/latest/iden3_repos/research/publications/zkproof-standards-workshop-2/baby-jubjub/baby-jubjub.html] [2], which is defined over the field $\mathbb{F}_{p}$ with

$p = 21888242871839275222246405745257275088548364400416034343698204186575808495617$

  • A Pedersen hash [3] function is used to create commitments during deposits. This hash function takes the concatenation of the nullifier and secret as input and maps them to points on the Baby Jubjub curve, producing a commitment value in $\mathbb{F}_{p}$.
  • Each Merkle tree $\mathcal{T}$ has a fixed height of $n=20$ and is initialized with all leaves set to pedersen("tornado"). Since most intermediate nodes remain unchanged when adding a new leaf, only the nodes along the path from the new leaf to the root need to be recomputed. This significantly reduces the computational cost of updating the tree. A MiMC (Minimal Multi-Round Hash) [4] permutation is used as the compression function for the nodes.
  • The protocol uses Groth16 as its zk-SNARK proof engine. A critical aspect of Groth16 is its requirement for a trusted setup phase to generate the proving and verification keys. While this setup could technically be performed by a single party, it is typically conducted through a multi-party powers of tau ceremony to distribute trust - as long as one participant in the ceremony is honest, the setup remains secure. Although the setup can be performed on-chain, it is usually done off-chain for efficiency, with the resulting proving and verification keys then hardcoded into the Verifier.sol contract. It’s worth noting that during testing, this setup process can be quite time-consuming and computationally intensive.

The snippet below handles the logic of a deposit in the Tornado.sol contract.

function deposit(bytes32 _commitment) external payable nonReentrant {
		require(!commitments[_commitment], "The commitment has been submitted");
		
		uint32 insertedIndex = _insert(_commitment);
		commitments[_commitment] = true;
		_processDeposit();
		
		emit Deposit(_commitment, insertedIndex, block.timestamp);
}

Due to its cryptographic simplicity, the bulk of the contract is dedicated to the Withdrawal logic. The withdraw function in Solidity ($C_{\mathsf{withdraw}}$) is as follows:

function withdraw(
    uint256[2] calldata _pA,
    uint256[2][2] calldata _pB,
    uint256[2] calldata _pC,
    bytes32 _root,
    bytes32 _nullifierHash,
    address _recipient,
    address _relayer,
    uint256 _fee,
    uint256 _refund
) external payable nonReentrant {
    require(_fee <= denomination, "Fee exceeds transfer value");
    require(!nullifierHashes[_nullifierHash], "The note has been already spent");
    require(isKnownRoot(_root), "Cannot find your merkle root"); // Make sure to use a recent one
    require(
        verifier.verifyProof(
            _pA,
            _pB,
            _pC,
            [
                uint256(_root),
                uint256(_nullifierHash),
                uint256(uint160(_recipient)),
                uint256(uint160(_relayer)),
                _fee,
                _refund
            ]
        ),
        "Invalid withdraw proof"
    );

    nullifierHashes[_nullifierHash] = true;
    _processWithdraw(_recipient, _relayer, _fee, _refund);
    emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee);
}

The withdraw method receives the following inputs:

  • pA, pB, pC: The Groth16 zero-knowledge proof consists of three uncompressed elliptic curve points:

    • pA: A point on the Baby Jubjub curve represented by [x, y] coordinates (2 uint256 values)
    • pB: A point on a quadratic extension curve represented by [[x1, x2], [y1, y2]] coordinates (4 uint256 values)
    • pC: Another point on the Baby Jubjub curve represented by [x, y] coordinates (2 uint256 values)
  • root: The Merkle tree root at the time of withdrawal ($o$ in the previous post). This must match a historical root stored in the contract to prove the commitment was previously included.

  • nullifierHash: The hash of the secret nullifier value ($h$ in the previous post). This prevents double-spending by marking used nullifiers as spent

  • recipient: The Ethereum address that will receive the withdrawn funds.

  • relayer: The address of an optional third-party that submits the transaction on behalf of the user. The function of the relayer is further explained below.

  • fee: The amount of tokens to pay the relayer for submitting the transaction.

When called, it first performs several crucial validations: it checks that the requested fee isn’t larger than the deposited amount, verifies that the nullifier hasn’t been used before (preventing double-spending), and confirms that the provided Merkle root exists in the system’s history. The most critical step is the verification of the zero-knowledge proof through the verifyProof call, which cryptographically ensures that the caller knows a secret corresponding to a commitment in the Merkle tree, without revealing which one. If all checks pass, the function marks the nullifier as spent and processes the withdrawal, distributing the funds between the recipient and the relayer (if one is used).

The relayer system is an optional feature that addresses a critical privacy challenge in the withdrawal process. Without it, users would need to fund their withdrawal address with ETH to pay for gas fees, potentially creating unwanted links between their deposits and withdrawals. To solve this, the protocol allows third-party relayers to submit withdrawal transactions on behalf of users. These relayers cover the associated gas costs and receive a small portion from the withdrawn amount as compensation (the fee). This system allows users to receive funds at completely new addresses that have never interacted with the blockchain.

Note that the verifier requires public inputs beyond the elliptic curve points, the Merkle root and the nullifier hash. These additional inputs (recipient address, relayer address, fee, and refund) are included in the proof verification despite not being directly related to the core withdrawal logic. This is a security measure implemented in the circuit: the circuit performs auxiliary computations on these values, effectively binding them to the proof itself and ensuring that these parameters cannot be tampered with after the proof is generated.


Let us now focus on the proof generation and verification process specified in the withdraw.circom circuit. The following snippet defines the CommitmentHasher template, which computes the Pedersen commitment to the secret and nullifier.

// computes Pedersen(nullifier + secret)
template CommitmentHasher() {
    signal input nullifier;
    signal input secret;
    signal output commitment;
    signal output nullifierHash;

    component commitmentHasher = Pedersen(496);
    component nullifierHasher = Pedersen(248);
    component nullifierBits = Num2Bits(248);
    component secretBits = Num2Bits(248);
    nullifierBits.in <== nullifier;
    secretBits.in <== secret;
    for (var i = 0; i < 248; i++) {
        nullifierHasher.in[i] <== nullifierBits.out[i];
        commitmentHasher.in[i] <== nullifierBits.out[i];
        commitmentHasher.in[i + 248] <== secretBits.out[i];
    }

    commitment <== commitmentHasher.out[0];
    nullifierHash <== nullifierHasher.out[0];
}

In particular, the CommitmentHasher template implements a cryptographic commitment using Pedersen commitments over the Baby Jubjub elliptic curve. It takes two 248-bit input signals: a nullifier and a secret. The circuit utilizes two Pedersen hashers: commitmentHasher processes the concatenated 496 bits of nullifier and secret to produce the commitment ($\mathsf{com} = h(\mathsf{nullifier} || \mathsf{secret})$), while nullifierHasher processes only the 248-bit nullifier to generate a public identifier ($\mathsf{nullifierHash} = h(\mathsf{nullifier})$). Both values are computed as points on the Baby Jubjub curve and mapped to field elements in $\mathbb{F}_p$.

A CommitmentHasher component is then used in the Withdraw template, which is responsible for generating the proof for the withdrawal relation $\mathcal{R_w}$. Here’s the relevant snippet:

// Verifies that commitment that corresponds to given secret and nullifier is included in the merkle tree of deposits
template Withdraw(levels) {
    signal input root;
    signal input nullifierHash;
    signal input recipient; // not taking part in any computations
    signal input relayer;  // not taking part in any computations
    signal input fee;      // not taking part in any computations
    signal input refund;   // not taking part in any computations
    
    signal input nullifier;
    signal input secret;
    signal input pathElements[levels];
    signal input pathIndices[levels];

    component hasher = CommitmentHasher();
    hasher.nullifier <== nullifier;
    hasher.secret <== secret;
    hasher.nullifierHash === nullifierHash;

    component tree = MerkleTreeChecker(levels);
    tree.leaf <== hasher.commitment;
    tree.root <== root;
    for (var i = 0; i < levels; i++) {
        tree.pathElements[i] <== pathElements[i];
        tree.pathIndices[i] <== pathIndices[i];
    }

    // Add hidden signals to make sure that tampering with recipient or fee will invalidate the snark proof
    // Most likely it is not required, but it's better to stay on the safe side and it only takes 2 constraints
    // Squares are used to prevent optimizer from removing those constraints
    signal recipientSquare;
    signal feeSquare;
    signal relayerSquare;
    signal refundSquare;
    recipientSquare <== recipient * recipient;
    feeSquare <== fee * fee;
    relayerSquare <== relayer * relayer;
    refundSquare <== refund * refund;
}

The overall logic of the Withdraw template is summarized in three steps:

  1. Compute the commitment and nullifier hash for the given secret and nullifier and verify that the computed nullifier hash matches the provided one.
  2. Verify that the commitment is included in the Merkle tree using the provided root and path using the MerkleTreeChecker component.
  3. Include additional constraints on the public input values (recipient, fee, relayer, refund) in the circuit. This is crucial because without these constraints, the circuit compiler would optimize away these unused inputs, making them vulnerable to tampering after proof generation. By incorporating them into the constraint system, we ensure these values become an integral part of the proof and cannot be modified once the proof is generated.

2. Compliant Implementation

Recall that in the previous post, we discussed the mechanism for revoking anonymity in Tornado Cash. The idea is to sample a nullifier $k$ uniformly at random and compute the commitment as a double hash of the nullifier. This commitment is then used to update the Merkle tree. At withdrawal time, the new nullifier hash used to prevent double-spending is defined as the encryption of the hash of the nullifier under the public key of the anonymity revoker. The prover must then provide a proof for the new relation $\tilde{\mathcal{R}}_w$ that verifies three things:

  1. The commitment is correctly computed
  2. The commitment is included in the Merkle tree
  3. The nullifier encryption is correctly computed

Modified Deposit Process

Let’s dive into how the new deposit process actually works in practice. The user performs the following steps:

  1. Sample a 248-bit string $k \xleftarrow{R} \{0, 1\}$ uniformly, which is again called the nullifier.
  2. Compute the commitment $n = H(H(k))$.

In the original implementation, $H$ is the Pedersen hash function, whose inputs are bit strings of length 248 and outputs a point on the Baby Jubjub elliptic curve. Instead of using the Pedersen hash twice, we compute the commitment as a composition of the Pedersen hash and the MiMC hash. More specifically, let $h_1: \{0, 1\}^{248} \to \mathbb{F}_p$ and $h_2’: \mathbb{F}_p \times \mathbb{F}_p \to \mathbb{F}_p$ be the Pedersen and MiMC hash functions respectively. MiMC operates on two field elements, but we adapt this by using the hash function $h_2$ defined as $h_2(P) = h_2’(P, P)$ where $P$ is the result of $h_1(k)$. Therefore, the commitment is computed as $n = h_2(h_1(k))$ in step 2. This design has the advantage of maintaining the security properties of the original protocol while simplifying the implementation, as the cryptographic primitives required by the circuit remain unchanged.

These two steps are executed on the client side, where JavaScript code invokes WebAssembly (WASM) modules to perform the actual cryptographic computations. The implementation utilizes circomlibjs, a JavaScript library that provides reference implementations of the cryptographic primitives defined in circomlib through WASM bindings.

After computing the note, the user can proceed to deposit the funds into the pool. The rest of the deposit process is identical to the original Tornado Cash protocol.

Modified Withdrawal Process

To withdraw the deposited funds, once the user has deposited some funds linked to a note $n$ with secret $k$, the user must compute the encrypted nullifier and generate a proof for the modified relation $\tilde{\mathcal{R}}_w$. The steps are as follows:

  1. The nullifier hash is encrypted under the public key of the anonymity revoker, resulting in $\mathsf{nullifierHashEncrypted} = \mathsf{Enc}_{pk}(h_1(k))$, where:

    • $pk$ is the public key of the anonymity revoker
    • $\mathsf{Enc}$ is the encryption function of a public-key encryption scheme (e.g., RSA, ElGamal, etc.)
  2. Generate a zero-knowledge proof $\pi$ for the modified relation $\tilde{\mathcal{R}}_w$ that proves:

    There exists a nullifier $k$ such that:

    • $n = h_2(h_1(k))$
    • $n$ is at position $i$ in the Merkle tree with root $o$
    • $\mathsf{nullifierHashEncrypted} = \mathsf{Enc}_{pk}(h_1(k))$

In circom, the commitment generation is implemented through a DoubleHasher template with the following definition:

// computes Mimcsponge(Pedersen(secret))
template DoubleHasher() {
    signal input nullifier;
    signal output nullifierHash;
    signal output commitment;

    component h1 = Pedersen(248);
    component h2 = HashLeftRight();

    // Compute first hash (nullifierHash)
    component nullifierBits = Num2Bits(248);
    nullifierBits.in <== nullifier;
    for (var i = 0; i < 248; i++) {
        h1.in[i] <== nullifierBits.out[i];
    }

    // Compute second hash (commitment)
    h2.left <== h1.out[0];
    h2.right <== h1.out[0];

    nullifierHash <== h1.out[0];
    commitment <== h2.hash;
}

First, it takes a nullifier as input and decomposes it into 248-bit representation. Then, it applies the Pedersen hash function ($h_1$) to it. The result of this first hash (h1.out[0]) is what we refer to as the nullifier hash. Then, to generate the final commitment, the circuit applies a second hash function using MiMC (HashLeftRight), where both inputs are set to the nullifier hash. The result of this second hash is the commitment.

For public key encryption, we utilize RSA-2048 which provides 112 bits of security. NIST recomends using RSA-3072 in order to achieve 128 bits of security, but as we will remark multiple times throughout this post, our implementation is merely a piece of education material and should by no means be used in production, for a detailed analysis of the security implications of using RSA-2048 please refer to the previous post.

The modulus $N$ is provided by the anonymity revoker and the value of the public exponent $e$ is fixed as $e = 65537$. The hash functions remain consistent with the deposit phase. Finally, the nullifier hash encryption is generated by encrypting $h_1(k)$ under the anonymity revoker’s modulus, resulting in $c = (h_1(k))^e \bmod N$.

A caveat of this approach is that the withdrawal process requires careful handling of the cryptographic operations due to the constraints imposed by the circom framework. Since we are working with RSA-2048 encryption and the circuit operates over the Baby Jubjub curve’s scalar field $\mathbb{F}_p$, we need to represent the RSA modulus $N$ in a way that allows for efficient computation within the circuit.

To achieve this, we decompose the $N$ into a sequence of smaller values that fit within the field: specifically, we express $N = \sum_{i = 0}^{k-1} N_i \cdot B^i$, where $B < p$ is a suitable base (we use $B = 2^{32}$) and $k = \lceil 2048/\log_2(B) \rceil$ is the number of limbs needed (in this case $k = 64$). The actual encryption of $h_1(k)$ in the circuit is performed by first computing $h_1(k)$ using the Pedersen hash function, which outputs a point $P$ on the Baby Jubjub curve. $P$ is interpreted as an integer $x < p$ and decomposed into limbs $x_i$ such that $x = \sum_{i=0}^{k-1} x_i \cdot B^i$. Eventually the circuit will also require the decomposition of $e$ into its limbs $(e_i)_{i=0}^{k-1}$.

The circuit then performs the modular exponentiation $\mathsf{nullifierHashEncrypted} = (h_1(k))^e \bmod N$ using a square-and-multiply algorithm adapted to work with the limb representation. This is the most complex part of the circuit since we need to implement multi-precision arithmetic operations (addition, multiplication, and division) that work with the limb representation while keeping the intermediate values within the field bounds. Fortunately, the circom-ecdsa repository provides a reference implementation of the modular multiplication algorithm on integers with $2^k$ limbs of size $2^n$ as the BigMultModP circuit, the values $k$ and $n$ are parameters of the circuit and can be chosen by the user.

All in all, the new withdrawal process is implemented in circom with the following circuit:

// Verifies that commitment that corresponds to given secret in the merkle tree of deposits 
// AND the nullifierHash is the hash of the nullifier
// AND the nullifierHashEncrypted is the RSA encryption of the nullifierHash
template WithdrawDoubleHash(levels) {
    signal input root;
    signal input recipient; // not taking part in any computations
    signal input relayer;  // not taking part in any computations
    signal input fee;      // not taking part in any computations
    signal input refund;   // not taking part in any computations
    signal input nullifierHashEncrypted[32];
    signal input modulus[32];
    signal input exponent[32];
    
    signal input nullifierHash;
    signal input nullifier;
    signal input pathElements[levels];
    signal input pathIndices[levels];

    // ============== Pedersen + Mimcsponge ==============

    component doubleHasher = DoubleHasher();

    doubleHasher.nullifier <== nullifier;

    // Assertion #1: nullifierHash is the hash of the nullifier
    nullifierHash === doubleHasher.nullifierHash;

    // ============== RSA encryption ==============

    component rsa = SmallMessageRSA(64, 32, 17);

    rsa.message <== nullifierHash;
    rsa.modulus <== modulus;
    rsa.exponent <== exponent;

    // Assertion #2: nullifierHashEncrypted is the RSA encryption of the nullifierHash
    nullifierHashEncrypted === rsa.encrypted;

    // ============== Merkle tree ==============

    component tree = MerkleTreeChecker(levels);
    tree.leaf <== doubleHasher.commitment;
    tree.root <== root;
    for (var i = 0; i < levels; i++) {
        tree.pathElements[i] <== pathElements[i];
        tree.pathIndices[i] <== pathIndices[i];
    }

    // ============== Dummy assertions ==============

    // Add hidden signals to make sure that tampering with recipient or fee will invalidate the snark proof
    // Most likely it is not required, but it's better to stay on the safe side and it only takes 2 constraints
    // Squares are used to prevent optimizer from removing those constraints
    signal recipientSquare;
    signal feeSquare;
    signal relayerSquare;
    signal refundSquare;
    recipientSquare <== recipient * recipient;
    feeSquare <== fee * fee;
    relayerSquare <== relayer * relayer;
    refundSquare <== refund * refund;
}

The code highly resembles the original Tornado Cash implementation, however a few remarks are in order:

  • The encrypted nullifier hash, being the result of RSA-2048 encryption, is a 2048-bit number that needs to be handled carefully both in the circuit and in the Solidity smart contract. In the circuit, we also represent it as an array of 32 limbs (nullifierHash[32]), where each limb is 64 bits. In the Solidity contract, the encrypted nullifier must be hashed with keccak256 before being stored in the consumed nullifier array.

  • The SmallMessageRSA component is designed to provide minimal security guarantees for our implementation. In particular, it solves the following problem: the message being encrypted (the nullifier) is only 248 bits, which is significantly smaller than the RSA modulus (2048 bits). When encrypting small messages with textbook RSA, the encryption function $m^e \bmod N$ might not provide adequate security if $m^e < N$. In this case, the modular reduction never occurs, making the encryption trivially reversible. SmallMessageRSA pads the message with null bits before encryption, which is a simple and effective solution to the problem.

Security and Performance

While maintaining the original Tornado Cash hash functions ($h_1$ and $h_2$ as Pedersen and MiMC respectively) helps preserve the protocol’s cryptographic foundations, several critical security considerations remain unaddressed. Some of these concerns were explored in the theoretical foundation, but others require careful analysis that is beyond the scope of this post. The choice of public-key encryption scheme is particularly crucial - while RSA offers implementation advantages due to its mathematical simplicity and widespread support, it introduces its own set of challenges. One of the most important ones is the lack of forward secrecy, which means that if an anonymity revoker’s private key is ever compromised, all historical transactions become vulnerable to deanonymization. Various mitigation strategies exist, such as using unique public keys per pool, implementing key rotation by encrypting batches of withdrawals under ephemeral keys or even encrypting each withdrawal to a fresh key provided by the anonymity revoker.

In terms of performance, the implementation of the double hashed withdrawal process in circom is far from optimal. The circuit currently takes 50 seconds to generate a Groth16 proof on a Lenovo ThinkPad E14 Gen 2 (11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz) with 8 cores, significantly slower than the original Tornado Cash protocol’s proof generation time of TODO ms. This performance degradation is primarily attributed to the computational overhead of RSA-2048 operations within the circuit. The modular exponentiation required for RSA encryption roughly multiplies the number of constraints in the circuit by a factor of 10, resulting in a total of $6 \times 10^6$ constraints, compared to the original 3.7 $\times 10^5$ constraints.

Another consequence of using RSA encryption is a substantial computational overhead in terms of gas consumption during the verification phase. This increased cost stems primarily from the expanded public input requirements: the encrypted nullifier, RSA modulus, and public exponent (each 2048 bits) must be passed to the verification contract, with each value represented as an array of 32 64-bit limbs to maintain compatibility with the circuit’s arithmetic constraints. While the original Tornado Cash implementation required only 6 public inputs for verification—root, nullifier hash encrypted, recipient address, relayer address, fee, and refund amount—our implementation necessitates 101 public inputs. Even if we assume that the modulus and the exponent are precomputed and stored in the contract, the number of public inputs is still significantly higher than the original implementation. This expanded calldata size translates directly to higher transaction costs.

While these performance limitations don’t impact our proof-of-concept implementation, a production system would require significant optimization. Here are a few approaches to improve performance:

  1. Hardcode RSA Parameters: The current implementation requires 101 public inputs compared to Tornado Cash’s 6 inputs. By hardcoding the RSA modulus and exponent in the contract, we can eliminate approximately 64 public inputs, significantly reducing gas costs associated to calldata.

  2. Optimize RSA Parameters: Our implementation uses $e = 65537$ as the public exponent, creating the vast majority of constraints in the circuit. Using smaller public exponents (e.g., $e = 3$) would dramatically reduce circuit constraints and computation time. This remains secure when combined with proper padding schemes like OAEP.

  3. Consider Alternative Encryption Schemes: One of the most promising alternatives is the Rabin cryptosystem, which requires only a modular squaring operation instead of RSA’s cube (or higher) modular operations. This improves performance while maintaining security equivalent to RSA, as both are based on the hardness of factoring.

References


Author: César Descalzo