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;