Files
authforge/docs/plans/2026-04-26-ubuntu-fido-design.md

19 KiB
Raw Blame History

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. ~2530% of Linux desktop on day one, ~5060% 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/<user>
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

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

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 <username> 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 <user> and create /var/lib/ubuntu-fido/pending/<user>.
  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 <user> (built-in Linux mechanism: forces password change at next login).
  3. Daemon writes flag file /var/lib/ubuntu-fido/pending/<user> 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 <user> (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:

[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. ~68 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. ~34 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.