Watch
1
0
Fork
You've already forked souveraine
0

sessiond: session authority daemon — lock-before-shell, lock-past-shell-death

ext-session-lock client that locks at session start, hands the lock to the
shell over a runtime socket without an unlocked instant (abandon + restore),
and retakes it the moment the shell heartbeat drops. Spartan PIN fallback
surface, PAM via hand-rolled libpam FFI, machined-style sync server.
Shell side: SessiondBridge singleton + LockScreen initIfReady routes the
startup lock decision through the handshake.
This commit is contained in:
Fimeg 2026-07-16 20:32:46 -04:00
commit 856c6e5576
13 changed files with 1817 additions and 6 deletions

2
Cargo.lock generated
View file

@ -9137,6 +9137,8 @@ dependencies = [
"unicode-width 0.1.14",
"uuid",
"walkdir",
"wayland-client",
"wayland-protocols",
"zbus",
"zvariant",
]

View file

@ -156,6 +156,12 @@ hkdf = { version = "0.12", optional = true }
num-bigint = { version = "0.4", optional = true }
num-traits = { version = "0.2", optional = true }
# Session authority daemon (souveraine-sessiond) — ext-session-lock client.
# Pure-Rust wayland stack; PAM is hand-rolled FFI against libpam (no new dep,
# same reasoning as machined: fewer deps = fewer on-device build failures).
wayland-client = { version = "0.31", optional = true }
wayland-protocols = { version = "0.32", optional = true, features = ["client", "staging"] }
@ -187,6 +193,11 @@ required-features = ["secrets"]
name = "souveraine-machined"
path = "src/bin/souveraine-machined.rs"
[[bin]]
name = "souveraine-sessiond"
path = "src/bin/souveraine-sessiond.rs"
required-features = ["sessiond"]
[profile.release]
opt-level = 3
lto = true
@ -207,3 +218,5 @@ matrix = ["dep:matrix-sdk"]
gui = ["tuie/gui"]
# Secret Service daemon (org.freedesktop.secrets), backed by the existing SeedId.
secrets = ["dep:zbus", "dep:zvariant", "dep:aes", "dep:cbc", "dep:cipher", "dep:hkdf", "dep:num-bigint", "dep:num-traits"]
# Session authority daemon — holds ext-session-lock before/under the shell.
sessiond = ["dep:wayland-client", "dep:wayland-protocols"]

View file

@ -0,0 +1,65 @@
//! souveraine-sessiond — session authority: lock-before-shell, lock-past-
//! shell-death.
//!
//! Runs on the user tier as the seat owner. Requires nothing from the seed
//! (it signs nothing yet — capability-token verification arrives with the
//! services review); it requires only a Wayland display that speaks
//! ext-session-lock-v1 and a PAM stack for its service name.
//!
//! Startup default is fail-closed: take the session lock immediately. The
//! shell later takes over via the handoff protocol (see
//! `sessiond/protocol.rs`). `--no-initial-lock` exists for bring-up and
//! laptop experiments only.
// The sessiond module tree lives in the main crate source but is compiled
// standalone into this bin, machined-style. Mounting it as `sessiond` keeps
// the modules' own `crate::sessiond::...` paths valid.
#[path = "../sessiond/mod.rs"]
pub(crate) mod sessiond;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.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")
})?));
}
"--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,
sessiond::protocol::SOCKET_RELPATH,
);
return Ok(());
}
other => anyhow::bail!("unknown argument: {other}"),
}
}
let socket = match socket {
Some(p) => p,
None => sessiond::server::socket_path()?,
};
sessiond::server::run(pam_service, initial_lock, &socket)
}

169
src/sessiond/auth.rs Normal file
View file

@ -0,0 +1,169 @@
//! 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.
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 = [0i8; 1024];
let mut result: *mut libc::passwd = std::ptr::null_mut();
let rc = libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), 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())
}
}

253
src/sessiond/draw.rs Normal file
View file

@ -0,0 +1,253 @@
//! Software renderer for the fallback lock 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.
//!
//! All drawing targets a raw ARGB8888 buffer (little-endian: B,G,R,A).
#![allow(dead_code)]
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Digit(u8),
Backspace,
Enter,
}
#[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>,
}
/// 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);
buf.fill(BG);
// 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);
}
}
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)]
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);
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)) },
);
assert!(buf.iter().any(|&p| p == BG));
assert!(buf.iter().any(|&p| p == KEY_FILL));
assert!(buf.iter().any(|&p| p == KEY_PRESSED));
}
}

715
src/sessiond/lock.rs Normal file
View file

@ -0,0 +1,715 @@
//! ext-session-lock-v1 client: acquire the compositor lock, render the
//! fallback surface, run PIN → PAM, and support the abandoned-lock handoff.
//!
//! 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.
use std::collections::HashMap;
use std::io::Write;
use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::mpsc::{Receiver, Sender};
use anyhow::{bail, Context, Result};
use tracing::{info, warn};
use wayland_client::{
delegate_noop,
protocol::{
wl_buffer::{self, WlBuffer},
wl_compositor::WlCompositor,
wl_keyboard::{self, WlKeyboard},
wl_output::WlOutput,
wl_pointer::{self, WlPointer},
wl_registry::{self, WlRegistry},
wl_seat::{self, WlSeat},
wl_shm::{self, WlShm},
wl_shm_pool::WlShmPool,
wl_surface::WlSurface,
wl_touch::{self, WlTouch},
},
Connection, Dispatch, Proxy, QueueHandle, WEnum,
};
use wayland_protocols::ext::session_lock::v1::client::{
ext_session_lock_manager_v1::ExtSessionLockManagerV1,
ext_session_lock_surface_v1::{self, ExtSessionLockSurfaceV1},
ext_session_lock_v1::{self, ExtSessionLockV1},
};
use crate::sessiond::auth;
use crate::sessiond::draw::{self, hit_test, Key, Mood, Scene};
/// Where the boot bloom listens; poked once our surface has real content so
/// the splash dissolves onto the lock, not onto the desktop.
const SPLASH_SOCK: &str = "/run/souveraine-splash.sock";
#[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).
Denied,
}
/// Messages into a running lock session. External commands and the auth
/// worker's verdict share one channel; the pipe write wakes the poll loop.
pub enum Msg {
Release,
AuthDone(Result<(), String>),
}
pub struct LockController {
tx: Sender<Msg>,
wake: OwnedFd,
}
impl LockController {
pub fn send(&self, msg: Msg) {
let _ = self.tx.send(msg);
// A single byte; the loop drains the pipe. Failure means the loop
// is gone, which the caller observes via the join handle anyway.
let _ = nix_write(&self.wake, b"x");
}
pub fn try_clone(&self) -> Result<LockController> {
Ok(LockController { tx: self.tx.clone(), wake: self.wake.try_clone()? })
}
}
fn nix_write(fd: &OwnedFd, buf: &[u8]) -> std::io::Result<usize> {
let n = unsafe { libc::write(fd.as_raw_fd(), buf.as_ptr() as *const _, buf.len()) };
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
pub fn channel() -> Result<(LockController, Receiver<Msg>, OwnedFd)> {
let (tx, rx) = std::sync::mpsc::channel();
let mut fds = [0i32; 2];
// SAFETY: pipe2 fills two fds on success.
let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC | libc::O_NONBLOCK) };
if rc != 0 {
bail!("pipe2 failed: {}", std::io::Error::last_os_error());
}
// SAFETY: fresh fds owned exactly once each.
let (read, write) = unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
Ok((LockController { tx, wake: write }, rx, read))
}
struct SurfaceCtx {
surface: WlSurface,
lock_surface: ExtSessionLockSurfaceV1,
output_name: u32,
width: i32,
height: i32,
configured: bool,
}
#[derive(Default)]
struct SeatDevices {
touch: Option<WlTouch>,
pointer: Option<WlPointer>,
keyboard: Option<WlKeyboard>,
}
struct LockState {
compositor: Option<WlCompositor>,
shm: Option<WlShm>,
lock_mgr: Option<ExtSessionLockManagerV1>,
lock: Option<ExtSessionLockV1>,
locked: bool,
finished: bool,
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
splash_poked: bool,
}
impl LockState {
fn new() -> Self {
Self {
compositor: None,
shm: None,
lock_mgr: None,
lock: None,
locked: false,
finished: false,
outputs: Vec::new(),
surfaces: Vec::new(),
seats: HashMap::new(),
pin: String::new(),
mood: Mood::Entering,
pressed: None,
dirty: false,
authing: false,
submit: None,
pointer_pos: (0.0, 0.0),
pointer_surface: None,
splash_poked: 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));
}
}
}
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
.iter()
.find(|s| s.surface.id().protocol_id() == id)
.map(|s| (s.width, s.height))
}
}
fn poke_splash() {
std::thread::spawn(|| {
if let Ok(mut s) = UnixStream::connect(SPLASH_SOCK) {
let _ = s.write_all(b"1");
}
});
}
/// 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> {
let conn = Connection::connect_to_env().context("connecting to Wayland display")?;
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut state = LockState::new();
queue.roundtrip(&mut state).context("initial roundtrip")?;
let (Some(_), Some(_)) = (&state.compositor, &state.shm) else {
bail!("compositor is missing wl_compositor/wl_shm");
};
let Some(mgr) = &state.lock_mgr else {
bail!("compositor does not advertise ext-session-lock-v1");
};
let lock = mgr.lock(&qh, ());
state.lock = Some(lock);
queue.roundtrip(&mut state).context("lock roundtrip")?;
if state.finished {
return Ok(SessionOutcome::Denied);
}
if !state.locked {
// Compositor may answer on a later dispatch; give it one blocking
// turn before declaring failure.
queue.blocking_dispatch(&mut state).context("waiting for locked")?;
if state.finished {
return Ok(SessionOutcome::Denied);
}
if !state.locked {
bail!("compositor never acknowledged the session lock");
}
}
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?)");
return Ok(SessionOutcome::Denied);
}
ensure_surfaces(&mut state, &qh);
if state.dirty {
redraw_all(&mut state, &qh)?;
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;
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.pin.clear();
state.dirty = true;
}
}
}
if acted {
continue;
}
queue.flush().context("flush")?;
if queue.dispatch_pending(&mut state).context("dispatch")? > 0 {
continue;
}
let Some(guard) = queue.prepare_read() else {
continue;
};
let wl_fd = guard.connection_fd().as_raw_fd();
let mut fds = [
libc::pollfd { fd: wl_fd, events: libc::POLLIN, revents: 0 },
libc::pollfd { fd: wake_read.as_raw_fd(), events: libc::POLLIN, revents: 0 },
];
// SAFETY: fds array outlives the call.
let rc = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
if rc < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(err).context("poll");
}
if fds[1].revents & libc::POLLIN != 0 {
let mut buf = [0u8; 64];
// Drain the wake pipe; messages are picked up next iteration.
loop {
let n = unsafe {
libc::read(wake_read.as_raw_fd(), buf.as_mut_ptr() as *mut _, buf.len())
};
if n <= 0 {
break;
}
}
}
if fds[0].revents & libc::POLLIN != 0 {
match guard.read() {
Ok(_) => {
queue.dispatch_pending(&mut state).context("dispatch after read")?;
}
Err(wayland_client::backend::WaylandError::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e).context("reading wayland events"),
}
}
// Guard dropped here if we didn't read — that cancels cleanly.
}
}
fn ensure_surfaces(state: &mut LockState, qh: &QueueHandle<LockState>) {
if !state.locked {
return;
}
let (Some(compositor), Some(lock)) = (&state.compositor, &state.lock) else {
return;
};
let existing: Vec<u32> = state.surfaces.iter().map(|s| s.output_name).collect();
let mut created = Vec::new();
for (name, output) in &state.outputs {
if existing.contains(name) {
continue;
}
let surface = compositor.create_surface(qh, ());
let lock_surface = lock.get_lock_surface(&surface, output, qh, ());
created.push(SurfaceCtx {
surface,
lock_surface,
output_name: *name,
width: 0,
height: 0,
configured: false,
});
}
state.surfaces.extend(created);
}
fn redraw_all(state: &mut LockState, qh: &QueueHandle<LockState>) -> Result<()> {
let scene = Scene { pin_len: state.pin.len(), mood: state.mood, pressed: state.pressed };
let Some(shm) = state.shm.clone() else { return Ok(()) };
let mut drew = false;
for ctx in &mut state.surfaces {
if !ctx.configured || ctx.width <= 0 || ctx.height <= 0 {
continue;
}
draw_frame(&shm, ctx, &scene, qh)?;
drew = true;
}
if drew && !state.splash_poked {
state.splash_poked = true;
poke_splash();
}
Ok(())
}
fn draw_frame(
shm: &WlShm,
ctx: &SurfaceCtx,
scene: &Scene,
qh: &QueueHandle<LockState>,
) -> Result<()> {
let (w, h) = (ctx.width, ctx.height);
let stride = w * 4;
let size = (stride * h) as usize;
// SAFETY: fresh memfd, sized before map, unmapped after the pixel write.
let fd = unsafe {
let raw = libc::memfd_create(b"sessiond-frame\0".as_ptr() as *const _, libc::MFD_CLOEXEC);
if raw < 0 {
bail!("memfd_create: {}", std::io::Error::last_os_error());
}
let fd = OwnedFd::from_raw_fd(raw);
if libc::ftruncate(fd.as_raw_fd(), size as libc::off_t) != 0 {
bail!("ftruncate: {}", std::io::Error::last_os_error());
}
fd
};
// SAFETY: mapping the region we just sized; checked for MAP_FAILED.
unsafe {
let ptr = libc::mmap(
std::ptr::null_mut(),
size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd.as_raw_fd(),
0,
);
if ptr == libc::MAP_FAILED {
bail!("mmap: {}", std::io::Error::last_os_error());
}
let pixels = std::slice::from_raw_parts_mut(ptr as *mut u32, (w * h) as usize);
draw::render(pixels, w, h, scene);
libc::munmap(ptr, size);
}
let pool = shm.create_pool(fd.as_fd(), size as i32, qh, ());
let buffer = pool.create_buffer(0, w, h, stride, wl_shm::Format::Argb8888, qh, ());
// The buffer keeps the pool's storage alive; the pool object itself can go.
pool.destroy();
ctx.surface.attach(Some(&buffer), 0, 0);
ctx.surface.damage_buffer(0, 0, w, h);
ctx.surface.commit();
Ok(())
}
// ---- Dispatch impls ----
impl Dispatch<WlRegistry, ()> for LockState {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global { name, interface, version } => match interface.as_str() {
"wl_compositor" => {
state.compositor =
Some(registry.bind::<WlCompositor, _, _>(name, version.min(4), qh, ()));
}
"wl_shm" => {
state.shm = Some(registry.bind::<WlShm, _, _>(name, 1, qh, ()));
}
"wl_seat" => {
let seat = registry.bind::<WlSeat, _, _>(name, version.min(5), qh, ());
state.seats.insert(name, (seat, SeatDevices::default()));
}
"wl_output" => {
let output = registry.bind::<WlOutput, _, _>(name, version.min(2), qh, ());
state.outputs.push((name, output));
}
"ext_session_lock_manager_v1" => {
state.lock_mgr =
Some(registry.bind::<ExtSessionLockManagerV1, _, _>(name, 1, qh, ()));
}
_ => {}
},
wl_registry::Event::GlobalRemove { name } => {
state.outputs.retain(|(n, _)| *n != name);
state.surfaces.retain(|s| s.output_name != name);
state.seats.remove(&name);
}
_ => {}
}
}
}
impl Dispatch<ExtSessionLockV1, ()> for LockState {
fn event(
state: &mut Self,
_: &ExtSessionLockV1,
event: ext_session_lock_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ext_session_lock_v1::Event::Locked => state.locked = true,
ext_session_lock_v1::Event::Finished => state.finished = true,
_ => {}
}
}
}
impl Dispatch<ExtSessionLockSurfaceV1, ()> for LockState {
fn event(
state: &mut Self,
surface: &ExtSessionLockSurfaceV1,
event: ext_session_lock_surface_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
surface.ack_configure(serial);
if let Some(ctx) = state
.surfaces
.iter_mut()
.find(|s| s.lock_surface.id() == surface.id())
{
ctx.width = width as i32;
ctx.height = height as i32;
ctx.configured = true;
state.dirty = true;
}
}
}
}
impl Dispatch<WlSeat, ()> for LockState {
fn event(
state: &mut Self,
seat: &WlSeat,
event: wl_seat::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event {
let entry = state
.seats
.values_mut()
.find(|(s, _)| s.id() == seat.id());
let Some((seat, devices)) = entry else { return };
if caps.contains(wl_seat::Capability::Touch) && devices.touch.is_none() {
devices.touch = Some(seat.get_touch(qh, ()));
}
if caps.contains(wl_seat::Capability::Pointer) && devices.pointer.is_none() {
devices.pointer = Some(seat.get_pointer(qh, ()));
}
if caps.contains(wl_seat::Capability::Keyboard) && devices.keyboard.is_none() {
devices.keyboard = Some(seat.get_keyboard(qh, ()));
}
}
}
}
impl Dispatch<WlTouch, ()> for LockState {
fn event(
state: &mut Self,
_: &WlTouch,
event: wl_touch::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_touch::Event::Down { surface, x, y, .. } => {
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;
}
}
_ => {}
}
}
}
impl Dispatch<WlPointer, ()> for LockState {
fn event(
state: &mut Self,
_: &WlPointer,
event: wl_pointer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_pointer::Event::Enter { surface, surface_x, surface_y, .. } => {
state.pointer_surface = Some(surface.id().protocol_id());
state.pointer_pos = (surface_x, surface_y);
}
wl_pointer::Event::Leave { .. } => {
state.pointer_surface = None;
}
wl_pointer::Event::Motion { surface_x, surface_y, .. } => {
state.pointer_pos = (surface_x, surface_y);
}
wl_pointer::Event::Button { state: WEnum::Value(st), .. } => match st {
wl_pointer::ButtonState::Pressed => {
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;
}
}
_ => {}
},
_ => {}
}
}
}
impl Dispatch<WlKeyboard, ()> for LockState {
fn event(
state: &mut Self,
_: &WlKeyboard,
event: wl_keyboard::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wl_keyboard::Event::Key { key, state: WEnum::Value(st), .. } => {
match st {
wl_keyboard::KeyState::Pressed => {
if let Some(k) = LockState::key_from_evdev(key) {
state.press(k);
}
}
wl_keyboard::KeyState::Released => {
if state.pressed.take().is_some() {
state.dirty = true;
}
}
_ => {}
}
}
// Keymap carries an fd we must not leak; OwnedFd drops it here.
wl_keyboard::Event::Keymap { .. } => {}
_ => {}
}
}
}
impl Dispatch<WlBuffer, ()> for LockState {
fn event(
_: &mut Self,
buffer: &WlBuffer,
event: wl_buffer::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_buffer::Event::Release = event {
buffer.destroy();
}
}
}
delegate_noop!(LockState: ignore WlCompositor);
delegate_noop!(LockState: ignore WlSurface);
delegate_noop!(LockState: ignore WlShm);
delegate_noop!(LockState: ignore WlShmPool);
delegate_noop!(LockState: ignore WlOutput);
delegate_noop!(LockState: ignore ExtSessionLockManagerV1);

17
src/sessiond/mod.rs Normal file
View file

@ -0,0 +1,17 @@
//! souveraine-sessiond — the session authority daemon.
//!
//! The lock authority below the shell: takes ext-session-lock before the
//! shell exists at boot, hands off to the shell's rich lock surface without
//! ever passing through an unlocked instant, and takes the lock back the
//! moment the shell heartbeat drops. See
//! `SouveraineOS/docs/session-authority-boot-order.md` (Phase C) and
//! `SESSION-AUTHORITY-DOCTRINE.md` §10–§11.
//!
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
//! same pattern as machined and secrets.
pub mod auth;
pub mod draw;
pub mod lock;
pub mod protocol;
pub mod server;

93
src/sessiond/protocol.rs Normal file
View file

@ -0,0 +1,93 @@
//! Wire protocol for souveraine-sessiond — the session authority daemon.
//!
//! One JSON object per line over a Unix socket in the user's runtime dir,
//! guarded `ok`/`reason` responses, same shape as machined's protocol. The
//! socket lives on the user tier (unlike machined's system-tier socket): the
//! session authority serves exactly one seat and dies with it.
//!
//! The handoff contract with the shell:
//!
//! 1. sessiond starts before the shell and takes ext-session-lock — the
//! session is locked before any shell surface can exist.
//! 2. The shell connects, sends `shell_ready`, and KEEPS the connection
//! open. That connection is the heartbeat; EOF means the shell died.
//! 3. sessiond releases by dropping its Wayland connection WITHOUT
//! unlocking. The compositor keeps the session locked (abandoned-client
//! state) and `misc:allow_session_lock_restore` lets the shell's own
//! WlSessionLock take over. There is never an unlocked instant.
//! 4. The shell sends `locked_ack` once its lock surface is secure. If the
//! ack does not arrive in time, sessiond takes the lock back.
//! 5. Heartbeat EOF at any point → sessiond retakes the lock immediately,
//! whether or not the session was locked at the time. Fail closed.
#![allow(dead_code)]
use serde::{Deserialize, Serialize};
/// Socket path relative to `$XDG_RUNTIME_DIR`.
pub const SOCKET_RELPATH: &str = "souveraine/sessiond.sock";
/// Upper bound on one request line.
pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;
/// PAM service for the fallback unlock surface. The shell's lock uses
/// 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.
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
/// Liveness + who currently owns the session lock.
Status,
/// The shell announces itself. The connection carrying this request
/// becomes the heartbeat. If sessiond is holding the lock, it releases
/// (see module docs) and the shell must lock and then `locked_ack`.
ShellReady,
/// The shell's lock surface reached compositor-acknowledged `secure`.
LockedAck,
/// Ask sessiond to take the session lock itself. Refused while a live
/// shell owns steady state — the shell's lock IPC is the front door.
Lock,
}
/// What sessiond currently is, as reported by `status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
/// sessiond holds ext-session-lock and renders the fallback surface.
Holding,
/// Lock released to the shell; heartbeat live; ack not yet seen.
AwaitingShellLock,
/// Shell owns steady state (heartbeat live, ack seen).
Released,
/// No shell heartbeat and not holding (initial-lock disabled or the
/// user unlocked at the fallback surface with no shell to hand off to).
Idle,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips() {
let req: Request = serde_json::from_str(r#"{"op":"shell_ready"}"#).unwrap();
assert!(matches!(req, Request::ShellReady));
let req: Request = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
assert!(matches!(req, Request::Status));
}
#[test]
fn phase_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&Phase::AwaitingShellLock).unwrap(),
r#""awaiting_shell_lock""#
);
}
}

317
src/sessiond/server.rs Normal file
View file

@ -0,0 +1,317 @@
//! Orchestration for souveraine-sessiond: the control socket, the shell
//! heartbeat, and the retake state machine.
//!
//! Sync, std-only, thread-per-connection — the same auditable shape as
//! machined's server. The daemon's whole job is to make sure that at every
//! moment either (a) it holds the session lock, (b) a live shell does, or
//! (c) the user deliberately unlocked at the fallback surface. Transitions
//! between those states are the only logic here.
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::protocol::{Phase, Request, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES};
struct Daemon {
phase: Phase,
/// Present exactly while a lock-session thread is running.
controller: Option<LockController>,
/// Bumped on every shell registration; a heartbeat EOF only counts if
/// its generation is still current (a restarted shell supersedes).
heartbeat_gen: u64,
shell_alive: bool,
pam_service: String,
}
pub struct Shared {
state: Mutex<Daemon>,
cond: Condvar,
}
impl Shared {
fn lock(&self) -> std::sync::MutexGuard<'_, Daemon> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
}
pub fn socket_path() -> Result<PathBuf> {
let runtime = std::env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR is not set")?;
Ok(PathBuf::from(runtime).join(crate::sessiond::protocol::SOCKET_RELPATH))
}
pub fn run(pam_service: String, 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()))?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).ok();
}
if socket_path.exists() {
if UnixStream::connect(socket_path).is_ok() {
anyhow::bail!(
"another souveraine-sessiond is already serving {}",
socket_path.display()
);
}
warn!("removing stale socket at {}", socket_path.display());
std::fs::remove_file(socket_path)?;
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("binding {}", socket_path.display()))?;
// Same-user only today. When agents get their own accounts, this widens
// to a group grant deliberately, machined-style — never by default.
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))?;
let shared = Arc::new(Shared {
state: Mutex::new(Daemon {
phase: Phase::Idle,
controller: None,
heartbeat_gen: 0,
shell_alive: false,
pam_service,
}),
cond: Condvar::new(),
});
if initial_lock {
spawn_lock_session(&shared);
}
info!("sessiond serving on {}", socket_path.display());
for stream in listener.incoming() {
match stream {
Ok(s) => {
let shared = Arc::clone(&shared);
std::thread::spawn(move || handle_connection(s, shared));
}
Err(e) => warn!("accept failed: {e}"),
}
}
Ok(())
}
/// Start a lock session unless one is already running. Returns whether a
/// session is running when we leave (either ours or a pre-existing one).
fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
let mut d = shared.lock();
if d.controller.is_some() {
return true;
}
let (controller, rx, wake_read) = match lock::channel() {
Ok(t) => t,
Err(e) => {
warn!("cannot create lock channel: {e:#}");
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;
drop(d);
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 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 };
}
Ok(SessionOutcome::Released) => {
d.phase = Phase::AwaitingShellLock;
let gen = d.heartbeat_gen;
drop(d);
start_ack_timer(&shared, gen);
shared.cond.notify_all();
return;
}
Ok(SessionOutcome::Denied) => {
info!("lock denied/revoked — another locker owns the session");
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
}
Err(e) => {
warn!("lock session failed: {e:#}");
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
}
}
drop(d);
shared.cond.notify_all();
});
true
}
/// If the shell never acks its lock after a handoff, take the lock back.
/// A retake against a shell that DID lock is refused by the compositor
/// (Denied outcome), so this is safe against a late or lost ack.
fn start_ack_timer(shared: &Arc<Shared>, gen: u64) {
let shared = Arc::clone(shared);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(LOCKED_ACK_TIMEOUT_SECS));
let d = shared.lock();
if d.phase == Phase::AwaitingShellLock && d.heartbeat_gen == gen {
warn!("shell never confirmed its lock after handoff — retaking");
drop(d);
spawn_lock_session(&shared);
}
});
}
fn refusal(reason: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "reason": reason })
}
fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
writer.write_all(value.to_string().as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()
}
fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
let mut writer = match stream.try_clone() {
Ok(w) => w,
Err(e) => {
warn!("could not clone stream: {e}");
return;
}
};
let mut reader = BufReader::new(stream);
// Whether THIS connection is the registered shell heartbeat, and under
// which generation.
let mut heartbeat: Option<u64> = None;
loop {
let mut line = String::new();
match (&mut reader).take(MAX_REQUEST_BYTES).read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => {}
Err(e) => {
warn!("read error on control socket: {e}");
break;
}
}
if line.len() as u64 >= MAX_REQUEST_BYTES {
let _ = respond(&mut writer, refusal("request exceeds size limit"));
break;
}
let line = line.trim();
if line.is_empty() {
continue;
}
let response = match serde_json::from_str::<Request>(line) {
Ok(req) => handle_request(req, &shared, &mut heartbeat),
Err(e) => refusal(&format!("malformed request: {e}")),
};
if respond(&mut writer, response).is_err() {
break;
}
}
// Connection gone. If it was the live heartbeat, the shell died —
// fail closed: take the session lock regardless of prior lock state.
if let Some(gen) = heartbeat {
let mut d = shared.lock();
if d.heartbeat_gen == gen && d.shell_alive {
d.shell_alive = false;
let holding = d.controller.is_some();
drop(d);
warn!("shell heartbeat lost — locking the session");
if !holding {
spawn_lock_session(&shared);
}
}
}
}
fn handle_request(
req: Request,
shared: &Arc<Shared>,
heartbeat: &mut Option<u64>,
) -> serde_json::Value {
match req {
Request::Status => {
let d = shared.lock();
serde_json::json!({
"ok": true,
"phase": d.phase,
"shell_alive": d.shell_alive,
})
}
Request::ShellReady => {
let mut d = shared.lock();
d.heartbeat_gen += 1;
d.shell_alive = true;
let gen = d.heartbeat_gen;
*heartbeat = Some(gen);
let holding = d.controller.is_some();
if holding {
if let Some(ctl) = &d.controller {
ctl.send(Msg::Release);
}
// Wait until the lock-session thread actually dropped its
// Wayland connection; only then may the shell lock (the
// compositor refuses a second locker while ours is alive).
let deadline = Duration::from_secs(5);
let (_guard, timed_out) = shared
.cond
.wait_timeout_while(d, deadline, |d| d.controller.is_some())
.unwrap_or_else(|e| e.into_inner());
if timed_out.timed_out() {
return refusal("lock session did not release in time");
}
info!("handoff: lock released to shell (gen {gen})");
serde_json::json!({ "ok": true, "held": true, "must_lock": true })
} else {
info!("shell registered (gen {gen}), no lock held");
if d.phase == Phase::Idle {
d.phase = Phase::Released;
}
serde_json::json!({ "ok": true, "held": false, "must_lock": false })
}
}
Request::LockedAck => {
let mut d = shared.lock();
if heartbeat.is_none() {
return refusal("locked_ack from a connection that never sent shell_ready");
}
d.phase = Phase::Released;
info!("shell lock confirmed");
serde_json::json!({ "ok": true })
}
Request::Lock => {
let d = shared.lock();
match d.phase {
Phase::Holding => serde_json::json!({ "ok": true, "already": true }),
Phase::Released | Phase::AwaitingShellLock if d.shell_alive => {
refusal("a live shell owns the session lock; use the shell's lock IPC")
}
_ => {
drop(d);
if spawn_lock_session(shared) {
serde_json::json!({ "ok": true })
} else {
refusal("could not start a lock session")
}
}
}
}
}
}

View file

@ -38,6 +38,7 @@ services/LockContentPolicy.qml souveraine/services/LockContentPolicy.qml
services/WallpaperAssets.qml souveraine/services/WallpaperAssets.qml
services/ConflictKiller.qml souveraine/services/ConflictKiller.qml
services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
services/StepUpAuth.qml souveraine/services/StepUpAuth.qml
services/SessionAudit.qml souveraine/services/SessionAudit.qml
modules/ii/sidebarLeft/SidebarLeft.qml souveraine/modules/ii/sidebarLeft/SidebarLeft.qml

View file

@ -161,13 +161,25 @@ Scope {
}
}
property bool initDone: false
function initIfReady() {
if (!Config.ready || !Persistent.ready) return;
if (Config.options.lock.launchOnStartup && (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked)) {
root.lock();
} else {
KeyringStorage.fetchKeyringData();
}
if (!Config.ready || !Persistent.ready || root.initDone) return;
root.initDone = true;
// Register with sessiond (and start the heartbeat) before deciding
// the startup lock state. If sessiond holds the session lock, it
// releases on our shell_ready and we MUST lock immediately — the
// compositor is holding an abandoned lock for us to inherit
// (misc:allow_session_lock_restore). No sessiond = cb(false) and
// the legacy launchOnStartup rules decide alone.
SessiondBridge.shellReady(function(mustLock) {
if (mustLock
|| (Config.options.lock.launchOnStartup
&& (Persistent.isNewHyprlandInstance || Persistent.states.lock.locked))) {
root.lock();
} else {
KeyringStorage.fetchKeyringData();
}
});
}
Connections {
target: Config

View file

@ -0,0 +1,153 @@
// Shell side of the souveraine-sessiond handoff protocol.
//
// sessiond takes ext-session-lock before the shell exists; this bridge is
// how the shell (a) announces itself and takes the lock over, (b) keeps the
// heartbeat connection open so sessiond can retake the lock the moment the
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
//
// The session is never unlocked during the handoff: sessiond abandons its
// lock (connection drop) and misc:allow_session_lock_restore lets our
// WlSessionLock inherit the locked session. See
// souveraine/src/sessiond/protocol.rs — that file is the contract.
//
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
// the legacy launchOnStartup path decides alone.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
Singleton {
id: root
// Registered = shell_ready was answered ok on the CURRENT connection.
property bool registered: false
// sessiond held the session lock when we registered; we owe it a lock
// and a locked_ack.
property bool oweLock: false
property bool ackSent: false
property var pendingReady: null // callback awaiting the shell_ready response
function load() {}
// Announce the shell. cb(mustLock) fires exactly once: mustLock true
// means sessiond was holding and the session IS locked — the shell must
// raise its own lock surface immediately.
function shellReady(cb) {
if (!sock.connected) {
cb(false);
return;
}
if (root.pendingReady) {
// Only one handshake in flight; late duplicate answers false.
cb(false);
return;
}
root.pendingReady = cb;
readyTimeout.restart();
sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
sock.flush();
}
function sendLockedAck() {
if (!sock.connected || !root.registered || root.ackSent) return;
root.ackSent = true;
sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
sock.flush();
console.log("[sessiond-bridge] locked_ack sent");
}
Timer {
id: readyTimeout
interval: 3000
repeat: false
onTriggered: {
if (root.pendingReady) {
console.log("[sessiond-bridge] shell_ready timed out — proceeding without sessiond");
const cb = root.pendingReady;
root.pendingReady = null;
cb(false);
}
}
}
// Reconnect: sessiond may restart (upgrade) or start late. While
// connected this timer is idle; the Socket does not retry by itself.
Timer {
id: reconnect
interval: 5000
repeat: true
running: !sock.connected
onTriggered: sock.connected = true
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
connected: true
onSocketConnected: {
console.log("[sessiond-bridge] connected");
// A fresh connection is never registered yet. If we were
// registered before (sessiond restarted underneath us),
// re-register right away so the heartbeat resumes.
const wasRegistered = root.registered;
root.registered = false;
root.ackSent = false;
if (wasRegistered) {
root.shellReady(function(mustLock) {
if (mustLock) {
// sessiond locked while we were disconnected (it
// treats our EOF as shell death). Mirror it.
GlobalStates.screenLocked = true;
}
});
}
}
onSocketDisconnected: {
console.log("[sessiond-bridge] disconnected");
root.registered = false;
root.pendingReady = null;
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.log("[sessiond-bridge] unparseable reply: " + message);
return;
}
if (root.pendingReady) {
// The only request we await a response for.
const cb = root.pendingReady;
root.pendingReady = null;
readyTimeout.stop();
if (reply.ok) {
root.registered = true;
root.oweLock = reply.must_lock === true;
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
cb(root.oweLock);
} else {
console.log("[sessiond-bridge] shell_ready refused: " + reply.reason);
cb(false);
}
}
}
}
}
// The compositor acknowledged OUR lock surface — tell sessiond the
// handoff is complete. Gated on secure, not the request, per doctrine.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
root.sendLockedAck();
}
}
}
}

View file

@ -30,6 +30,7 @@ ShellRoot {
Component.onCompleted: {
MaterialThemeLoader.reapplyTheme()
SessiondBridge.load()
Hyprsunset.load()
FirstRunExperience.load()
ConflictKiller.load()