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