aere-research/formal-consensus/bitstring_status_smt.py
Aere Network 4a0b48588c 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:30 +03:00

204 lines
12 KiB
Python

#!/usr/bin/env python3
# -----------------------------------------------------------------------------
# bitstring_status_smt.py
#
# SMT proof (z3) of the REVOCATION-BIT MONOTONICITY (terminal), no-silent-no-op,
# epoch-monotonicity, and bit-frame-isolation invariants of AereBitstringStatusList,
# the W3C Bitstring Status List v1.0 for credential revocation / suspension. Plus
# load-bearing NEGATIVE CONTROLS for the terminal-revocation guard and the bit mask.
#
# Contract: contracts/contracts/compliance/AereBitstringStatusList.sol
#
# A status list is a bitset; each credential owns one status bit at a numeric index.
# A list is created for exactly one W3C purpose:
# Revocation (purpose 0): a set bit is PERMANENT (monotonic, terminal) -- matching
# revocation's terminal semantics.
# Suspension (purpose 1): a bit is REVERSIBLE (may be set and later cleared).
# setStatus(listId, index, value) fail-closes on each guard (else revert, no change):
# S0 controller-only: msg.sender == l.controller (NotController)
# S1 in range: index < l.capacity (IndexOutOfRange)
# S2 no silent no-op: current != value (NoStatusChange)
# S3 terminal revocation: NOT (purpose == Revocation AND value == false)
# (RevocationIsTerminal)
# then _setBit(index, value) and epoch += 1.
#
# REVOCATION MONOTONICITY (the target):
# on a Revocation list, the only enabled bit transition is 0 -> 1, so once a
# credential's status bit is 1 (revoked) it can NEVER return to 0 (un-revoked).
#
# Method: model the guards + the single-bit update as first-order constraints and
# prove each property by asserting its NEGATION under the guards (UNSAT). Bit isolation
# uses the standard frame relation over an uninterpreted index->bit function (as in the
# append-only models). Each NEG-CTRL removes exactly one guard and shows the attack
# becomes SAT. Suspension reversibility is intentional and is NOT a safety violation,
# so the monotonicity property is asserted ONLY for Revocation lists. This checks the
# DESIGN-level state logic, NOT the compiled EVM bytecode.
#
# NOTE (honest scope). The zero-knowledge NON-REVOCATION path (verifyNonRevocation +
# the SP1 circuit) is [MEASURE]: the circuit is pending and the on-chain path is fail-
# closed off until a verifier + vkey are configured; its constructor codeless-verifier
# guard (L1) and fail-closed binding are covered by the aere-pq-screen Hardhat tests,
# not modelled here. This model covers the fully-implemented on-chain bit mechanics.
# -----------------------------------------------------------------------------
from z3 import (Int, Bool, Function, IntSort, BoolSort, Solver, And, Or, Not,
Implies, ForAll, If, sat, unsat)
REVOCATION, SUSPENSION = 0, 1
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()
print(" witness:", {str(d): m[d] for d in m.decls()})
return ok
def set_status_ok(sender, controller, index, capacity, current, value, purpose):
"""setStatus's fail-closed guards S0..S3. The bit changes iff all hold."""
return And(
sender == controller, # S0 NotController
index < capacity, index >= 0, # S1 IndexOutOfRange
current != value, # S2 NoStatusChange
Not(And(purpose == REVOCATION, value == False)), # S3 RevocationIsTerminal
)
print("### AereBitstringStatusList -- REVOCATION MONOTONICITY + epoch + bit-frame isolation\n")
# ---- RM REVOCATION MONOTONICITY: on a Revocation list an enabled transition can only
# be 0 -> 1. Model current/value as {0,1} bits; under the guards, prove the new bit
# is >= the old bit. Negation: a Revocation transition sets the new bit BELOW the
# old one (a 1 -> 0 clear, i.e. un-revoke). UNSAT (terminal guard blocks it).
s = Solver()
sender, controller = Int('sender'), Int('controller')
index, capacity = Int('index'), Int('capacity')
curBit, valBit, purpose = Int('curBit'), Int('valBit'), Int('purpose')
s.add(Or(curBit == 0, curBit == 1), Or(valBit == 0, valBit == 1))
s.add(purpose == REVOCATION)
s.add(set_status_ok(sender, controller, index, capacity,
curBit == 1, valBit == 1, purpose)) # current/value as booleans
newBit = valBit # _setBit writes `value`
s.add(newBit < curBit) # NEGATION: the bit was cleared (un-revoke)
check("RM a revocation bit is monotone: once revoked it can never be un-revoked", s)
# ---- RM2 the only enabled Revocation transition is 0 -> 1. Negation: an enabled
# Revocation transition has old bit already 1 (so it must be a 1->1 no-op or a
# 1->0 clear, both forbidden). UNSAT: an enabled revocation write starts from 0.
s = Solver()
sender, controller = Int('sender'), Int('controller')
index, capacity = Int('index'), Int('capacity')
curBit, valBit, purpose = Int('curBit'), Int('valBit'), Int('purpose')
s.add(Or(curBit == 0, curBit == 1), Or(valBit == 0, valBit == 1))
s.add(purpose == REVOCATION)
s.add(set_status_ok(sender, controller, index, capacity,
curBit == 1, valBit == 1, purpose))
s.add(curBit == 1) # NEGATION: revoke an already-revoked bit
check("RM2 an enabled revocation transition always starts from an unset bit (0 -> 1)", s)
# ---- NS NO SILENT NO-OP: setStatus requires current != value, so every recorded
# mutation is a real state change. Negation: a write with current == value. UNSAT.
s = Solver()
sender, controller = Int('sender'), Int('controller')
index, capacity = Int('index'), Int('capacity')
current, value, purpose = Bool('current'), Bool('value'), Int('purpose')
s.add(set_status_ok(sender, controller, index, capacity, current, value, purpose))
s.add(current == value) # NEGATION: a no-op write succeeded
check("NS every recorded status mutation is a real change (NoStatusChange guard)", s)
# ---- CTRL CONTROLLER-ONLY: a status write requires msg.sender == controller.
# Negation: a non-controller mutates a bit. UNSAT.
s = Solver()
sender, controller = Int('sender'), Int('controller')
index, capacity = Int('index'), Int('capacity')
current, value, purpose = Bool('current'), Bool('value'), Int('purpose')
s.add(set_status_ok(sender, controller, index, capacity, current, value, purpose))
s.add(sender != controller) # NEGATION: a stranger flipped a bit
check("CTRL only the list controller can mutate a status bit", s)
# ---- EP EPOCH MONOTONICITY: every mutation does epoch += 1, so the version counter
# strictly increases (a freshness anchor a published root binds to). Negation:
# the post-epoch does not exceed the pre-epoch. UNSAT.
s = Solver()
epPre, epPost = Int('epPre'), Int('epPost')
s.add(epPre >= 0, epPost == epPre + 1) # l.epoch += 1
s.add(Not(epPost > epPre)) # NEGATION
check("EP the list epoch strictly increases on every bit mutation", s)
# ---- FR BIT-FRAME ISOLATION: setStatus(index) changes ONLY that bit; every other
# index keeps its value (the _setBit word-mask touches one bit). Model bits as an
# index->bit function; the update sets bit(index) = value and preserves the rest.
# Negation: some other index k != index changed. UNSAT.
bB = Function('bB', IntSort(), IntSort()) # bits BEFORE the write
bA = Function('bA', IntSort(), IntSort()) # bits AFTER the write
s = Solver()
idx = Int('idx'); v = Int('v'); k = Int('k')
s.add(Or(v == 0, v == 1))
s.add(bA(idx) == v) # the target bit takes `value`
s.add(ForAll([k], Implies(k != idx, bA(k) == bB(k)))) # every other bit is preserved
kk = Int('kk')
s.add(kk != idx, bA(kk) != bB(kk)) # NEGATION: a neighbour bit changed
check("FR setting one status bit leaves every other credential's bit unchanged", s)
# ============================ NEGATIVE CONTROLS ==============================
# ---- NEG-CTRL 1 (drop the terminal guard): a BUGGY setStatus without the
# RevocationIsTerminal check lets a Revocation list CLEAR a set bit, un-revoking a
# credential that revocation semantics say is permanently revoked. -------------
s = Solver()
sender, controller = Int('sender'), Int('controller')
index, capacity = Int('index'), Int('capacity')
curBit, valBit, purpose = Int('curBit'), Int('valBit'), Int('purpose')
s.add(Or(curBit == 0, curBit == 1), Or(valBit == 0, valBit == 1))
s.add(purpose == REVOCATION)
# BUG: S3 removed; the write needs only controller + range + a real change.
buggy_ok = And(sender == controller, index < capacity, index >= 0, curBit != valBit)
s.add(buggy_ok)
s.add(curBit == 1, valBit == 0) # a 1 -> 0 clear on a Revocation list
check("no-terminal-guard list CAN clear a revocation bit (un-revoke a credential)", s,
expect_unsat=False, kind="NEG-CTRL")
# ---- NEG-CTRL 2 (drop the bit mask / frame): a BUGGY _setBit that also disturbs a
# neighbouring index flips another credential's status while setting one bit.
s = Solver()
idx = Int('idx'); v = Int('v'); kk = Int('kk')
s.add(Or(v == 0, v == 1))
s.add(bA(idx) == v)
# BUG: no frame preservation; a different index kk is free to change.
s.add(kk != idx, bA(kk) != bB(kk)) # a neighbour bit flipped as collateral
check("no-mask list CAN flip a neighbouring credential's bit while setting one", s,
expect_unsat=False, kind="NEG-CTRL")
# ------------------------------------------------------------------------------
print("\n=== SUMMARY ===")
allok = True
for name, tag, ok, kind in results:
print(f" {tag:9} [{kind}] {name}")
allok = allok and ok
print()
if allok:
print("AereBitstringStatusList revocation-list safety:")
print(" PROVED -- on a Revocation list a status bit is monotone: the only enabled transition is")
print(" 0 -> 1 (RM/RM2), so once a credential is revoked it can never be un-revoked; every")
print(" recorded mutation is a real change (NS, no silent no-op), only the controller can write")
print(" (CTRL), the epoch strictly increases on every mutation (EP, the freshness anchor), and")
print(" setting one bit leaves every other credential's bit unchanged (FR, frame isolation).")
print(" Two NEG-CTRLs fire: dropping the terminal guard un-revokes a credential, and dropping")
print(" the bit mask flips a neighbour's status, so both are load-bearing.")
print(" SCOPE (honest): Suspension lists are intentionally REVERSIBLE, so the monotonicity")
print(" property is asserted only for Revocation lists (purpose 0). The zero-knowledge non-")
print(" revocation path (verifyNonRevocation + SP1 circuit) is [MEASURE]: the circuit is pending")
print(" and the on-chain path is fail-closed off until configured; its codeless-verifier (L1)")
print(" guard and fail-closed binding are covered by the aere-pq-screen Hardhat tests, not")
print(" modelled here. Application-layer compliance tooling, no funds, no PII on chain (an")
print(" anonymous bitset keyed by numeric index), consensus classical ECDSA QBFT. DESIGN-level")
print(" state logic, not the compiled EVM bytecode.")
else:
print(" NOT fully established (see FAILED / unexpected CEX above).")
import sys
sys.exit(0 if allok else 1)