pcli/command/
init.rs

1use std::{
2    io::{stdin, IsTerminal as _, Read, Write},
3    str::FromStr,
4};
5
6use anyhow::{Context, Result};
7use camino::Utf8PathBuf;
8use penumbra_sdk_custody::threshold;
9#[cfg(feature = "ledger")]
10use penumbra_sdk_custody_ledger_usb as ledger;
11use penumbra_sdk_keys::keys::{Bip44Path, SeedPhrase, SpendKey};
12use rand_core::OsRng;
13use termion::screen::IntoAlternateScreen;
14use url::Url;
15
16use crate::{
17    config::{CustodyConfig, GovernanceCustodyConfig, PcliConfig},
18    terminal::ActualTerminal,
19};
20
21#[derive(Debug, clap::Parser)]
22pub struct InitCmd {
23    #[clap(subcommand)]
24    pub subcmd: InitTopSubCmd,
25    /// The GRPC URL that will be used in the generated config.
26    #[clap(
27        long,
28        // Note: reading from the environment here means that running
29        // pcli init inside of the test harness (where we override that)
30        // will correctly set the URL, even though we don't subsequently
31        // read it from the environment.
32        env = "PENUMBRA_NODE_PD_URL",
33        parse(try_from_str = Url::parse),
34    )]
35    grpc_url: Url,
36    /// For configs with spend authority, this will enable password encryption.
37    ///
38    /// This has no effect on a view only service.
39    #[clap(long, action)]
40    encrypted: bool,
41}
42
43#[derive(Debug, Clone, clap::Subcommand)]
44pub enum InitTopSubCmd {
45    #[clap(flatten)]
46    Spend(InitSubCmd),
47    /// Initialize `pcli` in view-only mode, without spending keys.
48    #[clap(display_order = 200)]
49    ViewOnly {},
50    /// Initialize a separate validator governance key for an existing `pcli` configuration (this
51    /// option is only meaningful for validators).
52    #[clap(subcommand, display_order = 300)]
53    ValidatorGovernanceSubkey(InitSubCmd),
54    /// Wipe all `pcli` configuration and data, INCLUDING KEYS.
55    #[clap(display_order = 900)]
56    UnsafeWipe {},
57}
58
59#[derive(Debug, Clone, clap::Subcommand)]
60pub enum InitSubCmd {
61    /// Initialize using a basic, file-based custody backend.
62    #[clap(subcommand, display_order = 100)]
63    SoftKms(SoftKmsInitCmd),
64    /// Initialize using a manual threshold signing backend.
65    #[clap(subcommand, display_order = 150)]
66    Threshold(ThresholdInitCmd),
67    // This is not accessible directly by the user, because it's impermissible to initialize the
68    // governance subkey as view-only.
69    #[clap(skip, display_order = 200)]
70    ViewOnly,
71    /// Initialize using a ledger hardware wallet.
72    #[cfg(feature = "ledger")]
73    #[clap(display_order = 250)]
74    Ledger,
75    /// If relevant, change the current config to an encrypted config, with a password.
76    #[clap(display_order = 800)]
77    ReEncrypt,
78}
79
80#[derive(Debug, Clone, clap::Subcommand)]
81pub enum SoftKmsInitCmd {
82    /// Generate a new seed phrase and import its corresponding key.
83    #[clap(display_order = 100)]
84    Generate {
85        /// If set, will write the seed phrase to stdout.
86        #[clap(long, action)]
87        stdout: bool,
88    },
89    /// Import a spend key from an existing seed phrase.
90    #[clap(display_order = 200)]
91    ImportPhrase {
92        /// If set, will use legacy BIP39 derivation.
93        ///
94        /// Use this ONLY if:
95        /// - you generated your wallet prior to Testnet 62.
96        /// - you need to replicate legacy derivation for some reason.
97        #[clap(long, action)]
98        legacy_raw_bip39_derivation: bool,
99    },
100}
101
102// Reusable function for prompting interactively for key material.
103fn prompt_for_password(msg: &str) -> Result<String> {
104    let mut password = String::new();
105    // The `rpassword` crate doesn't support reading from stdin, so we check
106    // for an interactive session. We must support non-interactive use cases,
107    // for integration with other tooling.
108    if std::io::stdin().is_terminal() {
109        password = rpassword::prompt_password(msg)?;
110    } else {
111        while let Ok(n_bytes) = std::io::stdin().lock().read_to_string(&mut password) {
112            if n_bytes == 0 {
113                break;
114            }
115            password = password.trim().to_string();
116        }
117    }
118    Ok(password)
119}
120
121impl SoftKmsInitCmd {
122    fn spend_key(&self, init_type: InitType) -> Result<SpendKey> {
123        Ok(match self {
124            SoftKmsInitCmd::Generate { stdout } => {
125                let seed_phrase = SeedPhrase::generate(OsRng);
126                let seed_msg = format!(
127                    "YOUR PRIVATE SEED PHRASE ({init_type:?}):\n\n\
128                   {seed_phrase}\n\n\
129                   Save this in a safe place!\n\
130                   DO NOT SHARE WITH ANYONE!\n"
131                );
132
133                let mut output = std::io::stdout();
134
135                if *stdout {
136                    output.write_all(seed_msg.as_bytes())?;
137                    output.flush()?;
138                } else {
139                    let mut screen = output.into_alternate_screen()?;
140                    writeln!(screen, "{seed_msg}")?;
141                    screen.flush()?;
142                    println!("Press enter to proceed.");
143                    let _ = stdin().bytes().next();
144                }
145
146                let path = Bip44Path::new(0);
147                SpendKey::from_seed_phrase_bip44(seed_phrase, &path)
148            }
149            SoftKmsInitCmd::ImportPhrase {
150                legacy_raw_bip39_derivation,
151            } => {
152                let seed_phrase = prompt_for_password("Enter seed phrase: ")?;
153                let seed_phrase = SeedPhrase::from_str(&seed_phrase)
154                    .context("failed to parse input as seed phrase")?;
155
156                if *legacy_raw_bip39_derivation {
157                    SpendKey::from_seed_phrase_bip39(seed_phrase, 0)
158                } else {
159                    let path = Bip44Path::new(0);
160                    SpendKey::from_seed_phrase_bip44(seed_phrase, &path)
161                }
162            }
163        })
164    }
165}
166
167#[derive(Debug, Clone, clap::Subcommand)]
168pub enum ThresholdInitCmd {
169    /// Use a centralized dealer to create config files for each signer.
170    ///
171    /// Unlike the other `pcli init` commands, this one ignores the global
172    /// `--home` argument, since it generates one config for each signer.
173    Deal {
174        /// The minimum number of signers required to make a signature (>= 2).
175        #[clap(short, long)]
176        threshold: u16,
177        /// A path to the home directory for each signer.
178        ///
179        /// Each directory will be configured to be used as the --home parameter
180        /// for that signer's pcli instance.  This implicitly specifies the
181        /// total number of signers (one for each --home).
182        #[clap(long, value_delimiter = ' ', multiple_values = true)]
183        home: Vec<Utf8PathBuf>,
184    },
185    /// Generate a config file without using a trusted dealer.
186    Dkg {
187        /// The minimum number of signers required to make a signature (>= 2).
188        #[clap(short, long)]
189        threshold: u16,
190        /// The maximum number of signers that can make a signature
191        #[clap(short, long)]
192        num_participants: u16,
193    },
194}
195
196fn exec_deal(
197    init_type: InitType,
198    threshold: u16,
199    home: Vec<Utf8PathBuf>,
200    grpc_url: Url,
201) -> Result<()> {
202    if threshold < 2 {
203        anyhow::bail!("threshold must be >= 2");
204    }
205    let n = home.len() as u16;
206
207    // Check before doing anything to make sure that files don't exist (spend key case) or that the
208    // governance key is missing in all of them (governance key case) -- we do this check first so
209    // that we don't write partial results if we would fail partway through (though we *also* check
210    // partway through to reduce chances of a race where we'd overwrite data)
211    for config_path in home.iter() {
212        let config_path = config_path.join(crate::CONFIG_FILE_NAME);
213        if let InitType::GovernanceKey = init_type {
214            let config = PcliConfig::load(&config_path)?;
215            if config.governance_custody.is_some() {
216                anyhow::bail!(
217                    "governance key already exists in config file at {:?}; refusing to overwrite it",
218                    config_path
219                );
220            }
221        } else if config_path.exists() {
222            anyhow::bail!(
223                "config file already exists at {:?}; refusing to overwrite it",
224                config_path
225            );
226        }
227    }
228
229    println!("Generating {}-of-{} threshold config.", threshold, n);
230    let configs = threshold::Config::deal(&mut OsRng, threshold, n)?;
231    println!("Writing dealt config files...");
232    for (i, (config, config_path)) in configs.into_iter().zip(home.iter()).enumerate() {
233        let full_viewing_key = config.fvk().clone();
234
235        let config = if let InitType::SpendKey = init_type {
236            PcliConfig {
237                custody: CustodyConfig::Threshold(config),
238                full_viewing_key,
239                grpc_url: grpc_url.clone(),
240                view_url: None,
241                disable_warning: false,
242                governance_custody: None,
243            }
244        } else {
245            let mut pcli_config = PcliConfig::load(config_path.join(crate::CONFIG_FILE_NAME))?;
246            if pcli_config.governance_custody.is_some() {
247                anyhow::bail!(
248                    "governance key already exists in config file at {:?}; refusing to overwrite it",
249                    config_path
250                );
251            }
252            pcli_config.governance_custody = Some(GovernanceCustodyConfig::Threshold(config));
253            pcli_config
254        };
255
256        println!("  Writing signer {} config to {}", i, config_path);
257        std::fs::create_dir_all(config_path)?;
258        config.save(config_path.join(crate::CONFIG_FILE_NAME))?;
259    }
260    Ok(())
261}
262
263/// Which kind of initialization are we doing?
264#[derive(Clone, Debug, Copy)]
265enum InitType {
266    /// Initialize from scratch with a spend key.
267    SpendKey,
268    /// Add a governance key to an existing configuration.
269    GovernanceKey,
270}
271
272impl InitCmd {
273    pub async fn exec(&self, home_dir: impl AsRef<camino::Utf8Path>) -> Result<()> {
274        let (init_type, subcmd) = match self.subcmd.clone() {
275            InitTopSubCmd::Spend(subcmd) => (InitType::SpendKey, subcmd),
276            InitTopSubCmd::ValidatorGovernanceSubkey(subcmd) => (InitType::GovernanceKey, subcmd),
277            InitTopSubCmd::ViewOnly {} => (InitType::SpendKey, InitSubCmd::ViewOnly),
278            InitTopSubCmd::UnsafeWipe {} => {
279                println!("Deleting all data in {}...", home_dir.as_ref());
280                std::fs::remove_dir_all(home_dir.as_ref())?;
281                return Ok(());
282            }
283        };
284
285        if let InitSubCmd::Threshold(ThresholdInitCmd::Deal { threshold, home }) = &subcmd {
286            exec_deal(
287                init_type,
288                threshold.clone(),
289                home.clone(),
290                self.grpc_url.clone(),
291            )?;
292            return Ok(());
293        }
294        let home_dir = home_dir.as_ref();
295
296        let existing_config = {
297            let config_path = home_dir.join(crate::CONFIG_FILE_NAME);
298            if config_path.exists() {
299                Some(PcliConfig::load(config_path)?)
300            } else {
301                None
302            }
303        };
304        let relevant_config_exists = match &init_type {
305            InitType::SpendKey => existing_config.is_some(),
306            InitType::GovernanceKey => existing_config
307                .as_ref()
308                .is_some_and(|x| x.governance_custody.is_some()),
309        };
310
311        let (full_viewing_key, custody) = match (&init_type, &subcmd, relevant_config_exists) {
312            (_, InitSubCmd::SoftKms(cmd), false) => {
313                let spend_key = cmd.spend_key(init_type)?;
314                (
315                    spend_key.full_viewing_key().clone(),
316                    if self.encrypted {
317                        let password = ActualTerminal::get_confirmed_password().await?;
318                        CustodyConfig::Encrypted(penumbra_sdk_custody::encrypted::Config::create(
319                            &password,
320                            penumbra_sdk_custody::encrypted::InnerConfig::SoftKms(spend_key.into()),
321                        )?)
322                    } else {
323                        CustodyConfig::SoftKms(spend_key.into())
324                    },
325                )
326            }
327            (
328                _,
329                InitSubCmd::Threshold(ThresholdInitCmd::Dkg {
330                    threshold,
331                    num_participants,
332                }),
333                false,
334            ) => {
335                let config =
336                    threshold::dkg(*threshold, *num_participants, &ActualTerminal::default())
337                        .await?;
338                let fvk = config.fvk().clone();
339                let custody_config = if self.encrypted {
340                    let password = ActualTerminal::get_confirmed_password().await?;
341                    CustodyConfig::Encrypted(penumbra_sdk_custody::encrypted::Config::create(
342                        &password,
343                        penumbra_sdk_custody::encrypted::InnerConfig::Threshold(config),
344                    )?)
345                } else {
346                    CustodyConfig::Threshold(config)
347                };
348                (fvk, custody_config)
349            }
350            (_, InitSubCmd::Threshold(ThresholdInitCmd::Deal { .. }), _) => {
351                unreachable!("this should already have been handled above")
352            }
353            (InitType::SpendKey, InitSubCmd::ViewOnly {}, false) => {
354                let full_viewing_key = prompt_for_password("Enter full viewing key: ")?
355                    .parse()
356                    .context("failed to parse input as FullViewingKey")?;
357                (full_viewing_key, CustodyConfig::ViewOnly)
358            }
359            (InitType::GovernanceKey, InitSubCmd::ViewOnly { .. }, false) => {
360                unreachable!("governance keys can't be initialized in view-only mode")
361            }
362            (typ, InitSubCmd::ReEncrypt, true) => {
363                let config = existing_config.expect("the config should exist in this branch");
364                let fvk = config.full_viewing_key;
365                let custody = match typ {
366                    InitType::SpendKey => config.custody,
367                    InitType::GovernanceKey => match config
368                        .governance_custody
369                        .expect("the governence custody should exist in this branch")
370                    {
371                        GovernanceCustodyConfig::SoftKms(c) => CustodyConfig::SoftKms(c),
372                        GovernanceCustodyConfig::Threshold(c) => CustodyConfig::Threshold(c),
373                        GovernanceCustodyConfig::Encrypted { config, .. } => {
374                            CustodyConfig::Encrypted(config)
375                        }
376                    },
377                };
378                let custody = match custody {
379                    x @ CustodyConfig::ViewOnly => x,
380                    x @ CustodyConfig::Encrypted(_) => x,
381                    CustodyConfig::SoftKms(spend_key) => {
382                        let password = ActualTerminal::get_confirmed_password().await?;
383                        CustodyConfig::Encrypted(penumbra_sdk_custody::encrypted::Config::create(
384                            &password,
385                            penumbra_sdk_custody::encrypted::InnerConfig::SoftKms(spend_key),
386                        )?)
387                    }
388                    CustodyConfig::Threshold(c) => {
389                        let password = ActualTerminal::get_confirmed_password().await?;
390                        CustodyConfig::Encrypted(penumbra_sdk_custody::encrypted::Config::create(
391                            &password,
392                            penumbra_sdk_custody::encrypted::InnerConfig::Threshold(c),
393                        )?)
394                    }
395                    #[cfg(feature = "ledger")]
396                    CustodyConfig::Ledger(_config) => {
397                        anyhow::bail!("An additional layer of password encryption is not (currently) possible for hardware wallets.");
398                    }
399                };
400                (fvk, custody)
401            }
402            (_, InitSubCmd::ReEncrypt, false) => {
403                anyhow::bail!("re-encrypt requires existing config to exist",);
404            }
405            #[cfg(feature = "ledger")]
406            (InitType::SpendKey, InitSubCmd::Ledger, false) => {
407                let config = ledger::Config::initialize(ledger::InitOptions::default()).await?;
408                let service = ledger::Service::new(config.clone());
409                let fvk = service.impl_export_full_viewing_key().await?;
410                (fvk, CustodyConfig::Ledger(config))
411            }
412            #[cfg(feature = "ledger")]
413            (InitType::GovernanceKey, InitSubCmd::Ledger, false) => {
414                anyhow::bail!("governance keys are not supported on ledger devices");
415            }
416            (InitType::SpendKey, _, true) => {
417                anyhow::bail!(
418                    "home directory {:?} is not empty; refusing to initialize",
419                    home_dir
420                );
421            }
422            (InitType::GovernanceKey, _, true) => {
423                anyhow::bail!(
424                        "governance key already exists in config file at {:?}; refusing to overwrite it",
425                        home_dir
426                    );
427            }
428        };
429
430        let config = if let InitType::SpendKey = init_type {
431            PcliConfig {
432                custody,
433                full_viewing_key,
434                grpc_url: self.grpc_url.clone(),
435                view_url: None,
436                disable_warning: false,
437                governance_custody: None,
438            }
439        } else {
440            let config_path = home_dir.join(crate::CONFIG_FILE_NAME);
441            let mut config = PcliConfig::load(config_path)?;
442            let governance_custody = match custody {
443                CustodyConfig::SoftKms(config) => GovernanceCustodyConfig::SoftKms(config),
444                CustodyConfig::Threshold(config) => GovernanceCustodyConfig::Threshold(config),
445                CustodyConfig::Encrypted(config) => GovernanceCustodyConfig::Encrypted {
446                    fvk: full_viewing_key,
447                    config,
448                },
449                _ => unreachable!("governance keys can't be initialized in view-only mode"),
450            };
451            config.governance_custody = Some(governance_custody);
452            config
453        };
454
455        let config_path = home_dir.join(crate::CONFIG_FILE_NAME);
456        println!("Writing generated config to {}", config_path);
457        config.save(config_path)?;
458
459        if let InitType::GovernanceKey = init_type {
460            println!("\nIf you defined a validator on-chain before initializing this separate governance subkey, you need to update its definition to use your new public governance key:\n");
461            println!("  governance_key = \"{}\"", config.governance_key());
462            println!("\nUntil you do this, your validator will not be able to vote on governance proposals, so it's best to do it at your earliest convenience.")
463        }
464
465        Ok(())
466    }
467}