173 lines
11 KiB
Markdown
173 lines
11 KiB
Markdown
# 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)"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|