Skip to content

Commit 47c7dbe

Browse files
authored
feat(NitroEnclaveVerifier): durable on-chain certificate revocation [non-urgent, post-Multiproving] (#276)
* feat(NitroEnclaveVerifier): add durable on-chain certificate revocation Defense-in-depth hardening for CHAIN-4194 / Immunefi #75608. Adds a persistent revokedCerts mapping that survives the _cacheNewCert suffix rewrite, so a previously revoked intermediate cannot be silently re-trusted by submitting a new attestation that re-derives the same accumulated path digest. Not urgent. Can ship post-Multiproving launch (Base V2 upgrade) since multiproof is the primary mitigation for any single verifier gap; this PR closes a defense-in-depth hole that requires either CA key compromise (Tier B) or an honest registrar/admin race (Tier A). Bumps semver to 0.4.0 (additive). * fix(NitroEnclaveVerifier): break instead of continue on revoked cert in _cacheNewCert Addresses review feedback: descendants of a revoked cert inherit trust from a revoked parent and must not be cached either. Switches the guard from `continue` to `break` and aligns with `checkTrustedIntermediateCerts`, which already uses `break` on the same condition. In current control flow this guard is unreachable (`_verifyJournal` Pass 2 already rejects journals whose suffix contains a revoked digest before `_cacheNewCert` runs), so this is a defense-in-depth / correctness fix with no functional impact today. * fix: regenerate semver-lock with ci profile The previous commit's semver-lock was regenerated with stale artifact cache (test build had polluted the build dir), producing an initCodeHash that didn't match what CI computes. Regenerated under FOUNDRY_PROFILE=ci to match the CI validation step.
1 parent f834bed commit 47c7dbe

6 files changed

Lines changed: 405 additions & 12 deletions

File tree

interfaces/L1/proofs/tee/INitroEnclaveVerifier.sol

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,26 @@ interface INitroEnclaveVerifier {
167167
*/
168168
function revoker() external view returns (address);
169169

170+
/**
171+
* @dev Returns whether the given intermediate certificate hash has been revoked.
172+
* @param _certHash Hash of the certificate
173+
* @return `true` if the certificate is currently marked as revoked
174+
*
175+
* The revocation sentinel is persistent across `_cacheNewCert` overwrites and
176+
* blocks both verification (via `_verifyJournal`) and the off-chain
177+
* `checkTrustedIntermediateCerts` helper from re-trusting the hash. Re-trust
178+
* requires an explicit `unrevokeCert` call.
179+
*/
180+
function revokedCerts(bytes32 _certHash) external view returns (bool);
181+
182+
/**
183+
* @dev Returns the cached `notAfter` timestamp (seconds) for an intermediate certificate.
184+
* @param _certHash Hash of the certificate
185+
* @return Cached expiry timestamp; `0` indicates the certificate is not currently
186+
* cached (either never seen, expired-and-evicted, or revoked).
187+
*/
188+
function trustedIntermediateCerts(bytes32 _certHash) external view returns (uint64);
189+
170190
/**
171191
* @dev Retrieves the configuration for a specific coprocessor
172192
* @param _zkCoProcessor Type of ZK coprocessor (RiscZero or Succinct)
@@ -261,15 +281,36 @@ interface INitroEnclaveVerifier {
261281
external;
262282

263283
/**
264-
* @dev Revokes a trusted intermediate certificate
284+
* @dev Revokes a trusted intermediate certificate.
265285
* @param _certHash Hash of the certificate to revoke
266286
*
267287
* Requirements:
268288
* - Only callable by contract owner or revoker
269289
* - Certificate must exist in the trusted set
290+
*
291+
* In addition to clearing the cached entry, this flips a persistent
292+
* revocation sentinel that survives later cache writes. Subsequent
293+
* verifications whose chain traverses the revoked hash are rejected
294+
* regardless of the journal-supplied `trustedCertsPrefixLen`. Re-trust
295+
* requires an explicit `unrevokeCert` call.
270296
*/
271297
function revokeCert(bytes32 _certHash) external;
272298

299+
/**
300+
* @dev Explicitly re-trusts a previously revoked intermediate certificate.
301+
* @param _certHash Hash of the certificate to un-revoke
302+
*
303+
* Requirements:
304+
* - Only callable by contract owner
305+
* - Certificate must currently be marked as revoked
306+
*
307+
* Clears the persistent revocation sentinel. The cached expiry is not
308+
* restored here; the next successful verification whose chain traverses
309+
* `_certHash` will re-cache it via `_cacheNewCert` with the journal-supplied
310+
* `notAfter` timestamp.
311+
*/
312+
function unrevokeCert(bytes32 _certHash) external;
313+
273314
/**
274315
* @dev Updates the verifier program ID, adding the new version to the supported set
275316
* @param _zkCoProcessor Type of ZK coprocessor

snapshots/abi/NitroEnclaveVerifier.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,25 @@
421421
"stateMutability": "nonpayable",
422422
"type": "function"
423423
},
424+
{
425+
"inputs": [
426+
{
427+
"internalType": "bytes32",
428+
"name": "",
429+
"type": "bytes32"
430+
}
431+
],
432+
"name": "revokedCerts",
433+
"outputs": [
434+
{
435+
"internalType": "bool",
436+
"name": "",
437+
"type": "bool"
438+
}
439+
],
440+
"stateMutability": "view",
441+
"type": "function"
442+
},
424443
{
425444
"inputs": [],
426445
"name": "revoker",
@@ -571,6 +590,19 @@
571590
"stateMutability": "view",
572591
"type": "function"
573592
},
593+
{
594+
"inputs": [
595+
{
596+
"internalType": "bytes32",
597+
"name": "certHash",
598+
"type": "bytes32"
599+
}
600+
],
601+
"name": "unrevokeCert",
602+
"outputs": [],
603+
"stateMutability": "nonpayable",
604+
"type": "function"
605+
},
574606
{
575607
"inputs": [
576608
{
@@ -841,6 +873,19 @@
841873
"name": "CertRevoked",
842874
"type": "event"
843875
},
876+
{
877+
"anonymous": false,
878+
"inputs": [
879+
{
880+
"indexed": false,
881+
"internalType": "bytes32",
882+
"name": "certHash",
883+
"type": "bytes32"
884+
}
885+
],
886+
"name": "CertUnrevoked",
887+
"type": "event"
888+
},
844889
{
845890
"anonymous": false,
846891
"inputs": [
@@ -1091,6 +1136,17 @@
10911136
"name": "CertificateNotFound",
10921137
"type": "error"
10931138
},
1139+
{
1140+
"inputs": [
1141+
{
1142+
"internalType": "bytes32",
1143+
"name": "certHash",
1144+
"type": "bytes32"
1145+
}
1146+
],
1147+
"name": "CertificateNotRevoked",
1148+
"type": "error"
1149+
},
10941150
{
10951151
"inputs": [],
10961152
"name": "InvalidVerifierAddress",

snapshots/semver-lock.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
"sourceCodeHash": "0xf6ca679ff7463ade68e1ad1ae49f5b406e0c723ec1a2a718828f6bec12c8e8a6"
4545
},
4646
"src/L1/proofs/tee/NitroEnclaveVerifier.sol:NitroEnclaveVerifier": {
47-
"initCodeHash": "0x82f42b4d578bfcf9dc35eaa2c4ada04a45e1eca63021bceceb2ec794b12a9dd6",
48-
"sourceCodeHash": "0x5b2938048ff85baceb963c54138ce209890d77c63ce8791b48f36c5fda5c81e5"
47+
"initCodeHash": "0x1be3b9418c022094fb60ea68d025910c69e95c7a2314bf1b57c740f601b7cb0e",
48+
"sourceCodeHash": "0xa0bb07f71960cda20c3b3a46c8cc24842f991c00469a9e90ce8d9a1c26e7a8d6"
4949
},
5050
"src/L1/proofs/tee/TEEProverRegistry.sol:TEEProverRegistry": {
5151
"initCodeHash": "0xfd1942e1c2f59b0aa72b33d698a948a53b6e4cf1040106f173fb5d89f63f57b0",

snapshots/storageLayout/NitroEnclaveVerifier.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,12 @@
5454
"offset": 0,
5555
"slot": "7",
5656
"type": "mapping(enum ZkCoProcessorType => bytes32)"
57+
},
58+
{
59+
"bytes": "32",
60+
"label": "revokedCerts",
61+
"offset": 0,
62+
"slot": "8",
63+
"type": "mapping(bytes32 => bool)"
5764
}
5865
]

src/L1/proofs/tee/NitroEnclaveVerifier.sol

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
7070
/// @dev Mapping from ZkCoProcessorType to its corresponding verifierProofId representation
7171
mapping(ZkCoProcessorType => bytes32) private _verifierProofIds;
7272

73+
/// @dev Persistent revocation sentinel for intermediate certificates.
74+
///
75+
/// `revokeCert` zeroes `trustedIntermediateCerts[certHash]`, but the suffix-cache
76+
/// path in `_cacheNewCert` would otherwise restore that entry on the next
77+
/// successful verification whose chain traverses the revoked hash. This
78+
/// mapping survives `_cacheNewCert` overwrites and is consulted in
79+
/// `_verifyJournal`, `_cacheNewCert`, and `checkTrustedIntermediateCerts`,
80+
/// making `revokeCert` durable independently of the journal's `trustedCertsPrefixLen`.
81+
///
82+
/// Re-trust requires an explicit `unrevokeCert` admin call; it is never
83+
/// granted as a side effect of verification.
84+
mapping(bytes32 => bool) public revokedCerts;
85+
7386
// ============ Custom Errors ============
7487

7588
/// @dev Error thrown when an unsupported or unknown ZK coprocessor type is used
@@ -114,6 +127,9 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
114127
/// @dev Thrown when caller is neither the owner nor the revoker
115128
error CallerNotOwnerOrRevoker();
116129

130+
/// @dev Thrown when `unrevokeCert` is called for a hash that is not currently revoked
131+
error CertificateNotRevoked(bytes32 certHash);
132+
117133
// ============ Events ============
118134

119135
/// @dev Emitted when a new verifier program ID is added/updated
@@ -146,6 +162,9 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
146162
/// @dev Event emitted when a certificate is revoked
147163
event CertRevoked(bytes32 certHash);
148164

165+
/// @dev Event emitted when a previously revoked certificate is explicitly re-trusted
166+
event CertUnrevoked(bytes32 certHash);
167+
149168
/// @dev Event emitted when the maximum time difference is updated
150169
event MaxTimeDiffUpdated(uint64 newMaxTimeDiff);
151170

@@ -265,6 +284,11 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
265284
revert RootCertMismatch(rootCertHash, certs[0]);
266285
}
267286
for (uint256 j = 1; j < certs.length; j++) {
287+
// Stop counting at any revoked entry so off-chain callers cannot derive a
288+
// prefix-len that walks past a revoked cert and then claim it as the trusted boundary.
289+
if (revokedCerts[certs[j]]) {
290+
break;
291+
}
268292
uint64 expiry = trustedIntermediateCerts[certs[j]];
269293
if (block.timestamp > expiry) {
270294
break;
@@ -332,7 +356,7 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
332356
}
333357

334358
/**
335-
* @dev Revokes a trusted intermediate certificate
359+
* @dev Revokes a trusted intermediate certificate.
336360
* @param certHash Hash of the certificate to revoke
337361
*
338362
* Requirements:
@@ -342,16 +366,46 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
342366
* This function allows the owner or revoker to revoke compromised intermediate certificates
343367
* without affecting the root certificate or other trusted certificates.
344368
*
345-
* Note: A revoked cert can be trusted again by reproving it.
369+
* Durability: in addition to clearing `trustedIntermediateCerts[certHash]`, this
370+
* function flips the persistent `revokedCerts[certHash]` sentinel. The sentinel
371+
* survives subsequent `_cacheNewCert` overwrites and causes both `_verifyJournal`
372+
* and `checkTrustedIntermediateCerts` to reject any chain whose suffix traverses
373+
* the revoked hash, regardless of the journal's `trustedCertsPrefixLen`. Reproving
374+
* the same chain therefore cannot silently restore trust; re-trust requires an
375+
* explicit `unrevokeCert` call by the owner.
346376
*/
347377
function revokeCert(bytes32 certHash) external onlyOwnerOrRevoker {
348378
if (trustedIntermediateCerts[certHash] == 0) {
349379
revert CertificateNotFound(certHash);
350380
}
351381
delete trustedIntermediateCerts[certHash];
382+
revokedCerts[certHash] = true;
352383
emit CertRevoked(certHash);
353384
}
354385

386+
/**
387+
* @dev Explicitly re-trusts a previously revoked intermediate certificate.
388+
* @param certHash Hash of the certificate to un-revoke
389+
*
390+
* Requirements:
391+
* - Only callable by contract owner
392+
* - Certificate must currently be marked as revoked
393+
*
394+
* Clearing the revocation sentinel does not by itself restore the cached
395+
* expiry; the next successful verification whose chain traverses `certHash`
396+
* will re-cache it via `_cacheNewCert`. This two-step design (admin clears
397+
* the sentinel, verification re-caches the expiry) keeps re-trust an
398+
* explicit, owner-only action while still letting the normal cache path
399+
* supply the up-to-date `notAfter` timestamp.
400+
*/
401+
function unrevokeCert(bytes32 certHash) external onlyOwner {
402+
if (!revokedCerts[certHash]) {
403+
revert CertificateNotRevoked(certHash);
404+
}
405+
delete revokedCerts[certHash];
406+
emit CertUnrevoked(certHash);
407+
}
408+
355409
/**
356410
* @dev Updates the verifier program ID, adding the new version to the supported set
357411
* @param zkCoProcessor Type of ZK coprocessor
@@ -570,10 +624,25 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
570624
* This function automatically adds any certificates beyond the trusted length
571625
* to the trusted intermediate certificates set. This optimizes future verifications
572626
* by expanding the known trusted certificate set based on successful verifications.
627+
*
628+
* Revoked entries terminate caching: once `revokedCerts[certHash]` is set by
629+
* `revokeCert`, no successful verification will silently restore the cache,
630+
* regardless of the journal's `trustedCertsPrefixLen`. Because `certs[i+1]` is
631+
* signed by `certs[i]`, every descendant of a revoked cert inherits its trust
632+
* from a revoked parent and must not be cached either — so we `break` rather
633+
* than `continue` on the first revoked entry, matching `checkTrustedIntermediateCerts`.
634+
*
635+
* Note: in current control flow this guard is unreachable because `_verifyJournal`
636+
* Pass 2 already rejects any journal whose suffix contains a revoked digest before
637+
* `_cacheNewCert` is invoked. The check is retained as defense-in-depth against
638+
* future refactors. Re-trust requires an explicit `unrevokeCert`.
573639
*/
574640
function _cacheNewCert(VerifierJournal memory journal) internal {
575641
for (uint256 i = journal.trustedCertsPrefixLen; i < journal.certs.length; i++) {
576642
bytes32 certHash = journal.certs[i];
643+
if (revokedCerts[certHash]) {
644+
break;
645+
}
577646
trustedIntermediateCerts[certHash] = journal.certExpiries[i];
578647
}
579648
}
@@ -586,9 +655,19 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
586655
* This function performs comprehensive validation:
587656
* 1. Checks if the initial ZK verification was successful
588657
* 2. Validates the root certificate matches the trusted root
589-
* 3. Ensures all trusted certificates are still valid (not revoked)
590-
* 4. Validates the attestation timestamp is within acceptable range
591-
* 5. Caches newly discovered certificates for future use
658+
* 3. Ensures all trusted certificates in the prefix are still valid (not revoked, not expired)
659+
* 4. Ensures no certificate in the suffix has been revoked, regardless of `trustedCertsPrefixLen`
660+
* 5. Validates the attestation timestamp is within acceptable range
661+
* 6. Caches newly discovered certificates for future use
662+
*
663+
* The suffix-side revocation check (step 4) is the load-bearing fix for the
664+
* `revokeCert` durability gap exposed under the production
665+
* `trustedCertsPrefixLen = 1` configuration. Without it, Pass 1 only walks
666+
* the root and a journal whose chain traverses a revoked intermediate in
667+
* the suffix would succeed and then re-cache the revoked entry via
668+
* `_cacheNewCert`. Rejecting any suffix entry present in `revokedCerts`
669+
* makes revocation durable independently of the journal-supplied prefix
670+
* length.
592671
*
593672
* The timestamp validation converts milliseconds to seconds and checks:
594673
* - Attestation is not too old (timestamp + maxTimeDiff > block.timestamp)
@@ -605,7 +684,8 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
605684
journal.result = VerificationResult.RootCertNotTrusted;
606685
return journal;
607686
}
608-
// Check every trusted certificate to ensure none have been revoked
687+
// Pass 1: trusted prefix — root must match the on-chain root, and every
688+
// intermediate must still hold a non-expired cached entry.
609689
for (uint256 i = 0; i < journal.trustedCertsPrefixLen; i++) {
610690
bytes32 certHash = journal.certs[i];
611691
if (i == 0) {
@@ -615,14 +695,31 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
615695
}
616696
continue;
617697
}
698+
// `revokeCert` zeroes `trustedIntermediateCerts[certHash]`, so the
699+
// expiry check below already catches a revoked cert reached through
700+
// the prefix path. The explicit `revokedCerts` guard is retained as
701+
// defense-in-depth against future code paths that might re-cache
702+
// before this loop runs.
703+
if (revokedCerts[certHash]) {
704+
journal.result = VerificationResult.IntermediateCertsNotTrusted;
705+
return journal;
706+
}
618707
uint64 expiry = trustedIntermediateCerts[certHash];
619708
if (block.timestamp > expiry) {
620709
journal.result = VerificationResult.IntermediateCertsNotTrusted;
621710
return journal;
622711
}
623712
}
624-
// Check any remaining certificates in the chain that are not yet trusted
713+
// Pass 2: suffix — journal-supplied expiries plus a hard reject on any
714+
// cert that the operator has explicitly revoked. This is the path that
715+
// closes the production `trustedCertsPrefixLen = 1` bypass: a revoked
716+
// intermediate in the suffix can no longer pass verification and then
717+
// be silently re-cached.
625718
for (uint256 i = journal.trustedCertsPrefixLen; i < journal.certs.length; i++) {
719+
if (revokedCerts[journal.certs[i]]) {
720+
journal.result = VerificationResult.IntermediateCertsNotTrusted;
721+
return journal;
722+
}
626723
uint64 expiry = journal.certExpiries[i];
627724
if (block.timestamp > expiry) {
628725
journal.result = VerificationResult.InvalidTimestamp;
@@ -702,8 +799,8 @@ contract NitroEnclaveVerifier is Ownable, INitroEnclaveVerifier, ISemver {
702799
}
703800

704801
/// @notice Semantic version.
705-
/// @custom:semver 0.3.0
802+
/// @custom:semver 0.4.0
706803
function version() public pure virtual returns (string memory) {
707-
return "0.3.0";
804+
return "0.4.0";
708805
}
709806
}

0 commit comments

Comments
 (0)