Skip to content

Commit 06a43a8

Browse files
leopoldjoyOpenCode
andcommitted
Reject non-canonical P-384 public keys
Co-authored-by: OpenCode <opencode-noreply@coinbase.com>
1 parent 662a32c commit 06a43a8

8 files changed

Lines changed: 77 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Fixed
10+
- Reject non-canonical P-384 public key coordinates greater than or equal to the field prime `p`.
11+
712
## [2.0.0-rc.1] - 2026-06-09
813

914
First release candidate of the **hinted P-384** rework. This is a major, breaking change motivated

docs/hinted-p384-nitro-attestation.md

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,14 @@ Both moduli (`p` and `n`) are prime, so any nonzero element has a *unique* inver
291291
most cause a revert (wasted gas), never a false accept. (A non-canonical hint such
292292
as `inv + m` also passes the check and is harmless, since every use reduces mod `m`.)
293293

294-
The equivalence anchor for this argument is that with hints **disabled** the code is
295-
identical to the upstream verifier; the hinted branches only *substitute a
296-
pre-verified value* for the same inverse the original computes.
294+
The equivalence anchor for this argument is that, aside from rejecting non-canonical
295+
public key coordinates, the hints-disabled code follows the upstream verifier; the
296+
hinted branches only *substitute a pre-verified value* for the same inverse the original
297+
computes.
297298

298299
### What a failed verification does
299-
- With hints **disabled**, behavior is identical to the original verifier.
300+
- With hints **disabled**, behavior is identical to the original verifier except that
301+
non-canonical public key coordinates are rejected.
300302
- A *well-formed but invalid* signature consumes its full hint stream and the verify
301303
returns `false` normally.
302304
- A signature that fails an **early** guard (scalar bounds or the on-curve check)
@@ -471,17 +473,19 @@ The off-chain↔on-chain hint equivalence is checked under an FFI test (it shell
471473
the generator and compares streams byte-for-byte), so the generator and the contract
472474
can never silently diverge.
473475

474-
**For auditors.** The entire trust delta versus the upstream library is the two
475-
`if (hintsEnabled)` branches shown in the appendix; the soundness argument is in §4
476-
(*Why this is sound*). Reviewers should confirm:
476+
**For auditors.** The main trust delta versus the upstream library is the two
477+
`if (hintsEnabled)` branches shown in the appendix; this repo also tightens public-key
478+
coordinate bounds to reject non-canonical field encodings. The hinting soundness
479+
argument is in §4 (*Why this is sound*). Reviewers should confirm:
477480

478481
1. every supplied inverse is constrained by `b · inv ≡ 1` in the correct modulus
479482
**before** it is used;
480483
2. point inverses use the field prime `p` and scalar inverses use the group order `n`,
481484
with no crossover;
482485
3. the underflow and surplus guards together force the hint count to match exactly
483486
(no truncated or leftover hints);
484-
4. the hints-disabled path is byte-identical to the upstream verifier; and
487+
4. the hints-disabled path differs from upstream only by the stricter public-key
488+
coordinate bounds; and
485489
5. the curve parameters (`p`, `n`, `G`, `a`, `b`, low-`s` bound) are correct.
486490

487491
## 10. Caveats and notes
@@ -494,9 +498,10 @@ can never silently diverge.
494498
`x_R mod n == r`, exactly as the upstream library, including its handling of the
495499
negligible (~2⁻¹⁹⁰) case where the recovered x-coordinate lies in `[n, p)`. Hinting
496500
does not alter this.
497-
- **Audit boundary.** Only the two inversion branches are new cryptographic code; the
498-
rest of the verifier is the upstream library unchanged, and the certificate parser,
499-
cache, and CBOR/COSE handling are the pre-existing validator logic.
501+
- **Audit boundary.** The hinted inversion branches are the primary new cryptographic
502+
code; this repo also tightens public-key coordinate bounds versus upstream. The
503+
certificate parser, cache, and CBOR/COSE handling are the pre-existing validator
504+
logic.
500505
- **The generator is liveness-critical, not trust-critical.** A bug in the off-chain
501506
hint generator can only cause a revert (every value is re-checked on-chain), never a
502507
false accept — but correct hints are required to verify at all, so the generator
@@ -551,12 +556,11 @@ The hinted verifier is the upstream `ECDSA384` verifier from
551556
`dl-solarity/solidity-lib`, vendored into this repo at `src/vendor/ECDSA384.sol` (see
552557
`src/vendor/README.md` for provenance and the exact upstream diff in
553558
`src/vendor/ECDSA384.hinted.patch`) with **one operation — modular inversion — made
554-
hint-aware in two places**. Everything else (the Strauss–Shamir ladder, precompute
555-
table, on-curve check, scalar bounds, final `x_R == r`) is unchanged. When hints are
556-
disabled the code follows the original path, so the unhinted path is the equivalence
557-
anchor.
559+
hint-aware in two places**, plus stricter public-key coordinate bounds. Everything else
560+
(the Strauss–Shamir ladder, precompute table, scalar bounds, final `x_R == r`) is
561+
unchanged.
558562

559-
The entire trust delta is these two `if (hintsEnabled)` branches.
563+
The hinting trust delta is these two `if (hintsEnabled)` branches.
560564

561565
**Point arithmetic — inverses mod `p`** (`moddivAssign`):
562566

@@ -613,12 +617,15 @@ no-truncation guard:
613617
+ }
614618
```
615619

620+
The public-key on-curve guard is also tightened to reject non-canonical coordinates
621+
`>= p`, not only coordinates equal to `p`.
622+
616623
The remaining changes are **plumbing**, not logic, and none can affect the
617624
accept/reject decision:
618625

619626
- a few words of scratch memory holding the hint stream pointer, length, cursor, and
620627
an enabled flag (`initCall` / `initCallWithHints`);
621-
- the `verify` entrypoint split into `verify` (no hints, identical to the original)
628+
- the `verify` entrypoint split into `verify` (no hints)
622629
and `verifyWithHints`, which adds the surplus guard
623630
`require(consumed == length, "unused inverse hints")`.
624631

src/vendor/ECDSA384.hinted.patch

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ index 9e43756..053803b 100644
7575
}
7676
}
7777

78+
@@ -168,7 +202,10 @@ library ECDSA384 {
79+
) private view returns (bool) {
80+
unchecked {
81+
- if (U384.eqInteger(x, 0) || U384.eq(x, p) || U384.eqInteger(y, 0) || U384.eq(y, p)) {
82+
+ if (
83+
+ U384.eqInteger(x, 0) || U384.cmp(x, p) >= 0 || U384.eqInteger(y, 0) || U384.cmp(y, p) >= 0
84+
+ ) {
85+
return false;
86+
}
87+
7888
@@ -486,11 +520,14 @@ library ECDSA384 {
7989
library U384 {
8090
uint256 private constant SHORT_ALLOCATION = 64;

src/vendor/ECDSA384.sol

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
//
33
// Vendored from Solarity solidity-lib: https://github.com/dl-solarity/solidity-lib
44
// Base (upstream, unmodified): commit b947757194de6436062c2d68118c0352be84ac4be
5-
// Local modification: "Add hinted P384 inverse verification" (verifyWithHints /
6-
// verifyWithHintsConsumed and the U384 hint-consumption paths). The exact upstream
7-
// diff is committed alongside this file as ECDSA384.hinted.patch for review.
5+
// Local modifications: "Add hinted P384 inverse verification" (verifyWithHints /
6+
// verifyWithHintsConsumed and the U384 hint-consumption paths), plus strict public
7+
// key coordinate bounds. The exact upstream diff is committed alongside this file
8+
// as ECDSA384.hinted.patch for review.
89
// Copyright (c) 2023 Solarity. Originally licensed MIT (see header above).
910
pragma solidity ^0.8.4;
1011

@@ -201,7 +202,9 @@ library ECDSA384 {
201202
uint256 y
202203
) private view returns (bool) {
203204
unchecked {
204-
if (U384.eqInteger(x, 0) || U384.eq(x, p) || U384.eqInteger(y, 0) || U384.eq(y, p)) {
205+
if (
206+
U384.eqInteger(x, 0) || U384.cmp(x, p) >= 0 || U384.eqInteger(y, 0) || U384.cmp(y, p) >= 0
207+
) {
205208
return false;
206209
}
207210

src/vendor/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ the code that is deployed — with no dependency on an external submodule or for
1818
The only imports either file makes are between these two vendored files; nothing else
1919
from `solidity-lib` is used by this repo.
2020

21-
## The one local modification (audit focus)
21+
## Local modifications (audit focus)
2222

23-
`ECDSA384.sol` carries a single functional change on top of the base commit:
24-
**"Add hinted P384 inverse verification"** — adding `verifyWithHints` /
25-
`verifyWithHintsConsumed` and the hint-consumption paths in `U384`
26-
(`initCallWithHints`, `_nextInverseHint`, the hinted branches of `moddivAssign` /
27-
`modinv`).
23+
`ECDSA384.sol` carries two functional changes on top of the base commit:
24+
25+
- **"Add hinted P384 inverse verification"** — adding `verifyWithHints` /
26+
`verifyWithHintsConsumed` and the hint-consumption paths in `U384`
27+
(`initCallWithHints`, `_nextInverseHint`, the hinted branches of `moddivAssign` /
28+
`modinv`).
29+
- **Strict public key coordinate bounds**`_isOnCurve` rejects coordinates `>= p`,
30+
not only `== p`, so non-canonical 48-byte field encodings cannot pass the curve
31+
equation modulo `p`.
2832

2933
The exact upstream diff is committed next to the file as
3034
[`ECDSA384.hinted.patch`](./ECDSA384.hinted.patch) so reviewers can see precisely

test/helpers/ECDSA384HintCollector.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ library ECDSA384HintCollectorLib {
186186
function _isOnCurve(uint256 call, uint256 p, uint256 a, uint256 b, uint256 x, uint256 y) private returns (bool) {
187187
unchecked {
188188
if (
189-
U384HintCollector.eqInteger(x, 0) || U384HintCollector.eq(x, p) || U384HintCollector.eqInteger(y, 0)
190-
|| U384HintCollector.eq(y, p)
189+
U384HintCollector.eqInteger(x, 0) || U384HintCollector.cmp(x, p) >= 0
190+
|| U384HintCollector.eqInteger(y, 0) || U384HintCollector.cmp(y, p) >= 0
191191
) {
192192
return false;
193193
}

test/hinted/HintedNitroAttestation.t.sol

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,24 @@ contract HintedNitroAttestationTest is Test {
273273
assertFalse(p384Verifier.verifyP384SignatureWithHints(hash, abi.encodePacked(one48, max48), pubKey, ""));
274274
}
275275

276+
function test_P384VerifierRejectsNonCanonicalPubKeyCoordinate() public view {
277+
bytes memory hash = new bytes(48);
278+
bytes memory one48 = new bytes(48);
279+
one48[47] = 0x01;
280+
281+
// x = p + 2 fits in 48 bytes and reduces to a valid P-384 x-coordinate modulo p.
282+
bytes memory nonCanonicalX =
283+
hex"fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffeffffffff000000000000000100000001";
284+
bytes memory y =
285+
hex"8cdeadbbd04911a3c1931e26df3fa6439dca9c7eb286fbd46fc319f0e2bb780232baf57825fc0c1912ada2fefe84024c";
286+
287+
assertFalse(
288+
p384Verifier.verifyP384SignatureWithHints(
289+
hash, abi.encodePacked(one48, one48), abi.encodePacked(nonCanonicalX, y), ""
290+
)
291+
);
292+
}
293+
276294
function test_OffchainWitnessGeneratorMatchesSolidityCollector() public {
277295
if (!vm.envOr("NITRO_RUN_FFI", false)) {
278296
console.log("==== OFFCHAIN WITNESS GENERATOR ====");

tools/p384_hints.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ function collectAttestationHintBytes(attestation, leafPubKey) {
162162
}
163163

164164
function isOnCurve(x, y) {
165-
if (x === 0n || x === P || y === 0n || y === P) {
165+
if (x === 0n || x >= P || y === 0n || y >= P) {
166166
return false;
167167
}
168168

0 commit comments

Comments
 (0)