Skip to content

Commit 662a32c

Browse files
leanthebeanclaude
andauthored
Add hinted P-384 Nitro attestation verification for Fusaka (#28)
* init * happy path & doc updates * Make hinted P-384 branch self-contained and review-ready Vendor the hinted ECDSA384 crypto in-tree and drop the personal-fork solidity-lib submodule so the repo builds standalone and the audited code is exactly what deploys: - src/vendor/{ECDSA384,MemoryUtils}.sol copied from dl-solarity@b947571, with provenance README + the upstream hinted diff (ECDSA384.hinted.patch) - repoint imports to relative paths, drop @Solarity remapping & submodule - exclude src/vendor from forge fmt to keep it byte-identical to upstream Tighten production hygiene for the human security review: - rewrite README for the hinted flow (deployment order, cold/warm phases, off-chain hint generation, verify-not-trust safety model) - add CI job running the FFI off-chain/Solidity hint parity tests - add negative tests: expired-cert-on-first-verification, notAfter validity boundary, out-of-range scalar rejection (r==0, r>=n, s==0, s>lowSmax) - move CertManagerDemo out of src/ into test/helpers/ - document the warm-cache precondition (NatSpec) on the hinted entrypoints Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Document behavioral gaps, add parser tests, version as v2.0.0-rc.1 Behavioral-gap documentation (no logic change): - ECDSA384Curve: explain why lowSmax = n-1 (AWS doesn't emit low-S) and the resulting signature malleability; warn against using the signature as a key - NitroValidator/P384Verifier NatSpec: integrator responsibilities for freshness/replay, malleability, and PCR/moduleID policy - README + design doc: "Security considerations" / "Integrator responsibilities" - Mark the reverting legacy stubs @deprecated (CertManager, NitroValidator) and reorder ICertManager to lead with the hinted entrypoints Test coverage for the parsing layers (the cert/attestation attack surface): - test/Asn1Decode.t.sol: malformed DER (multi-byte tag, oversized length, out-of-range length, wrong types, bad timestamps, non-0-padded bitstring) + fuzz - test/CborDecode.t.sol: unexpected/unsupported/indefinite types, null handling, truncated length, declared-length-exceeds-buffer + fuzz - test/LibBytes.t.sol: slice/keccak/readUintN bounds, overflow, round-trip fuzz (86 tests total, up from 42) Release hygiene: - add CHANGELOG.md describing the v2.0.0-rc.1 hinted P-384 release - README: installation + @nitro-validator remapping guidance - docs: fix stale leanthebean/solidity-lib fork reference to point at src/vendor Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: sync feature doc with current code - §3: ECDSA384 is vendored at src/vendor, not a solidity-lib submodule - §8: correct CertManager runtime size (19,394 / margin 5,182) - §9: add the new coverage (cold-expiry, validity boundary, scalar bounds, and DER/CBOR/LibBytes malformed-input + fuzz tests) Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: add plain-language "Intuition" section to the feature doc A non-cryptographer's walkthrough of hinted P-384 (factoring analogy + mod-7 worked example + why it's safe), placed before the formal sections, with cross-references into §4 so it complements rather than repeats the spec and soundness argument. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: emphasize the ~384-bit exponent and add per-signature hint table In the Intuition section: highlight that the on-chain exponent m-2 is itself a ~384-bit number (~10^115) — the reason the MODEXP step is so costly — and add the 48-byte hint definition plus the per-signature hint-bytes / inverse-count table for a cold attestation. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: condense "Preparing calls off-chain" section Trim §5 to the idea (hints are replayed off-chain, re-checked on-chain, so the generator is liveness-only and language-agnostic) plus a brief note that a reference implementation is bundled. Drops the codebase-specific CLI flags, step-by-step prep lists, JSON schema, and Go/Rust details. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Address hinted P-384 review feedback * Update hinted Nitro size table --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55860bc commit 662a32c

35 files changed

Lines changed: 6408 additions & 143 deletions

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
2+
PRIVATE_KEY=
3+
4+
# Demo-only: lets the January 2026 fixture pass certificate expiry checks on a
5+
# June 2026 Base Sepolia block. Production-named contracts keep zero grace.
6+
DEMO_CERT_EXPIRY_GRACE_SECONDS=31536000
7+
8+
# Uses the repaired real attestation fixture embedded in the script.
9+
USE_BUNDLED_REAL_ATTESTATION=true

.github/workflows/test.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,34 @@ jobs:
4343
run: |
4444
forge test -vvv
4545
id: test
46+
47+
ffi-parity:
48+
name: Off-chain witness parity (FFI)
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
with:
53+
submodules: recursive
54+
55+
- name: Install Foundry
56+
uses: foundry-rs/foundry-toolchain@v1
57+
with:
58+
version: nightly
59+
60+
# Asserts the off-chain hint generator (tools/p384_hints.js) produces byte-identical
61+
# hints to the on-chain Solidity reference; guards against the two drifting apart.
62+
# Node.js is preinstalled on ubuntu-latest and the tools use only Node built-ins.
63+
- name: Smoke-test off-chain tools
64+
run: |
65+
node --check tools/nitro_attestation_input.js
66+
node --check tools/hinted_attestation_calls.js
67+
node tools/nitro_attestation_input.js fixture > /dev/null
68+
node tools/hinted_attestation_calls.js fixture > /dev/null
69+
id: tools
70+
71+
- name: Run FFI parity tests
72+
env:
73+
NITRO_RUN_FFI: "true"
74+
run: |
75+
forge test --ffi -vvv --match-test test_OffchainWitness
76+
id: ffi

.gitignore

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ cache/
33
out/
44

55
# Ignores development broadcast logs
6-
!/broadcast
7-
/broadcast/*/31337/
8-
/broadcast/**/dry-run/
6+
broadcast/
97

108
# Docs
11-
docs/
9+
docs/*
10+
!docs/hinted-p384-nitro-attestation.md
1211

1312
# Dotenv file
1413
.env

.gitmodules

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
[submodule "lib/forge-std"]
22
path = lib/forge-std
33
url = https://github.com/foundry-rs/forge-std
4-
[submodule "lib/solidity-lib"]
5-
path = lib/solidity-lib
6-
url = https://github.com/dl-solarity/solidity-lib

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented here. The format is based on
4+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow
5+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [2.0.0-rc.1] - 2026-06-09
8+
9+
First release candidate of the **hinted P-384** rework. This is a major, breaking change motivated
10+
by the Fusaka upgrade (EIP-7883), which raises `MODEXP` pricing enough that the previous fully
11+
on-chain attestation verification no longer fits in a block on Base. Verification now moves the
12+
modular inversions off-chain as calldata "hints" that are re-verified on-chain (`b · inv ≡ 1 mod m`),
13+
so a wrong hint can only revert, never forge — the accept rule is unchanged.
14+
15+
This is a release candidate: it is intended for the human security audit and partner evaluation, not
16+
yet a general-availability release.
17+
18+
### Changed (breaking)
19+
- Verification is now hinted. Use `CertManager.verifyCACertWithHints` /
20+
`verifyClientCertWithHints` and `NitroValidator.validateAttestationWithHints`.
21+
- Constructors now take an `IP384Verifier`: deploy `P384Verifier``CertManager(p384Verifier)`
22+
`NitroValidator(certManager, p384Verifier)`.
23+
- `validateAttestationWithHints` requires the certificate bundle to be verified/cached first; an
24+
uncached bundle reverts with `"inverse hint underflow"`.
25+
26+
### Removed
27+
- The fully on-chain (non-hinted) verification path. `verifyCACert`, `verifyClientCert`, and
28+
`validateAttestation` are retained only as reverting stubs (marked deprecated) for ABI continuity.
29+
30+
### Added
31+
- `IP384Verifier` / `P384Verifier` (swappable hinted P-384 verifier) and `ECDSA384Curve` params.
32+
- Off-chain hint generator and tooling under `tools/` (Node.js, no dependencies), cross-checked for
33+
byte-identical parity with the on-chain reference via FFI tests.
34+
- `docs/hinted-p384-nitro-attestation.md` design/security/gas spec.
35+
- CI job running the FFI hint-parity tests.
36+
- Negative tests: expired cert (cold & cached), validity boundary, out-of-range scalar rejection;
37+
and malformed-input / fuzz tests for the DER, CBOR, and byte-slicing parsers.
38+
39+
### Internal / hygiene
40+
- Vendored the P-384 verifier (`src/vendor/ECDSA384.sol`, `MemoryUtils.sol`) from
41+
`dl-solarity/solidity-lib`, removing the personal-fork submodule; provenance and the exact
42+
upstream diff are recorded in `src/vendor/`.
43+
- Documented integrator responsibilities (freshness/replay, signature malleability, enclave policy)
44+
in NatSpec, the README, and the design doc.
45+
- Moved the demo `CertManagerDemo` out of `src/` into `test/helpers/`.
46+
47+
[2.0.0-rc.1]: https://github.com/base/nitro-validator/releases/tag/v2.0.0-rc.1

README.md

Lines changed: 121 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,150 @@
33
This repo provides solidity contracts for the verification of attestations generated by AWS Nitro Enclaves, as outlined in
44
[this doc](https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/4b851f3006c6fa98f23dcffb2cba03b39de9b8af/docs/attestation_process.md#3-attestation-document-validation).
55

6-
Note that it costs around 63m gas to validate an attestation with no prior verified certs.
7-
You can break this up into smaller transactions by verifying each cert in the chain separately.
8-
You can call `CertManager.verifyCACert` for each cert in the attestation `cabundle`.
9-
106
This library does not currently support certificate revocation, which is disabled in AWS's attestation verification documentation
117
[here](https://github.com/aws/aws-nitro-enclaves-nsm-api/blob/4b851f3006c6fa98f23dcffb2cba03b39de9b8af/docs/attestation_process.md#32-syntactical-validation).
128

9+
## Hinted P-384 verification
10+
11+
Nitro attestations are signed with ECDSA over the NIST P-384 curve. Verifying a P-384 signature
12+
on-chain requires modular inversion, which is computed via the `MODEXP` precompile — and the Fusaka
13+
upgrade (EIP-7883, live on Base) raises `MODEXP` pricing enough that the old fully on-chain flow no
14+
longer fits in a block.
15+
16+
This library therefore uses **hinted verification**: the modular inverses are computed *off-chain*
17+
and supplied in calldata as "hints". The contract does **not** trust them — before using each hint
18+
`inv` for a value `b` modulo `m`, it checks `b · inv ≡ 1 (mod m)` and reverts (`"bad inverse hint"`)
19+
otherwise. Because the moduli are prime the inverse is unique, so a wrong hint can only cause a
20+
revert, never a forged signature. The acceptance rule is identical to a standard on-chain ECDSA-384
21+
verification — hints are purely a gas optimization.
22+
23+
The legacy non-hinted entrypoints (`verifyCACert`, `verifyClientCert`, `validateAttestation`) are
24+
retained only as reverting stubs; use the `*WithHints` functions below.
25+
26+
For the full design, security argument, and measured gas, see
27+
[docs/hinted-p384-nitro-attestation.md](docs/hinted-p384-nitro-attestation.md).
28+
1329
## Usage
1430

15-
1. Deploy the `CertManager` separately.
16-
2. Validate Nitro attestation in your contract:
31+
### Deployment
32+
33+
Deploy in this order (the verifier references are immutable):
34+
35+
1. `P384Verifier`
36+
2. `CertManager(p384Verifier)` — pins the AWS Nitro root CA in its constructor.
37+
3. `NitroValidator(certManager, p384Verifier)`
38+
39+
### Verification flow
40+
41+
Verification has two phases. Certificate chains are reused across many attestations from the same
42+
enclave, so the chain is verified and **cached once**, after which each attestation only pays for its
43+
own document signature.
44+
45+
1. **Cold phase — verify & cache the certificate chain** (once per chain). For each CA cert in the
46+
`cabundle`, then the leaf cert, compute the inverse hints off-chain and submit them:
47+
- `certManager.verifyCACertWithHints(caCert, parentCertHash, hints)` (use
48+
`ROOT_CA_CERT_HASH` / `keccak256(rootCert)` for the first non-root CA; `0` is only for the
49+
pinned root itself)
50+
- `certManager.verifyClientCertWithHints(leafCert, parentCertHash, hints)`
51+
2. **Validation — verify the document signature.** Once the chain is cached:
52+
- `validator.validateAttestationWithHints(attestationTbs, signature, attestationHints)`
53+
- **Precondition:** the whole `cabundle` + leaf must already be cached from phase 1. This call
54+
re-walks the chain with empty hints (relying on the cache), so an uncached chain reverts with
55+
`"inverse hint underflow"`.
56+
57+
Splitting `attestation` into `attestationTbs` (to-be-signed bytes) and `signature` is cheapest
58+
off-chain, but `validator.decodeAttestationTbs(attestation)` is available on-chain too.
59+
60+
### Computing hints off-chain
61+
62+
`tools/p384_hints.js` is a reference generator (Node.js, no dependencies):
63+
64+
```sh
65+
node tools/p384_hints.js cert --cert <0x DER cert> --pubkey <0x parent pubkey>
66+
node tools/p384_hints.js attestation --attestation <0x COSE> --pubkey <0x leaf pubkey>
67+
```
68+
69+
Production callers should reimplement this in their backend language; the contract re-verifies every
70+
hint, so the generator is trusted only for liveness, never for correctness.
71+
72+
### Example consumer
1773

1874
```solidity
1975
pragma solidity ^0.8.0;
2076
2177
import {NitroValidator} from "@nitro-validator/NitroValidator.sol";
22-
import {CborDecode} from "@nitro-validator/CborDecode.sol";
2378
import {CertManager} from "@nitro-validator/CertManager.sol";
79+
import {CborDecode} from "@nitro-validator/CborDecode.sol";
2480
25-
contract Validator is NitroValidator {
81+
contract Validator {
2682
using CborDecode for bytes;
2783
2884
uint256 public constant MAX_AGE = 60 minutes;
2985
bytes32 public constant PCR0 = keccak256("some PCR0 value");
30-
31-
constructor(CertManager certManager) NitroValidator(certManager) {}
3286
33-
function registerSigner(bytes calldata attestationTbs, bytes calldata signature) external onlyOwner {
34-
Ptrs memory ptrs = validateAttestation(attestationTbs, signature);
87+
NitroValidator public immutable validator;
88+
89+
constructor(NitroValidator validator_) {
90+
validator = validator_;
91+
}
92+
93+
// Assumes the attestation's certificate chain has already been cached on the CertManager via
94+
// verifyCACertWithHints / verifyClientCertWithHints (see the cold phase above).
95+
function registerSigner(
96+
bytes calldata attestationTbs,
97+
bytes calldata signature,
98+
bytes calldata attestationHints // computed off-chain
99+
) external {
100+
NitroValidator.Ptrs memory ptrs =
101+
validator.validateAttestationWithHints(attestationTbs, signature, attestationHints);
102+
35103
bytes32 pcr0 = attestationTbs.keccak(ptrs.pcrs[0]);
36104
require(pcr0 == PCR0, "invalid pcr0 in attestation");
37-
require(ptrs.timestamp + MAX_AGE > block.timestamp, "attestation too old");
105+
require(ptrs.timestamp / 1000 + MAX_AGE > block.timestamp, "attestation too old");
38106
39107
bytes memory publicKey = attestationTbs.slice(ptrs.publicKey);
40108
// do something with the public key, user data, etc
41109
}
42110
}
43111
```
44-
3. Convert an attestation document to an attestationTbs / signature (TBS means to-be-signed).
45-
Note it's cheaper to perform this conversion offchain (e.g. using `cast call`).
46-
```solidity
47-
bytes memory attestation = hex"84..";
48-
(bytes memory attestationTbs, bytes memory signature) = validator.decodeAttestationTbs(attestation);
49-
validator.validateAttestation(attestationTbs, signature);
112+
113+
## Installation
114+
115+
As a Foundry dependency:
116+
117+
```sh
118+
forge install base/nitro-validator
50119
```
51120

121+
Then map the `@nitro-validator/` prefix used in the examples above to the package's `src/` in your
122+
`remappings.txt`:
123+
124+
```
125+
@nitro-validator/=lib/nitro-validator/src/
126+
```
127+
128+
The library vendors its only third-party dependency (the P-384 verifier) under `src/vendor/`, so no
129+
additional submodules are required beyond `forge-std`.
130+
131+
## Security considerations
132+
133+
Verification proves an attestation is genuine; some properties are intentionally left to the
134+
integrator (see [docs](docs/hinted-p384-nitro-attestation.md#integrator-responsibilities-what-the-contract-does-not-enforce)):
135+
136+
- **Freshness / replay** — the contract does not compare the attestation `timestamp` (milliseconds)
137+
to `block.timestamp` (seconds) or match the `nonce` to a challenge. Enforce freshness yourself if
138+
you need it.
139+
- **Signature malleability** — low-S is not enforced (AWS does not emit low-S), so the malleable
140+
twin `(r, n-s)` also verifies. Never use the signature as a uniqueness key; dedupe on canonical
141+
attestation fields instead.
142+
- **Enclave policy** — checking `pcrs` / `moduleID` against the enclave image(s) you trust is your
143+
responsibility.
144+
- **Revocation** — not supported (consistent with AWS's documented validation process, linked
145+
above).
146+
52147
## Build
53148

54149
```sh
55-
forge install
56150
forge build
57151
```
58152

@@ -61,3 +155,10 @@ forge build
61155
```sh
62156
forge test
63157
```
158+
159+
The off-chain witness generator is cross-checked for byte-identical parity against the on-chain
160+
Solidity reference under FFI (requires Node.js):
161+
162+
```sh
163+
NITRO_RUN_FFI=true forge test --ffi --match-test test_OffchainWitness
164+
```

0 commit comments

Comments
 (0)