kittynode_core/application/validator/
generate_keys.rs

1use 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(&params.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}