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::{BLSPrivKey, BLSPubKey, SignatureKey};
8use hotshot_types::{
9    light_client::{StateKeyPair, StateSignKey},
10    x25519,
11};
12use tagged_base64::TaggedBase64;
13
14/// Keys can be specified in one of three ways:
15/// * A mnemonic phrase
16/// * A path to a key file
17/// * Individual private keys set via environment variable
18///
19/// Note that the third option always takes precedence, but if not all keys are specified explicitly
20/// in this way, one of the first two options may be used to generate the remaining keys.
21#[derive(Clone, Derivative, Parser)]
22#[derivative(Debug)]
23pub struct KeySetOptions {
24    /// Mnemonic phrase used to generate keys.
25    #[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    /// Optional index to enable generating multiple keysets from the same mnemonic.
35    #[clap(long, env = "ESPRESSO_NODE_KEY_INDEX", requires = "MNEMONIC")]
36    pub index: Option<u64>,
37
38    /// Path to file containing private keys.
39    ///
40    /// The file should follow the .env format, with keys:
41    /// * ESPRESSO_NODE_PRIVATE_STAKING_KEY
42    /// * ESPRESSO_NODE_PRIVATE_STATE_KEY
43    /// * ESPRESSO_NODE_PRIVATE_X25519_KEY (optional)
44    ///
45    /// Appropriate key files can be generated with the `keygen` utility program.
46    #[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    /// Private staking key.
55    ///
56    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
57    #[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    /// Private state signing key.
66    ///
67    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
68    #[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    /// Private x25519 key.
77    ///
78    /// This can be used as an alternative to MNEMONIC or KEY_FILE.
79    #[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        // If any of the keys are set explicitly, those take precedence.
104        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 provided, a mnemonic or key file can be used to fill in missing keys.
121        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            // Tolerate missing x25519 key (falls through to random generation)
162            // but still fail on malformed.
163            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        // TODO: remove this fallback once the network upgrades to NEW_PROTOCOL_VERSION and x25519
179        // keys become required. For now, generate a random key so existing deployments without an
180        // x25519 key configured can still start.
181        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
216/// Look up a key file variable by new name, falling back to the deprecated
217/// `ESPRESSO_SEQUENCER_` prefix if the new name is not found.
218fn 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}