Skip to main content

decdn_common/
identity.rs

1//! Node identity: load or generate a persistent ed25519 `SecretKey`.
2//!
3//! The key is stored as 32 raw bytes at `<data_dir>/node.secret`. On Unix the
4//! temp file is opened with mode `0600` from creation (no umask-window where
5//! the key material is world-readable) and atomically renamed into place.
6//!
7//! On Unix, permissions are validated on every load to block key-replacement
8//! attacks: `data_dir` must be a directory with no group or world permission
9//! bits (e.g. `0o700`), and `node.secret` must be a regular file with no group
10//! or world permission bits (e.g. `0o600`). Setuid/setgid/sticky bits are not
11//! inspected. A world- or group-writable `data_dir` would let any local
12//! process replace `node.secret` between load and use. On first run the
13//! directory is created with `0o700` via `DirBuilder::mode` (no umask window).
14//! Non-Unix platforms skip these checks — POSIX mode bits do not map
15//! meaningfully to NTFS ACLs.
16//!
17//! New keys are built via [`SecretKey::generate`], which draws from
18//! the auto-seeded thread-local CSPRNG backed by OS entropy.
19
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, anyhow};
24use iroh::{PublicKey, SecretKey, Signature};
25use rand::Rng;
26
27const KEY_FILE_NAME: &str = "node.secret";
28const KEY_LEN: usize = 32;
29
30/// Mode used when creating `data_dir` on first run.
31#[cfg(unix)]
32const DATA_DIR_MODE: u32 = 0o700;
33/// Any group or other bit set on `data_dir` or the key file is rejected. A
34/// single mask for both: key material must be owner-only, and so must its
35/// containing directory (a group-writable dir allows the same key-replacement
36/// attack as a world-writable one, just with a smaller attacker set).
37#[cfg(unix)]
38const FORBIDDEN_BITS: u32 = 0o077;
39
40/// Location of the persistent node key within `data_dir`.
41pub fn key_path(data_dir: &Path) -> PathBuf {
42    data_dir.join(KEY_FILE_NAME)
43}
44
45/// Load the node's `SecretKey` from disk **without** generating one when it is
46/// absent — the read-only counterpart of [`load_or_generate`], used by
47/// `decdn whoami` to report the existing identity without minting a new key.
48///
49/// Applies the same validation as [`load_or_create`]'s load path: `data_dir`
50/// must be a secure directory and `node.secret` a secure 32-byte regular file.
51///
52/// # Errors
53///
54/// Returns an error if `node.secret` does not exist; if it is a symlink or
55/// other non-regular file, has the wrong size, or has insecure permissions;
56/// if `data_dir` is missing, not a directory, or has insecure permissions; if
57/// the key path can't be stat'd for reasons other than non-existence; or if
58/// reading fails.
59pub fn load(data_dir: &Path) -> anyhow::Result<SecretKey> {
60    let path = key_path(data_dir);
61
62    // Check `data_dir` first so a clear "invalid data_dir" error beats a
63    // confusing "failed to stat key path" when `data_dir` is e.g. a regular
64    // file (the `join` would make the key path unstat'able with ENOTDIR).
65    match fs::symlink_metadata(data_dir) {
66        Ok(_) => validate_data_dir(data_dir)?,
67        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
68            return Err(anyhow!(
69                "no node identity: data_dir {} does not exist (key path {})",
70                data_dir.display(),
71                path.display()
72            ));
73        }
74        Err(e) => {
75            return Err(anyhow::Error::new(e)
76                .context(format!("failed to stat data_dir {}", data_dir.display())));
77        }
78    }
79
80    // `symlink_metadata` rather than `path.exists()`: it returns `Ok` for a
81    // dangling symlink (so `validate_key_file` rejects it), and it distinguishes
82    // `NotFound` from other errors so a storage EIO is not mistaken for an
83    // absent key.
84    match fs::symlink_metadata(&path) {
85        Ok(_) => {
86            validate_key_file(&path)?;
87            load_from(&path)
88        }
89        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow!(
90            "no node identity at {}; run `decdn key-gen` to create one",
91            path.display()
92        )),
93        Err(e) => {
94            Err(anyhow::Error::new(e)
95                .context(format!("failed to stat key path {}", path.display())))
96        }
97    }
98}
99
100/// Load the node's `SecretKey` from disk, generating and persisting one if absent.
101///
102/// Logs the key path at `INFO` when it generates a key. [`load_or_create`] is
103/// the same load without the log, for a caller with no subscriber yet.
104///
105/// # Errors
106///
107/// As [`load_or_create`].
108pub fn load_or_generate(data_dir: &Path) -> anyhow::Result<SecretKey> {
109    let loaded = load_or_create(data_dir)?;
110    if loaded.generated {
111        tracing::info!(path = %key_path(data_dir).display(), "generated new node secret key");
112    }
113    Ok(loaded.key)
114}
115
116/// A node key from [`load_or_create`], and whether that call created it.
117#[derive(Debug)]
118pub struct LoadedKey {
119    /// The node's iroh secret key.
120    pub key: SecretKey,
121    /// `true` when the call generated and persisted `key`; `false` when it
122    /// read an existing key.
123    pub generated: bool,
124}
125
126/// Load the node's `SecretKey` from disk, generating and persisting one if
127/// absent, and report which of the two happened.
128///
129/// Emits no log, so a caller can run it before its tracing subscriber exists
130/// and log [`LoadedKey::generated`] itself. The daemon does this so that its
131/// JSON formatter can write `node_id` on every event.
132///
133/// # Errors
134///
135/// Returns an error if the file exists but is a symlink or other non-regular
136/// file, has the wrong size, or has insecure permissions; if `data_dir` is
137/// not a directory or has insecure permissions; if the key path can't be
138/// stat'd for reasons other than non-existence; if the directory cannot be
139/// created; or if reading/writing fails.
140pub fn load_or_create(data_dir: &Path) -> anyhow::Result<LoadedKey> {
141    let path = key_path(data_dir);
142
143    // Check `data_dir` first so a clear "invalid data_dir" error beats a
144    // confusing "failed to stat key path" when `data_dir` is e.g. a regular
145    // file (the `join` would make the key path unstat'able with ENOTDIR).
146    // NotFound is fine — the create path below handles it.
147    let data_dir_exists = match fs::symlink_metadata(data_dir) {
148        Ok(_) => {
149            validate_data_dir(data_dir)?;
150            true
151        }
152        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
153        Err(e) => {
154            return Err(anyhow::Error::new(e)
155                .context(format!("failed to stat data_dir {}", data_dir.display())));
156        }
157    };
158
159    // `symlink_metadata` rather than `path.exists()`: it returns `Ok` for a
160    // dangling symlink (so `validate_key_file` rejects it instead of `rename`
161    // overwriting the target), and we distinguish `NotFound` from other errors
162    // so a storage EIO doesn't silently mask an existing key and trigger a
163    // rogue regeneration.
164    if data_dir_exists {
165        match fs::symlink_metadata(&path) {
166            Ok(_) => {
167                validate_key_file(&path)?;
168                return Ok(LoadedKey {
169                    key: load_from(&path)?,
170                    generated: false,
171                });
172            }
173            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
174            Err(e) => {
175                return Err(anyhow::Error::new(e)
176                    .context(format!("failed to stat key path {}", path.display())));
177            }
178        }
179    }
180
181    create_data_dir_secure(data_dir)?;
182    // Re-validate even after we just validated above: `create_data_dir_secure`
183    // is a no-op on a pre-existing dir, so this branch runs on already-checked
184    // state — but if the dir did not exist before, this is the *only* check.
185    validate_data_dir(data_dir)?;
186
187    let key = fresh_secret_key();
188    write_atomic(&path, &key.to_bytes())?;
189    Ok(LoadedKey {
190        key,
191        generated: true,
192    })
193}
194
195/// Validate (or create+validate) `data_dir` with `0o700` semantics: if it
196/// exists, the permission policy is enforced; if it's `NotFound`, it's
197/// created securely and then validated. Other stat errors propagate.
198///
199/// Public surface so `decdn_incentive::eth_identity::generate_and_persist`
200/// (the eth keystore writer, #406) can share the same `data_dir` hardening
201/// path as `node.secret`. The lower-level `validate_data_dir` /
202/// `create_data_dir_secure` helpers stay `pub(crate)` — callers outside
203/// this crate should reach for `ensure_data_dir` instead.
204pub fn ensure_data_dir(data_dir: &Path) -> anyhow::Result<()> {
205    match fs::symlink_metadata(data_dir) {
206        Ok(_) => validate_data_dir(data_dir),
207        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
208            create_data_dir_secure(data_dir)?;
209            validate_data_dir(data_dir)
210        }
211        Err(e) => Err(anyhow::Error::new(e)
212            .context(format!("failed to stat data_dir {}", data_dir.display()))),
213    }
214}
215
216/// Rename `path` to `<filename>.bak.<unix_ts>` so an in-place key rotation
217/// preserves the prior key material rather than destroying it. Required by
218/// `appendix-operator-key-rotation.md` §1 step 9 ("Archive the old iroh
219/// keystore offline. Retain it for at least `MAX_EVIDENCE_AGE_US`") and the
220/// §5 rollback path ("Repoint config at old keystore"). The operator can
221/// later move the `.bak` file offline; deleting it is up to them.
222///
223/// Bails if the bak path already exists (two `key-gen` invocations within
224/// the same second) rather than silently clobbering — operator removes the
225/// stale archive manually and retries.
226///
227/// Returns the new bak path on success.
228pub fn move_aside(path: &Path) -> anyhow::Result<PathBuf> {
229    use std::time::{SystemTime, UNIX_EPOCH};
230    let unix_ts = SystemTime::now()
231        .duration_since(UNIX_EPOCH)
232        .map_or(0, |d| d.as_secs());
233    let file_name = path
234        .file_name()
235        .and_then(|s| s.to_str())
236        .ok_or_else(|| anyhow!("invalid path (no UTF-8 filename): {}", path.display()))?;
237    let parent = path
238        .parent()
239        .ok_or_else(|| anyhow!("path has no parent directory: {}", path.display()))?;
240    let bak = parent.join(format!("{file_name}.bak.{unix_ts}"));
241    anyhow::ensure!(
242        !bak.exists(),
243        "refusing to overwrite existing archive {}; \
244         move it aside manually and retry",
245        bak.display()
246    );
247    fs::rename(path, &bak)
248        .with_context(|| format!("failed to archive {} -> {}", path.display(), bak.display()))?;
249    Ok(bak)
250}
251
252/// Install the staged temp file `tmp` at `target`, archiving any existing
253/// `target` first. Shared commit primitive for the staged-key types
254/// ([`StagedNodeKey`] and `decdn_incentive`'s `StagedKeystore`) so their
255/// archive → rename → fail-safe-rollback logic stays in one place.
256///
257/// **Fail-safe.** If the install rename fails after the prior file was archived,
258/// the archive is restored (best-effort) so `target` keeps the prior file rather
259/// than going missing. The returned error distinguishes the two outcomes:
260/// - restore succeeded → the prior file is live at `target`, safe to retry;
261/// - restore also failed → `target` is now **missing**, and the error names the
262///   surviving `.bak` archive the operator must move back manually.
263///
264/// Does **not** remove `tmp` on failure — the caller's `Drop` owns temp cleanup,
265/// so the temp is reclaimed whether the caller `?`-propagates or not.
266///
267/// Returns the archive path if a prior file was moved aside, or `None` (fresh
268/// install).
269///
270/// # Errors
271///
272/// Returns an error if archiving the prior file fails, or if the install rename
273/// fails (with the best-effort restore outcome folded into the message).
274pub fn install_staged(tmp: &Path, target: &Path) -> anyhow::Result<Option<PathBuf>> {
275    let bak = if target.exists() {
276        Some(move_aside(target)?)
277    } else {
278        None
279    };
280    let Err(rename_err) = fs::rename(tmp, target) else {
281        return Ok(bak);
282    };
283    let base = format!("failed to rename {} -> {}", tmp.display(), target.display());
284    // The install rename failed *after* any prior file was archived, which would
285    // otherwise leave `target` missing. Restore the archive (best-effort) so a
286    // failed rotation is fail-safe. The caller's `Drop` reclaims `tmp`.
287    let Some(bak_path) = &bak else {
288        // Fresh install: nothing was archived, so nothing to restore — `target`
289        // was already absent and stays absent. Clean failure.
290        return Err(anyhow::Error::new(rename_err).context(base));
291    };
292    match fs::rename(bak_path, target) {
293        Ok(()) => Err(anyhow::Error::new(rename_err).context(format!(
294            "{base}; the prior file was restored to {} and stays live (safe to retry)",
295            target.display()
296        ))),
297        Err(restore_err) => Err(anyhow::Error::new(rename_err).context(format!(
298            "{base}, AND restoring the archived prior file failed ({restore_err}): {} is now \
299             MISSING — its only surviving copy is the archive at {}. Move it back manually before \
300             starting the node (appendix-operator-key-rotation.md §5 rollback)",
301            target.display(),
302            bak_path.display()
303        ))),
304    }
305}
306
307/// A freshly-generated node key written to a temp file in `data_dir`, not yet
308/// committed to `node.secret`.
309///
310/// Staging (generate + write the temp) is separated from committing
311/// (archive-old + rename) so a multi-file rotation can generate **all** key
312/// material before touching any canonical file — a failure while generating the
313/// *second* secret then leaves the first untouched rather than half-rotated
314/// (#844). Produced by [`stage_node_key`]; finished with [`Self::commit`].
315///
316/// Dropping a [`StagedNodeKey`] without committing removes the temp file, so an
317/// abandoned stage never litters `data_dir`.
318#[must_use = "a staged node key must be committed (or it is discarded on drop)"]
319pub struct StagedNodeKey {
320    tmp: PathBuf,
321    final_path: PathBuf,
322    key: SecretKey,
323    committed: bool,
324}
325
326impl std::fmt::Debug for StagedNodeKey {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        // Never format the secret key.
329        f.debug_struct("StagedNodeKey")
330            .field("final_path", &self.final_path)
331            .field("committed", &self.committed)
332            .finish_non_exhaustive()
333    }
334}
335
336impl StagedNodeKey {
337    /// The public key of the staged secret, for logging the node id before the
338    /// commit lands.
339    #[must_use]
340    pub fn public(&self) -> PublicKey {
341        self.key.public()
342    }
343
344    /// Sign `msg` with the staged secret, without committing it.
345    ///
346    /// `decdn node rotate-key` needs this: `CapacityBond.bindNodeId` demands an
347    /// ed25519 ownership proof from the key being bound, and that proof has to
348    /// exist *before* the transaction is submitted — while committing
349    /// `node.secret` has to happen *after* it confirms, or a reverted bind
350    /// would leave the daemon serving under an unbound identity. Signing from
351    /// the stage is what lets those two orders coexist.
352    ///
353    /// The secret itself stays unreachable: this is deliberately a signing
354    /// oracle rather than a `secret()` accessor, so a staged key cannot be
355    /// copied out and persisted somewhere the archive-on-commit discipline
356    /// does not cover.
357    #[must_use]
358    pub fn sign(&self, msg: &[u8]) -> Signature {
359        self.key.sign(msg)
360    }
361
362    /// Commit the staged key: archive any existing `node.secret` (so the
363    /// operator key-rotation runbook keeps the prior material, mirroring the
364    /// no-stage path) and atomically rename the temp into place.
365    ///
366    /// Returns the archive path if a prior key was moved aside, or `None` when
367    /// there was nothing to archive (fresh install).
368    ///
369    /// Takes `&mut self` rather than `self` **so a failed commit does not
370    /// destroy the key**. Consuming `self` meant the `?` below dropped the
371    /// stage with `committed == false`, and `Drop` deleted the temp — on a
372    /// caller path (`decdn node rotate-key`) that only reaches this call after
373    /// proving on-chain that the binding moved to this key. The caller must
374    /// stay able to [`Self::keep`] it instead. The cost is that a caller can
375    /// now observe a committed stage; `commit` is idempotent-safe in that a
376    /// second call would simply fail on the already-renamed temp.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if archiving the prior key or the install rename fails.
381    /// The commit is **fail-safe** via [`install_staged`]: if the install rename
382    /// fails after the prior key was archived, the archive is restored
383    /// (best-effort) so the old key stays live, and the error states whether the
384    /// restore succeeded or `node.secret` is now missing. On error `committed`
385    /// stays `false`, so the caller may still [`Self::keep`] the staged key —
386    /// and if it does not, `Drop` reclaims the temp.
387    pub fn commit(&mut self) -> anyhow::Result<Option<PathBuf>> {
388        let bak = install_staged(&self.tmp, &self.final_path)?;
389        self.committed = true;
390        Ok(bak)
391    }
392
393    /// Preserve the staged key *without* installing it, at
394    /// `<data_dir>/node.secret.pending.<node_id>`. Returns the path it landed at.
395    ///
396    /// The third outcome, between [`Self::commit`] and the `Drop` that discards
397    /// an abandoned stage. It exists for one situation, reached three ways: a
398    /// key-rotation transaction may have taken effect, but the caller cannot
399    /// confirm the binding moved — the receipt could not be read at all, or it
400    /// mined carrying no matching `NodeIdBound`, or it mined and the install of
401    /// the confirmed key then failed. Whether the chain now names this key is
402    /// **unknown** in the first two, and *known* in the third — where the key is
403    /// even more valuable. Discarding the secret there is unrecoverable — if the
404    /// transaction did land, the operator is bound to an identity whose private
405    /// key no longer exists anywhere, and the daemon keeps serving under a key
406    /// nothing binds. Installing it is equally wrong, because the transaction
407    /// may instead have failed.
408    ///
409    /// So the key is parked under a name that is deliberately *not*
410    /// `node.secret`: no daemon will load it (`load_or_create` reads exactly
411    /// `<data_dir>/node.secret`), and the operator can install it once they have
412    /// checked whether the transaction confirmed.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the destination has no parent directory, or if the
417    /// rename fails. In **both** cases the staged temp is deliberately left in
418    /// place and its path is named in the error, rather than being reclaimed by
419    /// `Drop`: this is only ever called when the key may already be bound
420    /// on-chain, so an awkwardly-named surviving file beats an unrecoverable
421    /// identity. The caller is expected to print that path.
422    pub fn keep(mut self) -> anyhow::Result<PathBuf> {
423        // Set FIRST, not after the rename: every exit from here on must leave
424        // the temp on disk. The success path renames it (so `Drop` must not
425        // chase the old path), and both failure paths need it preserved for the
426        // reason in the doc above. This is the inverse of `commit`, where a
427        // failure means the key is genuinely worthless.
428        self.committed = true;
429        let parent = self.final_path.parent().ok_or_else(|| {
430            anyhow!(
431                "staged key path has no parent directory: {}; the staged key is at {}",
432                self.final_path.display(),
433                self.tmp.display()
434            )
435        })?;
436        let dest = parent.join(format!("{KEY_FILE_NAME}.pending.{}", self.key.public()));
437        fs::rename(&self.tmp, &dest).with_context(|| {
438            format!(
439                "failed to park the staged node key at {}; it has been LEFT IN PLACE at {} — \
440                 move it there yourself before starting the node",
441                dest.display(),
442                self.tmp.display()
443            )
444        })?;
445        Ok(dest)
446    }
447}
448
449impl Drop for StagedNodeKey {
450    fn drop(&mut self) {
451        if !self.committed {
452            // Best-effort: an abandoned stage must not leave key material behind.
453            let _ = fs::remove_file(&self.tmp);
454        }
455    }
456}
457
458/// Generate a fresh node key and write it to a temp file in `data_dir` (mode
459/// `0o600` on Unix) **without** touching `node.secret`. Validates or securely
460/// creates `data_dir` with the same `0o700` semantics as [`load_or_create`].
461///
462/// The returned [`StagedNodeKey`] is committed via [`StagedNodeKey::commit`].
463/// This is the staged counterpart of [`load_or_create`]'s generate path, used
464/// by `decdn key-gen` so the node key and the eth keystore are both generated
465/// before either canonical file is replaced (#844).
466///
467/// # Errors
468///
469/// Returns an error if `data_dir` cannot be validated or created, or if writing
470/// the temp file fails.
471pub fn stage_node_key(data_dir: &Path) -> anyhow::Result<StagedNodeKey> {
472    ensure_data_dir(data_dir)?;
473    let final_path = key_path(data_dir);
474    let key = fresh_secret_key();
475    let tmp = write_temp(&final_path, &key.to_bytes())?;
476    Ok(StagedNodeKey {
477        tmp,
478        final_path,
479        key,
480        committed: false,
481    })
482}
483
484/// Reject `data_dir` if it isn't a directory or if any group/other permission
485/// bit is set.
486///
487/// Uses `symlink_metadata` so symlinks at `data_dir` are rejected outright.
488/// `metadata()` would follow the link and evaluate the target's mode, which
489/// leaves a race where the symlink can be repointed between validation and
490/// use — exercised by `rejects_symlinked_data_dir`.
491#[cfg(unix)]
492pub(crate) fn validate_data_dir(data_dir: &Path) -> anyhow::Result<()> {
493    use std::os::unix::fs::MetadataExt;
494    let meta = fs::symlink_metadata(data_dir)
495        .with_context(|| format!("invalid data_dir: cannot access {}", data_dir.display()))?;
496    anyhow::ensure!(
497        meta.file_type().is_dir(),
498        "invalid data_dir: {} is not a directory",
499        data_dir.display()
500    );
501    let mode = meta.mode() & 0o777;
502    anyhow::ensure!(
503        mode & FORBIDDEN_BITS == 0,
504        "invalid data_dir: {} has insecure permissions {:#o} \
505         (group/other bits must be clear; expected {:#o})",
506        data_dir.display(),
507        mode,
508        DATA_DIR_MODE
509    );
510    Ok(())
511}
512
513/// Reject the key file if it isn't a regular file or if any group/other
514/// permission bit is set.
515///
516/// There is a small TOCTOU window between this check and `load_from`'s
517/// `fs::read`, but exploiting it requires write access to `data_dir` — which
518/// `validate_data_dir` already denies, so the two checks compose. Tightening
519/// to an `O_NOFOLLOW` open would require a libc dep; deferred.
520#[cfg(unix)]
521fn validate_key_file(path: &Path) -> anyhow::Result<()> {
522    use std::os::unix::fs::MetadataExt;
523    let meta = fs::symlink_metadata(path)
524        .with_context(|| format!("invalid node.secret: cannot access {}", path.display()))?;
525    anyhow::ensure!(
526        meta.file_type().is_file(),
527        "invalid node.secret: {} is not a regular file",
528        path.display()
529    );
530    let mode = meta.mode() & 0o777;
531    anyhow::ensure!(
532        mode & FORBIDDEN_BITS == 0,
533        "invalid node.secret: {} has insecure permissions {:#o} \
534         (must be user-only, e.g. {:#o})",
535        path.display(),
536        mode,
537        0o600
538    );
539    Ok(())
540}
541
542/// Create `data_dir` (and any missing parents) with mode `0o700` on the leaf
543/// created by this call. `DirBuilder::mode` only applies to directories this
544/// call creates — pre-existing parents like `~/.local/share` keep their own
545/// permissions.
546#[cfg(unix)]
547pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
548    use std::os::unix::fs::DirBuilderExt;
549    fs::DirBuilder::new()
550        .recursive(true)
551        .mode(DATA_DIR_MODE)
552        .create(data_dir)
553        .with_context(|| format!("failed to create data dir {}", data_dir.display()))
554}
555
556#[cfg(not(unix))]
557#[allow(
558    clippy::unnecessary_wraps,
559    reason = "the Unix variant can fail; the shared call sites take a Result"
560)]
561#[allow(
562    clippy::missing_const_for_fn,
563    reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
564)]
565pub(crate) fn validate_data_dir(_data_dir: &Path) -> anyhow::Result<()> {
566    Ok(())
567}
568
569#[cfg(not(unix))]
570#[allow(
571    clippy::unnecessary_wraps,
572    reason = "the Unix variant can fail; the shared call sites take a Result"
573)]
574#[allow(
575    clippy::missing_const_for_fn,
576    reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
577)]
578fn validate_key_file(_path: &Path) -> anyhow::Result<()> {
579    Ok(())
580}
581
582#[cfg(not(unix))]
583pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
584    fs::create_dir_all(data_dir)
585        .with_context(|| format!("failed to create data dir {}", data_dir.display()))
586}
587
588/// Load the node's `SecretKey` from an existing file.
589///
590/// Callers must validate `path` via [`validate_key_file`] first — this
591/// function follows symlinks via `fs::read`.
592///
593/// # Errors
594///
595/// Returns an error if the file cannot be read or is not exactly 32 bytes.
596fn load_from(path: &Path) -> anyhow::Result<SecretKey> {
597    let bytes = fs::read(path)
598        .with_context(|| format!("failed to read secret key file {}", path.display()))?;
599    let len = bytes.len();
600    let arr: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
601        anyhow!(
602            "secret key file {} must be {KEY_LEN} bytes, got {len}",
603            path.display()
604        )
605    })?;
606    Ok(SecretKey::from_bytes(&arr))
607}
608
609/// A newly generated node secret key, drawn from the OS RNG. The caller owns
610/// persisting it; nothing here touches disk.
611pub fn fresh_secret_key() -> SecretKey {
612    SecretKey::generate()
613}
614
615/// Write `bytes` to `path` atomically. On Unix the temp file is created with
616/// mode 0600 from the start (via `OpenOptionsExt::mode`) so the key material is
617/// never briefly exposed under a permissive umask.
618fn write_atomic(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<()> {
619    let tmp = write_temp(path, bytes)?;
620    if let Err(e) = fs::rename(&tmp, path)
621        .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
622    {
623        // The rename never happened, so the staged temp is still on disk —
624        // remove it so a partial write doesn't accumulate in `data_dir`.
625        let _ = fs::remove_file(&tmp);
626        return Err(e);
627    }
628    Ok(())
629}
630
631/// Write `bytes` to a freshly-created `<path>.tmp.<suffix>` sibling (mode 0600
632/// on Unix from creation, then fsync'd) and return that temp path **without**
633/// renaming it into place. The caller commits by renaming `tmp` → `path`, or
634/// drops it by removing the file. This is the staging half of [`write_atomic`],
635/// shared with [`stage_node_key`] so a multi-file rotation can generate all key
636/// material before touching any canonical file (#844).
637///
638/// On any error the temp file is cleaned up before returning.
639fn write_temp(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<PathBuf> {
640    let parent = path
641        .parent()
642        .ok_or_else(|| anyhow!("key path {} has no parent directory", path.display()))?;
643    // Unique temp name per writer: pid + 8 random hex chars. Avoids the race
644    // where two concurrent writers clobber each other's temp file. The rename
645    // step (in the caller) is atomic on Unix, so only one final `path` will exist.
646    let suffix = {
647        let mut s = [0u8; 4];
648        rand::rng().fill_bytes(&mut s);
649        format!(
650            "{:08x}{:02x}{:02x}{:02x}{:02x}",
651            std::process::id(),
652            s[0],
653            s[1],
654            s[2],
655            s[3],
656        )
657    };
658    let tmp = parent.join(format!(
659        "{}.tmp.{suffix}",
660        path.file_name()
661            .and_then(|s| s.to_str())
662            .unwrap_or(KEY_FILE_NAME),
663    ));
664
665    // Closure lets us funnel every fallible step through a single cleanup
666    // path: on any error after `create_new`, remove the tmp file so partial
667    // writes don't accumulate in `data_dir`. Cleanup failures are ignored —
668    // the real error is already in flight.
669    let result: anyhow::Result<()> = (|| {
670        #[cfg(unix)]
671        {
672            use std::io::Write;
673            use std::os::unix::fs::OpenOptionsExt;
674            let mut file = fs::OpenOptions::new()
675                .write(true)
676                .create_new(true)
677                .mode(0o600)
678                .open(&tmp)
679                .with_context(|| format!("failed to create {}", tmp.display()))?;
680            file.write_all(bytes)
681                .with_context(|| format!("failed to write {}", tmp.display()))?;
682            file.sync_all()
683                .with_context(|| format!("failed to sync {}", tmp.display()))?;
684        }
685
686        #[cfg(not(unix))]
687        {
688            fs::write(&tmp, bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
689        }
690
691        Ok(())
692    })();
693
694    match result {
695        Ok(()) => Ok(tmp),
696        Err(e) => {
697            let _ = fs::remove_file(&tmp);
698            Err(e)
699        }
700    }
701}
702
703#[cfg(test)]
704#[allow(
705    clippy::unwrap_used,
706    clippy::expect_used,
707    clippy::panic,
708    clippy::indexing_slicing
709)]
710mod tests;