|
| 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 | +``` |
0 commit comments