mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
963 lines
28 KiB
Go
963 lines
28 KiB
Go
package pam
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||
)
|
||
|
||
const (
|
||
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"
|
||
|
||
DankshellPamPath = "/etc/pam.d/dankshell"
|
||
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).
|
||
// /usr/local/etc/pam.d is OpenPAM's ports dir on FreeBSD (openpam_configure).
|
||
var lockscreenPamBaseDirs = []string{"/etc/pam.d", "/usr/lib/pam.d", "/usr/share/pam.d", "/usr/local/etc/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-*), FreeBSD
|
||
// (system holds both stanzas, included by login).
|
||
var lockscreenPamSharedIncludePairs = []struct {
|
||
auth string
|
||
account string
|
||
}{
|
||
{auth: "common-auth", account: "common-account"},
|
||
{auth: "base-auth", account: "base-account"},
|
||
{auth: "system", account: "system"},
|
||
}
|
||
|
||
var includedPamAuthFiles = []string{
|
||
"system-auth",
|
||
"common-auth",
|
||
"password-auth",
|
||
"system-login",
|
||
"system-local-login",
|
||
"common-auth-pc",
|
||
"login",
|
||
"system",
|
||
}
|
||
|
||
type AuthSettings struct {
|
||
EnableFprint bool `json:"enableFprint"`
|
||
EnableU2f bool `json:"enableU2f"`
|
||
}
|
||
|
||
type SyncAuthOptions struct {
|
||
HomeDir string
|
||
}
|
||
|
||
type syncDeps struct {
|
||
pamDir 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
|
||
}
|
||
|
||
type lockscreenPamIncludeDirective struct {
|
||
target string
|
||
filterType string
|
||
}
|
||
|
||
type lockscreenPamResolver struct {
|
||
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",
|
||
dankshellPath: DankshellPamPath,
|
||
dankshellU2fPath: DankshellU2FPamPath,
|
||
isNixOS: IsNixOS,
|
||
readFile: os.ReadFile,
|
||
stat: os.Stat,
|
||
createTemp: os.CreateTemp,
|
||
removeFile: os.Remove,
|
||
runSudoCmd: func(password, command string, args ...string) error {
|
||
return privesc.Run(context.Background(), password, append([]string{command}, args...)...)
|
||
},
|
||
}
|
||
}
|
||
|
||
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 SyncAuthConfig(logFunc func(string), sudoPassword string, options SyncAuthOptions) error {
|
||
return syncAuthConfigWithDeps(logFunc, sudoPassword, options, 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
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
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 (r lockscreenPamResolver) resolveService(serviceName string, filterType string, stack []string) ([]string, error) {
|
||
path, err := r.locate(serviceName)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to read PAM file %s: %w", serviceName, 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 resolvedLinesHaveAuth(lines []string) bool {
|
||
for _, line := range lines {
|
||
if pamDirectiveType(strings.TrimSpace(line)) == "auth" {
|
||
return true
|
||
}
|
||
}
|
||
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
|
||
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
|
||
}
|
||
|
||
var lockscreenPamCandidateServices = []string{
|
||
"login",
|
||
"system-auth",
|
||
"system-login",
|
||
"system-local-login",
|
||
"common-auth",
|
||
"base-auth",
|
||
}
|
||
|
||
type LockscreenPamServiceInfo struct {
|
||
Name string `json:"name"`
|
||
Dir string `json:"dir"`
|
||
Path string `json:"path"`
|
||
HasAuth bool `json:"hasAuth"`
|
||
InlineFingerprint bool `json:"inlineFingerprint"`
|
||
InlineU2f bool `json:"inlineU2f"`
|
||
}
|
||
|
||
type LockscreenPamValidation struct {
|
||
Valid bool `json:"valid"`
|
||
Path string `json:"path"`
|
||
HasAuth bool `json:"hasAuth"`
|
||
InlineFingerprint bool `json:"inlineFingerprint"`
|
||
InlineU2f bool `json:"inlineU2f"`
|
||
MissingModules []string `json:"missingModules"`
|
||
Warnings []string `json:"warnings"`
|
||
Errors []string `json:"errors"`
|
||
}
|
||
|
||
type lockscreenPamValidateDeps struct {
|
||
baseDirs []string
|
||
readFile func(string) ([]byte, error)
|
||
stat func(string) (os.FileInfo, error)
|
||
pamModuleExists func(string) bool
|
||
}
|
||
|
||
func defaultValidateDeps() lockscreenPamValidateDeps {
|
||
return lockscreenPamValidateDeps{
|
||
baseDirs: lockscreenPamBaseDirs,
|
||
readFile: os.ReadFile,
|
||
stat: os.Stat,
|
||
pamModuleExists: pamModuleExists,
|
||
}
|
||
}
|
||
|
||
// lockscreenPamAnalysis is a non-destructive walk of a PAM service. Unlike
|
||
// resolveService it detects (rather than strips) pam_fprintd/pam_u2f and
|
||
// records unknown directives instead of hard-failing on them.
|
||
type lockscreenPamAnalysis struct {
|
||
lines []string
|
||
hasAuth bool
|
||
inlineFingerprint bool
|
||
inlineU2f bool
|
||
modules []string
|
||
authModules []string
|
||
unknownDirectives []string
|
||
err error
|
||
}
|
||
|
||
func (r lockscreenPamResolver) analyzePath(path string) lockscreenPamAnalysis {
|
||
var acc lockscreenPamAnalysis
|
||
if err := r.analyzeInto(filepath.Clean(path), "", nil, &acc); err != nil {
|
||
acc.err = err
|
||
}
|
||
return acc
|
||
}
|
||
|
||
func (r lockscreenPamResolver) analyzeInto(path string, filterType string, stack []string, acc *lockscreenPamAnalysis) error {
|
||
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 fmt.Errorf("cyclic PAM include detected: %s", strings.Join(display, " -> "))
|
||
}
|
||
}
|
||
|
||
data, err := r.readFile(path)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read PAM file %s: %w", path, err)
|
||
}
|
||
|
||
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, "#") {
|
||
continue
|
||
}
|
||
|
||
if include, ok := parseLockscreenPamIncludeDirective(trimmed, filterType); ok {
|
||
lineType := pamDirectiveType(trimmed)
|
||
if filterType != "" && lineType != "" && lineType != filterType {
|
||
continue
|
||
}
|
||
nestedPath := include.target
|
||
if filepath.IsAbs(nestedPath) {
|
||
nestedPath = filepath.Clean(nestedPath)
|
||
} else {
|
||
located, err := r.locate(include.target)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read PAM file %s: %w", include.target, err)
|
||
}
|
||
nestedPath = located
|
||
}
|
||
if err := r.analyzeInto(nestedPath, include.filterType, append(stack, path), acc); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
|
||
lineType := pamDirectiveType(trimmed)
|
||
if lineType == "" {
|
||
acc.unknownDirectives = append(acc.unknownDirectives, trimmed)
|
||
continue
|
||
}
|
||
if filterType != "" && lineType != filterType {
|
||
continue
|
||
}
|
||
|
||
acc.lines = append(acc.lines, rawLine)
|
||
if lineType == "auth" {
|
||
acc.hasAuth = true
|
||
}
|
||
|
||
foundModule := false
|
||
for _, field := range strings.Fields(trimmed) {
|
||
if strings.HasPrefix(field, "#") {
|
||
break
|
||
}
|
||
if strings.Contains(field, "pam_fprintd") {
|
||
acc.inlineFingerprint = true
|
||
}
|
||
if strings.Contains(field, "pam_u2f") {
|
||
acc.inlineU2f = true
|
||
}
|
||
if !foundModule && strings.HasSuffix(field, ".so") {
|
||
acc.modules = append(acc.modules, field)
|
||
if lineType == "auth" {
|
||
acc.authModules = append(acc.authModules, field)
|
||
}
|
||
foundModule = true
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Earlier base dir wins per name (libpam precedence).
|
||
func ListLockscreenPamServices() []LockscreenPamServiceInfo {
|
||
return listLockscreenPamServices(lockscreenPamBaseDirs, os.ReadFile)
|
||
}
|
||
|
||
func listLockscreenPamServices(baseDirs []string, readFile func(string) ([]byte, error)) []LockscreenPamServiceInfo {
|
||
resolver := lockscreenPamResolver{baseDirs: baseDirs, readFile: readFile}
|
||
out := make([]LockscreenPamServiceInfo, 0, len(lockscreenPamCandidateServices))
|
||
for _, name := range lockscreenPamCandidateServices {
|
||
path, err := resolver.locate(name)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
info := LockscreenPamServiceInfo{
|
||
Name: name,
|
||
Dir: filepath.Dir(path),
|
||
Path: path,
|
||
}
|
||
if analysis := resolver.analyzePath(path); analysis.err == nil {
|
||
info.HasAuth = analysis.hasAuth
|
||
info.InlineFingerprint = analysis.inlineFingerprint
|
||
info.InlineU2f = analysis.inlineU2f
|
||
}
|
||
out = append(out, info)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func ValidateLockscreenPamService(name string) LockscreenPamValidation {
|
||
return validateLockscreenPam(name, "", defaultValidateDeps())
|
||
}
|
||
|
||
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{},
|
||
Warnings: []string{},
|
||
Errors: []string{},
|
||
}
|
||
resolver := lockscreenPamResolver{baseDirs: deps.baseDirs, readFile: deps.readFile}
|
||
|
||
var analysis lockscreenPamAnalysis
|
||
if path != "" {
|
||
result.Path = path
|
||
analysis = resolver.analyzePath(path)
|
||
} else {
|
||
located, err := resolver.locate(serviceName)
|
||
if err != nil {
|
||
result.Errors = append(result.Errors, fmt.Sprintf("PAM service %q not found: %v", serviceName, err))
|
||
return result
|
||
}
|
||
result.Path = located
|
||
analysis = resolver.analyzePath(located)
|
||
}
|
||
|
||
if analysis.err != nil {
|
||
result.Errors = append(result.Errors, analysis.err.Error())
|
||
return result
|
||
}
|
||
|
||
result.HasAuth = analysis.hasAuth
|
||
result.InlineFingerprint = analysis.inlineFingerprint
|
||
result.InlineU2f = analysis.inlineU2f
|
||
|
||
if !analysis.hasAuth {
|
||
result.Errors = append(result.Errors, "no auth directives found after include resolution")
|
||
}
|
||
|
||
for _, directive := range analysis.unknownDirectives {
|
||
result.Warnings = append(result.Warnings, "unsupported PAM directive (libpam may still handle it at runtime): "+directive)
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
for _, ref := range analysis.modules {
|
||
name := filepath.Base(ref)
|
||
if seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
if moduleReferenceExists(ref, deps) {
|
||
continue
|
||
}
|
||
result.MissingModules = append(result.MissingModules, name)
|
||
result.Warnings = append(result.Warnings, "referenced PAM module not found: "+name)
|
||
}
|
||
|
||
if analysis.inlineFingerprint {
|
||
result.Warnings = append(result.Warnings, "pam_fprintd is present in the resolved stack; may double-prompt with DMS's separate fingerprint context")
|
||
}
|
||
if analysis.inlineU2f {
|
||
result.Warnings = append(result.Warnings, "pam_u2f is present in the resolved stack; may double-prompt with DMS's separate U2F context")
|
||
}
|
||
|
||
result.Valid = len(result.Errors) == 0
|
||
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)
|
||
return err == nil
|
||
}
|
||
return deps.pamModuleExists(ref)
|
||
}
|
||
|
||
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")
|
||
b.WriteString(LockscreenU2FPamManagedBlockStart + "\n")
|
||
b.WriteString("auth required pam_u2f.so cue nouserok timeout=10\n")
|
||
b.WriteString("account required pam_permit.so\n")
|
||
b.WriteString("password required pam_deny.so\n")
|
||
b.WriteString("session required pam_permit.so\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 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
|
||
}
|
||
|
||
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([]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)
|
||
}
|
||
|
||
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 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 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",
|
||
"/usr/local/lib/security",
|
||
"/usr/local/lib",
|
||
"/usr/lib",
|
||
} {
|
||
if _, err := os.Stat(filepath.Join(libDir, module)); err == nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|