aere-contracts/contracts/pqc/AereHybridAuthorizer.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

336 lines
16 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title AereHybridAuthorizer, a crypto-agility CONSUMER over AereCryptoRegistry (chain 2800)
*
* @notice AereCryptoRegistry (0xaE6fC596bb3eCcbf5c5D02D67B0Ef065b3Afbaa5) turns AERE's five
* live native crypto precompiles into a governed table
*
* algorithmId -> { verifier, wire-format, status, gas, successor }
*
* A registry has value only when something CONSUMES it. This contract is that
* consumer. Instead of pinning a scheme, an account/app authorizes against
* "whatever scheme is currently ACTIVE": every check first calls
* {AereCryptoRegistry.resolveActive} to follow the successor chain to the live
* ACTIVE row, then verifies through the registry's routed verifier.
*
* The payoff is a ZERO-REDEPLOY migration. When the Foundation (owner of the
* registry, later the Timelock) retires a scheme with the standard swap
*
* 1. addAlgorithm(newScheme) // register the successor, ACTIVE
* 2. setSuccessor(oldId, newId) // point old -> new
* 3. setStatus(oldId, DEPRECATED|REVOKED)
*
* every consumer that routes through this authorizer starts accepting the NEW
* scheme and rejecting the OLD one on the very next block, with no code change and
* no redeploy on this contract or on any dApp that calls it.
*
* @dev HONEST SCOPE. Application-layer authorization only. It changes NOTHING about AERE
* consensus: blocks are still produced and signed by Besu QBFT validators with
* classical ECDSA / secp256k1. All post-quantum verification is delegated to the
* SAME live native precompiles the registry routes to (Falcon-512/1024, ML-DSA-44,
* SLH-DSA-128s). This is NOT post-quantum consensus. No funds are ever held: there
* is no payable function, no selfdestruct, and no custody of any kind. The
* per-account "spend"/preference here is a routing choice, not money.
*
* @dev POLICY. This authorizer only ever accepts an ACTIVE scheme. {resolveActive}
* returns the first ACTIVE row on the successor chain, so:
* - A REVOKED id is NEVER used: it is skipped in favour of its ACTIVE successor,
* or, if it has none, resolveActive reverts and the fail-closed check returns
* false. A signature made under a revoked scheme's key can never authorize.
* - A DEPRECATED id with an ACTIVE successor transparently routes to the
* successor (a legacy signature under the deprecated key stops authorizing).
* - A DEPRECATED-or-REVOKED id with NO ACTIVE successor authorizes nothing.
* This is stricter than {AereCryptoRegistry.verify} (which still verifies a
* DEPRECATED row for legacy support) precisely because this surface is about
* "the currently recommended scheme", not legacy verification.
*
* @dev FAIL-CLOSED VIEWS. {checkAuthorized} / {checkAuthorizedFor} never revert: any
* registry revert (unknown id, dead-end successor chain, cycle) is caught and
* reported as `false`. The state-changing {authorize} lets a genuine registry
* revert surface (so a caller learns WHY), and reverts {AuthorizationFailed} when
* the routed verifier simply returns false.
*/
interface IAereCryptoRegistry {
/// @notice Follow the successor chain to the first ACTIVE id. Reverts on unknown id,
/// a dead-end with no ACTIVE row, or a cycle.
function resolveActive(uint256 algorithmId) external view returns (uint256);
/// @notice Fail-closed routed signature verification (returns false, never reverts,
/// for unknown/hash-only/UNKNOWN/REVOKED/malformed inputs).
function verify(uint256 algorithmId, bytes calldata pubKey, bytes32 messageHash, bytes calldata signature)
external
view
returns (bool);
/// @notice True iff `id` is a signature scheme safe to adopt for NEW work (ACTIVE).
function isUsable(uint256 id) external view returns (bool);
}
contract AereHybridAuthorizer is Ownable {
// ==========================================================================
// Immutable wiring
// ==========================================================================
/// @notice The governed crypto-agility registry this authorizer consumes.
IAereCryptoRegistry public immutable registry;
// ==========================================================================
// Storage
// ==========================================================================
/**
* @notice The app-wide DEFAULT algorithm id used for any account that has not chosen a
* personal preference. Owner-governed. Stored as a stable reference id: every
* check re-resolves it through {resolveActive}, so it auto-follows a registry
* swap even before the owner calls {migrate}. {migrate} simply crystallizes it
* to the resolved successor.
*/
uint256 public defaultAlgorithmId;
/// @dev Per-account preference; 0 means "use the app default" (the SAFE DEFAULT).
mapping(address => uint256) private _preferred;
// Authorization ledger (a state-changing proof surface; holds no value).
/// @notice Number of successful {authorize} calls recorded.
uint256 public authorizationCount;
/// @notice keccak256(pubKey || messageHash) => was this (key, message) ever authorized.
mapping(bytes32 => bool) private _authorizedProof;
/// @notice The ACTIVE algorithm id used by the most recent successful authorization.
uint256 public lastResolvedId;
/// @notice The message hash of the most recent successful authorization.
bytes32 public lastMessageHash;
// ==========================================================================
// Events
// ==========================================================================
event DefaultAlgorithmSet(uint256 indexed id);
event DefaultMigrated(uint256 indexed oldId, uint256 indexed newId);
event PreferredAlgorithmSet(address indexed account, uint256 indexed id);
event Authorized(
address indexed caller, uint256 indexed requestedId, uint256 indexed resolvedId, bytes32 messageHash
);
// ==========================================================================
// Errors
// ==========================================================================
error ZeroRegistry();
error UnusableAlgorithm(uint256 id); // chosen/default id does not resolve to a usable ACTIVE signature scheme
error NotCurrentDefault(uint256 got, uint256 current); // migrate(oldId) with oldId != defaultAlgorithmId
error AuthorizationFailed(uint256 requestedId, uint256 resolvedId);
// ==========================================================================
// Constructor
// ==========================================================================
/**
* @param registry_ the AereCryptoRegistry to route through (non-zero).
* @param defaultAlgorithmId_ the initial app default. It MUST resolve, via the
* registry, to a usable ACTIVE signature scheme right now,
* so the contract can never be born pointing at an unusable
* or hash-only default (fail-fast "safe default").
*/
constructor(address registry_, uint256 defaultAlgorithmId_) {
if (registry_ == address(0)) revert ZeroRegistry();
registry = IAereCryptoRegistry(registry_);
// Fail-fast: the initial default must be a usable ACTIVE signature scheme.
if (!_resolvesToUsable(defaultAlgorithmId_)) revert UnusableAlgorithm(defaultAlgorithmId_);
defaultAlgorithmId = defaultAlgorithmId_;
emit DefaultAlgorithmSet(defaultAlgorithmId_);
}
// ==========================================================================
// Owner: default governance
// ==========================================================================
/**
* @notice Set the app-wide default algorithm id. Owner only. The id must resolve to a
* usable ACTIVE signature scheme (guards against pointing the default at a
* revoked, dead-ended, or hash-only scheme).
*/
function setDefaultAlgorithm(uint256 id) external onlyOwner {
if (!_resolvesToUsable(id)) revert UnusableAlgorithm(id);
defaultAlgorithmId = id;
emit DefaultAlgorithmSet(id);
}
/**
* @notice Owner helper: crystallize the app default onto the ACTIVE successor of
* `oldId` after a registry swap. `oldId` must equal the current
* {defaultAlgorithmId} (a guard so a stale call cannot silently retarget it).
* Because {authorize}/{checkAuthorized} already re-resolve on every call, this
* is an OPTIMIZATION/CLARITY step (shorter successor walk, fresh views), not a
* correctness requirement: consumers are already migrated the moment the
* registry swap lands. Reverts (from {resolveActive}) if the chain dead-ends or
* cycles, so the default can never be moved onto a non-ACTIVE row.
* @return newId the ACTIVE id the default now points at (== oldId if already ACTIVE).
*/
function migrate(uint256 oldId) external onlyOwner returns (uint256 newId) {
if (defaultAlgorithmId != oldId) revert NotCurrentDefault(oldId, defaultAlgorithmId);
newId = registry.resolveActive(oldId); // reverts on unknown/dead-end/cycle
if (newId != oldId) {
defaultAlgorithmId = newId;
emit DefaultMigrated(oldId, newId);
}
}
// ==========================================================================
// Account: preference (self-service)
// ==========================================================================
/**
* @notice Choose a personal preferred algorithm id for msg.sender. Pass 0 to clear the
* preference and fall back to the app default. A non-zero id must resolve to a
* usable ACTIVE signature scheme at set time.
* @dev The stored value is a stable reference: like the default, it is re-resolved
* through {resolveActive} on every check, so a later registry swap migrates a
* preferring account automatically too.
*/
function setPreferredAlgorithm(uint256 id) external {
if (id != 0 && !_resolvesToUsable(id)) revert UnusableAlgorithm(id);
_preferred[msg.sender] = id;
emit PreferredAlgorithmSet(msg.sender, id);
}
/// @notice The raw stored preference for `account` (0 == none / use default).
function preferredAlgorithmOf(address account) external view returns (uint256) {
return _preferred[account];
}
/**
* @notice The effective (pre-resolution) algorithm id for `account`: its preference if
* set, else the app default. This is the STABLE reference id, before the
* successor chain is walked.
*/
function algorithmFor(address account) public view returns (uint256) {
uint256 p = _preferred[account];
return p == 0 ? defaultAlgorithmId : p;
}
/**
* @notice The ACTIVE algorithm id `account` currently authorizes under, after resolving
* the successor chain. Reverts (from {resolveActive}) if the chain dead-ends or
* cycles with no ACTIVE row.
*/
function resolvedAlgorithmFor(address account) external view returns (uint256) {
return registry.resolveActive(algorithmFor(account));
}
// ==========================================================================
// Authorization (fail-closed views)
// ==========================================================================
/**
* @notice Fail-closed check: is `signature` a valid signature over `messageHash` by
* `pubKey` under the currently-ACTIVE scheme reachable from `algorithmId`?
* Resolves the successor chain, then verifies through the registry's routed
* verifier. Returns FALSE (never reverts) for an unknown id, a dead-end or
* cyclic successor chain, a malformed input, or a failed verification.
*/
function checkAuthorized(
uint256 algorithmId,
bytes calldata pubKey,
bytes32 messageHash,
bytes calldata signature
) public view returns (bool) {
uint256 active;
try registry.resolveActive(algorithmId) returns (uint256 a) {
active = a;
} catch {
return false; // unknown id, dead-end, or cycle -> not authorized
}
try registry.verify(active, pubKey, messageHash, signature) returns (bool ok) {
return ok;
} catch {
return false;
}
}
/**
* @notice Fail-closed check under `account`'s effective algorithm (its preference, else
* the app default). Same semantics as {checkAuthorized}.
*/
function checkAuthorizedFor(
address account,
bytes calldata pubKey,
bytes32 messageHash,
bytes calldata signature
) external view returns (bool) {
return checkAuthorized(algorithmFor(account), pubKey, messageHash, signature);
}
/// @notice Whether (pubKey, messageHash) was ever recorded by a successful {authorize}.
function isProofAuthorized(bytes calldata pubKey, bytes32 messageHash) external view returns (bool) {
return _authorizedProof[keccak256(abi.encodePacked(pubKey, messageHash))];
}
// ==========================================================================
// Authorization (state-changing, recorded)
// ==========================================================================
/**
* @notice State-changing authorization under the ACTIVE scheme reachable from
* `algorithmId`. Reverts if the successor chain has no ACTIVE row (surfacing
* the registry's reason) or {AuthorizationFailed} if the routed verifier
* returns false. On success it records the (pubKey, messageHash) proof, bumps
* {authorizationCount}, and emits {Authorized}.
* @return resolvedId the ACTIVE id the signature was verified under.
*/
function authorize(
uint256 algorithmId,
bytes calldata pubKey,
bytes32 messageHash,
bytes calldata signature
) external returns (uint256 resolvedId) {
return _authorize(algorithmId, pubKey, messageHash, signature);
}
/**
* @notice State-changing authorization under msg.sender's effective algorithm (its
* preference, else the app default). See {authorize}.
*/
function authorizeWithPreferred(bytes calldata pubKey, bytes32 messageHash, bytes calldata signature)
external
returns (uint256 resolvedId)
{
return _authorize(algorithmFor(msg.sender), pubKey, messageHash, signature);
}
function _authorize(
uint256 algorithmId,
bytes calldata pubKey,
bytes32 messageHash,
bytes calldata signature
) internal returns (uint256 resolvedId) {
resolvedId = registry.resolveActive(algorithmId); // reverts on unknown/dead-end/cycle
bool ok = registry.verify(resolvedId, pubKey, messageHash, signature);
if (!ok) revert AuthorizationFailed(algorithmId, resolvedId);
_authorizedProof[keccak256(abi.encodePacked(pubKey, messageHash))] = true;
authorizationCount += 1;
lastResolvedId = resolvedId;
lastMessageHash = messageHash;
emit Authorized(msg.sender, algorithmId, resolvedId, messageHash);
}
// ==========================================================================
// Internal
// ==========================================================================
/// @dev True iff `id` resolves (via the successor chain) to a usable ACTIVE signature
/// scheme. Fail-closed: a revert in resolveActive (unknown/dead-end/cycle) is
/// treated as "not usable".
function _resolvesToUsable(uint256 id) internal view returns (bool) {
try registry.resolveActive(id) returns (uint256 active) {
return registry.isUsable(active);
} catch {
return false;
}
}
}