diff --git a/core/cmd/dms/commands_auth.go b/core/cmd/dms/commands_auth.go index aee7a3354..def6a8ad1 100644 --- a/core/cmd/dms/commands_auth.go +++ b/core/cmd/dms/commands_auth.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" @@ -56,10 +59,108 @@ var authResolveLockCmd = &cobra.Command{ }, } +var authListServicesCmd = &cobra.Command{ + Use: "list-services", + Short: "List candidate lock-screen PAM services available on this system", + Long: "Enumerate the lock-screen PAM services that exist on this system and report their resolved auth stack (whether it has an auth directive and whether fingerprint/U2F modules appear inline).", + Run: func(cmd *cobra.Command, args []string) { + asJSON, _ := cmd.Flags().GetBool("json") + services := sharedpam.ListLockscreenPamServices() + + if asJSON { + payload := struct { + Services []sharedpam.LockscreenPamServiceInfo `json:"services"` + }{Services: services} + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + log.Fatalf("Error encoding services: %v", err) + } + fmt.Println(string(data)) + return + } + + if len(services) == 0 { + fmt.Println("No candidate lock-screen PAM services found.") + return + } + for _, s := range services { + fmt.Printf("%-20s %-30s auth=%-5t fingerprint=%-5t u2f=%t\n", s.Name, s.Path, s.HasAuth, s.InlineFingerprint, s.InlineU2f) + } + }, +} + +var authValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate a PAM service file for use as the DMS lock-screen password stack", + Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.", + Run: func(cmd *cobra.Command, args []string) { + path, _ := cmd.Flags().GetString("path") + service, _ := cmd.Flags().GetString("service") + asJSON, _ := cmd.Flags().GetBool("json") + + if (path == "") == (service == "") { + log.Fatalf("Error: exactly one of --path or --service is required") + } + + var result sharedpam.LockscreenPamValidation + if path != "" { + if !filepath.IsAbs(path) { + result = sharedpam.LockscreenPamValidation{ + Path: path, + MissingModules: []string{}, + Warnings: []string{}, + Errors: []string{"--path must be an absolute file path"}, + } + } else { + result = sharedpam.ValidateLockscreenPamPath(path) + } + } else { + result = sharedpam.ValidateLockscreenPamService(service) + } + + if asJSON { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + log.Fatalf("Error encoding validation: %v", err) + } + fmt.Println(string(data)) + } else { + printLockscreenPamValidation(result) + } + + if !result.Valid { + os.Exit(1) + } + }, +} + +func printLockscreenPamValidation(result sharedpam.LockscreenPamValidation) { + fmt.Printf("Path: %s\n", result.Path) + fmt.Printf("Valid: %t\n", result.Valid) + fmt.Printf("Has auth: %t\n", result.HasAuth) + fmt.Printf("Inline fingerprint: %t\n", result.InlineFingerprint) + fmt.Printf("Inline U2F: %t\n", result.InlineU2f) + if len(result.MissingModules) > 0 { + fmt.Printf("Missing modules: %s\n", strings.Join(result.MissingModules, ", ")) + } + for _, w := range result.Warnings { + fmt.Println("⚠ " + w) + } + for _, e := range result.Errors { + fmt.Println("✗ " + e) + } +} + 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)") authResolveLockCmd.Flags().BoolP("quiet", "q", false, "Only print the resulting file path") + + authListServicesCmd.Flags().Bool("json", false, "Output as JSON") + + authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate") + authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs") + authValidateCmd.Flags().Bool("json", false, "Output as JSON") } func syncAuth(nonInteractive bool) error { diff --git a/core/cmd/dms/main.go b/core/cmd/dms/main.go index d738ff969..cf8ba99eb 100644 --- a/core/cmd/dms/main.go +++ b/core/cmd/dms/main.go @@ -20,7 +20,7 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) - authCmd.AddCommand(authSyncCmd, authResolveLockCmd) + authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) updateCmd.AddCommand(updateCheckCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) diff --git a/core/cmd/dms/main_distro.go b/core/cmd/dms/main_distro.go index a28d9a15f..f5a7a2209 100644 --- a/core/cmd/dms/main_distro.go +++ b/core/cmd/dms/main_distro.go @@ -20,7 +20,7 @@ func init() { runCmd.Flags().MarkHidden("daemon-child") greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd, greeterLaunchSessionCmd) - authCmd.AddCommand(authSyncCmd, authResolveLockCmd) + authCmd.AddCommand(authSyncCmd, authResolveLockCmd, authListServicesCmd, authValidateCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) rootCmd.AddCommand(getCommonCommands()...) diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go index 38de5cf60..f19deb0a2 100644 --- a/core/internal/pam/pam.go +++ b/core/internal/pam/pam.go @@ -70,10 +70,11 @@ var includedPamAuthFiles = []string{ } type AuthSettings struct { - EnableFprint bool `json:"enableFprint"` - EnableU2f bool `json:"enableU2f"` - GreeterEnableFprint bool `json:"greeterEnableFprint"` - GreeterEnableU2f bool `json:"greeterEnableU2f"` + EnableFprint bool `json:"enableFprint"` + EnableU2f bool `json:"enableU2f"` + GreeterEnableFprint bool `json:"greeterEnableFprint"` + GreeterEnableU2f bool `json:"greeterEnableU2f"` + GreeterPamExternallyManaged bool `json:"greeterPamExternallyManaged"` } type SyncAuthOptions struct { @@ -236,6 +237,14 @@ func syncAuthConfigWithDeps(logFunc func(string), sudoPassword string, options S return fmt.Errorf("failed to inspect %s: %w", deps.greetdPath, err) } + if settings.GreeterPamExternallyManaged { + if err := removeManagedGreeterPamBlockWithDeps(logFunc, sudoPassword, deps); err != nil { + return err + } + logFunc("ℹ /etc/pam.d/greetd is externally managed. Skipping DMS greeter PAM sync.") + return nil + } + if err := syncGreeterPamConfigWithDeps(logFunc, sudoPassword, settings, options.ForceGreeterAuth, deps); err != nil { return err } @@ -583,6 +592,270 @@ func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) ( return b.String(), nil } +// lockscreenPamCandidateServices are the services surfaced by list-services: +// the standalone entry points plus the shared auth blocks users may pin. +var lockscreenPamCandidateServices = []string{ + "login", + "system-auth", + "system-login", + "system-local-login", + "common-auth", + "base-auth", +} + +// LockscreenPamServiceInfo describes one candidate lock-screen PAM service. +type LockscreenPamServiceInfo struct { + Name string `json:"name"` + Dir string `json:"dir"` + Path string `json:"path"` + HasAuth bool `json:"hasAuth"` + InlineFingerprint bool `json:"inlineFingerprint"` + InlineU2f bool `json:"inlineU2f"` +} + +// LockscreenPamValidation is the result of validating a PAM service file for +// use as the DMS lock-screen password stack. +type LockscreenPamValidation struct { + Valid bool `json:"valid"` + Path string `json:"path"` + HasAuth bool `json:"hasAuth"` + InlineFingerprint bool `json:"inlineFingerprint"` + InlineU2f bool `json:"inlineU2f"` + MissingModules []string `json:"missingModules"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` +} + +type lockscreenPamValidateDeps struct { + baseDirs []string + readFile func(string) ([]byte, error) + stat func(string) (os.FileInfo, error) + pamModuleExists func(string) bool +} + +func defaultValidateDeps() lockscreenPamValidateDeps { + return lockscreenPamValidateDeps{ + baseDirs: lockscreenPamBaseDirs, + readFile: os.ReadFile, + stat: os.Stat, + pamModuleExists: pamModuleExists, + } +} + +// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike +// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and +// records unknown directives instead of hard-failing on them. +type lockscreenPamAnalysis struct { + lines []string + hasAuth bool + inlineFingerprint bool + inlineU2f bool + modules []string + unknownDirectives []string + err error +} + +func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis { + var acc lockscreenPamAnalysis + if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil { + acc.err = err + } + return acc +} + +func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error { + 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 fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> ")) + } + } + + data, err := r.readFile(path) + if err != nil { + return fmt.Errorf("failed to read PAM file %s: %w", path, err) + } + + 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, "#") { + continue + } + + if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok { + lineType := pamDirectiveType(trimmed) + if filterType != "" && lineType != "" && lineType != filterType { + continue + } + nestedPath := include.target + if filepath.IsAbs(nestedPath) { + nestedPath = filepath.Clean(nestedPath) + } else { + located, err := r.locate(include.target) + if err != nil { + return fmt.Errorf("failed to read PAM file %s: %w", include.target, err) + } + nestedPath = located + } + if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil { + return err + } + continue + } + + lineType := pamDirectiveType(trimmed) + if lineType == "" { + acc.unknownDirectives = append(acc.unknownDirectives, trimmed) + continue + } + if filterType != "" && lineType != filterType { + continue + } + + acc.lines = append(acc.lines, rawLine) + if lineType == "auth" { + acc.hasAuth = true + } + + foundModule := false + for _, field := range strings.Fields(trimmed) { + if strings.HasPrefix(field, "#") { + break + } + if strings.Contains(field, "pam_fprintd") { + acc.inlineFingerprint = true + } + if strings.Contains(field, "pam_u2f") { + acc.inlineU2f = true + } + if !foundModule && strings.HasSuffix(field, ".so") { + acc.modules = append(acc.modules, field) + foundModule = true + } + } + } + + return nil +} + +// ListLockscreenPamServices enumerates the candidate lock-screen services that +// exist on this system, earlier base dir winning per name (libpam precedence). +func ListLockscreenPamServices() []LockscreenPamServiceInfo { + return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile) +} + +func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo { + resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile} + out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices)) + for _, name := range lockscreenPamCandidateServices { + path, err := resolver.locate(name) + if err != nil { + continue + } + info := LockscreenPamServiceInfo{ + Name: name, + Dir: filepath.Dir(path), + Path: path, + } + if analysis := resolver.analyzePath(path); analysis.err == nil { + info.HasAuth = analysis.hasAuth + info.InlineFingerprint = analysis.inlineFingerprint + info.InlineU2f = analysis.inlineU2f + } + out = append(out, info) + } + return out +} + +// ValidateLockscreenPamService validates a named service resolved across the +// system PAM base dirs. +func ValidateLockscreenPamService(name string) LockscreenPamValidation { + return validateLockscreenPam(name, "", defaultValidateDeps()) +} + +// ValidateLockscreenPamPath validates an arbitrary absolute PAM file, which may +// live outside the standard base dirs. +func ValidateLockscreenPamPath(path string) LockscreenPamValidation { + return validateLockscreenPam("", path, defaultValidateDeps()) +} + +func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation { + result := LockscreenPamValidation{ + MissingModules: []string{}, + Warnings: []string{}, + Errors: []string{}, + } + resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile} + + var analysis lockscreenPamAnalysis + if path != "" { + result.Path = path + analysis = resolver.analyzePath(path) + } else { + located, err := resolver.locate(serviceName) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err)) + return result + } + result.Path = located + analysis = resolver.analyzePath(located) + } + + if analysis.err != nil { + result.Errors = append(result.Errors, analysis.err.Error()) + return result + } + + result.HasAuth = analysis.hasAuth + result.InlineFingerprint = analysis.inlineFingerprint + result.InlineU2f = analysis.inlineU2f + + if !analysis.hasAuth { + result.Errors = append(result.Errors, "no auth directives found after include resolution") + } + + for _, directive := range analysis.unknownDirectives { + result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive) + } + + seen := map[string]bool{} + for _, ref := range analysis.modules { + name := filepath.Base(ref) + if seen[name] { + continue + } + seen[name] = true + if moduleReferenceExists(ref, deps) { + continue + } + result.MissingModules = append(result.MissingModules, name) + result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name) + } + + if analysis.inlineFingerprint { + result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context") + } + if analysis.inlineU2f { + result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context") + } + + result.Valid = len(result.Errors) == 0 + return result +} + +func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool { + if filepath.IsAbs(ref) { + _, err := deps.stat(ref) + return err == nil + } + return deps.pamModuleExists(ref) +} + const UserLockscreenPamService = "dankshell" func UserLockscreenPamDir() string { diff --git a/core/internal/pam/pam_test.go b/core/internal/pam/pam_test.go index 3adb1114b..5f58d01ef 100644 --- a/core/internal/pam/pam_test.go +++ b/core/internal/pam/pam_test.go @@ -786,6 +786,223 @@ func TestRemoveManagedGreeterPamBlockWithDeps(t *testing.T) { } } +func (e *pamTestEnv) validateDeps() lockscreenPamValidateDeps { + return lockscreenPamValidateDeps{ + baseDirs: []string{e.pamDir}, + readFile: os.ReadFile, + stat: os.Stat, + pamModuleExists: func(module string) bool { return e.availableModules[module] }, + } +} + +func TestListLockscreenPamServices(t *testing.T) { + t.Parallel() + + t.Run("dedupes by name with earlier base dir winning", func(t *testing.T) { + t.Parallel() + + etcDir := t.TempDir() + vendorDir := t.TempDir() + // login exists in both dirs; system-auth only in the vendor dir. + writeTestFile(t, filepath.Join(etcDir, "login"), "#%PAM-1.0\nauth required pam_unix.so\naccount required pam_unix.so\n") + writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth required pam_deny.so\n") + writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n") + + services := listLockscreenPamServices([]string{etcDir, vendorDir}, os.ReadFile) + if len(services) != 2 { + t.Fatalf("expected 2 services (login, system-auth), got %d: %+v", len(services), services) + } + byName := map[string]LockscreenPamServiceInfo{} + for _, s := range services { + byName[s.Name] = s + } + login, ok := byName["login"] + if !ok { + t.Fatalf("expected login service, got %+v", services) + } + if login.Dir != etcDir || login.Path != filepath.Join(etcDir, "login") { + t.Fatalf("expected login to resolve in earlier dir %s, got dir=%s path=%s", etcDir, login.Dir, login.Path) + } + if !login.HasAuth { + t.Fatalf("expected login to report hasAuth") + } + if byName["system-auth"].Dir != vendorDir { + t.Fatalf("expected system-auth to resolve in vendor dir, got %s", byName["system-auth"].Dir) + } + }) + + t.Run("include resolution sets hasAuth and detects inline fprintd/u2f", 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\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n") + + services := listLockscreenPamServices([]string{env.pamDir}, os.ReadFile) + var login LockscreenPamServiceInfo + for _, s := range services { + if s.Name == "login" { + login = s + } + } + if !login.HasAuth { + t.Fatalf("expected hasAuth via resolved include") + } + if !login.InlineFingerprint || !login.InlineU2f { + t.Fatalf("expected inline fingerprint and u2f detection, got %+v", login) + } + }) +} + +func TestValidateLockscreenPam(t *testing.T) { + t.Parallel() + + t.Run("valid service with resolved auth", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = 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\naccount required pam_unix.so\n") + + result := validateLockscreenPam("login", "", env.validateDeps()) + if !result.Valid { + t.Fatalf("expected valid result, got %+v", result) + } + if !result.HasAuth { + t.Fatalf("expected hasAuth true") + } + if len(result.Errors) != 0 { + t.Fatalf("expected no errors, got %v", result.Errors) + } + }) + + t.Run("path outside base dirs is read directly", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + outside := filepath.Join(t.TempDir(), "custom-pam") + writeTestFile(t, outside, "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n") + + result := validateLockscreenPam("", outside, env.validateDeps()) + if !result.Valid || result.Path != outside { + t.Fatalf("expected valid result for outside path, got %+v", result) + } + }) + + t.Run("missing module produces warning and missingModules but stays valid", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth required pam_absent.so\naccount required pam_unix.so\n") + + result := validateLockscreenPam("system-auth", "", env.validateDeps()) + if !result.Valid { + t.Fatalf("expected valid despite missing module, got %+v", result) + } + if len(result.MissingModules) != 1 || result.MissingModules[0] != "pam_absent.so" { + t.Fatalf("expected missing pam_absent.so, got %v", result.MissingModules) + } + if !containsSubstr(result.Warnings, "pam_absent.so") { + t.Fatalf("expected warning about missing module, got %v", result.Warnings) + } + }) + + t.Run("unknown directive is a warning not an error", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.availableModules["pam_foo.so"] = true + env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nbadtype required pam_foo.so\naccount required pam_unix.so\n") + + result := validateLockscreenPam("system-auth", "", env.validateDeps()) + if !result.Valid { + t.Fatalf("expected valid with unknown directive, got %+v", result) + } + if len(result.Errors) != 0 { + t.Fatalf("expected no errors, got %v", result.Errors) + } + if !containsSubstr(result.Warnings, "unsupported PAM directive") { + t.Fatalf("expected unsupported directive warning, got %v", result.Warnings) + } + }) + + t.Run("cyclic include is an error", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.writePamFile(t, "login", "#%PAM-1.0\nauth include system-auth\n") + env.writePamFile(t, "system-auth", "auth include login\n") + + result := validateLockscreenPam("login", "", env.validateDeps()) + if result.Valid { + t.Fatalf("expected invalid on cyclic include, got %+v", result) + } + if !containsSubstr(result.Errors, "cyclic PAM include detected") { + t.Fatalf("expected cyclic include error, got %v", result.Errors) + } + }) + + t.Run("no auth directives is an error", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.writePamFile(t, "system-auth", "#%PAM-1.0\naccount required pam_unix.so\n") + + result := validateLockscreenPam("system-auth", "", env.validateDeps()) + if result.Valid { + t.Fatalf("expected invalid when no auth directives, got %+v", result) + } + if !containsSubstr(result.Errors, "no auth directives") { + t.Fatalf("expected no-auth error, got %v", result.Errors) + } + }) + + t.Run("missing file is an error", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + result := validateLockscreenPam("", filepath.Join(env.pamDir, "does-not-exist"), env.validateDeps()) + if result.Valid || len(result.Errors) == 0 { + t.Fatalf("expected invalid for missing file, got %+v", result) + } + }) + + t.Run("inline fingerprint and u2f produce warnings", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_unix.so"] = true + env.availableModules["pam_fprintd.so"] = true + env.availableModules["pam_u2f.so"] = true + env.writePamFile(t, "system-auth", "#%PAM-1.0\nauth sufficient pam_unix.so\nauth sufficient pam_fprintd.so\nauth sufficient pam_u2f.so cue\naccount required pam_unix.so\n") + + result := validateLockscreenPam("system-auth", "", env.validateDeps()) + if !result.Valid { + t.Fatalf("expected valid, got %+v", result) + } + if !result.InlineFingerprint || !result.InlineU2f { + t.Fatalf("expected inline flags set, got %+v", result) + } + if !containsSubstr(result.Warnings, "pam_fprintd") || !containsSubstr(result.Warnings, "pam_u2f") { + t.Fatalf("expected double-prompt warnings, got %v", result.Warnings) + } + }) +} + +func containsSubstr(items []string, substr string) bool { + for _, item := range items { + if strings.Contains(item, substr) { + return true + } + } + return false +} + func TestSyncAuthConfigWithDeps(t *testing.T) { t.Parallel() @@ -848,6 +1065,39 @@ func TestSyncAuthConfigWithDeps(t *testing.T) { } }) + t.Run("externally managed greetd is stripped and greeter sync skipped", func(t *testing.T) { + t.Parallel() + + env := newPamTestEnv(t) + env.availableModules["pam_fprintd.so"] = true + env.writeSettings(t, `{"greeterPamExternallyManaged":true,"greeterEnableFprint":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\naccount required pam_unix.so\n") + env.writePamFile(t, "greetd", "#%PAM-1.0\nauth include system-auth\n"+ + GreeterPamManagedBlockStart+"\n"+ + "auth sufficient pam_fprintd.so max-tries=2 timeout=10\n"+ + GreeterPamManagedBlockEnd+"\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) + } + + greetd := readFileString(t, env.greetdPath) + if strings.Contains(greetd, GreeterPamManagedBlockStart) || strings.Contains(greetd, "pam_fprintd") { + t.Fatalf("expected DMS-managed block stripped from externally managed greetd:\n%s", greetd) + } + if !strings.Contains(greetd, "auth include system-auth") { + t.Fatalf("expected non-DMS greetd lines to remain:\n%s", greetd) + } + if !containsSubstr(logs, "externally managed") { + t.Fatalf("expected externally-managed skip log, got %v", logs) + } + }) + t.Run("NixOS remains informational and non-mutating", func(t *testing.T) { t.Parallel() diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index db631db48..0c01a4621 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -895,6 +895,10 @@ Singleton { readonly property bool greeterU2fReady: Processes.greeterU2fReady readonly property string greeterU2fReason: Processes.greeterU2fReason readonly property string greeterU2fSource: Processes.greeterU2fSource + property string lockPamPath: "" + property bool lockPamInlineFprint: false + property bool lockPamInlineU2f: false + property bool greeterPamExternallyManaged: false property string lockScreenInactiveColor: "#000000" property int lockScreenNotificationMode: 0 property bool lockScreenVideoEnabled: false diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 6f0778301..0596669c5 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -233,6 +233,7 @@ var SPEC = { greeterLockDateFormat: { def: "", onChange: "markGreeterSyncPending" }, greeterFontFamily: { def: "", onChange: "markGreeterSyncPending" }, greeterWallpaperFillMode: { def: "", onChange: "markGreeterSyncPending" }, + greeterPamExternallyManaged: { def: false, onChange: "markGreeterSyncPending" }, greeterSyncPending: { def: false }, greeterSyncBaseline: { def: {} }, mediaSize: { def: 1 }, @@ -455,6 +456,9 @@ var SPEC = { maxFprintTries: { def: 15 }, enableU2f: { def: false, onChange: "scheduleAuthApply" }, u2fMode: { def: "or" }, + lockPamPath: { def: "" }, + lockPamInlineFprint: { def: false }, + lockPamInlineU2f: { def: false }, lockScreenInactiveColor: { def: "#000000" }, lockScreenNotificationMode: { def: 0 }, lockScreenVideoEnabled: { def: false }, diff --git a/quickshell/Modules/Lock/Pam.qml b/quickshell/Modules/Lock/Pam.qml index 66f621adf..05aa31b6d 100644 --- a/quickshell/Modules/Lock/Pam.qml +++ b/quickshell/Modules/Lock/Pam.qml @@ -90,6 +90,17 @@ Scope { fprint.checkAvail(); } + readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded + readonly property bool fprintSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineFprint + readonly property bool u2fSuppressedByCustomPam: customPamActive && SettingsData.lockPamInlineU2f + + FileView { + id: customPamWatcher + + path: SettingsData.lockPamPath !== "" ? SettingsData.lockPamPath : "" + printErrors: false + } + FileView { id: dankshellConfigWatcher @@ -148,6 +159,8 @@ Scope { id: passwd config: { + if (root.customPamActive) + return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1); if (dankshellConfigWatcher.loaded) return "dankshell"; if (nixosMarker.loaded || root.runningFromNixStore) @@ -157,6 +170,10 @@ Scope { return "login"; } configDirectory: { + if (root.customPamActive) { + const idx = SettingsData.lockPamPath.lastIndexOf("/"); + return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/"; + } if (dankshellConfigWatcher.loaded) return "/etc/pam.d"; if (nixosMarker.loaded || root.runningFromNixStore) @@ -248,7 +265,7 @@ Scope { property int errorTries function checkAvail(): void { - if (!available || !SettingsData.enableFprint || !root.lockSecured) { + if (!available || !SettingsData.enableFprint || !root.lockSecured || root.fprintSuppressedByCustomPam) { abort(); return; } @@ -308,7 +325,7 @@ Scope { property bool available: SettingsData.lockU2fReady function checkAvail(): void { - if (!available || !SettingsData.enableU2f || !root.lockSecured) { + if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByCustomPam) { abort(); return; } @@ -318,7 +335,7 @@ Scope { } function startForSecondFactor(): void { - if (!available || !SettingsData.enableU2f) { + if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam) { root.completeUnlock(); return; } @@ -331,7 +348,7 @@ Scope { } function startForAlternativeAuth(): void { - if (!available || !SettingsData.enableU2f || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) + if (!available || !SettingsData.enableU2f || root.u2fSuppressedByCustomPam || SettingsData.u2fMode !== "or" || root.unlockInProgress || passwd.active || active) return; abort(); root.u2fPending = true; @@ -486,6 +503,19 @@ Scope { u2f.checkAvail(); } + function onLockPamPathChanged(): void { + fprint.checkAvail(); + u2f.checkAvail(); + } + + function onLockPamInlineFprintChanged(): void { + fprint.checkAvail(); + } + + function onLockPamInlineU2fChanged(): void { + u2f.checkAvail(); + } + function onU2fModeChanged(): void { if (root.lockSecured) { u2f.abort(); diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index d0c39eb5c..ab41855f9 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -18,6 +18,9 @@ Item { readonly property bool greeterU2fToggleAvailable: SettingsData.greeterU2fCanEnable || SettingsData.greeterEnableU2f function greeterFingerprintDescription() { + if (SettingsData.greeterPamExternallyManaged) + return I18n.tr("greetd PAM is externally managed. DMS will not change fingerprint auth; configure it yourself."); + const source = SettingsData.greeterFingerprintSource; const reason = SettingsData.greeterFingerprintReason; @@ -51,6 +54,9 @@ Item { } function greeterU2fDescription() { + if (SettingsData.greeterPamExternallyManaged) + return I18n.tr("greetd PAM is externally managed. DMS will not change security-key auth; configure it yourself."); + const source = SettingsData.greeterU2fSource; const reason = SettingsData.greeterU2fReason; @@ -499,6 +505,15 @@ Item { horizontalAlignment: Text.AlignLeft } + SettingsToggleRow { + settingKey: "greeterPamExternallyManaged" + tags: ["greeter", "pam", "managed", "external", "greetd", "auth"] + text: I18n.tr("Manage greetd PAM automatically") + description: SettingsData.greeterPamExternallyManaged ? I18n.tr("DMS never touches /etc/pam.d/greetd; any existing DMS block is removed on next sync. Configure fingerprint and security-key auth yourself.") : I18n.tr("DMS writes its managed block into /etc/pam.d/greetd during sync.") + checked: !SettingsData.greeterPamExternallyManaged + onToggled: checked => SettingsData.set("greeterPamExternallyManaged", !checked) + } + SettingsToggleRow { settingKey: "greeterEnableFprint" tags: ["greeter", "fingerprint", "fprintd", "login", "auth"] @@ -506,7 +521,7 @@ Item { description: root.greeterFingerprintDescription() descriptionColor: (SettingsData.greeterFingerprintReason === "ready" || SettingsData.greeterFingerprintReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning checked: SettingsData.greeterEnableFprint - enabled: root.greeterFprintToggleAvailable + enabled: root.greeterFprintToggleAvailable && !SettingsData.greeterPamExternallyManaged onToggled: checked => SettingsData.set("greeterEnableFprint", checked) } @@ -517,7 +532,7 @@ Item { description: root.greeterU2fDescription() descriptionColor: (SettingsData.greeterU2fReason === "ready" || SettingsData.greeterU2fReason === "configured_externally") ? Theme.surfaceVariantText : Theme.warning checked: SettingsData.greeterEnableU2f - enabled: root.greeterU2fToggleAvailable + enabled: root.greeterU2fToggleAvailable && !SettingsData.greeterPamExternallyManaged onToggled: checked => SettingsData.set("greeterEnableU2f", checked) } } @@ -718,9 +733,13 @@ Item { MouseArea { anchors.fill: parent - cursorShape: Qt.PointingHandCursor - enabled: !root.greeterSyncRunning && !root.greeterInstallActionRunning - onClicked: root.runGreeterSync() + hoverEnabled: true + acceptedButtons: Qt.AllButtons + cursorShape: !root.greeterSyncRunning && !root.greeterInstallActionRunning ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: mouse => { + if (mouse.button === Qt.LeftButton && !root.greeterSyncRunning && !root.greeterInstallActionRunning) + root.runGreeterSync(); + } } Row { diff --git a/quickshell/Modules/Settings/LockScreenTab.qml b/quickshell/Modules/Settings/LockScreenTab.qml index 518b70387..9c4b400cc 100644 --- a/quickshell/Modules/Settings/LockScreenTab.qml +++ b/quickshell/Modules/Settings/LockScreenTab.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Io import qs.Common import qs.Modals.FileBrowser import qs.Services @@ -12,6 +13,71 @@ Item { readonly property bool lockFprintToggleAvailable: SettingsData.lockFingerprintCanEnable || SettingsData.enableFprint readonly property bool lockU2fToggleAvailable: SettingsData.lockU2fCanEnable || SettingsData.enableU2f + property var authServices: [] + property bool authValidateRunning: false + property bool authValidateOk: false + property bool authValidateWarn: false + property string authValidateMessage: "" + property string authPendingApplyPath: "" + property bool authShowCustom: false + + readonly property string authAutoLabel: I18n.tr("Auto (recommended)") + readonly property string authCustomLabel: I18n.tr("Custom…") + + function authServiceLabel(service) { + const label = service.name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("-"); + return service.dir === "/etc/pam.d" ? label : label + " (" + service.dir + ")"; + } + + readonly property var authOptions: { + var opts = [authAutoLabel]; + for (var i = 0; i < authServices.length; i++) + opts.push(authServiceLabel(authServices[i])); + opts.push(authCustomLabel); + return opts; + } + + readonly property string authCurrentValue: { + if (SettingsData.lockPamPath === "") + return authAutoLabel; + for (var i = 0; i < authServices.length; i++) { + if (authServices[i].path === SettingsData.lockPamPath) + return authServiceLabel(authServices[i]); + } + return authCustomLabel; + } + + function refreshAuthServices() { + authListServicesProcess.running = true; + } + + function applyAutoAuthSource() { + SettingsData.set("lockPamPath", ""); + SettingsData.set("lockPamInlineFprint", false); + SettingsData.set("lockPamInlineU2f", false); + root.authValidateOk = false; + root.authValidateWarn = false; + root.authValidateMessage = ""; + } + + function validateAndApplyAuthSource(path) { + if (!path || path === "") + return; + if (!path.startsWith("/")) { + root.authValidateOk = false; + root.authValidateWarn = false; + root.authValidateMessage = I18n.tr("Not a valid path"); + return; + } + root.authPendingApplyPath = path; + root.authValidateMessage = ""; + root.authValidateOk = false; + root.authValidateWarn = false; + root.authValidateRunning = true; + authValidateProcess.command = ["dms", "auth", "validate", "--path", path, "--json"]; + authValidateProcess.running = true; + } + function lockFingerprintDescription() { switch (SettingsData.lockFingerprintReason) { case "ready": @@ -48,10 +114,15 @@ Item { SettingsData.refreshAuthAvailability(); } - Component.onCompleted: refreshAuthDetection() + Component.onCompleted: { + refreshAuthDetection(); + refreshAuthServices(); + } onVisibleChanged: { - if (visible) + if (visible) { refreshAuthDetection(); + refreshAuthServices(); + } } FileBrowserModal { @@ -64,6 +135,78 @@ Item { onFileSelected: path => SettingsData.set("lockScreenVideoPath", path) } + Process { + id: authListServicesProcess + command: ["dms", "auth", "list-services", "--json"] + running: false + + property string collected: "" + + stdout: StdioCollector { + onStreamFinished: authListServicesProcess.collected = text || "" + } + + onExited: exitCode => { + if (exitCode !== 0) { + root.authServices = []; + return; + } + try { + const data = JSON.parse(authListServicesProcess.collected); + root.authServices = (data && Array.isArray(data.services)) ? data.services.filter(s => s.hasAuth) : []; + } catch (e) { + root.authServices = []; + } + } + } + + Process { + id: authValidateProcess + running: false + + property string collected: "" + + stdout: StdioCollector { + onStreamFinished: authValidateProcess.collected = text || "" + } + + onExited: exitCode => { + root.authValidateRunning = false; + var data = null; + try { + data = JSON.parse(authValidateProcess.collected); + } catch (e) { + data = null; + } + if (!data) { + root.authValidateOk = false; + root.authValidateWarn = false; + root.authValidateMessage = I18n.tr("Could not run validation. Ensure DMS is installed and dms is in PATH."); + return; + } + if (data.valid) { + SettingsData.set("lockPamPath", root.authPendingApplyPath); + SettingsData.set("lockPamInlineFprint", data.inlineFingerprint === true); + SettingsData.set("lockPamInlineU2f", data.inlineU2f === true); + const warns = Array.isArray(data.warnings) ? data.warnings : []; + root.authValidateOk = true; + root.authValidateWarn = warns.length > 0; + root.authValidateMessage = warns.length > 0 ? (I18n.tr("Applied with warnings:") + "\n" + warns.join("\n")) : I18n.tr("Applied successfully."); + } else { + root.authValidateOk = false; + root.authValidateWarn = false; + const errs = Array.isArray(data.errors) ? data.errors : []; + const missing = Array.isArray(data.missingModules) ? data.missingModules : []; + var msg = I18n.tr("Invalid — not applied."); + if (errs.length > 0) + msg = msg + "\n" + errs.join("\n"); + if (missing.length > 0) + msg = msg + "\n" + I18n.tr("Missing modules: ") + missing.join(", "); + root.authValidateMessage = msg; + } + } + } + DankFlickable { anchors.fill: parent clip: true @@ -207,6 +350,109 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "key" + title: I18n.tr("Authentication Source") + settingKey: "lockAuthSource" + + StyledText { + text: I18n.tr("Auto resolves the system auth stack automatically. Picking a source pins the lock screen to that PAM file.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + SettingsDropdownRow { + settingKey: "lockPamPath" + tags: ["lock", "screen", "pam", "authentication", "source", "service"] + text: I18n.tr("Authentication Source") + description: SettingsData.lockPamPath !== "" ? I18n.tr("Pinned to ") + SettingsData.lockPamPath : I18n.tr("Which PAM service the lock screen uses to authenticate") + options: root.authOptions + currentValue: root.authCurrentValue + onValueChanged: value => { + if (value === root.authAutoLabel) { + root.authShowCustom = false; + root.applyAutoAuthSource(); + return; + } + if (value === root.authCustomLabel) { + root.authShowCustom = true; + return; + } + root.authShowCustom = false; + var svc = root.authServices.find(s => root.authServiceLabel(s) === value); + if (svc) + root.validateAndApplyAuthSource(svc.path); + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: root.authShowCustom || root.authCurrentValue === root.authCustomLabel + + StyledText { + text: I18n.tr("Enter the absolute path to a PAM service file, then validate and apply.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.Wrap + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + id: customPamField + width: parent.width - validatePamButton.width - Theme.spacingS + placeholderText: "/etc/pam.d/my-service" + text: SettingsData.lockPamPath + backgroundColor: Theme.surfaceContainerHighest + } + + DankButton { + id: validatePamButton + text: I18n.tr("Validate & Apply") + enabled: !root.authValidateRunning && customPamField.text.trim() !== "" + onClicked: root.validateAndApplyAuthSource(customPamField.text.trim()) + } + } + } + + Rectangle { + width: parent.width + height: Math.min(160, authStatusText.implicitHeight + Theme.spacingM * 2) + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + visible: root.authValidateMessage !== "" + + StyledText { + id: authStatusText + anchors.fill: parent + anchors.margins: Theme.spacingM + text: root.authValidateMessage + font.pixelSize: Theme.fontSizeSmall + font.family: "monospace" + color: !root.authValidateOk ? Theme.error : (root.authValidateWarn ? Theme.warning : Theme.surfaceVariantText) + wrapMode: Text.Wrap + verticalAlignment: Text.AlignTop + } + } + + StyledText { + visible: (SettingsData.lockPamInlineFprint && SettingsData.enableFprint) || (SettingsData.lockPamInlineU2f && SettingsData.enableU2f) + text: I18n.tr("The chosen PAM stack already prompts for fingerprint or security key, so DMS's separate prompts are suppressed to avoid a double prompt.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + width: parent.width + wrapMode: Text.Wrap + topPadding: Theme.spacingS + } + } + SettingsCard { width: parent.width iconName: "lock" diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 832a8da09..219bdbdb4 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -4634,6 +4634,45 @@ "zenbrowser" ] }, + { + "section": "lockAuthSource", + "label": "Authentication Source", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "authentication", + "lock", + "login", + "pam", + "password", + "pinned", + "screen", + "security", + "service", + "source" + ], + "icon": "key", + "description": "Pinned to " + }, + { + "section": "lockPamPath", + "label": "Authentication Source", + "tabIndex": 11, + "category": "Lock Screen", + "keywords": [ + "authentication", + "lock", + "login", + "pam", + "password", + "pinned", + "screen", + "security", + "service", + "source" + ], + "description": "Pinned to " + }, { "section": "lockScreenVideoCycling", "label": "Automatic Cycling", @@ -7954,14 +7993,57 @@ "keywords": [ "auth", "authentication", + "block", + "configure", "display manager", + "existing", + "external", "fingerprint", - "fprintd", "greetd", "greeter", - "login" + "login", + "managed", + "never", + "next", + "pam", + "removed", + "security", + "sync", + "touches", + "yourself" ], - "icon": "fingerprint" + "icon": "fingerprint", + "description": "DMS never touches /etc/pam.d/greetd; any existing DMS block is removed on next sync. Configure fingerprint and security-key auth yourself." + }, + { + "section": "greeterPamExternallyManaged", + "label": "Manage greetd PAM automatically", + "tabIndex": 31, + "category": "Greeter", + "keywords": [ + "auth", + "automatically", + "block", + "configure", + "display manager", + "existing", + "external", + "fingerprint", + "greetd", + "greeter", + "login", + "manage", + "managed", + "never", + "next", + "pam", + "removed", + "security", + "sync", + "touches", + "yourself" + ], + "description": "DMS never touches /etc/pam.d/greetd; any existing DMS block is removed on next sync. Configure fingerprint and security-key auth yourself." }, { "section": "greeterRememberLastSession",