1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-03 20:32:07 -04:00

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
This commit is contained in:
Patrick Fischer
2026-02-27 04:58:21 +08:00
committed by GitHub
parent bd6ad53875
commit f82d7610e3
9 changed files with 298 additions and 18 deletions

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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 },

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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();
}
}
}
}

View File

@@ -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 {

View File

@@ -0,0 +1,3 @@
#%PAM-1.0
auth required pam_u2f.so cue nouserok timeout=10

View File

@@ -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",