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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
PRIVATE_KEY=

# Demo-only: lets the January 2026 fixture pass certificate expiry checks on a
# June 2026 Base Sepolia block. Production-named contracts keep zero grace.
DEMO_CERT_EXPIRY_GRACE_SECONDS=31536000

# Uses the repaired real attestation fixture embedded in the script.
USE_BUNDLED_REAL_ATTESTATION=true
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,34 @@ jobs:
run: |
forge test -vvv
id: test

ffi-parity:
name: Off-chain witness parity (FFI)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly

# Asserts the off-chain hint generator (tools/p384_hints.js) produces byte-identical
# hints to the on-chain Solidity reference; guards against the two drifting apart.
# Node.js is preinstalled on ubuntu-latest and the tools use only Node built-ins.
- name: Smoke-test off-chain tools
run: |
node --check tools/nitro_attestation_input.js
node --check tools/hinted_attestation_calls.js
node tools/nitro_attestation_input.js fixture > /dev/null
node tools/hinted_attestation_calls.js fixture > /dev/null
id: tools

- name: Run FFI parity tests
Comment thread
leopoldjoy marked this conversation as resolved.
env:
NITRO_RUN_FFI: "true"
run: |
forge test --ffi -vvv --match-test test_OffchainWitness
id: ffi
7 changes: 3 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@ cache/
out/

# Ignores development broadcast logs
!/broadcast
/broadcast/*/31337/
/broadcast/**/dry-run/
broadcast/

# Docs
docs/
docs/*
!docs/hinted-p384-nitro-attestation.md

# Dotenv file
.env
Expand Down
3 changes: 0 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "lib/solidity-lib"]
path = lib/solidity-lib
url = https://github.com/dl-solarity/solidity-lib
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Changelog

All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0-rc.1] - 2026-06-09

First release candidate of the **hinted P-384** rework. This is a major, breaking change motivated
by the Fusaka upgrade (EIP-7883), which raises `MODEXP` pricing enough that the previous fully
on-chain attestation verification no longer fits in a block on Base. Verification now moves the
modular inversions off-chain as calldata "hints" that are re-verified on-chain (`b · inv ≡ 1 mod m`),
so a wrong hint can only revert, never forge — the accept rule is unchanged.

This is a release candidate: it is intended for the human security audit and partner evaluation, not
yet a general-availability release.

### Changed (breaking)
- Verification is now hinted. Use `CertManager.verifyCACertWithHints` /
`verifyClientCertWithHints` and `NitroValidator.validateAttestationWithHints`.
- Constructors now take an `IP384Verifier`: deploy `P384Verifier` → `CertManager(p384Verifier)` →
`NitroValidator(certManager, p384Verifier)`.
- `validateAttestationWithHints` requires the certificate bundle to be verified/cached first; an
uncached bundle reverts with `"inverse hint underflow"`.

### Removed
- The fully on-chain (non-hinted) verification path. `verifyCACert`, `verifyClientCert`, and
`validateAttestation` are retained only as reverting stubs (marked deprecated) for ABI continuity.

### Added
- `IP384Verifier` / `P384Verifier` (swappable hinted P-384 verifier) and `ECDSA384Curve` params.
- Off-chain hint generator and tooling under `tools/` (Node.js, no dependencies), cross-checked for
byte-identical parity with the on-chain reference via FFI tests.
- `docs/hinted-p384-nitro-attestation.md` design/security/gas spec.
- CI job running the FFI hint-parity tests.
- Negative tests: expired cert (cold & cached), validity boundary, out-of-range scalar rejection;
and malformed-input / fuzz tests for the DER, CBOR, and byte-slicing parsers.

### Internal / hygiene
- Vendored the P-384 verifier (`src/vendor/ECDSA384.sol`, `MemoryUtils.sol`) from
`dl-solarity/solidity-lib`, removing the personal-fork submodule; provenance and the exact
upstream diff are recorded in `src/vendor/`.
- Documented integrator responsibilities (freshness/replay, signature malleability, enclave policy)
in NatSpec, the README, and the design doc.
- Moved the demo `CertManagerDemo` out of `src/` into `test/helpers/`.

[2.0.0-rc.1]: https://github.com/base/nitro-validator/releases/tag/v2.0.0-rc.1
141 changes: 121 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,56 +3,150 @@
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).

Note that it costs around 63m gas to validate an attestation with no prior verified certs.
You can break this up into smaller transactions by verifying each cert in the chain separately.
You can call `CertManager.verifyCACert` for each cert in the attestation `cabundle`.

This library does not currently support certificate revocation, which is disabled in AWS's attestation verification documentation
[here](https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/4b851f3006c6fa98f23dcffb2cba03b39de9b8af/docs/attestation_process.md#32-syntactical-validation).

## Hinted P-384 verification

Nitro attestations are signed with ECDSA over the NIST P-384 curve. Verifying a P-384 signature
on-chain requires modular inversion, which is computed via the `MODEXP` precompile — and the Fusaka
upgrade (EIP-7883, live on Base) raises `MODEXP` pricing enough that the old fully on-chain flow no
longer fits in a block.

This library therefore uses **hinted verification**: the modular inverses are computed *off-chain*
and supplied in calldata as "hints". The contract does **not** trust them — before using each hint
`inv` for a value `b` modulo `m`, it checks `b · inv ≡ 1 (mod m)` and reverts (`"bad inverse hint"`)
otherwise. Because the moduli are prime the inverse is unique, so a wrong hint can only cause a
revert, never a forged signature. The acceptance rule is identical to a standard on-chain ECDSA-384
verification — hints are purely a gas optimization.

The legacy non-hinted entrypoints (`verifyCACert`, `verifyClientCert`, `validateAttestation`) are
retained only as reverting stubs; use the `*WithHints` functions below.

For the full design, security argument, and measured gas, see
[docs/hinted-p384-nitro-attestation.md](docs/hinted-p384-nitro-attestation.md).

## Usage

1. Deploy the `CertManager` separately.
2. Validate Nitro attestation in your contract:
### Deployment

Deploy in this order (the verifier references are immutable):

1. `P384Verifier`
2. `CertManager(p384Verifier)` — pins the AWS Nitro root CA in its constructor.
3. `NitroValidator(certManager, p384Verifier)`

### Verification flow

Verification has two phases. Certificate chains are reused across many attestations from the same
enclave, so the chain is verified and **cached once**, after which each attestation only pays for its
own document signature.

1. **Cold phase — verify & cache the certificate chain** (once per chain). For each CA cert in the
`cabundle`, then the leaf cert, compute the inverse hints off-chain and submit them:
- `certManager.verifyCACertWithHints(caCert, parentCertHash, hints)` (use
`ROOT_CA_CERT_HASH` / `keccak256(rootCert)` for the first non-root CA; `0` is only for the
pinned root itself)
- `certManager.verifyClientCertWithHints(leafCert, parentCertHash, hints)`
2. **Validation — verify the document signature.** Once the chain is cached:
- `validator.validateAttestationWithHints(attestationTbs, signature, attestationHints)`
- **Precondition:** the whole `cabundle` + leaf must already be cached from phase 1. This call
re-walks the chain with empty hints (relying on the cache), so an uncached chain reverts with
`"inverse hint underflow"`.

Splitting `attestation` into `attestationTbs` (to-be-signed bytes) and `signature` is cheapest
off-chain, but `validator.decodeAttestationTbs(attestation)` is available on-chain too.

### Computing hints off-chain

`tools/p384_hints.js` is a reference generator (Node.js, no dependencies):

```sh
node tools/p384_hints.js cert --cert <0x DER cert> --pubkey <0x parent pubkey>
node tools/p384_hints.js attestation --attestation <0x COSE> --pubkey <0x leaf pubkey>
```

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.

### Example consumer

```solidity
pragma solidity ^0.8.0;

import {NitroValidator} from "@nitro-validator/NitroValidator.sol";
import {CborDecode} from "@nitro-validator/CborDecode.sol";
import {CertManager} from "@nitro-validator/CertManager.sol";
import {CborDecode} from "@nitro-validator/CborDecode.sol";

contract Validator is NitroValidator {
contract Validator {
using CborDecode for bytes;

uint256 public constant MAX_AGE = 60 minutes;
bytes32 public constant PCR0 = keccak256("some PCR0 value");

constructor(CertManager certManager) NitroValidator(certManager) {}

function registerSigner(bytes calldata attestationTbs, bytes calldata signature) external onlyOwner {
Ptrs memory ptrs = validateAttestation(attestationTbs, signature);
NitroValidator public immutable validator;

constructor(NitroValidator validator_) {
validator = validator_;
}

// Assumes the attestation's certificate chain has already been cached on the CertManager via
// verifyCACertWithHints / verifyClientCertWithHints (see the cold phase above).
function registerSigner(
bytes calldata attestationTbs,
bytes calldata signature,
bytes calldata attestationHints // computed off-chain
) external {
NitroValidator.Ptrs memory ptrs =
validator.validateAttestationWithHints(attestationTbs, signature, attestationHints);

bytes32 pcr0 = attestationTbs.keccak(ptrs.pcrs[0]);
require(pcr0 == PCR0, "invalid pcr0 in attestation");
require(ptrs.timestamp + MAX_AGE > block.timestamp, "attestation too old");
require(ptrs.timestamp / 1000 + MAX_AGE > block.timestamp, "attestation too old");

bytes memory publicKey = attestationTbs.slice(ptrs.publicKey);
// do something with the public key, user data, etc
}
}
```
3. Convert an attestation document to an attestationTbs / signature (TBS means to-be-signed).
Note it's cheaper to perform this conversion offchain (e.g. using `cast call`).
```solidity
bytes memory attestation = hex"84..";
(bytes memory attestationTbs, bytes memory signature) = validator.decodeAttestationTbs(attestation);
validator.validateAttestation(attestationTbs, signature);

## Installation

As a Foundry dependency:

```sh
forge install base/nitro-validator
```

Then map the `@nitro-validator/` prefix used in the examples above to the package's `src/` in your
`remappings.txt`:

```
@nitro-validator/=lib/nitro-validator/src/
```

The library vendors its only third-party dependency (the P-384 verifier) under `src/vendor/`, so no
additional submodules are required beyond `forge-std`.

## Security considerations

Verification proves an attestation is genuine; some properties are intentionally left to the
integrator (see [docs](docs/hinted-p384-nitro-attestation.md#integrator-responsibilities-what-the-contract-does-not-enforce)):

- **Freshness / replay** — the contract does not compare the attestation `timestamp` (milliseconds)
to `block.timestamp` (seconds) or match the `nonce` to a challenge. Enforce freshness yourself if
you need it.
- **Signature malleability** — low-S is not enforced (AWS does not emit low-S), so the malleable
twin `(r, n-s)` also verifies. Never use the signature as a uniqueness key; dedupe on canonical
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).

## Build

```sh
forge install
forge build
```

Expand All @@ -61,3 +155,10 @@ forge build
```sh
forge test
```

The off-chain witness generator is cross-checked for byte-identical parity against the on-chain
Solidity reference under FFI (requires Node.js):

```sh
NITRO_RUN_FFI=true forge test --ffi --match-test test_OffchainWitness
```
Loading
Loading