kittynode_core/application/validator/
generate_keys.rs1use super::filesystem::ensure_parent_secure;
2use super::ports::{CryptoProvider, ValidatorFilesystem};
3use crate::domain::validator::ValidatorKey;
4use crate::infra::validator::{SimpleCryptoProvider, StdValidatorFilesystem};
5use eyre::{Context, Result};
6use std::path::PathBuf;
7
8const DEFAULT_KEY_FILENAME: &str = "validator_key.json";
9
10#[derive(Debug, Clone)]
11pub struct GenerateKeysParams {
12 pub output_dir: PathBuf,
13 pub file_name: Option<String>,
14 pub entropy: String,
15 pub overwrite: bool,
16}
17
18impl GenerateKeysParams {
19 pub fn new(output_dir: PathBuf, entropy: String) -> Self {
20 Self {
21 output_dir,
22 file_name: None,
23 entropy,
24 overwrite: false,
25 }
26 }
27
28 pub fn key_path(&self) -> PathBuf {
29 self.output_dir.join(self.file_name())
30 }
31
32 fn file_name(&self) -> &str {
33 self.file_name.as_deref().unwrap_or(DEFAULT_KEY_FILENAME)
34 }
35}
36
37impl Default for GenerateKeysParams {
38 fn default() -> Self {
39 Self {
40 output_dir: PathBuf::new(),
41 file_name: None,
42 entropy: String::new(),
43 overwrite: false,
44 }
45 }
46}
47
48pub fn generate_keys(params: GenerateKeysParams) -> Result<ValidatorKey> {
49 let crypto = SimpleCryptoProvider;
50 let filesystem = StdValidatorFilesystem;
51 generate_keys_with(params, &crypto, &filesystem)
52}
53
54pub fn generate_keys_with<P, F>(
55 params: GenerateKeysParams,
56 crypto: &P,
57 filesystem: &F,
58) -> Result<ValidatorKey>
59where
60 P: CryptoProvider,
61 F: ValidatorFilesystem,
62{
63 let key_file = params.key_path();
64 ensure_parent_secure(&key_file, filesystem, "invalid validator output directory")?;
65
66 let key = crypto
67 .generate_key(¶ms.entropy)
68 .context("failed to generate validator key")?;
69
70 filesystem
71 .write_json_secure(&key_file, &key, params.overwrite)
72 .context("failed to write validator key")?;
73
74 Ok(key)
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80 use crate::application::validator::ports::{CryptoProvider, ValidatorFilesystem};
81 use crate::domain::validator::ValidatorKey;
82 use eyre::Result;
83 use serde::{Serialize, de::DeserializeOwned};
84 use std::collections::HashMap;
85 use std::fs;
86 use std::path::{Path, PathBuf};
87 use std::sync::Mutex;
88 use tempfile::tempdir;
89
90 #[derive(Default)]
91 struct TestFilesystem {
92 files: Mutex<HashMap<PathBuf, String>>,
93 insecure_dirs: Mutex<Vec<PathBuf>>,
94 }
95
96 impl ValidatorFilesystem for TestFilesystem {
97 fn ensure_secure_directory(&self, path: &Path) -> Result<()> {
98 if self
99 .insecure_dirs
100 .lock()
101 .expect("mutex poisoned")
102 .iter()
103 .any(|p| p == path)
104 {
105 eyre::bail!("directory has insecure permissions: {}", path.display());
106 }
107 Ok(())
108 }
109
110 fn write_json_secure<T: Serialize + ?Sized>(
111 &self,
112 path: &Path,
113 value: &T,
114 _overwrite: bool,
115 ) -> Result<()> {
116 let json = serde_json::to_string(value)?;
117 self.files
118 .lock()
119 .expect("mutex poisoned")
120 .insert(path.to_path_buf(), json);
121 Ok(())
122 }
123
124 fn read_json_secure<T: DeserializeOwned>(&self, path: &Path) -> Result<T> {
125 let files = self.files.lock().expect("mutex poisoned");
126 let contents = files
127 .get(path)
128 .ok_or_else(|| eyre::eyre!("missing file: {}", path.display()))?;
129 Ok(serde_json::from_str(contents)?)
130 }
131 }
132
133 #[derive(Default)]
134 struct TestCrypto;
135
136 impl CryptoProvider for TestCrypto {
137 fn generate_key(&self, entropy: &str) -> Result<ValidatorKey> {
138 Ok(ValidatorKey {
139 public_key: format!("pub-{entropy}"),
140 secret_key: format!("sec-{entropy}"),
141 })
142 }
143
144 fn create_deposit_data(
145 &self,
146 _key: &ValidatorKey,
147 _withdrawal_credentials: &str,
148 _amount_gwei: u64,
149 _fork_version: [u8; 4],
150 _genesis_validators_root: &str,
151 ) -> Result<crate::domain::validator::DepositData> {
152 unreachable!("not used in generate_keys tests")
153 }
154 }
155
156 #[test]
157 fn writes_key_using_filesystem() {
158 let fs = TestFilesystem::default();
159 let crypto = TestCrypto;
160 let params = GenerateKeysParams {
161 output_dir: PathBuf::from("/validators"),
162 file_name: Some("key.json".into()),
163 entropy: "seed".into(),
164 overwrite: false,
165 };
166
167 let key = generate_keys_with(params.clone(), &crypto, &fs).unwrap();
168 assert_eq!(
169 key,
170 ValidatorKey {
171 public_key: "pub-seed".into(),
172 secret_key: "sec-seed".into(),
173 }
174 );
175
176 let stored: ValidatorKey = fs
177 .read_json_secure(&PathBuf::from("/validators/key.json"))
178 .unwrap();
179 assert_eq!(stored, key);
180 }
181
182 #[test]
183 fn rejects_insecure_directory() {
184 let fs = TestFilesystem::default();
185 fs.insecure_dirs
186 .lock()
187 .expect("mutex poisoned")
188 .push(PathBuf::from("/validators"));
189 let crypto = TestCrypto;
190 let params = GenerateKeysParams {
191 output_dir: PathBuf::from("/validators"),
192 file_name: Some("key.json".into()),
193 entropy: "seed".into(),
194 overwrite: false,
195 };
196
197 let result = generate_keys_with(params, &crypto, &fs);
198 assert!(result.is_err());
199 }
200
201 #[test]
202 fn default_flow_creates_real_files() {
203 let dir = tempdir().unwrap();
204 let output_dir = dir.path().join("validators");
205 let params = GenerateKeysParams {
206 output_dir: output_dir.clone(),
207 file_name: None,
208 entropy: "kitty".into(),
209 overwrite: true,
210 };
211
212 let key = generate_keys(params).unwrap();
213 let written = fs::read_to_string(output_dir.join(DEFAULT_KEY_FILENAME)).unwrap();
214 let decoded: ValidatorKey = serde_json::from_str(&written).unwrap();
215 assert_eq!(decoded, key);
216 assert_eq!(key.public_key.len(), 96);
217 assert_eq!(key.secret_key.len(), 64);
218 }
219}