feat(nixpak): pass FIDO2 keys to Firefox, Chromium and Chrome (#515)

* feat(nixpak): pass FIDO2 keys to Firefox, Chromium and Chrome

Assisted-by: DeepSeek Harness (deepseek-flash)

* feat(nixpak): support FIDO2 hotplug via udev-maintained node directory

Replace launch-time hidraw discovery with host udev rules that hard-link
FIDO-tagged hidraw nodes into /dev/fido-hidraw and remove them on unplug.
The sandbox dev-binds that directory and gets static /dev/hidrawN symlinks
into it, so keys plugged in after launch work and no stale node survives a
reused hidraw minor.

Narrow the extra mounts from whole /sys and /run/udev to /sys/class/hidraw,
/run/udev/data and /run/udev/control; the policy suite now forbids the
broad mounts and checks the wrapper and bind follow the fido2 option.

Assisted-by: Claude Code (claude-opus-5-5)

* refactor(nixpak): rewrite FIDO2 bwrap wrapper in Python

The bash argument loop was hard to follow. Python inserts the /dev/hidrawN
symlinks before "--" with a single list splice, and the header now explains
the whole host-udev -> bind -> symlink chain step by step.

Assisted-by: Claude Code (claude-opus-5-5)

* docs(canokey): document phone-only management policy

PCs only use the CanoKey; CanoKey Console management stays in the phone
app and the Admin PIN is never typed on a PC. Record what the config
enforces (sandboxed browsers get no raw USB/CCID, no vendor WebUSB udev
rule) and what it does not (uaccess on the raw USB node, PC/SC access to
the Admin applet, CTAP management from browsers).

Assisted-by: Claude Code (claude-opus-5-5)

* docs(canokey): separate management from use, link CanoKey docs

Assisted-by: Claude Code (claude-opus-5-5)

* fix(nixpak): create /dev/fido-hidraw from the udev rule

NixOS applies /dev tmpfiles entries only at boot; switch-to-configuration
runs systemd-tmpfiles-resetup with --exclude-prefix=/dev. After a switch
without reboot the directory was missing and every `ln` failed with exit
code 1, so no browser saw the key. The add rule now runs `mkdir -p` first.

Also fix the documented trigger command: `udevadm trigger` defaults to
`change`, which the add-only rule ignores.

Assisted-by: Claude Code (claude-opus-5-5)

* refactor(nixpak): keep tmpfiles as the only creator of /dev/fido-hidraw

The /dev tmpfiles units run before systemd-udevd on every boot, so the
directory always exists before coldplug. The mkdir in the udev rule only
covered the first switch without reboot and duplicated the tmpfiles rule;
drop it and document the one-time `systemd-tmpfiles --prefix` command.

Assisted-by: Claude Code (claude-opus-5-5)

* docs(nixpak): record FIDO2 hardware validation in Firefox and Chrome

After a reboot on ai with the CanoKey present at boot, udev linked only
the FIDO interface and WebAuthn works in Firefox and Chrome. Chromium and
plugging the key in after launch remain pending.

Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-11-04 13:38:32 +08:00
1 parent 432de26764
commit 2cf3a58028
15 files changed
+171 -11

No files matched your search

+30 -7
View File
@@ -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.
+1
View File
@@ -13,6 +13,7 @@
desktopFile = "chromium-browser";
browserpass = true;
media = true;
fido2 = true;
hostLoopback = true;
personalFiles = true;
secretPortal = true;
+15
View File
@@ -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: {
+1
View File
@@ -37,6 +37,7 @@ let
media = true;
personalFiles = true;
browserpass = true;
fido2 = true;
};
# Firefox keeps the host network: local development servers and the
+1
View File
@@ -13,5 +13,6 @@
desktopFile = "google-chrome";
browserpass = true;
media = true;
fido2 = true;
secretPortal = true;
}
@@ -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; };
};
};
@@ -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 = [
+51
View File
@@ -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 <sandbox options> -- <browser command>
# 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])
''
+12 -1
View File
@@ -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);
+1
View File
@@ -7,4 +7,5 @@ mkAppPolicy "chromium" {
personalFiles = true;
browserpass = true;
secretPortal = true;
fido2 = true;
}
+1
View File
@@ -17,6 +17,7 @@ mkAppPolicy "firefox" {
personalFiles = true;
browserpass = true;
secretPortal = false;
fido2 = true;
extraChecks = {
capabilitiesMatchChromium =
firefox.browserPermissions.media == chromium.browserPermissions.media
+1
View File
@@ -7,4 +7,5 @@ mkAppPolicy "google-chrome" {
personalFiles = false;
browserpass = true;
secretPortal = true;
fido2 = true;
}
+14
View File
@@ -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
<https://webauthn.io>. 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