Skip to main content

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}