This document outlines the security architecture, threat models, and safety designs implemented in Ubuntu Hello.
Ubuntu Hello functions as a Pluggable Authentication Module (PAM) and GUI wizard, providing face authentication and keyring/KWallet auto-unlocking on Ubuntu Linux systems. Because PAM modules run inside privileged contexts (e.g. sudo, gdm-password, sddm, su), security is the primary design priority.
graph TD
A[PAM Module: pam_ubuntu_hello.so] -->|spawns via posix_spawn| B[Python Subprocess: compare.py]
A -->|temporary setreuid 0,0| C[TPM2 Tools / tpm2_unseal]
B -->|reads face camera| D[VideoCapture / OpenCV]
B -->|communicates with| E[GTK GUI: ubuntu-hello-gtk]
A -->|locks/unlocks keyring| F[Keyring Store /etc/ubuntu-hello/keyring-keys]
- Privilege boundary: PAM runs inside the authenticating process (
sudo,gdm-password,polkit-agent-helper-1, …), which has effective uid 0, sopam_ubuntu_hello.sospawnscompare.pyas root — it must read the root-owned models under/etc/ubuntu-helloand the camera device.compare.pynever takes on more than that: it inherits no environment (see 2.2), spawns children only by absolute path, and drops to the target user's uid/gid for the only child that touches user-owned resources (thegdbusnotification call on the user's session bus). The Settings GUI (ubuntu-hello-gtk) elevates through polkit (auth_admin) and runs as root for the same reason; it forwards only the display/locale environment and runs per-user probes (gsettings, browser hand-off) as the user viasudo -u. - Strictly Scoped PAM Elevation: When the PAM module needs to run a root helper (the TPM, which requires root access to read
/dev/tpmrm0, orcli.py keyring enable),run_root_helper()(throughspawn_with_root_uid()) temporarily raises the real UID withsetreuid(0, 0). It does this immediately beforeposix_spawnand lowers it again right after the spawn. The helper gets its own process group, so the 30 s timeout can kill it and any children. No shell is involved, and the child gets onlyhelper_environment():if (euid == 0 && ruid != 0) { if (setreuid(0, 0) == 0) { altered = true; } } // Own process group, so a timeout can kill the helper and its children. posix_spawnattr_setflags(&spawn_attr, POSIX_SPAWN_SETPGROUP); posix_spawnattr_setpgroup(&spawn_attr, 0); int spawn_err = posix_spawn(&child_pid, argv[0], &actions, &spawn_attr, const_cast<char *const *>(argv.data()), envp.data()); bool restore_failed = altered && setreuid(ruid, euid) != 0; // restore_failed -> the helper's result is treated as failure (-1)
- Strict Username Whitelisting: The PAM module retrieves the username via
pam_get_user(). To prevent path traversal or shell metacharacter injection in filesystem calls or commands, the username is validated usingis_safe_username()before any action is taken. - Python-Side Validation: The same strict username validation is enforced in both the
ubuntu-helloPython CLI (cli.py) and the face comparison subprocess (compare.py), providing defense-in-depth against direct or indirect malicious input. - No Inherited Environment / PATH: The PAM module spawns
compare.pyas root with an explicit minimal environment (onlyPATH=/usr/sbin:/usr/bin:/sbin:/bin) andpython3 -E -s, so a calling user'sPATH,PYTHONPATH,LD_PRELOADand similar can never reach the privileged helper.compare.pyspawns its own children (busctl,ubuntu-hello-gtk) by absolute path only. - Settings (
ubuntu-hello-gtk) environment allowlist: polkit pins only the interpreter andargv[1](the script), so any extra--env-KEY=valueargument reaching the elevated process is caller-controlled.window.pyrestores only the fixed display/locale set inSESSION_ENV_ALLOWLIST(the same setelevate()forwards) and drops everything else (PATH,LD_*,PYTHON*,HOME, …) with a stderr notice. As root it additionally pinsPATHtoROOT_SAFE_PATHbefore any helper (ubuntu-hello,sudo,gsettings,loginctl) is spawned, so no inherited or injected search path can redirect a privileged exec. - Notifications drop privileges: the per-attempt desktop notification (
notify.py) is sent on the target user's session bus withsubprocessuser=/group=/extra_groups=[](C-level identity change, nopreexec_fn) before exec'ing the absolute/usr/bin/gdbus; root never connects to a user bus, and the card body is markup-escaped. Root keeps its notification-id state only in the root-owned/run/ubuntu-hello/notify/(0700,lstat-verified,O_NOFOLLOW, nochown) — never in the user-controlled/run/user/<uid>/, where a symlink could redirect a privileged write. Failures only disable the card, never affect the auth result. - Polkit helper sandbox: polkit ≥ 126 runs
polkit-agent-helper-1underDevicePolicy=strict+ProtectSystem=strict; the shipped drop-in re-allows exactly the camera,/dev/uinput, the TPM devices (char-tpm,/dev/tpm0), write access to/etc/ubuntu-hello/tpm-keys(transient.ctxfiles) and/run/ubuntu-hello(root-only notification-id state), and — instead of relaxingProtectHome— keeps/homeand/rootempty inside the unit (ProtectHome=tmpfs) and binds only/run/userback in (BindReadOnlyPaths=-/run/user(a read-only bind: the helper only connects to the target user's session-bus socket after dropping to that uid; root itself is rejected by the user's bus, and no runtime file can be written or replaced)) so the user's session-bus socket is reachable for the desktop notification; the notifier drops to the target uid before connecting, so other users' runtime dirs (0700) stay inaccessible. Nothing broader. - No Shell Interpolation: All backend subprocess executions (in both C++ and Python) execute the binaries directly with argument lists (via
posix_spawnor array-basedsubprocess.run/subprocess.Popen), completely bypassing shell parsing (shell=True).
- Process Umask Hardening: Both python GUI executables, the python CLI, and the python face comparison process enforce
os.umask(0o077)immediately upon startup. This guarantees that all files, face models, keyring cache files, snapshots, and log files created by the application are restricted to0o600(for files) and0o700(for directories) by default, neutralizing race-condition time-of-check to time-of-use (TOCTOU) attacks on file permissions. - Access Control Lists (ACLs): Credentials stored under
/etc/ubuntu-hello/are strictly isolated:/etc/ubuntu-hello/itself is installed0755(root-owned): it holds non-secret configuration (config.ini,0644) and the downloaded dlib model files. Both the source installer and the packages set it this way; a booted-OS test (tests/vm/) checks these modes on a clean install.- Everything secret lives in root-only subdirectories:
models/(face templates),keyring-keys/andtpm-keys/are0700, key files0600. The one exception is the software-fallback master key below, which is written at the top level; its writer tightens the whole directory to0700when it creates the key (hosts without a TPM), which the root-only PAM and helper paths do not mind.
- Hardware TPM Sealing: When a TPM is active, credentials used for downstream PAM auto-unlocking are sealed inside the TPM using
tpm2_createandtpm2_unseal. After face auth,pam_ubuntu_hellosetsPAM_AUTHTOKfor consumers such aspam_gnome_keyring(GNOME/Ubuntu-family) andpam_kwallet5(KDE Plasma / KWallet). The same sealed blob is used for both; there is no KWallet-specific ciphertext format. - Software Fallback Cryptography: If no TPM is available, credentials are encrypted with AES-256-GCM using a randomly generated 32-byte master key stored at
/etc/ubuntu-hello/keyring-master.key(0600, directory0700). Ciphertext is stored asUH1:+ base64(nonce || ciphertext || tag). Legacy XOR/machine-idblobs are not decrypted on face authentication. - Legacy migration: Re-running
ubuntu-hello keyring enablealways overwrites software blobs withUH1:. On password login,pam_sm_setcredrewrites an existing keyring key file (including legacy XOR) by pipingPAM_AUTHTOKintoubuntu-hello keyring enable -U <user>, upgrading credentials in place. - Root helpers never see the caller's environment: every helper the PAM module starts (
compare.py, thetpm2_*unseal steps, andcli.py keyring enable) is launched withposix_spawnusing an absolute path, a fixed argv (no/bin/sh), andhelper_environment()(a fixedPATHplus validated locale variables). Python helpers run with-E -s. So the caller'sPATH,PYTHONPATHandBASH_ENV(glibc already removes loader variables such asLD_PRELOADfor setuid programs) never reach a uid-0 child. Each root helper also has a time limit (ROOT_HELPER_TIMEOUT, 30 s). It runs in its own process group, and if it is still running at the deadline, a hung TPM or resource manager included, the group is killed withSIGKILL. Login then continues without the keyring step instead of hanging. - Desktop coverage: Face auth through
common-authis DE-agnostic. Wallet unlock depends on the PAM stack shipping an AUTHTOK consumer (pam_gnome_keyringand/orpam_kwallet5). Packaging Depends on bothlibpam-gnome-keyringandlibpam-kwallet5so every supported DE can unlock the session wallet after face auth. - Uninstall restore:
uninstall.shanddebian/ubuntu-hello.prermcalltimeout 120 ubuntu-hello keyring restore --allbefore deleting/etc/ubuntu-hello. The helper decrypts each user’s sealed login password (TPM orUH1:) and re-asserts it as the GNOME Keyring master password (ChangeWithMasterPassword, old=new=P) or verifies KWallet viapamOpen(does not change the KWallet password).ChangeWithMasterPasswordneeds the current wallet password; Ubuntu Hello only has sealedP, so restore succeeds when the wallet already unlocks withPand cannot discover a divergent wallet password. Failures warn and do not abort removal. The password is never logged. Manual recovery remains Seahorse / KDE Wallet settings.ubuntu-hello keyring disabledeletes seals without restoring.
To prevent binary exploitation (such as stack overflows and GOT overwrite attacks) in the compiled C++ PAM module:
-fstack-protector-strong: Protects the stack against stack smashing buffer overflows.-D_FORTIFY_SOURCE=2: Adds runtime boundary checks for memory and string manipulation operations.-Wl,-z,relro,-Wl,-z,now: Forces Read-Only Relocations and immediate binding, securing the Global Offset Table (GOT) against hijacking.
The match threshold ([video] certainty) is a Euclidean distance on dlib's 128-d descriptor, times ten; lower is stricter, and dlib's own "same person" reference is 6.0, so all three shipped levels sit far inside it. Lowering it further is not the only lever, and past a point not a safe one at all. Measured on this project's IR hardware with a single enrolled model, the distance to the same face moves by roughly 0.4 between lighting conditions — enough that a threshold chosen to sit just under one session's numbers rejects that same person outright in the next. A threshold must therefore be picked with margin for that drift, not fitted to one measurement.
A scan takes dozens of independent looks at the camera, so accepting on the first frame under the bar means the deciding frame is the minimum of many draws — a single blurred or oddly lit frame from a stranger could carry the whole attempt. [video] confirmations requires several separate frames to agree before the match is trusted. The legitimate user, who matches a good share of frames, reaches the count in a fraction of a second; an isolated outlier never does. Every level asks for more than one, and a stricter level asks for more: Fast (3.5) 2, Balanced (3.0) 3, Secure (2.6) 4. The looser levels need it most, because a higher threshold lets far more frames through in the first place. A config written before this key existed keeps the historical single frame.
Neither setting defends against a video replay of the user's face. That is the documented limit of challenge-response liveness (ISO/IEC 30107-3); the optional nod defeats a still photo, not a recording.
- Concurrent
pam_get_authtokis workaround-gated (input/nativeonly). Withworkaround=off, greeters must run face-only without forcing a password conversation. - Forcing greeter
pam_get_authtokor customPAM_CONVwrappers ongdm-passwordaborts GDM user-selection → login (account-list bounce). Treat that as a login lockout regression, not an Esc/camera fix. - Canonical gate:
ask_pass = ask_auth_tok && workaround != Offinubuntu-hello/src/pam/main.cc. Agent rulebook: AGENTS.md,.agents/skills/pam-verifier/SKILL.md.
| Threat Vector | Mitigation Strategy | Verification Status |
|---|---|---|
| Malicious Username Injection | Enforced whitelist checking in C++ (is_safe_username), Python CLI, and compare.py |
Verified via pytest suite |
| Unauthorized GUI settings access | Polkit configuration restricts access to admin authorization (auth_admin) |
Verified in system integration |
| Privilege Leaks to Subprocesses | Explicit privilege drops immediately after posix_spawn; no shell and no inherited environment (run_root_helper) |
Verified via custom regression tests |
| Unauthorized file access | Enforced umask 077 on file/directory creation and strict 0700/0600 Unix ACL permissions |
Verified in install.sh and tests |
| Privileged postinstall lock/log hijack | Lock and log live under /run/ubuntu-hello (0700), opened with O_NOFOLLOW (and O_EXCL after dropping foreign leftovers); dpkg postinst does not append to /tmp |
Verified in tests/test_run_after_install.py |
| GDM greeter login lockout | Never force greeter pam_get_authtok when workaround=off |
Documented HARD RULE; recover via TTY + comment pam_ubuntu_hello.so |
| Lookalike admitted by one outlier frame | [video] confirmations — several separate frames must fall inside certainty before the match is trusted (2 at Fast, 3 at Balanced, 4 at Secure) |
Verified in tests/test_compare_confirmations.py, which runs the real scan loop |
| Photo held up to the camera | Opt-in nod challenge ([rubberstamps] enabled), written failsafe so a timeout aborts authentication |
Verified in tests/test_rubberstamps.py and live on IR hardware |