Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Added
- Operational certificate revocation in `CertManager`: an owner-managed `revoker` can mark one or
many certificates revoked, and the owner can rotate the revoker or undo accidental revocations.
- `CertManager.computeCertId(certDER)`: returns a certificate's `(issuer, serial)` revocation
identity key.

### Changed
- Certificate verification and cached reuse now reject revoked certificates and revoked cached
parent-chain ancestors independently of `notAfter`.
- Revocation is keyed by the `(issuer, serial)` identity `keccak256(issuerHash, serialHash)` (what
AWS CRLs use), not by `keccak256(certBytes)`. Byte-keying was bypassable, because ECDSA signature
malleability and DER re-encoding let a revoked certificate be re-presented with different bytes
that still verify; the signature-protected identity closes that gap and lets operators revoke
directly from CRL issuer/serial entries.
- Root certificate revocation is owner-only (keyed by the pinned `ROOT_CA_CERT_HASH`, since the root
is never parsed on-chain), while non-root revocation remains delegated to the revoker role.
- Cold certificate verification rejects submitted cert bytes with trailing data or fields after the
signature.

### Fixed
- Reject non-canonical P-384 public key coordinates greater than or equal to the field prime `p`.

Expand Down Expand Up @@ -49,4 +68,5 @@ yet a general-availability release.
in NatSpec, the README, and the design doc.
- Moved the demo `CertManagerDemo` out of `src/` into `test/helpers/`.

[Unreleased]: https://github.com/base/nitro-validator/compare/v2.0.0-rc.1...HEAD
[2.0.0-rc.1]: https://github.com/base/nitro-validator/releases/tag/v2.0.0-rc.1
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
This repo provides solidity contracts for the verification of attestations generated by AWS Nitro Enclaves, as outlined in
[this doc](https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/4b851f3006c6fa98f23dcffb2cba03b39de9b8af/docs/attestation_process.md#3-attestation-document-validation).

This library does not currently support certificate revocation, which is disabled in AWS's attestation verification documentation
AWS's attestation verification documentation disables CRL checks in its sample flow
[here](https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/4b851f3006c6fa98f23dcffb2cba03b39de9b8af/docs/attestation_process.md#32-syntactical-validation).
This library supports operational revocation with an authorized revoker: operators monitor AWS CRLs
off-chain and call `CertManager.revokeCert` / `revokeCerts` for affected certificate identity keys.

## Hinted P-384 verification

Expand Down Expand Up @@ -33,9 +35,12 @@ For the full design, security argument, and measured gas, see
Deploy in this order (the verifier references are immutable):

1. `P384Verifier`
2. `CertManager(p384Verifier)` — pins the AWS Nitro root CA in its constructor.
2. `CertManager(p384Verifier)` — pins the AWS Nitro root CA and sets the deployer as owner/revoker.
3. `NitroValidator(certManager, p384Verifier)`

After deployment, move ownership to the production admin and set the operational revoker with
`transferOwnership` / `setRevoker`.

### Verification flow

Verification has two phases. Certificate chains are reused across many attestations from the same
Expand Down Expand Up @@ -69,6 +74,28 @@ node tools/p384_hints.js attestation --attestation <0x COSE> --pubkey <0x leaf p
Production callers should reimplement this in their backend language; the contract re-verifies every
hint, so the generator is trusted only for liveness, never for correctness.

### Revocation operations

`CertManager` does not fetch or parse AWS CRLs on-chain. Instead, an authorized `revoker` address
Comment thread
leopoldjoy marked this conversation as resolved.
marks certificates revoked after checking AWS CRLs off-chain. Revocation is keyed by the
certificate's **(issuer, serial) identity** — `keccak256(issuerHash, serialHash)`, the same identity
AWS CRLs use to list revoked certs — not by `keccak256(certBytes)`. Raw cert bytes are **not** a
stable identity: ECDSA signatures are malleable (the `(r, n-s)` twin also verifies) and DER is
re-encodable, so a byte-keyed revocation could be bypassed by a re-encoded twin of the revoked cert.
Keying on the signature-protected (issuer, serial) pair closes that gap and lets operators revoke
straight from CRL data. Compute the key with `CertManager.computeCertId(certDER)` (or replicate it
off-chain). Revoked certificates are rejected on both cold verification and cached reuse,
independently of `notAfter`. Cached descendants are also rejected when their cached parent chain
contains a revoked certificate.

- The deployer starts as both `owner` and `revoker`.
- The owner can call `transferOwnership`, `setRevoker`, `unrevokeCert`, and revoke
`ROOT_CA_CERT_HASH` as an emergency global halt (the root is identified by its pinned hash, since
it is never parsed on-chain).
- The revoker can call `revokeCert` or `revokeCerts` for non-root certificate identity keys.
- `loadVerified` is a raw cache read; returned metadata does not imply the certificate is
currently trusted.

### Example consumer

```solidity
Expand Down Expand Up @@ -141,8 +168,10 @@ integrator (see [docs](docs/hinted-p384-nitro-attestation.md#integrator-responsi
attestation fields instead.
- **Enclave policy** — checking `pcrs` / `moduleID` against the enclave image(s) you trust is your
responsibility.
- **Revocation** — not supported (consistent with AWS's documented validation process, linked
above).
- **Revocation operations** — the contract enforces the on-chain revoked set, but an off-chain
operator must monitor AWS CRLs and submit the affected certificate identity keys
(`keccak256(issuerHash, serialHash)`, computed via `computeCertId` or directly from the CRL's
issuer/serial entries).

## Build

Expand Down
78 changes: 61 additions & 17 deletions docs/hinted-p384-nitro-attestation.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ Three deployable contracts:
limit. It uses the hint-aware `ECDSA384` library vendored at `src/vendor/ECDSA384.sol`
(see `src/vendor/README.md`). `CertManager` and `NitroValidator` hold **immutable**
references to it.
- **`CertManager`** — parses/validates certificates, caches verified ones, and
pins the AWS Nitro root. Implements `ICertManager`.
- **`CertManager`** — parses/validates certificates, caches verified ones, pins the
AWS Nitro root, and enforces an owner-managed revocation set. Implements
`ICertManager`.
- **`NitroValidator`** — parses the CBOR/COSE attestation and drives the
certificate chain through `CertManager`.

Expand Down Expand Up @@ -372,9 +373,46 @@ Practical reuse cases:

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

### Revocation model
AWS's Nitro attestation documentation disables CRL checking in its sample validation
flow. This implementation keeps CRL parsing off-chain and exposes an operational
revocation hook on-chain:

- the `CertManager` deployer starts as both `owner` and `revoker`;
- the owner can transfer ownership, rotate the revoker, undo accidental revocations
with `unrevokeCert`, and revoke `ROOT_CA_CERT_HASH` as an emergency global halt;
- the revoker can call `revokeCert` / `revokeCerts` for non-root AWS certificate
identity keys after checking AWS CRLs off-chain.

Revocation keys are **(issuer, serial) identities**: `keccak256(issuerHash, serialHash)`,
the same pair AWS CRLs use to list revoked certificates. `CertManager.computeCertId(certDER)`
returns this key (operators can also replicate it off-chain directly from a CRL entry). The
key is deliberately **not** `keccak256(certBytes)`: raw cert bytes are not a stable identity,
because ECDSA signatures are malleable (for a valid `(r, s)` the twin `(r, n-s)` also verifies)
and DER can be re-encoded — so a byte-keyed revocation could be bypassed by submitting a
re-encoded twin of the revoked certificate, which hashes differently but still verifies. The
(issuer, serial) pair lives inside the CA-signed TBS, so it is fixed for every byte-encoding of
a given certificate, and the identity recorded when a cert is first verified matches the key an
operator computes from the CRL. The root is the one exception: it is never parsed on-chain (it
is pinned by `ROOT_CA_CERT_HASH`), so its emergency-halt key is that pinned hash. Cold
verification additionally rejects certificate byte strings whose outer ASN.1 certificate object
does not consume all submitted bytes, or whose certificate sequence contains fields after the
signature.

Revoked certs are rejected during cold verification, cached reuse, and warm attestation
bundle re-walks. Parent-chain revocation is also enforced for cached intermediates, so a
cached descendant cannot keep verifying through an ancestor that was later revoked.
Revocation is checked independently of `notAfter`, so a revoked cert is untrusted even if
its X.509 validity period has not expired.

`loadVerified` is intentionally a raw cache read. A non-empty return value means the cert
metadata was cached previously; it does not imply the cert is currently trusted, unexpired,
or unrevoked.

**Warm-only guard.** `validateAttestationWithHints` re-runs the cabundle checks with an
*empty* hint stream. Cached certs return before signature verification; a missing cert
Expand Down Expand Up @@ -439,7 +477,7 @@ Runtime sizes (`forge build --sizes`); EIP-170 limit is 24,576 bytes:
| contract | runtime size | margin |
|----------|-------------:|-------:|
| `P384Verifier` | 7,805 | 16,771 |
| `CertManager` | 19,620 | 4,956 |
| `CertManager` | 22,297 | 2,279 |
| `NitroValidator` | 14,062 | 10,514 |

(Test-only helper contracts are not part of the deployable contract set.)
Expand All @@ -448,13 +486,13 @@ Runtime sizes (`forge build --sizes`); EIP-170 limit is 24,576 bytes:

The hinted contracts are exercised against the real fixture and adversarial inputs.
Covered failure modes: mutated hint, truncated hint, surplus hint, wrong parent hash,
expired cached cert, expired cert on first (cold) verification, the `notAfter` validity
boundary, CA/client role mismatch, missing warm cache, invalid final signature,
out-of-range ECDSA scalars (`r=0`, `r≥n`, `s=0`, `s>lowSmax`), disabled unhinted
entrypoints, EIP-170 fit, and off-chain↔on-chain hint equivalence. The DER, CBOR, and
byte-slicing parsers additionally have direct unit and fuzz tests for malformed and
out-of-bounds input (`test/Asn1Decode.t.sol`, `test/CborDecode.t.sol`,
`test/LibBytes.t.sol`).
revoked certs, revoked parents, expired cached cert, expired cert on first (cold)
verification, the `notAfter` validity boundary, CA/client role mismatch, missing warm
cache, invalid final signature, out-of-range ECDSA scalars (`r=0`, `r≥n`, `s=0`,
`s>lowSmax`), disabled unhinted entrypoints, EIP-170 fit, and off-chain↔on-chain hint
equivalence. The DER, CBOR, and byte-slicing parsers additionally have direct unit and
fuzz tests for malformed and out-of-bounds input (`test/Asn1Decode.t.sol`,
`test/CborDecode.t.sol`, `test/LibBytes.t.sol`).

| invariant | component | how it is tested |
|-----------|-----------|------------------|
Expand All @@ -464,6 +502,7 @@ out-of-bounds input (`test/Asn1Decode.t.sol`, `test/CborDecode.t.sol`,
| hinted verifier matches the original accept/reject set | `P384Verifier` | accepts a valid signature; rejects mutated hash / signature / public key |
| no unhinted fallback via hinted entrypoints | `CertManager` | the unhinted entrypoints revert |
| warm validation requires cached certs | `NitroValidator` | empty-hint final validation reverts when a cert is uncached |
| revoked certs are never trusted | `CertManager` | revoked cold/cached certs, revoked parents/ancestors, and revoked root/leaf warm paths revert |
| out-of-range scalars are rejected | `P384Verifier` | `r=0` / `r≥n` / `s=0` / `s>lowSmax` signatures return false |
| certificate validity is enforced at the boundary | `CertManager` | cold-path expiry reverts; valid at `notAfter`, expired at `notAfter+1` |
| parsers reject malformed / out-of-bounds input | `Asn1Decode`, `CborDecode`, `LibBytes` | direct unit + fuzz tests for bad tags, lengths, types, and slices |
Expand Down Expand Up @@ -516,10 +555,9 @@ deliberately left to the caller and must be handled in the consuming contract:
- **Freshness / anti-replay.** `validateAttestationWithHints` only checks that
`timestamp` is non-zero and that `nonce` is within a size bound; it never compares
`timestamp` (milliseconds) to `block.timestamp` (seconds) nor matches `nonce` to a
challenge. A valid
attestation can be replayed until its short-lived leaf certificate expires. If you
need freshness, compare `ptrs.timestamp / 1000` to `block.timestamp` and/or verify
`ptrs.nonce` against a value you issued.
challenge. A valid attestation can be replayed until its short-lived leaf certificate
expires or is revoked. If you need freshness, compare `ptrs.timestamp / 1000` to
`block.timestamp` and/or verify `ptrs.nonce` against a value you issued.
- **Signature malleability.** Low-S is intentionally not enforced (AWS does not
guarantee low-S; see `CURVE_LOW_S_MAX` in `ECDSA384Curve.sol`), so for a valid
signature `(r, s)` the twin `(r, n−s)` also verifies. This cannot forge an
Expand All @@ -528,6 +566,12 @@ deliberately left to the caller and must be handled in the consuming contract:
attestation fields (e.g. `moduleID + timestamp + nonce`).
- **Enclave-image / PCR policy.** The contract returns the parsed `pcrs` and
`moduleID`; deciding which enclave images you trust is application policy.
- **CRL monitoring.** `CertManager` enforces the certificate identity keys that have been
marked revoked on-chain, but it does not fetch or parse AWS CRLs. A trusted off-chain
operator must monitor AWS CRLs and submit `revokeCert` / `revokeCerts` transactions
promptly, passing each affected cert's `(issuer, serial)` identity key
(`keccak256(issuerHash, serialHash)`, via `computeCertId` or computed directly from the
CRL entry).

## 11. On-chain demo

Expand Down
Loading
Loading