Skip to main content

bsv/compat/
ecies.rs

1//! ECIES (Elliptic Curve Integrated Encryption Scheme) implementation.
2//!
3//! Provides both Electrum and Bitcore variants of ECIES encryption
4//! for backward compatibility with existing BSV ecosystem tools.
5//! The preferred modern equivalent is BRC-78.
6
7use crate::compat::CompatError;
8use crate::primitives::aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt};
9use crate::primitives::big_number::Endian;
10use crate::primitives::hash::{sha256_hmac, sha512};
11use crate::primitives::private_key::PrivateKey;
12use crate::primitives::public_key::PublicKey;
13use crate::primitives::random::random_bytes;
14
15/// ECIES encryption with Electrum and Bitcore variants.
16///
17/// Electrum uses BIE1 magic bytes, derives IV from ECDH shared secret,
18/// and uses AES-128-CBC.
19///
20/// Bitcore uses a random IV prepended to ciphertext, derives a 32-byte
21/// key from ECDH shared secret, and uses AES-256-CBC.
22pub struct ECIES;
23
24/// Derive Electrum-variant keys from ECDH shared secret.
25///
26/// ECDH: P = pub_key.point * priv_key
27/// S = compressed encoding of P (33 bytes)
28/// hash = SHA-512(S)
29/// Returns (iv[16], kE[16], kM[32])
30fn derive_electrum_keys(
31    priv_key: &PrivateKey,
32    pub_key: &PublicKey,
33) -> ([u8; 16], [u8; 16], [u8; 32]) {
34    let p = pub_key.point().mul(priv_key.bn());
35    let s_buf = p.to_der(true); // compressed 33 bytes
36    let hash = sha512(&s_buf);
37
38    let mut iv = [0u8; 16];
39    let mut k_e = [0u8; 16];
40    let mut k_m = [0u8; 32];
41    iv.copy_from_slice(&hash[0..16]);
42    k_e.copy_from_slice(&hash[16..32]);
43    k_m.copy_from_slice(&hash[32..64]);
44
45    (iv, k_e, k_m)
46}
47
48/// Derive Bitcore-variant keys from ECDH shared secret.
49///
50/// ECDH: P = pub_key.point * priv_key
51/// S = P.x as 32-byte big-endian
52/// hash = SHA-512(S)
53/// Returns (kE[32], kM[32])
54fn derive_bitcore_keys(priv_key: &PrivateKey, pub_key: &PublicKey) -> ([u8; 32], [u8; 32]) {
55    let p = pub_key.point().mul(priv_key.bn());
56    let s_buf = p.get_x().to_array(Endian::Big, Some(32));
57    let hash = sha512(&s_buf);
58
59    let mut k_e = [0u8; 32];
60    let mut k_m = [0u8; 32];
61    k_e.copy_from_slice(&hash[0..32]);
62    k_m.copy_from_slice(&hash[32..64]);
63
64    (k_e, k_m)
65}
66
67impl ECIES {
68    /// Encrypt plaintext using the Electrum ECIES variant (BIE1 format).
69    ///
70    /// Format: "BIE1" (4 bytes) || ephemeral_pubkey (33 bytes) || ciphertext || HMAC (32 bytes)
71    ///
72    /// If sender_priv_key is provided, it is used instead of an ephemeral key.
73    pub fn electrum_encrypt(
74        plaintext: &[u8],
75        recipient_pub_key: &PublicKey,
76        sender_priv_key: Option<&PrivateKey>,
77    ) -> Result<Vec<u8>, CompatError> {
78        let ephemeral_key = match sender_priv_key {
79            Some(key) => key.clone(),
80            None => PrivateKey::from_random()?,
81        };
82
83        let (iv, k_e, k_m) = derive_electrum_keys(&ephemeral_key, recipient_pub_key);
84
85        // Encrypt with AES-128-CBC (16-byte key)
86        let ct = aes_cbc_encrypt(&k_e, &iv, plaintext)?;
87
88        // Build payload: "BIE1" + ephemeral compressed pubkey + ciphertext
89        let ephemeral_pub = ephemeral_key.to_public_key();
90        let r_buf = ephemeral_pub.to_der(); // compressed 33 bytes
91
92        let mut payload = Vec::with_capacity(4 + 33 + ct.len() + 32);
93        payload.extend_from_slice(b"BIE1");
94        payload.extend_from_slice(&r_buf);
95        payload.extend_from_slice(&ct);
96
97        // HMAC over payload (before appending HMAC)
98        let mac = sha256_hmac(&k_m, &payload);
99
100        payload.extend_from_slice(&mac);
101        Ok(payload)
102    }
103
104    /// Decrypt ciphertext using the Electrum ECIES variant (BIE1 format).
105    ///
106    /// Expects format: "BIE1" (4) || pubkey (33) || ciphertext || HMAC (32)
107    /// Minimum length: 4 + 33 + 16 + 32 = 85 (at least one AES block)
108    pub fn electrum_decrypt(
109        ciphertext: &[u8],
110        recipient_priv_key: &PrivateKey,
111    ) -> Result<Vec<u8>, CompatError> {
112        // Minimum: 4 (BIE1) + 33 (pubkey) + 16 (min ciphertext block) + 32 (hmac) = 85
113        if ciphertext.len() < 85 {
114            return Err(CompatError::InvalidCiphertext(format!(
115                "electrum ciphertext too short: {} bytes (min 85)",
116                ciphertext.len()
117            )));
118        }
119
120        // Verify magic bytes
121        if &ciphertext[0..4] != b"BIE1" {
122            return Err(CompatError::InvalidMagic);
123        }
124
125        // Extract components
126        let ephemeral_pub_bytes = &ciphertext[4..37];
127        let hmac_start = ciphertext.len() - 32;
128        let encrypted_data = &ciphertext[37..hmac_start];
129        let mac = &ciphertext[hmac_start..];
130
131        // Parse ephemeral public key
132        let ephemeral_pub = PublicKey::from_der_bytes(ephemeral_pub_bytes)?;
133
134        // Derive keys
135        let (iv, k_e, k_m) = derive_electrum_keys(recipient_priv_key, &ephemeral_pub);
136
137        // Verify HMAC over everything except the HMAC itself
138        let expected_mac = sha256_hmac(&k_m, &ciphertext[0..hmac_start]);
139        if mac != expected_mac {
140            return Err(CompatError::HmacMismatch);
141        }
142
143        // Decrypt
144        let plaintext = aes_cbc_decrypt(&k_e, &iv, encrypted_data)?;
145        Ok(plaintext)
146    }
147
148    /// Encrypt plaintext using the Bitcore ECIES variant.
149    ///
150    /// Format: ephemeral_pubkey (33) || iv (16) || aes_ciphertext || HMAC (32)
151    /// HMAC is over iv + aes_ciphertext only (not including pubkey).
152    ///
153    /// Uses AES-256-CBC with a 32-byte key derived from ECDH.
154    pub fn bitcore_encrypt(
155        plaintext: &[u8],
156        recipient_pub_key: &PublicKey,
157        sender_priv_key: Option<&PrivateKey>,
158    ) -> Result<Vec<u8>, CompatError> {
159        let ephemeral_key = match sender_priv_key {
160            Some(key) => key.clone(),
161            None => PrivateKey::from_random()?,
162        };
163
164        let (k_e, k_m) = derive_bitcore_keys(&ephemeral_key, recipient_pub_key);
165
166        // Generate random 16-byte IV
167        let iv_vec = random_bytes(16);
168        let mut iv = [0u8; 16];
169        iv.copy_from_slice(&iv_vec);
170
171        // Encrypt with AES-256-CBC (32-byte key)
172        let ct = aes_cbc_encrypt(&k_e, &iv, plaintext)?;
173
174        // Build c = iv || ciphertext (matching TS SDK AESCBC.encrypt with concatIvBuf=true)
175        let mut c = Vec::with_capacity(16 + ct.len());
176        c.extend_from_slice(&iv);
177        c.extend_from_slice(&ct);
178
179        // HMAC over c (iv + ciphertext, NOT including pubkey)
180        let mac = sha256_hmac(&k_m, &c);
181
182        // Final output: pubkey || c || hmac
183        let r_buf = ephemeral_key.to_public_key().to_der();
184        let mut result = Vec::with_capacity(33 + c.len() + 32);
185        result.extend_from_slice(&r_buf);
186        result.extend_from_slice(&c);
187        result.extend_from_slice(&mac);
188        Ok(result)
189    }
190
191    /// Decrypt ciphertext using the Bitcore ECIES variant.
192    ///
193    /// Expects format: pubkey (33) || iv (16) || aes_ciphertext || HMAC (32)
194    /// HMAC is verified over iv + aes_ciphertext only.
195    pub fn bitcore_decrypt(
196        ciphertext: &[u8],
197        recipient_priv_key: &PrivateKey,
198    ) -> Result<Vec<u8>, CompatError> {
199        // Minimum: 33 (pubkey) + 16 (iv) + 16 (min ct block) + 32 (hmac) = 97
200        if ciphertext.len() < 97 {
201            return Err(CompatError::InvalidCiphertext(format!(
202                "bitcore ciphertext too short: {} bytes (min 97)",
203                ciphertext.len()
204            )));
205        }
206
207        // Extract ephemeral public key
208        let ephemeral_pub_bytes = &ciphertext[0..33];
209        let ephemeral_pub = PublicKey::from_der_bytes(ephemeral_pub_bytes)?;
210
211        // c = everything between pubkey and hmac (iv + aes ciphertext)
212        let c = &ciphertext[33..ciphertext.len() - 32];
213        let mac = &ciphertext[ciphertext.len() - 32..];
214
215        // Derive keys
216        let (k_e, k_m) = derive_bitcore_keys(recipient_priv_key, &ephemeral_pub);
217
218        // Verify HMAC over c (iv + aes ciphertext)
219        let expected_mac = sha256_hmac(&k_m, c);
220        if mac != expected_mac {
221            return Err(CompatError::HmacMismatch);
222        }
223
224        // Extract IV and ciphertext from c
225        // Length check above guarantees c.len() >= 32, so this slice is always valid.
226        let iv: [u8; 16] = c[0..16]
227            .try_into()
228            .map_err(|_| CompatError::InvalidCiphertext("IV extraction failed".into()))?;
229        let encrypted_data = &c[16..];
230
231        // Decrypt with AES-256-CBC
232        let plaintext = aes_cbc_decrypt(&k_e, &iv, encrypted_data)?;
233        Ok(plaintext)
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::primitives::hash::sha256;
241
242    // Base64 decode helper
243    fn base64_decode(input: &str) -> Vec<u8> {
244        let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
245        let mut result = Vec::new();
246        let mut buf: u32 = 0;
247        let mut bits: u32 = 0;
248
249        for &byte in input.as_bytes() {
250            if byte == b'=' {
251                break;
252            }
253            let val = table.iter().position(|&b| b == byte);
254            if let Some(v) = val {
255                buf = (buf << 6) | (v as u32);
256                bits += 6;
257                if bits >= 8 {
258                    bits -= 8;
259                    result.push((buf >> bits) as u8);
260                    buf &= (1 << bits) - 1;
261                }
262            }
263        }
264        result
265    }
266
267    // Base64 encode helper
268    fn base64_encode(data: &[u8]) -> String {
269        let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
270        let mut result = String::new();
271        let mut i = 0;
272        while i < data.len() {
273            let b0 = data[i] as u32;
274            let b1 = if i + 1 < data.len() {
275                data[i + 1] as u32
276            } else {
277                0
278            };
279            let b2 = if i + 2 < data.len() {
280                data[i + 2] as u32
281            } else {
282                0
283            };
284            let triple = (b0 << 16) | (b1 << 8) | b2;
285            result.push(table[((triple >> 18) & 0x3f) as usize] as char);
286            result.push(table[((triple >> 12) & 0x3f) as usize] as char);
287            if i + 1 < data.len() {
288                result.push(table[((triple >> 6) & 0x3f) as usize] as char);
289            } else {
290                result.push('=');
291            }
292            if i + 2 < data.len() {
293                result.push(table[(triple & 0x3f) as usize] as char);
294            } else {
295                result.push('=');
296            }
297            i += 3;
298        }
299        result
300    }
301
302    #[allow(dead_code)]
303    fn hex_to_bytes(hex: &str) -> Vec<u8> {
304        (0..hex.len())
305            .step_by(2)
306            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
307            .collect()
308    }
309
310    #[allow(dead_code)]
311    fn bytes_to_hex(bytes: &[u8]) -> String {
312        bytes.iter().map(|b| format!("{b:02x}")).collect()
313    }
314
315    // ---- Electrum tests ----
316
317    #[test]
318    fn test_electrum_encrypt_decrypt_roundtrip() {
319        let sender_key = PrivateKey::from_hex(
320            "77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb",
321        )
322        .unwrap();
323        let recipient_key = PrivateKey::from_hex(
324            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
325        )
326        .unwrap();
327        let recipient_pub = recipient_key.to_public_key();
328
329        let plaintext = b"this is my test message";
330        let encrypted =
331            ECIES::electrum_encrypt(plaintext, &recipient_pub, Some(&sender_key)).unwrap();
332        let decrypted = ECIES::electrum_decrypt(&encrypted, &recipient_key).unwrap();
333
334        assert_eq!(decrypted, plaintext);
335    }
336
337    #[test]
338    fn test_electrum_ciphertext_starts_with_bie1() {
339        let recipient_key = PrivateKey::from_hex(
340            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
341        )
342        .unwrap();
343        let recipient_pub = recipient_key.to_public_key();
344
345        let encrypted = ECIES::electrum_encrypt(b"hello", &recipient_pub, None).unwrap();
346        assert_eq!(
347            &encrypted[0..4],
348            b"BIE1",
349            "Electrum ciphertext must start with BIE1"
350        );
351    }
352
353    #[test]
354    fn test_electrum_hmac_rejects_tampered_ciphertext() {
355        let recipient_key = PrivateKey::from_hex(
356            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
357        )
358        .unwrap();
359        let sender_key = PrivateKey::from_hex(
360            "77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb",
361        )
362        .unwrap();
363        let recipient_pub = recipient_key.to_public_key();
364
365        let mut encrypted =
366            ECIES::electrum_encrypt(b"hello", &recipient_pub, Some(&sender_key)).unwrap();
367
368        // Tamper with a byte in the middle (ciphertext area)
369        let mid = encrypted.len() / 2;
370        encrypted[mid] ^= 0xff;
371
372        let result = ECIES::electrum_decrypt(&encrypted, &recipient_key);
373        assert!(result.is_err(), "should reject tampered ciphertext");
374    }
375
376    #[test]
377    fn test_electrum_decrypt_wrong_key_fails() {
378        let sender_key = PrivateKey::from_hex(
379            "77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb",
380        )
381        .unwrap();
382        let recipient_key = PrivateKey::from_hex(
383            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
384        )
385        .unwrap();
386        let wrong_key = PrivateKey::from_hex(
387            "0000000000000000000000000000000000000000000000000000000000000001",
388        )
389        .unwrap();
390
391        let recipient_pub = recipient_key.to_public_key();
392        let encrypted =
393            ECIES::electrum_encrypt(b"secret", &recipient_pub, Some(&sender_key)).unwrap();
394
395        let result = ECIES::electrum_decrypt(&encrypted, &wrong_key);
396        assert!(result.is_err(), "should fail with wrong private key");
397    }
398
399    // ---- Cross-SDK Electrum tests ----
400
401    #[test]
402    fn test_electrum_cross_sdk_decrypt_alice_to_bob() {
403        // From TS SDK ECIES test: alice encrypts for bob
404        let bob_key = PrivateKey::from_hex(
405            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
406        )
407        .unwrap();
408
409        let ciphertext = base64_decode(
410            "QklFMQM55QTWSSsILaluEejwOXlrBs1IVcEB4kkqbxDz4Fap53XHOt6L3tKmrXho6yj6phfoiMkBOhUldRPnEI4fSZXbvZJHgyAzxA6SoujduvJXv+A9ri3po9veilrmc8p6dwo="
411        );
412
413        let plaintext = ECIES::electrum_decrypt(&ciphertext, &bob_key).unwrap();
414        assert_eq!(
415            std::str::from_utf8(&plaintext).unwrap(),
416            "this is my test message"
417        );
418    }
419
420    #[test]
421    fn test_electrum_cross_sdk_decrypt_bob_to_alice() {
422        // From TS SDK ECIES test: bob encrypts for alice
423        let alice_key = PrivateKey::from_hex(
424            "77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb",
425        )
426        .unwrap();
427
428        let ciphertext = base64_decode(
429            "QklFMQOGFyMXLo9Qv047K3BYJhmnJgt58EC8skYP/R2QU/U0yXXHOt6L3tKmrXho6yj6phfoiMkBOhUldRPnEI4fSZXbiaH4FsxKIOOvzolIFVAS0FplUmib2HnlAM1yP/iiPsU="
430        );
431
432        let plaintext = ECIES::electrum_decrypt(&ciphertext, &alice_key).unwrap();
433        assert_eq!(
434            std::str::from_utf8(&plaintext).unwrap(),
435            "this is my test message"
436        );
437    }
438
439    #[test]
440    fn test_electrum_cross_sdk_encrypt_matches_ts() {
441        // Verify that our encrypt output matches TS SDK exactly (deterministic with known keys)
442        let alice_key = PrivateKey::from_hex(
443            "77e06abc52bf065cb5164c5deca839d0276911991a2730be4d8d0a0307de7ceb",
444        )
445        .unwrap();
446        let bob_key = PrivateKey::from_hex(
447            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
448        )
449        .unwrap();
450        let bob_pub = bob_key.to_public_key();
451
452        let message = b"this is my test message";
453        let encrypted = ECIES::electrum_encrypt(message, &bob_pub, Some(&alice_key)).unwrap();
454        let expected_b64 = "QklFMQM55QTWSSsILaluEejwOXlrBs1IVcEB4kkqbxDz4Fap53XHOt6L3tKmrXho6yj6phfoiMkBOhUldRPnEI4fSZXbvZJHgyAzxA6SoujduvJXv+A9ri3po9veilrmc8p6dwo=";
455        assert_eq!(base64_encode(&encrypted), expected_b64);
456    }
457
458    // ---- Bitcore tests ----
459
460    #[test]
461    fn test_bitcore_encrypt_decrypt_roundtrip() {
462        let sender_key = PrivateKey::from_hex(
463            "000000000000000000000000000000000000000000000000000000000000002a",
464        )
465        .unwrap();
466        let recipient_key = PrivateKey::from_hex(
467            "0000000000000000000000000000000000000000000000000000000000000058",
468        )
469        .unwrap();
470        let recipient_pub = recipient_key.to_public_key();
471
472        // Use sha256 of a message as plaintext (matching TS SDK test pattern)
473        let plaintext = sha256(b"my message is the hash of this string");
474
475        let encrypted =
476            ECIES::bitcore_encrypt(&plaintext, &recipient_pub, Some(&sender_key)).unwrap();
477        let decrypted = ECIES::bitcore_decrypt(&encrypted, &recipient_key).unwrap();
478
479        assert_eq!(decrypted, plaintext.to_vec());
480    }
481
482    #[test]
483    fn test_bitcore_no_bie1_magic() {
484        let recipient_key = PrivateKey::from_hex(
485            "0000000000000000000000000000000000000000000000000000000000000058",
486        )
487        .unwrap();
488        let recipient_pub = recipient_key.to_public_key();
489
490        let encrypted = ECIES::bitcore_encrypt(b"hello", &recipient_pub, None).unwrap();
491        assert_ne!(
492            &encrypted[0..4],
493            b"BIE1",
494            "Bitcore should NOT have BIE1 magic"
495        );
496        // First byte should be 0x02 or 0x03 (compressed pubkey)
497        assert!(
498            encrypted[0] == 0x02 || encrypted[0] == 0x03,
499            "Bitcore ciphertext should start with compressed pubkey prefix"
500        );
501    }
502
503    #[test]
504    fn test_bitcore_encrypt_decrypt_with_random_ephemeral() {
505        let recipient_key = PrivateKey::from_hex(
506            "0000000000000000000000000000000000000000000000000000000000000058",
507        )
508        .unwrap();
509        let recipient_pub = recipient_key.to_public_key();
510
511        let plaintext = sha256(b"random ephemeral test");
512        let encrypted = ECIES::bitcore_encrypt(&plaintext, &recipient_pub, None).unwrap();
513        let decrypted = ECIES::bitcore_decrypt(&encrypted, &recipient_key).unwrap();
514
515        assert_eq!(decrypted, plaintext.to_vec());
516    }
517
518    #[test]
519    fn test_bitcore_hmac_rejects_tampered() {
520        let sender_key = PrivateKey::from_hex(
521            "000000000000000000000000000000000000000000000000000000000000002a",
522        )
523        .unwrap();
524        let recipient_key = PrivateKey::from_hex(
525            "0000000000000000000000000000000000000000000000000000000000000058",
526        )
527        .unwrap();
528        let recipient_pub = recipient_key.to_public_key();
529
530        let mut encrypted =
531            ECIES::bitcore_encrypt(b"secret data", &recipient_pub, Some(&sender_key)).unwrap();
532
533        // Tamper with ciphertext (not the pubkey or hmac)
534        let mid = 33 + 8; // past the pubkey, in the iv/ct area
535        encrypted[mid] ^= 0xff;
536
537        let result = ECIES::bitcore_decrypt(&encrypted, &recipient_key);
538        assert!(result.is_err(), "should reject tampered bitcore ciphertext");
539    }
540
541    #[test]
542    fn test_electrum_ephemeral_encrypt_decrypt() {
543        let recipient_key = PrivateKey::from_hex(
544            "2b57c7c5e408ce927eef5e2efb49cfdadde77961d342daa72284bb3d6590862d",
545        )
546        .unwrap();
547        let recipient_pub = recipient_key.to_public_key();
548
549        let plaintext = b"ephemeral key test message";
550        let encrypted = ECIES::electrum_encrypt(plaintext, &recipient_pub, None).unwrap();
551        let decrypted = ECIES::electrum_decrypt(&encrypted, &recipient_key).unwrap();
552        assert_eq!(decrypted, plaintext);
553    }
554}