1use std::{collections::HashMap, path::PathBuf};
2
3use alloy::signers::local::coins_bip39::{English, Mnemonic};
4use anyhow::{Context, bail};
5use clap::Parser;
6use derivative::Derivative;
7use hotshot::types::{BLSPrivKey, BLSPubKey, SignatureKey};
8use hotshot_types::{
9 light_client::{StateKeyPair, StateSignKey},
10 x25519,
11};
12use tagged_base64::TaggedBase64;
13
14#[derive(Clone, Derivative, Parser)]
22#[derivative(Debug)]
23pub struct KeySetOptions {
24 #[clap(
26 long,
27 name = "MNEMONIC",
28 env = "ESPRESSO_NODE_KEY_MNEMONIC",
29 conflicts_with = "KEY_FILE"
30 )]
31 #[derivative(Debug = "ignore")]
32 pub mnemonic: Option<Mnemonic<English>>,
33
34 #[clap(long, env = "ESPRESSO_NODE_KEY_INDEX", requires = "MNEMONIC")]
36 pub index: Option<u64>,
37
38 #[clap(
47 long,
48 name = "KEY_FILE",
49 env = "ESPRESSO_NODE_KEY_FILE",
50 conflicts_with = "MNEMONIC"
51 )]
52 pub key_file: Option<PathBuf>,
53
54 #[clap(
58 long,
59 env = "ESPRESSO_NODE_PRIVATE_STAKING_KEY",
60 conflicts_with = "KEY_FILE"
61 )]
62 #[derivative(Debug = "ignore")]
63 pub private_staking_key: Option<TaggedBase64>,
64
65 #[clap(
69 long,
70 env = "ESPRESSO_NODE_PRIVATE_STATE_KEY",
71 conflicts_with = "KEY_FILE"
72 )]
73 #[derivative(Debug = "ignore")]
74 pub private_state_key: Option<TaggedBase64>,
75
76 #[clap(
80 long,
81 env = "ESPRESSO_NODE_PRIVATE_X25519_KEY",
82 conflicts_with = "KEY_FILE"
83 )]
84 #[derivative(Debug = "ignore")]
85 pub private_x25519_key: Option<TaggedBase64>,
86}
87
88#[derive(Clone, Derivative)]
89#[derivative(Debug)]
90pub struct KeySet {
91 #[derivative(Debug = "ignore")]
92 pub staking: BLSPrivKey,
93 #[derivative(Debug = "ignore")]
94 pub state: StateSignKey,
95 #[derivative(Debug = "ignore")]
96 pub x25519: x25519::SecretKey,
97}
98
99impl TryFrom<KeySetOptions> for KeySet {
100 type Error = anyhow::Error;
101
102 fn try_from(opt: KeySetOptions) -> Result<Self, Self::Error> {
103 let mut staking = opt
105 .private_staking_key
106 .map(BLSPrivKey::try_from)
107 .transpose()
108 .context("parsing private staking key")?;
109 let mut state = opt
110 .private_state_key
111 .map(StateSignKey::try_from)
112 .transpose()
113 .context("parsing private state key")?;
114 let mut x25519 = opt
115 .private_x25519_key
116 .map(x25519::SecretKey::try_from)
117 .transpose()
118 .context("parsing private x25519 key")?;
119
120 if let Some(mnemonic) = opt.mnemonic {
122 let entropy = mnemonic.to_seed(None).context("invalid mnemonic")?;
123 let index = opt.index.unwrap_or_default();
124 if staking.is_none() {
125 let seed = blake3::derive_key("espresso staking key", &entropy);
126 staking = Some(BLSPubKey::generated_from_seed_indexed(seed, index).1);
127 }
128 if state.is_none() {
129 let seed = blake3::derive_key("espresso state key", &entropy);
130 state = Some(
131 StateKeyPair::generate_from_seed_indexed(seed, index)
132 .0
133 .sign_key(),
134 );
135 }
136 if x25519.is_none() {
137 let seed = blake3::derive_key("espresso x25519 key", &entropy);
138 x25519 = Some(
139 x25519::Keypair::generated_from_seed_indexed(seed, index)
140 .context("generating x25519 key from mnemonic")?
141 .secret_key(),
142 );
143 }
144 } else if let Some(path) = &opt.key_file {
145 let vars = dotenvy::from_path_iter(path)
146 .context("reading key file")?
147 .collect::<Result<HashMap<_, _>, _>>()
148 .context("reading key file")?;
149 if staking.is_none() {
150 staking = Some(read_from_key_file(
151 &vars,
152 "ESPRESSO_NODE_PRIVATE_STAKING_KEY",
153 )?);
154 }
155 if state.is_none() {
156 state = Some(read_from_key_file(
157 &vars,
158 "ESPRESSO_NODE_PRIVATE_STATE_KEY",
159 )?);
160 }
161 if x25519.is_none()
164 && let Some(raw) = lookup_key_file_var(&vars, "ESPRESSO_NODE_PRIVATE_X25519_KEY")
165 {
166 x25519 = Some(
167 TaggedBase64::parse(raw)
168 .and_then(|tb64| tb64.try_into())
169 .context("key file has malformed ESPRESSO_NODE_PRIVATE_X25519_KEY")?,
170 );
171 }
172 }
173
174 let (Some(staking), Some(state)) = (staking, state) else {
175 bail!("neither mnemonic, key file nor full set of private keys was provided")
176 };
177
178 let x25519 = match x25519 {
182 Some(key) => key,
183 None => {
184 tracing::warn!(
185 "No x25519 key provided, generating a random ephemeral key. A persistent key \
186 (via ESPRESSO_NODE_PRIVATE_X25519_KEY or mnemonic) will be required for the \
187 Cliquenet protocol upgrade."
188 );
189 x25519::Keypair::generate()
190 .context("generating random x25519 key")?
191 .secret_key()
192 },
193 };
194
195 Ok(Self {
196 staking,
197 state,
198 x25519,
199 })
200 }
201}
202
203fn read_from_key_file<
204 T: TryFrom<TaggedBase64, Error: Send + Sync + std::error::Error + 'static>,
205>(
206 vars: &HashMap<String, String>,
207 key: &str,
208) -> anyhow::Result<T> {
209 let val = lookup_key_file_var(vars, key).context(format!("key file missing {key}"))?;
210 TaggedBase64::parse(val)
211 .context(format!("key file has malformed {key}"))?
212 .try_into()
213 .context(format!("key file has malformed {key}"))
214}
215
216fn lookup_key_file_var<'a>(vars: &'a HashMap<String, String>, key: &str) -> Option<&'a String> {
219 vars.get(key).or_else(|| {
220 let legacy = key.replacen("ESPRESSO_NODE_", "ESPRESSO_SEQUENCER_", 1);
221 vars.get(&legacy).inspect(|_| {
222 tracing::warn!("Key file uses deprecated {legacy}, please rename to {key}");
223 })
224 })
225}
226
227#[cfg(test)]
228mod tests {
229 use std::io::Write;
230
231 use super::*;
232
233 fn generate_keys() -> KeySet {
234 let mnemonic: Mnemonic<English> = Mnemonic::new(&mut rand::rngs::OsRng);
235 KeySet::try_from(KeySetOptions {
236 mnemonic: Some(mnemonic),
237 index: None,
238 key_file: None,
239 private_staking_key: None,
240 private_state_key: None,
241 private_x25519_key: None,
242 })
243 .unwrap()
244 }
245
246 fn staking_tb64(keys: &KeySet) -> TaggedBase64 {
247 keys.staking.to_tagged_base64().unwrap()
248 }
249
250 fn state_tb64(keys: &KeySet) -> TaggedBase64 {
251 StateKeyPair::from_sign_key(keys.state.clone())
252 .sign_key_ref()
253 .to_tagged_base64()
254 .unwrap()
255 }
256
257 fn x25519_tb64(keys: &KeySet) -> TaggedBase64 {
258 TaggedBase64::try_from(keys.x25519.clone()).unwrap()
259 }
260
261 fn write_key_file(lines: &[String]) -> tempfile::NamedTempFile {
262 let mut f = tempfile::NamedTempFile::new().unwrap();
263 for line in lines {
264 writeln!(f, "{line}").unwrap();
265 }
266 f
267 }
268
269 #[test]
270 fn env_vars_without_x25519_succeeds() {
271 let keys = generate_keys();
272 let opts = KeySetOptions {
273 mnemonic: None,
274 index: None,
275 key_file: None,
276 private_staking_key: Some(staking_tb64(&keys)),
277 private_state_key: Some(state_tb64(&keys)),
278 private_x25519_key: None,
279 };
280 KeySet::try_from(opts).unwrap();
281 }
282
283 #[test]
284 fn env_vars_with_x25519_uses_provided() {
285 let keys = generate_keys();
286 let opts = KeySetOptions {
287 mnemonic: None,
288 index: None,
289 key_file: None,
290 private_staking_key: Some(staking_tb64(&keys)),
291 private_state_key: Some(state_tb64(&keys)),
292 private_x25519_key: Some(x25519_tb64(&keys)),
293 };
294 let result = KeySet::try_from(opts).unwrap();
295 assert_eq!(result.x25519, keys.x25519);
296 }
297
298 #[test]
299 fn key_file_without_x25519_succeeds() {
300 let keys = generate_keys();
301 let f = write_key_file(&[
302 format!("ESPRESSO_NODE_PRIVATE_STAKING_KEY={}", staking_tb64(&keys)),
303 format!("ESPRESSO_NODE_PRIVATE_STATE_KEY={}", state_tb64(&keys)),
304 ]);
305 let opts = KeySetOptions {
306 mnemonic: None,
307 index: None,
308 key_file: Some(f.path().to_path_buf()),
309 private_staking_key: None,
310 private_state_key: None,
311 private_x25519_key: None,
312 };
313 KeySet::try_from(opts).unwrap();
314 }
315
316 #[test]
317 fn key_file_with_x25519_uses_provided() {
318 let keys = generate_keys();
319 let f = write_key_file(&[
320 format!("ESPRESSO_NODE_PRIVATE_STAKING_KEY={}", staking_tb64(&keys)),
321 format!("ESPRESSO_NODE_PRIVATE_STATE_KEY={}", state_tb64(&keys)),
322 format!("ESPRESSO_NODE_PRIVATE_X25519_KEY={}", x25519_tb64(&keys)),
323 ]);
324 let opts = KeySetOptions {
325 mnemonic: None,
326 index: None,
327 key_file: Some(f.path().to_path_buf()),
328 private_staking_key: None,
329 private_state_key: None,
330 private_x25519_key: None,
331 };
332 let result = KeySet::try_from(opts).unwrap();
333 assert_eq!(result.x25519, keys.x25519);
334 }
335
336 #[test]
337 fn key_file_with_malformed_x25519_fails() {
338 let keys = generate_keys();
339 let f = write_key_file(&[
340 format!("ESPRESSO_NODE_PRIVATE_STAKING_KEY={}", staking_tb64(&keys)),
341 format!("ESPRESSO_NODE_PRIVATE_STATE_KEY={}", state_tb64(&keys)),
342 "ESPRESSO_NODE_PRIVATE_X25519_KEY=not-a-valid-key".to_string(),
343 ]);
344 let opts = KeySetOptions {
345 mnemonic: None,
346 index: None,
347 key_file: Some(f.path().to_path_buf()),
348 private_staking_key: None,
349 private_state_key: None,
350 private_x25519_key: None,
351 };
352 assert!(
353 KeySet::try_from(opts)
354 .unwrap_err()
355 .to_string()
356 .contains("malformed")
357 );
358 }
359
360 #[test]
361 fn key_file_with_legacy_sequencer_names() {
362 let keys = generate_keys();
363 let f = write_key_file(&[
364 format!(
365 "ESPRESSO_SEQUENCER_PRIVATE_STAKING_KEY={}",
366 staking_tb64(&keys)
367 ),
368 format!("ESPRESSO_SEQUENCER_PRIVATE_STATE_KEY={}", state_tb64(&keys)),
369 format!(
370 "ESPRESSO_SEQUENCER_PRIVATE_X25519_KEY={}",
371 x25519_tb64(&keys)
372 ),
373 ]);
374 let opts = KeySetOptions {
375 mnemonic: None,
376 index: None,
377 key_file: Some(f.path().to_path_buf()),
378 private_staking_key: None,
379 private_state_key: None,
380 private_x25519_key: None,
381 };
382 let result = KeySet::try_from(opts).unwrap();
383 assert_eq!(result.staking, keys.staking);
384 assert_eq!(result.x25519, keys.x25519);
385 }
386}