Math

brainpoolP384r1

brainpoolP384r1 point math and ECDSA verification for OpenPGP keys and Brainpool certificates

brainpoolP384r1, from RFC 5639

brainpoolP384r1 supports SEC1 point math and ECDSA verification. It can verify OpenPGP and TLS Brainpool signatures. RFC 9580 lists the curve for ECDSA and ECDH. RFC 7027 specifies Brainpool curves for TLS. The twisted brainpoolP384t1 isn't in the package.

RFC 5639 section 3.6 defines the curve. Its equation is y² = x³ + ax + b. It uses a 384-bit prime field and cofactor 1. Its a is neither 0 nor -3. Here's that coefficient in hex:

text
7bc382c63d8c150c3c72080ace05afa0c2bea28e4fb22787139165efba91f90f8aa5814a503ad4eb04a8c7dd22ce2826

Try the generator and two point checks:

ts
import { isOnCurve, multiplyGenerator } from "@agntn/curves/brainpoolp384r1";

multiplyGenerator(1n); // "031d1c64f068cf45…47d4af1e"
isOnCurve(`02${"0".repeat(96)}`); // false
isOnCurve("0208d999057ba3d2d969260045c55b97f089025959a6f434d651d207d19fb96e9e4fe0e86ebe0e64f85b96a9c75295df61"); // true, P-384's compressed 2G

Coordinates and scalars each need 48 bytes. That's 96 hex digits. Compressed points need 49 bytes, uncompressed points 97. P-384 uses exactly those lengths too. That's an annoying trap, not a way to identify a curve.

Verify a brainpoolP384r1 ECDSA signature

Got a digest and a public key? Pass them alongside the signature:

ts
import { verify } from "@agntn/curves/brainpoolp384r1";

const publicKey =
  "04192ed5ce547d2336911d3f6cecba227f08df077f6242a9147a914e854e6e32d325fd23ccc42921dc4a7e4c2eb71defd3" +
  "631e69079ba982e7a1cad0a39eff47fc6d6e3a280d081286b624886ba1f3069671ec1a29986d84fb79736d2799e6fc21";
const digest =
  "f9b127f0d81ebcd17b7ba0ea131c660d340b05ce557c82160e0f793de07d38179023942871acb7002dfafdfffc8deace"; // SHA-384 of 313233343030
const signature =
  "0e8e114a1c351405560bf8d47b166bfe957087a8003b353433b6144f7ee7d6f79c8dd14ef229fa7a2f2782bf33708b91" +
  "83aa7ba485dc060df9922f9ccc5da29adb75d44671d18bad0636d2e09c5e2f95e892a79b9fd3b37e1f798b157b567a24";

verify(signature, { digest, publicKey }); // true
verify(signature, { digest: `00${digest.slice(2)}`, publicKey }); // false

The example uses Wycheproof's SHA-384 vector tcId 1. Its six-byte message is 313233343030 in hex, or 123400 in ASCII. The second call replaces the digest's first byte. It doesn't prepend a byte.

The signature contains r followed by s. Together they take 96 bytes, or 192 hex digits. The public key is SEC1 hex, compressed or uncompressed.

What about DER and the digest?

For X.509 DER or OpenPGP's two MPIs, extract r and s. Pad each to 96 hex digits. Join them without 0x.

Pass a digest of 1 to 64 bytes as hex. Don't include 0x. The library doesn't hash the message. OpenPGP and TLS pair this curve with SHA-384.

The order n has 384 bits. Longer digests use only their leftmost 384 bits. A SHA-512 digest gets cut.

What does it refuse?

text
verify("00", { digest, publicKey })               // Signature must be 192 hex digits without 0x, r then s
verify(signature, { digest: "0x00", publicKey })  // Digest must be hex without 0x, 1 to 64 bytes
verify(signature, { digest, publicKey: p384G })   // Invalid SEC1 brainpoolP384r1 public key
liftX("0".repeat(96))                             // No point of brainpoolP384r1 has this x

Here p384G is P-384's uncompressed generator. Malformed inputs throw. Public keys off this curve throw too.

An r or s outside 1 to n - 1 returns false. The optional lowS flag defaults to false. Set it to true to reject s above n / 2.

CLI and agent tool

Want to check from a terminal? Use curves brainpoolp384r1 <operation>:

shell
curves brainpoolp384r1 verify --point 04192ed5ce547d2336911d3f6cecba227f08df077f6242a9147a914e854e6e32d325fd23ccc42921dc4a7e4c2eb71defd3631e69079ba982e7a1cad0a39eff47fc6d6e3a280d081286b624886ba1f3069671ec1a29986d84fb79736d2799e6fc21 --digest f9b127f0d81ebcd17b7ba0ea131c660d340b05ce557c82160e0f793de07d38179023942871acb7002dfafdfffc8deace --signature 0e8e114a1c351405560bf8d47b166bfe957087a8003b353433b6144f7ee7d6f79c8dd14ef229fa7a2f2782bf33708b9183aa7ba485dc060df9922f9ccc5da29adb75d44671d18bad0636d2e09c5e2f95e892a79b9fd3b37e1f798b157b567a24

The output is:

text
{"operation":"verify","valid":true}

The CLI operations are add, subtract, negate, multiply, lift, check and verify. The agent tool curves_brainpoolp384r1_compute supports those operations too. Its arguments are point, other, scalar, x, digest, signature, lowS and compressed.

brainpoolP384r1 point math

The @agntn/curves/brainpoolp384r1 entry exports:

  • addPoints, subtractPoints and negatePoint
  • multiplyPoint and multiplyGenerator
  • liftX, isOnCurve and convertPoint
  • verify, brainpoolP384r1 and INVALID_POINT

The names match the P-384 entry, except for the domain export. brainpoolP384r1 contains the domain parameters. Give liftX an x with 96 hex digits. Scalars range from 1 to n - 1. They're bigints or hex with at most 96 digits.

Why does x = 0 fail? Here b isn't a square modulo p. So zero has no point, but x = 1 does. Zero misses, one hits. The curve isn't taking turns, though: x = 5 and x = 6 both have points.

P-384's compressed G fails the check. Its compressed 2G passes. A compressed point carries x and a parity bit. About half of all x values have a point on any curve. This x happens to work on Brainpool too. The uncompressed P-384 point fails because its y doesn't match.

Know the curve before checking the key. A passing point check isn't a curve label. This entry doesn't sign, recover keys or provide scalar math.

Written from the specs

RFC 5639 describes reproducible, verifiable parameter generation. Brainpool uses pseudo-random parameters instead of special primes for speed. Its prime isn't a shortcut form. Neither is a.

The curve shares src/core/sec1.ts with six others: secp256k1, P-256, P-384, P-521, brainpoolP256r1 and brainpoolP512r1. That's seven curves using one implementation.

The a = 0 and a = -3 formulas reject this curve. They report These formulas are for another a. Addition and doubling use algorithms 1 and 3 from Renes, Costello and Batina (2016). These complete formulas work for any a. They cost a few more multiplications. That's the price of an a nobody picked for speed.

brainpoolP256r1 uses those formulas too. This curve only changes the widths. Both n and p have 384 bits. Since p is 3 mod 4, a square root takes one power.

Multiplication walks 96 hex digits. The secret comb has four rows, 24 steps apart. The verification table has 96 rows of 15 points. Verification reads G from that faster table. Its scalars are public.

A secret multiply takes the same steps for every scalar. test/brainpoolp384r1.test.ts times 1 against n - 1. JavaScript bigints don't promise constant time. This library isn't audited. Keep real money away.

In a local run, multiplying G took about 1.3 ms. Another point took about 3.2 ms. Verification took about 3.2 ms. Generic code for curves you define took about 22.5 ms per multiplication. These timings aren't test assertions.

All 292 Wycheproof SHA-384 P1363 vectors pass. That's 206 valid and 86 invalid signatures. RFC 7027 appendix A.2 supplies two key pairs and their shared secret. Both match.

@noble/curves has no Brainpool. Multiples of G are frozen from OpenSSL 3.6.5 through node:crypto. In a local run, OpenSSL agreed on 200 random keys. The library also verified 20 OpenSSL signatures.