From b0c36a9d7d8b9d917c17fba702f308107d05fcb3 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Thu, 1 Oct 2026 03:20:29 +0000 Subject: [PATCH] Warn on Windows about fips.yaml and fips.key in \etc\fips The config search still probes the drive-relative \etc\fips\fips.yaml on Windows, where any local user can create files, and merges it under the real config. A node upgraded from a hand-made service that ran from \etc\fips now reads only C:\ProgramData\fips and silently comes up with a new identity. Log a warning when a config was loaded from \etc\fips, saying the search stops looking there in v0.6.0; it is logged before identity resolution, so a start that fails on a key the file supplied still names the file. After resolution, warn when fips.yaml or fips.key is there but this run did not use it, counting a key generated this run as in use. Paths are compared the way Windows does, with either separator, any case and any drive prefix, so an explicit C:\etc\fips\fips.yaml given with -c or FIPS_CONFIG is recognised. The decision is a pure function tested on every platform; the key is only checked for presence, never read. --- packaging/windows/build-zip.ps1 | 6 +- src/bin/fips.rs | 10 ++ src/config/mod.rs | 271 +++++++++++++++++++++++++++++++- 3 files changed, 283 insertions(+), 4 deletions(-) diff --git a/packaging/windows/build-zip.ps1 b/packaging/windows/build-zip.ps1 index 027c145f..d54c64e2 100644 --- a/packaging/windows/build-zip.ps1 +++ b/packaging/windows/build-zip.ps1 @@ -138,9 +138,9 @@ Configuration: %USERPROFILE%\.fips.yaml and .\fips.yaml. The key file sits beside the last config loaded. \etc\fips\fips.yaml was the system config of earlier releases, and any local user can - create it; the daemon warns when it loads it, and v0.6.0 stops - reading it. Move what you need from it into - C:\ProgramData\fips\fips.yaml and delete it. + create it; the daemon warns when it loads it, and from v0.6.0 + the search no longer looks there. Move what you need from it + into C:\ProgramData\fips\fips.yaml and delete it. A service that earlier releases ran from \etc\fips reads only C:\ProgramData\fips once install-service.ps1 has run, diff --git a/src/bin/fips.rs b/src/bin/fips.rs index 52bfaf00..f89276c9 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -145,6 +145,11 @@ async fn run_daemon( #[cfg(windows)] fips::config::warn_legacy(&loaded_paths); + // Earlier releases could run a Windows node from \etc\fips, where any + // local user can create files; flag a config the search loaded there. + #[cfg(windows)] + fips::config::warn_legacy_etc_config(&loaded_paths); + // Identity provisioning: config nsec > key file > generate ephemeral let mut resolved = match resolve_identity(&config, &loaded_paths) { Ok(r) => r, @@ -164,6 +169,11 @@ async fn run_daemon( IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"), } + // Flag a config or key left in \etc\fips that this run did not use. After + // identity resolution, which decides whether that key was used. + #[cfg(windows)] + fips::config::warn_legacy_etc_unused(&loaded_paths, &resolved.source); + // Create node with resolved identity let mut config = config; // Take the nsec rather than move it: `ResolvedIdentity` clears its copy diff --git a/src/config/mod.rs b/src/config/mod.rs index f323214b..1bacf9e9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -171,6 +171,128 @@ pub fn warn_legacy(loaded: &[PathBuf]) { } } +/// A file in the drive-relative `\etc\fips` that a Windows run reports. +/// +/// Earlier releases could run a Windows node from `\etc\fips`, a directory +/// any local user can create files in, and the config search still probes +/// `fips.yaml` there. +#[cfg(any(windows, test))] +#[derive(Debug, PartialEq, Eq)] +enum EtcFile { + /// `fips.yaml` there was loaded, found by the config search or named + /// with `-c` or `FIPS_CONFIG`. + Loaded(PathBuf), + /// `fips.yaml` or `fips.key` is there and this run did not use it, so a + /// node that used to run from it now runs on another config or identity. + Unused(PathBuf), +} + +/// Whether two paths name the same file in the way Windows compares them: +/// either separator, any case, and with any drive prefix ignored. +/// +/// The drive-relative `/etc/fips\fips.yaml` the config search probes and an +/// explicit `C:\etc\fips\fips.yaml` given with `-c` or `FIPS_CONFIG` are +/// different `Path`s, since one has a drive prefix, and `Path` comparison is +/// case-sensitive while Windows file names are not. Comparing the text lets +/// the rule run on every platform. +#[cfg(any(windows, test))] +fn same_windows_path(a: &Path, b: &Path) -> bool { + fn key(path: &Path) -> String { + let text = path.to_string_lossy().replace('/', "\\"); + let text = text.strip_prefix(r"\\?\").unwrap_or(&text); + let text = match text.as_bytes() { + [drive, b':', ..] if drive.is_ascii_alphabetic() => &text[2..], + _ => text, + }; + text.to_ascii_lowercase() + } + key(a) == key(b) +} + +/// Decide what to report about `fips.yaml` and `fips.key` in `etc`. +/// +/// `loaded` is the list of config files the daemon loaded, and `key_used` +/// the key file its identity came from, if any. `present` reports whether a +/// file exists; nothing else about the files is looked at, and the key is +/// never read. +#[cfg(any(windows, test))] +fn etc_files( + loaded: &[PathBuf], + key_used: Option<&Path>, + etc: &Path, + present: impl Fn(&Path) -> bool, +) -> Vec { + let config = etc.join(CONFIG_FILENAME); + let key = etc.join(KEY_FILENAME); + let mut found = Vec::new(); + if let Some(path) = loaded.iter().find(|p| same_windows_path(p, &config)) { + found.push(EtcFile::Loaded(path.clone())); + } else if present(config.as_path()) { + found.push(EtcFile::Unused(config)); + } + if !key_used.is_some_and(|k| same_windows_path(k, &key)) && present(key.as_path()) { + found.push(EtcFile::Unused(key)); + } + found +} + +/// The key file this run's identity is held in: the one it loaded, or the one +/// it generated and saved. An identity from the config, or an ephemeral one, +/// uses no key file. +#[cfg(any(windows, test))] +fn key_in_use(identity: &IdentitySource) -> Option<&Path> { + match identity { + IdentitySource::KeyFile(path) | IdentitySource::Generated(path) => Some(path), + IdentitySource::Config | IdentitySource::Ephemeral => None, + } +} + +/// Warn about a config loaded from the drive-relative `\etc\fips`. +/// +/// Any local user may have written it, whether the config search found it or +/// it was named explicitly, and from v0.6.0 the search no longer looks +/// there. Called before identity resolution, so the warning is logged even +/// when a key the file supplies fails to resolve. +#[cfg(windows)] +pub fn warn_legacy_etc_config(loaded: &[PathBuf]) { + let etc = Path::new(LEGACY_SYSTEM_CONFIG_DIR); + // With nothing reported present, only a loaded config can be found. + for file in etc_files(loaded, None, etc, |_| false) { + if let EtcFile::Loaded(path) = file { + tracing::warn!( + path = %path.display(), + current = %Path::new(SYSTEM_CONFIG_DIR).join(CONFIG_FILENAME).display(), + "Config loaded from \\etc\\fips, where any local user can create files; \ + from v0.6.0 the config search no longer looks there. Move the settings \ + this node needs into C:\\ProgramData\\fips\\fips.yaml and delete the file" + ); + } + } +} + +/// Warn about `fips.yaml` or `fips.key` left in the drive-relative +/// `\etc\fips` and not used by this run. +/// +/// A node that ran from them before an upgrade now has a different config, +/// and possibly a different identity. Called after identity resolution, +/// which decides which key file the run uses. +#[cfg(windows)] +pub fn warn_legacy_etc_unused(loaded: &[PathBuf], identity: &IdentitySource) { + let etc = Path::new(LEGACY_SYSTEM_CONFIG_DIR); + for file in etc_files(loaded, key_in_use(identity), etc, Path::exists) { + if let EtcFile::Unused(path) = file { + tracing::warn!( + path = %path.display(), + current = %Path::new(SYSTEM_CONFIG_DIR).display(), + "File in \\etc\\fips is not used by this run; a node that ran from it \ + before now runs on another config or identity. If it is this node's, \ + move fips.yaml and fips.key into C:\\ProgramData\\fips and run \ + install-service.ps1 again; if not, delete it" + ); + } + } +} + /// Find an identity key stranded at the legacy system config directory. /// /// Adding a second system config directory to the search path moves the @@ -1064,7 +1186,7 @@ impl Config { // System config — /etc/fips is always probed so existing installs // keep working after an upgrade. - paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME)); + paths.push(PathBuf::from(LEGACY_SYSTEM_CONFIG_DIR).join(CONFIG_FILENAME)); // macOS and FreeBSD packaging install config under /usr/local/etc/fips, // and the Windows service installer under C:\ProgramData\fips; probe @@ -1890,6 +2012,153 @@ node: ); } + /// A presence check that reports exactly `files` as existing. + fn only(files: &[&Path]) -> impl Fn(&Path) -> bool { + let files: Vec = files.iter().map(|f| f.to_path_buf()).collect(); + move |p| files.iter().any(|f| f == p) + } + + #[test] + fn etc_files_reports_a_config_the_search_loaded_from_etc_fips() { + let etc = Path::new("/etc-legacy/fips"); + let planted = etc.join(CONFIG_FILENAME); + let system = Path::new("/sys-cfg/fips").join(CONFIG_FILENAME); + + assert_eq!( + etc_files( + &[planted.clone(), system.clone()], + None, + etc, + only(&[&planted]) + ), + [EtcFile::Loaded(planted.clone())], + "a config loaded from \\etc\\fips under the real one must be reported as loaded" + ); + assert_eq!( + etc_files( + std::slice::from_ref(&planted), + Some(&etc.join(KEY_FILENAME)), + etc, + only(&[&planted, &etc.join(KEY_FILENAME)]) + ), + [EtcFile::Loaded(planted)], + "a node running from \\etc\\fips is told the config goes away, and its key, \ + which it uses, is not reported" + ); + } + + #[test] + fn etc_files_reports_a_config_and_key_left_in_etc_fips_that_the_run_did_not_use() { + let etc = Path::new("/etc-legacy/fips"); + let old_config = etc.join(CONFIG_FILENAME); + let old_key = etc.join(KEY_FILENAME); + let system = Path::new("/sys-cfg/fips"); + let loaded = [system.join(CONFIG_FILENAME)]; + + assert_eq!( + etc_files(&loaded, None, etc, only(&[&old_config, &old_key])), + [ + EtcFile::Unused(old_config.clone()), + EtcFile::Unused(old_key.clone()) + ], + "after an upgrade to FIPS_CONFIG the old config and key must both be reported" + ); + assert_eq!( + etc_files( + &loaded, + Some(&system.join(KEY_FILENAME)), + etc, + only(&[&old_key]) + ), + [EtcFile::Unused(old_key)], + "a key left in \\etc\\fips while the identity comes from another key file \ + must be reported" + ); + assert_eq!( + etc_files(&loaded, None, etc, only(&[&old_config])), + [EtcFile::Unused(old_config)], + "a config left in \\etc\\fips and not loaded must be reported" + ); + } + + #[test] + fn same_windows_path_ignores_the_drive_the_separator_and_case() { + let probed = PathBuf::from("/etc/fips").join(CONFIG_FILENAME); + for explicit in [ + r"C:\etc\fips\fips.yaml", + r"c:/ETC/Fips/FIPS.yaml", + r"\\?\C:\etc\fips\fips.yaml", + r"\etc\fips\fips.yaml", + ] { + assert!( + same_windows_path(&probed, Path::new(explicit)), + "{explicit} must match the probed /etc/fips\\fips.yaml" + ); + } + for other in [ + r"C:\ProgramData\fips\fips.yaml", + r"C:\etc\fips\fips.key", + r"C:etc\fips\fips.yaml", + r"etc\fips\fips.yaml", + r"C:\x\etc\fips\fips.yaml", + ] { + assert!( + !same_windows_path(&probed, Path::new(other)), + "{other} must not match the probed /etc/fips\\fips.yaml" + ); + } + } + + #[test] + fn etc_files_reports_an_explicit_drive_path_to_etc_fips_as_loaded_and_its_key_as_used() { + let etc = PathBuf::from("/etc/fips"); + let explicit = PathBuf::from(r"C:\etc\fips\fips.yaml"); + let key = PathBuf::from(r"C:\etc\fips\fips.key"); + + assert_eq!( + etc_files(std::slice::from_ref(&explicit), Some(&key), &etc, |_| true), + [EtcFile::Loaded(explicit)], + "a config named as C:\\etc\\fips\\fips.yaml is the legacy file, loaded, and \ + the key beside it is in use" + ); + } + + #[test] + fn key_in_use_counts_a_key_generated_this_run_as_well_as_one_loaded() { + let key = PathBuf::from("/etc-legacy/fips").join(KEY_FILENAME); + assert_eq!( + key_in_use(&IdentitySource::KeyFile(key.clone())), + Some(key.as_path()) + ); + assert_eq!( + key_in_use(&IdentitySource::Generated(key.clone())), + Some(key.as_path()), + "a key generated and saved this run is the one in use, not an unused leftover" + ); + assert_eq!(key_in_use(&IdentitySource::Config), None); + assert_eq!(key_in_use(&IdentitySource::Ephemeral), None); + } + + #[test] + fn etc_files_reports_nothing_when_etc_fips_holds_neither_file() { + let etc = Path::new("/etc-legacy/fips"); + let system = Path::new("/sys-cfg/fips"); + let config = system.join(CONFIG_FILENAME); + let key = system.join(KEY_FILENAME); + + assert_eq!( + etc_files( + std::slice::from_ref(&config), + Some(&key), + etc, + only(&[&config, &key]) + ), + Vec::new(), + "files present only in the current directory must not be reported" + ); + assert_eq!(etc_files(&[], None, etc, only(&[])), Vec::new()); + } + #[test] fn windows_key_fallback_finds_a_key_in_the_old_appdata_dir() { let root = TempDir::new().unwrap();