Skip to content

Commit 8d69af7

Browse files
authored
Merge pull request #37 from sgbett/feature/14-ecies-encryption
feat(primitives): add Electrum ECIES (BIE1) encryption
2 parents 4082708 + 657b2a6 commit 8d69af7

File tree

5 files changed

+453
-0
lines changed

5 files changed

+453
-0
lines changed
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
```

.rubocop.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ RSpec/MultipleExpectations:
111111
- 'spec/bsv/attest/**/*'
112112
- 'spec/integration/**/*'
113113

114+
RSpec/MultipleMemoizedHelpers:
115+
Exclude:
116+
- 'spec/bsv/primitives/**/*'
117+
114118
RSpec/ExampleLength:
115119
Exclude:
116120
- 'spec/bsv/primitives/**/*'

lib/bsv/primitives.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module Primitives
77
autoload :Base58, 'bsv/primitives/base58'
88
autoload :Signature, 'bsv/primitives/signature'
99
autoload :ECDSA, 'bsv/primitives/ecdsa'
10+
autoload :ECIES, 'bsv/primitives/ecies'
1011
autoload :PublicKey, 'bsv/primitives/public_key'
1112
autoload :PrivateKey, 'bsv/primitives/private_key'
1213
autoload :ExtendedKey, 'bsv/primitives/extended_key'

lib/bsv/primitives/ecies.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+
require 'openssl'
4+
5+
module BSV
6+
module Primitives
7+
module ECIES
8+
MAGIC = 'BIE1'.b.freeze
9+
10+
class DecryptionError < StandardError; end
11+
12+
module_function
13+
14+
def encrypt(message, public_key, private_key: nil)
15+
message = message.b if message.encoding != Encoding::ASCII_8BIT
16+
17+
ephemeral = private_key || PrivateKey.generate
18+
ephemeral_pub = ephemeral.public_key
19+
20+
iv, key_e, key_m = derive_keys(public_key.point, ephemeral.bn)
21+
22+
cipher = OpenSSL::Cipher.new('aes-128-cbc')
23+
cipher.encrypt
24+
cipher.key = key_e
25+
cipher.iv = iv
26+
ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final
27+
28+
payload = MAGIC + ephemeral_pub.compressed + ciphertext
29+
mac = Digest.hmac_sha256(key_m, payload)
30+
31+
payload + mac
32+
end
33+
34+
def decrypt(data, private_key)
35+
data = data.b if data.encoding != Encoding::ASCII_8BIT
36+
37+
raise ArgumentError, 'data too short' if data.bytesize < 85
38+
39+
magic = data[0, 4]
40+
raise ArgumentError, 'invalid magic: expected BIE1' unless magic == MAGIC
41+
42+
ephemeral_pub_bytes = data[4, 33]
43+
mac = data[-32, 32]
44+
ciphertext = data[37...-32]
45+
46+
ephemeral_pub = PublicKey.from_bytes(ephemeral_pub_bytes)
47+
48+
iv, key_e, key_m = derive_keys(ephemeral_pub.point, private_key.bn)
49+
50+
# Verify HMAC before decryption (encrypt-then-MAC)
51+
payload = data[0...-32]
52+
expected_mac = Digest.hmac_sha256(key_m, payload)
53+
54+
raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)
55+
56+
begin
57+
cipher = OpenSSL::Cipher.new('aes-128-cbc')
58+
cipher.decrypt
59+
cipher.key = key_e
60+
cipher.iv = iv
61+
cipher.update(ciphertext) + cipher.final
62+
rescue OpenSSL::Cipher::CipherError => e
63+
raise DecryptionError, "decryption failed: #{e.message}"
64+
end
65+
end
66+
67+
class << self
68+
private
69+
70+
def secure_compare(mac, expected)
71+
return false unless mac.bytesize == expected.bytesize
72+
73+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
74+
OpenSSL.fixed_length_secure_compare(mac, expected)
75+
else
76+
# Constant-time comparison for Ruby < 3.2
77+
result = 0
78+
mac.bytes.zip(expected.bytes) { |x, y| result |= x ^ y }
79+
result.zero?
80+
end
81+
end
82+
83+
def derive_keys(point, scalar_bn)
84+
shared_point = Curve.multiply_point(point, scalar_bn)
85+
ecdh_key = shared_point.to_octet_string(:compressed)
86+
derived = Digest.sha512(ecdh_key)
87+
88+
iv = derived[0, 16]
89+
key_e = derived[16, 16]
90+
key_m = derived[32, 32]
91+
92+
[iv, key_e, key_m]
93+
end
94+
end
95+
end
96+
end
97+
end

0 commit comments

Comments
 (0)