# ubuntu-fido — Design Document **Date:** 2026-04-26 **Status:** Draft (brainstorm complete, validated through Section 2 with stakeholder) **Owner:** Dangerous Things (ops@dangerousthings.com) ## Problem Enabling U2F / FIDO2 passkey / TOTP on Ubuntu desktops today is a manual, error-prone process. `libpam-u2f` exists and works, but: - New-account enrollment requires the admin to either physically have the user's key at provisioning time, or talk the user through `pamu2fcfg` over chat. - There is no GUI. Admins hand-edit `/etc/pam.d/*` files, with no safety net against locking themselves out. - There is no first-login enrollment pattern equivalent to Windows OOBE / macOS Setup Assistant. - TOTP and U2F live in separate, unrelated tools. This project ships a single deb package that gives end users and admins a turnkey path to MFA — install, click, done — while staying compatible with fleet-management workflows. ## Scope decisions (locked) | # | Decision | Reasoning | |---|---|---| | 1 | **Audience: end-user-first, admin-extensible.** PPA + apt + GUI is the headline path; drop-in `/etc/` config files cover fleet/Ansible. | Maximizes reach and matches Dangerous Things' customer mix (hobbyists + small business IT). | | 2 | **v1 target: Ubuntu LTS + GNOME.** Ultimate target: Ubuntu + Debian × GNOME + KDE Plasma. | ~25–30% of Linux desktop on day one, ~50–60% at target state. v1 ships in months not years. | | 3 | **GUI: standalone GTK4/libadwaita app + optional gnome-control-center Users-panel shortcut deb.** | gnome-control-center has no stable plugin API. Standalone app is forward-compatible; shortcut deb adds discoverability without coupling to GNOME release cycle. | | 4 | **Authenticators: U2F + FIDO2 passkey + TOTP.** TOTP behind a build-time feature flag. | Full scope as written; flag lets us ship a beta without TOTP if the QR/recovery-code UX slips schedule. | | 5 | **Policy contexts: 3 in main GUI (gdm-password, sudo, sshd) with Disabled / Optional / Required modes.** Full list (polkit, su, login, plus auto-detected stacks) under pkexec admin gate or via `/etc/ubuntu-fido/policy.d/*.conf`. | 95% case stays simple; advanced/fleet path unrestricted. Mandatory pre-commit lockout simulation. | | 6 | **First-login enrollment: `chage -d 0` + autostart enrollment app + small `pam_ubuntu_fido_pending.so` blocking module.** | Belt-and-suspenders: leverages existing forced-password-change, adds enrollment via autostart, PAM module ensures user can't bypass by killing the modal. | ## Architecture ``` ┌─────────────────────────┐ ┌──────────────────────────┐ ┌──────────────┐ │ ubuntu-fido GTK app │ │ ubuntu-fidoctl (CLI) │ │ Future KDE │ │ (libadwaita panel) │ │ (admin/fleet scripting) │ │ KCM module │ └─────────────┬───────────┘ └────────────┬─────────────┘ └──────┬───────┘ │ D-Bus (org.dt.UbuntuFido) │ │ └─────────────┬─────────────┴────────────────────────┘ ▼ ┌────────────────────────────────────┐ │ ubuntu-fidod (system daemon) │ ← polkit-mediated │ - enrollment orchestration │ │ - policy read/write + lockout │ │ safety simulation │ │ - libfido2 bindings (Rust) │ │ - PAM stack edits via │ │ pam-auth-update │ └────────────────┬───────────────────┘ ▼ ┌─────────────────┬───────┴────────┬──────────────────────┐ ▼ ▼ ▼ ▼ /etc/pam.d/* /etc/ubuntu-fido/ ~/.config/Yubico/ /var/lib/ubuntu-fido/ (stack edits policy.d/*.conf u2f_keys (per-user) pending/ via pam-auth- (admin/fleet /etc/u2f_mappings (first-login flag) update) drop-ins) (central override) ``` ### Trust boundary The daemon owns all writes to `/etc/pam.d/`, `/etc/ubuntu-fido/`, and `/var/lib/ubuntu-fido/`. GUIs never write these directly. polkit policies decide who can call which method: | D-Bus method | Default polkit action | |---|---| | `EnrollOwn(user)`, `RemoveOwn(user)` | `auth_self_keep` (user authenticates with password once per 5 min) | | `EnrollOther(user)`, `SetPolicy(...)`, `SetPendingFlag(user)` | `auth_admin_keep` (admin password) | | `GetState()`, `GetPolicy()` | `yes` (read is free) | ### Why Rust for the daemon - Memory safety in code that handles credential material. - `ctap-hid-fido2` and `webauthn-rs` crates are mature and actively maintained. - Cross-compiles cleanly to other platforms if the project ever expands beyond Linux. - Slightly larger build footprint than C, immaterial for a deb package. ## Package layout | Package | Contents | Type | |---|---|---| | `ubuntu-fido` | Empty metapackage. | Depends | | `ubuntu-fido-daemon` | `/usr/sbin/ubuntu-fidod`, systemd unit (`ubuntu-fido.service`), D-Bus service file, polkit rules under `/usr/share/polkit-1/actions/` | Hard dep of metapackage | | `ubuntu-fido-pam` | `/usr/lib/$DEB_HOST_MULTIARCH/security/pam_ubuntu_fido_pending.so`, pam-auth-update profile at `/usr/share/pam-configs/ubuntu-fido` | Hard dep | | `ubuntu-fido-cli` | `/usr/bin/ubuntu-fidoctl` (Rust binary, talks D-Bus) | Hard dep | | `ubuntu-fido-gui` | `/usr/bin/ubuntu-fido` (GTK4/libadwaita), `.desktop` file with `Categories=Settings;Security;`, app icon | **Recommends** (so headless servers can skip) | | `ubuntu-fido-gnome-integration` | Tiny shim that adds a "Configure security…" launcher button to gnome-control-center's Users panel via overlay `.desktop` extension | **Suggests** (not auto-installed) | ### Headline install ```bash sudo add-apt-repository ppa:dangerousthings/ubuntu-fido sudo apt install ubuntu-fido ``` Two commands. After install: - Daemon is running. - All PAM policy is **Disabled** by default. Installation never enables MFA enforcement on its own. - GUI is available from Activities (search "Authentication" or "Security Keys"). - Empty state: "No security keys configured. Plug in or scan a key to enroll." ### Headless / fleet install ```bash apt install ubuntu-fido-daemon ubuntu-fido-pam ubuntu-fido-cli ``` Skips the GUI metadata. Configure via `/etc/ubuntu-fido/policy.d/90-fleet.conf` or `ubuntu-fidoctl`. We additionally publish: - An **Ansible role** `dangerousthings.ubuntu_fido` on Galaxy wrapping `apt + debconf + drop-in config`. - A **debconf preseed schema** so `debian-installer`/cloud-init can answer questions at install time. ### Clean uninstall `apt purge ubuntu-fido*` runs `pam-auth-update --remove` (cleanly retracts our PAM hooks), stops and disables the systemd unit, removes `/etc/ubuntu-fido/` only on `purge` (not `remove`), and leaves `~/.config/Yubico/u2f_keys` files alone (so reinstall is painless). ## GUI design ### App: `ubuntu-fido` GTK4 + libadwaita. Single-window adaptive layout, looks like a stock Settings panel. Top-level navigation via libadwaita `AdwViewStack`: #### 1. **Security Keys** tab (default) - List of currently enrolled credentials per the active user. Each row: nickname, type icon (USB-A / NFC / implant / passkey), date added, last used, "Test" button, "Remove" button. - "+ Add Security Key" primary action button. - Empty state: large illustration + "Plug in your security key, or tap an NFC implant to begin." #### 2. **TOTP** tab (visible only if TOTP build-flag enabled) - List of TOTP enrollments (typically 0 or 1 per user — Linux PAM TOTP is one-secret-per-user by convention). - "Generate TOTP secret" → modal with QR code + secret string + 8 recovery codes (one-time use). #### 3. **Authentication Policy** tab - Three master toggles for the 95% case: - **System login** (gdm-password): `Disabled / Optional / Required` - **Privileged commands** (sudo): same - **Remote SSH** (sshd): same - "Advanced…" disclosure → pkexec prompt → full list including polkit, su, login, and any auto-detected PAM stacks. Each with same three modes plus a free-text condition (e.g., "only outside corporate network" via pam_access — v2 feature, scoped out of v1). - Big yellow callout above the toggles when local user has no MFA credentials enrolled: **"You have no security keys. Enabling 'Required' will lock you out at next login."** Apply button disabled until user enrolls or explicitly confirms with second password prompt. #### 4. **Recovery** tab - Manage recovery codes (regenerate, view remaining count). - Print recovery sheet (PDF, optionally including a QR-encoded backup of the user's TOTP secret for users who consent). - "Emergency unlock" — generates a single-use recovery code an admin can hand to a user who lost their key, valid for 24 h. ### App: gnome-control-center Users panel shortcut Optional `ubuntu-fido-gnome-integration` package ships an overlay file under `/usr/share/gnome-control-center/users/` (or whatever the current GNOME version uses) that adds an additional row labeled **"Authentication & Security Keys…"** to each user's detail view. Clicking launches `ubuntu-fido --user ` with elevated D-Bus permissions if the launching user is in `sudo`/`admin` group. The overlay is shipped separately precisely because gnome-control-center has no stable plugin API — this package may need re-tuning every GNOME release. If it ever breaks, the standalone app keeps working; only the discoverability shortcut is lost. ### Add User flow extension When `ubuntu-fido-gnome-integration` is installed, the **gnome-control-center Users → Add User** dialog gains a new section: **Configure security**, with three radio options: 1. **Enroll security credential now** — opens enrollment modal, admin must have the user's key/implant present. Sets a real password (admin-supplied). 2. **Require user to set up at first login** — admin sets a temp password (or accepts a generated one). We run `chage -d 0 ` and create `/var/lib/ubuntu-fido/pending/`. 3. **No MFA** — only available if local policy permits ("Required" stacks would block this option with a tooltip). ## Enrollment flows ### Flow A: User self-enrollment (the common case) 1. User opens `ubuntu-fido` from Activities. 2. Clicks "+ Add Security Key". 3. Modal: "Plug in your key now or tap your NFC implant to a reader." Daemon polls `libfido2` for new device. 4. Device detected → optional PIN/UV prompt for FIDO2 → daemon emits `pamu2fcfg`-equivalent registration line. 5. User names the credential ("Yellow Yubikey", "xSIID implant"). 6. Daemon writes line to `~/.config/Yubico/u2f_keys` (or central file if policy says so). 7. Modal closes, list refreshes. polkit asks for user password if it's been > 5 minutes since last auth (auth_self_keep). ### Flow B: Admin-enrolls-now at account creation 1. Admin opens Users panel, clicks Add User. 2. Fills in account, picks "Enroll security credential now". 3. Same modal as Flow A but for the new account; daemon writes to that user's home (creates dir with correct ownership) or to central file. 4. Account ready for normal login from minute one. polkit asks admin password. ### Flow C: First-login enrollment (the novel piece) **At account creation (admin side):** 1. Admin picks "Require user to set up at first login", enters/accepts temp password. 2. Daemon runs `chage -d 0 ` (built-in Linux mechanism: forces password change at next login). 3. Daemon writes flag file `/var/lib/ubuntu-fido/pending/` containing JSON: `{"required_methods": ["fido2"], "created": "...", "deadline": null}`. **First login (user side):** 1. User logs into GDM with temp password. 2. PAM `passwd` module forces password change (existing Ubuntu behavior, no work for us). 3. GNOME session starts. 4. Our autostart entry `/etc/xdg/autostart/ubuntu-fido-firstrun.desktop` runs `ubuntu-fido --first-run`. 5. Detects pending flag, takes over the screen with a fullscreen modal: "Welcome. Before you continue, please enroll a security key." 6. User enrolls (Flow A inside the modal). 7. On success, daemon clears pending flag. 8. Modal closes, user lands on a normal desktop. **Backstop (the PAM module):** - If user kills `ubuntu-fido --first-run` and tries to do anything sudo/login-related, `pam_ubuntu_fido_pending.so` is in their auth stack; it sees the pending flag and denies auth with a friendly message: "Account setup incomplete. Please complete enrollment in the Authentication app." - A watchdog inside `ubuntu-fido --first-run` calls `gnome-session-quit --logout --no-prompt` after 60 seconds of inactivity in the modal. ### Flow D: Lost key recovery 1. User can't authenticate. 2. User contacts admin. 3. Admin runs `ubuntu-fidoctl recovery generate ` (or clicks button in GUI). Outputs an 8-digit one-time code valid 24 h. 4. User enters code at GDM password prompt — `pam_ubuntu_fido_pending.so` recognizes recovery code prefix, lets them through with a forced re-enrollment flag set. 5. User logs in, immediately sees first-login-style modal forcing them to enroll a new key before doing anything else. ## PAM and policy details ### pam-auth-update profile `/usr/share/pam-configs/ubuntu-fido`: ``` Name: Dangerous Things ubuntu-fido MFA Default: no Priority: 192 Auth-Type: Additional Auth: [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings [success=ok default=die] pam_ubuntu_fido_pending.so ``` Default `no` means the package install does not enable enforcement — admins / GUI explicitly turn it on per stack. ### Policy file format (`/etc/ubuntu-fido/policy.d/*.conf`) TOML, parsed in lexical order, last-key-wins: ```toml [stacks.gdm-password] mode = "required" # disabled | optional | required methods = ["fido2", "totp"] # any-of [stacks.sudo] mode = "optional" methods = ["fido2"] [stacks.sshd] mode = "required" methods = ["fido2"] [storage] backend = "central" # per-user | central central_path = "/etc/u2f_mappings" [firstrun] default_required_methods = ["fido2"] deadline_hours = 0 # 0 = no auto-expire ``` Daemon reloads on `SIGHUP` or when a watched file changes (uses `inotify`). ### Lockout safety simulation Before applying any policy change, daemon simulates the new policy against: - The currently logged-in interactive user (resolved via `loginctl`). - Every user listed in `/var/lib/ubuntu-fido/users.db` (cached enrollment registry). For each user, it runs the new policy through a dry-run PAM check. If the result would deny login *without* a way to recover (no enrolled credentials of any required method), the daemon refuses the change and returns an actionable error: "Applying this policy would lock out user `alice` from `gdm-password` (no fido2 credentials enrolled). Enroll a credential for alice or set this stack to 'optional'." The GUI surfaces this as a red banner with a one-click "fix it" suggestion. The CLI returns non-zero with the same message. Override flag `--force-i-know-what-im-doing` exists for the determined. ## Testing strategy ### Unit - Daemon: Rust `cargo test`, mocking `libfido2` via trait abstraction. - PAM module: small C test harness using `pam_test_authenticate` from libpam-tests-dev. ### Integration - Disposable Multipass / LXD VM that boots a minimal Ubuntu 24.04 GNOME image, installs the deb from a local apt repo, and runs scripted enrollment via virtual FIDO2 device (`vhci-hcd` + `umockdev` for USB simulation, or the `libfido2` software backend). - One scripted test per flow A/B/C/D above. Each asserts both successful path and lockout-prevention failure mode. ### Manual smoke (release gate) - Real Yubikey (USB-A and NFC), Token2, and a Dangerous Things xSIID/Apex implant via NFC reader. - Test on Ubuntu 22.04 LTS, 24.04 LTS, and the current latest LTS at release time. ### Continuous fleet test - Vagrant-based fleet rehearsal: 5 VMs preseeded with our debconf answers, joined to a fake LDAP, Ansible role applied, simulated user creations, simulated lost-key recoveries. ## Roadmap to ultimate target (C) The architecture is designed so each step here is additive — no refactor of v1. | Phase | Deliverable | Effort | |---|---|---| | **v1.0** | Ubuntu LTS + GNOME, U2F + FIDO2 passkey + TOTP. | Reference. | | **v1.1** | Debian stable packaging (same source, second build target). | ~2 weeks. | | **v1.2** | KDE Plasma front-end as a KCM module (`kcm_ubuntu_fido`). Reuses the same daemon over D-Bus. Built with KF6/Qt6. | ~6–8 weeks (mostly Qt port of GUI). | | **v1.3** | Fedora/RHEL spec file. PAM module and daemon already work; needs `.rpm` packaging and SELinux policy. GNOME GUI works as-is. | ~3–4 weeks. | | **v2.0** | Optional: organization-wide credential sync via FreeIPA / LDAP integration. Pure backend feature. | Out of scope for now. | ## Risks and open questions | Risk | Mitigation | |---|---| | GNOME panel shortcut deb breaks on GNOME version bumps. | Standalone app remains functional; shortcut deb is `Suggests:` only, version-pinned, and can be skipped without losing core feature. | | `gdm-password` and screen unlock share a PAM stack — making MFA "required" affects both. Some users want different policies for "log in" vs "unlock screensaver". | Document the limitation. v2 could split via custom stacks with `pam_succeed_if user_unlock_session`. | | User loses last security key with no TOTP and no recovery codes. | Recovery flow (Flow D) requires admin involvement — accepted trade-off. Document prominently in onboarding. Single-user-laptop edge case: instruct user to keep recovery codes printed. | | Implants need NFC readers; not all desktop systems have them. | Out of scope for the package. Document supported readers. Standard libfido2 NFC stack works once a reader is plugged in. | | Pre-existing libpam-u2f config is already in use on the system. | postinst checks for existing `pam_u2f.so` lines in `/etc/pam.d/`; if found, prompts admin (or refuses to install non-interactively) until they migrate or accept import. | ## Out of scope for v1 - Fingerprint readers / FIDO2 platform authenticators (laptop biometric sensors). Future phase via `pam_fprintd` integration. - WebAuthn for browser-based logins to web apps. Different layer entirely. - Smart-card / PIV authentication. Adjacent space, future phase via `pam_pkcs11`. - Per-network policy ("require MFA only off corp VPN"). Defer to v2. - macOS / Windows clients. This is Linux-specific. --- *This design was developed via guided brainstorming on 2026-04-26. The next artifact is a detailed implementation plan in `2026-04-26-ubuntu-fido-implementation-plan.md`.*