Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/charon-k1util/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ libp2p.workspace = true

[dev-dependencies]
criterion.workspace = true
tempfile.workspace = true

[[bench]]
name = "k1util"
Expand Down
69 changes: 69 additions & 0 deletions crates/charon-k1util/src/k1util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -185,9 +187,30 @@ pub fn recover(hash: &[u8], sig: &[u8]) -> Result<PublicKey> {
Ok(pubkey.into())
}

/// Load loads a secret key from a file.
pub fn load(file: &Path) -> Result<SecretKey> {
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::*;

Expand Down Expand Up @@ -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");
}
}
}
3 changes: 3 additions & 0 deletions crates/charon-p2p/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
229 changes: 229 additions & 0 deletions crates/charon-p2p/src/k1.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, K1Error>;

/// 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<SecretKey> {
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<SecretKey> {
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<SecretKey> {
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(())
}
}
3 changes: 3 additions & 0 deletions crates/charon-p2p/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ pub mod name;

/// P2P configuration.
pub mod config;

/// K1 utilities.
pub mod k1;