{ pkgs, lib, multiPeer ? false, ... }: let wgInterface = "mywg"; wgPort = 51820; rpPort = 51821; rosenpassKeyFolder = "/var/secrets"; keyExchangePathAB = "/root/peer-ab.osk"; keyExchangePathBA = "/root/peer-ba.osk"; keyExchangePathAC = "/root/peer-ac.osk"; keyExchangePathCA = "/root/peer-ca.osk"; keyExchangePathBC = "/root/peer-bc.osk"; keyExchangePathCB = "/root/peer-cb.osk"; getConfigFileVersion = rosenpassVersion: let configFileVersion = if builtins.hasAttr "configFileVersion" rosenpassVersion then rosenpassVersion.configFileVersion else "0"; in configFileVersion; peerAConfigFileVersion = getConfigFileVersion pkgs.rosenpass-peer-a; peerBConfigFileVersion = getConfigFileVersion pkgs.rosenpass-peer-b; peerCConfigFileVersion = if multiPeer then getConfigFileVersion pkgs.rosenpass-peer-c else null; generateWgKeys = name: let # The trailing line break that is generated by `wg genkey` and `wg pubkey` breaks the script rp-key-sync.nix to copy the preshared keys. # We therefore remove the trailing spaces here. privateKey = pkgs.runCommand "wg-private-${name}" { } '' ${pkgs.wireguard-tools}/bin/wg genkey | tr -d '\n' > $out ''; publicKey = pkgs.runCommand "wg-public-${name}" { } '' cat ${privateKey} | ${pkgs.wireguard-tools}/bin/wg pubkey | tr -d '\n' > $out ''; in { inherit privateKey publicKey; }; peerAWgKeys = generateWgKeys "peerA"; peerBWgKeys = generateWgKeys "peerB"; peerCWgKeys = if multiPeer then generateWgKeys "peerC" else null; generateRPKeys = name: rosenpassVersion: let keyPair = pkgs.runCommand "rp-genkeys-${name}" { } '' mkdir $out ${rosenpassVersion}/bin/rosenpass gen-keys -p $out/key.pk -s $out/key.sk ''; in { publicKey = "${keyPair}/key.pk"; privateKey = "${keyPair}/key.sk"; }; peerARpKeys = generateRPKeys "peerA" pkgs.rosenpass-peer-a; peerBRpKeys = generateRPKeys "peerB" pkgs.rosenpass-peer-b; peerCRpKeys = if multiPeer then generateRPKeys "peerC" pkgs.rosenpass-peer-c else null; staticConfig = { peerA = { innerIp = "10.100.0.1"; privateKey = builtins.readFile peerAWgKeys.privateKey; publicKey = builtins.readFile peerAWgKeys.publicKey; rosenpassConfig = builtins.toFile "peer-a.toml" ( '' public_key = "${rosenpassKeyFolder}/self.pk" secret_key = "${rosenpassKeyFolder}/self.sk" listen = ["[::]:${builtins.toString rpPort}"] verbosity = "Verbose" [[peers]] public_key = "${rosenpassKeyFolder}/peer-b.pk" endpoint = "peerbkeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathAB}" '' + (lib.optionalString multiPeer '' [[peers]] public_key = "${rosenpassKeyFolder}/peer-c.pk" endpoint = "peerckeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathAC}" '') ); }; peerB = { innerIp = "10.100.0.2"; privateKey = builtins.readFile peerBWgKeys.privateKey; publicKey = builtins.readFile peerBWgKeys.publicKey; rosenpassConfig = builtins.toFile "peer-b.toml" ( '' public_key = "${rosenpassKeyFolder}/self.pk" secret_key = "${rosenpassKeyFolder}/self.sk" listen = ["[::]:${builtins.toString rpPort}"] verbosity = "Verbose" [[peers]] public_key = "${rosenpassKeyFolder}/peer-a.pk" endpoint = "peerakeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathBA}" '' + (lib.optionalString multiPeer '' [[peers]] public_key = "${rosenpassKeyFolder}/peer-c.pk" endpoint = "peerckeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathBC}" '') ); }; } // lib.optionalAttrs multiPeer { # peerC is only defined if we are in a multiPeer context. peerC = { innerIp = "10.100.0.3"; privateKey = builtins.readFile peerCWgKeys.privateKey; publicKey = builtins.readFile peerCWgKeys.publicKey; rosenpassConfig = builtins.toFile "peer-c.toml" '' public_key = "${rosenpassKeyFolder}/self.pk" secret_key = "${rosenpassKeyFolder}/self.sk" listen = ["[::]:${builtins.toString rpPort}"] verbosity = "Verbose" [[peers]] public_key = "${rosenpassKeyFolder}/peer-a.pk" endpoint = "peerakeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathCA}" [[peers]] public_key = "${rosenpassKeyFolder}/peer-b.pk" endpoint = "peerckeyexchanger:${builtins.toString rpPort}" key_out = "${keyExchangePathCB}" ''; }; }; inherit (import (pkgs.path + "/nixos/tests/ssh-keys.nix") pkgs) snakeOilPublicKey snakeOilPrivateKey ; # All hosts in this scenario use the same key pair # The script takes the host as parameter and prepares passwordless login prepareSshLogin = pkgs.writeShellScriptBin "prepare-ssh-login" '' set -euo pipefail mkdir -p /root/.ssh cp ${snakeOilPrivateKey} /root/.ssh/id_ecdsa chmod 0400 /root/.ssh/id_ecdsa ${pkgs.openssh}/bin/ssh -o StrictHostKeyChecking=no "$1" true ''; in { name = "rosenpass with key exchangers"; defaults = { imports = [ ./rp-key-exchange.nix ./rp-key-sync.nix ]; systemd.tmpfiles.rules = [ "d ${rosenpassKeyFolder} 0400 root root - -" ]; }; nodes = { # peerA and peerB are the only neccessary peers unless we are in the multiPeer test. peerA = { networking.wireguard.interfaces.${wgInterface} = { listenPort = wgPort; ips = [ "${staticConfig.peerA.innerIp}/24" ]; inherit (staticConfig.peerA) privateKey; peers = [ { inherit (staticConfig.peerB) publicKey; allowedIPs = [ "${staticConfig.peerB.innerIp}/32" ]; presharedKey = "AR/yvSvMAzW6eS27PsRHUMWwC8cLhaD96t42cysxrb0="; } # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. ] ++ (lib.optional multiPeer { inherit (staticConfig.peerC) publicKey; allowedIPs = [ "${staticConfig.peerC.innerIp}/32" ]; presharedKey = "LfWvJCN8h7NhS+JWRG7GMIY20JxUV4WUs7MJ45ZGoCE="; } # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. ); }; networking.firewall.allowedUDPPorts = [ wgPort ]; # Each instance of the key sync service loads a symmetric key from a rosenpass keyexchanger node and sets it as the preshared key for the appropriate wireguard tunnel. services.rosenpassKeySync.instances = { AB = { create = true; enable = false; inherit wgInterface; rpHost = "peerakeyexchanger"; peerPubkey = staticConfig.peerB.publicKey; remoteKeyPath = keyExchangePathAB; }; } // lib.optionalAttrs multiPeer { AC = { create = true; enable = false; inherit wgInterface; rpHost = "peerakeyexchanger"; peerPubkey = staticConfig.peerC.publicKey; remoteKeyPath = keyExchangePathAC; }; }; }; peerB = { networking.wireguard.interfaces.${wgInterface} = { listenPort = wgPort; ips = [ "${staticConfig.peerB.innerIp}/24" ]; inherit (staticConfig.peerB) privateKey; peers = [ { inherit (staticConfig.peerA) publicKey; allowedIPs = [ "${staticConfig.peerA.innerIp}/32" ]; endpoint = "peerA:${builtins.toString wgPort}"; presharedKey = "o25fjoIOI623cnRyhvD4YEGtuSY4BFRZmY3UHvZ0BCA="; # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. } ] ++ (lib.optional multiPeer { inherit (staticConfig.peerC) publicKey; allowedIPs = [ "${staticConfig.peerC.innerIp}/32" ]; presharedKey = "GsYTUd/4Ph7wMy5r+W1no9yGe0UeZlmCPeiyu4tb6yM="; # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. }); }; networking.firewall.allowedUDPPorts = [ wgPort ]; # Each instance of the key sync service loads a symmetric key from a rosenpass keyexchanger node and sets it as the preshared key for the appropriate wireguard tunnel. services.rosenpassKeySync.instances = { BA = { create = true; enable = false; inherit wgInterface; rpHost = "peerbkeyexchanger"; peerPubkey = staticConfig.peerA.publicKey; remoteKeyPath = keyExchangePathBA; }; } // lib.optionalAttrs multiPeer { BC = { create = true; enable = false; inherit wgInterface; rpHost = "peerbkeyexchanger"; peerPubkey = staticConfig.peerC.publicKey; remoteKeyPath = keyExchangePathBC; }; }; }; # The key exchanger node for peerA is the node that actually runs rosenpass. It takes the rosenpass confguration for peerA and runs it. # The key sync services of peerA will ssh into this node and download the exchanged keys from here. peerakeyexchanger = { services.openssh.enable = true; users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; networking.firewall.allowedUDPPorts = [ rpPort ]; services.rosenpassKeyExchange = { create = true; enable = false; config = staticConfig.peerA.rosenpassConfig; rosenpassVersion = pkgs.rosenpass-peer-a; }; }; # The key exchanger node for peerB is the node that actually runs rosenpass. It takes the rosenpass confguration for peerB and runs it. # The key sync services of peerB will ssh into this node and download the exchanged keys from here. peerbkeyexchanger = { services.openssh.enable = true; users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; services.rosenpassKeyExchange = { create = true; enable = false; config = staticConfig.peerB.rosenpassConfig; rosenpassVersion = pkgs.rosenpass-peer-b; }; }; } // lib.optionalAttrs multiPeer { peerC = { networking.wireguard.interfaces.${wgInterface} = { listenPort = wgPort; ips = [ "${staticConfig.peerC.innerIp}/24" ]; inherit (staticConfig.peerC) privateKey; peers = [ { inherit (staticConfig.peerA) publicKey; allowedIPs = [ "${staticConfig.peerA.innerIp}/32" ]; endpoint = "peerA:${builtins.toString wgPort}"; presharedKey = "s9aIG1pY6nj2lH6p61tP8WRETNgQvoTfgel5BmVjYeI="; } # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. { inherit (staticConfig.peerB) publicKey; allowedIPs = [ "${staticConfig.peerB.innerIp}/32" ]; endpoint = "peerB:${builtins.toString wgPort}"; presharedKey = "DYlFqWg/M6EfnMolBO+b4DFNrRyS6YWr4lM/2xRE1FQ="; } # NOTE: We use mismatching preshared keys on purpose to make the wireguard key exchange fail until the rosenpass key exchange succeeded. ]; }; networking.firewall.allowedUDPPorts = [ wgPort ]; # Each instance of the key sync service loads a symmetric key from a rosenpass keyexchanger node and sets it as the preshared key for the appropriate wireguard tunnel. services.rosenpassKeySync.instances = { CA = { create = true; enable = false; inherit wgInterface; rpHost = "peerckeyexchanger"; peerPubkey = staticConfig.peerA.publicKey; remoteKeyPath = keyExchangePathCA; }; CB = { create = true; enable = false; inherit wgInterface; rpHost = "peerckeyexchanger"; peerPubkey = staticConfig.peerB.publicKey; remoteKeyPath = keyExchangePathCB; }; }; }; # The key exchanger node for peerC is the node that actually runs rosenpass. It takes the rosenpass confguration for peerC and runs it. # The key sync services of peerC will ssh into this node and download the exchanged keys from here. peerckeyexchanger = { services.openssh.enable = true; users.users.root.openssh.authorizedKeys.keys = [ snakeOilPublicKey ]; networking.firewall.allowedUDPPorts = [ rpPort ]; services.rosenpassKeyExchange = { create = true; enable = false; config = staticConfig.peerC.rosenpassConfig; rosenpassVersion = pkgs.rosenpass-peer-c; }; }; }; interactive = { defaults = { users.extraUsers.root.initialPassword = ""; services.openssh = { enable = true; settings = { PermitRootLogin = "yes"; PermitEmptyPasswords = "yes"; }; }; security.pam.services.sshd.allowNullPassword = true; environment.systemPackages = [ prepareSshLogin (pkgs.writeSellScriptBin "install-rosenpass-keys" ( '' ${pkgs.openssh}/bin/scp ${peerARpKeys.privateKey} peerakeyexchanger:${rosenpassKeyFolder}/self.sk ${pkgs.openssh}/bin/scp ${peerARpKeys.publicKey} peerakeyexchanger:${rosenpassKeyFolder}/self.pk ${pkgs.openssh}/bin/scp ${peerBRpKeys.publicKey} peerakeyexchanger:${rosenpassKeyFolder}/peer-b.pk ${pkgs.openssh}/bin/scp ${peerBRpKeys.privateKey} peerbkeyexchanger:${rosenpassKeyFolder}/self.sk ${pkgs.openssh}/bin/scp ${peerBRpKeys.publicKey} peerbkeyexchanger:${rosenpassKeyFolder}/self.pk ${pkgs.openssh}/bin/scp ${peerARpKeys.publicKey} peerbkeyexchanger:${rosenpassKeyFolder}/peer-a.pk '' + lib.optionalString multiPeer '' ${pkgs.openssh}/bin/scp ${peerCRpKeys.privateKey} peerckeyexchanger:${rosenpassKeyFolder}/self.sk ${pkgs.openssh}/bin/scp ${peerCRpKeys.publicKey} peerckeyexchanger:${rosenpassKeyFolder}/self.pk ${pkgs.openssh}/bin/scp ${peerARpKeys.publicKey} peerckeyexchanger:${rosenpassKeyFolder}/peer-a.pk ${pkgs.openssh}/bin/scp ${peerBRpKeys.publicKey} peerckeyexchanger:${rosenpassKeyFolder}/peer-b.pk ${pkgs.openssh}/bin/scp ${peerCRpKeys.publicKey} peerakeyexchanger:${rosenpassKeyFolder}/peer-c.pk ${pkgs.openssh}/bin/scp ${peerCRpKeys.publicKey} peerbkeyexchanger:${rosenpassKeyFolder}/peer-c.pk '' )) (pkgs.writeShellScriptBin "watch-wg" '' ${pkgs.procps}/bin/watch -n1 \ ${pkgs.wireguard-tools}/bin/wg show all preshared-keys '') ]; }; nodes.peerA = { virtualisation.forwardPorts = [ { from = "host"; host.port = 2222; guest.port = 22; } ]; }; nodes.peerB = { virtualisation.forwardPorts = [ { from = "host"; host.port = 2223; guest.port = 22; } ]; }; nodes.peerC = { virtualisation.forwardPorts = [ { from = "host"; host.port = 2224; guest.port = 22; } ]; }; }; testScript = ('' start_all() print("""Config file versions supported by peers peerA: ${peerAConfigFileVersion} peerB: ${peerBConfigFileVersion} ${lib.optionalString multiPeer '' peerC: ${peerCConfigFileVersion} ''} """) for m in [peerA, peerB, peerakeyexchanger, peerbkeyexchanger]: m.wait_for_unit("network-online.target") ${lib.optionalString multiPeer '' for m in [peerC, peerckeyexchanger]: m.wait_for_unit("network-online.target") ''} # The wireguard connection can't work because the sync services fail on # non-recognized SSH host keys, we didn't deploy the secrets and because the preshared keyes don't match. peerB.fail("ping -c 1 ${staticConfig.peerA.innerIp}") peerA.fail("ping -c 1 ${staticConfig.peerB.innerIp}") ${lib.optionalString multiPeer '' peerA.fail("ping -c 1 ${staticConfig.peerC.innerIp}") peerB.fail("ping -c 1 ${staticConfig.peerC.innerIp}") peerC.fail("ping -c 1 ${staticConfig.peerA.innerIp}") peerC.fail("ping -c 1 ${staticConfig.peerB.innerIp}") ''} # In admin-reality, this should be done with your favorite secret # provisioning/deployment tool peerakeyexchanger.succeed( "cp ${peerARpKeys.privateKey} ${rosenpassKeyFolder}/self.sk" ) peerakeyexchanger.succeed( "cp ${peerARpKeys.publicKey} ${rosenpassKeyFolder}/self.pk" ) peerakeyexchanger.succeed( "cp ${peerBRpKeys.publicKey} ${rosenpassKeyFolder}/peer-b.pk" ) peerbkeyexchanger.succeed( "cp ${peerBRpKeys.privateKey} ${rosenpassKeyFolder}/self.sk" ) peerbkeyexchanger.succeed( "cp ${peerBRpKeys.publicKey} ${rosenpassKeyFolder}/self.pk" ) peerbkeyexchanger.succeed( "cp ${peerARpKeys.publicKey} ${rosenpassKeyFolder}/peer-a.pk" ) ${lib.optionalString multiPeer '' peerakeyexchanger.succeed( "cp ${peerCRpKeys.publicKey} ${rosenpassKeyFolder}/peer-c.pk" ) peerbkeyexchanger.succeed( "cp ${peerCRpKeys.publicKey} ${rosenpassKeyFolder}/peer-c.pk" ) peerckeyexchanger.succeed( "cp ${peerCRpKeys.privateKey} ${rosenpassKeyFolder}/self.sk" ) peerckeyexchanger.succeed( "cp ${peerCRpKeys.publicKey} ${rosenpassKeyFolder}/self.pk" ) peerckeyexchanger.succeed( "cp ${peerARpKeys.publicKey} ${rosenpassKeyFolder}/peer-a.pk" ) peerckeyexchanger.succeed( "cp ${peerBRpKeys.publicKey} ${rosenpassKeyFolder}/peer-b.pk" ) ''} # Until now, the services were disbaled and didn't start (using the enable option of the services) peerakeyexchanger.succeed("systemctl start rp-exchange.service") peerbkeyexchanger.succeed("systemctl start rp-exchange.service") ${lib.optionalString multiPeer '' peerckeyexchanger.succeed("systemctl start rp-exchange.service") ''} # Wait for the service to have started. for m in [peerbkeyexchanger, peerakeyexchanger]: m.wait_for_unit("rp-exchange.service") ${lib.optionalString multiPeer '' peerckeyexchanger.wait_for_unit("rp-exchange.service") ''} # In reality, admins would carefully manage known SSH host keys with # their favorite secret provisioning/deployment tool peerA.succeed("${prepareSshLogin}/bin/prepare-ssh-login peerakeyexchanger") peerB.succeed("${prepareSshLogin}/bin/prepare-ssh-login peerbkeyexchanger") ${lib.optionalString multiPeer '' peerC.succeed("${prepareSshLogin}/bin/prepare-ssh-login peerckeyexchanger") ''} # Dump current network config peerA.succeed("ip addr 1>&2") peerA.succeed("ip route 1>&2") peerakeyexchanger.succeed("ip addr 1>&2") peerakeyexchanger.succeed("ip route 1>&2") peerB.succeed("ip addr 1>&2") peerB.succeed("ip route 1>&2") peerbkeyexchanger.succeed("ip addr 1>&2") peerbkeyexchanger.succeed("ip route 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("ip addr 1>&2") peerC.succeed("ip route 1>&2") peerckeyexchanger.succeed("ip addr 1>&2") peerckeyexchanger.succeed("ip route 1>&2") ''} # Dump current state of WireGuard tunnels peerA.succeed("wg show all 1>&2") peerB.succeed("wg show all 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all 1>&2") ''} peerA.succeed("wg show all preshared-keys 1>&2") peerB.succeed("wg show all preshared-keys 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all preshared-keys 1>&2") ''} # Start key sync services and wait for them to start. peerA.succeed("systemctl start rp-key-sync-AB.service") peerB.succeed("systemctl start rp-key-sync-BA.service") ${lib.optionalString multiPeer '' peerA.succeed("systemctl start rp-key-sync-AC.service") peerB.succeed("systemctl start rp-key-sync-BC.service") peerC.succeed("systemctl start rp-key-sync-CA.service") peerC.succeed("systemctl start rp-key-sync-CB.service") ''} peerA.wait_for_unit("rp-key-sync-AB.service") peerB.wait_for_unit("rp-key-sync-BA.service") ${lib.optionalString multiPeer '' peerA.wait_for_unit("rp-key-sync-AC.service") peerB.wait_for_unit("rp-key-sync-BC.service") peerC.wait_for_unit("rp-key-sync-CA.service") peerC.wait_for_unit("rp-key-sync-CB.service") ''} # Dump current network config peerA.succeed("ip addr 1>&2") peerA.succeed("ip route 1>&2") peerakeyexchanger.succeed("ip addr 1>&2") peerakeyexchanger.succeed("ip route 1>&2") peerB.succeed("ip addr 1>&2") peerB.succeed("ip route 1>&2") peerbkeyexchanger.succeed("ip addr 1>&2") peerbkeyexchanger.succeed("ip route 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("ip addr 1>&2") peerC.succeed("ip route 1>&2") peerckeyexchanger.succeed("ip addr 1>&2") peerckeyexchanger.succeed("ip route 1>&2") ''} # Dump current state of WireGuard tunnels peerA.succeed("wg show all 1>&2") peerB.succeed("wg show all 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all 1>&2") ''} peerA.succeed("wg show all preshared-keys 1>&2") peerB.succeed("wg show all preshared-keys 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all preshared-keys 1>&2") ''} # Voila! peerB.succeed("ping -c 1 -W 10 ${staticConfig.peerA.innerIp}") ${lib.optionalString multiPeer '' peerC.succeed("ping -c 1 -W 10 ${staticConfig.peerA.innerIp}") peerC.succeed("ping -c 1 -W 10 ${staticConfig.peerB.innerIp}") peerA.succeed("ping -c 1 -W 10 ${staticConfig.peerC.innerIp}") peerB.succeed("ping -c 1 -W 10 ${staticConfig.peerC.innerIp}") ''} peerA.succeed("ping -c 1 -W 10 ${staticConfig.peerB.innerIp}") # Dump current state of WireGuard tunnels peerA.succeed("wg show all 1>&2") peerB.succeed("wg show all 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all 1>&2") ''} peerA.succeed("wg show all preshared-keys 1>&2") peerB.succeed("wg show all preshared-keys 1>&2") ${lib.optionalString multiPeer '' peerC.succeed("wg show all preshared-keys 1>&2") ''} ''); }