From 2cc0839618def63a8d3493c88084219259c88ae8 Mon Sep 17 00:00:00 2001 From: murd3r17 Date: Wed, 27 May 2026 13:05:31 +0800 Subject: [PATCH] fix: prevent ZK proof replay in ZkAuditVerifier Add global _usedProofHashes dedup to block re-submission of the same Groth16 proof across verifyAuditScore and verifyFingerprint. Split the Groth16 internal call helpers for 8- and 3-input verifiers, fix NatSpec, and add MockGroth16Verifier plus regression tests (Fixes #4). Co-authored-by: Cursor --- contracts/package.json | 1 + contracts/scripts/compileZk.js | 103 +++++++++++++++++ contracts/src/ZkAuditVerifier.sol | 74 ++++++------ contracts/src/mocks/MockGroth16Verifier.sol | 26 +++++ contracts/tests/ZkAuditVerifier.test.js | 122 ++++++++++++++++++++ 5 files changed, 293 insertions(+), 33 deletions(-) create mode 100644 contracts/scripts/compileZk.js create mode 100644 contracts/src/mocks/MockGroth16Verifier.sol create mode 100644 contracts/tests/ZkAuditVerifier.test.js diff --git a/contracts/package.json b/contracts/package.json index 7c2c832..671be36 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -7,6 +7,7 @@ "compile": "node scripts/compile.js", "deploy:local": "npm run compile && HOME=$PWD/.home XDG_CONFIG_HOME=$PWD/.xdg/config XDG_DATA_HOME=$PWD/.xdg/data npx hardhat run --no-compile scripts/runDeployLocal.js --network hardhat", "deploy:edge": "npm run compile && node scripts/runDeployEdge.js", + "test:zk": "node scripts/compileZk.js && npx hardhat test tests/ZkAuditVerifier.test.js --no-compile", "test": "npm run compile && HOME=$PWD/.home XDG_CONFIG_HOME=$PWD/.xdg/config XDG_DATA_HOME=$PWD/.xdg/data npx hardhat test --no-compile" }, "devDependencies": { diff --git a/contracts/scripts/compileZk.js b/contracts/scripts/compileZk.js new file mode 100644 index 0000000..d1b33b1 --- /dev/null +++ b/contracts/scripts/compileZk.js @@ -0,0 +1,103 @@ +const fs = require("fs"); +const path = require("path"); + +let solc; + +try { + solc = require("solc"); +} catch (error) { + console.error( + [ + "Missing Solidity compiler dependency: solc", + "Run `npm install` inside contracts/ once npm registry access is available." + ].join("\n") + ); + process.exit(1); +} + +const sourceDir = path.join(__dirname, "..", "src"); +const artifactDir = path.join(__dirname, "..", "artifacts"); + +const CONTRACTS = [ + { name: "MockGroth16Verifier", file: "mocks/MockGroth16Verifier.sol" }, + { name: "ZkAuditVerifier", file: "ZkAuditVerifier.sol" } +]; + +function formatCompilerVersion(version) { + const match = /^(\d+\.\d+\.\d+)/.exec(version); + return match ? match[1] : version; +} + +function compileAll() { + const sources = {}; + for (const contract of CONTRACTS) { + const sourcePath = path.join(sourceDir, contract.file); + sources[`src/${contract.file}`] = { + content: fs.readFileSync(sourcePath, "utf8") + }; + } + + const input = { + language: "Solidity", + sources, + settings: { + optimizer: { enabled: true, runs: 200 }, + evmVersion: "paris", + viaIR: true, + outputSelection: { + "*": { + "*": ["abi", "evm.bytecode.object", "evm.deployedBytecode.object", "metadata"] + } + } + } + }; + + return JSON.parse(solc.compile(JSON.stringify(input))); +} + +function assertNoCompilerErrors(output) { + const messages = output.errors ?? []; + const fatalErrors = messages.filter((entry) => entry.severity === "error"); + + for (const message of messages) { + console.error(message.formattedMessage ?? message.message); + } + + if (fatalErrors.length > 0) { + process.exit(1); + } +} + +function writeArtifact(output, contractFile, contractName) { + const contractOutput = output.contracts?.[`src/${contractFile}`]?.[contractName]; + + if (!contractOutput) { + throw new Error(`${contractName} output was not produced`); + } + + const artifact = { + contractName, + sourceName: `src/${contractFile}`, + abi: contractOutput.abi, + bytecode: `0x${contractOutput.evm.bytecode.object}`, + deployedBytecode: `0x${contractOutput.evm.deployedBytecode.object}`, + compiler: { version: formatCompilerVersion(solc.version()) }, + metadata: JSON.parse(contractOutput.metadata) + }; + + fs.mkdirSync(artifactDir, { recursive: true }); + const artifactPath = path.join(artifactDir, `${contractName}.json`); + fs.writeFileSync(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`); + console.log(`Compiled ${contractName} → ${artifactPath}`); +} + +function main() { + const output = compileAll(); + assertNoCompilerErrors(output); + + for (const contract of CONTRACTS) { + writeArtifact(output, contract.file, contract.name); + } +} + +main(); diff --git a/contracts/src/ZkAuditVerifier.sol b/contracts/src/ZkAuditVerifier.sol index d70df4e..78bddb9 100644 --- a/contracts/src/ZkAuditVerifier.sol +++ b/contracts/src/ZkAuditVerifier.sol @@ -72,6 +72,9 @@ contract ZkAuditVerifier { // tokenId => number of verified audit proofs mapping(uint256 => uint256) public auditProofCount; + // Global dedup: each Groth16 proof may only be consumed once + mapping(bytes32 => bool) private _usedProofHashes; + // --- Modifiers --- modifier onlyOwner() { @@ -108,7 +111,9 @@ contract ZkAuditVerifier { * @param dimensionalScores The 6 claimed dimensional scores [0-100] * @param overallScore The claimed weighted overall score [0-100] * @param inputCommitment Poseidon hash binding the proof to private inputs - * @param proof The Groth16 proof (a, b, c points) + * @param a Groth16 proof component A + * @param b Groth16 proof component B + * @param c Groth16 proof component C */ function verifyAuditScore( uint256 tokenId, @@ -131,17 +136,17 @@ contract ZkAuditVerifier { pubInputs[7] = inputCommitment; // Call the Groth16 verifier - bool valid = _callGroth16Verifier( + bool valid = _callGroth16Verifier8( auditScoreVerifierContract, a, b, c, - pubInputs, - 8 + pubInputs ); require(valid, "INVALID_PROOF"); - // Store the verified proof bytes32 proofHash = keccak256(abi.encodePacked(a[0], a[1], b[0][0], b[0][1], c[0], c[1])); + require(!_usedProofHashes[proofHash], "PROOF_REPLAYED"); + _usedProofHashes[proofHash] = true; auditScoreProofs[tokenId][auditId] = AuditScoreProof({ tokenId: tokenId, @@ -168,7 +173,9 @@ contract ZkAuditVerifier { * @param tokenId The agent's NFT token ID * @param fingerprintHash The public fingerprint hash * @param developerHash The developer ownership hash - * @param proof The Groth16 proof (a, b, c points) + * @param a Groth16 proof component A + * @param b Groth16 proof component B + * @param c Groth16 proof component C */ function verifyFingerprint( uint256 tokenId, @@ -186,16 +193,17 @@ contract ZkAuditVerifier { pubInputs[1] = tokenId; pubInputs[2] = developerHash; - bool valid = _callGroth16Verifier( + bool valid = _callGroth16Verifier3( fingerprintVerifierContract, a, b, c, - pubInputs, - 3 + pubInputs ); require(valid, "INVALID_PROOF"); bytes32 proofHash = keccak256(abi.encodePacked(a[0], a[1], b[0][0], b[0][1], c[0], c[1])); + require(!_usedProofHashes[proofHash], "PROOF_REPLAYED"); + _usedProofHashes[proofHash] = true; fingerprintProofs[tokenId] = AgentFingerprintProof({ tokenId: tokenId, @@ -239,36 +247,36 @@ contract ZkAuditVerifier { // --- Internal: Generic Groth16 Verifier Call --- - function _callGroth16Verifier( + function _callGroth16Verifier8( address verifier, uint256[2] calldata a, uint256[2][2] calldata b, uint256[2] calldata c, - uint256[8] memory pubInputs, - uint256 numInputs + uint256[8] memory pubInputs ) internal view returns (bool) { - // snarkjs-generated verifiers expose: - // verifyProof(uint[2] a, uint[2][2] b, uint[2] c, uint[N] input) → bool - // We encode the call dynamically based on numInputs - bytes memory payload; - - if (numInputs == 8) { - payload = abi.encodeWithSignature( - "verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[8])", - a, b, c, pubInputs - ); - } else if (numInputs == 3) { - uint256[3] memory inputs3; - for (uint256 i = 0; i < 3; i++) { - inputs3[i] = pubInputs[i]; - } - payload = abi.encodeWithSignature( - "verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[3])", - a, b, c, inputs3 - ); - } else { - revert("UNSUPPORTED_INPUT_COUNT"); + bytes memory payload = abi.encodeWithSignature( + "verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[8])", + a, b, c, pubInputs + ); + + (bool success, bytes memory result) = verifier.staticcall(payload); + if (!success || result.length < 32) { + return false; } + return abi.decode(result, (bool)); + } + + function _callGroth16Verifier3( + address verifier, + uint256[2] calldata a, + uint256[2][2] calldata b, + uint256[2] calldata c, + uint256[3] memory pubInputs + ) internal view returns (bool) { + bytes memory payload = abi.encodeWithSignature( + "verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[3])", + a, b, c, pubInputs + ); (bool success, bytes memory result) = verifier.staticcall(payload); if (!success || result.length < 32) { diff --git a/contracts/src/mocks/MockGroth16Verifier.sol b/contracts/src/mocks/MockGroth16Verifier.sol new file mode 100644 index 0000000..a46a7bd --- /dev/null +++ b/contracts/src/mocks/MockGroth16Verifier.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/** + * @title MockGroth16Verifier + * @notice Test-only Groth16 verifier that accepts all proofs. + */ +contract MockGroth16Verifier { + function verifyProof( + uint256[2] calldata, + uint256[2][2] calldata, + uint256[2] calldata, + uint256[8] calldata + ) external pure returns (bool) { + return true; + } + + function verifyProof( + uint256[2] calldata, + uint256[2][2] calldata, + uint256[2] calldata, + uint256[3] calldata + ) external pure returns (bool) { + return true; + } +} diff --git a/contracts/tests/ZkAuditVerifier.test.js b/contracts/tests/ZkAuditVerifier.test.js new file mode 100644 index 0000000..325b5ba --- /dev/null +++ b/contracts/tests/ZkAuditVerifier.test.js @@ -0,0 +1,122 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const { ethers } = require("hardhat"); + +function loadArtifact(name) { + const flatPath = path.join(__dirname, "..", "artifacts", `${name}.json`); + if (fs.existsSync(flatPath)) { + return JSON.parse(fs.readFileSync(flatPath, "utf8")); + } + const hardhatPath = path.join(__dirname, "..", "artifacts", "src", `${name}.sol`, `${name}.json`); + return JSON.parse(fs.readFileSync(hardhatPath, "utf8")); +} + +const PROOF_A = [1, 2]; +const PROOF_B = [ + [3, 4], + [5, 6] +]; +const PROOF_C = [7, 8]; + +const DIMENSIONAL_SCORES = [80, 85, 90, 75, 88, 92]; +const OVERALL_SCORE = 85; +const INPUT_COMMITMENT = 12345; + +async function deployZkAuditVerifier() { + const [owner] = await ethers.getSigners(); + + const mockArtifact = loadArtifact("MockGroth16Verifier"); + const mockFactory = new ethers.ContractFactory(mockArtifact.abi, mockArtifact.bytecode, owner); + const mockVerifier = await mockFactory.deploy(); + await mockVerifier.deployed(); + + const verifierArtifact = loadArtifact("ZkAuditVerifier"); + const verifierFactory = new ethers.ContractFactory(verifierArtifact.abi, verifierArtifact.bytecode, owner); + const verifier = await verifierFactory.deploy(); + await verifier.deployed(); + + await (await verifier.setAuditScoreVerifier(mockVerifier.address)).wait(); + await (await verifier.setFingerprintVerifier(mockVerifier.address)).wait(); + + return { verifier, owner }; +} + +function buildAuditScoreArgs(tokenId, auditId) { + return [ + tokenId, + auditId, + DIMENSIONAL_SCORES, + OVERALL_SCORE, + INPUT_COMMITMENT, + PROOF_A, + PROOF_B, + PROOF_C + ]; +} + +function buildFingerprintArgs(tokenId, fingerprintHash, developerHash) { + return [tokenId, fingerprintHash, developerHash, PROOF_A, PROOF_B, PROOF_C]; +} + +describe("ZkAuditVerifier — proof replay protection", function () { + it("verifyAuditScore stores the first proof and increments auditProofCount", async function () { + const { verifier } = await deployZkAuditVerifier(); + + await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait(); + + assert.strictEqual(await verifier.isAuditScoreVerified(1, 1), true); + assert.strictEqual((await verifier.auditProofCount(1)).toNumber(), 1); + + const stored = await verifier.getAuditScoreProof(1, 1); + assert.strictEqual(stored.overallScore.toNumber(), OVERALL_SCORE); + assert.strictEqual(stored.verified, true); + }); + + it("rejects replaying the same audit score proof for the same tokenId and auditId", async function () { + const { verifier } = await deployZkAuditVerifier(); + + await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait(); + + await assert.rejects( + verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1)), + /PROOF_REPLAYED/ + ); + + assert.strictEqual((await verifier.auditProofCount(1)).toNumber(), 1); + }); + + it("rejects replaying the same audit score proof for a different tokenId and auditId", async function () { + const { verifier } = await deployZkAuditVerifier(); + + await (await verifier.verifyAuditScore(...buildAuditScoreArgs(1, 1))).wait(); + + await assert.rejects( + verifier.verifyAuditScore(...buildAuditScoreArgs(2, 2)), + /PROOF_REPLAYED/ + ); + + assert.strictEqual(await verifier.isAuditScoreVerified(2, 2), false); + assert.strictEqual((await verifier.auditProofCount(2)).toNumber(), 0); + }); + + it("rejects replaying the same fingerprint proof", async function () { + const { verifier } = await deployZkAuditVerifier(); + + await (await verifier.verifyFingerprint(...buildFingerprintArgs(1, 111, 222))).wait(); + assert.strictEqual(await verifier.isFingerprintVerified(1), true); + + await assert.rejects( + verifier.verifyFingerprint(...buildFingerprintArgs(1, 111, 222)), + /PROOF_REPLAYED/ + ); + + await assert.rejects( + verifier.verifyFingerprint(...buildFingerprintArgs(2, 333, 444)), + /PROOF_REPLAYED/ + ); + + assert.strictEqual(await verifier.isFingerprintVerified(2), false); + }); +});