Skip to content

Commit 8963dcf

Browse files
authored
feat: add p2p::k1 module (#92)
1 parent 1416f9b commit 8963dcf

7 files changed

Lines changed: 310 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ base64 = { version = "0.22.1" }
4545
sha3 = { version = "0.10.8" }
4646
k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] }
4747
criterion = "0.7.0"
48+
tempfile = "3.10.0"
4849
tracing = "0.1.32"
4950
tracing-subscriber = { version = "0.3.9", features = ["env-filter"] }
5051
tracing-loki = "0.2.6"

crates/charon-k1util/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ libp2p.workspace = true
1414

1515
[dev-dependencies]
1616
criterion.workspace = true
17+
tempfile.workspace = true
1718

1819
[[bench]]
1920
name = "k1util"

crates/charon-k1util/src/k1util.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
//!
33
//! Helper functions for working with secp256k1 keys.
44
5+
use std::path::Path;
6+
57
use k256::{
68
AffinePoint, FieldBytes, PublicKey, SecretKey,
79
ecdsa::{self, RecoveryId, Signature, SigningKey, hazmat::VerifyPrimitive},
@@ -185,9 +187,30 @@ pub fn recover(hash: &[u8], sig: &[u8]) -> Result<PublicKey> {
185187
Ok(pubkey.into())
186188
}
187189

190+
/// Load loads a secret key from a file.
191+
pub fn load(file: &Path) -> Result<SecretKey> {
192+
let contents = std::fs::read_to_string(file).map_err(K1UtilError::FailedToReadFile)?;
193+
194+
let decoded = hex::decode(contents.trim())?;
195+
196+
let key = SecretKey::from_slice(&decoded).map_err(K1UtilError::FailedToParseSecretKey)?;
197+
198+
Ok(key)
199+
}
200+
201+
/// Save saves a secret key to a file.
202+
pub fn save(key: &SecretKey, file: &Path) -> Result<()> {
203+
let encoded = hex::encode(key.to_bytes());
204+
205+
std::fs::write(file, encoded).map_err(K1UtilError::FailedToWriteFile)?;
206+
207+
Ok(())
208+
}
209+
188210
#[cfg(test)]
189211
mod tests {
190212
use k256::elliptic_curve::rand_core::OsRng;
213+
use std::io::Write;
191214

192215
use super::*;
193216

@@ -258,4 +281,50 @@ mod tests {
258281
"Recovered public key should match"
259282
);
260283
}
284+
285+
#[test]
286+
fn test_load_nonexistent_file() {
287+
let file = Path::new("nonexistent-file");
288+
let result = load(file);
289+
assert!(result.is_err());
290+
}
291+
292+
#[test]
293+
fn test_invalid_hex_encoded_file() {
294+
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
295+
write!(temp_file, "abcXYZ123").unwrap(); // invalid hex encoded file
296+
297+
let result = load(temp_file.path());
298+
assert!(result.is_err());
299+
assert!(matches!(
300+
result.unwrap_err(),
301+
K1UtilError::FailedToDecodeHex(_)
302+
));
303+
}
304+
305+
#[test]
306+
fn test_valid_hex_strings() {
307+
let key = SecretKey::random(&mut OsRng);
308+
let key_str = hex::encode(key.to_bytes()).to_string();
309+
310+
let hex_strs = vec![
311+
format!("{}\n", key_str.clone()),
312+
format!("{}\r\n", key_str.clone()),
313+
format!("{} ", key_str.clone()),
314+
key_str.clone(),
315+
];
316+
317+
for hex_str in hex_strs {
318+
let mut temp_file = tempfile::NamedTempFile::new().unwrap();
319+
write!(temp_file, "{}", hex_str).unwrap();
320+
321+
let result = load(Path::new(&temp_file.path()));
322+
assert!(
323+
result.is_ok(),
324+
"Failed to load key from file: {:?}",
325+
&hex_str[hex_str.len().saturating_sub(2)..].to_string()
326+
);
327+
assert_eq!(result.unwrap(), key, "Key should match");
328+
}
329+
}
261330
}

crates/charon-p2p/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ thiserror.workspace = true
1212
k256.workspace = true
1313
charon-eth2.workspace = true
1414
charon-k1util.workspace = true
15+
chrono.workspace = true
16+
rand.workspace = true
1517

1618
[dev-dependencies]
1719
charon-testutil.workspace = true
20+
tempfile.workspace = true
1821

1922
[lints]
2023
workspace = true

crates/charon-p2p/src/k1.rs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//! # Charon P2P K1
2+
3+
use std::path::{Path, PathBuf};
4+
5+
use charon_k1util as k1util;
6+
use k256::{SecretKey, elliptic_curve::rand_core::OsRng};
7+
use rand::RngCore;
8+
9+
const KEY_FILE_NAME: &str = "charon-enr-private-key";
10+
const KEY_BACKUP_DIR: &str = "charon-enr-private-key-backups";
11+
12+
type Result<T> = std::result::Result<T, K1Error>;
13+
14+
/// An error that can occur when loading a private key.
15+
#[derive(Debug, thiserror::Error)]
16+
pub enum K1Error {
17+
/// K1 utility error.
18+
#[error("K1 utility error: {0}")]
19+
K1UtilError(#[from] k1util::K1UtilError),
20+
21+
/// IOError.
22+
#[error("IO error: {0}")]
23+
IoError(#[from] std::io::Error),
24+
}
25+
26+
/// Returns the charon-enr-private-key path relative to the data dir.
27+
pub fn key_path(data_dir: &Path) -> PathBuf {
28+
data_dir.join(KEY_FILE_NAME)
29+
}
30+
31+
/// Loads the private key from the data dir.
32+
pub fn load_priv_key(data_dir: &Path) -> Result<SecretKey> {
33+
k1util::load(&key_path(data_dir)).map_err(K1Error::K1UtilError)
34+
}
35+
36+
/// Generates a new private key and saves it to the data dir.
37+
pub fn new_saved_priv_key(data_dir: &Path) -> Result<SecretKey> {
38+
backup_priv_key(data_dir)?;
39+
40+
std::fs::create_dir_all(data_dir)?;
41+
42+
let key = SecretKey::random(&mut OsRng);
43+
44+
k1util::save(&key, &key_path(data_dir)).map_err(K1Error::K1UtilError)?;
45+
46+
Ok(key)
47+
}
48+
49+
/// Backs up the private key to the backup directory.
50+
///
51+
/// The backup directory is created if it doesn't exist.
52+
fn backup_priv_key(data_dir: &Path) -> Result<()> {
53+
let key_path = key_path(data_dir);
54+
55+
if !key_path.exists() {
56+
// Nothing to backup
57+
return Ok(());
58+
}
59+
60+
let current_time = chrono::Utc::now();
61+
let nonce = OsRng.next_u64();
62+
let backup_path = data_dir.join(KEY_BACKUP_DIR).join(format!(
63+
"{}_{}",
64+
current_time.format("%Y-%m-%d_%H-%M-%S_%f"),
65+
nonce
66+
));
67+
std::fs::create_dir_all(
68+
backup_path
69+
.parent()
70+
.expect("Backup path parent should exist"),
71+
)
72+
.map_err(K1Error::IoError)?;
73+
if backup_path.is_dir() {
74+
panic!("Backup path is a directory: {:?}", backup_path);
75+
}
76+
std::fs::copy(key_path, backup_path).map_err(K1Error::IoError)?;
77+
Ok(())
78+
}
79+
80+
#[cfg(test)]
81+
mod tests {
82+
use super::*;
83+
use k256::elliptic_curve::rand_core::OsRng;
84+
use std::{collections::HashSet, fs};
85+
use tempfile::TempDir;
86+
87+
fn setup_temp_dir() -> TempDir {
88+
tempfile::tempdir().expect("Failed to create temp dir")
89+
}
90+
91+
fn create_test_key_file(data_dir: &Path) -> Result<SecretKey> {
92+
let key = SecretKey::random(&mut OsRng);
93+
k1util::save(&key, &key_path(data_dir))?;
94+
Ok(key)
95+
}
96+
97+
#[test]
98+
fn test_key_path() {
99+
let data_dir = Path::new("/test/data");
100+
let path = key_path(data_dir);
101+
assert_eq!(path, PathBuf::from("/test/data/charon-enr-private-key"));
102+
}
103+
104+
#[test]
105+
fn test_new_saved_priv_key_creates_key() -> Result<()> {
106+
let temp_dir = setup_temp_dir();
107+
let data_dir = temp_dir.path();
108+
109+
let key = new_saved_priv_key(data_dir)?;
110+
111+
let key_file = key_path(data_dir);
112+
assert!(key_file.exists());
113+
114+
let loaded_key = load_priv_key(data_dir)?;
115+
assert_eq!(key.to_bytes(), loaded_key.to_bytes());
116+
117+
Ok(())
118+
}
119+
120+
#[test]
121+
fn test_new_saved_priv_key_creates_data_dir() -> Result<()> {
122+
let temp_dir = setup_temp_dir();
123+
let data_dir = temp_dir.path().join("new_dir");
124+
125+
assert!(!data_dir.exists());
126+
127+
new_saved_priv_key(&data_dir)?;
128+
129+
assert!(data_dir.exists());
130+
assert!(data_dir.is_dir());
131+
132+
assert!(key_path(&data_dir).exists());
133+
134+
Ok(())
135+
}
136+
137+
#[test]
138+
fn test_load_priv_key_success() -> Result<()> {
139+
let temp_dir = setup_temp_dir();
140+
let data_dir = temp_dir.path();
141+
142+
let original_key = create_test_key_file(data_dir)?;
143+
144+
let loaded_key = load_priv_key(data_dir)?;
145+
146+
assert_eq!(original_key.to_bytes(), loaded_key.to_bytes());
147+
148+
Ok(())
149+
}
150+
151+
#[test]
152+
fn test_load_priv_key_file_not_found() {
153+
let temp_dir = setup_temp_dir();
154+
let data_dir = temp_dir.path();
155+
156+
let result = load_priv_key(data_dir);
157+
158+
assert!(result.is_err());
159+
assert!(matches!(result, Err(K1Error::K1UtilError(_))));
160+
}
161+
162+
#[test]
163+
fn test_backup_priv_key_creates_backup() -> Result<()> {
164+
let temp_dir = setup_temp_dir();
165+
let data_dir = temp_dir.path();
166+
167+
create_test_key_file(data_dir)?;
168+
169+
backup_priv_key(data_dir)?;
170+
171+
let backup_dir = data_dir.join(KEY_BACKUP_DIR);
172+
assert!(backup_dir.exists());
173+
assert!(backup_dir.is_dir());
174+
175+
let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect();
176+
assert_eq!(entries.len(), 1);
177+
178+
Ok(())
179+
}
180+
181+
#[test]
182+
fn test_new_saved_priv_key_with_existing_key() -> Result<()> {
183+
let temp_dir = setup_temp_dir();
184+
let data_dir = temp_dir.path();
185+
186+
let first_key = new_saved_priv_key(data_dir)?;
187+
188+
let second_key = new_saved_priv_key(data_dir)?;
189+
190+
assert_ne!(first_key.to_bytes(), second_key.to_bytes());
191+
192+
let loaded_key = load_priv_key(data_dir)?;
193+
assert_eq!(second_key.to_bytes(), loaded_key.to_bytes());
194+
195+
let backup_dir = data_dir.join(KEY_BACKUP_DIR);
196+
assert!(backup_dir.exists());
197+
198+
let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect();
199+
assert_eq!(entries.len(), 1);
200+
201+
Ok(())
202+
}
203+
204+
#[test]
205+
fn test_backup_uniqueness() -> Result<()> {
206+
const NUM_BACKUPS: usize = 5;
207+
208+
let temp_dir = setup_temp_dir();
209+
let data_dir = temp_dir.path();
210+
211+
create_test_key_file(data_dir)?;
212+
213+
for _ in 0..NUM_BACKUPS {
214+
backup_priv_key(data_dir)?;
215+
}
216+
217+
let backup_dir = data_dir.join(KEY_BACKUP_DIR);
218+
let entries: Vec<_> = fs::read_dir(&backup_dir)?.filter_map(|e| e.ok()).collect();
219+
let backup_names: HashSet<_> = entries.iter().map(|e| e.file_name()).collect();
220+
221+
assert_eq!(
222+
backup_names.len(),
223+
NUM_BACKUPS,
224+
"Should have 5 unique backup names"
225+
);
226+
227+
Ok(())
228+
}
229+
}

crates/charon-p2p/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ pub mod name;
1313

1414
/// P2P configuration.
1515
pub mod config;
16+
17+
/// K1 utilities.
18+
pub mod k1;

0 commit comments

Comments
 (0)