From 97deac13b2f413159ae9b04bdf7b97615fb56c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 1 Aug 2026 20:13:51 -0400 Subject: [PATCH 1/3] Assert secret-bearing paths are not Nix store paths --- nixos/frost-gate.nix | 9 +++++++++ nixos/ingress.nix | 12 ++++++++++++ nixos/vault-replication.nix | 9 +++++++++ tests/ha-failover.nix | 18 +++++++++++++----- tests/ingress.nix | 14 ++++++++++++-- tests/mesh-replication.nix | 21 +++++++++++++++++---- tests/oprf-gate-2of3.nix | 11 ++++++++++- tests/oprf-gate.nix | 13 ++++++++++--- 8 files changed, 92 insertions(+), 15 deletions(-) diff --git a/nixos/frost-gate.nix b/nixos/frost-gate.nix index c048ade..1d7d7dc 100644 --- a/nixos/frost-gate.nix +++ b/nixos/frost-gate.nix @@ -831,6 +831,15 @@ in assertion = cfg.volumeDevice != null; message = "keepNode.frostGate.enable requires keepNode.frostGate.volumeDevice."; } + { + # Loaded as a systemd EnvironmentFile holding KEEP_PASSWORD. Its + # description says to pass a runtime path; nothing enforced it, so a + # Nix-path literal would put the vault password in /nix/store at 0444. + assertion = + cfg.keepPasswordEnvFile == null + || !(lib.hasPrefix builtins.storeDir (toString cfg.keepPasswordEnvFile)); + message = "keepNode.frostGate.keepPasswordEnvFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: KEEP_PASSWORD would be world-readable in /nix/store."; + } { assertion = cfg.quorum.threshold <= cfg.quorum.total && cfg.quorum.threshold >= 1; message = "keepNode.frostGate.quorum: need 1 <= threshold <= total."; diff --git a/nixos/ingress.nix b/nixos/ingress.nix index 49d613e..4fa417e 100644 --- a/nixos/ingress.nix +++ b/nixos/ingress.nix @@ -102,6 +102,18 @@ in assertion = config.keepNode.vaultwarden.enable; message = "keepNode.ingress requires keepNode.vaultwarden.enable (it proxies to Vaultwarden)."; } + { + # Both descriptions say to pass a runtime path; nothing enforced it. A + # Nix-path literal copies the TLS private key into /nix/store at 0444. + assertion = + cfg.tlsKeyFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsKeyFile)); + message = "keepNode.ingress.tlsKeyFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the TLS private key would be world-readable in /nix/store."; + } + { + assertion = + cfg.tlsCertFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsCertFile)); + message = "keepNode.ingress.tlsCertFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path."; + } ]; # Vaultwarden must know its public URL (links, WebAuthn, etc.) and must trust the proxy's diff --git a/nixos/vault-replication.nix b/nixos/vault-replication.nix index 0ce784e..f61797d 100644 --- a/nixos/vault-replication.nix +++ b/nixos/vault-replication.nix @@ -197,6 +197,15 @@ in assertion = config.services.vaultwarden.enable; message = "keepNode.vaultReplication.rsaKeyFile is set but services.vaultwarden.enable is false: the shared-key installer has nothing to seed a key for."; } + { + # The option description already says "not a Nix-path literal"; nothing + # enforced it. `rsaKeyFile = "${./secrets/rsa_key.pem}"` type-checks and + # copies the cluster-wide JWT signing key into /nix/store at 0444, and CI + # pushes store paths to a public Cachix cache. Mirrors the assertions + # keepWeb and mesh already carry for the same wording. + assertion = !(lib.hasPrefix builtins.storeDir (toString cfg.rsaKeyFile)); + message = "keepNode.vaultReplication.rsaKeyFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the cluster's JWT signing key would be world-readable in /nix/store."; + } ]; systemd.services.keep-node-vault-rsa-key = { diff --git a/tests/ha-failover.nix b/tests/ha-failover.nix index 77853ba..9ebd35d 100644 --- a/tests/ha-failover.nix +++ b/tests/ha-failover.nix @@ -22,20 +22,28 @@ nvpnPackage, ... }: +let + rsaDir = "/run/keep-node-vault-rsa"; +in { name = "keep-node-ha-failover"; # nodeA and nodeB are identical; share one module across both. # - # Test-only: this deliberately does the anti-pattern the rsaKeyFile option warns against, feeding - # a Nix-store path so the key sits world-readable in /nix/store. Safe here because the fixture is - # an ephemeral per-build key, never a real secret. A real deploy must pass an out-of-band path. defaults = { pkgs, ... }: { imports = [ ../nixos/keep-node.nix ]; - keepNode.vaultReplication.rsaKeyFile = "${vaultRsaKeyFixture}/rsa_key.pem"; - keepNode.vaultReplication.rsaKeyPubFile = "${vaultRsaKeyFixture}/rsa_key.pub.pem"; + # Staged into /run rather than referenced in the store directly: the + # rsaKeyFile assertion rejects store paths, because a real deploy passing + # one would leave the cluster's JWT signing key world-readable in + # /nix/store. Same tmpfiles "C" copy the mesh tests use for identityDir. + systemd.tmpfiles.rules = [ + "C ${rsaDir}/rsa_key.pem 0600 root root - ${vaultRsaKeyFixture}/rsa_key.pem" + "C ${rsaDir}/rsa_key.pub.pem 0644 root root - ${vaultRsaKeyFixture}/rsa_key.pub.pem" + ]; + keepNode.vaultReplication.rsaKeyFile = "${rsaDir}/rsa_key.pem"; + keepNode.vaultReplication.rsaKeyPubFile = "${rsaDir}/rsa_key.pub.pem"; # sqlite: write/read the WAL probe; litestream: restore the replica on the peer. environment.systemPackages = [ pkgs.sqlite diff --git a/tests/ingress.nix b/tests/ingress.nix index 39617e0..307a88b 100644 --- a/tests/ingress.nix +++ b/tests/ingress.nix @@ -4,6 +4,7 @@ { ... }: let hostName = "vault.test"; + tlsDir = "/run/keep-node-ingress-tls"; in { name = "keep-node-ingress"; @@ -22,11 +23,20 @@ in imports = [ ../nixos/keep-node.nix ]; keepNode.vaultwarden.enable = true; + # Staged into /run rather than referenced in the store directly: the + # tlsKeyFile assertion rejects store paths, because a real deploy passing + # one would leave the TLS private key world-readable in /nix/store. Same + # tmpfiles "C" copy the mesh tests use for identityDir. + systemd.tmpfiles.rules = [ + "C ${tlsDir}/cert.pem 0644 root root - ${cert}/cert.pem" + "C ${tlsDir}/key.pem 0600 root root - ${cert}/key.pem" + ]; + keepNode.ingress = { enable = true; hostName = hostName; - tlsCertFile = "${cert}/cert.pem"; - tlsKeyFile = "${cert}/key.pem"; + tlsCertFile = "${tlsDir}/cert.pem"; + tlsKeyFile = "${tlsDir}/key.pem"; }; environment.systemPackages = [ diff --git a/tests/mesh-replication.nix b/tests/mesh-replication.nix index 6045c19..67cc818 100644 --- a/tests/mesh-replication.nix +++ b/tests/mesh-replication.nix @@ -12,6 +12,9 @@ vaultRsaKeyFixture, ... }: +let + rsaDir = "/run/keep-node-vault-rsa"; +in { name = "keep-node-mesh-replication"; @@ -19,13 +22,18 @@ { pkgs, ... }: { imports = [ ../nixos/keep-node.nix ]; + # Staged into /run: the rsaKeyFile assertion rejects store paths, because a + # real deploy passing one would leave the cluster's JWT signing key + # world-readable in /nix/store. Same tmpfiles "C" copy the mesh tests use. + systemd.tmpfiles.rules = [ + "C ${rsaDir}/rsa_key.pem 0600 root root - ${vaultRsaKeyFixture}/rsa_key.pem" + ]; keepNode.mesh = { enable = true; package = nvpnPackage; }; keepNode.vaultReplication = { - # Test-only anti-pattern (exactly what rsaKeyFile warns against): a Nix-store path leaves the key world-readable in /nix/store. Safe only because this is an ephemeral per-build fixture, never a real cluster signing key; a real deploy must pass an out-of-band path. - rsaKeyFile = "${vaultRsaKeyFixture}/rsa_key.pem"; + rsaKeyFile = "${rsaDir}/rsa_key.pem"; litestream.enable = true; role = "active"; meshReplication.enable = true; @@ -39,13 +47,18 @@ { pkgs, ... }: { imports = [ ../nixos/keep-node.nix ]; + # Staged into /run: the rsaKeyFile assertion rejects store paths, because a + # real deploy passing one would leave the cluster's JWT signing key + # world-readable in /nix/store. Same tmpfiles "C" copy the mesh tests use. + systemd.tmpfiles.rules = [ + "C ${rsaDir}/rsa_key.pem 0600 root root - ${vaultRsaKeyFixture}/rsa_key.pem" + ]; keepNode.mesh = { enable = true; package = nvpnPackage; }; keepNode.vaultReplication = { - # Test-only anti-pattern (exactly what rsaKeyFile warns against): a Nix-store path leaves the key world-readable in /nix/store. Safe only because this is an ephemeral per-build fixture, never a real cluster signing key; a real deploy must pass an out-of-band path. - rsaKeyFile = "${vaultRsaKeyFixture}/rsa_key.pem"; + rsaKeyFile = "${rsaDir}/rsa_key.pem"; role = "standby"; meshReplication.enable = true; # Low threshold so the lag test can prove the healthy->stale transition without a long wait. diff --git a/tests/oprf-gate-2of3.nix b/tests/oprf-gate-2of3.nix index 0e3c175..f3a7718 100644 --- a/tests/oprf-gate-2of3.nix +++ b/tests/oprf-gate-2of3.nix @@ -22,6 +22,8 @@ }: let vwClient = import ./lib/vw-client.nix { inherit pkgs; }; + passEnvFile = "/run/keep-node-oprf/keep-pass-env"; + passEnvFixture = pkgs.writeText "keep-pass-env" "KEEP_PASSWORD=fixturepass123"; in { name = "keep-node-oprf-gate-2of3-test"; @@ -58,6 +60,13 @@ in }: { imports = [ ../nixos/keep-node.nix ]; + # Staged into /run: the keepPasswordEnvFile assertion rejects store paths, + # because a real deploy passing one would leave KEEP_PASSWORD world-readable + # in /nix/store. The consuming provision unit is operator-run and not + # wantedBy boot, so it cannot race systemd-tmpfiles-setup. + systemd.tmpfiles.rules = [ + "C ${passEnvFile} 0600 root root - ${passEnvFixture}" + ]; keepNode.frostGate = { enable = true; @@ -81,7 +90,7 @@ in bootUnlockTimeoutSec = 100; keepPasswordCred = "/var/lib/keep-node/keep-password.cred"; oprfShareCred = "/var/lib/keep-node/oprf-share.cred"; - keepPasswordEnvFile = "${pkgs.writeText "keep-pass-env" "KEEP_PASSWORD=fixturepass123"}"; + keepPasswordEnvFile = passEnvFile; tpmTcti = "device:/dev/tpmrm0"; allowInsecureWs = true; }; diff --git a/tests/oprf-gate.nix b/tests/oprf-gate.nix index c9e08e9..6b58bdf 100644 --- a/tests/oprf-gate.nix +++ b/tests/oprf-gate.nix @@ -23,6 +23,8 @@ let # Headless Bitwarden client for the M0 vault round-trip (crypto validated by the vw-client-check # test): register an account, store a login, read it back -- no browser, no bw/rbw agent. vwClient = import ./lib/vw-client.nix { inherit pkgs; }; + passEnvFile = "/run/keep-node-oprf/keep-pass-env"; + passEnvFixture = pkgs.writeText "keep-pass-env" "KEEP_PASSWORD=fixturepass123"; in { name = "keep-node-oprf-gate-test"; @@ -59,6 +61,13 @@ in }: { imports = [ ../nixos/keep-node.nix ]; + # Staged into /run: the keepPasswordEnvFile assertion rejects store paths, + # because a real deploy passing one would leave KEEP_PASSWORD world-readable + # in /nix/store. The consuming provision unit is operator-run and not + # wantedBy boot, so it cannot race systemd-tmpfiles-setup. + systemd.tmpfiles.rules = [ + "C ${passEnvFile} 0600 root root - ${passEnvFixture}" + ]; keepNode.frostGate = { enable = true; @@ -76,9 +85,7 @@ in }; keepPasswordCred = "/var/lib/keep-node/keep-password.cred"; oprfShareCred = "/var/lib/keep-node/oprf-share.cred"; - # Test-only: the provision unit reads KEEP_PASSWORD from here. A real deploy uses a - # 0400 secret, not a store path. Matches the fixture DB's password. - keepPasswordEnvFile = "${pkgs.writeText "keep-pass-env" "KEEP_PASSWORD=fixturepass123"}"; + keepPasswordEnvFile = passEnvFile; tpmTcti = "device:/dev/tpmrm0"; # Test-only: forward KEEP_ALLOW_WS into the confined boot scope so it can reach the in-VM # ws:// relay. Never set in production, where the boot OPRF exchange must stay over wss://. From 0cc962abddd87f598f3e10a0446503ac04ec0196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sat, 1 Aug 2026 20:56:28 -0400 Subject: [PATCH 2/3] Let nginx read the staged key, and format --- nixos/ingress.nix | 3 +-- tests/ingress.nix | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/nixos/ingress.nix b/nixos/ingress.nix index 4fa417e..f8260ef 100644 --- a/nixos/ingress.nix +++ b/nixos/ingress.nix @@ -105,8 +105,7 @@ in { # Both descriptions say to pass a runtime path; nothing enforced it. A # Nix-path literal copies the TLS private key into /nix/store at 0444. - assertion = - cfg.tlsKeyFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsKeyFile)); + assertion = cfg.tlsKeyFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsKeyFile)); message = "keepNode.ingress.tlsKeyFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the TLS private key would be world-readable in /nix/store."; } { diff --git a/tests/ingress.nix b/tests/ingress.nix index 307a88b..71e5c94 100644 --- a/tests/ingress.nix +++ b/tests/ingress.nix @@ -27,9 +27,14 @@ in # tlsKeyFile assertion rejects store paths, because a real deploy passing # one would leave the TLS private key world-readable in /nix/store. Same # tmpfiles "C" copy the mesh tests use for identityDir. + # The key is owned by nginx, not root: the module points + # services.nginx.sslCertificateKey straight at this path, and nginx runs + # its config test unprivileged, so a root-only mode fails the pre-start + # with "cannot load certificate key ... Permission denied". A real deploy + # has the same requirement. The certificate is public, so it stays 0644. systemd.tmpfiles.rules = [ "C ${tlsDir}/cert.pem 0644 root root - ${cert}/cert.pem" - "C ${tlsDir}/key.pem 0600 root root - ${cert}/key.pem" + "C ${tlsDir}/key.pem 0400 nginx nginx - ${cert}/key.pem" ]; keepNode.ingress = { From 068de0baa34bc47966823ce0430621082e7dbec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Sun, 2 Aug 2026 11:26:48 -0400 Subject: [PATCH 3/3] Anchor the store-path guards at a path boundary --- nixos/frost-gate.nix | 2 +- nixos/ingress.nix | 5 +++-- nixos/keep-web.nix | 9 +++++---- nixos/mesh.nix | 2 +- nixos/vault-replication.nix | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/nixos/frost-gate.nix b/nixos/frost-gate.nix index 1d7d7dc..df0d885 100644 --- a/nixos/frost-gate.nix +++ b/nixos/frost-gate.nix @@ -837,7 +837,7 @@ in # Nix-path literal would put the vault password in /nix/store at 0444. assertion = cfg.keepPasswordEnvFile == null - || !(lib.hasPrefix builtins.storeDir (toString cfg.keepPasswordEnvFile)); + || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.keepPasswordEnvFile)); message = "keepNode.frostGate.keepPasswordEnvFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: KEEP_PASSWORD would be world-readable in /nix/store."; } { diff --git a/nixos/ingress.nix b/nixos/ingress.nix index f8260ef..671232e 100644 --- a/nixos/ingress.nix +++ b/nixos/ingress.nix @@ -105,12 +105,13 @@ in { # Both descriptions say to pass a runtime path; nothing enforced it. A # Nix-path literal copies the TLS private key into /nix/store at 0444. - assertion = cfg.tlsKeyFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsKeyFile)); + assertion = + cfg.tlsKeyFile == null || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.tlsKeyFile)); message = "keepNode.ingress.tlsKeyFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the TLS private key would be world-readable in /nix/store."; } { assertion = - cfg.tlsCertFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.tlsCertFile)); + cfg.tlsCertFile == null || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.tlsCertFile)); message = "keepNode.ingress.tlsCertFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path."; } ]; diff --git a/nixos/keep-web.nix b/nixos/keep-web.nix index 0a40ce8..1756bad 100644 --- a/nixos/keep-web.nix +++ b/nixos/keep-web.nix @@ -129,18 +129,18 @@ in } { assertion = - cfg.passwordFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.passwordFile)); + cfg.passwordFile == null || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.passwordFile)); message = "keepNode.keepWeb.passwordFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the password would be world-readable in /nix/store."; } { assertion = - cfg.authTokenFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.authTokenFile)); + cfg.authTokenFile == null || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.authTokenFile)); message = "keepNode.keepWeb.authTokenFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the token would be world-readable in /nix/store."; } { assertion = cfg.stateIdentityFile == null - || !(lib.hasPrefix builtins.storeDir (toString cfg.stateIdentityFile)); + || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.stateIdentityFile)); message = "keepNode.keepWeb.stateIdentityFile must be a runtime path, not a Nix store path: the shared cluster nsec would be world-readable in /nix/store."; } { @@ -149,7 +149,8 @@ in } { assertion = - cfg.storageKeyFile == null || !(lib.hasPrefix builtins.storeDir (toString cfg.storageKeyFile)); + cfg.storageKeyFile == null + || !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.storageKeyFile)); message = "keepNode.keepWeb.storageKeyFile must be a runtime path, not a Nix store path: the shared cluster vault key would be world-readable in /nix/store."; } ]; diff --git a/nixos/mesh.nix b/nixos/mesh.nix index ac5a008..756f8fe 100644 --- a/nixos/mesh.nix +++ b/nixos/mesh.nix @@ -227,7 +227,7 @@ in # off the world-readable /nix/store. A path literal (`./secrets/a`) coerces into the store, 0444 # and pushed to any binary cache -- exactly the cleartext-key leak the FROST gate exists to stop. # Reject it at eval time instead of leaving it to the option's prose. - assertion = cfg.identityDir == null || !lib.hasPrefix builtins.storeDir cfg.identityDir; + assertion = cfg.identityDir == null || !lib.hasPrefix "${builtins.storeDir}/" cfg.identityDir; message = "keepNode.mesh.identityDir (${toString cfg.identityDir}) is inside the Nix store: that copies the mesh Nostr secret key into the world-readable /nix/store. Deliver it out-of-band to a path on the target host (e.g. /run/secrets/... via agenix/sops), never as a Nix path literal."; } { diff --git a/nixos/vault-replication.nix b/nixos/vault-replication.nix index f61797d..f731db3 100644 --- a/nixos/vault-replication.nix +++ b/nixos/vault-replication.nix @@ -203,7 +203,7 @@ in # copies the cluster-wide JWT signing key into /nix/store at 0444, and CI # pushes store paths to a public Cachix cache. Mirrors the assertions # keepWeb and mesh already carry for the same wording. - assertion = !(lib.hasPrefix builtins.storeDir (toString cfg.rsaKeyFile)); + assertion = !(lib.hasPrefix "${builtins.storeDir}/" (toString cfg.rsaKeyFile)); message = "keepNode.vaultReplication.rsaKeyFile must be a runtime path (e.g. /run/secrets/...), not a Nix store path: the cluster's JWT signing key would be world-readable in /nix/store."; } ];