// SPDX-License-Identifier: MIT pragma solidity 0.8.23; /** * @title AereThresholdRegistry, an on-chain registry of t-of-n MPC/TSS signing * committees for threshold ECDSA custody (AERE chain 2800) * * @notice The on-chain anchor a non-custodial custody committee (e.g. a Bank28 vault) * uses to PUBLISH the composition of a t-of-n threshold-ECDSA signing group and * to let any relying party VERIFY that a submitted signature was produced under * that group's key. A committee is `{ members[], threshold t, groupPubKey }`. * The group public key is a single secp256k1 point produced by an off-chain * Distributed Key Generation (DKG); no single party ever holds the whole private * key. On-chain we verify a signature under the group key with `ecrecover`, the * same primitive that secures every EOA on this chain. * * Lifecycle covered: * - registerCommittee: publish members, t, and the DKG group key. * - verifyThresholdSignature / isValidGroupSignature: relying-party checks. * - reshareCommittee: change membership/threshold, SAME group key (a proactive * resharing / refresh), authorized by a threshold signature of the CURRENT * committee over the exact reshare intent. * - rotateKey: move to a NEW group key (post-compromise recovery), authorized by * a threshold signature of the current committee. * - slashMember: record misbehaviour of a named member, authorized by a * threshold signature of the committee (or by an optional guardian). * - guardian pause/unpause for emergency freeze. * * @dev HONEST SCOPE, read this before trusting anything. * * 1. WHAT ecrecover PROVES, AND WHAT IT DOES NOT. On-chain, a correctly produced * t-of-n threshold-ECDSA signature is byte-for-byte INDISTINGUISHABLE from an * ordinary single-key ECDSA signature: both are (r,s,v) that recover to the same * address. Therefore this contract proves *authenticity under the group key*, it * can NOT and does NOT prove that at least t distinct members actually * participated. The "t-of-n" guarantee lives ENTIRELY in the off-chain DKG + * signing protocol (no party ever reconstructs the full key). If someone * reconstructs the group secret and signs alone, this contract would still * accept it. That is a property of threshold ECDSA on the EVM in general, not a * weakness unique to this contract. Do not read on-chain verification as a proof * of live t-participation. * * 2. NOT POST-QUANTUM. The group key is classical secp256k1 ECDSA, verified by the * chain's classical `ecrecover`. This contract has NOTHING to do with AERE's * live PQC precompiles, and AERE consensus itself is classical ECDSA QBFT. * Threshold PQC (threshold ML-DSA / Falcon) is an open research area and is NOT * implemented here; see docs THRESHOLD_PQC.md for the honest maturity status. * * 3. CUSTODY. This registry holds NO funds. It is metadata + verification only. It * never has custody of any key or asset and has no payable path and no * selfdestruct. A vault contract would CONSUME this registry (call * verifyThresholdSignature) to gate a withdrawal; that vault, not this, holds * value. * * @dev AUTHORIZATION MODEL. Governance actions (reshare / rotate / slash) are authorized * by the committee ITSELF: the caller must supply a valid threshold ECDSA signature * by the CURRENT group over a domain-separated intent hash binding * {chainId, this contract, committeeId, epoch, actionNonce, action params}. This is * replay-safe across chains, committees, epochs, and repeated actions. An optional * `guardian` address (set at registration, may be zero) can additionally pause the * committee and slash members, as a break-glass control; the guardian can never * move the key or forge a signature. */ contract AereThresholdRegistry { // ========================================================================== // Types // ========================================================================== enum Status { NONE, // 0: unused id ACTIVE, // 1: usable PAUSED, // 2: frozen by guardian, verification returns false RETIRED // 3: terminal, superseded or shut down } struct Committee { Status status; uint8 threshold; // t: signatures required off-chain to sign uint8 size; // n: number of members uint64 epoch; // increments on every reshare/rotate uint64 actionNonce; // increments on every governance action (replay guard) address groupAddress; // = address(keccak256(groupPubKey)[12:]) for ecrecover address guardian; // optional break-glass (0 = none) bytes32 membersHash; // keccak256(abi.encode(members)) integrity commitment bytes groupPubKey; // 64-byte uncompressed secp256k1 point (x||y), no 0x04 prefix } // secp256k1 group order; s in the LOWER half prevents signature malleability. uint256 internal constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; uint256 internal constant SECP256K1_N_HALF = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0; // Domain tags for the governance intent hash (kept distinct so a reshare auth can // never be replayed as a rotate auth or a slash auth). bytes32 internal constant ACTION_RESHARE = keccak256("AereThresholdRegistry.reshare.v1"); bytes32 internal constant ACTION_ROTATE = keccak256("AereThresholdRegistry.rotateKey.v1"); bytes32 internal constant ACTION_SLASH = keccak256("AereThresholdRegistry.slashMember.v1"); // ========================================================================== // Storage // ========================================================================== uint256 public committeeCount; mapping(uint256 => Committee) private _committees; // committeeId => member address => index+1 (0 = not a member) mapping(uint256 => mapping(address => uint256)) private _memberIndex1; // committeeId => member address => slashed? mapping(uint256 => mapping(address => bool)) public slashed; // committeeId => ordered member list mapping(uint256 => address[]) private _members; // ========================================================================== // Events // ========================================================================== event CommitteeRegistered( uint256 indexed committeeId, address indexed groupAddress, uint8 threshold, uint8 size, address guardian ); event CommitteeReshared( uint256 indexed committeeId, uint64 indexed epoch, uint8 threshold, uint8 size, bytes32 membersHash ); event KeyRotated( uint256 indexed committeeId, uint64 indexed epoch, address indexed newGroupAddress, uint8 threshold, uint8 size ); event MemberSlashed(uint256 indexed committeeId, address indexed member, bytes32 evidenceHash, address reporter); event CommitteePaused(uint256 indexed committeeId, address indexed by); event CommitteeUnpaused(uint256 indexed committeeId, address indexed by); event CommitteeRetired(uint256 indexed committeeId, address indexed by); // ========================================================================== // Registration // ========================================================================== /** * @notice Publish a new t-of-n committee. * @param members Ordered, unique, non-zero member identity addresses (n = length). * @param threshold t, with 1 <= t <= n. Off-chain the protocol needs >= t signers. * @param groupPubKey 64-byte uncompressed secp256k1 group key (x||y), no 0x04 prefix. * @param guardian Optional break-glass address (0 for none). * @return committeeId The id of the new committee. * * @dev The registrant does NOT become privileged: all later governance is by threshold * signature of the committee (plus the optional guardian). We deliberately do not * keep an "admin" EOA, so the on-chain trust model matches the off-chain one. */ function registerCommittee( address[] calldata members, uint8 threshold, bytes calldata groupPubKey, address guardian ) external returns (uint256 committeeId) { uint256 n = members.length; require(n >= 1 && n <= 255, "bad n"); require(threshold >= 1 && threshold <= n, "bad t"); address groupAddress = _pubKeyToAddress(groupPubKey); require(groupAddress != address(0), "bad groupPubKey"); committeeId = ++committeeCount; for (uint256 i = 0; i < n; i++) { address m = members[i]; require(m != address(0), "zero member"); require(_memberIndex1[committeeId][m] == 0, "dup member"); _memberIndex1[committeeId][m] = i + 1; _members[committeeId].push(m); } Committee storage c = _committees[committeeId]; c.status = Status.ACTIVE; c.threshold = threshold; c.size = uint8(n); c.epoch = 1; c.actionNonce = 0; c.groupAddress = groupAddress; c.guardian = guardian; c.membersHash = keccak256(abi.encode(members)); c.groupPubKey = groupPubKey; emit CommitteeRegistered(committeeId, groupAddress, threshold, uint8(n), guardian); } // ========================================================================== // Relying-party verification // ========================================================================== /** * @notice Verify a threshold-ECDSA signature under a committee's group key. * @param committeeId Target committee. * @param messageHash 32-byte digest that was signed (caller's responsibility to bind * domain separation into this digest for its own use case). * @param signature 65-byte (r||s||v) ECDSA signature, v in {27,28}, s in lower half. * @return ok True iff the committee is ACTIVE and `signature` recovers to its group * address. See the contract-level HONEST SCOPE note: this proves authenticity * under the group key, NOT that >= t members actually participated. * * @dev Fail-closed: returns false (never reverts) for a paused/retired/unknown * committee, a malformed signature, high-s, or a bad v. */ function verifyThresholdSignature(uint256 committeeId, bytes32 messageHash, bytes calldata signature) public view returns (bool ok) { Committee storage c = _committees[committeeId]; if (c.status != Status.ACTIVE) return false; address rec = _recover(messageHash, signature); return rec != address(0) && rec == c.groupAddress; } /** * @notice EIP-1271-style helper: returns the magic value if the committee's group key * signed `hash`, else 0xffffffff. Lets a committee's group key be used anywhere * an EIP-1271 signer is accepted (via a thin proxy that forwards here). */ function isValidGroupSignature(uint256 committeeId, bytes32 hash, bytes calldata signature) external view returns (bytes4) { return verifyThresholdSignature(committeeId, hash, signature) ? bytes4(0x1626ba7e) : bytes4(0xffffffff); } // ========================================================================== // Governance (threshold-authorized) // ========================================================================== /** * @notice Change membership and/or threshold while KEEPING the same group key (a * proactive resharing / share refresh). Authorized by a threshold signature of * the CURRENT committee over the reshare intent. * @param newMembers New ordered, unique, non-zero member set. * @param newThreshold New t (1..newMembers.length). * @param authSig 65-byte threshold ECDSA signature by the CURRENT group over the * intent hash (see {reshareIntent}). * * @dev groupPubKey is unchanged: an honest off-chain resharing preserves the group key. * We increment epoch (invalidating any earlier resharing) and actionNonce. */ function reshareCommittee( uint256 committeeId, address[] calldata newMembers, uint8 newThreshold, bytes calldata authSig ) external { Committee storage c = _committees[committeeId]; require(c.status == Status.ACTIVE, "not active"); uint256 n = newMembers.length; require(n >= 1 && n <= 255, "bad n"); require(newThreshold >= 1 && newThreshold <= n, "bad t"); bytes32 intent = reshareIntent(committeeId, newMembers, newThreshold); require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth"); _replaceMembers(committeeId, newMembers); c.threshold = newThreshold; c.size = uint8(n); c.epoch += 1; c.actionNonce += 1; c.membersHash = keccak256(abi.encode(newMembers)); emit CommitteeReshared(committeeId, c.epoch, newThreshold, uint8(n), c.membersHash); } /** * @notice Rotate to a NEW group key (post-compromise recovery), optionally with new * members / threshold. Authorized by a threshold signature of the CURRENT * (pre-rotation) committee over the rotation intent. * @param newGroupPubKey 64-byte uncompressed secp256k1 key for the new group. * @param authSig 65-byte threshold ECDSA signature by the CURRENT group over the * rotation intent hash (see {rotateIntent}). */ function rotateKey( uint256 committeeId, bytes calldata newGroupPubKey, address[] calldata newMembers, uint8 newThreshold, bytes calldata authSig ) external { Committee storage c = _committees[committeeId]; require(c.status == Status.ACTIVE, "not active"); uint256 n = newMembers.length; require(n >= 1 && n <= 255, "bad n"); require(newThreshold >= 1 && newThreshold <= n, "bad t"); address newGroupAddress = _pubKeyToAddress(newGroupPubKey); require(newGroupAddress != address(0), "bad newGroupPubKey"); require(newGroupAddress != c.groupAddress, "key unchanged"); bytes32 intent = rotateIntent(committeeId, newGroupPubKey, newMembers, newThreshold); require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth"); _replaceMembers(committeeId, newMembers); c.threshold = newThreshold; c.size = uint8(n); c.epoch += 1; c.actionNonce += 1; c.groupAddress = newGroupAddress; c.groupPubKey = newGroupPubKey; c.membersHash = keccak256(abi.encode(newMembers)); emit KeyRotated(committeeId, c.epoch, newGroupAddress, newThreshold, uint8(n)); } /** * @notice Record that a named member misbehaved. Authorized EITHER by a threshold * signature of the committee over the slash intent, OR by the guardian. * @param member The member being slashed (must be current member). * @param evidenceHash Off-chain evidence commitment (e.g. hash of an equivocation). * @param authSig Threshold ECDSA signature over the slash intent; ignored (may be * empty) when the caller is the guardian. * * @dev This is a HOOK: it records the fact and emits an event for an external * staking/insurance contract to act on. It does not itself move stake (this * registry holds no funds). Slashing a member does not change t/n or the key; the * committee should follow with a reshare that drops the member. */ function slashMember(uint256 committeeId, address member, bytes32 evidenceHash, bytes calldata authSig) external { Committee storage c = _committees[committeeId]; require(c.status == Status.ACTIVE || c.status == Status.PAUSED, "gone"); require(_memberIndex1[committeeId][member] != 0, "not a member"); require(!slashed[committeeId][member], "already slashed"); bool byGuardian = (c.guardian != address(0) && msg.sender == c.guardian); if (!byGuardian) { bytes32 intent = slashIntent(committeeId, member, evidenceHash); require(verifyThresholdSignature(committeeId, intent, authSig), "bad committee auth"); } slashed[committeeId][member] = true; c.actionNonce += 1; emit MemberSlashed(committeeId, member, evidenceHash, msg.sender); } // ========================================================================== // Guardian break-glass // ========================================================================== function pauseCommittee(uint256 committeeId) external { Committee storage c = _committees[committeeId]; require(c.status == Status.ACTIVE, "not active"); require(c.guardian != address(0) && msg.sender == c.guardian, "not guardian"); c.status = Status.PAUSED; emit CommitteePaused(committeeId, msg.sender); } function unpauseCommittee(uint256 committeeId) external { Committee storage c = _committees[committeeId]; require(c.status == Status.PAUSED, "not paused"); require(c.guardian != address(0) && msg.sender == c.guardian, "not guardian"); c.status = Status.ACTIVE; emit CommitteeUnpaused(committeeId, msg.sender); } // ========================================================================== // Intent hashes (domain-separated) // ========================================================================== function reshareIntent(uint256 committeeId, address[] calldata newMembers, uint8 newThreshold) public view returns (bytes32) { Committee storage c = _committees[committeeId]; return keccak256( abi.encode( ACTION_RESHARE, block.chainid, address(this), committeeId, c.epoch, c.actionNonce, newMembers, newThreshold ) ); } function rotateIntent( uint256 committeeId, bytes calldata newGroupPubKey, address[] calldata newMembers, uint8 newThreshold ) public view returns (bytes32) { Committee storage c = _committees[committeeId]; return keccak256( abi.encode( ACTION_ROTATE, block.chainid, address(this), committeeId, c.epoch, c.actionNonce, keccak256(newGroupPubKey), newMembers, newThreshold ) ); } function slashIntent(uint256 committeeId, address member, bytes32 evidenceHash) public view returns (bytes32) { Committee storage c = _committees[committeeId]; return keccak256( abi.encode(ACTION_SLASH, block.chainid, address(this), committeeId, c.epoch, c.actionNonce, member, evidenceHash) ); } // ========================================================================== // Views // ========================================================================== function getCommittee(uint256 committeeId) external view returns ( Status status, uint8 threshold, uint8 size, uint64 epoch, uint64 actionNonce, address groupAddress, address guardian, bytes32 membersHash, bytes memory groupPubKey ) { Committee storage c = _committees[committeeId]; return (c.status, c.threshold, c.size, c.epoch, c.actionNonce, c.groupAddress, c.guardian, c.membersHash, c.groupPubKey); } function getMembers(uint256 committeeId) external view returns (address[] memory) { return _members[committeeId]; } function isMember(uint256 committeeId, address who) external view returns (bool) { return _memberIndex1[committeeId][who] != 0; } function groupAddressOf(uint256 committeeId) external view returns (address) { return _committees[committeeId].groupAddress; } // ========================================================================== // Internals // ========================================================================== function _replaceMembers(uint256 committeeId, address[] calldata newMembers) internal { // Clear old membership index. address[] storage old = _members[committeeId]; for (uint256 i = 0; i < old.length; i++) { delete _memberIndex1[committeeId][old[i]]; // Note: `slashed` flags persist by design (history of a slashed address). } delete _members[committeeId]; // Install new membership. for (uint256 i = 0; i < newMembers.length; i++) { address m = newMembers[i]; require(m != address(0), "zero member"); require(_memberIndex1[committeeId][m] == 0, "dup member"); _memberIndex1[committeeId][m] = i + 1; _members[committeeId].push(m); } } /// @dev secp256k1 uncompressed pubkey (64 bytes, x||y) -> ethereum address. function _pubKeyToAddress(bytes calldata pubKey) internal pure returns (address) { if (pubKey.length != 64) return address(0); return address(uint160(uint256(keccak256(pubKey)))); } /// @dev Strict ECDSA recover: 65-byte sig, low-s, v in {27,28}. 0 on any failure. function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := calldataload(sig.offset) s := calldataload(add(sig.offset, 32)) v := byte(0, calldataload(add(sig.offset, 64))) } if (uint256(s) > SECP256K1_N_HALF) return address(0); if (v != 27 && v != 28) return address(0); return ecrecover(hash, v, r, s); } }