aere-contracts/test/formal/AereCryptoRegistry.symbolic.t.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

118 lines
5.7 KiB
Solidity

// 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.
}
}