diff --git a/interfaces/L1/proofs/tee/INitroEnclaveVerifier.sol b/interfaces/L1/proofs/tee/INitroEnclaveVerifier.sol index 7b212b42..cffe9388 100644 --- a/interfaces/L1/proofs/tee/INitroEnclaveVerifier.sol +++ b/interfaces/L1/proofs/tee/INitroEnclaveVerifier.sol @@ -167,6 +167,26 @@ interface INitroEnclaveVerifier { */ function revoker() external view returns (address); + /** + * @dev Returns whether the given intermediate certificate hash has been revoked. + * @param _certHash Hash of the certificate + * @return `true` if the certificate is currently marked as revoked + * + * The revocation sentinel is persistent across `_cacheNewCert` overwrites and + * blocks both verification (via `_verifyJournal`) and the off-chain + * `checkTrustedIntermediateCerts` helper from re-trusting the hash. Re-trust + * requires an explicit `unrevokeCert` call. + */ + function revokedCerts(bytes32 _certHash) external view returns (bool); + + /** + * @dev Returns the cached `notAfter` timestamp (seconds) for an intermediate certificate. + * @param _certHash Hash of the certificate + * @return Cached expiry timestamp; `0` indicates the certificate is not currently + * cached (either never seen, expired-and-evicted, or revoked). + */ + function trustedIntermediateCerts(bytes32 _certHash) external view returns (uint64); + /** * @dev Retrieves the configuration for a specific coprocessor * @param _zkCoProcessor Type of ZK coprocessor (RiscZero or Succinct) @@ -261,15 +281,36 @@ interface INitroEnclaveVerifier { external; /** - * @dev Revokes a trusted intermediate certificate + * @dev Revokes a trusted intermediate certificate. * @param _certHash Hash of the certificate to revoke * * Requirements: * - Only callable by contract owner or revoker * - Certificate must exist in the trusted set + * + * In addition to clearing the cached entry, this flips a persistent + * revocation sentinel that survives later cache writes. Subsequent + * verifications whose chain traverses the revoked hash are rejected + * regardless of the journal-supplied `trustedCertsPrefixLen`. Re-trust + * requires an explicit `unrevokeCert` call. */ function revokeCert(bytes32 _certHash) external; + /** + * @dev Explicitly re-trusts a previously revoked intermediate certificate. + * @param _certHash Hash of the certificate to un-revoke + * + * Requirements: + * - Only callable by contract owner + * - Certificate must currently be marked as revoked + * + * Clears the persistent revocation sentinel. The cached expiry is not + * restored here; the next successful verification whose chain traverses + * `_certHash` will re-cache it via `_cacheNewCert` with the journal-supplied + * `notAfter` timestamp. + */ + function unrevokeCert(bytes32 _certHash) external; + /** * @dev Updates the verifier program ID, adding the new version to the supported set * @param _zkCoProcessor Type of ZK coprocessor diff --git a/snapshots/abi/NitroEnclaveVerifier.json b/snapshots/abi/NitroEnclaveVerifier.json index fb95a558..83026498 100644 --- a/snapshots/abi/NitroEnclaveVerifier.json +++ b/snapshots/abi/NitroEnclaveVerifier.json @@ -421,6 +421,25 @@ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "revokedCerts", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "revoker", @@ -571,6 +590,19 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "certHash", + "type": "bytes32" + } + ], + "name": "unrevokeCert", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -841,6 +873,19 @@ "name": "CertRevoked", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "bytes32", + "name": "certHash", + "type": "bytes32" + } + ], + "name": "CertUnrevoked", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -1091,6 +1136,17 @@ "name": "CertificateNotFound", "type": "error" }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "certHash", + "type": "bytes32" + } + ], + "name": "CertificateNotRevoked", + "type": "error" + }, { "inputs": [], "name": "InvalidVerifierAddress", diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index cef8249c..062480ae 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -44,8 +44,8 @@ "sourceCodeHash": "0x52926494b37fbf68ce1110d8097a399a23d15eb6e1a878eb56b0bed62c5cb926" }, "src/L1/proofs/tee/NitroEnclaveVerifier.sol:NitroEnclaveVerifier": { - "initCodeHash": "0x82f42b4d578bfcf9dc35eaa2c4ada04a45e1eca63021bceceb2ec794b12a9dd6", - "sourceCodeHash": "0x5b2938048ff85baceb963c54138ce209890d77c63ce8791b48f36c5fda5c81e5" + "initCodeHash": "0xb4b79601bf956e3859e982028036344fc1c0b36af09686b5f0bfeeabf8540c5f", + "sourceCodeHash": "0x07f0bca150c8799aaa67ca3d960d51456add0c945d293eec233d67573371343e" }, "src/L1/proofs/tee/TEEProverRegistry.sol:TEEProverRegistry": { "initCodeHash": "0xfd1942e1c2f59b0aa72b33d698a948a53b6e4cf1040106f173fb5d89f63f57b0", diff --git a/snapshots/storageLayout/NitroEnclaveVerifier.json b/snapshots/storageLayout/NitroEnclaveVerifier.json index fcf6a6ec..f273406b 100644 --- a/snapshots/storageLayout/NitroEnclaveVerifier.json +++ b/snapshots/storageLayout/NitroEnclaveVerifier.json @@ -54,5 +54,12 @@ "offset": 0, "slot": "7", "type": "mapping(enum ZkCoProcessorType => bytes32)" + }, + { + "bytes": "32", + "label": "revokedCerts", + "offset": 0, + "slot": "8", + "type": "mapping(bytes32 => bool)" } ] \ No newline at end of file diff --git a/src/L1/proofs/tee/NitroEnclaveVerifier.sol b/src/L1/proofs/tee/NitroEnclaveVerifier.sol index f6ad3ee9..94163b27 100644 --- a/src/L1/proofs/tee/NitroEnclaveVerifier.sol +++ b/src/L1/proofs/tee/NitroEnclaveVerifier.sol @@ -70,6 +70,19 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { /// @dev Mapping from ZkCoProcessorType to its corresponding verifierProofId representation mapping(ZkCoProcessorType => bytes32) private _verifierProofIds; + /// @dev Persistent revocation sentinel for intermediate certificates. + /// + /// `revokeCert` zeroes `trustedIntermediateCerts[certHash]`, but the suffix-cache + /// path in `_cacheNewCert` would otherwise restore that entry on the next + /// successful verification whose chain traverses the revoked hash. This + /// mapping survives `_cacheNewCert` overwrites and is consulted in + /// `_verifyJournal`, `_cacheNewCert`, and `checkTrustedIntermediateCerts`, + /// making `revokeCert` durable independently of the journal's `trustedCertsPrefixLen`. + /// + /// Re-trust requires an explicit `unrevokeCert` admin call; it is never + /// granted as a side effect of verification. + mapping(bytes32 => bool) public revokedCerts; + // ============ Custom Errors ============ /// @dev Error thrown when an unsupported or unknown ZK coprocessor type is used @@ -114,6 +127,9 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { /// @dev Thrown when caller is neither the owner nor the revoker error CallerNotOwnerOrRevoker(); + /// @dev Thrown when `unrevokeCert` is called for a hash that is not currently revoked + error CertificateNotRevoked(bytes32 certHash); + // ============ Events ============ /// @dev Emitted when a new verifier program ID is added/updated @@ -146,6 +162,9 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { /// @dev Event emitted when a certificate is revoked event CertRevoked(bytes32 certHash); + /// @dev Event emitted when a previously revoked certificate is explicitly re-trusted + event CertUnrevoked(bytes32 certHash); + /// @dev Event emitted when the maximum time difference is updated event MaxTimeDiffUpdated(uint64 newMaxTimeDiff); @@ -265,6 +284,11 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { revert RootCertMismatch(rootCertHash, certs[0]); } for (uint256 j = 1; j < certs.length; j++) { + // Stop counting at any revoked entry so off-chain callers cannot derive a + // prefix-len that walks past a revoked cert and then claim it as the trusted boundary. + if (revokedCerts[certs[j]]) { + break; + } uint64 expiry = trustedIntermediateCerts[certs[j]]; if (block.timestamp > expiry) { break; @@ -332,7 +356,7 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { } /** - * @dev Revokes a trusted intermediate certificate + * @dev Revokes a trusted intermediate certificate. * @param certHash Hash of the certificate to revoke * * Requirements: @@ -342,16 +366,46 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { * This function allows the owner or revoker to revoke compromised intermediate certificates * without affecting the root certificate or other trusted certificates. * - * Note: A revoked cert can be trusted again by reproving it. + * Durability: in addition to clearing `trustedIntermediateCerts[certHash]`, this + * function flips the persistent `revokedCerts[certHash]` sentinel. The sentinel + * survives subsequent `_cacheNewCert` overwrites and causes both `_verifyJournal` + * and `checkTrustedIntermediateCerts` to reject any chain whose suffix traverses + * the revoked hash, regardless of the journal's `trustedCertsPrefixLen`. Reproving + * the same chain therefore cannot silently restore trust; re-trust requires an + * explicit `unrevokeCert` call by the owner. */ function revokeCert(bytes32 certHash) external onlyOwnerOrRevoker { if (trustedIntermediateCerts[certHash] == 0) { revert CertificateNotFound(certHash); } delete trustedIntermediateCerts[certHash]; + revokedCerts[certHash] = true; emit CertRevoked(certHash); } + /** + * @dev Explicitly re-trusts a previously revoked intermediate certificate. + * @param certHash Hash of the certificate to un-revoke + * + * Requirements: + * - Only callable by contract owner + * - Certificate must currently be marked as revoked + * + * Clearing the revocation sentinel does not by itself restore the cached + * expiry; the next successful verification whose chain traverses `certHash` + * will re-cache it via `_cacheNewCert`. This two-step design (admin clears + * the sentinel, verification re-caches the expiry) keeps re-trust an + * explicit, owner-only action while still letting the normal cache path + * supply the up-to-date `notAfter` timestamp. + */ + function unrevokeCert(bytes32 certHash) external onlyOwner { + if (!revokedCerts[certHash]) { + revert CertificateNotRevoked(certHash); + } + delete revokedCerts[certHash]; + emit CertUnrevoked(certHash); + } + /** * @dev Updates the verifier program ID, adding the new version to the supported set * @param zkCoProcessor Type of ZK coprocessor @@ -570,10 +624,17 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { * This function automatically adds any certificates beyond the trusted length * to the trusted intermediate certificates set. This optimizes future verifications * by expanding the known trusted certificate set based on successful verifications. + * + * Revoked entries are skipped: once `revokedCerts[certHash]` is set by `revokeCert`, + * no successful verification will silently restore the cache, regardless of the + * journal's `trustedCertsPrefixLen`. Re-trust requires an explicit `unrevokeCert`. */ function _cacheNewCert(VerifierJournal memory journal) internal { for (uint256 i = journal.trustedCertsPrefixLen; i < journal.certs.length; i++) { bytes32 certHash = journal.certs[i]; + if (revokedCerts[certHash]) { + continue; + } trustedIntermediateCerts[certHash] = journal.certExpiries[i]; } } @@ -586,9 +647,19 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { * This function performs comprehensive validation: * 1. Checks if the initial ZK verification was successful * 2. Validates the root certificate matches the trusted root - * 3. Ensures all trusted certificates are still valid (not revoked) - * 4. Validates the attestation timestamp is within acceptable range - * 5. Caches newly discovered certificates for future use + * 3. Ensures all trusted certificates in the prefix are still valid (not revoked, not expired) + * 4. Ensures no certificate in the suffix has been revoked, regardless of `trustedCertsPrefixLen` + * 5. Validates the attestation timestamp is within acceptable range + * 6. Caches newly discovered certificates for future use + * + * The suffix-side revocation check (step 4) is the load-bearing fix for the + * `revokeCert` durability gap exposed under the production + * `trustedCertsPrefixLen = 1` configuration. Without it, Pass 1 only walks + * the root and a journal whose chain traverses a revoked intermediate in + * the suffix would succeed and then re-cache the revoked entry via + * `_cacheNewCert`. Rejecting any suffix entry present in `revokedCerts` + * makes revocation durable independently of the journal-supplied prefix + * length. * * The timestamp validation converts milliseconds to seconds and checks: * - Attestation is not too old (timestamp + maxTimeDiff > block.timestamp) @@ -605,7 +676,8 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { journal.result = VerificationResult.RootCertNotTrusted; return journal; } - // Check every trusted certificate to ensure none have been revoked + // Pass 1: trusted prefix — root must match the on-chain root, and every + // intermediate must still hold a non-expired cached entry. for (uint256 i = 0; i < journal.trustedCertsPrefixLen; i++) { bytes32 certHash = journal.certs[i]; if (i == 0) { @@ -615,14 +687,31 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { } continue; } + // `revokeCert` zeroes `trustedIntermediateCerts[certHash]`, so the + // expiry check below already catches a revoked cert reached through + // the prefix path. The explicit `revokedCerts` guard is retained as + // defense-in-depth against future code paths that might re-cache + // before this loop runs. + if (revokedCerts[certHash]) { + journal.result = VerificationResult.IntermediateCertsNotTrusted; + return journal; + } uint64 expiry = trustedIntermediateCerts[certHash]; if (block.timestamp > expiry) { journal.result = VerificationResult.IntermediateCertsNotTrusted; return journal; } } - // Check any remaining certificates in the chain that are not yet trusted + // Pass 2: suffix — journal-supplied expiries plus a hard reject on any + // cert that the operator has explicitly revoked. This is the path that + // closes the production `trustedCertsPrefixLen = 1` bypass: a revoked + // intermediate in the suffix can no longer pass verification and then + // be silently re-cached. for (uint256 i = journal.trustedCertsPrefixLen; i < journal.certs.length; i++) { + if (revokedCerts[journal.certs[i]]) { + journal.result = VerificationResult.IntermediateCertsNotTrusted; + return journal; + } uint64 expiry = journal.certExpiries[i]; if (block.timestamp > expiry) { journal.result = VerificationResult.InvalidTimestamp; @@ -702,8 +791,8 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver { } /// @notice Semantic version. - /// @custom:semver 0.3.0 + /// @custom:semver 0.4.0 function version() public pure virtual returns (string memory) { - return "0.3.0"; + return "0.4.0"; } } diff --git a/test/L1/proofs/NitroEnclaveVerifier.t.sol b/test/L1/proofs/NitroEnclaveVerifier.t.sol index 246c4d0f..5196445d 100644 --- a/test/L1/proofs/NitroEnclaveVerifier.t.sol +++ b/test/L1/proofs/NitroEnclaveVerifier.t.sol @@ -218,6 +218,198 @@ contract NitroEnclaveVerifierTest is Test { verifier.revokeCert(INTERMEDIATE_CERT_1); } + function testRevokeCertSetsDurableSentinel() public { + assertFalse(verifier.revokedCerts(INTERMEDIATE_CERT_1)); + verifier.revokeCert(INTERMEDIATE_CERT_1); + assertTrue(verifier.revokedCerts(INTERMEDIATE_CERT_1)); + } + + // ============ unrevokeCert Tests ============ + + function testUnrevokeCertClearsSentinel() public { + verifier.revokeCert(INTERMEDIATE_CERT_1); + assertTrue(verifier.revokedCerts(INTERMEDIATE_CERT_1)); + + vm.expectEmit(false, false, false, true); + emit NitroEnclaveVerifier.CertUnrevoked(INTERMEDIATE_CERT_1); + verifier.unrevokeCert(INTERMEDIATE_CERT_1); + + assertFalse(verifier.revokedCerts(INTERMEDIATE_CERT_1)); + // Cached expiry is intentionally not restored: the next successful + // verification re-caches it via _cacheNewCert with the journal-supplied + // notAfter timestamp. + assertEq(verifier.trustedIntermediateCerts(INTERMEDIATE_CERT_1), 0); + } + + function testUnrevokeCertRevertsIfNotRevoked() public { + vm.expectRevert( + abi.encodeWithSelector(NitroEnclaveVerifier.CertificateNotRevoked.selector, INTERMEDIATE_CERT_1) + ); + verifier.unrevokeCert(INTERMEDIATE_CERT_1); + } + + function testUnrevokeCertRevertsIfNotOwner() public { + verifier.revokeCert(INTERMEDIATE_CERT_1); + vm.prank(revokerAddr); + vm.expectRevert(); + verifier.unrevokeCert(INTERMEDIATE_CERT_1); + } + + function testUnrevokeCertThenReproveRestoresCache() public { + _setUpRiscZeroConfig(); + verifier.revokeCert(INTERMEDIATE_CERT_1); + verifier.unrevokeCert(INTERMEDIATE_CERT_1); + + // Submit a verification whose chain re-introduces INTERMEDIATE_CERT_1 + // in the suffix; _cacheNewCert should now restore the cached expiry + // because the sentinel is clear. + VerifierJournal memory journal = _createSuccessJournal(); + bytes32[] memory certs = new bytes32[](3); + certs[0] = ROOT_CERT; + certs[1] = INTERMEDIATE_CERT_1; // in the suffix (prefixLen = 1) + certs[2] = keccak256("leaf"); + journal.certs = certs; + + uint64[] memory expiries = new uint64[](3); + expiries[0] = INTERMEDIATE_CERT_1_EXPIRY + 100_000_000; + expiries[1] = INTERMEDIATE_CERT_1_EXPIRY; + expiries[2] = NEW_LEAF_CERT_EXPIRY; + journal.certExpiries = expiries; + journal.trustedCertsPrefixLen = 1; + + bytes memory output = abi.encode(journal); + bytes memory proofBytes = abi.encodePacked(bytes4(0), bytes32(0)); + _mockRiscZeroVerify(VERIFIER_ID, output, proofBytes); + + vm.prank(submitter); + VerifierJournal memory result = verifier.verify(output, ZkCoProcessorType.RiscZero, proofBytes); + + assertEq(uint8(result.result), uint8(VerificationResult.Success)); + assertEq(verifier.trustedIntermediateCerts(INTERMEDIATE_CERT_1), INTERMEDIATE_CERT_1_EXPIRY); + } + + // ============ Durable Revocation: production prefixLen = 1 bypass ============ + + /// Reproduces the Immunefi #75608 attack shape: with the production + /// `trustedCertsPrefixLen = 1`, a chain whose revoked intermediate sits in + /// the suffix would previously pass `_verifyJournal` and be silently + /// re-cached by `_cacheNewCert`. The suffix-side `revokedCerts` guard now + /// rejects the verification and leaves the cache zeroed. + function testVerifyRejectsRevokedCertInSuffixUnderProductionPrefixLen() public { + _setUpRiscZeroConfig(); + verifier.revokeCert(INTERMEDIATE_CERT_1); + + VerifierJournal memory journal = _createSuccessJournal(); + bytes32[] memory certs = new bytes32[](3); + certs[0] = ROOT_CERT; + certs[1] = INTERMEDIATE_CERT_1; // revoked, lives in the suffix + certs[2] = keccak256("attacker-leaf"); + journal.certs = certs; + + uint64[] memory expiries = new uint64[](3); + expiries[0] = INTERMEDIATE_CERT_1_EXPIRY + 100_000_000; + expiries[1] = INTERMEDIATE_CERT_1_EXPIRY; + expiries[2] = NEW_LEAF_CERT_EXPIRY; + journal.certExpiries = expiries; + journal.trustedCertsPrefixLen = 1; // production default — only root in prefix + + bytes memory output = abi.encode(journal); + bytes memory proofBytes = abi.encodePacked(bytes4(0), bytes32(0)); + _mockRiscZeroVerify(VERIFIER_ID, output, proofBytes); + + vm.prank(submitter); + VerifierJournal memory result = verifier.verify(output, ZkCoProcessorType.RiscZero, proofBytes); + + assertEq(uint8(result.result), uint8(VerificationResult.IntermediateCertsNotTrusted)); + // Cache must remain zeroed — _cacheNewCert never ran. + assertEq(verifier.trustedIntermediateCerts(INTERMEDIATE_CERT_1), 0); + // And the durable sentinel must still be set. + assertTrue(verifier.revokedCerts(INTERMEDIATE_CERT_1)); + } + + /// Direct exercise of `_cacheNewCert`'s revocation skip: a verification + /// where the suffix contains both a revoked entry and an unrelated new cert + /// should leave the revoked cache zeroed but still cache the new cert. + /// Drives this through verify() because _cacheNewCert is internal. + function testCacheNewCertSkipsRevokedEntries() public { + _setUpRiscZeroConfig(); + bytes32 freshCert = keccak256("fresh-intermediate"); + + // Seed: cache INTERMEDIATE_CERT_2 by running a successful verification + // whose chain passes through it. + _seedIntermediateCert2(); + + // Revoke INTERMEDIATE_CERT_2, then submit a journal whose suffix re-presents + // it alongside `freshCert`. The verification must reject (because the suffix + // contains a revoked entry), and neither cache rewrite must happen. + verifier.revokeCert(INTERMEDIATE_CERT_2); + + VerifierJournal memory result = _verifySuffixWithRevokedAndFresh(freshCert); + + assertEq(uint8(result.result), uint8(VerificationResult.IntermediateCertsNotTrusted)); + assertEq(verifier.trustedIntermediateCerts(INTERMEDIATE_CERT_2), 0); + assertEq(verifier.trustedIntermediateCerts(freshCert), 0); + } + + function _seedIntermediateCert2() private { + VerifierJournal memory j = _createSuccessJournal(); + bytes32[] memory c = new bytes32[](3); + c[0] = ROOT_CERT; + c[1] = INTERMEDIATE_CERT_1; + c[2] = INTERMEDIATE_CERT_2; + j.certs = c; + uint64[] memory e = new uint64[](3); + e[0] = INTERMEDIATE_CERT_1_EXPIRY + 100_000_000; + e[1] = INTERMEDIATE_CERT_1_EXPIRY; + e[2] = INTERMEDIATE_CERT_2_EXPIRY; + j.certExpiries = e; + j.trustedCertsPrefixLen = 2; + + bytes memory output = abi.encode(j); + bytes memory proofBytes = abi.encodePacked(bytes4(0), bytes32(0)); + _mockRiscZeroVerify(VERIFIER_ID, output, proofBytes); + vm.prank(submitter); + verifier.verify(output, ZkCoProcessorType.RiscZero, proofBytes); + assertEq(verifier.trustedIntermediateCerts(INTERMEDIATE_CERT_2), INTERMEDIATE_CERT_2_EXPIRY); + } + + function _verifySuffixWithRevokedAndFresh(bytes32 freshCert) private returns (VerifierJournal memory) { + VerifierJournal memory j = _createSuccessJournal(); + bytes32[] memory c = new bytes32[](4); + c[0] = ROOT_CERT; + c[1] = INTERMEDIATE_CERT_1; + c[2] = INTERMEDIATE_CERT_2; // revoked + c[3] = freshCert; + j.certs = c; + uint64[] memory e = new uint64[](4); + e[0] = INTERMEDIATE_CERT_1_EXPIRY + 100_000_000; + e[1] = INTERMEDIATE_CERT_1_EXPIRY; + e[2] = INTERMEDIATE_CERT_2_EXPIRY; + e[3] = uint64(REALISTIC_TIMESTAMP + 86_400); + j.certExpiries = e; + j.trustedCertsPrefixLen = 2; + + bytes memory output = abi.encode(j); + bytes memory proofBytes = abi.encodePacked(bytes4(0), bytes32(0)); + _mockRiscZeroVerify(VERIFIER_ID, output, proofBytes); + vm.prank(submitter); + return verifier.verify(output, ZkCoProcessorType.RiscZero, proofBytes); + } + + function testCheckTrustedIntermediateCertsBreaksAtRevokedEntry() public { + // INTERMEDIATE_CERT_1 is initially trusted; revoke it and confirm the + // off-chain helper no longer counts it. + verifier.revokeCert(INTERMEDIATE_CERT_1); + + bytes32[][] memory reportCerts = new bytes32[][](1); + reportCerts[0] = new bytes32[](2); + reportCerts[0][0] = ROOT_CERT; + reportCerts[0][1] = INTERMEDIATE_CERT_1; // revoked + + uint8[] memory results = verifier.checkTrustedIntermediateCerts(reportCerts); + assertEq(results[0], 1); + } + // ============ Revoker Role Tests ============ function testConstructorSetsRevoker() public view {