Skip to content

Security/crypto review fixes - #80

Open
GeneralZero wants to merge 15 commits into
mainfrom
security/crypto-review-fixes
Open

Security/crypto review fixes#80
GeneralZero wants to merge 15 commits into
mainfrom
security/crypto-review-fixes

Conversation

PoseidonDecrypt(l) pads the message up to a multiple of three, so the number of
padding slots is decryptedLength - l: two when l % 3 == 1, one when l % 3 == 2.
The two branch bodies were the other way round, which the comment directly above
them already describes correctly as "(3 - (l mod 3))".

Every PCT in the system is PoseidonDecrypt(1), so the live effect was the
l % 3 == 1 branch checking one slot instead of two, leaving decrypted[1]
unconstrained. The auth tag does not cover that gap: a prover chooses
ciphertext[0..2] freely and then computes the tag over them, so an arbitrary
field element could be smuggled through every transfer, mint, burn and withdraw
PCT. Verified with a forged-but-valid-tag ciphertext, which the old code accepts
and the new code rejects.

The l % 3 == 2 branch was over-constrained for the same reason and would have
rejected valid two-element ciphertexts. Latent, since nothing instantiates l=2.
Both randomness inputs carried only a range check, and zero passes it.
CheckPublicKey already asserts privKey != 0; these bring the randomness up to
the same standard.

In ElGamalEncrypt, random = 0 makes c1 the identity and leaves the message
unblinded, so the ciphertext is (identity, value*G) and anyone can recover the
amount by a short discrete-log search.

In CheckPCT, random = 0 makes both the auth key and the Poseidon encryption key
the identity point, which is public, so anyone can decrypt the PCT.

The sender is the only party who picks these values, so this is disclosure of
their own transfer rather than theft - but it is silent, undetectable by the
counterparty or the auditor, and a clean covert channel. There is no legitimate
reason for either value to be zero.
…lic key

register() combined its two guards with &&, so it reverted only when the
registration hash was already used AND the account already held a key. A user
re-registering with a different private key produces a different registration
hash, so the first clause was false and the guard let the overwrite through.

Nothing migrates the balance. The stored ciphertext stays encrypted under the old
key while _validatePublicKey compares proofs against the new one, so every spend
proof the account can build is rejected and the funds are unreachable, with no
warning and no recovery path. The same overwrite silently staleness the eERC's
auditorPublicKey if the auditor re-registers, leaving PCTs encrypted to a key the
auditor may no longer hold.

The circuit side matters too: register() reads the account as
address(uint160(input[2])) while CheckRegistrationHash hashed the full field
element, so senderAddress + k * 2^160 minted fresh hashes for the same account.
Without the 160-bit bound the guard stays bypassable however the clauses combine.

The existing "already registered user can not register again" test replays the
same proof, for which both clauses are true either way, so it could not
distinguish a correct guard from a broken one. The added test registers a second
time with a freshly generated key, which is the case that actually fails against
the old condition.
BabyJubJub.encrypt hardcodes random = 1, which is a reasonable choice given a
contract has no source of randomness, but the consequences were undocumented.

Deposit ciphertexts are deterministic: equal amounts to the same key produce
identical ciphertexts, and the accumulated randomness of a deposit-only account is
just its publicly observable operation count, so anyone can compute
b*G = c2 - n*pk and confirm the balance. That leaks nothing beyond the deposit and
withdrawal amounts, which are already public in the ERC20 transfers - but it does
mean a converter balance carries no confidentiality at all until the account
receives a private transfer with real randomness, and that is worth stating where
someone reading the deposit path will see it.
decimals() was read live from the token on both legs of every conversion, and
nothing in ERC20 requires it to stay constant. A token free to change what it
reports could be scaled one way going in and another coming out: deposit while it
reports 10 decimals and no scaling applies, flip it to 18, then withdraw the same
encrypted units and _convertTo multiplies the payout by 10^8. The difference is
paid out of the contract's holdings of that token, which is every other depositor
of it. It does not even need malice - a legitimately upgradeable token that
changes its reported decimals corrupts accounting for every holder.

Reading it once in _addToken and storing it per token id makes the in-scaling and
out-scaling agree by construction, whatever the token does afterwards. Two smaller
things fall out: the two call sites no longer disagree on type (uint8 on deposit,
uint256 on withdrawal), and a token with no decimals() now reverts when it is
first registered rather than on every deposit attempt.

_convertFrom had to be reordered to register before reading, since registration is
what populates the cache.

Note for anyone applying this to a live instance: tokens registered before this
change would read 0 from the new mapping and need a backfill.
When the eERC has more decimals than the token, _convertTo divides, and an amount
below the scaling factor floors to zero. _privateBurn has already debited the
caller's encrypted balance in full by that point, so the call succeeded, emitted a
Withdraw event for the full amount, and moved no tokens. The deposit leg handles the
mirror case properly by returning the remainder as dust; this leg dropped it.

The loss per call is bounded by scalingFactor - 1 encrypted units, always less than
one indivisible unit of the underlying token, and it is never directable at anyone
else - so the magnitude is small. What earns the fix is that the call reports
success: a 10-decimal eERC over a 0-decimal token gives a 10^10 threshold, under
which every withdrawal silently pays nothing.

The test suite encoded the broken behaviour as correct. The 6-decimal block
withdrew 1000 units through a 10^4 scaling factor - exactly zero tokens - and
passed, because every deposit test checks the real ERC20 delta while none of the
three withdraw blocks referenced the token at all. They now assert the balance
moved and by how much, the amount is raised above the scaling factor, and a case
below it asserts the revert. The assertions fail against the pre-fix contract.
…oken

amountPCTs is an unbounded storage array. Every credit pushes an entry and every
spend walks the whole array in _deleteUserHistory, popping matches. The asymmetry
is the problem: inbound transfers grow the array, and the account pays to walk it.
A third party could send transfers until the victim's next spend exceeded the block
gas limit, at which point that balance is unspendable permanently, with no way to
prune. One transfer per entry is expensive for the attacker but bounded; the
victim's loss is total and irreversible.

Capping the pending entries changes the failure mode from the victim's funds being
frozen to the attacker's transfer reverting, and the account clears its history
with a single spend. That is still a griefing vector - an attacker can block an
account from receiving once it holds 300 unspent credits - but it is recoverable
rather than permanent.

300 keeps the prune loop well inside the gas limit while leaving generous headroom
for ordinary use.
_convertFrom reimplemented _addToUserBalance inline. The helper had existed for a
week when the deposit path was written, and the copy then drifted from it, which is
the usual fate of duplicated state-machine code.

The consequence that matters is that the copy bypassed the MAX_PENDING_AMOUNT_PCTS
ceiling, because that guard lives in _addToUserHistory and only the helper calls it.
Nobody can weaponise that - _executeDeposit credits msg.sender only, so deposits
cannot grow someone else's history - but an account could grow its own past the
point where its prune loop fits in a block and then be unable to spend. Measured:
with the inline copy the 301st deposit succeeds and the array reaches 301; via the
helper it reverts with PendingHistoryLimitReached.

The other drift goes away with it: deposits incremented transactionIndex twice, once
directly and once inside _commitUserBalance, where every other credit increments it
once. Deposits now advance it by one like the rest. Balance ciphertexts, decrypted
balances, nonces and pruning behaviour are unchanged - verified by dumping every
observable field across three deposits and a withdrawal under both versions - but
the counter's absolute value is lower, so any off-chain consumer reading
transactionIndex or an amount PCT index will see different numbers.
contributionSettings.contributions was 0, so hardhat-zkit emitted verification keys
straight from the initial zkey, where the phase-2 trapdoor delta is still 1. All
five generated verifiers had DELTA byte-identical to GAMMA, both equal to the BN254
G2 generator.

Groth16 soundness rests entirely on delta being secret. With delta = 1 the
verification equation collapses and a proof can be forged for any public input from
public data alone: take A = alpha, B = beta + t*g2, C = t*alpha - L, and
e(-A,B)*e(alpha,beta)*e(L,gamma)*e(C,delta) = 1 holds by construction. Confirmed
against the committed constants for t = 1, 2 and 7, with no witness and no proving
key. Against a deployment built from these scripts that means registering any key to
any address, forging withdraw proofs to drain the converter's reserves, and forging
transfers and mints.

The keys here are regenerated with one contribution, which makes them unforgeable.
It does NOT make them trustworthy: whoever ran the setup knows the toxic waste.
Production keys must come from a multi-party ceremony ending in a public beacon,
with the transcript published so the key can be reproduced from these circuits.

The IC constants are unchanged by the contribution and reproduce exactly from the
circuit source, so only DELTA moves here beyond the circuit fixes earlier in this
series.
Two failure modes that have both occurred in this repository, neither of which any
existing check would catch.

delta == gamma in a verification key means the phase-2 trapdoor is 1 and the key
accepts forged proofs for any statement. That is what a setup with zero
contributions produces, so this makes the unsound configuration fail the build
rather than ship quietly.

Drift between the production keys and the circuits is the second. With gamma fixed
to the G2 generator, the IC public-input commitments depend only on the phase-1 ptau
and the compiled constraint system, not on the contributions - so the IC array is a
fingerprint of the circuit. Comparing the production and generated verifiers per
circuit detects a key that no longer describes the source it is committed alongside.

This currently fails on all five production keys, which is correct: they predate the
circuit changes in this series and cannot be used until the ceremony is re-run.

Written as plain JS rather than TypeScript so it needs no ts-node, which the repo
does not declare.
deployVerifiers took isProd as an optional flag defaulting to the generated
verifiers, and both deploy scripts called it without passing anything. So the
branch that deploys contracts/prod was unreachable and the scripts shipped the
development keys - the ones with delta = 1 - purely by omission. A footgun that
silent is worth removing even now that the generated keys are sound, because they
are still not ceremony output.

The parameter is now required, so every caller has to state which key set it means,
and the deploy scripts pass true.

Note that npm run check:verifiers currently reports the production keys as stale
against these circuits, so the setup has to be re-run before either script is
pointed at a live network.
Belongs with the padding-count fix earlier in this series; kept separate only to
avoid rewriting signed history. Squash it into that commit if you prefer.
GeneralZero and others added 2 commits August 20, 2026 12:59
* test(history): add pending PCT withdrawal gas coverage

* chore: fix lint issues
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants