xmtp_cryptography/
ethereum.rs

1use crate::Secret;
2use alloy::primitives::{Address, B256, eip191_hash_message};
3use alloy::signers::SignerSync;
4use alloy::signers::local::PrivateKeySigner;
5use thiserror::Error;
6
7// Constants for secp256k1 and Ethereum cryptography
8const UNCOMPRESSED_PUBKEY_PREFIX: u8 = 0x04;
9const PUBKEY_UNCOMPRESSED_LEN: usize = 65;
10const PUBKEY_XY_LEN: usize = 64;
11const PRIVATE_KEY_LEN: usize = 32;
12const SIGNATURE_LEN: usize = 65;
13const HASH_LEN: usize = 32;
14
15#[derive(Debug, Error)]
16pub enum EthereumCryptoError {
17    #[error("invalid length")]
18    InvalidLength,
19    #[error("invalid key")]
20    InvalidKey,
21    #[error("signing failure")]
22    SignFailure,
23    #[error("pubkey decompress failure")]
24    DecompressFailure,
25}
26
27/// Generate uncompressed public key (65 bytes: 0x04 || X || Y) from 32-byte private key
28/// Internal function that does not zeroize the private key
29/// For external use, use public_key_uncompressed
30/// FFI-friendly wrapper around alloy's PrivateKeySigner
31fn public_key_uncompressed_internal(
32    private_key32: &[u8],
33) -> Result<[u8; PUBKEY_UNCOMPRESSED_LEN], EthereumCryptoError> {
34    // Validate private key length
35    if private_key32.len() != PRIVATE_KEY_LEN {
36        return Err(EthereumCryptoError::InvalidLength);
37    }
38    let private_key_array: &[u8; PRIVATE_KEY_LEN] = private_key32.try_into().unwrap(); // Safe after length check
39
40    // Create alloy signer (handles validation internally)
41    let signer = PrivateKeySigner::from_slice(private_key_array)
42        .map_err(|_| EthereumCryptoError::InvalidKey)?;
43
44    // Get public key and convert to uncompressed format
45    let xy: [u8; PUBKEY_XY_LEN] = signer.public_key().into(); // B512 -> [u8; 64] (X||Y)
46    let mut out = [0u8; PUBKEY_UNCOMPRESSED_LEN];
47    out[0] = UNCOMPRESSED_PUBKEY_PREFIX;
48    out[1..].copy_from_slice(&xy);
49    Ok(out)
50}
51
52/// Generate uncompressed public key with automatic private key zeroization
53/// Public wrapper around public_key_uncompressed_internal where the private key is automatically zeroized after use
54pub fn public_key_uncompressed(
55    private_key_secret: Secret,
56) -> Result<[u8; PUBKEY_UNCOMPRESSED_LEN], EthereumCryptoError> {
57    // The Secret will be automatically zeroized when it goes out of scope
58    public_key_uncompressed_internal(private_key_secret.as_slice())
59}
60
61/// Recoverable ECDSA signing (Ethereum-style) - FFI-friendly wrapper around alloy
62/// Internal function that does not zeroize the private key
63/// For external use, use sign_recoverable
64/// Returns 65 bytes: r||s||v where v ∈ {27,28} (Ethereum standard recovery ID)
65/// - if `hashing == true`: EIP-191 personal message signing
66/// - else: `msg` must be a 32-byte prehash
67fn sign_recoverable_internal(
68    msg: &[u8],
69    private_key32: &[u8],
70    hashing: bool,
71) -> Result<[u8; SIGNATURE_LEN], EthereumCryptoError> {
72    // Validate private key length
73    if private_key32.len() != PRIVATE_KEY_LEN {
74        return Err(EthereumCryptoError::InvalidLength);
75    }
76    let private_key_array: &[u8; PRIVATE_KEY_LEN] = private_key32.try_into().unwrap(); // Safe after length check
77
78    // Create alloy signer (handles zero key validation internally)
79    let signer = PrivateKeySigner::from_slice(private_key_array)
80        .map_err(|_| EthereumCryptoError::InvalidKey)?;
81
82    // Use alloy's built-in signing methods
83    let signature = if hashing {
84        // Use alloy's EIP-191 personal message signing
85        signer
86            .sign_message_sync(msg)
87            .map_err(|_| EthereumCryptoError::SignFailure)?
88    } else {
89        // Sign pre-computed hash
90        if msg.len() != HASH_LEN {
91            return Err(EthereumCryptoError::InvalidLength);
92        }
93        let hash = B256::from_slice(msg);
94        signer
95            .sign_hash_sync(&hash)
96            .map_err(|_| EthereumCryptoError::SignFailure)?
97    };
98
99    // Convert alloy signature to FFI-friendly byte array
100    Ok(signature.as_bytes()) // alloy signatures are always 65 bytes
101}
102
103/// Derive Ethereum address from public key (accepts 65-byte 0x04||XY or 64-byte XY)
104pub fn address_from_pubkey(pubkey: &[u8]) -> Result<String, EthereumCryptoError> {
105    let xy = match pubkey.len() {
106        PUBKEY_UNCOMPRESSED_LEN if pubkey[0] == UNCOMPRESSED_PUBKEY_PREFIX => &pubkey[1..],
107        PUBKEY_XY_LEN => pubkey,
108        _ => return Err(EthereumCryptoError::InvalidLength),
109    };
110
111    let addr = Address::from_raw_public_key(xy); // derives keccak(XY)[12..]
112    Ok(format!("{addr:?}")) // lowercased 0x… (Debug prints raw lower-hex without checksum)
113}
114
115/// EIP-191 personal message hash: keccak256("\x19Ethereum Signed Message:\n{len}" || message)
116pub fn hash_personal(message: &str) -> [u8; HASH_LEN] {
117    eip191_hash_message(message).into()
118}
119
120/// Create a zeroizing private key from bytes - automatically zeroized on drop
121pub fn zeroizing_private_key(private_key_bytes: &[u8]) -> Result<Secret, EthereumCryptoError> {
122    if private_key_bytes.len() != PRIVATE_KEY_LEN {
123        return Err(EthereumCryptoError::InvalidLength);
124    }
125    Ok(Secret::from(private_key_bytes.to_vec()))
126}
127
128/// Recoverable ECDSA signing with automatic private key zeroization
129/// Public wrapper around sign_recoverable_internal where the private key is automatically zeroized after use
130/// For usage see
131pub fn sign_recoverable(
132    msg: &[u8],
133    private_key_secret: Secret,
134    hashing: bool,
135) -> Result<[u8; SIGNATURE_LEN], EthereumCryptoError> {
136    // The Secret will be automatically zeroized when it goes out of scope
137    sign_recoverable_internal(msg, private_key_secret.as_slice(), hashing)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_public_key_generation_and_address() {
146        // Pre-calculated test constants
147        let private_key = "90b7388a7427358cb7fc7e9042805b1942eae47ee783e627a989719da35e76fb";
148        let expected_ethereum_address = "0x34dd95109b587ca90778cde5e2dd87e022453699";
149
150        // Convert private key from hex string to bytes
151        let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
152
153        // Test uncompressed public key generation
154        let zeroizing_key =
155            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
156        let public_key_65 = public_key_uncompressed(zeroizing_key)
157            .expect("Should generate public key successfully");
158
159        // Verify public key is 65 bytes and starts with 0x04
160        assert_eq!(public_key_65.len(), PUBKEY_UNCOMPRESSED_LEN);
161        assert_eq!(public_key_65[0], UNCOMPRESSED_PUBKEY_PREFIX);
162
163        // Generate Ethereum address from 65-byte public key
164        let address_from_65 =
165            address_from_pubkey(&public_key_65).expect("Should derive address from 65-byte key");
166        assert_eq!(
167            address_from_65.to_lowercase(),
168            expected_ethereum_address.to_lowercase()
169        );
170
171        // Test that we can also derive address from 64-byte public key (XY coordinates only)
172        let public_key_64 = &public_key_65[1..]; // Remove 0x04 prefix to get XY coordinates
173        let address_from_64 =
174            address_from_pubkey(public_key_64).expect("Should derive address from 64-byte key");
175        assert_eq!(
176            address_from_64.to_lowercase(),
177            expected_ethereum_address.to_lowercase()
178        );
179    }
180
181    #[test]
182    fn test_sign_recoverable_with_known_values() {
183        // Pre-calculated test constants
184        let private_key = "90b7388a7427358cb7fc7e9042805b1942eae47ee783e627a989719da35e76fb";
185        let message = "test message";
186
187        let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
188        let message_bytes = message.as_bytes();
189
190        // Test with hashing enabled
191        let zeroizing_key =
192            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
193        let signature = sign_recoverable(message_bytes, zeroizing_key, true)
194            .expect("Should sign successfully with hashing");
195
196        // Verify signature is 65 bytes
197        assert_eq!(signature.len(), SIGNATURE_LEN);
198
199        // Verify recovery ID is 27 or 28 (Ethereum standard)
200        let recovery_id = signature[PUBKEY_XY_LEN];
201        assert!(recovery_id == 27 || recovery_id == 28);
202
203        // Test with hashing disabled (message must be 32 bytes) - using pre-computed EIP-191 hash
204        let hash = hash_personal(message);
205
206        let zeroizing_key =
207            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
208        let signature_no_hash =
209            sign_recoverable(&hash, zeroizing_key, false).expect("Should sign pre-hashed message");
210
211        assert_eq!(signature_no_hash.len(), SIGNATURE_LEN);
212        let recovery_id_no_hash = signature_no_hash[PUBKEY_XY_LEN];
213        assert!(recovery_id_no_hash == 27 || recovery_id_no_hash == 28);
214    }
215
216    #[test]
217    fn test_hash_personal() {
218        let message = "test message";
219        let hash = hash_personal(message);
220
221        // Should always return 32 bytes
222        assert_eq!(hash.len(), HASH_LEN);
223
224        // Should be deterministic
225        let hash2 = hash_personal(message);
226        assert_eq!(hash, hash2);
227
228        // Different messages should produce different hashes
229        let different_hash = hash_personal("different message");
230        assert_ne!(hash, different_hash);
231    }
232
233    #[test]
234    fn test_invalid_inputs() {
235        // Test invalid private keys
236        let zero_key = [0u8; PRIVATE_KEY_LEN]; // All zeros - mathematically invalid
237        let zeroizing_key =
238            zeroizing_private_key(&zero_key).expect("Should create zeroizing private key");
239        assert!(public_key_uncompressed(zeroizing_key).is_err());
240
241        let zeroizing_key =
242            zeroizing_private_key(&zero_key).expect("Should create zeroizing private key");
243        assert!(sign_recoverable(b"test", zeroizing_key, true).is_err());
244
245        // Test maximum value key (also invalid for secp256k1)
246        let max_key = [0xFFu8; PRIVATE_KEY_LEN];
247        let zeroizing_key =
248            zeroizing_private_key(&max_key).expect("Should create zeroizing private key");
249        assert!(public_key_uncompressed(zeroizing_key).is_err());
250
251        let zeroizing_key =
252            zeroizing_private_key(&max_key).expect("Should create zeroizing private key");
253        assert!(sign_recoverable(b"test", zeroizing_key, true).is_err());
254
255        // Test invalid pubkey lengths for address derivation
256        assert!(address_from_pubkey(&[0u8; PUBKEY_XY_LEN - 1]).is_err()); // Too short
257        assert!(address_from_pubkey(&[0u8; PUBKEY_UNCOMPRESSED_LEN + 1]).is_err()); // Too long
258
259        // Test invalid 65-byte key (wrong prefix)
260        let mut invalid_65 = [0u8; PUBKEY_UNCOMPRESSED_LEN];
261        invalid_65[0] = 0x03; // Wrong prefix
262        assert!(address_from_pubkey(&invalid_65).is_err());
263
264        // Test sign_recoverable with wrong hash length when hashing=false
265        let valid_key = [1u8; PRIVATE_KEY_LEN];
266        let zeroizing_key =
267            zeroizing_private_key(&valid_key).expect("Should create zeroizing private key");
268        assert!(sign_recoverable(&[0u8; HASH_LEN - 1], zeroizing_key, false).is_err());
269        // Wrong hash length
270    }
271
272    #[test]
273    fn test_eip191_hashing_compatibility() {
274        use alloy::signers::{SignerSync, local::PrivateKeySigner};
275
276        let private_key = "90b7388a7427358cb7fc7e9042805b1942eae47ee783e627a989719da35e76fb";
277        let message = "Hello, Ethereum!";
278        let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
279
280        let zeroizing_key =
281            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
282
283        // Create signatures using both our function and alloy
284        let our_signature = sign_recoverable(
285            message.as_bytes(),
286            zeroizing_key,
287            true, // Use EIP-191 hashing
288        )
289        .expect("Should sign successfully");
290
291        let alloy_signer =
292            PrivateKeySigner::from_slice(&private_key_bytes).expect("Valid private key");
293        let alloy_signature = alloy_signer
294            .sign_message_sync(message.as_bytes())
295            .expect("Should sign");
296
297        // Both signatures should recover to the same address when verified with the same message
298        use alloy::primitives::Signature as AlloySignature;
299
300        let our_parsed = AlloySignature::try_from(our_signature.as_slice()).expect("Should parse");
301        let our_recovered = our_parsed
302            .recover_address_from_msg(message)
303            .expect("Should recover");
304
305        let alloy_recovered = alloy_signature
306            .recover_address_from_msg(message)
307            .expect("Should recover");
308
309        // Both should recover to the same address (the correct one for this private key)
310        assert_eq!(
311            our_recovered.to_string().to_lowercase(),
312            alloy_recovered.to_string().to_lowercase(),
313            "Both signatures should recover to the same address when using EIP-191 hashing"
314        );
315    }
316
317    #[test]
318    fn test_signature_round_trip_compatibility() {
319        // This test ensures our signatures work with the same verification patterns
320        // used elsewhere in the XMTP codebase
321        use alloy::primitives::Signature as AlloySignature;
322
323        let private_key = "a1b2c3d4e5f67890123456789012345678901234567890123456789012345678";
324        let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
325        let zeroizing_key =
326            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
327
328        let message = "XMTP signature test message";
329
330        let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
331
332        // Generate public key and address using our functions
333        let zeroizing_key_for_pubkey =
334            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
335        let public_key =
336            public_key_uncompressed(zeroizing_key_for_pubkey).expect("Should generate public key");
337        let _expected_address = address_from_pubkey(&public_key).expect("Should generate address");
338
339        // Sign the message
340        let signature = sign_recoverable(
341            message.as_bytes(),
342            zeroizing_key,
343            true, // Use EIP-191 hashing
344        )
345        .expect("Should sign message");
346
347        // Test that alloy can parse our signature format
348        let alloy_signature =
349            AlloySignature::try_from(signature.as_slice()).expect("Should parse signature");
350        let recovered_address = alloy_signature
351            .recover_address_from_msg(message)
352            .expect("Should recover address");
353
354        // The recovered address should be a valid Ethereum address
355        let recovered_str = recovered_address.to_string();
356        assert!(
357            recovered_str.starts_with("0x"),
358            "Should be a valid Ethereum address"
359        );
360        assert_eq!(recovered_str.len(), 42, "Should be 42 characters long");
361
362        // Test with wrong message should recover a different address
363        let wrong_signature =
364            AlloySignature::try_from(signature.as_slice()).expect("Should parse signature");
365        let wrong_recovered = wrong_signature
366            .recover_address_from_msg("Different message")
367            .expect("Should recover some address");
368
369        assert_ne!(
370            wrong_recovered.to_string().to_lowercase(),
371            recovered_address.to_string().to_lowercase(),
372            "Wrong message should recover a different address"
373        );
374    }
375
376    #[test]
377    fn test_zeroizing_private_key() {
378        let private_key_hex = "a1b2c3d4e5f67890123456789012345678901234567890123456789012345678";
379        let private_key_bytes = hex::decode(private_key_hex).expect("Valid hex private key");
380        let message = "test message for zeroizing";
381
382        // Create a zeroizing private key
383        let zeroizing_key =
384            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
385
386        // Use the zeroizing signing function
387        let signature = sign_recoverable(
388            message.as_bytes(),
389            zeroizing_key, // This will be automatically zeroized after the function call
390            true,
391        )
392        .expect("Should sign with zeroizing key");
393
394        // Verify the signature works
395        use alloy::primitives::Signature as AlloySignature;
396        let alloy_signature =
397            AlloySignature::try_from(signature.as_slice()).expect("Should parse signature");
398        let recovered_address = alloy_signature
399            .recover_address_from_msg(message)
400            .expect("Should recover address");
401
402        // Generate expected address for comparison
403        let zeroizing_key_for_comparison =
404            zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
405        let public_key = public_key_uncompressed(zeroizing_key_for_comparison)
406            .expect("Should generate public key");
407        let expected_address = address_from_pubkey(&public_key).expect("Should generate address");
408
409        assert_eq!(
410            recovered_address.to_string().to_lowercase(),
411            expected_address.to_lowercase(),
412            "Zeroizing signature should work the same as regular signature"
413        );
414
415        // At this point, the zeroizing_key has been automatically zeroized
416    }
417}