Skip to content

Commit d681aa9

Browse files
authored
Fix certificate cache key malleability (#35)
* fix: canonicalize certificate cache keys Key non-root certificate verification cache entries by the signed tbsCertificate instead of the full DER bytes so ECDSA signature malleability cannot create shadow cache identities. Keep the pinned root under ROOT_CA_CERT_HASH and reject malleable aliases of that trust anchor. Add regression coverage for malleated certificate signatures and update docs/scripts to use the returned cache key. * fix: use canonical cert ids in hint plans
1 parent bc53b17 commit d681aa9

9 files changed

Lines changed: 250 additions & 38 deletions

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ own document signature.
5353
`ROOT_CA_CERT_HASH` / `keccak256(rootCert)` for the first non-root CA; `0` is only for the
5454
pinned root itself)
5555
- `certManager.verifyClientCertWithHints(leafCert, parentCertHash, hints)`
56+
Use each CA verification's returned hash as the `parentCertHash` for the next child. For non-root
57+
certs this cache key is `keccak256(tbsCertificate)`, not `keccak256(certBytes)`, so malleable
58+
signature encodings share one cache entry. Leaf verification returns metadata, not a child
59+
`parentCertHash`.
5660
2. **Validation — verify the document signature.** Once the chain is cached:
5761
- `validator.validateAttestationWithHints(attestationTbs, signature, attestationHints)`
5862
- **Precondition:** the whole `cabundle` + leaf must already be cached from phase 1. This call
@@ -93,8 +97,8 @@ contains a revoked certificate.
9397
`ROOT_CA_CERT_HASH` as an emergency global halt (the root is identified by its pinned hash, since
9498
it is never parsed on-chain).
9599
- The revoker can call `revokeCert` or `revokeCerts` for non-root certificate identity keys.
96-
- `loadVerified` is a raw cache read; returned metadata does not imply the certificate is
97-
currently trusted.
100+
- `loadVerified` is a raw cache read by verification cache key; returned metadata does not imply
101+
the certificate is currently trusted.
98102

99103
### Example consumer
100104

@@ -164,7 +168,8 @@ integrator (see [docs](docs/hinted-p384-nitro-attestation.md#integrator-responsi
164168
to `block.timestamp` (seconds) or match the `nonce` to a challenge. Enforce freshness yourself if
165169
you need it.
166170
- **Signature malleability** — low-S is not enforced (AWS does not emit low-S), so the malleable
167-
twin `(r, n-s)` also verifies. Never use the signature as a uniqueness key; dedupe on canonical
171+
twin `(r, n-s)` also verifies. Certificate caching excludes the outer signature bytes, but
172+
integrators must still never use attestation signatures as uniqueness keys; dedupe on canonical
168173
attestation fields instead.
169174
- **Enclave policy** — checking `pcrs` / `moduleID` against the enclave image(s) you trust is your
170175
responsibility.

docs/hinted-p384-nitro-attestation.md

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -361,8 +361,9 @@ document signature.
361361
Once the leaf and its CA chain are cached and unexpired, a later attestation signed by
362362
the same leaf is a **single transaction** (`validateAttestationWithHints`) carrying
363363
only the COSE signature hints. The cabundle certs are not re-verified — they are
364-
reloaded by `keccak256(cert)` identity, checked against their original cached parent,
365-
and their cached metadata is re-checked.
364+
reloaded by verification cache key (`ROOT_CA_CERT_HASH` for the pinned root,
365+
`keccak256(tbsCertificate)` for non-root certs), checked against their original cached
366+
parent, and their cached metadata is re-checked.
366367

367368
Practical reuse cases:
368369

@@ -371,12 +372,12 @@ Practical reuse cases:
371372
document → 2 transactions;
372373
- **CA chain and leaf cached:** validate the document only → 1 transaction.
373374

374-
**Cache reuse** is allowed when: the submitted DER hashes to a cached cert; the cert
375-
is unexpired (`notAfter ≥ block.timestamp`); the cached CA/client role matches; and
376-
`parentCertHash` matches the parent used during cold verification; and neither the cert
377-
nor its cached parent chain is revoked. The cache is global on-chain state — once any
378-
caller verifies a cert, others reuse it until expiry or revocation, but only under the
379-
same parent binding.
375+
**Cache reuse** is allowed when: the submitted DER contains a TBS that hashes to a
376+
cached cert; the cert is unexpired (`notAfter ≥ block.timestamp`); the cached CA/client
377+
role matches; `parentCertHash` matches the parent used during cold verification; and
378+
neither the cert nor its cached parent chain is revoked. The cache is global on-chain
379+
state — once any caller verifies a cert, others reuse it until expiry or revocation,
380+
but only under the same parent binding.
380381

381382
### Revocation model
382383
AWS's Nitro attestation documentation disables CRL checking in its sample validation
@@ -410,23 +411,24 @@ cached descendant cannot keep verifying through an ancestor that was later revok
410411
Revocation is checked independently of `notAfter`, so a revoked cert is untrusted even if
411412
its X.509 validity period has not expired.
412413

413-
`loadVerified` is intentionally a raw cache read. A non-empty return value means the cert
414-
metadata was cached previously; it does not imply the cert is currently trusted, unexpired,
415-
or unrevoked.
414+
`loadVerified` is intentionally a raw cache read by verification cache key. A non-empty
415+
return value means the cert metadata was cached previously; it does not imply the cert is
416+
currently trusted, unexpired, or unrevoked.
416417

417418
**First-verified parent pinning.** A cached cert is pinned to the parent it was first
418-
verified under: cold verification records `verifiedParent[certHash]` once, and every
419-
later warm reuse requires the caller to present that exact `parentCertHash` (a mismatch
420-
reverts with `parent cert mismatch`). This is a deliberate, conservative binding — warm
421-
reuse skips signature verification, so it must reflect the precise chain that was
422-
cryptographically checked, not merely *a* valid same-subject issuer. The liveness
423-
consequence is that if the same certificate is genuinely issued under two different CA
424-
objects (for example a same-key CA renewal that produces new DER bytes, hence a new
425-
parent hash), the cached leaf keeps verifying only through its first parent; a second
426-
caller chaining it through the renewed parent must wait for the cached entry to expire.
427-
For AWS Nitro this is effectively a non-issue because leaf certificates are short-lived
428-
(~3h) and expire long before their issuing CA is rotated, so the binding self-heals; it
429-
is documented here as a known edge rather than a fixed bug.
419+
verified under: cold verification records `verifiedParent[certHash]` once, where
420+
`certHash` is the canonical cache key, and every later warm reuse requires the caller to
421+
present that exact `parentCertHash` (a mismatch reverts with `parent cert mismatch`).
422+
This is a deliberate, conservative binding — warm reuse skips signature verification,
423+
so it must reflect the precise chain that was cryptographically checked, not merely *a*
424+
valid same-subject issuer. The liveness consequence is that if the same certificate is
425+
genuinely issued under two different CA objects (for example a same-key CA renewal that
426+
produces a new TBS, hence a new parent hash), the cached leaf keeps verifying only
427+
through its first parent; a second caller chaining it through the renewed parent must
428+
wait for the cached entry to expire. For AWS Nitro this is effectively a non-issue
429+
because leaf certificates are short-lived (~3h) and expire long before their issuing CA
430+
is rotated, so the binding self-heals; it is documented here as a known edge rather than
431+
a fixed bug.
430432

431433
**Warm-only guard.** `validateAttestationWithHints` re-runs the cabundle checks with an
432434
*empty* hint stream. Cached certs return before signature verification; a missing cert

script/BaseSepoliaDemo.s.sol

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pragma solidity ^0.8.26;
33

44
import {Script, console2} from "forge-std/Script.sol";
5+
import {Asn1Decode, LibAsn1Ptr, Asn1Ptr} from "../src/Asn1Decode.sol";
56
import {CborDecode, CborElement, LibCborElement} from "../src/CborDecode.sol";
67
import {CertManagerDemo} from "../test/helpers/CertManagerDemo.sol";
78
import {ICertManager} from "../src/ICertManager.sol";
@@ -20,7 +21,9 @@ contract NitroValidatorScriptParser is NitroValidator {
2021

2122
/// @dev Uses vm.ffi to run the off-chain hint tools; invoke the script with Foundry's `--ffi` flag.
2223
contract BaseSepoliaDemo is Script {
24+
using Asn1Decode for bytes;
2325
using CborDecode for bytes;
26+
using LibAsn1Ptr for Asn1Ptr;
2427
using LibBytes for bytes;
2528
using LibCborElement for CborElement;
2629

@@ -97,7 +100,13 @@ contract BaseSepoliaDemo is Script {
97100
leaf = certManager.verifyClientCertWithHints(clientCert, parentHash, clientHints);
98101
require(leaf.pubKey.length > 0, "leaf not cached");
99102
console2.log("cached client cert");
100-
console2.logBytes32(keccak256(clientCert));
103+
console2.logBytes32(_certCacheKey(clientCert));
104+
}
105+
106+
function _certCacheKey(bytes memory certificate) internal pure returns (bytes32) {
107+
Asn1Ptr root = certificate.root();
108+
Asn1Ptr tbsCertPtr = certificate.firstChildOf(root);
109+
return certificate.keccak(tbsCertPtr.header(), tbsCertPtr.totalLength());
101110
}
102111

103112
function _loadAttestation() internal returns (bytes memory) {

src/CertManager.sol

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ contract CertManager is ICertManager {
4848
bytes32 public constant BASIC_CONSTRAINTS_OID = 0x6351d72a43cb42fb9a2531a28608c278c89629f8f025b5f5dc705f3fe45e950a; // keccak256(hex"551d13")
4949
bytes32 public constant KEY_USAGE_OID = 0x45529d8772b07ebd6d507a1680da791f4a2192882bf89d518801579f7a5167d2; // keccak256(hex"551d0f")
5050

51-
// certHash -> VerifiedCert
51+
// certHash -> VerifiedCert. The root is keyed by ROOT_CA_CERT_HASH; every non-root cert is keyed
52+
// by keccak256(tbsCertificate), excluding the outer malleable ECDSA signature bytes.
5253
mapping(bytes32 => bytes) public verified;
5354
// certHash -> parent cert hash used during cold verification.
5455
// A cached cert is pinned to the parent it was FIRST verified under: warm reuse requires the
@@ -120,12 +121,13 @@ contract CertManager is ICertManager {
120121
/// match the parent used during cold verification. On a cold cert, `signatureHints` must
121122
/// contain the real off-chain inverse hints for the cert signature; they are re-verified
122123
/// on-chain, so a wrong hint only reverts. Pass 0 only when submitting the pinned root;
123-
/// otherwise pass the cached parent cert hash.
124+
/// otherwise pass the cached parent cert hash. The returned hash is ROOT_CA_CERT_HASH for
125+
/// the pinned root and keccak256(tbsCertificate) for every non-root cert.
124126
function verifyCACertWithHints(bytes memory cert, bytes32 parentCertHash, bytes memory signatureHints)
125127
external
126128
returns (bytes32)
127129
{
128-
bytes32 certHash = keccak256(cert);
130+
bytes32 certHash = _certCacheKey(cert);
129131
_verifyCert(cert, certHash, true, parentCertHash, signatureHints);
130132
return certHash;
131133
}
@@ -138,12 +140,13 @@ contract CertManager is ICertManager {
138140
external
139141
returns (VerifiedCert memory)
140142
{
141-
return _verifyCert(cert, keccak256(cert), false, parentCertHash, signatureHints);
143+
return _verifyCert(cert, _certCacheKey(cert), false, parentCertHash, signatureHints);
142144
}
143145

144146
/// @notice Return raw cached certificate metadata without current trust checks.
145147
/// @dev A non-empty return value only means the cert was cached previously. It may now be
146-
/// expired or revoked; use the verification entrypoints for trust-aware reuse.
148+
/// expired or revoked; use the verification entrypoints for trust-aware reuse. Pass the
149+
/// cache key returned by the verification entrypoints.
147150
function loadVerified(bytes32 certHash) external view returns (VerifiedCert memory) {
148151
return _loadVerified(certHash);
149152
}
@@ -262,6 +265,9 @@ contract CertManager is ICertManager {
262265

263266
bytes32 identity;
264267
(cert, identity) = _verifyUncachedCert(certificate, ca, parent, signatureHints);
268+
// The pinned root is already present under ROOT_CA_CERT_HASH. Do not allow a signature
269+
// malleability twin of that same trust anchor to become a second cached parent key.
270+
require(!_isPinnedRootAlias(certHash, cert), "root cert alias");
265271
// Reject by (issuer, serial) identity so a re-encoded twin of a revoked cert cannot pass.
266272
_requireNotRevoked(identity);
267273
_saveVerified(certHash, cert);
@@ -273,6 +279,24 @@ contract CertManager is ICertManager {
273279
return cert;
274280
}
275281

282+
function _certCacheKey(bytes memory certificate) internal pure returns (bytes32) {
283+
bytes32 rawCertHash = keccak256(certificate);
284+
if (rawCertHash == ROOT_CA_CERT_HASH) {
285+
return ROOT_CA_CERT_HASH;
286+
}
287+
288+
Asn1Ptr root = certificate.root();
289+
require(root.totalLength() == certificate.length, "invalid cert length");
290+
Asn1Ptr tbsCertPtr = certificate.firstChildOf(root);
291+
return certificate.keccak(tbsCertPtr.header(), tbsCertPtr.totalLength());
292+
}
293+
294+
function _isPinnedRootAlias(bytes32 certHash, VerifiedCert memory cert) internal pure returns (bool) {
295+
return certHash != ROOT_CA_CERT_HASH && cert.ca && cert.subjectHash == ROOT_CA_CERT_SUBJECT_HASH
296+
&& cert.pubKey.length == ROOT_CA_CERT_PUB_KEY.length
297+
&& keccak256(cert.pubKey) == keccak256(ROOT_CA_CERT_PUB_KEY);
298+
}
299+
276300
function _verifyUncachedCert(
277301
bytes memory certificate,
278302
bool ca,

src/ICertManager.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface ICertManager {
1818

1919
function revoked(bytes32 certHash) external view returns (bool);
2020

21+
/// @return The cache key to use as the parent for child certs: ROOT_CA_CERT_HASH for the pinned
22+
/// root, otherwise keccak256(tbsCertificate).
2123
function verifyCACertWithHints(bytes memory cert, bytes32 parentCertHash, bytes memory signatureHints)
2224
external
2325
returns (bytes32);
@@ -26,7 +28,7 @@ interface ICertManager {
2628
external
2729
returns (VerifiedCert memory);
2830

29-
/// @notice Raw cache read. A returned cert may be expired or revoked.
31+
/// @notice Raw cache read by verification cache key. A returned cert may be expired or revoked.
3032
function loadVerified(bytes32 certHash) external view returns (VerifiedCert memory);
3133

3234
function isRevoked(bytes32 certHash) external view returns (bool);

0 commit comments

Comments
 (0)