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