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 was not read at all (a failed
400 /// read, or none within the send's bound), or it mined carrying no matching
401 /// `NodeIdBound`, or it mined and the install of the confirmed key then
402 /// failed. Whether the chain now names this key is
403 /// **unknown** in the first two, and *known* in the third — where the key is
404 /// even more valuable. Discarding the secret there is unrecoverable — if the
405 /// transaction did land, the operator is bound to an identity whose private
406 /// key no longer exists anywhere, and the daemon keeps serving under a key
407 /// nothing binds. Installing it is equally wrong, because the transaction
408 /// may instead have failed.
409 ///
410 /// So the key is parked under a name that is deliberately *not*
411 /// `node.secret`: no daemon will load it (`load_or_create` reads exactly
412 /// `<data_dir>/node.secret`), and the operator can install it once they have
413 /// checked whether the transaction confirmed.
414 ///
415 /// # Errors
416 ///
417 /// Returns an error if the destination has no parent directory, or if the
418 /// rename fails. In **both** cases the staged temp is deliberately left in
419 /// place and its path is named in the error, rather than being reclaimed by
420 /// `Drop`: this is only ever called when the key may already be bound
421 /// on-chain, so an awkwardly-named surviving file beats an unrecoverable
422 /// identity. The caller is expected to print that path.
423 pub fn keep(mut self) -> anyhow::Result<PathBuf> {
424 // Set FIRST, not after the rename: every exit from here on must leave
425 // the temp on disk. The success path renames it (so `Drop` must not
426 // chase the old path), and both failure paths need it preserved for the
427 // reason in the doc above. This is the inverse of `commit`, where a
428 // failure means the key is genuinely worthless.
429 self.committed = true;
430 let parent = self.final_path.parent().ok_or_else(|| {
431 anyhow!(
432 "staged key path has no parent directory: {}; the staged key is at {}",
433 self.final_path.display(),
434 self.tmp.display()
435 )
436 })?;
437 let dest = parent.join(format!("{KEY_FILE_NAME}.pending.{}", self.key.public()));
438 fs::rename(&self.tmp, &dest).with_context(|| {
439 format!(
440 "failed to park the staged node key at {}; it has been LEFT IN PLACE at {} — \
441 move it there yourself before starting the node",
442 dest.display(),
443 self.tmp.display()
444 )
445 })?;
446 Ok(dest)
447 }
448}
449
450impl Drop for StagedNodeKey {
451 fn drop(&mut self) {
452 if !self.committed {
453 // Best-effort: an abandoned stage must not leave key material behind.
454 let _ = fs::remove_file(&self.tmp);
455 }
456 }
457}
458
459/// Generate a fresh node key and write it to a temp file in `data_dir` (mode
460/// `0o600` on Unix) **without** touching `node.secret`. Validates or securely
461/// creates `data_dir` with the same `0o700` semantics as [`load_or_create`].
462///
463/// The returned [`StagedNodeKey`] is committed via [`StagedNodeKey::commit`].
464/// This is the staged counterpart of [`load_or_create`]'s generate path, used
465/// by `decdn key-gen` so the node key and the eth keystore are both generated
466/// before either canonical file is replaced (#844).
467///
468/// # Errors
469///
470/// Returns an error if `data_dir` cannot be validated or created, or if writing
471/// the temp file fails.
472pub fn stage_node_key(data_dir: &Path) -> anyhow::Result<StagedNodeKey> {
473 ensure_data_dir(data_dir)?;
474 let final_path = key_path(data_dir);
475 let key = fresh_secret_key();
476 let tmp = write_temp(&final_path, &key.to_bytes())?;
477 Ok(StagedNodeKey {
478 tmp,
479 final_path,
480 key,
481 committed: false,
482 })
483}
484
485/// Reject `data_dir` if it isn't a directory or if any group/other permission
486/// bit is set.
487///
488/// Uses `symlink_metadata` so symlinks at `data_dir` are rejected outright.
489/// `metadata()` would follow the link and evaluate the target's mode, which
490/// leaves a race where the symlink can be repointed between validation and
491/// use — exercised by `rejects_symlinked_data_dir`.
492#[cfg(unix)]
493pub(crate) fn validate_data_dir(data_dir: &Path) -> anyhow::Result<()> {
494 use std::os::unix::fs::MetadataExt;
495 let meta = fs::symlink_metadata(data_dir)
496 .with_context(|| format!("invalid data_dir: cannot access {}", data_dir.display()))?;
497 anyhow::ensure!(
498 meta.file_type().is_dir(),
499 "invalid data_dir: {} is not a directory",
500 data_dir.display()
501 );
502 let mode = meta.mode() & 0o777;
503 anyhow::ensure!(
504 mode & FORBIDDEN_BITS == 0,
505 "invalid data_dir: {} has insecure permissions {:#o} \
506 (group/other bits must be clear; expected {:#o})",
507 data_dir.display(),
508 mode,
509 DATA_DIR_MODE
510 );
511 Ok(())
512}
513
514/// Reject the key file if it isn't a regular file or if any group/other
515/// permission bit is set.
516///
517/// There is a small TOCTOU window between this check and `load_from`'s
518/// `fs::read`, but exploiting it requires write access to `data_dir` — which
519/// `validate_data_dir` already denies, so the two checks compose. Tightening
520/// to an `O_NOFOLLOW` open would require a libc dep; deferred.
521#[cfg(unix)]
522fn validate_key_file(path: &Path) -> anyhow::Result<()> {
523 use std::os::unix::fs::MetadataExt;
524 let meta = fs::symlink_metadata(path)
525 .with_context(|| format!("invalid node.secret: cannot access {}", path.display()))?;
526 anyhow::ensure!(
527 meta.file_type().is_file(),
528 "invalid node.secret: {} is not a regular file",
529 path.display()
530 );
531 let mode = meta.mode() & 0o777;
532 anyhow::ensure!(
533 mode & FORBIDDEN_BITS == 0,
534 "invalid node.secret: {} has insecure permissions {:#o} \
535 (must be user-only, e.g. {:#o})",
536 path.display(),
537 mode,
538 0o600
539 );
540 Ok(())
541}
542
543/// Create `data_dir` (and any missing parents) with mode `0o700` on the leaf
544/// created by this call. `DirBuilder::mode` only applies to directories this
545/// call creates — pre-existing parents like `~/.local/share` keep their own
546/// permissions.
547#[cfg(unix)]
548pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
549 use std::os::unix::fs::DirBuilderExt;
550 fs::DirBuilder::new()
551 .recursive(true)
552 .mode(DATA_DIR_MODE)
553 .create(data_dir)
554 .with_context(|| format!("failed to create data dir {}", data_dir.display()))
555}
556
557#[cfg(not(unix))]
558#[allow(
559 clippy::unnecessary_wraps,
560 reason = "the Unix variant can fail; the shared call sites take a Result"
561)]
562#[allow(
563 clippy::missing_const_for_fn,
564 reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
565)]
566pub(crate) fn validate_data_dir(_data_dir: &Path) -> anyhow::Result<()> {
567 Ok(())
568}
569
570#[cfg(not(unix))]
571#[allow(
572 clippy::unnecessary_wraps,
573 reason = "the Unix variant can fail; the shared call sites take a Result"
574)]
575#[allow(
576 clippy::missing_const_for_fn,
577 reason = "the Unix variant does filesystem I/O and cannot be const; the shared call sites are not const contexts"
578)]
579fn validate_key_file(_path: &Path) -> anyhow::Result<()> {
580 Ok(())
581}
582
583#[cfg(not(unix))]
584pub(crate) fn create_data_dir_secure(data_dir: &Path) -> anyhow::Result<()> {
585 fs::create_dir_all(data_dir)
586 .with_context(|| format!("failed to create data dir {}", data_dir.display()))
587}
588
589/// Load the node's `SecretKey` from an existing file.
590///
591/// Callers must validate `path` via [`validate_key_file`] first — this
592/// function follows symlinks via `fs::read`.
593///
594/// # Errors
595///
596/// Returns an error if the file cannot be read or is not exactly 32 bytes.
597fn load_from(path: &Path) -> anyhow::Result<SecretKey> {
598 let bytes = fs::read(path)
599 .with_context(|| format!("failed to read secret key file {}", path.display()))?;
600 let len = bytes.len();
601 let arr: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| {
602 anyhow!(
603 "secret key file {} must be {KEY_LEN} bytes, got {len}",
604 path.display()
605 )
606 })?;
607 Ok(SecretKey::from_bytes(&arr))
608}
609
610/// A newly generated node secret key, drawn from the OS RNG. The caller owns
611/// persisting it; nothing here touches disk.
612pub fn fresh_secret_key() -> SecretKey {
613 SecretKey::generate()
614}
615
616/// Write `bytes` to `path` atomically. On Unix the temp file is created with
617/// mode 0600 from the start (via `OpenOptionsExt::mode`) so the key material is
618/// never briefly exposed under a permissive umask.
619fn write_atomic(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<()> {
620 let tmp = write_temp(path, bytes)?;
621 if let Err(e) = fs::rename(&tmp, path)
622 .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))
623 {
624 // The rename never happened, so the staged temp is still on disk —
625 // remove it so a partial write doesn't accumulate in `data_dir`.
626 let _ = fs::remove_file(&tmp);
627 return Err(e);
628 }
629 Ok(())
630}
631
632/// Write `bytes` to a freshly-created `<path>.tmp.<suffix>` sibling (mode 0600
633/// on Unix from creation, then fsync'd) and return that temp path **without**
634/// renaming it into place. The caller commits by renaming `tmp` → `path`, or
635/// drops it by removing the file. This is the staging half of [`write_atomic`],
636/// shared with [`stage_node_key`] so a multi-file rotation can generate all key
637/// material before touching any canonical file (#844).
638///
639/// On any error the temp file is cleaned up before returning.
640fn write_temp(path: &Path, bytes: &[u8; KEY_LEN]) -> anyhow::Result<PathBuf> {
641 let parent = path
642 .parent()
643 .ok_or_else(|| anyhow!("key path {} has no parent directory", path.display()))?;
644 // Unique temp name per writer: pid + 8 random hex chars. Avoids the race
645 // where two concurrent writers clobber each other's temp file. The rename
646 // step (in the caller) is atomic on Unix, so only one final `path` will exist.
647 let suffix = {
648 let mut s = [0u8; 4];
649 rand::rng().fill_bytes(&mut s);
650 format!(
651 "{:08x}{:02x}{:02x}{:02x}{:02x}",
652 std::process::id(),
653 s[0],
654 s[1],
655 s[2],
656 s[3],
657 )
658 };
659 let tmp = parent.join(format!(
660 "{}.tmp.{suffix}",
661 path.file_name()
662 .and_then(|s| s.to_str())
663 .unwrap_or(KEY_FILE_NAME),
664 ));
665
666 // Closure lets us funnel every fallible step through a single cleanup
667 // path: on any error after `create_new`, remove the tmp file so partial
668 // writes don't accumulate in `data_dir`. Cleanup failures are ignored —
669 // the real error is already in flight.
670 let result: anyhow::Result<()> = (|| {
671 #[cfg(unix)]
672 {
673 use std::io::Write;
674 use std::os::unix::fs::OpenOptionsExt;
675 let mut file = fs::OpenOptions::new()
676 .write(true)
677 .create_new(true)
678 .mode(0o600)
679 .open(&tmp)
680 .with_context(|| format!("failed to create {}", tmp.display()))?;
681 file.write_all(bytes)
682 .with_context(|| format!("failed to write {}", tmp.display()))?;
683 file.sync_all()
684 .with_context(|| format!("failed to sync {}", tmp.display()))?;
685 }
686
687 #[cfg(not(unix))]
688 {
689 fs::write(&tmp, bytes).with_context(|| format!("failed to write {}", tmp.display()))?;
690 }
691
692 Ok(())
693 })();
694
695 match result {
696 Ok(()) => Ok(tmp),
697 Err(e) => {
698 let _ = fs::remove_file(&tmp);
699 Err(e)
700 }
701 }
702}
703
704#[cfg(test)]
705#[allow(
706 clippy::unwrap_used,
707 clippy::expect_used,
708 clippy::panic,
709 clippy::indexing_slicing
710)]
711mod tests;