From f82d7610e38600a830192c8f34d51c80278aa3b7 Mon Sep 17 00:00:00 2001 From: Patrick Fischer Date: Fri, 27 Feb 2026 04:58:21 +0800 Subject: [PATCH] feat: Add FIDO2/U2F security key support for lock screen (#1842) * feat: Add FIDO2/U2F security key support for lock screen Adds hardware security key authentication (e.g. YubiKey) with two modes: Alternative (OR) and Second Factor (AND). Includes settings UI, PAM integration, availability detection, and proper state cleanup. Also fixes persist:false properties being reset on settings file reload. * feat: Add U2F pending timeout and Escape to cancel Cancel U2F second factor after 30s or on Escape key press, returning to password/fingerprint input. * fix: U2F detection honors custom PAM override for non-default key paths --- quickshell/Common/SettingsData.qml | 4 + quickshell/Common/settings/Processes.qml | 14 ++ quickshell/Common/settings/SettingsSpec.js | 3 + quickshell/Common/settings/SettingsStore.js | 3 + quickshell/Modules/Lock/LockScreenContent.qml | 50 +++++- quickshell/Modules/Lock/Pam.qml | 165 +++++++++++++++++- quickshell/Modules/Settings/LockScreenTab.qml | 32 +++- quickshell/assets/pam/u2f | 3 + .../translations/settings_search_index.json | 42 +++++ 9 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 quickshell/assets/pam/u2f diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index d9f4f02e..51fab00b 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -494,6 +494,9 @@ Singleton { property bool enableFprint: false property int maxFprintTries: 15 property bool fprintdAvailable: false + property bool enableU2f: false + property string u2fMode: "or" + property bool u2fAvailable: false property string lockScreenActiveMonitor: "all" property string lockScreenInactiveColor: "#000000" property int lockScreenNotificationMode: 0 @@ -985,6 +988,7 @@ Singleton { loadSettings(); initializeListModels(); Processes.detectFprintd(); + Processes.detectU2f(); Processes.checkPluginSettings(); } } diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index d14cf6fa..e07e9558 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -18,6 +18,10 @@ Singleton { fprintdDetectionProcess.running = true; } + function detectU2f() { + u2fDetectionProcess.running = true; + } + function checkPluginSettings() { pluginSettingsCheckProcess.running = true; } @@ -57,6 +61,16 @@ Singleton { } } + property var u2fDetectionProcess: Process { + command: ["sh", "-c", "(test -f /usr/lib/security/pam_u2f.so || test -f /usr/lib64/security/pam_u2f.so) && (test -f /etc/pam.d/dankshell-u2f || test -f \"$HOME/.config/Yubico/u2f_keys\")"] + running: false + onExited: function (exitCode) { + if (!settingsRoot) + return; + settingsRoot.u2fAvailable = (exitCode === 0); + } + } + property var pluginSettingsCheckProcess: Process { command: ["test", "-f", settingsRoot?.pluginSettingsPath || ""] running: false diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index ec38d87e..9a94d6e2 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -317,6 +317,9 @@ var SPEC = { enableFprint: { def: false }, maxFprintTries: { def: 15 }, fprintdAvailable: { def: false, persist: false }, + enableU2f: { def: false }, + u2fMode: { def: "or" }, + u2fAvailable: { def: false, persist: false }, lockScreenActiveMonitor: { def: "all" }, lockScreenInactiveColor: { def: "#000000" }, lockScreenNotificationMode: { def: 0 }, diff --git a/quickshell/Common/settings/SettingsStore.js b/quickshell/Common/settings/SettingsStore.js index fb491033..96e419a8 100644 --- a/quickshell/Common/settings/SettingsStore.js +++ b/quickshell/Common/settings/SettingsStore.js @@ -9,6 +9,9 @@ function parse(root, jsonObj) { for (var k in SPEC) { if (k === "pluginSettings") continue; + // Runtime-only keys are never in the JSON; resetting them here + // would wipe values set by detection processes on every reload. + if (SPEC[k].persist === false) continue; if (!(k in jsonObj)) { root[k] = SPEC[k].def; } diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 9a1a1f45..945e69b5 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -687,14 +687,24 @@ Item { anchors.centerIn: parent name: { + if (pam.u2fPending) + return "passkey"; if (pam.fprint.tries >= SettingsData.maxFprintTries) return "fingerprint_off"; if (pam.fprint.active) return "fingerprint"; + if (pam.u2f.active) + return "passkey"; return "lock"; } size: 20 - color: pam.fprint.tries >= SettingsData.maxFprintTries ? Theme.error : (passwordField.activeFocus ? Theme.primary : Theme.surfaceVariantText) + color: { + if (pam.fprint.tries >= SettingsData.maxFprintTries) + return Theme.error; + if (pam.u2fState !== "") + return Theme.tertiary; + return passwordField.activeFocus ? Theme.primary : Theme.surfaceVariantText; + } opacity: pam.passwd.active ? 0 : 1 Behavior on opacity { @@ -745,8 +755,7 @@ Item { } } onAccepted: { - if (!demoMode && !pam.passwd.active) { - console.log("Enter pressed, starting PAM authentication"); + if (!demoMode && !pam.passwd.active && !pam.u2fPending) { pam.passwd.start(); } } @@ -756,6 +765,11 @@ Item { } if (event.key === Qt.Key_Escape) { + if (pam.u2fPending) { + pam.cancelU2fPending(); + event.accepted = true; + return; + } clear(); } @@ -820,6 +834,11 @@ Item { if (root.unlocking) { return "Unlocking..."; } + if (pam.u2fPending) { + if (pam.u2fState === "insert") + return "Insert your security key..."; + return "Touch your security key..."; + } if (pam.passwd.active) { return "Authenticating..."; } @@ -894,7 +913,7 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard" buttonSize: 32 - visible: !demoMode && !pam.passwd.active && !root.unlocking + visible: !demoMode && !pam.passwd.active && !root.unlocking && !pam.u2fPending enabled: visible onClicked: { if (keyboardController.isKeyboardActive) { @@ -995,11 +1014,10 @@ Item { anchors.verticalCenter: parent.verticalCenter iconName: "keyboard_return" buttonSize: 36 - visible: (demoMode || (!pam.passwd.active && !root.unlocking)) + visible: (demoMode || (!pam.passwd.active && !root.unlocking && !pam.u2fPending)) enabled: !demoMode onClicked: { - if (!demoMode) { - console.log("Enter button clicked, starting PAM authentication"); + if (!demoMode && !pam.u2fPending) { pam.passwd.start(); } } @@ -1025,6 +1043,12 @@ Item { Layout.fillWidth: true Layout.preferredHeight: 20 text: { + if (pam.u2fState === "insert" && !pam.u2fPending) { + return "Insert your security key..."; + } + if (pam.u2fState === "waiting" && !pam.u2fPending) { + return "Touch your security key..."; + } if (root.pamState === "error") { return "Authentication error - try again"; } @@ -1036,10 +1060,10 @@ Item { } return ""; } - color: Theme.error + color: (pam.u2fState === "waiting" || pam.u2fState === "insert") ? Theme.outline : Theme.error font.pixelSize: Theme.fontSizeSmall horizontalAlignment: Text.AlignHCenter - opacity: root.pamState !== "" ? 1 : 0 + opacity: (root.pamState !== "" || ((pam.u2fState === "waiting" || pam.u2fState === "insert") && !pam.u2fPending)) ? 1 : 0 Behavior on opacity { NumberAnimation { @@ -1607,6 +1631,14 @@ Item { root.passwordBuffer = ""; } } + onU2fPendingChanged: { + if (u2fPending) { + passwordField.text = ""; + root.passwordBuffer = ""; + if (keyboardController.isKeyboardActive) + keyboardController.hide(); + } + } } Binding { diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 4f7c94dd..28346e09 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -14,14 +14,51 @@ Scope { readonly property alias passwd: passwd readonly property alias fprint: fprint + readonly property alias u2f: u2f property string lockMessage property string state property string fprintState + property string u2fState + property bool u2fPending: false property string buffer signal flashMsg signal unlockRequested + function completeUnlock(): void { + if (!unlockInProgress) { + unlockInProgress = true; + passwd.abort(); + fprint.abort(); + u2f.abort(); + errorRetry.running = false; + u2fErrorRetry.running = false; + u2fPendingTimeout.running = false; + u2fPending = false; + u2fState = ""; + unlockRequested(); + } + } + + function proceedAfterPrimaryAuth(): void { + if (SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) { + u2f.startForSecondFactor(); + } else { + completeUnlock(); + } + } + + function cancelU2fPending(): void { + if (!u2fPending) + return; + u2f.abort(); + u2fErrorRetry.running = false; + u2fPendingTimeout.running = false; + u2fPending = false; + u2fState = ""; + fprint.checkAvail(); + } + FileView { id: dankshellConfigWatcher @@ -30,9 +67,9 @@ Scope { } FileView { - id: loginConfigWatcher + id: u2fConfigWatcher - path: "/etc/pam.d/login" + path: "/etc/pam.d/dankshell-u2f" printErrors: false } @@ -40,7 +77,7 @@ Scope { id: passwd config: dankshellConfigWatcher.loaded ? "dankshell" : "login" - configDirectory: dankshellConfigWatcher.loaded || loginConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + configDirectory: dankshellConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" onMessageChanged: { if (message.startsWith("The account is locked")) @@ -59,9 +96,8 @@ Scope { onCompleted: res => { if (res === PamResult.Success) { if (!root.unlockInProgress) { - root.unlockInProgress = true; fprint.abort(); - root.unlockRequested(); + root.proceedAfterPrimaryAuth(); } return; } @@ -105,9 +141,8 @@ Scope { if (res === PamResult.Success) { if (!root.unlockInProgress) { - root.unlockInProgress = true; passwd.abort(); - root.unlockRequested(); + root.proceedAfterPrimaryAuth(); } return; } @@ -135,6 +170,74 @@ Scope { } } + PamContext { + id: u2f + + property bool available + + function checkAvail(): void { + if (!available || !SettingsData.enableU2f || !root.lockSecured) { + abort(); + return; + } + + if (SettingsData.u2fMode === "or") { + start(); + } + } + + function startForSecondFactor(): void { + if (!available || !SettingsData.enableU2f) { + root.completeUnlock(); + return; + } + abort(); + root.u2fPending = true; + root.u2fState = ""; + u2fPendingTimeout.restart(); + start(); + } + + config: u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f" + configDirectory: u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + + onMessageChanged: { + if (message.toLowerCase().includes("touch")) + root.u2fState = "waiting"; + } + + onCompleted: res => { + if (!available || root.unlockInProgress) + return; + + if (res === PamResult.Success) { + root.completeUnlock(); + return; + } + + if (res === PamResult.Error || res === PamResult.MaxTries || res === PamResult.Failed) { + abort(); + + if (root.u2fPending) { + if (root.u2fState === "waiting") { + // AND mode: device was found but auth failed → back to password + root.u2fPending = false; + root.u2fState = ""; + fprint.checkAvail(); + } else { + // AND mode: no device found → keep pending, show "Insert...", retry + root.u2fState = "insert"; + u2fErrorRetry.restart(); + } + } else { + // OR mode: prompt to insert key, silently retry + root.u2fState = "insert"; + u2fErrorRetry.restart(); + } + } + } + } + Process { id: availProc @@ -145,6 +248,16 @@ Scope { } } + Process { + id: u2fAvailProc + + command: ["sh", "-c", "(test -f /usr/lib/security/pam_u2f.so || test -f /usr/lib64/security/pam_u2f.so) && (test -f /etc/pam.d/dankshell-u2f || test -f \"$HOME/.config/Yubico/u2f_keys\")"] + onExited: code => { + u2f.available = code === 0; + u2f.checkAvail(); + } + } + Timer { id: errorRetry @@ -152,6 +265,20 @@ Scope { onTriggered: fprint.start() } + Timer { + id: u2fErrorRetry + + interval: 800 + onTriggered: u2f.start() + } + + Timer { + id: u2fPendingTimeout + + interval: 30000 + onTriggered: root.cancelU2fPending() + } + Timer { id: stateReset @@ -175,13 +302,22 @@ Scope { onLockSecuredChanged: { if (lockSecured) { availProc.running = true; + u2fAvailProc.running = true; root.state = ""; root.fprintState = ""; + root.u2fState = ""; + root.u2fPending = false; root.lockMessage = ""; root.unlockInProgress = false; } else { fprint.abort(); passwd.abort(); + u2f.abort(); + errorRetry.running = false; + u2fErrorRetry.running = false; + u2fPendingTimeout.running = false; + root.u2fPending = false; + root.u2fState = ""; root.unlockInProgress = false; } } @@ -192,5 +328,20 @@ Scope { function onEnableFprintChanged(): void { fprint.checkAvail(); } + + function onEnableU2fChanged(): void { + u2f.checkAvail(); + } + + function onU2fModeChanged(): void { + if (root.lockSecured) { + u2f.abort(); + u2fErrorRetry.running = false; + u2fPendingTimeout.running = false; + root.u2fPending = false; + root.u2fState = ""; + u2f.checkAvail(); + } + } } } diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 8dd207dd..b6e3dfbf 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -172,11 +172,39 @@ Item { settingKey: "enableFprint" tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"] text: I18n.tr("Enable fingerprint authentication") - description: I18n.tr("Use fingerprint reader for lock screen authentication (requires enrolled fingerprints)") + description: SettingsData.fprintdAvailable ? I18n.tr("Use fingerprint reader for lock screen authentication (requires enrolled fingerprints)") : I18n.tr("Not enrolled", "fingerprint not detected status") + descriptionColor: SettingsData.fprintdAvailable ? Theme.surfaceVariantText : Theme.warning checked: SettingsData.enableFprint - visible: SettingsData.fprintdAvailable + enabled: SettingsData.fprintdAvailable onToggled: checked => SettingsData.set("enableFprint", checked) } + + SettingsToggleRow { + settingKey: "enableU2f" + tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "fido", "authentication", "hardware"] + text: I18n.tr("Enable security key authentication", "Enable FIDO2/U2F hardware security key for lock screen") + description: SettingsData.u2fAvailable ? I18n.tr("Use a FIDO2/U2F security key (e.g. YubiKey) for lock screen authentication (requires enrolled keys)", "lock screen U2F security key setting") : I18n.tr("Not enrolled", "security key not detected status") + descriptionColor: SettingsData.u2fAvailable ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.enableU2f + enabled: SettingsData.u2fAvailable + onToggled: checked => SettingsData.set("enableU2f", checked) + } + + SettingsDropdownRow { + settingKey: "u2fMode" + tags: ["lock", "screen", "u2f", "yubikey", "security", "key", "mode", "factor", "second"] + text: I18n.tr("Security key mode", "lock screen U2F security key mode setting") + description: I18n.tr("'Alternative' lets the key unlock on its own. 'Second factor' requires password or fingerprint first, then the key.", "lock screen U2F security key mode setting") + visible: SettingsData.u2fAvailable && SettingsData.enableU2f + options: [I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method"), I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")] + currentValue: SettingsData.u2fMode === "and" ? I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint") : I18n.tr("Alternative (OR)", "U2F mode option: key works as standalone unlock method") + onValueChanged: value => { + if (value === I18n.tr("Second Factor (AND)", "U2F mode option: key required after password or fingerprint")) + SettingsData.set("u2fMode", "and"); + else + SettingsData.set("u2fMode", "or"); + } + } } SettingsCard { diff --git a/quickshell/assets/pam/u2f b/quickshell/assets/pam/u2f new file mode 100644 index 00000000..bdac4cef --- /dev/null +++ b/quickshell/assets/pam/u2f @@ -0,0 +1,3 @@ +#%PAM-1.0 + +auth required pam_u2f.so cue nouserok timeout=10 diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index f3820ff4..df9458b6 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3771,6 +3771,48 @@ ], "description": "Use fingerprint reader for lock screen authentication (requires enrolled fingerprints)" }, + { + "section": "enableU2f", + "label": "Enable security key authentication", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "authentication", + "enable", + "fido", + "hardware", + "key", + "lock", + "lockscreen", + "login", + "password", + "screen", + "security", + "u2f", + "yubikey" + ], + "description": "Use a FIDO2/U2F security key (e.g. YubiKey) for lock screen authentication (requires enrolled keys)" + }, + { + "section": "u2fMode", + "label": "Security key mode", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "alternative", + "authentication", + "factor", + "key", + "lock", + "lockscreen", + "mode", + "second", + "security", + "u2f", + "yubikey" + ], + "description": "Alternative lets the key unlock on its own. Second factor requires password or fingerprint first, then the key." + }, { "section": "loginctlLockIntegration", "label": "Enable loginctl lock integration",