Skip to main content

espresso_keyset/
lib.rs

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/// Keys can be specified in one of three ways:
16/// * A mnemonic phrase
17/// * A path to a key file
18/// * Individual private keys set via environment variable
19///
20/// Note that the third option always takes precedence, but if not all keys are specified explicitly
21/// in this way, one of the first two options may be used to generate the remaining keys.
22#[derive(Clone, Derivative, Parser)]
23#[derivative(Debug)]
24pub struct KeySetOptions {
25    /// Mnemonic phrase used to generate keys.
26    #[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    /// Optional index to enable generating multiple keysets from the same mnemonic.
36    #[clap(long, env = "ESPRESSO_NODE_KEY_INDEX", requires = "MNEMONIC")]
37    pub index: Option<u64>,
38
39    /// Path to file containing private keys.
40    ///
41    /// The file should follow the .env format, with keys:
42    /// * ESPRESSO_NODE_PRIVATE_STAKING_KEY
43    /// * ESPRESSO_NODE_PRIVATE_STATE_KEY
44    /// * ESPRESSO_NODE_PRIVATE_X25519_KEY (optional)
45    ///
46    /// Appropriate key files can be generated with the `keygen` utility program.
47    #[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    /// Private staking key.
56    ///
57    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
58    #[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    /// Private state signing key.
67    ///
68    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
69    #[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    /// Private x25519 key.
78    ///
79    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
80    #[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    /// Derive a keyset from a mnemonic, at `index` or 0.
102    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        // If any of the keys are set explicitly, those take precedence.
119        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 provided, a mnemonic or key file can be used to fill in missing keys.
136        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            // Tolerate missing x25519 key (falls through to random generation)
177            // but still fail on malformed.
178            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        // TODO: remove this fallback once the network upgrades to NEW_PROTOCOL_VERSION and x25519
194        // keys become required. For now, generate a random key so existing deployments without an
195        // x25519 key configured can still start.
196        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
231/// Look up a key file variable by new name, falling back to the deprecated
232/// `ESPRESSO_SEQUENCER_` prefix if the new name is not found.
233fn 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}