1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

auth: add some more intelligent pam config resolution for lock screen

and greeter
related #2789

port: 1.5
This commit is contained in:
bbedward
2026-07-09 15:06:49 -04:00
parent c0eeed4e89
commit 9cf2ca7196
7 changed files with 474 additions and 56 deletions
+170 -46
View File
@@ -12,6 +12,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
const (
@@ -32,6 +33,32 @@ const (
DankshellU2FPamPath = "/etc/pam.d/dankshell-u2f"
)
// lockscreenPamEntryCandidates are the /etc/pam.d entry-point services tried in
// order. "login" is first so systems that ship it behave exactly as before; the
// rest cover distros (or minimal installs) with no /etc/pam.d/login.
// lockscreenPamBaseDirs mirrors libpam's search order: /etc overrides, then the
// vendor dir (/usr/lib) and the stateless-distro default (/usr/share).
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d"}
// Standalone auth+account services, most universal first. login exists almost
// everywhere (util-linux); system-* cover Fedora/Arch/Gentoo/SUSE-Leap.
var lockscreenPamEntryCandidates = []string{
"login",
"system-auth",
"system-login",
"system-local-login",
}
// Fallback for distros with no standalone login service, only shared building
// blocks: openSUSE/Debian (common-*), Alpine/postmarketOS (base-*).
var lockscreenPamSharedIncludePairs = []struct {
auth string
account string
}{
{auth: "common-auth", account: "common-account"},
{auth: "base-auth", account: "base-account"},
}
var includedPamAuthFiles = []string{
"system-auth",
"common-auth",
@@ -75,10 +102,50 @@ type lockscreenPamIncludeDirective struct {
}
type lockscreenPamResolver struct {
pamDir string
baseDirs []string
readFile func(string) ([]byte, error)
}
// locate resolves a service/include name across baseDirs (libpam vendor-dir
// fallback). Targets may not escape the base dirs.
func (r lockscreenPamResolver) locate(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("empty PAM include target")
}
if filepath.IsAbs(target) {
clean := filepath.Clean(target)
for _, dir := range r.baseDirs {
if filepath.Dir(clean) == filepath.Clean(dir) {
return clean, nil
}
}
return "", fmt.Errorf("unsupported PAM include outside PAM dirs: %s", target)
}
clean := filepath.Clean(target)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid PAM include target: %s", target)
}
var firstErr error
for _, dir := range r.baseDirs {
path := filepath.Join(filepath.Clean(dir), clean)
if _, err := r.readFile(path); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
return path, nil
}
if firstErr == nil {
firstErr = os.ErrNotExist
}
return "", firstErr
}
func defaultSyncDeps() syncDeps {
return syncDeps{
pamDir: "/etc/pam.d",
@@ -375,32 +442,10 @@ func parseLockscreenPamIncludeDirective(trimmed string, inheritedFilter string)
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)
path, err := r.locate(serviceName)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, err)
}
for _, seen := range stack {
@@ -458,29 +503,73 @@ func (r lockscreenPamResolver) resolveService(serviceName string, filterType str
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 {
func resolvedLinesHaveAuth(lines []string) bool {
for _, line := range lines {
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
hasAuth = true
break
return true
}
}
if !hasAuth {
return "", fmt.Errorf("no auth directives remained after filtering %s", filepath.Join(pamDir, "login"))
return false
}
func (r lockscreenPamResolver) resolveLines() ([]string, error) {
var lastErr error
// Standalone login-like services: an existing one is authoritative.
for _, service := range lockscreenPamEntryCandidates {
if _, err := r.locate(service); err != nil {
lastErr = err
continue
}
lines, err := r.resolveService(service, "", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(lines) {
return nil, fmt.Errorf("no auth directives remained after filtering %s", service)
}
return lines, nil
}
// Shared building blocks for distros without a login service (openSUSE,
// Alpine): stitch the auth stanza to the account stanza when present.
for _, pair := range lockscreenPamSharedIncludePairs {
if _, err := r.locate(pair.auth); err != nil {
lastErr = err
continue
}
authLines, err := r.resolveService(pair.auth, "auth", nil)
if err != nil {
return nil, err
}
if !resolvedLinesHaveAuth(authLines) {
lastErr = fmt.Errorf("no auth directives remained after filtering %s", pair.auth)
continue
}
resolved := append([]string{}, authLines...)
if _, err := r.locate(pair.account); err == nil {
acctLines, err := r.resolveService(pair.account, "account", nil)
if err != nil {
return nil, err
}
resolved = append(resolved, acctLines...)
}
return resolved, nil
}
if lastErr != nil {
return nil, fmt.Errorf("no usable PAM auth service found: %w", lastErr)
}
return nil, fmt.Errorf("no usable PAM auth service found")
}
func buildManagedLockscreenPamContent(baseDirs []string, readFile func(string) ([]byte, error)) (string, error) {
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
resolvedLines, err := resolver.resolveLines()
if err != nil {
return "", err
}
var b strings.Builder
@@ -494,6 +583,41 @@ func buildManagedLockscreenPamContent(pamDir string, readFile func(string) ([]by
return b.String(), nil
}
const UserLockscreenPamService = "dankshell"
func UserLockscreenPamDir() string {
return filepath.Join(utils.XDGStateHome(), "DankMaterialShell", "pam")
}
// WriteUserLockscreenPamConfig resolves the distro's real auth stack into a
// self-contained lock-screen service under the user state dir, unprivileged
// (reads world-readable PAM dirs, writes the user's own state dir). Rewrites
// only on change to avoid inotify churn. Returns the written path.
func WriteUserLockscreenPamConfig(logFunc func(string)) (string, error) {
content, err := buildManagedLockscreenPamContent(lockscreenPamBaseDirs, os.ReadFile)
if err != nil {
return "", fmt.Errorf("failed to resolve system PAM auth stack: %w", err)
}
dir := UserLockscreenPamDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("failed to create %s: %w", dir, err)
}
path := filepath.Join(dir, UserLockscreenPamService)
if existing, err := os.ReadFile(path); err == nil && string(existing) == content {
return path, nil
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("failed to write %s: %w", path, err)
}
if logFunc != nil {
logFunc("✓ Wrote lock-screen PAM config " + path)
}
return path, nil
}
func buildManagedLockscreenU2FPamContent() string {
var b strings.Builder
b.WriteString("#%PAM-1.0\n")
@@ -522,7 +646,7 @@ func syncLockscreenPamConfigWithDeps(logFunc func(string), sudoPassword string,
return fmt.Errorf("failed to read %s: %w", deps.dankshellPath, err)
}
content, err := buildManagedLockscreenPamContent(deps.pamDir, deps.readFile)
content, err := buildManagedLockscreenPamContent([]string{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)
}
+212 -1
View File
@@ -246,6 +246,31 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
"session optional pam_lastlog.so silent": 1,
},
},
{
name: "falls back to system-auth when login is absent",
files: map[string]string{
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so try_first_pass nullok\naccount required pam_unix.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass nullok",
"account required pam_unix.so",
},
},
{
name: "no usable service when none of the candidates exist",
files: map[string]string{
"other": "#%PAM-1.0\nauth required pam_deny.so\n",
},
wantErr: "no usable PAM auth service found",
},
{
name: "existing login with bad include is authoritative and does not fall back",
files: map[string]string{
"login": "#%PAM-1.0\nauth include missing-auth\n",
"system-auth": "#%PAM-1.0\nauth sufficient pam_unix.so\naccount required pam_unix.so\n",
},
wantErr: "failed to read PAM file",
},
{
name: "missing include fails",
files: map[string]string{
@@ -281,7 +306,7 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
env.writePamFile(t, name, content)
}
content, err := buildManagedLockscreenPamContent(env.pamDir, os.ReadFile)
content, err := buildManagedLockscreenPamContent([]string{env.pamDir}, os.ReadFile)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
@@ -314,6 +339,192 @@ func TestBuildManagedLockscreenPamContent(t *testing.T) {
}
}
// Real /etc/pam.d layouts of the non-Arch-shaped distros (#2789).
func TestBuildManagedLockscreenPamContent_DistroShapes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
files map[string]string
wantContains []string
wantNotContains []string
}{
{
// openSUSE: `include` (not @include), common-auth symlinked to
// common-auth-pc (here just a plain file), bracketed securetty
// control, keyring modules, pam_sss.
name: "openSUSE include + common-auth + bracket control",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"auth [user_unknown=ignore success=ok ignore=ignore auth_err=die default=bad] pam_securetty.so\n" +
"auth include common-auth\n" +
"account include common-account\n" +
"session required pam_loginuid.so\n" +
"session include common-session\n",
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\naccount sufficient pam_localuser.so\n",
"common-session": "session optional pam_gnome_keyring.so auto_start\n",
},
wantContains: []string{
"pam_securetty.so",
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
},
},
{
name: "openSUSE without login stitches common-auth and common-account",
files: map[string]string{
"common-auth": "auth required pam_env.so\n" +
"auth optional pam_gnome_keyring.so\n" +
"auth sufficient pam_unix.so try_first_pass\n" +
"auth required pam_sss.so use_first_pass\n",
"common-account": "account required pam_unix.so try_first_pass\n" +
"account sufficient pam_localuser.so\n" +
"account required pam_sss.so use_first_pass\n",
},
wantContains: []string{
"auth sufficient pam_unix.so try_first_pass",
"auth required pam_sss.so use_first_pass",
"account required pam_unix.so try_first_pass",
"account required pam_sss.so use_first_pass",
},
},
{
name: "openSUSE with only common-auth resolves auth-only",
files: map[string]string{
"common-auth": "auth sufficient pam_unix.so try_first_pass\nauth required pam_deny.so\n",
},
wantContains: []string{"auth sufficient pam_unix.so try_first_pass"},
wantNotContains: []string{
"account",
},
},
{
name: "Debian @include common-auth and common-account",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth requisite pam_nologin.so\n" +
"@include common-auth\n" +
"@include common-account\n" +
"session required pam_loginuid.so\n" +
"@include common-session\n",
"common-auth": "auth\t[success=1 default=ignore]\tpam_unix.so nullok\n" +
"auth\trequisite\t\t\tpam_deny.so\n" +
"auth\trequired\t\t\tpam_permit.so\n",
"common-account": "account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so\naccount\trequisite\t\t\tpam_deny.so\n",
"common-session": "session\t[default=1]\t\t\tpam_permit.so\n",
},
wantContains: []string{
"auth\t[success=1 default=ignore]\tpam_unix.so nullok",
"account\t[success=1 new_authtok_reqd=done default=ignore]\tpam_unix.so",
},
},
{
name: "NixOS flat login with absolute paths and dash directives",
files: map[string]string{
"login": "#%PAM-1.0\n" +
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass\n" +
"auth sufficient /nix/store/abc-pam-u2f/lib/security/pam_u2f.so\n" +
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so\n" +
"account required /nix/store/abc-pam/lib/security/pam_unix.so\n" +
"-session optional /nix/store/abc-sd/lib/security/pam_systemd.so\n",
},
wantContains: []string{
"auth required /nix/store/abc-pam/lib/security/pam_unix.so likeauth nullok try_first_pass",
"-auth optional /nix/store/abc-kbd/lib/security/pam_gnome_keyring.so",
"account required /nix/store/abc-pam/lib/security/pam_unix.so",
},
wantNotContains: []string{"pam_u2f"},
},
{
name: "Gentoo deep include chain login->system-local-login->system-login->system-auth",
files: map[string]string{
"login": "#%PAM-1.0\nauth\tinclude\t\tsystem-local-login\naccount\tinclude\t\tsystem-local-login\n",
"system-local-login": "auth\trequired\tpam_group.so\nauth\tinclude\t\tsystem-login\naccount\tinclude\t\tsystem-login\n",
"system-login": "auth\tinclude\t\tsystem-auth\naccount\tinclude\t\tsystem-auth\n",
"system-auth": "auth\trequired\tpam_env.so\nauth\tsufficient\tpam_unix.so try_first_pass likeauth nullok\nauth\trequired\tpam_deny.so\naccount\trequired\tpam_unix.so\n",
},
wantContains: []string{
"auth\trequired\tpam_group.so",
"auth\tsufficient\tpam_unix.so try_first_pass likeauth nullok",
"account\trequired\tpam_unix.so",
},
},
{
name: "no login, entry falls through to system-auth",
files: map[string]string{
"system-auth": "#%PAM-1.0\n" +
"auth required pam_env.so\n" +
"auth sufficient pam_unix.so nullok\n" +
"auth sufficient pam_sss.so forward_pass\n" +
"auth required pam_deny.so\n" +
"account required pam_unix.so\n" +
"account [default=bad success=ok user_unknown=ignore] pam_sss.so\n",
},
wantContains: []string{
"auth sufficient pam_unix.so nullok",
"auth sufficient pam_sss.so forward_pass",
"account required pam_unix.so",
},
},
}
for _, tt := range tests {
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([]string{env.pamDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
if !strings.Contains(content, "auth") {
t.Fatalf("resolved content has no auth line:\n%s", content)
}
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)
}
}
})
}
}
func TestBuildManagedLockscreenPamContent_VendorDirFallback(t *testing.T) {
t.Parallel()
// Stateless/vendored-PAM systems (Clear Linux) ship the stack in
// /usr/lib|share/pam.d with /etc/pam.d empty; includes resolve in that dir.
etcDir := t.TempDir()
vendorDir := t.TempDir()
writeTestFile(t, filepath.Join(vendorDir, "login"), "#%PAM-1.0\nauth include system-auth\naccount include system-auth\n")
writeTestFile(t, filepath.Join(vendorDir, "system-auth"), "auth sufficient pam_unix.so nullok\naccount required pam_unix.so\n")
content, err := buildManagedLockscreenPamContent([]string{etcDir, vendorDir}, os.ReadFile)
if err != nil {
t.Fatalf("buildManagedLockscreenPamContent returned error: %v", err)
}
for _, want := range []string{"auth sufficient pam_unix.so nullok", "account required pam_unix.so"} {
if !strings.Contains(content, want) {
t.Errorf("missing %q in output:\n%s", want, content)
}
}
}
func TestSyncLockscreenPamConfigWithDeps(t *testing.T) {
t.Parallel()