kittynode_core/application/validator/
generate_keys.rs

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