bsv-sdk 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4532a163439721455da80290d8b02342e597a01bb1e00b259d13343f8f9bd592
4
- data.tar.gz: b7cfda4a8084b96c28f34afb6829fcba9f5be0dabb01fca536147bfe2c66bad8
3
+ metadata.gz: 27489947d80a203e32fad08b53cfee4edabca7df61f244dff2ca12c5fa45e261
4
+ data.tar.gz: d3bd5ead19a4fa67f4111e9bd3a203a1cad3c083ad4b8e174ee114b5b7fdb4bd
5
5
  SHA512:
6
- metadata.gz: ed0b97db3cec8503eba509f232a463d8566373b34ccaf5fe7cd130a72e712cde17b083b4ce94ef64fa84786d6e6aec8965423ca6b8a7471ada70ebd522956e2a
7
- data.tar.gz: 0747a93bef059a8eccf0f504e703d20ddc875f8c562db8f01d3af4050bf7562deb262eb4494db4d98b78665f91274ed77754fa76e99254b8592b7e0f1526aa13
6
+ metadata.gz: 5ebdd5176602d584debb675d470dc31079cb16a29bb858c806123227efe03783c7c7f77eccf38174e254b7579fae9bad71d17c9e71791c21a694653647e15ad6
7
+ data.tar.gz: cedddebaec0eeb9c2fa037af4227ad7307492146c2b4dc18d3a7f4dfc7f3179c20428245424d0a8542db97bbad08831a334d7821f17fd3c05264d38289271552
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to the `bsv-sdk` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.15.0 — 2026-04-29
9
+
10
+ ### Added
11
+ - Extracted secp256k1 elliptic curve operations to the standalone
12
+ `secp256k1-native` gem, with an optional native C extension providing
13
+ ~22x speedup for field, scalar, and Jacobian point operations (#648)
14
+ - Native C extension scaffold with field arithmetic, scalar arithmetic,
15
+ and Jacobian point operations (#627, #628, #629, #630, #631)
16
+ - Constant-time Montgomery ladder with branchless conditional swap for
17
+ scalar multiplication; `mul` is now constant-time by default, with
18
+ `mul_vt` available for variable-time use cases (#641, #653)
19
+ - Wycheproof Bitcoin ECDSA test vectors (463 cases) with explicit
20
+ categorisation of high-S malleability cases as mathematically valid
21
+ but policy-rejected — documenting the layer separation between
22
+ ECDSA verification and Bitcoin's low-S enforcement (#652)
23
+ - Wycheproof standard ECDSA test vectors (474 cases), RFC 6979
24
+ compliance suite, and secp256k1 field/scalar/point compliance
25
+ examples (#636, #637, #638)
26
+
27
+ ### Fixed
28
+ - Carry overflow in schoolbook multiplication (L2) and branchless
29
+ borrow extraction in field reduction (#631)
30
+ - Gemspec version floor, docstring, and variable name corrections
31
+ from PR review (#653)
32
+
33
+ ### Changed
34
+ - Replaced `BN` hex intermediaries with direct binary construction
35
+ for improved performance (#622)
36
+ - Binary byte comparison in `SignedMessage` verification (#624)
37
+
8
38
  ## 0.14.0 — 2026-04-22
9
39
 
10
40
  ### Added
data/README.md CHANGED
@@ -34,7 +34,9 @@ These are maintained under the BSV Blockchain organisation and backed by the Bit
34
34
 
35
35
  The BSV Blockchain Libraries Project aims to structure and maintain a middleware layer of the BSV Blockchain technology stack. By facilitating the development and maintenance of core libraries, it serves as an essential toolkit for developers looking to build on the BSV Blockchain.
36
36
 
37
- This Ruby SDK brings maximum compatibility with the official SDK family to the Ruby ecosystem. It was born from a practical need: building an attestation gem ([bsv-attest](https://rubygems.org/gems/bsv-attest)) required a complete, idiomatic Ruby implementation of BSV primitives, script handling, and transaction construction. Rather than wrapping FFI bindings or shelling out to other languages, the SDK implements everything in pure Ruby. Elliptic curve operations (secp256k1) use a native Ruby implementation ported from the TypeScript reference SDK; OpenSSL is used only for hashing, HMAC, and symmetric encryption.
37
+ This Ruby SDK brings maximum compatibility with the official SDK family to the Ruby ecosystem. It was born from a practical need: building an attestation gem ([bsv-attest](https://rubygems.org/gems/bsv-attest)) required a complete, idiomatic Ruby implementation of BSV primitives, script handling, and transaction construction.
38
+
39
+ Elliptic curve operations (secp256k1) are provided by the [`secp256k1-native`](https://github.com/sgbett/secp256k1-native) gem, which was originally part of this library and has been extracted as a standalone dependency. This is a custom implementation ported from the TypeScript reference SDK — it does not wrap `libsecp256k1`. It includes a pure Ruby implementation with an optional native C extension for constant-time field arithmetic and performance. OpenSSL is used only for hashing, HMAC, and symmetric encryption.
38
40
 
39
41
  <!-- TODO: Update bsv-attest link once gem documentation is published (see #48) -->
40
42
 
@@ -59,6 +61,16 @@ Or install directly:
59
61
  gem install bsv-sdk
60
62
  ```
61
63
 
64
+ ### Native C Extension (Recommended)
65
+
66
+ The SDK depends on [`secp256k1-native`](https://github.com/sgbett/secp256k1-native) for elliptic curve operations. It works out of the box in pure Ruby, but compiling the optional C extension is strongly recommended — it provides constant-time field arithmetic, which protects against timing side-channel attacks on private key operations:
67
+
68
+ ```bash
69
+ cd $(bundle show secp256k1-native) && bundle exec rake compile
70
+ ```
71
+
72
+ This requires a C99 compiler with `__uint128_t` support (GCC or Clang on macOS/Linux). If compilation is not possible, the SDK falls back to pure Ruby automatically — but the pure Ruby path does not guarantee constant-time execution.
73
+
62
74
  ### Basic Usage
63
75
 
64
76
  Create and sign a P2PKH transaction:
@@ -101,7 +113,7 @@ puts tx.to_hex
101
113
 
102
114
  ## Features & Deliverables
103
115
 
104
- - **Cryptographic Primitives** — ECDSA signing with RFC 6979 deterministic nonces, Schnorr signatures, ECIES encryption/decryption, Bitcoin Signed Messages. Elliptic curve operations use a [pure Ruby secp256k1 implementation](docs/about/secp256k1.md) ported from the TypeScript reference SDK.
116
+ - **Cryptographic Primitives** — ECDSA signing with RFC 6979 deterministic nonces, Schnorr signatures, ECIES encryption/decryption, Bitcoin Signed Messages. Elliptic curve operations are provided by the [`secp256k1-native`](https://github.com/sgbett/secp256k1-native) gem a pure Ruby secp256k1 implementation with an optional C extension (~22× speedup).
105
117
  - **Key Management** — BIP-32 HD key derivation, BIP-39 mnemonic generation (12/24-word phrases), WIF import/export, Base58Check encoding/decoding.
106
118
  - **Script Layer** — Complete opcode set, script parsing and serialisation, type detection and predicates (`p2pkh?`, `p2pk?`, `p2sh?`, `multisig?`, `op_return?`), data extraction (pubkey hashes, script hashes, addresses), and a fluent builder API.
107
119
  - **Script Templates** — Ready-made locking and unlocking script generators for P2PKH, P2PK, P2MS (multisig), and OP_RETURN.
@@ -42,6 +42,7 @@ module BSV
42
42
  bytes.each_byte { |b| b.zero? ? leading_zeros += 1 : break }
43
43
 
44
44
  # Convert to big integer and repeatedly divide by 58
45
+ # C-backed hex conversion — 10x faster than pure-Ruby byte shifting; not a porting artefact.
45
46
  n = bytes.unpack1('H*').to_i(16)
46
47
  result = +''
47
48
  while n.positive?
@@ -78,7 +79,7 @@ module BSV
78
79
  n = (n * BASE) + digit
79
80
  end
80
81
 
81
- # Convert integer to bytes
82
+ # Convert integer to bytes — C-backed hex round-trip is the fastest pure-Ruby integer→bytes path.
82
83
  hex = n.zero? ? '' : n.to_s(16)
83
84
  hex = "0#{hex}" if hex.length.odd?
84
85
  result = [hex].pack('H*')
@@ -25,10 +25,11 @@ module BSV
25
25
 
26
26
  module_function
27
27
 
28
- # Multiply the generator point by a scalar (variable-time, wNAF).
28
+ # Multiply the generator point by a scalar (constant-time).
29
29
  #
30
- # Suitable for public scalars only (e.g. verify paths). For secret
31
- # scalars use {multiply_generator_ct}.
30
+ # Uses the Montgomery ladder by default, matching OpenSSL convention.
31
+ # Safe for both secret and public scalars. For explicit variable-time
32
+ # multiplication of public scalars, use {multiply_generator_vt}.
32
33
  #
33
34
  # @param scalar_bn [OpenSSL::BN] the scalar multiplier
34
35
  # @return [OpenSSL::PKey::EC::Point] the resulting curve point
@@ -38,19 +39,31 @@ module BSV
38
39
 
39
40
  # Multiply the generator point by a secret scalar (constant-time).
40
41
  #
41
- # Uses the Montgomery ladder to avoid timing side-channels on the
42
- # scalar. Use for key generation and signing.
42
+ # Alias for {multiply_generator} retained for backward compatibility
43
+ # and expressiveness.
43
44
  #
44
45
  # @param scalar_bn [OpenSSL::BN] the secret scalar multiplier
45
46
  # @return [OpenSSL::PKey::EC::Point] the resulting curve point
46
47
  def multiply_generator_ct(scalar_bn)
47
- G.mul_ct(scalar_bn)
48
+ G.mul(scalar_bn)
48
49
  end
49
50
 
50
- # Multiply an arbitrary curve point by a scalar (variable-time, wNAF).
51
+ # Multiply the generator point by a public scalar (variable-time, wNAF).
51
52
  #
52
- # Suitable for public scalars only. For secret scalars use
53
- # {multiply_point_ct}.
53
+ # Faster than {multiply_generator} but leaks timing information about
54
+ # the scalar. Use only for public scalars (e.g. signature verification).
55
+ #
56
+ # @param scalar_bn [OpenSSL::BN] the public scalar multiplier
57
+ # @return [OpenSSL::PKey::EC::Point] the resulting curve point
58
+ def multiply_generator_vt(scalar_bn)
59
+ G.mul_vt(scalar_bn)
60
+ end
61
+
62
+ # Multiply an arbitrary curve point by a scalar (constant-time).
63
+ #
64
+ # Uses the Montgomery ladder by default, matching OpenSSL convention.
65
+ # Safe for both secret and public scalars. For explicit variable-time
66
+ # multiplication of public scalars, use {multiply_point_vt}.
54
67
  #
55
68
  # @param point [OpenSSL::PKey::EC::Point] the point to multiply
56
69
  # @param scalar_bn [OpenSSL::BN] the scalar multiplier
@@ -61,14 +74,26 @@ module BSV
61
74
 
62
75
  # Multiply an arbitrary curve point by a secret scalar (constant-time).
63
76
  #
64
- # Uses the Montgomery ladder to avoid timing side-channels on the
65
- # scalar. Use for ECDH shared-secret derivation.
77
+ # Alias for {multiply_point} retained for backward compatibility
78
+ # and expressiveness.
66
79
  #
67
80
  # @param point [OpenSSL::PKey::EC::Point] the base point
68
81
  # @param scalar_bn [OpenSSL::BN] the secret scalar multiplier
69
82
  # @return [OpenSSL::PKey::EC::Point] the resulting curve point
70
83
  def multiply_point_ct(point, scalar_bn)
71
- point.mul_ct(scalar_bn)
84
+ point.mul(scalar_bn)
85
+ end
86
+
87
+ # Multiply an arbitrary curve point by a public scalar (variable-time, wNAF).
88
+ #
89
+ # Faster than {multiply_point} but leaks timing information about
90
+ # the scalar. Use only for public scalars (e.g. signature verification).
91
+ #
92
+ # @param point [OpenSSL::PKey::EC::Point] the point to multiply
93
+ # @param scalar_bn [OpenSSL::BN] the public scalar multiplier
94
+ # @return [OpenSSL::PKey::EC::Point] the resulting curve point
95
+ def multiply_point_vt(point, scalar_bn)
96
+ point.mul_vt(scalar_bn)
72
97
  end
73
98
 
74
99
  # Add two curve points together.
@@ -82,8 +82,8 @@ module BSV
82
82
  u1 = ((n - e) * r_inv) % n
83
83
  u2 = (s * r_inv) % n
84
84
 
85
- p1 = Curve.multiply_generator(u1)
86
- p2 = Curve.multiply_point(r_point, u2)
85
+ p1 = Curve.multiply_generator_vt(u1)
86
+ p2 = Curve.multiply_point_vt(r_point, u2)
87
87
  q = Curve.add_points(p1, p2)
88
88
 
89
89
  raise ArgumentError, 'recovered point is at infinity' if q.infinity?
@@ -112,8 +112,8 @@ module BSV
112
112
  u2 = (r * s_inv) % n
113
113
 
114
114
  # R' = u1*G + u2*Q
115
- point1 = Curve.multiply_generator(u1)
116
- point2 = Curve.multiply_point(public_key_point, u2)
115
+ point1 = Curve.multiply_generator_vt(u1)
116
+ point2 = Curve.multiply_point_vt(public_key_point, u2)
117
117
  result_point = Curve.add_points(point1, point2)
118
118
 
119
119
  return false if result_point.infinity?
@@ -66,6 +66,22 @@ class BSVShimECPoint
66
66
  pt
67
67
  end
68
68
 
69
+ # Scalar multiplication: self * scalar (constant-time, Montgomery ladder).
70
+ #
71
+ # Matches OpenSSL convention where +EC_POINT_mul+ is always constant-time.
72
+ # Safe for both secret and public scalars.
73
+ #
74
+ # Also supports the multi-scalar form: +mul(bns, points)+ computes
75
+ # <tt>bns[0]*self + bns[1]*points[0] + ...</tt>
76
+ # where +bns.length == points.length + 1+.
77
+ #
78
+ # @overload mul(scalar_bn)
79
+ # @param scalar_bn [OpenSSL::BN, Integer] the scalar multiplier
80
+ # @overload mul(bns, points)
81
+ # @param bns [Array<OpenSSL::BN>] scalars; must have +points.length + 1+ elements
82
+ # @param points [Array<BSVShimECPoint>] additional points
83
+ # @raise [NoMethodError] if +bns+ and +points+ lengths are mismatched
84
+ # @return [BSVShimECPoint]
69
85
  def mul(*args)
70
86
  if args.length == 1
71
87
  scalar = bn_to_int(args[0])
@@ -85,16 +101,27 @@ class BSVShimECPoint
85
101
  end
86
102
  end
87
103
 
88
- # Constant-time scalar multiplication via the Montgomery ladder.
104
+ # Constant-time scalar multiplication (alias for {#mul}).
89
105
  #
90
- # Delegates to {BSV::Primitives::Secp256k1::Point#mul_ct} to ensure
91
- # secret-scalar paths execute in constant time.
106
+ # Retained for backward compatibility and expressiveness. Delegates
107
+ # to {#mul}, which is constant-time by default.
92
108
  #
93
- # @param scalar_bn [OpenSSL::BN, Integer] the secret scalar
109
+ # @param scalar_bn [OpenSSL::BN, Integer] the scalar multiplier
94
110
  # @return [BSVShimECPoint]
95
111
  def mul_ct(scalar_bn)
112
+ mul(scalar_bn)
113
+ end
114
+
115
+ # Variable-time scalar multiplication (wNAF).
116
+ #
117
+ # Faster than {#mul} but leaks timing information about the scalar.
118
+ # Use only for public scalars (e.g. signature verification).
119
+ #
120
+ # @param scalar_bn [OpenSSL::BN, Integer] the public scalar multiplier
121
+ # @return [BSVShimECPoint]
122
+ def mul_vt(scalar_bn)
96
123
  scalar = bn_to_int(scalar_bn)
97
- result = @secp_point.mul_ct(scalar)
124
+ result = @secp_point.mul_vt(scalar)
98
125
  self.class.from_secp_point(@group, result)
99
126
  end
100
127
 
@@ -165,7 +165,7 @@ module BSV
165
165
  def derive_child(public_key, invoice_number)
166
166
  shared = derive_shared_secret(public_key)
167
167
  hmac = Digest.hmac_sha256(shared.compressed, invoice_number.encode('UTF-8'))
168
- hmac_bn = OpenSSL::BN.new(hmac.unpack1('H*'), 16)
168
+ hmac_bn = OpenSSL::BN.new(hmac, 2)
169
169
  PrivateKey.new(@bn.mod_add(hmac_bn, Curve::N))
170
170
  end
171
171
 
@@ -207,7 +207,7 @@ module BSV
207
207
  loop do
208
208
  counter_bytes = [i, attempts].pack('N*') + SecureRandom.random_bytes(32)
209
209
  h = Digest.hmac_sha512(seed, counter_bytes)
210
- candidate = OpenSSL::BN.new(h.unpack1('H*'), 16) % PointInFiniteField::P
210
+ candidate = OpenSSL::BN.new(h, 2) % PointInFiniteField::P
211
211
 
212
212
  attempts += 1
213
213
  raise ArgumentError, 'failed to generate unique x-coordinate after 5 attempts' if attempts > 5
@@ -132,7 +132,7 @@ module BSV
132
132
  def derive_child(private_key, invoice_number)
133
133
  shared = derive_shared_secret(private_key)
134
134
  hmac = Digest.hmac_sha256(shared.compressed, invoice_number.encode('UTF-8'))
135
- hmac_bn = OpenSSL::BN.new(hmac.unpack1('H*'), 16)
135
+ hmac_bn = OpenSSL::BN.new(hmac, 2)
136
136
  hmac_point = Curve.multiply_generator_ct(hmac_bn)
137
137
  child_point = Curve.add_points(@point, hmac_point)
138
138
  PublicKey.new(child_point)
@@ -97,15 +97,15 @@ module BSV
97
97
  e = compute_challenge(public_key_a, public_key_b, shared_secret, proof.s_prime, proof.r)
98
98
 
99
99
  # Equation 1: z·G == R + e·A
100
- z_g = Curve.multiply_generator(proof.z)
101
- e_a = Curve.multiply_point(public_key_a.point, e)
100
+ z_g = Curve.multiply_generator_vt(proof.z)
101
+ e_a = Curve.multiply_point_vt(public_key_a.point, e)
102
102
  r_plus_ea = Curve.add_points(proof.r.point, e_a)
103
103
 
104
104
  return false unless points_equal?(z_g, r_plus_ea)
105
105
 
106
106
  # Equation 2: z·B == S' + e·S
107
- z_b = Curve.multiply_point(public_key_b.point, proof.z)
108
- e_s = Curve.multiply_point(shared_secret.point, e)
107
+ z_b = Curve.multiply_point_vt(public_key_b.point, proof.z)
108
+ e_s = Curve.multiply_point_vt(shared_secret.point, e)
109
109
  sp_plus_es = Curve.add_points(proof.s_prime.point, e_s)
110
110
 
111
111
  points_equal?(z_b, sp_plus_es)
@@ -1,602 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Single-letter parameter names (k, p, q, x, y, z, etc.) match standard
4
- # elliptic-curve mathematical notation and the BSV TypeScript reference SDK
5
- # this module is ported from. The whole-module length cop is disabled because
6
- # the curve implementation (field arithmetic + Jacobian point ops + wNAF
7
- # scalar multiplication + Point class) intentionally lives in one module to
8
- # keep the secp256k1 surface coherent.
9
- # rubocop:disable Naming/MethodParameterName, Metrics/ModuleLength
3
+ require 'secp256k1'
10
4
 
11
5
  module BSV
12
6
  module Primitives
13
- # Pure Ruby secp256k1 elliptic curve implementation.
14
- #
15
- # Provides field arithmetic, point operations with Jacobian coordinates,
16
- # and windowed-NAF scalar multiplication. Ported from the BSV TypeScript
17
- # SDK reference implementation.
18
- #
19
- # All field operations work on plain Ruby +Integer+ values (arbitrary
20
- # precision, C-backed in MRI). No external gems required.
21
- module Secp256k1
22
- # The secp256k1 field prime: p = 2^256 - 2^32 - 977
23
- P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
24
-
25
- # The curve order (number of points on the curve).
26
- N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
27
-
28
- # Half the curve order, used for low-S normalisation (BIP-62).
29
- HALF_N = N >> 1
30
-
31
- # Generator point x-coordinate.
32
- GX = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
33
-
34
- # Generator point y-coordinate.
35
- GY = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
36
-
37
- # (P + 1) / 4 — used for modular square root since P ≡ 3 (mod 4).
38
- P_PLUS1_DIV4 = (P + 1) >> 2
39
-
40
- # 256-bit mask for fast reduction.
41
- MASK_256 = (1 << 256) - 1
42
-
43
- module_function
44
-
45
- # -------------------------------------------------------------------
46
- # Byte conversion helpers
47
- # -------------------------------------------------------------------
48
-
49
- # Convert a big-endian binary string to an Integer.
50
- #
51
- # @param bytes [String] binary string (ASCII-8BIT)
52
- # @return [Integer]
53
- def bytes_to_int(bytes)
54
- bytes.unpack1('H*').to_i(16)
55
- end
56
-
57
- # Convert an Integer to a fixed-length big-endian binary string.
58
- #
59
- # @param n [Integer] the integer to convert
60
- # @param length [Integer] desired byte length (default 32)
61
- # @return [String] binary string (ASCII-8BIT)
62
- def int_to_bytes(n, length = 32)
63
- raise ArgumentError, 'negative integer' if n.negative?
64
-
65
- hex = n.to_s(16)
66
- hex = "0#{hex}" if hex.length.odd?
67
- raise ArgumentError, "integer too large for #{length} bytes" if hex.length > length * 2
68
-
69
- hex = hex.rjust(length * 2, '0')
70
- [hex].pack('H*')
71
- end
72
-
73
- # -------------------------------------------------------------------
74
- # Field arithmetic (mod P)
75
- # -------------------------------------------------------------------
76
-
77
- # Fast reduction modulo the secp256k1 prime.
78
- #
79
- # Exploits the structure P = 2^256 - 2^32 - 977 to avoid generic
80
- # modular division. Two folding passes plus a conditional subtraction.
81
- #
82
- # @param x [Integer] non-negative integer
83
- # @return [Integer] x mod P, in range [0, P)
84
- def fred(x)
85
- # First fold
86
- hi = x >> 256
87
- x = (x & MASK_256) + (hi << 32) + (hi * 977)
88
-
89
- # Second fold (hi <= 2^32 + 977, so one more pass suffices)
90
- hi = x >> 256
91
- x = (x & MASK_256) + (hi << 32) + (hi * 977)
92
-
93
- # Final conditional subtraction
94
- x >= P ? x - P : x
95
- end
96
-
97
- # Modular multiplication in the field.
98
- def fmul(a, b)
99
- fred(a * b)
100
- end
101
-
102
- # Modular squaring in the field.
103
- def fsqr(a)
104
- fred(a * a)
105
- end
106
-
107
- # Modular addition in the field.
108
- def fadd(a, b)
109
- fred(a + b)
110
- end
111
-
112
- # Modular subtraction in the field.
113
- def fsub(a, b)
114
- a >= b ? a - b : P - (b - a)
115
- end
116
-
117
- # Modular negation in the field.
118
- def fneg(a)
119
- a.zero? ? 0 : P - a
120
- end
121
-
122
- # Modular multiplicative inverse in the field (Fermat's little theorem).
123
- #
124
- # @param a [Integer] value to invert (must be non-zero mod P)
125
- # @return [Integer] a^(P-2) mod P
126
- # @raise [ArgumentError] if a is zero mod P
127
- def finv(a)
128
- raise ArgumentError, 'field inverse is undefined for zero' if (a % P).zero?
129
-
130
- a.pow(P - 2, P)
131
- end
132
-
133
- # Modular square root in the field.
134
- #
135
- # Uses the identity sqrt(a) = a^((P+1)/4) mod P, valid because
136
- # P ≡ 3 (mod 4). Returns +nil+ if +a+ is not a quadratic residue.
137
- #
138
- # @param a [Integer]
139
- # @return [Integer, nil] the square root, or nil if none exists
140
- def fsqrt(a)
141
- r = a.pow(P_PLUS1_DIV4, P)
142
- fsqr(r) == (a % P) ? r : nil
143
- end
144
-
145
- # -------------------------------------------------------------------
146
- # Scalar arithmetic (mod N)
147
- # -------------------------------------------------------------------
148
-
149
- # Reduce modulo the curve order.
150
- def scalar_mod(a)
151
- r = a % N
152
- r += N if r.negative?
153
- r
154
- end
155
-
156
- # Scalar multiplicative inverse (Fermat).
157
- #
158
- # @raise [ArgumentError] if a is zero mod N
159
- def scalar_inv(a)
160
- raise ArgumentError, 'scalar inverse is undefined for zero' if (a % N).zero?
161
-
162
- a.pow(N - 2, N)
163
- end
164
-
165
- # Scalar multiplication mod N.
166
- def scalar_mul(a, b)
167
- (a * b) % N
168
- end
169
-
170
- # Scalar addition mod N.
171
- def scalar_add(a, b)
172
- (a + b) % N
173
- end
174
-
175
- # -------------------------------------------------------------------
176
- # Jacobian point operations (internal)
177
- #
178
- # Points are represented as [X, Y, Z] arrays of Integers.
179
- # The point at infinity is [0, 1, 0].
180
- # -------------------------------------------------------------------
181
-
182
- # @!visibility private
183
- JP_INFINITY = [0, 1, 0].freeze
184
-
185
- # Double a Jacobian point.
186
- #
187
- # Formula from hyperelliptic.org/EFD/g1p/auto-shortw-jacobian-0.html
188
- # (a=0 for secp256k1).
189
- #
190
- # @param p [Array(Integer, Integer, Integer)] Jacobian point [X, Y, Z]
191
- # @return [Array(Integer, Integer, Integer)]
192
- def jp_double(p)
193
- x1, y1, z1 = p
194
- return JP_INFINITY if y1.zero?
195
-
196
- y1sq = fsqr(y1)
197
- s = fmul(4, fmul(x1, y1sq))
198
- m = fmul(3, fsqr(x1)) # a=0 for secp256k1
199
- x3 = fsub(fsqr(m), fmul(2, s))
200
- y3 = fsub(fmul(m, fsub(s, x3)), fmul(8, fsqr(y1sq)))
201
- z3 = fmul(2, fmul(y1, z1))
202
- [x3, y3, z3]
203
- end
204
-
205
- # Add two Jacobian points.
206
- #
207
- # @param p [Array] first Jacobian point
208
- # @param q [Array] second Jacobian point
209
- # @return [Array] resulting Jacobian point
210
- def jp_add(p, q)
211
- _px, _py, pz = p
212
- _qx, _qy, qz = q
213
- return q if pz.zero?
214
- return p if qz.zero?
215
-
216
- z1z1 = fsqr(pz)
217
- z2z2 = fsqr(qz)
218
- u1 = fmul(p[0], z2z2)
219
- u2 = fmul(q[0], z1z1)
220
- s1 = fmul(p[1], fmul(z2z2, qz))
221
- s2 = fmul(q[1], fmul(z1z1, pz))
222
-
223
- h = fsub(u2, u1)
224
- r = fsub(s2, s1)
225
-
226
- if h.zero?
227
- return r.zero? ? jp_double(p) : JP_INFINITY
228
- end
229
-
230
- hh = fsqr(h)
231
- hhh = fmul(h, hh)
232
- v = fmul(u1, hh)
233
-
234
- x3 = fsub(fsub(fsqr(r), hhh), fmul(2, v))
235
- y3 = fsub(fmul(r, fsub(v, x3)), fmul(s1, hhh))
236
- z3 = fmul(h, fmul(pz, qz))
237
- [x3, y3, z3]
238
- end
239
-
240
- # Convert a Jacobian point to affine coordinates.
241
- #
242
- # @param jp [Array(Integer, Integer, Integer)]
243
- # @return [Array(Integer, Integer)] affine [x, y], or nil for infinity
244
- def jp_to_affine(jp)
245
- _x, _y, z = jp
246
- return nil if z.zero?
247
-
248
- zinv = finv(z)
249
- zinv2 = fsqr(zinv)
250
- x = fmul(jp[0], zinv2)
251
- y = fmul(jp[1], fmul(zinv2, zinv))
252
- [x, y]
253
- end
254
-
255
- # -------------------------------------------------------------------
256
- # Windowed-NAF scalar multiplication (variable-time, public scalars)
257
- # -------------------------------------------------------------------
258
-
259
- # @!visibility private
260
- # Maximum number of entries kept in the wNAF precomputation cache.
261
- # Bounds memory usage for long-running processes (e.g. servers).
262
- WNAF_CACHE_MAX = 512
263
-
264
- # @!visibility private
265
- # Cache for precomputed wNAF tables, keyed by "window:x:y".
266
- # Evicts oldest entry when the LRU limit is reached.
267
- WNAF_TABLE_CACHE = {} # rubocop:disable Style/MutableConstant
268
-
269
- # @!visibility private
270
- # Multiply a point by a scalar using windowed-NAF.
271
- #
272
- # Variable-time algorithm — suitable only for public scalars (e.g.
273
- # signature verification). Secret-scalar paths MUST use
274
- # {scalar_multiply_ct} instead.
275
- #
276
- # Internal method — use {Point#mul} or {Point#mul_ct} instead.
277
- # Exposed as a module function only so the nested Point class can
278
- # call it; not part of the public API.
279
- #
280
- # @param k [Integer] the scalar (must be in [1, N))
281
- # @param px [Integer] affine x-coordinate of the base point
282
- # @param py [Integer] affine y-coordinate of the base point
283
- # @param window [Integer] wNAF window size (default 5)
284
- # @return [Array(Integer, Integer, Integer)] result as Jacobian point
285
- def scalar_multiply_wnaf(k, px, py, window = 5)
286
- return JP_INFINITY if k.zero?
287
-
288
- cache_key = "#{window}:#{px.to_s(16)}:#{py.to_s(16)}"
289
- tbl = WNAF_TABLE_CACHE[cache_key]
290
-
291
- if tbl.nil?
292
- # Evict the oldest entry when the cache is full (simple LRU).
293
- WNAF_TABLE_CACHE.delete(WNAF_TABLE_CACHE.keys.first) if WNAF_TABLE_CACHE.size >= WNAF_CACHE_MAX
294
-
295
- tbl_size = 1 << (window - 1) # e.g. w=5 -> 16 entries
296
- tbl = Array.new(tbl_size)
297
- tbl[0] = [px, py, 1]
298
- two_p = jp_double(tbl[0])
299
- 1.upto(tbl_size - 1) do |i|
300
- tbl[i] = jp_add(tbl[i - 1], two_p)
301
- end
302
- WNAF_TABLE_CACHE[cache_key] = tbl
303
- end
304
-
305
- # Build wNAF representation
306
- w_big = 1 << window
307
- w_half = w_big >> 1
308
- wnaf = []
309
- k_tmp = k
310
- while k_tmp.positive?
311
- if k_tmp.odd?
312
- z = k_tmp & (w_big - 1)
313
- z -= w_big if z > w_half
314
- wnaf << z
315
- k_tmp -= z
316
- else
317
- wnaf << 0
318
- end
319
- k_tmp >>= 1
320
- end
321
-
322
- # Accumulate from MSB to LSB
323
- q = JP_INFINITY
324
- (wnaf.length - 1).downto(0) do |i|
325
- q = jp_double(q)
326
- di = wnaf[i]
327
- next if di.zero?
328
-
329
- idx = di.abs >> 1
330
- addend = di.positive? ? tbl[idx] : jp_neg(tbl[idx])
331
- q = jp_add(q, addend)
332
- end
333
- q
334
- end
335
-
336
- # -------------------------------------------------------------------
337
- # Montgomery ladder scalar multiplication (constant-time, secret scalars)
338
- # -------------------------------------------------------------------
339
-
340
- # @!visibility private
341
- # Multiply a point by a scalar using the Montgomery ladder.
342
- #
343
- # Executes a fixed number of iterations (256) with one +jp_double+
344
- # and one +jp_add+ per iteration regardless of the scalar value.
345
- # Use this for ALL secret-scalar paths (key generation, signing,
346
- # ECDH, BIP-32 derivation).
347
- #
348
- # *Best-effort constant-time in interpreted Ruby.* The branch on
349
- # +bit+ selects which register receives each operation, and both
350
- # operations always execute. However, Ruby's interpreter, GC, and
351
- # the early-return branches in +jp_add+/+jp_double+ (for infinity
352
- # edge cases) mean true constant-time execution is not achievable
353
- # without native code. This matches the ts-sdk's TypeScript
354
- # implementation, which has the same structural properties. For
355
- # production deployments requiring side-channel resistance beyond
356
- # what an interpreted language can offer, use a native secp256k1
357
- # library (e.g. libsecp256k1 via FFI).
358
- #
359
- # Internal method — use {Point#mul_ct} instead. Not part of the
360
- # public API.
361
- #
362
- # @param k [Integer] secret scalar (must be in [1, N))
363
- # @param px [Integer] affine x-coordinate of the base point
364
- # @param py [Integer] affine y-coordinate of the base point
365
- # @return [Array(Integer, Integer, Integer)] result as Jacobian point
366
- def scalar_multiply_ct(k, px, py)
367
- return JP_INFINITY if k.zero?
368
-
369
- # r0 accumulates the result; r1 = r0 + base_point at all times.
370
- r0 = JP_INFINITY
371
- r1 = [px, py, 1]
372
-
373
- 256.times do |i|
374
- bit = (k >> (255 - i)) & 1
375
- if bit.zero?
376
- r1 = jp_add(r0, r1)
377
- r0 = jp_double(r0)
378
- else
379
- r0 = jp_add(r0, r1)
380
- r1 = jp_double(r1)
381
- end
382
- end
383
-
384
- r0
385
- end
386
-
387
- # Negate a Jacobian point.
388
- def jp_neg(p)
389
- return p if p[2].zero?
390
-
391
- [p[0], fneg(p[1]), p[2]]
392
- end
393
-
394
- # -------------------------------------------------------------------
395
- # Point class
396
- # -------------------------------------------------------------------
397
-
398
- # An elliptic curve point on secp256k1.
399
- #
400
- # Stores affine coordinates (x, y) or represents the point at infinity.
401
- # Scalar multiplication uses Jacobian coordinates internally with
402
- # windowed-NAF for performance.
403
- class Point
404
- # @return [Integer, nil] x-coordinate (nil for infinity)
405
- attr_reader :x
406
-
407
- # @return [Integer, nil] y-coordinate (nil for infinity)
408
- attr_reader :y
409
-
410
- # @param x [Integer, nil] x-coordinate (nil for infinity)
411
- # @param y [Integer, nil] y-coordinate (nil for infinity)
412
- def initialize(x, y)
413
- @x = x
414
- @y = y
415
- end
416
-
417
- # The point at infinity (additive identity).
418
- #
419
- # @return [Point]
420
- def self.infinity
421
- new(nil, nil)
422
- end
423
-
424
- # The generator point G.
425
- #
426
- # @return [Point]
427
- def self.generator
428
- @generator ||= new(GX, GY)
429
- end
430
-
431
- # Deserialise a point from compressed (33 bytes) or uncompressed
432
- # (65 bytes) SEC1 encoding.
433
- #
434
- # @param bytes [String] binary string
435
- # @return [Point]
436
- # @raise [ArgumentError] if the encoding is invalid or the point
437
- # is not on the curve
438
- def self.from_bytes(bytes)
439
- bytes = bytes.b if bytes.encoding != Encoding::BINARY
440
- prefix = bytes.getbyte(0)
441
-
442
- case prefix
443
- when 0x04 # Uncompressed
444
- raise ArgumentError, 'invalid uncompressed point length' unless bytes.length == 65
445
-
446
- x = Secp256k1.bytes_to_int(bytes[1, 32])
447
- y = Secp256k1.bytes_to_int(bytes[33, 32])
448
- raise ArgumentError, 'x coordinate out of field range' if x >= P
449
- raise ArgumentError, 'y coordinate out of field range' if y >= P
450
-
451
- pt = new(x, y)
452
- raise ArgumentError, 'point is not on the curve' unless pt.on_curve?
453
-
454
- pt
455
- when 0x02, 0x03 # Compressed
456
- raise ArgumentError, 'invalid compressed point length' unless bytes.length == 33
457
-
458
- x = Secp256k1.bytes_to_int(bytes[1, 32])
459
- raise ArgumentError, 'x coordinate out of field range' if x >= P
460
-
461
- y_squared = Secp256k1.fadd(Secp256k1.fmul(Secp256k1.fsqr(x), x), 7)
462
- y = Secp256k1.fsqrt(y_squared)
463
- raise ArgumentError, 'invalid point: x not on curve' if y.nil?
464
-
465
- # Ensure y-parity matches prefix
466
- y = Secp256k1.fneg(y) if (y.odd? ? 0x03 : 0x02) != prefix
467
-
468
- new(x, y)
469
- else
470
- raise ArgumentError, "unknown point prefix: 0x#{prefix.to_s(16).rjust(2, '0')}"
471
- end
472
- end
473
-
474
- # Whether this is the point at infinity.
475
- #
476
- # @return [Boolean]
477
- def infinity?
478
- @x.nil?
479
- end
480
-
481
- # Whether this point lies on the secp256k1 curve (y² = x³ + 7).
482
- #
483
- # @return [Boolean]
484
- def on_curve?
485
- return true if infinity?
486
-
487
- lhs = Secp256k1.fsqr(@y)
488
- rhs = Secp256k1.fadd(Secp256k1.fmul(Secp256k1.fsqr(@x), @x), 7)
489
- lhs == rhs
490
- end
491
-
492
- # Serialise the point in SEC1 format.
493
- #
494
- # @param format [:compressed, :uncompressed]
495
- # @return [String] binary string (33 or 65 bytes)
496
- # @raise [RuntimeError] if the point is at infinity
497
- def to_octet_string(format = :compressed)
498
- raise 'cannot serialise point at infinity' if infinity?
499
-
500
- case format
501
- when :compressed
502
- prefix = @y.odd? ? "\x03".b : "\x02".b
503
- prefix + Secp256k1.int_to_bytes(@x, 32)
504
- when :uncompressed
505
- "\x04".b + Secp256k1.int_to_bytes(@x, 32) + Secp256k1.int_to_bytes(@y, 32)
506
- else
507
- raise ArgumentError, "unknown format: #{format}"
508
- end
509
- end
510
-
511
- # Scalar multiplication: self * scalar (variable-time, wNAF).
512
- #
513
- # Suitable for public scalars only (e.g. signature verification).
514
- # For secret-scalar paths use {#mul_ct}.
515
- #
516
- # @param scalar [Integer] the scalar multiplier
517
- # @return [Point] the resulting point
518
- def mul(scalar)
519
- return self.class.infinity if scalar.zero? || infinity?
520
-
521
- scalar %= N
522
- return self.class.infinity if scalar.zero?
523
-
524
- jp = Secp256k1.scalar_multiply_wnaf(scalar, @x, @y)
525
- affine = Secp256k1.jp_to_affine(jp)
526
- return self.class.infinity if affine.nil?
527
-
528
- self.class.new(affine[0], affine[1])
529
- end
530
-
531
- # Constant-time scalar multiplication: self * scalar (Montgomery ladder).
532
- #
533
- # Processes all 256 bits unconditionally so execution time does not
534
- # depend on the scalar value. Use this for secret-scalar paths:
535
- # key generation, signing, and ECDH shared-secret derivation.
536
- #
537
- # @param scalar [Integer] the secret scalar multiplier
538
- # @return [Point] the resulting point
539
- def mul_ct(scalar)
540
- return self.class.infinity if scalar.zero? || infinity?
541
-
542
- scalar %= N
543
- return self.class.infinity if scalar.zero?
544
-
545
- jp = Secp256k1.scalar_multiply_ct(scalar, @x, @y)
546
- affine = Secp256k1.jp_to_affine(jp)
547
- return self.class.infinity if affine.nil?
548
-
549
- self.class.new(affine[0], affine[1])
550
- end
551
-
552
- # Point addition: self + other.
553
- #
554
- # @param other [Point]
555
- # @return [Point]
556
- def add(other)
557
- return other if infinity?
558
- return self if other.infinity?
559
-
560
- jp1 = [@x, @y, 1]
561
- jp2 = [other.x, other.y, 1]
562
- jp_result = Secp256k1.jp_add(jp1, jp2)
563
- affine = Secp256k1.jp_to_affine(jp_result)
564
- return self.class.infinity if affine.nil?
565
-
566
- self.class.new(affine[0], affine[1])
567
- end
568
-
569
- # Point negation: -self.
570
- #
571
- # @return [Point]
572
- def negate
573
- return self if infinity?
574
-
575
- self.class.new(@x, Secp256k1.fneg(@y))
576
- end
577
-
578
- # Equality comparison.
579
- #
580
- # @param other [Point]
581
- # @return [Boolean]
582
- def ==(other)
583
- return false unless other.is_a?(Point)
584
-
585
- if infinity? && other.infinity?
586
- true
587
- elsif infinity? || other.infinity?
588
- false
589
- else
590
- @x == other.x && @y == other.y
591
- end
592
- end
593
- alias eql? ==
594
-
595
- def hash
596
- infinity? ? 0 : [@x, @y].hash
597
- end
598
- end
599
- end
7
+ # Backwards-compatibility alias: BSV::Primitives::Secp256k1 ::Secp256k1
8
+ # This mapping will be removed in the next major version.
9
+ Secp256k1 = ::Secp256k1
600
10
  end
601
11
  end
602
- # rubocop:enable Naming/MethodParameterName, Metrics/ModuleLength
@@ -59,7 +59,9 @@ module BSV
59
59
 
60
60
  # Parse S
61
61
  s_offset = 4 + r_len
62
+ raise ArgumentError, 'truncated: missing S tag' if s_offset >= bytes.length
62
63
  raise ArgumentError, 'invalid integer tag for S' unless bytes[s_offset] == 0x02
64
+ raise ArgumentError, 'truncated: missing S length' if s_offset + 1 >= bytes.length
63
65
 
64
66
  s_len = bytes[s_offset + 1]
65
67
  raise ArgumentError, 'S length overflows' if s_offset + 2 + s_len > bytes.length
@@ -73,17 +73,18 @@ module BSV
73
73
  else
74
74
  # Specific recipient
75
75
  verifier_pub_bytes = sig.byteslice(37, 33)
76
- verifier_pub_hex = verifier_pub_bytes.unpack1('H*')
77
76
 
78
77
  if recipient.nil?
79
78
  raise ArgumentError,
80
- "this signature can only be verified with knowledge of a specific private key. The associated public key is: #{verifier_pub_hex}"
79
+ 'this signature can only be verified with knowledge of a specific private key. ' \
80
+ "The associated public key is: #{verifier_pub_bytes.unpack1('H*')}"
81
81
  end
82
82
 
83
- recipient_pub_hex = recipient.public_key.compressed.unpack1('H*')
84
- if verifier_pub_hex != recipient_pub_hex
83
+ recipient_pub_bytes = recipient.public_key.compressed
84
+ if verifier_pub_bytes != recipient_pub_bytes
85
85
  raise ArgumentError,
86
- "the recipient public key is #{recipient_pub_hex} but the signature requires the recipient to have public key #{verifier_pub_hex}"
86
+ "the recipient public key is #{recipient_pub_bytes.unpack1('H*')} " \
87
+ "but the signature requires the recipient to have public key #{verifier_pub_bytes.unpack1('H*')}"
87
88
  end
88
89
 
89
90
  key_id_offset = 70
Binary file
data/lib/bsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BSV
4
- VERSION = '0.14.0'
4
+ VERSION = '0.15.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: secp256k1-native
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.16'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.16'
26
40
  description: A Ruby library for interacting with the BSV Blockchain — keys, scripts,
27
41
  transactions, and more.
28
42
  executables:
@@ -142,6 +156,7 @@ files:
142
156
  - lib/bsv/script/opcodes.rb
143
157
  - lib/bsv/script/push_drop_template.rb
144
158
  - lib/bsv/script/script.rb
159
+ - lib/bsv/secp256k1_native.bundle
145
160
  - lib/bsv/transaction.rb
146
161
  - lib/bsv/transaction/beef.rb
147
162
  - lib/bsv/transaction/chain_tracker.rb