Skip to content

Conversation

@Stephen-Thomson
Copy link
Collaborator

@Stephen-Thomson Stephen-Thomson commented Dec 18, 2025

Description of Changes

This PR hardens elliptic-curve scalar multiplication against timing side-channel attacks by introducing a constant-time mulCT implementation and integrating it into relevant cryptographic flows.

Key improvements include:

  • Added constant-time scalar multiplication (Point.mulCT) using a Montgomery ladder–style approach with constant-time swaps.
  • Ensured consistent handling of edge cases such as zero, negative scalars, and non-generator base points.
  • Integrated constant-time multiplication into ECDSA signing paths where secret scalars are used.
  • Added comprehensive test coverage to validate correctness against existing multiplication logic and to exercise constant-time code paths.

These changes eliminate scalar-dependent branching and memory access patterns that could previously leak information through timing analysis.

This change introduces a measurable performance regression in transaction verification benchmarks (≈30–55%), which is expected and unavoidable due to the removal of variable-time elliptic curve scalar multiplication.

The new implementation enforces constant-time behavior across all scalar operations to mitigate timing side-channel attacks (TOB-4). All other benchmark categories remain unaffected, confirming that the performance impact is isolated to ECC-heavy verification paths.

This tradeoff aligns with industry-standard cryptographic hardening practices and prioritizes key-material safety over raw throughput.

Linked Issues / Tickets

Closes TOB-4

Testing Procedure

  • Added new unit tests covering:
    • Constant-time scalar multiplication correctness vs regular multiplication
    • Negative, zero, and large scalar handling
    • Non-generator base points
    • Edge cases that exercise ctSwap paths
  • Ran the full test suite locally.
  • Verified ECDSA signing and verification still function correctly with constant-time multiplication enabled.
  • I have added new unit tests
  • All tests pass locally
  • I have tested manually in my local environment

Checklist

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have updated CHANGELOG.md with my changes
  • I have run npm run doc and npm run lint one final time before requesting a review
  • I have fixed all linter errors to ensure these changes are compliant with ts-standard
  • I have run npm version patch so that my changes will trigger a new version to be released when they are merged

@codecov
Copy link

codecov bot commented Dec 18, 2025

Codecov Report

❌ Patch coverage is 98.21429% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/primitives/ECDSA.ts 93.75% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link

🏁 Benchmark Comparison (Node 22)

Comparing this PR (7d0ae70) against master (d64fe7f).

⚠️ 4 regressions detected (>5% slower).

Regressions

  • ⚠️ Transaction Verification – deep chain verify is 31.51% slower (580.85 ms vs 441.68 ms).
  • ⚠️ Transaction Verification – wide transaction verify is 29.00% slower (588.72 ms vs 456.36 ms).
  • ⚠️ Transaction Verification – large tx verify is 30.19% slower (293.12 ms vs 225.15 ms).
  • ⚠️ Transaction Verification – nested inputs verify is 55.22% slower (163.35 ms vs 105.24 ms).
Benchmark Metric PR Branch Master Δ Change
BigNumber Arithmetic mul large numbers 8.84 ms 8.90 ms -0.06 ms -0.67%
BigNumber Arithmetic add large numbers 1.47 ms 1.47 ms +0.00 ms 0.00%
BigNumber Serialization toSm big 2.89 ms 2.91 ms -0.02 ms -0.69%
BigNumber Serialization toSm little 2.97 ms 2.98 ms -0.01 ms -0.34%
BigNumber Serialization fromSm big 3.01 ms 3.05 ms -0.04 ms -1.31%
BigNumber Serialization fromSm little 3.10 ms 3.10 ms +0.00 ms 0.00%
BigNumber Serialization fromScriptNum 3.10 ms 3.13 ms -0.03 ms -0.96%
Script Serialization Big script round trip 3.83 ms 3.82 ms +0.01 ms +0.26%
Transaction Verification deep chain verify 580.85 ms 441.68 ms +139.17 ms +31.51%
Transaction Verification wide transaction verify 588.72 ms 456.36 ms +132.36 ms +29.00%
Transaction Verification large tx verify 293.12 ms 225.15 ms +67.97 ms +30.19%
Transaction Verification nested inputs verify 163.35 ms 105.24 ms +58.11 ms +55.22%
Symmetric Key encrypt large 2MB 1578.45 ms 1580.02 ms -1.57 ms -0.10%
Symmetric Key decrypt large 2MB 1535.03 ms 1530.41 ms +4.62 ms +0.30%
Symmetric Key encrypt 50 small 6.71 ms 6.76 ms -0.05 ms -0.74%
Symmetric Key decrypt 50 small 6.34 ms 6.35 ms -0.01 ms -0.16%
Symmetric Key encrypt 200 medium 163.66 ms 164.38 ms -0.72 ms -0.44%
Symmetric Key decrypt 200 medium 159.50 ms 159.96 ms -0.46 ms -0.29%
Reader & Writer mixed ops 0.22 ms 0.22 ms +0.00 ms 0.00%
Reader & Writer large payloads 29.22 ms 28.81 ms +0.41 ms +1.42%
Reader & Writer 3000 small payloads 1.76 ms 1.76 ms +0.00 ms 0.00%
Reader & Writer 400 medium payloads 15.56 ms 15.80 ms -0.24 ms -1.52%
Atomic BEEF Transaction.toAtomicBEEF 1.51 ms 1.52 ms -0.01 ms -0.66%
Atomic BEEF Transaction.fromAtomicBEEF 3.66 ms 3.65 ms +0.01 ms +0.27%

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements constant-time scalar multiplication for elliptic curve points to mitigate timing side-channel attacks (TOB-4). The key improvement is the addition of a mulCT method that uses a Montgomery ladder algorithm with constant-time swaps, eliminating secret-dependent branching and memory access patterns that could leak information through timing analysis.

Key changes:

  • Added constant-time Point.mulCT() method using Montgomery ladder with constant-time swaps
  • Integrated mulCT into ECDSA signing, ECDH key derivation, and public key generation to protect secret scalars
  • Added comprehensive test coverage for constant-time multiplication including edge cases and correctness validation

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/primitives/Point.ts Implements ctSwap helper and mulCT method using Montgomery ladder for constant-time scalar multiplication
src/primitives/ECDSA.ts Replaces variable-time wNAF scalar multiplication with mulCT in signing to protect ephemeral key k
src/primitives/PrivateKey.ts Updates toPublicKey() and deriveSharedSecret() to use mulCT for constant-time private key operations
src/primitives/PublicKey.ts Updates deriveSharedSecret() to use mulCT for constant-time shared secret derivation
src/primitives/__tests/Point.test.ts Adds comprehensive tests for mulCT covering zero scalars, large scalars, non-generator points, and alternating bit patterns
src/primitives/__tests/ECDSA.test.ts Adds stress test for ECDSA signing with large private keys to validate mulCT integration
CHANGELOG.md Documents the constant-time scalar multiplication addition and security improvements
package.json Version bump to 1.9.30
package-lock.json Updates version references to 1.9.30

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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 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.

---

## [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.
Comment on lines +804 to +839
if (kBig < 0n) kBig = -kBig
kBig = biMod(kBig)

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))
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.
Comment on lines +54 to +103
describe('Point.mulCT (constant-time scalar multiplication)', () => {
const G = Point.fromString(
'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
)

it('returns point at infinity for scalar = 0', () => {
const r = G.mulCT(0)
expect(r.isInfinity()).toBe(true)
})

it('matches regular mul for small scalar', () => {
const k = 5
const r1 = G.mul(k)
const r2 = G.mulCT(k)

expect(r2.eq(r1)).toBe(true)
})

it('matches regular mul for large scalar', () => {
const k =
'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'

const r1 = G.mul(k)
const r2 = G.mulCT(k)

expect(r2.eq(r1)).toBe(true)
})

it('works with non-generator base point', () => {
const base = G.mul(3)
const k = 11

const r1 = base.mul(k)
const r2 = base.mulCT(k)

expect(r2.eq(r1)).toBe(true)
})

it('handles alternating bit patterns (ctSwap exercised)', () => {
// 101010... pattern forces both swap paths
const k = BigInt(
'0b101010101010101010101010101010101010101010101010101010101010101'
)

const r1 = G.mul(k.toString(10))
const r2 = G.mulCT(k.toString(10))

expect(r2.eq(r1)).toBe(true)
})
})
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 test suite for mulCT lacks coverage for negative scalars, which is an important edge case. The existing mul method supports negative scalars by returning the negated point, and mulCT should behave consistently. Add a test case that verifies mulCT(-k) equals the negation of mulCT(k), and that it matches mul(-k).

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link

@github-actions
Copy link

🏁 Benchmark Comparison (Node 22)

Comparing this PR (b667e19) against master (446573b).

⚠️ 5 regressions detected (>5% slower). 🎉 1 significant speedup (>5% faster).

Regressions

  • ⚠️ Transaction Verification – deep chain verify is 29.59% slower (628.95 ms vs 485.32 ms).
  • ⚠️ Transaction Verification – wide transaction verify is 29.55% slower (654.65 ms vs 505.33 ms).
  • ⚠️ Transaction Verification – large tx verify is 29.83% slower (329.59 ms vs 253.87 ms).
  • ⚠️ Transaction Verification – nested inputs verify is 55.56% slower (179.91 ms vs 115.65 ms).
  • ⚠️ Atomic BEEF – Transaction.fromAtomicBEEF is 10.12% slower (4.46 ms vs 4.05 ms).

Speedups

  • 🎉 Reader & Writer – 3000 small payloads is 5.64% faster (1.84 ms vs 1.95 ms).
Benchmark Metric PR Branch Master Δ Change
BigNumber Arithmetic mul large numbers 8.80 ms 8.85 ms -0.05 ms -0.56%
BigNumber Arithmetic add large numbers 1.81 ms 1.81 ms +0.00 ms 0.00%
BigNumber Serialization toSm big 2.76 ms 2.76 ms +0.00 ms 0.00%
BigNumber Serialization toSm little 2.82 ms 2.83 ms -0.01 ms -0.35%
BigNumber Serialization fromSm big 3.27 ms 3.28 ms -0.01 ms -0.30%
BigNumber Serialization fromSm little 3.34 ms 3.29 ms +0.05 ms +1.52%
BigNumber Serialization fromScriptNum 3.39 ms 3.37 ms +0.02 ms +0.59%
Script Serialization Big script round trip 3.50 ms 3.53 ms -0.03 ms -0.85%
Transaction Verification deep chain verify 628.95 ms 485.32 ms +143.63 ms +29.59%
Transaction Verification wide transaction verify 654.65 ms 505.33 ms +149.32 ms +29.55%
Transaction Verification large tx verify 329.59 ms 253.87 ms +75.72 ms +29.83%
Transaction Verification nested inputs verify 179.91 ms 115.65 ms +64.26 ms +55.56%
Symmetric Key encrypt large 2MB 1767.70 ms 1783.18 ms -15.48 ms -0.87%
Symmetric Key decrypt large 2MB 1721.41 ms 1755.58 ms -34.17 ms -1.95%
Symmetric Key encrypt 50 small 7.52 ms 7.63 ms -0.11 ms -1.44%
Symmetric Key decrypt 50 small 7.41 ms 7.56 ms -0.15 ms -1.98%
Symmetric Key encrypt 200 medium 185.61 ms 188.66 ms -3.05 ms -1.62%
Symmetric Key decrypt 200 medium 182.45 ms 185.76 ms -3.31 ms -1.78%
Reader & Writer mixed ops 0.26 ms 0.26 ms +0.00 ms 0.00%
Reader & Writer large payloads 42.98 ms 43.93 ms -0.95 ms -2.16%
Reader & Writer 3000 small payloads 1.84 ms 1.95 ms -0.11 ms -5.64%
Reader & Writer 400 medium payloads 24.15 ms 24.69 ms -0.54 ms -2.19%
Atomic BEEF Transaction.toAtomicBEEF 1.43 ms 1.44 ms -0.01 ms -0.69%
Atomic BEEF Transaction.fromAtomicBEEF 4.46 ms 4.05 ms +0.41 ms +10.12%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants