Skip to main content

decdn_incentive/
eth_identity.rs

1//! Ethereum keystore: generate, persist, and load a Web3 Secret Storage v3
2//! file into a live `alloy::signers::local::PrivateKeySigner`.
3//!
4//! Lives in `decdn-incentive` (per `appendix-poc-production-seams.md`
5//! §Seam 1) because the keystore surface is shared between nodes (slash
6//! signing, on-chain settlement) and clients (voucher signing per ADR 003
7//! §EIP-712 Voucher Signature).
8//!
9//! Same security model as [`decdn_common::identity`] — the keystore file is
10//! encrypted at rest, but we still enforce `0o600` on the file and `0o700`
11//! on `data_dir` (defense-in-depth: a leaked ciphertext lowers the bar to an
12//! offline KDF attack, so we delegate `data_dir` setup to
13//! [`decdn_common::identity::ensure_data_dir`] for permission parity).
14//!
15//! Atomic write: encrypted into a temp filename via alloy's
16//! `LocalSigner::encrypt_keystore`, then chmodded `0o600` and renamed into
17//! `keystore.json`. The encryption KDF (scrypt by default) takes hundreds of
18//! milliseconds; runtime callers must wrap [`load_signer`] in
19//! `tokio::task::spawn_blocking`.
20
21use std::fs;
22use std::io::IsTerminal;
23use std::path::{Path, PathBuf};
24
25use alloy::primitives::Address;
26use alloy::signers::k256::elliptic_curve::rand_core::OsRng;
27use alloy::signers::local::{LocalSigner, PrivateKeySigner};
28use anyhow::{Context, anyhow};
29use rand::Rng;
30use zeroize::Zeroizing;
31
32/// Process environment variable that supplies the keystore password.
33/// [`standard_sources`] places it first in the [`PasswordSource`] list, ahead
34/// of a password file and an interactive prompt.
35pub const KEYSTORE_PASSWORD_ENV: &str = "DECDN_KEYSTORE_PASSWORD";
36
37const KEYSTORE_FILE_NAME: &str = "keystore.json";
38/// Forbid any group/world bit on the keystore file. Encrypted at rest, but a
39/// readable ciphertext + a leaked or weak password is enough for offline
40/// dictionary attacks. Mirrors [`decdn_common::identity`]'s `0o077` mask.
41#[cfg(unix)]
42const FORBIDDEN_BITS: u32 = 0o077;
43#[cfg(unix)]
44const KEYSTORE_FILE_MODE: u32 = 0o600;
45
46/// What the caller is about to do with the keystore password, which is what
47/// decides whether the interactive prompt asks once or twice.
48///
49/// Each call site names which one it means, so the reason for the double entry
50/// travels with the argument.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PasswordUse {
53    /// The command CREATES the keystore. The prompt asks twice and requires the
54    /// entries to match: a password typed once has nothing to check it against,
55    /// so a typo would be encrypted into the file and stay undiscovered until
56    /// the next unlock fails, by which point the key is unrecoverable.
57    Create,
58    /// The command opens a keystore that already exists. One entry is enough —
59    /// a wrong password simply fails to decrypt, and says so immediately.
60    Unlock,
61}
62
63/// Source for the keystore password, in precedence order. The first source in
64/// the slice that is **present** wins, and it supplies its value as-is — the
65/// empty string included, because an empty password is a legitimate Web3 Secret
66/// Storage password. Presence, not emptiness, decides: a source that is not
67/// there falls through to the next entry.
68#[derive(Debug, Clone)]
69pub enum PasswordSource {
70    /// Process environment variable. Present when the variable is set; its
71    /// value is the password, empty included. An unset variable falls through;
72    /// a value that is not valid UTF-8 is an error, because the variable is
73    /// there and the operator meant it to be read.
74    Env(&'static str),
75    /// File whose contents are the password. A single trailing `\n` (or
76    /// `\r\n`) is stripped; other whitespace is part of the password. Present
77    /// when the file exists — an empty file is an empty password. A path that
78    /// does not exist falls through; any other read failure is an error.
79    File(PathBuf),
80    /// Interactive prompt via `rpassword`. Present when `stdin` is a TTY; a
81    /// non-TTY `stdin` falls through. An empty entry is an empty password.
82    /// [`PasswordUse::Create`] re-prompts and verifies the entries match.
83    Prompt {
84        /// What the password is for; see [`PasswordUse`]. `Create` prompts
85        /// twice and requires both entries to match, `Unlock` takes the single
86        /// entry as given.
87        usage: PasswordUse,
88    },
89}
90
91/// Which [`PasswordSource`] supplied the password that [`read_password`]
92/// returned. Lets a caller name the winning source in its own messages —
93/// `key-gen` uses it to refuse an empty password that came from the
94/// environment (a shell expanding an unset variable), and operator commands
95/// use it to say which source won.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum PasswordOrigin {
98    /// The environment variable named here supplied the password.
99    Env(&'static str),
100    /// This file supplied the password.
101    File(PathBuf),
102    /// An interactive terminal prompt supplied the password.
103    Prompt,
104}
105
106impl PasswordOrigin {
107    /// A human phrase naming the source, for operator-facing messages — "the
108    /// environment variable" followed by the variable name, "the password file"
109    /// followed by the path, or "an interactive prompt". Plain text with no
110    /// backticks, and never the password itself.
111    #[must_use]
112    pub fn describe(&self) -> String {
113        match self {
114            PasswordOrigin::Env(name) => format!("the environment variable {name}"),
115            PasswordOrigin::File(path) => format!("the password file {}", path.display()),
116            PasswordOrigin::Prompt => "an interactive prompt".to_owned(),
117        }
118    }
119}
120
121/// The outcome of [`read_password`]: the password, the source that won, and any
122/// operator-facing warnings about sources that were configured but not used.
123///
124/// The warnings exist because a mistyped `--keystore-password-file` on a TTY
125/// silently falls through to an interactive prompt — correct at the terminal,
126/// but a different mode than the headless unit the operator ultimately deploys.
127/// The caller decides where the warnings go: the CLI prints them to stderr
128/// (`decdn-incentive` cannot, it denies `print_stderr`), the daemon logs them
129/// through `tracing`.
130#[derive(Debug)]
131pub struct ResolvedPassword {
132    secret: Zeroizing<String>,
133    origin: PasswordOrigin,
134    warnings: Vec<String>,
135}
136
137impl ResolvedPassword {
138    /// The resolved password. Borrowed as `&str` for the common case of
139    /// passing it straight to [`load_signer`] or [`stage_keystore`].
140    #[must_use]
141    pub fn secret(&self) -> &str {
142        &self.secret
143    }
144
145    /// Whether the resolved password is the empty string.
146    #[must_use]
147    pub fn is_empty(&self) -> bool {
148        self.secret.is_empty()
149    }
150
151    /// Which source supplied the password.
152    #[must_use]
153    pub const fn origin(&self) -> &PasswordOrigin {
154        &self.origin
155    }
156
157    /// Operator-facing warnings about configured-but-unused sources, in source
158    /// order. Empty in the common case. Each is a complete sentence with no
159    /// `warning:` prefix and never contains the password.
160    #[must_use]
161    pub fn warnings(&self) -> &[String] {
162        &self.warnings
163    }
164
165    /// Consume the outcome and yield the password, still zeroized on drop. Use
166    /// when the secret must be moved (e.g. into a `spawn_blocking` closure)
167    /// after the warnings and origin have been inspected.
168    #[must_use]
169    pub fn into_secret(self) -> Zeroizing<String> {
170        self.secret
171    }
172}
173
174/// The keystore password sources every binary consults, in precedence order:
175/// the `DECDN_KEYSTORE_PASSWORD` env var, then `password_file` when the
176/// caller passed one, then an interactive prompt on a TTY. Presence decides
177/// at each step (see [`read_password`]), so an env var set to the empty
178/// string is the password rather than a skipped source.
179///
180/// `usage` reaches the [`PasswordSource::Prompt`] entry and nothing else:
181/// [`PasswordUse::Create`] makes that prompt ask twice and require a match,
182/// [`PasswordUse::Unlock`] makes it ask once. A password arriving from the env
183/// var or the file is used as given under either.
184///
185/// `password_file` is used as given; no expansion happens here. Tilde
186/// expansion is the caller's concern. The CLI expands at its boundary
187/// (`chain_ctx::password_sources`), the daemon resolves it in `decdn-common`
188/// config resolution.
189pub fn standard_sources(password_file: Option<PathBuf>, usage: PasswordUse) -> Vec<PasswordSource> {
190    let mut sources = vec![PasswordSource::Env(KEYSTORE_PASSWORD_ENV)];
191    if let Some(path) = password_file {
192        sources.push(PasswordSource::File(path));
193    }
194    sources.push(PasswordSource::Prompt { usage });
195    sources
196}
197
198/// Path of the persistent Ethereum keystore within `data_dir`.
199pub fn keystore_path(data_dir: &Path) -> PathBuf {
200    data_dir.join(KEYSTORE_FILE_NAME)
201}
202
203/// Generate a fresh secp256k1 keypair, encrypt it as a Web3 Secret Storage v3
204/// JSON keystore, and persist it to `<data_dir>/keystore.json` with mode
205/// `0o600`. Returns the EIP-55 `Address` derived from the new key.
206///
207/// A thin wrapper over [`stage_keystore`] + [`StagedKeystore::commit`]: all the
208/// generation (RNG, encryption, chmod) happens before any existing keystore is
209/// archived, so an encryption failure leaves the prior `keystore.json` in place.
210///
211/// # Errors
212///
213/// Returns an error if `data_dir` exists with insecure permissions; if the
214/// keystore file already exists and `force == false`; or if encryption,
215/// chmod, or the atomic rename fails. The temp file is cleaned up on any
216/// failure path; partial writes never accumulate in `data_dir`.
217pub fn generate_and_persist(
218    data_dir: &Path,
219    password: &str,
220    force: bool,
221) -> anyhow::Result<Address> {
222    let target = keystore_path(data_dir);
223    if !force && target.exists() {
224        anyhow::bail!(
225            "eth keystore already exists at {}; pass --force to overwrite",
226            target.display()
227        );
228    }
229    let staged = stage_keystore(data_dir, password)?;
230    let address = staged.address();
231    // Commit archives any existing keystore (the `force` case) and renames the
232    // staged temp into place. The bak path is dropped here; the `decdn key-gen`
233    // CLI uses the staged API directly to log it.
234    staged.commit()?;
235    Ok(address)
236}
237
238/// A freshly-generated, fully-encrypted keystore written to a temp file in
239/// `data_dir`, not yet committed to `keystore.json`.
240///
241/// Staging (generate + encrypt + chmod the temp) is separated from committing
242/// (archive-old + rename) so a multi-file key rotation can generate **all** key
243/// material before touching any canonical file — an encryption failure then
244/// leaves the prior keystore (and the sibling `node.secret`) untouched rather
245/// than half-rotated (#844). Produced by [`stage_keystore`]; finished with
246/// [`Self::commit`].
247///
248/// Dropping a [`StagedKeystore`] without committing removes the temp file, so an
249/// abandoned stage never litters `data_dir`.
250#[must_use = "a staged keystore must be committed (or it is discarded on drop)"]
251// `derive(Debug)` is safe only because every field is non-secret: the secret key
252// is already encrypted into the temp file and `key_bytes` is zeroed in
253// `stage_keystore`. If a secret field is ever added, hand-write `Debug` to omit
254// it (as `StagedNodeKey` does).
255#[derive(Debug)]
256pub struct StagedKeystore {
257    tmp: PathBuf,
258    target: PathBuf,
259    address: Address,
260    committed: bool,
261}
262
263impl StagedKeystore {
264    /// The EIP-55 address derived from the staged key, for logging before the
265    /// commit lands.
266    #[must_use]
267    pub const fn address(&self) -> Address {
268        self.address
269    }
270
271    /// Commit the staged keystore: archive any existing `keystore.json` (so the
272    /// operator key-rotation runbook keeps the prior ciphertext) and atomically
273    /// rename the temp into place.
274    ///
275    /// Returns the archive path if a prior keystore was moved aside, or `None`
276    /// when there was nothing to archive (fresh install).
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if archiving the prior keystore or the install rename
281    /// fails. The commit is **fail-safe** via
282    /// [`decdn_common::identity::install_staged`]: if the install rename fails
283    /// after the prior keystore was archived, the archive is restored
284    /// (best-effort) so the old keystore stays live, and the error states whether
285    /// the restore succeeded or `keystore.json` is now missing. On any error the
286    /// staged temp is removed by the `Drop` handler (`committed` stays `false`).
287    pub fn commit(mut self) -> anyhow::Result<Option<PathBuf>> {
288        // Archiving (inside `install_staged`) keeps the prior ciphertext rather
289        // than destroying it: the operator key-rotation runbook
290        // (`appendix-operator-key-rotation.md` §5) relies on rollback to the prior
291        // key, and the offline-archive requirement applies symmetrically here.
292        let bak = decdn_common::identity::install_staged(&self.tmp, &self.target)?;
293        self.committed = true;
294        Ok(bak)
295    }
296}
297
298impl Drop for StagedKeystore {
299    fn drop(&mut self) {
300        if !self.committed {
301            // Best-effort: an abandoned stage must not leave a keystore behind.
302            let _ = fs::remove_file(&self.tmp);
303        }
304    }
305}
306
307/// Generate a fresh secp256k1 keypair and encrypt it into a temp keystore file
308/// in `data_dir` (mode `0o600` on Unix) **without** touching `keystore.json`.
309/// Validates or securely creates `data_dir` with the same `0o700` semantics as
310/// [`decdn_common::identity::load_or_create`].
311///
312/// The returned [`StagedKeystore`] is committed via [`StagedKeystore::commit`].
313/// Used by `decdn key-gen` so the keystore and the node key are both generated
314/// before either canonical file is replaced (#844).
315///
316/// # Errors
317///
318/// Returns an error if `data_dir` cannot be validated or created, or if
319/// encryption or the chmod fails. The temp file is cleaned up on any failure
320/// path; partial writes never accumulate in `data_dir`.
321pub fn stage_keystore(data_dir: &Path, password: &str) -> anyhow::Result<StagedKeystore> {
322    // Validate (or create+validate) `data_dir` with the same semantics as
323    // `identity::load_or_create`: shared via the public helper so the
324    // node.secret and keystore.json paths stay in lock-step.
325    decdn_common::identity::ensure_data_dir(data_dir)?;
326
327    // Alloy's `encrypt_keystore` writes the file in-place inside `data_dir`
328    // with the given name. Use a unique temp name + chmod so we end with a
329    // properly-permissioned file that the caller renames into place.
330    let temp_name = temp_filename();
331
332    // 32 cryptographically random bytes for the secp256k1 secret. We sample
333    // via `rand::rng()` (workspace rand 0.10) and feed alloy's `OsRng`
334    // (rand_core 0.6, the version alloy-signer-local 2.0 expects in its
335    // `Rng + CryptoRng` bound) for the keystore's KDF salt + IV. Two
336    // separate RNGs is fine — both are CSPRNGs backed by the OS entropy
337    // pool.
338    let mut key_bytes = [0u8; 32];
339    rand::rng().fill_bytes(&mut key_bytes);
340
341    let result: anyhow::Result<StagedKeystore> = (|| {
342        let mut alloy_rng = OsRng;
343        let (signer, _emitted_name) = PrivateKeySigner::encrypt_keystore(
344            data_dir,
345            &mut alloy_rng,
346            key_bytes,
347            password,
348            Some(&temp_name),
349        )
350        .with_context(|| format!("failed to encrypt eth keystore at {}", data_dir.display()))?;
351
352        let temp_path = data_dir.join(&temp_name);
353        // alloy's `encrypt_keystore` writes the temp file with default umask
354        // perms (typically ~0o644). The chmod below tightens it before the
355        // commit's rename. The window is bounded by `data_dir = 0o700`, so only
356        // the owner can traverse the directory — equivalent to legitimate-user
357        // access.
358        chmod_keystore_file(&temp_path)?;
359
360        Ok(StagedKeystore {
361            tmp: temp_path,
362            target: keystore_path(data_dir),
363            address: signer.address(),
364            committed: false,
365        })
366    })();
367
368    if result.is_err() {
369        // Best-effort cleanup. The real error is already in flight; if
370        // cleanup also fails (e.g. permission denied on a broken setup),
371        // surfacing both would obscure the root cause.
372        let _ = fs::remove_file(data_dir.join(&temp_name));
373    }
374
375    // Wipe the in-memory key material we don't need anymore. Not a hard
376    // guarantee against a sufficiently determined attacker (the optimizer
377    // can elide writes to soon-to-be-dropped buffers), but cheap and
378    // useful against casual heap inspection.
379    key_bytes.fill(0);
380
381    result
382}
383
384/// Decrypt the keystore at `path` with `password` and return a live signer.
385///
386/// **Blocks** for the duration of the KDF (scrypt or argon2 — typically
387/// hundreds of milliseconds, configurable up to multiple seconds). Async
388/// callers must wrap this in [`tokio::task::spawn_blocking`].
389///
390/// # Errors
391///
392/// Returns an error if `path` is not a regular file, has insecure
393/// permissions (group/world bits set), is missing, or contains a JSON that
394/// is not a valid v3 keystore decryptable with `password`. The error
395/// context never echoes the password.
396pub fn load_signer(path: &Path, password: &str) -> anyhow::Result<PrivateKeySigner> {
397    validate_keystore_file(path)?;
398    LocalSigner::decrypt_keystore(path, password)
399        .with_context(|| format!("failed to decrypt eth keystore at {}", path.display()))
400}
401
402/// Resolve a password from the first source that is present. An unset `Env`, a
403/// `File` path that does not exist, and a `Prompt` without a TTY fall through to
404/// the next entry; a source that is present supplies its value, the empty string
405/// included. When every source falls through, the error lists why each one did.
406///
407/// Returns a [`ResolvedPassword`]: the secret, which source won, and any
408/// warnings about configured-but-unused sources for the caller to surface. The
409/// secret is held in a `Zeroizing<String>` so it is overwritten in memory on
410/// drop — defense-in-depth against post-mortem heap inspection. The `String`
411/// itself is still subject to allocator reuse, but the wrapper guarantees the
412/// bytes are scrubbed before that reuse becomes possible.
413///
414/// # Errors
415///
416/// - `Env` set to a value that is not valid UTF-8.
417/// - A `File` that exists but cannot be read: permissions, a directory, or
418///   contents that are not valid UTF-8.
419/// - `Prompt` when the terminal read itself fails.
420/// - `Prompt` with mismatched confirmations after 3 attempts.
421/// - All sources exhausted without a present one.
422pub fn read_password(
423    sources: &[PasswordSource],
424    prompt_label: &str,
425) -> anyhow::Result<ResolvedPassword> {
426    // Every skipped source is reported, not just the last: a mistyped
427    // `--keystore-password-file` falls through, and if only the final skip survived
428    // (`stdin is not a TTY`) the path the operator got wrong would never reach
429    // them.
430    let mut skipped: Vec<String> = Vec::new();
431    for (idx, source) in sources.iter().enumerate() {
432        let won: Option<(Zeroizing<String>, PasswordOrigin)> = match source {
433            PasswordSource::Env(name) => match std::env::var(name) {
434                // A set variable is a deliberate value, so an empty one is an
435                // empty password rather than a reason to try the next source.
436                Ok(value) => Some((Zeroizing::new(value), PasswordOrigin::Env(name))),
437                Err(std::env::VarError::NotPresent) => {
438                    skipped.push(format!("env {name} unset"));
439                    None
440                }
441                // Set but unreadable is a misconfiguration, not an absent
442                // source. The message never echoes the value — it is a password.
443                Err(std::env::VarError::NotUnicode(_)) => {
444                    return Err(anyhow!("env {name} is set but is not valid UTF-8"));
445                }
446            },
447            PasswordSource::File(path) => match read_password_file(path) {
448                Ok(Some(value)) => Some((value, PasswordOrigin::File(path.clone()))),
449                Ok(None) => {
450                    skipped.push(format!(
451                        "password file {} not found (missing path or broken symlink)",
452                        path.display()
453                    ));
454                    None
455                }
456                Err(e) => return Err(e),
457            },
458            PasswordSource::Prompt { usage } => {
459                if std::io::stdin().is_terminal() {
460                    Some((
461                        prompt_password(prompt_label, *usage)?,
462                        PasswordOrigin::Prompt,
463                    ))
464                } else {
465                    skipped.push("stdin is not a TTY".to_owned());
466                    None
467                }
468            }
469        };
470        if let Some((secret, origin)) = won {
471            let warnings = unused_file_warnings(sources, idx, &origin);
472            return Ok(ResolvedPassword {
473                secret,
474                origin,
475                warnings,
476            });
477        }
478    }
479    let why = if skipped.is_empty() {
480        "no sources configured".to_owned()
481    } else {
482        skipped.join("; ")
483    };
484    Err(anyhow!("no keystore password source available ({why})"))
485}
486
487/// Build the operator warnings for a successful resolve: a `File` source the
488/// caller configured but that did not win is worth a word, because the usual
489/// reason is a typo.
490///
491/// Two shapes, told apart by position relative to the winner at `win_idx`:
492/// a file *before* the winner was reached and fell through as not-found (so a
493/// later source quietly won — the 3am-under-systemd trap); a file *after* the
494/// winner was never consulted because a higher-precedence source was present
495/// (so the flag is inert). A file read fatally (permissions, a directory)
496/// never reaches here — that path returns an error instead.
497fn unused_file_warnings(
498    sources: &[PasswordSource],
499    win_idx: usize,
500    origin: &PasswordOrigin,
501) -> Vec<String> {
502    let winner = origin.describe();
503    let mut warnings = Vec::new();
504    for (idx, source) in sources.iter().enumerate() {
505        let PasswordSource::File(path) = source else {
506            continue;
507        };
508        match idx.cmp(&win_idx) {
509            // The file itself won; nothing to warn about.
510            std::cmp::Ordering::Equal => {}
511            // Reached and fell through as not-found; a later source won.
512            std::cmp::Ordering::Less => warnings.push(format!(
513                "password file {} not found; used {winner} instead",
514                path.display()
515            )),
516            // Never consulted because a higher-precedence source was present.
517            std::cmp::Ordering::Greater => warnings.push(format!(
518                "password file {} was not used; {winner} took precedence",
519                path.display()
520            )),
521        }
522    }
523    warnings
524}
525
526/// Read `path` as a password. `Ok(None)` means the file is not there, which is
527/// an absent source; `Ok(Some(_))` carries the file's contents with a single
528/// trailing `\n` or `\r\n` removed, the empty string included.
529fn read_password_file(path: &Path) -> anyhow::Result<Option<Zeroizing<String>>> {
530    let raw = match fs::read_to_string(path) {
531        Ok(raw) => Zeroizing::new(raw),
532        // A path that is not there is an absent source and falls through. Every
533        // other read failure (unreadable, a directory) names a real mistake, so
534        // it stays fatal rather than silently selecting another source.
535        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
536        Err(e) => {
537            return Err(anyhow::Error::new(e))
538                .with_context(|| format!("failed to read password file {}", path.display()));
539        }
540    };
541    // Strip a single trailing `\n` (or `\r\n`). `String::trim_end` would also
542    // eat trailing spaces — passwords legitimately contain whitespace, so we
543    // strip exactly the one newline that nearly every editor and `echo`
544    // appends.
545    let trimmed = raw.strip_suffix("\r\n").or_else(|| raw.strip_suffix('\n'));
546    Ok(Some(Zeroizing::new(
547        trimmed.map_or(raw.as_str(), |s| s).to_owned(),
548    )))
549}
550
551#[expect(
552    clippy::print_stderr,
553    reason = "interactive terminal prompt; there is no subscriber to route this to"
554)]
555fn prompt_password(label: &str, usage: PasswordUse) -> anyhow::Result<Zeroizing<String>> {
556    const MAX_ATTEMPTS: u8 = 3;
557    let mut attempts: u8 = 0;
558    loop {
559        attempts = attempts.saturating_add(1);
560        let pw = Zeroizing::new(
561            rpassword::prompt_password(format!("{label}: "))
562                .with_context(|| "failed to read password from terminal")?,
563        );
564        // Matched, not compared, so a variant added later fails the build here
565        // rather than inheriting `Create`'s double entry by default.
566        match usage {
567            PasswordUse::Unlock => return Ok(pw),
568            PasswordUse::Create => {}
569        }
570        let again = Zeroizing::new(
571            rpassword::prompt_password(format!("{label} (confirm): "))
572                .with_context(|| "failed to read password confirmation from terminal")?,
573        );
574        if *pw == *again {
575            return Ok(pw);
576        }
577        if attempts >= MAX_ATTEMPTS {
578            return Err(anyhow!(
579                "passwords did not match after {MAX_ATTEMPTS} attempts"
580            ));
581        }
582        eprintln!("passwords did not match; please try again");
583    }
584}
585
586#[cfg(unix)]
587fn validate_keystore_file(path: &Path) -> anyhow::Result<()> {
588    use std::io::Read;
589    use std::os::unix::fs::MetadataExt;
590    let meta = fs::symlink_metadata(path)
591        .with_context(|| format!("invalid eth keystore: cannot access {}", path.display()))?;
592    anyhow::ensure!(
593        meta.file_type().is_file(),
594        "invalid eth keystore: {} is not a regular file",
595        path.display()
596    );
597    let mode = meta.mode() & 0o777;
598    anyhow::ensure!(
599        mode & FORBIDDEN_BITS == 0,
600        "invalid eth keystore: {} has insecure permissions {:#o} \
601         (must be user-only, e.g. {:#o})",
602        path.display(),
603        mode,
604        KEYSTORE_FILE_MODE
605    );
606    // Prove read permission early: alloy's `decrypt_keystore` would also
607    // surface this, but its error mentions the JSON parser, not the open
608    // step. A direct check produces a clearer diagnostic for operators.
609    let mut probe = fs::File::open(path)
610        .with_context(|| format!("invalid eth keystore: cannot open {}", path.display()))?;
611    let mut byte = [0u8; 1];
612    let _ = probe.read(&mut byte).with_context(|| {
613        format!(
614            "invalid eth keystore: cannot read first byte of {}",
615            path.display()
616        )
617    })?;
618    Ok(())
619}
620
621#[cfg(not(unix))]
622fn validate_keystore_file(path: &Path) -> anyhow::Result<()> {
623    let meta = fs::symlink_metadata(path)
624        .with_context(|| format!("invalid eth keystore: cannot access {}", path.display()))?;
625    anyhow::ensure!(
626        meta.file_type().is_file(),
627        "invalid eth keystore: {} is not a regular file",
628        path.display()
629    );
630    Ok(())
631}
632
633#[cfg(unix)]
634fn chmod_keystore_file(path: &Path) -> anyhow::Result<()> {
635    use std::os::unix::fs::PermissionsExt;
636    fs::set_permissions(path, fs::Permissions::from_mode(KEYSTORE_FILE_MODE))
637        .with_context(|| format!("failed to chmod {} to 0o600", path.display()))
638}
639
640#[cfg(not(unix))]
641#[allow(
642    clippy::unnecessary_wraps,
643    reason = "the Unix variant can fail; the shared call sites take a Result"
644)]
645#[allow(
646    clippy::missing_const_for_fn,
647    reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
648)]
649fn chmod_keystore_file(_path: &Path) -> anyhow::Result<()> {
650    // No POSIX mode bits on Windows; relying on the parent dir / NTFS ACLs.
651    Ok(())
652}
653
654fn temp_filename() -> String {
655    let mut salt = [0u8; 4];
656    rand::rng().fill_bytes(&mut salt);
657    format!(
658        "{KEYSTORE_FILE_NAME}.tmp.{:08x}{:02x}{:02x}{:02x}{:02x}",
659        std::process::id(),
660        salt[0],
661        salt[1],
662        salt[2],
663        salt[3],
664    )
665}
666
667#[cfg(test)]
668#[allow(
669    clippy::unwrap_used,
670    clippy::expect_used,
671    clippy::panic,
672    clippy::indexing_slicing
673)]
674mod tests;