diff --git a/README.md b/README.md index 5f9800f..6a3dbf5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Part of the [Keep](https://github.com/privkeyio/keep) ecosystem; the node daemon - **Threshold custody**: the box holds one FROST share; steal it and get nothing. - **Multi-node HA**: nodes sync, so a single failure doesn't take your vault down. - **Seedless**: recovery via a device quorum, no 24 words to lose. +- **Hardware-backed admin**: optional YubiKey/FIDO2 SSH, PIN + touch on every login. - **Open**: MIT software on commodity hardware. ## Quick Start @@ -77,6 +78,36 @@ HA), see [Hardware bring-up](docs/hardware.md). To open the Vaultwarden web vault before the mesh is set up, tunnel it over that SSH session (it binds localhost only): `ssh -L 8222:localhost:8222 keepadmin@`, then browse `http://localhost:8222` (localhost is a secure context, so the web vault loads). Then onboard the node onto the encrypted mesh and redeploy with `keepNode.adminAccess.lanBringup = false` for the mesh-only posture. +### YubiKey / FIDO2 (optional, recommended) + +`--ssh-key` accepts a FIDO2 public key (`ed25519-sk` / `ecdsa-sk`) as well as a software key, so you can +install straight onto hardware-backed admin auth. Generate one on your laptop, the private key never +leaves the token: + +```bash +ssh-keygen -t ed25519-sk -O resident -O verify-required -C yubikey-primary +``` + +Enroll **two** tokens (primary + off-site backup), then require them: + +```nix +keepNode.security.yubikey = { + enable = true; + authorizedKeys = [ "sk-ssh-ed25519@openssh.com AAAA... yubikey-primary" /* ...backup */ ]; + requireHardwareKey = true; # sshd then refuses every non-hardware-backed key +}; +``` + +`requireHardwareKey` narrows sshd's accepted algorithms to `sk-` only, so a stolen laptop is no longer +enough to reach the node; `verify-required` puts a PIN and a physical touch on every login. It is off by +default, so existing software-key deployments are unaffected, and the build refuses to enable it with no +usable hardware key (anti-lockout). On an already-installed node, add a token with +`keepnode-enroll-yubikey `. Full walkthrough, including Vaultwarden WebAuthn: [Deployment](docs/deployment.md). + +> **A YubiKey is node access, not vault recovery.** It does not replace or weaken the FROST threshold +> model. Lose every token and you lose SSH (physical console is the permanent break-glass), never the +> vault, recovery is still the device quorum. +> > **Hardened by default, reachable by your key.** The installer image is the hardened profile: no known password, key-only SSH (the `keepadmin` account, your enrolled key), `debugAccess` off, Vaultwarden bound to localhost, signups default-deny. During bring-up SSH is reachable on the LAN (`keepNode.adminAccess.lanBringup`) because a fresh node has no mesh yet; once it joins the mesh you redeploy mesh-only. See [Deployment](docs/deployment.md) for the declarative multi-node + admin-access how-to. The legacy `keepnode-debug` profile (known root password, password SSH, open signups) still exists as an explicit opt-in for keyless evaluation, but is never the installed default. `frost-gate` is off, so Vaultwarden data sits on the plain disk with no TPM unlock yet. ## License diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0b1ec78..0b1fec1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -89,6 +89,27 @@ true of all full-disk encryption. The quorum protects the key at rest and gates unlock on a second holder; it does not protect a live, compromised, running system. Keep Node does not claim to. +### A stolen operator laptop + +Admin access is key-only SSH as `keepadmin`, reachable only over the mesh. With a software key, that +key is a **file**: an attacker who takes the laptop, or lands malware on it, inherits full node admin +(including passwordless sudo), silently and replayably, for as long as the key exists. + +`keepNode.security.yubikey` narrows that. With a FIDO2 credential (`ed25519-sk` / `ecdsa-sk`) the +private key lives in the token's secure element and cannot be extracted or copied, and +`verify-required` makes every single authentication need a PIN *and* a physical touch. Possession of +the laptop stops being sufficient; remote malware cannot authenticate without the operator physically +present at the token. `requireHardwareKey = true` enforces this at sshd (`PubkeyAcceptedAlgorithms` +restricted to `sk-` algorithms), so a software key is refused by the daemon rather than merely absent +from a file. + +The boundary: this protects the *authentication*, not a session already established. An attacker who +compromises the laptop while the operator is logged in can act inside that live session; the touch +requirement bounds the damage to the moments the operator is present, and the credential cannot be +stolen for later use. It is also strictly node access , it does not gate the vault, whose protection is +the FROST quorum above. Losing every token costs SSH (physical console is the break-glass), never the +vault. + ### Duress and coercion The quorum protects against theft and remote compromise. It does not, on its own, protect diff --git a/docs/deployment.md b/docs/deployment.md index 7b5609a..d6329ac 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -93,6 +93,103 @@ Then, from your (mesh-joined) laptop: ssh keepadmin@10.44.x.y # the node's mesh IP; not reachable from the LAN ``` +## 4. Harden admin access with a YubiKey (FIDO2) + +An SSH key is a *file on your laptop*: steal the laptop and you inherit node admin, silently. A FIDO2 +credential (`ed25519-sk` / `ecdsa-sk`) keeps the private key inside the token's secure element, where it +cannot be copied, and `verify-required` demands a PIN **and** a physical touch on every login. + +**This is node access only. It is not FROST.** Losing every YubiKey costs you SSH to the box (physical +console remains the permanent break-glass); it never costs you the vault. Vault recovery is the FROST +threshold quorum, unchanged by anything on this page. + +### Generate the credential (on your laptop) + +Do this **twice**: a primary token and a backup you store off-site. One hardware key is a single point +of failure for node access. + +```bash +ssh-keygen -t ed25519-sk -O resident -O verify-required -C yubikey-primary +``` + +- `-O resident` stores the credential *on the token*, so you can recover it onto a new laptop with + `ssh-keygen -K` (run in `~/.ssh`). Non-resident keys work too, but then the key handle file is + something you must back up yourself. +- `-O verify-required` is what forces the PIN in addition to the touch. +- `ecdsa-sk` instead of `ed25519-sk` if your token's firmware predates FIDO2 ed25519 support. + +The `.pub` file it writes is what you enroll; the private half never leaves the YubiKey. + +### Enroll it + +```nix +keepNode.security.yubikey = { + enable = true; + authorizedKeys = [ + "sk-ssh-ed25519@openssh.com AAAA... yubikey-primary" + "sk-ssh-ed25519@openssh.com AAAA... yubikey-backup" + ]; + requireHardwareKey = true; # refuse every non-hardware-backed key at sshd +}; +``` + +- `requireHardwareKey` narrows sshd's `PubkeyAcceptedAlgorithms` to the `sk-` algorithms, so a software + key is **rejected by the daemon**, not merely left out of a file. Leave it `false` (the default) while + you still need software-key access; the module is fully backward compatible in that mode, and the + hardware keys still carry a per-key `verify-required`. +- `keyTypes` (default `[ "ed25519-sk" "ecdsa-sk" ]`) is the declarative allow-list of credential types. + Narrow it to `[ "ed25519-sk" ]` to refuse ecdsa-sk outright. +- **Anti-lockout:** `requireHardwareKey = true` with no usable hardware key fails the build; if the key + lives in the runtime `authorizedKeysFile` (which the build cannot inspect), the + `keep-node-hardware-key-check` unit fails loudly at boot instead, without blocking boot or sshd. +- Password authentication stays disabled throughout. This module only ever *removes* an authentication + path. + +> **What the anti-lockout guards cannot see.** Both of them , the build assertion and the boot check , +> read *key algorithms in a file*. They cannot see whether the credential on your token was created +> with `-O verify-required`, and they cannot see whether the key body is intact. So with +> `requireVerification = true` (the default), a token enrolled **without** that flag satisfies both +> guards and is still refused by sshd at login: both report healthy on a node you cannot reach. +> **Actually log in over the mesh with the YubiKey , from a second terminal, keeping your current +> session open , before you flip `requireHardwareKey` on or remove your software key.** That login is +> the only real proof; everything else is a file check. + +### Enroll after install + +On a node installed from the generic ISO the closure is fixed, so add a token at runtime: + +```bash +ssh keepadmin@ +keepnode-enroll-yubikey "sk-ssh-ed25519@openssh.com AAAA... yubikey-backup" +``` + +It validates the key, refuses anything that is not `sk-`, de-duplicates, appends `verify-required`, and +re-runs the anti-lockout checks. Enroll the backup token **before** flipping `requireHardwareKey` on. + +Pass **one** key per invocation: a multi-line paste is refused outright, because only the first line +would be validated while every line got written. + +### Vaultwarden: YubiKey as 2FA / passkey + +Vaultwarden supports WebAuthn, but it binds every credential to the origin in its `DOMAIN` setting: if +that does not match the URL you actually browse, registration appears to work and then fails on the next +login. Declare it: + +```nix +keepNode.vaultwarden.domain = "http://localhost:8222"; # the SSH-tunnelled default +``` + +`http://localhost` is a secure context, so WebAuthn works over the tunnel without TLS. Do **not** put a +mesh IP or LAN name behind plain `http://`: browsers treat that as a non-secure context and refuse +WebAuthn entirely (and Vaultwarden drops secure cookies), so registration cannot even start. Off +localhost, use HTTPS. If you enable +`keepNode.ingress`, it sets `DOMAIN` to its public HTTPS hostname and takes precedence , register your +token against whichever origin you actually use, and re-register if you change it. + +Then in the web vault: **Settings -> Security -> Two-step login -> FIDO2 WebAuthn**, add both tokens, and +save the recovery code somewhere offline. Vaultwarden's own passkey/2FA state lives in the vault DB on +the FROST-gated volume, so it is covered by the same threshold guarantee as everything else. + ## Installer bring-up (generic ISO) Mesh-only admin assumes your laptop is already a rostered mesh peer. For a co-owned cluster you author diff --git a/flake.nix b/flake.nix index 479f12b..3fdd6c0 100644 --- a/flake.nix +++ b/flake.nix @@ -378,6 +378,167 @@ authorizedKeysFile = ""; }).success; + # Pure-eval guards for the YubiKey / FIDO2 module (issue #99). The security value is in what the + # module REFUSES, so each case forces the system toplevel under tryEval: a fired assertion becomes + # success=false, without booting a VM. `settings` is read from the same fixture so the sshd policy + # (sk-only algorithms + verify-required) is checked alongside the guards that keep it reachable. + yubikeySystem = + { + yubikeyKeys ? [ skFixtureKey ], + adminKeys ? [ ], + authorizedKeysFile ? null, + requireHardwareKey ? false, + # The yubikey module only shapes the account adminAccess defines, so "adminAccess off" is a + # posture the guards must cover, not an unrelated fixture. + adminAccess ? true, + extra ? { }, + }: + nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + ./nixos/admin-access.nix + ./nixos/yubikey.nix + { + fileSystems."/" = { + device = "/dev/disk/by-label/root"; + fsType = "ext4"; + }; + boot.loader.grub.enable = false; + networking.firewall.enable = true; + keepNode.adminAccess = { + enable = adminAccess; + authorizedKeys = adminKeys; + inherit authorizedKeysFile; + }; + keepNode.security.yubikey = { + enable = true; + authorizedKeys = yubikeyKeys; + inherit requireHardwareKey; + } + // extra; + } + ]; + }; + yubikeyEvals = args: builtins.tryEval (yubikeySystem args).config.system.build.toplevel.drvPath; + # The two postures every non-guard check reads: sk-only enforcement, and the default (mixed) mode + # that must stay backward compatible with software keys. + yubikeyStrict = (yubikeySystem { requireHardwareKey = true; }).config; + yubikeyMixed = (yubikeySystem { }).config; + skFixtureKey = "sk-ssh-ed25519@openssh.com AAAAeval-only-fixture-key yubikey-eval"; + softwareFixtureKey = "ssh-ed25519 AAAAeval-only-fixture-key keepadmin-eval"; + + yubikeyGuardsHold = + # Control: a hardware key alone builds, and so does the strict posture backed by one. + (yubikeyEvals { }).success + && (yubikeyEvals { requireHardwareKey = true; }).success + # A SOFTWARE key under the hardware namespace is refused: it would present a copyable file-based + # key to the operator as YubiKey-protected. + && !(yubikeyEvals { yubikeyKeys = [ softwareFixtureKey ]; }).success + # Anti-lockout: sk-only enforcement with only a software key (and no runtime keys file) leaves + # sshd with nothing it will accept and password auth off , a permanent remote lockout. + && !(yubikeyEvals { + yubikeyKeys = [ ]; + adminKeys = [ softwareFixtureKey ]; + requireHardwareKey = true; + }).success + # ...and the same config is accepted once the runtime authorizedKeysFile escape exists, since a + # key is provisioned into it after the build (install-keepnode / keepnode-enroll-yubikey). + && (yubikeyEvals { + yubikeyKeys = [ ]; + adminKeys = [ softwareFixtureKey ]; + requireHardwareKey = true; + authorizedKeysFile = "/etc/keepnode/admin_authorized_keys"; + }).success + # A hardware key of a type EXCLUDED by keyTypes does not satisfy the guard: sshd would refuse + # that algorithm at auth time, so it is a lockout dressed up as a hardware key. + && !(yubikeyEvals { + requireHardwareKey = true; + extra.keyTypes = [ "ecdsa-sk" ]; + }).success + && (yubikeyEvals { + requireHardwareKey = true; + extra.keyTypes = [ "ed25519-sk" ]; + }).success + # An empty keyTypes under enforcement would emit an EMPTY PubkeyAcceptedAlgorithms, i.e. sshd + # accepts no public key at all. + && !(yubikeyEvals { + requireHardwareKey = true; + extra.keyTypes = [ ]; + }).success + # The module hardens the account adminAccess defines, so it must not be enabled alone. + && !(yubikeyEvals { adminAccess = false; }).success; + + # Pure-eval table over authorized_keys LINE SHAPES, run directly against the shared classifier the + # module's assertions and its keepadmin key set are built on. Every shape here is legal for sshd, + # and each one this classifier gets wrong is a security outcome, not a cosmetic one: an + # options-prefixed or tab-separated hardware key misread as software is a FALSE build-time lockout, + # while an algorithm name lifted out of a free-form comment is a hardware key reported on a node + # whose only key sshd refuses. A table is the cheapest place to pin that , the VM test can only + # reach these shapes indirectly. + authorizedKeyShapesHold = + let + k = import ./nixos/lib/authorized-keys.nix { lib = nixpkgs.lib; }; + in + k.algorithmOf "sk-ssh-ed25519@openssh.com AAAAfixture yubikey-eval" == "sk-ssh-ed25519@openssh.com" + && k.algorithmOf "ssh-ed25519 AAAAfixture keepadmin-eval" == "ssh-ed25519" + # Tab-separated fields are legal; splitting on a literal space reads the whole line as one token. + && k.isHardwareKey "sk-ssh-ed25519@openssh.com\tAAAAfixture\tyubikey-eval" + # An options field precedes the algorithm and is not one. + && k.isHardwareKey ''from="10.44.0.0/16" sk-ssh-ed25519@openssh.com AAAAfixture'' + && k.isHardwareKey "verify-required sk-ssh-ed25519@openssh.com AAAAfixture" + # A certificate line's algorithm field is the cert algorithm. + && + k.algorithmOf "sk-ssh-ed25519-cert-v01@openssh.com AAAAfixture yubikey-eval" + == "sk-ssh-ed25519-cert-v01@openssh.com" + # The COMMENT is free-form text that may name an algorithm. It is still a software key. + && !(k.isHardwareKey "ssh-ed25519 AAAAfixture my sk-ssh-ed25519@openssh.com backup key") + && + k.trimKeys [ + "" + " " + " ssh-ed25519 AAAAfixture " + ] == [ "ssh-ed25519 AAAAfixture" ] + # Options are ONE comma-separated field before the algorithm. A line that already carries + # options must be extended in place: a second space-separated field is unparseable to sshd, so + # the key is silently ignored while still counting towards the anti-lockout guards. + && + k.withOption "verify-required" ''from="10.44.0.0/16" sk-ssh-ed25519@openssh.com AAAAfixture'' + == ''verify-required,from="10.44.0.0/16" sk-ssh-ed25519@openssh.com AAAAfixture'' + && + k.withOption "verify-required" "sk-ssh-ed25519@openssh.com AAAAfixture" + == "verify-required sk-ssh-ed25519@openssh.com AAAAfixture" + # Idempotent: a line already stating the option is left alone rather than doubled. + && + k.withOption "verify-required" "verify-required sk-ssh-ed25519@openssh.com AAAAfixture" + == "verify-required sk-ssh-ed25519@openssh.com AAAAfixture"; + + yubikeyPostureHolds = + let + strict = yubikeyStrict.services.openssh.settings; + lax = yubikeyMixed.services.openssh.settings; + keepadminKeys = yubikeyMixed.users.users.keepadmin.openssh.authorizedKeys.keys; + in + # Strict: only sk- algorithms are accepted, PIN + touch is required globally, and no software + # algorithm survives in the set. + strict.PubkeyAcceptedAlgorithms + == "sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com" + && strict.PubkeyAuthOptions == "verify-required" + # Backward compatibility: with requireHardwareKey off (the default) sshd's algorithm policy is + # NOT touched, so existing software-key deployments negotiate exactly as before, and no global + # verify-required is imposed (which would lock those software keys out). + && !(lax ? PubkeyAcceptedAlgorithms) + && !(lax ? PubkeyAuthOptions) + # Even in that mixed mode the hardware key itself still carries per-key verify-required. + && keepadminKeys == [ "verify-required ${skFixtureKey}" ]; + + # Password auth must stay off in every YubiKey posture: the module may only REMOVE authentication + # paths, never add one. Checked on the strict fixture, the one that rewrites sshd's auth policy. + yubikeyKeepsPasswordsOff = + let + s = yubikeyStrict.services.openssh.settings; + in + s.PasswordAuthentication == false && s.KbdInteractiveAuthentication == false; + # Pure-eval guard for the mesh-interface single source of truth: setting keepNode.mesh.interface # once must propagate to every mesh-scoped service's meshInterface, so they cannot drift apart. # A refactor that reverts one service to a hardcoded default flips this red. @@ -522,6 +683,11 @@ authorizedKeysFile = "/etc/keepnode/admin_authorized_keys"; lanBringup = true; }; + # Ships `keepnode-enroll-yubikey` (and libfido2) on the installed node so an operator can add + # a FIDO2 key after install, when the closure is already fixed. requireHardwareKey stays OFF + # here: the box is reached with the software key install-keepnode enrolled, and flipping it on + # before a token exists would lock the node out. + keepNode.security.yubikey.enable = true; } ]; }; @@ -654,6 +820,37 @@ else "echo 'adminAccess anti-lockout regression: the module either built with no usable key (empty or whitespace-only authorizedKeys and no authorizedKeysFile, a permanent key-only-SSH remote lockout) or refused a valid config (a real inline key, or the installer authorizedKeysFile escape)' >&2; exit 1" ); + yubikey-ssh = pkgs.testers.runNixOSTest { + imports = [ ./tests/yubikey-ssh.nix ]; + _module.args = { inherit adminKeyFixture; }; + }; + yubikey-assertions = pkgs.runCommand "yubikey-assertions" { } ( + if !yubikeyGuardsHold then + "echo 'YubiKey module guard regression: the module either accepted a software key under keepNode.security.yubikey.authorizedKeys (a copyable key masquerading as hardware-backed), accepted requireHardwareKey with no usable sk- key of an allowed type and no runtime authorizedKeysFile (a permanent key-only-SSH remote lockout), accepted an empty keyTypes (an empty PubkeyAcceptedAlgorithms accepts nothing), enabled without adminAccess, or refused a valid config' >&2; exit 1" + else if !yubikeyPostureHolds then + "echo 'YubiKey sshd posture regression: requireHardwareKey did not narrow PubkeyAcceptedAlgorithms to exactly the sk- algorithms with verify-required, or it leaked that policy into the default (requireHardwareKey off) case and would break existing software-key deployments, or the per-key verify-required option was dropped from the enrolled hardware keys' >&2; exit 1" + else if !authorizedKeyShapesHold then + "echo 'authorized_keys classifier regression: a legal line shape is misclassified (an options prefix such as from=\"...\" or verify-required, a tab-separated line, a certificate algorithm, or an algorithm name occurring in the free-form comment). A hardware key read as software is a false anti-lockout build failure; a comment read as an algorithm reports a hardware key present on a node whose only key sshd will refuse' >&2; exit 1" + else if !yubikeyKeepsPasswordsOff then + "echo 'YubiKey module regression: password or keyboard-interactive authentication is no longer disabled under the hardware-key posture. This module may only remove authentication paths, never add one' >&2; exit 1" + else + "touch $out" + ); + # The FIDO2 posture is worthless if the shipped OpenSSH was built without security-key support: + # sk- credentials would simply not be a thing sshd can parse or negotiate, and the failure would + # surface only when an operator plugs in a YubiKey. `ssh -Q key` enumerates what the binary was + # compiled to understand, so this is a real capability probe of the exact package the module + # configures, not an assumption about nixpkgs' build flags. + openssh-fido-support = pkgs.runCommand "openssh-fido-support" { } '' + ssh="${yubikeyMixed.services.openssh.package}/bin/ssh" + for alg in sk-ssh-ed25519@openssh.com sk-ecdsa-sha2-nistp256@openssh.com; do + "$ssh" -Q key | grep -qx "$alg" || { + echo "OpenSSH FIDO/U2F support regression: the configured openssh package does not support $alg (built without withFIDO), so YubiKey SSH keys cannot be used at all" >&2 + exit 1 + } + done + touch $out + ''; mesh-interface-consolidation = pkgs.runCommand "mesh-interface-consolidation" { } ( if meshInterfaceInheritance then "touch $out" diff --git a/nixos/admin-access.nix b/nixos/admin-access.nix index 2c5d3dd..c690707 100644 --- a/nixos/admin-access.nix +++ b/nixos/admin-access.nix @@ -10,6 +10,7 @@ { config, lib, ... }: let cfg = config.keepNode.adminAccess; + keyCheck = import ./lib/key-check.nix { inherit lib; }; in { imports = [ ./mesh-interface.nix ]; @@ -162,45 +163,19 @@ in # brick. This oneshot checks the effective key sources at boot and, if none holds a usable key, fails # loudly: a console message an operator sees on the physical box, and a `systemctl --failed` entry. # Nothing requires this unit, so its failure surfaces the lockout WITHOUT blocking boot or sshd. - systemd.services.keep-node-admin-key-check = { + # The key-source loop and the loud-failure tail are shared with the yubikey module's check, so a + # key source can never be taught to one of them alone. + systemd.services.keep-node-admin-key-check = keyCheck.mkKeyCheck { description = "Anti-lockout: fail loudly if keepadmin has no authorized SSH key"; - wantedBy = [ "multi-user.target" ]; - after = [ "local-fs.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - script = '' - present=0 - # Bind the runtime file path to a shell VARIABLE via escapeShellArg, then reference it only as - # "$akf". The path is operator config, but embedding it raw in a double-quoted string would let a - # value containing $(...) or a backtick run as root at boot; a shell variable's contents are never - # re-scanned for command substitution, so this stays safe on any path. - ${lib.optionalString ( - cfg.authorizedKeysFile != null - ) "akf=${lib.escapeShellArg (toString cfg.authorizedKeysFile)}"} - # The inline `authorizedKeys` land in the Nix-managed per-user file; the runtime - # authorizedKeysFile (when configured) is the installer/operator-provisioned path. - for f in /etc/ssh/authorized_keys.d/keepadmin ${ - lib.optionalString (cfg.authorizedKeysFile != null) ''"$akf"'' - }; do - # A usable key is any non-blank, non-comment line. - if [ -f "$f" ] && grep -qE '^[[:space:]]*[^#[:space:]]' "$f" 2>/dev/null; then - present=1 - fi - done - if [ "$present" -eq 0 ]; then - msg="KEEP NODE ANTI-LOCKOUT: keepadmin has NO authorized SSH key (inline keys empty${ - lib.optionalString (cfg.authorizedKeysFile != null) "and $akf is absent/empty" - }). Key-only SSH means remote access is IMPOSSIBLE. Provision an admin public key (keepNode.adminAccess.authorizedKeys${ - lib.optionalString (cfg.authorizedKeysFile != null) "or $akf" - }), then: systemctl restart keep-node-admin-key-check.service" - echo "$msg" > /dev/console 2>/dev/null || true - echo "$msg" >&2 - exit 1 - fi - echo "keepadmin has at least one authorized SSH key" - ''; + inherit (cfg) authorizedKeysFile; + # A usable key is any non-blank, non-comment line. + matcher = ''grep -qE '^[[:space:]]*[^#[:space:]]' "$f" 2>/dev/null''; + message = "KEEP NODE ANTI-LOCKOUT: keepadmin has NO authorized SSH key (inline keys empty${ + lib.optionalString (cfg.authorizedKeysFile != null) " and $akf is absent/empty" + }). Key-only SSH means remote access is IMPOSSIBLE. Provision an admin public key (keepNode.adminAccess.authorizedKeys${ + lib.optionalString (cfg.authorizedKeysFile != null) " or $akf" + }), then: systemctl restart keep-node-admin-key-check.service"; + okMessage = "keepadmin has at least one authorized SSH key"; }; services.openssh = { diff --git a/nixos/keep-node.nix b/nixos/keep-node.nix index cbc3d66..361dc8f 100644 --- a/nixos/keep-node.nix +++ b/nixos/keep-node.nix @@ -15,6 +15,7 @@ ./vault-replication.nix ./mesh.nix ./admin-access.nix + ./yubikey.nix ]; # Appliance defaults (overridable). Hostname is left to the host/VM/test layer so it does diff --git a/nixos/lib/authorized-keys.nix b/nixos/lib/authorized-keys.nix new file mode 100644 index 0000000..8fbe0f0 --- /dev/null +++ b/nixos/lib/authorized-keys.nix @@ -0,0 +1,62 @@ +# Shared authorized_keys line classifier for the admin SSH modules. +# +# A line is `[options] algorithm base64-body [comment]`: the options field may be absent, the field +# separator may be a TAB (sshd accepts it), and the comment is free-form text. So the algorithm is not +# simply everything up to the first space, and getting it wrong fails in BOTH directions, a +# tab-separated or options-prefixed hardware key read as software is a false anti-lockout build +# failure, while an algorithm name occurring in a software key's COMMENT read as the key type reports +# a hardware key present on a node whose only key sshd will refuse. Only the first two fields are ever +# considered, so a comment can never be mistaken for an algorithm. +{ lib }: +let + # Every SSH public key algorithm name starts with one of these; no authorized_keys OPTION does + # (`from=`, `command=`, `verify-required`, `restrict`, `cert-authority`, ...), which is what makes + # an options field distinguishable from the algorithm without parsing the options grammar. + algorithmPrefixes = [ + "ssh-" + "sk-" + "ecdsa-" + "rsa-" + "webauthn-" + ]; + isAlgorithmField = t: lib.any (p: lib.hasPrefix p t) algorithmPrefixes; +in +rec { + # An empty or whitespace-only entry is not a usable key. + trimKeys = ks: lib.filter (k: k != "") (map lib.strings.trim ks); + + # Split on any whitespace RUN rather than a literal space, so tab-separated fields are fields. + tokensOf = k: lib.filter (t: builtins.isString t && t != "") (builtins.split "[[:space:]]+" k); + + # Field 1, or field 2 when field 1 is an options field. Falls back to field 1 so an unparseable + # line still yields something nameable for an assertion message. + algorithmOf = + k: + let + fields = lib.take 2 (tokensOf k); + fallback = if fields == [ ] then "" else lib.head fields; + in + lib.findFirst isAlgorithmField fallback fields; + + isHardwareKey = k: lib.hasPrefix "sk-" (algorithmOf k); + + # Add `option` to a line's options field. Options are ONE comma-separated field before the algorithm, + # so a line that already carries options must be extended in place: prepending a second + # space-separated field yields `verify-required from="..." sk-... body`, which sshd cannot parse, and + # the key is then silently ignored, an enrolled-but-dead key that still satisfies the anti-lockout + # guards. Idempotent, so a line that already states the option is returned unchanged. + withOption = + option: k: + let + fields = tokensOf k; + first = if fields == [ ] then "" else lib.head fields; + in + if fields == [ ] then + k + else if isAlgorithmField first then + "${option} ${k}" + else if lib.elem option (lib.splitString "," first) then + k + else + "${option},${k}"; +} diff --git a/nixos/lib/key-check.nix b/nixos/lib/key-check.nix new file mode 100644 index 0000000..59f9d66 --- /dev/null +++ b/nixos/lib/key-check.nix @@ -0,0 +1,56 @@ +# The shared shape of the anti-lockout boot checks (admin-access and yubikey). +# +# Both units answer the same question , does keepadmin still have a key sshd will accept? , over the +# same key sources, and differ only in what counts as a usable key. Keeping the SOURCE LIST in one +# place is the point: a third key source added to one check and not the other leaves the other +# reporting a lockout that is not there, or worse, missing one that is , which is precisely the +# failure both units exist to catch. Nothing requires either unit, so a failure surfaces the lockout +# (console message + a `systemctl --failed` entry) WITHOUT blocking boot or sshd. +{ lib }: +{ + # `matcher` is a shell command evaluated with "$f" bound to each key source in turn; exit 0 means + # that source holds a usable key. `message` is interpolated into a double-quoted shell string, so it + # may reference "$akf" (the runtime keys file) and must not contain a double quote or a backtick. + mkKeyCheck = + { + description, + authorizedKeysFile, + matcher, + message, + okMessage, + }: + let + hasRuntimeFile = authorizedKeysFile != null; + in + { + inherit description; + wantedBy = [ "multi-user.target" ]; + after = [ "local-fs.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + present=0 + # Bind the operator-supplied path to a shell VARIABLE via escapeShellArg, then reference it only + # as "$akf". The path is operator config, but embedding it raw in a double-quoted string would + # let a value containing $(...) or a backtick run as root at boot; a shell variable's contents + # are never re-scanned for command substitution, so this stays safe on any path. + ${lib.optionalString hasRuntimeFile "akf=${lib.escapeShellArg (toString authorizedKeysFile)}"} + # The inline `authorizedKeys` land in the Nix-managed per-user file; the runtime + # authorizedKeysFile (when configured) is the installer/operator-provisioned path. + for f in /etc/ssh/authorized_keys.d/keepadmin ${lib.optionalString hasRuntimeFile ''"$akf"''}; do + if [ -f "$f" ] && ${matcher}; then + present=1 + fi + done + if [ "$present" -eq 0 ]; then + msg="${message}" + echo "$msg" > /dev/console 2>/dev/null || true + echo "$msg" >&2 + exit 1 + fi + echo ${lib.escapeShellArg okMessage} + ''; + }; +} diff --git a/nixos/vaultwarden.nix b/nixos/vaultwarden.nix index 425462e..8b25dc3 100644 --- a/nixos/vaultwarden.nix +++ b/nixos/vaultwarden.nix @@ -23,6 +23,29 @@ in default = 8222; description = "Vaultwarden HTTP port."; }; + domain = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "http://localhost:8222"; + description = '' + The exact origin clients reach this vault on, published to Vaultwarden as DOMAIN. WebAuthn + (YubiKey as 2FA or passkey) binds every credential to this origin: registered against the wrong + DOMAIN, the token silently fails to verify on the next login. Set it to the origin you actually + type , `http://localhost:8222` for the SSH-tunnelled default (localhost is a secure context, so + WebAuthn works there without TLS), or the mesh URL you use. Left null, Vaultwarden falls back to + its own default and WebAuthn registration is unreliable. + + Use `http://` ONLY for `localhost`. Browsers treat plain HTTP on any other host (a mesh IP, a + LAN name) as a non-secure context and refuse WebAuthn outright, and Vaultwarden drops secure + cookies there , so a token cannot be registered at all. Off localhost, reach the vault over an + SSH tunnel to `http://localhost:PORT`, or terminate real HTTPS in front of it + (`keepNode.ingress`) and use that hostname. + + Only a default: `keepNode.ingress` sets DOMAIN to its own public HTTPS hostname and takes + precedence, since that is then the real client-facing origin. + ''; + }; + signupsAllowed = lib.mkOption { type = lib.types.bool; default = false; # default-deny; first user via admin/invite, never open self-registration @@ -42,7 +65,9 @@ in ROCKET_ADDRESS = "127.0.0.1"; ROCKET_PORT = cfg.port; SIGNUPS_ALLOWED = cfg.signupsAllowed; - }; + } + # mkDefault so keepNode.ingress, which knows the real public origin, wins when it is enabled. + // lib.optionalAttrs (cfg.domain != null) { DOMAIN = lib.mkDefault cfg.domain; }; }; # No firewall port opened: nothing reaches Vaultwarden except via localhost / the mesh. diff --git a/nixos/yubikey.nix b/nixos/yubikey.nix new file mode 100644 index 0000000..61e0026 --- /dev/null +++ b/nixos/yubikey.nix @@ -0,0 +1,334 @@ +# YubiKey / FIDO2 hardware-backed admin authentication, layered on top of keepNode.adminAccess. +# +# adminAccess already gives key-only SSH over the mesh, but the operator's private key is a FILE on a +# laptop: steal the laptop (or land malware on it) and you inherit full node admin, silently and +# replayably. A FIDO2 key (`ed25519-sk` / `ecdsa-sk`) moves the private key into the token's secure +# element, where it cannot be copied, and `verify-required` puts a PIN + a physical touch on EVERY +# authentication. Laptop theft alone stops being enough, and remote malware cannot log in without the +# operator physically present. +# +# This is NODE ACCESS ONLY. It does not touch, replace, or weaken the FROST threshold model: vault +# recovery is still the device quorum, and losing every YubiKey costs SSH access, never the vault. The +# permanent break-glass is PHYSICAL access to re-provision the node (boot/recovery media re-enrolls a +# key or reinstalls), not a console login: production leaves no getty autologin and keepadmin/root have +# no password, so a physical console shows no login prompt. +# +# Backward compatible by construction: the module is off by default, and with `requireHardwareKey` +# false (the default) sshd's accepted-algorithm set is untouched, so existing software keys keep +# working. Nothing here introduces a password path, a listening port, or a daemon. +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.keepNode.security.yubikey; + admin = config.keepNode.adminAccess; + keys = import ./lib/authorized-keys.nix { inherit lib; }; + keyCheck = import ./lib/key-check.nix { inherit lib; }; + + # The wire algorithm names each FIDO2 key type presents at authentication. Certificate variants are + # included so an sk-only PubkeyAcceptedAlgorithms set does not silently refuse a signed sk key. + algorithmsFor = { + "ed25519-sk" = [ + "sk-ssh-ed25519@openssh.com" + "sk-ssh-ed25519-cert-v01@openssh.com" + ]; + "ecdsa-sk" = [ + "sk-ecdsa-sha2-nistp256@openssh.com" + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com" + ]; + }; + acceptedAlgorithms = lib.concatMap (t: algorithmsFor.${t}) cfg.keyTypes; + + # An authorized_keys line may carry an options field before the algorithm and may be tab-separated, + # and this classifier is applied to `admin.authorizedKeys` too, where both are legal and unguarded. + # See nixos/lib/authorized-keys.nix for why field-wise parsing (not "everything up to the first + # space") is what keeps a misread line from becoming a false lockout or a false all-clear. + inherit (keys) algorithmOf isHardwareKey; + + # Keys sshd would actually ACCEPT under an sk-only policy: hardware-backed AND of an allowed type. A + # hardware key whose algorithm is excluded by `keyTypes` is refused at auth time, so it does not count + # towards the anti-lockout guard. + hardwareKeys = keys.trimKeys cfg.authorizedKeys; + inlineKeys = hardwareKeys ++ keys.trimKeys admin.authorizedKeys; + usableHardwareKeys = lib.filter ( + k: isHardwareKey k && lib.elem (algorithmOf k) acceptedAlgorithms + ) inlineKeys; + + badHardwareKeys = lib.filter (k: !(isHardwareKey k)) hardwareKeys; + + # Runtime matchers for the SAME allow-list `usableHardwareKeys` applies at eval. Both are derived from + # acceptedAlgorithms rather than matching `sk-` generically: a hardware key of a type excluded by + # keyTypes is refused by sshd at authentication, so treating it as usable (in the boot check) or + # enrolling it (in the helper) would manufacture the exact silent lockout those paths exist to prevent. + # Certificate algorithms are included because a cert line's algorithm field is the cert algorithm. + # + # The boot check matches FIELD-WISE (field 1, or field 2 behind an options prefix such as + # `verify-required sk-ssh-ed25519@...`), never as a substring of the line: an algorithm name is + # perfectly legal inside a software key's free-form COMMENT, and matching it there would report a + # hardware key present on a node whose only key sshd refuses , failing in the unsafe direction, which + # is the one thing this backstop may never do. awk's default field splitting also handles tabs. + acceptedKeyProgram = '' + BEGIN { split("${lib.concatStringsSep " " acceptedAlgorithms}", want, " "); for (i in want) accepted[want[i]] = 1 } + /^[[:space:]]*#/ { next } + (accepted[$1] || accepted[$2]) { found = 1; exit } + END { exit found ? 0 : 1 } + ''; + enrollCasePattern = lib.concatStringsSep " | " (map (a: ''"${a} "*'') acceptedAlgorithms); + + # verify-required as a per-key authorized_keys option (not only the global PubkeyAuthOptions) so PIN + + # touch is enforced on the hardware keys even in a MIXED deployment, where software keys are still + # accepted and a global verify-required would lock them out. + keyLine = k: if cfg.requireVerification then keys.withOption "verify-required" k else k; + + enroll = pkgs.writeShellScriptBin "keepnode-enroll-yubikey" '' + set -euo pipefail + # The runtime keys file is root-owned; the operator logs in as keepadmin. + if [ "$(id -u)" -ne 0 ]; then exec sudo -- "$0" "$@"; fi + + target=${lib.escapeShellArg (toString admin.authorizedKeysFile)} + key="''${1:-}" + if [ -z "$key" ]; then + echo "usage: keepnode-enroll-yubikey " >&2 + echo "Generate one on your LAPTOP (the private key never leaves the token):" >&2 + echo " ssh-keygen -t ${lib.head cfg.keyTypes} -O resident -O verify-required -C yubikey-backup" >&2 + exit 1 + fi + if [ -f "$key" ]; then key="$(${pkgs.coreutils}/bin/cat "$key")"; fi + + # Exactly ONE key per invocation. Every check below is line-agnostic while the WRITE is not: the + # `case` glob spans newlines, ssh-keygen -lf exits 0 if ANY line parses, and the verify-required + # prefix is applied to the payload as a whole. So a two-line paste whose first line is a genuine + # FIDO2 key would carry a second, un-prefixed SOFTWARE key straight into the keys file , voiding + # the one guarantee this helper makes. Refuse the shape outright rather than validate per line. + if [ "$(printf '%s' "$key" | ${pkgs.coreutils}/bin/wc -l)" -ne 0 ]; then + echo "keepnode-enroll-yubikey: input holds more than one line; enroll exactly one public key per invocation." >&2 + exit 1 + fi + + # Hardware-backed types the CONFIGURED keyTypes allow, nothing else: a software key here would hand + # back the exact laptop-theft exposure the module removes, and an sk- key of an excluded type would + # be enrolled only for sshd to refuse it at authentication. + case "$key" in + ${enrollCasePattern}) : ;; + *) + echo "keepnode-enroll-yubikey: not an accepted FIDO2 public key (keepNode.security.yubikey.keyTypes allows ${lib.concatStringsSep ", " acceptedAlgorithms})." >&2 + echo "Use keepNode.adminAccess.authorizedKeys for software keys." >&2 + exit 1 + ;; + esac + # The prefix check above rejects obvious junk; ssh-keygen parses the key BODY, so a truncated or + # corrupted paste cannot be enrolled as an unusable "key". + if ! printf '%s\n' "$key" | ${pkgs.openssh}/bin/ssh-keygen -lf /dev/stdin >/dev/null 2>&1; then + echo "keepnode-enroll-yubikey: public key failed to parse." >&2 + exit 1 + fi + + # Create only what is missing, and never write THROUGH a symlink. This runs as root: on a keys path + # placed under a directory some other service can write, blindly chmod'ing and appending to whatever + # sits at $target would turn an enrollment into an arbitrary root-owned write. Not reachable with the + # shipped root-owned /etc/keepnode, which is exactly why it should stay unreachable by construction. + dir="$(${pkgs.coreutils}/bin/dirname "$target")" + [ -d "$dir" ] || ${pkgs.coreutils}/bin/install -d -m 0755 "$dir" + if [ -L "$target" ]; then + echo "keepnode-enroll-yubikey: $target is a symlink; refusing to enroll through it." >&2 + exit 1 + fi + if [ ! -e "$target" ]; then + ${pkgs.coreutils}/bin/install -m 0644 /dev/null "$target" + elif [ ! -f "$target" ]; then + echo "keepnode-enroll-yubikey: $target is not a regular file." >&2 + exit 1 + fi + # Repair the mode every run, not just on create: sshd's StrictModes ignores a group- or + # world-writable keys file outright, while the boot check reads content only and would report the + # node healthy. Left unrepaired that pair is a silent lockout. + ${pkgs.coreutils}/bin/chmod 0644 "$target" + + # De-duplicate on algorithm + body, ignoring the trailing comment, so re-running with a renamed + # comment does not append a second copy of the same credential. + body="$(printf '%s\n' "$key" | ${pkgs.gawk}/bin/awk '{print $1" "$2}')" + if ${pkgs.gnugrep}/bin/grep -qF "$body" "$target"; then + echo "already enrolled: $body" + else + printf '%s\n' ${lib.escapeShellArg (lib.optionalString cfg.requireVerification "verify-required ")}"$key" >> "$target" + echo "enrolled into $target" + fi + + systemctl restart keep-node-admin-key-check.service 2>/dev/null || true + systemctl restart keep-node-hardware-key-check.service 2>/dev/null || true + ''; +in +{ + options.keepNode.security.yubikey = { + enable = lib.mkEnableOption "YubiKey / FIDO2 hardware-backed SSH authentication for the keepadmin account"; + + authorizedKeys = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "sk-ssh-ed25519@openssh.com AAAA... yubikey-primary" ]; + description = '' + FIDO2 SSH public keys (`ed25519-sk` / `ecdsa-sk`) authorized for `keepadmin`, merged with + `keepNode.adminAccess.authorizedKeys`. Every entry must be an `sk-` type , a software key here + is refused at build time, so the hardware namespace cannot silently hold a file-based key. + Enroll TWO tokens (primary + off-site backup): one hardware key is a single point of failure + for node access. + + Only the ALGORITHM is checked at build time; the key body cannot be. A well-formed line with a + corrupt or truncated body (`sk-ssh-ed25519@openssh.com garbage`) passes every assertion, counts + towards the anti-lockout guard, and is then silently ignored by sshd. Paste the `.pub` file + whole, and confirm an actual login before you rely on a token. + ''; + }; + + keyTypes = lib.mkOption { + type = lib.types.nonEmptyListOf ( + lib.types.enum [ + "ed25519-sk" + "ecdsa-sk" + ] + ); + default = [ + "ed25519-sk" + "ecdsa-sk" + ]; + description = '' + Declarative allow-list of FIDO2 credential types accepted for admin SSH. Under + `requireHardwareKey` this becomes sshd's `PubkeyAcceptedAlgorithms` (plus the matching + certificate algorithms), so narrowing it to `[ "ed25519-sk" ]` genuinely refuses an ecdsa-sk + credential at authentication time rather than merely omitting it from a file. + ''; + }; + + requireHardwareKey = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Refuse every non-hardware-backed SSH key: sshd's `PubkeyAcceptedAlgorithms` is narrowed to the + `sk-` algorithms implied by `keyTypes`, so an enrolled software key is rejected by the daemon, + not merely absent. Off by default so existing software-key deployments (and initial bring-up, + where the operator may not have a token yet) are unaffected. Password auth stays disabled in + both cases , this never adds an authentication path, it only removes one. + + Anti-lockout: turning this on with no usable hardware key is a permanent remote lockout, so it + is refused at build time unless `keepNode.adminAccess.authorizedKeysFile` is configured (whose + contents cannot be known at eval); the `keep-node-hardware-key-check` unit then covers that + case loudly at boot. + ''; + }; + + enableEnrollmentHelper = lib.mkOption { + type = lib.types.bool; + default = admin.authorizedKeysFile != null; + defaultText = lib.literalExpression "config.keepNode.adminAccess.authorizedKeysFile != null"; + description = '' + Install `keepnode-enroll-yubikey `, which validates a FIDO2 public key, appends + it (de-duplicated) to `keepNode.adminAccess.authorizedKeysFile`, and re-runs the anti-lockout + checks , the post-install path for adding a token, or a second one, to a node whose closure was + fixed at build time. Defaults on exactly when that runtime file exists to write to. A node + deployed declaratively lists its keys in `authorizedKeys` instead and needs no helper. + ''; + }; + + requireVerification = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Require user verification (PIN) in addition to the touch on every authentication, matching a + credential created with `ssh-keygen -O verify-required`. Applied as a per-key `verify-required` + option on `authorizedKeys`, so it binds the hardware keys without locking out software keys in a + mixed deployment; under `requireHardwareKey` it is ALSO set globally (`PubkeyAuthOptions`), which + additionally covers keys provisioned into the runtime `authorizedKeysFile`. Turning this off + leaves touch-only credentials, which a laptop-resident attacker can drive whenever the token is + plugged in. + + LOCKOUT CAVEAT: whether a credential was created with `-O verify-required` is a property of the + credential ON THE TOKEN, invisible to both the build-time assertion and the + `keep-node-hardware-key-check` unit , they can only see algorithms in a file. A token enrolled + WITHOUT that flag therefore satisfies both anti-lockout guards and is still refused by sshd at + authentication, so both report healthy on a node you cannot reach. Generate credentials with + `-O verify-required` (as the docs show) and confirm a real login from your laptop before + flipping `requireHardwareKey` on or discarding your software key. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + # The module only shapes adminAccess's sshd and keepadmin's keys; with adminAccess off there is + # no keepadmin account and no sshd config to harden, so the options would silently do nothing. + assertion = admin.enable; + message = "keepNode.security.yubikey.enable is true but keepNode.adminAccess.enable is false: the YubiKey module hardens the keepadmin SSH account that adminAccess defines, so on its own it configures nothing. Enable keepNode.adminAccess (or leave the yubikey module off)."; + } + { + # A software key listed under the hardware namespace would be presented to the operator as + # YubiKey-protected while actually being a copyable file , the precise misconception this module + # exists to remove. Refuse it rather than quietly enroll it. + assertion = badHardwareKeys == [ ]; + message = "keepNode.security.yubikey.authorizedKeys contains a non-FIDO2 key (${lib.concatStringsSep ", " (map algorithmOf badHardwareKeys)}): only hardware-backed sk- types (sk-ssh-ed25519@openssh.com, sk-ecdsa-sha2-nistp256@openssh.com) belong here, otherwise a copyable software key would masquerade as YubiKey-protected. Put software keys in keepNode.adminAccess.authorizedKeys."; + } + { + # Anti-lockout, mirroring adminAccess's guard: with sk-only algorithms enforced, a config whose + # only keys are software keys (or hardware keys of a type excluded by keyTypes) has NO key sshd + # will accept, and password auth is off. That is a permanent remote lockout; fail the build. The + # runtime authorizedKeysFile escape satisfies it because its contents are provisioned after the + # build (the installer / keepnode-enroll-yubikey), so eval cannot see them. + assertion = + !cfg.requireHardwareKey || usableHardwareKeys != [ ] || admin.authorizedKeysFile != null; + message = "keepNode.security.yubikey.requireHardwareKey is true but no usable hardware key is configured: sshd would accept only ${lib.concatStringsSep ", " acceptedAlgorithms}, no inline key matches, password auth is disabled, and no keepNode.adminAccess.authorizedKeysFile is set , a permanent remote lockout. Add an sk- key of an allowed type to keepNode.security.yubikey.authorizedKeys (or set authorizedKeysFile and enroll one with keepnode-enroll-yubikey)."; + } + { + # The helper writes into adminAccess's runtime keys file; with no such file configured there is + # nowhere to enroll to, and the command would fail at the point the operator needs it most. + assertion = !cfg.enableEnrollmentHelper || admin.authorizedKeysFile != null; + message = "keepNode.security.yubikey.enableEnrollmentHelper is true but keepNode.adminAccess.authorizedKeysFile is null: keepnode-enroll-yubikey writes the new key into that runtime file, so there is nowhere to enroll. Set keepNode.adminAccess.authorizedKeysFile (or disable the helper and enroll declaratively via keepNode.security.yubikey.authorizedKeys)."; + } + ]; + + # Feed the hardware keys through adminAccess rather than straight into users.users: that is the one + # place keepadmin's key set is assembled, so these keys are also seen by adminAccess's own + # anti-lockout assertion and its boot-time check. A YubiKey-only deployment (no software key listed + # at all) therefore builds, instead of tripping a guard that cannot see the hardware keys. + keepNode.adminAccess.authorizedKeys = map keyLine hardwareKeys; + + services.openssh.settings = lib.mkIf cfg.requireHardwareKey { + # sshd-level enforcement: a software key is refused during authentication even if it is present in + # an authorized_keys file. Comma-separated, as sshd_config expects. + PubkeyAcceptedAlgorithms = lib.concatStringsSep "," acceptedAlgorithms; + # Global backstop for keys the Nix-managed per-key `verify-required` option cannot reach (the + # runtime authorizedKeysFile). Safe here precisely because only sk- keys are accepted at all. + PubkeyAuthOptions = lib.mkIf cfg.requireVerification "verify-required"; + }; + + # ssh-keygen needs FIDO support (nixpkgs builds openssh with withFIDO) and libfido2's tooling to talk + # to a token, so an operator can generate or inspect a credential from the node itself if needed. + environment.systemPackages = [ pkgs.libfido2 ] ++ lib.optional cfg.enableEnrollmentHelper enroll; + + # Runtime anti-lockout backstop for what the build cannot see: with sk-only algorithms enforced and + # the key material living in a runtime file, an un-enrolled, failed, or deleted credential leaves + # keepadmin with nothing sshd will accept , a silent brick. This oneshot inspects the effective key + # sources at boot and fails LOUDLY (console message + a systemctl --failed entry) when none holds a + # hardware key. Nothing requires the unit, so surfacing the lockout never blocks boot or sshd. + systemd.services.keep-node-hardware-key-check = lib.mkIf cfg.requireHardwareKey ( + keyCheck.mkKeyCheck { + description = "Anti-lockout: fail loudly if keepadmin has no hardware-backed (FIDO2) SSH key"; + authorizedKeysFile = admin.authorizedKeysFile; + # A usable hardware key is a non-comment line whose ALGORITHM FIELD is one of the accepted + # algorithms (not merely any sk- type, which sshd would refuse if keyTypes excludes it), whether + # or not it sits behind an options field (e.g. `verify-required sk-ssh-ed25519@...`). + matcher = "${pkgs.gawk}/bin/awk ${lib.escapeShellArg acceptedKeyProgram} \"$f\" 2>/dev/null"; + # The passing case is deliberately narrower than "you can log in": this sees ALGORITHMS in a + # file, never whether the credential behind one was created with -O verify-required, so under + # requireVerification a token missing it clears this check and is still refused at auth. Say so + # here, because an operator reading this message is mid-lockout and needs the real next step. + message = + "KEEP NODE ANTI-LOCKOUT: keepadmin has NO hardware-backed (sk-) SSH key, but keepNode.security.yubikey.requireHardwareKey restricts sshd to ${lib.concatStringsSep "," acceptedAlgorithms}. Password auth is off, so remote access is IMPOSSIBLE. Enroll a FIDO2 key (keepnode-enroll-yubikey ), then: systemctl restart keep-node-hardware-key-check.service" + + lib.optionalString cfg.requireVerification " NOTE: this check reads key algorithms only. requireVerification is on, so a credential created WITHOUT -O verify-required will satisfy this check and still be refused by sshd; confirm an actual login from your laptop before relying on it."; + okMessage = "keepadmin has at least one hardware-backed SSH key"; + } + ); + }; +} diff --git a/tests/yubikey-ssh.nix b/tests/yubikey-ssh.nix new file mode 100644 index 0000000..3eb63fe --- /dev/null +++ b/tests/yubikey-ssh.nix @@ -0,0 +1,172 @@ +# YubiKey / FIDO2 admin SSH posture (issue #99). Two nodes, differing only in +# `keepNode.security.yubikey.requireHardwareKey`, prove the security-relevant behaviour of the module: +# +# strict: requireHardwareKey = true -> sshd accepts ONLY sk- algorithms, so the enrolled SOFTWARE key +# is refused at authentication time (not merely absent from a file), verify-required is +# enforced globally, and the runtime anti-lockout unit fails loudly while no hardware key +# exists , then passes once one is enrolled. +# mixed: requireHardwareKey = false -> the accepted-algorithm set is untouched and the same software +# key logs in normally. This is the backward-compatibility guarantee for existing deployments. +# +# A real FIDO2 authentication cannot be exercised here (it needs physical hardware; there is no virtual +# authenticator in the test VM), so the test asserts the sshd policy that gates it plus the refusal of +# everything that is not hardware-backed , which is the part a refactor can silently break. +# +# Run: nix build .#checks.x86_64-linux.yubikey-ssh +{ + adminKeyFixture, + ... +}: +let + keysFile = "/etc/keepnode/admin_authorized_keys"; + base = + { + requireHardwareKey, + keyTypes ? [ + "ed25519-sk" + "ecdsa-sk" + ], + }: + { + imports = [ + ../nixos/admin-access.nix + ../nixos/yubikey.nix + ]; + keepNode.adminAccess = { + enable = true; + # The operator's SOFTWARE key is provisioned into the runtime file by the test script, mirroring + # how install-keepnode enrolls it post-install. + authorizedKeysFile = keysFile; + lanBringup = true; + }; + keepNode.security.yubikey = { + enable = true; + inherit requireHardwareKey keyTypes; + }; + }; +in +{ + name = "keep-node-yubikey-ssh"; + + nodes.strict = { ... }: base { requireHardwareKey = true; }; + nodes.mixed = { ... }: base { requireHardwareKey = false; }; + # keyTypes narrowed to one credential type: an ecdsa-sk key is hardware-backed but sshd is configured + # to refuse it, so neither the anti-lockout check nor the enrollment helper may treat it as usable. + nodes.narrow = + { ... }: + base { + requireHardwareKey = true; + keyTypes = [ "ed25519-sk" ]; + }; + + testScript = + { nodes, ... }: + let + strictLan = nodes.strict.networking.primaryIPAddress; + mixedLan = nodes.mixed.networking.primaryIPAddress; + # A syntactically well-formed sk- authorized_keys line. It is never authenticated with (no + # authenticator exists in the VM); it only has to be the kind of line the anti-lockout check must + # recognise as a hardware key. + skKey = "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fAAAABHNzaDo= yubikey-fixture"; + # A REAL, parseable ecdsa-sk credential (valid P-256 point), so the narrow node's refusals are + # attributable to the keyTypes allow-list and not to a malformed key body. + skEcdsaKey = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBOKRXwzBjTsl22Z1ZTrbbYm69yDI7inkAVNomlkgMUZSoRbudm0h2HRnmlkOfeltknXGkZ5an2nI9bzuhTeg41IAAAAEc3NoOg== yubikey-ecdsa-fixture"; + in + '' + start_all() + strict.wait_for_unit("sshd.service") + mixed.wait_for_unit("sshd.service") + narrow.wait_for_unit("sshd.service") + + ssh = ( + "ssh -i /root/id -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " + "-o BatchMode=yes -o ConnectTimeout=10" + ) + for m in (strict, mixed, narrow): + m.succeed("install -m 0600 ${adminKeyFixture}/id /root/id") + m.succeed("install -d -m 0755 /etc/keepnode") + m.succeed("install -m 0644 ${adminKeyFixture}/id.pub ${keysFile}") + # The hardened posture is untouched by this module: no password path, no root over the network. + m.succeed("sshd -T | grep -qx 'passwordauthentication no'") + m.succeed("sshd -T | grep -qx 'kbdinteractiveauthentication no'") + m.succeed("sshd -T | grep -qx 'permitrootlogin no'") + + # requireHardwareKey narrows sshd to the sk- algorithms (both key types are allowed by default, + # certificate variants included) and requires user verification (PIN + touch) on every auth. + strict.succeed("sshd -T | grep -q '^pubkeyacceptedalgorithms .*sk-ssh-ed25519@openssh.com'") + strict.succeed("sshd -T | grep -q '^pubkeyacceptedalgorithms .*sk-ecdsa-sha2-nistp256@openssh.com'") + strict.succeed("sshd -T | grep -qx 'pubkeyauthoptions verify-required'") + # ...and NOTHING software-backed survives in that set. + strict.fail("sshd -T | grep -E '^pubkeyacceptedalgorithms ' | grep -qE '(^| |,)(ssh-ed25519|rsa-sha2-[0-9]+|ecdsa-sha2-nistp256)(,| |$)'") + + # The mixed node keeps stock algorithm negotiation (software keys still negotiable) and does not + # impose a global verify-required, which would lock those software keys out. + # Anchored on the FIELD: bare 'ssh-ed25519' is a substring of 'sk-ssh-ed25519@openssh.com', which + # is present in the strict set too, so an unanchored match would pass even if this node had + # regressed into the sk-only posture , proving nothing about backward compatibility. + mixed.succeed("sshd -T | grep -E '^pubkeyacceptedalgorithms ' | grep -qE '(^| |,)ssh-ed25519(,| |$)'") + mixed.fail("sshd -T | grep -qx 'pubkeyauthoptions verify-required'") + + # Anti-lockout: the strict node enforces sk-only but no hardware key is enrolled, so the runtime + # check must FAIL LOUDLY , while boot and sshd carry on regardless. + strict.wait_until_succeeds("systemctl is-failed --quiet keep-node-hardware-key-check.service") + strict.succeed("journalctl -u keep-node-hardware-key-check.service --no-pager | grep -q 'ANTI-LOCKOUT'") + # The unit is not part of any dependency chain, so the failure did not take sshd down. + strict.succeed("systemctl is-active --quiet sshd.service") + # A SOFTWARE key present in the same file does not clear it: the check counts hardware keys, not + # keys, which is exactly the lockout it exists to catch. + strict.fail("systemctl restart keep-node-hardware-key-check.service") + # Nor does a software key whose free-form COMMENT merely names an sk- algorithm: the check must + # match the algorithm FIELD, since a substring match here reports a hardware key on a node whose + # only key sshd will refuse , the silent brick this unit exists to catch, in the unsafe direction. + strict.succeed("cp ${keysFile} /tmp/keys.bak") + strict.succeed("printf '%s\\n' \"$(cat ${adminKeyFixture}/id.pub) my sk-ssh-ed25519@openssh.com backup key\" >> ${keysFile}") + strict.fail("systemctl restart keep-node-hardware-key-check.service") + strict.succeed("cp /tmp/keys.bak ${keysFile}") + + # The software key is enrolled and would authenticate on any ordinary key-only node; the strict + # node refuses it at the DAEMON, purely because it is not hardware-backed. This is the property. + strict.fail(f"{ssh} keepadmin@${strictLan} true") + # Same key, same account, requireHardwareKey off -> logs in with passwordless sudo (backward + # compatibility for every existing software-key deployment). + mixed.wait_until_succeeds(f"{ssh} keepadmin@${mixedLan} true", timeout=60) + mixed.succeed(f"{ssh} keepadmin@${mixedLan} sudo -n true") + + # Enrolling a hardware key into the runtime file clears the anti-lockout alarm , no false alarm on + # a correctly-provisioned node. + strict.succeed("printf '%s\\n' 'verify-required ${skKey}' >> ${keysFile}") + strict.succeed("systemctl restart keep-node-hardware-key-check.service") + strict.succeed("systemctl is-active --quiet keep-node-hardware-key-check.service") + + # The enrollment helper is the post-install path for a second (backup) token: it accepts a FIDO2 + # key, de-duplicates, and refuses a software key outright, so the hardware guarantee cannot be + # eroded by enrolling a copyable key through it. + strict.succeed("keepnode-enroll-yubikey '${skKey}' | grep -q 'already enrolled'") + strict.fail("keepnode-enroll-yubikey \"$(cat ${adminKeyFixture}/id.pub)\"") + # A MULTI-LINE payload whose first line is a genuine FIDO2 key must be refused whole. Validating + # only line 1 (which every check here does, being line-agnostic) would append line 2 , a software + # key, without the verify-required prefix , through a helper whose entire point is refusing one. + strict.fail("keepnode-enroll-yubikey \"$(printf '%s\\n%s' '${skKey}' \"$(cat ${adminKeyFixture}/id.pub)\")\"") + strict.succeed("test \"$(grep -c '^ssh-ed25519 ' ${keysFile})\" = 1") + + # keyTypes is a real allow-list, not documentation: with it narrowed to ed25519-sk, sshd carries no + # ecdsa-sk algorithm at all... + narrow.succeed("sshd -T | grep -q '^pubkeyacceptedalgorithms .*sk-ssh-ed25519@openssh.com'") + narrow.fail("sshd -T | grep -E '^pubkeyacceptedalgorithms ' | grep -q 'sk-ecdsa'") + # ...the helper refuses to enroll a credential sshd would then reject (citing keyTypes)... + narrow.fail("keepnode-enroll-yubikey '${skEcdsaKey}'") + # Captured to a file rather than piped: the driver runs commands under `pipefail`, so a pipeline + # whose left-hand side exits non-zero can never pass regardless of what the message says. + narrow.succeed("keepnode-enroll-yubikey '${skEcdsaKey}' 2>/tmp/enroll.err || true") + narrow.succeed("grep -q 'keyTypes' /tmp/enroll.err") + narrow.fail("grep -q 'sk-ecdsa' ${keysFile}") + # ...and an excluded-type key sitting in the keys file does NOT satisfy the anti-lockout check, + # which is the silent lockout a generic sk- match would have waved through. + narrow.succeed("printf '%s\\n' 'verify-required ${skEcdsaKey}' >> ${keysFile}") + narrow.fail("systemctl restart keep-node-hardware-key-check.service") + # An ALLOWED type clears it, so the check is not simply always-failing. + narrow.succeed("keepnode-enroll-yubikey '${skKey}'") + narrow.succeed("systemctl restart keep-node-hardware-key-check.service") + narrow.succeed("systemctl is-active --quiet keep-node-hardware-key-check.service") + ''; +}