xmtp_cryptography/
ethereum.rs1use crate::Secret;
2use alloy::primitives::{Address, B256, eip191_hash_message};
3use alloy::signers::SignerSync;
4use alloy::signers::local::PrivateKeySigner;
5use thiserror::Error;
6
7const 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
27fn public_key_uncompressed_internal(
32 private_key32: &[u8],
33) -> Result<[u8; PUBKEY_UNCOMPRESSED_LEN], EthereumCryptoError> {
34 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(); let signer = PrivateKeySigner::from_slice(private_key_array)
42 .map_err(|_| EthereumCryptoError::InvalidKey)?;
43
44 let xy: [u8; PUBKEY_XY_LEN] = signer.public_key().into(); 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
52pub fn public_key_uncompressed(
55 private_key_secret: Secret,
56) -> Result<[u8; PUBKEY_UNCOMPRESSED_LEN], EthereumCryptoError> {
57 public_key_uncompressed_internal(private_key_secret.as_slice())
59}
60
61fn sign_recoverable_internal(
68 msg: &[u8],
69 private_key32: &[u8],
70 hashing: bool,
71) -> Result<[u8; SIGNATURE_LEN], EthereumCryptoError> {
72 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(); let signer = PrivateKeySigner::from_slice(private_key_array)
80 .map_err(|_| EthereumCryptoError::InvalidKey)?;
81
82 let signature = if hashing {
84 signer
86 .sign_message_sync(msg)
87 .map_err(|_| EthereumCryptoError::SignFailure)?
88 } else {
89 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 Ok(signature.as_bytes()) }
102
103pub 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); Ok(format!("{addr:?}")) }
114
115pub fn hash_personal(message: &str) -> [u8; HASH_LEN] {
117 eip191_hash_message(message).into()
118}
119
120pub 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
128pub fn sign_recoverable(
132 msg: &[u8],
133 private_key_secret: Secret,
134 hashing: bool,
135) -> Result<[u8; SIGNATURE_LEN], EthereumCryptoError> {
136 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 let private_key = "90b7388a7427358cb7fc7e9042805b1942eae47ee783e627a989719da35e76fb";
148 let expected_ethereum_address = "0x34dd95109b587ca90778cde5e2dd87e022453699";
149
150 let private_key_bytes = hex::decode(private_key).expect("Valid hex private key");
152
153 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 assert_eq!(public_key_65.len(), PUBKEY_UNCOMPRESSED_LEN);
161 assert_eq!(public_key_65[0], UNCOMPRESSED_PUBKEY_PREFIX);
162
163 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 let public_key_64 = &public_key_65[1..]; 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 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 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 assert_eq!(signature.len(), SIGNATURE_LEN);
198
199 let recovery_id = signature[PUBKEY_XY_LEN];
201 assert!(recovery_id == 27 || recovery_id == 28);
202
203 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 assert_eq!(hash.len(), HASH_LEN);
223
224 let hash2 = hash_personal(message);
226 assert_eq!(hash, hash2);
227
228 let different_hash = hash_personal("different message");
230 assert_ne!(hash, different_hash);
231 }
232
233 #[test]
234 fn test_invalid_inputs() {
235 let zero_key = [0u8; PRIVATE_KEY_LEN]; 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 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 assert!(address_from_pubkey(&[0u8; PUBKEY_XY_LEN - 1]).is_err()); assert!(address_from_pubkey(&[0u8; PUBKEY_UNCOMPRESSED_LEN + 1]).is_err()); let mut invalid_65 = [0u8; PUBKEY_UNCOMPRESSED_LEN];
261 invalid_65[0] = 0x03; assert!(address_from_pubkey(&invalid_65).is_err());
263
264 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 }
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 let our_signature = sign_recoverable(
285 message.as_bytes(),
286 zeroizing_key,
287 true, )
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 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 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 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 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 let signature = sign_recoverable(
341 message.as_bytes(),
342 zeroizing_key,
343 true, )
345 .expect("Should sign message");
346
347 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 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 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 let zeroizing_key =
384 zeroizing_private_key(&private_key_bytes).expect("Should create zeroizing private key");
385
386 let signature = sign_recoverable(
388 message.as_bytes(),
389 zeroizing_key, true,
391 )
392 .expect("Should sign with zeroizing key");
393
394 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 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 }
417}