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.
198 lines
11 KiB
Python
198 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# -----------------------------------------------------------------------------
|
|
# agentdid_lifecycle_smt.py
|
|
#
|
|
# SMT proof (z3) of the ROOT-LIFECYCLE and CONTROLLER-AUTHORITY invariants of
|
|
# AereAgentDID, the LIVE (mainnet chain 2800, deployed 2026-07-12) PQC-rooted DID for
|
|
# AI agents, at 0xce641d7d7C10553D82b06B7C21d423550e7522C5.
|
|
#
|
|
# Contract: contracts/contracts/pqc/AereAgentDID.sol
|
|
#
|
|
# This COMPLEMENTS formal-consensus/agentdid_session_smt.py (which proved the
|
|
# cumulative spend cap C1, the general liveness gate C2, the anti-replay nonce C3, and
|
|
# terminal expiry C4). Here we prove the two properties the build task calls out that
|
|
# the session model did not make explicit:
|
|
# D1 A session DIES the moment its Falcon ROOT is ROTATED or REVOKED (the liveness
|
|
# check re-reads the registry on every call; a non-ACTIVE / non-Falcon root makes
|
|
# _isSessionLive false, so authorize / isSessionValid / remainingSpend all fail).
|
|
# D2 ONLY THE CONTROLLER MUTATES the DID/session LIFECYCLE:
|
|
# - createAgent requires msg.sender == the registry owner of the root key;
|
|
# - revokeSession requires msg.sender == the CURRENT registry owner of the root;
|
|
# - spend is recorded ONLY with a valid session-key ECDSA signature
|
|
# (signer == sessionAddr), never by an arbitrary caller.
|
|
# D3 A session cannot OUTLIVE its expiry nor EXCEED its cap (restated jointly here as
|
|
# the containment envelope, cross-checking the session model).
|
|
#
|
|
# Style: PROVED = negation UNSAT under the exact Solidity guards; each paired with a
|
|
# firing NEGATIVE CONTROL.
|
|
#
|
|
# ASSUMPTIONS (bound every PROVED):
|
|
# A1. ecrecover is a sound signature oracle (returns sessionAddr ONLY for a genuine
|
|
# session-key signature over the exact digest). Out of scope; same class as the
|
|
# halmos precompile-oracle assumption. The gating proofs hold for BOTH branches.
|
|
# A2. The registry read (getKey) reflects the root key's TRUE current status; the
|
|
# contract re-reads it on every liveness check (no stale cache) -- this is the
|
|
# fact D1 exploits and we model it directly.
|
|
# A3. Design model (unbounded Int); the halmos suite is the bytecode layer.
|
|
# -----------------------------------------------------------------------------
|
|
from z3 import Int, Bool, Solver, And, Or, Not, Implies, If, sat, unsat
|
|
|
|
# registry statuses / schemes (as AereAgentDID reads them)
|
|
REG_ACTIVE = 1
|
|
FALCON512, FALCON1024 = 1, 2
|
|
|
|
results = []
|
|
def check(name, s, expect_unsat=True, kind="PROOF"):
|
|
r = s.check()
|
|
if expect_unsat:
|
|
ok = (r == unsat); tag = "PROVED" if ok else "FAILED"
|
|
else:
|
|
ok = (r == sat); tag = "CEX-FOUND" if ok else "FAILED"
|
|
results.append((name, tag, ok, kind))
|
|
print(f"[{tag:9}] ({kind}) {name}: z3={r} (expected {'unsat' if expect_unsat else 'sat'})")
|
|
if r == sat:
|
|
m = s.model()
|
|
wit = {}
|
|
for d in m.decls():
|
|
try: wit[str(d)] = m[d].as_long()
|
|
except Exception:
|
|
try: wit[str(d)] = bool(m[d])
|
|
except Exception: wit[str(d)] = "?"
|
|
print(" witness:", wit)
|
|
return ok
|
|
|
|
print("### AereAgentDID -- root-lifecycle kill-switch + controller-only mutation\n")
|
|
|
|
# _isSessionLive(s): exists AND !revoked AND now<=expiry AND rootStatus==ACTIVE AND
|
|
# rootScheme in {Falcon512, Falcon1024}
|
|
def is_live(exists, revoked, now, expiry, root_status, root_scheme):
|
|
return And(exists,
|
|
Not(revoked),
|
|
now <= expiry,
|
|
root_status == REG_ACTIVE,
|
|
Or(root_scheme == FALCON512, root_scheme == FALCON1024))
|
|
|
|
# ---- D1a : a ROTATED root (registry status != ACTIVE) kills the session ----------
|
|
# When the Falcon root is rotated, its registry status becomes ROTATED (2), not ACTIVE.
|
|
s = Solver()
|
|
now, expiry, root_scheme, root_status = Int('now'), Int('expiry'), Int('rootScheme'), Int('rootStatus')
|
|
s.add(root_status != REG_ACTIVE) # rotated (=2) or revoked (=3): any non-ACTIVE
|
|
# a session that is otherwise perfectly fine (exists, not revoked, not expired, Falcon):
|
|
s.add(is_live(True, False, now, expiry, root_status, FALCON512))
|
|
check("D1a a non-ACTIVE (rotated/revoked) root makes the session non-live (kill-switch)", s)
|
|
|
|
# ---- D1b : if the root is no longer a FALCON scheme, the session is dead ----------
|
|
s = Solver()
|
|
now, expiry, root_scheme = Int('now'), Int('expiry'), Int('rootScheme')
|
|
s.add(root_scheme != FALCON512, root_scheme != FALCON1024) # not a Falcon root anymore
|
|
s.add(is_live(True, False, now, expiry, REG_ACTIVE, root_scheme))
|
|
check("D1b a non-Falcon root scheme makes the session non-live", s)
|
|
|
|
# ---- D1c : authorize() cannot record spend when the root is not ACTIVE Falcon -----
|
|
# authorize requires _isSessionLive BEFORE touching spent/nonce. Model: reachedEffects
|
|
# implies live; with a rotated root, live is false, so no spend is recorded.
|
|
s = Solver()
|
|
root_status = Int('rootStatus'); reachedEffects = Bool('reachedEffects')
|
|
now, expiry = Int('now'), Int('expiry')
|
|
live = is_live(True, False, now, expiry, root_status, FALCON512)
|
|
s.add(reachedEffects == live) # the _isSessionLive guard
|
|
s.add(root_status != REG_ACTIVE) # root rotated/revoked
|
|
s.add(reachedEffects) # claim spend was still recorded
|
|
check("D1c authorize() records no spend after the root is rotated/revoked", s)
|
|
|
|
# ---- NEG-CTRL D1: a session that CACHES root-active at issuance (never re-reads)
|
|
# keeps authorizing after the root is revoked. z3 finds the live-after-revoke state.
|
|
s = Solver()
|
|
cached_active_at_issue = Bool('cachedActiveAtIssue')
|
|
root_status_now = Int('rootStatusNow'); now, expiry = Int('now'), Int('expiry')
|
|
s.add(cached_active_at_issue == True) # BUG: liveness uses the cached flag
|
|
buggy_live = And(True, Not(False), now <= expiry, cached_active_at_issue)
|
|
s.add(root_status_now != REG_ACTIVE) # root has since been revoked
|
|
s.add(buggy_live) # session still 'live' -> can spend
|
|
check("NEG-CTRL a cached root-active flag keeps a session live after the root is revoked", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- D2a : revokeSession is CONTROLLER-ONLY -------------------------------------
|
|
# require msg.sender == _currentController(root) (the registry owner of the root key).
|
|
s = Solver()
|
|
sender, controller = Int('msgSender'), Int('controller')
|
|
s.add(sender != controller) # a non-controller caller
|
|
reached_revoke_effect = (sender == controller) # the guard: only equal passes
|
|
s.add(reached_revoke_effect) # claim the non-controller revoked
|
|
check("D2a revokeSession is controller-only (non-controller cannot mutate session state)", s)
|
|
|
|
# ---- D2b : createAgent is ROOT-OWNER-ONLY ---------------------------------------
|
|
# require msg.sender == rootOwner (the registry owner who proved Falcon possession).
|
|
s = Solver()
|
|
sender, root_owner = Int('msgSender'), Int('rootOwner')
|
|
s.add(sender != root_owner)
|
|
reached_create = (sender == root_owner)
|
|
s.add(reached_create) # claim a non-owner created the agent
|
|
check("D2b createAgent is root-owner-only (only the Falcon key holder creates the DID)", s)
|
|
|
|
# ---- D2c : spend is recorded ONLY with a valid session-key signature -------------
|
|
# authorize: signer = ecrecover(digest, sig); require signer != 0 AND signer == sessionAddr.
|
|
# Model: spend recorded => sig valid for the session key. A caller without the session
|
|
# key (sigValid == False) cannot record spend.
|
|
s = Solver()
|
|
sig_valid = Bool('sigValidForSessionKey')
|
|
reached_spend = Bool('reachedSpend')
|
|
s.add(reached_spend == sig_valid) # the ecrecover==sessionAddr guard
|
|
s.add(Not(sig_valid)) # caller lacks the session key
|
|
s.add(reached_spend) # claim spend was recorded anyway
|
|
check("D2c spend is recorded only with a valid session-key signature (no key -> no spend)", s)
|
|
|
|
# ---- NEG-CTRL D2: dropping the controller check lets ANYONE revoke a session ------
|
|
s = Solver()
|
|
sender, controller = Int('msgSender'), Int('controller')
|
|
s.add(sender != controller)
|
|
buggy_reached = True # BUG: no controller gate
|
|
s.add(buggy_reached)
|
|
check("NEG-CTRL removing the controller gate lets anyone revoke a session", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- NEG-CTRL D2c: dropping the ecrecover==sessionAddr check lets anyone spend -----
|
|
s = Solver()
|
|
sig_valid = Bool('sigValidForSessionKey'); reached_spend = Bool('reachedSpend')
|
|
s.add(reached_spend == True) # BUG: effects reached unconditionally
|
|
s.add(Not(sig_valid)) # caller has no valid session signature
|
|
s.add(reached_spend)
|
|
check("NEG-CTRL removing the session-signature check lets anyone record spend", s,
|
|
expect_unsat=False, kind="NEG-CTRL")
|
|
|
|
# ---- D3 : containment envelope (joint restate) -----------------------------------
|
|
# A session can neither outlive expiry (now>expiry => dead) nor exceed cap
|
|
# (spent<=cap inductive). Both directions in one check as a sanity cross-tie.
|
|
s = Solver()
|
|
now, expiry, spent, cap, amount = Int('now'), Int('expiry'), Int('spent'), Int('cap'), Int('amount')
|
|
s.add(spent >= 0, spent <= cap, amount >= 0)
|
|
# authorize success guards: live (now<=expiry) AND cumulative cap
|
|
s.add(now <= expiry, spent + amount >= spent, spent + amount <= cap)
|
|
spent2 = spent + amount
|
|
# negate the joint post-condition: spent2<=cap AND (we did not act past expiry)
|
|
s.add(Not(And(spent2 <= cap, now <= expiry)))
|
|
check("D3 containment: a successful action stays within cap AND within expiry", s)
|
|
|
|
# ------------------------------------------------------------------------------
|
|
print("\n=== SUMMARY ===")
|
|
allok = True
|
|
for name, tag, ok, kind in results:
|
|
print(f" {tag:9} [{kind}] {name}")
|
|
allok = allok and ok
|
|
print()
|
|
print("AereAgentDID (root lifecycle + controller authority):")
|
|
if allok:
|
|
print(" PROVED (under A1 ecrecover-soundness + A2 live registry read): a session dies")
|
|
print(" the instant its Falcon root is ROTATED or REVOKED, or ceases to be a Falcon")
|
|
print(" scheme (D1a/b/c -- authorize records nothing thereafter); the DID/session")
|
|
print(" lifecycle is mutated ONLY by the proper authority -- createAgent by the root")
|
|
print(" owner, revokeSession by the current controller, spend only by a valid session")
|
|
print(" signature (D2a/b/c); and the cap+expiry containment envelope holds (D3). Four")
|
|
print(" NEG-CTRLs fire (cached-root-active, missing controller gate, missing signature")
|
|
print(" gate), confirming the live registry read and each authority gate are")
|
|
print(" load-bearing. Complements agentdid_session_smt.py (spend cap / anti-replay).")
|
|
else:
|
|
print(" NOT fully established (see FAILED / unexpected result above).")
|
|
import sys
|
|
sys.exit(0 if allok else 1)
|