Introduction
In the two weeks from September 22nd to October 6th 2023, zkSecurity performed a security audit of Silent Protocol’s Circom circuits.
A number of observations and findings have been reported to the Silent Protocol team. The findings are detailed in the latter section of this report.
Silent Protocol’s circuits were found to be of high quality, accompanied with thorough documentation, specifications and tests. As of writing, all high and medium-severity issues we found were patched by the Silent Protocol team, and zkSecurity confirms that these patches properly address our findings.
Note that security audits are a valuable tool for identifying and mitigating security risks, but they are not a guarantee of perfect security. Security is a continuous process, and organizations should always be working to improve their security posture.
Scope
A consultant from zkSecurity spent two weeks auditing the Circom circuits for the Silent multi-asset shielded pool (SMASP) application. These circuits represent the privacy-preserving portion of the overall SMASP application. They are used in a set of Solidity smart contracts that verify zero-knowledge proofs created from these circuits.
Smart contracts were reviewed by zkSecurity at an earlier date, and were only in scope insofar as they perform preparation and validation of public circuit inputs. The main focus of this audit were the application’s circuits written in Circom. These include circuits for the main SMASP protocol, circuits used by the compliance logic, and circuits representing future functionality not used in the contracts at the time of review. An overview of audited circuits can be found below.
The audit also covers all auxiliary circuit templates and utilities maintained by Silent Protocol itself, including (but not limited to) templates for foreign-field arithmetic and ElGamal encryption. Not covered by the audit are templates imported from circomlib, Circom’s standard library; and functionality contained in snarkjs, such as the Groth16 prover.
Circuits overview
An overview of the overall SMASP protocol can be found in zkSecurity’s first report for Silent Protocol. Here, we focus on introducing recurring concepts that provide context for understanding our findings below.
Encrypted anonymized asset transfers
To understand the flow of assets through Silent’s shielded pool, we focus on one example first: the deposit circuit.
Deposit. A public Ethereum account deposits assets into a shielded pool account.
- The deposited amount is added to a shielded balance, without revealing that balance.
- The sender’s shielded account is anonymized by making indistinguishable dummy updates to 7 other accounts.
The privacy properties of this method are achieved by combining a zero-knowledge proof with ElGamal encryption of balances.
The ElGamal ciphertext representing a balance is defined as
Here, is the ciphertext, is encryption randomness, is a public elliptic curve base point, is the balance, and is the public key of the account owner.
The deposit circuit performs two operations on ciphertexts:
- The sender account is updated by adding the deposited amount to the balance, exploiting additive homomorphism:
- All 8 balance ciphertexts (on the sender’s and 7 decoy accounts) are updated with new randomness :
The re-randomization step is what makes the sender’s balance update indistinguishable from updates to the 7 decoy accounts, which leave their balance in place. Note that we can perform both the re-randomization and the balance update without knowing the accounts owner’s private keys, and we also preserve their ability to decrypt their balances. Therefore, decoys can be real, active accounts by other users, which is necessary for this scheme to provide actual anonymity for the sender. We have to ensure that the sender knows their own private key, by proving that we can rederive their public key .
The ciphertexts before and after applying the deposit update are public inputs to the circuit, while the new randomness is a private input. The deposit amount is public as well, because the contract needs to equate it to the amount received from the sender’s Ethereum account. The circuit asserts correct execution of the ciphertext updates given above.
A second part of the circuit computes a senderHash, defined as
Here, is the Poseidon hash function, is randomness, is the sender’s private key and is the public key shared by the compliance committee. The senderHash and are made available as public inputs. Note that the key is shared between the sender and compliance committee. Both the sender and the compliance committee can recompute the senderHash to check whether this transaction belongs to the sender.
SMASP circuits
Other circuits to interact with the shielded pool make use of the same concepts outlined above for the deposit circuit, with slight variations:
DepositAndWithdraw. The withdraw logic shares its circuit with deposits, but uses a negative amount. The only difference, which is handled in-circuit, is that for withdrawals we also check that the amount is smaller than the balance. In order to do this, the circuit takes the current balance as private input and verifies that it is encrypted correctly.
Transfer differs from deposits in that the sent amount is private, and that two accounts are anonymously updated: the sender and recipient. The circuit likewise encrypts transaction details in two versions: One shared between sender and recipient, and one shared with the compliance committee. Both versions derive a shared secret using ECDH and use it as the key in MiMC-based encryption.
Register is the circuit that creates new shielded accounts. It generates new ElGamal ciphertexts of zero balances for four different assets.
FeeRegistration lets a user subscribe for zero-fee withdrawals. It uses the same technique as deposits to encrypt the end of the subscription period.
WithdrawFeeReduction is the method unlocked for a user after calling FeeRegistration. It is similar to withdraw, except that it also verifies the validity of the encrypted subscription period against the current block number, which is a public input.
TransferToNonSilent, ClaimAndRegister and ClaimBatchPoints are not used by smart contracts in the audited version of the protocol. They use the same techniques as Transfer and Register to verify encrypted balances and compute encrypted transaction details.
All of these circuits – with the exception of Register, ClaimAndRegister and ClaimBatchPoints – hide the sender in a size-8 anonymity set, using the same re-encryption technique as deposits.
Generally speaking, we found the implementation of encrypted and anonymized asset transfers to be solid, with consistent usage of the same patterns and well-documented core templates, like BalanceVerify() and BalanceUpdate(). Only one major issue was found in this part of the code, which stems from a non-standard application of ElGamal encryption in the FeeRegistration logic, breaking the sender’s anonymity; see finding #00.
Secret sharing for compliance
After joining the compliance committee, every member will create a secret that they share with all other members. Likewise, the other members send a secret share to them. The scheme uses a variant of Shamir secret sharing that is suitable for threshold decryption, by avoiding the need for a single dealer; it also ensures that secret shares are verifiable against public commitments to the secret generated by each member. See AHS20 for an overview of the scheme.
The SecretSharing template represents the scheme in circuit form. The template is used by a committee member when they share secrets with other members, by posting them in encrypted form to a compliance smart contract. Along with encrypted shares, commitments to the underlying secret are also posted publicly; this enables members to verify their shares. The circuit’s purpose is to prove that encrypted shares and commitments are computed correctly and from the same polynomial.
In mathematical terms, the secret is an element of a finite field, . The sharing entity constructs a polynomial of degree which evaluates to the secret at 0:
Polynomial coefficients are passed as private inputs, along with public evaluation points , . The SecretSharing template evaluates the polynomial at each to obtain the th secret share, . Note that (and uniqueness of the ) ensures that or more shares can be combined to reconstruct the secret. In practice, members will only combine shares “in the exponent” so as to not reveal them, to collectively compute a curve point for ElGamal decryption.
Besides evaluating the polynomial, the template also needs to compute commitments to the polynomial coefficients, which are defined as
The are broadcast by storing them on the smart contract. To validate the share they received, each member can check that
To do scalar multiplications efficiently in the circuit, the chosen curve is BabyJubJub, whose base field is the native circuit field. Note, however, that the polynomial lives in the scalar field of that curve. This means we have to perform polynomial operations in non-native arithmetic modulo the curve order ; with coefficients, evaluation points and secret shares all represented as bigints.
Non-native arithmetic is a major source of complexity in the SecretSharing template. Indeed, out of the 6 high-to-medium findings reported, 5 are related to SecretSharing and non-native arithmetic (see findings #01 through #05).
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. Secret shares are computed by evaluating a polynomial on a set of points :
This evaluation happens in non-native arithmetic, so the polynomial coefficients and evaluation points are passed in as bigints: fixed-size arrays of signals which represent the limbs of a bigint. Concretely, 4 limbs of 64 bits each are used, so a value like is represented as where .
The polynomial evaluation circuit is built on top of non-native arithmetic gadgets like FpAdd and FpMultiply originally taken from circom-pairing, which themselves rely on bigint implementations taken from circom-ecdsa.
These non-native gadgets constrain their outputs to be valid bigints with limbs in . However, none of them constrain their inputs; so, input limbs to FpAdd and FpMultiply are assumed to already be range-checked to 64 bits.
The choice to range-check outputs but not inputs is very natural. If each gadget would range-check its inputs, then range checks would be applied multiple times when you used different gadgets on the same input values, wasting constraints. By following the convention of always constraining witnesses where they originate, we keep the code auditable and also ensure that the same constraints aren’t applied multiple times.
The issue is that secretShare.circom doesn’t follow this convention: None of the input bigint limbs are range-checked. In the abbreviated code below, the signals c2, indexes, polynomial and secretShare all represent bigints that are not range-checked.
template SecretSharing(THRESHOLD, SHARES, n, k, p) {
// Public Signals
// ...
signal input c2[SHARES][k];
signal input indexes[SHARES][k];
// Private Signals
signal input polynomial[THRESHOLD][k];
signal input secretShares[SHARES][k];
// ...
/// 4- evaluate the polynomial at index_i and
/// verify p(index_i) = secretShares_i
component evalPoly[SHARES];
// ...
for (var i = 0; i < SHARES; i++) {
evalPoly[i] = EvalPoly(THRESHOLD, n, k, p);
for (var j = 0; j < THRESHOLD; j++) {
for (var l= 0; l < k; l++) {
evalPoly[i].polynomial[j][l] <== polynomial[j][l];
}
}
for (var j=0; j < k; j++) {
evalPoly[i].eval[j] <== secretShares[i][j];
evalPoly[i].at[j] <== indexes[i][j];
}
// ...
Impact. To understand the impact at the hand of a simple example, let’s look at the ModSum template which is used under the hood by FpAdd to add two limbs with a carry:
// addition mod 2**n with carry bit
template ModSum(n) {
assert(n <= 252);
signal input a;
signal input b;
signal output sum;
signal output carry;
component n2b = Num2Bits(n + 1);
n2b.in <== a + b;
carry <== n2b.out[n];
sum <== a + b - carry * (1 << n);
}
If both a and b are constrained to bits, this template indeed performs addition modulo . However, if a and b can be any field element, we can make a + b overflow the native field. For example, let where is the native modulus, and . Then the sum is , which is not the correct result mod .
This demonstrates a general principle: Missing range checks often let us introduce overflow of in non-native arithmetic, where it’s not supposed to happen. This can make room for the prover to modify the output or input of non-native computations, by adding or subtracting multiples of .
We explain a concrete attack which breaks the compliance protocol when exploiting this in combination with finding #03; see that finding for details. But the missing range checks alone can likely be exploited to modify either commitments to polynomial coefficients or secret shares.
Recommendation. All limbs of the input signals c2, indexes and polynomial should be range-checked to 64 bits. An appropriate way to do this is to pass the checked limb as input to Num2Bits(64) from circomlib/circuits/bitify.circom. Notably, secretShares does not need to be range-checked because it is forced to equal the output of the EvalPoly template, which is already range-checked.
Client response. Silent Protocol fixed the issue by adding range checks to indexes and polynomial as recommended. The signal c2 was refactored to be a native field element in response to finding #01, so the need for a range check no longer applies to it.