Skip to content

Commit 6048ed2

Browse files
authored
Merge pull request #414 from bsv-blockchain/TOB-24
Fix TOB-24: Improve EC point validation in fromDER, fromX, and fromJSON
2 parents 39aa7ae + 78fa951 commit 6048ed2

File tree

5 files changed

+134
-23
lines changed

5 files changed

+134
-23
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format
55
## Table of Contents
66

77
- [Unreleased](#unreleased)
8+
- [1.9.27 - 2025-12-11](#1927---2025-12-11)
89
- [1.9.26 - 2025-12-10](#1926---2025-12-10)
910
- [1.9.25 - 2025-12-09](#1925---2025-12-09)
1011
- [1.9.24 - 2025-12-09](#1924---2025-12-09)
@@ -199,6 +200,17 @@ All notable changes to this project will be documented in this file. The format
199200

200201
---
201202

203+
## [1.9.27] - 2025-12-11
204+
205+
### Fixed
206+
- Addressed TOB-24: hardened elliptic-curve point validation across `fromDER`, `fromX`, and `fromJSON`.
207+
- Added bigint-secure curve equation checking to `Point.validate()`.
208+
- Fixed modular sqrt and pow logic (`biModSqrt`, `biModPow`) to correctly detect invalid X coordinates.
209+
- Ensured consistent `Invalid point` errors for malformed input.
210+
- Added negative tests and roundtrip validation tests.
211+
212+
---
213+
202214
## [1.9.26] - 2025-12-10
203215

204216
### Security

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bsv/sdk",
3-
"version": "1.9.26",
3+
"version": "1.9.27",
44
"type": "module",
55
"description": "BSV Blockchain Software Development Kit",
66
"main": "dist/cjs/mod.js",

src/primitives/Point.ts

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,30 @@ export const biModInv = (a: bigint): bigint => { // binary‑ext GCD
4444
export const biModSqr = (a: bigint): bigint => biModMul(a, a)
4545

4646
export const biModPow = (base: bigint, exp: bigint): bigint => {
47-
let result = BI_ONE
47+
let result = 1n
4848
base = biMod(base)
49-
let e = exp
50-
while (e > BI_ZERO) {
51-
if ((e & BI_ONE) === BI_ONE) result = biModMul(result, base)
49+
50+
while (exp > 0n) {
51+
if ((exp & 1n) !== 0n) {
52+
result = biModMul(result, base)
53+
}
5254
base = biModMul(base, base)
53-
e >>= BI_ONE
55+
exp >>= 1n
5456
}
57+
5558
return result
5659
}
5760

5861
export const P_PLUS1_DIV4 = (P_BIGINT + 1n) >> 2n
5962

6063
export const biModSqrt = (a: bigint): bigint | null => {
6164
const r = biModPow(a, P_PLUS1_DIV4)
62-
return biModMul(r, r) === biMod(a) ? r : null
65+
66+
if (biModMul(r, r) !== biMod(a)) {
67+
return null
68+
}
69+
70+
return r
6371
}
6472

6573
const toBigInt = (x: BigNumber | number | number[] | string): bigint => {
@@ -220,6 +228,13 @@ export default class Point extends BasePoint {
220228
y: BigNumber | null
221229
inf: boolean
222230

231+
static _assertOnCurve (p: Point): Point {
232+
if (!p.validate()) {
233+
throw new Error('Invalid point')
234+
}
235+
return p
236+
}
237+
223238
/**
224239
* Creates a point object from a given Array. These numbers can represent coordinates in hex format, or points
225240
* in multiple established formats.
@@ -238,7 +253,6 @@ export default class Point extends BasePoint {
238253
*/
239254
static fromDER (bytes: number[]): Point {
240255
const len = 32
241-
// uncompressed, hybrid-odd, hybrid-even
242256
if (
243257
(bytes[0] === 0x04 || bytes[0] === 0x06 || bytes[0] === 0x07) &&
244258
bytes.length - 1 === 2 * len
@@ -258,12 +272,14 @@ export default class Point extends BasePoint {
258272
bytes.slice(1 + len, 1 + 2 * len)
259273
)
260274

261-
return res
275+
return Point._assertOnCurve(res)
262276
} else if (
263277
(bytes[0] === 0x02 || bytes[0] === 0x03) &&
264278
bytes.length - 1 === len
265279
) {
266-
return Point.fromX(bytes.slice(1, 1 + len), bytes[0] === 0x03)
280+
return Point._assertOnCurve(
281+
Point.fromX(bytes.slice(1, 1 + len), bytes[0] === 0x03)
282+
)
267283
}
268284
throw new Error('Unknown point format')
269285
}
@@ -287,7 +303,7 @@ export default class Point extends BasePoint {
287303
*/
288304
static fromString (str: string): Point {
289305
const bytes = toArray(str, 'hex')
290-
return Point.fromDER(bytes)
306+
return Point._assertOnCurve(Point.fromDER(bytes))
291307
}
292308

293309
/**
@@ -308,16 +324,22 @@ export default class Point extends BasePoint {
308324
static fromX (x: BigNumber | number | number[] | string, odd: boolean): Point {
309325
let xBigInt = toBigInt(x)
310326
xBigInt = biMod(xBigInt)
327+
311328
const y2 = biModAdd(biModMul(biModSqr(xBigInt), xBigInt), 7n)
312329
const y = biModSqrt(y2)
313-
if (y === null) throw new Error('Invalid point')
330+
if (y === null) {
331+
throw new Error('Invalid point')
332+
}
333+
314334
let yBig = y
315335
if ((yBig & BI_ONE) !== (odd ? BI_ONE : BI_ZERO)) {
316336
yBig = biModSub(P_BIGINT, yBig)
317337
}
338+
318339
const xBN = new BigNumber(xBigInt.toString(16), 16)
319340
const yBN = new BigNumber(yBig.toString(16), 16)
320-
return new Point(xBN, yBN)
341+
342+
return Point._assertOnCurve(new Point(xBN, yBN))
321343
}
322344

323345
/**
@@ -339,33 +361,45 @@ export default class Point extends BasePoint {
339361
if (typeof obj === 'string') {
340362
obj = JSON.parse(obj)
341363
}
342-
const res = new Point(obj[0], obj[1], isRed)
343-
if (typeof obj[2] !== 'object') {
364+
365+
let res = new Point(obj[0], obj[1], isRed)
366+
res = Point._assertOnCurve(res)
367+
368+
if (typeof obj[2] !== 'object' || obj[2] === null) {
344369
return res
345370
}
346371

347-
const obj2point = (obj): Point => {
348-
return new Point(obj[0], obj[1], isRed)
372+
const pre = obj[2]
373+
374+
const obj2point = (p): Point => {
375+
const pt = new Point(p[0], p[1], isRed)
376+
return Point._assertOnCurve(pt)
349377
}
350378

351-
const pre = obj[2]
352379
res.precomputed = {
353380
beta: null,
381+
354382
doubles:
355383
typeof pre.doubles === 'object' && pre.doubles !== null
356384
? {
357385
step: pre.doubles.step,
358-
points: [res].concat(pre.doubles.points.map(obj2point))
386+
points: [res].concat(
387+
pre.doubles.points.map(obj2point)
388+
)
359389
}
360390
: undefined,
391+
361392
naf:
362393
typeof pre.naf === 'object' && pre.naf !== null
363394
? {
364395
wnd: pre.naf.wnd,
365-
points: [res].concat(pre.naf.points.map(obj2point))
396+
points: [res].concat(
397+
pre.naf.points.map(obj2point)
398+
)
366399
}
367400
: undefined
368401
}
402+
369403
return res
370404
}
371405

@@ -426,7 +460,20 @@ export default class Point extends BasePoint {
426460
* const isValid = aPoint.validate();
427461
*/
428462
validate (): boolean {
429-
return this.curve.validate(this)
463+
if (this.inf || this.x == null || this.y == null) return false
464+
465+
try {
466+
const xBig = BigInt('0x' + this.x.fromRed().toString(16))
467+
const yBig = BigInt('0x' + this.y.fromRed().toString(16))
468+
469+
// compute y² and x³ + 7 using bigint-secure field ops
470+
const lhs = biModMul(yBig, yBig)
471+
const rhs = biModAdd(biModMul(biModMul(xBig, xBig), xBig), 7n)
472+
473+
return lhs === rhs
474+
} catch {
475+
return false
476+
}
430477
}
431478

432479
/**
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Point from '../../primitives/Point'
2+
3+
describe('Point.fromJSON / fromDER / fromX curve validation (TOB-24)', () => {
4+
it('rejects clearly off-curve coordinates', () => {
5+
expect(() =>
6+
Point.fromJSON([123, 456], true)
7+
).toThrow(/Invalid point/)
8+
})
9+
10+
it('rejects nested off-curve precomputed points', () => {
11+
const bad = [
12+
123,
13+
456,
14+
{
15+
doubles: {
16+
step: 2,
17+
points: [
18+
[1, 2],
19+
[3, 4]
20+
]
21+
}
22+
}
23+
]
24+
expect(() => Point.fromJSON(bad, true)).toThrow(/Invalid point/)
25+
})
26+
27+
it('accepts valid generator point from toJSON → fromJSON roundtrip', () => {
28+
// Compressed secp256k1 G:
29+
const G_COMPRESSED =
30+
'0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'
31+
32+
const g = Point.fromString(G_COMPRESSED)
33+
const serialized = g.toJSON()
34+
const restored = Point.fromJSON(serialized as any, true)
35+
36+
expect(restored.eq(g)).toBe(true)
37+
})
38+
39+
it('rejects invalid compressed points in fromDER', () => {
40+
// 0x02 is a valid compressed prefix, but x = 0 gives y^2 = 7,
41+
// which has no square root mod p on secp256k1 → invalid point.
42+
const der = [0x02, ...Array(32).fill(0x00)]
43+
expect(() => Point.fromDER(der)).toThrow(/Invalid point/)
44+
})
45+
46+
it('fromX rejects values with no square root mod p', () => {
47+
// x = 0 ⇒ y^2 = 7, which has no square root mod p on secp256k1.
48+
// This guarantees that fromX must reject it.
49+
const badX = '0000000000000000000000000000000000000000000000000000000000000000'
50+
expect(() => Point.fromX(badX, true)).toThrow(/Invalid point/)
51+
})
52+
})

0 commit comments

Comments
 (0)