mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-03 20:32:07 -04:00
feat(Auth): Unify shared PAM sync across greeter & lockscreen
- Add a neutral `dms auth sync` command and reuse the shared auth flow from: - Settings auth toggle auto-apply - `dms greeter sync` - `dms greeter install` - greeter auth cleanup paths - Rework lockscreen PAM so DMS builds /etc/pam.d/dankshell from the system login stack, but removes fingerprint and U2F from that password path. Keep /etc/pam.d/dankshell-u2f separate. - Preserve custom PAM files in place to avoid adding duplicate greeter auth when the distro already provides it, and keep NixOS on the non-writing path.
This commit is contained in:
76
core/cmd/dms/commands_auth.go
Normal file
76
core/cmd/dms/commands_auth.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage DMS authentication sync",
|
||||
Long: "Manage shared PAM/authentication setup for DMS greeter and lock screen",
|
||||
}
|
||||
|
||||
var authSyncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync DMS authentication configuration",
|
||||
Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
term, _ := cmd.Flags().GetBool("terminal")
|
||||
if term {
|
||||
if err := syncAuthInTerminal(yes); err != nil {
|
||||
log.Fatalf("Error launching auth sync in terminal: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := syncAuth(yes); err != nil {
|
||||
log.Fatalf("Error syncing authentication: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
authSyncCmd.Flags().BoolP("yes", "y", false, "Non-interactive mode: skip prompts")
|
||||
authSyncCmd.Flags().BoolP("terminal", "t", false, "Run auth sync in a new terminal (for entering sudo password)")
|
||||
}
|
||||
|
||||
func syncAuth(nonInteractive bool) error {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Authentication Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
logFunc := func(msg string) {
|
||||
fmt.Println(msg)
|
||||
}
|
||||
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, "", sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Println("\n=== Authentication Sync Complete ===")
|
||||
fmt.Println("\nAuthentication changes have been applied.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuthInTerminal(nonInteractive bool) error {
|
||||
syncFlags := make([]string, 0, 1)
|
||||
if nonInteractive {
|
||||
syncFlags = append(syncFlags, "--yes")
|
||||
}
|
||||
|
||||
shellSyncCmd := "dms auth sync"
|
||||
if len(syncFlags) > 0 {
|
||||
shellSyncCmd += " " + strings.Join(syncFlags, " ")
|
||||
}
|
||||
shellCmd := shellSyncCmd + `; echo; echo "Authentication sync finished. Closing in 3 seconds..."; sleep 3`
|
||||
return runCommandInTerminal(shellCmd)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/greeter"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/text/cases"
|
||||
@@ -25,6 +26,11 @@ var greeterCmd = &cobra.Command{
|
||||
Long: "Manage DMS greeter (greetd)",
|
||||
}
|
||||
|
||||
var (
|
||||
greeterConfigSyncFn = greeter.SyncDMSConfigs
|
||||
sharedAuthSyncFn = sharedpam.SyncAuthConfig
|
||||
)
|
||||
|
||||
var greeterInstallCmd = &cobra.Command{
|
||||
Use: "install",
|
||||
Short: "Install and configure DMS greeter",
|
||||
@@ -148,6 +154,16 @@ func init() {
|
||||
greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)")
|
||||
}
|
||||
|
||||
func syncGreeterConfigsAndAuth(dmsPath, compositor string, logFunc func(string), options sharedpam.SyncAuthOptions, beforeAuth func()) error {
|
||||
if err := greeterConfigSyncFn(dmsPath, compositor, logFunc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if beforeAuth != nil {
|
||||
beforeAuth()
|
||||
}
|
||||
return sharedAuthSyncFn(logFunc, "", options)
|
||||
}
|
||||
|
||||
func installGreeter(nonInteractive bool) error {
|
||||
fmt.Println("=== DMS Greeter Installation ===")
|
||||
|
||||
@@ -243,7 +259,9 @@ func installGreeter(nonInteractive bool) error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, selectedCompositor, logFunc, "", false); err != nil {
|
||||
if err := syncGreeterConfigsAndAuth(dmsPath, selectedCompositor, logFunc, sharedpam.SyncAuthOptions{}, func() {
|
||||
fmt.Println("\nConfiguring authentication...")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -278,7 +296,7 @@ func uninstallGreeter(nonInteractive bool) error {
|
||||
}
|
||||
|
||||
if !nonInteractive {
|
||||
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
|
||||
fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS-managed greeter auth block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if strings.ToLower(strings.TrimSpace(response)) != "y" {
|
||||
@@ -297,8 +315,8 @@ func uninstallGreeter(nonInteractive bool) error {
|
||||
fmt.Println(" ✓ greetd disabled")
|
||||
}
|
||||
|
||||
fmt.Println("\nRemoving DMS PAM configuration...")
|
||||
if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil {
|
||||
fmt.Println("\nRemoving DMS authentication configuration...")
|
||||
if err := sharedpam.RemoveManagedGreeterPamBlock(logFunc, ""); err != nil {
|
||||
fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -535,7 +553,7 @@ func resolveLocalWrapperShell() (string, error) {
|
||||
|
||||
func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
if !nonInteractive {
|
||||
fmt.Println("=== DMS Greeter Theme Sync ===")
|
||||
fmt.Println("=== DMS Greeter Sync ===")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -721,7 +739,11 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
}
|
||||
|
||||
fmt.Println("\nSynchronizing DMS configurations...")
|
||||
if err := greeter.SyncDMSConfigs(dmsPath, compositor, logFunc, "", forceAuth); err != nil {
|
||||
if err := syncGreeterConfigsAndAuth(dmsPath, compositor, logFunc, sharedpam.SyncAuthOptions{
|
||||
ForceGreeterAuth: forceAuth,
|
||||
}, func() {
|
||||
fmt.Println("\nConfiguring authentication...")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -734,8 +756,9 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error {
|
||||
|
||||
fmt.Println("\n=== Sync Complete ===")
|
||||
fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.")
|
||||
fmt.Println("Shared authentication settings were also checked and reconciled where needed.")
|
||||
if forceAuth {
|
||||
fmt.Println("PAM has been configured for fingerprint and U2F (where modules exist).")
|
||||
fmt.Println("Authentication has been configured for fingerprint and U2F (where modules exist).")
|
||||
}
|
||||
fmt.Println("The changes will be visible on the next login screen.")
|
||||
|
||||
@@ -1297,39 +1320,7 @@ func extractGreeterPathOverrideFromCommand(command string) string {
|
||||
}
|
||||
|
||||
func parseManagedGreeterPamAuth(pamText string) (managed bool, fingerprint bool, u2f bool, legacy bool) {
|
||||
if pamText == "" {
|
||||
return false, false, false, false
|
||||
}
|
||||
|
||||
lines := strings.Split(pamText, "\n")
|
||||
inManaged := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch trimmed {
|
||||
case greeter.GreeterPamManagedBlockStart:
|
||||
managed = true
|
||||
inManaged = true
|
||||
continue
|
||||
case greeter.GreeterPamManagedBlockEnd:
|
||||
inManaged = false
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "# DMS greeter fingerprint") || strings.HasPrefix(trimmed, "# DMS greeter U2F") {
|
||||
legacy = true
|
||||
}
|
||||
if !inManaged {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_fprintd") {
|
||||
fingerprint = true
|
||||
}
|
||||
if strings.Contains(trimmed, "pam_u2f") {
|
||||
u2f = true
|
||||
}
|
||||
}
|
||||
|
||||
return managed, fingerprint, u2f, legacy
|
||||
return sharedpam.ParseManagedGreeterPamAuth(pamText)
|
||||
}
|
||||
|
||||
func packageInstallHint() string {
|
||||
@@ -1639,29 +1630,29 @@ func checkGreeterStatus() error {
|
||||
fmt.Println(" ℹ No managed auth block present (DMS-managed fingerprint/U2F lines are disabled)")
|
||||
}
|
||||
if legacyManaged {
|
||||
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms greeter sync' to normalize.")
|
||||
fmt.Println(" ⚠ Legacy unmanaged DMS PAM lines detected. Run 'dms auth sync' to normalize.")
|
||||
allGood = false
|
||||
}
|
||||
enableFprintToggle, enableU2fToggle := false, false
|
||||
if enableFprint, enableU2f, settingsErr := greeter.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
|
||||
if enableFprint, enableU2f, settingsErr := sharedpam.ReadGreeterAuthToggles(homeDir); settingsErr == nil {
|
||||
enableFprintToggle = enableFprint
|
||||
enableU2fToggle = enableU2f
|
||||
} else {
|
||||
fmt.Printf(" ℹ Could not read greeter auth toggles from settings: %v\n", settingsErr)
|
||||
}
|
||||
|
||||
includedFprintFile := greeter.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
|
||||
includedU2fFile := greeter.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := greeter.FingerprintAuthAvailableForCurrentUser()
|
||||
includedFprintFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_fprintd.so")
|
||||
includedU2fFile := sharedpam.DetectIncludedPamModule(string(pamData), "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := sharedpam.FingerprintAuthAvailableForCurrentUser()
|
||||
|
||||
if managedFprint && includedFprintFile != "" {
|
||||
fmt.Printf(" ⚠ pam_fprintd found in both DMS managed block and %s.\n", includedFprintFile)
|
||||
fmt.Println(" Double fingerprint auth detected — run 'dms greeter sync' to resolve.")
|
||||
fmt.Println(" Double fingerprint auth detected — run 'dms auth sync' to resolve.")
|
||||
allGood = false
|
||||
}
|
||||
if managedU2f && includedU2fFile != "" {
|
||||
fmt.Printf(" ⚠ pam_u2f found in both DMS managed block and %s.\n", includedU2fFile)
|
||||
fmt.Println(" Double security-key auth detected — run 'dms greeter sync' to resolve.")
|
||||
fmt.Println(" Double security-key auth detected — run 'dms auth sync' to resolve.")
|
||||
allGood = false
|
||||
}
|
||||
|
||||
|
||||
87
core/cmd/dms/commands_greeter_test.go
Normal file
87
core/cmd/dms/commands_greeter_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
)
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthDelegatesSharedAuth(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
var calls []string
|
||||
greeterConfigSyncFn = func(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
if dmsPath != "/tmp/dms" {
|
||||
t.Fatalf("unexpected dmsPath %q", dmsPath)
|
||||
}
|
||||
if compositor != "niri" {
|
||||
t.Fatalf("unexpected compositor %q", compositor)
|
||||
}
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
calls = append(calls, "configs")
|
||||
return nil
|
||||
}
|
||||
|
||||
var gotOptions sharedpam.SyncAuthOptions
|
||||
sharedAuthSyncFn = func(logFunc func(string), sudoPassword string, options sharedpam.SyncAuthOptions) error {
|
||||
if sudoPassword != "" {
|
||||
t.Fatalf("expected empty sudoPassword, got %q", sudoPassword)
|
||||
}
|
||||
gotOptions = options
|
||||
calls = append(calls, "auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{
|
||||
ForceGreeterAuth: true,
|
||||
}, func() {
|
||||
calls = append(calls, "before-auth")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("syncGreeterConfigsAndAuth returned error: %v", err)
|
||||
}
|
||||
|
||||
wantCalls := []string{"configs", "before-auth", "auth"}
|
||||
if !reflect.DeepEqual(calls, wantCalls) {
|
||||
t.Fatalf("call order = %v, want %v", calls, wantCalls)
|
||||
}
|
||||
if !gotOptions.ForceGreeterAuth {
|
||||
t.Fatalf("expected ForceGreeterAuth to be true, got %+v", gotOptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncGreeterConfigsAndAuthStopsOnConfigError(t *testing.T) {
|
||||
origGreeterConfigSyncFn := greeterConfigSyncFn
|
||||
origSharedAuthSyncFn := sharedAuthSyncFn
|
||||
t.Cleanup(func() {
|
||||
greeterConfigSyncFn = origGreeterConfigSyncFn
|
||||
sharedAuthSyncFn = origSharedAuthSyncFn
|
||||
})
|
||||
|
||||
greeterConfigSyncFn = func(string, string, func(string), string) error {
|
||||
return errors.New("config sync failed")
|
||||
}
|
||||
|
||||
authCalled := false
|
||||
sharedAuthSyncFn = func(func(string), string, sharedpam.SyncAuthOptions) error {
|
||||
authCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
err := syncGreeterConfigsAndAuth("/tmp/dms", "niri", func(string) {}, sharedpam.SyncAuthOptions{}, nil)
|
||||
if err == nil || err.Error() != "config sync failed" {
|
||||
t.Fatalf("expected config sync error, got %v", err)
|
||||
}
|
||||
if authCalled {
|
||||
t.Fatal("expected auth sync not to run after config sync failure")
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
authCmd.AddCommand(authSyncCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
updateCmd.AddCommand(updateCheckCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
|
||||
rootCmd.AddCommand(authCmd)
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
|
||||
@@ -17,9 +17,11 @@ func init() {
|
||||
runCmd.Flags().MarkHidden("daemon-child")
|
||||
|
||||
greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd)
|
||||
authCmd.AddCommand(authSyncCmd)
|
||||
setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd)
|
||||
pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd)
|
||||
rootCmd.AddCommand(getCommonCommands()...)
|
||||
rootCmd.AddCommand(authCmd)
|
||||
|
||||
rootCmd.SetHelpTemplate(getHelpTemplate())
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/matugen"
|
||||
sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"github.com/sblinch/kdl-go"
|
||||
"github.com/sblinch/kdl-go/document"
|
||||
@@ -25,26 +26,7 @@ var appArmorProfileData []byte
|
||||
|
||||
const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter"
|
||||
|
||||
const (
|
||||
GreeterCacheDir = "/var/cache/dms-greeter"
|
||||
|
||||
GreeterPamManagedBlockStart = "# BEGIN DMS GREETER AUTH (managed by dms greeter sync)"
|
||||
GreeterPamManagedBlockEnd = "# END DMS GREETER AUTH"
|
||||
|
||||
legacyGreeterPamFprintComment = "# DMS greeter fingerprint"
|
||||
legacyGreeterPamU2FComment = "# DMS greeter U2F"
|
||||
)
|
||||
|
||||
// Common PAM auth stack names referenced by greetd across supported distros.
|
||||
var includedPamAuthFiles = []string{
|
||||
"system-auth",
|
||||
"common-auth",
|
||||
"password-auth",
|
||||
"system-login",
|
||||
"system-local-login",
|
||||
"common-auth-pc",
|
||||
"login",
|
||||
}
|
||||
const GreeterCacheDir = "/var/cache/dms-greeter"
|
||||
|
||||
func DetectDMSPath() (string, error) {
|
||||
return config.LocateDMSConfig()
|
||||
@@ -749,49 +731,6 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveGreeterPamManagedBlock strips the DMS managed auth block from /etc/pam.d/greetd
|
||||
func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) error {
|
||||
if IsNixOS() {
|
||||
return nil
|
||||
}
|
||||
const greetdPamPath = "/etc/pam.d/greetd"
|
||||
data, err := os.ReadFile(greetdPamPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
|
||||
}
|
||||
|
||||
stripped, removed := stripManagedGreeterPamBlock(string(data))
|
||||
strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped)
|
||||
if !removed && !removedLegacy {
|
||||
return nil
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "dms-pam-greetd-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp PAM file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.WriteString(strippedAgain); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write temp PAM file: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to write PAM config: %w", err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to set PAM config permissions: %w", err)
|
||||
}
|
||||
logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor.
|
||||
// It is a no-op when AppArmor is not active or the profile does not exist.
|
||||
func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error {
|
||||
@@ -1322,7 +1261,7 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat
|
||||
return nil
|
||||
}
|
||||
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string, forceAuth bool) error {
|
||||
func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPassword string) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||
@@ -1387,10 +1326,6 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo
|
||||
return fmt.Errorf("greeter wallpaper override sync failed: %w", err)
|
||||
}
|
||||
|
||||
if err := syncGreeterPamConfig(homeDir, logFunc, sudoPassword, forceAuth); err != nil {
|
||||
return fmt.Errorf("greeter PAM config sync failed: %w", err)
|
||||
}
|
||||
|
||||
if strings.ToLower(compositor) != "niri" {
|
||||
return nil
|
||||
}
|
||||
@@ -1439,378 +1374,6 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas
|
||||
return nil
|
||||
}
|
||||
|
||||
func pamModuleExists(module string) bool {
|
||||
for _, libDir := range []string{
|
||||
"/usr/lib64/security",
|
||||
"/usr/lib/security",
|
||||
"/lib64/security",
|
||||
"/lib/security",
|
||||
"/lib/x86_64-linux-gnu/security",
|
||||
"/usr/lib/x86_64-linux-gnu/security",
|
||||
"/lib/aarch64-linux-gnu/security",
|
||||
"/usr/lib/aarch64-linux-gnu/security",
|
||||
"/run/current-system/sw/lib64/security",
|
||||
"/run/current-system/sw/lib/security",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stripManagedGreeterPamBlock(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
inManagedBlock := false
|
||||
removed := false
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == GreeterPamManagedBlockStart {
|
||||
inManagedBlock = true
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if trimmed == GreeterPamManagedBlockEnd {
|
||||
inManagedBlock = false
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
if inManagedBlock {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func stripLegacyGreeterPamLines(content string) (string, bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
filtered := make([]string, 0, len(lines))
|
||||
removed := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if strings.HasPrefix(trimmed, legacyGreeterPamFprintComment) || strings.HasPrefix(trimmed, legacyGreeterPamU2FComment) {
|
||||
removed = true
|
||||
if i+1 < len(lines) {
|
||||
nextLine := strings.TrimSpace(lines[i+1])
|
||||
if strings.HasPrefix(nextLine, "auth") &&
|
||||
(strings.Contains(nextLine, "pam_fprintd") || strings.Contains(nextLine, "pam_u2f")) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, lines[i])
|
||||
}
|
||||
|
||||
return strings.Join(filtered, "\n"), removed
|
||||
}
|
||||
|
||||
func insertManagedGreeterPamBlock(content string, blockLines []string, greetdPamPath string) (string, error) {
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "auth") {
|
||||
block := strings.Join(blockLines, "\n")
|
||||
prefix := strings.Join(lines[:i], "\n")
|
||||
suffix := strings.Join(lines[i:], "\n")
|
||||
switch {
|
||||
case prefix == "":
|
||||
return block + "\n" + suffix, nil
|
||||
case suffix == "":
|
||||
return prefix + "\n" + block, nil
|
||||
default:
|
||||
return prefix + "\n" + block + "\n" + suffix, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no auth directive found in %s", greetdPamPath)
|
||||
}
|
||||
|
||||
func PamTextIncludesFile(pamText, filename string) bool {
|
||||
lines := strings.Split(pamText, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, filename) &&
|
||||
(strings.Contains(trimmed, "include") || strings.Contains(trimmed, "substack") || strings.HasPrefix(trimmed, "@include")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func PamFileHasModule(pamFilePath, module string) bool {
|
||||
data, err := os.ReadFile(pamFilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trimmed, module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func DetectIncludedPamModule(pamText, module string) string {
|
||||
for _, includedFile := range includedPamAuthFiles {
|
||||
if PamTextIncludesFile(pamText, includedFile) && PamFileHasModule("/etc/pam.d/"+includedFile, module) {
|
||||
return includedFile
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type greeterAuthSettings struct {
|
||||
GreeterEnableFprint bool `json:"greeterEnableFprint"`
|
||||
GreeterEnableU2f bool `json:"greeterEnableU2f"`
|
||||
}
|
||||
|
||||
func readGreeterAuthSettings(homeDir string) (greeterAuthSettings, error) {
|
||||
settingsPath := filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json")
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return greeterAuthSettings{}, nil
|
||||
}
|
||||
return greeterAuthSettings{}, fmt.Errorf("failed to read settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return greeterAuthSettings{}, nil
|
||||
}
|
||||
var settings greeterAuthSettings
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return greeterAuthSettings{}, fmt.Errorf("failed to parse settings at %s: %w", settingsPath, err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func ReadGreeterAuthToggles(homeDir string) (enableFprint bool, enableU2f bool, err error) {
|
||||
settings, err := readGreeterAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return settings.GreeterEnableFprint, settings.GreeterEnableU2f, nil
|
||||
}
|
||||
|
||||
func hasEnrolledFingerprintOutput(output string) bool {
|
||||
lower := strings.ToLower(output)
|
||||
if strings.Contains(lower, "no fingers enrolled") ||
|
||||
strings.Contains(lower, "no fingerprints enrolled") ||
|
||||
strings.Contains(lower, "no prints enrolled") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "has fingers enrolled") ||
|
||||
strings.Contains(lower, "has fingerprints enrolled") {
|
||||
return true
|
||||
}
|
||||
for _, line := range strings.Split(lower, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "finger:") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") && strings.Contains(trimmed, "finger") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func FingerprintAuthAvailableForUser(username string) bool {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
if !pamModuleExists("pam_fprintd.so") {
|
||||
return false
|
||||
}
|
||||
if _, err := exec.LookPath("fprintd-list"); err != nil {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "fprintd-list", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hasEnrolledFingerprintOutput(string(out))
|
||||
}
|
||||
|
||||
func FingerprintAuthAvailableForCurrentUser() bool {
|
||||
username := strings.TrimSpace(os.Getenv("SUDO_USER"))
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(os.Getenv("USER"))
|
||||
}
|
||||
if username == "" {
|
||||
out, err := exec.Command("id", "-un").Output()
|
||||
if err == nil {
|
||||
username = strings.TrimSpace(string(out))
|
||||
}
|
||||
}
|
||||
return FingerprintAuthAvailableForUser(username)
|
||||
}
|
||||
|
||||
func pamManagerHintForCurrentDistro() string {
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
config, exists := distros.Registry[osInfo.Distribution.ID]
|
||||
if !exists {
|
||||
return "Disable it in your PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
|
||||
switch config.Family {
|
||||
case distros.FamilyFedora:
|
||||
return "Disable it in authselect to force password-only greeter login."
|
||||
case distros.FamilyDebian, distros.FamilyUbuntu:
|
||||
return "Disable it in pam-auth-update to force password-only greeter login."
|
||||
default:
|
||||
return "Disable it in your distro PAM manager (authselect/pam-auth-update) or in the included PAM stack to force password-only greeter login."
|
||||
}
|
||||
}
|
||||
|
||||
func syncGreeterPamConfig(homeDir string, logFunc func(string), sudoPassword string, forceAuth bool) error {
|
||||
var wantFprint, wantU2f bool
|
||||
fprintToggleEnabled := forceAuth
|
||||
u2fToggleEnabled := forceAuth
|
||||
if forceAuth {
|
||||
wantFprint = pamModuleExists("pam_fprintd.so")
|
||||
wantU2f = pamModuleExists("pam_u2f.so")
|
||||
} else {
|
||||
settings, err := readGreeterAuthSettings(homeDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fprintToggleEnabled = settings.GreeterEnableFprint
|
||||
u2fToggleEnabled = settings.GreeterEnableU2f
|
||||
fprintModule := pamModuleExists("pam_fprintd.so")
|
||||
u2fModule := pamModuleExists("pam_u2f.so")
|
||||
wantFprint = settings.GreeterEnableFprint && fprintModule
|
||||
wantU2f = settings.GreeterEnableU2f && u2fModule
|
||||
if settings.GreeterEnableFprint && !fprintModule {
|
||||
logFunc("⚠ Warning: greeter fingerprint toggle is enabled, but pam_fprintd.so was not found.")
|
||||
}
|
||||
if settings.GreeterEnableU2f && !u2fModule {
|
||||
logFunc("⚠ Warning: greeter security key toggle is enabled, but pam_u2f.so was not found.")
|
||||
}
|
||||
}
|
||||
|
||||
if IsNixOS() {
|
||||
logFunc("ℹ NixOS detected: PAM config is managed by NixOS modules. Skipping DMS PAM block write.")
|
||||
logFunc(" Configure fingerprint/U2F auth via your greetd NixOS module options (e.g. security.pam.services.greetd).")
|
||||
return nil
|
||||
}
|
||||
|
||||
greetdPamPath := "/etc/pam.d/greetd"
|
||||
pamData, err := os.ReadFile(greetdPamPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", greetdPamPath, err)
|
||||
}
|
||||
originalContent := string(pamData)
|
||||
content, _ := stripManagedGreeterPamBlock(originalContent)
|
||||
content, _ = stripLegacyGreeterPamLines(content)
|
||||
|
||||
includedFprintFile := DetectIncludedPamModule(content, "pam_fprintd.so")
|
||||
includedU2fFile := DetectIncludedPamModule(content, "pam_u2f.so")
|
||||
fprintAvailableForCurrentUser := FingerprintAuthAvailableForCurrentUser()
|
||||
if wantFprint && includedFprintFile != "" {
|
||||
logFunc("⚠ pam_fprintd already present in included " + includedFprintFile + " (managed by authselect/pam-auth-update). Skipping DMS fprint block to avoid double-fingerprint auth.")
|
||||
wantFprint = false
|
||||
}
|
||||
if wantU2f && includedU2fFile != "" {
|
||||
logFunc("⚠ pam_u2f already present in included " + includedU2fFile + " (managed by authselect/pam-auth-update). Skipping DMS U2F block to avoid double security-key auth.")
|
||||
wantU2f = false
|
||||
}
|
||||
if !wantFprint && includedFprintFile != "" {
|
||||
if fprintToggleEnabled {
|
||||
logFunc("ℹ Fingerprint auth is still enabled via included " + includedFprintFile + ".")
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc(" DMS toggle is enabled, and effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc(" No enrolled fingerprints detected for the current user; password auth remains the effective path.")
|
||||
}
|
||||
} else {
|
||||
if fprintAvailableForCurrentUser {
|
||||
logFunc("ℹ Fingerprint auth is active via included " + includedFprintFile + " while DMS fingerprint toggle is off.")
|
||||
logFunc(" Password login will work but may be delayed while the fingerprint module runs first.")
|
||||
logFunc(" To eliminate the delay, " + pamManagerHintForCurrentDistro())
|
||||
} else {
|
||||
logFunc("ℹ pam_fprintd is present via included " + includedFprintFile + ", but no enrolled fingerprints were detected for the current user.")
|
||||
logFunc(" Password auth remains the effective login path.")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !wantU2f && includedU2fFile != "" {
|
||||
if u2fToggleEnabled {
|
||||
logFunc("ℹ Security-key auth is still enabled via included " + includedU2fFile + ".")
|
||||
logFunc(" DMS toggle is enabled, but effective auth is provided by the included PAM stack.")
|
||||
} else {
|
||||
logFunc("⚠ Security-key auth is active via included " + includedU2fFile + " while DMS security-key toggle is off.")
|
||||
logFunc(" " + pamManagerHintForCurrentDistro())
|
||||
}
|
||||
}
|
||||
|
||||
if wantFprint || wantU2f {
|
||||
blockLines := []string{GreeterPamManagedBlockStart}
|
||||
if wantFprint {
|
||||
blockLines = append(blockLines, "auth sufficient pam_fprintd.so max-tries=1 timeout=5")
|
||||
}
|
||||
if wantU2f {
|
||||
blockLines = append(blockLines, "auth sufficient pam_u2f.so cue nouserok timeout=10")
|
||||
}
|
||||
blockLines = append(blockLines, GreeterPamManagedBlockEnd)
|
||||
|
||||
content, err = insertManagedGreeterPamBlock(content, blockLines, greetdPamPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if content == originalContent {
|
||||
return nil
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "greetd-pam-*.conf")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
tmpFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to install updated PAM config at %s: %w", greetdPamPath, err)
|
||||
}
|
||||
if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil {
|
||||
return fmt.Errorf("failed to set permissions on %s: %w", greetdPamPath, err)
|
||||
}
|
||||
if wantFprint || wantU2f {
|
||||
logFunc("✓ Configured greetd PAM for fingerprint/U2F")
|
||||
} else {
|
||||
logFunc("✓ Cleared DMS-managed greeter PAM auth block")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type niriGreeterSync struct {
|
||||
processed map[string]bool
|
||||
nodes []*document.Node
|
||||
@@ -2484,10 +2047,15 @@ func AutoSetupGreeter(compositor, sudoPassword string, logFunc func(string)) err
|
||||
}
|
||||
|
||||
logFunc("Synchronizing DMS configurations...")
|
||||
if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword, false); err != nil {
|
||||
if err := SyncDMSConfigs(dmsPath, compositor, logFunc, sudoPassword); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: config sync error: %v", err))
|
||||
}
|
||||
|
||||
logFunc("Configuring authentication...")
|
||||
if err := sharedpam.SyncAuthConfig(logFunc, sudoPassword, sharedpam.SyncAuthOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to sync authentication: %w", err)
|
||||
}
|
||||
|
||||
logFunc("Checking for conflicting display managers...")
|
||||
if err := DisableConflictingDisplayManagers(sudoPassword, logFunc); err != nil {
|
||||
logFunc(fmt.Sprintf("⚠ Warning: %v", err))
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTestJSON(t *testing.T, path string, content string) {
|
||||
func writeTestFile(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("failed to create parent dir for %s: %v", path, err)
|
||||
@@ -70,8 +70,8 @@ func TestResolveGreeterThemeSyncState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
homeDir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
|
||||
writeTestJSON(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
|
||||
writeTestFile(t, filepath.Join(homeDir, ".config", "DankMaterialShell", "settings.json"), tt.settingsJSON)
|
||||
writeTestFile(t, filepath.Join(homeDir, ".local", "state", "DankMaterialShell", "session.json"), tt.sessionJSON)
|
||||
|
||||
state, err := resolveGreeterThemeSyncState(homeDir)
|
||||
if err != nil {
|
||||
|
||||
892
core/internal/pam/pam.go
Normal file
892
core/internal/pam/pam.go
Normal file
@@ -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()
|
||||
}
|
||||
671
core/internal/pam/pam_test.go
Normal file
671
core/internal/pam/pam_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -139,6 +139,13 @@ in
|
||||
'';
|
||||
}
|
||||
];
|
||||
# DMS currently relies on /etc/pam.d/login for lock screen password auth on NixOS.
|
||||
# Declare security.pam.services.dankshell only if you want to override that runtime fallback.
|
||||
# U2F and fingerprint are handled separately by DMS — do not add pam_u2f or pam_fprintd here.
|
||||
# security.pam.services.dankshell = {
|
||||
# # Example: add faillock
|
||||
# faillock.enable = true;
|
||||
# };
|
||||
services.greetd = {
|
||||
enable = lib.mkDefault true;
|
||||
settings.default_session.command = lib.mkDefault (lib.getExe greeterScript);
|
||||
|
||||
@@ -1203,13 +1203,23 @@ Singleton {
|
||||
Quickshell.execDetached(["sh", "-lc", script]);
|
||||
}
|
||||
|
||||
function scheduleAuthApply() {
|
||||
if (isGreeterMode)
|
||||
return;
|
||||
Qt.callLater(() => {
|
||||
Processes.settingsRoot = root;
|
||||
Processes.scheduleAuthApply();
|
||||
});
|
||||
}
|
||||
|
||||
readonly property var _hooks: ({
|
||||
"applyStoredTheme": applyStoredTheme,
|
||||
"regenSystemThemes": regenSystemThemes,
|
||||
"updateCompositorLayout": updateCompositorLayout,
|
||||
"applyStoredIconTheme": applyStoredIconTheme,
|
||||
"updateBarConfigs": updateBarConfigs,
|
||||
"updateCompositorCursor": updateCompositorCursor
|
||||
"updateCompositorCursor": updateCompositorCursor,
|
||||
"scheduleAuthApply": scheduleAuthApply
|
||||
})
|
||||
|
||||
function set(key, value) {
|
||||
|
||||
@@ -4,6 +4,8 @@ pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -52,6 +54,14 @@ Singleton {
|
||||
|
||||
readonly property var forcedFprintAvailable: envFlag("DMS_FORCE_FPRINT_AVAILABLE")
|
||||
readonly property var forcedU2fAvailable: envFlag("DMS_FORCE_U2F_AVAILABLE")
|
||||
property bool authApplyRunning: false
|
||||
property bool authApplyQueued: false
|
||||
property bool authApplyRerunRequested: false
|
||||
property bool authApplyTerminalFallbackFromPrecheck: false
|
||||
property string authApplyStdout: ""
|
||||
property string authApplyStderr: ""
|
||||
property string authApplySudoProbeStderr: ""
|
||||
property string authApplyTerminalFallbackStderr: ""
|
||||
|
||||
function detectQtTools() {
|
||||
qtToolsDetectionProcess.running = true;
|
||||
@@ -92,6 +102,50 @@ Singleton {
|
||||
pluginSettingsCheckProcess.running = true;
|
||||
}
|
||||
|
||||
function scheduleAuthApply() {
|
||||
if (!settingsRoot || settingsRoot.isGreeterMode)
|
||||
return;
|
||||
|
||||
authApplyQueued = true;
|
||||
if (authApplyRunning) {
|
||||
authApplyRerunRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
authApplyDebounce.restart();
|
||||
}
|
||||
|
||||
function beginAuthApply() {
|
||||
if (!authApplyQueued || authApplyRunning || !settingsRoot || settingsRoot.isGreeterMode)
|
||||
return;
|
||||
|
||||
authApplyQueued = false;
|
||||
authApplyRerunRequested = false;
|
||||
authApplyStdout = "";
|
||||
authApplyStderr = "";
|
||||
authApplySudoProbeStderr = "";
|
||||
authApplyTerminalFallbackStderr = "";
|
||||
authApplyTerminalFallbackFromPrecheck = false;
|
||||
authApplyRunning = true;
|
||||
authApplySudoProbeProcess.running = true;
|
||||
}
|
||||
|
||||
function launchAuthApplyTerminalFallback(fromPrecheck, details) {
|
||||
authApplyTerminalFallbackFromPrecheck = fromPrecheck;
|
||||
if (details && details !== "")
|
||||
ToastService.showInfo(I18n.tr("Authentication changes need sudo. Opening terminal so you can use password or fingerprint."), details, "", "auth-sync");
|
||||
authApplyTerminalFallbackStderr = "";
|
||||
authApplyTerminalFallbackProcess.running = true;
|
||||
}
|
||||
|
||||
function finishAuthApply() {
|
||||
const shouldRerun = authApplyQueued || authApplyRerunRequested;
|
||||
authApplyRunning = false;
|
||||
authApplyRerunRequested = false;
|
||||
if (shouldRerun)
|
||||
authApplyDebounce.restart();
|
||||
}
|
||||
|
||||
function stripPamComment(line) {
|
||||
if (!line)
|
||||
return "";
|
||||
@@ -417,6 +471,91 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: authApplyDebounce
|
||||
interval: 300
|
||||
repeat: false
|
||||
onTriggered: root.beginAuthApply()
|
||||
}
|
||||
|
||||
property var authApplyProcess: Process {
|
||||
command: ["dms", "auth", "sync", "--yes"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.authApplyStdout = text || ""
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: root.authApplyStderr = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
const out = (root.authApplyStdout || "").trim();
|
||||
const err = (root.authApplyStderr || "").trim();
|
||||
|
||||
if (exitCode === 0) {
|
||||
let details = out;
|
||||
if (err !== "")
|
||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||
ToastService.showInfo(I18n.tr("Authentication changes applied."), details, "", "auth-sync");
|
||||
root.detectAuthCapabilities();
|
||||
root.finishAuthApply();
|
||||
return;
|
||||
}
|
||||
|
||||
let details = "";
|
||||
if (out !== "")
|
||||
details = out;
|
||||
if (err !== "")
|
||||
details = details !== "" ? details + "\n\nstderr:\n" + err : "stderr:\n" + err;
|
||||
ToastService.showWarning(I18n.tr("Background authentication sync failed. Trying terminal mode."), details, "", "auth-sync");
|
||||
root.launchAuthApplyTerminalFallback(false, "");
|
||||
}
|
||||
}
|
||||
|
||||
property var authApplySudoProbeProcess: Process {
|
||||
command: ["sudo", "-n", "true"]
|
||||
running: false
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: root.authApplySudoProbeStderr = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
const err = (root.authApplySudoProbeStderr || "").trim();
|
||||
if (exitCode === 0) {
|
||||
ToastService.showInfo(I18n.tr("Applying authentication changes…"), "", "", "auth-sync");
|
||||
root.authApplyProcess.running = true;
|
||||
return;
|
||||
}
|
||||
|
||||
root.launchAuthApplyTerminalFallback(true, err);
|
||||
}
|
||||
}
|
||||
|
||||
property var authApplyTerminalFallbackProcess: Process {
|
||||
command: ["dms", "auth", "sync", "--terminal", "--yes"]
|
||||
running: false
|
||||
|
||||
stderr: StdioCollector {
|
||||
onStreamFinished: root.authApplyTerminalFallbackStderr = text || ""
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode === 0) {
|
||||
const message = root.authApplyTerminalFallbackFromPrecheck
|
||||
? I18n.tr("Terminal opened. Complete authentication setup there; it will close automatically when done.")
|
||||
: I18n.tr("Terminal fallback opened. Complete authentication setup there; it will close automatically when done.");
|
||||
ToastService.showInfo(message, "", "", "auth-sync");
|
||||
} else {
|
||||
let details = (root.authApplyTerminalFallbackStderr || "").trim();
|
||||
ToastService.showError(I18n.tr("Terminal fallback failed. Install a supported terminal emulator or run 'dms auth sync' manually.") + " (exit " + exitCode + ")", details, "", "auth-sync");
|
||||
}
|
||||
root.finishAuthApply();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: greetdPamWatcher
|
||||
path: "/etc/pam.d/greetd"
|
||||
|
||||
@@ -169,8 +169,8 @@ var SPEC = {
|
||||
lockDateFormat: { def: "" },
|
||||
greeterRememberLastSession: { def: true },
|
||||
greeterRememberLastUser: { def: true },
|
||||
greeterEnableFprint: { def: false },
|
||||
greeterEnableU2f: { def: false },
|
||||
greeterEnableFprint: { def: false, onChange: "scheduleAuthApply" },
|
||||
greeterEnableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
greeterWallpaperPath: { def: "" },
|
||||
greeterUse24HourClock: { def: true },
|
||||
greeterShowSeconds: { def: false },
|
||||
@@ -353,7 +353,7 @@ var SPEC = {
|
||||
lockScreenShowMediaPlayer: { def: true },
|
||||
lockScreenPowerOffMonitorsOnLock: { def: false },
|
||||
lockAtStartup: { def: false },
|
||||
enableFprint: { def: false },
|
||||
enableFprint: { def: false, onChange: "scheduleAuthApply" },
|
||||
maxFprintTries: { def: 15 },
|
||||
fprintdAvailable: { def: false, persist: false },
|
||||
lockFingerprintCanEnable: { def: false, persist: false },
|
||||
@@ -363,7 +363,7 @@ var SPEC = {
|
||||
greeterFingerprintReady: { def: false, persist: false },
|
||||
greeterFingerprintReason: { def: "probe_failed", persist: false },
|
||||
greeterFingerprintSource: { def: "none", persist: false },
|
||||
enableU2f: { def: false },
|
||||
enableU2f: { def: false, onChange: "scheduleAuthApply" },
|
||||
u2fMode: { def: "or" },
|
||||
u2fAvailable: { def: false, persist: false },
|
||||
lockU2fCanEnable: { def: false, persist: false },
|
||||
|
||||
@@ -52,6 +52,12 @@ Item {
|
||||
return I18n.tr("Touch your security key...");
|
||||
if (pam.lockMessage && pam.lockMessage.length > 0)
|
||||
return pam.lockMessage;
|
||||
if (root.pamState === "error")
|
||||
return I18n.tr("Authentication error - try again");
|
||||
if (root.pamState === "max")
|
||||
return I18n.tr("Too many attempts - locked out");
|
||||
if (root.pamState === "fail")
|
||||
return I18n.tr("Incorrect password - try again");
|
||||
if (pam.fprintState === "error") {
|
||||
const detail = (pam.fprint.message || "").trim();
|
||||
return detail.length > 0 ? I18n.tr("Fingerprint error: %1").arg(detail) : I18n.tr("Fingerprint error");
|
||||
@@ -60,12 +66,6 @@ Item {
|
||||
return I18n.tr("Maximum fingerprint attempts reached. Please use password.");
|
||||
if (pam.fprintState === "fail")
|
||||
return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries);
|
||||
if (root.pamState === "error")
|
||||
return I18n.tr("Authentication error - try again");
|
||||
if (root.pamState === "max")
|
||||
return I18n.tr("Too many attempts - locked out");
|
||||
if (root.pamState === "fail")
|
||||
return I18n.tr("Incorrect password - try again");
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ Scope {
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: loginConfigWatcher
|
||||
id: nixosMarker
|
||||
|
||||
path: "/etc/pam.d/login"
|
||||
path: "/etc/NIXOS"
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ Scope {
|
||||
id: passwd
|
||||
|
||||
config: dankshellConfigWatcher.loaded ? "dankshell" : "login"
|
||||
configDirectory: (dankshellConfigWatcher.loaded || loginConfigWatcher.loaded) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
|
||||
configDirectory: (dankshellConfigWatcher.loaded || nixosMarker.loaded) ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
|
||||
|
||||
onMessageChanged: {
|
||||
if (message.startsWith("The account is locked")) {
|
||||
|
||||
@@ -36,7 +36,7 @@ Item {
|
||||
|
||||
switch (reason) {
|
||||
case "ready":
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Run Sync to apply. Fingerprint-only login may not unlock GNOME Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled.");
|
||||
return SettingsData.greeterEnableFprint ? I18n.tr("Authentication changes apply automatically. Fingerprint-only login may not unlock Keyring.") : I18n.tr("Only affects DMS-managed PAM. If greetd already includes pam_fprintd, fingerprint stays enabled.");
|
||||
case "missing_enrollment":
|
||||
if (SettingsData.greeterEnableFprint)
|
||||
return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints and run Sync.");
|
||||
@@ -60,7 +60,7 @@ Item {
|
||||
|
||||
switch (reason) {
|
||||
case "ready":
|
||||
return SettingsData.greeterEnableU2f ? I18n.tr("Run Sync to apply.") : I18n.tr("Available.");
|
||||
return SettingsData.greeterEnableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Available.");
|
||||
case "missing_key_registration":
|
||||
if (SettingsData.greeterEnableU2f)
|
||||
return I18n.tr("Enabled, but no registered security key was found yet. Register a key and run Sync.");
|
||||
@@ -448,7 +448,7 @@ Item {
|
||||
settingKey: "greeterStatus"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, PAM config, and wallpaper to the login screen in one step. Must run Sync to apply changes.")
|
||||
text: I18n.tr("Check sync status on demand. Sync copies your theme, settings, and wallpaper configuration to the login screen. Authentication changes apply automatically.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
@@ -525,7 +525,7 @@ Item {
|
||||
settingKey: "greeterAuth"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Run Sync to apply and configure PAM.")
|
||||
text: I18n.tr("Enable fingerprint or security key for DMS Greeter. Authentication changes apply automatically.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
@@ -754,7 +754,7 @@ Item {
|
||||
settingKey: "greeterDeps"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("DMS greeter needs: greetd, dms-greeter. Fingerprint: fprintd, pam_fprintd. Security keys: pam_u2f. Add your user to the greeter group. Sync checks sudo first and opens a terminal when interactive authentication is required.")
|
||||
text: I18n.tr("DMS greeter needs: greetd, dms-greeter. Fingerprint: fprintd, pam_fprintd. Security keys: pam_u2f. Add your user to the greeter group. Authentication changes apply automatically and may open a terminal when sudo authentication is required.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
|
||||
@@ -15,10 +15,10 @@ Item {
|
||||
function lockFingerprintDescription() {
|
||||
switch (SettingsData.lockFingerprintReason) {
|
||||
case "ready":
|
||||
return I18n.tr("Use fingerprint authentication for the lock screen.");
|
||||
return SettingsData.enableFprint ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use fingerprint authentication for the lock screen.");
|
||||
case "missing_enrollment":
|
||||
if (SettingsData.enableFprint)
|
||||
return I18n.tr("Enabled, but no prints are enrolled yet. Enroll fingerprints to use it.");
|
||||
return I18n.tr("Enabled, but no prints are enrolled yet. Authentication changes apply automatically once you enroll fingerprints.");
|
||||
return I18n.tr("Fingerprint reader detected, but no prints are enrolled yet. You can enable this now and enroll later.");
|
||||
case "missing_reader":
|
||||
return SettingsData.enableFprint ? I18n.tr("Enabled, but no fingerprint reader was detected.") : I18n.tr("No fingerprint reader detected.");
|
||||
@@ -32,10 +32,10 @@ Item {
|
||||
function lockU2fDescription() {
|
||||
switch (SettingsData.lockU2fReason) {
|
||||
case "ready":
|
||||
return I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting");
|
||||
return SettingsData.enableU2f ? I18n.tr("Authentication changes apply automatically.") : I18n.tr("Use a security key for lock screen authentication.", "lock screen U2F security key setting");
|
||||
case "missing_key_registration":
|
||||
if (SettingsData.enableU2f)
|
||||
return I18n.tr("Enabled, but no registered security key was found yet. Register a key or update your U2F config.");
|
||||
return I18n.tr("Enabled, but no registered security key was found yet. Authentication changes apply automatically once your key is registered or your U2F config is updated.");
|
||||
return I18n.tr("Security-key support was detected, but no registered key was found yet. You can enable this now and register one later.");
|
||||
case "missing_pam_support":
|
||||
return I18n.tr("Not available — install or configure pam_u2f.");
|
||||
@@ -213,6 +213,15 @@ Item {
|
||||
onToggled: checked => SettingsData.set("lockAtStartup", checked)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Lock screen authentication changes apply automatically and may open a terminal when sudo authentication is required.")
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
width: parent.width
|
||||
wrapMode: Text.Wrap
|
||||
topPadding: Theme.spacingS
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "enableFprint"
|
||||
tags: ["lock", "screen", "fingerprint", "authentication", "biometric", "fprint"]
|
||||
|
||||
Reference in New Issue
Block a user