1use crate::{
2 errors::{KeyPairError, Result},
3 keystore::{CipherparamsJson, CryptoJson, EthKeystore, KdfType, KdfparamsType, PrivateKeys},
4};
5use aes::{
6 cipher::{self, InnerIvInit, KeyInit, StreamCipherCore},
7 Aes128,
8};
9use hex;
10use hopr_crypto_random::random_bytes;
11use hopr_crypto_types::prelude::*;
12use hopr_platform::file::native::{metadata, read_to_string, write};
13use hopr_primitive_types::prelude::*;
14use scrypt::{scrypt, Params as ScryptParams};
15use serde::{ser::SerializeStruct, Serialize, Serializer};
16use serde_json::{from_str as from_json_string, to_string as to_json_string};
17use sha3::{digest::Update, Digest, Keccak256};
18use std::fmt::Debug;
19use tracing::{info, warn};
20use typenum::Unsigned;
21use uuid::Uuid;
22
23const HOPR_CIPHER: &str = "aes-128-ctr";
24const HOPR_KEY_SIZE: usize = 32usize;
25const HOPR_IV_SIZE: usize = 16usize;
26const HOPR_KDF_PARAMS_DKLEN: u8 = 32u8;
27const HOPR_KDF_PARAMS_LOG_N: u8 = 13u8;
28const HOPR_KDF_PARAMS_R: u32 = 8u32;
29const HOPR_KDF_PARAMS_P: u32 = 1u32;
30
31const PACKET_KEY_LENGTH: usize = <OffchainKeypair as Keypair>::SecretLen::USIZE;
32const CHAIN_KEY_LENGTH: usize = <ChainKeypair as Keypair>::SecretLen::USIZE;
33
34const V1_PRIVKEY_LENGTH: usize = 32;
35const V2_PRIVKEYS_LENGTH: usize = 172;
36
37const VERSION: u32 = 2;
39
40#[cfg(any(debug_assertions, test))]
41const USE_WEAK_CRYPTO: bool = true;
42
43#[cfg(all(not(debug_assertions), not(test)))]
44const USE_WEAK_CRYPTO: bool = false;
45
46struct Aes128Ctr {
47 inner: ctr::CtrCore<Aes128, ctr::flavors::Ctr128BE>,
48}
49
50impl Aes128Ctr {
51 fn new(key: &[u8], iv: &[u8]) -> std::result::Result<Self, cipher::InvalidLength> {
52 let cipher = aes::Aes128::new_from_slice(key).unwrap();
53 let inner = ctr::CtrCore::inner_iv_slice_init(cipher, iv).unwrap();
54 Ok(Self { inner })
55 }
56
57 fn apply_keystream(self, buf: &mut [u8]) {
58 self.inner.apply_keystream_partial(buf.into());
59 }
60}
61
62pub enum IdentityRetrievalModes<'a> {
63 FromFile {
66 password: &'a str,
68 id_path: &'a str,
70 },
71 FromPrivateKey {
75 private_key: &'a str,
77 },
78 #[cfg(any(feature = "hopli", test))]
81 FromIdIntoFile {
82 id: Uuid,
84 password: &'a str,
86 id_path: &'a str,
88 },
89}
90
91pub struct HoprKeys {
92 pub packet_key: OffchainKeypair,
93 pub chain_key: ChainKeypair,
94 id: Uuid,
95}
96
97impl TryFrom<IdentityRetrievalModes<'_>> for HoprKeys {
98 type Error = KeyPairError;
99
100 fn try_from(value: IdentityRetrievalModes) -> std::result::Result<Self, Self::Error> {
101 Self::init(value)
102 }
103}
104
105impl Serialize for HoprKeys {
106 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
108 where
109 S: Serializer,
110 {
111 let mut s = serializer.serialize_struct("HoprKeys", 3)?;
112 s.serialize_field("peer_id", self.packet_key.public().to_peerid_str().as_str())?;
113 s.serialize_field("packet_key", self.packet_key.public().to_hex().as_str())?;
114 s.serialize_field("chain_key", &self.chain_key.public().to_hex().as_str())?;
115 s.serialize_field("native_address", &self.chain_key.public().to_address().to_string())?;
116 s.serialize_field("uuid", &self.id)?;
117 s.end()
118 }
119}
120
121impl std::fmt::Display for HoprKeys {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str(
124 format!(
125 "packet_key: {}, chain_key: {} (Ethereum address: {})\nUUID: {}",
126 self.packet_key.public().to_peerid_str(),
127 self.chain_key.public().to_hex(),
128 self.chain_key.public().0.to_address(),
129 self.id
130 )
131 .as_str(),
132 )
133 }
134}
135
136impl TryFrom<&str> for HoprKeys {
137 type Error = KeyPairError;
138
139 fn try_from(s: &str) -> std::result::Result<Self, Self::Error> {
148 let maybe_priv_key = s.strip_prefix("0x").unwrap_or(s);
149
150 if maybe_priv_key.len() != 2 * (PACKET_KEY_LENGTH + CHAIN_KEY_LENGTH) {
151 return Err(KeyPairError::InvalidPrivateKeySize {
152 actual: maybe_priv_key.len(),
153 expected: 2 * (PACKET_KEY_LENGTH + CHAIN_KEY_LENGTH),
154 });
155 }
156
157 let mut priv_key_raw = [0u8; PACKET_KEY_LENGTH + CHAIN_KEY_LENGTH];
158 hex::decode_to_slice(maybe_priv_key, &mut priv_key_raw[..])?;
159
160 priv_key_raw.try_into()
161 }
162}
163
164impl TryFrom<[u8; PACKET_KEY_LENGTH + CHAIN_KEY_LENGTH]> for HoprKeys {
165 type Error = KeyPairError;
166 fn try_from(value: [u8; CHAIN_KEY_LENGTH + PACKET_KEY_LENGTH]) -> std::result::Result<Self, Self::Error> {
180 let mut packet_key = [0u8; PACKET_KEY_LENGTH];
181 packet_key.copy_from_slice(&value[0..32]);
182 let mut chain_key = [0u8; CHAIN_KEY_LENGTH];
183 chain_key.copy_from_slice(&value[32..64]);
184
185 (packet_key, chain_key).try_into()
186 }
187}
188
189impl TryFrom<([u8; PACKET_KEY_LENGTH], [u8; CHAIN_KEY_LENGTH])> for HoprKeys {
190 type Error = KeyPairError;
191
192 fn try_from(value: ([u8; PACKET_KEY_LENGTH], [u8; CHAIN_KEY_LENGTH])) -> std::result::Result<Self, Self::Error> {
208 Ok(HoprKeys {
209 packet_key: OffchainKeypair::from_secret(&value.0)?,
210 chain_key: ChainKeypair::from_secret(&value.1)?,
211 id: Uuid::new_v4(),
212 })
213 }
214}
215
216impl PartialEq for HoprKeys {
217 fn eq(&self, other: &Self) -> bool {
218 self.packet_key.public().eq(other.packet_key.public()) && self.chain_key.public().eq(other.chain_key.public())
219 }
220}
221
222impl HoprKeys {
223 pub fn random() -> Self {
226 Self {
227 packet_key: OffchainKeypair::random(),
228 chain_key: ChainKeypair::random(),
229 id: Uuid::new_v4(),
230 }
231 }
232
233 fn init(retrieval_mode: IdentityRetrievalModes) -> Result<Self> {
235 match retrieval_mode {
236 IdentityRetrievalModes::FromFile { password, id_path } => {
237 let identity_file_exists = metadata(id_path).is_ok();
238
239 if identity_file_exists {
240 info!("identity file exists at {}", id_path);
241
242 match HoprKeys::read_eth_keystore(id_path, password) {
243 Ok((keys, needs_migration)) => {
244 info!("migration needed = {}", needs_migration);
245 if needs_migration {
246 keys.write_eth_keystore(id_path, password)?
247 }
248 Ok(keys)
249 }
250 Err(e) => Err(KeyPairError::GeneralError(
251 format!("An identity file is present at {id_path} but the provided password <REDACTED> is not sufficient to decrypt it {e}"),
252 )),
253 }
254 } else {
255 let keys = HoprKeys::random();
256
257 info!("created a new set of keypairs at {}", id_path);
258 info!("{}", keys);
259
260 keys.write_eth_keystore(id_path, password)?;
261 Ok(keys)
262 }
263 }
264 IdentityRetrievalModes::FromPrivateKey { private_key } => {
265 info!("initializing HoprKeys with provided private keys <REDACTED>");
266
267 private_key.try_into()
268 }
269 #[cfg(any(feature = "hopli", test))]
270 IdentityRetrievalModes::FromIdIntoFile { id, password, id_path } => {
271 let identity_file_exists = metadata(id_path).is_ok();
272
273 if identity_file_exists {
274 info!("identity file exists at {}", id_path);
275
276 Err(KeyPairError::GeneralError(format!(
277 "Cannot create identity file at {} because the file already exists.",
278 id_path
279 )))
280 } else {
281 let keys: HoprKeys = HoprKeys {
282 id,
283 packet_key: OffchainKeypair::random(),
284 chain_key: ChainKeypair::random(),
285 };
286
287 keys.write_eth_keystore(id_path, password)?;
288
289 Ok(keys)
290 }
291 }
292 }
293 }
294
295 pub fn read_eth_keystore(path: &str, password: &str) -> Result<(Self, bool)> {
299 let json_string = read_to_string(path)?;
300 let keystore: EthKeystore = from_json_string(&json_string)?;
301
302 let key = match keystore.crypto.kdfparams {
303 KdfparamsType::Scrypt { dklen, n, p, r, salt } => {
304 let mut key = vec![0u8; dklen as usize];
305 let log_n = (n as f32).log2() as u8;
306 let scrypt_params = ScryptParams::new(log_n, r, p, dklen.into())
307 .map_err(|err| KeyPairError::KeyDerivationError(err.to_string()))?;
308 scrypt(password.as_ref(), &salt, &scrypt_params, &mut key)
310 .map_err(|err| KeyPairError::KeyDerivationError(err.to_string()))?;
311 key
312 }
313 _ => panic!("HOPR only supports scrypt"),
314 };
315
316 let derived_mac = Keccak256::new()
318 .chain(&key[16..32])
319 .chain(&keystore.crypto.ciphertext)
320 .finalize();
321
322 if *derived_mac != *keystore.crypto.mac {
323 return Err(KeyPairError::MacMismatch);
324 }
325
326 let decryptor = Aes128Ctr::new(&key[..16], &keystore.crypto.cipherparams.iv[..16]).expect("invalid length");
328
329 let mut pk = keystore.crypto.ciphertext;
330
331 match pk.len() {
332 V1_PRIVKEY_LENGTH => {
333 decryptor.apply_keystream(&mut pk);
334
335 let packet_key: [u8; PACKET_KEY_LENGTH] = random_bytes();
336
337 let mut chain_key = [0u8; CHAIN_KEY_LENGTH];
338 chain_key.clone_from_slice(&pk.as_slice()[0..CHAIN_KEY_LENGTH]);
339
340 let ret: HoprKeys = (packet_key, chain_key).try_into().unwrap();
341
342 Ok((ret, true))
343 }
344 V2_PRIVKEYS_LENGTH => {
345 decryptor.apply_keystream(&mut pk);
346
347 let private_keys = serde_json::from_slice::<PrivateKeys>(&pk)?;
348
349 if private_keys.packet_key.len() != PACKET_KEY_LENGTH {
350 return Err(KeyPairError::InvalidEncryptedKeyLength {
351 actual: private_keys.packet_key.len(),
352 expected: PACKET_KEY_LENGTH,
353 });
354 }
355
356 if private_keys.chain_key.len() != CHAIN_KEY_LENGTH {
357 return Err(KeyPairError::InvalidEncryptedKeyLength {
358 actual: private_keys.chain_key.len(),
359 expected: CHAIN_KEY_LENGTH,
360 });
361 }
362
363 let mut packet_key = [0u8; PACKET_KEY_LENGTH];
364 packet_key.clone_from_slice(private_keys.packet_key.as_slice());
365
366 let mut chain_key = [0u8; CHAIN_KEY_LENGTH];
367 chain_key.clone_from_slice(private_keys.chain_key.as_slice());
368
369 Ok((
370 HoprKeys {
371 packet_key: OffchainKeypair::from_secret(&packet_key).unwrap(),
372 chain_key: ChainKeypair::from_secret(&chain_key).unwrap(),
373 id: keystore.id,
374 },
375 false,
376 ))
377 }
378 _ => Err(KeyPairError::InvalidEncryptedKeyLength {
379 actual: pk.len(),
380 expected: V2_PRIVKEYS_LENGTH,
381 }),
382 }
383 }
384
385 pub fn write_eth_keystore(&self, path: &str, password: &str) -> Result<()> {
389 if USE_WEAK_CRYPTO {
390 warn!("USING WEAK CRYPTO -> this build is not meant for production!");
391 }
392 let salt: [u8; HOPR_KEY_SIZE] = random_bytes();
394
395 let mut key = [0u8; HOPR_KDF_PARAMS_DKLEN as usize];
397 let scrypt_params = ScryptParams::new(
398 if USE_WEAK_CRYPTO { 1 } else { HOPR_KDF_PARAMS_LOG_N },
399 HOPR_KDF_PARAMS_R,
400 HOPR_KDF_PARAMS_P,
401 HOPR_KDF_PARAMS_DKLEN.into(),
402 )
403 .map_err(|e| KeyPairError::KeyDerivationError(e.to_string()))?;
404
405 scrypt(password.as_ref(), &salt, &scrypt_params, &mut key)
407 .map_err(|e| KeyPairError::KeyDerivationError(e.to_string()))?;
408
409 let iv: [u8; HOPR_IV_SIZE] = random_bytes();
411
412 let encryptor = Aes128Ctr::new(&key[..16], &iv[..16]).expect("invalid length");
413
414 let private_keys = PrivateKeys {
415 chain_key: self.chain_key.secret().as_ref().to_vec(),
416 packet_key: self.packet_key.secret().as_ref().to_vec(),
417 version: VERSION,
418 };
419
420 let mut ciphertext = serde_json::to_vec(&private_keys)?;
421 encryptor.apply_keystream(&mut ciphertext);
422
423 let mac = Keccak256::new().chain(&key[16..32]).chain(&ciphertext).finalize();
425
426 let keystore = EthKeystore {
428 id: self.id,
429 version: 3,
430 crypto: CryptoJson {
431 cipher: String::from(HOPR_CIPHER),
432 cipherparams: CipherparamsJson { iv: iv.to_vec() },
433 ciphertext,
434 kdf: KdfType::Scrypt,
435 kdfparams: KdfparamsType::Scrypt {
436 dklen: HOPR_KDF_PARAMS_DKLEN,
437 n: 2u32.pow(if USE_WEAK_CRYPTO { 1 } else { HOPR_KDF_PARAMS_LOG_N } as u32),
438 p: HOPR_KDF_PARAMS_P,
439 r: HOPR_KDF_PARAMS_R,
440 salt: salt.to_vec(),
441 },
442 mac: mac.to_vec(),
443 },
444 };
445
446 let serialized = to_json_string(&keystore)?;
447
448 write(path, serialized).map_err(|e| e.into())
449 }
450
451 pub fn id(&self) -> &Uuid {
452 &self.id
453 }
454}
455
456impl Debug for HoprKeys {
457 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
458 f.debug_struct("HoprKeys")
459 .field(
460 "packet_key",
461 &format_args!("(priv_key: <REDACTED>, pub_key: {}", self.packet_key.public().to_hex()),
462 )
463 .field(
464 "chain_key",
465 &format_args!("(priv_key: <REDACTED>, pub_key: {}", self.chain_key.public().to_hex()),
466 )
467 .finish()
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use std::fs;
474
475 use anyhow::Context;
476 use hopr_crypto_types::prelude::*;
477 use tempfile::tempdir;
478 use uuid::Uuid;
479
480 use super::HoprKeys;
481
482 const DEFAULT_PASSWORD: &str = "dummy password for unit testing";
483
484 #[test]
485 fn create_keys() {
486 println!("{:?}", super::HoprKeys::random())
487 }
488
489 #[test]
490 fn store_keys_and_read_them() -> anyhow::Result<()> {
491 let tmp = tempdir()?;
492
493 let identity_dir = tmp.path().join("hopr-unit-test-identity");
494
495 let keys = super::HoprKeys::random();
496
497 keys.write_eth_keystore(
498 identity_dir.to_str().context("should be convertible to string")?,
499 DEFAULT_PASSWORD,
500 )?;
501
502 let (deserialized, needs_migration) = super::HoprKeys::read_eth_keystore(
503 identity_dir.to_str().context("should be convertible to string")?,
504 DEFAULT_PASSWORD,
505 )?;
506
507 assert!(!needs_migration);
508 assert_eq!(deserialized, keys);
509
510 Ok(())
511 }
512
513 #[test]
514 fn test_migration() -> anyhow::Result<()> {
515 let tmp = tempdir()?;
516
517 let identity_dir = tmp.path().join("hopr-unit-test-identity");
518
519 let old_keystore_file = r#"{"id":"8e5fe142-6ef9-4fbb-aae8-5de32b680e31","version":3,"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"04141354edb9dfb0c65e6905a3a0b9dd"},"ciphertext":"74f12f72cf2d3d73ff09f783cb9b57995b3808f7d3f71aa1fa1968696aedfbdd","kdf":"scrypt","kdfparams":{"salt":"f5e3f04eaa0c9efffcb5168c6735d7e1fe4d96f48a636c4f00107e7c34722f45","n":1,"dklen":32,"p":1,"r":8},"mac":"d0daf0e5d14a2841f0f7221014d805addfb7609d85329d4c6424a098e50b6fbe"}}"#;
520
521 fs::write(
522 identity_dir.to_str().context("should be convertible to string")?,
523 old_keystore_file.as_bytes(),
524 )?;
525
526 let (deserialized, needs_migration) = super::HoprKeys::read_eth_keystore(
527 identity_dir.to_str().context("should be convertible to string")?,
528 "local",
529 )?;
530
531 assert!(needs_migration);
532 assert_eq!(
533 deserialized.chain_key.public().0.to_address().to_string(),
534 "0x826a1bf3d51fa7f402a1e01d1b2c8a8bac28e666"
535 );
536
537 Ok(())
538 }
539
540 #[test]
541 fn test_auto_migration() -> anyhow::Result<()> {
542 let tmp = tempdir()?;
543 let identity_dir = tmp.path().join("hopr-unit-test-identity");
544 let identity_path: &str = identity_dir.to_str().context("should be convertible to string")?;
545
546 let old_keystore_file = r#"{"id":"8e5fe142-6ef9-4fbb-aae8-5de32b680e31","version":3,"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"04141354edb9dfb0c65e6905a3a0b9dd"},"ciphertext":"74f12f72cf2d3d73ff09f783cb9b57995b3808f7d3f71aa1fa1968696aedfbdd","kdf":"scrypt","kdfparams":{"salt":"f5e3f04eaa0c9efffcb5168c6735d7e1fe4d96f48a636c4f00107e7c34722f45","n":1,"dklen":32,"p":1,"r":8},"mac":"d0daf0e5d14a2841f0f7221014d805addfb7609d85329d4c6424a098e50b6fbe"}}"#;
547 fs::write(identity_path, old_keystore_file.as_bytes()).unwrap();
548
549 assert!(super::HoprKeys::init(super::IdentityRetrievalModes::FromFile {
550 password: "local".into(),
551 id_path: identity_path.into()
552 })
553 .is_ok());
554
555 let (deserialized, needs_migration) = super::HoprKeys::read_eth_keystore(identity_path, "local").unwrap();
556
557 assert!(!needs_migration);
558 assert_eq!(
559 deserialized.chain_key.public().0.to_address().to_string(),
560 "0x826a1bf3d51fa7f402a1e01d1b2c8a8bac28e666"
561 );
562
563 Ok(())
564 }
565
566 #[test]
567 fn test_should_not_overwrite_existing() -> anyhow::Result<()> {
568 let tmp = tempdir()?;
569 let identity_dir = tmp.path().join("hopr-unit-test-identity");
570 let identity_path: &str = identity_dir.to_str().context("should be convertible to string")?;
571
572 fs::write(identity_path, "".as_bytes())?;
573
574 assert!(super::HoprKeys::init(super::IdentityRetrievalModes::FromFile {
576 password: "local".into(),
577 id_path: identity_path.into()
578 })
579 .is_err());
580
581 Ok(())
582 }
583
584 #[test]
585 fn test_from_privatekey() {
586 let private_key = "0x56b29cefcdf576eea306ba2fd5f32e651c09e0abbc018c47bdc6ef44f6b7506f1050f95137770478f50b456267f761f1b8b341a13da68bc32e5c96984fcd52ae";
587
588 let from_private_key = HoprKeys::init(super::IdentityRetrievalModes::FromPrivateKey { private_key }).unwrap();
589
590 let private_key_without_prefix = "56b29cefcdf576eea306ba2fd5f32e651c09e0abbc018c47bdc6ef44f6b7506f1050f95137770478f50b456267f761f1b8b341a13da68bc32e5c96984fcd52ae";
591
592 let from_private_key_without_prefix = HoprKeys::init(super::IdentityRetrievalModes::FromPrivateKey {
593 private_key: private_key_without_prefix,
594 })
595 .unwrap();
596
597 assert_eq!(from_private_key, from_private_key_without_prefix);
599
600 let too_short_private_key = "0xb29cefcdf576eea306ba2fd5f32e651c09e0abbc018c47bdc6ef44f6b7506f1050f95137770478f50b456267f761f1b8b341a13da68bc32e5c96984fcd52ae";
601
602 assert!(HoprKeys::init(super::IdentityRetrievalModes::FromPrivateKey {
603 private_key: too_short_private_key
604 })
605 .is_err());
606
607 let too_long_private_key = "0x56b29cefcdf576eea306ba2fd5f32e651c09e0abbc018c47bdc6ef44f6b7506f1050f95137770478f50b456267f761f1b8b341a13da68bc32e5c96984fcd52aeae";
608
609 assert!(HoprKeys::init(super::IdentityRetrievalModes::FromPrivateKey {
610 private_key: too_long_private_key
611 })
612 .is_err());
613
614 let non_hex_private = "this is the story of hopr: in 2018 ...";
615 assert!(HoprKeys::init(super::IdentityRetrievalModes::FromPrivateKey {
616 private_key: non_hex_private
617 })
618 .is_err());
619 }
620
621 #[test]
622 fn test_from_privatekey_into_file() -> anyhow::Result<()> {
623 let tmp = tempdir()?;
624 let identity_dir = tmp.path().join("hopr-unit-test-identity");
625 let identity_path = identity_dir.to_str().context("should be convertible to string")?;
626 let id = Uuid::new_v4();
627
628 let keys = HoprKeys::init(super::IdentityRetrievalModes::FromIdIntoFile {
629 password: "local",
630 id_path: identity_path,
631 id,
632 })
633 .expect("should initialize new key");
634
635 let (deserialized, needs_migration) = super::HoprKeys::read_eth_keystore(identity_path, "local").unwrap();
636
637 assert!(!needs_migration);
638 assert_eq!(
639 deserialized.chain_key.public().to_address(),
640 keys.chain_key.public().to_address()
641 );
642
643 assert!(super::HoprKeys::init(super::IdentityRetrievalModes::FromIdIntoFile {
645 password: "local",
646 id_path: identity_path,
647 id
648 })
649 .is_err());
650
651 Ok(())
652 }
653}