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