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

494 lines
22 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
interface IAerePQCKeyRegistry {
function getKey(uint256 keyId)
external
view
returns (
address owner,
uint8 scheme,
uint8 status,
uint64 registeredBlock,
uint256 rotatedFromId,
uint256 rotatedToId,
bytes memory pubKey
);
function verifyWithKey(uint256 keyId, bytes32 message, bytes calldata signature) external view returns (bool);
function keyCount() external view returns (uint256);
}
/**
* @title AereAgentDID, post-quantum-rooted decentralized identity for AI agents
* (AERE chain 2800)
*
* @notice A DID for autonomous/AI agents whose ROOT authority is a quantum-durable
* Falcon key held in AerePQCKeyRegistry, and whose day-to-day work is signed by
* cheap, short-lived, revocable secp256k1 SESSION keys.
*
* WHY THE SPLIT. The Falcon root is a cold, high-value, quantum-safe authority:
* possession was proven on-chain when the key was registered, and it is the only
* thing that can authorize a new session (issuance is Falcon-proof-of-possession
* gated, verified in full on-chain by AERE's live native precompile). Sessions
* are hot keys for high-frequency signing: each agent action is a plain secp256k1
* signature recovered with ecrecover (~3k gas), bounded by a per-session SCOPE
* and cumulative SPEND CAP, expiring at a deadline, and revocable at any time.
* A leaked session key is contained by its scope, cap, expiry, and the on-chain
* revocation list; the quantum-durable root is never exposed to hot use.
*
* ROOT LIFECYCLE IS ENFORCED. A session is only valid while its root key is still
* ACTIVE in the registry: if the Falcon root is ROTATED or REVOKED, every session
* under it stops validating immediately (checked on each read via the registry).
*
* HONEST SCOPE. Application-layer identity + authorization. This does NOT change
* AERE consensus (blocks are still Besu QBFT ECDSA). Falcon verification is done
* by the live native precompile through AerePQCKeyRegistry.verifyWithKey. This
* contract holds no funds: the SPEND CAP is an accounting policy the contract
* enforces on recorded actions, not custody of value.
*/
contract AereAgentDID {
// ----- Falcon scheme ids in AerePQCKeyRegistry (root must be one of these) -----
uint8 internal constant SCHEME_FALCON512 = 1;
uint8 internal constant SCHEME_FALCON1024 = 2;
uint8 internal constant REGISTRY_STATUS_ACTIVE = 1;
// ----- domain separators -----
bytes32 public constant SESSION_DOMAIN = keccak256("AereAgentDID.v1.issueSession");
bytes32 public constant ACTION_DOMAIN = keccak256("AereAgentDID.v1.action");
// ----- immutable wiring -----
IAerePQCKeyRegistry public immutable keyRegistry;
// Read-only cross-links to the agent economic/reputation layer (may be zero if the
// deployer does not wire them). Informational: consumers read these to locate an
// agent's bond / reputation contracts. This contract never calls into them.
address public immutable agentBond; // AereAgentBond (slashable agent bond) or 0
address public immutable aiReputation; // AereAIReputation (composable reputation) or 0
// ----- agents: one DID per Falcon root key (agentId == rootKeyId) -----
struct Agent {
uint256 rootKeyId; // the Falcon root key in AerePQCKeyRegistry
address controller; // the registry owner of rootKeyId at creation (fast-revoke authority)
uint64 createdBlock;
uint64 sessionNonce; // per-agent nonce making each issuance challenge unique
bool exists;
}
mapping(uint256 => Agent) private _agents; // rootKeyId => Agent
// ----- sessions (global incrementing id -> on-chain revocation list) -----
struct Session {
uint256 rootKeyId; // the agent this session belongs to
address sessionAddr; // secp256k1 address that signs actions (ecrecover target)
bytes32 scopeHash; // opaque scope tag the session is locked to
uint256 spendCap; // cumulative spend allowed under this session
uint256 spent; // cumulative spend recorded so far
uint64 issuedBlock;
uint64 expiry; // unix timestamp after which the session is invalid
uint64 actionNonce; // per-session action counter (anti-replay for authorize)
bool revoked;
bool exists;
}
Session[] private _sessions; // sessionId is the index (0-based)
mapping(uint256 => uint256[]) private _agentSessions; // rootKeyId => sessionIds
// ----- events -----
event AgentCreated(uint256 indexed rootKeyId, address indexed controller, uint8 rootScheme);
event SessionIssued(
uint256 indexed sessionId,
uint256 indexed rootKeyId,
address indexed sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint64 expiry
);
event SessionRevoked(uint256 indexed sessionId, uint256 indexed rootKeyId, address by);
event ActionAuthorized(
uint256 indexed sessionId, uint256 indexed rootKeyId, bytes32 indexed actionHash, uint256 amount, uint256 spent
);
error ZeroRegistry();
error RootKeyNotFalcon(uint256 rootKeyId, uint8 scheme);
error RootKeyNotActive(uint256 rootKeyId);
error NotRootController(uint256 rootKeyId);
error AgentExists(uint256 rootKeyId);
error UnknownAgent(uint256 rootKeyId);
error UnknownSession(uint256 sessionId);
error SessionAuthFailed();
error SessionExpiredOrInvalid(uint256 sessionId);
error SessionScopeViolation(uint256 sessionId);
error SessionSpendExceeded(uint256 sessionId);
error SessionSigInvalid(uint256 sessionId);
error InvalidSessionParams();
error NotSessionRevoker(uint256 sessionId);
constructor(address keyRegistry_, address agentBond_, address aiReputation_) {
if (keyRegistry_ == address(0)) revert ZeroRegistry();
keyRegistry = IAerePQCKeyRegistry(keyRegistry_);
agentBond = agentBond_;
aiReputation = aiReputation_;
}
// ==========================================================================
// Agent creation
// ==========================================================================
/**
* @notice Create the DID for the Falcon root key `rootKeyId`. Callable only by the
* registry owner of that key (the party who proved Falcon possession at
* registration). The root must be an ACTIVE Falcon-512/1024 key. One DID per
* root key; the agent id IS the rootKeyId.
*/
function createAgent(uint256 rootKeyId) external {
(address rootOwner, uint8 scheme, uint8 status) = _rootKeyState(rootKeyId);
if (scheme != SCHEME_FALCON512 && scheme != SCHEME_FALCON1024) revert RootKeyNotFalcon(rootKeyId, scheme);
if (status != REGISTRY_STATUS_ACTIVE) revert RootKeyNotActive(rootKeyId);
if (msg.sender != rootOwner) revert NotRootController(rootKeyId);
if (_agents[rootKeyId].exists) revert AgentExists(rootKeyId);
_agents[rootKeyId] = Agent({
rootKeyId: rootKeyId,
controller: rootOwner,
createdBlock: uint64(block.number),
sessionNonce: 0,
exists: true
});
emit AgentCreated(rootKeyId, rootOwner, scheme);
}
// ==========================================================================
// Session issuance
// ==========================================================================
/**
* @notice Issue a short-lived secp256k1 session key for an agent. Authorized by a
* Falcon PROOF-OF-POSSESSION: `popSig` must be a valid signature by the agent's
* root key over the issuance challenge for the current session nonce. May be
* relayed by any msg.sender (only the root-key holder can produce popSig). The
* root key must still be ACTIVE Falcon at issuance time.
* @param rootKeyId the agent (root Falcon key) issuing the session.
* @param sessionAddr the secp256k1 address that will sign this session's actions.
* @param scopeHash opaque scope tag the session is locked to (enforced in authorize).
* @param spendCap cumulative spend allowed under this session.
* @param expiry unix timestamp after which the session is invalid (must be future).
* @param popSig the Falcon proof-of-possession envelope over the challenge.
* @return sessionId the id of the newly issued session.
*/
function issueSession(
uint256 rootKeyId,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint64 expiry,
bytes calldata popSig
) external returns (uint256 sessionId) {
Agent storage a = _agents[rootKeyId];
if (!a.exists) revert UnknownAgent(rootKeyId);
if (sessionAddr == address(0) || expiry <= block.timestamp) revert InvalidSessionParams();
// Root must still be an ACTIVE Falcon key (it could have been rotated/revoked).
(, uint8 scheme, uint8 status) = _rootKeyState(rootKeyId);
if (scheme != SCHEME_FALCON512 && scheme != SCHEME_FALCON1024) revert RootKeyNotFalcon(rootKeyId, scheme);
if (status != REGISTRY_STATUS_ACTIVE) revert RootKeyNotActive(rootKeyId);
uint64 nonce = a.sessionNonce;
bytes32 challenge = _sessionChallenge(rootKeyId, nonce, sessionAddr, scopeHash, spendCap, expiry);
// Full on-chain Falcon verification via the registry (live native precompile).
if (!keyRegistry.verifyWithKey(rootKeyId, challenge, popSig)) revert SessionAuthFailed();
// Effects.
a.sessionNonce = nonce + 1;
sessionId = _sessions.length;
_sessions.push(
Session({
rootKeyId: rootKeyId,
sessionAddr: sessionAddr,
scopeHash: scopeHash,
spendCap: spendCap,
spent: 0,
issuedBlock: uint64(block.number),
expiry: expiry,
actionNonce: 0,
revoked: false,
exists: true
})
);
_agentSessions[rootKeyId].push(sessionId);
emit SessionIssued(sessionId, rootKeyId, sessionAddr, scopeHash, spendCap, expiry);
}
// ==========================================================================
// Session revocation
// ==========================================================================
/**
* @notice Revoke a session immediately. Fast path for incident response: callable by
* the agent's controller (the registry owner of the root key) with a cheap
* ECDSA tx, no Falcon signature required. Idempotent-safe: revoking an already
* revoked session is a no-op success. Sessions also auto-expire at `expiry`.
*/
function revokeSession(uint256 sessionId) external {
if (sessionId >= _sessions.length) revert UnknownSession(sessionId);
Session storage s = _sessions[sessionId];
// Only the current registry owner of the root key may fast-revoke.
address controller = _currentController(s.rootKeyId);
if (msg.sender != controller) revert NotSessionRevoker(sessionId);
if (!s.revoked) {
s.revoked = true;
emit SessionRevoked(sessionId, s.rootKeyId, msg.sender);
}
}
// ==========================================================================
// Action authorization
// ==========================================================================
/**
* @notice Authorize (and record) an agent action signed by a session key. The hot
* path: verifies a secp256k1 signature by the session address over the action
* digest (ecrecover), enforces the session SCOPE and SPEND CAP, and records
* the spend. Reverts (recording nothing) if the session is invalid/expired/
* revoked, its root key is no longer ACTIVE Falcon, the scope does not match,
* the cap would be exceeded, or the signature is not by the session key.
* @param sessionId the session authorizing the action.
* @param scope the scope tag for this action (must equal the session's scopeHash).
* @param actionHash a 32-byte commitment to the action being authorized.
* @param amount the spend to charge against the session's cap.
* @param sig 65-byte secp256k1 signature (r||s||v) by the session address over
* actionDigest(sessionId, scope, actionHash, amount, actionNonce).
*/
function authorize(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, bytes calldata sig)
external
returns (bool)
{
if (sessionId >= _sessions.length) revert UnknownSession(sessionId);
Session storage s = _sessions[sessionId];
if (!_isSessionLive(s)) revert SessionExpiredOrInvalid(sessionId);
if (scope != s.scopeHash) revert SessionScopeViolation(sessionId);
if (s.spent + amount < s.spent || s.spent + amount > s.spendCap) revert SessionSpendExceeded(sessionId);
uint64 nonce = s.actionNonce;
bytes32 digest = _actionDigest(sessionId, scope, actionHash, amount, nonce);
address signer = _recover(digest, sig);
if (signer == address(0) || signer != s.sessionAddr) revert SessionSigInvalid(sessionId);
// Effects.
s.actionNonce = nonce + 1;
s.spent += amount;
emit ActionAuthorized(sessionId, s.rootKeyId, actionHash, amount, s.spent);
return true;
}
// ==========================================================================
// Validity / digest views
// ==========================================================================
/**
* @notice True iff the session exists, is not revoked, has not expired, and its root
* key is still ACTIVE Falcon in the registry.
*/
function isSessionValid(uint256 sessionId) external view returns (bool) {
if (sessionId >= _sessions.length) return false;
return _isSessionLive(_sessions[sessionId]);
}
function _isSessionLive(Session storage s) internal view returns (bool) {
if (!s.exists || s.revoked || block.timestamp > s.expiry) return false;
(, uint8 scheme, uint8 status) = _rootKeyStateNoRevert(s.rootKeyId);
if (status != REGISTRY_STATUS_ACTIVE) return false;
if (scheme != SCHEME_FALCON512 && scheme != SCHEME_FALCON1024) return false;
return true;
}
/// @notice The exact Falcon issuance challenge for a session's parameters at `nonce`.
function sessionChallenge(
uint256 rootKeyId,
uint64 nonce,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint64 expiry
) external view returns (bytes32) {
return _sessionChallenge(rootKeyId, nonce, sessionAddr, scopeHash, spendCap, expiry);
}
/// @notice The current issuance challenge for an agent (uses its current sessionNonce).
function currentSessionChallenge(
uint256 rootKeyId,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint64 expiry
) external view returns (bytes32) {
return _sessionChallenge(rootKeyId, _agents[rootKeyId].sessionNonce, sessionAddr, scopeHash, spendCap, expiry);
}
/// @notice The digest a session key must sign to authorize an action at `actionNonce`.
function actionDigest(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, uint64 actionNonce)
external
view
returns (bytes32)
{
return _actionDigest(sessionId, scope, actionHash, amount, actionNonce);
}
function _sessionChallenge(
uint256 rootKeyId,
uint64 nonce,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint64 expiry
) internal view returns (bytes32) {
return keccak256(
abi.encode(
SESSION_DOMAIN, block.chainid, address(this), rootKeyId, nonce, sessionAddr, scopeHash, spendCap, expiry
)
);
}
function _actionDigest(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, uint64 actionNonce)
internal
view
returns (bytes32)
{
return keccak256(
abi.encode(ACTION_DOMAIN, block.chainid, address(this), sessionId, scope, actionHash, amount, actionNonce)
);
}
// ==========================================================================
// Registry reads
// ==========================================================================
function _rootKeyState(uint256 rootKeyId) internal view returns (address owner, uint8 scheme, uint8 status) {
(owner, scheme, status,,,,) = keyRegistry.getKey(rootKeyId);
}
/// @dev Like _rootKeyState but never reverts (an out-of-range key on the registry
/// would revert getKey). Returns status 0 (not ACTIVE) so callers fail closed.
function _rootKeyStateNoRevert(uint256 rootKeyId)
internal
view
returns (address owner, uint8 scheme, uint8 status)
{
try keyRegistry.getKey(rootKeyId) returns (
address o, uint8 sc, uint8 st, uint64, uint256, uint256, bytes memory
) {
return (o, sc, st);
} catch {
return (address(0), 0, 0);
}
}
/// @dev The address currently allowed to fast-revoke: the registry owner of the root
/// key (falls back to the cached controller if the registry read reverts).
function _currentController(uint256 rootKeyId) internal view returns (address) {
// A rotated/revoked root still lets its current registry owner fast-revoke.
(address owner,,) = _rootKeyStateNoRevert(rootKeyId);
if (owner != address(0)) return owner;
// Registry read failed; fall back to the controller cached at creation.
return _agents[rootKeyId].controller;
}
// ==========================================================================
// ECDSA recovery
// ==========================================================================
/// @dev Minimal ecrecover with malleability guard (low-s, v in {27,28}). Returns
/// address(0) on any malformed input so callers treat it as an invalid signature.
function _recover(bytes32 digest, 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)))
}
// Reject high-s (EIP-2) to prevent signature malleability.
if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) {
return address(0);
}
if (v != 27 && v != 28) return address(0);
return ecrecover(digest, v, r, s);
}
// ==========================================================================
// Views
// ==========================================================================
/// @notice Total sessions ever issued (sessionIds run 0..sessionCount-1).
function sessionCount() external view returns (uint256) {
return _sessions.length;
}
/// @notice Read an agent (DID). Reverts if the agent does not exist.
function getAgent(uint256 rootKeyId)
external
view
returns (address controller, uint64 createdBlock, uint64 sessionNonce, uint256 sessionCountForAgent)
{
Agent storage a = _agents[rootKeyId];
if (!a.exists) revert UnknownAgent(rootKeyId);
return (a.controller, a.createdBlock, a.sessionNonce, _agentSessions[rootKeyId].length);
}
/// @notice True iff a DID exists for `rootKeyId`.
function agentExists(uint256 rootKeyId) external view returns (bool) {
return _agents[rootKeyId].exists;
}
/// @notice Read a session. Reverts on an unknown sessionId.
function getSession(uint256 sessionId)
external
view
returns (
uint256 rootKeyId,
address sessionAddr,
bytes32 scopeHash,
uint256 spendCap,
uint256 spent,
uint64 issuedBlock,
uint64 expiry,
uint64 actionNonce,
bool revoked
)
{
if (sessionId >= _sessions.length) revert UnknownSession(sessionId);
Session storage s = _sessions[sessionId];
return (
s.rootKeyId,
s.sessionAddr,
s.scopeHash,
s.spendCap,
s.spent,
s.issuedBlock,
s.expiry,
s.actionNonce,
s.revoked
);
}
/// @notice The remaining spend under a session (0 if invalid/exhausted).
function remainingSpend(uint256 sessionId) external view returns (uint256) {
if (sessionId >= _sessions.length) return 0;
Session storage s = _sessions[sessionId];
if (!_isSessionLive(s) || s.spent >= s.spendCap) return 0;
return s.spendCap - s.spent;
}
/// @notice All sessionIds ever issued for an agent (append-only, includes revoked).
function sessionsOf(uint256 rootKeyId) external view returns (uint256[] memory) {
return _agentSessions[rootKeyId];
}
}