Skip to content

Commit 756a7fc

Browse files
authored
Merge pull request #38 from sgbett/feature/15-bitcoin-signed-message
feat(primitives): add Bitcoin Signed Message (BSM) sign/verify
2 parents 8d69af7 + cdc6509 commit 756a7fc

File tree

7 files changed

+663
-12
lines changed

7 files changed

+663
-12
lines changed
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Bitcoin Signed Message (BSM) — Task #15
2+
3+
## Context
4+
5+
Issue #15, sub-task of HLR #4. Adds traditional Bitcoin Signed Message sign/verify (compact 65-byte signature with public key recovery). This is the legacy BSM format (`\x18Bitcoin Signed Message:\n` prefix), NOT BRC-77 SignedMessage.
6+
7+
The Go SDK implements this in `compat/bsm/`.
8+
9+
---
10+
11+
## File Structure
12+
13+
```
14+
lib/bsv/primitives/ecdsa.rb # MODIFIED — add sign_recoverable + recover_public_key
15+
lib/bsv/primitives/bsm.rb # NEW — BSM sign/verify module
16+
lib/bsv/primitives.rb # MODIFIED — add autoload :BSM
17+
spec/bsv/primitives/ecdsa_spec.rb # MODIFIED — add recovery specs
18+
spec/bsv/primitives/bsm_spec.rb # NEW — BSM specs with Go SDK test vectors
19+
```
20+
21+
---
22+
23+
## Implementation
24+
25+
### 1. ECDSA Recovery Operations (`lib/bsv/primitives/ecdsa.rb`)
26+
27+
Add two new `module_function` methods.
28+
29+
#### `sign_recoverable(hash, private_key_bn)``[Signature, recovery_id]`
30+
31+
Refactor: extract shared signing logic into a private `sign_raw` method that returns `[Signature, recovery_id]`. Then:
32+
- `sign` calls `sign_raw` and returns just the signature (preserving existing API)
33+
- `sign_recoverable` calls `sign_raw` and returns both
34+
35+
```ruby
36+
def sign(hash, private_key_bn)
37+
sig, _recovery_id = sign_raw(hash, private_key_bn)
38+
sig
39+
end
40+
41+
def sign_recoverable(hash, private_key_bn)
42+
sign_raw(hash, private_key_bn)
43+
end
44+
```
45+
46+
Private `sign_raw`:
47+
```ruby
48+
def sign_raw(hash, private_key_bn)
49+
k = nonce_rfc6979(private_key_bn, hash)
50+
k_inv = k.mod_inverse(Curve::N)
51+
r_point = Curve.multiply_generator(k)
52+
r = Curve.point_x(r_point) % Curve::N
53+
raise 'calculated R is zero' if r.zero?
54+
55+
e = OpenSSL::BN.new(hash, 2)
56+
s = (k_inv * ((e + (private_key_bn * r)) % Curve::N)) % Curve::N
57+
raise 'calculated S is zero' if s.zero?
58+
59+
# Recovery ID: bit 0 = R.y parity, bit 1 = R.x overflow (≥ N)
60+
r_y_odd = r_point.to_octet_string(:compressed).getbyte(0) == 0x03
61+
r_overflow = Curve.point_x(r_point) >= Curve::N
62+
recovery_id = (r_y_odd ? 1 : 0) + (r_overflow ? 2 : 0)
63+
64+
sig = Signature.new(r, s)
65+
unless sig.low_s?
66+
sig = sig.to_low_s
67+
recovery_id ^= 1 # Flipping s negates R.y, toggling parity
68+
end
69+
70+
[sig, recovery_id]
71+
end
72+
```
73+
74+
#### `recover_public_key(hash, signature, recovery_id)``PublicKey`
75+
76+
```ruby
77+
def recover_public_key(hash, signature, recovery_id)
78+
r = signature.r
79+
s = signature.s
80+
n = Curve::N
81+
82+
# Reconstruct R.x (may include overflow)
83+
x = recovery_id >= 2 ? r + n : r
84+
85+
# Decompress R from x-coordinate and y-parity
86+
prefix = (recovery_id & 1).odd? ? "\x03".b : "\x02".b
87+
x_bytes = x.to_s(2)
88+
x_bytes = ("\x00".b * (32 - x_bytes.length)) + x_bytes if x_bytes.length < 32
89+
r_point = Curve.point_from_bytes(prefix + x_bytes)
90+
91+
# Q = r^(-1) * (s*R - e*G)
92+
r_inv = r.mod_inverse(n)
93+
e = OpenSSL::BN.new(hash, 2)
94+
u1 = ((n - e) * r_inv) % n
95+
u2 = (s * r_inv) % n
96+
97+
p1 = Curve.multiply_generator(u1)
98+
p2 = Curve.multiply_point(r_point, u2)
99+
q = Curve.add_points(p1, p2)
100+
101+
raise ArgumentError, 'recovered point is at infinity' if q.infinity?
102+
103+
PublicKey.new(q)
104+
end
105+
```
106+
107+
### 2. BSM Module (`lib/bsv/primitives/bsm.rb`)
108+
109+
Follows the `module_function` pattern. Stateless operations.
110+
111+
#### API
112+
113+
| Method | Description |
114+
|--------|-------------|
115+
| `.sign(message, private_key)` | Sign message, return base64 compact signature |
116+
| `.verify(message, signature, address)` | Verify signature against address, return boolean |
117+
| `.magic_hash(message)` | Compute the BSM double-SHA256 hash (exposed for testing) |
118+
119+
#### Algorithm: `sign`
120+
121+
1. `hash = magic_hash(message)`
122+
2. `sig, recovery_id = ECDSA.sign_recoverable(hash, private_key.bn)`
123+
3. `flag = 31 + recovery_id` (31–34 = compressed P2PKH per BIP-137)
124+
4. `compact = [flag].pack('C') + bn_to_bytes(sig.r) + bn_to_bytes(sig.s)` (65 bytes)
125+
5. Return `[compact].pack('m0')` (base64, no line breaks)
126+
127+
Always uses compressed keys (the SDK only supports compressed public keys).
128+
129+
#### Algorithm: `verify`
130+
131+
1. Decode: `compact = signature.unpack1('m0')` — validate 65 bytes
132+
2. Parse: `flag = compact.getbyte(0)`, `r = compact[1, 32]`, `s = compact[33, 32]`
133+
3. Validate flag in range 27–34
134+
4. `recovery_id = (flag - 27) & 3`
135+
5. `compressed = flag >= 31`
136+
6. `hash = magic_hash(message)`
137+
7. `sig = Signature.new(r_bn, s_bn)`
138+
8. `pub = ECDSA.recover_public_key(hash, sig, recovery_id)`
139+
9. Derive address from recovered key (respecting compressed/uncompressed)
140+
10. Return `derived_address == address`
141+
142+
Rescue recovery errors (infinity, invalid point) → return `false`.
143+
144+
#### `magic_hash(message)`
145+
146+
```ruby
147+
MAGIC_PREFIX = "\x18Bitcoin Signed Message:\n".b.freeze
148+
149+
def magic_hash(message)
150+
message = message.encode('UTF-8') if message.encoding != Encoding::UTF_8
151+
msg_bytes = message.b
152+
buf = encode_varint(MAGIC_PREFIX.bytesize) + MAGIC_PREFIX +
153+
encode_varint(msg_bytes.bytesize) + msg_bytes
154+
Digest.sha256d(buf)
155+
end
156+
```
157+
158+
VarInt encoding is implemented locally (6 lines) to avoid cross-module dependency on `BSV::Transaction::VarInt`:
159+
160+
```ruby
161+
def encode_varint(len)
162+
if len < 0xFD
163+
[len].pack('C')
164+
elsif len <= 0xFFFF
165+
"\xFD".b + [len].pack('v')
166+
else
167+
"\xFE".b + [len].pack('V')
168+
end
169+
end
170+
```
171+
172+
---
173+
174+
## Existing Code to Reuse
175+
176+
| Need | Code | File |
177+
|------|------|------|
178+
| ECDSA signing | `ECDSA.sign` / RFC 6979 nonce | `lib/bsv/primitives/ecdsa.rb` |
179+
| Signature r/s model | `Signature.new(r, s)`, `#low_s?`, `#to_low_s` | `lib/bsv/primitives/signature.rb` |
180+
| Point operations | `Curve.multiply_generator`, `Curve.multiply_point`, `Curve.add_points` | `lib/bsv/primitives/curve.rb` |
181+
| Point decompression | `Curve.point_from_bytes(bytes)` | `lib/bsv/primitives/curve.rb` |
182+
| Double SHA-256 | `Digest.sha256d(data)` | `lib/bsv/primitives/digest.rb` |
183+
| Address derivation | `PublicKey#address(network:)` | `lib/bsv/primitives/public_key.rb` |
184+
| Key management | `PrivateKey#bn`, `PrivateKey#public_key` | `lib/bsv/primitives/private_key.rb` |
185+
186+
---
187+
188+
## Test Vectors
189+
190+
### Go SDK — Compressed Key Signatures (primary)
191+
192+
```
193+
Key hex: 0499f8239bfe10eb0f5e53d543635a423c96529dd85fa4bad42049a0b435ebdd
194+
Message: "test message"
195+
Expected: "IFxPx8JHsCiivB+DW/RgNpCLT6yG3j436cUNWKekV3ORBrHNChIjeVReyAco7PVmmDtVD3POs9FhDlm/nk5I6O8="
196+
197+
Key hex: ef0b8bad0be285099534277fde328f8f19b3be9cadcd4c08e6ac0b5f863745ac
198+
Message: "This is a test message"
199+
Expected: "H+zZagsyz7ioC/ZOa5EwsaKice0vs2BvZ0ljgkFHxD3vGsMlGeD4sXHEcfbI4h8lP29VitSBdf4A+nHXih7svf4="
200+
201+
Key hex: 93596babb564cbbdc84f2370c710b9bcc94333495b60af719b5fcf9ba00ba82c
202+
Message: "This is a test message"
203+
Expected: "IIuDw09ffPgEDuxEw5yHVp1+mi4QpuhAwLyQdpMTfsHCOkMqTKXuP7dSNWMEJqZsiQ8eKMDRvf2wZ4e5bxcu4O0="
204+
205+
Key hex: 50381cf8f52936faae4a05a073a03d688a9fa206d005e87a39da436c75476d78
206+
Message: "This is a test message"
207+
Expected: "ILBmbjCY2Z7eSXGXZoBI3x2ZRaYUYOGtEaDjXetaY+zNDtMOvagsOGEHnVT3f5kXlEbuvmPydHqLnyvZP3cDOWk="
208+
209+
Key hex: c7726663147afd1add392d129086e57c0b05aa66a6ded564433c04bd55741434
210+
Message: "This is a test message"
211+
Expected: "IOI207QUnTLr2Ll+s4kUxNgLgorkc/Z5Pc+XNvUBYLy2TxaU6oHEJ2TTJ1mZVrtUyHm6e315v1tIjeosW3Odfqw="
212+
213+
Key hex: c7726663147afd1add392d129086e57c0b05aa66a6ded564433c04bd55741434
214+
Message: "1"
215+
Expected: "IMcRFG1VNN9TDGXpCU+9CqKLNOuhwQiXI5hZpkTOuYHKBDOWayNuAABofYLqUHYTMiMf9mYFQ0sPgFJZz3F7ELQ="
216+
```
217+
218+
### Spec Coverage
219+
220+
**Deterministic vectors (Go SDK cross-compatibility):**
221+
- Sign each Go SDK vector, compare base64 output
222+
- Verify each Go SDK vector against derived address
223+
- Verify returns false for wrong address
224+
- Verify returns false for wrong message
225+
226+
**ECDSA recovery unit tests:**
227+
- `sign_recoverable` returns `[Signature, Integer]`
228+
- `sign_recoverable` signature matches `sign` output
229+
- `recover_public_key` recovers the correct public key
230+
- Recovery round-trip: sign → recover → compare pubkey
231+
- Multiple key/message combinations
232+
233+
**Round-trip:**
234+
- Sign then verify with various messages
235+
- Empty message
236+
- Unicode message
237+
- Long message (>252 bytes, exercises 3-byte varint)
238+
239+
**Error handling:**
240+
- Invalid base64 → `ArgumentError`
241+
- Signature wrong length → `ArgumentError`
242+
- Flag byte out of range → `ArgumentError`
243+
- Wrong address → returns `false`
244+
- Tampered signature → returns `false`
245+
246+
---
247+
248+
## Wiring
249+
250+
Add to `lib/bsv/primitives.rb` after the `ECIES` line:
251+
252+
```ruby
253+
autoload :BSM, 'bsv/primitives/bsm'
254+
```
255+
256+
---
257+
258+
## Commit
259+
260+
Single commit: `feat(primitives): add Bitcoin Signed Message (BSM) sign/verify`
261+
262+
---
263+
264+
## Verification
265+
266+
```bash
267+
bundle exec rspec spec/bsv/primitives/ecdsa_spec.rb
268+
bundle exec rspec spec/bsv/primitives/bsm_spec.rb
269+
bundle exec rubocop lib/bsv/primitives/ecdsa.rb lib/bsv/primitives/bsm.rb
270+
bundle exec rake
271+
```

.rubocop.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Metrics/PerceivedComplexity:
7676

7777
Metrics/ModuleLength:
7878
Exclude:
79+
- 'lib/bsv/primitives/ecdsa.rb'
7980
- 'lib/bsv/script/opcodes.rb'
8081

8182
Metrics/ParameterLists:

lib/bsv/primitives.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module Primitives
88
autoload :Signature, 'bsv/primitives/signature'
99
autoload :ECDSA, 'bsv/primitives/ecdsa'
1010
autoload :ECIES, 'bsv/primitives/ecies'
11+
autoload :BSM, 'bsv/primitives/bsm'
1112
autoload :PublicKey, 'bsv/primitives/public_key'
1213
autoload :PrivateKey, 'bsv/primitives/private_key'
1314
autoload :ExtendedKey, 'bsv/primitives/extended_key'

lib/bsv/primitives/bsm.rb

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# frozen_string_literal: true
2+
3+
module BSV
4+
module Primitives
5+
module BSM
6+
MAGIC_PREFIX = "Bitcoin Signed Message:\n".b.freeze
7+
8+
module_function
9+
10+
def sign(message, private_key)
11+
hash = magic_hash(message)
12+
sig, recovery_id = ECDSA.sign_recoverable(hash, private_key.bn)
13+
14+
# Flag byte: 31-34 = compressed P2PKH (BIP-137)
15+
flag = 31 + recovery_id
16+
compact = [flag].pack('C') + bn_to_bytes(sig.r) + bn_to_bytes(sig.s)
17+
[compact].pack('m0')
18+
end
19+
20+
def verify(message, signature, address)
21+
compact = decode_compact(signature)
22+
flag = compact.getbyte(0)
23+
validate_flag!(flag)
24+
25+
recovery_id = (flag - 27) & 3
26+
compressed = flag >= 31
27+
28+
r_bn = OpenSSL::BN.new(compact[1, 32], 2)
29+
s_bn = OpenSSL::BN.new(compact[33, 32], 2)
30+
sig = Signature.new(r_bn, s_bn)
31+
32+
hash = magic_hash(message)
33+
pub = ECDSA.recover_public_key(hash, sig, recovery_id)
34+
35+
derived = if compressed
36+
pub.address
37+
else
38+
h160 = Digest.hash160(pub.uncompressed)
39+
Base58.check_encode(PublicKey::MAINNET_PUBKEY_HASH + h160)
40+
end
41+
42+
derived == address
43+
rescue OpenSSL::PKey::EC::Point::Error
44+
false
45+
end
46+
47+
def magic_hash(message)
48+
message = message.encode('UTF-8') if message.encoding != Encoding::UTF_8
49+
msg_bytes = message.b
50+
buf = encode_varint(MAGIC_PREFIX.bytesize) + MAGIC_PREFIX +
51+
encode_varint(msg_bytes.bytesize) + msg_bytes
52+
Digest.sha256d(buf)
53+
end
54+
55+
class << self
56+
private
57+
58+
def bn_to_bytes(bn)
59+
bytes = bn.to_s(2)
60+
bytes = ("\x00".b * (32 - bytes.length)) + bytes if bytes.length < 32
61+
bytes
62+
end
63+
64+
def decode_compact(signature)
65+
compact = signature.unpack1('m0')
66+
unless compact.bytesize == 65
67+
raise ArgumentError,
68+
"invalid signature length: #{compact.bytesize} (expected 65)"
69+
end
70+
71+
compact
72+
rescue ArgumentError => e
73+
raise e if e.message.include?('invalid signature length')
74+
75+
raise ArgumentError, "invalid base64 encoding: #{e.message}"
76+
end
77+
78+
def validate_flag!(flag)
79+
return if flag.between?(27, 34)
80+
81+
raise ArgumentError,
82+
"flag byte #{flag} out of range (expected 27-34)"
83+
end
84+
85+
def encode_varint(len)
86+
if len < 0xFD
87+
[len].pack('C')
88+
elsif len <= 0xFFFF
89+
"\xFD".b + [len].pack('v')
90+
else
91+
"\xFE".b + [len].pack('V')
92+
end
93+
end
94+
end
95+
end
96+
end
97+
end

0 commit comments

Comments
 (0)