mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18:28 -04:00
feat(auth): expand shared PAM support for custom policies
- Add support for DMS and System managed factor policies - Add validated dedicated security-key PAM / U2F Key sources - Restore U2F OR/AND flows Related: #2874 Port 1.5
This commit is contained in:
@@ -91,21 +91,30 @@ var authListServicesCmd = &cobra.Command{
|
||||
|
||||
var authValidateCmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate a PAM service file for use as the DMS lock-screen password stack",
|
||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password stack. Exits 1 when the file is not usable.",
|
||||
Short: "Validate a PAM service file for use by the DMS lock screen",
|
||||
Long: "Validate one PAM service (by --service NAME or --path /abs/file) for use as the DMS lock-screen password or dedicated U2F stack. Exits 1 when the file is not usable.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
path, _ := cmd.Flags().GetString("path")
|
||||
service, _ := cmd.Flags().GetString("service")
|
||||
purpose, _ := cmd.Flags().GetString("purpose")
|
||||
asJSON, _ := cmd.Flags().GetBool("json")
|
||||
|
||||
if (path == "") == (service == "") {
|
||||
log.Fatalf("Error: exactly one of --path or --service is required")
|
||||
}
|
||||
|
||||
if purpose != "password" && purpose != "u2f" {
|
||||
log.Fatalf("Error: --purpose must be password or u2f")
|
||||
}
|
||||
|
||||
var result sharedpam.LockscreenPamValidation
|
||||
switch {
|
||||
case service != "":
|
||||
result = sharedpam.ValidateLockscreenPamService(service)
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamService(service)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamService(service)
|
||||
}
|
||||
case !filepath.IsAbs(path):
|
||||
result = sharedpam.LockscreenPamValidation{
|
||||
Path: path,
|
||||
@@ -114,7 +123,11 @@ var authValidateCmd = &cobra.Command{
|
||||
Errors: []string{"--path must be an absolute file path"},
|
||||
}
|
||||
default:
|
||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||
if purpose == "u2f" {
|
||||
result = sharedpam.ValidateLockscreenU2fPamPath(path)
|
||||
} else {
|
||||
result = sharedpam.ValidateLockscreenPamPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
if asJSON {
|
||||
@@ -159,6 +172,7 @@ func init() {
|
||||
|
||||
authValidateCmd.Flags().String("path", "", "Absolute path to a PAM service file to validate")
|
||||
authValidateCmd.Flags().String("service", "", "Name of a PAM service to resolve across the system PAM dirs")
|
||||
authValidateCmd.Flags().String("purpose", "password", "Validation purpose: password or u2f")
|
||||
authValidateCmd.Flags().Bool("json", false, "Output as JSON")
|
||||
}
|
||||
|
||||
|
||||
@@ -646,6 +646,7 @@ type lockscreenPamAnalysis struct {
|
||||
inlineFingerprint bool
|
||||
inlineU2f bool
|
||||
modules []string
|
||||
authModules []string
|
||||
unknownDirectives []string
|
||||
err error
|
||||
}
|
||||
@@ -730,6 +731,9 @@ func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack
|
||||
}
|
||||
if !foundModule && strings.HasSuffix(field, ".so") {
|
||||
acc.modules = append(acc.modules, field)
|
||||
if lineType == "auth" {
|
||||
acc.authModules = append(acc.authModules, field)
|
||||
}
|
||||
foundModule = true
|
||||
}
|
||||
}
|
||||
@@ -774,6 +778,14 @@ func ValidateLockscreenPamPath(path string) LockscreenPamValidation {
|
||||
return validateLockscreenPam("", path, defaultValidateDeps())
|
||||
}
|
||||
|
||||
func ValidateLockscreenU2fPamService(name string) LockscreenPamValidation {
|
||||
return validateLockscreenU2fPam(name, "", defaultValidateDeps())
|
||||
}
|
||||
|
||||
func ValidateLockscreenU2fPamPath(path string) LockscreenPamValidation {
|
||||
return validateLockscreenU2fPam("", path, defaultValidateDeps())
|
||||
}
|
||||
|
||||
func validateLockscreenPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||
result := LockscreenPamValidation{
|
||||
MissingModules: []string{},
|
||||
@@ -838,6 +850,64 @@ func validateLockscreenPam(serviceName string, path string, deps lockscreenPamVa
|
||||
return result
|
||||
}
|
||||
|
||||
func validateLockscreenU2fPam(serviceName string, path string, deps lockscreenPamValidateDeps) LockscreenPamValidation {
|
||||
result := validateLockscreenPam(serviceName, path, deps)
|
||||
if result.Path == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
||||
analysis := resolver.analyzePath(result.Path)
|
||||
if analysis.err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
filteredWarnings := result.Warnings[:0]
|
||||
for _, warning := range result.Warnings {
|
||||
if strings.Contains(warning, "pam_u2f is present") && strings.Contains(warning, "double-prompt") {
|
||||
continue
|
||||
}
|
||||
filteredWarnings = append(filteredWarnings, warning)
|
||||
}
|
||||
result.Warnings = filteredWarnings
|
||||
|
||||
hasU2fAuth := false
|
||||
unsafeModules := []string{}
|
||||
unsafeSeen := map[string]bool{}
|
||||
for _, ref := range analysis.authModules {
|
||||
name := filepath.Base(ref)
|
||||
if name == "pam_u2f.so" {
|
||||
hasU2fAuth = true
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "pam_env.so", "pam_faildelay.so", "pam_nologin.so":
|
||||
continue
|
||||
default:
|
||||
if !unsafeSeen[name] {
|
||||
unsafeSeen[name] = true
|
||||
unsafeModules = append(unsafeModules, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasU2fAuth {
|
||||
result.Errors = append(result.Errors, "no pam_u2f auth directive found; select a dedicated security-key PAM service")
|
||||
}
|
||||
for _, name := range unsafeModules {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("additional auth module %s is not allowed in a dedicated security-key PAM service", name))
|
||||
}
|
||||
for _, name := range result.MissingModules {
|
||||
if strings.Contains(name, "pam_u2f") {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s is not installed or its configured path is unavailable", name))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = len(result.Errors) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
func moduleReferenceExists(ref string, deps lockscreenPamValidateDeps) bool {
|
||||
if filepath.IsAbs(ref) {
|
||||
_, err := deps.stat(ref)
|
||||
@@ -895,7 +965,7 @@ func buildManagedLockscreenU2FPamContent() 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.")
|
||||
logFunc("ℹ NixOS detected. DMS does not write /etc/pam.d/dankshell; the lock screen uses a sanitized password-only service in the user state directory unless you select a custom PAM source.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -611,8 +611,8 @@ func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
|
||||
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 len(logs) == 0 || !strings.Contains(logs[0], "NixOS detected") || !strings.Contains(logs[0], "sanitized password-only service") {
|
||||
t.Fatalf("expected NixOS informational log describing the user-state fallback, 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)
|
||||
@@ -994,6 +994,84 @@ func TestValidateLockscreenPam(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLockscreenU2fPam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("accepts a dedicated U2F stack with custom options", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue authfile=/etc/u2f-mappings\naccount required pam_permit.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid dedicated U2F stack, got %+v", result)
|
||||
}
|
||||
if !result.InlineU2f {
|
||||
t.Fatalf("expected inline U2F detection, got %+v", result)
|
||||
}
|
||||
if containsSubstr(result.Warnings, "double-prompt") {
|
||||
t.Fatalf("dedicated U2F validation should not warn about its expected U2F module: %v", result.Warnings)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a primary login stack that also prompts for a password", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.availableModules["pam_u2f.so"] = true
|
||||
env.writePamFile(t, "login", "#%PAM-1.0\nauth required pam_unix.so\nauth required pam_u2f.so cue\naccount required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("login", "", env.validateDeps())
|
||||
if result.Valid {
|
||||
t.Fatalf("expected mixed password/U2F stack to be rejected, got %+v", result)
|
||||
}
|
||||
if !containsSubstr(result.Errors, "pam_unix.so") || !containsSubstr(result.Errors, "dedicated security-key") {
|
||||
t.Fatalf("expected actionable mixed-stack error, got %v", result.Errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a stack without pam_u2f", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_unix.so"] = true
|
||||
env.writePamFile(t, "password-only", "#%PAM-1.0\nauth required pam_unix.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("password-only", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f") {
|
||||
t.Fatalf("expected missing-U2F error, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not accept a similarly named module as pam_u2f", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.availableModules["pam_u2f_helper.so"] = true
|
||||
env.writePamFile(t, "not-u2f", "#%PAM-1.0\nauth required pam_u2f_helper.so\n")
|
||||
|
||||
result := validateLockscreenU2fPam("not-u2f", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "no pam_u2f auth directive") {
|
||||
t.Fatalf("expected exact pam_u2f module validation, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a missing pam_u2f module", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newPamTestEnv(t)
|
||||
env.writePamFile(t, "dankshell-u2f", "#%PAM-1.0\nauth required pam_u2f.so cue\n")
|
||||
|
||||
result := validateLockscreenU2fPam("dankshell-u2f", "", env.validateDeps())
|
||||
if result.Valid || !containsSubstr(result.Errors, "pam_u2f.so is not installed") {
|
||||
t.Fatalf("expected missing-module error, got %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func containsSubstr(items []string, substr string) bool {
|
||||
for _, item := range items {
|
||||
if strings.Contains(item, substr) {
|
||||
|
||||
@@ -23,3 +23,51 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) {
|
||||
t.Fatalf("passwordField should handle physical key text manually instead of relying on a text input control")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenAuthenticationCardOwnsFactorControls(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../quickshell/Modules/Settings/LockScreenTab.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen settings QML: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
authCard := strings.Index(content, `title: I18n.tr("Lock Screen Authentication")`)
|
||||
behaviorCard := strings.Index(content, `title: I18n.tr("Lock Screen behaviour")`)
|
||||
fingerprintToggle := strings.Index(content, `settingKey: "enableFprint"`)
|
||||
u2fToggle := strings.Index(content, `settingKey: "enableU2f"`)
|
||||
u2fSource := strings.Index(content, `settingKey: "lockU2fPamPath"`)
|
||||
if authCard < 0 || behaviorCard < 0 || fingerprintToggle < 0 || u2fToggle < 0 || u2fSource < 0 {
|
||||
t.Fatalf("expected authentication card, factor toggles, and U2F source setting")
|
||||
}
|
||||
for name, position := range map[string]int{
|
||||
"fingerprint toggle": fingerprintToggle,
|
||||
"U2F toggle": u2fToggle,
|
||||
"U2F source": u2fSource,
|
||||
} {
|
||||
if position < authCard || position > behaviorCard {
|
||||
t.Fatalf("%s must remain in the authentication card", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockScreenPamSupportsManagedAndSystemPolicies(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../quickshell/Modules/Lock/Pam.qml")
|
||||
if err != nil {
|
||||
t.Fatalf("read lock screen PAM QML: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, required := range []string{
|
||||
"SettingsData.lockPamExternallyManaged",
|
||||
"SettingsData.lockU2fPamPath",
|
||||
"customU2fPamActive",
|
||||
"u2fSuppressedByPrimaryPam",
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Fatalf("lock screen PAM must contain %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "runningFromNixStore || resolveUserPam.running") {
|
||||
t.Fatalf("DMS-managed policy must generate the sanitized user PAM stack on Nix-store installs")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user