From c6b93abc6022e3a4ec61a9225d08210a4fb30740 Mon Sep 17 00:00:00 2001 From: Zara Date: Fri, 10 Jul 2026 12:49:30 -0700 Subject: [PATCH 1/5] fixed div_mod in U64 (IF-001 ) --- circuits/lib/src/math/modulo/U64.nr | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/circuits/lib/src/math/modulo/U64.nr b/circuits/lib/src/math/modulo/U64.nr index 59c707f63..10bee2c4e 100644 --- a/circuits/lib/src/math/modulo/U64.nr +++ b/circuits/lib/src/math/modulo/U64.nr @@ -100,9 +100,14 @@ impl ModU64 { /// (a / b) mod m: multiplies a by b^(-1) mod m. pub fn div_mod(self, a: Field, b: Field) -> Field { - // Safety: __inv_mod_u64 uses Fermat's Little Theorem; verified via mul_mod below. + // Safety: __inv_mod_u64 uses Fermat's Little Theorem; verified below by result*b == a mod m. let b_inv = unsafe { __inv_mod_u64(b, self.m) }; - self.mul_mod(a, b_inv as Field) + let result = self.mul_mod(a, b_inv as Field); + assert( + self.mul_mod(result, b) == self.reduce_mod(a), + "Division verification failed: result * b != a (mod m)", + ); + result } /// (-val) mod m. @@ -351,3 +356,14 @@ fn test_u64_division_multiplication_inverse() { let quotient = m.div_mod(a, b); assert(m.mul_mod(quotient, b) == m.reduce_mod(a)); } + +#[test(should_fail_with = "Division verification failed: result * b != a (mod m)")] +fn test_u64_div_mod_rejects_non_invertible_divisor() { + // m = 100 is composite and b = 10 shares a factor with it, so b has no true + // modular inverse. The Fermat's-Little-Theorem-based unconstrained hint + // (`__inv_mod_u64`, which assumes a prime modulus) silently returns 0 here, + // which previously let `div_mod` return an unconstrained/bogus result. This + // regression-tests IF-001: the verification assert must now catch it. + let m = ModU64::new(100); + let _ = m.div_mod(7, 10); +} From 5a96fa5c72055ca92209ccd518690a06312232bd Mon Sep 17 00:00:00 2001 From: Zara Date: Fri, 10 Jul 2026 13:32:14 -0700 Subject: [PATCH 2/5] fixed decoding in C7 (IF-002 ) --- .../lib/src/core/threshold/decrypted_shares_aggregation.nr | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/circuits/lib/src/core/threshold/decrypted_shares_aggregation.nr b/circuits/lib/src/core/threshold/decrypted_shares_aggregation.nr index 66e9fe7c6..90250dc31 100644 --- a/circuits/lib/src/core/threshold/decrypted_shares_aggregation.nr +++ b/circuits/lib/src/core/threshold/decrypted_shares_aggregation.nr @@ -159,10 +159,7 @@ impl Date: Mon, 13 Jul 2026 18:52:40 -0700 Subject: [PATCH 3/5] cross phase check:bind decryption proof sk/esm to stored DKG anchors (IF-003) --- .../interfaces/IDecryptionVerifier.sol | 6 + .../verifiers/bfv/BfvDecryptionVerifier.sol | 95 ++++++- .../deployAndSave/bfvDecryptionVerifier.ts | 2 + .../scripts/deployInterfold.ts | 6 +- packages/interfold-contracts/scripts/utils.ts | 16 ++ .../test/BfvDecryptionVerifier.spec.ts | 235 +++++++++++++++++- .../test/BfvVkBindingIntegration.spec.ts | 42 +++- 7 files changed, 386 insertions(+), 16 deletions(-) diff --git a/packages/interfold-contracts/contracts/interfaces/IDecryptionVerifier.sol b/packages/interfold-contracts/contracts/interfaces/IDecryptionVerifier.sol index 06d13aa32..237ef110d 100644 --- a/packages/interfold-contracts/contracts/interfaces/IDecryptionVerifier.sol +++ b/packages/interfold-contracts/contracts/interfaces/IDecryptionVerifier.sol @@ -36,6 +36,12 @@ interface IDecryptionVerifier { /// @notice The domain-binding public-input slot does not equal the value /// recomputed on-chain from the call context. error DomainBindingMismatch(); + /// @notice A `party_id` returned by the proof is not present in the + /// registry's stored DKG anchors for this E3. + error DkgAnchorNotFound(); + /// @notice The proof's `expected_sk`/`expected_esm` commitment for a + /// party does not match the registry's stored DKG anchor. + error DkgAnchorMismatch(); /// @notice Verify a DecryptionAggregator EVM proof and bind it to the full /// on-chain call context. diff --git a/packages/interfold-contracts/contracts/verifiers/bfv/BfvDecryptionVerifier.sol b/packages/interfold-contracts/contracts/verifiers/bfv/BfvDecryptionVerifier.sol index 84b26f5cd..191cc0216 100644 --- a/packages/interfold-contracts/contracts/verifiers/bfv/BfvDecryptionVerifier.sol +++ b/packages/interfold-contracts/contracts/verifiers/bfv/BfvDecryptionVerifier.sol @@ -7,6 +7,7 @@ pragma solidity 0.8.28; import { IDecryptionVerifier } from "../../interfaces/IDecryptionVerifier.sol"; import { ICircuitVerifier } from "../../interfaces/ICircuitVerifier.sol"; +import { ICiphernodeRegistry } from "../../interfaces/ICiphernodeRegistry.sol"; import { CommitteeHashLib } from "../../lib/CommitteeHashLib.sol"; /** @@ -31,12 +32,19 @@ import { CommitteeHashLib } from "../../lib/CommitteeHashLib.sol"; * construction; this anchors the recursive aggregation trust and * prevents a malicious aggregator from substituting a forged sub-VK. * - * NOTE -- domain binding relaxation: wrapper-level chainId/deployment/e3Id - * binding requires a dedicated circuit public input. The current circuits - * do not expose such a slot. Full cryptographic enforcement tracked as - * future work. The caller-supplied `e3Id`, `committeeRoot`, `sortedNodes`, - * `ciphertextOutputHash`, and `committeePublicKey` are preserved in the - * interface for forward compatibility. + * The `party_ids`/`expected_sk`/`expected_esm` columns are cross-checked + * against `ciphernodeRegistry.getDkgAnchors(e3Id)` (`_verifyDkgAnchors`): + * the circuit only proves a decryption share is internally consistent + * with some self-declared commitment, so this binds that commitment to + * the address-signed DKG output actually recorded for this E3. + * + * NOTE -- binding relaxation still open: there is no ciphertext-binding + * check (the circuits do not expose a ciphertext commitment on this + * proof) and no wrapper-level chainId/deployment binding beyond the + * committee-hash and DKG-anchor checks above. The caller-supplied + * `committeeRoot`, `sortedNodes`, `ciphertextOutputHash`, and + * `committeePublicKey` are preserved in the interface for forward + * compatibility but not yet checked. */ contract BfvDecryptionVerifier is IDecryptionVerifier { /// @dev Message is always the last 100 public inputs (100 uint64 coeffs = 800 bytes plaintext). @@ -45,7 +53,9 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { /// @dev `decryption_aggregator` return tail: `1 + 3*(T+1) + MESSAGE_COEFFS_COUNT` fields. uint256 internal constant DEC_RETURN_PREFIX_LEN = 1; - /// @dev `decryption_aggregator` return columns after the leading key hash (sk, esm, ct). + /// @dev `decryption_aggregator` return columns after the leading key hash + /// (party_ids, expected_sk, expected_esm). NOTE: despite the name, there is + /// no ciphertext column here -- `decryption_aggregator` does not expose one. uint256 internal constant DEC_RETURN_COLUMN_COUNT = 3; /// @dev `publicInputs` index for `committee_hash_hi` (after sub-circuit key hashes). @@ -60,9 +70,26 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { /// @dev `4 + DEC_RETURN_PREFIX_LEN + DEC_RETURN_COLUMN_COUNT*(T+1) + MESSAGE_COEFFS_COUNT`. uint256 internal immutable expectedPublicInputsLen; + /// @dev `publicInputs` start index of the `party_ids[T+1]` column. + /// Circuit-side `party_ids` are 1-indexed Shamir x-coordinates + /// (1..N_PARTIES); the registry's `dkgPartyIds` are 0-indexed + /// sortition slots, so comparisons subtract 1 (see `_verifyDkgAnchors`). + uint256 internal immutable partyIdColOffset; + + /// @dev `publicInputs` start index of the `expected_sk[T+1]` column. + uint256 internal immutable skColOffset; + + /// @dev `publicInputs` start index of the `expected_esm[T+1]` column. + uint256 internal immutable esmColOffset; + /// @notice Underlying Honk verifier for the DecryptionAggregator circuit. ICircuitVerifier public immutable circuitVerifier; + /// @notice Registry holding the per-E3 DKG anchors (`dkgPartyIds`, + /// `dkgSkAggCommits`, `dkgEsmAggCommits`) that the proof's + /// `party_ids`/`expected_sk`/`expected_esm` outputs must match. + ICiphernodeRegistry public immutable ciphernodeRegistry; + /// @notice keccak256 commitment to the C6-fold recursive VK; expected at /// `publicInputs[0]`. Provenance: `bb verify_key -b /// circuits/bin/recursive_aggregation/c6_fold/target/...` -- pinned @@ -75,6 +102,7 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { constructor( address _circuitVerifier, + address _ciphernodeRegistry, bytes32 _expectedC6FoldKeyHash, bytes32 _expectedC7KeyHash, uint256 _threshold @@ -87,7 +115,12 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { (DEC_RETURN_COLUMN_COUNT * (_threshold + 1)) + MESSAGE_COEFFS_COUNT; + partyIdColOffset = 4 + DEC_RETURN_PREFIX_LEN; + skColOffset = partyIdColOffset + (_threshold + 1); + esmColOffset = skColOffset + (_threshold + 1); + circuitVerifier = ICircuitVerifier(_circuitVerifier); + ciphernodeRegistry = ICiphernodeRegistry(_ciphernodeRegistry); expectedC6FoldKeyHash = _expectedC6FoldKeyHash; expectedC7KeyHash = _expectedC7KeyHash; } @@ -139,9 +172,12 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { revert PlaintextHashMismatch(); } + // Cross-phase binding: the proof's per-party sk/esm commitments must match + // the DKG anchors this registry recorded (address-signed) for this E3. + _verifyDkgAnchors(e3Id, publicInputs); + // Suppress unused-variable warnings for forward-compatibility params. // These will be used for circuit-level domain binding in a future circuit update. - e3Id; committeeRoot; sortedNodes; ciphertextOutputHash; @@ -168,4 +204,47 @@ contract BfvDecryptionVerifier is IDecryptionVerifier { } return keccak256(plaintext) == expected; } + + /// @dev Binds the proof's `party_ids`/`expected_sk`/`expected_esm` outputs to the + /// registry's stored DKG anchors for this E3, closing the gap where the circuit + /// only proves internal self-consistency ("I know a share matching some claimed + /// commitment") without tying that commitment to the address-signed DKG output. + /// + /// Index note: circuit `party_ids` are 1-indexed Shamir x-coordinates + /// (1..N_PARTIES); `getDkgAnchors` returns the registry's 0-indexed sortition + /// `party_id` (matching `topNodes`/`canonicalCommitteeNodeAt`). Subtract 1 + /// before comparing. + function _verifyDkgAnchors( + uint256 e3Id, + bytes32[] memory publicInputs + ) internal view { + ( + uint256[] memory dkgPartyIds, + bytes32[] memory dkgSkAggCommits, + bytes32[] memory dkgEsmAggCommits + ) = ciphernodeRegistry.getDkgAnchors(e3Id); + + for (uint256 i = 0; i < threshold + 1; i++) { + uint256 circuitPartyId = uint256(publicInputs[partyIdColOffset + i]); + uint256 registryPartyId = circuitPartyId - 1; + + uint256 matchedIdx = type(uint256).max; + for (uint256 j = 0; j < dkgPartyIds.length; j++) { + if (dkgPartyIds[j] == registryPartyId) { + matchedIdx = j; + break; + } + } + if (matchedIdx == type(uint256).max) { + revert DkgAnchorNotFound(); + } + + if ( + publicInputs[skColOffset + i] != dkgSkAggCommits[matchedIdx] || + publicInputs[esmColOffset + i] != dkgEsmAggCommits[matchedIdx] + ) { + revert DkgAnchorMismatch(); + } + } + } } diff --git a/packages/interfold-contracts/scripts/deployAndSave/bfvDecryptionVerifier.ts b/packages/interfold-contracts/scripts/deployAndSave/bfvDecryptionVerifier.ts index 80f5a22b5..b7d3504bd 100644 --- a/packages/interfold-contracts/scripts/deployAndSave/bfvDecryptionVerifier.ts +++ b/packages/interfold-contracts/scripts/deployAndSave/bfvDecryptionVerifier.ts @@ -20,6 +20,7 @@ import { export const deployAndSaveBfvDecryptionVerifier = async ( hre: HardhatRuntimeEnvironment, + ciphernodeRegistryAddress: string, ): Promise<{ bfvDecryptionVerifier: BfvDecryptionVerifier; }> => { @@ -66,6 +67,7 @@ export const deployAndSaveBfvDecryptionVerifier = async ( ); const bfvDecryptionVerifier = await bfvDecryptionVerifierFactory.deploy( circuitVerifierArgs.address, + ciphernodeRegistryAddress, expectedC6FoldKeyHash, expectedC7KeyHash, BFV_THRESHOLD_T, diff --git a/packages/interfold-contracts/scripts/deployInterfold.ts b/packages/interfold-contracts/scripts/deployInterfold.ts index 9309f7e6c..842736ed9 100644 --- a/packages/interfold-contracts/scripts/deployInterfold.ts +++ b/packages/interfold-contracts/scripts/deployInterfold.ts @@ -553,8 +553,10 @@ export const deployInterfold = async ( if (shouldHaveZKVerification) { console.log("Deploying BfvDecryptionVerifier and registering for prod..."); - const { bfvDecryptionVerifier } = - await deployAndSaveBfvDecryptionVerifier(hre); + const { bfvDecryptionVerifier } = await deployAndSaveBfvDecryptionVerifier( + hre, + ciphernodeRegistryAddress, + ); const bfvDecryptionVerifierAddress = await bfvDecryptionVerifier.getAddress(); const deployedDecryptionVerifier = diff --git a/packages/interfold-contracts/scripts/utils.ts b/packages/interfold-contracts/scripts/utils.ts index add04d1f0..fdd12ef2a 100644 --- a/packages/interfold-contracts/scripts/utils.ts +++ b/packages/interfold-contracts/scripts/utils.ts @@ -118,6 +118,22 @@ export function bfvDecCommitteeHashIndices(): { hi: number; lo: number } { return { hi: 2, lo: 3 }; } +/** + * `publicInputs` start indices for the `party_ids`/`expected_sk`/`expected_esm` + * columns (each `threshold + 1` wide), matching `BfvDecryptionVerifier`'s + * `partyIdColOffset`/`skColOffset`/`esmColOffset`. + */ +export function bfvDecPartyColOffsets(threshold: number): { + partyId: number; + sk: number; + esm: number; +} { + const partyId = 5; // 4 + DEC_RETURN_PREFIX_LEN + const sk = partyId + (threshold + 1); + const esm = sk + (threshold + 1); + return { partyId, sk, esm }; +} + /** Recursive VK hashes for `BfvPkVerifier` sub-circuits (from `pnpm compile:circuits`). */ export function getBfvPkSubCircuitVkHashPaths() { const root = getRepoRoot(); diff --git a/packages/interfold-contracts/test/BfvDecryptionVerifier.spec.ts b/packages/interfold-contracts/test/BfvDecryptionVerifier.spec.ts index 7a71a27b3..b26b72336 100644 --- a/packages/interfold-contracts/test/BfvDecryptionVerifier.spec.ts +++ b/packages/interfold-contracts/test/BfvDecryptionVerifier.spec.ts @@ -6,13 +6,16 @@ import { expect } from "chai"; import { network } from "hardhat"; +import MockCiphernodeRegistryModule from "../ignition/modules/mockCiphernodeRegistry"; import MockCircuitVerifierModule from "../ignition/modules/mockSlashingVerifier"; import { BFV_THRESHOLD_T, bfvDecExpectedPublicInputsLen, + bfvDecPartyColOffsets, } from "../scripts/utils"; import { BfvDecryptionVerifier__factory as BfvDecryptionVerifierFactory, + MockCiphernodeRegistry__factory as MockCiphernodeRegistryFactory, MockCircuitVerifier__factory as MockCircuitVerifierFactory, } from "../types"; @@ -36,6 +39,28 @@ const EXPECTED_PUBLIC_INPUTS_LEN = bfvDecExpectedPublicInputsLen(THRESHOLD); const COMMITTEE_HASH_HI_IDX = 2; const COMMITTEE_HASH_LO_IDX = 3; +/** `party_ids`/`expected_sk`/`expected_esm` column start indices for `THRESHOLD`. */ +const { partyId: PARTY_ID_COL_OFFSET, sk: SK_COL_OFFSET, esm: ESM_COL_OFFSET } = + bfvDecPartyColOffsets(THRESHOLD); + +/** + * Default DKG anchors for `THRESHOLD + 1` reconstructing parties, consistent across + * circuit-side (1-indexed) and registry-side (0-indexed) party id conventions. + */ +const DEFAULT_REGISTRY_PARTY_IDS = Array.from( + { length: THRESHOLD + 1 }, + (_, i) => i, +); // 0-indexed, matches `dkgPartyIds`/`topNodes` +const DEFAULT_CIRCUIT_PARTY_IDS = DEFAULT_REGISTRY_PARTY_IDS.map( + (id) => id + 1, +); // 1-indexed Shamir x-coordinates, as emitted by `decryption_aggregator` +const DEFAULT_SK_COMMITS = DEFAULT_REGISTRY_PARTY_IDS.map((id) => + ethers.id(`sk-${id}`), +); +const DEFAULT_ESM_COMMITS = DEFAULT_REGISTRY_PARTY_IDS.map((id) => + ethers.id(`esm-${id}`), +); + function committeeHashHi(committeeHash: string): string { const v = BigInt(committeeHash); return "0x" + (v >> 128n).toString(16).padStart(64, "0"); @@ -55,6 +80,12 @@ function buildPublicInputsWithMessage( EXPECTED_C7_KEY_HASH, ], committeeHash = ethers.ZeroHash, + // Circuit-side (1-indexed) party_ids and their sk/esm commitments. Defaults + // match `DEFAULT_REGISTRY_PARTY_IDS`'s anchors as configured by `deployWithMockCircuit`, + // so tests unrelated to DKG-anchor binding pass that check transparently. + partyIds: bigint[] = DEFAULT_CIRCUIT_PARTY_IDS.map(BigInt), + skCommits: string[] = DEFAULT_SK_COMMITS, + esmCommits: string[] = DEFAULT_ESM_COMMITS, ): string[] { const arr: string[] = new Array(totalInputs); arr[0] = subCircuitHashes[0]; @@ -64,6 +95,16 @@ function buildPublicInputsWithMessage( } arr[COMMITTEE_HASH_HI_IDX] = committeeHashHi(committeeHash); arr[COMMITTEE_HASH_LO_IDX] = committeeHashLo(committeeHash); + for (let i = 0; i < partyIds.length; i++) { + arr[PARTY_ID_COL_OFFSET + i] = + "0x" + partyIds[i].toString(16).padStart(64, "0"); + } + for (let i = 0; i < skCommits.length; i++) { + arr[SK_COL_OFFSET + i] = skCommits[i]; + } + for (let i = 0; i < esmCommits.length; i++) { + arr[ESM_COL_OFFSET + i] = esmCommits[i]; + } const offset = totalInputs - MESSAGE_COEFFS_COUNT; for (let i = 0; i < messageCoeffs.length && i < MESSAGE_COEFFS_COUNT; i++) { arr[offset + i] = "0x" + messageCoeffs[i].toString(16).padStart(64, "0"); @@ -97,6 +138,8 @@ function encodeProof(rawProof: string, publicInputs: string[]): string { } describe("BfvDecryptionVerifier", function () { + const E3_ID = 7n; + const deployWithMockCircuit = async () => { const [owner] = await ethers.getSigners(); const { mockCircuitVerifier } = await ignition.deploy( @@ -104,10 +147,26 @@ describe("BfvDecryptionVerifier", function () { ); const mockAddr = await mockCircuitVerifier.getAddress(); + const { mockCiphernodeRegistry } = await ignition.deploy( + MockCiphernodeRegistryModule, + ); + const registryAddr = await mockCiphernodeRegistry.getAddress(); + const registry = MockCiphernodeRegistryFactory.connect( + registryAddr, + owner, + ); + await registry.setDkgAnchors( + E3_ID, + DEFAULT_REGISTRY_PARTY_IDS, + DEFAULT_SK_COMMITS, + DEFAULT_ESM_COMMITS, + ); + const bfvDecryptionVerifier = await ( await ethers.getContractFactory("BfvDecryptionVerifier") ).deploy( mockAddr, + registryAddr, EXPECTED_C6_FOLD_KEY_HASH, EXPECTED_C7_KEY_HASH, THRESHOLD, @@ -119,12 +178,16 @@ describe("BfvDecryptionVerifier", function () { owner, ); const mc = MockCircuitVerifierFactory.connect(mockAddr, owner); - return { bfvDecryptionVerifier: dv, mockCircuit: mc }; + return { + bfvDecryptionVerifier: dv, + mockCircuit: mc, + mockCiphernodeRegistry: registry, + }; }; /** Contextual params forwarded to verify; not checked against circuit outputs (future domain binding). */ const ctx = () => { - const e3Id = 7n; + const e3Id = E3_ID; const root = BigInt(ethers.id("test-root")); const nodes = [testSigner.address]; const ciphertextHash = ethers.id("ct-hash"); @@ -372,15 +435,19 @@ describe("BfvDecryptionVerifier", function () { }); it("reverts VkHashMismatch when constructor expected hashes do not match proof", async function () { - const { mockCircuit } = await loadFixture(deployWithMockCircuit); + const { mockCircuit, mockCiphernodeRegistry } = await loadFixture( + deployWithMockCircuit, + ); await mockCircuit.setReturnValue(true); const mockAddr = await mockCircuit.getAddress(); + const registryAddr = await mockCiphernodeRegistry.getAddress(); const { e3Id, root, nodes, ciphertextHash, committeePk } = ctx(); const bfvDecryptionVerifier = await ( await ethers.getContractFactory("BfvDecryptionVerifier") ).deploy( mockAddr, + registryAddr, ethers.id("wrong-c6"), ethers.id("wrong-c7"), THRESHOLD, @@ -405,6 +472,160 @@ describe("BfvDecryptionVerifier", function () { ), ).to.be.revertedWithCustomError(bfvDecryptionVerifier, "VkHashMismatch"); }); + + it("reverts DkgAnchorMismatch when a party's sk commitment doesn't match the stored DKG anchor", async function () { + const { bfvDecryptionVerifier, mockCircuit } = await loadFixture( + deployWithMockCircuit, + ); + await mockCircuit.setReturnValue(true); + const { e3Id, root, nodes, ciphertextHash, committeePk } = ctx(); + + const messageCoeffs = [1n, 2n, 3n]; + const forgedSkCommits = [ethers.id("forged-sk-0"), DEFAULT_SK_COMMITS[1]]; + const publicInputs = buildPublicInputsWithMessage( + messageCoeffs, + EXPECTED_PUBLIC_INPUTS_LEN, + [EXPECTED_C6_FOLD_KEY_HASH, EXPECTED_C7_KEY_HASH], + ethers.ZeroHash, + DEFAULT_CIRCUIT_PARTY_IDS.map(BigInt), + forgedSkCommits, + DEFAULT_ESM_COMMITS, + ); + const plaintextHash = plaintextToHash(messageCoeffs); + const proof = encodeProof("0x01", publicInputs); + + await expect( + bfvDecryptionVerifier.verify.staticCall( + e3Id, + root, + nodes, + ciphertextHash, + committeePk, + plaintextHash, + ethers.ZeroHash, + proof, + ), + ).to.be.revertedWithCustomError( + bfvDecryptionVerifier, + "DkgAnchorMismatch", + ); + }); + + it("reverts DkgAnchorMismatch when a party's esm commitment doesn't match the stored DKG anchor", async function () { + const { bfvDecryptionVerifier, mockCircuit } = await loadFixture( + deployWithMockCircuit, + ); + await mockCircuit.setReturnValue(true); + const { e3Id, root, nodes, ciphertextHash, committeePk } = ctx(); + + const messageCoeffs = [1n, 2n, 3n]; + const forgedEsmCommits = [ + DEFAULT_ESM_COMMITS[0], + ethers.id("forged-esm-1"), + ]; + const publicInputs = buildPublicInputsWithMessage( + messageCoeffs, + EXPECTED_PUBLIC_INPUTS_LEN, + [EXPECTED_C6_FOLD_KEY_HASH, EXPECTED_C7_KEY_HASH], + ethers.ZeroHash, + DEFAULT_CIRCUIT_PARTY_IDS.map(BigInt), + DEFAULT_SK_COMMITS, + forgedEsmCommits, + ); + const plaintextHash = plaintextToHash(messageCoeffs); + const proof = encodeProof("0x01", publicInputs); + + await expect( + bfvDecryptionVerifier.verify.staticCall( + e3Id, + root, + nodes, + ciphertextHash, + committeePk, + plaintextHash, + ethers.ZeroHash, + proof, + ), + ).to.be.revertedWithCustomError( + bfvDecryptionVerifier, + "DkgAnchorMismatch", + ); + }); + + it("reverts DkgAnchorNotFound when a party_id is not present in the stored DKG anchors", async function () { + const { bfvDecryptionVerifier, mockCircuit } = await loadFixture( + deployWithMockCircuit, + ); + await mockCircuit.setReturnValue(true); + const { e3Id, root, nodes, ciphertextHash, committeePk } = ctx(); + + // Registry only has anchors for 0-indexed party ids [0, 1] (circuit-side [1, 2]). + // Circuit-side id 99 has no corresponding registry entry at any offset. + const messageCoeffs = [1n, 2n, 3n]; + const unknownPartyIds = [99n, ...DEFAULT_CIRCUIT_PARTY_IDS.slice(1).map(BigInt)]; + const publicInputs = buildPublicInputsWithMessage( + messageCoeffs, + EXPECTED_PUBLIC_INPUTS_LEN, + [EXPECTED_C6_FOLD_KEY_HASH, EXPECTED_C7_KEY_HASH], + ethers.ZeroHash, + unknownPartyIds, + DEFAULT_SK_COMMITS, + DEFAULT_ESM_COMMITS, + ); + const plaintextHash = plaintextToHash(messageCoeffs); + const proof = encodeProof("0x01", publicInputs); + + await expect( + bfvDecryptionVerifier.verify.staticCall( + e3Id, + root, + nodes, + ciphertextHash, + committeePk, + plaintextHash, + ethers.ZeroHash, + proof, + ), + ).to.be.revertedWithCustomError( + bfvDecryptionVerifier, + "DkgAnchorNotFound", + ); + }); + + it("reverts (arithmetic underflow) when circuit party_id is 0 -- x=0 is the secret's own point, never a valid share", async function () { + const { bfvDecryptionVerifier, mockCircuit } = await loadFixture( + deployWithMockCircuit, + ); + await mockCircuit.setReturnValue(true); + const { e3Id, root, nodes, ciphertextHash, committeePk } = ctx(); + + const messageCoeffs = [1n, 2n, 3n]; + const zeroPartyIds = [0n, ...DEFAULT_CIRCUIT_PARTY_IDS.slice(1).map(BigInt)]; + const publicInputs = buildPublicInputsWithMessage( + messageCoeffs, + EXPECTED_PUBLIC_INPUTS_LEN, + [EXPECTED_C6_FOLD_KEY_HASH, EXPECTED_C7_KEY_HASH], + ethers.ZeroHash, + zeroPartyIds, + DEFAULT_SK_COMMITS, + DEFAULT_ESM_COMMITS, + ); + const plaintextHash = plaintextToHash(messageCoeffs); + const proof = encodeProof("0x01", publicInputs); + + await expect( + bfvDecryptionVerifier.verify.staticCall( + e3Id, + root, + nodes, + ciphertextHash, + committeePk, + plaintextHash, + ethers.ZeroHash, + proof, + ), + ).to.be.revert(ethers); + }); }); describe("success", function () { @@ -571,5 +792,13 @@ describe("BfvDecryptionVerifier", function () { EXPECTED_C7_KEY_HASH, ); }); + + it("exposes correct ciphernodeRegistry", async function () { + const { bfvDecryptionVerifier, mockCiphernodeRegistry } = + await loadFixture(deployWithMockCircuit); + expect(await bfvDecryptionVerifier.ciphernodeRegistry()).to.equal( + await mockCiphernodeRegistry.getAddress(), + ); + }); }); }); diff --git a/packages/interfold-contracts/test/BfvVkBindingIntegration.spec.ts b/packages/interfold-contracts/test/BfvVkBindingIntegration.spec.ts index 7d43ae1f4..3b9c6135b 100644 --- a/packages/interfold-contracts/test/BfvVkBindingIntegration.spec.ts +++ b/packages/interfold-contracts/test/BfvVkBindingIntegration.spec.ts @@ -9,6 +9,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import MockCiphernodeRegistryModule from "../ignition/modules/mockCiphernodeRegistry"; import { BFV_DKG_H, BFV_THRESHOLD_T, @@ -16,6 +17,7 @@ import { assertBfvPkVerifierSubCircuitVkHashes, bfvDecCommitteeHashIndices, bfvDecExpectedPublicInputsLen, + bfvDecPartyColOffsets, bfvDkgCommitteeHashIndices, bfvPkExpectedPublicInputsLen, committeeHashFromLimbs, @@ -23,9 +25,13 @@ import { getBfvPkSubCircuitVkHashPaths, readVkRecursiveHash, } from "../scripts/utils"; -import type { BfvDecryptionVerifier, BfvPkVerifier } from "../types"; +import type { + BfvDecryptionVerifier, + BfvPkVerifier, + MockCiphernodeRegistry, +} from "../types"; -const { ethers, networkHelpers } = await network.connect(); +const { ethers, ignition, networkHelpers } = await network.connect(); const { loadFixture } = networkHelpers; const testDir = path.dirname(fileURLToPath(import.meta.url)); @@ -138,6 +144,11 @@ function plaintextHashFromPublicInputs(publicInputs: string[]): string { describe("BfvVkBindingIntegration", function () { const deployHonkAndBfv = async () => { + const { mockCiphernodeRegistry } = await ignition.deploy( + MockCiphernodeRegistryModule, + ); + const registryAddr = await mockCiphernodeRegistry.getAddress(); + const libFactory = await ethers.getContractFactory( "contracts/verifiers/bfv/honk/DkgAggregatorVerifier.sol:ZKTranscriptLib", ); @@ -196,6 +207,7 @@ describe("BfvVkBindingIntegration", function () { await ethers.getContractFactory("BfvDecryptionVerifier") ).deploy( await decAgg.getAddress(), + registryAddr, expectedC6FoldKeyHash, expectedC7KeyHash, BFV_THRESHOLD_T, @@ -205,6 +217,8 @@ describe("BfvVkBindingIntegration", function () { return { bfvPk: bfvPk as unknown as BfvPkVerifier, bfvDec: bfvDec as unknown as BfvDecryptionVerifier, + mockCiphernodeRegistry: + mockCiphernodeRegistry as unknown as MockCiphernodeRegistry, }; }; @@ -240,6 +254,7 @@ describe("BfvVkBindingIntegration", function () { await ethers.getContractFactory("BfvDecryptionVerifier") ).deploy( await bfvDec.circuitVerifier(), + await bfvDec.ciphernodeRegistry(), ethers.id("stale-c6"), ethers.id("stale-c7"), BFV_THRESHOLD_T, @@ -326,13 +341,34 @@ describe("BfvVkBindingIntegration", function () { await networkHelpers.setBlockGasLimit(HONK_VERIFY_GAS_LIMIT); - const { bfvPk, bfvDec } = await deployHonkAndBfv(); + const { bfvPk, bfvDec, mockCiphernodeRegistry } = + await deployHonkAndBfv(); const [testSigner] = await ethers.getSigners(); const testE3Id = 1n; const testRoot = BigInt(ethers.id("test-root")); const abiCoder = ethers.AbiCoder.defaultAbiCoder(); const verifyOverrides = { gasLimit: HONK_VERIFY_GAS_LIMIT }; + // Derive DKG anchors straight from the real folded proof's own public inputs + // (circuit-side party_ids are 1-indexed; registry-side are 0-indexed) so the + // new cross-phase sk/esm binding check passes for this genuine proof. + const { partyId: partyIdOffset, sk: skOffset, esm: esmOffset } = + bfvDecPartyColOffsets(BFV_THRESHOLD_T); + const registryPartyIds: bigint[] = []; + const skCommits: string[] = []; + const esmCommits: string[] = []; + for (let i = 0; i < BFV_THRESHOLD_T + 1; i++) { + registryPartyIds.push(BigInt(decPublicInputs[partyIdOffset + i]) - 1n); + skCommits.push(decPublicInputs[skOffset + i]); + esmCommits.push(decPublicInputs[esmOffset + i]); + } + await mockCiphernodeRegistry.setDkgAnchors( + testE3Id, + registryPartyIds, + skCommits, + esmCommits, + ); + const dkgEncoded = abiCoder.encode( ["bytes", "bytes32[]"], [folded.dkg_aggregator.proof_hex, dkgPublicInputs], From 2b80ac1af9acda5725c142c98261b30243af50fc Mon Sep 17 00:00:00 2001 From: Zara Date: Mon, 13 Jul 2026 19:00:07 -0700 Subject: [PATCH 4/5] correct decryption_aggregator party_id range (IF-003) --- .../decryption_aggregator/src/main.nr | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr b/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr index 67f6a513b..dbf69721a 100644 --- a/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr +++ b/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr @@ -41,6 +41,17 @@ pub global C7_MESSAGE_OFFSET: u32 = 2 * (T + 1); pub global C7_PUBLIC_LEN: u32 = (2 * (T + 1)) + MAX_MSG_NON_ZERO_COEFFS; +/// Validates a single circuit-side `party_id`: must be a 1-indexed Shamir x-coordinate +/// in `[1, N_PARTIES]`. `x=0` is reserved for the secret itself (see DKG +/// `share_computation.nr`'s `y[...][party_idx + 1]`) and must never be accepted as a +/// participating share -- regression coverage for the off-by-one this circuit + +fn assert_valid_party_id(id: Field) { + let id_u32 = id as u32; + assert(id_u32 >= 1); + assert(id_u32 <= N_PARTIES); +} + fn main( c6_fold_vk: UltraHonkVerificationKey, c6_fold_proof: UltraHonkProof, @@ -83,11 +94,11 @@ fn main( expected_sk[i] = c6_fold_public[C6_FOLD_PREFIX_LEN + C6_FOLD_SK_COL_OFFSET + i]; expected_esm[i] = c6_fold_public[C6_FOLD_PREFIX_LEN + C6_FOLD_ESM_COL_OFFSET + i]; - // Range-check each participating `party_ids[i]` against the full committee size - // `N_PARTIES`, so `committee_members[party_ids[i]]` is a well-defined committee slot. - // Structural pre-condition for future per-party signer/identity constraints. - let id = party_ids[i] as u32; - assert(id < N_PARTIES); + // `committee_members` and `topNodes` are 0-indexed; a future per-party + // signer/identity check must index them with `committee_members[party_ids[i] - 1]`, + // not `party_ids[i]` (which is a 1-indexed Shamir x-coordinate -- see + // `assert_valid_party_id`). + assert_valid_party_id(party_ids[i]); } let mut message: [Field; MAX_MSG_NON_ZERO_COEFFS] = [0; MAX_MSG_NON_ZERO_COEFFS]; @@ -97,3 +108,27 @@ fn main( (key_hash, party_ids, expected_sk, expected_esm, message) } + +// ------------------------------ TESTS ------------------------------ + +#[test] +fn test_party_id_min_valid() { + assert_valid_party_id(1); +} + +#[test] +fn test_party_id_max_valid() { + assert_valid_party_id(N_PARTIES as Field); +} + +#[test(should_fail)] +fn test_party_id_zero_rejected() { + // x=0 is the secret's own evaluation point, never a valid share -- this is the + // exact value the old `id < N_PARTIES` bound wrongly admitted. + assert_valid_party_id(0); +} + +#[test(should_fail)] +fn test_party_id_above_n_parties_rejected() { + assert_valid_party_id((N_PARTIES + 1) as Field); +} From 4e699ad5fe8355dbe35d83b39468f897415b7a66 Mon Sep 17 00:00:00 2001 From: Zara Date: Mon, 13 Jul 2026 20:05:50 -0700 Subject: [PATCH 5/5] enforce distinct party_ids in decryption_aggregator (IF-003) --- .../decryption_aggregator/src/main.nr | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr b/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr index dbf69721a..74a60d6f4 100644 --- a/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr +++ b/circuits/bin/recursive_aggregation/decryption_aggregator/src/main.nr @@ -44,12 +44,23 @@ pub global C7_PUBLIC_LEN: u32 = (2 * (T + 1)) + MAX_MSG_NON_ZERO_COEFFS; /// Validates a single circuit-side `party_id`: must be a 1-indexed Shamir x-coordinate /// in `[1, N_PARTIES]`. `x=0` is reserved for the secret itself (see DKG /// `share_computation.nr`'s `y[...][party_idx + 1]`) and must never be accepted as a -/// participating share -- regression coverage for the off-by-one this circuit - -fn assert_valid_party_id(id: Field) { +/// participating share. Returns the `u32` cast for reuse by the caller (e.g. the +/// strictly-increasing check below), avoiding a second `Field -> u32` cast. +fn assert_valid_party_id(id: Field) -> u32 { let id_u32 = id as u32; assert(id_u32 >= 1); assert(id_u32 <= N_PARTIES); + id_u32 +} + +/// Asserts `curr > prev`. Used to force the `T + 1` reconstructing `party_ids` to be +/// strictly increasing, which (a) implies distinctness -- no party's share can occupy +/// two reconstruction slots, which would let a single real share masquerade as T+1 +/// independent contributions and silently defeat the T+1 honest-party threshold -- and +/// (b) matches the ordering `decrypted_shares_aggregation.nr`'s own Lagrange +/// sign-computation already assumes but never enforced. +fn assert_strictly_increasing(prev: u32, curr: u32) { + assert(curr > prev); } fn main( @@ -89,6 +100,7 @@ fn main( let mut party_ids: [Field; T + 1] = [0; T + 1]; let mut expected_sk: [Field; T + 1] = [0; T + 1]; let mut expected_esm: [Field; T + 1] = [0; T + 1]; + let mut prev_party_id: u32 = 0; for i in 0..(T + 1) { party_ids[i] = c7_public[C7_PARTY_ID_OFFSET + i]; expected_sk[i] = c6_fold_public[C6_FOLD_PREFIX_LEN + C6_FOLD_SK_COL_OFFSET + i]; @@ -98,7 +110,11 @@ fn main( // signer/identity check must index them with `committee_members[party_ids[i] - 1]`, // not `party_ids[i]` (which is a 1-indexed Shamir x-coordinate -- see // `assert_valid_party_id`). - assert_valid_party_id(party_ids[i]); + let id_u32 = assert_valid_party_id(party_ids[i]); + if i > 0 { + assert_strictly_increasing(prev_party_id, id_u32); + } + prev_party_id = id_u32; } let mut message: [Field; MAX_MSG_NON_ZERO_COEFFS] = [0; MAX_MSG_NON_ZERO_COEFFS]; @@ -113,22 +129,39 @@ fn main( #[test] fn test_party_id_min_valid() { - assert_valid_party_id(1); + let _ = assert_valid_party_id(1); } #[test] fn test_party_id_max_valid() { - assert_valid_party_id(N_PARTIES as Field); + let _ = assert_valid_party_id(N_PARTIES as Field); } #[test(should_fail)] fn test_party_id_zero_rejected() { // x=0 is the secret's own evaluation point, never a valid share -- this is the // exact value the old `id < N_PARTIES` bound wrongly admitted. - assert_valid_party_id(0); + let _ = assert_valid_party_id(0); } #[test(should_fail)] fn test_party_id_above_n_parties_rejected() { - assert_valid_party_id((N_PARTIES + 1) as Field); + let _ = assert_valid_party_id((N_PARTIES + 1) as Field); +} + +#[test] +fn test_strictly_increasing_accepts_ascending_pair() { + assert_strictly_increasing(1, 2); +} + +#[test(should_fail)] +fn test_strictly_increasing_rejects_duplicate() { + // The exact attack this closes: the same party's share occupying two + // reconstruction slots, letting one real share masquerade as two. + assert_strictly_increasing(3, 3); +} + +#[test(should_fail)] +fn test_strictly_increasing_rejects_descending_pair() { + assert_strictly_increasing(5, 2); }