|
| 1 | +# Electrum ECIES (BIE1) Encryption — Task #14 |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +Issue #14, sub-task of HLR #4. Adds `BSV::Primitives::ECIES` module implementing Electrum-compatible ECIES encryption/decryption (BIE1 format). |
| 6 | + |
| 7 | +BRC-78 `EncryptedMessage` (AES-256-GCM + BRC-42 key derivation) will be a separate follow-up task — it requires BRC-42 child key derivation which the SDK doesn't have yet. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## File Structure |
| 12 | + |
| 13 | +``` |
| 14 | +lib/bsv/primitives/ecies.rb # NEW — module with encrypt/decrypt |
| 15 | +lib/bsv/primitives.rb # MODIFIED — add autoload :ECIES |
| 16 | +spec/bsv/primitives/ecies_spec.rb # NEW — specs with cross-SDK test vectors |
| 17 | +``` |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## Implementation |
| 22 | + |
| 23 | +### `BSV::Primitives::ECIES` |
| 24 | + |
| 25 | +Follows the `module_function` pattern used by `ECDSA` and `Digest` — stateless cryptographic operations. |
| 26 | + |
| 27 | +**Constants:** |
| 28 | + |
| 29 | +```ruby |
| 30 | +MAGIC = "BIE1".b.freeze |
| 31 | +``` |
| 32 | + |
| 33 | +**Error class:** |
| 34 | + |
| 35 | +```ruby |
| 36 | +class DecryptionError < StandardError; end |
| 37 | +``` |
| 38 | + |
| 39 | +Distinguishes "structurally invalid" (`ArgumentError`) from "valid structure but wrong key / tampered" (`DecryptionError`). |
| 40 | + |
| 41 | +**API:** |
| 42 | + |
| 43 | +| Method | Description | |
| 44 | +|--------|-------------| |
| 45 | +| `.encrypt(message, public_key, private_key: nil)` | Encrypt message for recipient. Optional `private_key:` for deterministic sender (ephemeral if nil). Returns binary. | |
| 46 | +| `.decrypt(data, private_key)` | Decrypt BIE1 payload. Returns plaintext binary. Raises `DecryptionError` on HMAC/padding failure. | |
| 47 | + |
| 48 | +### Algorithm: `encrypt` |
| 49 | + |
| 50 | +1. Ephemeral key: use `private_key` if provided, otherwise `PrivateKey.generate` |
| 51 | +2. ECDH: `shared_point = Curve.multiply_point(public_key.point, ephemeral.bn)` |
| 52 | +3. `ecdh_key = shared_point.to_octet_string(:compressed)` (33 bytes) |
| 53 | +4. `derived = Digest.sha512(ecdh_key)` → 64 bytes |
| 54 | +5. Split: `iv = derived[0,16]`, `key_e = derived[16,16]`, `key_m = derived[32,32]` |
| 55 | +6. `ciphertext = AES-128-CBC(key_e, iv, message)` with PKCS7 padding (OpenSSL default) |
| 56 | +7. `payload = MAGIC + ephemeral_pub_compressed + ciphertext` |
| 57 | +8. `mac = Digest.hmac_sha256(key_m, payload)` |
| 58 | +9. Return: `payload + mac` |
| 59 | + |
| 60 | +### Algorithm: `decrypt` |
| 61 | + |
| 62 | +1. Validate minimum length: 4 + 33 + 16 + 32 = 85 bytes |
| 63 | +2. Parse: `magic(4) + ephemeral_pub(33) + ciphertext(variable) + mac(32)` |
| 64 | +3. Verify magic == `"BIE1"` |
| 65 | +4. Parse ephemeral public key via `PublicKey.from_bytes` (validates point on curve) |
| 66 | +5. ECDH + SHA-512 key derivation (same as encrypt) |
| 67 | +6. **Verify HMAC first** (Encrypt-then-MAC — must check before decrypting to prevent padding oracle) |
| 68 | +7. Use `OpenSSL.fixed_length_secure_compare` for constant-time MAC comparison |
| 69 | +8. AES-128-CBC decrypt, catch `OpenSSL::Cipher::CipherError` → re-raise as `DecryptionError` |
| 70 | + |
| 71 | +### Wire Format |
| 72 | + |
| 73 | +``` |
| 74 | +| "BIE1" (4) | ephemeral pubkey (33) | AES-CBC ciphertext (variable, PKCS7) | HMAC-SHA-256 (32) | |
| 75 | +``` |
| 76 | + |
| 77 | +--- |
| 78 | + |
| 79 | +## Existing Code to Reuse |
| 80 | + |
| 81 | +| Need | Code | File | |
| 82 | +|------|------|------| |
| 83 | +| ECDH point multiplication | `Curve.multiply_point(point, bn)` | `lib/bsv/primitives/curve.rb` | |
| 84 | +| SHA-512 | `Digest.sha512(data)` | `lib/bsv/primitives/digest.rb` | |
| 85 | +| HMAC-SHA-256 | `Digest.hmac_sha256(key, data)` | `lib/bsv/primitives/digest.rb` | |
| 86 | +| Key generation | `PrivateKey.generate` | `lib/bsv/primitives/private_key.rb` | |
| 87 | +| Point parsing | `PublicKey.from_bytes(bytes)` | `lib/bsv/primitives/public_key.rb` | |
| 88 | +| Compressed pubkey | `PublicKey#compressed` | `lib/bsv/primitives/public_key.rb` | |
| 89 | +| AES-128-CBC | `OpenSSL::Cipher.new('aes-128-cbc')` | Ruby OpenSSL stdlib | |
| 90 | +| Constant-time compare | `OpenSSL.fixed_length_secure_compare` | Ruby 2.7+ OpenSSL stdlib | |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## Test Vectors |
| 95 | + |
| 96 | +### TypeScript SDK — bidirectional (primary vector) |
| 97 | + |
| 98 | +``` |
| 99 | +Alice privkey: 77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb |
| 100 | +Bob privkey: 2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d |
| 101 | +Plaintext: "this is my test message" |
| 102 | +
|
| 103 | +Alice→Bob: QklFMQM55QTWSSsILaluEejwOXlrBs1IVcEB4kkqbxDz4Fap53XHOt6L3tKmrXho6yj6phfoiMkBOhUldRPnEI4fSZXbvZJHgyAzxA6SoujduvJXv+A9ri3po9veilrmc8p6dwo= |
| 104 | +Bob→Alice: QklFMQOGFyMXLo9Qv047K3BYJhmnJgt58EC8skYP/R2QU/U0yXXHOt6L3tKmrXho6yj6phfoiMkBOhUldRPnEI4fSZXbiaH4FsxKIOOvzolIFVAS0FplUmib2HnlAM1yP/iiPsU= |
| 105 | +``` |
| 106 | + |
| 107 | +### Go SDK — self-encryption |
| 108 | + |
| 109 | +``` |
| 110 | +WIF: L211enC224G1kV8pyyq7bjVd9SxZebnRYEzzM3i7ZHCc1c5E7dQu |
| 111 | +Plaintext: "hello world" |
| 112 | +Ciphertext: QklFMQO7zpX/GS4XpthCy6/hT38ZKsBGbn8JKMGHOY5ifmaoT890Krt9cIRk/ULXaB5uC08owRICzenFbm31pZGu0gCM2uOxpofwHacKidwZ0Q7aEw== |
| 113 | +``` |
| 114 | + |
| 115 | +### Spec Coverage |
| 116 | + |
| 117 | +**Deterministic vectors:** |
| 118 | +- TS SDK vector: Alice→Bob encrypt matches expected base64 |
| 119 | +- TS SDK vector: Bob→Alice encrypt matches expected base64 |
| 120 | +- Go SDK vector: self-encrypt matches expected base64 |
| 121 | +- Decrypt all three vectors, verify plaintext |
| 122 | + |
| 123 | +**Round-trip:** |
| 124 | +- Ephemeral key (no `private_key:` arg): encrypt then decrypt |
| 125 | +- Various message sizes: 0, 1, 15, 16, 17, 31, 32, 33, 1000 bytes |
| 126 | +- Binary data round-trip |
| 127 | +- Self-encryption (encrypt to own pubkey) |
| 128 | + |
| 129 | +**Error handling:** |
| 130 | +- Data too short (< 85 bytes) → `ArgumentError` |
| 131 | +- Wrong magic bytes → `ArgumentError` |
| 132 | +- Invalid ephemeral pubkey bytes → error |
| 133 | +- Wrong private key → `DecryptionError` (HMAC failure) |
| 134 | +- Tampered ciphertext → `DecryptionError` |
| 135 | +- Tampered MAC → `DecryptionError` |
| 136 | + |
| 137 | +**Output format:** |
| 138 | +- Starts with "BIE1" |
| 139 | +- Compressed pubkey at offset 4 (33 bytes) |
| 140 | +- Ends with 32-byte MAC |
| 141 | +- Return encoding is ASCII-8BIT |
| 142 | + |
| 143 | +--- |
| 144 | + |
| 145 | +## Security Notes |
| 146 | + |
| 147 | +- **Constant-time MAC comparison**: `OpenSSL.fixed_length_secure_compare` (Ruby 2.7+), not `==` |
| 148 | +- **Encrypt-then-MAC order**: HMAC verified before AES decryption (prevents padding oracle) |
| 149 | +- **No `base64` gem**: specs use `Array#pack('m0')` / `String#unpack1('m0')` (core Ruby, no gem needed) |
| 150 | + |
| 151 | +--- |
| 152 | + |
| 153 | +## Wiring |
| 154 | + |
| 155 | +Add to `lib/bsv/primitives.rb` after the `ECDSA` line: |
| 156 | + |
| 157 | +```ruby |
| 158 | +autoload :ECIES, 'bsv/primitives/ecies' |
| 159 | +``` |
| 160 | + |
| 161 | +--- |
| 162 | + |
| 163 | +## Commit |
| 164 | + |
| 165 | +Single commit: `feat(primitives): add Electrum ECIES (BIE1) encryption` |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## Verification |
| 170 | + |
| 171 | +```bash |
| 172 | +bundle exec rspec spec/bsv/primitives/ecies_spec.rb |
| 173 | +bundle exec rubocop lib/bsv/primitives/ecies.rb |
| 174 | +bundle exec rake |
| 175 | +``` |
0 commit comments