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 #[clap(
27 long,
28 env = "PENUMBRA_NODE_PD_URL",
33 parse(try_from_str = Url::parse),
34 )]
35 grpc_url: Url,
36 #[clap(long, action)]
40 encrypted: bool,
41}
42
43#[derive(Debug, Clone, clap::Subcommand)]
44pub enum InitTopSubCmd {
45 #[clap(flatten)]
46 Spend(InitSubCmd),
47 #[clap(display_order = 200)]
49 ViewOnly {},
50 #[clap(subcommand, display_order = 300)]
53 ValidatorGovernanceSubkey(InitSubCmd),
54 #[clap(display_order = 900)]
56 UnsafeWipe {},
57}
58
59#[derive(Debug, Clone, clap::Subcommand)]
60pub enum InitSubCmd {
61 #[clap(subcommand, display_order = 100)]
63 SoftKms(SoftKmsInitCmd),
64 #[clap(subcommand, display_order = 150)]
66 Threshold(ThresholdInitCmd),
67 #[clap(skip, display_order = 200)]
70 ViewOnly,
71 #[cfg(feature = "ledger")]
73 #[clap(display_order = 250)]
74 Ledger,
75 #[clap(display_order = 800)]
77 ReEncrypt,
78}
79
80#[derive(Debug, Clone, clap::Subcommand)]
81pub enum SoftKmsInitCmd {
82 #[clap(display_order = 100)]
84 Generate {
85 #[clap(long, action)]
87 stdout: bool,
88 },
89 #[clap(display_order = 200)]
91 ImportPhrase {
92 #[clap(long, action)]
98 legacy_raw_bip39_derivation: bool,
99 },
100}
101
102fn prompt_for_password(msg: &str) -> Result<String> {
104 let mut password = String::new();
105 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 Deal {
174 #[clap(short, long)]
176 threshold: u16,
177 #[clap(long, value_delimiter = ' ', multiple_values = true)]
183 home: Vec<Utf8PathBuf>,
184 },
185 Dkg {
187 #[clap(short, long)]
189 threshold: u16,
190 #[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 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#[derive(Clone, Debug, Copy)]
265enum InitType {
266 SpendKey,
268 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}