mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 11:08:25 +00:00
Stop writing the identity key file in ephemeral mode
An ephemeral node wrote the private key of an identity it discards on every restart to fips.key, and overwrote any key already at that path, including an operator's key in the case where persistent: true was forgotten. It now writes only fips.pub, so the running npub stays visible, and holds the private key in memory only. A fips.key found at an ephemeral start is moved aside to fips.key.unused with a warning rather than used or overwritten, so an operator's key is recoverable and a stale key from an earlier release stops being read by fipsctl address. If that name is already taken or the rename fails, the file is left in place, a warning says so, and the start continues. Persistent and explicit-nsec identities are unchanged. The keygen note, the Windows service installer's closing note, and the documentation that described the old write are corrected, including the persistent identity how-to, which told operators to start once in ephemeral mode and then pin the key it wrote.
This commit is contained in:
@@ -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
|
The Debian/Ubuntu `.deb` and the Arch `fips` AUR package both ship a
|
||||||
default `/etc/fips/fips.yaml` with `node.identity.persistent` left as
|
default `/etc/fips/fips.yaml` with `node.identity.persistent` left as
|
||||||
the upstream default (false), so the daemon writes a fresh keypair to
|
the upstream default (false). In that mode the daemon generates a
|
||||||
`/etc/fips/fips.{key,pub}` on every start until you set
|
fresh keypair on every start, holds the private key only in memory,
|
||||||
`persistent: true`. To pin the current keypair:
|
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
|
1. Install the package, then edit `/etc/fips/fips.yaml` and set:
|
||||||
`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:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
node:
|
node:
|
||||||
@@ -55,22 +48,31 @@ the upstream default (false), so the daemon writes a fresh keypair to
|
|||||||
persistent: true
|
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
|
```sh
|
||||||
sudo systemctl restart fips
|
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"'
|
fipsctl show status | grep -E '"npub"|"node_addr"'
|
||||||
cat /etc/fips/fips.pub
|
cat /etc/fips/fips.pub
|
||||||
```
|
```
|
||||||
|
|
||||||
The npub printed by `fipsctl show status` should match
|
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 package's `postinst` script does **not** generate the keypair.
|
||||||
the daemon does, on first start. This means the keypair is only
|
The first successful daemon start with `persistent: true` does, so
|
||||||
present after the first successful daemon start. If the daemon never
|
`fips.key` is only present after that start. If the daemon never came
|
||||||
came up cleanly (config error, permission problem), the key files
|
up cleanly (config error, permission problem), the key file will be
|
||||||
will be missing.
|
missing.
|
||||||
|
|
||||||
### macOS note
|
### macOS note
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
leftover `/etc/fips` copy from an earlier install. Windows likewise
|
||||||
probes `\etc\fips` on the current drive, then `C:\ProgramData\fips`.
|
probes `\etc\fips` on the current drive, then `C:\ProgramData\fips`.
|
||||||
|
|
||||||
Adjacent to the highest-priority config file the daemon reads (or
|
Adjacent to the highest-priority config file the daemon keeps the
|
||||||
writes, on first start) the identity files:
|
identity files:
|
||||||
|
|
||||||
| File | Mode | Purpose |
|
| 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.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 corresponding to `fips.key`. |
|
| `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
|
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`,
|
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
|
rolled at 10 MiB with four old files kept; a foreground run logs to the
|
||||||
|
|||||||
@@ -108,8 +108,10 @@ Identity resolution follows a three-tier priority:
|
|||||||
3. **Ephemeral** — when `persistent: false` (default) and no `nsec`, generates a fresh
|
3. **Ephemeral** — when `persistent: false` (default) and no `nsec`, generates a fresh
|
||||||
keypair on each start
|
keypair on each start
|
||||||
|
|
||||||
Key files (`fips.key` with mode 0600, `fips.pub` with mode 0644) are written adjacent
|
`fips.pub` (mode 0644) is written adjacent to the highest-priority config file
|
||||||
to the highest-priority config file for operator visibility, even in ephemeral mode.
|
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
|
### General
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ operator-editable runtime state:
|
|||||||
| Path | Purpose | Writable | Seeded from |
|
| Path | Purpose | Writable | Seeded from |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `/var/lib/fips/fips.yaml` | Main config | yes | `services.fips.configFile` (first run only) |
|
| `/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.key` | Node identity (private) | yes | generated by fips on first start with `persistent: true` |
|
||||||
| `/var/lib/fips/fips.pub` | Node identity (public) | yes | generated by fips on first start |
|
| `/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/hosts` | Static hostname → npub map | yes | shipped `hosts` (first run only) |
|
||||||
| `/etc/fips/peers.allow` | Peer allowlist (ACL) | yes | operator-created |
|
| `/etc/fips/peers.allow` | Peer allowlist (ACL) | yes | operator-created |
|
||||||
| `/etc/fips/peers.deny` | Peer denylist (ACL) | yes | operator-created |
|
| `/etc/fips/peers.deny` | Peer denylist (ACL) | yes | operator-created |
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ sudo ./install.sh
|
|||||||
| fipstop (TUI) | /usr/local/bin/fipstop |
|
| fipstop (TUI) | /usr/local/bin/fipstop |
|
||||||
| fips-gateway (LAN bridge) | /usr/local/bin/fips-gateway |
|
| fips-gateway (LAN bridge) | /usr/local/bin/fips-gateway |
|
||||||
| Configuration | /etc/fips/fips.yaml |
|
| Configuration | /etc/fips/fips.yaml |
|
||||||
| Identity key | /etc/fips/fips.key (auto-generated) |
|
| Identity key | /etc/fips/fips.key (generated on first start with `persistent: true`) |
|
||||||
| Public key | /etc/fips/fips.pub (auto-generated) |
|
| Public key | /etc/fips/fips.pub (written on every start) |
|
||||||
| Hosts file | /etc/fips/hosts |
|
| Hosts file | /etc/fips/hosts |
|
||||||
| Firewall baseline | /etc/fips/fips.nft |
|
| Firewall baseline | /etc/fips/fips.nft |
|
||||||
| Firewall drop-in directory | /etc/fips/fips.d/ |
|
| Firewall drop-in directory | /etc/fips/fips.d/ |
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ Control Socket:
|
|||||||
|
|
||||||
Configuration:
|
Configuration:
|
||||||
The service reads C:\ProgramData\fips\fips.yaml, where
|
The service reads C:\ProgramData\fips\fips.yaml, where
|
||||||
install-service.ps1 puts it, and keeps fips.key, hosts,
|
install-service.ps1 puts it, and keeps hosts, peers.allow,
|
||||||
peers.allow and peers.deny beside it. Edit fips.yaml there
|
peers.deny and, with node.identity.persistent: true, fips.key
|
||||||
before starting the service.
|
beside it. Edit fips.yaml there before starting the service.
|
||||||
|
|
||||||
install-service.ps1 restricts C:\ProgramData\fips to SYSTEM
|
install-service.ps1 restricts C:\ProgramData\fips to SYSTEM
|
||||||
and Administrators before writing into it. Reading or editing
|
and Administrators before writing into it. Reading or editing
|
||||||
|
|||||||
+2
-1
@@ -484,7 +484,8 @@ mod service {
|
|||||||
"Configuration: the service reads {}",
|
"Configuration: the service reads {}",
|
||||||
dir.join("fips.yaml").display()
|
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!(
|
println!(
|
||||||
"Logs: the service writes {}",
|
"Logs: the service writes {}",
|
||||||
dir.join("fips.log").display()
|
dir.join("fips.log").display()
|
||||||
|
|||||||
+6
-2
@@ -593,8 +593,12 @@ fn main() {
|
|||||||
eprintln!("{npub}");
|
eprintln!("{npub}");
|
||||||
eprintln!("Key files written to: {}/", dir.display());
|
eprintln!("Key files written to: {}/", dir.display());
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
|
eprintln!(
|
||||||
eprintln!(" or these keys will be overwritten on next daemon start.");
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+265
-30
@@ -595,13 +595,60 @@ pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move a key file found at an ephemeral start to `<name>.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<PathBuf> {
|
||||||
|
// 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.
|
/// Resolve identity from config and key file.
|
||||||
///
|
///
|
||||||
/// Behavior depends on `node.identity.persistent`:
|
/// Behavior depends on `node.identity.persistent`:
|
||||||
///
|
///
|
||||||
/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
|
/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
|
||||||
/// every start. Key files are written for operator visibility but overwritten
|
/// every start. Only `fips.pub` is written, so the running npub is visible;
|
||||||
/// on each restart.
|
/// 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:
|
/// - **`persistent: true`**: use three-tier resolution:
|
||||||
/// 1. Explicit nsec in config — highest priority
|
/// 1. Explicit nsec in config — highest priority
|
||||||
@@ -737,8 +784,8 @@ pub fn resolve_identity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Ephemeral mode (default): fresh keypair every start, write key files
|
// Ephemeral mode (default): a fresh keypair every start, held only in
|
||||||
// for operator visibility
|
// memory. Only the public key file is written.
|
||||||
let identity = Identity::generate();
|
let identity = Identity::generate();
|
||||||
// `keypair()` and `secret_key()` each hand back a whole private key
|
// `keypair()` and `secret_key()` each hand back a whole private key
|
||||||
// rather than a handle, so both temporaries are bound and erased.
|
// 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);
|
let _ = std::fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// symlink_metadata rather than exists: a dangling symlink at the key
|
retire_key(&key_path);
|
||||||
// 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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if let Err(e) = write_pub_file(&pub_path, &npub) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
path = %pub_path.display(),
|
path = %pub_path.display(),
|
||||||
@@ -2020,29 +2050,64 @@ node:
|
|||||||
assert_eq!(fs::read_to_string(&victim).unwrap(), "victim contents\n");
|
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<String> {
|
||||||
|
let mut names: Vec<String> = 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]
|
#[test]
|
||||||
fn test_ephemeral_over_existing_key_warns() {
|
fn ephemeral_start_moves_an_existing_key_aside_intact() {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let config_path = temp_dir.path().join("fips.yaml");
|
let config_path = temp_dir.path().join("fips.yaml");
|
||||||
let key_path = temp_dir.path().join("fips.key");
|
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();
|
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||||
let identity = crate::Identity::generate();
|
let identity = crate::Identity::generate();
|
||||||
let existing = crate::encode_nsec(&identity.keypair().secret_key());
|
let existing = crate::encode_nsec(&identity.keypair().secret_key());
|
||||||
write_key_file(&key_path, &existing).unwrap();
|
write_key_file(&key_path, &existing).unwrap();
|
||||||
|
let planted = fs::read(&key_path).unwrap();
|
||||||
|
|
||||||
let config = Config::load_file(&config_path).unwrap();
|
let config = Config::load_file(&config_path).unwrap();
|
||||||
let (resolved, logs) =
|
let (resolved, logs) =
|
||||||
capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap());
|
capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap());
|
||||||
|
|
||||||
assert_ne!(resolved.nsec, existing);
|
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();
|
let warnings = logs.warnings();
|
||||||
assert!(
|
assert!(
|
||||||
warnings
|
warnings
|
||||||
.iter()
|
.iter()
|
||||||
.any(|w| w.contains(&key_path.display().to_string())
|
.any(|w| w.contains(&key_path.display().to_string())
|
||||||
&& w.contains("node.identity.persistent")),
|
&& w.contains(&aside_path.display().to_string())
|
||||||
"expected a warning naming the key path and the config key, got {warnings:?}"
|
&& 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 temp_dir = TempDir::new().unwrap();
|
||||||
let config_path = temp_dir.path().join("fips.yaml");
|
let config_path = temp_dir.path().join("fips.yaml");
|
||||||
let key_path = temp_dir.path().join("fips.key");
|
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");
|
let target = temp_dir.path().join("absent-target");
|
||||||
|
|
||||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||||
@@ -2065,8 +2131,21 @@ node:
|
|||||||
assert!(
|
assert!(
|
||||||
warnings
|
warnings
|
||||||
.iter()
|
.iter()
|
||||||
.any(|w| w.contains(&key_path.display().to_string())),
|
.any(|w| w.contains(&key_path.display().to_string())
|
||||||
"expected a warning naming the key path, got {warnings:?}"
|
&& 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!(
|
assert!(
|
||||||
!target.exists(),
|
!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)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_persistent_permissive_key_warns() {
|
fn test_persistent_permissive_key_warns() {
|
||||||
@@ -2205,11 +2433,18 @@ node:
|
|||||||
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
||||||
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
|
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 key_path = temp_dir.path().join("fips.key");
|
||||||
let pub_path = temp_dir.path().join("fips.pub");
|
let pub_path = temp_dir.path().join("fips.pub");
|
||||||
assert!(key_path.exists());
|
assert_eq!(
|
||||||
assert!(pub_path.exists());
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user