// 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::). 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; } }