aere-contracts/contracts/compliance/AereVerifiableCredential.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

326 lines
16 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @dev The AereTrustRegistry surface this contract relies on: accreditation lookups and the
/// POST-QUANTUM issuer signature verification (which routes to the live precompile 0x0AE1).
interface IAereTrustRegistryVC {
function isAccredited(uint256 issuerId, bytes32 credentialType) external view returns (bool);
function issuerStatus(uint256 issuerId) external view returns (uint8); // AereTrustRegistry.Status
function verifyIssuerSignature(uint256 issuerId, bytes32 message, bytes calldata signature)
external
view
returns (bool);
}
/// @dev The AereBitstringStatusList surface used for the not-revoked check.
interface IAereStatusListVC {
function statusOf(uint256 listId, uint256 index) external view returns (bool);
}
/**
* @title AereVerifiableCredential, a W3C Verifiable Credentials 2.0 style on-chain anchor + verifier
*
* @notice Anchors and verifies W3C Verifiable Credentials (VC 2.0 data model) on Aere Network with a
* POST-QUANTUM issuer signature path. A credential here binds:
* - an ISSUER, an accredited issuer id in the AereTrustRegistry;
* - a SUBJECT, identified by a did:aere DID (bound by its hash; the DID string itself is a
* public identifier carried off-chain, no PII on chain);
* - a CREDENTIAL TYPE (an opaque bytes32 tag, e.g. keccak256("KYCCredential"));
* - a VALIDITY window (validFrom / validUntil, W3C validFrom / validUntil);
* - a STATUS-LIST ENTRY (a W3C BitstringStatusListEntry: a list id + a status index);
* - a CLAIMS HASH, the keccak256 of the off-chain VC JSON claims (the credentialSubject),
* so the on-chain anchor commits to the credential content without publishing it.
*
* VERIFICATION (verifyPresented / isValid) checks, in order and fail-closed:
* 1. the issuer is ACCREDITED for this credential type in the AereTrustRegistry;
* 2. the issuer's POST-QUANTUM (Falcon-512) signature over the credential digest is valid,
* verified in full on-chain via the live precompile 0x0AE1 (through the Trust Registry);
* 3. the credential is WITHIN its validity window (not-yet-valid and expired both fail);
* 4. the credential is NOT REVOKED per its Bitstring Status List entry.
*
* DID METHOD. Subjects (and issuers) use the `did:aere` method, consistent with Aere's
* AereIdentityRegistry8004 (did:aere:<chainId>:<id>). Full DID Core syntax validation is an
* off-chain concern; anchorCredentialWithDid enforces the `did:aere:` prefix as a minimal
* on-chain guard. [VERIFY] against the finalized did:aere method registration.
*
* ANCHORING. anchorCredential verifies the FULL path (accreditation + PQC signature + validity)
* fail-closed and records the credential once. Authorship is the POST-QUANTUM signature alone,
* so anchoring may be relayed by any msg.sender (only the issuer's Falcon key can produce a
* valid signature over the contract-bound digest). A recorded anchor is then re-checked LIVE
* by isValid, which fails closed the instant the issuer is suspended / revoked or the
* credential is revoked on its status list, without needing the signature again.
*
* @dev HONEST SCOPE. Application-layer compliance tooling. Holds no funds; changes nothing about
* Aere consensus (classical secp256k1 ECDSA QBFT). What is post-quantum is the AUTHENTICITY of
* the issuer's attestation, verified by the live precompile. Whether any real institution
* issues credentials here is a partnership matter external to this code and is NOT asserted.
*
* @dev [VERIFY] against the finalized W3C VC 2.0 serialization: this contract anchors a COMMITMENT
* to a VC (its digest + claims hash), not the JSON-LD document itself. The mapping of these
* fields to the VC JSON members (context, type, issuer, validFrom, credentialStatus) is
* defined in docs/AERE-PQ-SCREEN.md; reconcile exact JSON member names before claiming wire
* conformance.
*/
contract AereVerifiableCredential {
/// @notice AereTrustRegistry.Status.Active (an issuer must be Active to issue / stay valid).
uint8 internal constant ISSUER_STATUS_ACTIVE = 1;
/// @notice Domain separator binding a credential digest to this contract and version.
bytes32 public constant VC_DOMAIN = keccak256("AereVerifiableCredential.v2.credential");
/// @notice The DID method these credentials use for subjects and issuers.
string public constant DID_METHOD = "did:aere";
/// @notice The federated accredited-issuer trust registry.
IAereTrustRegistryVC public immutable TRUST_REGISTRY;
/// @notice The W3C Bitstring Status List used for revocation / suspension.
IAereStatusListVC public immutable STATUS_LIST;
/// @notice A W3C VC 2.0 credential binding. `subjectDidHash` is keccak256(bytes(subjectDid)); the
/// DID string is carried off-chain to keep the hot path free of variable-length data and
/// to keep identifiers off the chain. `claimsHash` is keccak256 of the off-chain VC claims.
struct Credential {
uint256 issuerId; // accredited issuer id in the AereTrustRegistry
bytes32 subjectDidHash; // keccak256(bytes(subjectDid)), subjectDid is a did:aere DID
bytes32 credentialType; // opaque type tag, e.g. keccak256("KYCCredential")
uint64 validFrom; // W3C validFrom (unix seconds, inclusive)
uint64 validUntil; // W3C validUntil (unix seconds, inclusive); 0 = no expiry
uint256 statusListId; // W3C BitstringStatusListEntry: the list
uint256 statusIndex; // W3C BitstringStatusListEntry: the index within the list
bytes32 claimsHash; // keccak256 of the off-chain credentialSubject claims (no PII on chain)
}
struct Anchor {
bool exists;
uint256 issuerId;
bytes32 credentialType;
bytes32 subjectDidHash;
uint64 validFrom;
uint64 validUntil;
uint256 statusListId;
uint256 statusIndex;
uint64 anchoredAt;
}
mapping(bytes32 => Anchor) private _anchors; // credentialId => anchor
bytes32[] private _anchorIds; // append-only list of anchored credential ids
event CredentialAnchored(
bytes32 indexed credentialId,
uint256 indexed issuerId,
bytes32 indexed credentialType,
bytes32 subjectDidHash,
uint256 statusListId,
uint256 statusIndex
);
event CredentialAnchoredWithDid(bytes32 indexed credentialId, string subjectDid);
error ZeroAddress();
error NotAccredited(uint256 issuerId, bytes32 credentialType);
error SignatureInvalid();
error NotYetValid(uint64 validFrom);
error Expired(uint64 validUntil);
error AlreadyAnchored(bytes32 credentialId);
error UnknownCredential(bytes32 credentialId);
error DidMismatch();
error NotAereDid();
constructor(address trustRegistry_, address statusList_) {
if (trustRegistry_ == address(0) || statusList_ == address(0)) revert ZeroAddress();
TRUST_REGISTRY = IAereTrustRegistryVC(trustRegistry_);
STATUS_LIST = IAereStatusListVC(statusList_);
}
// ==========================================================================
// Digest (what the issuer signs)
// ==========================================================================
/**
* @notice The canonical 32-byte digest an issuer's Falcon-512 key signs to attest a credential.
* Binds chainId + this contract + every credential field, so a signature cannot be
* replayed against another contract, chain, subject, type, validity, or status entry.
* This value is also the credentialId used to anchor and look the credential up.
*/
function credentialDigest(Credential calldata c) public view returns (bytes32) {
return keccak256(
abi.encode(
VC_DOMAIN,
block.chainid,
address(this),
c.issuerId,
c.subjectDidHash,
c.credentialType,
c.validFrom,
c.validUntil,
c.statusListId,
c.statusIndex,
c.claimsHash
)
);
}
/// @notice Alias of {credentialDigest}: the id a credential is anchored and referenced under.
function credentialId(Credential calldata c) external view returns (bytes32) {
return credentialDigest(c);
}
/// @notice Helper: the subjectDidHash for a DID string (keccak256 of its UTF-8 bytes).
function hashDid(string calldata did) external pure returns (bytes32) {
return keccak256(bytes(did));
}
// ==========================================================================
// Verification (views)
// ==========================================================================
/// @notice True iff the issuer is accredited for the type AND its Falcon-512 signature over the
/// credential digest verifies on-chain via 0x0AE1. Does NOT check validity or revocation.
/// Fail-closed (returns false, never reverts).
function verifyCredentialSignature(Credential calldata c, bytes calldata issuerSig) public view returns (bool) {
if (!TRUST_REGISTRY.isAccredited(c.issuerId, c.credentialType)) return false;
return TRUST_REGISTRY.verifyIssuerSignature(c.issuerId, credentialDigest(c), issuerSig);
}
/// @notice True iff `c` is within its validity window at the current block time.
function isWithinValidity(Credential calldata c) public view returns (bool) {
if (block.timestamp < c.validFrom) return false;
if (c.validUntil != 0 && block.timestamp > c.validUntil) return false;
return true;
}
/// @notice True iff `c` is marked revoked/suspended on its Bitstring Status List entry.
function isRevoked(Credential calldata c) public view returns (bool) {
return STATUS_LIST.statusOf(c.statusListId, c.statusIndex);
}
/**
* @notice Full W3C verification of a PRESENTED credential in one call, WITHOUT requiring it to be
* anchored: accreditation + post-quantum signature + within-validity + not-revoked. Returns
* true only if all four hold. Fail-closed (never reverts), so any verifier can call it via
* eth_call to check a credential a holder presents off-chain.
*/
function verifyPresented(Credential calldata c, bytes calldata issuerSig) external view returns (bool) {
if (!verifyCredentialSignature(c, issuerSig)) return false;
if (!isWithinValidity(c)) return false;
if (isRevoked(c)) return false;
return true;
}
// ==========================================================================
// Anchor
// ==========================================================================
/**
* @notice Anchor a credential on-chain after verifying the FULL path fail-closed: the issuer must
* be accredited for the type, the credential must be within its validity window, and the
* issuer's Falcon-512 signature over the credential digest must verify via 0x0AE1. Records
* the credential once and emits CredentialAnchored. Reverts (records nothing) on any
* failure. Relayable by anyone: authorship is the post-quantum signature.
* @return id the credentialId (== credentialDigest) the credential was anchored under.
*/
function anchorCredential(Credential calldata c, bytes calldata issuerSig) public returns (bytes32 id) {
id = credentialDigest(c);
if (_anchors[id].exists) revert AlreadyAnchored(id);
if (!TRUST_REGISTRY.isAccredited(c.issuerId, c.credentialType)) {
revert NotAccredited(c.issuerId, c.credentialType);
}
if (block.timestamp < c.validFrom) revert NotYetValid(c.validFrom);
if (c.validUntil != 0 && block.timestamp > c.validUntil) revert Expired(c.validUntil);
// Full on-chain post-quantum verification of the issuer's attestation (fail-closed).
if (!TRUST_REGISTRY.verifyIssuerSignature(c.issuerId, id, issuerSig)) revert SignatureInvalid();
_anchors[id] = Anchor({
exists: true,
issuerId: c.issuerId,
credentialType: c.credentialType,
subjectDidHash: c.subjectDidHash,
validFrom: c.validFrom,
validUntil: c.validUntil,
statusListId: c.statusListId,
statusIndex: c.statusIndex,
anchoredAt: uint64(block.timestamp)
});
_anchorIds.push(id);
emit CredentialAnchored(id, c.issuerId, c.credentialType, c.subjectDidHash, c.statusListId, c.statusIndex);
}
/**
* @notice As {anchorCredential}, but additionally binds and emits the subject's did:aere DID
* string (enforcing keccak256(subjectDid) == c.subjectDidHash and the `did:aere:` prefix),
* so resolvers can recover the subject DID from events without any PII on chain.
*/
function anchorCredentialWithDid(Credential calldata c, string calldata subjectDid, bytes calldata issuerSig)
external
returns (bytes32 id)
{
if (keccak256(bytes(subjectDid)) != c.subjectDidHash) revert DidMismatch();
if (!_isAereDid(subjectDid)) revert NotAereDid();
id = anchorCredential(c, issuerSig);
emit CredentialAnchoredWithDid(id, subjectDid);
}
// ==========================================================================
// Anchored-credential reads
// ==========================================================================
/**
* @notice Live validity of an ANCHORED credential: the issuer must STILL be accredited for the
* type, the credential must be within its validity window, and it must not be revoked on
* its status list. Fails closed the instant the issuer is suspended / revoked (which flips
* isAccredited to false) or the credential's status bit is set. The post-quantum signature
* was verified at anchor time and cannot silently become valid again after revocation,
* because issuer suspension / key revocation both fail this check.
*/
function isValid(bytes32 id) external view returns (bool) {
Anchor storage a = _anchors[id];
if (!a.exists) return false;
if (!TRUST_REGISTRY.isAccredited(a.issuerId, a.credentialType)) return false;
if (block.timestamp < a.validFrom) return false;
if (a.validUntil != 0 && block.timestamp > a.validUntil) return false;
if (STATUS_LIST.statusOf(a.statusListId, a.statusIndex)) return false;
return true;
}
/// @notice True iff a credential id has been anchored (regardless of current validity).
function isAnchored(bytes32 id) external view returns (bool) {
return _anchors[id].exists;
}
/// @notice Read an anchored credential record. Reverts on an unknown id.
function getAnchor(bytes32 id) external view returns (Anchor memory) {
Anchor storage a = _anchors[id];
if (!a.exists) revert UnknownCredential(id);
return a;
}
/// @notice Total number of anchored credentials.
function anchorCount() external view returns (uint256) {
return _anchorIds.length;
}
/// @notice The credential id at an index in the append-only anchor list.
function anchorIdAt(uint256 index) external view returns (bytes32) {
return _anchorIds[index];
}
// ==========================================================================
// Internal
// ==========================================================================
/// @dev Minimal on-chain guard that a DID string begins with "did:aere:". Full DID Core syntax
/// validation is an off-chain concern. [VERIFY] against the finalized did:aere method.
function _isAereDid(string calldata did) internal pure returns (bool) {
bytes calldata b = bytes(did);
bytes memory prefix = bytes("did:aere:");
if (b.length < prefix.length) return false;
for (uint256 i = 0; i < prefix.length; i++) {
if (b[i] != prefix[i]) return false;
}
return true;
}
}