aere-contracts/contracts/modular/AerePQCSocialRecoveryModule.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

334 lines
17 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @notice Root-owner surface of AereModularAccount used by this module.
interface IAereModularAccountOwner {
function rootOwner() external view returns (address);
function setRootOwner(address newOwner) external;
}
/// @notice Subset of AerePQCKeyRegistry (chain 2800) this module depends on. Each guardian is a
/// NIST PQC public key registered under the registry with an on-chain PROOF-OF-POSSESSION,
/// so a guardian keyId is a key the guardian has already proven it controls. Verification
/// of every recovery leg is delegated to `verifyWithKey`, which runs the full PQC check via
/// AERE's live precompiles (Falcon-512 0x0AE1, Falcon-1024 0x0AE2, ML-DSA-44 0x0AE3,
/// SLH-DSA-128s 0x0AE4). `isActiveKey` is used so a rotated or revoked guardian key can
/// never authorize a recovery.
interface IAerePQCKeyRegistry {
function verifyWithKey(uint256 keyId, bytes32 message, bytes calldata signature) external view returns (bool);
function isActiveKey(uint256 keyId) external view returns (bool);
function ownerOf(uint256 keyId) external view returns (address);
function schemeOf(uint256 keyId) external view returns (uint8);
function keyCount() external view returns (uint256);
}
/// @title AerePQCSocialRecoveryModule - quantum-durable M-of-N recovery for AereModularAccount
/// @notice ERC-7579 executor module (module type 2). Recovers an account's root owner when the
/// primary key is lost, using an M-of-N committee of POST-QUANTUM guardians instead of
/// classical ECDSA guardian addresses. Each guardian is a NIST PQC public key
/// (Falcon-512 / Falcon-1024 / ML-DSA-44 / SLH-DSA-128s) registered with proof-of-
/// possession in AerePQCKeyRegistry; a recovery is authorized by >= `threshold` DISTINCT
/// guardians, each supplying a valid PQC signature over a domain-separated challenge that
/// binds (account, newOwner, round, chainid). Every leg is verified IN FULL, on-chain, by
/// the registry's live precompiles. The state change is held behind the same 48h timelock
/// as AereSocialRecoveryModule, giving the true owner a window to cancel a malicious
/// recovery.
///
/// @dev Singleton: per-account state is keyed by the account address, exactly like
/// AereSocialRecoveryModule. Install as module type 2 on the account; executeRecovery then
/// calls account.setRootOwner(), which AereModularAccount permits for installed executors.
///
/// The t-of-n leg pattern is taken VERBATIM from AereThresholdAccount:
/// - a `Leg` is {guardianIndex, signature};
/// - distinctness is counted by guardian INDEX via a `seen` bitmask, so one key can never
/// fill two slots (duplicate guardian keyIds are additionally rejected at config time);
/// - malformed / duplicate / non-verifying legs are ignored, never counted, so an
/// attacker can never inflate the distinct-valid count;
/// - `round` (a per-account monotone nonce) is inside the challenge and advances on
/// execute/cancel, so a set of legs is single-use and can never be replayed.
///
/// HONEST SCOPE. Application/account layer only. This changes nothing about AERE consensus,
/// which remains classical ECDSA QBFT (7 Foundation validators, f=2). What is post-quantum
/// here is the GUARDIAN AUTHORIZATION: recovery approval survives a quantum adversary that
/// can forge ECDSA, because it is gated on NIST PQC signatures verified by the live
/// precompiles, not on secp256k1 guardian keys.
contract AerePQCSocialRecoveryModule {
/// @notice Timelock between a threshold-approved recovery and the owner swap. Matches
/// AereSocialRecoveryModule.RECOVERY_DELAY so the two modules behave identically in time.
uint256 public constant RECOVERY_DELAY = 48 hours;
uint256 public constant MODULE_TYPE_EXECUTOR = 2;
/// @notice The AerePQCKeyRegistry guardians are registered against (immutable; set at deploy).
IAerePQCKeyRegistry public immutable registry;
/// @notice Domain separator for the recovery challenge (distinct from any other AERE PQC domain,
/// so a signature made here can never be replayed on the key registry or a PQC account).
bytes32 public constant RECOVERY_DOMAIN = keccak256("AerePQCSocialRecoveryModule.v1.recovery");
uint256 internal constant MAX_GUARDIANS = 255; // guardianIndex is uint8; bitmask fits in uint256
/// @notice One authorizing leg: which guardian (index into the account's guardian keyId array)
/// plus that guardian's PQC signature envelope over the recovery challenge.
struct Leg {
uint8 guardianIndex;
bytes signature;
}
struct Recovery {
address newOwner; // proposed new root owner
uint64 executeAfter; // scheduling time + RECOVERY_DELAY
uint256 round; // recoveryNonce snapshot the legs were signed for
bool active;
}
/// @notice account => ordered guardian keyIds (position = guardianIndex used in a Leg).
mapping(address => uint256[]) internal _guardianKeyIds;
/// @notice account => keyId => is a guardian (membership + duplicate guard).
mapping(address => mapping(uint256 => bool)) public isGuardianKey;
/// @notice account => M in the M-of-N committee.
mapping(address => uint256) public thresholdOf;
/// @notice account => monotone recovery round id (replay barrier for the legs).
mapping(address => uint256) public recoveryNonce;
/// @notice account => currently pending recovery (if any).
mapping(address => Recovery) public activeRecovery;
event GuardianKeyAdded(address indexed account, uint256 indexed keyId, uint8 scheme);
event GuardianKeyRemoved(address indexed account, uint256 indexed keyId);
event ThresholdChanged(address indexed account, uint256 threshold);
event RecoveryScheduled(address indexed account, uint256 indexed round, address indexed newOwner, uint64 executeAfter, uint256 signers);
event RecoveryExecuted(address indexed account, uint256 indexed round, address indexed newOwner);
event RecoveryCancelled(address indexed account, uint256 indexed round, address canceller);
error BadRegistry();
error AlreadyConfigured();
error NotConfigured();
error InvalidGuardianSet();
error DuplicateGuardianKey(uint256 keyId);
error GuardianKeyNotActive(uint256 keyId);
error InvalidGuardianKey(uint256 keyId);
error NotGuardianKey();
error InvalidThreshold();
error InvalidNewOwner();
error RecoveryAlreadyActive();
error NoActiveRecovery();
error BelowThreshold(uint256 got, uint256 need);
error TimelockNotElapsed(uint64 executeAfter, uint256 nowTimestamp);
error NotOwner();
constructor(address _registry) {
if (_registry == address(0)) revert BadRegistry();
registry = IAerePQCKeyRegistry(_registry);
}
// ---- ERC-7579-style module base -------------------------------------------
function isModuleType(uint256 moduleTypeId) external pure returns (bool) {
return moduleTypeId == MODULE_TYPE_EXECUTOR;
}
function name() external pure returns (string memory) {
return "aere.pqc-social-recovery.1.0.0";
}
/// @notice initData layout: abi.encode(uint256[] guardianKeyIds, uint256 threshold).
/// Each keyId must be a distinct, currently ACTIVE registry key NOT owned by the account
/// itself (a self-owned guardian would collapse the non-custodial recovery guarantee).
function onInstall(bytes calldata data) external {
address account = msg.sender;
if (_guardianKeyIds[account].length != 0) revert AlreadyConfigured();
(uint256[] memory keyIds, uint256 threshold) = abi.decode(data, (uint256[], uint256));
uint256 n = keyIds.length;
if (n == 0 || n > MAX_GUARDIANS) revert InvalidGuardianSet();
for (uint256 i = 0; i < n; i++) {
_addGuardianKey(account, keyIds[i]);
}
if (threshold == 0 || threshold > n) revert InvalidThreshold();
thresholdOf[account] = threshold;
emit ThresholdChanged(account, threshold);
}
/// @notice Full per-account cleanup so a later reinstall starts clean. `recoveryNonce` is
/// intentionally NOT reset: keeping it monotone across (un)installs means legs signed
/// for a prior round can never be replayed after a reinstall.
function onUninstall(bytes calldata) external {
address account = msg.sender;
uint256[] storage list = _guardianKeyIds[account];
for (uint256 i = 0; i < list.length; i++) {
isGuardianKey[account][list[i]] = false;
}
delete _guardianKeyIds[account];
delete thresholdOf[account];
delete activeRecovery[account];
}
// ---- Owner-only guardian management (msg.sender == the smart account) ------
function addGuardianKey(uint256 keyId) external {
_addGuardianKey(msg.sender, keyId);
}
function removeGuardianKey(uint256 keyId) external {
address account = msg.sender;
if (!isGuardianKey[account][keyId]) revert NotGuardianKey();
isGuardianKey[account][keyId] = false;
uint256[] storage list = _guardianKeyIds[account];
for (uint256 i = 0; i < list.length; i++) {
if (list[i] == keyId) {
list[i] = list[list.length - 1];
list.pop();
break;
}
}
// Keep threshold satisfiable and the set non-empty.
if (list.length == 0 || thresholdOf[account] > list.length) revert InvalidThreshold();
emit GuardianKeyRemoved(account, keyId);
}
function setThreshold(uint256 threshold) external {
address account = msg.sender;
if (threshold == 0 || threshold > _guardianKeyIds[account].length) revert InvalidThreshold();
thresholdOf[account] = threshold;
emit ThresholdChanged(account, threshold);
}
function _addGuardianKey(address account, uint256 keyId) internal {
if (isGuardianKey[account][keyId]) revert DuplicateGuardianKey(keyId);
// The key must EXIST and be currently ACTIVE (a rotated/revoked key can never be a guardian).
if (!registry.isActiveKey(keyId)) revert GuardianKeyNotActive(keyId);
// A guardian key owned by the account itself would let the account's own key approve its own
// recovery, defeating the "distinct third parties" guarantee.
if (registry.ownerOf(keyId) == account) revert InvalidGuardianKey(keyId);
if (_guardianKeyIds[account].length >= MAX_GUARDIANS) revert InvalidGuardianSet();
isGuardianKey[account][keyId] = true;
_guardianKeyIds[account].push(keyId);
emit GuardianKeyAdded(account, keyId, registry.schemeOf(keyId));
}
// ---- Recovery flow ---------------------------------------------------------
/// @notice The exact 32-byte challenge each guardian must PQC-sign to authorize replacing
/// `account`'s root owner with `newOwner` at `round`. Off-chain guardians query this
/// (or reconstruct it) to know what to sign. Binds account + newOwner + round + chainid
/// + this module, so a leg is scoped to one account, one target owner, one round, one
/// chain, and one module deployment.
function recoveryChallenge(address account, address newOwner, uint256 round) public view returns (bytes32) {
return keccak256(abi.encode(RECOVERY_DOMAIN, block.chainid, address(this), account, newOwner, round));
}
/// @notice The challenge for the round a scheduleRecovery call would use RIGHT NOW.
function currentRecoveryChallenge(address account, address newOwner) external view returns (bytes32) {
return recoveryChallenge(account, newOwner, recoveryNonce[account]);
}
/// @notice Schedule a recovery to `newOwner`. Permissionless: the PQC legs ARE the authorization
/// (a relayer can submit them), exactly like AereThresholdAccount.executeThreshold. The
/// legs must carry >= threshold DISTINCT valid guardian signatures over the current
/// round's challenge; below threshold reverts and nothing is scheduled. On success the
/// owner swap is armed for `block.timestamp + RECOVERY_DELAY`.
function scheduleRecovery(address account, address newOwner, Leg[] calldata legs)
external
returns (uint256 round)
{
if (newOwner == address(0)) revert InvalidNewOwner();
uint256 t = thresholdOf[account];
if (t == 0) revert NotConfigured();
if (activeRecovery[account].active) revert RecoveryAlreadyActive();
round = recoveryNonce[account];
bytes32 challenge = recoveryChallenge(account, newOwner, round);
uint256 signers = _countLegs(account, challenge, legs);
if (signers < t) revert BelowThreshold(signers, t);
uint64 executeAfter = uint64(block.timestamp + RECOVERY_DELAY);
activeRecovery[account] = Recovery({newOwner: newOwner, executeAfter: executeAfter, round: round, active: true});
emit RecoveryScheduled(account, round, newOwner, executeAfter, signers);
}
/// @notice Execute a scheduled recovery once the 48h timelock elapses. Permissionless (anyone
/// may poke it, the authorization was proven at schedule time). Advances `recoveryNonce`
/// so the scheduling round's legs are single-use, then calls account.setRootOwner().
function executeRecovery(address account) external {
Recovery memory r = activeRecovery[account];
if (!r.active) revert NoActiveRecovery();
if (block.timestamp < r.executeAfter) revert TimelockNotElapsed(r.executeAfter, block.timestamp);
// effects before interaction
recoveryNonce[account] = r.round + 1;
delete activeRecovery[account];
IAereModularAccountOwner(account).setRootOwner(r.newOwner);
emit RecoveryExecuted(account, r.round, r.newOwner);
}
/// @notice Cancel a pending recovery. Callable by the current root owner or the account itself
/// (the true owner's window to stop a malicious recovery). Advances `recoveryNonce` so
/// the cancelled round's legs can never be reused.
function cancelRecovery(address account) external {
if (msg.sender != account && msg.sender != IAereModularAccountOwner(account).rootOwner()) {
revert NotOwner();
}
Recovery memory r = activeRecovery[account];
if (!r.active) revert NoActiveRecovery();
recoveryNonce[account] = r.round + 1;
delete activeRecovery[account];
emit RecoveryCancelled(account, r.round, msg.sender);
}
// ---- Threshold counting (t-of-n pattern from AereThresholdAccount) ----------
/// @dev Count DISTINCT valid guardian signers over `challenge`. A leg is counted only if it is
/// in-range, not-yet-seen, references a currently ACTIVE guardian key, and verifies in full
/// via the registry precompile. Everything else (out-of-range index, duplicate index,
/// rotated/revoked key, malformed or wrong signature) is ignored, so the distinct-valid
/// count can never be inflated.
function _countLegs(address account, bytes32 challenge, Leg[] calldata legs)
internal
view
returns (uint256 valid)
{
uint256[] storage keyIds = _guardianKeyIds[account];
uint256 n = keyIds.length;
uint256 seen = 0;
for (uint256 i = 0; i < legs.length; i++) {
uint256 idx = legs[i].guardianIndex;
if (idx >= n) continue;
uint256 bit = uint256(1) << idx;
if (seen & bit != 0) continue; // never double-count a guardian
uint256 keyId = keyIds[idx];
// A rotated/revoked guardian key must never authorize (verifyWithKey alone would still
// accept a ROTATED key as historically valid, so gate on isActiveKey too).
if (!registry.isActiveKey(keyId)) continue;
if (registry.verifyWithKey(keyId, challenge, legs[i].signature)) {
seen |= bit;
valid += 1;
}
}
}
// ---- Views -----------------------------------------------------------------
/// @notice The ordered guardian keyIds for `account` (position = guardianIndex used in a Leg).
function guardianKeysOf(address account) external view returns (uint256[] memory) {
return _guardianKeyIds[account];
}
function guardianCount(address account) external view returns (uint256) {
return _guardianKeyIds[account].length;
}
/// @notice keyId at a given guardian index (reverts on out-of-range).
function guardianKeyAt(address account, uint256 index) external view returns (uint256) {
return _guardianKeyIds[account][index];
}
/// @notice Compact committee view: (threshold, size, round, pending?).
function committee(address account)
external
view
returns (uint256 threshold, uint256 size, uint256 round, bool pending)
{
return (thresholdOf[account], _guardianKeyIds[account].length, recoveryNonce[account], activeRecovery[account].active);
}
}