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,p521are 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.tsandsrc/primitives/<id>/impl/<id>.native.ts - AND the
native.tsfile is either a working implementation or a stub that throwsPrimitiveNotImplemented('<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.jsonis inspected after Phase 0.5 completes - THEN
@noble/curvesdoes not appear independenciesorpeerDependencies - AND
src/vendored/contains the forked source with its original LICENSE preserved - AND
NOBLE_UPSTREAM.mdrecords 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.tsto 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.tsin this package is modified - THEN
scripts/ct-lint.tsscans 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
Uint8Arrayof length 65 ANDresult[64]is0or1
Scenario: signWithRecovery output verifies via standard verify
- WHEN the first 64 bytes of
signWithRecovery(digest, privateKey)are passed to the existingverify(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)producessigandrecoverPublicKey(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
SuaveplanErrorwith codeCRYPTO_CURVE.RECOVERY_BYTE_MISSINGandcontext.signatureLength === 64
Scenario: invalid recovery byte rejected
- WHEN a 65-byte signature is passed but
signatureWithRecovery[64]is2,3, or any value > 3 - THEN it throws
SuaveplanErrorwith codeCRYPTO_CURVE.RECOVERY_BYTE_INVALIDandcontext.recoveryBytereflects 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 producedsig - 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 producesig - THEN the result is
false(no exception is thrown)
Scenario: comparison is constant-time
- WHEN the recovered public key and
expectedPublicKeydiffer 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
trueAND 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
verifyreturnsfalse
Scenario: P-384 verify returns false on malformed inputs
- WHEN
verifyis invoked with non-Uint8Arrayinputs 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
trueAND 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
verifyreturnsfalse
Scenario: P-521 verify returns false on malformed inputs
- WHEN
verifyis invoked with non-Uint8Arrayinputs 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)"