summaryrefslogtreecommitdiff
path: root/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam
diff options
context:
space:
mode:
authorAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
committerAlexanderCurl <alexc@alexc.hu>2026-04-18 17:46:06 +0100
commit70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch)
treeab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam
parent5d94c0a7d44a2255b81815a52a7056a94a39842d (diff)
downloadRaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz
RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam')
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam.go892
-rw-r--r--raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/pam/pam_test.go671
2 files changed, 1563 insertions, 0 deletions
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)
+ }
+ })
+}