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;