// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import {Test} from "forge-std/Test.sol"; import {AereCryptoRegistry} from "../../contracts/pqc/AereCryptoRegistry.sol"; /** * @title Symbolic (halmos) proofs for the AereCryptoRegistry governance/routing layer. * * WHAT IS PROVEN (see PROVEN-PROPERTIES.md): * R1 FAIL-CLOSED: a REVOKED or UNKNOWN algorithm id can NEVER pass verify(), for * any pubkey/message/signature, regardless of what the routed verifier returns. * R2 status transitions are OWNER-ONLY: a non-owner call to setStatus (and * setSuccessor) cannot change registry state. * R3 resolveActive TERMINATES: the successor walk always halts (returns an ACTIVE * id or reverts) within the bounded number of registered rows; it never * diverges, even on a successor cycle. * * ASSUMPTIONS: * - R1 does not need the precompile oracle at all: verify() gates on status BEFORE * touching the verifier, so fail-closed holds independently of any precompile. * - R3's termination proof relies on the loop being structurally bounded by _count * and on halmos unrolling the loop to at least _count+1 iterations (--loop >= 12, * with _count == 7 rows seeded here). We deliberately seed a successor CYCLE so * the cycle-detection path is exercised. * - Ownership: the test contract is the deployer, hence the Ownable owner. The * symbolic non-owner caller is constrained to differ from it. */ contract AereCryptoRegistrySymbolic is Test { AereCryptoRegistry internal reg; function setUp() public { reg = new AereCryptoRegistry(); reg.seedLiveSchemes(); // rows 1..5 (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s ACTIVE; SHAKE256 hash-only) // Make row 1 REVOKED so the REVOKED branch of R1 is reachable and terminal. reg.setStatus(1, AereCryptoRegistry.Status.REVOKED); // Two extra rows (6, 7) put into a DEPRECATED successor CYCLE to exercise R3's // cycle detection (neither is ACTIVE, so resolveActive must walk then revert). reg.addAlgorithm("legacy-a", 100, address(0xBEEF), 32, 100, 0, 1000, true); // id 6 reg.addAlgorithm("legacy-b", 101, address(0xCAFE), 32, 100, 0, 1000, true); // id 7 reg.setStatus(6, AereCryptoRegistry.Status.DEPRECATED); reg.setStatus(7, AereCryptoRegistry.Status.DEPRECATED); reg.setSuccessor(6, 7); reg.setSuccessor(7, 6); // 6 -> 7 -> 6 -> ... (cycle) } // --------------------------------------------------------------------- // R1 : fail-closed. Any REVOKED or UNKNOWN id can never verify true. // --------------------------------------------------------------------- function check_revokedOrUnknownNeverVerifies( uint256 id, bytes calldata pubKey, bytes32 messageHash, bytes calldata signature ) external view { AereCryptoRegistry.Status st = reg.statusOf(id); vm.assume(st == AereCryptoRegistry.Status.UNKNOWN || st == AereCryptoRegistry.Status.REVOKED); // For these statuses verify() short-circuits to false before ever routing to a // verifier, so the result is false for every possible signature/key/message. bool ok = reg.verify(id, pubKey, messageHash, signature); assert(ok == false); } // --------------------------------------------------------------------- // R2 : setStatus is owner-only. A non-owner cannot change status. // --------------------------------------------------------------------- function check_setStatusIsOwnerOnly(address caller, uint256 id, uint8 newStatusRaw) external { vm.assume(caller != reg.owner()); vm.assume(newStatusRaw >= 1 && newStatusRaw <= 3); // ACTIVE/DEPRECATED/REVOKED (valid enum, decode-safe) AereCryptoRegistry.Status pre = reg.statusOf(id); vm.prank(caller); (bool ok,) = address(reg).call( abi.encodeCall(reg.setStatus, (id, AereCryptoRegistry.Status(newStatusRaw))) ); assert(!ok); // onlyOwner must revert the non-owner call assert(reg.statusOf(id) == pre); // and status is provably unchanged } // --------------------------------------------------------------------- // R2b : setSuccessor is owner-only. // --------------------------------------------------------------------- function check_setSuccessorIsOwnerOnly(address caller, uint256 id, uint256 successorId) external { vm.assume(caller != reg.owner()); AereCryptoRegistry.Status preStatus = reg.statusOf(id); vm.prank(caller); (bool ok,) = address(reg).call(abi.encodeCall(reg.setSuccessor, (id, successorId))); assert(!ok); assert(reg.statusOf(id) == preStatus); } // --------------------------------------------------------------------- // R3 : resolveActive terminates. Over any symbolic start id, the call always // halts within the unroll bound and, when it returns, returns an ACTIVE id. // (Reaching the end of this function at all == termination was demonstrated // by halmos fully exploring the bounded loop.) // --------------------------------------------------------------------- function check_resolveActiveTerminatesAndReturnsActive(uint256 startId) external view { (bool ok, bytes memory ret) = address(reg).staticcall( abi.encodeCall(reg.resolveActive, (startId)) ); if (ok) { uint256 resolved = abi.decode(ret, (uint256)); assert(reg.isActive(resolved)); // resolveActive only ever returns an ACTIVE row } // If !ok it reverted (UnknownAlgorithm / NoActiveSuccessor / SuccessorCycle): // that is still termination. The cycle 6->7->6 forces the SuccessorCycle path. } }