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.
494 lines
22 KiB
Solidity
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];
|
|
}
|
|
}
|