Skip to main content

decdn_cli/commands/
whoami.rs

1//! `decdn whoami` — print the local identity read-only.
2//!
3//! Reports the persistent identity under the data dir and generates, stages,
4//! rotates, and overwrites nothing.
5//!
6//! Two identities can share one data dir, and the command reports both:
7//!  - the **node** identity at the top level: the persisted `node.secret` iroh
8//!    key (its Ed25519 public key — the value a serving node logs as `remote`
9//!    on `cdn/client/v1`) and the eth keystore at `<data_dir>/keystore.json`.
10//!  - the **client** identity in the `client/` subdir: only its eth keystore at
11//!    `<data_dir>/client/keystore.json`. A client's iroh key is ephemeral (a
12//!    fresh key per fetch), so a client has no persistent node id to report. The
13//!    client line prints only when that keystore exists.
14//!
15//! An absent node key is a note, not an error: a client-only install has no
16//! `node.secret`, and the command still reports its client eth address. The
17//! node id and paths print first and unconditionally, so the command stays
18//! useful for the node-id diagnosis even when no password is at hand.
19//!
20//! An `eth address:` line always prints; only the decrypted address *value* is
21//! gated by a password. The address lives inside the encrypted keystore, so it
22//! shows only when a keystore password is available (the
23//! `DECDN_KEYSTORE_PASSWORD` env var, a `--keystore-password-file`, or an
24//! interactive prompt on a TTY); otherwise the line is a note (no keystore, or a
25//! password is needed).
26//!
27//! One resolved password is tried against whichever keystores are present, and
28//! the two can hold different ones — `key-gen` writes them at different times
29//! and prompts for each. A keystore that does not open degrades its own line to
30//! a note naming the reason; every other line still prints, and the command
31//! still exits 0. On a TTY the failing keystore gets one more prompt of its own
32//! before the note.
33
34use std::path::{Path, PathBuf};
35
36use decdn_common::{cli, identity};
37use decdn_incentive::eth_identity::{self, PasswordSource, PasswordUse};
38use zeroize::Zeroizing;
39
40/// Whether a keystore file is there to decrypt. A stat error other than
41/// `NotFound` is neither `Present` nor `Absent` — it propagates as a command
42/// error rather than being silently reported as "no keystore".
43enum KeystoreState {
44    /// A keystore file exists at the path.
45    Present,
46    /// No keystore file exists (the path is definitively `NotFound`).
47    Absent,
48}
49
50/// How the keystore password was resolved before printing the eth address.
51enum KeystorePassword {
52    /// A password source was present and supplied this value.
53    Supplied(Zeroizing<String>),
54    /// No source was present (no env var, no readable file, and stdin is not a
55    /// TTY to prompt on), so the eth address cannot be decrypted.
56    Unavailable,
57}
58
59/// Print the local identity read-only: node id, key paths, and eth addresses.
60///
61/// The data directory resolves with the same precedence the daemon uses, so
62/// `whoami` reports the paths the running node actually reads: an explicit
63/// `--output-dir` wins, then `identity.data_dir` from the config file (the
64/// global `--config`, or the default `~/.decdn/node.toml`), and finally the
65/// `~/.decdn` default. Without the config-file step, a node whose `data_dir` is
66/// set in `node.toml` — the common service layout, where the `decdn` user's home
67/// is the data dir itself — would report a phantom `~/.decdn` path the node
68/// never uses.
69///
70/// The config file is read only when `--output-dir` is absent: the flag wins
71/// outright, so an explicit `--output-dir` must not fail on an unrelated broken
72/// `node.toml` it never consults.
73pub fn whoami(args: &cli::WhoamiArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
74    let data_dir = resolve_data_dir(args.output_dir.as_deref(), config_path)?
75        .ok_or_else(|| anyhow::anyhow!("cannot determine data directory: home dir not found"))?;
76
77    for line in report(&data_dir, args.keystore_password_file.as_deref())? {
78        println!("{line}");
79    }
80    Ok(())
81}
82
83/// Resolve the data directory with the daemon's precedence: an explicit
84/// `--output-dir` first, then `identity.data_dir` from the config file, then the
85/// `~/.decdn` default. Returns `Ok(None)` only when every source is absent and
86/// the home directory cannot be found. A leading `~` in either explicit source
87/// is expanded, mirroring `resolve_identity_into`.
88///
89/// The config file is loaded (and parsed) only when `output_dir` is `None`, so a
90/// caller that passed `--output-dir` never surfaces a config-parse error for a
91/// file whose value the flag overrides anyway.
92fn resolve_data_dir(
93    output_dir: Option<&Path>,
94    config_path: Option<&Path>,
95) -> anyhow::Result<Option<PathBuf>> {
96    if let Some(dir) = output_dir {
97        return Ok(Some(cli::common::expand_tilde(dir)));
98    }
99    let file = decdn_common::config::load_file_config(config_path)?;
100    Ok(file
101        .identity
102        .and_then(|i| i.data_dir)
103        .map(|p| cli::common::expand_tilde(&p))
104        .or_else(cli::default_data_dir))
105}
106
107/// Build the read-only whoami report for `data_dir` as the lines to print.
108///
109/// The node id and paths come first, before any keystore password resolution
110/// that could prompt on a TTY: a stuck-fetch diagnosis needs the node id even
111/// when no password is at hand, so gating it behind the prompt would defeat the
112/// command's point. An absent node key is a note here, not an error — a
113/// client-only install has no persistent node key, and the client eth address
114/// still reports.
115///
116/// The password is resolved once, and only when a keystore is present, then
117/// tried against whichever keystores exist. A keystore the shared password does
118/// not open gets one prompt of its own on a TTY, then degrades to a note — the
119/// two keystores can legitimately hold different passwords (#2008), and one
120/// wrong password must not take the whole report down. Split from [`whoami`] so
121/// the ordering and the client/node sections are testable without capturing
122/// stdout.
123fn report(data_dir: &Path, password_file: Option<&Path>) -> anyhow::Result<Vec<String>> {
124    let mut lines = Vec::new();
125
126    let node_keystore = eth_identity::keystore_path(data_dir);
127    // The client identity is scoped to the `client/` subdir; only its eth
128    // keystore persists (the client iroh key is ephemeral, so no client node id).
129    let client_keystore = eth_identity::keystore_path(&data_dir.join("client"));
130    let key_path = identity::key_path(data_dir);
131
132    // Node id — read-only. A definitively-absent `node.secret` is a note; any
133    // other stat/load failure (insecure permissions, wrong size, IO) still
134    // propagates so a real problem is not hidden behind the note.
135    match std::fs::symlink_metadata(&key_path) {
136        Ok(_) => {
137            let secret = identity::load(data_dir)?;
138            lines.push(format!("node id: {}", secret.public()));
139            lines.push(format!("key path: {}", key_path.display()));
140        }
141        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
142            lines.push(format!(
143                "node id: (none — no node key at {}; run `decdn key-gen` to create one)",
144                key_path.display()
145            ));
146        }
147        Err(e) => {
148            return Err(anyhow::Error::new(e)
149                .context(format!("failed to stat node key at {}", key_path.display())));
150        }
151    }
152    lines.push(format!("keystore path: {}", node_keystore.display()));
153
154    // The eth addresses live inside the encrypted keystores, so they resolve
155    // last: a missing keystore or absent password degrades to a note, while a
156    // supplied password decrypts them. One password unlocks either keystore.
157    let node_state = keystore_state(&node_keystore)?;
158    let client_state = keystore_state(&client_keystore)?;
159    let password = if matches!(node_state, KeystoreState::Present)
160        || matches!(client_state, KeystoreState::Present)
161    {
162        resolve_password(password_file)?
163    } else {
164        KeystorePassword::Unavailable
165    };
166
167    lines.push(unlock_line(
168        &node_keystore,
169        &node_state,
170        &password,
171        "node eth keystore password",
172    ));
173    if matches!(client_state, KeystoreState::Present) {
174        lines.push(format!(
175            "client keystore path: {}",
176            client_keystore.display()
177        ));
178        lines.push(format!(
179            "client {}",
180            unlock_line(
181                &client_keystore,
182                &client_state,
183                &password,
184                "client eth keystore password",
185            )
186        ));
187    }
188
189    Ok(lines)
190}
191
192/// One keystore's `eth address:` line, with a second attempt when the shared
193/// password does not open it and a TTY is there to ask on.
194///
195/// The retry is what makes two keystores with two passwords reportable. The
196/// shared password is the common case and is tried first, so an install whose
197/// keystores share a password prompts once, as it would with no retry at all.
198///
199/// The first attempt's note goes to stderr before the prompt. Without it the
200/// prompt appears with no explanation — the report is buffered and printed
201/// after this returns — and an operator who set `DECDN_KEYSTORE_PASSWORD`
202/// cannot see why it was not used. It also keeps a failure no password can fix,
203/// such as a keystore at `0o644`, from reading as a password problem: the
204/// reason is on screen before anything is typed.
205///
206/// A failed retry, or no TTY to retry on, leaves a note — the reason is on the
207/// line either way, and the rest of the report is unaffected.
208fn unlock_line(
209    keystore: &Path,
210    state: &KeystoreState,
211    password: &KeystorePassword,
212    prompt_label: &str,
213) -> String {
214    let attempt = eth_address_line(keystore, state, password);
215    if !attempt.retryable || !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
216        return attempt.line;
217    }
218    eprintln!("warning: {}", attempt.line);
219    let sources = [PasswordSource::Prompt {
220        usage: PasswordUse::Unlock,
221    }];
222    match super::chain_ctx::read_keystore_password(&sources, prompt_label) {
223        Ok(resolved) => {
224            let retry = KeystorePassword::Supplied(resolved.into_secret());
225            eth_address_line(keystore, state, &retry).line
226        }
227        // A prompt that cannot be read is not a reason to lose the report. Say
228        // that it was the terminal, though: the first attempt's note blames
229        // "this password", and after a prompt an operator reads that as the one
230        // they just typed.
231        Err(err) => format!("{} [no second attempt: {}]", attempt.line, err.root_cause()),
232    }
233}
234
235/// Classify the keystore path without letting a non-`NotFound` stat error read
236/// as "no keystore": `symlink_metadata` (not [`Path::exists`], which collapses
237/// every error to `false`) so a permission or IO problem surfaces instead of
238/// being mistaken for an absent file.
239fn keystore_state(keystore: &Path) -> anyhow::Result<KeystoreState> {
240    match std::fs::symlink_metadata(keystore) {
241        Ok(_) => Ok(KeystoreState::Present),
242        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(KeystoreState::Absent),
243        Err(e) => Err(anyhow::Error::new(e).context(format!(
244            "failed to stat eth keystore at {}",
245            keystore.display()
246        ))),
247    }
248}
249
250/// Resolve the keystore password from the standard sources, but only when one
251/// is actually present — otherwise `read_password` would error on exhausted
252/// sources, which must instead degrade to a note on the eth line.
253fn resolve_password(password_file: Option<&Path>) -> anyhow::Result<KeystorePassword> {
254    let sources = super::chain_ctx::password_sources(password_file, PasswordUse::Unlock);
255    if password_source_present(&sources) {
256        Ok(KeystorePassword::Supplied(
257            super::chain_ctx::read_keystore_password(&sources, "eth keystore password")?
258                .into_secret(),
259        ))
260    } else {
261        Ok(KeystorePassword::Unavailable)
262    }
263}
264
265/// True when at least one source would supply a value without further input we
266/// do not have: the env var is set, a password file is there, or stdin is a TTY
267/// the prompt can read from. Mirrors the presence rule
268/// [`eth_identity::read_password`] applies, so a `true` here means that call
269/// will not exhaust its sources.
270fn password_source_present(sources: &[PasswordSource]) -> bool {
271    sources.iter().any(|source| match source {
272        // `var_os`, not `var`: a set-but-non-UTF-8 value is still present, and
273        // `read_password` surfaces the UTF-8 error rather than silently skipping.
274        PasswordSource::Env(name) => std::env::var_os(name).is_some(),
275        // Present unless the path is definitively `NotFound`: a permission/IO
276        // error counts as present so `read_password` surfaces it rather than
277        // silently skipping the source, matching its own File handling.
278        PasswordSource::File(path) => {
279            !matches!(std::fs::symlink_metadata(path), Err(e) if e.kind() == std::io::ErrorKind::NotFound)
280        }
281        PasswordSource::Prompt { .. } => std::io::IsTerminal::is_terminal(&std::io::stdin()),
282    })
283}
284
285/// One attempt at an eth-address line, and whether another password could
286/// still change the answer.
287struct EthLine {
288    /// The line to print — an address, or a note saying why there is none.
289    line: String,
290    /// True when a keystore is present and the password tried did not open it.
291    /// Only then is a second prompt worth the operator's time.
292    retryable: bool,
293}
294
295/// Format the eth-address line: decrypt the keystore when a password was
296/// supplied, report it absent when there is no keystore, and otherwise note
297/// that a password is needed.
298///
299/// A keystore that does not decrypt is a note, not an error. The node and
300/// client keystores are written at different times under separate prompts, so
301/// one password failing on one of them says nothing about the other, and
302/// aborting would print neither (#2008). The failure text rides on the line, so
303/// a corrupt file is as visible as a wrong password.
304///
305/// Split from [`whoami`] so the decrypt branch and the notes are testable
306/// without touching the environment or a TTY.
307fn eth_address_line(
308    keystore: &Path,
309    state: &KeystoreState,
310    password: &KeystorePassword,
311) -> EthLine {
312    let note = |line: String| EthLine {
313        line,
314        retryable: false,
315    };
316    match state {
317        KeystoreState::Absent => note(format!(
318            "eth address: (no keystore at {})",
319            keystore.display()
320        )),
321        KeystoreState::Present => match password {
322            KeystorePassword::Unavailable => note(format!(
323                "eth address: (keystore present at {}; set DECDN_KEYSTORE_PASSWORD or pass \
324                 --keystore-password-file to show it)",
325                keystore.display()
326            )),
327            KeystorePassword::Supplied(pw) => {
328                match eth_identity::load_signer(keystore, pw.as_str()) {
329                    Ok(signer) => note(format!("eth address: {}", signer.address())),
330                    Err(err) => EthLine {
331                        // The root cause, not the whole chain: every wrapping
332                        // layer repeats this path, and the innermost message is
333                        // the one that separates a wrong password from a
334                        // keystore that is broken.
335                        line: format!(
336                            "eth address: (keystore at {} did not open with this password: {})",
337                            keystore.display(),
338                            err.root_cause()
339                        ),
340                        retryable: true,
341                    },
342                }
343            }
344        },
345    }
346}
347
348#[cfg(test)]
349#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // tests
350mod tests;