diff --git a/docs/how-to/persistent-identity.md b/docs/how-to/persistent-identity.md index 975ee503..015c6772 100644 --- a/docs/how-to/persistent-identity.md +++ b/docs/how-to/persistent-identity.md @@ -35,19 +35,12 @@ nodes, and tests where you actively want a fresh identity per run. The Debian/Ubuntu `.deb` and the Arch `fips` AUR package both ship a default `/etc/fips/fips.yaml` with `node.identity.persistent` left as -the upstream default (false), so the daemon writes a fresh keypair to -`/etc/fips/fips.{key,pub}` on every start until you set -`persistent: true`. To pin the current keypair: +the upstream default (false). In that mode the daemon generates a +fresh keypair on every start, holds the private key only in memory, +and writes only `/etc/fips/fips.pub`. To give the node a stable +identity: -1. Install the package and start the daemon once so it generates - `fips.key` / `fips.pub`: - - ```sh - sudo systemctl start fips - sudo systemctl status fips # confirm it came up - ``` - -2. Edit `/etc/fips/fips.yaml` and set: +1. Install the package, then edit `/etc/fips/fips.yaml` and set: ```yaml node: @@ -55,22 +48,31 @@ the upstream default (false), so the daemon writes a fresh keypair to persistent: true ``` -3. Restart the daemon and verify the identity is reused: +2. Start or restart the daemon. On this first persistent start it + generates a keypair and saves it to `/etc/fips/fips.key`: ```sh sudo systemctl restart fips + sudo systemctl status fips # confirm it came up + ``` + +3. Verify the identity: + + ```sh fipsctl show status | grep -E '"npub"|"node_addr"' cat /etc/fips/fips.pub ``` The npub printed by `fipsctl show status` should match - `/etc/fips/fips.pub` and remain stable across subsequent restarts. + `/etc/fips/fips.pub`. If the daemon was already running + ephemeral, the npub changes once at this restart and is stable + from then on. -The package's `postinst` script does **not** generate the keypair — -the daemon does, on first start. This means the keypair is only -present after the first successful daemon start. If the daemon never -came up cleanly (config error, permission problem), the key files -will be missing. +The package's `postinst` script does **not** generate the keypair. +The first successful daemon start with `persistent: true` does, so +`fips.key` is only present after that start. If the daemon never came +up cleanly (config error, permission problem), the key file will be +missing. ### macOS note diff --git a/docs/reference/cli-fips.md b/docs/reference/cli-fips.md index 653291a1..b147a0f7 100644 --- a/docs/reference/cli-fips.md +++ b/docs/reference/cli-fips.md @@ -76,16 +76,18 @@ first, then `/usr/local/etc/fips`, so the packaged file wins over a leftover `/etc/fips` copy from an earlier install. Windows likewise probes `\etc\fips` on the current drive, then `C:\ProgramData\fips`. -Adjacent to the highest-priority config file the daemon reads (or -writes, on first start) the identity files: +Adjacent to the highest-priority config file the daemon keeps the +identity files: | File | Mode | Purpose | | ---- | ---- | ------- | -| `fips.key` | `0600` | Bech32 nsec for the persistent identity (Unix; on Windows the file takes its directory's ACL, which `install-service.ps1` restricts to SYSTEM and Administrators). | -| `fips.pub` | `0644` | Bech32 npub corresponding to `fips.key`. | +| `fips.key` | `0600` | Bech32 nsec for the persistent identity, written only in persistent mode (Unix; on Windows the file takes its directory's ACL, which `install-service.ps1` restricts to SYSTEM and Administrators). | +| `fips.pub` | `0644` | Bech32 npub of the running identity, written on every start. In persistent mode it corresponds to `fips.key`. | When `node.identity.persistent` is `false` (the default), a fresh -keypair is written to these files on every start. +keypair is generated on every start and only `fips.pub` is written. +A `fips.key` found there is moved aside to `fips.key.unused` and a +warning is logged. On Windows the service writes its log to `C:\ProgramData\fips\fips.log`, rolled at 10 MiB with four old files kept; a foreground run logs to the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1641fa5c..117f82fd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -108,8 +108,10 @@ Identity resolution follows a three-tier priority: 3. **Ephemeral** — when `persistent: false` (default) and no `nsec`, generates a fresh keypair on each start -Key files (`fips.key` with mode 0600, `fips.pub` with mode 0644) are written adjacent -to the highest-priority config file for operator visibility, even in ephemeral mode. +`fips.pub` (mode 0644) is written adjacent to the highest-priority config file +on every start. `fips.key` (mode 0600) is written only in persistent mode. In +ephemeral mode a `fips.key` found at startup is moved aside to +`fips.key.unused` with a warning. ### General diff --git a/packaging/nixos/README.md b/packaging/nixos/README.md index 87f8cd2d..2f6f52b1 100644 --- a/packaging/nixos/README.md +++ b/packaging/nixos/README.md @@ -70,8 +70,8 @@ operator-editable runtime state: | Path | Purpose | Writable | Seeded from | |---|---|---|---| | `/var/lib/fips/fips.yaml` | Main config | yes | `services.fips.configFile` (first run only) | -| `/var/lib/fips/fips.key` | Node identity (private) | yes | generated by fips on first start | -| `/var/lib/fips/fips.pub` | Node identity (public) | yes | generated by fips on first start | +| `/var/lib/fips/fips.key` | Node identity (private) | yes | generated by fips on first start with `persistent: true` | +| `/var/lib/fips/fips.pub` | Node identity (public) | yes | written by fips on every start | | `/etc/fips/hosts` | Static hostname → npub map | yes | shipped `hosts` (first run only) | | `/etc/fips/peers.allow` | Peer allowlist (ACL) | yes | operator-created | | `/etc/fips/peers.deny` | Peer denylist (ACL) | yes | operator-created | diff --git a/packaging/systemd/README.install.md b/packaging/systemd/README.install.md index 2448dbf5..01ebfd0a 100644 --- a/packaging/systemd/README.install.md +++ b/packaging/systemd/README.install.md @@ -17,8 +17,8 @@ sudo ./install.sh | fipstop (TUI) | /usr/local/bin/fipstop | | fips-gateway (LAN bridge) | /usr/local/bin/fips-gateway | | Configuration | /etc/fips/fips.yaml | -| Identity key | /etc/fips/fips.key (auto-generated) | -| Public key | /etc/fips/fips.pub (auto-generated) | +| Identity key | /etc/fips/fips.key (generated on first start with `persistent: true`) | +| Public key | /etc/fips/fips.pub (written on every start) | | Hosts file | /etc/fips/hosts | | Firewall baseline | /etc/fips/fips.nft | | Firewall drop-in directory | /etc/fips/fips.d/ | diff --git a/packaging/windows/build-zip.ps1 b/packaging/windows/build-zip.ps1 index 532cca75..ae3731ae 100644 --- a/packaging/windows/build-zip.ps1 +++ b/packaging/windows/build-zip.ps1 @@ -101,9 +101,9 @@ Control Socket: Configuration: The service reads C:\ProgramData\fips\fips.yaml, where - install-service.ps1 puts it, and keeps fips.key, hosts, - peers.allow and peers.deny beside it. Edit fips.yaml there - before starting the service. + install-service.ps1 puts it, and keeps hosts, peers.allow, + peers.deny and, with node.identity.persistent: true, fips.key + beside it. Edit fips.yaml there before starting the service. install-service.ps1 restricts C:\ProgramData\fips to SYSTEM and Administrators before writing into it. Reading or editing diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 8b9572bf..52bfaf00 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -484,7 +484,8 @@ mod service { "Configuration: the service reads {}", dir.join("fips.yaml").display() ); - println!(" keep fips.key, hosts, peers.allow and peers.deny beside it."); + println!(" keep hosts, peers.allow and peers.deny beside it, and fips.key"); + println!(" too when node.identity.persistent is true."); println!( "Logs: the service writes {}", dir.join("fips.log").display() diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 5ee737db..3df15b8e 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -593,8 +593,12 @@ fn main() { eprintln!("{npub}"); eprintln!("Key files written to: {}/", dir.display()); eprintln!(); - eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml"); - eprintln!(" or these keys will be overwritten on next daemon start."); + eprintln!( + "NOTE: Set 'node.identity.persistent: true' in fips.yaml before the next daemon start." + ); + eprintln!( + " Without it the daemon does not use this key: it moves fips.key aside to fips.key.unused." + ); return; } diff --git a/src/config/mod.rs b/src/config/mod.rs index 72e95a8c..f323214b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -595,13 +595,60 @@ pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> { Ok(()) } +/// Move a key file found at an ephemeral start to `.unused` beside it, +/// so it is neither used nor overwritten and stays recoverable. +/// +/// Returns the new path when the file was moved. Does nothing when no file is +/// at `key_path`; a dangling symlink counts as a file and is moved as a link. +/// An existing file at the aside path is never replaced: the key is left in +/// place and a warning says so, as it does when the rename fails. Neither case +/// stops the start. +fn retire_key(key_path: &Path) -> Option { + // symlink_metadata rather than exists: a dangling symlink at the key path + // reports exists() == false but is still a file the operator put there. + key_path.symlink_metadata().ok()?; + + let mut name = key_path.file_name()?.to_os_string(); + name.push(".unused"); + let aside = key_path.with_file_name(name); + + let failure = match aside.symlink_metadata() { + Ok(_) => "a file already exists at the aside path".to_string(), + Err(e) if e.kind() != std::io::ErrorKind::NotFound => e.to_string(), + Err(_) => match std::fs::rename(key_path, &aside) { + Ok(()) => { + tracing::warn!( + path = %key_path.display(), + moved_to = %aside.display(), + config_key = "node.identity.persistent", + "An identity key file was found in ephemeral mode and moved aside, not used; \ + set node.identity.persistent: true and move it back to use it" + ); + return Some(aside); + } + Err(e) => e.to_string(), + }, + }; + + tracing::warn!( + path = %key_path.display(), + aside = %aside.display(), + error = %failure, + config_key = "node.identity.persistent", + "An identity key file was found in ephemeral mode and could not be moved aside; \ + it is not used; set node.identity.persistent: true to use it, or remove it" + ); + None +} + /// Resolve identity from config and key file. /// /// Behavior depends on `node.identity.persistent`: /// /// - **`persistent: false`** (default): generate a fresh ephemeral keypair -/// every start. Key files are written for operator visibility but overwritten -/// on each restart. +/// every start. Only `fips.pub` is written, so the running npub is visible; +/// the private key is never written. A `fips.key` already at the path is +/// moved aside to `fips.key.unused` with a warning, not used or overwritten. /// /// - **`persistent: true`**: use three-tier resolution: /// 1. Explicit nsec in config — highest priority @@ -737,8 +784,8 @@ pub fn resolve_identity( } } } else { - // Ephemeral mode (default): fresh keypair every start, write key files - // for operator visibility + // Ephemeral mode (default): a fresh keypair every start, held only in + // memory. Only the public key file is written. let identity = Identity::generate(); // `keypair()` and `secret_key()` each hand back a whole private key // rather than a handle, so both temporaries are bound and erased. @@ -753,25 +800,8 @@ pub fn resolve_identity( let _ = std::fs::create_dir_all(parent); } - // symlink_metadata rather than exists: a dangling symlink at the key - // path reports exists() == false but is still an existing file the - // write is about to act on. - if key_path.symlink_metadata().is_ok() { - tracing::warn!( - path = %key_path.display(), - config_key = "node.identity.persistent", - "An existing key file at this path is being replaced by a fresh ephemeral \ - identity; set node.identity.persistent: true to keep the existing identity" - ); - } + retire_key(&key_path); - if let Err(e) = write_key_file(&key_path, &nsec) { - tracing::warn!( - path = %key_path.display(), - error = %e, - "Failed to write the ephemeral key file" - ); - } if let Err(e) = write_pub_file(&pub_path, &npub) { tracing::warn!( path = %pub_path.display(), @@ -2020,29 +2050,64 @@ node: assert_eq!(fs::read_to_string(&victim).unwrap(), "victim contents\n"); } + /// The names in `dir`, sorted, so a test can assert on the whole directory. + fn dir_names(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + /// The npub a resolved identity runs as. + fn resolved_npub(resolved: &ResolvedIdentity) -> String { + crate::Identity::from_secret_str(&resolved.nsec) + .unwrap() + .npub() + } + #[test] - fn test_ephemeral_over_existing_key_warns() { + fn ephemeral_start_moves_an_existing_key_aside_intact() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("fips.yaml"); let key_path = temp_dir.path().join("fips.key"); + let aside_path = temp_dir.path().join("fips.key.unused"); fs::write(&config_path, "node:\n identity: {}\n").unwrap(); let identity = crate::Identity::generate(); let existing = crate::encode_nsec(&identity.keypair().secret_key()); write_key_file(&key_path, &existing).unwrap(); + let planted = fs::read(&key_path).unwrap(); let config = Config::load_file(&config_path).unwrap(); let (resolved, logs) = capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap()); assert_ne!(resolved.nsec, existing); + assert!( + key_path.symlink_metadata().is_err(), + "the key file must be moved away from the path a persistent start reads" + ); + assert_eq!( + fs::read(&aside_path).unwrap(), + planted, + "the key set aside must hold the planted bytes exactly" + ); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(fs::metadata(&aside_path).unwrap().mode() & 0o777, 0o600); + } let warnings = logs.warnings(); assert!( warnings .iter() .any(|w| w.contains(&key_path.display().to_string()) - && w.contains("node.identity.persistent")), - "expected a warning naming the key path and the config key, got {warnings:?}" + && w.contains(&aside_path.display().to_string()) + && w.contains("node.identity.persistent") + && w.contains("and moved aside, not used")), + "expected the moved-aside warning naming both paths and the config key, got {warnings:?}" ); } @@ -2052,6 +2117,7 @@ node: let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("fips.yaml"); let key_path = temp_dir.path().join("fips.key"); + let aside_path = temp_dir.path().join("fips.key.unused"); let target = temp_dir.path().join("absent-target"); fs::write(&config_path, "node:\n identity: {}\n").unwrap(); @@ -2065,8 +2131,21 @@ node: assert!( warnings .iter() - .any(|w| w.contains(&key_path.display().to_string())), - "expected a warning naming the key path, got {warnings:?}" + .any(|w| w.contains(&key_path.display().to_string()) + && w.contains("and moved aside, not used")), + "expected the moved-aside warning naming the key path, got {warnings:?}" + ); + assert!( + key_path.symlink_metadata().is_err(), + "the dangling symlink must be moved away from the key path" + ); + assert!( + aside_path + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink(), + "the symlink itself must be what was moved aside, not a file written through it" ); assert!( !target.exists(), @@ -2074,6 +2153,155 @@ node: ); } + #[test] + fn ephemeral_start_leaves_a_key_in_place_when_the_aside_name_is_taken() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + let key_path = temp_dir.path().join("fips.key"); + let aside_path = temp_dir.path().join("fips.key.unused"); + + fs::write(&config_path, "node:\n identity: {}\n").unwrap(); + let current = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key()); + let earlier = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key()); + write_key_file(&key_path, ¤t).unwrap(); + write_key_file(&aside_path, &earlier).unwrap(); + let key_bytes = fs::read(&key_path).unwrap(); + let aside_bytes = fs::read(&aside_path).unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + let (resolved, logs) = + capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap()); + + assert!(matches!(resolved.source, IdentitySource::Ephemeral)); + assert_ne!(resolved.nsec, current); + assert_eq!( + fs::read(&key_path).unwrap(), + key_bytes, + "the key must be left in place, not overwritten" + ); + assert_eq!( + fs::read(&aside_path).unwrap(), + aside_bytes, + "the file already at the aside path must not be replaced" + ); + let warnings = logs.warnings(); + assert!( + warnings + .iter() + .any(|w| w.contains(&key_path.display().to_string()) + && w.contains(&aside_path.display().to_string()) + && w.contains("node.identity.persistent") + && w.contains("could not be moved aside")), + "expected the could-not-move warning naming both paths and the config key, got {warnings:?}" + ); + } + + #[cfg(unix)] + #[test] + fn ephemeral_start_proceeds_when_the_key_cannot_be_moved() { + use std::os::unix::fs::PermissionsExt; + + /// Puts the directory's mode back when dropped, so a failing + /// assertion does not leave a read-only directory behind that + /// `TempDir` cannot remove. + struct RestoreMode<'a>(&'a Path); + impl Drop for RestoreMode<'_> { + fn drop(&mut self) { + let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755)); + } + } + + // Coverage gap: root bypasses directory permissions, so the rename + // succeeds and this branch goes unexercised when the suite runs as + // root. The aside-name-taken test still covers the same warning. + if unsafe { libc::geteuid() } == 0 { + eprintln!("skipped: running as root, which a read-only directory does not stop"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + let key_path = temp_dir.path().join("fips.key"); + + fs::write(&config_path, "node:\n identity: {}\n").unwrap(); + let existing = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key()); + write_key_file(&key_path, &existing).unwrap(); + let planted = fs::read(&key_path).unwrap(); + let config = Config::load_file(&config_path).unwrap(); + + fs::set_permissions(temp_dir.path(), fs::Permissions::from_mode(0o555)).unwrap(); + let _restore = RestoreMode(temp_dir.path()); + + let (resolved, logs) = + capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap()); + + assert!(matches!(resolved.source, IdentitySource::Ephemeral)); + assert_ne!(resolved.nsec, existing); + assert_eq!( + fs::read(&key_path).unwrap(), + planted, + "a key that cannot be moved must be left as it was, not overwritten" + ); + let warnings = logs.warnings(); + assert!( + warnings + .iter() + .any(|w| w.contains(&key_path.display().to_string()) + && w.contains("could not be moved aside") + && w.contains("os error")), + "expected a warning naming the key path and the rename error, got {warnings:?}" + ); + } + + #[test] + fn ephemeral_restarts_never_leave_a_private_key_file() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + let pub_path = temp_dir.path().join("fips.pub"); + + fs::write(&config_path, "node:\n identity: {}\n").unwrap(); + let config = Config::load_file(&config_path).unwrap(); + + for start in 1..=3 { + let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + assert_eq!( + dir_names(temp_dir.path()), + ["fips.pub", "fips.yaml"], + "after start {start} the directory must hold only the config and the public key" + ); + assert_eq!( + fs::read_to_string(&pub_path).unwrap().trim(), + resolved_npub(&resolved), + "after start {start} fips.pub must name the running identity" + ); + } + } + + #[test] + fn persistent_start_ignores_a_key_set_aside() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("fips.yaml"); + let key_path = temp_dir.path().join("fips.key"); + let aside_path = temp_dir.path().join("fips.key.unused"); + + fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap(); + let earlier = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key()); + write_key_file(&aside_path, &earlier).unwrap(); + let aside_bytes = fs::read(&aside_path).unwrap(); + + let config = Config::load_file(&config_path).unwrap(); + let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); + + assert!(matches!(resolved.source, IdentitySource::Generated(_))); + assert_ne!(resolved.nsec, earlier); + assert_eq!(read_key_file(&key_path).unwrap(), resolved.nsec); + assert_eq!( + fs::read(&aside_path).unwrap(), + aside_bytes, + "a persistent start must leave the key set aside untouched" + ); + } + #[cfg(unix)] #[test] fn test_persistent_permissive_key_warns() { @@ -2205,11 +2433,18 @@ node: let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap(); assert!(matches!(resolved.source, IdentitySource::Ephemeral)); - // Key files should still be written for operator visibility + // Only the public key is written: an ephemeral private key lives in + // memory and nowhere else. let key_path = temp_dir.path().join("fips.key"); let pub_path = temp_dir.path().join("fips.pub"); - assert!(key_path.exists()); - assert!(pub_path.exists()); + assert_eq!( + key_path.symlink_metadata().unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + assert_eq!( + fs::read_to_string(&pub_path).unwrap().trim(), + resolved_npub(&resolved) + ); } #[test]