kittynode_core/application/validator/
generate_keys.rs1use 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(¶ms.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}