Unverified Commit b94f2597 authored by Michele Guerini Rocco's avatar Michele Guerini Rocco Committed by GitHub
Browse files

nixos/wireless: reimplement secrets using ext_password_backend (#180872)

parents a2fb8b9c 89eb93dc
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -197,6 +197,9 @@
  moved into the top level scope (i.e., `budgie.budgie-desktop` is now
  `budgie-desktop`)

- The method to safely handle secrets in the `networking.wireless` module has been changed to benefit from a [new feature](https://w1.fi/cgit/hostap/commit/?id=e680a51e94a33591f61edb210926bcb71217a21a) of wpa_supplicant.
  The syntax to refer to secrets has changed slightly and the option `networking.wireless.environmentFile` has been replaced by `networking.wireless.secretsFile`; see the description of the latter for how to upgrade.

- All Cinnamon and XApp packages have been moved to top-level (i.e., `cinnamon.nemo` is now `nemo`).

- All GNOME packages have been moved to top-level (i.e., `gnome.nautilus` is now `nautilus`).
+82 −80
Original line number Diff line number Diff line
@@ -45,20 +45,11 @@ let
        "update_config=1"
      ])
    ++ [ "pmf=1" ]
    ++ optional (cfg.secretsFile != null)
      "ext_password_backend=file:${cfg.secretsFile}"
    ++ optional cfg.scanOnLowSignal ''bgscan="simple:30:-70:3600"''
    ++ optional (cfg.extraConfig != "") cfg.extraConfig);

  configIsGenerated = with cfg;
    networks != {} || extraConfig != "" || userControlled.enable;

  # the original configuration file
  configFile =
    if configIsGenerated
      then pkgs.writeText "wpa_supplicant.conf" generatedConfig
      else "/etc/wpa_supplicant.conf";
  # the config file with environment variables replaced
  finalConfig = ''"$RUNTIME_DIRECTORY"/wpa_supplicant.conf'';

  # Creates a network block for wpa_supplicant.conf
  mkNetwork = opts:
  let
@@ -90,8 +81,8 @@ let
    let
      deviceUnit = optional (iface != null) "sys-subsystem-net-devices-${utils.escapeSystemdPath iface}.device";
      configStr = if cfg.allowAuxiliaryImperativeNetworks
        then "-c /etc/wpa_supplicant.conf -I ${finalConfig}"
        else "-c ${finalConfig}";
        then "-c /etc/wpa_supplicant.conf -I ${pkgs.writeText "wpa_supplicant.conf" generatedConfig}"
        else "-c /etc/wpa_supplicant.conf";
    in {
      description = "WPA Supplicant instance" + optionalString (iface != null) " for interface ${iface}";

@@ -109,37 +100,14 @@ let
      serviceConfig.UMask = "066";
      serviceConfig.RuntimeDirectory = "wpa_supplicant";
      serviceConfig.RuntimeDirectoryMode = "700";
      serviceConfig.EnvironmentFile = mkIf (cfg.environmentFile != null)
        (builtins.toString cfg.environmentFile);

      script =
      ''
        ${optionalString (configIsGenerated && !cfg.allowAuxiliaryImperativeNetworks) ''
          if [ -f /etc/wpa_supplicant.conf ]; then
            echo >&2 "<3>/etc/wpa_supplicant.conf present but ignored. Generated ${configFile} is used instead."
          fi
        ''}

        # ensure wpa_supplicant.conf exists, or the daemon will fail to start
        ${optionalString cfg.allowAuxiliaryImperativeNetworks ''
          touch /etc/wpa_supplicant.conf
        ''}

        # substitute environment variables
        if [ -f "${configFile}" ]; then
          ${pkgs.gawk}/bin/awk '{
            for(varname in ENVIRON) {
              find = "@"varname"@"
              repl = ENVIRON[varname]
              if (i = index($0, find))
                $0 = substr($0, 1, i-1) repl substr($0, i+length(find))
            }
            print
          }' "${configFile}" > ${finalConfig}
        else
          touch ${finalConfig}
        fi

        iface_args="-s ${optionalString cfg.dbusControlled "-u"} -D${cfg.driver} ${configStr}"

        ${if iface == null then ''
@@ -231,36 +199,34 @@ in {
        '';
      };

      environmentFile = mkOption {
      secretsFile = mkOption {
        type = types.nullOr types.path;
        default = null;
        example = "/run/secrets/wireless.env";
        example = "/run/secrets/wireless.conf";
        description = ''
          File consisting of lines of the form `varname=value`
          to define variables for the wireless configuration.

          See section "EnvironmentFile=" in {manpage}`systemd.exec(5)` for a syntax reference.

          Secrets (PSKs, passwords, etc.) can be provided without adding them to
          the world-readable Nix store by defining them in the environment file and
          referring to them in option {option}`networking.wireless.networks`
          with the syntax `@varname@`. Example:
          the world-readable Nix store by defining them in the secrets file and
          referring to them in option [](#opt-networking.wireless.networks)
          with the syntax `ext:secretname`. Example:

          ```
          # content of /run/secrets/wireless.env
          PSK_HOME=mypassword
          PASS_WORK=myworkpassword
          ```
          # content of /run/secrets/wireless.conf
          psk_home=mypassword
          psk_other=6a381cea59c7a2d6b30736ba0e6f397f7564a044bcdb7a327a1d16a1ed91b327
          pass_work=myworkpassword

          ```
          # wireless-related configuration
          networking.wireless.environmentFile = "/run/secrets/wireless.env";
          networking.wireless.secretsFile = "/run/secrets/wireless.conf";
          networking.wireless.networks = {
            home.psk = "@PSK_HOME@";
            home.pskRaw = "ext:psk_home";
            other.pskRaw = "ext:psk_other";
            work.auth = '''
              eap=PEAP
              identity="my-user@example.com"
              password="@PASS_WORK@"
              password=ext:pass_work
            ''';
          };
          ```
@@ -271,15 +237,16 @@ in {
        type = types.attrsOf (types.submodule {
          options = {
            psk = mkOption {
              type = types.nullOr types.str;
              type = types.nullOr (types.strMatching "[[:print:]]{8,63}");
              default = null;
              description = ''
                The network's pre-shared key in plaintext defaulting
                to being a network without any authentication.

                ::: {.warning}
                Be aware that this will be written to the nix store
                in plaintext! Use an environment variable instead.
                Be aware that this will be written to the Nix store
                in plaintext! Use {var}`pskRaw` with an external
                reference to keep it safe.
                :::

                ::: {.note}
@@ -289,19 +256,28 @@ in {
            };

            pskRaw = mkOption {
              type = types.nullOr types.str;
              type = types.nullOr
                (types.strMatching "([[:xdigit:]]{64})|(ext:[^=]+)");
              default = null;
              example = "ext:name_of_the_secret_here";
              description = ''
                The network's pre-shared key in hex defaulting
                to being a network without any authentication.
                Either the raw pre-shared key in hexadecimal format
                or the name of the secret (as defined inside
                [](#opt-networking.wireless.secretsFile) and prefixed
                with `ext:`) containing the network pre-shared key.

                ::: {.warning}
                Be aware that this will be written to the nix store
                in plaintext! Use an environment variable instead.
                Be aware that this will be written to the Nix store
                in plaintext! Always use an external reference.
                :::

                ::: {.note}
                The external secret can be either the plaintext
                passphrase or the raw pre-shared key.
                :::

                ::: {.note}
                Mutually exclusive with {var}`psk`.
                Mutually exclusive with {var}`psk` and {var}`auth`.
                :::
              '';
            };
@@ -354,22 +330,21 @@ in {
              example = ''
                eap=PEAP
                identity="user@example.com"
                password="@EXAMPLE_PASSWORD@"
                password=ext:example_password
              '';
              description = ''
                Use this option to configure advanced authentication methods like EAP.
                See
                {manpage}`wpa_supplicant.conf(5)`
                for example configurations.
                Use this option to configure advanced authentication methods
                like EAP. See {manpage}`wpa_supplicant.conf(5)` for example
                configurations.

                ::: {.warning}
                Be aware that this will be written to the nix store
                in plaintext! Use an environment variable for secrets.
                Be aware that this will be written to the Nix store
                in plaintext! Use an external reference like
                `ext:secretname` for secrets.
                :::

                ::: {.note}
                Mutually exclusive with {var}`psk` and
                {var}`pskRaw`.
                Mutually exclusive with {var}`psk` and {var}`pskRaw`.
                :::
              '';
            };
@@ -393,13 +368,14 @@ in {
              type = types.nullOr types.int;
              default = null;
              description = ''
                By default, all networks will get same priority group (0). If some of the
                networks are more desirable, this field can be used to change the order in
                which wpa_supplicant goes through the networks when selecting a BSS. The
                priority groups will be iterated in decreasing priority (i.e., the larger the
                priority value, the sooner the network is matched against the scan results).
                Within each priority group, networks will be selected based on security
                policy, signal strength, etc.
                By default, all networks will get same priority group (0). If
                some of the networks are more desirable, this field can be used
                to change the order in which wpa_supplicant goes through the
                networks when selecting a BSS. The priority groups will be
                iterated in decreasing priority (i.e., the larger the priority
                value, the sooner the network is matched against the scan
                results). Within each priority group, networks will be selected
                based on security policy, signal strength, etc.
              '';
            };

@@ -411,9 +387,7 @@ in {
              '';
              description = ''
                Extra configuration lines appended to the network block.
                See
                {manpage}`wpa_supplicant.conf(5)`
                for available options.
                See {manpage}`wpa_supplicant.conf(5)` for available options.
              '';
            };

@@ -432,7 +406,7 @@ in {
            };

            echelon = {                   # safe version of the above: read PSK from the
              psk = "@PSK_ECHELON@";      # variable PSK_ECHELON, defined in environmentFile,
              pskRaw = "ext:psk_echelon"; # variable psk_echelon, defined in secretsFile,
            };                            # this won't leak into /nix/store

            "echelon's AP" = {            # SSID with spaces and/or special characters
@@ -493,6 +467,31 @@ in {
    };
  };

  imports = [
    (mkRemovedOptionModule [ "networking" "wireless" "environmentFile" ]
    ''
      Secrets are now handled by the `networking.wireless.secretsFile` and
      `networking.wireless.networks.<name>.pskRaw` options.
      The change is motivated by a mechanism recently added by wpa_supplicant
      itself to separate secrets from configuration, making the previous
      method obsolete.

      The syntax of the `secretsFile` is the same as before, except the
      values are interpreted literally, unlike environment variables.
      To update, remove quotes or character escapes, if necessary, and
      apply the following changes to your configuration:
        {
          home.psk = "@psk_home@";          →  home.pskRaw = "ext:psk_home";
          other.pskRaw = "@psk_other@";     →  other.pskRaw = "ext:psk_other";
          work.auth = '''
            eap=PEAP
            identity="my-user@example.com"
            password=@pass_work@            →  password=ext:pass_work
          ''';
        }
    '')
  ];

  config = mkIf cfg.enable {
    assertions = flip mapAttrsToList cfg.networks (name: cfg: {
      assertion = with cfg; count (x: x != null) [ psk pskRaw auth ] <= 1;
@@ -517,6 +516,9 @@ in {

    hardware.wirelessRegulatoryDatabase = true;

    environment.etc."wpa_supplicant.conf" =
      lib.mkIf (!cfg.allowAuxiliaryImperativeNetworks) { text = generatedConfig; };

    environment.systemPackages = [ pkgs.wpa_supplicant ];
    services.dbus.packages = optional cfg.dbusControlled pkgs.wpa_supplicant;

+1 −1
Original line number Diff line number Diff line
@@ -1101,7 +1101,7 @@ in {
  without-nix = handleTest ./without-nix.nix {};
  wmderland = handleTest ./wmderland.nix {};
  workout-tracker = handleTest ./workout-tracker.nix {};
  wpa_supplicant = handleTest ./wpa_supplicant.nix {};
  wpa_supplicant = import ./wpa_supplicant.nix { inherit pkgs runTest; };
  wordpress = handleTest ./wordpress.nix {};
  wrappers = handleTest ./wrappers.nix {};
  writefreely = handleTest ./web-apps/writefreely.nix {};
+117 −142
Original line number Diff line number Diff line
import ./make-test-python.nix ({ pkgs, lib, ...}:
{
  name = "wpa_supplicant";
{ pkgs, runTest }:

let

  inherit (pkgs) lib;

  meta = with lib.maintainers; {
    maintainers = [ oddlama rnhmjoj ];
  };

  nodes = let
    machineWithHostapd = extraConfigModule: { ... }: {
      imports = [
        ../modules/profiles/minimal.nix
        extraConfigModule
      ];
  runConnectionTest = name: extraConfig: runTest {
    name = "wpa_supplicant-${name}";
    inherit meta;

    nodes.machine = {
      # add a virtual wlan interface
      boot.kernelModules = [ "mac80211_hwsim" ];

@@ -53,7 +54,8 @@ import ./make-test-python.nix ({ pkgs, lib, ...}:
      };

      # wireless client
      networking.wireless = {
      networking.wireless = lib.mkMerge [
        {
          # the override is needed because the wifi is
          # disabled with mkVMOverride in qemu-vm.nix.
          enable = lib.mkOverride 0 true;
@@ -61,19 +63,40 @@ import ./make-test-python.nix ({ pkgs, lib, ...}:
          interfaces = [ "wlan1" ];
          fallbackToWPA2 = lib.mkDefault true;

        # networks will be added on-demand below for the specific
        # network that should be tested

          # secrets
        environmentFile = pkgs.writeText "wpa-secrets" ''
          PSK_NIXOS_TEST="reproducibility"
          secretsFile = pkgs.writeText "wpa-secrets" ''
            psk_nixos_test=reproducibility
          '';
        }
        extraConfig
      ];
    };

    testScript = ''
      # save hostapd config file for manual inspection
      machine.wait_for_unit("hostapd.service")
      machine.copy_from_vm("/run/hostapd/wlan0.hostapd.conf")

      with subtest("Daemon can connect to the access point"):
          machine.wait_for_unit("wpa_supplicant-wlan1.service")
          machine.wait_until_succeeds(
            "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED"
          )
    '';
  };
  in {
    basic = { ... }: {
      imports = [ ../modules/profiles/minimal.nix ];

in

{
  # Test the basic setup:
  #   - automatic interface discovery
  #   - WPA2 fallbacks
  #   - connecting to the daemon
  basic = runTest {
    name = "wpa_supplicant-basic";
    inherit meta;

    nodes.machine = {
      # add a virtual wlan interface
      boot.kernelModules = [ "mac80211_hwsim" ];

@@ -83,7 +106,6 @@ import ./make-test-python.nix ({ pkgs, lib, ...}:
        # disabled with mkVMOverride in qemu-vm.nix.
        enable = lib.mkOverride 0 true;
        userControlled.enable = true;
        interfaces = [ "wlan1" ];
        fallbackToWPA2 = true;

        networks = {
@@ -96,28 +118,34 @@ import ./make-test-python.nix ({ pkgs, lib, ...}:
            psk = "password";
            authProtocols = [ "SAE" ];
          };

          # secrets substitution test cases
          test1.psk = "@PSK_VALID@";              # should be replaced
          test2.psk = "@PSK_SPECIAL@";            # should be replaced
          test3.psk = "@PSK_MISSING@";            # should not be replaced
          test4.psk = "P@ssowrdWithSome@tSymbol"; # should not be replaced
          test5.psk = "@PSK_AWK_REGEX@";          # should be replaced
        };
      };
    };

        # secrets
        environmentFile = pkgs.writeText "wpa-secrets" ''
          PSK_VALID="S0m3BadP4ssw0rd";
          # taken from https://github.com/minimaxir/big-list-of-naughty-strings
          PSK_SPECIAL=",./;'[]\/\-= <>?:\"{}|_+ !@#$%^&*()`~";
          PSK_AWK_REGEX="PassowrdWith&symbol";
    testScript = ''
      config_file = "/etc/static/wpa_supplicant.conf"

      with subtest("Daemon is running and accepting connections"):
          machine.wait_for_unit("wpa_supplicant.service")
          status = machine.wait_until_succeeds("wpa_cli status")
          assert "Failed to connect" not in status, \
                 "Failed to connect to the daemon"

      with subtest("WPA2 fallbacks have been generated"):
          assert int(machine.succeed(f"grep -c sae-only {config_file}")) == 1
          assert int(machine.succeed(f"grep -c mixed-wpa {config_file}")) == 2

      # save file for manual inspection
      machine.copy_from_vm(config_file)
    '';
  };
    };

    imperative = { ... }: {
      imports = [ ../modules/profiles/minimal.nix ];
  # Test configuring the daemon imperatively
  imperative = runTest {
    name = "wpa_supplicant-imperative";
    inherit meta;

    nodes.machine = {
      # add a virtual wlan interface
      boot.kernelModules = [ "mac80211_hwsim" ];

@@ -130,108 +158,55 @@ import ./make-test-python.nix ({ pkgs, lib, ...}:
      };
    };

    # Test connecting to the SAE-only hotspot using SAE
    machineSae = machineWithHostapd {
      networking.wireless = {
    testScript = ''
      with subtest("Daemon is running and accepting connections"):
          machine.wait_for_unit("wpa_supplicant-wlan1.service")
          status = machine.wait_until_succeeds("wpa_cli -i wlan1 status")
          assert "Failed to connect" not in status, \
                 "Failed to connect to the daemon"

      with subtest("Daemon can be configured imperatively"):
          machine.succeed("wpa_cli -i wlan1 add_network")
          machine.succeed("wpa_cli -i wlan1 set_network 0 ssid '\"nixos-test\"'")
          machine.succeed("wpa_cli -i wlan1 set_network 0 psk '\"reproducibility\"'")
          machine.succeed("wpa_cli -i wlan1 save_config")
          machine.succeed("grep -q nixos-test /etc/wpa_supplicant.conf")
    '';
  };

  # Test connecting to a SAE-only hotspot using SAE
  saeOnly = runConnectionTest "sae-only" {
    fallbackToWPA2 = false;
    networks.nixos-test-sae = {
          psk = "@PSK_NIXOS_TEST@";
      pskRaw = "ext:psk_nixos_test";
      authProtocols = [ "SAE" ];
    };
  };
    };

    # Test connecting to the SAE and WPA2 mixed hotspot using SAE
    machineMixedUsingSae = machineWithHostapd {
      networking.wireless = {
  # Test connecting to a mixed SAE/WPA2 hotspot using SAE
  mixedUsingSae = runConnectionTest "mixed-using-sae" {
    fallbackToWPA2 = false;
    networks.nixos-test-mixed = {
          psk = "@PSK_NIXOS_TEST@";
      pskRaw = "ext:psk_nixos_test";
      authProtocols = [ "SAE" ];
    };
  };
    };

    # Test connecting to the SAE and WPA2 mixed hotspot using WPA2
    machineMixedUsingWpa2 = machineWithHostapd {
      networking.wireless = {
  # Test connecting to a mixed SAE/WPA2 hotspot using WPA2
  mixedUsingWpa2 = runConnectionTest "mixed-using-wpa2" {
    fallbackToWPA2 = true;
    networks.nixos-test-mixed = {
          psk = "@PSK_NIXOS_TEST@";
      pskRaw = "ext:psk_nixos_test";
      authProtocols = [ "WPA-PSK-SHA256" ];
    };
  };
    };

    # Test connecting to the WPA2 legacy hotspot using WPA2
    machineWpa2 = machineWithHostapd {
      networking.wireless = {
  # Test connecting to a legacy WPA2-only hotspot using WPA2
  legacy = runConnectionTest "legacy" {
    fallbackToWPA2 = true;
    networks.nixos-test-wpa2 = {
          psk = "@PSK_NIXOS_TEST@";
      pskRaw = "ext:psk_nixos_test";
      authProtocols = [ "WPA-PSK-SHA256" ];
    };
  };
    };
  };

  testScript =
    ''
      config_file = "/run/wpa_supplicant/wpa_supplicant.conf"

      with subtest("Configuration file is inaccessible to other users"):
          basic.wait_for_file(config_file)
          basic.fail(f"sudo -u nobody ls {config_file}")

      with subtest("Secrets variables have been substituted"):
          basic.fail(f"grep -q @PSK_VALID@ {config_file}")
          basic.fail(f"grep -q @PSK_SPECIAL@ {config_file}")
          basic.succeed(f"grep -q @PSK_MISSING@ {config_file}")
          basic.succeed(f"grep -q P@ssowrdWithSome@tSymbol {config_file}")
          basic.succeed(f"grep -q 'PassowrdWith&symbol' {config_file}")

      with subtest("WPA2 fallbacks have been generated"):
          assert int(basic.succeed(f"grep -c sae-only {config_file}")) == 1
          assert int(basic.succeed(f"grep -c mixed-wpa {config_file}")) == 2

      # save file for manual inspection
      basic.copy_from_vm(config_file)

      with subtest("Daemon is running and accepting connections"):
          basic.wait_for_unit("wpa_supplicant-wlan1.service")
          status = basic.succeed("wpa_cli -i wlan1 status")
          assert "Failed to connect" not in status, \
                 "Failed to connect to the daemon"

      with subtest("Daemon can be configured imperatively"):
          imperative.wait_for_unit("wpa_supplicant-wlan1.service")
          imperative.wait_until_succeeds("wpa_cli -i wlan1 status")
          imperative.succeed("wpa_cli -i wlan1 add_network")
          imperative.succeed("wpa_cli -i wlan1 set_network 0 ssid '\"nixos-test\"'")
          imperative.succeed("wpa_cli -i wlan1 set_network 0 psk '\"reproducibility\"'")
          imperative.succeed("wpa_cli -i wlan1 save_config")
          imperative.succeed("grep -q nixos-test /etc/wpa_supplicant.conf")

      machineSae.wait_for_unit("hostapd.service")
      machineSae.copy_from_vm("/run/hostapd/wlan0.hostapd.conf")
      with subtest("Daemon can connect to the SAE access point using SAE"):
          machineSae.wait_until_succeeds(
            "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED"
          )

      with subtest("Daemon can connect to the SAE and WPA2 mixed access point using SAE"):
          machineMixedUsingSae.wait_until_succeeds(
            "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED"
          )

      with subtest("Daemon can connect to the SAE and WPA2 mixed access point using WPA2"):
          machineMixedUsingWpa2.wait_until_succeeds(
            "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED"
          )

      with subtest("Daemon can connect to the WPA2 access point using WPA2"):
          machineWpa2.wait_until_succeeds(
            "wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED"
          )
    '';
})
}