sessiond: - device_state.rs: 8-state unified device state machine with legal transition table, sensor evidence model (proximity/accel/light/touch), confidence scoring, cross-sensor disagreement detection, and forensic logging with full state snapshots at every decision point. - protocol.rs: extended with DeviceState, SensorInput, and ForensicLog IPC requests. SensorSource/SensorValue types for proximity, accel, light, touch. - server.rs: DeviceStateMachine integrated into Daemon struct. Handlers for DeviceState (full state query), SensorInput (sensor evidence + Observed transitions), ForensicLog (recent entries query). - lock.rs: Failed attempt counter on PIN surface (advisory, PAM owns lockout policy). Rendered as red digit glyphs below PIN dots. - draw.rs: Attempt counter rendering + test. - auth.rs: PAM config docs reference. - mod.rs: device_state module added. shell: - IdleCoordinator.qml: Legal transition table with runtime enforcement. setState() refuses illegal transitions with warning. returnActive() explicitly only allows Dimmed/Waking. - GlobalStates.qml: Write authority comments on every property (// WRITER:). - SessionAudit.qml: SHA-256 replaces MD5 for hash chain. Forensic event wiring (device-state-transition, device-error, sensor-input, wake-event). logDeviceError/logSensorInput/logWakeEvent functions for QML callers. Design doc: SouveraineOS/docs/DEVICE-STATE-MACHINE.md (separate repo). Tests: 22 passing (was 7). Full lifecycle test exercises Active → Dimmed → Locked → Observed → DozeLight → DozeDeep → Suspending → Asleep → Locked with 23 forensic entries.
183 lines
6.4 KiB
Rust
183 lines
6.4 KiB
Rust
//! Minimal PAM client for the fallback unlock surface.
|
|
//!
|
|
//! Hand-rolled FFI against libpam rather than a wrapper crate: the daemon
|
|
//! asks exactly one question ("does this PIN authenticate this user") and a
|
|
//! dependency is a bigger liability than 100 lines of well-understood
|
|
//! unsafe. pam_unix answers via the setuid unix_chkpwd helper, so this works
|
|
//! from an unprivileged session process — the same mechanism hyprlock and
|
|
//! the shell's PamContext rely on.
|
|
//!
|
|
//! Doctrine: auth goes through PAM, always (SESSION-AUTHORITY-DOCTRINE §4).
|
|
//! This module never inspects credentials itself; it carries the PIN to the
|
|
//! stack configured for the service and reports the verdict.
|
|
//!
|
|
//! The expected PAM service config is documented in
|
|
//! docs/pam-souveraine-sessiond.md. The file `/etc/pam.d/souveraine-sessiond`
|
|
//! is shipped as root-owned system config by the OS overlay, never by this
|
|
//! crate. Without it, pam_start fails and the lock session does not start.
|
|
|
|
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
|
|
|
const PAM_SUCCESS: c_int = 0;
|
|
const PAM_CONV_ERR: c_int = 19;
|
|
|
|
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
|
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
|
const PAM_ERROR_MSG: c_int = 3;
|
|
const PAM_TEXT_INFO: c_int = 4;
|
|
|
|
#[repr(C)]
|
|
struct PamMessage {
|
|
msg_style: c_int,
|
|
msg: *const c_char,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct PamResponse {
|
|
resp: *mut c_char,
|
|
resp_retcode: c_int,
|
|
}
|
|
|
|
#[repr(C)]
|
|
struct PamConv {
|
|
conv: unsafe extern "C" fn(
|
|
num_msg: c_int,
|
|
msg: *mut *const PamMessage,
|
|
resp: *mut *mut PamResponse,
|
|
appdata_ptr: *mut c_void,
|
|
) -> c_int,
|
|
appdata_ptr: *mut c_void,
|
|
}
|
|
|
|
#[link(name = "pam")]
|
|
extern "C" {
|
|
fn pam_start(
|
|
service: *const c_char,
|
|
user: *const c_char,
|
|
conv: *const PamConv,
|
|
handle: *mut *mut c_void,
|
|
) -> c_int;
|
|
fn pam_authenticate(handle: *mut c_void, flags: c_int) -> c_int;
|
|
fn pam_end(handle: *mut c_void, status: c_int) -> c_int;
|
|
fn pam_strerror(handle: *mut c_void, errnum: c_int) -> *const c_char;
|
|
}
|
|
|
|
/// The conversation only ever answers prompts with the PIN. Info/error
|
|
/// messages are acknowledged and dropped — the fallback surface has nowhere
|
|
/// to render a module's prose and must not block on it.
|
|
unsafe extern "C" fn conv_fn(
|
|
num_msg: c_int,
|
|
msg: *mut *const PamMessage,
|
|
resp: *mut *mut PamResponse,
|
|
appdata_ptr: *mut c_void,
|
|
) -> c_int {
|
|
if num_msg <= 0 || msg.is_null() || resp.is_null() || appdata_ptr.is_null() {
|
|
return PAM_CONV_ERR;
|
|
}
|
|
let pin = &*(appdata_ptr as *const CString);
|
|
|
|
// Linux-PAM passes an array of message pointers. Responses are freed by
|
|
// the module with free(), so they must come from malloc/strdup.
|
|
let responses =
|
|
libc::calloc(num_msg as usize, std::mem::size_of::<PamResponse>()) as *mut PamResponse;
|
|
if responses.is_null() {
|
|
return PAM_CONV_ERR;
|
|
}
|
|
|
|
// Linux-PAM convention: *msg points at a contiguous array of messages
|
|
// (modules there prompt one message at a time anyway).
|
|
for i in 0..num_msg as usize {
|
|
let m = (*msg).add(i);
|
|
if m.is_null() {
|
|
libc::free(responses as *mut c_void);
|
|
return PAM_CONV_ERR;
|
|
}
|
|
let slot = &mut *responses.add(i);
|
|
match (*m).msg_style {
|
|
PAM_PROMPT_ECHO_OFF | PAM_PROMPT_ECHO_ON => {
|
|
slot.resp = libc::strdup(pin.as_ptr());
|
|
if slot.resp.is_null() {
|
|
libc::free(responses as *mut c_void);
|
|
return PAM_CONV_ERR;
|
|
}
|
|
}
|
|
PAM_ERROR_MSG | PAM_TEXT_INFO => {
|
|
slot.resp = std::ptr::null_mut();
|
|
}
|
|
_ => {
|
|
libc::free(responses as *mut c_void);
|
|
return PAM_CONV_ERR;
|
|
}
|
|
}
|
|
slot.resp_retcode = 0;
|
|
}
|
|
|
|
*resp = responses;
|
|
PAM_SUCCESS
|
|
}
|
|
|
|
/// Run one authenticate conversation. Blocking (pam_unix sleeps ~2s on a
|
|
/// wrong answer); callers own threading. Returns Ok(()) on success and the
|
|
/// stack's complaint otherwise.
|
|
pub fn authenticate(service: &str, user: &str, pin: &str) -> Result<(), String> {
|
|
let service = CString::new(service).map_err(|_| "service contains NUL".to_string())?;
|
|
let user_c = CString::new(user).map_err(|_| "user contains NUL".to_string())?;
|
|
let pin = CString::new(pin).map_err(|_| "pin contains NUL".to_string())?;
|
|
|
|
// Boxed so the conversation callback has a stable pointer for the whole
|
|
// transaction; dropped after pam_end.
|
|
let pin = Box::new(pin);
|
|
let conv = PamConv {
|
|
conv: conv_fn,
|
|
appdata_ptr: &*pin as *const CString as *mut c_void,
|
|
};
|
|
|
|
let mut handle: *mut c_void = std::ptr::null_mut();
|
|
// SAFETY: all pointers live across the calls; handle is only used
|
|
// between a successful pam_start and pam_end.
|
|
unsafe {
|
|
let rc = pam_start(service.as_ptr(), user_c.as_ptr(), &conv, &mut handle);
|
|
if rc != PAM_SUCCESS {
|
|
return Err(format!("pam_start failed (code {rc})"));
|
|
}
|
|
let rc = pam_authenticate(handle, 0);
|
|
let verdict = if rc == PAM_SUCCESS {
|
|
Ok(())
|
|
} else {
|
|
let msg = pam_strerror(handle, rc);
|
|
Err(if msg.is_null() {
|
|
format!("authentication failed (code {rc})")
|
|
} else {
|
|
CStr::from_ptr(msg).to_string_lossy().into_owned()
|
|
})
|
|
};
|
|
pam_end(handle, rc);
|
|
verdict
|
|
}
|
|
}
|
|
|
|
/// The user this session belongs to, from the process's own identity —
|
|
/// sessiond authenticates the seat owner, never a name a caller supplies.
|
|
pub fn session_user() -> Result<String, String> {
|
|
// SAFETY: getpwuid_r with a stack buffer; result pointer checked.
|
|
unsafe {
|
|
let uid = libc::getuid();
|
|
let mut pwd: libc::passwd = std::mem::zeroed();
|
|
let mut buf = [0u8; 1024];
|
|
let mut result: *mut libc::passwd = std::ptr::null_mut();
|
|
// libc's `c_char` is i8 on some targets and u8 on others (aarch64),
|
|
// so the buffer is `u8` and we cast through `c_char` to satisfy
|
|
// whichever signature the target's libc crate exposes.
|
|
let rc = libc::getpwuid_r(
|
|
uid,
|
|
&mut pwd,
|
|
buf.as_mut_ptr() as *mut libc::c_char,
|
|
buf.len(),
|
|
&mut result,
|
|
);
|
|
if rc != 0 || result.is_null() {
|
|
return Err(format!("getpwuid_r failed for uid {uid}"));
|
|
}
|
|
Ok(CStr::from_ptr(pwd.pw_name).to_string_lossy().into_owned())
|
|
}
|
|
}
|