From fbff8e52542d2cdc0e26dbd74157bfd7058fcd82 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:02:06 +0200 Subject: [PATCH] feat: add p2p::k1 module --- Cargo.lock | 4 + Cargo.toml | 1 + crates/charon-k1util/Cargo.toml | 1 + crates/charon-k1util/src/k1util.rs | 69 +++++++++ crates/charon-p2p/Cargo.toml | 3 + crates/charon-p2p/src/k1.rs | 229 +++++++++++++++++++++++++++++ crates/charon-p2p/src/lib.rs | 3 + 7 files changed, 310 insertions(+) create mode 100644 crates/charon-p2p/src/k1.rs diff --git a/Cargo.lock b/Cargo.lock index f853ade5..70579775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,6 +549,7 @@ dependencies = [ "hex", "k256", "libp2p", + "tempfile", "thiserror 2.0.17", ] @@ -559,8 +560,11 @@ dependencies = [ "charon-eth2", "charon-k1util", "charon-testutil", + "chrono", "k256", "libp2p", + "rand 0.8.5", + "tempfile", "thiserror 2.0.17", ] diff --git a/Cargo.toml b/Cargo.toml index eafea64e..93e7e646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ base64 = { version = "0.22.1" } sha3 = { version = "0.10.8" } k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] } criterion = "0.7.0" +tempfile = "3.10.0" tracing = "0.1.32" tracing-subscriber = { version = "0.3.9", features = ["env-filter"] } tracing-loki = "0.2.6" diff --git a/crates/charon-k1util/Cargo.toml b/crates/charon-k1util/Cargo.toml index 02a86117..624bad30 100644 --- a/crates/charon-k1util/Cargo.toml +++ b/crates/charon-k1util/Cargo.toml @@ -14,6 +14,7 @@ libp2p.workspace = true [dev-dependencies] criterion.workspace = true +tempfile.workspace = true [[bench]] name = "k1util" diff --git a/crates/charon-k1util/src/k1util.rs b/crates/charon-k1util/src/k1util.rs index 76d1f532..299c335a 100644 --- a/crates/charon-k1util/src/k1util.rs +++ b/crates/charon-k1util/src/k1util.rs @@ -2,6 +2,8 @@ //! //! Helper functions for working with secp256k1 keys. +use std::path::Path; + use k256::{ AffinePoint, FieldBytes, PublicKey, SecretKey, ecdsa::{self, RecoveryId, Signature, SigningKey, hazmat::VerifyPrimitive}, @@ -185,9 +187,30 @@ pub fn recover(hash: &[u8], sig: &[u8]) -> Result { Ok(pubkey.into()) } +/// Load loads a secret key from a file. +pub fn load(file: &Path) -> Result { + let contents = std::fs::read_to_string(file).map_err(K1UtilError::FailedToReadFile)?; + + let decoded = hex::decode(contents.trim())?; + + let key = SecretKey::from_slice(&decoded).map_err(K1UtilError::FailedToParseSecretKey)?; + + Ok(key) +} + +/// Save saves a secret key to a file. +pub fn save(key: &SecretKey, file: &Path) -> Result<()> { + let encoded = hex::encode(key.to_bytes()); + + std::fs::write(file, encoded).map_err(K1UtilError::FailedToWriteFile)?; + + Ok(()) +} + #[cfg(test)] mod tests { use k256::elliptic_curve::rand_core::OsRng; + use std::io::Write; use super::*; @@ -258,4 +281,50 @@ mod tests { "Recovered public key should match" ); } + + #[test] + fn test_load_nonexistent_file() { + let file = Path::new("nonexistent-file"); + let result = load(file); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_hex_encoded_file() { + let mut temp_file = tempfile::NamedTempFile::new().unwrap(); + write!(temp_file, "abcXYZ123").unwrap(); // invalid hex encoded file + + let result = load(temp_file.path()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + K1UtilError::FailedToDecodeHex(_) + )); + } + + #[test] + fn test_valid_hex_strings() { + let key = SecretKey::random(&mut OsRng); + let key_str = hex::encode(key.to_bytes()).to_string(); + + let hex_strs = vec![ + format!("{}\n", key_str.clone()), + format!("{}\r\n", key_str.clone()), + format!("{} ", key_str.clone()), + key_str.clone(), + ]; + + for hex_str in hex_strs { + let mut temp_file = tempfile::NamedTempFile::new().unwrap(); + write!(temp_file, "{}", hex_str).unwrap(); + + let result = load(Path::new(&temp_file.path())); + assert!( + result.is_ok(), + "Failed to load key from file: {:?}", + &hex_str[hex_str.len().saturating_sub(2)..].to_string() + ); + assert_eq!(result.unwrap(), key, "Key should match"); + } + } } diff --git a/crates/charon-p2p/Cargo.toml b/crates/charon-p2p/Cargo.toml index c140c7ec..42e10b31 100644 --- a/crates/charon-p2p/Cargo.toml +++ b/crates/charon-p2p/Cargo.toml @@ -12,9 +12,12 @@ thiserror.workspace = true k256.workspace = true charon-eth2.workspace = true charon-k1util.workspace = true +chrono.workspace = true +rand.workspace = true [dev-dependencies] charon-testutil.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/crates/charon-p2p/src/k1.rs b/crates/charon-p2p/src/k1.rs new file mode 100644 index 00000000..774d2cfd --- /dev/null +++ b/crates/charon-p2p/src/k1.rs @@ -0,0 +1,229 @@ +//! # Charon P2P K1 + +use std::path::{Path, PathBuf}; + +use charon_k1util as k1util; +use k256::{SecretKey, elliptic_curve::rand_core::OsRng}; +use rand::RngCore; + +const KEY_FILE_NAME: &str = "charon-enr-private-key"; +const KEY_BACKUP_DIR: &str = "charon-enr-private-key-backups"; + +type Result = std::result::Result; + +/// An error that can occur when loading a private key. +#[derive(Debug, thiserror::Error)] +pub enum K1Error { + /// K1 utility error. + #[error("K1 utility error: {0}")] + K1UtilError(#[from] k1util::K1UtilError), + + /// IOError. + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), +} + +/// Returns the charon-enr-private-key path relative to the data dir. +pub fn key_path(data_dir: &Path) -> PathBuf { + data_dir.join(KEY_FILE_NAME) +} + +/// Loads the private key from the data dir. +pub fn load_priv_key(data_dir: &Path) -> Result { + k1util::load(&key_path(data_dir)).map_err(K1Error::K1UtilError) +} + +/// Generates a new private key and saves it to the data dir. +pub fn new_saved_priv_key(data_dir: &Path) -> Result { + backup_priv_key(data_dir)?; + + std::fs::create_dir_all(data_dir)?; + + let key = SecretKey::random(&mut OsRng); + + k1util::save(&key, &key_path(data_dir)).map_err(K1Error::K1UtilError)?; + + Ok(key) +} + +/// Backs up the private key to the backup directory. +/// +/// The backup directory is created if it doesn't exist. +fn backup_priv_key(data_dir: &Path) -> Result<()> { + let key_path = key_path(data_dir); + + if !key_path.exists() { + // Nothing to backup + return Ok(()); + } + + let current_time = chrono::Utc::now(); + let nonce = OsRng.next_u64(); + let backup_path = data_dir.join(KEY_BACKUP_DIR).join(format!( + "{}_{}", + current_time.format("%Y-%m-%d_%H-%M-%S_%f"), + nonce + )); + std::fs::create_dir_all( + backup_path + .parent() + .expect("Backup path parent should exist"), + ) + .map_err(K1Error::IoError)?; + if backup_path.is_dir() { + panic!("Backup path is a directory: {:?}", backup_path); + } + std::fs::copy(key_path, backup_path).map_err(K1Error::IoError)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use k256::elliptic_curve::rand_core::OsRng; + use std::{collections::HashSet, fs}; + use tempfile::TempDir; + + fn setup_temp_dir() -> TempDir { + tempfile::tempdir().expect("Failed to create temp dir") + } + + fn create_test_key_file(data_dir: &Path) -> Result { + let key = SecretKey::random(&mut OsRng); + k1util::save(&key, &key_path(data_dir))?; + Ok(key) + } + + #[test] + fn test_key_path() { + let data_dir = Path::new("/test/data"); + let path = key_path(data_dir); + assert_eq!(path, PathBuf::from("/test/data/charon-enr-private-key")); + } + + #[test] + fn test_new_saved_priv_key_creates_key() -> Result<()> { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + let key = new_saved_priv_key(data_dir)?; + + let key_file = key_path(data_dir); + assert!(key_file.exists()); + + let loaded_key = load_priv_key(data_dir)?; + assert_eq!(key.to_bytes(), loaded_key.to_bytes()); + + Ok(()) + } + + #[test] + fn test_new_saved_priv_key_creates_data_dir() -> Result<()> { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path().join("new_dir"); + + assert!(!data_dir.exists()); + + new_saved_priv_key(&data_dir)?; + + assert!(data_dir.exists()); + assert!(data_dir.is_dir()); + + assert!(key_path(&data_dir).exists()); + + Ok(()) + } + + #[test] + fn test_load_priv_key_success() -> Result<()> { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + let original_key = create_test_key_file(data_dir)?; + + let loaded_key = load_priv_key(data_dir)?; + + assert_eq!(original_key.to_bytes(), loaded_key.to_bytes()); + + Ok(()) + } + + #[test] + fn test_load_priv_key_file_not_found() { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + let result = load_priv_key(data_dir); + + assert!(result.is_err()); + assert!(matches!(result, Err(K1Error::K1UtilError(_)))); + } + + #[test] + fn test_backup_priv_key_creates_backup() -> Result<()> { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + create_test_key_file(data_dir)?; + + backup_priv_key(data_dir)?; + + let backup_dir = data_dir.join(KEY_BACKUP_DIR); + assert!(backup_dir.exists()); + assert!(backup_dir.is_dir()); + + let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect(); + assert_eq!(entries.len(), 1); + + Ok(()) + } + + #[test] + fn test_new_saved_priv_key_with_existing_key() -> Result<()> { + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + let first_key = new_saved_priv_key(data_dir)?; + + let second_key = new_saved_priv_key(data_dir)?; + + assert_ne!(first_key.to_bytes(), second_key.to_bytes()); + + let loaded_key = load_priv_key(data_dir)?; + assert_eq!(second_key.to_bytes(), loaded_key.to_bytes()); + + let backup_dir = data_dir.join(KEY_BACKUP_DIR); + assert!(backup_dir.exists()); + + let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect(); + assert_eq!(entries.len(), 1); + + Ok(()) + } + + #[test] + fn test_backup_uniqueness() -> Result<()> { + const NUM_BACKUPS: usize = 5; + + let temp_dir = setup_temp_dir(); + let data_dir = temp_dir.path(); + + create_test_key_file(data_dir)?; + + for _ in 0..NUM_BACKUPS { + backup_priv_key(data_dir)?; + } + + let backup_dir = data_dir.join(KEY_BACKUP_DIR); + let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect(); + let backup_names: HashSet<_> = entries.iter().map(|e| e.file_name()).collect(); + + assert_eq!( + backup_names.len(), + NUM_BACKUPS, + "Should have 5 unique backup names" + ); + + Ok(()) + } +} diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 63312162..2e513476 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -13,3 +13,6 @@ pub mod name; /// P2P configuration. pub mod config; + +/// K1 utilities. +pub mod k1;