From cc6c2d32f297744e7ef7848fdf5b1886fa04ba4f Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Mon, 23 Oct 2023 19:29:30 +0200 Subject: [PATCH] rosenpass: refactor, add module and test (#254813) --- .../manual/release-notes/rl-2311.section.md | 2 + nixos/modules/module-list.nix | 1 + .../modules/services/networking/rosenpass.nix | 233 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/rosenpass.nix | 217 ++++++++++++++++ pkgs/tools/misc/envsubst/default.nix | 1 + pkgs/tools/networking/rosenpass/default.nix | 72 ++---- pkgs/tools/networking/rosenpass/tools.nix | 30 +++ pkgs/top-level/all-packages.nix | 2 + 9 files changed, 507 insertions(+), 52 deletions(-) create mode 100644 nixos/modules/services/networking/rosenpass.nix create mode 100644 nixos/tests/rosenpass.nix create mode 100644 pkgs/tools/networking/rosenpass/tools.nix diff --git a/nixos/doc/manual/release-notes/rl-2311.section.md b/nixos/doc/manual/release-notes/rl-2311.section.md index be4ce3c27d8b..bc2a15124275 100644 --- a/nixos/doc/manual/release-notes/rl-2311.section.md +++ b/nixos/doc/manual/release-notes/rl-2311.section.md @@ -121,6 +121,8 @@ - [Soft Serve](https://github.com/charmbracelet/soft-serve), a tasty, self-hostable Git server for the command line. Available as [services.soft-serve](#opt-services.soft-serve.enable). +- [Rosenpass](https://rosenpass.eu/), a service for post-quantum-secure VPNs with WireGuard. Available as [services.rosenpass](#opt-services.rosenpass.enable). + ## Backward Incompatibilities {#sec-release-23.11-incompatibilities} - `network-online.target` has been fixed to no longer time out for systems with `networking.useDHCP = true` and `networking.useNetworkd = true`. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 47b262bf4d98..81b7b981a446 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1047,6 +1047,7 @@ ./services/networking/redsocks.nix ./services/networking/resilio.nix ./services/networking/robustirc-bridge.nix + ./services/networking/rosenpass.nix ./services/networking/routedns.nix ./services/networking/rpcbind.nix ./services/networking/rxe.nix diff --git a/nixos/modules/services/networking/rosenpass.nix b/nixos/modules/services/networking/rosenpass.nix new file mode 100644 index 000000000000..d2a264b83d67 --- /dev/null +++ b/nixos/modules/services/networking/rosenpass.nix @@ -0,0 +1,233 @@ +{ config +, lib +, options +, pkgs +, ... +}: +let + inherit (lib) + attrValues + concatLines + concatMap + filter + filterAttrsRecursive + flatten + getExe + mdDoc + mkIf + optional + ; + + cfg = config.services.rosenpass; + opt = options.services.rosenpass; + settingsFormat = pkgs.formats.toml { }; +in +{ + options.services.rosenpass = + let + inherit (lib) + literalExpression + mdDoc + mkOption + ; + inherit (lib.types) + enum + listOf + nullOr + path + str + submodule + ; + in + { + enable = lib.mkEnableOption (mdDoc "Rosenpass"); + + package = lib.mkPackageOption pkgs "rosenpass" { }; + + defaultDevice = mkOption { + type = nullOr str; + description = mdDoc "Name of the network interface to use for all peers by default."; + example = "wg0"; + }; + + settings = mkOption { + type = submodule { + freeformType = settingsFormat.type; + + options = { + public_key = mkOption { + type = path; + description = mdDoc "Path to a file containing the public key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`."; + }; + + secret_key = mkOption { + type = path; + description = mdDoc "Path to a file containing the secret key of the local Rosenpass peer. Generate this by running {command}`rosenpass gen-keys`."; + }; + + listen = mkOption { + type = listOf str; + description = mdDoc "List of local endpoints to listen for connections."; + default = [ ]; + example = literalExpression "[ \"0.0.0.0:10000\" ]"; + }; + + verbosity = mkOption { + type = enum [ "Verbose" "Quiet" ]; + default = "Quiet"; + description = mdDoc "Verbosity of output produced by the service."; + }; + + peers = + let + peer = submodule { + freeformType = settingsFormat.type; + + options = { + public_key = mkOption { + type = path; + description = mdDoc "Path to a file containing the public key of the remote Rosenpass peer."; + }; + + endpoint = mkOption { + type = nullOr str; + default = null; + description = mdDoc "Endpoint of the remote Rosenpass peer."; + }; + + device = mkOption { + type = str; + default = cfg.defaultDevice; + defaultText = literalExpression "config.${opt.defaultDevice}"; + description = mdDoc "Name of the local WireGuard interface to use for this peer."; + }; + + peer = mkOption { + type = str; + description = mdDoc "WireGuard public key corresponding to the remote Rosenpass peer."; + }; + }; + }; + in + mkOption { + type = listOf peer; + description = mdDoc "List of peers to exchange keys with."; + default = [ ]; + }; + }; + }; + default = { }; + description = mdDoc "Configuration for Rosenpass, see for further information."; + }; + }; + + config = mkIf cfg.enable { + warnings = + let + # NOTE: In the descriptions below, we tried to refer to e.g. + # options.systemd.network.netdevs."".wireguardPeers.*.PublicKey + # directly, but don't know how to traverse "" and * in this path. + extractions = [ + { + relevant = config.systemd.network.enable; + root = config.systemd.network.netdevs; + peer = (x: x.wireguardPeers); + key = (x: if x.wireguardPeerConfig ? PublicKey then x.wireguardPeerConfig.PublicKey else null); + description = mdDoc "${options.systemd.network.netdevs}.\"\".wireguardPeers.*.wireguardPeerConfig.PublicKey"; + } + { + relevant = config.networking.wireguard.enable; + root = config.networking.wireguard.interfaces; + peer = (x: x.peers); + key = (x: x.publicKey); + description = mdDoc "${options.networking.wireguard.interfaces}.\"\".peers.*.publicKey"; + } + rec { + relevant = root != { }; + root = config.networking.wg-quick.interfaces; + peer = (x: x.peers); + key = (x: x.publicKey); + description = mdDoc "${options.networking.wg-quick.interfaces}.\"\".peers.*.publicKey"; + } + ]; + relevantExtractions = filter (x: x.relevant) extractions; + extract = { root, peer, key, ... }: + filter (x: x != null) (flatten (concatMap (x: (map key (peer x))) (attrValues root))); + configuredKeys = flatten (map extract relevantExtractions); + itemize = xs: concatLines (map (x: " - ${x}") xs); + descriptions = map (x: "`${x.description}`"); + missingKeys = filter (key: !builtins.elem key configuredKeys) (map (x: x.peer) cfg.settings.peers); + unusual = '' + While this may work as expected, e.g. you want to manually configure WireGuard, + such a scenario is unusual. Please double-check your configuration. + ''; + in + (optional (relevantExtractions != [ ] && missingKeys != [ ]) '' + You have configured Rosenpass peers with the WireGuard public keys: + ${itemize missingKeys} + But there is no corresponding active Wireguard peer configuration in any of: + ${itemize (descriptions relevantExtractions)} + ${unusual} + '') + ++ + optional (relevantExtractions == [ ]) '' + You have configured Rosenpass, but you have not configured Wireguard via any of: + ${itemize (descriptions extractions)} + ${unusual} + ''; + + environment.systemPackages = [ cfg.package pkgs.wireguard-tools ]; + + systemd.services.rosenpass = + let + filterNonNull = filterAttrsRecursive (_: v: v != null); + config = settingsFormat.generate "config.toml" ( + filterNonNull (cfg.settings + // + ( + let + credentialPath = id: "$CREDENTIALS_DIRECTORY/${id}"; + # NOTE: We would like to remove all `null` values inside `cfg.settings` + # recursively, since `settingsFormat.generate` cannot handle `null`. + # This would require to traverse both attribute sets and lists recursively. + # `filterAttrsRecursive` only recurses into attribute sets, but not + # into values that might contain other attribute sets (such as lists, + # e.g. `cfg.settings.peers`). Here, we just specialize on `cfg.settings.peers`, + # and this may break unexpectedly whenever a `null` value is contained + # in a list in `cfg.settings`, other than `cfg.settings.peers`. + peersWithoutNulls = map filterNonNull cfg.settings.peers; + in + { + secret_key = credentialPath "pqsk"; + public_key = credentialPath "pqpk"; + peers = peersWithoutNulls; + } + ) + ) + ); + in + rec { + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + path = [ cfg.package pkgs.wireguard-tools ]; + + serviceConfig = { + User = "rosenpass"; + Group = "rosenpass"; + RuntimeDirectory = "rosenpass"; + DynamicUser = true; + AmbientCapabilities = [ "CAP_NET_ADMIN" ]; + LoadCredential = [ + "pqsk:${cfg.settings.secret_key}" + "pqpk:${cfg.settings.public_key}" + ]; + }; + + # See + environment.CONFIG = "%t/${serviceConfig.RuntimeDirectory}/config.toml"; + + preStart = "${getExe pkgs.envsubst} -i ${config} -o \"$CONFIG\""; + script = "rosenpass exchange-config \"$CONFIG\""; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 42d620b512c4..d9d58bbd66a8 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -703,6 +703,7 @@ in { rkvm = handleTest ./rkvm {}; robustirc-bridge = handleTest ./robustirc-bridge.nix {}; roundcube = handleTest ./roundcube.nix {}; + rosenpass = handleTest ./rosenpass.nix {}; rshim = handleTest ./rshim.nix {}; rspamd = handleTest ./rspamd.nix {}; rss2email = handleTest ./rss2email.nix {}; diff --git a/nixos/tests/rosenpass.nix b/nixos/tests/rosenpass.nix new file mode 100644 index 000000000000..ec4046c8c035 --- /dev/null +++ b/nixos/tests/rosenpass.nix @@ -0,0 +1,217 @@ +import ./make-test-python.nix ({ pkgs, ... }: +let + deviceName = "rp0"; + + server = { + ip = "fe80::1"; + wg = { + public = "mQufmDFeQQuU/fIaB2hHgluhjjm1ypK4hJr1cW3WqAw="; + secret = "4N5Y1dldqrpsbaEiY8O0XBUGUFf8vkvtBtm8AoOX7Eo="; + listen = 10000; + }; + }; + client = { + ip = "fe80::2"; + wg = { + public = "Mb3GOlT7oS+F3JntVKiaD7SpHxLxNdtEmWz/9FMnRFU="; + secret = "uC5dfGMv7Oxf5UDfdPkj6rZiRZT2dRWp5x8IQxrNcUE="; + }; + }; +in +{ + name = "rosenpass"; + + nodes = + let + shared = peer: { config, modulesPath, ... }: { + imports = [ "${modulesPath}/services/networking/rosenpass.nix" ]; + + boot.kernelModules = [ "wireguard" ]; + + services.rosenpass = { + enable = true; + defaultDevice = deviceName; + settings = { + verbosity = "Verbose"; + public_key = "/etc/rosenpass/pqpk"; + secret_key = "/etc/rosenpass/pqsk"; + }; + }; + + networking.firewall.allowedUDPPorts = [ 9999 ]; + + systemd.network = { + enable = true; + networks."rosenpass" = { + matchConfig.Name = deviceName; + networkConfig.IPForward = true; + address = [ "${peer.ip}/64" ]; + }; + + netdevs."10-rp0" = { + netdevConfig = { + Kind = "wireguard"; + Name = deviceName; + }; + wireguardConfig.PrivateKeyFile = "/etc/wireguard/wgsk"; + }; + }; + + environment.etc."wireguard/wgsk" = { + text = peer.wg.secret; + user = "systemd-network"; + group = "systemd-network"; + }; + }; + in + { + server = { + imports = [ (shared server) ]; + + networking.firewall.allowedUDPPorts = [ server.wg.listen ]; + + systemd.network.netdevs."10-${deviceName}" = { + wireguardConfig.ListenPort = server.wg.listen; + wireguardPeers = [ + { + wireguardPeerConfig = { + AllowedIPs = [ "::/0" ]; + PublicKey = client.wg.public; + }; + } + ]; + }; + + services.rosenpass.settings = { + listen = [ "0.0.0.0:9999" ]; + peers = [ + { + public_key = "/etc/rosenpass/peers/client/pqpk"; + peer = client.wg.public; + } + ]; + }; + }; + client = { + imports = [ (shared client) ]; + + systemd.network.netdevs."10-${deviceName}".wireguardPeers = [ + { + wireguardPeerConfig = { + AllowedIPs = [ "::/0" ]; + PublicKey = server.wg.public; + Endpoint = "server:${builtins.toString server.wg.listen}"; + }; + } + ]; + + services.rosenpass.settings.peers = [ + { + public_key = "/etc/rosenpass/peers/server/pqpk"; + endpoint = "server:9999"; + peer = server.wg.public; + } + ]; + }; + }; + + testScript = { ... }: '' + from os import system + + # Full path to rosenpass in the store, to avoid fiddling with `$PATH`. + rosenpass = "${pkgs.rosenpass}/bin/rosenpass" + + # Path in `/etc` where keys will be placed. + etc = "/etc/rosenpass" + + start_all() + + for machine in [server, client]: + machine.wait_for_unit("multi-user.target") + + # Gently stop Rosenpass to avoid crashes during key generation/distribution. + for machine in [server, client]: + machine.execute("systemctl stop rosenpass.service") + + for (name, machine, remote) in [("server", server, client), ("client", client, server)]: + pk, sk = f"{name}.pqpk", f"{name}.pqsk" + system(f"{rosenpass} gen-keys --force --secret-key {sk} --public-key {pk}") + machine.copy_from_host(sk, f"{etc}/pqsk") + machine.copy_from_host(pk, f"{etc}/pqpk") + remote.copy_from_host(pk, f"{etc}/peers/{name}/pqpk") + + for machine in [server, client]: + machine.execute("systemctl start rosenpass.service") + + for machine in [server, client]: + machine.wait_for_unit("rosenpass.service") + + with subtest("ping"): + client.succeed("ping -c 2 -i 0.5 ${server.ip}%${deviceName}") + + with subtest("preshared-keys"): + # Rosenpass works by setting the WireGuard preshared key at regular intervals. + # Thus, if it is not active, then no key will be set, and the output of `wg show` will contain "none". + # Otherwise, if it is active, then the key will be set and "none" will not be found in the output of `wg show`. + for machine in [server, client]: + machine.wait_until_succeeds("wg show all preshared-keys | grep --invert-match none", timeout=5) + ''; + + # NOTE: Below configuration is for "interactive" (=developing/debugging) only. + interactive.nodes = + let + inherit (import ./ssh-keys.nix pkgs) snakeOilPublicKey snakeOilPrivateKey; + + sshAndKeyGeneration = { + services.openssh.enable = true; + users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; + environment.systemPackages = [ + (pkgs.writeShellApplication { + name = "gen-keys"; + runtimeInputs = [ pkgs.rosenpass ]; + text = '' + HOST="$(hostname)" + if [ "$HOST" == "server" ] + then + PEER="client" + else + PEER="server" + fi + + # Generate keypair. + mkdir -vp /etc/rosenpass/peers/$PEER + rosenpass gen-keys --force --secret-key /etc/rosenpass/pqsk --public-key /etc/rosenpass/pqpk + + # Set up SSH key. + mkdir -p /root/.ssh + cp ${snakeOilPrivateKey} /root/.ssh/id_ecdsa + chmod 0400 /root/.ssh/id_ecdsa + + # Copy public key to other peer. + # shellcheck disable=SC2029 + ssh -o StrictHostKeyChecking=no $PEER "mkdir -pv /etc/rosenpass/peers/$HOST" + scp /etc/rosenpass/pqpk "$PEER:/etc/rosenpass/peers/$HOST/pqpk" + ''; + }) + ]; + }; + + # Use kmscon + # to provide a slightly nicer console, and while we're at it, + # also use a nice font. + # With kmscon, we can for example zoom in/out using [Ctrl] + [+] + # and [Ctrl] + [-] + niceConsoleAndAutologin.services.kmscon = { + enable = true; + autologinUser = "root"; + fonts = [{ + name = "Fira Code"; + package = pkgs.fira-code; + }]; + }; + in + { + server = sshAndKeyGeneration // niceConsoleAndAutologin; + client = sshAndKeyGeneration // niceConsoleAndAutologin; + }; +}) diff --git a/pkgs/tools/misc/envsubst/default.nix b/pkgs/tools/misc/envsubst/default.nix index b3a1be04d929..8cd59df31b1e 100644 --- a/pkgs/tools/misc/envsubst/default.nix +++ b/pkgs/tools/misc/envsubst/default.nix @@ -22,5 +22,6 @@ buildGoModule rec { homepage = "https://github.com/a8m/envsubst"; license = licenses.mit; maintainers = with maintainers; [ nicknovitski ]; + mainProgram = "envsubst"; }; } diff --git a/pkgs/tools/networking/rosenpass/default.nix b/pkgs/tools/networking/rosenpass/default.nix index 07669cfeb01e..9467904fe698 100644 --- a/pkgs/tools/networking/rosenpass/default.nix +++ b/pkgs/tools/networking/rosenpass/default.nix @@ -1,85 +1,53 @@ { lib -, targetPlatform , fetchFromGitHub +, nixosTests , rustPlatform +, targetPlatform +, installShellFiles , cmake -, makeWrapper -, pkg-config -, removeReferencesTo -, coreutils -, findutils -, gawk -, wireguard-tools -, bash , libsodium +, pkg-config }: - -let - rpBinPath = lib.makeBinPath [ - coreutils - findutils - gawk - wireguard-tools - ]; -in rustPlatform.buildRustPackage rec { pname = "rosenpass"; - version = "0.2.0"; + version = "unstable-2023-09-28"; + src = fetchFromGitHub { owner = pname; repo = pname; - rev = "v${version}"; - sha256 = "sha256-r7/3C5DzXP+9w4rp9XwbP+/NK1axIP6s3Iiio1xRMbk="; + rev = "b15f17133f8b5c3c5175b4cfd4fc10039a4e203f"; + hash = "sha256-UXAkmt4VY0irLK2k4t6SW+SEodFE3CbX5cFbsPG0ZCo="; }; - cargoHash = "sha256-g2w3lZXQ3Kg3ydKdFs8P2lOPfIkfTbAF0MhxsJoX/E4="; + cargoHash = "sha256-N1DQHkgKgkDQ6DbgQJlpZkZ7AMTqX3P8R/cWr14jK2I="; nativeBuildInputs = [ cmake # for oqs build in the oqs-sys crate - makeWrapper # for the rp shellscript - pkg-config # let libsodium-sys-stable find libsodium - removeReferencesTo + pkg-config rustPlatform.bindgenHook # for C-bindings in the crypto libs + installShellFiles ]; - buildInputs = [ - bash # for patchShebangs to find it - libsodium - ]; - - # otherwise pkg-config tries to link non-existent dynamic libs during the build of liboqs - PKG_CONFIG_ALL_STATIC = true; - - # liboqs requires quite a lot of stack memory, thus we adjust the default stack size picked for - # new threads (which is used by `cargo test`) to be _big enough_ - RUST_MIN_STACK = 8 * 1024 * 1024; # 8 MiB + buildInputs = [ libsodium ]; # nix defaults to building for aarch64 _without_ the armv8-a # crypto extensions, but liboqs depends on these - preBuild = lib.optionalString targetPlatform.isAarch - ''NIX_CFLAGS_COMPILE="$NIX_CFLAGS_COMPILE -march=armv8-a+crypto"''; - - preInstall = '' - install -D rp $out/bin/rp - wrapProgram $out/bin/rp --prefix PATH : "${ rpBinPath }" - for file in doc/*.1 - do - install -D $file $out/share/man/man1/''${file##*/} - done + preBuild = lib.optionalString targetPlatform.isAarch64 '' + NIX_CFLAGS_COMPILE="$NIX_CFLAGS_COMPILE -march=armv8-a+crypto" ''; - # nix propagates the *.dev outputs of buildInputs for static builds, but that is non-sense for an - # executables only package - postFixup = '' - find -type f -exec remove-references-to -t ${bash.dev} \ - -t ${libsodium.dev} {} \; + postInstall = '' + installManPage doc/rosenpass.1 ''; + passthru.tests.rosenpass = nixosTests.rosenpass; + meta = with lib; { description = "Build post-quantum-secure VPNs with WireGuard!"; homepage = "https://rosenpass.eu/"; license = with licenses; [ mit /* or */ asl20 ]; maintainers = with maintainers; [ wucke13 ]; - platforms = platforms.all; + platforms = [ "aarch64-darwin" "aarch64-linux" "x86_64-darwin" "x86_64-linux" ]; + mainProgram = "rosenpass"; }; } diff --git a/pkgs/tools/networking/rosenpass/tools.nix b/pkgs/tools/networking/rosenpass/tools.nix new file mode 100644 index 000000000000..fb59436b3810 --- /dev/null +++ b/pkgs/tools/networking/rosenpass/tools.nix @@ -0,0 +1,30 @@ +{ lib +, stdenv +, makeWrapper +, installShellFiles +, coreutils +, findutils +, gawk +, rosenpass +, wireguard-tools +}: +stdenv.mkDerivation { + inherit (rosenpass) version src; + pname = "rosenpass-tools"; + + nativeBuildInputs = [ makeWrapper installShellFiles ]; + + postInstall = '' + install -D $src/rp $out/bin/rp + installManPage $src/doc/rp.1 + wrapProgram $out/bin/rp \ + --prefix PATH : ${lib.makeBinPath [ + coreutils findutils gawk rosenpass wireguard-tools + ]} + ''; + + meta = rosenpass.meta // { + description = "This package contains the Rosenpass tool `rp`, which is a script that wraps the `rosenpass` binary."; + mainProgram = "rp"; + }; +} diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix index c3ad477f7427..bdcbe115f01f 100644 --- a/pkgs/top-level/all-packages.nix +++ b/pkgs/top-level/all-packages.nix @@ -12827,6 +12827,8 @@ with pkgs; rosenpass = callPackage ../tools/networking/rosenpass { }; + rosenpass-tools = callPackage ../tools/networking/rosenpass/tools.nix { }; + rot8 = callPackage ../tools/misc/rot8 { }; rowhammer-test = callPackage ../tools/system/rowhammer-test { };