Files
2026-05-27 23:29:53 +01:00

11 KiB

crypto-curve

Purpose

Elliptic-curve primitives for the Suaveplan monorepo: secp256k1 (Bitcoin / Ethereum) including ECDSA recovery, ed25519 signing, x25519 key agreement, and the NIST curves p256, p384, p521. Per-primitive selector layout enables flipping the default backend from noble to a native engine without changing any caller imports.

Requirements

Requirement: Universal curve primitive surface

@suaveplan/crypto-curve SHALL expose a stable public API for the curves listed below, independent of the underlying implementation source.

Scenario: Curves available

  • WHEN a consumer imports from @suaveplan/crypto-curve
  • THEN secp256k1, ed25519, x25519, p256, p384, p521 are exported
  • AND each curve exposes the operations it supports: getPublicKey, sign, verify, getSharedSecret (ECDH), utils.randomPrivateKey, as appropriate
  • AND each curve is also exported from the subpath @suaveplan/crypto-curve/<id> for tree-shaking

Requirement: Per-primitive replaceability

Every curve in @suaveplan/crypto-curve SHALL have both a vendor-forked noble implementation and a native implementation slot, selected per-primitive at build time via the root crypto.config.ts.

Scenario: Every curve has both impl files

  • WHEN the package is inspected
  • THEN every curve id has both src/primitives/<id>/impl/<id>.noble.ts and src/primitives/<id>/impl/<id>.native.ts
  • AND the native.ts file is either a working implementation or a stub that throws PrimitiveNotImplemented('<id>')

Requirement: Vendored noble source (Phase 0.5)

From Phase 0.5 onward, the noble implementation for every curve SHALL come from a vendor-fork of @noble/curves stored inside this package, not from the @noble/curves npm package.

Scenario: No external runtime dependency on @noble/curves (post-0.5)

  • WHEN packages/universal/crypto-curve/package.json is inspected after Phase 0.5 completes
  • THEN @noble/curves does not appear in dependencies or peerDependencies
  • AND src/vendored/ contains the forked source with its original LICENSE preserved
  • AND NOBLE_UPSTREAM.md records the pinned upstream commit SHA and sync date

Requirement: Dangerous-tier CI gate

Every primitive in this package belongs to the dangerous tier. A curve's default SHALL NOT be changed from 'noble' to 'native' in crypto.config.ts unless the flip PR demonstrates all gates required by crypto-hash, plus a third-party cryptographic audit sign-off covering the specific native implementation being flipped.

Scenario: Audit required to flip

  • WHEN a PR modifies crypto.config.ts to change a curve entry from 'noble' to 'native'
  • THEN the flip PR template requires an audit report (PDF + auditor signature) attached to the PR
  • AND the audit report references the exact commit SHA of the native impl
  • AND the merge is blocked without a valid audit attachment

Requirement: Constant-time curve math

Native curve implementations SHALL NOT contain data-dependent branches on private-key material, scalar values, or intermediate field elements derived from secrets. Array indexing on secret values is prohibited.

Scenario: Constant-time lint enforces this

  • WHEN any impl/*.native.ts in this package is modified
  • THEN scripts/ct-lint.ts scans for the prohibited patterns
  • AND any match fails CI with a line-level diagnostic

Requirement: secp256k1 ECDSA signing with recovery byte

The secp256k1 primitive SHALL expose signWithRecovery(digest, privateKey) returning a 65-byte signature (64-byte compact signature followed by a single recovery byte). The recovery byte SHALL be the byte returned by secp256k1.sign(digest, privateKey).recovery from the @noble/curves library; valid values are 0 or 1.

Scenario: produces 65-byte signature

  • WHEN signWithRecovery(digest, privateKey) is called for a valid digest and private key
  • THEN the result is a Uint8Array of length 65 AND result[64] is 0 or 1

Scenario: signWithRecovery output verifies via standard verify

  • WHEN the first 64 bytes of signWithRecovery(digest, privateKey) are passed to the existing verify(digest, sig64, publicKey)
  • THEN the signature verifies successfully

Verified by: packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery returns 65-byte signature with recovery byte 0 or 1" AND packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"signWithRecovery output verifies via standard verify on first 64 bytes"


Requirement: secp256k1 public-key recovery

The secp256k1 primitive SHALL expose recoverPublicKey(signatureWithRecovery, digest) returning the 33-byte compressed public key recovered from a 65-byte signature-with-recovery and the original digest. Inputs may be supplied as Uint8Array or hex strings.

Scenario: recovers the signing public key

  • WHEN signWithRecovery(digest, privateKey) produces sig and recoverPublicKey(sig, digest) is called
  • THEN the recovered public key equals getPublicKey(privateKey) byte-for-byte

Scenario: missing recovery byte rejected

  • WHEN a 64-byte signature (without recovery byte) is passed to recoverPublicKey
  • THEN it throws SuaveplanError with code CRYPTO_CURVE.RECOVERY_BYTE_MISSING and context.signatureLength === 64

Scenario: invalid recovery byte rejected

  • WHEN a 65-byte signature is passed but signatureWithRecovery[64] is 2, 3, or any value > 3
  • THEN it throws SuaveplanError with code CRYPTO_CURVE.RECOVERY_BYTE_INVALID and context.recoveryByte reflects the offending value

Verified by: packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"recoverPublicKey returns the same key as getPublicKey for the signer" AND packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_MISSING for 64-byte signature" AND packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"throws RECOVERY_BYTE_INVALID when recovery byte is out of range"


Requirement: secp256k1 verify-with-recovery

The secp256k1 primitive SHALL expose verifyWithRecovery(signatureWithRecovery, digest, expectedPublicKey) returning true only when the public key recovered from the signature equals expectedPublicKey (compared in constant time on the byte buffer). When the recovered key differs, the function SHALL return false (it does NOT throw on mismatch — mismatch is a routine path).

Scenario: returns true on matching key

  • WHEN verifyWithRecovery(sig, digest, expectedPublicKey) is called with the public key that produced sig
  • THEN the result is true

Scenario: returns false on non-matching key

  • WHEN verifyWithRecovery(sig, digest, otherPublicKey) is called with a public key that did not produce sig
  • THEN the result is false (no exception is thrown)

Scenario: comparison is constant-time

  • WHEN the recovered public key and expectedPublicKey differ in their first byte vs differ only in their last byte
  • THEN the timing distribution observed across many invocations does not reveal the position of the difference (verified via a statistical timing test)

Verified by: packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns true on matching public key" AND packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.test.ts::"verifyWithRecovery returns false on non-matching public key" AND packages/crypto/crypto-curve/src/primitives/secp256k1/secp256k1.spec-conformance.test.ts::"verifyWithRecovery comparison is constant-time"


Requirement: NIST P-384 (secp384r1) ECDSA primitive

The package SHALL export a p384 namespace from the root barrel and a matching ./p384 subpath export, providing getPublicKey, sign, verify, and randomPrivateKey. Private keys SHALL be 48 bytes, compressed public keys SHALL be 49 bytes, and compact ECDSA signatures SHALL be 96 bytes. Callers SHALL pre-hash messages; the module operates on digests. The selector SHALL forward to the noble-backed implementation by default; the native implementation SHALL throw PrimitiveNotImplemented("p384") until a WebCrypto bridge lands.

Scenario: P-384 sign + verify round-trip

  • GIVEN a freshly-generated P-384 private key and 48-byte digest
  • WHEN verify(sign(digest, sk), digest, getPublicKey(sk)) is computed
  • THEN the result is true AND the signature length is 96 bytes

Scenario: P-384 verify rejects tampered signature

  • WHEN a single byte of a valid P-384 signature is XORed with 0xff
  • THEN verify returns false

Scenario: P-384 verify returns false on malformed inputs

  • WHEN verify is invoked with non-Uint8Array inputs or empty buffers
  • THEN the call returns false (does not throw)

Verified by: packages/crypto/crypto-curve/src/primitives/p384/p384.test.ts::"sign + verify round-trips" AND …::"verify returns false for tampered signature (no throw)" AND …::"verify returns false for bogus input (caught)"


Requirement: NIST P-521 (secp521r1) ECDSA primitive

The package SHALL export a p521 namespace from the root barrel and a matching ./p521 subpath export, providing getPublicKey, sign, verify, and randomPrivateKey. Private keys SHALL be 66 bytes (521-bit scalar left-padded to a byte boundary), compressed public keys SHALL be 67 bytes, and compact ECDSA signatures SHALL be 132 bytes. Callers SHALL pre-hash messages; the module operates on digests. The selector SHALL forward to the noble-backed implementation by default; the native implementation SHALL throw PrimitiveNotImplemented("p521").

Scenario: P-521 sign + verify round-trip

  • GIVEN a freshly-generated P-521 private key and 64-byte digest
  • WHEN verify(sign(digest, sk), digest, getPublicKey(sk)) is computed
  • THEN the result is true AND the signature length is 132 bytes

Scenario: P-521 verify rejects tampered signature

  • WHEN a single byte of a valid P-521 signature is XORed with 0xff
  • THEN verify returns false

Scenario: P-521 verify returns false on malformed inputs

  • WHEN verify is invoked with non-Uint8Array inputs or empty buffers
  • THEN the call returns false (does not throw)

Verified by: packages/crypto/crypto-curve/src/primitives/p521/p521.test.ts::"sign + verify round-trips" AND …::"verify returns false for tampered signature (no throw)" AND …::"verify returns false for bogus input (caught)"