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.
This commit is contained in:
Johnathan Corgan
2026-10-01 14:20:06 +00:00
parent ee453c56ea
commit b0c36a9d7d
3 changed files with 283 additions and 4 deletions
+3 -3
View File
@@ -138,9 +138,9 @@ Configuration:
%USERPROFILE%\.fips.yaml and .\fips.yaml. The key file sits %USERPROFILE%\.fips.yaml and .\fips.yaml. The key file sits
beside the last config loaded. \etc\fips\fips.yaml was the beside the last config loaded. \etc\fips\fips.yaml was the
system config of earlier releases, and any local user can system config of earlier releases, and any local user can
create it; the daemon warns when it loads it, and v0.6.0 stops create it; the daemon warns when it loads it, and from v0.6.0
reading it. Move what you need from it into the search no longer looks there. Move what you need from it
C:\ProgramData\fips\fips.yaml and delete it. into C:\ProgramData\fips\fips.yaml and delete it.
A service that earlier releases ran from \etc\fips reads A service that earlier releases ran from \etc\fips reads
only C:\ProgramData\fips once install-service.ps1 has run, only C:\ProgramData\fips once install-service.ps1 has run,
+10
View File
@@ -145,6 +145,11 @@ async fn run_daemon(
#[cfg(windows)] #[cfg(windows)]
fips::config::warn_legacy(&loaded_paths); 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 // Identity provisioning: config nsec > key file > generate ephemeral
let mut resolved = match resolve_identity(&config, &loaded_paths) { let mut resolved = match resolve_identity(&config, &loaded_paths) {
Ok(r) => r, Ok(r) => r,
@@ -164,6 +169,11 @@ async fn run_daemon(
IdentitySource::Ephemeral => info!("Using ephemeral identity (new keypair each start)"), 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 // Create node with resolved identity
let mut config = config; let mut config = config;
// Take the nsec rather than move it: `ResolvedIdentity` clears its copy // Take the nsec rather than move it: `ResolvedIdentity` clears its copy
+270 -1
View File
@@ -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<EtcFile> {
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. /// Find an identity key stranded at the legacy system config directory.
/// ///
/// Adding a second system config directory to the search path moves the /// 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 // System config — /etc/fips is always probed so existing installs
// keep working after an upgrade. // 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, // macOS and FreeBSD packaging install config under /usr/local/etc/fips,
// and the Windows service installer under C:\ProgramData\fips; probe // 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<PathBuf> = 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] #[test]
fn windows_key_fallback_finds_a_key_in_the_old_appdata_dir() { fn windows_key_fallback_finds_a_key_in_the_old_appdata_dir() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();