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.
346 lines
18 KiB
Solidity
346 lines
18 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
/// @dev Matches Succinct's canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS
|
|
/// NOTHING and REVERTS on an invalid proof. Used here as the (future) NON-REVOCATION proof
|
|
/// verifier. See AereZKScreen.sol for the same pattern already used in Aere's compliance stack.
|
|
interface INonRevocationVerifier {
|
|
function verifyProof(bytes32 programVKey, bytes calldata publicValues, bytes calldata proof) external view;
|
|
}
|
|
|
|
/**
|
|
* @title AereBitstringStatusList, a W3C Bitstring Status List for credential revocation / suspension
|
|
*
|
|
* @notice An on-chain implementation of the W3C Bitstring Status List v1.0 mechanism. A status list
|
|
* is a compact bitset; every credential is assigned a status INDEX into a list, and the bit
|
|
* at that index encodes the credential's status (default 0 = active; 1 = revoked or
|
|
* suspended, per the list's purpose). Revoking a credential flips one bit. Because a single
|
|
* list packs many credentials, a verifier learns only "the bit at index i", which gives herd
|
|
* privacy: it cannot tell WHICH credential a status update concerns from the list alone.
|
|
*
|
|
* PURPOSES. A list is created for exactly one W3C statusPurpose:
|
|
* - Revocation: a status change is PERMANENT. Once a bit is set it can never be cleared
|
|
* (monotonic), matching revocation's terminal semantics.
|
|
* - Suspension: a status change is REVERSIBLE. A bit may be set and later cleared.
|
|
*
|
|
* ZERO-KNOWLEDGE NON-REVOCATION (see the zk section below). The plain on-chain read
|
|
* statusOf(listId, index) is public and reveals the queried index. For privacy-preserving
|
|
* verification, a holder can instead prove in zero knowledge that "the status bit of the
|
|
* credential I hold is 0" WITHOUT revealing which index it is, binding the proof to the list's
|
|
* current committed state. This file defines that verifier interface and the exact
|
|
* public-input shape; the SP1 circuit that produces such a proof is [MEASURE] (to be
|
|
* implemented against Aere's existing SP1 zk stack, the same stack behind AereZKScreen).
|
|
*
|
|
* @dev HONEST SCOPE. Application-layer compliance tooling. Holds no funds; changes nothing about
|
|
* Aere consensus (classical secp256k1 ECDSA QBFT). No PII on chain: a status list is an
|
|
* anonymous bitset keyed by numeric index only.
|
|
*/
|
|
contract AereBitstringStatusList {
|
|
/// @notice W3C statusPurpose for a list.
|
|
enum Purpose {
|
|
Revocation,
|
|
Suspension
|
|
}
|
|
|
|
struct List {
|
|
address controller; // the party (typically the issuer) that owns this list's bits
|
|
Purpose purpose;
|
|
uint256 capacity; // number of status entries (bits) in the list
|
|
uint64 createdAt;
|
|
uint64 epoch; // increments on every bit mutation (monotonic version counter)
|
|
uint64 lastMutatedAt;
|
|
bytes32 publishedRoot; // controller-attested commitment to the CURRENT bitstring [MEASURE]
|
|
uint64 rootEpoch; // the epoch publishedRoot was published at (freshness anchor)
|
|
}
|
|
|
|
// listId is the index into _lists.
|
|
List[] private _lists;
|
|
// listId => wordIndex => 256-bit packed status word (bit j of word w is entry w*256 + j).
|
|
mapping(uint256 => mapping(uint256 => uint256)) private _words;
|
|
|
|
// ---- zero-knowledge non-revocation program (network-level config) ----
|
|
|
|
/// @notice The admin allowed to publish the non-revocation program vkey (the deployer). This is
|
|
/// intentionally NOT the per-list controller: the circuit is a network artifact.
|
|
address public immutable ADMIN;
|
|
|
|
/// @notice The SP1 verifier (SP1VerifierGateway) that checks a non-revocation proof. May be the
|
|
/// zero address at deploy, in which case the zk non-revocation path is fail-closed off
|
|
/// until configured. Immutable once set at construction.
|
|
INonRevocationVerifier public immutable NON_REVOCATION_VERIFIER;
|
|
|
|
/// @notice The SP1 program verification key for the non-revocation circuit. Zero until the
|
|
/// circuit is built and its vkey published. [MEASURE]: the circuit itself is pending.
|
|
bytes32 public nonRevocationVKey;
|
|
|
|
event ListCreated(uint256 indexed listId, address indexed controller, Purpose purpose, uint256 capacity);
|
|
event StatusSet(uint256 indexed listId, uint256 indexed index, bool value, uint64 epoch);
|
|
event StatusRootPublished(uint256 indexed listId, bytes32 root, uint64 epoch);
|
|
event NonRevocationVKeySet(bytes32 oldVKey, bytes32 newVKey);
|
|
|
|
error NotAdmin();
|
|
error NotController(uint256 listId);
|
|
error UnknownList(uint256 listId);
|
|
error ZeroCapacity();
|
|
error IndexOutOfRange(uint256 listId, uint256 index);
|
|
error RevocationIsTerminal(uint256 listId, uint256 index); // cannot clear a revocation bit
|
|
error NoStatusChange(uint256 listId, uint256 index); // the bit already holds `value`
|
|
error NonRevocationNotConfigured();
|
|
error VerifierHasNoCode();
|
|
|
|
constructor(address nonRevocationVerifier_) {
|
|
ADMIN = msg.sender;
|
|
// A non-zero but CODE-LESS verifier would make the try/verifyProof in verifyNonRevocation
|
|
// succeed silently (verifyProof returns no data), so a revoked credential would prove "not
|
|
// revoked" = fail-open. Guard it exactly as the sibling AereFinalityCertificateVerifier does.
|
|
// The zero address is still allowed: it keeps the zk non-revocation path fail-closed OFF until
|
|
// a real gateway is configured (nonRevocationConfigured() stays false).
|
|
if (nonRevocationVerifier_ != address(0) && nonRevocationVerifier_.code.length == 0) {
|
|
revert VerifierHasNoCode();
|
|
}
|
|
NON_REVOCATION_VERIFIER = INonRevocationVerifier(nonRevocationVerifier_); // zero allowed
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Lists
|
|
// ==========================================================================
|
|
|
|
/// @notice Create a status list for a single W3C statusPurpose. msg.sender becomes its controller
|
|
/// (typically the credential issuer). Returns the new listId.
|
|
/// @param purpose Revocation (terminal bits) or Suspension (reversible bits).
|
|
/// @param capacity the number of status entries (bits) the list holds. [VERIFY] W3C recommends a
|
|
/// large minimum (e.g. 131072) for herd privacy; that is a deployment policy
|
|
/// choice and is NOT enforced here.
|
|
function createList(Purpose purpose, uint256 capacity) external returns (uint256 listId) {
|
|
if (capacity == 0) revert ZeroCapacity();
|
|
listId = _lists.length;
|
|
_lists.push(
|
|
List({
|
|
controller: msg.sender,
|
|
purpose: purpose,
|
|
capacity: capacity,
|
|
createdAt: uint64(block.timestamp),
|
|
epoch: 0,
|
|
lastMutatedAt: uint64(block.timestamp),
|
|
publishedRoot: bytes32(0),
|
|
rootEpoch: 0
|
|
})
|
|
);
|
|
emit ListCreated(listId, msg.sender, purpose, capacity);
|
|
}
|
|
|
|
/// @notice Set the status bit of a credential index. Controller-only.
|
|
/// Revocation lists: `value` must be true and the bit must currently be 0 (monotonic,
|
|
/// terminal). Suspension lists: `value` may be true or false (reversible). Reverts if the
|
|
/// bit already equals `value` (no silent no-op).
|
|
function setStatus(uint256 listId, uint256 index, bool value) public {
|
|
List storage l = _requireList(listId);
|
|
if (msg.sender != l.controller) revert NotController(listId);
|
|
if (index >= l.capacity) revert IndexOutOfRange(listId, index);
|
|
|
|
bool current = _getBit(listId, index);
|
|
if (current == value) revert NoStatusChange(listId, index);
|
|
if (l.purpose == Purpose.Revocation && !value) revert RevocationIsTerminal(listId, index);
|
|
|
|
_setBit(listId, index, value);
|
|
unchecked {
|
|
l.epoch += 1;
|
|
}
|
|
l.lastMutatedAt = uint64(block.timestamp);
|
|
emit StatusSet(listId, index, value, l.epoch);
|
|
}
|
|
|
|
/// @notice Convenience: revoke (set bit to 1) the credential at `index` on a Revocation list.
|
|
function revoke(uint256 listId, uint256 index) external {
|
|
setStatus(listId, index, true);
|
|
}
|
|
|
|
/// @notice Publish a controller-attested commitment to the CURRENT bitstring, anchored to the
|
|
/// current epoch. A zk non-revocation proof (below) binds to this root, so a verifier
|
|
/// accepts a proof only against an up-to-date root.
|
|
/// @dev [MEASURE] The root is controller-attested in this version: the controller is trusted
|
|
/// to publish a commitment matching the live bits it just wrote. A future version derives
|
|
/// the root on-chain (or inside the SP1 VM) from getWord so it is trustless. The raw words
|
|
/// are exposed by getWord for independent recomputation regardless.
|
|
function publishStatusRoot(uint256 listId, bytes32 root) external {
|
|
List storage l = _requireList(listId);
|
|
if (msg.sender != l.controller) revert NotController(listId);
|
|
l.publishedRoot = root;
|
|
l.rootEpoch = l.epoch;
|
|
emit StatusRootPublished(listId, root, l.epoch);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Reads
|
|
// ==========================================================================
|
|
|
|
/// @notice The status bit of a credential index (true = revoked/suspended per the list purpose).
|
|
function statusOf(uint256 listId, uint256 index) external view returns (bool) {
|
|
List storage l = _requireList(listId);
|
|
if (index >= l.capacity) revert IndexOutOfRange(listId, index);
|
|
return _getBit(listId, index);
|
|
}
|
|
|
|
/// @notice The raw 256-bit packed word at `wordIndex` (entries [wordIndex*256, wordIndex*256+255]).
|
|
/// Exposed so anyone can recompute a commitment over the full bitstring off-chain.
|
|
function getWord(uint256 listId, uint256 wordIndex) external view returns (uint256) {
|
|
_requireList(listId);
|
|
return _words[listId][wordIndex];
|
|
}
|
|
|
|
/// @notice The number of 256-bit words backing a list's capacity.
|
|
function wordCount(uint256 listId) external view returns (uint256) {
|
|
List storage l = _requireList(listId);
|
|
return (l.capacity + 255) / 256;
|
|
}
|
|
|
|
/// @notice Number of lists ever created (listId runs 0..listCount-1).
|
|
function listCount() external view returns (uint256) {
|
|
return _lists.length;
|
|
}
|
|
|
|
/// @notice Read a list's metadata. Reverts on an unknown listId.
|
|
function getList(uint256 listId)
|
|
external
|
|
view
|
|
returns (
|
|
address controller,
|
|
Purpose purpose,
|
|
uint256 capacity,
|
|
uint64 createdAt,
|
|
uint64 epoch,
|
|
uint64 lastMutatedAt,
|
|
bytes32 publishedRoot,
|
|
uint64 rootEpoch
|
|
)
|
|
{
|
|
List storage l = _requireList(listId);
|
|
return (l.controller, l.purpose, l.capacity, l.createdAt, l.epoch, l.lastMutatedAt, l.publishedRoot, l.rootEpoch);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Zero-knowledge non-revocation
|
|
// ==========================================================================
|
|
//
|
|
// GOAL. Let a credential HOLDER prove "the status bit of my credential is 0" (i.e. my credential
|
|
// is NOT revoked) WITHOUT revealing which status index it is, and bind that proof to the list's
|
|
// current on-chain state so a stale proof is rejected.
|
|
//
|
|
// PUBLIC-INPUT SHAPE (the SP1 program's committed public values), ABI-encoded exactly as:
|
|
// abi.encode(
|
|
// uint256 chainId, // must equal block.chainid
|
|
// address statusListContract, // must equal address(this)
|
|
// uint256 statusListId, // the list the credential's status lives in
|
|
// bytes32 statusRoot, // commitment to the list bitstring the proof was made against
|
|
// bytes32 credentialCommitment,// hiding commitment to the holder's credential (binds a real
|
|
// // anchored credential without revealing its index)
|
|
// uint8 purpose // must equal uint8(list.purpose)
|
|
// )
|
|
//
|
|
// IN-CIRCUIT WITNESS (private, [MEASURE] to be implemented):
|
|
// - the status index i of the holder's credential,
|
|
// - an opening of `statusRoot` at i proving bitstring[i] == 0,
|
|
// - an opening of `credentialCommitment` to the credential (issuer, type, index i),
|
|
// so the statement proven is: "there is an index i, committed by credentialCommitment, whose
|
|
// bit under statusRoot is 0", revealing neither i nor the credential.
|
|
//
|
|
// ON-CHAIN BINDING. verifyNonRevocation enforces chainId / contract / listId / purpose, and
|
|
// requires statusRoot to equal the list's publishedRoot AT THE CURRENT EPOCH (rootEpoch == epoch),
|
|
// so a proof is only accepted against the freshest committed state. It then delegates soundness of
|
|
// the bit-is-0 statement to the SP1 verifier. FAIL-CLOSED if the verifier or vkey is unconfigured.
|
|
|
|
/// @notice The canonical ABI type string of the non-revocation public inputs (for off-chain tooling).
|
|
string public constant NON_REVOCATION_PUBLIC_VALUES_ABI =
|
|
"(uint256 chainId,address statusListContract,uint256 statusListId,bytes32 statusRoot,bytes32 credentialCommitment,uint8 purpose)";
|
|
|
|
/// @notice Publish the SP1 non-revocation program vkey (admin-only). Settable because the circuit
|
|
/// is [MEASURE]: once built and its vkey known, the admin sets it; setting it to a fresh
|
|
/// value is how the program is upgraded. Emits the transition for auditability.
|
|
function setNonRevocationVKey(bytes32 vkey) external {
|
|
if (msg.sender != ADMIN) revert NotAdmin();
|
|
bytes32 old = nonRevocationVKey;
|
|
nonRevocationVKey = vkey;
|
|
emit NonRevocationVKeySet(old, vkey);
|
|
}
|
|
|
|
/// @notice True once a non-revocation verifier AND a program vkey are both configured.
|
|
function nonRevocationConfigured() public view returns (bool) {
|
|
return address(NON_REVOCATION_VERIFIER) != address(0) && nonRevocationVKey != bytes32(0);
|
|
}
|
|
|
|
/**
|
|
* @notice Verify a zero-knowledge NON-REVOCATION proof for `listId` against its current committed
|
|
* state. Returns true iff (a) the public inputs bind this chain, this contract, this list
|
|
* and its purpose, (b) the proof's statusRoot equals the list's publishedRoot at the
|
|
* current epoch (freshness), and (c) the SP1 verifier accepts the proof. FAIL-CLOSED:
|
|
* returns false (never reverts) on any binding mismatch or invalid proof, and reverts
|
|
* with NonRevocationNotConfigured only when the program is not yet configured, so callers
|
|
* cannot mistake "unconfigured" for "not revoked".
|
|
*
|
|
* @dev [MEASURE] The SP1 circuit that produces the proof is not yet implemented; when it is,
|
|
* its vkey is published via setNonRevocationVKey and the verifier accepts real proofs. The
|
|
* on-chain binding here is complete and final; only the circuit is pending.
|
|
*/
|
|
function verifyNonRevocation(uint256 listId, bytes calldata publicValues, bytes calldata proof)
|
|
external
|
|
view
|
|
returns (bool)
|
|
{
|
|
List storage l = _requireList(listId);
|
|
if (!nonRevocationConfigured()) revert NonRevocationNotConfigured();
|
|
|
|
// Exact length check (6 * 32 = 192) prevents a trailing-bytes decode spoof.
|
|
if (publicValues.length != 192) return false;
|
|
(
|
|
uint256 chainId,
|
|
address statusListContract,
|
|
uint256 statusListId,
|
|
bytes32 statusRoot,
|
|
, // credentialCommitment: bound inside the circuit, not re-checked on chain
|
|
uint8 purpose
|
|
) = abi.decode(publicValues, (uint256, address, uint256, bytes32, bytes32, uint8));
|
|
|
|
if (chainId != block.chainid) return false;
|
|
if (statusListContract != address(this)) return false;
|
|
if (statusListId != listId) return false;
|
|
if (purpose != uint8(l.purpose)) return false;
|
|
// Freshness: the proof must be against the current published root (root must be up to date).
|
|
if (l.publishedRoot == bytes32(0)) return false;
|
|
if (l.rootEpoch != l.epoch) return false;
|
|
if (statusRoot != l.publishedRoot) return false;
|
|
|
|
// Delegate the "bit is 0 for a hidden index" statement to the SP1 verifier (reverts if invalid).
|
|
try NON_REVOCATION_VERIFIER.verifyProof(nonRevocationVKey, publicValues, proof) {
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Internal
|
|
// ==========================================================================
|
|
|
|
function _requireList(uint256 listId) internal view returns (List storage l) {
|
|
if (listId >= _lists.length) revert UnknownList(listId);
|
|
l = _lists[listId];
|
|
}
|
|
|
|
function _getBit(uint256 listId, uint256 index) internal view returns (bool) {
|
|
uint256 word = _words[listId][index >> 8];
|
|
return ((word >> (index & 0xff)) & 1) == 1;
|
|
}
|
|
|
|
function _setBit(uint256 listId, uint256 index, bool value) internal {
|
|
uint256 wordIndex = index >> 8;
|
|
uint256 mask = uint256(1) << (index & 0xff);
|
|
uint256 word = _words[listId][wordIndex];
|
|
if (value) {
|
|
word |= mask;
|
|
} else {
|
|
word &= ~mask;
|
|
}
|
|
_words[listId][wordIndex] = word;
|
|
}
|
|
}
|