diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 683bd5fc..aeac6a92 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -332,6 +332,13 @@ isolation. Source review and socket metadata—not reads of private credentials Browser media and browserpass are enabled only for Firefox/Chromium/Chrome. Brave Origin and Edge are backup browsers with no media grants. browserpass exposes password-store/GPG data and agent operations. +- Firefox/Chromium/Chrome receive FIDO2 security-key passthrough for WebAuthn/passkeys. Host udev + rules hard-link only FIDO-tagged hidraw nodes into `/dev/fido-hidraw` and remove them on unplug; + the sandbox binds that directory plus read-only `/sys/class/hidraw` and the udev database. The + CanoKey KBD and CCID interfaces, other HID devices and raw USB stay outside the sandbox; Brave + Origin and Edge receive no FIDO2 passthrough. A compromised browser can use the key's FIDO + interface; the key's PIN and touch policy remain the authentication boundary. Hotplug works for + host-network Firefox/Chromium; pasta-isolated Chrome needs the key present at launch. - Nixpak supplements Chromium's renderer sandbox, which remains enabled. Zoom/WeChat retain their FHS runtimes inside the outer boundary; inner mounts cannot recover hidden host resources. - Desktop access has limits: GPU drivers remain exposed, PulseAudio permits recording, read-only @@ -348,8 +355,9 @@ isolation. Source review and socket metadata—not reads of private credentials - Policy/build passes for all nine wrappers. Interactive validation passed for Firefox, Chrome, Chromium, Brave Origin, Edge, QQ, Telegram and Zoom; the Chromium smoke test also validates renderer sandboxing, environment clearing, bind scope and profile persistence. WeChat's - interactive Wayland check remains pending. See the - [runtime validation](../hardening/tests/runtime-validation.md). + interactive Wayland check remains pending. FIDO2 browser passthrough passed WebAuthn with the + CanoKey in Firefox and Chrome; Chromium and plugging the key in after launch still need + validation. See the [runtime validation](../hardening/tests/runtime-validation.md). #### Agent-first diagnostics by host role diff --git a/hardening/README.md b/hardening/README.md index fa409b03..d2a9164e 100644 --- a/hardening/README.md +++ b/hardening/README.md @@ -18,8 +18,12 @@ workflow. browsers share an app-specific `/tmp` so external URLs reuse one browser instance. - Read-only `/nix/store`; GPU and display; fonts and themes; LAN and Internet. - Portals for file chooser, URI opening, notifications, input methods and document exports. -- No whole host home, system D-Bus, SSH agent, USB/serial or raw camera. Raw PipeWire is granted - only to MuseScore for its ALSA-via-PipeWire audio path; see [Limits](#limits). +- No whole host home, system D-Bus, SSH agent, raw USB/serial or raw camera. For + Firefox/Chromium/Chrome, the only device passthrough is the FIDO HID interface of a security key: + host udev rules hard-link FIDO-tagged hidraw nodes into `/dev/fido-hidraw`, which the sandbox + binds together with read-only `/sys/class/hidraw` and the udev database, so keys can be plugged in + after launch. Other HID interfaces, CCID and raw USB stay hidden. Raw PipeWire is granted only to + MuseScore for its ALSA-via-PipeWire audio path; see [Limits](#limits). - Host loopback only for Firefox/Chromium; all other apps use pasta-isolated loopback. Chromium is an intentional development exception. Brave Origin and Edge are backup browsers and receive no media grants. @@ -31,9 +35,9 @@ explicitly; other files use the document portal. Every app also has private writ | App | Network | Writable paths | Grants | | ------------ | ------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle; camera/screen sharing intentionally unavailable | -| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, v12 Secret | -| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, v12 Secret | +| Firefox | Host loopback | `~/.mozilla`, personal files, Trash, Firefox artwork | Audio, browserpass, MPRIS, idle, FIDO2 key; camera/screen sharing intentionally unavailable | +| Chromium | Host loopback | `~/.config/chromium`, personal files | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret | +| Chrome | Isolated | `~/.config/google-chrome`, Downloads | Media, browserpass, MPRIS, idle, FIDO2 key, v12 Secret | | Brave Origin | Isolated | `~/.config/BraveSoftware/Brave-Origin`, Downloads | v12 Secret | | Edge | Isolated | `~/.config/microsoft-edge`, Downloads | v12 Secret | | QQ | Isolated | Personal files, private app state | Media, notifications, tray, MPRIS, idle, accessibility | @@ -73,7 +77,22 @@ All browsers get read-only NSS certificates, printing, native notifications and - PulseAudio grants recording authority. Read-only GPG sockets still permit signing and decryption. - pasta isolates loopback, not LAN access. Chromecast discovery is unverified. - Brave Origin and Edge intentionally have no PulseAudio, Camera, ScreenCast or MPRIS grants. They - are backup browsers, not media browsers. + are backup browsers, not media browsers. They also receive no FIDO2 passthrough. +- FIDO2 passthrough is limited to the hidraw interface whose report descriptor declares the FIDO + usage page. On the CanoKey that is the FIDO interface only: its KBD and CCID interfaces, other HID + devices and raw USB stay outside the sandbox. A compromised browser process can send raw CTAP to + the key; the key's PIN and touch policy remain the authentication boundary. The read-only udev + database exposes properties of all host devices, much of which is already readable via the sysfs + device binds. +- CanoKey management (CanoKey Console over WebUSB) is intentionally unavailable: Firefox has no + WebUSB and the sandbox exposes no raw USB. PCs only use the key; management happens in the phone + app. See [canokey.nix](../modules/nixos/desktop/canokey.nix) for the policy and its limits. +- FIDO2 hotplug relies on udev events, which only reach the host network namespace. Firefox and + Chromium therefore detect keys plugged in after launch; pasta-isolated Chrome needs the key + present before launch. `/dev/fido-hidraw` is created at boot. When first deploying with `switch` + instead of a reboot, run `sudo systemd-tmpfiles --create --prefix=/dev/fido-hidraw`, then replug + the key (or `sudo udevadm trigger --action=add -s hidraw`) and fully restart the browsers: a + browser started while the directory was missing has no bind for it. - Firefox camera and remote screen sharing are intentionally disabled. Firefox does not support the XDG Camera/ScreenCast portal path used here, so granting camera or screen-capture access would require widening the sandbox with raw `/dev/video*` or direct PipeWire access. We choose not to @@ -108,6 +127,8 @@ All browsers get read-only NSS certificates, printing, native notifications and and user workflows still need confirmation. - Chromium smoke test passed: renderer sandbox, environment clearing, bind scope, disposable-profile persistence and loopback policy. +- FIDO2 browser passthrough is enabled for Firefox/Chromium/Chrome only. WebAuthn with the CanoKey + passed in Firefox and Chrome; Chromium and plugging the key in after launch still need validation. - WeChat uses the native nixpkgs-master package with nixpak as the only bwrap layer. Build/policy pass; interactive Wayland validation is still pending. - Evidence and repeat commands: [runtime validation](tests/runtime-validation.md), @@ -121,7 +142,9 @@ All browsers get read-only NSS certificates, printing, native notifications and [desktop-integration.nix](nixpaks/modules/desktop-integration.nix): portal and desktop service grants. - [browser-permissions.nix](nixpaks/modules/browser-permissions.nix), - [chromium-browser.nix](nixpaks/modules/chromium-browser.nix): browser scopes and launcher. + [fido-bwrap.nix](nixpaks/modules/fido-bwrap.nix), + [chromium-browser.nix](nixpaks/modules/chromium-browser.nix): browser scopes, FIDO2 passthrough + wrapper and launcher. - [nixpaks/wechat.nix](nixpaks/wechat.nix): WeChat package and data setup. - [kernel-hardening.nix](kernel-hardening.nix), [apparmor/](apparmor/): kernel sysctl and AppArmor profiles. diff --git a/hardening/nixpaks/chromium.nix b/hardening/nixpaks/chromium.nix index d9641149..ca2ad494 100644 --- a/hardening/nixpaks/chromium.nix +++ b/hardening/nixpaks/chromium.nix @@ -13,6 +13,7 @@ desktopFile = "chromium-browser"; browserpass = true; media = true; + fido2 = true; hostLoopback = true; personalFiles = true; secretPortal = true; diff --git a/hardening/nixpaks/default.nix b/hardening/nixpaks/default.nix index 4edbd84f..6b8a5539 100644 --- a/hardening/nixpaks/default.nix +++ b/hardening/nixpaks/default.nix @@ -16,6 +16,21 @@ let wrapper = _pkgs: path: (_pkgs.callPackage path callArgs); in { + # FIDO2 hotplug for sandboxed browsers (see modules/fido-bwrap.nix). The + # directory is created at boot, before udevd starts, so the browser bind + # succeeds even with no key present. NixOS applies /dev tmpfiles entries only + # at boot; after a switch without reboot, run + # `sudo systemd-tmpfiles --create --prefix=/dev/fido-hidraw` once. + # Hard links share the device inode, so logind's uaccess ACLs stay + # authoritative, and removal on unplug leaves no stale node for a reused + # hidraw minor. 60-fido-id.rules sets ID_FIDO_TOKEN from the report + # descriptor, so the CanoKey's keyboard interface is never linked. + systemd.tmpfiles.rules = [ "d /dev/fido-hidraw 0755 root root -" ]; + services.udev.extraRules = '' + ACTION=="add", SUBSYSTEM=="hidraw", ENV{ID_FIDO_TOKEN}=="1", RUN+="${pkgs.coreutils}/bin/ln -f /dev/%k /dev/fido-hidraw/%k" + ACTION=="remove", SUBSYSTEM=="hidraw", RUN+="${pkgs.coreutils}/bin/rm -f /dev/fido-hidraw/%k" + ''; + # Add nixpaked Apps into nixpkgs, and reference them in home-manager or other nixos modules nixpkgs.overlays = [ (_: super: { diff --git a/hardening/nixpaks/firefox.nix b/hardening/nixpaks/firefox.nix index b541f7fd..b428395b 100644 --- a/hardening/nixpaks/firefox.nix +++ b/hardening/nixpaks/firefox.nix @@ -37,6 +37,7 @@ let media = true; personalFiles = true; browserpass = true; + fido2 = true; }; # Firefox keeps the host network: local development servers and the diff --git a/hardening/nixpaks/google-chrome.nix b/hardening/nixpaks/google-chrome.nix index e4917ef4..27332270 100644 --- a/hardening/nixpaks/google-chrome.nix +++ b/hardening/nixpaks/google-chrome.nix @@ -13,5 +13,6 @@ desktopFile = "google-chrome"; browserpass = true; media = true; + fido2 = true; secretPortal = true; } diff --git a/hardening/nixpaks/modules/browser-permissions.nix b/hardening/nixpaks/modules/browser-permissions.nix index 4c26004f..26852350 100644 --- a/hardening/nixpaks/modules/browser-permissions.nix +++ b/hardening/nixpaks/modules/browser-permissions.nix @@ -1,14 +1,19 @@ # Shared browser capabilities. Network topology and application profiles are -# selected by the app; personal files, browserpass, Secret portal and media are explicit opt-ins. +# selected by the app; personal files, browserpass, Secret portal, media and +# FIDO2 security-key access are explicit opt-ins. { config, lib, + pkgs, sloth, ... }: let cfg = config.browserPermissions; passwordStore = sloth.concat' sloth.homeDir "/.local/share/password-store"; + # Maintained by the host udev rules in ../default.nix. + fidoDir = "/dev/fido-hidraw"; + fidoBwrap = import ./fido-bwrap.nix { inherit pkgs fidoDir; }; in { imports = [ ./desktop-base.nix ]; @@ -17,6 +22,7 @@ in personalFiles = lib.mkEnableOption "personal Documents, media directories and Trash"; browserpass = lib.mkEnableOption "browserpass access to the password store and GPG agent"; secretPortal = lib.mkEnableOption "the app-scoped Secret portal for v12 cookie encryption"; + fido2 = lib.mkEnableOption "FIDO2 security-key (CanoKey/YubiKey) WebAuthn and passkey access"; }; config = { portalGroups = { @@ -36,6 +42,7 @@ in accessibility = true; }; bubblewrap = { + package = lib.mkIf cfg.fido2 fidoBwrap; sockets.pulse = cfg.media; bind.rw = [ sloth.xdgDownloadDir @@ -57,7 +64,17 @@ in (sloth.concat' sloth.homeDir "/.gnupg") passwordStore (sloth.concat' sloth.runtimeDir "/gnupg") + ] + # libudev enumeration and hotplug: hidraw class links (device directories + # come from desktop-base/GPU sysfs binds), the udev database without which + # devnode-backed devices count as uninitialized, and the control socket + # whose presence enables the udev monitor. Root owns the socket. + ++ lib.optionals cfg.fido2 [ + "/sys/class/hidraw" + "/run/udev/data" + "/run/udev/control" ]; + bind.dev = lib.optional cfg.fido2 fidoDir; env = lib.optionalAttrs cfg.browserpass { PASSWORD_STORE_DIR = passwordStore; }; }; }; diff --git a/hardening/nixpaks/modules/chromium-browser.nix b/hardening/nixpaks/modules/chromium-browser.nix index 9b8a893c..7245bbf9 100644 --- a/hardening/nixpaks/modules/chromium-browser.nix +++ b/hardening/nixpaks/modules/chromium-browser.nix @@ -21,6 +21,7 @@ secretPortal ? false, hostLoopback ? false, personalFiles ? false, + fido2 ? false, privateWindowFlag ? "--incognito", }: let @@ -64,6 +65,7 @@ let personalFiles media secretPortal + fido2 ; }; bubblewrap.bind.rw = [ diff --git a/hardening/nixpaks/modules/fido-bwrap.nix b/hardening/nixpaks/modules/fido-bwrap.nix new file mode 100644 index 00000000..e520f379 --- /dev/null +++ b/hardening/nixpaks/modules/fido-bwrap.nix @@ -0,0 +1,51 @@ +# Replacement `bwrap` for browsers with browserPermissions.fido2 (Firefox, +# Chromium, Chrome), so they can use FIDO2 security keys. +# +# The problem: browsers find keys by listing /sys/class/hidraw, then open the +# FIDO ones at /dev/hidrawN (e.g. /dev/hidraw6). The sandbox /dev is a private +# tmpfs, so those nodes are missing. The number N is only known once a key is +# plugged in, which may be after the browser starts. +# +# How keys reach the sandbox: +# 1. Host (../default.nix): when a key is plugged in, udev hard-links its FIDO +# node into /dev/fido-hidraw/ and deletes it on unplug. Non-FIDO interfaces, +# such as the CanoKey keyboard, are never linked. +# 2. browser-permissions.nix binds /dev/fido-hidraw into the sandbox. It is a +# shared live directory, so later plug/unplug shows up inside immediately. +# 3. This script creates /dev/hidraw0 ... /dev/hidraw63 in the sandbox as +# symlinks into /dev/fido-hidraw/. A link works only while a FIDO key with +# that number is plugged in; the others fail to open with ENOENT. +# +# nixpak has no option for creating symlinks, so this script adds the +# `--symlink` options and then runs the real bwrap. +{ pkgs, fidoDir }: +pkgs.writers.writePython3Bin "bwrap" + { + # Interpolated store paths exceed the default line length. + flakeIgnore = [ "E501" ]; + } + '' + import os + import sys + + REAL_BWRAP = "${pkgs.bubblewrap}/bin/bwrap" + FIDO_DIR = "${fidoDir}" + # The kernel allows at most 64 hidraw devices (HIDRAW_MAX_DEVICES). + HIDRAW_COUNT = 64 + + args = sys.argv[1:] + + # nixpak runs: bwrap -- + # Insert the symlinks just before "--": by then nixpak's "--dev /dev" has + # created the sandbox /dev they live in. nixpak's D-Bus proxy sandbox runs + # bwrap without "--" and stays unchanged. + if "--" in args: + symlinks = [] + for n in range(HIDRAW_COUNT): + # e.g. --symlink /dev/fido-hidraw/hidraw6 /dev/hidraw6 + symlinks += ["--symlink", f"{FIDO_DIR}/hidraw{n}", f"/dev/hidraw{n}"] + separator = args.index("--") + args[separator:separator] = symlinks + + os.execv(REAL_BWRAP, [REAL_BWRAP, *args]) + '' diff --git a/hardening/tests/policy-lib.nix b/hardening/tests/policy-lib.nix index 23a1b264..9e431200 100644 --- a/hardening/tests/policy-lib.nix +++ b/hardening/tests/policy-lib.nix @@ -42,7 +42,9 @@ let || lib.hasPrefix "/dev/video" path || lib.hasPrefix "/dev/bus/usb" path || path == "/run/dbus" - || path == "/run/dbus/system_bus_socket"; + || path == "/run/dbus/system_bus_socket" + || path == "/sys" + || path == "/run/udev"; hasHostFontconfig = app: lib.elem [ "/etc/static/fonts" "/etc/fonts" ] app.bubblewrap.bind.ro; @@ -95,6 +97,14 @@ let !(app.browserPermissions.secretPortal or false) && !(hasDbusFragment app "org.freedesktop.portal.Secret"); + # FIDO2 needs both the udev-maintained node directory and the bwrap wrapper + # that links /dev/hidrawN into it; neither may leak to other apps. + fido2Check = + app: expected: + (app.browserPermissions.fido2 or false) == expected + && lib.elem "/dev/fido-hidraw" app.bubblewrap.bind.dev == expected + && (lib.getName app.bubblewrap.package == "bwrap") == expected; + trayCheck = app: expected: if expected then @@ -130,6 +140,7 @@ let "${name}/tray" = trayCheck app (spec.tray or false); "${name}/browserpass" = (app.browserPermissions.browserpass or false) == (spec.browserpass or false); + "${name}/fido2" = fido2Check app (spec.fido2 or false); "${name}/personalFiles" = (app.browserPermissions.personalFiles or false) == (spec.personalFiles or false); "${name}/secretPortal" = secretPortalCheck app (spec.secretPortal or false); diff --git a/hardening/tests/policy/chromium.nix b/hardening/tests/policy/chromium.nix index a4c7da69..b7321f09 100644 --- a/hardening/tests/policy/chromium.nix +++ b/hardening/tests/policy/chromium.nix @@ -7,4 +7,5 @@ mkAppPolicy "chromium" { personalFiles = true; browserpass = true; secretPortal = true; + fido2 = true; } diff --git a/hardening/tests/policy/firefox.nix b/hardening/tests/policy/firefox.nix index bb583b94..e80837a7 100644 --- a/hardening/tests/policy/firefox.nix +++ b/hardening/tests/policy/firefox.nix @@ -17,6 +17,7 @@ mkAppPolicy "firefox" { personalFiles = true; browserpass = true; secretPortal = false; + fido2 = true; extraChecks = { capabilitiesMatchChromium = firefox.browserPermissions.media == chromium.browserPermissions.media diff --git a/hardening/tests/policy/google-chrome.nix b/hardening/tests/policy/google-chrome.nix index 7bb2d342..52cb62db 100644 --- a/hardening/tests/policy/google-chrome.nix +++ b/hardening/tests/policy/google-chrome.nix @@ -7,4 +7,5 @@ mkAppPolicy "google-chrome" { personalFiles = false; browserpass = true; secretPortal = true; + fido2 = true; } diff --git a/hardening/tests/runtime-validation.md b/hardening/tests/runtime-validation.md index bb8076b3..c33f412e 100644 --- a/hardening/tests/runtime-validation.md +++ b/hardening/tests/runtime-validation.md @@ -87,6 +87,20 @@ v10 storage; monitor `Local State.os_crypt.portal.prev_init_success`. Firefox us instead. Browserpass separately exposes the password store and GPG-agent socket to Firefox/Chromium/Chrome only. +## FIDO2 passthrough + +Passed after a reboot on `ai` (CanoKey Canary, PR #515 head): with the key present at boot, the host +linked only its FIDO interface into `/dev/fido-hidraw`, and WebAuthn works in Firefox and Chrome. +Chromium, plugging the key in after browser launch, and the in-sandbox keyboard-interface check are +still pending. Full checklist: + +1. On the host, `ls -l /dev/fido-hidraw/` lists only the FIDO interface (`ID_FIDO_TOKEN=1`); it + follows plug and unplug. +2. Firefox/Chromium: start without the key, plug it in, then register and sign in on + . Unplug and replug without restarting, then sign in again. +3. Chrome: the same flow with the key present at launch; record whether a later plug is detected. +4. In each sandbox, the CanoKey keyboard interface's `/dev/hidrawN` fails to open with `ENOENT`. + ## Zoom Zoom keeps its upstream FHS runtime inside the outer nixpak boundary. The nested sandbox propagates diff --git a/modules/nixos/desktop/canokey.nix b/modules/nixos/desktop/canokey.nix index cba61865..2c8c3117 100644 --- a/modules/nixos/desktop/canokey.nix +++ b/modules/nixos/desktop/canokey.nix @@ -12,6 +12,19 @@ in enable = lib.mkEnableOption "CanoKey hardware security key support"; }; + # CanoKey docs: https://docs.canokeys.org/userguide/ + # + # Principle: management and use happen on different devices. Linux PCs only + # use the key (WebAuthn/passkeys, OpenPGP, SSH). CanoKey Console management + # (Admin PIN, application/factory reset, device settings) happens only in the + # phone app (https://docs.canokeys.org/userguide/admin/), so the Admin PIN is + # never typed on a PC. + # + # Sandboxed browsers back this up: they see only the FIDO HID interface + # (hardening/nixpaks), not raw USB or CCID, so the WebUSB console cannot reach + # the key, and the vendor's WebUSB udev rule (MODE 0666 on the raw USB node) + # is deliberately not installed. Unsandboxed software is trusted; if it is + # malicious, the PC is compromised regardless. config = lib.mkIf cfg.enable { # systemd tags FIDO/CCID devices uaccess (60-fido-id.rules); pcscd covers OpenPGP. services.pcscd.enable = true;