Math

P-256

P-256 point math and ECDSA verification for passkeys and ES256

secp256r1, not secp256k1

P-256 sits next to secp256k1. SEC 2 v2 calls it secp256r1 in section 2.4.2. OpenSSL calls it prime256v1. One curve, three names.

The k means Koblitz. The r means random, for verifiably random parameters. P-256 uses y² = x³ - 3x + b over a 256-bit prime. secp256k1 uses y² = x³ + 7. One letter apart, different curves.

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

multiplyGenerator(1n); // "036b17d1f2e12c42…d898c296"
isOnCurve("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"); // false

Both curves use SEC1 hex for points. Compressed points take 33 bytes. Uncompressed points take 65. secp256k1's G looks like a key, but it's knocking on the wrong door.

Verify a P-256 ECDSA signature

P-256 turns up in WebAuthn passkeys and ES256 tokens. It's also in TLS certificates and OpenPGP keys on NIST curves.

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

const publicKey =
  "0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb6" +
  "7903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299";
const digest = "af2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf"; // SHA-256 of "sample"
const signature =
  "efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716" +
  "f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8";

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

The key and signature come from RFC 6979, appendix A.2.5. The RFC signed the six bytes of "sample". Deterministically, with SHA-256.

The signature is r followed by s. It takes 128 hex digits. The digest is the hash of the signed bytes. The public key is compressed or uncompressed SEC1 hex. Keys and signatures never take a 0x prefix.

What about DER and the digest?

Got a passkey assertion or a certificate? Their signatures are DER, so unpack r and s before calling verify. A JWS ES256 signature is already r then s, 64 bytes.

Hash the signed bytes yourself. ES256 and WebAuthn use SHA-256. The digest takes 1 to 64 bytes as hex, without 0x. Past 32 bytes, SEC 1 counts only the leftmost 256 bits.

What does it refuse?

text
verify("00", { digest, publicKey })                        // Signature must be 128 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: secp256k1G })       // Invalid SEC1 P-256 public key
liftX("0000000000000000000000000000000000000000000000000000000000000001")  // No point of P-256 has this x

Wrong shapes throw. An r or s outside 1 to n - 1 returns false. The optional lowS is false by default. Set it to true to refuse s above n / 2.

CLI and agent tool

The CLI uses curves p256 <operation>. Verification takes the same inputs.

shell
curves p256 verify --point 0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299 --digest af2bdbe1aa9b6ec1e2ade1d694f41fc71a831d0268e9891562113d8a62add1bf --signature efd48b2aacb6a8fd1140dd9cd45e81d69d2c877b56aaf991c34d0ea84eaf3716f7cb1c942d657c41d436c7a1b6e29f65f3e900dbb9aff4064dc4ab2f843acda8

It prints:

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

The CLI offers add, subtract, negate, multiply, lift, check and verify. The agent tool curves_p256_compute offers the same operations. Its arguments are point, other, scalar, x, digest, signature, lowS and compressed.

P-256 point math

The @agntn/curves/p256 entry exports addPoints, subtractPoints, negatePoint, multiplyPoint, multiplyGenerator, liftX, isOnCurve, convertPoint and verify. It also exports p256 and INVALID_POINT.

There's no signing, key recovery or scalar math here. Need a wider curve? P-384 and P-521 are here too. Same length, other curve? That's brainpoolP256r1.

Written from the specs

Both curves run on one SEC 1 code. Shared functions behave the same, and the messages only swap the curve's name.

Only addition and doubling differ. For a = -3, P-256 takes algorithms 4 and 6 from Renes, Costello and Batina (2016). secp256k1 takes 7 and 9 for a = 0. The formulas are complete. Infinity and doubling need no branch.

Multiplication takes the same steps for every scalar. In a local run, G took about 0.5 ms. Any other point took about 1.3 ms. A fixed scalar and random ones stayed within 0.33 percent in a local timing probe. Is that constant time? The steps are. JavaScript's bigints promise nothing, so keep real money away from it.

Verification reads G from a faster table. Its scalars are public. In a local run, verification took about 1.5 ms. @noble/curves 2.4.0 took about 2.2 ms on the same signatures.

The tests cover all 262 Wycheproof ECDSA P-256 SHA-256 vectors in r then s form. They check RFC 6979 A.2.5 signatures over SHA-256 and SHA-512. Multiples of G are frozen from @noble/curves 2.4.0. The root entry's generic curve code gets edge scalars too. A local run against noble agreed on 300 random cases.