mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 19:24:01 -04:00
Add opt-in administrator lock restrictions (#3869)
(cherry picked from commit 7781c207f1)
This commit is contained in:
@@ -9,12 +9,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/duxlabs-dev/dux-go/shellapp"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/config"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/gpu"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/shellembed"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var shellApp = shellapp.New(shellapp.Config{
|
||||
@@ -121,6 +123,11 @@ func dmsExtraEnv(string) []string {
|
||||
env = append(env, "MALLOC_CONF=thp:never,narenas:4,dirty_decay_ms:3000")
|
||||
}
|
||||
env = append(env, gpu.EGLVendorEnv()...)
|
||||
policyEnv, err := clipolicy.ShellEnv(afero.NewOsFs())
|
||||
if err != nil {
|
||||
log.Errorf("CLI policy unreadable, enforcing every shell switch: %v", err)
|
||||
}
|
||||
env = append(env, policyEnv...)
|
||||
// systemd user services miss the login shell PATH, so tools in ~/.local/bin
|
||||
// (herdr, pip/cargo installs) would be invisible to the shell and its children.
|
||||
for _, entry := range utils.EnvWithUserBinPath(nil) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
@@ -14,11 +15,28 @@ const (
|
||||
)
|
||||
|
||||
type File struct {
|
||||
PolicyVersion int `json:"policy_version"`
|
||||
ImmutableSystem *bool `json:"immutable_system"`
|
||||
BlockedCommands *[]string `json:"blocked_commands"`
|
||||
Message *string `json:"message"`
|
||||
DisableRegistries *bool `json:"disable_registries"`
|
||||
PolicyVersion int `json:"policy_version"`
|
||||
ImmutableSystem *bool `json:"immutable_system"`
|
||||
BlockedCommands *[]string `json:"blocked_commands"`
|
||||
Message *string `json:"message"`
|
||||
DisableRegistries *bool `json:"disable_registries"`
|
||||
DisableLockIPCUnlock *bool `json:"disable_lock_ipc_unlock"`
|
||||
DisableLogindUnlock *bool `json:"disable_logind_unlock"`
|
||||
DisableCustomLockPAM *bool `json:"disable_custom_lock_pam"`
|
||||
DisableUserPlugins *bool `json:"disable_user_plugins"`
|
||||
DisableSettingsIPCSet *bool `json:"disable_settings_ipc_set"`
|
||||
}
|
||||
|
||||
// shellSwitches reach the shell as DMS_POLICY_<KEY> in its launch environment.
|
||||
var shellSwitches = []struct {
|
||||
key string
|
||||
value func(*File) *bool
|
||||
}{
|
||||
{"disable_lock_ipc_unlock", func(p *File) *bool { return p.DisableLockIPCUnlock }},
|
||||
{"disable_logind_unlock", func(p *File) *bool { return p.DisableLogindUnlock }},
|
||||
{"disable_custom_lock_pam", func(p *File) *bool { return p.DisableCustomLockPAM }},
|
||||
{"disable_user_plugins", func(p *File) *bool { return p.DisableUserPlugins }},
|
||||
{"disable_settings_ipc_set", func(p *File) *bool { return p.DisableSettingsIPCSet }},
|
||||
}
|
||||
|
||||
// LoadFile returns nil without an error when the file does not exist.
|
||||
@@ -39,18 +57,65 @@ func LoadFile(fs afero.Fs, path string) (*File, error) {
|
||||
return &policy, nil
|
||||
}
|
||||
|
||||
// RegistriesDisabled reports whether the policy turns off the plugin and theme
|
||||
// registries. An explicit value in AdminPath overrides PackagedPath.
|
||||
func RegistriesDisabled(fs afero.Fs) (bool, error) {
|
||||
disabled := false
|
||||
// load returns the policy files that exist, PackagedPath first.
|
||||
func load(fs afero.Fs) ([]*File, error) {
|
||||
var policies []*File
|
||||
for _, path := range []string{PackagedPath, AdminPath} {
|
||||
policy, err := LoadFile(fs, path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
if policy != nil && policy.DisableRegistries != nil {
|
||||
disabled = *policy.DisableRegistries
|
||||
if policy != nil {
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
return disabled, nil
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// flag reports a boolean key, false unless set. An explicit value in AdminPath
|
||||
// overrides PackagedPath.
|
||||
func flag(policies []*File, value func(*File) *bool) bool {
|
||||
set := false
|
||||
for _, policy := range policies {
|
||||
if v := value(policy); v != nil {
|
||||
set = *v
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// RegistriesDisabled reports whether the policy turns off the plugin and theme
|
||||
// registries. An explicit value in AdminPath overrides PackagedPath.
|
||||
func RegistriesDisabled(fs afero.Fs) (bool, error) {
|
||||
policies, err := load(fs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return flag(policies, func(p *File) *bool { return p.DisableRegistries }), nil
|
||||
}
|
||||
|
||||
// LogindUnlockDisabled reports whether a logind Unlock must leave a lock in
|
||||
// place. An explicit value in AdminPath overrides PackagedPath.
|
||||
func LogindUnlockDisabled(fs afero.Fs) (bool, error) {
|
||||
policies, err := load(fs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return flag(policies, func(p *File) *bool { return p.DisableLogindUnlock }), nil
|
||||
}
|
||||
|
||||
// ShellEnv returns every shell switch as DMS_POLICY_<KEY>=1 or 0, so a value
|
||||
// inherited by the shell never counts. A policy file that fails to load turns
|
||||
// every switch on, and its error is returned with them.
|
||||
func ShellEnv(fs afero.Fs) ([]string, error) {
|
||||
policies, err := load(fs)
|
||||
env := make([]string, 0, len(shellSwitches))
|
||||
for _, s := range shellSwitches {
|
||||
value := "0"
|
||||
if err != nil || flag(policies, s.value) {
|
||||
value = "1"
|
||||
}
|
||||
env = append(env, "DMS_POLICY_"+strings.ToUpper(s.key)+"="+value)
|
||||
}
|
||||
return env, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package clipolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
@@ -45,3 +46,103 @@ func TestRegistriesDisabled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogindUnlockDisabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
packaged string
|
||||
admin string
|
||||
want bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "no policy files"},
|
||||
{name: "admin policy", admin: `{"disable_logind_unlock": true}`, want: true},
|
||||
{name: "admin policy overrides packaged", packaged: `{"disable_logind_unlock": true}`, admin: `{"disable_logind_unlock": false}`},
|
||||
{name: "another key", admin: `{"disable_lock_ipc_unlock": true}`},
|
||||
{name: "malformed admin policy", admin: "{not json", wantErr: "failed to parse " + AdminPath},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
if tt.packaged != "" {
|
||||
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(tt.packaged), 0o644))
|
||||
}
|
||||
if tt.admin != "" {
|
||||
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(tt.admin), 0o644))
|
||||
}
|
||||
|
||||
disabled, err := LogindUnlockDisabled(fs)
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, disabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var shellSwitchEnv = map[string]string{
|
||||
"disable_lock_ipc_unlock": "DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK",
|
||||
"disable_logind_unlock": "DMS_POLICY_DISABLE_LOGIND_UNLOCK",
|
||||
"disable_custom_lock_pam": "DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM",
|
||||
"disable_user_plugins": "DMS_POLICY_DISABLE_USER_PLUGINS",
|
||||
"disable_settings_ipc_set": "DMS_POLICY_DISABLE_SETTINGS_IPC_SET",
|
||||
}
|
||||
|
||||
func TestShellEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
packaged string
|
||||
admin string
|
||||
on bool
|
||||
}{
|
||||
{name: "no policy files"},
|
||||
{name: "packaged policy", packaged: `{"KEY": true}`, on: true},
|
||||
{name: "admin policy", admin: `{"KEY": true}`, on: true},
|
||||
{name: "admin policy overrides packaged", packaged: `{"KEY": true}`, admin: `{"KEY": false}`},
|
||||
{name: "admin policy without the key keeps packaged", packaged: `{"KEY": true}`, admin: `{"immutable_system": true}`, on: true},
|
||||
}
|
||||
|
||||
for key, name := range shellSwitchEnv {
|
||||
for _, tt := range tests {
|
||||
t.Run(key+"/"+tt.name, func(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
if tt.packaged != "" {
|
||||
require.NoError(t, afero.WriteFile(fs, PackagedPath, []byte(strings.ReplaceAll(tt.packaged, "KEY", key)), 0o644))
|
||||
}
|
||||
if tt.admin != "" {
|
||||
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte(strings.ReplaceAll(tt.admin, "KEY", key)), 0o644))
|
||||
}
|
||||
|
||||
var want []string
|
||||
for _, other := range shellSwitchEnv {
|
||||
if other == name && tt.on {
|
||||
want = append(want, other+"=1")
|
||||
} else {
|
||||
want = append(want, other+"=0")
|
||||
}
|
||||
}
|
||||
|
||||
env, err := ShellEnv(fs)
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, want, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellEnvMalformedPolicyTurnsEverySwitchOn(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
require.NoError(t, afero.WriteFile(fs, AdminPath, []byte("{not json"), 0o644))
|
||||
|
||||
var want []string
|
||||
for _, name := range shellSwitchEnv {
|
||||
want = append(want, name+"=1")
|
||||
}
|
||||
|
||||
env, err := ShellEnv(fs)
|
||||
require.ErrorContains(t, err, "failed to parse "+AdminPath)
|
||||
assert.ElementsMatch(t, want, env)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func NewManager() (*Manager, error) {
|
||||
func NewManager(keepLockOnUnlock bool) (*Manager, error) {
|
||||
conn, err := dbus.SystemBus()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||
@@ -25,6 +25,8 @@ func NewManager() (*Manager, error) {
|
||||
conn: conn,
|
||||
dirty: make(chan struct{}, 1),
|
||||
signals: make(chan *dbus.Signal, 256),
|
||||
|
||||
keepLockOnUnlock: keepLockOnUnlock,
|
||||
}
|
||||
m.sleepInhibitorEnabled.Store(true)
|
||||
|
||||
|
||||
@@ -30,13 +30,22 @@ func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
||||
}
|
||||
|
||||
case dbusSessionInterface + ".Unlock":
|
||||
lockedHint := m.sessionLockedHint()
|
||||
m.stateMutex.Lock()
|
||||
m.state.Locked = false
|
||||
m.state.LockedHint = lockedHint
|
||||
m.stateMutex.Unlock()
|
||||
m.notifySubscribers()
|
||||
m.prelockedReady.Store(false)
|
||||
if m.keepLockOnUnlock {
|
||||
// Only the locker clears LockedHint, so take both from logind, and
|
||||
// keep the cached state when logind cannot be read.
|
||||
if m.sessionObj != nil && m.updateSessionState() == nil && !m.snapshotState().Locked {
|
||||
m.prelockedReady.Store(false)
|
||||
}
|
||||
m.notifySubscribers()
|
||||
} else {
|
||||
lockedHint := m.sessionLockedHint()
|
||||
m.stateMutex.Lock()
|
||||
m.state.Locked = false
|
||||
m.state.LockedHint = lockedHint
|
||||
m.stateMutex.Unlock()
|
||||
m.notifySubscribers()
|
||||
m.prelockedReady.Store(false)
|
||||
}
|
||||
|
||||
// Cancel the lock timer if it's still running
|
||||
m.lockTimerMu.Lock()
|
||||
|
||||
@@ -37,42 +37,58 @@ func TestManager_HandleDBusSignal_Lock(t *testing.T) {
|
||||
|
||||
func TestManager_HandleDBusSignal_Unlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lockedHint bool
|
||||
name string
|
||||
keepLock bool
|
||||
call *dbus.Call // logind's GetAll answer; nil means no session object
|
||||
wantLocked bool
|
||||
wantLockedHint bool
|
||||
}{
|
||||
{name: "logind cleared LockedHint", lockedHint: false},
|
||||
{name: "logind still reports LockedHint", lockedHint: true},
|
||||
{name: "logind cleared LockedHint", call: lockedHintCall(false)},
|
||||
{name: "logind still reports LockedHint", call: lockedHintCall(true), wantLockedHint: true},
|
||||
{name: "logind unreachable", call: &dbus.Call{Err: assert.AnError}},
|
||||
{name: "policy, LockedHint still set", keepLock: true, call: lockedHintCall(true), wantLocked: true, wantLockedHint: true},
|
||||
{name: "policy, LockedHint cleared", keepLock: true, call: lockedHintCall(false)},
|
||||
{name: "policy, logind unreachable", keepLock: true, call: &dbus.Call{Err: assert.AnError}, wantLocked: true, wantLockedHint: true},
|
||||
{name: "policy, no session object", keepLock: true, wantLocked: true, wantLockedHint: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sessionObj := mockdbus.NewMockBusObject(t)
|
||||
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(&dbus.Call{
|
||||
Body: []any{map[string]dbus.Variant{"LockedHint": dbus.MakeVariant(tt.lockedHint)}},
|
||||
})
|
||||
|
||||
manager := &Manager{
|
||||
state: &SessionState{
|
||||
Locked: true,
|
||||
LockedHint: true,
|
||||
},
|
||||
stateMutex: sync.RWMutex{},
|
||||
dirty: make(chan struct{}, 1),
|
||||
sessionObj: sessionObj,
|
||||
stateMutex: sync.RWMutex{},
|
||||
dirty: make(chan struct{}, 1),
|
||||
keepLockOnUnlock: tt.keepLock,
|
||||
}
|
||||
if tt.call != nil {
|
||||
sessionObj := mockdbus.NewMockBusObject(t)
|
||||
sessionObj.EXPECT().CallWithContext(mock.Anything, "org.freedesktop.DBus.Properties.GetAll", dbus.Flags(0), "org.freedesktop.login1.Session").Return(tt.call)
|
||||
manager.sessionObj = sessionObj
|
||||
}
|
||||
manager.markLockerReady()
|
||||
|
||||
manager.handleDBusSignal(&dbus.Signal{
|
||||
Name: "org.freedesktop.login1.Session.Unlock",
|
||||
})
|
||||
|
||||
assert.Equal(t, tt.wantLocked, manager.prelockedReady.Load())
|
||||
manager.stateMutex.RLock()
|
||||
defer manager.stateMutex.RUnlock()
|
||||
assert.False(t, manager.state.Locked)
|
||||
assert.Equal(t, tt.lockedHint, manager.state.LockedHint)
|
||||
assert.Equal(t, tt.wantLocked, manager.state.Locked)
|
||||
assert.Equal(t, tt.wantLockedHint, manager.state.LockedHint)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lockedHintCall(lockedHint bool) *dbus.Call {
|
||||
return &dbus.Call{Body: []any{map[string]dbus.Variant{
|
||||
"LockedHint": dbus.MakeVariant(lockedHint),
|
||||
}}}
|
||||
}
|
||||
|
||||
func TestManager_HandleDBusSignal_PrepareForSleep(t *testing.T) {
|
||||
t.Run("preparing for sleep - true", func(t *testing.T) {
|
||||
manager := &Manager{
|
||||
|
||||
@@ -69,4 +69,5 @@ type Manager struct {
|
||||
lockTimer *time.Timer
|
||||
sleepInhibitorEnabled atomic.Bool
|
||||
fallbackDelay time.Duration
|
||||
keepLockOnUnlock bool
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/duxlabs-dev/dux-go/ipc"
|
||||
"github.com/duxlabs-dev/dux-go/syncmap"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/clipolicy"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/matugen"
|
||||
@@ -37,6 +38,7 @@ import (
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/server/wlroutput"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/systemd"
|
||||
"github.com/duxlabs-dev/dux-shell/core/internal/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const APIVersion = 38
|
||||
@@ -108,7 +110,12 @@ func InitializeNetworkManager() error {
|
||||
}
|
||||
|
||||
func InitializeLoginctlManager() error {
|
||||
manager, err := loginctl.NewManager()
|
||||
keepLockOnUnlock, err := clipolicy.LogindUnlockDisabled(afero.NewOsFs())
|
||||
if err != nil {
|
||||
log.Errorf("CLI policy unreadable, keeping locks through logind Unlock: %v", err)
|
||||
keepLockOnUnlock = true
|
||||
}
|
||||
manager, err := loginctl.NewManager(keepLockOnUnlock)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to initialize loginctl manager: %v", err)
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
|
||||
// cli-policy.json switches, set by `dms run` on each launch (core/internal/clipolicy).
|
||||
Singleton {
|
||||
readonly property bool disableLockIpcUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOCK_IPC_UNLOCK") === "1"
|
||||
readonly property bool disableLogindUnlock: Quickshell.env("DMS_POLICY_DISABLE_LOGIND_UNLOCK") === "1"
|
||||
readonly property bool disableCustomLockPam: Quickshell.env("DMS_POLICY_DISABLE_CUSTOM_LOCK_PAM") === "1"
|
||||
readonly property bool disableUserPlugins: Quickshell.env("DMS_POLICY_DISABLE_USER_PLUGINS") === "1"
|
||||
readonly property bool disableSettingsIpcSet: Quickshell.env("DMS_POLICY_DISABLE_SETTINGS_IPC_SET") === "1"
|
||||
}
|
||||
@@ -1102,6 +1102,8 @@ Item {
|
||||
}
|
||||
|
||||
function set(key: string, value: string): string {
|
||||
if (AdminPolicy.disableSettingsIpcSet)
|
||||
return "SETTINGS_SET_DISABLED";
|
||||
if (!(key in SettingsData)) {
|
||||
log.warn("Cannot set property, not found:", key);
|
||||
return "SETTINGS_INVALID_KEY";
|
||||
|
||||
@@ -220,6 +220,8 @@ Scope {
|
||||
|
||||
function onSessionUnlocked() {
|
||||
customLockerSpawned = false;
|
||||
if (AdminPolicy.disableLogindUnlock)
|
||||
return;
|
||||
if (pendingLock) {
|
||||
pendingLock = false;
|
||||
lockInitiatedLocally = false;
|
||||
@@ -412,14 +414,20 @@ Scope {
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (AdminPolicy.disableLockIpcUnlock)
|
||||
return;
|
||||
root.unlock();
|
||||
}
|
||||
|
||||
function forceReset() {
|
||||
if (AdminPolicy.disableLockIpcUnlock)
|
||||
return;
|
||||
root.forceReset();
|
||||
}
|
||||
|
||||
function demo() {
|
||||
if (AdminPolicy.disableLockIpcUnlock)
|
||||
return;
|
||||
demoWindow.showDemo();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import qs.Services
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
readonly property var log: Log.scoped("Pam")
|
||||
|
||||
property bool lockSecured: false
|
||||
property bool unlockInProgress: false
|
||||
|
||||
@@ -96,6 +98,8 @@ Scope {
|
||||
if (!root.u2fSuppressedByPrimaryPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && u2f.available) {
|
||||
u2f.startForSecondFactor();
|
||||
} else {
|
||||
if (AdminPolicy.disableCustomLockPam && SettingsData.enableU2f && SettingsData.u2fMode === "and" && !u2fConfigWatcher.loaded)
|
||||
log.warn("/etc/pam.d/dankshell-u2f is missing, unlocking without the security key");
|
||||
completeUnlock();
|
||||
}
|
||||
}
|
||||
@@ -113,8 +117,8 @@ Scope {
|
||||
}
|
||||
|
||||
readonly property bool customPamActive: SettingsData.lockPamPath !== "" && customPamWatcher.loaded
|
||||
readonly property bool fprintSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint)
|
||||
readonly property bool u2fSuppressedByPrimaryPam: SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f)
|
||||
readonly property bool fprintSuppressedByPrimaryPam: !AdminPolicy.disableCustomLockPam && (SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineFprint))
|
||||
readonly property bool u2fSuppressedByPrimaryPam: !AdminPolicy.disableCustomLockPam && (SettingsData.lockPamExternallyManaged || (customPamActive && SettingsData.lockPamInlineU2f))
|
||||
readonly property bool customU2fPamActive: SettingsData.lockU2fPamPath !== "" && customU2fPamWatcher.loaded
|
||||
|
||||
FileView {
|
||||
@@ -139,6 +143,13 @@ Scope {
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: fprintConfigWatcher
|
||||
|
||||
path: AdminPolicy.disableCustomLockPam ? "/etc/pam.d/dankshell-fprint" : ""
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: customU2fPamWatcher
|
||||
|
||||
@@ -169,7 +180,7 @@ Scope {
|
||||
}
|
||||
|
||||
function ensureUserPamConfig(): void {
|
||||
if (SettingsData.lockPamExternallyManaged || resolveUserPam.running)
|
||||
if (AdminPolicy.disableCustomLockPam || SettingsData.lockPamExternallyManaged || resolveUserPam.running)
|
||||
return;
|
||||
resolveUserPam.running = true;
|
||||
}
|
||||
@@ -180,6 +191,8 @@ Scope {
|
||||
id: passwd
|
||||
|
||||
config: {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return dankshellConfigWatcher.loaded ? "dankshell" : "login";
|
||||
if (root.customPamActive)
|
||||
return SettingsData.lockPamPath.slice(SettingsData.lockPamPath.lastIndexOf("/") + 1);
|
||||
if (SettingsData.lockPamExternallyManaged)
|
||||
@@ -191,6 +204,8 @@ Scope {
|
||||
return "login";
|
||||
}
|
||||
configDirectory: {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return "/etc/pam.d";
|
||||
if (root.customPamActive) {
|
||||
const idx = SettingsData.lockPamPath.lastIndexOf("/");
|
||||
return idx > 0 ? SettingsData.lockPamPath.slice(0, idx) : "/";
|
||||
@@ -299,14 +314,15 @@ Scope {
|
||||
PamContext {
|
||||
id: fprint
|
||||
|
||||
readonly property bool available: SettingsData.lockFingerprintReady
|
||||
readonly property bool available: SettingsData.lockFingerprintReady && (!AdminPolicy.disableCustomLockPam || fprintConfigWatcher.loaded)
|
||||
property int tries: 0
|
||||
property int errorTries: 0
|
||||
property double attemptStartedAt: 0
|
||||
property bool completedDuringStart: false
|
||||
readonly property int maxErrorTries: 200
|
||||
readonly property int daemonIdleExitMs: 30000
|
||||
// sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint
|
||||
// sessionTimeoutMs mirrors `timeout=` in assets/pam/fprint; under the
|
||||
// policy, /etc/pam.d/dankshell-fprint must carry the same value
|
||||
readonly property int sessionTimeoutMs: 90000
|
||||
readonly property int verifyStartSlackMs: 10000
|
||||
readonly property bool retrying: errorRetry.running
|
||||
@@ -401,8 +417,8 @@ Scope {
|
||||
Qt.callLater(checkAvail);
|
||||
}
|
||||
|
||||
config: "fprint"
|
||||
configDirectory: Quickshell.shellDir + "/assets/pam"
|
||||
config: AdminPolicy.disableCustomLockPam ? "dankshell-fprint" : "fprint"
|
||||
configDirectory: AdminPolicy.disableCustomLockPam ? "/etc/pam.d" : Quickshell.shellDir + "/assets/pam"
|
||||
|
||||
onPamMessage: {
|
||||
if (attemptStartedAt === 0)
|
||||
@@ -446,7 +462,7 @@ Scope {
|
||||
PamContext {
|
||||
id: u2f
|
||||
|
||||
property bool available: SettingsData.lockU2fReady
|
||||
property bool available: SettingsData.lockU2fReady && (!AdminPolicy.disableCustomLockPam || u2fConfigWatcher.loaded)
|
||||
|
||||
function checkAvail(): void {
|
||||
if (!available || !SettingsData.enableU2f || !root.lockSecured || root.u2fSuppressedByPrimaryPam) {
|
||||
@@ -483,11 +499,15 @@ Scope {
|
||||
}
|
||||
|
||||
config: {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return "dankshell-u2f";
|
||||
if (root.customU2fPamActive)
|
||||
return SettingsData.lockU2fPamPath.slice(SettingsData.lockU2fPamPath.lastIndexOf("/") + 1);
|
||||
return u2fConfigWatcher.loaded ? "dankshell-u2f" : "u2f";
|
||||
}
|
||||
configDirectory: {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return "/etc/pam.d";
|
||||
if (root.customU2fPamActive) {
|
||||
const idx = SettingsData.lockU2fPamPath.lastIndexOf("/");
|
||||
return idx > 0 ? SettingsData.lockU2fPamPath.slice(0, idx) : "/";
|
||||
@@ -694,6 +714,8 @@ Scope {
|
||||
}
|
||||
|
||||
function onLockPamExternallyManagedChanged(): void {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return;
|
||||
root.resetAuthFlows();
|
||||
if (!SettingsData.lockPamExternallyManaged)
|
||||
root.ensureUserPamConfig();
|
||||
@@ -702,6 +724,8 @@ Scope {
|
||||
}
|
||||
|
||||
function onLockU2fPamPathChanged(): void {
|
||||
if (AdminPolicy.disableCustomLockPam)
|
||||
return;
|
||||
u2f.abort();
|
||||
u2f.checkAvail();
|
||||
}
|
||||
|
||||
@@ -1051,6 +1051,9 @@ Singleton {
|
||||
}
|
||||
|
||||
function getDmsActions() {
|
||||
return Actions.getDmsActions(CompositorService.isNiri, CompositorService.isHyprland);
|
||||
const actions = Actions.getDmsActions(CompositorService.isNiri, CompositorService.isHyprland);
|
||||
if (!AdminPolicy.disableLockIpcUnlock)
|
||||
return actions;
|
||||
return actions.filter(action => action.id !== "spawn dms ipc call lock demo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ Singleton {
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
userWatcher.folder = Paths.toFileUrl(root.pluginDirectory);
|
||||
// A FolderListModel never given a folder lists the working directory.
|
||||
userWatcher.folder = AdminPolicy.disableUserPlugins ? "" : Paths.toFileUrl(root.pluginDirectory);
|
||||
systemWatcher.folder = Paths.toFileUrl(root.systemPluginDirectory);
|
||||
Qt.callLater(resyncAll);
|
||||
Qt.callLater(checkPluginDirectoryExists);
|
||||
@@ -150,7 +151,7 @@ Singleton {
|
||||
}
|
||||
|
||||
function resyncAll() {
|
||||
const userList = snapshotModel(userWatcher, "user");
|
||||
const userList = AdminPolicy.disableUserPlugins ? [] : snapshotModel(userWatcher, "user");
|
||||
const sysList = snapshotModel(systemWatcher, "system");
|
||||
const seenPaths = {};
|
||||
const modifiedTimes = {};
|
||||
@@ -1154,7 +1155,8 @@ Singleton {
|
||||
const userUrl = Paths.toFileUrl(root.pluginDirectory);
|
||||
const systemUrl = Paths.toFileUrl(root.systemPluginDirectory);
|
||||
userWatcher.folder = "";
|
||||
userWatcher.folder = userUrl;
|
||||
if (!AdminPolicy.disableUserPlugins)
|
||||
userWatcher.folder = userUrl;
|
||||
systemWatcher.folder = "";
|
||||
systemWatcher.folder = systemUrl;
|
||||
resyncDebounce.restart();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { loadScript } from "./qml-script.mjs";
|
||||
|
||||
function functionSource(file, name) {
|
||||
const source = readFileSync(new URL(file, import.meta.url), "utf8");
|
||||
const found = source.match(new RegExp(`^ function ${name}\\([^)]*\\)(?:: \\w+)? \\{\\n[\\s\\S]*?^ \\}`, "m"));
|
||||
if (!found)
|
||||
throw new Error(`${name} not found in ${file}`);
|
||||
return found[0];
|
||||
}
|
||||
|
||||
test("the lock IPC switch drops the lock demo keybind action and keeps the others", () => {
|
||||
const getDmsActions = functionSource("../Services/KeybindsService.qml", "getDmsActions");
|
||||
const Actions = loadScript(new URL("../Common/KeybindActions.js", import.meta.url));
|
||||
for (const disableLockIpcUnlock of [false, true]) {
|
||||
const context = vm.createContext({ Actions, CompositorService: { isNiri: false, isHyprland: false }, AdminPolicy: { disableLockIpcUnlock } });
|
||||
vm.runInContext(getDmsActions, context);
|
||||
const ids = context.getDmsActions().map(action => action.id);
|
||||
assert.equal(ids.includes("spawn dms ipc call lock demo"), !disableLockIpcUnlock);
|
||||
assert.ok(ids.includes("spawn dms ipc call lock lock"));
|
||||
assert.equal(ids.length, Actions.getDmsActions(false, false).length - (disableLockIpcUnlock ? 1 : 0));
|
||||
}
|
||||
});
|
||||
|
||||
test("the user plugin switch keeps the user tree out of a scan and a resync", () => {
|
||||
const user = "/home/user/.config/DankMaterialShell/plugins";
|
||||
const system = "/etc/xdg/quickshell/dms-plugins";
|
||||
const folder = dirs => ({ folder: "", count: dirs.length, get: (i, role) => role === "filePath" ? "file://" + dirs[i] : 0 });
|
||||
for (const disableUserPlugins of [false, true]) {
|
||||
const loaded = [];
|
||||
const context = vm.createContext({
|
||||
Date,
|
||||
AdminPolicy: { disableUserPlugins },
|
||||
Paths: { toFileUrl: path => "file://" + path },
|
||||
pluginDirectory: user,
|
||||
systemPluginDirectory: system,
|
||||
userWatcher: folder([user + "/probe"]),
|
||||
systemWatcher: folder([system + "/admitted"]),
|
||||
resyncDebounce: { restart() {} },
|
||||
checkPluginDirectoryExists() {},
|
||||
knownManifests: {},
|
||||
pathToPluginId: {},
|
||||
directoryModifiedTimes: {},
|
||||
loadPluginManifestFile: (path, source) => loaded.push([path, source])
|
||||
});
|
||||
context.root = context;
|
||||
for (const name of ["snapshotModel", "resyncAll", "scanPlugins"])
|
||||
vm.runInContext(functionSource("../Services/PluginService.qml", name), context);
|
||||
context.scanPlugins();
|
||||
assert.equal(context.userWatcher.folder, disableUserPlugins ? "" : "file://" + user);
|
||||
assert.equal(context.systemWatcher.folder, "file://" + system);
|
||||
context.resyncAll();
|
||||
const userManifests = disableUserPlugins ? [] : [[user + "/probe/plugin.json", "user"]];
|
||||
assert.deepEqual(loaded, [...userManifests, [system + "/admitted/plugin.json", "system"]]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the settings IPC switch refuses set and keeps get", () => {
|
||||
const source = readFileSync(new URL("../DMSShellIPC.qml", import.meta.url), "utf8");
|
||||
const block = source.slice(source.indexOf(" function get(key: string)"), source.indexOf(' target: "settings"'));
|
||||
const handlers = [...block.matchAll(/^ {8}function \w+\([^)]*\)(?:: \w+)? \{\n[\s\S]*?^ {8}\}$/gm)].map(match => match[0].replace(/\((.*?)\)(?:: \w+)? \{/, (_, params) => "(" + params.replace(/: \w+/g, "") + ") {"));
|
||||
for (const disableSettingsIpcSet of [false, true]) {
|
||||
const SettingsData = { lockBeforeSuspend: false, saveSettings() {} };
|
||||
const context = vm.createContext({ AdminPolicy: { disableSettingsIpcSet }, SettingsData, log: { warn() {} } });
|
||||
for (const handler of handlers)
|
||||
vm.runInContext(handler, context);
|
||||
assert.equal(context.set("lockBeforeSuspend", "true"), disableSettingsIpcSet ? "SETTINGS_SET_DISABLED" : "SETTINGS_SET_SUCCESS");
|
||||
assert.equal(SettingsData.lockBeforeSuspend, !disableSettingsIpcSet);
|
||||
assert.equal(context.get("lockBeforeSuspend"), String(!disableSettingsIpcSet));
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ Item {
|
||||
property string path
|
||||
property bool printErrors
|
||||
property bool watchChanges
|
||||
property bool loaded: true
|
||||
readonly property bool loaded: path !== "" && !MockFiles.missing.includes(path)
|
||||
function reload() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property var missing: []
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
module Quickshell.Io
|
||||
FileView 1.0 FileView.qml
|
||||
singleton MockFiles 1.0 MockFiles.qml
|
||||
Process 1.0 Process.qml
|
||||
|
||||
@@ -7,5 +7,6 @@ this directory.
|
||||
|
||||
`PamContext` models start/abort/completion and records attempts. Tests explicitly
|
||||
supply completion results; Qt timers and QML bindings run normally. File and
|
||||
process doubles do no I/O. These tests cover authentication flow control, not
|
||||
process doubles do no I/O; a `FileView` is loaded unless its path is empty or
|
||||
listed in `MockFiles.missing`. These tests cover authentication flow control, not
|
||||
PAM policy enforcement, daemon behavior, or fingerprint hardware.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
property bool disableCustomLockPam: false
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
module qs.Common
|
||||
singleton SettingsData 1.0 SettingsData.qml
|
||||
singleton AdminPolicy 1.0 AdminPolicy.qml
|
||||
singleton Paths 1.0 Paths.qml
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
property var warnings: []
|
||||
function scoped(module) {
|
||||
return {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn(...args) {
|
||||
root.warnings = root.warnings.concat([args.join(" ")]);
|
||||
},
|
||||
error() {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
module qs.Services
|
||||
singleton SessionService 1.0 SessionService.qml
|
||||
singleton IdleService 1.0 IdleService.qml
|
||||
singleton Log 1.0 Log.qml
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pam
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
@@ -21,8 +22,13 @@ TestCase {
|
||||
SettingsData.lockFingerprintReady = true;
|
||||
SettingsData.lockPamExternallyManaged = false;
|
||||
SettingsData.lockPamPath = "";
|
||||
SettingsData.lockU2fPamPath = "";
|
||||
SettingsData.lockPamInlineFprint = false;
|
||||
SettingsData.lockPamInlineU2f = false;
|
||||
SettingsData.maxFprintTries = 2;
|
||||
AdminPolicy.disableCustomLockPam = false;
|
||||
MockFiles.missing = [];
|
||||
Log.warnings = [];
|
||||
SessionService.preparingForSleep = false;
|
||||
IdleService.monitorsOff = false;
|
||||
}
|
||||
@@ -73,6 +79,73 @@ TestCase {
|
||||
compare(pam.fprint.status, "disabled");
|
||||
}
|
||||
|
||||
function test_pamStacks_data() {
|
||||
const policy = {
|
||||
policy: true,
|
||||
missing: [],
|
||||
passwd: "/etc/pam.d/dankshell",
|
||||
fprint: "/etc/pam.d/dankshell-fprint",
|
||||
u2f: "/etc/pam.d/dankshell-u2f",
|
||||
fprintStatus: "active",
|
||||
secondFactor: true,
|
||||
warnings: 0
|
||||
};
|
||||
return [
|
||||
{
|
||||
tag: "settings",
|
||||
policy: false,
|
||||
missing: [],
|
||||
passwd: "/test/custom",
|
||||
fprint: "/test/assets/pam/fprint",
|
||||
u2f: "/test/custom-u2f",
|
||||
fprintStatus: "disabled",
|
||||
secondFactor: false,
|
||||
warnings: 0
|
||||
},
|
||||
Object.assign({}, policy, {
|
||||
tag: "policy"
|
||||
}),
|
||||
Object.assign({}, policy, {
|
||||
tag: "policyWithoutDankshell",
|
||||
missing: ["/etc/pam.d/dankshell"],
|
||||
passwd: "/etc/pam.d/login"
|
||||
}),
|
||||
Object.assign({}, policy, {
|
||||
tag: "policyWithoutU2fStack",
|
||||
missing: ["/etc/pam.d/dankshell-u2f"],
|
||||
secondFactor: false,
|
||||
warnings: 1
|
||||
}),
|
||||
Object.assign({}, policy, {
|
||||
tag: "policyWithoutFprintStack",
|
||||
missing: ["/etc/pam.d/dankshell-fprint"],
|
||||
fprintStatus: "disabled"
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
function test_pamStacks(data) {
|
||||
AdminPolicy.disableCustomLockPam = data.policy;
|
||||
MockFiles.missing = data.missing;
|
||||
SettingsData.enableU2f = true;
|
||||
SettingsData.lockPamPath = "/test/custom";
|
||||
SettingsData.lockU2fPamPath = "/test/custom-u2f";
|
||||
SettingsData.lockPamInlineFprint = true;
|
||||
SettingsData.lockPamInlineU2f = true;
|
||||
SettingsData.lockPamExternallyManaged = true;
|
||||
const pam = createTemporaryObject(factory, tests);
|
||||
pam.lockSecured = true;
|
||||
compare(pam.passwd.configDirectory + "/" + pam.passwd.config, data.passwd);
|
||||
compare(pam.fprint.configDirectory + "/" + pam.fprint.config, data.fprint);
|
||||
compare(pam.u2f.configDirectory + "/" + pam.u2f.config, data.u2f);
|
||||
tryCompare(pam.fprint, "status", data.fprintStatus);
|
||||
pam.passwd.start();
|
||||
pam.passwd.finish(PamResult.Success);
|
||||
compare(pam.u2fPending, data.secondFactor);
|
||||
compare(pam.unlockInProgress, !data.secondFactor);
|
||||
compare(Log.warnings.length, data.warnings);
|
||||
}
|
||||
|
||||
function test_disabledContextIgnoresLateSuccess() {
|
||||
const pam = lockedPam();
|
||||
SettingsData.enableFprint = false;
|
||||
|
||||
Reference in New Issue
Block a user