mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-04 12:52:06 -04:00
- 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.
893 lines
27 KiB
Go
893 lines
27 KiB
Go
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()
|
||
}
|