pcli/command/
init.rs

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