decdn_cli/commands/key_gen.rs
1//! `decdn key-gen` — write or refresh the persistent Ed25519 node key and
2//! the Ethereum keystore.
3
4use anyhow::Context;
5use decdn_common::{cli, identity};
6use decdn_incentive::eth_identity::{self, PasswordUse};
7
8/// Generate (or reuse) the persistent Ed25519 node key, and a fresh
9/// Ethereum keystore alongside it.
10pub fn key_gen(args: &cli::KeyGenArgs) -> anyhow::Result<()> {
11 let output_dir = args
12 .output_dir
13 .as_deref()
14 .map(cli::common::expand_tilde)
15 .or_else(cli::default_data_dir)
16 .ok_or_else(|| anyhow::anyhow!("cannot determine output directory: home dir not found"))?;
17
18 let key_path = identity::key_path(&output_dir);
19 let keystore_path = eth_identity::keystore_path(&output_dir);
20
21 // Pre-flight check: error before any writes if either target exists and
22 // --force was not passed. Halving the work into "check then act" keeps
23 // partial-success states out of the operator's data_dir.
24 if !args.force {
25 if key_path.exists() {
26 anyhow::bail!(
27 "node key already exists at {}; pass --force to overwrite",
28 key_path.display()
29 );
30 }
31 if keystore_path.exists() {
32 anyhow::bail!(
33 "eth keystore already exists at {}; pass --force to overwrite",
34 keystore_path.display()
35 );
36 }
37 }
38
39 // Source the keystore password before any disk writes — interactive
40 // prompts that abort (Ctrl-C, mismatch retries exhausted) shouldn't
41 // leave a half-written `node.secret` behind.
42 let resolved = super::chain_ctx::read_keystore_password(
43 &super::chain_ctx::password_sources(
44 args.keystore_password_file.as_deref(),
45 PasswordUse::Create,
46 ),
47 "eth keystore password",
48 )?;
49
50 // An empty password is representable on purpose (#1931), but CREATING a
51 // keystore with one is unverifiable from the operator's side: the wrong
52 // password is only caught at the next unlock, by which point the key is
53 // sealed. Loading needs no such care — a wrong password fails to decrypt at
54 // once.
55 //
56 // An empty password from the environment variable is refused (#1935): the
57 // usual cause is a shell expanding an unset variable (`$SOME_UNSET_VAR` ->
58 // "") into `DECDN_KEYSTORE_PASSWORD`, which then silently outranks a correct
59 // password file sitting right beside it. A deliberate empty password stays
60 // reachable by pointing `--keystore-password-file` at an empty file, an
61 // unambiguous act no shell produces by accident.
62 if resolved.is_empty() {
63 if let eth_identity::PasswordOrigin::Env(name) = resolved.origin() {
64 anyhow::bail!(
65 "refusing to create {} with an empty password from {}; an empty \
66 ${name} is usually an unset shell variable. To create an \
67 empty-password keystore on purpose, point --keystore-password-file \
68 at an empty file.",
69 keystore_path.display(),
70 resolved.origin().describe(),
71 );
72 }
73 eprintln!(
74 "warning: creating {} with an EMPTY password; \
75 anyone who can read the file can use the key",
76 keystore_path.display()
77 );
78 }
79 let password = resolved.into_secret();
80
81 // Two-phase rotation so a `--force` overwrite can't half-rotate the key
82 // pair (#844). STAGE both secrets first — all the expensive, failure-prone
83 // work (RNG, scrypt encryption, temp-file writes) lands in temp files and
84 // touches neither canonical file. Only once BOTH stages succeed do we
85 // COMMIT (archive-old + atomic rename), so a failure generating the second
86 // secret leaves the first untouched rather than mismatched. If
87 // `stage_keystore` fails, `staged_key` drops and its temp is cleaned up.
88 let mut staged_key = identity::stage_node_key(&output_dir)?;
89 let staged_keystore = eth_identity::stage_keystore(&output_dir, &password)?;
90
91 // Commit phase: only fast metadata ops (archive + atomic rename) run here —
92 // all key generation, encryption, and RNG already succeeded during staging,
93 // so the half-rotation hazard is bounded to this window. Existing files are
94 // archived rather than destroyed so the operator key-rotation runbook
95 // (`appendix-operator-key-rotation.md` §1 step 9 / §5 rollback) has the prior
96 // key material to fall back on.
97 //
98 // The window is narrow but NOT crash-only: a commit can still return an error
99 // (a `move_aside` archive collision, or a rename hitting ENOSPC/EXDEV/EACCES).
100 // If the node key commits and the keystore commit then fails, the pair is
101 // half-rotated; the keystore error below is annotated with that fact and the
102 // recovery path so the operator isn't misled into thinking nothing changed.
103 println!("node id: {}", staged_key.public());
104 // A node-key commit failure here is not half-rotated: the keystore hasn't been
105 // touched yet, and `commit` is fail-safe — on a failed install rename it
106 // restores the archived prior key in place (or, if that restore itself fails,
107 // its error names the `.bak` archive to restore manually). Either way the eth
108 // side is untouched, so we let it propagate as-is.
109 if let Some(bak) = staged_key.commit()? {
110 println!("archived previous node key -> {}", bak.display());
111 }
112 println!("wrote secret key to {}", key_path.display());
113
114 let address = staged_keystore.address();
115 let keystore_bak = staged_keystore.commit().context(
116 "the node key was already rotated but committing the new eth keystore failed — the key \
117 pair is now HALF-ROTATED. Restore node.secret from its `.bak` archive per \
118 appendix-operator-key-rotation.md §5, or re-run `decdn key-gen --force` once the cause \
119 (e.g. disk space) is resolved",
120 )?;
121 if let Some(bak) = keystore_bak {
122 println!("archived previous eth keystore -> {}", bak.display());
123 }
124 println!("eth address: {address}");
125 println!("wrote eth keystore to {}", keystore_path.display());
126
127 Ok(())
128}