From 05a42bcc4be7cf96eeab7c41721797242a1a23b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:40:16 +0000 Subject: [PATCH] feat: harden btcpay and nbxplorer config handling Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- flake.nix | 3 + modules/bitcoin/bitcoind.nix | 29 ++++++- modules/bitcoin/btcpayserver.nix | 89 ++++++++++++++----- tests/bitcoin-btcpay-hardening.nix | 132 +++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 tests/bitcoin-btcpay-hardening.nix diff --git a/flake.nix b/flake.nix index d3a98be..d79d8aa 100644 --- a/flake.nix +++ b/flake.nix @@ -65,6 +65,9 @@ mempoolPkgs = pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; }; in { + bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix { + inherit nixpkgs overlay-stable; + }; mempool-backend = mempoolPkgs.mempool-backend; mempool-frontend = mempoolPkgs.mempool-frontend; rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; }; diff --git a/modules/bitcoin/bitcoind.nix b/modules/bitcoin/bitcoind.nix index ba97135..e33e37b 100644 --- a/modules/bitcoin/bitcoind.nix +++ b/modules/bitcoin/bitcoind.nix @@ -406,7 +406,8 @@ in { extraRpcauth = concatMapStrings (name: let user = cfg.rpc.users.${name}; in optionalString user.passwordHMACFromFile '' - echo "rpcauth=${user.name}:$(cat ${secretsDir}/bitcoin-HMAC-${name})" + hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1 + printf '%s\n' "rpcauth=${user.name}:$hmacPayload" '' ) (builtins.attrNames cfg.rpc.users); in '' @@ -416,6 +417,32 @@ in { fi ''} + readValidatedRpcHmac() { + local hmacFile="$1" + local hmacPayload + + if [[ ! -e "$hmacFile" ]]; then + echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2 + exit 1 + fi + if [[ ! -r "$hmacFile" ]]; then + echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2 + exit 1 + fi + + hmacPayload="$(<"$hmacFile")" + if [[ -z "$hmacPayload" ]]; then + echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2 + exit 1 + fi + if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then + echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2 + exit 1 + fi + + printf '%s\n' "$hmacPayload" + } + cfg=$( cat ${configFile} ${extraRpcauth} diff --git a/modules/bitcoin/btcpayserver.nix b/modules/bitcoin/btcpayserver.nix index e0a60ec..9dbcafb 100644 --- a/modules/bitcoin/btcpayserver.nix +++ b/modules/bitcoin/btcpayserver.nix @@ -139,29 +139,43 @@ let }; nbLib = config.nix-bitcoin.lib; + secretsDir = config.nix-bitcoin.secretsDir; in { inherit options; config = mkMerge [ (mkIf cfg.nbxplorer.enable { - # For backwards compatibility only - systemd.tmpfiles.rules = mkIf cfg.nbxplorer.addNetworkSymlink [ - "L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'" - ]; + systemd.tmpfiles.rules = [ + "d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -" + ] ++ optional cfg.nbxplorer.addNetworkSymlink + "L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'"; - systemd.services.nbxplorer = rec { + systemd.services.nbxplorer = let + configFile = builtins.toFile "nbxplorer-config" '' + network=${cfg.bitcoind.network} + btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name} + btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port} + btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort} + bind=${cfg.nbxplorer.address} + port=${toString cfg.nbxplorer.port} + postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer + ''; + in rec { wantedBy = [ "multi-user.target" ]; - requires = [ "bitcoind.service" ]; - after = requires; + requires = [ "postgresql.target" ]; + wants = [ "bitcoind.service" ]; + after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ]; preStart = '' - { - echo "btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}" - echo "btcrpcpassword=$(cat ${config.nix-bitcoin.secretsDir}/bitcoin-rpcpassword-btcpayserver)" - } >> '${cfg.nbxplorer.dataDir}/settings.config' + install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config' + printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \ + >> '${cfg.nbxplorer.dataDir}/settings.config' ''; serviceConfig = nbLib.defaultHardening // { - ExecStart = "${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config"; + ExecStart = '' + ${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \ + --datadir='${cfg.nbxplorer.dataDir}' + ''; RuntimeDirectory = "nbxplorer"; StateDirectory = "nbxplorer"; User = cfg.nbxplorer.user; @@ -169,11 +183,13 @@ in { Restart = "on-failure"; RestartSec = "10s"; ReadWritePaths = [ cfg.nbxplorer.dataDir ]; + MemoryDenyWriteExecute = false; } // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce; }; services.bitcoind = { enable = true; + listenWhitelisted = true; txindex = true; }; @@ -188,6 +204,7 @@ in { services.nbxplorer.enable = true; services.bitcoind = { + listenWhitelisted = true; rpc.users.btcpayserver = { name = "btcpayserver"; passwordHMACFromFile = true; @@ -222,18 +239,40 @@ in { }; }; - systemd.services.btcpayserver = rec { + systemd.tmpfiles.rules = [ + "d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -" + ]; + + systemd.services.btcpayserver = let + nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/"; + configFile = builtins.toFile "btcpayserver-config" ( + '' + network=${cfg.bitcoind.network} + bind=${cfg.btcpayserver.address} + port=${toString cfg.btcpayserver.port} + socksendpoint=${config.nix-bitcoin.torClientAddressWithPort} + btcexplorerurl=${nbExplorerUrl} + explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer + postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver + '' + optionalString (cfg.btcpayserver.lightningBackend == "lnd") + ( + "btclightning=type=lnd-rest;" + + "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;" + + "macaroonfilepath=/run/lnd/btcpayserver.macaroon;" + + "certfilepath=${config.services.lnd.certPath}\n" + ) + ); + in rec { wantedBy = [ "multi-user.target" ]; - requires = [ "nbxplorer.service" ]; - after = requires; - preStart = '' - { - echo "postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver" - echo "explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer" - } >> '${cfg.btcpayserver.dataDir}/settings.config' - ''; + requires = [ "postgresql.target" ]; + wants = [ "nbxplorer.service" ] + ++ optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service"; + after = requires ++ wants; serviceConfig = nbLib.defaultHardening // { - ExecStart = "${cfg.btcpayserver.package}/bin/btcpayserver --conf=${cfg.btcpayserver.dataDir}/settings.config"; + ExecStart = '' + ${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \ + --datadir='${cfg.btcpayserver.dataDir}' + ''; RuntimeDirectory = "btcpayserver"; StateDirectory = "btcpayserver"; User = cfg.btcpayserver.user; @@ -241,6 +280,7 @@ in { Restart = "on-failure"; RestartSec = "10s"; ReadWritePaths = [ cfg.btcpayserver.dataDir ]; + MemoryDenyWriteExecute = false; } // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce; }; @@ -261,7 +301,10 @@ in { users.groups.${cfg.btcpayserver.group} = {}; nix-bitcoin.secrets = { - bitcoin-rpcpassword-btcpayserver.user = cfg.btcpayserver.user; + bitcoin-rpcpassword-btcpayserver = { + user = cfg.bitcoind.user; + group = cfg.nbxplorer.group; + }; bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user; }; nix-bitcoin.generateSecretsCmds.btcpayserver = '' diff --git a/tests/bitcoin-btcpay-hardening.nix b/tests/bitcoin-btcpay-hardening.nix new file mode 100644 index 0000000..818786f --- /dev/null +++ b/tests/bitcoin-btcpay-hardening.nix @@ -0,0 +1,132 @@ +{ nixpkgs, overlay-stable, system ? "x86_64-linux" }: + +let + lib = nixpkgs.lib; + pkgs = import nixpkgs { + inherit system; + overlays = [ overlay-stable ]; + }; + + normalize = s: + lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s; + + extractAfter = prefix: str: + let + match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str); + in + if match == null then + throw "Unable to extract ${prefix} from: ${normalize str}" + else + builtins.head match; + + extractFlagValue = flag: str: + let + match = builtins.match ".*${flag}=([^ ]+).*" (normalize str); + in + if match == null then + throw "Unable to extract ${flag} from: ${normalize str}" + else + builtins.head match; + + config = (lib.nixosSystem { + inherit system; + modules = [ + { nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; } + ../modules/bitcoin + { + nix-bitcoin.generateSecrets = true; + nix-bitcoin.secretsDir = "/build/secrets"; + + services.btcpayserver.enable = true; + services.btcpayserver.lightningBackend = "lnd"; + services.nbxplorer.dataDir = "/build/nbxplorer"; + services.btcpayserver.dataDir = "/build/btcpayserver"; + services.lnd.dataDir = "/build/lnd"; + services.bitcoind.dataDir = "/build/bitcoind"; + } + ]; + }).config; + + nbxplorerPreStart = config.systemd.services.nbxplorer.preStart; + bitcoindPreStart = config.systemd.services.bitcoind.preStart; + btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart; + + nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart; + btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart; + + nbxplorerConfig = builtins.readFile nbxplorerConfigPath; + btcpayConfig = builtins.readFile btcpayConfigPath; +in +assert lib.assertMsg + (config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user) + "bitcoin-HMAC-btcpayserver must be owned by bitcoind"; +assert lib.assertMsg + (config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user) + "bitcoin-rpcpassword-btcpayserver must be owned by bitcoind"; +assert lib.assertMsg + (config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group) + "bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer"; +assert lib.assertMsg + (!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups)) + "btcpayserver must not receive the nbxplorer group"; +assert lib.assertMsg + (lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after) + "nbxplorer must wait for nix-bitcoin-secrets.target"; +assert lib.assertMsg + (config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false) + "nbxplorer needs MemoryDenyWriteExecute = false"; +assert lib.assertMsg + (config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false) + "btcpayserver needs MemoryDenyWriteExecute = false"; +assert lib.assertMsg + (lib.hasInfix "network=mainnet" nbxplorerConfig + && lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig + && lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig + && lib.hasInfix "bind=127.0.0.1" nbxplorerConfig + && lib.hasInfix "port=24444" nbxplorerConfig + && lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig) + "nbxplorer base config must contain the expected non-secret settings"; +assert lib.assertMsg + (!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart + && lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart) + "btcpayserver must use a deterministic config file plus --datadir"; +assert lib.assertMsg + (lib.hasInfix "network=mainnet" btcpayConfig + && lib.hasInfix "bind=127.0.0.1" btcpayConfig + && lib.hasInfix "port=23000" btcpayConfig + && lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig + && lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig + && lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig + && lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig) + "btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings"; +assert lib.assertMsg + (lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart + && lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart + && lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart + && lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart + && lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart + && lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart + && lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart) + "bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files"; +pkgs.runCommand "bitcoin-btcpay-hardening" {} '' + mkdir -p /build/secrets /build/nbxplorer + + printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver + bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart} + + test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600" + test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1" + test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1" + test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1" + + printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver + bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart} + + test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1" + test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1" + test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1" + ! grep -q 'first-password' /build/nbxplorer/settings.config + grep -q 'rotated-password' /build/nbxplorer/settings.config + + touch "$out" +''