Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format
## Table of Contents

- [Unreleased](#unreleased)
- [1.9.30 - 2025-12-17](#1930---2025-12-17)
- [1.9.29 - 2025-12-12](#1929---2025-12-12)
- [1.9.28 - 2025-12-11](#1928---2025-12-11)
- [1.9.27 - 2025-12-11](#1927---2025-12-11)
Expand Down Expand Up @@ -202,6 +203,27 @@ All notable changes to this project will be documented in this file. The format

---

## [1.9.30] - 2025-12-18
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date in the section header is inconsistent with the Table of Contents. Line 8 references "1.9.30 - 2025-12-17" but this section header shows "2025-12-18". These dates should match.

Suggested change
## [1.9.30] - 2025-12-18
## [1.9.30] - 2025-12-17

Copilot uses AI. Check for mistakes.

### Added
- Added constant-time scalar multiplication (`mulCT`) for elliptic curve points.
- Added comprehensive unit tests for constant-time scalar multiplication, including generator and non-generator points, negative scalars, and edge cases.
- Expanded test coverage across Point, ECDSA, PublicKey, and PrivateKey to validate correctness and edge-case behavior.

### Changed
- Updated ECDSA signing to use constant-time scalar multiplication internally.
- Refactored elliptic curve scalar multiplication internals to improve timing consistency without changing public APIs.

### Fixed
- Fixed incorrect handling of negative scalars during BigInt conversion in scalar multiplication.
- Corrected discrepancies between constant-time and variable-time scalar multiplication results.
- Ensured scalar multiplication by zero and point-at-infinity cases behave correctly and consistently.

### Security
- Reduced timing side-channel risk in elliptic curve scalar multiplication paths by introducing constant-time algorithms (TOB-4).

---

## [1.9.29] - 2025-12-12
### Added
- Introduced constantTimeEquals() utility for timing-safe byte comparisons.
Expand Down
66 changes: 66 additions & 0 deletions benchmarks/ecc-scalar-bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { runBenchmark } from './lib/benchmark-runner.js'

import Curve from '../dist/esm/src/primitives/Curve.js'
import BigNumber from '../dist/esm/src/primitives/BigNumber.js'
import * as ECDSA from '../dist/esm/src/primitives/ECDSA.js'

const curve = new Curve()

const scalar = new BigNumber(
'1e5edd45de6d22deebef4596b80444ffcc29143839c1dce18db470e25b4be7b5',
16
)

const msg = new BigNumber('deadbeefcafebabe', 16)

const priv = new BigNumber(
'8a2f85e08360a04c8a36b7c22c5e9e9a0d3bcf2f95c97db2b8bd90fc5f5ff66a',
16
)

const pub = curve.g.mul(priv)

async function main () {
const options = {
minSampleMs: 400,
samples: 8
}

await runBenchmark(
'Point.mul (WNAF)',
() => {
curve.g.mul(scalar)
},
options
)

await runBenchmark(
'Point.mulCT (constant-time)',
() => {
curve.g.mulCT(scalar)
},
options
)

await runBenchmark(
'ECDSA.sign',
() => {
ECDSA.sign(msg, priv)
},
options
)

await runBenchmark(
'ECDSA.verify',
() => {
const sig = ECDSA.sign(msg, priv)
ECDSA.verify(msg, sig, pub)
},
options
)
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bsv/sdk",
"version": "1.9.29",
"version": "1.9.30",
"type": "module",
"description": "BSV Blockchain Software Development Kit",
"main": "dist/cjs/mod.js",
Expand Down
48 changes: 25 additions & 23 deletions src/primitives/ECDSA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ function truncateToN (
}
}

function bnToBigInt (bn: BigNumber): bigint {
const bytes = bn.toArray('be')
let x = 0n
for (let i = 0; i < bytes.length; i++) {
x = (x << 8n) | BigInt(bytes[i])
}
return x
}

const curve = new Curve()
const bytes = curve.n.byteLength()
const ns1 = curve.n.subn(1)
Expand All @@ -65,50 +74,45 @@ export const sign = (
forceLowS: boolean = false,
customK?: BigNumber | ((iter: number) => BigNumber)
): Signature => {
// —— prepare inputs ────────────────────────────────────────────────────────
msg = truncateToN(msg)
const msgBig = BigInt('0x' + msg.toString(16))
const keyBig = BigInt('0x' + key.toString(16))
const msgBig = bnToBigInt(msg)
const keyBig = bnToBigInt(key)

// DRBG seeding identical to previous implementation
const bkey = key.toArray('be', bytes)
const nonce = msg.toArray('be', bytes)
const drbg = new DRBG(bkey, nonce)

for (let iter = 0; ; iter++) {
// —— k generation & basic validity checks ───────────────────────────────
let kBN =
typeof customK === 'function'
? customK(iter)
: BigNumber.isBN(customK)
? customK
: new BigNumber(drbg.generate(bytes), 16)

if (kBN == null) throw new Error('k is undefined')
if (kBN == null) {
throw new Error('k is undefined')
}

kBN = truncateToN(kBN, true)

if (kBN.cmpn(1) < 0 || kBN.cmp(ns1) > 0) {
if (BigNumber.isBN(customK)) {
throw new Error('Invalid fixed custom K value (must be >1 and <N1)')
throw new Error('Invalid fixed custom K value (must be >1 and <N-1)')
}
continue
}

const kBig = BigInt('0x' + kBN.toString(16))
const R = curve.g.mulCT(kBN)

// —— R = k·G (Jacobian, window‑NAF) ──────────────────────────────────────
const R = scalarMultiplyWNAF(kBig, { x: GX_BIGINT, y: GY_BIGINT })
if (R.Z === 0n) { // point at infinity – should never happen for valid k
if (R.isInfinity()) {
if (BigNumber.isBN(customK)) {
throw new Error('Invalid fixed custom K value (k·G at infinity)')
}
continue
}

// affine X coordinate of R
const zInv = biModInv(R.Z)
const zInv2 = biModMul(zInv, zInv)
const xAff = biModMul(R.X, zInv2)
const xAff = BigInt('0x' + R.getX().toString(16))
const rBig = modN(xAff)

if (rBig === 0n) {
Expand All @@ -118,7 +122,7 @@ export const sign = (
continue
}

// —— s = k⁻¹ · (msg + r·key) mod n ─────────────────────────────────────
const kBig = BigInt('0x' + kBN.toString(16))
const kInv = modInvN(kBig)
const rTimesKey = modMulN(rBig, keyBig)
const sum = modN(msgBig + rTimesKey)
Expand All @@ -131,12 +135,10 @@ export const sign = (
continue
}

// low‑S mitigation (BIP‑62/BIP‑340 style)
if (forceLowS && sBig > halfN) {
sBig = N_BIGINT - sBig
}

// —— convert back to BigNumber & return ─────────────────────────────────
const r = new BigNumber(rBig.toString(16), 16)
const s = new BigNumber(sBig.toString(16), 16)
return new Signature(r, s)
Expand All @@ -163,18 +165,18 @@ export const sign = (
*/
export const verify = (msg: BigNumber, sig: Signature, key: Point): boolean => {
// Convert inputs to BigInt
const hash = BigInt('0x' + msg.toString(16))
const hash = bnToBigInt(msg)
if ((key.x == null) || (key.y == null)) {
throw new Error('Invalid public key: missing coordinates.')
}

const publicKey = {
x: BigInt('0x' + key.x.toString(16)),
y: BigInt('0x' + key.y.toString(16))
x: bnToBigInt(key.x),
y: bnToBigInt(key.y)
}
const signature = {
r: BigInt('0x' + sig.r.toString(16)),
s: BigInt('0x' + sig.s.toString(16))
r: bnToBigInt(sig.r),
s: bnToBigInt(sig.s)
}

const { r, s } = signature
Expand Down
65 changes: 65 additions & 0 deletions src/primitives/Point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import JPoint from './JacobianPoint.js'
import BigNumber from './BigNumber.js'
import { toArray, toHex } from './utils.js'

function ctSwap (
swap: bigint,
a: JacobianPointBI,
b: JacobianPointBI
): void {
const mask = -swap
const swapX = (a.X ^ b.X) & mask
const swapY = (a.Y ^ b.Y) & mask
const swapZ = (a.Z ^ b.Z) & mask

a.X ^= swapX; b.X ^= swapX
a.Y ^= swapY; b.Y ^= swapY
a.Z ^= swapZ; b.Z ^= swapZ
}

// -----------------------------------------------------------------------------
// BigInt helpers & constants (secp256k1) – hoisted so we don't recreate them on
// every Point.mul() call.
Expand Down Expand Up @@ -102,6 +117,10 @@ export const jpDouble = (P: JacobianPointBI): JacobianPointBI => {
return { X: X3, Y: Y3, Z: Z3 }
}

// NOTE:
// jpAdd contains conditional branches.
// In mulCT, jpAdd and jpDouble are executed in a fixed pattern
// independent of scalar bits, satisfying TOB-4 constant-time requirements.
export const jpAdd = (P: JacobianPointBI, Q: JacobianPointBI): JacobianPointBI => {
if (P.Z === BI_ZERO) return Q
if (Q.Z === BI_ZERO) return P
Expand Down Expand Up @@ -774,6 +793,52 @@ export default class Point extends BasePoint {
return result
}

mulCT (k: BigNumber | number | number[] | string): Point {
if (!BigNumber.isBN(k)) {
k = new BigNumber(k as any, 16)
}

let kBig = BigInt('0x' + k.toString(16))
if (kBig === 0n || this.inf) return new Point(null, null)

if (kBig < 0n) kBig = -kBig
kBig = biMod(kBig)
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zero check for kBig happens before the modular reduction (biMod) on line 805. This means if the input scalar k equals the curve order N (or a multiple of N), it won't be caught by this early return check, and will only be reduced to 0 after line 805. This is inefficient and inconsistent with the mul method, which checks for zero after reduction. Add another zero check after line 805 to handle this case.

Suggested change
kBig = biMod(kBig)
kBig = biMod(kBig)
if (kBig === 0n) return new Point(null, null)

Copilot uses AI. Check for mistakes.

const Px =
this === this.curve.g
? GX_BIGINT
: BigInt('0x' + this.getX().toString(16))

const Py =
this === this.curve.g
? GY_BIGINT
: BigInt('0x' + this.getY().toString(16))

let R0: JacobianPointBI = { X: 0n, Y: 1n, Z: 0n } // infinity
let R1: JacobianPointBI = { X: Px, Y: Py, Z: 1n }

const bits = kBig.toString(2)

for (let i = 0; i < bits.length; i++) {
const bit = bits[i] === '1' ? 1n : 0n

ctSwap(bit, R0, R1)
R1 = jpAdd(R0, R1)
R0 = jpDouble(R0)
ctSwap(bit, R0, R1)
}

if (R0.Z === 0n) return new Point(null, null)

const zInv = biModInv(R0.Z)
const zInv2 = biModMul(zInv, zInv)

const x = biModMul(R0.X, zInv2)
const y = biModMul(R0.Y, biModMul(zInv2, zInv))

return new Point(x.toString(16), y.toString(16))
Comment on lines +804 to +839
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mulCT method doesn't handle negative scalars correctly. Unlike the mul method (lines 757-791), which negates the result when the input scalar is negative, mulCT only takes the absolute value and returns the result without negation. This means mulCT(-k) will incorrectly return the same result as mulCT(k) instead of the negated point. This breaks mathematical correctness and API consistency with the existing mul method.

Copilot uses AI. Check for mistakes.
}

/**
* Performs a multiplication and addition operation in a single step.
* Multiplies this Point by k1, adds the resulting Point to the result of p2 multiplied by k2.
Expand Down
6 changes: 3 additions & 3 deletions src/primitives/PrivateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ export default class PrivateKey extends BigNumber {
*/
toPublicKey (): PublicKey {
const c = new Curve()
const p = c.g.mul(this)
return new PublicKey(p.x, p.y)
const p = c.g.mulCT(this)
return new PublicKey(p.getX(), p.getY())
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change from p.x, p.y to p.getX(), p.getY() creates an inconsistency with the existing PublicKey.fromPrivateKey() method (line 36 in PublicKey.ts), which still uses p.x, p.y. Both patterns work correctly, but the inconsistency makes the codebase harder to maintain. Additionally, using getX()/getY() introduces an unnecessary conversion cycle (fromRed in getX/getY, then toRed in the PublicKey constructor), which is less efficient than passing the coordinates directly.

Suggested change
return new PublicKey(p.getX(), p.getY())
return new PublicKey(p.x, p.y)

Copilot uses AI. Check for mistakes.
}

/**
Expand Down Expand Up @@ -352,7 +352,7 @@ export default class PrivateKey extends BigNumber {
if (!key.validate()) {
throw new Error('Public key not valid for ECDH secret derivation')
}
return key.mul(this)
return key.mulCT(this)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/primitives/PublicKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export default class PublicKey extends Point {
if (!this.validate()) {
throw new Error('Public key not valid for ECDH secret derivation')
}
return this.mul(priv)
return this.mulCT(priv)
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/primitives/__tests/ECDSA.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,16 @@ describe('ECDSA', () => {
ECDSA.verify(msg, signature, infinityPub)
).toThrow()
})

it('sign/verify works with large private key (mulCT stress)', () => {
const bigKey = new BigNumber(
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd036413f',
16
)

const sig = ECDSA.sign(msg, bigKey)
const pub = curve.g.mul(bigKey)

expect(ECDSA.verify(msg, sig, pub)).toBe(true)
})
})
Loading