From 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 Mon Sep 17 00:00:00 2001 From: AlexanderCurl Date: Sat, 18 Apr 2026 17:46:06 +0100 Subject: Replaced file structures for themes in the correct format --- .../DankMaterialShell/core/internal/pam/pam.go | 892 +++++++++++++++++++++ .../core/internal/pam/pam_test.go | 671 ++++++++++++++++ 2 files changed, 1563 insertions(+) create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam_test.go (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam') diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam.go new file mode 100644 index 0000000..16ca3f9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/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/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam_test.go new file mode 100644 index 0000000..113bf10 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/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) + } + }) +} -- cgit v1.3