Watch
1
0
Fork
You've already forked souveraine
0

sessiond: delete the fallback login; this daemon locks and never unlocks

It authenticated through PAM, called unlock_and_destroy(), and returned
Unlocked — and then the shell, whose GlobalStates.screenLocked is a separate
bool, re-registered, was told must_lock=true, and locked again. The protocol
has a lock directive and no unlock directive, so the fallback could open the
compositor's lock and had no way to tell the session it had. Casey hit it
exactly that way: "it logs in, and then qs says Locked still."

It was also not a last resort in practice. It is raised whenever shell_alive is
false, and that flag lied for 90 minutes on 07-29 because a scene reload's
re-registration was refused and the shell gave up (45fbbea). So it was the
first thing reached, on a bad signal, to do a job it could not finish — the
fallback shape the doctrine forbids.

Gone: auth.rs, the PIN pad, the keypad geometry and digit font, the
failed-attempt counter, SessionOutcome::Unlocked, Msg::AuthDone, and the
--pam-service plumbing down to the CLI.

Kept: acquiring ext-session-lock before any shell exists, which was never a
fallback but the ordering invariant from LOCK-DPMS-LESSONS.md §1, and the
Released handoff. A locked session with no shell now stays locked and says so
in the journal. Recovery is the shell returning, or a reboot.

79 tests pass.
This commit is contained in:
Fimeg 2026-07-29 11:37:08 -04:00
commit c8507e924b
7 changed files with 90 additions and 694 deletions

View file

@ -27,18 +27,12 @@ fn main() -> anyhow::Result<()> {
)
.init();
let mut pam_service = sessiond::protocol::DEFAULT_PAM_SERVICE.to_string();
let mut initial_lock = true;
let mut socket: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--pam-service" => {
pam_service = args
.next()
.ok_or_else(|| anyhow::anyhow!("--pam-service requires a name"))?;
}
"--socket" => {
socket = Some(PathBuf::from(args.next().ok_or_else(|| {
anyhow::anyhow!("--socket requires a path")
@ -47,8 +41,7 @@ fn main() -> anyhow::Result<()> {
"--no-initial-lock" => initial_lock = false,
"--help" | "-h" => {
println!(
"souveraine-sessiond [--pam-service {}] [--socket $XDG_RUNTIME_DIR/{}] [--no-initial-lock]",
sessiond::protocol::DEFAULT_PAM_SERVICE,
"souveraine-sessiond [--socket $XDG_RUNTIME_DIR/{}] [--no-initial-lock]",
sessiond::protocol::SOCKET_RELPATH,
);
return Ok(());
@ -61,5 +54,5 @@ fn main() -> anyhow::Result<()> {
Some(p) => p,
None => sessiond::server::socket_path()?,
};
sessiond::server::run(pam_service, initial_lock, &socket)
sessiond::server::run(initial_lock, &socket)
}

View file

@ -1,183 +0,0 @@
//! 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())
}
}

View file

@ -1,260 +1,46 @@
//! Software renderer for the fallback lock surface.
//! Software renderer for sessiond's holding surface.
//!
//! Deliberately spartan: a dark field, a row of PIN dots, a 3x4 keypad,
//! digits from a 5x7 bitmap font. This surface exists for the seconds
//! before the shell takes over at boot and for the case where the shell is
//! a corpse — it competes on availability, not looks. The rich lockscreen
//! (swipe, widgets) stays in the shell.
//! A dark field. That is the whole surface, deliberately.
//!
//! Until 2026-07-29 this drew a PIN pad — dots, a 3x4 keypad, a 5x7 digit
//! font, a failed-attempt counter — because sessiond could authenticate and
//! unlock. It cannot any more, and it should not: the protocol has a lock
//! directive and no unlock directive, so a successful PAM here opened the
//! compositor's lock and had no way to tell the shell, which then re-locked on
//! its next registration. A keypad that takes input and cannot finish the job
//! is worse than no keypad, so the keypad is gone rather than left inert.
//!
//! Nothing replaced it. A glyph on a locked screen with no text renderer is
//! not legible — the honest place for "the shell is not running and this
//! session is held locked" is the journal, where it can be read, searched and
//! correlated. `lock.rs` says it there.
//!
//! All drawing targets a raw ARGB8888 buffer (little-endian: B,G,R,A).
#![allow(dead_code)]
/// The field. Matches the shell lockscreen's backdrop closely enough that a
/// handoff in either direction is not a visible flash.
pub const BG: u32 = 0xFF0E0E12;
pub const KEY_FILL: u32 = 0xFF1D1D26;
pub const KEY_PRESSED: u32 = 0xFF3C3C4C;
pub const FG: u32 = 0xFFE6E6F0;
pub const DOT_EMPTY: u32 = 0xFF44444F;
pub const FAIL: u32 = 0xFFC63A4A;
pub const BUSY: u32 = 0xFF8A8A99;
/// Keypad cells in layout order: 1-9, backspace, 0, enter.
/// What the surface is currently expressing. One variant today; kept as an
/// enum because the surface still has states worth distinguishing later
/// (holding for a shell that is starting, vs holding over a dead one) and a
/// bool would have to be renamed to say so.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Digit(u8),
Backspace,
Enter,
pub enum Mood {
/// The session is locked and this daemon is holding it.
Holding,
}
#[derive(Debug, Clone, Copy)]
pub struct Rect {
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
}
impl Rect {
pub fn contains(&self, px: f64, py: f64) -> bool {
px >= self.x as f64
&& py >= self.y as f64
&& px < (self.x + self.w) as f64
&& py < (self.y + self.h) as f64
}
}
/// 5x7 glyphs, one bit per pixel, top row first.
fn glyph(key: Key) -> [u8; 7] {
match key {
Key::Digit(0) => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
Key::Digit(1) => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
Key::Digit(2) => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
Key::Digit(3) => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
Key::Digit(4) => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
Key::Digit(5) => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
Key::Digit(6) => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
Key::Digit(7) => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
Key::Digit(8) => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
Key::Digit(9) => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
Key::Digit(_) => [0; 7],
// "<" — take one off
Key::Backspace => [0b00010, 0b00100, 0b01000, 0b10000, 0b01000, 0b00100, 0b00010],
// check mark — submit
Key::Enter => [0b00000, 0b00001, 0b00010, 0b00100, 0b10100, 0b01000, 0b00000],
}
}
/// Visual feedback state, decided by the caller's state machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mood {
Entering,
Checking,
Failed,
}
pub struct Scene {
pub pin_len: usize,
pub mood: Mood,
pub pressed: Option<Key>,
/// Boot-handoff face: render nothing but the dark field. The keypad
/// appears on first input, so a normal boot (shell takes over in
/// seconds) never flashes a second PIN style at the user.
pub quiet: bool,
/// Number of failed authentication attempts. Displayed below the PIN
/// dots so the user knows how many tries they've had. Advisory only —
/// the actual lockout policy lives in PAM.
pub failed_attempts: u32,
}
/// The keypad geometry for a surface of w x h. Pure function of size so
/// input hit-testing and drawing can never disagree.
pub fn keypad_layout(w: i32, h: i32) -> Vec<(Rect, Key)> {
let keys = [
Key::Digit(1),
Key::Digit(2),
Key::Digit(3),
Key::Digit(4),
Key::Digit(5),
Key::Digit(6),
Key::Digit(7),
Key::Digit(8),
Key::Digit(9),
Key::Backspace,
Key::Digit(0),
Key::Enter,
];
// Grid occupies the lower ~55% of the surface, centered, capped width.
let grid_w = (w * 8 / 10).min(520);
let grid_h = h * 55 / 100;
let x0 = (w - grid_w) / 2;
let y0 = h - grid_h - h / 20;
let cell_w = grid_w / 3;
let cell_h = grid_h / 4;
let pad = (cell_w / 10).max(4);
keys.iter()
.enumerate()
.map(|(i, &k)| {
let col = (i % 3) as i32;
let row = (i / 3) as i32;
(
Rect {
x: x0 + col * cell_w + pad,
y: y0 + row * cell_h + pad,
w: cell_w - 2 * pad,
h: cell_h - 2 * pad,
},
k,
)
})
.collect()
}
pub fn hit_test(w: i32, h: i32, px: f64, py: f64) -> Option<Key> {
keypad_layout(w, h)
.into_iter()
.find(|(r, _)| r.contains(px, py))
.map(|(_, k)| k)
}
fn fill_rect(buf: &mut [u32], w: i32, h: i32, r: Rect, color: u32) {
let x0 = r.x.max(0);
let y0 = r.y.max(0);
let x1 = (r.x + r.w).min(w);
let y1 = (r.y + r.h).min(h);
for y in y0..y1 {
let row = (y * w) as usize;
for x in x0..x1 {
buf[row + x as usize] = color;
}
}
}
fn fill_circle(buf: &mut [u32], w: i32, h: i32, cx: i32, cy: i32, radius: i32, color: u32) {
let r2 = radius * radius;
for y in (cy - radius).max(0)..(cy + radius + 1).min(h) {
for x in (cx - radius).max(0)..(cx + radius + 1).min(w) {
let dx = x - cx;
let dy = y - cy;
if dx * dx + dy * dy <= r2 {
buf[(y * w + x) as usize] = color;
}
}
}
}
fn draw_glyph(buf: &mut [u32], w: i32, h: i32, key: Key, cell: Rect, color: u32) {
let g = glyph(key);
let scale = (cell.h / 12).max(2);
let gw = 5 * scale;
let gh = 7 * scale;
let ox = cell.x + (cell.w - gw) / 2;
let oy = cell.y + (cell.h - gh) / 2;
for (row, bits) in g.iter().enumerate() {
for col in 0..5 {
if bits & (1 << (4 - col)) != 0 {
fill_rect(
buf,
w,
h,
Rect {
x: ox + col as i32 * scale,
y: oy + row as i32 * scale,
w: scale,
h: scale,
},
color,
);
}
}
}
}
/// Render the whole scene into an ARGB8888 buffer of exactly w*h pixels.
pub fn render(buf: &mut [u32], w: i32, h: i32, scene: &Scene) {
debug_assert_eq!(buf.len(), (w * h) as usize);
let _ = scene;
let _ = (w, h);
buf.fill(BG);
if scene.quiet {
return;
}
// PIN dots, centered in the upper region above the keypad.
let dot_color = match scene.mood {
Mood::Entering => FG,
Mood::Checking => BUSY,
Mood::Failed => FAIL,
};
let shown = scene.pin_len.min(12).max(0);
let slots = shown.max(4); // always draw at least 4 positions
let radius = (w / 60).clamp(5, 12);
let gap = radius * 4;
let total = (slots as i32 - 1) * gap;
let cy = h / 5;
let cx0 = w / 2 - total / 2;
for i in 0..slots {
let cx = cx0 + i as i32 * gap;
if i < shown {
fill_circle(buf, w, h, cx, cy, radius, dot_color);
} else {
fill_circle(buf, w, h, cx, cy, radius, DOT_EMPTY);
fill_circle(buf, w, h, cx, cy, radius - 2, BG);
}
}
// Failed attempt counter: rendered as a red number below the PIN dots.
// Only visible after at least one failed attempt. Uses the same digit
// glyphs as the keypad.
if scene.failed_attempts > 0 {
let counter_y = cy + radius * 3;
let digits: Vec<u8> = scene.failed_attempts
.to_string()
.bytes()
.map(|b| b - b'0')
.collect();
let digit_w = (w / 20).clamp(12, 24);
let digit_h = digit_w * 7 / 5;
let counter_total = digits.len() as i32 * digit_w;
let counter_x0 = w / 2 - counter_total / 2;
for (i, &d) in digits.iter().enumerate() {
let cell = Rect {
x: counter_x0 + i as i32 * digit_w,
y: counter_y,
w: digit_w,
h: digit_h,
};
draw_glyph(buf, w, h, Key::Digit(d), cell, FAIL);
}
}
for (rect, key) in keypad_layout(w, h) {
let fill = if scene.pressed == Some(key) {
KEY_PRESSED
} else {
KEY_FILL
};
fill_rect(buf, w, h, rect, fill);
draw_glyph(buf, w, h, key, rect, FG);
}
}
#[cfg(test)]
@ -262,65 +48,20 @@ mod tests {
use super::*;
#[test]
fn layout_and_hit_test_agree() {
let (w, h) = (1080, 2160);
for (rect, key) in keypad_layout(w, h) {
let cx = rect.x as f64 + rect.w as f64 / 2.0;
let cy = rect.y as f64 + rect.h as f64 / 2.0;
assert_eq!(hit_test(w, h, cx, cy), Some(key));
}
assert_eq!(hit_test(w, h, 5.0, 5.0), None);
}
#[test]
fn render_fills_buffer() {
let (w, h) = (400, 800);
fn render_fills_the_whole_buffer_with_the_field() {
let (w, h) = (8, 4);
let mut buf = vec![0u32; (w * h) as usize];
render(
&mut buf,
w,
h,
&Scene { pin_len: 3, mood: Mood::Entering, pressed: Some(Key::Digit(5)), quiet: false, failed_attempts: 0 },
);
assert!(buf.iter().any(|&p| p == BG));
assert!(buf.iter().any(|&p| p == KEY_FILL));
assert!(buf.iter().any(|&p| p == KEY_PRESSED));
render(&mut buf, w, h, &Scene { mood: Mood::Holding });
assert!(buf.iter().all(|&px| px == BG));
}
#[test]
fn quiet_scene_is_bare_field() {
let (w, h) = (400, 800);
fn render_is_opaque_everywhere() {
// A lock surface that is even partly transparent shows the session
// underneath it, which is the one thing it exists to prevent.
let (w, h) = (4, 4);
let mut buf = vec![0u32; (w * h) as usize];
render(
&mut buf,
w,
h,
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: true, failed_attempts: 0 },
);
assert!(buf.iter().all(|&p| p == BG));
}
#[test]
fn failed_attempts_renders_red_digits() {
let (w, h) = (400, 800);
let mut buf_no_fail = vec![0u32; (w * h) as usize];
render(
&mut buf_no_fail,
w,
h,
&Scene { pin_len: 0, mood: Mood::Entering, pressed: None, quiet: false, failed_attempts: 0 },
);
let mut buf_with_fail = vec![0u32; (w * h) as usize];
render(
&mut buf_with_fail,
w,
h,
&Scene { pin_len: 0, mood: Mood::Failed, pressed: None, quiet: false, failed_attempts: 3 },
);
// The failed-attempts render should introduce FAIL-colored pixels
// that aren't in the zero-attempts render.
let has_fail_color = buf_with_fail.iter().zip(buf_no_fail.iter())
.any(|(&new, &old)| new == FAIL && old != FAIL);
assert!(has_fail_color, "failed attempt counter should render FAIL-colored pixels");
render(&mut buf, w, h, &Scene { mood: Mood::Holding });
assert!(buf.iter().all(|&px| px >> 24 == 0xFF));
}
}

View file

@ -1,18 +1,43 @@
//! ext-session-lock-v1 client: acquire the compositor lock, render the
//! fallback surface, run PIN → PAM, and support the abandoned-lock handoff.
//! holding surface, and support the abandoned-lock handoff.
//!
//! **This daemon locks. It does not unlock.** There is no PIN pad and no PAM
//! conversation here, deliberately, removed 2026-07-29.
//!
//! What was here was a second lockscreen: it authenticated, called
//! `unlock_and_destroy()`, and returned `Unlocked` — and then the shell, whose
//! `GlobalStates.screenLocked` is a separate bool, re-registered, was told
//! `must_lock=true`, and locked again. The protocol has a lock directive and
//! no unlock directive, so the fallback could open the compositor's lock and
//! had no way to tell the session it had. Casey hit it exactly that way: "it
//! logs in, and then qs says Locked still."
//!
//! It also was not a last resort in practice. It is raised whenever
//! `shell_alive` is false, and that flag lied for 90 minutes on 2026-07-29
//! because a scene reload's re-registration was refused and the shell gave up
//! (fixed in 45fbbea). So the "fallback" was the first thing reached, on a bad
//! signal, to do a job it could not finish. That is the fallback shape the
//! doctrine forbids: a second implementation of a job, on an unreliable
//! trigger, with different and worse behaviour than the first.
//!
//! What remains is the part that was never a fallback: something must hold the
//! session locked before any shell exists, or there is a window where the panel
//! is live and unlocked (`LOCK-DPMS-LESSONS.md` §1). A locked session with no
//! shell now stays locked and says so. Recovery is the shell coming back, or
//! the user rebooting — both louder and more honest than a keypad that cannot
//! hand control back.
//!
//! One lock session = one Wayland connection. Releasing to the shell is
//! `SessionOutcome::Released`: the connection is dropped WITHOUT unlocking,
//! which leaves the compositor holding the session locked until the shell's
//! own lock takes over (`misc:allow_session_lock_restore`). The unlocked
//! path only exists through a successful PAM conversation here.
//! own lock takes over (`misc:allow_session_lock_restore`).
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::sync::mpsc::{Receiver, Sender};
use anyhow::{bail, Context, Result};
use tracing::{info, warn};
use tracing::info;
use wayland_client::{
delegate_noop,
protocol::{
@ -36,13 +61,10 @@ use wayland_protocols::ext::session_lock::v1::client::{
ext_session_lock_v1::{self, ExtSessionLockV1},
};
use crate::sessiond::auth;
use crate::sessiond::draw::{self, hit_test, Key, Mood, Scene};
use crate::sessiond::draw::{self, Mood, Scene};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionOutcome {
/// The user authenticated at the fallback surface; session is unlocked.
Unlocked,
/// Told to hand off: connection dropped, session still locked.
Released,
/// The compositor refused or revoked the lock (another locker active).
@ -53,7 +75,6 @@ pub enum SessionOutcome {
/// worker's verdict share one channel; the pipe write wakes the poll loop.
pub enum Msg {
Release,
AuthDone(Result<(), String>),
}
pub struct LockController {
@ -122,25 +143,10 @@ struct LockState {
outputs: Vec<(u32, WlOutput)>,
surfaces: Vec<SurfaceCtx>,
seats: HashMap<u32, (WlSeat, SeatDevices)>,
pin: String,
mood: Mood,
pressed: Option<Key>,
dirty: bool,
authing: bool,
submit: Option<String>,
pointer_pos: (f64, f64),
pointer_surface: Option<u32>, // protocol id of the entered wl_surface
/// True until the user touches the surface. While quiet we render only
/// the dark field, so the boot handoff to the shell never flashes the
/// fallback keypad. The first input reveals the keypad (and is
/// swallowed — it's a "show me" gesture, not a digit).
quiet: bool,
/// Number of failed authentication attempts since the lock surface
/// appeared. Displayed on the PIN pad so the user knows how many
/// tries they've had. Reset on successful auth (the session unlocks).
/// The actual lockout policy lives in PAM (pam_faillock); this counter
/// is advisory for user visibility only.
failed_attempts: u32,
}
impl LockState {
@ -155,69 +161,22 @@ impl LockState {
outputs: Vec::new(),
surfaces: Vec::new(),
seats: HashMap::new(),
pin: String::new(),
mood: Mood::Entering,
pressed: None,
mood: Mood::Holding,
dirty: false,
authing: false,
submit: None,
pointer_pos: (0.0, 0.0),
pointer_surface: None,
quiet: true,
failed_attempts: 0,
}
}
/// First input while quiet reveals the keypad instead of registering.
/// Returns true when the input was consumed by the reveal.
fn reveal(&mut self) -> bool {
if self.quiet {
self.quiet = false;
self.dirty = true;
return true;
}
false
}
fn press(&mut self, key: Key) {
if self.authing {
return;
}
if self.mood == Mood::Failed {
self.mood = Mood::Entering;
}
self.pressed = Some(key);
match key {
Key::Digit(d) => {
if self.pin.len() < 32 {
self.pin.push((b'0' + d) as char);
}
}
Key::Backspace => {
self.pin.pop();
}
Key::Enter => {
if !self.pin.is_empty() {
self.submit = Some(std::mem::take(&mut self.pin));
}
}
}
/// Input reaches a locked session with no shell and goes nowhere, on
/// purpose. There is nothing to type into: this daemon cannot unlock.
/// Kept as a no-op rather than deleted so the surface still repaints on
/// touch — a screen that ignores you entirely is indistinguishable from a
/// hung one, and the point is to be legible, not silent.
fn press(&mut self) {
self.dirty = true;
}
fn key_from_evdev(code: u32) -> Option<Key> {
match code {
2..=10 => Some(Key::Digit(code as u8 - 1)), // KEY_1..KEY_9
11 => Some(Key::Digit(0)), // KEY_0
79..=81 => Some(Key::Digit(code as u8 - 72)), // KP7..KP9
75..=77 => Some(Key::Digit(code as u8 - 71)), // KP4..KP6
71..=73 => Some(Key::Digit(code as u8 - 70)), // KP1..KP3
82 => Some(Key::Digit(0)), // KP0
14 => Some(Key::Backspace),
28 | 96 => Some(Key::Enter),
_ => None,
}
}
fn surface_size_by_proto_id(&self, id: u32) -> Option<(i32, i32)> {
self.surfaces
@ -227,14 +186,9 @@ impl LockState {
}
}
/// Run one full lock session on the current thread. Blocks until unlock,
/// release, or denial.
pub fn run(
rx: Receiver<Msg>,
wake_read: OwnedFd,
controller_for_auth: LockController,
pam_service: String,
) -> Result<SessionOutcome> {
/// Run one full lock session on the current thread. Blocks until release or
/// denial. There is no unlock outcome — this daemon does not unlock.
pub fn run(rx: Receiver<Msg>, wake_read: OwnedFd) -> Result<SessionOutcome> {
let conn = Connection::connect_to_env().context("connecting to Wayland display")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
@ -274,8 +228,6 @@ pub fn run(
}
info!("session lock acquired");
let user = auth::session_user().map_err(|e| anyhow::anyhow!(e))?;
loop {
if state.finished {
info!("lock revoked by compositor (another locker?)");
@ -289,59 +241,16 @@ pub fn run(
state.dirty = false;
}
if let Some(pin) = state.submit.take() {
if !state.authing {
state.authing = true;
state.mood = Mood::Checking;
state.pressed = None;
state.dirty = true;
let service = pam_service.clone();
let user = user.clone();
let ctl = LockController {
tx: controller_for_auth.tx.clone(),
wake: controller_for_auth.wake.try_clone()?,
};
std::thread::spawn(move || {
let verdict = auth::authenticate(&service, &user, &pin);
ctl.send(Msg::AuthDone(verdict));
});
continue;
}
}
// Drain control/auth messages before sleeping.
let mut acted = false;
// Drain control messages before sleeping. Release is the only one
// left and it returns, so there is nothing to loop back for.
while let Ok(msg) = rx.try_recv() {
acted = true;
match msg {
Msg::Release => {
info!("releasing session lock to shell (connection drop, stays locked)");
return Ok(SessionOutcome::Released);
}
Msg::AuthDone(Ok(())) => {
info!("PAM success — unlocking session");
if let Some(lock) = &state.lock {
lock.unlock_and_destroy();
}
// The protocol requires a roundtrip after unlock so the
// compositor sees it before we disconnect; otherwise the
// exit is indistinguishable from an abandon.
queue.roundtrip(&mut state).context("unlock roundtrip")?;
return Ok(SessionOutcome::Unlocked);
}
Msg::AuthDone(Err(reason)) => {
warn!("PAM refused: {reason}");
state.authing = false;
state.mood = Mood::Failed;
state.failed_attempts += 1;
state.pin.clear();
state.dirty = true;
}
}
}
if acted {
continue;
}
queue.flush().context("flush")?;
if queue.dispatch_pending(&mut state).context("dispatch")? > 0 {
@ -419,13 +328,7 @@ fn ensure_surfaces(state: &mut LockState, qh: &QueueHandle<LockState>) {
}
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
let scene = Scene {
pin_len: state.pin.len(),
mood: state.mood,
pressed: state.pressed,
quiet: state.quiet,
failed_attempts: state.failed_attempts,
};
let scene = Scene { mood: state.mood };
let Some(shm) = state.shm.clone() else { return Ok(()) };
for ctx in &mut state.surfaces {
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
@ -612,21 +515,10 @@ impl Dispatch<WlTouch, ()> for LockState {
) {
match event {
wl_touch::Event::Down { surface, x, y, .. } => {
if state.reveal() {
return;
}
let pid = surface.id().protocol_id();
if let Some((w, h)) = state.surface_size_by_proto_id(pid) {
if let Some(key) = hit_test(w, h, x, y) {
state.press(key);
}
}
}
wl_touch::Event::Up { .. } => {
if state.pressed.take().is_some() {
state.dirty = true;
}
let _ = (surface, x, y);
state.press();
}
wl_touch::Event::Up { .. } => {}
_ => {}
}
}
@ -653,24 +545,8 @@ impl Dispatch<WlPointer, ()> for LockState {
state.pointer_pos = (surface_x, surface_y);
}
wl_pointer::Event::Button { state: WEnum::Value(st), .. } => match st {
wl_pointer::ButtonState::Pressed => {
if state.reveal() {
return;
}
if let Some(pid) = state.pointer_surface {
if let Some((w, h)) = state.surface_size_by_proto_id(pid) {
let (px, py) = state.pointer_pos;
if let Some(key) = hit_test(w, h, px, py) {
state.press(key);
}
}
}
}
wl_pointer::ButtonState::Released => {
if state.pressed.take().is_some() {
state.dirty = true;
}
}
wl_pointer::ButtonState::Pressed => state.press(),
wl_pointer::ButtonState::Released => {}
_ => {}
},
_ => {}
@ -691,17 +567,10 @@ impl Dispatch<WlKeyboard, ()> for LockState {
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => {
match st {
wl_keyboard::KeyState::Pressed => {
if state.reveal() {
return;
}
if let Some(k) = LockState::key_from_evdev(key) {
state.press(k);
}
let _ = key;
state.press();
}
wl_keyboard::KeyState::Released => {
if state.pressed.take().is_some() {
state.dirty = true;
}
}
_ => {}
}

View file

@ -10,7 +10,6 @@
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
//! same pattern as machined and secrets.
pub mod auth;
pub mod device_state;
pub mod draw;
pub mod idle;

View file

@ -34,7 +34,6 @@ pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
/// quickshell's default (`login`); the dedicated file lets the PIN stack be
/// audited separately and is shipped as root-owned system config by the OS
/// overlay, never by this crate (same stance as `souveraine-stepup`).
pub const DEFAULT_PAM_SERVICE: &str = "souveraine-sessiond";
/// How long after releasing the lock we wait for the shell's `locked_ack`
/// before deciding the shell is broken and taking the lock back.

View file

@ -60,7 +60,6 @@ struct Daemon {
/// daemon that owns the *decision* reaches the process that owns the
/// *surface*.
shell_directives: Option<UnixStream>,
pam_service: String,
/// The unified device state machine. The single authority for device
/// power state — idle, lock, doze, sleep. All actors route through it.
device_state: DeviceStateMachine,
@ -82,7 +81,7 @@ pub fn socket_path() -> Result<PathBuf> {
Ok(PathBuf::from(runtime).join(crate::sessiond::protocol::SOCKET_RELPATH))
}
pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Result<()> {
pub fn run(initial_lock: bool, socket_path: &Path) -> Result<()> {
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
@ -111,7 +110,6 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
heartbeat_gen: 0,
shell_alive: false,
shell_directives: None,
pam_service,
device_state: DeviceStateMachine::new(),
}),
cond: Condvar::new(),
@ -392,14 +390,6 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
return false;
}
};
// The auth worker feeds verdicts back into the SAME session's channel.
let auth_controller = match controller.try_clone() {
Ok(c) => c,
Err(e) => {
warn!("cannot clone lock controller: {e:#}");
return false;
}
};
d.controller = Some(controller);
d.phase = Phase::Holding;
// sessiond itself now holds the lock — boot lock, retake after a shell
@ -409,21 +399,10 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
let shared = Arc::clone(shared);
std::thread::spawn(move || {
let pam_service = shared.lock().pam_service.clone();
let outcome = lock::run(rx, wake_read, auth_controller, pam_service);
let outcome = lock::run(rx, wake_read);
let mut d = shared.lock();
d.controller = None;
match outcome {
Ok(SessionOutcome::Unlocked) => {
info!("session unlocked at fallback surface");
d.phase = if d.shell_alive {
Phase::Released
} else {
Phase::Idle
};
// PAM succeeded. Only this path may leave the locked tier.
set_tier(&mut d, DeviceState::Active, "unlocked at fallback surface");
}
Ok(SessionOutcome::Released) => {
d.phase = Phase::AwaitingShellLock;
let gen = d.heartbeat_gen;
@ -974,7 +953,6 @@ mod tests {
heartbeat_gen: 0,
shell_alive: false,
shell_directives: None,
pam_service: "test".to_string(),
device_state: DeviceStateMachine::new(),
}),
cond: Condvar::new(),