diff --git a/core/cmd/dms/commands_auth.go b/core/cmd/dms/commands_auth.go new file mode 100644 index 00000000..727700e7 --- /dev/null +++ b/core/cmd/dms/commands_auth.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/spf13/cobra" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage DMS authentication sync", + Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen", +} + +var authSyncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync DMS authentication configuration", + Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + if err := syncAuthInTerminal(yes); err != nil { + log.Fatalf("Error launching auth sync in terminal: %v", err) + } + return + } + if err := syncAuth(yes); err != nil { + log.Fatalf("Error syncing authentication: %v", err) + } + }, +} + +func init() { + authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts") + authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)") +} + +func syncAuth(nonInteractive bool) error { + if !nonInteractive { + fmt.Println("=== DMS Authentication Sync ===") + fmt.Println() + } + + logFunc := func(msg string) { + fmt.Println(msg) + } + + if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil { + return err + } + + if !nonInteractive { + fmt.Println("\n=== Authentication Sync Complete ===") + fmt.Println("\nAuthentication changes have been applied.") + } + + return nil +} + +func syncAuthInTerminal(nonInteractive bool) error { + syncFlags := make([]string, 0, 1) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + + shellSyncCmd := "dms auth sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index 68cd3789..1efa545c 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -13,6 +13,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/spf13/cobra" "golang.org/x/text/cases" @@ -25,6 +26,11 @@ var greeterCmd = &cobra.Command{ Long: "Manage DMS greeter (greetd)", } +var ( + greeterConfigSyncFn = greeter.SyncDMSConfigs + sharedAuthSyncFn = sharedpam.SyncAuthConfig +) + var greeterInstallCmd = &cobra.Command{ Use: "install", Short: "Install and configure DMS greeter", @@ -148,6 +154,16 @@ func init() { greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") } +func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error { + if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil { + return err + } + if beforeAuth != nil { + beforeAuth() + } + return sharedAuthSyncFn(logFunc, "", options) +} + func installGreeter(nonInteractive bool) error { fmt.Println("=== DMS Greeter Installation ===") @@ -243,7 +259,9 @@ func installGreeter(nonInteractive bool) error { } fmt.Println("\nSynchronizing DMS configurations...") - if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil { + if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { return err } @@ -278,7 +296,7 @@ func uninstallGreeter(nonInteractive bool) error { } if !nonInteractive { - fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") + fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") var response string fmt.Scanln(&response) if strings.ToLower(strings.TrimSpace(response)) != "y" { @@ -297,8 +315,8 @@ func uninstallGreeter(nonInteractive bool) error { fmt.Println(" ✓ greetd disabled") } - fmt.Println("\nRemoving DMS PAM configuration...") - if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil { + fmt.Println("\nRemoving DMS authentication configuration...") + if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil { fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) } @@ -535,7 +553,7 @@ func resolveLocalWrapperShell() (string, error) { func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { if !nonInteractive { - fmt.Println("=== DMS Greeter Theme Sync ===") + fmt.Println("=== DMS Greeter Sync ===") fmt.Println() } @@ -721,7 +739,11 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { } fmt.Println("\nSynchronizing DMS configurations...") - if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil { + if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: forceAuth, + }, func() { + fmt.Println("\nConfiguring authentication...") + }); err != nil { return err } @@ -734,8 +756,9 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { fmt.Println("\n=== Sync Complete ===") fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") + fmt.Println("Shared authentication settings were also checked and reconciled where needed.") if forceAuth { - fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).") + fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).") } fmt.Println("The changes will be visible on the next login screen.") @@ -1297,39 +1320,7 @@ func extractGreeterPathOverrideFromCommand(command string) string { } func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { - if pamText == "" { - return false, false, false, false - } - - lines := strings.Split(pamText, "\n") - inManaged := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - switch trimmed { - case greeter.GreeterPamManagedBlockStart: - managed = true - inManaged = true - continue - case greeter.GreeterPamManagedBlockEnd: - inManaged = false - continue - } - - if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") { - legacy = true - } - if !inManaged { - continue - } - if strings.Contains(trimmed, "pam_fprintd") { - fingerprint = true - } - if strings.Contains(trimmed, "pam_u2f") { - u2f = true - } - } - - return managed, fingerprint, u2f, legacy + return sharedpam.ParseManagedGreeterPamAuth(pamText) } func packageInstallHint() string { @@ -1639,29 +1630,29 @@ func checkGreeterStatus() error { fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)") } if legacyManaged { - fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.") + fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.") allGood = false } enableFprintToggle, enableU2fToggle := false, false - if enableFprint, enableU2f, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil { + if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil { enableFprintToggle = enableFprint enableU2fToggle = enableU2f } else { fmt.Printf(" ℹ Could not read greeter auth toggles from settings: %v\n", settingsErr) } - includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") - includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so") - fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser() + includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so") + includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so") + fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser() if managedFprint && includedFprintFile != "" { fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile) - fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.") + fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.") allGood = false } if managedU2f && includedU2fFile != "" { fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile) - fmt.Println(" Double security-key auth detected — run 'dms greeter sync' to resolve.") + fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.") allGood = false } diff --git a/core/cmd/dms/commands_greeter_test.go b/core/cmd/dms/commands_greeter_test.go new file mode 100644 index 00000000..775149fc --- /dev/null +++ b/core/cmd/dms/commands_greeter_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "errors" + "reflect" + "testing" + + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" +) + +func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + var calls []string + greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { + if dmsPath != "/tmp/dms" { + t.Fatalf("unexpected dmsPath %q", dmsPath) + } + if compositor != "niri" { + t.Fatalf("unexpected compositor %q", compositor) + } + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + calls = append(calls, "configs") + return nil + } + + var gotOptions sharedpam.SyncAuthOptions + sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error { + if sudoPassword != "" { + t.Fatalf("expected empty sudoPassword, got %q", sudoPassword) + } + gotOptions = options + calls = append(calls, "auth") + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{ + ForceGreeterAuth: true, + }, func() { + calls = append(calls, "before-auth") + }) + if err != nil { + t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err) + } + + wantCalls := []string{"configs", "before-auth", "auth"} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("call order = %v, want %v", calls, wantCalls) + } + if !gotOptions.ForceGreeterAuth { + t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions) + } +} + +func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) { + origGreeterConfigSyncFn := greeterConfigSyncFn + origSharedAuthSyncFn := sharedAuthSyncFn + t.Cleanup(func() { + greeterConfigSyncFn = origGreeterConfigSyncFn + sharedAuthSyncFn = origSharedAuthSyncFn + }) + + greeterConfigSyncFn = func(string, string, func(string), string) error { + return errors.New("config sync failed") + } + + authCalled := false + sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error { + authCalled = true + return nil + } + + err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil) + if err == nil || err.Error() != "config sync failed" { + t.Fatalf("expected config sync error, got %v", err) + } + if authCalled { + t.Fatal("expected auth sync not to run after config sync failure") + } +} diff --git a/core/cmd/dms/main.go b/core/cmd/dms/main.go index d6fe9b8d..9a1f1b1e 100644 --- a/core/cmd/dms/main.go +++ b/core/cmd/dms/main.go @@ -17,11 +17,13 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) updateCmd.AddCommand(updateCheckCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) rootCmd.AddCommand(getCommonCommands()...) + rootCmd.AddCommand(authCmd) rootCmd.AddCommand(updateCmd) rootCmd.SetHelpTemplate(getHelpTemplate()) diff --git a/core/cmd/dms/main_distro.go b/core/cmd/dms/main_distro.go index 339bf40a..d4485b76 100644 --- a/core/cmd/dms/main_distro.go +++ b/core/cmd/dms/main_distro.go @@ -17,9 +17,11 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) + authCmd.AddCommand(authSyncCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) rootCmd.AddCommand(getCommonCommands()...) + rootCmd.AddCommand(authCmd) rootCmd.SetHelpTemplate(getHelpTemplate()) } diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index 37c11644..ace72ce2 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -16,6 +16,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" + sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go/document" @@ -25,26 +26,7 @@ var appArmorProfileData []byte const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter" -const ( - GreeterCacheDir = "/var/cache/dms-greeter" - - GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)" - GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH" - - legacyGreeterPamFprintComment = "# DMS greeter fingerprint" - legacyGreeterPamU2FComment = "# DMS greeter U2F" -) - -// Common PAM auth stack names referenced by greetd across supported distros. -var includedPamAuthFiles = []string{ - "system-auth", - "common-auth", - "password-auth", - "system-login", - "system-local-login", - "common-auth-pc", - "login", -} +const GreeterCacheDir = "/var/cache/dms-greeter" func DetectDMSPath() (string, error) { return config.LocateDMSConfig() @@ -749,49 +731,6 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { return nil } -// RemoveGreeterPamManagedBlock strips the DMS managed auth block from /etc/pam.d/greetd -func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) error { - if IsNixOS() { - return nil - } - const greetdPamPath = "/etc/pam.d/greetd" - data, err := os.ReadFile(greetdPamPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("failed to read %s: %w", greetdPamPath, err) - } - - stripped, removed := stripManagedGreeterPamBlock(string(data)) - strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped) - if !removed && !removedLegacy { - return nil - } - - tmp, err := os.CreateTemp("", "dms-pam-greetd-*") - if err != nil { - return fmt.Errorf("failed to create temp PAM file: %w", err) - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - - if _, err := tmp.WriteString(strippedAgain); err != nil { - tmp.Close() - return fmt.Errorf("failed to write temp PAM file: %w", err) - } - tmp.Close() - - if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { - return fmt.Errorf("failed to write PAM config: %w", err) - } - if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { - return fmt.Errorf("failed to set PAM config permissions: %w", err) - } - logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath) - return nil -} - // UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor. // It is a no-op when AppArmor is not active or the profile does not exist. func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { @@ -1322,7 +1261,7 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat return nil } -func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string, forceAuth bool) error { +func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error { homeDir, err := os.UserHomeDir() if err != nil { return fmt.Errorf("failed to get user home directory: %w", err) @@ -1387,10 +1326,6 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo return fmt.Errorf("greeter wallpaper override sync failed: %w", err) } - if err := syncGreeterPamConfig(homeDir, logFunc, sudoPassword, forceAuth); err != nil { - return fmt.Errorf("greeter PAM config sync failed: %w", err) - } - if strings.ToLower(compositor) != "niri" { return nil } @@ -1439,378 +1374,6 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas return nil } -func pamModuleExists(module string) bool { - for _, libDir := range []string{ - "/usr/lib64/security", - "/usr/lib/security", - "/lib64/security", - "/lib/security", - "/lib/x86_64-linux-gnu/security", - "/usr/lib/x86_64-linux-gnu/security", - "/lib/aarch64-linux-gnu/security", - "/usr/lib/aarch64-linux-gnu/security", - "/run/current-system/sw/lib64/security", - "/run/current-system/sw/lib/security", - } { - if _, err := os.Stat(filepath.Join(libDir, module)); err == nil { - return true - } - } - return false -} - -func stripManagedGreeterPamBlock(content string) (string, bool) { - lines := strings.Split(content, "\n") - filtered := make([]string, 0, len(lines)) - inManagedBlock := false - removed := false - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == GreeterPamManagedBlockStart { - inManagedBlock = true - removed = true - continue - } - if trimmed == GreeterPamManagedBlockEnd { - inManagedBlock = false - removed = true - continue - } - if inManagedBlock { - removed = true - continue - } - filtered = append(filtered, line) - } - - return strings.Join(filtered, "\n"), removed -} - -func stripLegacyGreeterPamLines(content string) (string, bool) { - lines := strings.Split(content, "\n") - filtered := make([]string, 0, len(lines)) - removed := false - - for i := 0; i < len(lines); i++ { - trimmed := strings.TrimSpace(lines[i]) - if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { - removed = true - if i+1 < len(lines) { - nextLine := strings.TrimSpace(lines[i+1]) - if strings.HasPrefix(nextLine, "auth") && - (strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) { - i++ - } - } - continue - } - filtered = append(filtered, lines[i]) - } - - return strings.Join(filtered, "\n"), removed -} - -func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) { - lines := strings.Split(content, "\n") - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") { - block := strings.Join(blockLines, "\n") - prefix := strings.Join(lines[:i], "\n") - suffix := strings.Join(lines[i:], "\n") - switch { - case prefix == "": - return block + "\n" + suffix, nil - case suffix == "": - return prefix + "\n" + block, nil - default: - return prefix + "\n" + block + "\n" + suffix, nil - } - } - } - return "", fmt.Errorf("no auth directive found in %s", greetdPamPath) -} - -func PamTextIncludesFile(pamText, filename string) bool { - lines := strings.Split(pamText, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.Contains(trimmed, filename) && - (strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) { - return true - } - } - return false -} - -func PamFileHasModule(pamFilePath, module string) bool { - data, err := os.ReadFile(pamFilePath) - if err != nil { - return false - } - lines := strings.Split(string(data), "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.Contains(trimmed, module) { - return true - } - } - return false -} - -func DetectIncludedPamModule(pamText, module string) string { - for _, includedFile := range includedPamAuthFiles { - if PamTextIncludesFile(pamText, includedFile) && PamFileHasModule("/etc/pam.d/"+includedFile, module) { - return includedFile - } - } - return "" -} - -type greeterAuthSettings struct { - GreeterEnableFprint bool `json:"greeterEnableFprint"` - GreeterEnableU2f bool `json:"greeterEnableU2f"` -} - -func readGreeterAuthSettings(homeDir string) (greeterAuthSettings, error) { - settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") - data, err := os.ReadFile(settingsPath) - if err != nil { - if os.IsNotExist(err) { - return greeterAuthSettings{}, nil - } - return greeterAuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) - } - if strings.TrimSpace(string(data)) == "" { - return greeterAuthSettings{}, nil - } - var settings greeterAuthSettings - if err := json.Unmarshal(data, &settings); err != nil { - return greeterAuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) - } - return settings, nil -} - -func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) { - settings, err := readGreeterAuthSettings(homeDir) - if err != nil { - return false, false, err - } - return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil -} - -func hasEnrolledFingerprintOutput(output string) bool { - lower := strings.ToLower(output) - if strings.Contains(lower, "no fingers enrolled") || - strings.Contains(lower, "no fingerprints enrolled") || - strings.Contains(lower, "no prints enrolled") { - return false - } - if strings.Contains(lower, "has fingers enrolled") || - strings.Contains(lower, "has fingerprints enrolled") { - return true - } - for _, line := range strings.Split(lower, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "finger:") { - return true - } - if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") { - return true - } - } - return false -} - -func FingerprintAuthAvailableForUser(username string) bool { - username = strings.TrimSpace(username) - if username == "" { - return false - } - if !pamModuleExists("pam_fprintd.so") { - return false - } - if _, err := exec.LookPath("fprintd-list"); err != nil { - return false - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput() - if err != nil { - return false - } - return hasEnrolledFingerprintOutput(string(out)) -} - -func FingerprintAuthAvailableForCurrentUser() bool { - username := strings.TrimSpace(os.Getenv("SUDO_USER")) - if username == "" { - username = strings.TrimSpace(os.Getenv("USER")) - } - if username == "" { - out, err := exec.Command("id", "-un").Output() - if err == nil { - username = strings.TrimSpace(string(out)) - } - } - return FingerprintAuthAvailableForUser(username) -} - -func pamManagerHintForCurrentDistro() string { - osInfo, err := distros.GetOSInfo() - if err != nil { - return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." - } - config, exists := distros.Registry[osInfo.Distribution.ID] - if !exists { - return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." - } - - switch config.Family { - case distros.FamilyFedora: - return "Disable it in authselect to force password-only greeter login." - case distros.FamilyDebian, distros.FamilyUbuntu: - return "Disable it in pam-auth-update to force password-only greeter login." - default: - return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." - } -} - -func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error { - var wantFprint, wantU2f bool - fprintToggleEnabled := forceAuth - u2fToggleEnabled := forceAuth - if forceAuth { - wantFprint = pamModuleExists("pam_fprintd.so") - wantU2f = pamModuleExists("pam_u2f.so") - } else { - settings, err := readGreeterAuthSettings(homeDir) - if err != nil { - return err - } - fprintToggleEnabled = settings.GreeterEnableFprint - u2fToggleEnabled = settings.GreeterEnableU2f - fprintModule := pamModuleExists("pam_fprintd.so") - u2fModule := pamModuleExists("pam_u2f.so") - wantFprint = settings.GreeterEnableFprint && fprintModule - wantU2f = settings.GreeterEnableU2f && u2fModule - if settings.GreeterEnableFprint && !fprintModule { - logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.") - } - if settings.GreeterEnableU2f && !u2fModule { - logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.") - } - } - - if IsNixOS() { - logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.") - logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).") - return nil - } - - greetdPamPath := "/etc/pam.d/greetd" - pamData, err := os.ReadFile(greetdPamPath) - if err != nil { - return fmt.Errorf("failed to read %s: %w", greetdPamPath, err) - } - originalContent := string(pamData) - content, _ := stripManagedGreeterPamBlock(originalContent) - content, _ = stripLegacyGreeterPamLines(content) - - includedFprintFile := DetectIncludedPamModule(content, "pam_fprintd.so") - includedU2fFile := DetectIncludedPamModule(content, "pam_u2f.so") - fprintAvailableForCurrentUser := FingerprintAuthAvailableForCurrentUser() - if wantFprint && includedFprintFile != "" { - logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.") - wantFprint = false - } - if wantU2f && includedU2fFile != "" { - logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.") - wantU2f = false - } - if !wantFprint && includedFprintFile != "" { - if fprintToggleEnabled { - logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".") - if fprintAvailableForCurrentUser { - logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.") - } else { - logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.") - } - } else { - if fprintAvailableForCurrentUser { - logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.") - logFunc(" Password login will work but may be delayed while the fingerprint module runs first.") - logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro()) - } else { - logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.") - logFunc(" Password auth remains the effective login path.") - } - } - } - if !wantU2f && includedU2fFile != "" { - if u2fToggleEnabled { - logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".") - logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.") - } else { - logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.") - logFunc(" " + pamManagerHintForCurrentDistro()) - } - } - - if wantFprint || wantU2f { - blockLines := []string{GreeterPamManagedBlockStart} - if wantFprint { - blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") - } - if wantU2f { - blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10") - } - blockLines = append(blockLines, GreeterPamManagedBlockEnd) - - content, err = insertManagedGreeterPamBlock(content, blockLines, greetdPamPath) - if err != nil { - return err - } - } - - if content == originalContent { - return nil - } - - tmpFile, err := os.CreateTemp("", "greetd-pam-*.conf") - if err != nil { - return err - } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) - if _, err := tmpFile.WriteString(content); err != nil { - tmpFile.Close() - return err - } - if err := tmpFile.Close(); err != nil { - return err - } - if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { - return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err) - } - if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { - return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err) - } - if wantFprint || wantU2f { - logFunc("✓ Configured greetd PAM for fingerprint/U2F") - } else { - logFunc("✓ Cleared DMS-managed greeter PAM auth block") - } - return nil -} - type niriGreeterSync struct { processed map[string]bool nodes []*document.Node @@ -2484,10 +2047,15 @@ func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) err } logFunc("Synchronizing DMS configurations...") - if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword, false); err != nil { + if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil { logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err)) } + logFunc("Configuring authentication...") + if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil { + return fmt.Errorf("failed to sync authentication: %w", err) + } + logFunc("Checking for conflicting display managers...") if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil { logFunc(fmt.Sprintf("⚠ Warning: %v", err)) diff --git a/core/internal/greeter/installer_test.go b/core/internal/greeter/installer_test.go index 12f139e0..9acee578 100644 --- a/core/internal/greeter/installer_test.go +++ b/core/internal/greeter/installer_test.go @@ -6,7 +6,7 @@ import ( "testing" ) -func writeTestJSON(t *testing.T, path string, content string) { +func writeTestFile(t *testing.T, path string, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("failed to create parent dir for %s: %v", path, err) @@ -70,8 +70,8 @@ func TestResolveGreeterThemeSyncState(t *testing.T) { t.Parallel() homeDir := t.TempDir() - writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON) - writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON) + writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON) + writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON) state, err := resolveGreeterThemeSyncState(homeDir) if err != nil { diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go new file mode 100644 index 00000000..16ca3f95 --- /dev/null +++ b/core/internal/pam/pam.go @@ -0,0 +1,892 @@ +package pam + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" +) + +const ( + GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)" + GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH" + + LockscreenPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN AUTH (managed by dms greeter sync)" + LockscreenPamManagedBlockEnd = "# END DMS LOCKSCREEN AUTH" + + LockscreenU2FPamManagedBlockStart = "# BEGIN DMS LOCKSCREEN U2F AUTH (managed by dms auth sync)" + LockscreenU2FPamManagedBlockEnd = "# END DMS LOCKSCREEN U2F AUTH" + + legacyGreeterPamFprintComment = "# DMS greeter fingerprint" + legacyGreeterPamU2FComment = "# DMS greeter U2F" + + GreetdPamPath = "/etc/pam.d/greetd" + DankshellPamPath = "/etc/pam.d/dankshell" + DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f" +) + +var includedPamAuthFiles = []string{ + "system-auth", + "common-auth", + "password-auth", + "system-login", + "system-local-login", + "common-auth-pc", + "login", +} + +type AuthSettings struct { + EnableFprint bool `json:"enableFprint"` + EnableU2f bool `json:"enableU2f"` + GreeterEnableFprint bool `json:"greeterEnableFprint"` + GreeterEnableU2f bool `json:"greeterEnableU2f"` +} + +type SyncAuthOptions struct { + HomeDir string + ForceGreeterAuth bool +} + +type syncDeps struct { + pamDir string + greetdPath string + dankshellPath string + dankshellU2fPath string + isNixOS func() bool + readFile func(string) ([]byte, error) + stat func(string) (os.FileInfo, error) + createTemp func(string, string) (*os.File, error) + removeFile func(string) error + runSudoCmd func(string, string, ...string) error + pamModuleExists func(string) bool + fingerprintAvailableForCurrentUser func() bool +} + +type lockscreenPamIncludeDirective struct { + target string + filterType string +} + +type lockscreenPamResolver struct { + pamDir string + readFile func(string) ([]byte, error) +} + +func defaultSyncDeps() syncDeps { + return syncDeps{ + pamDir: "/etc/pam.d", + greetdPath: GreetdPamPath, + dankshellPath: DankshellPamPath, + dankshellU2fPath: DankshellU2FPamPath, + isNixOS: IsNixOS, + readFile: os.ReadFile, + stat: os.Stat, + createTemp: os.CreateTemp, + removeFile: os.Remove, + runSudoCmd: runSudoCmd, + pamModuleExists: pamModuleExists, + fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser, + } +} + +func IsNixOS() bool { + _, err := os.Stat("/etc/NIXOS") + return err == nil +} + +func ReadAuthSettings(homeDir string) (AuthSettings, error) { + settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json") + data, err := os.ReadFile(settingsPath) + if err != nil { + if os.IsNotExist(err) { + return AuthSettings{}, nil + } + return AuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err) + } + if strings.TrimSpace(string(data)) == "" { + return AuthSettings{}, nil + } + + var settings AuthSettings + if err := json.Unmarshal(data, &settings); err != nil { + return AuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err) + } + return settings, nil +} + +func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) { + settings, err := ReadAuthSettings(homeDir) + if err != nil { + return false, false, err + } + return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil +} + +func SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error { + return syncAuthConfigWithDeps(logFunc, sudoPassword, options, defaultSyncDeps()) +} + +func RemoveManagedGreeterPamBlock(logFunc func(string), sudoPassword string) error { + return removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, defaultSyncDeps()) +} + +func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options SyncAuthOptions, deps syncDeps) error { + homeDir := strings.TrimSpace(options.HomeDir) + if homeDir == "" { + var err error + homeDir, err = os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + } + + settings, err := ReadAuthSettings(homeDir) + if err != nil { + return err + } + + if err := syncLockscreenPamConfigWithDeps(logFunc, sudoPassword, deps); err != nil { + return err + } + if err := syncLockscreenU2FPamConfigWithDeps(logFunc, sudoPassword, settings.EnableU2f, deps); err != nil { + return err + } + + if _, err := deps.stat(deps.greetdPath); err != nil { + if os.IsNotExist(err) { + logFunc("ℹ /etc/pam.d/greetd not found. Skipping greeter PAM sync.") + return nil + } + return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err) + } + + if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil { + return err + } + + return nil +} + +func removeManagedGreeterPamBlockWithDeps(logFunc func(string), sudoPassword string, deps syncDeps) error { + if deps.isNixOS() { + return nil + } + + data, err := deps.readFile(deps.greetdPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err) + } + + originalContent := string(data) + stripped, removed := stripManagedGreeterPamBlock(originalContent) + strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped) + if !removed && !removedLegacy { + return nil + } + + if err := writeManagedPamFile(strippedAgain, deps.greetdPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.greetdPath, err) + } + + logFunc("✓ Removed DMS managed PAM block from " + deps.greetdPath) + return nil +} + +func ParseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) { + if pamText == "" { + return false, false, false, false + } + + lines := strings.Split(pamText, "\n") + inManaged := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch trimmed { + case GreeterPamManagedBlockStart: + managed = true + inManaged = true + continue + case GreeterPamManagedBlockEnd: + inManaged = false + continue + } + + if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { + legacy = true + } + if !inManaged { + continue + } + if strings.Contains(trimmed, "pam_fprintd") { + fingerprint = true + } + if strings.Contains(trimmed, "pam_u2f") { + u2f = true + } + } + + return managed, fingerprint, u2f, legacy +} + +func StripManagedGreeterPamContent(pamText string) (string, bool) { + stripped, removed := stripManagedGreeterPamBlock(pamText) + stripped, removedLegacy := stripLegacyGreeterPamLines(stripped) + return stripped, removed || removedLegacy +} + +func PamTextIncludesFile(pamText, filename string) bool { + lines := strings.Split(pamText, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, filename) && + (strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) { + return true + } + } + return false +} + +func PamFileHasModule(pamFilePath, module string) bool { + data, err := os.ReadFile(pamFilePath) + if err != nil { + return false + } + return pamContentHasModule(string(data), module) +} + +func DetectIncludedPamModule(pamText, module string) string { + return detectIncludedPamModule(pamText, module, defaultSyncDeps()) +} + +func detectIncludedPamModule(pamText, module string, deps syncDeps) string { + for _, includedFile := range includedPamAuthFiles { + if !PamTextIncludesFile(pamText, includedFile) { + continue + } + path := filepath.Join(deps.pamDir, includedFile) + data, err := deps.readFile(path) + if err != nil { + continue + } + if pamContentHasModule(string(data), module) { + return includedFile + } + } + return "" +} + +func pamContentHasModule(content, module string) bool { + lines := strings.Split(content, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + if strings.Contains(trimmed, module) { + return true + } + } + return false +} + +func hasManagedLockscreenPamFile(content string) bool { + return strings.Contains(content, LockscreenPamManagedBlockStart) && + strings.Contains(content, LockscreenPamManagedBlockEnd) +} + +func hasManagedLockscreenU2FPamFile(content string) bool { + return strings.Contains(content, LockscreenU2FPamManagedBlockStart) && + strings.Contains(content, LockscreenU2FPamManagedBlockEnd) +} + +func pamDirectiveType(line string) string { + fields := strings.Fields(line) + if len(fields) == 0 { + return "" + } + + directiveType := strings.TrimPrefix(fields[0], "-") + switch directiveType { + case "auth", "account", "password", "session": + return directiveType + default: + return "" + } +} + +func isExcludedLockscreenPamLine(line string) bool { + for _, field := range strings.Fields(line) { + if strings.HasPrefix(field, "#") { + break + } + if strings.Contains(field, "pam_u2f") || strings.Contains(field, "pam_fprintd") { + return true + } + } + return false +} + +func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string) (lockscreenPamIncludeDirective, bool) { + fields := strings.Fields(trimmed) + if len(fields) >= 2 && fields[0] == "@include" { + return lockscreenPamIncludeDirective{ + target: fields[1], + filterType: inheritedFilter, + }, true + } + + if len(fields) >= 3 && (fields[1] == "include" || fields[1] == "substack") { + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return lockscreenPamIncludeDirective{}, false + } + return lockscreenPamIncludeDirective{ + target: fields[2], + filterType: lineType, + }, true + } + + if len(fields) >= 3 && fields[1] == "@include" { + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return lockscreenPamIncludeDirective{}, false + } + return lockscreenPamIncludeDirective{ + target: fields[2], + filterType: lineType, + }, true + } + + return lockscreenPamIncludeDirective{}, false +} + +func resolveLockscreenPamIncludePath(pamDir, target string) (string, error) { + if strings.TrimSpace(target) == "" { + return "", fmt.Errorf("empty PAM include target") + } + + cleanPamDir := filepath.Clean(pamDir) + if filepath.IsAbs(target) { + cleanTarget := filepath.Clean(target) + if filepath.Dir(cleanTarget) != cleanPamDir { + return "", fmt.Errorf("unsupported PAM include outside %s: %s", cleanPamDir, target) + } + return cleanTarget, nil + } + + cleanTarget := filepath.Clean(target) + if cleanTarget == "." || cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid PAM include target: %s", target) + } + + return filepath.Join(cleanPamDir, cleanTarget), nil +} + +func (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) { + path, err := resolveLockscreenPamIncludePath(r.pamDir, serviceName) + if err != nil { + return nil, err + } + + for _, seen := range stack { + if seen == path { + chain := append(append([]string{}, stack...), path) + display := make([]string, 0, len(chain)) + for _, item := range chain { + display = append(display, filepath.Base(item)) + } + return nil, fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> ")) + } + } + + data, err := r.readFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read PAM file %s: %w", path, err) + } + + var resolved []string + for _, rawLine := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") { + rawLine = strings.TrimRight(rawLine, "\r") + trimmed := strings.TrimSpace(rawLine) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || trimmed == "#%PAM-1.0" { + continue + } + + if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok { + lineType := pamDirectiveType(trimmed) + if filterType != "" && lineType != "" && lineType != filterType { + continue + } + + nested, err := r.resolveService(include.target, include.filterType, append(stack, path)) + if err != nil { + return nil, err + } + resolved = append(resolved, nested...) + continue + } + + lineType := pamDirectiveType(trimmed) + if lineType == "" { + return nil, fmt.Errorf("unsupported PAM directive in %s: %s", filepath.Base(path), trimmed) + } + if filterType != "" && lineType != filterType { + continue + } + if isExcludedLockscreenPamLine(trimmed) { + continue + } + + resolved = append(resolved, rawLine) + } + + return resolved, nil +} + +func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]byte, error)) (string, error) { + resolver := lockscreenPamResolver{ + pamDir: pamDir, + readFile: readFile, + } + + resolvedLines, err := resolver.resolveService("login", "", nil) + if err != nil { + return "", err + } + if len(resolvedLines) == 0 { + return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) + } + + hasAuth := false + for _, line := range resolvedLines { + if pamDirectiveType(strings.TrimSpace(line)) == "auth" { + hasAuth = true + break + } + } + if !hasAuth { + return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login")) + } + + var b strings.Builder + b.WriteString("#%PAM-1.0\n") + b.WriteString(LockscreenPamManagedBlockStart + "\n") + for _, line := range resolvedLines { + b.WriteString(line) + b.WriteByte('\n') + } + b.WriteString(LockscreenPamManagedBlockEnd + "\n") + return b.String(), nil +} + +func buildManagedLockscreenU2FPamContent() string { + var b strings.Builder + b.WriteString("#%PAM-1.0\n") + b.WriteString(LockscreenU2FPamManagedBlockStart + "\n") + b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n") + b.WriteString(LockscreenU2FPamManagedBlockEnd + "\n") + return b.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.") + return nil + } + + existingData, err := deps.readFile(deps.dankshellPath) + if err == nil { + if !hasManagedLockscreenPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell found (no DMS block). Skipping.") + return nil + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err) + } + + content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile) + if err != nil { + return fmt.Errorf("failed to build %s from %s: %w", deps.dankshellPath, filepath.Join(deps.pamDir, "login"), err) + } + + if err := writeManagedPamFile(content, deps.dankshellPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.dankshellPath, err) + } + + logFunc("✓ Created or updated /etc/pam.d/dankshell for lock screen authentication") + return nil +} + +func syncLockscreenU2FPamConfigWithDeps(logFunc func(string), sudoPassword string, enabled bool, deps syncDeps) error { + if deps.isNixOS() { + logFunc("ℹ NixOS detected. DMS does not manage /etc/pam.d/dankshell-u2f on NixOS. Keep using the bundled U2F helper or configure a custom PAM service yourself.") + return nil + } + + existingData, err := deps.readFile(deps.dankshellU2fPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to read %s: %w", deps.dankshellU2fPath, err) + } + + if enabled { + if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Skipping.") + return nil + } + if err := writeManagedPamFile(buildManagedLockscreenU2FPamContent(), deps.dankshellU2fPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to write %s: %w", deps.dankshellU2fPath, err) + } + logFunc("✓ Created or updated /etc/pam.d/dankshell-u2f for lock screen security-key authentication") + return nil + } + + if os.IsNotExist(err) { + return nil + } + if err == nil && !hasManagedLockscreenU2FPamFile(string(existingData)) { + logFunc("ℹ Custom /etc/pam.d/dankshell-u2f found (no DMS block). Leaving it untouched.") + return nil + } + + if err := deps.runSudoCmd(sudoPassword, "rm", "-f", deps.dankshellU2fPath); err != nil { + return fmt.Errorf("failed to remove %s: %w", deps.dankshellU2fPath, err) + } + logFunc("✓ Removed DMS-managed /etc/pam.d/dankshell-u2f") + return nil +} + +func stripManagedGreeterPamBlock(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + inManagedBlock := false + removed := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == GreeterPamManagedBlockStart { + inManagedBlock = true + removed = true + continue + } + if trimmed == GreeterPamManagedBlockEnd { + inManagedBlock = false + removed = true + continue + } + if inManagedBlock { + removed = true + continue + } + filtered = append(filtered, line) + } + + return strings.Join(filtered, "\n"), removed +} + +func stripLegacyGreeterPamLines(content string) (string, bool) { + lines := strings.Split(content, "\n") + filtered := make([]string, 0, len(lines)) + removed := false + + for i := 0; i < len(lines); i++ { + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) { + removed = true + if i+1 < len(lines) { + nextLine := strings.TrimSpace(lines[i+1]) + if strings.HasPrefix(nextLine, "auth") && + (strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) { + i++ + } + } + continue + } + filtered = append(filtered, lines[i]) + } + + return strings.Join(filtered, "\n"), removed +} + +func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") { + block := strings.Join(blockLines, "\n") + prefix := strings.Join(lines[:i], "\n") + suffix := strings.Join(lines[i:], "\n") + switch { + case prefix == "": + return block + "\n" + suffix, nil + case suffix == "": + return prefix + "\n" + block, nil + default: + return prefix + "\n" + block + "\n" + suffix, nil + } + } + } + return "", fmt.Errorf("no auth directive found in %s", greetdPamPath) +} + +func syncGreeterPamConfigWithDeps(logFunc func(string), sudoPassword string, settings AuthSettings, forceAuth bool, deps syncDeps) error { + var wantFprint, wantU2f bool + fprintToggleEnabled := forceAuth + u2fToggleEnabled := forceAuth + if forceAuth { + wantFprint = deps.pamModuleExists("pam_fprintd.so") + wantU2f = deps.pamModuleExists("pam_u2f.so") + } else { + fprintToggleEnabled = settings.GreeterEnableFprint + u2fToggleEnabled = settings.GreeterEnableU2f + fprintModule := deps.pamModuleExists("pam_fprintd.so") + u2fModule := deps.pamModuleExists("pam_u2f.so") + wantFprint = settings.GreeterEnableFprint && fprintModule + wantU2f = settings.GreeterEnableU2f && u2fModule + if settings.GreeterEnableFprint && !fprintModule { + logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.") + } + if settings.GreeterEnableU2f && !u2fModule { + logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.") + } + } + + if deps.isNixOS() { + logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.") + logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).") + return nil + } + + pamData, err := deps.readFile(deps.greetdPath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", deps.greetdPath, err) + } + originalContent := string(pamData) + content, _ := stripManagedGreeterPamBlock(originalContent) + content, _ = stripLegacyGreeterPamLines(content) + + includedFprintFile := detectIncludedPamModule(content, "pam_fprintd.so", deps) + includedU2fFile := detectIncludedPamModule(content, "pam_u2f.so", deps) + fprintAvailableForCurrentUser := deps.fingerprintAvailableForCurrentUser() + if wantFprint && includedFprintFile != "" { + logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.") + wantFprint = false + } + if wantU2f && includedU2fFile != "" { + logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.") + wantU2f = false + } + if !wantFprint && includedFprintFile != "" { + if fprintToggleEnabled { + logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".") + if fprintAvailableForCurrentUser { + logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.") + } else { + logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.") + } + } else { + if fprintAvailableForCurrentUser { + logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.") + logFunc(" Password login will work but may be delayed while the fingerprint module runs first.") + logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro()) + } else { + logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.") + logFunc(" Password auth remains the effective login path.") + } + } + } + if !wantU2f && includedU2fFile != "" { + if u2fToggleEnabled { + logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".") + logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.") + } else { + logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.") + logFunc(" " + pamManagerHintForCurrentDistro()) + } + } + + if wantFprint || wantU2f { + blockLines := []string{GreeterPamManagedBlockStart} + if wantFprint { + blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") + } + if wantU2f { + blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10") + } + blockLines = append(blockLines, GreeterPamManagedBlockEnd) + + content, err = insertManagedGreeterPamBlock(content, blockLines, deps.greetdPath) + if err != nil { + return err + } + } + + if content == originalContent { + return nil + } + + if err := writeManagedPamFile(content, deps.greetdPath, sudoPassword, deps); err != nil { + return fmt.Errorf("failed to install updated PAM config at %s: %w", deps.greetdPath, err) + } + if wantFprint || wantU2f { + logFunc("✓ Configured greetd PAM for fingerprint/U2F") + } else { + logFunc("✓ Cleared DMS-managed greeter PAM auth block") + } + return nil +} + +func writeManagedPamFile(content string, destPath string, sudoPassword string, deps syncDeps) error { + tmpFile, err := deps.createTemp("", "dms-pam-*.conf") + if err != nil { + return err + } + tmpPath := tmpFile.Name() + defer func() { + _ = deps.removeFile(tmpPath) + }() + + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + if err := deps.runSudoCmd(sudoPassword, "cp", tmpPath, destPath); err != nil { + return err + } + if err := deps.runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { + return fmt.Errorf("failed to set permissions on %s: %w", destPath, err) + } + return nil +} + +func pamManagerHintForCurrentDistro() string { + osInfo, err := distros.GetOSInfo() + if err != nil { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + config, exists := distros.Registry[osInfo.Distribution.ID] + if !exists { + return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } + + switch config.Family { + case distros.FamilyFedora: + return "Disable it in authselect to force password-only greeter login." + case distros.FamilyDebian, distros.FamilyUbuntu: + return "Disable it in pam-auth-update to force password-only greeter login." + default: + return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login." + } +} + +func pamModuleExists(module string) bool { + for _, libDir := range []string{ + "/usr/lib64/security", + "/usr/lib/security", + "/lib64/security", + "/lib/security", + "/lib/x86_64-linux-gnu/security", + "/usr/lib/x86_64-linux-gnu/security", + "/lib/aarch64-linux-gnu/security", + "/usr/lib/aarch64-linux-gnu/security", + "/run/current-system/sw/lib64/security", + "/run/current-system/sw/lib/security", + } { + if _, err := os.Stat(filepath.Join(libDir, module)); err == nil { + return true + } + } + return false +} + +func hasEnrolledFingerprintOutput(output string) bool { + lower := strings.ToLower(output) + if strings.Contains(lower, "no fingers enrolled") || + strings.Contains(lower, "no fingerprints enrolled") || + strings.Contains(lower, "no prints enrolled") { + return false + } + if strings.Contains(lower, "has fingers enrolled") || + strings.Contains(lower, "has fingerprints enrolled") { + return true + } + for _, line := range strings.Split(lower, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "finger:") { + return true + } + if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") { + return true + } + } + return false +} + +func FingerprintAuthAvailableForCurrentUser() bool { + username := strings.TrimSpace(os.Getenv("SUDO_USER")) + if username == "" { + username = strings.TrimSpace(os.Getenv("USER")) + } + if username == "" { + out, err := exec.Command("id", "-un").Output() + if err == nil { + username = strings.TrimSpace(string(out)) + } + } + return fingerprintAuthAvailableForUser(username) +} + +func fingerprintAuthAvailableForUser(username string) bool { + username = strings.TrimSpace(username) + if username == "" { + return false + } + if !pamModuleExists("pam_fprintd.so") { + return false + } + if _, err := exec.LookPath("fprintd-list"); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput() + if err != nil { + return false + } + return hasEnrolledFingerprintOutput(string(out)) +} + +func runSudoCmd(sudoPassword string, command string, args ...string) error { + var cmd *exec.Cmd + + if sudoPassword != "" { + fullArgs := append([]string{command}, args...) + quotedArgs := make([]string, len(fullArgs)) + for i, arg := range fullArgs { + quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" + } + cmdStr := strings.Join(quotedArgs, " ") + + cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) + } else { + cmd = exec.Command("sudo", append([]string{command}, args...)...) + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/core/internal/pam/pam_test.go b/core/internal/pam/pam_test.go new file mode 100644 index 00000000..113bf108 --- /dev/null +++ b/core/internal/pam/pam_test.go @@ -0,0 +1,671 @@ +package pam + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("failed to create parent dir for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +type pamTestEnv struct { + pamDir string + greetdPath string + dankshellPath string + dankshellU2fPath string + tmpDir string + homeDir string + availableModules map[string]bool + fingerprintAvailable bool +} + +func newPamTestEnv(t *testing.T) *pamTestEnv { + t.Helper() + + root := t.TempDir() + pamDir := filepath.Join(root, "pam.d") + tmpDir := filepath.Join(root, "tmp") + homeDir := filepath.Join(root, "home") + + for _, dir := range []string{pamDir, tmpDir, homeDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("failed to create %s: %v", dir, err) + } + } + + return &pamTestEnv{ + pamDir: pamDir, + greetdPath: filepath.Join(pamDir, "greetd"), + dankshellPath: filepath.Join(pamDir, "dankshell"), + dankshellU2fPath: filepath.Join(pamDir, "dankshell-u2f"), + tmpDir: tmpDir, + homeDir: homeDir, + availableModules: map[string]bool{}, + } +} + +func (e *pamTestEnv) writePamFile(t *testing.T, name string, content string) { + t.Helper() + writeTestFile(t, filepath.Join(e.pamDir, name), content) +} + +func (e *pamTestEnv) writeSettings(t *testing.T, content string) { + t.Helper() + writeTestFile(t, filepath.Join(e.homeDir, ".config", "DankMaterialShell", "settings.json"), content) +} + +func (e *pamTestEnv) deps(isNixOS bool) syncDeps { + return syncDeps{ + pamDir: e.pamDir, + greetdPath: e.greetdPath, + dankshellPath: e.dankshellPath, + dankshellU2fPath: e.dankshellU2fPath, + isNixOS: func() bool { return isNixOS }, + readFile: os.ReadFile, + stat: os.Stat, + createTemp: func(_ string, pattern string) (*os.File, error) { + return os.CreateTemp(e.tmpDir, pattern) + }, + removeFile: os.Remove, + runSudoCmd: func(_ string, command string, args ...string) error { + switch command { + case "cp": + if len(args) != 2 { + return fmt.Errorf("unexpected cp args: %v", args) + } + data, err := os.ReadFile(args[0]) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(args[1]), 0o755); err != nil { + return err + } + return os.WriteFile(args[1], data, 0o644) + case "chmod": + if len(args) != 2 { + return fmt.Errorf("unexpected chmod args: %v", args) + } + return nil + case "rm": + if len(args) != 2 || args[0] != "-f" { + return fmt.Errorf("unexpected rm args: %v", args) + } + if err := os.Remove(args[1]); err != nil && !os.IsNotExist(err) { + return err + } + return nil + default: + return fmt.Errorf("unexpected sudo command: %s %v", command, args) + } + }, + pamModuleExists: func(module string) bool { + return e.availableModules[module] + }, + fingerprintAvailableForCurrentUser: func() bool { + return e.fingerprintAvailable + }, + } +} + +func readFileString(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} + +func TestHasManagedLockscreenPamFile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want bool + }{ + { + name: "both markers present", + content: "#%PAM-1.0\n" + + LockscreenPamManagedBlockStart + "\n" + + "auth sufficient pam_unix.so\n" + + LockscreenPamManagedBlockEnd + "\n", + want: true, + }, + { + name: "missing end marker is not managed", + content: "#%PAM-1.0\n" + + LockscreenPamManagedBlockStart + "\n" + + "auth sufficient pam_unix.so\n", + want: false, + }, + { + name: "custom file is not managed", + content: "#%PAM-1.0\nauth sufficient pam_unix.so\n", + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := hasManagedLockscreenPamFile(tt.content); got != tt.want { + t.Fatalf("hasManagedLockscreenPamFile() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestBuildManagedLockscreenPamContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files map[string]string + wantContains []string + wantNotContains []string + wantCounts map[string]int + wantErr string + }{ + { + name: "preserves custom modules and strips direct u2f and fprint directives", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth include system-auth\n" + + "account include system-auth\n" + + "session include system-auth\n", + "system-auth": "auth requisite pam_nologin.so\n" + + "auth sufficient pam_unix.so try_first_pass nullok\n" + + "auth sufficient pam_u2f.so cue\n" + + "auth sufficient pam_fprintd.so max-tries=1\n" + + "auth required pam_radius_auth.so conf=/etc/raddb/server\n" + + "account required pam_access.so\n" + + "session optional pam_lastlog.so silent\n", + }, + wantContains: []string{ + "#%PAM-1.0", + LockscreenPamManagedBlockStart, + LockscreenPamManagedBlockEnd, + "auth requisite pam_nologin.so", + "auth sufficient pam_unix.so try_first_pass nullok", + "auth required pam_radius_auth.so conf=/etc/raddb/server", + "account required pam_access.so", + "session optional pam_lastlog.so silent", + }, + wantNotContains: []string{ + "pam_u2f", + "pam_fprintd", + }, + wantCounts: map[string]int{ + "auth required pam_radius_auth.so conf=/etc/raddb/server": 1, + "account required pam_access.so": 1, + }, + }, + { + name: "resolves nested include substack and @include transitively", + files: map[string]string{ + "login": "#%PAM-1.0\n" + + "auth include system-auth\n" + + "account include system-auth\n" + + "password include system-auth\n" + + "session include system-auth\n", + "system-auth": "auth substack custom-auth\n" + + "account include custom-auth\n" + + "password include custom-auth\n" + + "session @include common-session\n", + "custom-auth": "auth required pam_custom.so one=two\n" + + "account required pam_custom_account.so\n" + + "password required pam_custom_password.so\n", + "common-session": "session optional pam_fprintd.so max-tries=1\n" + + "session optional pam_lastlog.so silent\n", + }, + wantContains: []string{ + "auth required pam_custom.so one=two", + "account required pam_custom_account.so", + "password required pam_custom_password.so", + "session optional pam_lastlog.so silent", + }, + wantNotContains: []string{ + "pam_fprintd", + }, + wantCounts: map[string]int{ + "auth required pam_custom.so one=two": 1, + "account required pam_custom_account.so": 1, + "password required pam_custom_password.so": 1, + "session optional pam_lastlog.so silent": 1, + }, + }, + { + name: "missing include fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include missing-auth\n", + }, + wantErr: "failed to read PAM file", + }, + { + name: "cyclic include fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include system-auth\n", + "system-auth": "auth include login\n", + }, + wantErr: "cyclic PAM include detected", + }, + { + name: "no auth directives remain after filtering fails", + files: map[string]string{ + "login": "#%PAM-1.0\nauth include system-auth\n", + "system-auth": "auth sufficient pam_u2f.so cue\n", + }, + wantErr: "no auth directives remained after filtering", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + for name, content := range tt.files { + env.writePamFile(t, name, content) + } + + content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(content, want) { + t.Errorf("missing expected string %q in output:\n%s", want, content) + } + } + for _, notWant := range tt.wantNotContains { + if strings.Contains(content, notWant) { + t.Errorf("unexpected string %q found in output:\n%s", notWant, content) + } + } + for want, wantCount := range tt.wantCounts { + if gotCount := strings.Count(content, want); gotCount != wantCount { + t.Errorf("count for %q = %d, want %d\noutput:\n%s", want, gotCount, wantCount, content) + } + } + }) + } +} + +func TestSyncLockscreenPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("custom dankshell file is skipped untouched", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + customContent := "#%PAM-1.0\nauth required pam_unix.so\n" + env.writePamFile(t, "dankshell", customContent) + + var logs []string + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err) + } + + if got := readFileString(t, env.dankshellPath); got != customContent { + t.Fatalf("custom dankshell content changed\ngot:\n%s\nwant:\n%s", got, customContent) + } + if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell found") { + t.Fatalf("expected custom-file skip log, got %v", logs) + } + }) + + t.Run("managed dankshell file is rewritten from resolved login stack", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\nauth sufficient pam_u2f.so cue\naccount required pam_access.so\n") + env.writePamFile(t, "dankshell", "#%PAM-1.0\n"+LockscreenPamManagedBlockStart+"\nauth required pam_env.so\n"+LockscreenPamManagedBlockEnd+"\n") + + var logs []string + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenPamConfigWithDeps returned error: %v", err) + } + + output := readFileString(t, env.dankshellPath) + for _, want := range []string{ + LockscreenPamManagedBlockStart, + "auth sufficient pam_unix.so try_first_pass nullok", + "account required pam_access.so", + LockscreenPamManagedBlockEnd, + } { + if !strings.Contains(output, want) { + t.Errorf("missing expected string %q in rewritten dankshell:\n%s", want, output) + } + } + if strings.Contains(output, "pam_u2f") { + t.Errorf("rewritten dankshell still contains pam_u2f:\n%s", output) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell") { + t.Fatalf("expected success log, got %v", logs) + } + }) + + t.Run("mutable systems fail when login stack cannot be converted safely", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + err := syncLockscreenPamConfigWithDeps(func(string) {}, "", env.deps(false)) + if err == nil { + t.Fatal("expected error when login PAM file is missing, got nil") + } + if !strings.Contains(err.Error(), "failed to build") { + t.Fatalf("error = %q, want substring %q", err.Error(), "failed to build") + } + }) + + t.Run("NixOS remains informational and does not write dankshell", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + var logs []string + + err := syncLockscreenPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", env.deps(true)) + 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 _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { + t.Fatalf("expected no dankshell file to be written on NixOS path, stat err = %v", err) + } + }) +} + +func TestSyncLockscreenU2FPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("enabled creates managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + var logs []string + + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", true, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.dankshellU2fPath) + if got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("unexpected managed dankshell-u2f content:\n%s", got) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Created or updated /etc/pam.d/dankshell-u2f") { + t.Fatalf("expected create log, got %v", logs) + } + }) + + t.Run("enabled rewrites existing managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\n"+LockscreenU2FPamManagedBlockStart+"\nauth required pam_u2f.so old\n"+LockscreenU2FPamManagedBlockEnd+"\n") + + if err := syncLockscreenU2FPamConfigWithDeps(func(string) {}, "", true, env.deps(false)); err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("managed dankshell-u2f was not rewritten:\n%s", got) + } + }) + + t.Run("disabled removes DMS-managed file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "dankshell-u2f", buildManagedLockscreenU2FPamContent()) + + var logs []string + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", false, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected managed dankshell-u2f to be removed, stat err = %v", err) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "Removed DMS-managed /etc/pam.d/dankshell-u2f") { + t.Fatalf("expected removal log, got %v", logs) + } + }) + + t.Run("disabled preserves custom file", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + customContent := "#%PAM-1.0\nauth required pam_u2f.so cue\n" + env.writePamFile(t, "dankshell-u2f", customContent) + + var logs []string + err := syncLockscreenU2FPamConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", false, env.deps(false)) + if err != nil { + t.Fatalf("syncLockscreenU2FPamConfigWithDeps returned error: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != customContent { + t.Fatalf("custom dankshell-u2f content changed\ngot:\n%s\nwant:\n%s", got, customContent) + } + if len(logs) == 0 || !strings.Contains(logs[0], "Custom /etc/pam.d/dankshell-u2f found") { + t.Fatalf("expected custom-file log, got %v", logs) + } + }) +} + +func TestSyncGreeterPamConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("adds managed block for enabled auth modules", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so\naccount required pam_unix.so\n") + + settings := AuthSettings{GreeterEnableFprint: true, GreeterEnableU2f: true} + if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil { + t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + for _, want := range []string{ + GreeterPamManagedBlockStart, + "auth sufficient pam_fprintd.so max-tries=1 timeout=5", + "auth sufficient pam_u2f.so cue nouserok timeout=10", + GreeterPamManagedBlockEnd, + } { + if !strings.Contains(got, want) { + t.Errorf("missing expected string %q in greetd PAM:\n%s", want, got) + } + } + if strings.Index(got, GreeterPamManagedBlockStart) > strings.Index(got, "auth include system-auth") { + t.Fatalf("managed block was not inserted before first auth line:\n%s", got) + } + }) + + t.Run("avoids duplicate fingerprint when included stack already provides it", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.fingerprintAvailable = true + original := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n" + env.writePamFile(t, "greetd", original) + env.writePamFile(t, "system-auth", "auth sufficient pam_fprintd.so max-tries=1\nauth sufficient pam_unix.so\n") + + settings := AuthSettings{GreeterEnableFprint: true} + if err := syncGreeterPamConfigWithDeps(func(string) {}, "", settings, false, env.deps(false)); err != nil { + t.Fatalf("syncGreeterPamConfigWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + if got != original { + t.Fatalf("greetd PAM changed despite included pam_fprintd stack\ngot:\n%s\nwant:\n%s", got, original) + } + if strings.Contains(got, GreeterPamManagedBlockStart) { + t.Fatalf("managed block should not be inserted when included stack already has pam_fprintd:\n%s", got) + } + }) +} + +func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "greetd", "#%PAM-1.0\n"+ + legacyGreeterPamFprintComment+"\n"+ + "auth sufficient pam_fprintd.so max-tries=1\n"+ + GreeterPamManagedBlockStart+"\n"+ + "auth sufficient pam_u2f.so cue nouserok timeout=10\n"+ + GreeterPamManagedBlockEnd+"\n"+ + "auth include system-auth\n") + + if err := removeManagedGreeterPamBlockWithDeps(func(string) {}, "", env.deps(false)); err != nil { + t.Fatalf("removeManagedGreeterPamBlockWithDeps returned error: %v", err) + } + + got := readFileString(t, env.greetdPath) + if strings.Contains(got, GreeterPamManagedBlockStart) || strings.Contains(got, legacyGreeterPamFprintComment) { + t.Fatalf("managed or legacy DMS auth lines remained in greetd PAM:\n%s", got) + } + if !strings.Contains(got, "auth include system-auth") { + t.Fatalf("expected non-DMS greetd auth lines to remain:\n%s", got) + } +} + +func TestSyncAuthConfigWithDeps(t *testing.T) { + t.Parallel() + + t.Run("creates lockscreen targets and skips greetd when greeter is not installed", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writeSettings(t, `{"enableU2f":true}`) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n") + + var logs []string + err := syncAuthConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + if _, err := os.Stat(env.dankshellPath); err != nil { + t.Fatalf("expected dankshell to be created: %v", err) + } + if got := readFileString(t, env.dankshellU2fPath); got != buildManagedLockscreenU2FPamContent() { + t.Fatalf("unexpected dankshell-u2f content:\n%s", got) + } + if len(logs) == 0 || !strings.Contains(logs[len(logs)-1], "greetd not found") { + t.Fatalf("expected greetd skip log, got %v", logs) + } + }) + + t.Run("separate greeter and lockscreen toggles are respected", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.writeSettings(t, `{"enableU2f":false,"greeterEnableFprint":true,"greeterEnableU2f":false}`) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + env.writePamFile(t, "system-auth", "auth sufficient pam_unix.so try_first_pass nullok\naccount required pam_access.so\n") + env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n") + + err := syncAuthConfigWithDeps(func(string) {}, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(false)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + dankshell := readFileString(t, env.dankshellPath) + if strings.Contains(dankshell, "pam_fprintd") || strings.Contains(dankshell, "pam_u2f") { + t.Fatalf("lockscreen PAM should strip fingerprint and U2F modules:\n%s", dankshell) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell-u2f to remain absent when enableU2f is false, stat err = %v", err) + } + + greetd := readFileString(t, env.greetdPath) + if !strings.Contains(greetd, "auth sufficient pam_fprintd.so max-tries=1 timeout=5") { + t.Fatalf("expected greetd PAM to receive fingerprint auth block:\n%s", greetd) + } + if strings.Contains(greetd, "auth sufficient pam_u2f.so cue nouserok timeout=10") { + t.Fatalf("did not expect greetd PAM to receive U2F auth block:\n%s", greetd) + } + }) + + t.Run("NixOS remains informational and non-mutating", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writeSettings(t, `{"enableU2f":true,"greeterEnableFprint":true,"greeterEnableU2f":true}`) + originalGreetd := "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n" + env.writePamFile(t, "greetd", originalGreetd) + + var logs []string + err := syncAuthConfigWithDeps(func(msg string) { + logs = append(logs, msg) + }, "", SyncAuthOptions{HomeDir: env.homeDir}, env.deps(true)) + if err != nil { + t.Fatalf("syncAuthConfigWithDeps returned error: %v", err) + } + + if _, err := os.Stat(env.dankshellPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell to remain absent on NixOS path, stat err = %v", err) + } + if _, err := os.Stat(env.dankshellU2fPath); !os.IsNotExist(err) { + t.Fatalf("expected dankshell-u2f to remain absent on NixOS path, stat err = %v", err) + } + if got := readFileString(t, env.greetdPath); got != originalGreetd { + t.Fatalf("expected greetd PAM to remain unchanged on NixOS path\ngot:\n%s\nwant:\n%s", got, originalGreetd) + } + if len(logs) < 2 || !strings.Contains(strings.Join(logs, "\n"), "NixOS detected") { + t.Fatalf("expected informational NixOS logs, got %v", logs) + } + }) +} diff --git a/distro/nix/greeter.nix b/distro/nix/greeter.nix index 163240c9..29acb7b3 100644 --- a/distro/nix/greeter.nix +++ b/distro/nix/greeter.nix @@ -139,6 +139,13 @@ in ''; } ]; + # DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS. + # Declare security.pam.services.dankshell only if you want to override that runtime fallback. + # U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here. + # security.pam.services.dankshell = { + # # Example: add faillock + # faillock.enable = true; + # }; services.greetd = { enable = lib.mkDefault true; settings.default_session.command = lib.mkDefault (lib.getExe greeterScript); diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 0435f8f4..71fdd588 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -1203,13 +1203,23 @@ Singleton { Quickshell.execDetached(["sh", "-lc", script]); } + function scheduleAuthApply() { + if (isGreeterMode) + return; + Qt.callLater(() => { + Processes.settingsRoot = root; + Processes.scheduleAuthApply(); + }); + } + readonly property var _hooks: ({ "applyStoredTheme": applyStoredTheme, "regenSystemThemes": regenSystemThemes, "updateCompositorLayout": updateCompositorLayout, "applyStoredIconTheme": applyStoredIconTheme, "updateBarConfigs": updateBarConfigs, - "updateCompositorCursor": updateCompositorCursor + "updateCompositorCursor": updateCompositorCursor, + "scheduleAuthApply": scheduleAuthApply }) function set(key, value) { diff --git a/quickshell/Common/settings/Processes.qml b/quickshell/Common/settings/Processes.qml index a29fe99c..5904b212 100644 --- a/quickshell/Common/settings/Processes.qml +++ b/quickshell/Common/settings/Processes.qml @@ -4,6 +4,8 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io +import qs.Common +import qs.Services Singleton { id: root @@ -52,6 +54,14 @@ Singleton { readonly property var forcedFprintAvailable: envFlag("DMS_FORCE_FPRINT_AVAILABLE") readonly property var forcedU2fAvailable: envFlag("DMS_FORCE_U2F_AVAILABLE") + property bool authApplyRunning: false + property bool authApplyQueued: false + property bool authApplyRerunRequested: false + property bool authApplyTerminalFallbackFromPrecheck: false + property string authApplyStdout: "" + property string authApplyStderr: "" + property string authApplySudoProbeStderr: "" + property string authApplyTerminalFallbackStderr: "" function detectQtTools() { qtToolsDetectionProcess.running = true; @@ -92,6 +102,50 @@ Singleton { pluginSettingsCheckProcess.running = true; } + function scheduleAuthApply() { + if (!settingsRoot || settingsRoot.isGreeterMode) + return; + + authApplyQueued = true; + if (authApplyRunning) { + authApplyRerunRequested = true; + return; + } + + authApplyDebounce.restart(); + } + + function beginAuthApply() { + if (!authApplyQueued || authApplyRunning || !settingsRoot || settingsRoot.isGreeterMode) + return; + + authApplyQueued = false; + authApplyRerunRequested = false; + authApplyStdout = ""; + authApplyStderr = ""; + authApplySudoProbeStderr = ""; + authApplyTerminalFallbackStderr = ""; + authApplyTerminalFallbackFromPrecheck = false; + authApplyRunning = true; + authApplySudoProbeProcess.running = true; + } + + function launchAuthApplyTerminalFallback(fromPrecheck, details) { + authApplyTerminalFallbackFromPrecheck = fromPrecheck; + if (details && details !== "") + ToastService.showInfo(I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync"); + authApplyTerminalFallbackStderr = ""; + authApplyTerminalFallbackProcess.running = true; + } + + function finishAuthApply() { + const shouldRerun = authApplyQueued || authApplyRerunRequested; + authApplyRunning = false; + authApplyRerunRequested = false; + if (shouldRerun) + authApplyDebounce.restart(); + } + function stripPamComment(line) { if (!line) return ""; @@ -417,6 +471,91 @@ Singleton { } } + Timer { + id: authApplyDebounce + interval: 300 + repeat: false + onTriggered: root.beginAuthApply() + } + + property var authApplyProcess: Process { + command: ["dms", "auth", "sync", "--yes"] + running: false + + stdout: StdioCollector { + onStreamFinished: root.authApplyStdout = text || "" + } + + stderr: StdioCollector { + onStreamFinished: root.authApplyStderr = text || "" + } + + onExited: exitCode => { + const out = (root.authApplyStdout || "").trim(); + const err = (root.authApplyStderr || "").trim(); + + if (exitCode === 0) { + let details = out; + if (err !== "") + details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; + ToastService.showInfo(I18n.tr("Authentication changes applied."), details, "", "auth-sync"); + root.detectAuthCapabilities(); + root.finishAuthApply(); + return; + } + + let details = ""; + if (out !== "") + details = out; + if (err !== "") + details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err; + ToastService.showWarning(I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync"); + root.launchAuthApplyTerminalFallback(false, ""); + } + } + + property var authApplySudoProbeProcess: Process { + command: ["sudo", "-n", "true"] + running: false + + stderr: StdioCollector { + onStreamFinished: root.authApplySudoProbeStderr = text || "" + } + + onExited: exitCode => { + const err = (root.authApplySudoProbeStderr || "").trim(); + if (exitCode === 0) { + ToastService.showInfo(I18n.tr("Applying authentication changes…"), "", "", "auth-sync"); + root.authApplyProcess.running = true; + return; + } + + root.launchAuthApplyTerminalFallback(true, err); + } + } + + property var authApplyTerminalFallbackProcess: Process { + command: ["dms", "auth", "sync", "--terminal", "--yes"] + running: false + + stderr: StdioCollector { + onStreamFinished: root.authApplyTerminalFallbackStderr = text || "" + } + + onExited: exitCode => { + if (exitCode === 0) { + const message = root.authApplyTerminalFallbackFromPrecheck + ? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.") + : I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done."); + ToastService.showInfo(message, "", "", "auth-sync"); + } else { + let details = (root.authApplyTerminalFallbackStderr || "").trim(); + ToastService.showError(I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync"); + } + root.finishAuthApply(); + } + } + FileView { id: greetdPamWatcher path: "/etc/pam.d/greetd" diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 993f824f..031d28b6 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -169,8 +169,8 @@ var SPEC = { lockDateFormat: { def: "" }, greeterRememberLastSession: { def: true }, greeterRememberLastUser: { def: true }, - greeterEnableFprint: { def: false }, - greeterEnableU2f: { def: false }, + greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" }, + greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" }, greeterWallpaperPath: { def: "" }, greeterUse24HourClock: { def: true }, greeterShowSeconds: { def: false }, @@ -353,7 +353,7 @@ var SPEC = { lockScreenShowMediaPlayer: { def: true }, lockScreenPowerOffMonitorsOnLock: { def: false }, lockAtStartup: { def: false }, - enableFprint: { def: false }, + enableFprint: { def: false, onChange: "scheduleAuthApply" }, maxFprintTries: { def: 15 }, fprintdAvailable: { def: false, persist: false }, lockFingerprintCanEnable: { def: false, persist: false }, @@ -363,7 +363,7 @@ var SPEC = { greeterFingerprintReady: { def: false, persist: false }, greeterFingerprintReason: { def: "probe_failed", persist: false }, greeterFingerprintSource: { def: "none", persist: false }, - enableU2f: { def: false }, + enableU2f: { def: false, onChange: "scheduleAuthApply" }, u2fMode: { def: "or" }, u2fAvailable: { def: false, persist: false }, lockU2fCanEnable: { def: false, persist: false }, diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 3a7f0ab2..c96affa9 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -52,6 +52,12 @@ Item { return I18n.tr("Touch your security key..."); if (pam.lockMessage && pam.lockMessage.length > 0) return pam.lockMessage; + if (root.pamState === "error") + return I18n.tr("Authentication error - try again"); + if (root.pamState === "max") + return I18n.tr("Too many attempts - locked out"); + if (root.pamState === "fail") + return I18n.tr("Incorrect password - try again"); if (pam.fprintState === "error") { const detail = (pam.fprint.message || "").trim(); return detail.length > 0 ? I18n.tr("Fingerprint error: %1").arg(detail) : I18n.tr("Fingerprint error"); @@ -60,12 +66,6 @@ Item { return I18n.tr("Maximum fingerprint attempts reached. Please use password."); if (pam.fprintState === "fail") return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries); - if (root.pamState === "error") - return I18n.tr("Authentication error - try again"); - if (root.pamState === "max") - return I18n.tr("Too many attempts - locked out"); - if (root.pamState === "fail") - return I18n.tr("Incorrect password - try again"); return ""; } diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 0a772e5d..8c60277b 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -91,9 +91,9 @@ Scope { } FileView { - id: loginConfigWatcher + id: nixosMarker - path: "/etc/pam.d/login" + path: "/etc/NIXOS" printErrors: false } @@ -108,7 +108,7 @@ Scope { id: passwd config: dankshellConfigWatcher.loaded ? "dankshell" : "login" - configDirectory: (dankshellConfigWatcher.loaded || loginConfigWatcher.loaded) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" + configDirectory: (dankshellConfigWatcher.loaded || nixosMarker.loaded) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam" onMessageChanged: { if (message.startsWith("The account is locked")) { diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index c4cc84c0..b3d3a41f 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -36,7 +36,7 @@ Item { switch (reason) { case "ready": - return SettingsData.greeterEnableFprint ? I18n.tr("Run Sync to apply. Fingerprint-only login may not unlock GNOME Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled."); + return SettingsData.greeterEnableFprint ? I18n.tr("Authentication changes apply automatically. Fingerprint-only login may not unlock Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled."); case "missing_enrollment": if (SettingsData.greeterEnableFprint) return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints and run Sync."); @@ -60,7 +60,7 @@ Item { switch (reason) { case "ready": - return SettingsData.greeterEnableU2f ? I18n.tr("Run Sync to apply.") : I18n.tr("Available."); + return SettingsData.greeterEnableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Available."); case "missing_key_registration": if (SettingsData.greeterEnableU2f) return I18n.tr("Enabled, but no registered security key was found yet. Register a key and run Sync."); @@ -448,7 +448,7 @@ Item { settingKey: "greeterStatus" StyledText { - text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, PAM config, and wallpaper to the login screen in one step. Must run Sync to apply changes.") + text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, and wallpaper configuration to the login screen. Authentication changes apply automatically.") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText width: parent.width @@ -525,7 +525,7 @@ Item { settingKey: "greeterAuth" StyledText { - text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Run Sync to apply and configure PAM.") + text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Authentication changes apply automatically.") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText width: parent.width @@ -754,7 +754,7 @@ Item { settingKey: "greeterDeps" StyledText { - text: I18n.tr("DMS greeter needs: greetd, dms-greeter. Fingerprint: fprintd, pam_fprintd. Security keys: pam_u2f. Add your user to the greeter group. Sync checks sudo first and opens a terminal when interactive authentication is required.") + text: I18n.tr("DMS greeter needs: greetd, dms-greeter. Fingerprint: fprintd, pam_fprintd. Security keys: pam_u2f. Add your user to the greeter group. Authentication changes apply automatically and may open a terminal when sudo authentication is required.") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText width: parent.width diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 3ed274f0..d86589cc 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -15,10 +15,10 @@ Item { function lockFingerprintDescription() { switch (SettingsData.lockFingerprintReason) { case "ready": - return I18n.tr("Use fingerprint authentication for the lock screen."); + return SettingsData.enableFprint ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use fingerprint authentication for the lock screen."); case "missing_enrollment": if (SettingsData.enableFprint) - return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints to use it."); + return I18n.tr("Enabled, but no prints are enrolled yet. Authentication changes apply automatically once you enroll fingerprints."); return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later."); case "missing_reader": return SettingsData.enableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected."); @@ -32,10 +32,10 @@ Item { function lockU2fDescription() { switch (SettingsData.lockU2fReason) { case "ready": - return I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting"); + return SettingsData.enableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting"); case "missing_key_registration": if (SettingsData.enableU2f) - return I18n.tr("Enabled, but no registered security key was found yet. Register a key or update your U2F config."); + return I18n.tr("Enabled, but no registered security key was found yet. Authentication changes apply automatically once your key is registered or your U2F config is updated."); return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later."); case "missing_pam_support": return I18n.tr("Not available — install or configure pam_u2f."); @@ -213,6 +213,15 @@ Item { 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"]