diff --git a/core/cmd/dms/commands_auth.go b/core/cmd/dms/commands_auth.go index d514ef9a3..f0e020d9a 100644 --- a/core/cmd/dms/commands_auth.go +++ b/core/cmd/dms/commands_auth.go @@ -91,21 +91,30 @@ var authListServicesCmd = &cobra.Command{ var authValidateCmd = &cobra.Command{ Use: "validate", - Short: "Validate a PAM service file for use as the DMS lock-screen password stack", - Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.", + Short: "Validate a PAM service file for use by the DMS lock screen", + Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.", Run: func(cmd *cobra.Command, args []string) { path, _ := cmd.Flags().GetString("path") service, _ := cmd.Flags().GetString("service") + purpose, _ := cmd.Flags().GetString("purpose") asJSON, _ := cmd.Flags().GetBool("json") if (path == "") == (service == "") { log.Fatalf("Error: exactly one of --path or --service is required") } + if purpose != "password" && purpose != "u2f" { + log.Fatalf("Error: --purpose must be password or u2f") + } + var result sharedpam.LockscreenPamValidation switch { case service != "": - result = sharedpam.ValidateLockscreenPamService(service) + if purpose == "u2f" { + result = sharedpam.ValidateLockscreenU2fPamService(service) + } else { + result = sharedpam.ValidateLockscreenPamService(service) + } case !filepath.IsAbs(path): result = sharedpam.LockscreenPamValidation{ Path: path, @@ -114,7 +123,11 @@ var authValidateCmd = &cobra.Command{ Errors: []string{"--path must be an absolute file path"}, } default: - result = sharedpam.ValidateLockscreenPamPath(path) + if purpose == "u2f" { + result = sharedpam.ValidateLockscreenU2fPamPath(path) + } else { + result = sharedpam.ValidateLockscreenPamPath(path) + } } if asJSON { @@ -159,6 +172,7 @@ func init() { authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate") authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs") + authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f") authValidateCmd.Flags().Bool("json", false, "Output as JSON") } diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go index a34577801..c20fe51bb 100644 --- a/core/internal/pam/pam.go +++ b/core/internal/pam/pam.go @@ -646,6 +646,7 @@ type lockscreenPamAnalysis struct { inlineFingerprint bool inlineU2f bool modules []string + authModules []string unknownDirectives []string err error } @@ -730,6 +731,9 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack } if !foundModule && strings.HasSuffix(field, ".so") { acc.modules = append(acc.modules, field) + if lineType == "auth" { + acc.authModules = append(acc.authModules, field) + } foundModule = true } } @@ -774,6 +778,14 @@ func ValidateLockscreenPamPath(path string) LockscreenPamValidation { return validateLockscreenPam("", path, defaultValidateDeps()) } +func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation { + return validateLockscreenU2fPam(name, "", defaultValidateDeps()) +} + +func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation { + return validateLockscreenU2fPam("", path, defaultValidateDeps()) +} + func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation { result := LockscreenPamValidation{ MissingModules: []string{}, @@ -838,6 +850,64 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa return result } +func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation { + result := validateLockscreenPam(serviceName, path, deps) + if result.Path == "" { + return result + } + + resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile} + analysis := resolver.analyzePath(result.Path) + if analysis.err != nil { + return result + } + + filteredWarnings := result.Warnings[:0] + for _, warning := range result.Warnings { + if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") { + continue + } + filteredWarnings = append(filteredWarnings, warning) + } + result.Warnings = filteredWarnings + + hasU2fAuth := false + unsafeModules := []string{} + unsafeSeen := map[string]bool{} + for _, ref := range analysis.authModules { + name := filepath.Base(ref) + if name == "pam_u2f.so" { + hasU2fAuth = true + continue + } + switch name { + case "pam_env.so", "pam_faildelay.so", "pam_nologin.so": + continue + default: + if !unsafeSeen[name] { + unsafeSeen[name] = true + unsafeModules = append(unsafeModules, name) + } + } + } + + if !hasU2fAuth { + result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service") + } + for _, name := range unsafeModules { + result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name)) + } + for _, name := range result.MissingModules { + if strings.Contains(name, "pam_u2f") { + result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name)) + break + } + } + + result.Valid = len(result.Errors) == 0 + return result +} + func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool { if filepath.IsAbs(ref) { _, err := deps.stat(ref) @@ -895,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() string { func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { if deps.isNixOS() { - logFunc("ℹ NixOS detected. DMS continues to use /etc/pam.d/login for lock screen password auth on NixOS unless you declare security.pam.services.dankshell yourself. U2F and fingerprint are handled separately and should not be included in dankshell.") + logFunc("ℹ NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.") return nil } diff --git a/core/internal/pam/pam_test.go b/core/internal/pam/pam_test.go index 5f58d01ef..454799ebe 100644 --- a/core/internal/pam/pam_test.go +++ b/core/internal/pam/pam_test.go @@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) { if err != nil { t.Fatalf("syncLockscreenPamConfigWithDeps returned error on NixOS path: %v", err) } - if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "/etc/pam.d/login") { - t.Fatalf("expected NixOS informational log mentioning /etc/pam.d/login, got %v", logs) + if len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") { + t.Fatalf("expected NixOS informational log describing the user-state fallback, got %v", logs) } if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err) @@ -994,6 +994,84 @@ func TestValidateLockscreenPam(t *testing.T) { }) } +func TestValidateLockscreenU2fPam(t *testing.T) { + t.Parallel() + + t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_u2f.so"] = true + env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n") + + result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps()) + if !result.Valid { + t.Fatalf("expected valid dedicated U2F stack, got %+v", result) + } + if !result.InlineU2f { + t.Fatalf("expected inline U2F detection, got %+v", result) + } + if containsSubstr(result.Warnings, "double-prompt") { + t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings) + } + }) + + t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n") + + result := validateLockscreenU2fPam("login", "", env.validateDeps()) + if result.Valid { + t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result) + } + if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") { + t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors) + } + }) + + t.Run("rejects a stack without pam_u2f", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n") + + result := validateLockscreenU2fPam("password-only", "", env.validateDeps()) + if result.Valid || !containsSubstr(result.Errors, "pam_u2f") { + t.Fatalf("expected missing-U2F error, got %+v", result) + } + }) + + t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_u2f_helper.so"] = true + env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n") + + result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps()) + if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") { + t.Fatalf("expected exact pam_u2f module validation, got %+v", result) + } + }) + + t.Run("rejects a missing pam_u2f module", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n") + + result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps()) + if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") { + t.Fatalf("expected missing-module error, got %+v", result) + } + }) +} + func containsSubstr(items []string, substr string) bool { for _, item := range items { if strings.Contains(item, substr) { diff --git a/core/internal/qmlchecks/lockscreen_input_test.go b/core/internal/qmlchecks/lockscreen_input_test.go index d873fe100..0e234096e 100644 --- a/core/internal/qmlchecks/lockscreen_input_test.go +++ b/core/internal/qmlchecks/lockscreen_input_test.go @@ -23,3 +23,51 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) { t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control") } } + +func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) { + data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml") + if err != nil { + t.Fatalf("read lock screen settings QML: %v", err) + } + + content := string(data) + authCard := strings.Index(content, `title: I18n.tr("Lock Screen Authentication")`) + behaviorCard := strings.Index(content, `title: I18n.tr("Lock Screen behaviour")`) + fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`) + u2fToggle := strings.Index(content, `settingKey: "enableU2f"`) + u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`) + if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 { + t.Fatalf("expected authentication card, factor toggles, and U2F source setting") + } + for name, position := range map[string]int{ + "fingerprint toggle": fingerprintToggle, + "U2F toggle": u2fToggle, + "U2F source": u2fSource, + } { + if position < authCard || position > behaviorCard { + t.Fatalf("%s must remain in the authentication card", name) + } + } +} + +func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) { + data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml") + if err != nil { + t.Fatalf("read lock screen PAM QML: %v", err) + } + + content := string(data) + for _, required := range []string{ + "SettingsData.lockPamExternallyManaged", + "SettingsData.lockU2fPamPath", + "customU2fPamActive", + "u2fSuppressedByPrimaryPam", + } { + if !strings.Contains(content, required) { + t.Fatalf("lock screen PAM must contain %q", required) + } + } + if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") { + t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs") + } +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 0c01a4621..79d265f41 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -898,6 +898,8 @@ Singleton { property string lockPamPath: "" property bool lockPamInlineFprint: false property bool lockPamInlineU2f: false + property bool lockPamExternallyManaged: false + property string lockU2fPamPath: "" property bool greeterPamExternallyManaged: false property string lockScreenInactiveColor: "#000000" property int lockScreenNotificationMode: 0 diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index 2458e5eae..b6f12a9e6 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -63,6 +63,7 @@ Singleton { readonly property string u2fKeysPath: homeDir ? homeDir + "/.config/Yubico/u2f_keys" : "" readonly property bool homeU2fKeysDetected: u2fKeysPath !== "" && u2fKeysWatcher.loaded && u2fKeysText.trim() !== "" readonly property bool lockU2fCustomConfigDetected: pamModuleEnabled(dankshellU2fPamText, "pam_u2f") + readonly property bool lockU2fCustomSourceDetected: (settingsRoot?.lockU2fPamPath || "") !== "" && customU2fPamWatcher.loaded readonly property bool greeterPamHasFprint: greeterPamStackHasModule("pam_fprintd") readonly property bool greeterPamHasU2f: greeterPamStackHasModule("pam_u2f") @@ -176,7 +177,7 @@ Singleton { readonly property bool lockU2fReady: { if (forcedU2fAvailable !== null) return forcedU2fAvailable; - return lockU2fCustomConfigDetected || homeU2fKeysDetected; + return lockU2fCustomSourceDetected || lockU2fCustomConfigDetected || homeU2fKeysDetected; } readonly property bool lockU2fCanEnable: { @@ -727,6 +728,12 @@ Singleton { onLoadFailed: root.dankshellU2fPamText = "" } + FileView { + id: customU2fPamWatcher + path: root.settingsRoot?.lockU2fPamPath || "" + printErrors: false + } + FileView { id: u2fKeysWatcher path: root.u2fKeysPath diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 0596669c5..b1113d21a 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -459,6 +459,8 @@ var SPEC = { lockPamPath: { def: "" }, lockPamInlineFprint: { def: false }, lockPamInlineU2f: { def: false }, + lockPamExternallyManaged: { def: false }, + lockU2fPamPath: { def: "" }, lockScreenInactiveColor: { def: "#000000" }, lockScreenNotificationMode: { def: 0 }, lockScreenVideoEnabled: { def: false }, diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 05aa31b6d..8938a5d81 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -71,7 +71,7 @@ Scope { } function proceedAfterPrimaryAuth(): void { - if (SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) { + if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) { u2f.startForSecondFactor(); } else { completeUnlock(); @@ -91,8 +91,9 @@ Scope { } readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded - readonly property bool fprintSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineFprint - readonly property bool u2fSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineU2f + readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint) + readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f) + readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded FileView { id: customPamWatcher @@ -109,16 +110,16 @@ Scope { } FileView { - id: nixosMarker + id: u2fConfigWatcher - path: "/etc/NIXOS" + path: "/etc/pam.d/dankshell-u2f" printErrors: false } FileView { - id: u2fConfigWatcher + id: customU2fPamWatcher - path: "/etc/pam.d/dankshell-u2f" + path: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : "" printErrors: false } @@ -145,26 +146,23 @@ Scope { } function ensureUserPamConfig(): void { - if (root.runningFromNixStore || resolveUserPam.running) + if (SettingsData.lockPamExternallyManaged || resolveUserPam.running) return; resolveUserPam.running = true; } Component.onCompleted: ensureUserPamConfig() - // Detects Nix-installed DMS on non-NixOS systems - readonly property bool runningFromNixStore: Quickshell.shellDir.startsWith("/nix/store/") - PamContext { id: passwd config: { if (root.customPamActive) return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1); + if (SettingsData.lockPamExternallyManaged) + return "login"; if (dankshellConfigWatcher.loaded) return "dankshell"; - if (nixosMarker.loaded || root.runningFromNixStore) - return "login"; if (userPamWatcher.loaded) return "dankshell"; return "login"; @@ -174,9 +172,9 @@ Scope { const idx = SettingsData.lockPamPath.lastIndexOf("/"); return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/"; } - if (dankshellConfigWatcher.loaded) + if (SettingsData.lockPamExternallyManaged) return "/etc/pam.d"; - if (nixosMarker.loaded || root.runningFromNixStore) + if (dankshellConfigWatcher.loaded) return "/etc/pam.d"; if (userPamWatcher.loaded) return root.userPamDir; @@ -265,7 +263,7 @@ Scope { property int errorTries function checkAvail(): void { - if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByCustomPam) { + if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByPrimaryPam) { abort(); return; } @@ -325,7 +323,7 @@ Scope { property bool available: SettingsData.lockU2fReady function checkAvail(): void { - if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByCustomPam) { + if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) { abort(); return; } @@ -335,7 +333,7 @@ Scope { } function startForSecondFactor(): void { - if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam) { + if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam) { root.completeUnlock(); return; } @@ -348,7 +346,7 @@ Scope { } function startForAlternativeAuth(): void { - if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) + if (!available || !SettingsData.enableU2f || root.u2fSuppressedByPrimaryPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) return; abort(); root.u2fPending = true; @@ -358,8 +356,18 @@ Scope { start(); } - config: u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f" - configDirectory: u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + config: { + if (root.customU2fPamActive) + return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1); + return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f"; + } + configDirectory: { + if (root.customU2fPamActive) { + const idx = SettingsData.lockU2fPamPath.lastIndexOf("/"); + return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/"; + } + return u2fConfigWatcher.loaded ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"; + } onMessageChanged: { if (message.toLowerCase().includes("touch")) @@ -478,7 +486,7 @@ Scope { root.attemptInfoMessages = []; root.lockoutAnnouncedThisAttempt = false; root.resetAuthFlows(); - if (!dankshellConfigWatcher.loaded && !nixosMarker.loaded && !userPamWatcher.loaded) + if (!SettingsData.lockPamExternallyManaged && !dankshellConfigWatcher.loaded && !userPamWatcher.loaded) ensureUserPamConfig(); fprint.checkAvail(); u2f.checkAvail(); @@ -516,6 +524,19 @@ Scope { u2f.checkAvail(); } + function onLockPamExternallyManagedChanged(): void { + root.resetAuthFlows(); + if (!SettingsData.lockPamExternallyManaged) + root.ensureUserPamConfig(); + fprint.checkAvail(); + u2f.checkAvail(); + } + + function onLockU2fPamPathChanged(): void { + u2f.abort(); + u2f.checkAvail(); + } + function onU2fModeChanged(): void { if (root.lockSecured) { u2f.abort(); diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 8b57b03bd..ef319e863 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -11,6 +11,10 @@ Item { readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint readonly property bool lockU2fToggleAvailable: SettingsData.lockU2fCanEnable || SettingsData.enableU2f + readonly property bool primaryPamHasFprint: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineFprint + readonly property bool primaryPamHasU2f: SettingsData.lockPamPath !== "" && SettingsData.lockPamInlineU2f + readonly property bool lockFprintControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasFprint + readonly property bool lockU2fControlledByPrimary: SettingsData.lockPamExternallyManaged || primaryPamHasU2f property var authServices: [] property bool authValidateRunning: false @@ -19,6 +23,12 @@ Item { property string authValidateMessage: "" property string authPendingApplyPath: "" property bool authShowCustom: false + property bool u2fValidateRunning: false + property bool u2fValidateOk: false + property bool u2fValidateWarn: false + property string u2fValidateMessage: "" + property string u2fPendingApplyPath: "" + property bool u2fShowCustom: false readonly property string authAutoLabel: I18n.tr("Auto", "automatic PAM authentication source option") readonly property string authCustomLabel: I18n.tr("Custom...", "custom PAM authentication source option") @@ -37,6 +47,8 @@ Item { return svc ? authServiceLabel(svc) : authCustomLabel; } + readonly property string u2fAuthCurrentValue: SettingsData.lockU2fPamPath === "" ? authAutoLabel : authCustomLabel + function refreshAuthServices() { authListServicesProcess.running = true; } @@ -62,6 +74,25 @@ Item { authValidateProcess.running = true; } + function applyAutoU2fSource() { + SettingsData.set("lockU2fPamPath", ""); + root.u2fValidateOk = false; + root.u2fValidateWarn = false; + root.u2fValidateMessage = ""; + } + + function validateAndApplyU2fSource(path) { + if (!path) + return; + root.u2fPendingApplyPath = path; + root.u2fValidateMessage = ""; + root.u2fValidateOk = false; + root.u2fValidateWarn = false; + root.u2fValidateRunning = true; + u2fValidateProcess.command = ["dms", "auth", "validate", "--purpose", "u2f", "--path", path, "--json"]; + u2fValidateProcess.running = true; + } + function lockFingerprintDescription() { switch (SettingsData.lockFingerprintReason) { case "ready": @@ -161,12 +192,12 @@ Item { } catch (e) {} if (!data) { - root.authValidateMessage = "validation failed — is dms in PATH?"; + root.authValidateMessage = "Validation failed — is DMS in PATH?"; return; } if (!data.valid) { const errs = Array.isArray(data.errors) ? data.errors : []; - root.authValidateMessage = ["not applied:", ...errs].join("\n"); + root.authValidateMessage = ["Not applied.", ...errs].join("\n"); return; } @@ -176,7 +207,46 @@ Item { const warns = Array.isArray(data.warnings) ? data.warnings : []; root.authValidateOk = true; root.authValidateWarn = warns.length > 0; - root.authValidateMessage = warns.length > 0 ? ["applied with warnings:", ...warns].join("\n") : "applied"; + root.authValidateMessage = warns.length > 0 ? ["Applied with warnings.", ...warns].join("\n") : "Applied."; + } + } + + Process { + id: u2fValidateProcess + running: false + + property string collected: "" + + stdout: StdioCollector { + onStreamFinished: u2fValidateProcess.collected = text || "" + } + + onExited: exitCode => { + root.u2fValidateRunning = false; + root.u2fValidateOk = false; + root.u2fValidateWarn = false; + + let data = null; + try { + data = JSON.parse(u2fValidateProcess.collected); + } catch (e) {} + + if (!data) { + root.u2fValidateMessage = "Validation failed — is DMS in PATH?"; + return; + } + if (!data.valid) { + const errs = Array.isArray(data.errors) ? data.errors : []; + root.u2fValidateMessage = ["Not applied.", ...errs].join("\n"); + return; + } + + SettingsData.set("lockU2fPamPath", root.u2fPendingApplyPath); + const warns = Array.isArray(data.warnings) ? data.warnings : []; + root.u2fValidateOk = true; + root.u2fValidateWarn = warns.length > 0; + root.u2fValidateMessage = warns.length > 0 ? ["Applied with warnings.", ...warns].join("\n") : "Applied."; + root.refreshAuthDetection(); } } @@ -326,9 +396,17 @@ Item { SettingsCard { width: parent.width iconName: "key" - title: "Authentication Source" + title: I18n.tr("Lock Screen Authentication") settingKey: "lockAuthSource" + StyledText { + text: I18n.tr("Changes apply automatically") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + SettingsDropdownRow { settingKey: "lockPamPath" tags: ["lock", "screen", "pam", "authentication", "source", "service"] @@ -395,14 +473,120 @@ Item { } StyledText { - visible: (SettingsData.lockPamInlineFprint && SettingsData.enableFprint) || (SettingsData.lockPamInlineU2f && SettingsData.enableU2f) - text: "The pinned PAM stack already prompts for fingerprint or security key, so DMS skips its own prompts" + visible: !SettingsData.lockPamExternallyManaged && (root.primaryPamHasFprint || root.primaryPamHasU2f) + text: I18n.tr("Selected PAM source already manages the detected factors.") font.pixelSize: Theme.fontSizeSmall color: Theme.warning width: parent.width wrapMode: Text.Wrap topPadding: Theme.spacingS } + + SettingsToggleRow { + settingKey: "lockPamExternallyManaged" + tags: ["lock", "screen", "pam", "managed", "external", "authentication", "policy"] + text: I18n.tr("Use system PAM authentication") + description: SettingsData.lockPamExternallyManaged ? I18n.tr("System PAM sets the authentication policy.") : I18n.tr("DMS manages the factors below.") + checked: SettingsData.lockPamExternallyManaged + onToggled: checked => SettingsData.set("lockPamExternallyManaged", checked) + } + + SettingsToggleRow { + settingKey: "enableFprint" + tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"] + text: I18n.tr("Enable fingerprint authentication") + description: root.lockFprintControlledByPrimary ? I18n.tr("Managed by the primary PAM source.") : root.lockFingerprintDescription() + descriptionColor: root.lockFprintControlledByPrimary || SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.enableFprint || root.primaryPamHasFprint + enabled: root.lockFprintToggleAvailable && !root.lockFprintControlledByPrimary + 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: root.lockU2fControlledByPrimary ? I18n.tr("Managed by the primary PAM source.") : root.lockU2fDescription() + descriptionColor: root.lockU2fControlledByPrimary || SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning + checked: SettingsData.enableU2f || root.primaryPamHasU2f + enabled: root.lockU2fToggleAvailable && !root.lockU2fControlledByPrimary + 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 uses the passkey button. Second factor follows password or fingerprint.", "lock screen U2F security key mode setting") + visible: SettingsData.enableU2f && !root.lockU2fControlledByPrimary + 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"); + } + } + + SettingsDropdownRow { + settingKey: "lockU2fPamPath" + tags: ["lock", "screen", "pam", "u2f", "security", "key", "source", "service"] + text: I18n.tr("Security Key PAM Source") + description: SettingsData.lockU2fPamPath !== "" ? SettingsData.lockU2fPamPath : I18n.tr("Auto uses an installed or bundled key-only service.") + visible: !root.lockU2fControlledByPrimary + options: [root.authAutoLabel, root.authCustomLabel] + currentValue: root.u2fAuthCurrentValue + onValueChanged: value => { + if (value === root.authAutoLabel) { + root.u2fShowCustom = false; + root.applyAutoU2fSource(); + return; + } + root.u2fShowCustom = true; + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: !root.lockU2fControlledByPrimary && (root.u2fShowCustom || root.u2fAuthCurrentValue === root.authCustomLabel) + + DankTextField { + id: customU2fPamField + width: parent.width - validateU2fPamButton.width - Theme.spacingS + placeholderText: "/etc/pam.d/dankshell-u2f" + text: SettingsData.lockU2fPamPath + backgroundColor: Theme.surfaceContainerHighest + } + + DankButton { + id: validateU2fPamButton + text: I18n.tr("Apply Changes", "validate and apply custom U2F PAM authentication source") + enabled: !root.u2fValidateRunning && customU2fPamField.text.trim() !== "" + onClicked: root.validateAndApplyU2fSource(customU2fPamField.text.trim()) + } + } + + Rectangle { + width: parent.width + height: Math.min(160, u2fStatusText.implicitHeight + Theme.spacingM * 2) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + visible: !root.lockU2fControlledByPrimary && root.u2fValidateMessage !== "" + + StyledText { + id: u2fStatusText + anchors.fill: parent + anchors.margins: Theme.spacingM + text: root.u2fValidateMessage + font.pixelSize: Theme.fontSizeSmall + font.family: "monospace" + color: !root.u2fValidateOk ? Theme.error : (root.u2fValidateWarn ? Theme.warning : Theme.surfaceVariantText) + wrapMode: Text.Wrap + verticalAlignment: Text.AlignTop + } + } } SettingsCard { @@ -461,53 +645,6 @@ Item { checked: SettingsData.lockAtStartup onToggled: checked => SettingsData.set("lockAtStartup", checked) } - - StyledText { - text: I18n.tr("Lock screen authentication changes apply automatically and may open a terminal when sudo authentication is required.") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - width: parent.width - wrapMode: Text.Wrap - topPadding: Theme.spacingS - } - - SettingsToggleRow { - settingKey: "enableFprint" - tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"] - text: I18n.tr("Enable fingerprint authentication") - description: root.lockFingerprintDescription() - descriptionColor: SettingsData.lockFingerprintReason === "ready" ? Theme.surfaceVariantText : Theme.warning - checked: SettingsData.enableFprint - enabled: root.lockFprintToggleAvailable - 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: root.lockU2fDescription() - descriptionColor: SettingsData.lockU2fReason === "ready" ? Theme.surfaceVariantText : Theme.warning - checked: SettingsData.enableU2f - enabled: root.lockU2fToggleAvailable - 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.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/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index c9eeff7f0..b2365929d 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -4634,24 +4634,6 @@ "zenbrowser" ] }, - { - "section": "lockAuthSource", - "label": "Authentication Source", - "tabIndex": 11, - "category": "Lock Screen", - "keywords": [ - "authentication", - "lock", - "login", - "pam", - "password", - "screen", - "security", - "service", - "source" - ], - "icon": "key" - }, { "section": "lockPamPath", "label": "Authentication Source", @@ -4728,10 +4710,14 @@ "fprint", "lock", "login", + "managed", "password", + "primary", "screen", - "security" - ] + "security", + "source" + ], + "description": "Managed by the primary PAM source." }, { "section": "loginctlLockIntegration", @@ -4769,12 +4755,16 @@ "key", "lock", "login", + "managed", "password", + "primary", "screen", "security", + "source", "u2f", "yubikey" - ] + ], + "description": "Managed by the primary PAM source." }, { "section": "_tab_11", @@ -4814,6 +4804,25 @@ "icon": "palette", "description": "Font used for the clock and date on the lock screen" }, + { + "section": "lockAuthSource", + "label": "Lock Screen Authentication", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "authentication", + "lock", + "lockscreen", + "login", + "pam", + "password", + "screen", + "security", + "service", + "source" + ], + "icon": "key" + }, { "section": "lockDisplay", "label": "Lock Screen Display", @@ -5005,24 +5014,54 @@ ], "description": "Turn off all displays immediately when the lock screen activates" }, + { + "section": "lockU2fPamPath", + "label": "Security Key PAM Source", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "auto", + "bundled", + "installed", + "key", + "lock", + "login", + "pam", + "password", + "screen", + "security", + "service", + "source", + "u2f", + "uses" + ], + "description": "Auto uses an installed or bundled key-only service." + }, { "section": "u2fMode", "label": "Security key mode", "tabIndex": 11, "category": "Lock Screen", "keywords": [ + "alternative", + "button", "factor", + "fingerprint", + "follows", "key", "lock", "login", "mode", + "passkey", "password", "screen", "second", "security", "u2f", + "uses", "yubikey" - ] + ], + "description": "Alternative uses the passkey button. Second factor follows password or fingerprint." }, { "section": "lockScreenShowMediaPlayer", @@ -5159,6 +5198,27 @@ "time" ] }, + { + "section": "lockPamExternallyManaged", + "label": "Use system PAM authentication", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "authentication", + "external", + "lock", + "login", + "managed", + "pam", + "password", + "policy", + "screen", + "security", + "sets", + "system" + ], + "description": "System PAM sets the authentication policy." + }, { "section": "videoScreensaver", "label": "Video Screensaver",