diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index da548abb..40fc4e40 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -62,13 +62,33 @@ pragma language_version >= 0.23.0; * high-entropy, and secret for every invocation. * * Seed REUSE leaks plaintext differences, and the circuit does NOT prevent - * it. The debit and escrow ciphertexts (`_debit`, `approve`, `_spendEscrow`, - * and the re-approve refund) derive randomness from a static domain tag with - * no per-operation nonce, so two operations under one seed share randomness - * and their public ciphertext deltas reveal the amount difference. The credit - * path folds the recipient's memo-list length in as a nonce (defense in - * depth), but that counter RESETS on `clearMemos`, so it is an in-epoch - * backstop only, never a substitute for freshness. + * it. The debit and re-approve-refund ciphertexts (`_debit`, `approve`) derive + * randomness from a static domain tag with no per-op nonce, so two + * operations under one seed share randomness and their public ciphertext deltas + * reveal the amount difference. `_spendEscrow` is the exception: every value it + * derives folds in the spend epoch AND both counterparties, so its randomness + * is unique per spend regardless of the seed. The credit path folds the + * memo-list length in as a nonce (defense in depth), but that counter RESETS on + * `clearMemos`, so it is an in-epoch backstop only, never a substitute for + * freshness. + * + * @notice WHOSE confidentiality a seed protects is not always the caller's. On + * most paths a wallet's seed hygiene guards its own amounts, so the incentive + * and the exposure sit with the same party. The escrow spend path is the + * exception: `_spendEscrow` expands every random value it uses from the + * SPENDER's seed, but all three protect the OWNER. Two re-encrypt their escrow + * and the third masks their remaining allowance. A spender reusing a seed leaks + * a counterparty's balance, not their own, and the owner cannot prevent it by + * rotating their own seed as the leaking call is not theirs. + * + * The accidental case is removed by binding both counterparties into the + * derivation alongside the epoch, which counts per (owner, spender) and so + * restarts for each pair. That covers the two ways one seed repeats randomness + * across calls: one spender drawing on two owners, and two wallets sharing a + * seed drawing on one owner. Nothing stops a spender who deliberately publishes + * their seed, since any construction must let them compute the pad. Wallet + * authors supplying a seed for `transferFrom` / `burnFrom` should understand + * they are protecting the owner they are spending on behalf of. * * Seed generation is a WALLET responsibility and OUT OF SCOPE for this * contract, which cannot enforce it. The witness shipped in this repo @@ -174,15 +194,15 @@ pragma language_version >= 0.23.0; * into the derivations is a possible defense-in-depth hardening; under review, deferred. * * Block weight. A deploy bundles every exported entry point's on-chain IR into - * one transaction, and the value circuits are SHA256-heavy (k=16), so a - * full-surface deployment is byte-heavy. Whether it fits is a property of the + * one transaction, and the value circuits are SHA256-heavy (k=16, k=17 for + * `transferFrom`), so a full-surface deployment is byte-heavy. Whether it fits is a property of the * target network, not the contract: the per-transaction block byte budget is a * governance-configurable, node-side ledger parameter. On the local ledger-8 dev * stack the full surface exceeded it and was rejected (`1010: Transaction would * exhaust the block limits`), while a reduced surface (e.g. receive + transfer, * dropping allowances and supply accounting) fit. So confirm deployability * against the target network; if a deployment is over budget there, the lever is - * exposing only the k=16 value operations it needs, not just trimming getters. + * exposing only the value operations it needs, not just trimming getters. * A deployed contract can also be upgraded by its Contract Maintenance Authority * (CMA), so an over-budget deployment can start with a lean surface and add * operations by upgrade instead of fitting the whole surface into one deploy. @@ -254,6 +274,20 @@ module ConfidentialFungibleToken { ownerMemo: EcdhMask_Ciphertext; } + // Hash preimage for the per-spend randomness `_spendEscrow` derives. The epoch + // counts per (owner, spender) and so restarts for each pair, which is why both + // parties are bound in alongside it. Drop the owner and one spender's first + // spend against two owners repeats `rSpender`; drop the spender and two wallets + // sharing a seed repeat `rOwner` against one owner. Either repeat cancels on + // subtraction, because the colliding pair is encrypted under one key. + struct SpendRandomnessPreimage { + seed: Bytes<32>; + domain: Bytes<32>; + owner: Bytes<32>; + spender: Bytes<32>; + epoch: Field; + } + // --------------------------------------------------------------------------- // Ledger state // --------------------------------------------------------------------------- @@ -270,6 +304,15 @@ module ConfidentialFungibleToken { export ledger _memos: Map, List>; export ledger _escrow: Map, Map, EscrowEntry>>; + // Per-(owner, spender) escrow-spend counter, folded into the randomness + // `_spendEscrow` derives so repeat spends by one spender against one owner + // never share a one-time pad. It restarts for each pair, which is why both + // counterparties are bound in too; see `SpendRandomnessPreimage`. + // + // Kept out of `EscrowEntry`: `approve` replaces that entry wholesale, so a + // counter living there would restart on every re-approve. + export ledger _escrowSpendEpochs: Map, Map, Counter>>; + export ledger _isInitialized: Boolean; export sealed ledger _name: Opaque<"string">; @@ -345,9 +388,10 @@ module ConfidentialFungibleToken { * module header). The derivation is deterministic and public, so an observer * who knows the seed can strip a memo's mask and read the delivered amount, and * recover balance/escrow amounts from the public ciphertexts. Seed REUSE leaks - * plaintext differences and the circuit does NOT prevent it: only the credit - * path folds a per-recipient nonce (the memo-list length) in as defense in - * depth, and even that resets on `clearMemos`. The seed MUST be fresh, + * plaintext differences and the circuit does NOT prevent it on most paths. + * `_spendEscrow` is an exception (its randomness folds the spend epoch and both + * counterparties); the credit path folds the recipient's memo-list length as + * defense in depth, and that resets on `clearMemos`. The seed MUST be fresh, * high-entropy, and secret for every invocation. */ witness wit_RandomnessSeed(): Bytes<32>; @@ -936,7 +980,7 @@ module ConfidentialFungibleToken { * caller via `approve`) and credits it to `to`, reducing both escrow copies by * `value`. The amount is hidden. * - * @circuitInfo k=16, rows=63207 + * @circuitInfo k=17, rows=70164 * * @notice Returns the caller's (spender's) `accountId` for caller-side gating. * @@ -1086,9 +1130,23 @@ module ConfidentialFungibleToken { "ConfidentialFungibleToken: insufficient allowance"); const seed = wit_RandomnessSeed(); - const rSpender = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_spender")); - const rOwner = ElGamal_expandRandomness(seed, pad(32, "spend_escrow_owner")); - const eOwnerMemo = ElGamal_expandRandomness(seed, pad(32, "spend_owner_memo")); + + // The seed is the SPENDER's, but all three values protect the OWNER. The + // epoch below plus both counterparties in the preimage are what keep them + // distinct when a seed is reused. See the module header's @notice. + if (!_escrowSpendEpochs.member(disclose(fromAddress))) { + _escrowSpendEpochs.insert(disclose(fromAddress), default, Counter>>); + } + if (!_escrowSpendEpochs.lookup(disclose(fromAddress)).member(disclose(spenderId))) { + _escrowSpendEpochs.lookup(disclose(fromAddress)).insert(disclose(spenderId), default); + } + _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)).increment(1); + const spendEpoch = + _escrowSpendEpochs.lookup(disclose(fromAddress)).lookup(disclose(spenderId)) as Field; + + const rSpender = _expandSpendRandomness(seed, pad(32, "spend_escrow_spender"), fromAddress, spenderId, spendEpoch); + const rOwner = _expandSpendRandomness(seed, pad(32, "spend_escrow_owner"), fromAddress, spenderId, spendEpoch); + const eOwnerMemo = _expandSpendRandomness(seed, pad(32, "spend_owner_memo"), fromAddress, spenderId, spendEpoch); const ownerPk = _encryptionKeys.lookup(disclose(fromAddress)); const newSpenderCt = ElGamal_subEncrypted(entry.spenderCt, spenderPk, value, rSpender); @@ -1119,7 +1177,7 @@ module ConfidentialFungibleToken { * recipient credit. The building block for a composing contract's * `burnFrom`; it performs no supply accounting (see `_burn`). * - * @circuitInfo k=16, rows=36274 + * @circuitInfo k=16, rows=43231 * * Requirements: * @@ -1135,6 +1193,25 @@ module ConfidentialFungibleToken { return _spendEscrow(fromAddress, value); } + /** + * @description Derives one per-spend random scalar for `_spendEscrow`. Does + * what `ElGamal_expandRandomness` does, but folds both counterparties and the + * escrow-spend epoch into the preimage as well as the seed and domain tag, so + * each spend gets distinct randomness even when a seed is reused. + */ + pure circuit _expandSpendRandomness( + seed: Bytes<32>, domain: Bytes<32>, owner: Bytes<32>, spender: Bytes<32>, + epoch: Field): Field { + return degradeToTransient( + persistentHash( + SpendRandomnessPreimage { + seed: seed, domain: domain, owner: owner, spender: spender, + epoch: epoch + } + ) + ); + } + /** * @description Refunds any prior escrow for (ownerId, spender) to the owner's * main balance and clears the slot, so re-approving replaces rather than diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 92cf7d77..f0204fe8 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -377,22 +377,69 @@ describe.skipIf(isLiveBackend())( // Alice (owner) funds with `amount` and approves Bob (spender) for `cap`. // Leaves Alice active. - const approveBob = async (amount: bigint, cap: bigint) => { - await registerAll(); + const fundAndApprove = async ( + owner: typeof ALICE, + amount: bigint, + cap: bigint, + ) => { await cft.privateState.switchIdentity( - ALICE.secretKey, - ALICE.encryptionKey, + owner.secretKey, + owner.encryptionKey, ); - await cft._mint(ALICE.accountId, amount); + await cft._mint(owner.accountId, amount); // Dual-balance: sweep the minted value into spendable so approve can debit it. await cft.sweep(); await cft.privateState.cachePlaintext( - await cft.balanceOf(ALICE.accountId), + await cft.balanceOf(owner.accountId), amount, ); await cft.approve(BOB.accountId, cap); }; + const approveBob = async (amount: bigint, cap: bigint) => { + await registerAll(); + await fundAndApprove(ALICE, amount, cap); + }; + + // One escrow spend by Bob, returning the entry either side. `allowance` is + // his current remaining, which he must cache to prove the spend. + const spendEscrow = async ( + value: bigint, + allowance: bigint, + via: 'transferFrom' | 'burnFrom' = 'transferFrom', + owner = ALICE, + spender = BOB, + ) => { + await cft.privateState.switchIdentity( + spender.secretKey, + spender.encryptionKey, + ); + const before = await cft.allowance(owner.accountId, spender.accountId); + await cft.privateState.cachePlaintext(before.spenderCt, allowance); + if (via === 'transferFrom') { + await cft.transferFrom(owner.accountId, CHARLIE.accountId, value); + } else { + await cft._burnFrom(owner.accountId, value); + } + const after = await cft.allowance(owner.accountId, spender.accountId); + return { before, after }; + }; + + type SpendPair = Awaited>; + + // Not plain inequality: an intervening re-approve re-randomizes the escrow + // anyway so that would pass unfixed. Both spends subtract `Enc(value, r)` + // for the same value, so repeated randomness means + // `before1 - after1 == before2 - after2` i.e. `b1 + a2 == b2 + a1` + const expectRerandomized = (a: SpendPair, b: SpendPair) => { + expect( + elgamal.add(a.before.spenderCt, b.after.spenderCt), + ).not.toStrictEqual(elgamal.add(b.before.spenderCt, a.after.spenderCt)); + expect(elgamal.add(a.before.ownerCt, b.after.ownerCt)).not.toStrictEqual( + elgamal.add(b.before.ownerCt, a.after.ownerCt), + ); + }; + it('records an allowance and debits the owner balance', async () => { await approveBob(100n, 40n); @@ -706,6 +753,124 @@ describe.skipIf(isLiveBackend())( await cft._burn(55n); }); + it('re-randomizes every escrow value across spends under one spender seed', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n); // remaining 30 + + // Load-bearing: equal remainders across both spends. Differing + // plaintexts would mask a repeated pad + await cft.privateState.switchIdentity( + ALICE.secretKey, + ALICE.encryptionKey, + ); + const escrow = await cft.allowance(ALICE.accountId, BOB.accountId); + const refunded = elgamal.add( + await cft.balanceOf(ALICE.accountId), + escrow.ownerCt, + ); + await cft.privateState.cachePlaintext(refunded, 60n + 30n); + await cft.approve(BOB.accountId, 40n); + + const second = await spendEscrow(10n, 40n); // remaining 30 again + + expect(second.after.ownerMemo).not.toStrictEqual(first.after.ownerMemo); + expectRerandomized(first, second); + }); + + // The memo can't be compared here: consecutive spends leave different + // remainders, so the memos differ on plaintext alone. + it('re-randomizes across consecutive spends on one approval', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n); // 40 -> 30 + const second = await spendEscrow(10n, 30n); // 30 -> 20 + + expectRerandomized(first, second); + }); + + it('re-randomizes across spends against different owners under one spender seed', async () => { + await approveBob(100n, 40n); + await fundAndApprove(CHARLIE, 100n, 40n); + + const fromAlice = await spendEscrow(10n, 40n, 'burnFrom', ALICE); + const fromCharlie = await spendEscrow(10n, 40n, 'burnFrom', CHARLIE); + + // Only the spender copies are comparable: both are encrypted under Bob's + // key, so a repeated `rSpender` makes the two subtracted encryptions + // coincide. The owner copies are under different keys and differ anyway. + expect( + elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt), + ).not.toStrictEqual( + elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt), + ); + }); + + it('re-randomizes across spends against different owners via transferFrom', async () => { + await approveBob(100n, 40n); + await fundAndApprove(CHARLIE, 100n, 40n); + + const fromAlice = await spendEscrow(10n, 40n, 'transferFrom', ALICE); + const fromCharlie = await spendEscrow(10n, 40n, 'transferFrom', CHARLIE); + + expect( + elgamal.add(fromAlice.before.spenderCt, fromCharlie.after.spenderCt), + ).not.toStrictEqual( + elgamal.add(fromCharlie.before.spenderCt, fromAlice.after.spenderCt), + ); + }); + + // Two wallets shipping the same fixed seed is a realistic wallet bug, and the + // owner they both spend from carries the leak. + it('re-randomizes across spends by different spenders on one owner', async () => { + await approveBob(100n, 40n); + + await cft.privateState.switchIdentity( + ALICE.secretKey, + ALICE.encryptionKey, + ); + await cft.privateState.cachePlaintext( + await cft.balanceOf(ALICE.accountId), + 60n, + ); + await cft.approve(CHARLIE.accountId, 40n); + + const byBob = await spendEscrow(10n, 40n, 'burnFrom', ALICE, BOB); + const byCharlie = await spendEscrow(10n, 40n, 'burnFrom', ALICE, CHARLIE); + + // Both owner copies are under Alice's key, so a repeated `rOwner` makes + // the subtracted encryptions coincide. + expect( + elgamal.add(byBob.before.ownerCt, byCharlie.after.ownerCt), + ).not.toStrictEqual( + elgamal.add(byCharlie.before.ownerCt, byBob.after.ownerCt), + ); + // Both memos mask 30 under Alice's key, so a repeated ephemeral would + // make them byte-identical. + expect(byCharlie.after.ownerMemo).not.toStrictEqual( + byBob.after.ownerMemo, + ); + }); + + // Both entry points reach `_spendEscrow`, so they must share one counter. + it('re-randomizes across one pair spending via both entry points', async () => { + await approveBob(100n, 40n); + + const viaTransfer = await spendEscrow(10n, 40n, 'transferFrom'); + const viaBurn = await spendEscrow(10n, 30n, 'burnFrom'); + + expectRerandomized(viaTransfer, viaBurn); + }); + + it('re-randomizes across escrow burns under one spender seed', async () => { + await approveBob(100n, 40n); + + const first = await spendEscrow(10n, 40n, 'burnFrom'); + const second = await spendEscrow(10n, 30n, 'burnFrom'); + + expectRerandomized(first, second); + }); + it('re-approve after a partial spend fails if the owner assumes the escrow is untouched', async () => { await approveBob(100n, 40n); diff --git a/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts b/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts index b9bae452..920d0936 100644 --- a/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts +++ b/contracts/src/token/test/witnesses/ConfidentialFungibleTokenWitnesses.ts @@ -13,8 +13,8 @@ // delivered amount from a memo (any size; the ECDH memo channel removed the // former 2^48 brute-force bound), and can recover balance/escrow amounts from // the public ciphertexts. Reusing a seed across transactions also leaks -// plaintext differences (the debit and escrow paths have no per-operation -// nonce). Do not copy this seed behavior into a real wallet. +// plaintext differences on the debit path, which has no per-operation nonce +// (`_spendEscrow` does). Do not copy this seed behavior into a real wallet. import { getRandomValues } from 'node:crypto'; import type {