Skip to content

Commit b0043a3

Browse files
committed
r1 1
1 parent 726e75d commit b0043a3

File tree

2 files changed

+330
-0
lines changed

2 files changed

+330
-0
lines changed

src/primitives/Secp256r1.ts

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import crypto from 'crypto'
2+
3+
export type P256Point = { x: bigint, y: bigint } | null
4+
5+
type ByteSource = string | Uint8Array | ArrayBufferView
6+
7+
const HEX_REGEX = /^[0-9a-fA-F]+$/
8+
9+
const P = BigInt('0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff')
10+
const N = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551')
11+
const A = P - 3n // a = -3 mod p
12+
const B = BigInt('0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b')
13+
const GX = BigInt('0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296')
14+
const GY = BigInt('0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5')
15+
const G: P256Point = { x: GX, y: GY }
16+
const HALF_N = N >> 1n
17+
18+
const COMPRESSED_EVEN = '02'
19+
const COMPRESSED_ODD = '03'
20+
const UNCOMPRESSED = '04'
21+
22+
export default class Secp256r1 {
23+
readonly p = P
24+
readonly n = N
25+
readonly a = A
26+
readonly b = B
27+
readonly g = G
28+
29+
private mod (x: bigint, m: bigint = this.p): bigint {
30+
const v = x % m
31+
return v >= 0n ? v : v + m
32+
}
33+
34+
private modInv (x: bigint, m: bigint): bigint {
35+
if (x === 0n || m <= 0n) throw new Error('Invalid mod inverse input')
36+
let [a, b] = [this.mod(x, m), m]
37+
let [u, v] = [1n, 0n]
38+
while (b !== 0n) {
39+
const q = a / b
40+
;[a, b] = [b, a - q * b]
41+
;[u, v] = [v, u - q * v]
42+
}
43+
if (a !== 1n) throw new Error('Inverse does not exist')
44+
return this.mod(u, m)
45+
}
46+
47+
private modPow (base: bigint, exponent: bigint, modulus: bigint): bigint {
48+
if (modulus === 1n) return 0n
49+
let result = 1n
50+
let b = this.mod(base, modulus)
51+
let e = exponent
52+
while (e > 0n) {
53+
if (e & 1n) result = this.mod(result * b, modulus)
54+
e >>= 1n
55+
b = this.mod(b * b, modulus)
56+
}
57+
return result
58+
}
59+
60+
private isInfinity (p: P256Point): p is null {
61+
return p === null
62+
}
63+
64+
private assertOnCurve (p: P256Point): void {
65+
if (this.isInfinity(p)) return
66+
const { x, y } = p
67+
const left = this.mod(y * y)
68+
const right = this.mod(this.mod(x * x * x + this.a * x) + this.b)
69+
if (left !== right) {
70+
throw new Error('Point is not on secp256r1')
71+
}
72+
}
73+
74+
pointFromAffine (x: bigint, y: bigint): P256Point {
75+
const point: P256Point = { x: this.mod(x), y: this.mod(y) }
76+
this.assertOnCurve(point)
77+
return point
78+
}
79+
80+
pointFromHex (hex: string): P256Point {
81+
if (hex.startsWith(UNCOMPRESSED)) {
82+
const x = BigInt('0x' + hex.slice(2, 66))
83+
const y = BigInt('0x' + hex.slice(66))
84+
return this.pointFromAffine(x, y)
85+
}
86+
if (hex.startsWith(COMPRESSED_EVEN) || hex.startsWith(COMPRESSED_ODD)) {
87+
const x = BigInt('0x' + hex.slice(2))
88+
const ySq = this.mod(this.mod(x * x * x + this.a * x) + this.b)
89+
const y = this.modPow(ySq, (this.p + 1n) >> 2n, this.p)
90+
const isOdd = (y & 1n) === 1n
91+
const shouldBeOdd = hex.startsWith(COMPRESSED_ODD)
92+
const yFinal = (isOdd === shouldBeOdd) ? y : this.p - y
93+
return this.pointFromAffine(x, yFinal)
94+
}
95+
throw new Error('Invalid point encoding')
96+
}
97+
98+
pointToHex (p: P256Point, compressed = false): string {
99+
if (this.isInfinity(p)) return '00'
100+
const xHex = this.to32BytesHex(p.x)
101+
const yHex = this.to32BytesHex(p.y)
102+
if (!compressed) return UNCOMPRESSED + xHex + yHex
103+
const prefix = (p.y & 1n) === 0n ? COMPRESSED_EVEN : COMPRESSED_ODD
104+
return prefix + xHex
105+
}
106+
107+
private addPoints (p1: P256Point, p2: P256Point): P256Point {
108+
if (this.isInfinity(p1)) return p2
109+
if (this.isInfinity(p2)) return p1
110+
111+
const { x: x1, y: y1 } = p1
112+
const { x: x2, y: y2 } = p2
113+
114+
if (x1 === x2) {
115+
if (y1 === y2) {
116+
return this.doublePoint(p1)
117+
}
118+
return null
119+
}
120+
121+
const m = this.mod((y2 - y1) * this.modInv(x2 - x1, this.p))
122+
const x3 = this.mod(m * m - x1 - x2)
123+
const y3 = this.mod(m * (x1 - x3) - y1)
124+
return { x: x3, y: y3 }
125+
}
126+
127+
private doublePoint (p: P256Point): P256Point {
128+
if (this.isInfinity(p)) return p
129+
if (p.y === 0n) return null
130+
const m = this.mod((3n * p.x * p.x + this.a) * this.modInv(2n * p.y, this.p))
131+
const x3 = this.mod(m * m - 2n * p.x)
132+
const y3 = this.mod(m * (p.x - x3) - p.y)
133+
return { x: x3, y: y3 }
134+
}
135+
136+
add (p1: P256Point, p2: P256Point): P256Point {
137+
return this.addPoints(p1, p2)
138+
}
139+
140+
multiply (point: P256Point, scalar: bigint): P256Point {
141+
if (scalar === 0n || this.isInfinity(point)) return null
142+
let k = this.mod(scalar, this.n)
143+
let result: P256Point = null
144+
let addend: P256Point = point
145+
while (k > 0n) {
146+
if (k & 1n) {
147+
result = this.addPoints(result, addend)
148+
}
149+
addend = this.doublePoint(addend)
150+
k >>= 1n
151+
}
152+
return result
153+
}
154+
155+
multiplyBase (scalar: bigint): P256Point {
156+
return this.multiply(this.g, scalar)
157+
}
158+
159+
isOnCurve (p: P256Point): boolean {
160+
try {
161+
this.assertOnCurve(p)
162+
return true
163+
} catch (err) {
164+
return false
165+
}
166+
}
167+
168+
generatePrivateKeyHex (): string {
169+
return this.to32BytesHex(this.randomScalar())
170+
}
171+
172+
private randomScalar (): bigint {
173+
while (true) {
174+
const bytes = crypto.randomBytes(32)
175+
const k = BigInt('0x' + bytes.toString('hex'))
176+
if (k > 0n && k < this.n) return k
177+
}
178+
}
179+
180+
private normalizePrivateKey (d: bigint): bigint {
181+
const key = this.mod(d, this.n)
182+
if (key === 0n) throw new Error('Invalid private key')
183+
return key
184+
}
185+
186+
private toScalar (input: string | bigint): bigint {
187+
if (typeof input === 'bigint') return this.normalizePrivateKey(input)
188+
const hex = input.startsWith('0x') ? input.slice(2) : input
189+
if (!HEX_REGEX.test(hex) || hex.length === 0 || hex.length > 64) {
190+
throw new Error('Private key must be a hex string <= 32 bytes')
191+
}
192+
const value = BigInt('0x' + hex.padStart(64, '0'))
193+
return this.normalizePrivateKey(value)
194+
}
195+
196+
publicKeyFromPrivate (privateKey: string | bigint): P256Point {
197+
const d = this.toScalar(privateKey)
198+
return this.multiplyBase(d)
199+
}
200+
201+
sign (message: ByteSource, privateKey: string | bigint, opts: { prehashed?: boolean, nonce?: bigint } = {}): { r: string, s: string } {
202+
const { prehashed = false, nonce } = opts
203+
const d = this.toScalar(privateKey)
204+
const z = this.messageToBigInt(message, prehashed)
205+
let k = nonce ?? this.randomScalar()
206+
207+
while (true) {
208+
const p = this.multiplyBase(k)
209+
if (this.isInfinity(p)) {
210+
k = nonce ?? this.randomScalar()
211+
continue
212+
}
213+
const r = this.mod(p.x, this.n)
214+
if (r === 0n) {
215+
k = nonce ?? this.randomScalar()
216+
continue
217+
}
218+
const kinv = this.modInv(k, this.n)
219+
let s = this.mod(kinv * (z + r * d), this.n)
220+
if (s === 0n) {
221+
k = nonce ?? this.randomScalar()
222+
continue
223+
}
224+
if (s > HALF_N) s = this.n - s // enforce low-s
225+
return { r: this.to32BytesHex(r), s: this.to32BytesHex(s) }
226+
}
227+
}
228+
229+
verify (message: ByteSource, signature: { r: string | bigint, s: string | bigint }, publicKey: P256Point | string, opts: { prehashed?: boolean } = {}): boolean {
230+
const { prehashed = false } = opts
231+
const q = typeof publicKey === 'string' ? this.pointFromHex(publicKey) : publicKey
232+
if (!q || !this.isOnCurve(q)) return false
233+
234+
const r = typeof signature.r === 'bigint' ? signature.r : BigInt('0x' + signature.r)
235+
const s = typeof signature.s === 'bigint' ? signature.s : BigInt('0x' + signature.s)
236+
if (r <= 0n || r >= this.n || s <= 0n || s >= this.n) return false
237+
238+
const z = this.messageToBigInt(message, prehashed)
239+
const w = this.modInv(s, this.n)
240+
const u1 = this.mod(z * w, this.n)
241+
const u2 = this.mod(r * w, this.n)
242+
const p = this.addPoints(this.multiplyBase(u1), this.multiply(q, u2))
243+
if (this.isInfinity(p)) return false
244+
const v = this.mod(p.x, this.n)
245+
return v === r
246+
}
247+
248+
private messageToBigInt (message: ByteSource, prehashed: boolean): bigint {
249+
const bytes = this.toBuffer(message)
250+
const digest = prehashed ? bytes : crypto.createHash('sha256').update(bytes).digest()
251+
const hex = digest.toString('hex')
252+
return BigInt('0x' + hex) % this.n
253+
}
254+
255+
private toBuffer (data: ByteSource): Buffer {
256+
if (typeof data === 'string') {
257+
if (HEX_REGEX.test(data) && data.length % 2 === 0) {
258+
return Buffer.from(data, 'hex')
259+
}
260+
return Buffer.from(data, 'utf8')
261+
}
262+
if (data instanceof Uint8Array) return Buffer.from(data)
263+
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
264+
throw new Error('Unsupported message format')
265+
}
266+
267+
private to32BytesHex (num: bigint): string {
268+
return num.toString(16).padStart(64, '0')
269+
}
270+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import crypto from 'crypto'
2+
import Secp256r1 from '../Secp256r1.js'
3+
4+
const curve = new Secp256r1()
5+
6+
const TWO_G =
7+
'047cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc4766997807775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1'
8+
const THREE_G =
9+
'045ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032'
10+
11+
const toBase64Url = (hex: string): string => Buffer.from(hex, 'hex').toString('base64url')
12+
13+
describe('Secp256r1', () => {
14+
test('base point multiplication matches known coordinates', () => {
15+
const twoG = curve.multiplyBase(2n)
16+
const threeG = curve.multiplyBase(3n)
17+
expect(curve.pointToHex(twoG)).toBe(TWO_G)
18+
expect(curve.pointToHex(threeG)).toBe(THREE_G)
19+
expect(curve.multiplyBase(curve.n)).toBeNull()
20+
})
21+
22+
test('public key generation stays on-curve and supports compression', () => {
23+
const priv = curve.generatePrivateKeyHex()
24+
const pub = curve.publicKeyFromPrivate(priv)
25+
expect(curve.isOnCurve(pub)).toBe(true)
26+
const compressed = curve.pointToHex(pub, true)
27+
const roundTrip = curve.pointFromHex(compressed)
28+
expect(roundTrip).toEqual(pub)
29+
})
30+
31+
test('ECDSA sign and verify round-trip', () => {
32+
const priv = '1'.repeat(64)
33+
const pub = curve.publicKeyFromPrivate(priv)
34+
const message = Buffer.from('p256 check')
35+
const signature = curve.sign(message, priv)
36+
expect(curve.verify(message, signature, pub)).toBe(true)
37+
expect(curve.verify(Buffer.from('different'), signature, pub)).toBe(false)
38+
const tampered = { r: signature.r, s: signature.s.slice(0, 62) + '00' }
39+
expect(curve.verify(message, tampered, pub)).toBe(false)
40+
})
41+
42+
test('signatures interoperate with Node crypto (ieee-p1363 encoding)', () => {
43+
const priv = '3'.repeat(64)
44+
const pub = curve.publicKeyFromPrivate(priv)
45+
const message = Buffer.from('interop check')
46+
const signature = curve.sign(message, priv)
47+
const sigBuf = Buffer.concat([Buffer.from(signature.r, 'hex'), Buffer.from(signature.s, 'hex')])
48+
49+
const pubHex = curve.pointToHex(pub)
50+
const jwk = {
51+
kty: 'EC',
52+
crv: 'P-256',
53+
x: toBase64Url(pubHex.slice(2, 66)),
54+
y: toBase64Url(pubHex.slice(66))
55+
}
56+
57+
const ok = crypto.verify('sha256', message, { key: jwk, format: 'jwk', dsaEncoding: 'ieee-p1363' }, sigBuf)
58+
expect(ok).toBe(true)
59+
})
60+
})

0 commit comments

Comments
 (0)