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 #[clap(
25 long,
26 env = "PENUMBRA_NODE_PD_URL",
31 parse(try_from_str = Url::parse),
32 )]
33 grpc_url: Url,
34 #[clap(long, action)]
38 encrypted: bool,
39}
40
41#[derive(Debug, Clone, clap::Subcommand)]
42pub enum InitTopSubCmd {
43 #[clap(flatten)]
44 Spend(InitSubCmd),
45 #[clap(display_order = 200)]
47 ViewOnly {
48 full_viewing_key: String,
50 },
51 #[clap(subcommand, display_order = 300)]
54 ValidatorGovernanceSubkey(InitSubCmd),
55 #[clap(display_order = 900)]
57 UnsafeWipe {},
58}
59
60#[derive(Debug, Clone, clap::Subcommand)]
61pub enum InitSubCmd {
62 #[clap(subcommand, display_order = 100)]
64 SoftKms(SoftKmsInitCmd),
65 #[clap(subcommand, display_order = 150)]
67 Threshold(ThresholdInitCmd),
68 #[clap(skip, display_order = 200)]
71 ViewOnly { full_viewing_key: String },
72 #[clap(display_order = 800)]
74 ReEncrypt,
75}
76
77#[derive(Debug, Clone, clap::Subcommand)]
78pub enum SoftKmsInitCmd {
79 #[clap(display_order = 100)]
81 Generate {
82 #[clap(long, action)]
84 stdout: bool,
85 },
86 #[clap(display_order = 200)]
88 ImportPhrase {
89 #[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 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 Deal {
166 #[clap(short, long)]
168 threshold: u16,
169 #[clap(long, value_delimiter = ' ', multiple_values = true)]
175 home: Vec<Utf8PathBuf>,
176 },
177 Dkg {
179 #[clap(short, long)]
181 threshold: u16,
182 #[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 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#[derive(Clone, Debug, Copy)]
257enum InitType {
258 SpendKey,
260 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}