Class: Remitmd::PrivateKeySigner

Inherits:
Object
  • Object
show all
Includes:
Signer
Defined in:
lib/remitmd/signer.rb

Overview

Signs remit.md API requests using a raw secp256k1 private key. The private key is held in memory and never exposed via public methods.

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Signer

#sign_hash

Constructor Details

#initialize(private_key_hex) ⇒ PrivateKeySigner

Returns a new instance of PrivateKeySigner.

Raises:

  • (ArgumentError)


43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/remitmd/signer.rb', line 43

def initialize(private_key_hex)
  hex = private_key_hex.to_s.delete_prefix("0x")
  raise ArgumentError, "Private key must be 64 hex characters" unless hex.match?(/\A[0-9a-fA-F]{64}\z/)

  key_bytes = [hex].pack("H*")
  group = OpenSSL::PKey::EC::Group.new("secp256k1")
  bn = OpenSSL::BN.new(key_bytes, 2)
  pub_point = group.generator.mul(bn)

  # Build SEC1 DER-encoded private key (OpenSSL 3.x: PKey objects are immutable)
  asn1 = OpenSSL::ASN1::Sequence.new([
    OpenSSL::ASN1::Integer.new(1),
    OpenSSL::ASN1::OctetString.new(key_bytes),
    OpenSSL::ASN1::ASN1Data.new(
      [OpenSSL::ASN1::ObjectId.new("secp256k1")], 0, :CONTEXT_SPECIFIC
    ),
    OpenSSL::ASN1::ASN1Data.new(
      [OpenSSL::ASN1::BitString.new(pub_point.to_octet_string(:uncompressed))],
      1, :CONTEXT_SPECIFIC
    )
  ])
  @key = OpenSSL::PKey::EC.new(asn1.to_der)
  @address = derive_address(pub_point)
end

Instance Attribute Details

#addressObject (readonly)

The Ethereum address (checksummed, 0x-prefixed).



122
123
124
# File 'lib/remitmd/signer.rb', line 122

def address
  @address
end

Instance Method Details

#inspectObject Also known as: to_s

Never expose the key in inspect/to_s output.



125
126
127
# File 'lib/remitmd/signer.rb', line 125

def inspect
  "#<Remitmd::PrivateKeySigner address=#{@address}>"
end

#sign(digest_bytes) ⇒ Object

Sign a 32-byte EIP-712 digest (raw binary bytes). Returns a 0x-prefixed 65-byte hex signature (r || s || v) in Ethereum style. v is 27 (0x1b) or 28 (0x1c).



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/remitmd/signer.rb', line 71

def sign(digest_bytes)
  group = @key.group
  n     = group.order

  # ECDSA sign - dsa_sign_asn1 uses the input directly as the hash (no pre-hashing)
  der  = @key.dsa_sign_asn1(digest_bytes)
  asn1 = OpenSSL::ASN1.decode(der)
  bn_r = asn1.value[0].value
  bn_s = asn1.value[1].value

  # Compute the recovery ID (0 or 1) by checking which candidate R recovers our address.
  z     = OpenSSL::BN.new(digest_bytes.unpack1("H*"), 16)
  r_inv = bn_r.mod_inverse(n)
  # Q_candidate = r_inv * (s * R - z * G) = (r_inv*s)*R + (-(r_inv*z))*G
  a = r_inv.mod_mul(bn_s, n)
  b = n - r_inv.mod_mul(z, n)   # -r_inv*z mod n

  # Normalize s to low-s canonical form (required by the server's k256 verifier).
  # k256::ecdsa::Signature::from_slice rejects signatures with s > n/2.
  half_n = n >> 1
  if bn_s > half_n
    bn_s = n - bn_s
    # Recompute a and b for the new s
    a = r_inv.mod_mul(bn_s, n)
    b = n - r_inv.mod_mul(z, n)
  end

  v = nil
  [0, 1].each do |parity|
    r_point = recover_r_point(group, bn_r, parity)
    next unless r_point

    # Q = b*G + a*R (separate mul + add for OpenSSL 3.x compatibility)
    bg = group.generator.mul(b)
    ar = r_point.mul(a)
    q = bg.add(ar)
    candidate = derive_address(q)
    if candidate.downcase == @address.downcase
      v = 27 + parity
      break
    end
  end
  raise "Could not determine recovery ID - key or hash may be invalid" if v.nil?

  # Build 65-byte Ethereum signature: r (32) || s (32) || v (1)
  r_bytes = [bn_r.to_s(16).rjust(64, "0")].pack("H*")
  s_bytes = [bn_s.to_s(16).rjust(64, "0")].pack("H*")
  "0x#{(r_bytes + s_bytes + v.chr(Encoding::BINARY)).unpack1("H*")}"
end