Module: Runar::ECDSA

Includes:
ECPrimitives
Defined in:
lib/runar/ecdsa.rb

Overview

Real secp256k1 ECDSA signing and verification. See the module-level file comment for full documentation. rubocop:disable Metrics/ModuleLength

Constant Summary collapse

CURVE_P =

secp256k1 constants — re-exported for convenience.

ECPrimitives::SECP256K1_P
CURVE_N =
ECPrimitives::SECP256K1_N
CURVE_GX =
ECPrimitives::SECP256K1_GX
CURVE_GY =
ECPrimitives::SECP256K1_GY
TEST_MESSAGE =

The canonical test message shared across all Runar SDKs.

'runar-test-message-v1'
TEST_MESSAGE_DIGEST =
Digest::SHA256.digest(TEST_MESSAGE)

Constants included from ECPrimitives

Runar::ECPrimitives::SECP256K1_GX, Runar::ECPrimitives::SECP256K1_GY, Runar::ECPrimitives::SECP256K1_N, Runar::ECPrimitives::SECP256K1_P

Class Method Summary collapse

Methods included from ECPrimitives

extended_gcd, mod_inv, point_add, point_mul

Class Method Details

.decompress_compressed_pubkey(bytes, prefix) ⇒ Object

Handle the 0x02/0x03 compressed key case. rubocop:disable Metrics/AbcSize

Raises:

  • (ArgumentError)


369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/runar/ecdsa.rb', line 369

def decompress_compressed_pubkey(bytes, prefix)
  x    = bytes[1, 32].pack('C*').unpack1('H*').to_i(16)
  y_sq = (x.pow(3, CURVE_P) + 7) % CURVE_P
  y    = y_sq.pow((CURVE_P + 1) / 4, CURVE_P)

  raise ArgumentError, 'Point not on curve' unless (y * y) % CURVE_P == y_sq

  # Choose the correct parity
  if prefix == 0x02 && y.odd?
    y = CURVE_P - y
  elsif prefix == 0x03 && y.even?
    y = CURVE_P - y
  end

  [x, y]
end

.decompress_pubkey_bytes(pk_bytes) ⇒ Object

Decompress a binary public key to [x, y] integer coordinates.

Compressed format (33 bytes): 0x02 or 0x03 prefix + 32-byte x-coordinate. y = sqrt(x^3 + 7) mod p, choosing even/odd based on prefix. Since p ≡ 3 (mod 4), sqrt is y = (x^3 + 7)^((p+1)/4) mod p.

Uncompressed format (65 bytes): 0x04 prefix + 32-byte x + 32-byte y.

Raises:

  • (ArgumentError)


282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/runar/ecdsa.rb', line 282

def decompress_pubkey_bytes(pk_bytes)
  bytes  = pk_bytes.bytes
  prefix = bytes[0]

  return decompress_uncompressed_pubkey(bytes) if prefix == 0x04

  raise ArgumentError, "Expected 33-byte compressed pubkey, got #{bytes.length}" unless bytes.length == 33
  raise ArgumentError, "Invalid compressed pubkey prefix: 0x#{prefix.to_s(16).rjust(2, '0')}" unless
    [0x02, 0x03].include?(prefix)

  decompress_compressed_pubkey(bytes, prefix)
end

.decompress_public_key(hex) ⇒ Array(Integer, Integer)

Decompress a public key to [x, y] integer coordinates.

Handles:

- 33-byte compressed keys (0x02 or 0x03 prefix)
- 65-byte uncompressed keys (0x04 prefix)

Parameters:

  • hex (String)

    hex-encoded public key (33 or 65 bytes)

Returns:

  • (Array(Integer, Integer))
    x, y

    point coordinates

Raises:

  • (ArgumentError)

    if the key is malformed or not on the curve



98
99
100
# File 'lib/runar/ecdsa.rb', line 98

def decompress_public_key(hex)
  decompress_pubkey_bytes([hex].pack('H*'))
end

.decompress_uncompressed_pubkey(bytes) ⇒ Object

Handle the 0x04 uncompressed key case.

Raises:

  • (ArgumentError)


357
358
359
360
361
362
363
364
365
# File 'lib/runar/ecdsa.rb', line 357

def decompress_uncompressed_pubkey(bytes)
  raise ArgumentError, "Expected 65-byte uncompressed pubkey, got #{bytes.length}" unless bytes.length == 65

  x = bytes[1, 32].pack('C*').unpack1('H*').to_i(16)
  y = bytes[33, 32].pack('C*').unpack1('H*').to_i(16)
  raise ArgumentError, 'Point not on curve' unless on_curve_secp256k1?(x, y)

  [x, y]
end

.ecdsa_sign(priv_key, msg_hash) ⇒ String

Sign a message hash with a private key integer.

rubocop:disable Metrics/MethodLength, Metrics/AbcSize

Parameters:

  • priv_key (Integer)
  • msg_hash (String)

    binary 32-byte hash

Returns:

  • (String)

    binary DER-encoded signature



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/runar/ecdsa.rb', line 158

def ecdsa_sign(priv_key, msg_hash)
  z = msg_hash.unpack1('H*').to_i(16)
  k = rfc6979_k(priv_key, msg_hash)

  rx_pt = ECPrimitives.point_mul(k, [CURVE_GX, CURVE_GY])
  raise 'ECDSA signing failed: R is infinity' if rx_pt.nil?

  sig_r = rx_pt[0] % CURVE_N
  raise 'ECDSA signing failed: r == 0' if sig_r.zero?

  k_inv = ECPrimitives.mod_inv(k, CURVE_N)
  sig_s = (k_inv * (z + sig_r * priv_key)) % CURVE_N
  raise 'ECDSA signing failed: s == 0' if sig_s.zero?

  # Low-S normalization (BIP 62): if s > n/2, use n - s
  sig_s = CURVE_N - sig_s if sig_s > CURVE_N / 2

  encode_der_signature(sig_r, sig_s)
end

.ecdsa_verify(sig_bytes, pk_bytes, msg_hash) ⇒ Boolean

Verify an ECDSA signature (binary inputs).

Standard ECDSA verification:

1. w  = s^-1 mod n
2. u1 = z * w mod n
3. u2 = r * w mod n
4. (x, y) = u1*G + u2*Q
5. Valid if x mod n == r

rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity

Parameters:

  • sig_bytes (String)

    binary DER signature

  • pk_bytes (String)

    binary public key (33 or 65 bytes)

  • msg_hash (String)

    binary 32-byte message hash

Returns:



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/runar/ecdsa.rb', line 120

def ecdsa_verify(sig_bytes, pk_bytes, msg_hash)
  parsed = parse_der_signature_bytes(sig_bytes)
  return false if parsed.nil?

  sig_r, sig_s = parsed
  return false if sig_r <= 0 || sig_r >= CURVE_N || sig_s <= 0 || sig_s >= CURVE_N

  # BIP-62 rule 5 / SCRIPT_VERIFY_LOW_S: reject high-S signatures.
  # Bitcoin nodes enforce this on-chain; the signer already normalizes to
  # low-S (see ecdsa_sign), so the verifier must mirror that enforcement.
  half_n = CURVE_N >> 1
  return false if sig_s > half_n

  qx, qy = decompress_pubkey_bytes(pk_bytes)

  z   = msg_hash.unpack1('H*').to_i(16)
  w   = ECPrimitives.mod_inv(sig_s, CURVE_N)
  u1  = (z * w) % CURVE_N
  u2  = (sig_r * w) % CURVE_N

  pt1   = ECPrimitives.point_mul(u1, [CURVE_GX, CURVE_GY])
  pt2   = ECPrimitives.point_mul(u2, [qx, qy])
  rx_pt = ECPrimitives.point_add(pt1, pt2)

  return false if rx_pt.nil?

  rx_pt[0] % CURVE_N == sig_r
rescue ArgumentError
  false
end

.encode_der_signature(r, s) ⇒ Object

Encode r and s integers as a binary DER ECDSA signature.

DER format: 0x30 [total_len] 0x02 [r_len] [r_bytes] 0x02 [s_len] [s_bytes] Integer bytes are unsigned big-endian with a leading 0x00 if the high bit is set (to keep them positive in DER’s signed-integer encoding).

rubocop:disable Naming/MethodParameterName



247
248
249
250
251
252
253
# File 'lib/runar/ecdsa.rb', line 247

def encode_der_signature(r, s)
  r_bytes = int_to_der_bytes(r)
  s_bytes = int_to_der_bytes(s)

  inner = "\x02#{[r_bytes.length].pack('C')}#{r_bytes}\x02#{[s_bytes.length].pack('C')}#{s_bytes}"
  "\x30#{[inner.length].pack('C')}#{inner}"
end

.hmac_sha256(key, data) ⇒ String

Compute HMAC-SHA256 of data with key.

Parameters:

  • key (String)

    binary key

  • data (String)

    binary data

Returns:

  • (String)

    32-byte binary digest



343
344
345
# File 'lib/runar/ecdsa.rb', line 343

def hmac_sha256(key, data)
  OpenSSL::HMAC.digest('sha256', key, data)
end

.int_to_32_bytes(value) ⇒ Object

Encode a non-negative integer as a 32-byte big-endian binary string.



348
349
350
# File 'lib/runar/ecdsa.rb', line 348

def int_to_32_bytes(value)
  [value.to_s(16).rjust(64, '0')].pack('H*')
end

.int_to_der_bytes(value) ⇒ Object

Convert a positive integer to unsigned big-endian DER bytes.

Prepends a 0x00 byte if the high bit of the first byte is set, to distinguish positive integers from negatives in DER’s signed encoding.



260
261
262
263
264
265
266
267
268
# File 'lib/runar/ecdsa.rb', line 260

def int_to_der_bytes(value)
  byte_len = (value.bit_length + 7) / 8
  byte_len = 1 if byte_len.zero?

  bytes = byte_len.times.map { |i| (value >> (8 * (byte_len - 1 - i))) & 0xFF }
  bytes.unshift(0x00) if bytes[0] & 0x80 != 0

  bytes.pack('C*')
end

.on_curve_secp256k1?(x, y) ⇒ Boolean

Check whether (x, y) lies on the secp256k1 curve (y^2 = x^3 + 7 mod p).

rubocop:disable Naming/MethodParameterName

Returns:



390
391
392
# File 'lib/runar/ecdsa.rb', line 390

def on_curve_secp256k1?(x, y)
  (y * y) % CURVE_P == (x.pow(3, CURVE_P) + 7) % CURVE_P
end

.parse_der_signature(hex) ⇒ Array(Integer, Integer)?

Parse a DER-encoded ECDSA signature into [r, s] integers.

Also handles a trailing sighash byte (Bitcoin convention): if the actual length exceeds the declared DER length by 1, the last byte is stripped.

Parameters:

  • hex (String)

    hex-encoded DER signature

Returns:

  • (Array(Integer, Integer), nil)
    r, s

    or nil on parse failure



85
86
87
# File 'lib/runar/ecdsa.rb', line 85

def parse_der_signature(hex)
  parse_der_signature_bytes([hex].pack('H*'))
end

.parse_der_signature_bytes(der_bytes) ⇒ Object

Parse a binary DER signature into [r, s] integers, or nil on failure.

DER format: 0x30 [total_len] 0x02 [r_len] [r_bytes] 0x02 [s_len] [s_bytes]

rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/runar/ecdsa.rb', line 188

def parse_der_signature_bytes(der_bytes)
  bytes = der_bytes.bytes
  return nil if bytes.length < 8
  return nil if bytes[0] != 0x30

  declared_len      = bytes[1]
  expected_pure_der = declared_len + 2

  # Strip trailing sighash byte if present (Bitcoin convention)
  if bytes.length == expected_pure_der + 1
    bytes = bytes[0, expected_pure_der]
  elsif bytes.length != expected_pure_der
    return nil
  end

  idx = 2

  # Parse r
  return nil if idx >= bytes.length || bytes[idx] != 0x02

  idx   += 1
  r_len  = bytes[idx]
  idx   += 1
  return nil if r_len.zero?
  return nil if idx + r_len > bytes.length

  r_component = bytes[idx, r_len]
  # Non-minimal encoding: leading 0x00 when the next byte's high bit is clear
  return nil if r_len > 1 && r_component[0] == 0x00 && r_component[1] & 0x80 == 0

  parsed_r  = r_component.pack('C*').unpack1('H*').to_i(16)
  idx      += r_len

  # Parse s
  return nil if idx >= bytes.length || bytes[idx] != 0x02

  idx   += 1
  s_len  = bytes[idx]
  idx   += 1
  return nil if s_len.zero?
  return nil if idx + s_len > bytes.length

  s_component = bytes[idx, s_len]
  # Non-minimal encoding: leading 0x00 when the next byte's high bit is clear
  return nil if s_len > 1 && s_component[0] == 0x00 && s_component[1] & 0x80 == 0

  parsed_s = s_component.pack('C*').unpack1('H*').to_i(16)

  [parsed_r, parsed_s]
end

.pub_key_from_priv_key(priv_key_hex) ⇒ String

Derive the compressed public key from a private key.

Parameters:

  • priv_key_hex (String)

    64-character hex private key

Returns:

  • (String)

    hex-encoded 33-byte compressed public key



55
56
57
58
59
60
# File 'lib/runar/ecdsa.rb', line 55

def pub_key_from_priv_key(priv_key_hex)
  priv_key = priv_key_hex.to_i(16)
  px, py = ECPrimitives.point_mul(priv_key, [CURVE_GX, CURVE_GY])
  prefix = py.even? ? 0x02 : 0x03
  ([prefix].pack('C') + int_to_32_bytes(px)).unpack1('H*')
end

.rfc6979_k(priv_key, msg_hash) ⇒ Object

Generate deterministic k per RFC 6979 using HMAC-SHA256.

Implements the HMAC-DRBG algorithm from Section 3.2 of RFC 6979. Using the same algorithm as Python/TypeScript ensures signing produces identical signatures across all SDK runtimes.

rubocop:disable Metrics/MethodLength, Metrics/AbcSize



306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/runar/ecdsa.rb', line 306

def rfc6979_k(priv_key, msg_hash)
  # Private key as 32-byte big-endian binary string
  priv_bytes = int_to_32_bytes(priv_key)

  # Steps b–c: V = 0x01*32, K = 0x00*32
  v     = "\x01" * 32
  k_mac = "\x00" * 32

  # Steps d–g: two rounds of HMAC-DRBG seeding
  k_mac = hmac_sha256(k_mac, "#{v}\x00#{priv_bytes}#{msg_hash}")
  v     = hmac_sha256(k_mac, v)
  k_mac = hmac_sha256(k_mac, "#{v}\x01#{priv_bytes}#{msg_hash}")
  v     = hmac_sha256(k_mac, v)

  # Step h: generate candidate k values
  loop do
    v         = hmac_sha256(k_mac, v)
    candidate = v.unpack1('H*').to_i(16)

    return candidate if candidate >= 1 && candidate < CURVE_N

    # Retry: update K and V
    k_mac = hmac_sha256(k_mac, "#{v}\x00")
    v     = hmac_sha256(k_mac, v)
  end
end

.sign_test_message(priv_key_hex) ⇒ String

Sign the fixed TEST_MESSAGE with a private key.

Returns a hex-encoded DER ECDSA signature. The result is deterministic (RFC 6979) and matches the Python/TypeScript SDK output for the same key.

Parameters:

  • priv_key_hex (String)

    64-character hex private key

Returns:

  • (String)

    hex-encoded DER signature



46
47
48
49
# File 'lib/runar/ecdsa.rb', line 46

def sign_test_message(priv_key_hex)
  priv_key = priv_key_hex.to_i(16)
  ecdsa_sign(priv_key, TEST_MESSAGE_DIGEST).unpack1('H*')
end

.verify(msg_hash_hex, sig_der_hex, pubkey_hex) ⇒ Boolean

Verify an ECDSA signature over a message hash.

Parameters:

  • msg_hash_hex (String)

    hex-encoded 32-byte SHA-256 message hash

  • sig_der_hex (String)

    hex-encoded DER signature (with optional trailing sighash byte)

  • pubkey_hex (String)

    hex-encoded compressed or uncompressed public key (33 or 65 bytes)

Returns:

  • (Boolean)

    true if the signature is valid, false otherwise



70
71
72
73
74
75
76
# File 'lib/runar/ecdsa.rb', line 70

def verify(msg_hash_hex, sig_der_hex, pubkey_hex)
  sig_bytes = [sig_der_hex].pack('H*')
  pk_bytes  = [pubkey_hex].pack('H*')
  msg_hash  = [msg_hash_hex].pack('H*')

  ecdsa_verify(sig_bytes, pk_bytes, msg_hash)
end