- commit
- 0065c99d0157ab6fc44528f7ab944995a0fa6062
- parent
- e3c7bce84c2ccd68ac96dce925ac98959c18bdf5
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | crates/ | 77 | ++++++++++ |
| M | crates/ | 235 | +++++++++++++++++++++++++++++- |
| M | docs/ | 11 | +- |
| M | docs/ | 29 | ++++ |
@@ -181,6 +181,12 @@ pub struct UinputConfig {
181181 pub expected_owner_uid: u32,182182 pub expected_group_gid: u32,183183 pub expected_mode: u32,184+ /// Optional single named-user POSIX ACL entry expected for the daemon UID.185+ ///186+ /// Omission preserves the default group-only scheme and rejects every187+ /// extended access ACL.188+ 189+ pub expected_acl_user_uid: Option<u32>,184190 }185191 186192 impl UinputConfig {@@ -206,6 +212,16 @@ impl UinputConfig {
206212 self.expected_mode & 0o007 == 0,207213 "uinput.expected_mode must not grant access to other users"208214 );215+ if let Some(acl_uid) = self.expected_acl_user_uid {216+ ensure!(217+ acl_uid != self.expected_owner_uid,218+ "uinput.expected_acl_user_uid must differ from expected_owner_uid"219+ );220+ ensure!(221+ self.expected_mode & 0o060 == 0o060,222+ "uinput.expected_mode must grant ACL mask read/write access when expected_acl_user_uid is set"223+ );224+ }209225 Ok(())210226 }211227 }@@ -981,6 +997,28 @@ expected_mode = 0o660
981997 )982998 .unwrap();983999 config.validate().unwrap();1000+ assert_eq!(config.uinput.as_ref().unwrap().expected_acl_user_uid, None);1001+1002+ let acl_config: HostConfig = toml::from_str(1003+ r#"1004+identity_path = "/tmp/host.key"1005+state_path = "/tmp/state"1006+input_mode = "uinput"1007+1008+[uinput]1009+device_path = "/dev/uinput"1010+expected_owner_uid = 01011+expected_group_gid = 9861012+expected_mode = 0o6601013+expected_acl_user_uid = 10001014+"#,1015+ )1016+ .unwrap();1017+ acl_config.validate().unwrap();1018+ assert_eq!(1019+ acl_config.uinput.as_ref().unwrap().expected_acl_user_uid,1020+ Some(1000)1021+ );9841022 }9851023 9861024 @@ -1009,6 +1047,45 @@ expected_mode = 0o666
10091047 assert!(config.validate().is_err());10101048 }10111049 1050+ 1051+ fn explicit_uinput_acl_principal_fails_closed() {1052+ let mut config: HostConfig = toml::from_str(1053+ r#"1054+identity_path = "/tmp/host.key"1055+state_path = "/tmp/state"1056+input_mode = "uinput"1057+1058+[uinput]1059+device_path = "/dev/uinput"1060+expected_owner_uid = 01061+expected_group_gid = 9861062+expected_mode = 0o6601063+expected_acl_user_uid = 10001064+"#,1065+ )1066+ .unwrap();1067+1068+ config.uinput.as_mut().unwrap().expected_acl_user_uid = Some(0);1069+ assert!(1070+ config1071+ .validate()1072+ .unwrap_err()1073+ .to_string()1074+ .contains("must differ from expected_owner_uid")1075+ );1076+1077+ let uinput = config.uinput.as_mut().unwrap();1078+ uinput.expected_acl_user_uid = Some(1000);1079+ uinput.expected_mode = 0o640;1080+ assert!(1081+ config1082+ .validate()1083+ .unwrap_err()1084+ .to_string()1085+ .contains("ACL mask read/write")1086+ );1087+ }1088+10121089 10131090 10141091 fn rejects_symlink_and_permissive_runtime_directories() {@@ -1,4 +1,6 @@
11 use std::fmt;2+3+use std::mem::size_of;24 35 use std::sync::Arc;46 @@ -19,6 +21,99 @@ const UINPUT_CAPABILITIES: &[Capability] = &[
1921 Capability::InputAck,2022 ];2123 24+25+const POSIX_ACL_XATTR_VERSION: u32 = 0x0002;26+27+const POSIX_ACL_ENTRY_BYTES: usize = 8;28+29+const POSIX_ACL_REQUIRED_ENTRIES: usize = 5;30+31+const POSIX_ACL_REQUIRED_BYTES: usize =32+ size_of::<u32>() + POSIX_ACL_ENTRY_BYTES * POSIX_ACL_REQUIRED_ENTRIES;33+34+const POSIX_ACL_UNDEFINED_ID: u32 = u32::MAX;35+36+const POSIX_ACL_USER_OBJ: u16 = 0x01;37+38+const POSIX_ACL_USER: u16 = 0x02;39+40+const POSIX_ACL_GROUP_OBJ: u16 = 0x04;41+42+const POSIX_ACL_MASK: u16 = 0x10;43+44+const POSIX_ACL_OTHER: u16 = 0x20;45+46+const POSIX_ACL_READ_WRITE: u16 = 0x06;47+48+/// Validate the only extended uinput ACL shape Sigil accepts.49+///50+/// Linux stores POSIX ACL xattrs as one little-endian version followed by51+/// fixed-size entries. Requiring the canonical five-entry form prevents a52+/// configured one-user exception from admitting extra users or groups.53+54+fn validate_single_user_access_acl(55+ bytes: &[u8],56+ expected_uid: u32,57+ expected_mode: u32,58+) -> Result<()> {59+ if bytes.len() != POSIX_ACL_REQUIRED_BYTES {60+ bail!(61+ "configured uinput access ACL must contain exactly one named user and no named groups"62+ );63+ }64+ let version = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);65+ if version != POSIX_ACL_XATTR_VERSION {66+ bail!("configured uinput access ACL has an unsupported Linux ACL version");67+ }68+69+ let expected_entries = [70+ (71+ POSIX_ACL_USER_OBJ,72+ ((expected_mode >> 6) & 0o7) as u16,73+ POSIX_ACL_UNDEFINED_ID,74+ ),75+ (POSIX_ACL_USER, POSIX_ACL_READ_WRITE, expected_uid),76+ (77+ POSIX_ACL_GROUP_OBJ,78+ ((expected_mode >> 3) & 0o7) as u16,79+ POSIX_ACL_UNDEFINED_ID,80+ ),81+ (82+ POSIX_ACL_MASK,83+ ((expected_mode >> 3) & 0o7) as u16,84+ POSIX_ACL_UNDEFINED_ID,85+ ),86+ (87+ POSIX_ACL_OTHER,88+ (expected_mode & 0o7) as u16,89+ POSIX_ACL_UNDEFINED_ID,90+ ),91+ ];92+93+ for (index, (expected_tag, expected_permissions, expected_id)) in94+ expected_entries.into_iter().enumerate()95+ {96+ let offset = size_of::<u32>() + index * POSIX_ACL_ENTRY_BYTES;97+ let tag = u16::from_le_bytes([bytes[offset], bytes[offset + 1]]);98+ let permissions = u16::from_le_bytes([bytes[offset + 2], bytes[offset + 3]]);99+ let id = u32::from_le_bytes([100+ bytes[offset + 4],101+ bytes[offset + 5],102+ bytes[offset + 6],103+ bytes[offset + 7],104+ ]);105+ if permissions & !0o7 != 0 {106+ bail!("configured uinput access ACL contains invalid permission bits");107+ }108+ if tag != expected_tag || permissions != expected_permissions || id != expected_id {109+ bail!(110+ "configured uinput access ACL does not match the exact one-user owner/group/mode contract"111+ );112+ }113+ }114+ Ok(())115+}116+22117 23118 const UINPUT_VENDOR_ID: u16 = 0x5347;24119 @@ -550,8 +645,9 @@ mod linux {
550645 use super::{551646 ALL_MAPPED_KEYS, GAMEPAD_DEVICE_NAME, GAMEPAD_PRODUCT_ID, GamepadAxis, GamepadButton,552647 InputDeviceClass, KEYBOARD_DEVICE_NAME, KEYBOARD_PRODUCT_ID, MappedKey,553- POINTER_DEVICE_NAME, POINTER_PRODUCT_ID, PointerReportEvent, UINPUT_VENDOR_ID,554- input_device_class, map_gamepad_state, map_key, pointer_position_sync_report,648+ POINTER_DEVICE_NAME, POINTER_PRODUCT_ID, POSIX_ACL_REQUIRED_BYTES, PointerReportEvent,649+ UINPUT_VENDOR_ID, input_device_class, map_gamepad_state, map_key,650+ pointer_position_sync_report, validate_single_user_access_acl,555651 };556652 use crate::config::UinputConfig;557653 @@ -1091,30 +1187,65 @@ mod linux {
10911187 libc::major(device) == UINPUT_MAJOR && libc::minor(device) == UINPUT_MINOR,10921188 "configured character device is not the Linux uinput misc device"10931189 );1094- reject_extended_access_acl(file)?;1190+ validate_access_acl(file, config)?;10951191 Ok(())10961192 }10971193 1098- fn reject_extended_access_acl(file: &File) -> Result<()> {1194+ fn validate_access_acl(file: &File, config: &UinputConfig) -> Result<()> {1195+ if let Some(expected_uid) = config.expected_acl_user_uid {1196+ let effective_uid = unsafe { libc::geteuid() };1197+ ensure!(1198+ expected_uid == effective_uid,1199+ "configured uinput ACL user UID {expected_uid} does not match Sigil effective UID {effective_uid}"1200+ );1201+ }1202+10991203 let attribute = c"system.posix_acl_access";1204+ let mut bytes = [0_u8; POSIX_ACL_REQUIRED_BYTES];11001205 let result = unsafe {11011206 libc::fgetxattr(11021207 file.as_raw_fd(),11031208 attribute.as_ptr(),1104- std::ptr::null_mut(),1105- 0,1209+ bytes.as_mut_ptr().cast(),1210+ bytes.len(),11061211 )11071212 };11081213 if result >= 0 {1109- anyhow::bail!("configured uinput device must not have an extended access ACL");1214+ let Some(expected_uid) = config.expected_acl_user_uid else {1215+ anyhow::bail!(1216+ "configured uinput device must use the group-only scheme with no extended access ACL; set uinput.expected_acl_user_uid only for the explicit one-user ACL scheme"1217+ );1218+ };1219+ let length = usize::try_from(result)1220+ .context("converting configured uinput access ACL length")?;1221+ return validate_single_user_access_acl(1222+ bytes1223+ .get(..length)1224+ .context("configured uinput access ACL exceeded its fixed bound")?,1225+ expected_uid,1226+ config.expected_mode,1227+ )1228+ .context("validating configured uinput one-user access ACL");11101229 }11111230 let error = std::io::Error::last_os_error();11121231 if error11131232 .raw_os_error()11141233 .is_some_and(|code| code == libc::ENODATA || code == libc::ENOTSUP)11151234 {1235+ ensure!(1236+ config.expected_acl_user_uid.is_none(),1237+ "configured uinput device is missing the explicitly required one-user access ACL"1238+ );11161239 return Ok(());11171240 }1241+ if error.raw_os_error() == Some(libc::ERANGE) {1242+ if config.expected_acl_user_uid.is_some() {1243+ anyhow::bail!("configured uinput access ACL exceeds the exact one-user ACL scheme");1244+ }1245+ anyhow::bail!(1246+ "configured uinput device must use the group-only scheme with no extended access ACL"1247+ );1248+ }11181249 Err(error).context("checking configured uinput device access ACL")11191250 }11201251 @@ -1266,6 +1397,96 @@ mod linux {
12661397 mod tests {12671398 use super::*;12681399 1400+ fn acl_entry(tag: u16, permissions: u16, id: u32) -> [u8; POSIX_ACL_ENTRY_BYTES] {1401+ let mut bytes = [0_u8; POSIX_ACL_ENTRY_BYTES];1402+ bytes[0..2].copy_from_slice(&tag.to_le_bytes());1403+ bytes[2..4].copy_from_slice(&permissions.to_le_bytes());1404+ bytes[4..8].copy_from_slice(&id.to_le_bytes());1405+ bytes1406+ }1407+1408+ fn one_user_access_acl(uid: u32, mode: u32) -> Vec<u8> {1409+ let entries = [1410+ acl_entry(1411+ POSIX_ACL_USER_OBJ,1412+ ((mode >> 6) & 0o7) as u16,1413+ POSIX_ACL_UNDEFINED_ID,1414+ ),1415+ acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, uid),1416+ acl_entry(1417+ POSIX_ACL_GROUP_OBJ,1418+ ((mode >> 3) & 0o7) as u16,1419+ POSIX_ACL_UNDEFINED_ID,1420+ ),1421+ acl_entry(1422+ POSIX_ACL_MASK,1423+ ((mode >> 3) & 0o7) as u16,1424+ POSIX_ACL_UNDEFINED_ID,1425+ ),1426+ acl_entry(POSIX_ACL_OTHER, (mode & 0o7) as u16, POSIX_ACL_UNDEFINED_ID),1427+ ];1428+ let mut bytes = Vec::with_capacity(POSIX_ACL_REQUIRED_BYTES);1429+ bytes.extend_from_slice(&POSIX_ACL_XATTR_VERSION.to_le_bytes());1430+ for entry in entries {1431+ bytes.extend_from_slice(&entry);1432+ }1433+ bytes1434+ }1435+1436+ 1437+ fn exact_one_user_access_acl_is_accepted() {1438+ let bytes = one_user_access_acl(1000, 0o660);1439+ assert_eq!(bytes.len(), POSIX_ACL_REQUIRED_BYTES);1440+ validate_single_user_access_acl(&bytes, 1000, 0o660).unwrap();1441+ }1442+1443+ 1444+ fn one_user_access_acl_rejects_wrong_principal_and_permissions() {1445+ let bytes = one_user_access_acl(1000, 0o660);1446+ assert!(validate_single_user_access_acl(&bytes, 1001, 0o660).is_err());1447+1448+ for permission_offset in [6, 14, 22, 30, 38] {1449+ let mut changed = bytes.clone();1450+ changed[permission_offset] ^= 0x01;1451+ assert!(validate_single_user_access_acl(&changed, 1000, 0o660).is_err());1452+ }1453+1454+ let mut invalid_permissions = bytes;1455+ invalid_permissions[14] = 0x08;1456+ assert!(validate_single_user_access_acl(&invalid_permissions, 1000, 0o660).is_err());1457+ }1458+1459+ 1460+ fn one_user_access_acl_rejects_extra_duplicate_and_named_group_entries() {1461+ let bytes = one_user_access_acl(1000, 0o660);1462+1463+ let mut extra = bytes.clone();1464+ extra.extend_from_slice(&acl_entry(POSIX_ACL_USER, POSIX_ACL_READ_WRITE, 1001));1465+ assert!(validate_single_user_access_acl(&extra, 1000, 0o660).is_err());1466+1467+ let mut duplicate_user = bytes.clone();1468+ duplicate_user[20..22].copy_from_slice(&POSIX_ACL_USER.to_le_bytes());1469+ assert!(validate_single_user_access_acl(&duplicate_user, 1000, 0o660).is_err());1470+1471+ let mut named_group = bytes;1472+ named_group[20..22].copy_from_slice(&0x08_u16.to_le_bytes());1473+ assert!(validate_single_user_access_acl(&named_group, 1000, 0o660).is_err());1474+ }1475+1476+ 1477+ fn one_user_access_acl_rejects_bad_version_and_non_exact_lengths() {1478+ let bytes = one_user_access_acl(1000, 0o660);1479+1480+ let mut bad_version = bytes.clone();1481+ bad_version[0..4].copy_from_slice(&3_u32.to_le_bytes());1482+ assert!(validate_single_user_access_acl(&bad_version, 1000, 0o660).is_err());1483+ assert!(validate_single_user_access_acl(&bytes[..bytes.len() - 1], 1000, 0o660).is_err());1484+1485+ let mut oversized = bytes;1486+ oversized.push(0);1487+ assert!(validate_single_user_access_acl(&oversized, 1000, 0o660).is_err());1488+ }1489+12691490 12701491 fn virtual_input_topology_has_stable_distinct_identities() {12711492 assert_eq!(UINPUT_VENDOR_ID, 0x5347);@@ -1123,10 +1123,13 @@ event nodes and assigns the same single-purpose group. Without it, correct
11231123 `BTN_LEFT` and `REL_X/Y` events can appear in `evtest` while Gamescope silently11241124 ignores an inaccessible hot-plugged device.11251125 The daemon independently opens the configured path three times with1126-`O_NOFOLLOW`, verifies that character-device identity, exact owner/group/mode,1127-and absence of an extended access ACL on every descriptor, then creates1128-separate pointer, keyboard, and gamepad devices before it binds Iroh. Any change1129-fails closed. Access to1126+`O_NOFOLLOW`, verifies that character-device identity and exact1127+owner/group/mode, and—under this default Bazzite configuration—requires the1128+absence of an extended access ACL on every descriptor. The portable activation1129+guide documents the separate, explicit `expected_acl_user_uid` contract for a1130+distribution that intentionally owns a one-user seat ACL; it is not enabled by1131+the shipped rules. Sigil then creates separate pointer, keyboard, and gamepad1132+devices before it binds Iroh. Any change fails closed. Access to11301133 uinput is equivalent to local keyboard, pointer, and gamepad control, so keep11311134 this dedicated group single-purpose. Do not add11321135 `PrivateDevices=true` to the user unit because that would deliberately hide the@@ -69,6 +69,35 @@ Do not add this account to the broad `input` group and do not run Sigil as
6969 root. Access to uinput is equivalent to local controller, keyboard, and pointer7070 control.7171 72+The shipped Bazzite/SteamOS-like setup above is the default **group-only**73+scheme. Sigil expects the configured owner, group, and mode and rejects every74+extended access ACL. Its preflight error names that scheme explicitly.75+76+On a distribution that deliberately provisions `/dev/uinput` with a77+seat-managed POSIX ACL instead, opt in to exactly one named-user entry:78+79+```toml80+[uinput]81+device_path = "/dev/uinput"82+expected_owner_uid = 083+expected_group_gid = 98684+expected_mode = 0o66085+expected_acl_user_uid = 100086+```87+88+Use the machine-local numeric UID of the non-root account that runs Sigil.89+Sigil requires it to equal the daemon's effective UID. The access ACL must have90+exactly the canonical `user::rw-`, `user:<uid>:rw-`, `group::rw-`, `mask::rw-`,91+and `other::---` entries matching the configured mode; extra users, named92+groups, different permissions, or a missing ACL fail closed. Owner, group,93+mode, character-device identity, and major/minor checks still apply.94+95+This opt-in does not make logind seat state optional. If a `uaccess` ACL is96+removed during a headless boot, SSH session, or seat transition, Sigil refuses97+to start. Prefer the shipped dedicated-group scheme for an appliance; use the98+one-user ACL form only when the operator owns and verifies that distribution's99+seat lifecycle.100+72101 ## 2. Create the host identity once73102 74103 ```bash