Class: Remitmd::PrivateKeySigner
- Inherits:
-
Object
- Object
- Remitmd::PrivateKeySigner
- 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
-
#address ⇒ Object
readonly
The Ethereum address (checksummed, 0x-prefixed).
Instance Method Summary collapse
-
#initialize(private_key_hex) ⇒ PrivateKeySigner
constructor
A new instance of PrivateKeySigner.
-
#inspect ⇒ Object
(also: #to_s)
Never expose the key in inspect/to_s output.
-
#sign(digest_bytes) ⇒ Object
Sign a 32-byte EIP-712 digest (raw binary bytes).
Methods included from Signer
Constructor Details
#initialize(private_key_hex) ⇒ PrivateKeySigner
Returns a new instance of PrivateKeySigner.
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
#address ⇒ Object (readonly)
The Ethereum address (checksummed, 0x-prefixed).
122 123 124 |
# File 'lib/remitmd/signer.rb', line 122 def address @address end |
Instance Method Details
#inspect ⇒ Object 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 |