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