git.lucas.co / cce-authenticator
login authentication (PAM + fingerprint)
git clone https://git.lucas.co/cce-authenticator.git

src/main.rs (63.9K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
   3 use cce_ui::widget::{
   4     Button, WidgetHost, ElementState, MouseButton, Key, NamedKey, KeyEvent, TextBox,
   5     MouseScrollDelta
   6 };
   7 use futures::StreamExt;
   8 use std::sync::{Arc, Mutex};
   9 use std::io::Write;
  10 use tokio::sync::oneshot;
  11 use std::ops::Deref;
  12 
  13 const ACCENT: [f32; 4] = [0.30, 0.50, 0.32, 1.0];
  14 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
  15 /// `TOGGLE_OFF` for a control that is not a control — see `fingerprint_interactive`.
  16 const TOGGLE_INERT: [f32; 4] = [0.11, 0.11, 0.15, 1.0];
  17 
  18 
  19 #[derive(Clone, Debug)]
  20 enum AuthResult {
  21     Success,
  22     ExitWindow,
  23     Failure(String),
  24     FingerprintStatus(String),
  25 }
  26 
  27 #[derive(Clone, Debug)]
  28 enum AppMessage {
  29     PasswordVerify,
  30     FingerprintScanStart,
  31     AuthDone(AuthResult),
  32     Cancel,
  33     PromptReceived(String, bool), // (prompt, echo)
  34     StatusReceived(String, bool), // (message, is_error)
  35 }
  36 
  37 struct GuiRequest {
  38     username: String,
  39     message: String,
  40     cookie: String,
  41     tx_result: oneshot::Sender<Result<(), String>>,
  42 }
  43 
  44 static ACTIVE_REQUEST: Mutex<Option<GuiRequest>> = Mutex::new(None);
  45 static ACTIVE_SENDER: Mutex<Option<calloop::channel::Sender<AppMessage>>> = Mutex::new(None);
  46 
  47 /// Cancellation state for every cookie polkitd has handed us, not just the one
  48 /// whose window is up. Requests queue (the GUI runs on the main thread, one at a
  49 /// time), so a CancelAuthentication can arrive for a cookie whose window has not
  50 /// opened yet — or has not finished starting. A single active-cookie slot dropped
  51 /// both of those on the floor and stranded the dialog.
  52 struct CookieState {
  53     active: Option<String>,
  54     cancelled: Vec<String>,
  55 }
  56 
  57 static COOKIES: Mutex<CookieState> = Mutex::new(CookieState {
  58     active: None,
  59     cancelled: Vec::new(),
  60 });
  61 
  62 /// Whether the simulated authenticator may stand in for PAM.
  63 ///
  64 /// Simulation reports success on its own, and in polkit mode that success is handed to
  65 /// polkitd as `Ok(())` — granting the privileged action with nothing checked. So a live
  66 /// request vetoes it outright, whatever asked for it: `CCE_AUTH_SIMULATE` once won here,
  67 /// which turned every pkexec in the desktop into a silent auto-yes.
  68 ///
  69 /// Gate on the dangerous state, never on an allowlist of the ways in. Kept as a pure
  70 /// function of its inputs so the veto is settled by the test suite rather than by
  71 /// arranging a live authentication bypass to check it.
  72 fn simulate_allowed(polkit_mode: bool, env_requested: bool, uid: u32) -> bool {
  73     !polkit_mode && (env_requested || uid == 0)
  74 }
  75 
  76 /// Shorten a caption to what a column `width` logical px wide can show, breaking at
  77 /// a word boundary.
  78 ///
  79 /// The fingerprint column's captions are arbitrary-length strings from PAM, fprintd
  80 /// and D-Bus errors (`No reader: <zbus error>`). The paint API clips to a rect, and a
  81 /// clip rect is not a layout strategy — it cuts mid-word and gives no hint that
  82 /// anything is missing. There is no cheap shaping call here to measure exactly, so the
  83 /// budget comes from the advance observed at this size (~4.15 px/char at 9pt) and is
  84 /// deliberately a few characters short: erring low only moves the ellipsis earlier.
  85 ///
  86 /// The width is a parameter because the column is sized from the window — it was a
  87 /// hardcoded 220px back when the dialog drew a fixed-size card inside itself.
  88 fn fit_column(text: &str, width: f32) -> String {
  89     const PX_PER_CHAR: f32 = 4.15;
  90     let max_chars = ((width / PX_PER_CHAR) as usize).max(8);
  91     if text.chars().count() <= max_chars {
  92         return text.to_string();
  93     }
  94     let head: String = text.chars().take(max_chars - 1).collect();
  95     let cut = head.rfind(' ').unwrap_or(head.len());
  96     format!("{}…", head[..cut].trim_end())
  97 }
  98 
  99 /// A toolkit color as the `[u8; 3]` the text prims take.
 100 fn text_rgb(c: [f32; 4]) -> [u8; 3] {
 101     [
 102         (c[0] * 255.0).round().clamp(0.0, 255.0) as u8,
 103         (c[1] * 255.0).round().clamp(0.0, 255.0) as u8,
 104         (c[2] * 255.0).round().clamp(0.0, 255.0) as u8,
 105     ]
 106 }
 107 
 108 /// PAM service backing the standalone password check. Polkit mode never reaches it:
 109 /// `polkit-agent-helper-1` runs its own `polkit-1` service inside the helper process.
 110 const PAM_SERVICE: &str = "system-local-login";
 111 
 112 /// Who we authenticate as when nothing more specific is known.
 113 ///
 114 /// The passwd database is asked first and `$USER` is only a fallback, which is the
 115 /// opposite of what this used to do: a user unit's environment is whatever
 116 /// `systemctl --user import-environment` was told to carry, so `$USER` can simply be
 117 /// absent here — and the old code answered that by authenticating as a login name
 118 /// hardcoded to this developer's machine.
 119 fn current_username() -> Option<String> {
 120     users::get_current_username()
 121         .map(|name| name.to_string_lossy().into_owned())
 122         .or_else(|| std::env::var("USER").ok())
 123         .filter(|name| !name.is_empty())
 124 }
 125 
 126 /// A polkit cookie as the log shows it: its first few characters, enough to
 127 /// tell requests apart in a log, never the whole one-time token. Until
 128 /// 2026-10-02 every request's full cookie went into the journal at info.
 129 fn cookie_tag(cookie: &str) -> String {
 130     let head: String = cookie.chars().take(6).collect();
 131     if head.len() < cookie.len() { format!("{head}…") } else { head }
 132 }
 133 
 134 /// Consume a pending cancellation for `cookie`, reporting whether one was there.
 135 fn take_cancelled(cookie: &str) -> bool {
 136     let mut st = COOKIES.lock().unwrap();
 137     match st.cancelled.iter().position(|c| c == cookie) {
 138         Some(pos) => {
 139             st.cancelled.remove(pos);
 140             true
 141         }
 142         None => false,
 143     }
 144 }
 145 
 146 struct AuthenticatorApp {
 147     password_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 148     verify_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 149     cancel_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 150     fingerprint_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 151     
 152     status_msg: String,
 153     status_is_error: bool,
 154     status_is_success: bool,
 155     
 156     fingerprint_msg: String,
 157     fingerprint_active: bool,
 158     fingerprint_success: bool,
 159     /// Whether the fingerprint button does anything if pressed. In polkit mode it
 160     /// does not: `pam_fprintd` inside the helper owns the reader, and whether it is
 161     /// even in the stack is PAM's business, not ours — so the column stays dimmed
 162     /// and unclaimed until a PAM message shows it is asking for a finger.
 163     fingerprint_interactive: bool,
 164     
 165     rx_auth: std::sync::mpsc::Receiver<AuthResult>,
 166     tx_auth: std::sync::mpsc::Sender<AuthResult>,
 167     
 168     width: f32,
 169     height: f32,
 170     
 171     simulate_mode: bool,
 172     glow_timer: f32,
 173     
 174     polkit_mode: bool,
 175     helper_stdin: Option<std::process::ChildStdin>,
 176     shared_child: Option<Arc<Mutex<Option<std::process::Child>>>>,
 177     /// Identity and cookie of the in-flight polkit request, kept so a failed
 178     /// attempt can start a fresh helper — see `RETRIES`.
 179     username: String,
 180     cookie: String,
 181     retries_left: u32,
 182     sender: calloop::channel::Sender<AppMessage>,
 183     ui_context: cce_ui::context::UiContext,
 184 }
 185 
 186 /// Extra helper runs allowed after the first attempt fails. `polkit-agent-helper-1`
 187 /// runs one PAM conversation and exits, so a retry means a new process; bounding the
 188 /// count also keeps a helper that fails *instantly* (a cookie polkitd no longer
 189 /// recognises) from spawning in a tight loop.
 190 const RETRIES: u32 = 2;
 191 
 192 /// Start `polkit-agent-helper-1` for one attempt, returning its stdin and a handle
 193 /// the Cancel path can kill. The reader thread translates the helper's PAM protocol
 194 /// into AppMessages and reports the exit status as the attempt's verdict.
 195 fn spawn_helper(
 196     username: &str,
 197     cookie: &str,
 198     sender: &calloop::channel::Sender<AppMessage>,
 199 ) -> std::io::Result<(std::process::ChildStdin, Arc<Mutex<Option<std::process::Child>>>)> {
 200     let mut child = std::process::Command::new("/usr/lib/polkit-1/polkit-agent-helper-1")
 201         .arg(username)
 202         .arg(cookie)
 203         .stdin(std::process::Stdio::piped())
 204         .stdout(std::process::Stdio::piped())
 205         .stderr(std::process::Stdio::inherit())
 206         .spawn()?;
 207 
 208     let missing = |what| std::io::Error::new(std::io::ErrorKind::Other, what);
 209     let stdin = child.stdin.take().ok_or_else(|| missing("helper stdin"))?;
 210     let stdout = child.stdout.take().ok_or_else(|| missing("helper stdout"))?;
 211 
 212     let child_arc = Arc::new(Mutex::new(Some(child)));
 213     let reader_arc = child_arc.clone();
 214     let sender = sender.clone();
 215 
 216     std::thread::spawn(move || {
 217         use std::io::BufRead;
 218         let reader = std::io::BufReader::new(stdout);
 219         for line in reader.lines().map_while(Result::ok) {
 220             if let Some(prompt) = line.strip_prefix("PAM_PROMPT_ECHO_OFF ") {
 221                 let _ = sender.send(AppMessage::PromptReceived(prompt.to_string(), false));
 222             } else if let Some(prompt) = line.strip_prefix("PAM_PROMPT_ECHO_ON ") {
 223                 let _ = sender.send(AppMessage::PromptReceived(prompt.to_string(), true));
 224             } else if let Some(msg) = line.strip_prefix("PAM_ERROR_MSG ") {
 225                 let _ = sender.send(AppMessage::StatusReceived(msg.to_string(), true));
 226             } else if let Some(msg) = line.strip_prefix("PAM_TEXT_INFO ") {
 227                 let _ = sender.send(AppMessage::StatusReceived(msg.to_string(), false));
 228             }
 229         }
 230 
 231         // Cancel takes the child to kill it; finding None here means this attempt
 232         // was abandoned deliberately and owes no verdict.
 233         let mut lock = reader_arc.lock().unwrap();
 234         if let Some(mut child) = lock.take() {
 235             drop(lock);
 236             match child.wait() {
 237                 Ok(status) if status.success() => {
 238                     let _ = sender.send(AppMessage::AuthDone(AuthResult::Success));
 239                 }
 240                 _ => {
 241                     let _ = sender.send(AppMessage::AuthDone(AuthResult::Failure(
 242                         "Authentication failed".to_string(),
 243                     )));
 244                 }
 245             }
 246         }
 247     });
 248 
 249     Ok((stdin, child_arc))
 250 }
 251 
 252 impl Application for AuthenticatorApp {
 253     type Message = AppMessage;
 254 
 255     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 256         Some(&self.ui_context)
 257     }
 258 
 259     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 260         // The app keeps calloop's sender; `AppSender` converts into it.
 261         let sender: calloop::channel::Sender<Self::Message> = sender.into();
 262         let password_box = TextBox::new(String::new())
 263             .with_password(true)
 264             .with_label("PASSWORD");
 265             
 266         let btn_h = cce_ui::layout::button_height();
 267         let verify_btn = Button::new(0.0, 0.0, 100.0, btn_h).with_label("Verify");
 268         let cancel_btn = Button::new(0.0, 0.0, 100.0, btn_h).with_label("Cancel");
 269         let mut fingerprint_btn = Button::new(0.0, 0.0, 120.0, 120.0).with_label("Scan");
 270         
 271         let (tx_auth, rx_auth) = std::sync::mpsc::channel();
 272         
 273         let active_req = ACTIVE_REQUEST.lock().unwrap();
 274         let polkit_mode = active_req.is_some();
 275         
 276         let mut helper_stdin = None;
 277         let mut shared_child = None;
 278         let mut status_msg = "Authenticate using password or fingerprint".to_string();
 279         // Simulation stands in for PAM, and a simulated success answers polkitd with
 280         // Ok(()) — i.e. grants the privileged action having checked no credential at
 281         // all. So it is gated on the unsafe state (a real request is in flight), not
 282         // on how simulation was asked for: with a request present it is off, full
 283         // stop, whatever CCE_AUTH_SIMULATE says. The password and fingerprint paths
 284         // below exclude it a second time on the same condition.
 285         let simulate_mode = simulate_allowed(
 286             polkit_mode,
 287             std::env::var("CCE_AUTH_SIMULATE").is_ok(),
 288             users::get_current_uid(),
 289         );
 290 
 291         let mut username = String::new();
 292         let mut cookie = String::new();
 293 
 294         // In polkit mode the button reports the reader rather than driving it, so it
 295         // should not read as something to press.
 296         if polkit_mode {
 297             fingerprint_btn.set_label("Reader");
 298         }
 299 
 300         if let Some(ref req) = *active_req {
 301             if std::env::var("CCE_AUTH_SIMULATE").is_ok() {
 302                 log::warn!(
 303                     "CCE_AUTH_SIMULATE is set and is being IGNORED: a real polkit request is in flight"
 304                 );
 305             }
 306             status_msg = req.message.clone();
 307             username = req.username.clone();
 308             cookie = req.cookie.clone();
 309 
 310             match spawn_helper(&username, &cookie, &sender) {
 311                 Ok((stdin, child)) => {
 312                     helper_stdin = Some(stdin);
 313                     shared_child = Some(child);
 314                 }
 315                 Err(e) => {
 316                     status_msg = format!("Failed to spawn helper: {}", e);
 317                 }
 318             }
 319         }
 320 
 321         // Store active sender for Cancel D-Bus calls
 322         *ACTIVE_SENDER.lock().unwrap() = Some(sender.clone());
 323 
 324         // A cancel that landed while this window was starting found no sender to
 325         // deliver to; claim it now that there is one.
 326         if polkit_mode && take_cancelled(&cookie) {
 327             log::info!("cookie {} was cancelled while its window was starting", cookie_tag(&cookie));
 328             let _ = sender.send(AppMessage::Cancel);
 329         }
 330 
 331         // The context owns the widgets; the app keeps their handles.
 332         let mut ui_context = cce_ui::context::UiContext::new();
 333         let mut app = Self {
 334             password_box: ui_context.insert(password_box),
 335             verify_btn: ui_context.insert(verify_btn),
 336             cancel_btn: ui_context.insert(cancel_btn),
 337             fingerprint_btn: ui_context.insert(fingerprint_btn),
 338             
 339             status_msg,
 340             status_is_error: false,
 341             status_is_success: false,
 342             
 343             fingerprint_msg: if polkit_mode {
 344                 "Handled by PAM — follow the prompt".to_string()
 345             } else {
 346                 "Fingerprint scanner ready".to_string()
 347             },
 348             fingerprint_active: false,
 349             fingerprint_success: false,
 350             fingerprint_interactive: !polkit_mode,
 351             
 352             rx_auth,
 353             tx_auth,
 354             
 355             width: 800.0,
 356             height: 600.0,
 357             
 358             simulate_mode,
 359             glow_timer: 0.0,
 360             
 361             polkit_mode,
 362             helper_stdin,
 363             shared_child,
 364             username,
 365             cookie,
 366             retries_left: RETRIES,
 367             sender: sender.clone(),
 368             ui_context,
 369         };
 370         
 371         let tx = app.tx_auth.clone();
 372         if app.simulate_mode {
 373             app.status_msg = "SIMULATION MODE: use password 'password' or click fingerprint".to_string();
 374             app.fingerprint_msg = "Click fingerprint sensor to scan".to_string();
 375             let tx_clone = tx.clone();
 376             tokio::spawn(async move {
 377                 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
 378                 log::info!("Auto-authenticating in simulation mode...");
 379                 let _ = tx_clone.send(AuthResult::Success);
 380             });
 381         } else if !app.polkit_mode {
 382             let Some(username) = current_username() else {
 383                 app.fingerprint_msg = "Cannot determine the current user".to_string();
 384                 return app;
 385             };
 386             tokio::spawn(async move {
 387                 if let Err(e) = run_dbus_fingerprint(username, tx.clone()).await {
 388                     let _ = tx.send(AuthResult::FingerprintStatus(format!("No reader: {}", e)));
 389                     tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
 390                     let _ = tx.send(AuthResult::FingerprintStatus("Simulation mode active. Click icon to verify.".to_string()));
 391                 }
 392             });
 393         } else {
 394             // In Polkit mode, pam_fprintd.so running inside polkit-agent-helper-1
 395             // will handle claiming and verifying the fingerprint reader natively.
 396         }
 397         
 398         app
 399     }
 400 
 401     fn settings(&self) -> WindowSettings {
 402         WindowSettings {
 403             title: "CCE Authenticator".to_string(),
 404             app_id: "cce-authenticator".to_string(),
 405             width: 640,
 406             // Sized to the content now that there is no inset card: title band,
 407             // two column wells, status shelf. At 400 the wells ran ~70px past
 408             // anything in them and the dialog read as half empty.
 409             height: 360,
 410             fullscreen: false,
 411             min_size: Some((560, 340)),
 412         }
 413     }
 414 
 415     /// A session modal is a utility window: two fixed columns and a status
 416     /// shelf, nothing worth resizing, and nothing it should ever inherit.
 417     ///
 418     /// The size matters more here than for an ordinary tool. The compositor
 419     /// restores a saved size per app_id over the client's request, so before
 420     /// this the prompt came back at whatever it was last left at — and a
 421     /// prompt is not something the user chose to open at a size, it is
 422     /// something that appeared. Utility means no geometry is saved for it, so
 423     /// none can be restored: every prompt is the shape this dialog asks for.
 424     /// It also drops the resize affordance (the whole border band moves it)
 425     /// and keeps the window out of the overview displacement.
 426     ///
 427     /// Placement stays the compositor's — `Window::try_center_on_view` centers
 428     /// this app_id on the current view, and it is exempt from Utility's
 429     /// self-sizing for position only.
 430     fn utility(&self) -> bool {
 431         true
 432     }
 433 
 434     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
 435         *needs_rebuild = true;
 436         match msg {
 437             AppMessage::PasswordVerify => {
 438                 if self.status_is_success { return; }
 439                 let password = self.ui_context[self.password_box].text.clone();
 440                 self.status_msg = "Verifying password...".to_string();
 441                 self.status_is_error = false;
 442                 
 443                 // Polkit mode answers through the helper or not at all — never through
 444                 // the local PAM/simulation branch, which can report success on its own.
 445                 if self.polkit_mode {
 446                     match self.helper_stdin {
 447                         Some(ref mut stdin) => {
 448                             let _ = writeln!(stdin, "{}", password);
 449                             let _ = stdin.flush();
 450                             self.ui_context[self.password_box].text.clear();
 451                         }
 452                         None => {
 453                             self.status_msg =
 454                                 "No authentication helper — press Escape to cancel".to_string();
 455                             self.status_is_error = true;
 456                         }
 457                     }
 458                 } else {
 459                     let tx = self.tx_auth.clone();
 460                     let simulate = self.simulate_mode;
 461                     tokio::spawn(async move {
 462                         if simulate {
 463                             tokio::time::sleep(std::time::Duration::from_millis(800)).await;
 464                             if password == "password" || password.is_empty() {
 465                                 let _ = tx.send(AuthResult::Success);
 466                             } else {
 467                                 let _ = tx.send(AuthResult::Failure("Invalid password (use 'password' or empty)".to_string()));
 468                             }
 469                         } else {
 470                             let Some(username) = current_username() else {
 471                                 let _ = tx.send(AuthResult::Failure(
 472                                     "Cannot determine the current user".to_string(),
 473                                 ));
 474                                 return;
 475                             };
 476                             match tokio::task::spawn_blocking(move || run_pam_auth(&username, &password)).await {
 477                                 Ok(Ok(())) => {
 478                                     let _ = tx.send(AuthResult::Success);
 479                                 }
 480                                 Ok(Err(e)) => {
 481                                     let _ = tx.send(AuthResult::Failure(e));
 482                                 }
 483                                 Err(_) => {
 484                                     let _ = tx.send(AuthResult::Failure("Auth task panicked".to_string()));
 485                                 }
 486                             }
 487                         }
 488                     });
 489                 }
 490             }
 491             AppMessage::FingerprintScanStart => {
 492                 if self.fingerprint_success || self.status_is_success { return; }
 493                 if self.polkit_mode {
 494                     // PAM fprintd handles the hardware reader natively in Polkit mode
 495                     return;
 496                 }
 497                 self.fingerprint_active = true;
 498                 self.fingerprint_msg = "Place finger on reader...".to_string();
 499                 
 500                 let tx = self.tx_auth.clone();
 501                 let simulate = self.simulate_mode;
 502                 
 503                 tokio::spawn(async move {
 504                     if simulate {
 505                         tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
 506                         let _ = tx.send(AuthResult::Success);
 507                     } else {
 508                         let Some(username) = current_username() else {
 509                             let _ = tx.send(AuthResult::FingerprintStatus(
 510                                 "Cannot determine the current user".to_string(),
 511                             ));
 512                             return;
 513                         };
 514                         if let Err(e) = run_dbus_fingerprint(username, tx.clone()).await {
 515                             let _ = tx.send(AuthResult::FingerprintStatus(format!("Scan error: {}", e)));
 516                         }
 517                     }
 518                 });
 519             }
 520             AppMessage::Cancel => {
 521                 if let Some(ref shared_child) = self.shared_child {
 522                     if let Some(mut child) = shared_child.lock().unwrap().take() {
 523                         let _ = child.kill();
 524                         // `kill` only signals — Rust never reaps on drop — and taking the
 525                         // child here means the reader thread won't wait() on it either, so
 526                         // without this every cancelled prompt left a zombie for the life of
 527                         // the session. Reaped off-thread because this daemon must never
 528                         // wedge on a wait: it is the session's only polkit agent.
 529                         std::thread::spawn(move || {
 530                             let _ = child.wait();
 531                         });
 532                     }
 533                 }
 534                 *exit = true;
 535             }
 536             AppMessage::PromptReceived(prompt, _echo) => {
 537                 self.ui_context[self.password_box].set_label(&prompt);
 538                 self.ui_context[self.password_box].text.clear();
 539             }
 540             AppMessage::StatusReceived(msg, is_error) => {
 541                 self.status_msg = msg.clone();
 542                 self.status_is_error = is_error;
 543                 self.status_is_success = false;
 544                 if msg.to_lowercase().contains("finger") {
 545                     // PAM's wording is a whole sentence naming the finger and the
 546                     // reader, and the wide status line above already carries it
 547                     // verbatim. Repeating it inside the narrow column printed it
 548                     // twice and cut the copy mid-word ("…on the fingerprint read"),
 549                     // so the column reports the state instead.
 550                     self.fingerprint_active = true;
 551                     self.fingerprint_msg = "Waiting for finger…".to_string();
 552                 }
 553             }
 554             AppMessage::AuthDone(res) => {
 555                 log::debug!("AppMessage::AuthDone received: {:?}", res);
 556                 match res {
 557                     AuthResult::Success => {
 558                         self.status_is_success = true;
 559                         self.status_is_error = false;
 560                         self.fingerprint_success = true;
 561                         self.fingerprint_active = false;
 562                         self.status_msg = "Authentication Successful!".to_string();
 563                         self.fingerprint_msg = "Authenticated".to_string();
 564                         
 565                         if self.polkit_mode {
 566                             log::info!("AuthResult::Success in Polkit mode. Sending Ok to tx_result and spawning exit timer.");
 567                             if let Some(req) = ACTIVE_REQUEST.lock().unwrap().take() {
 568                                 let _ = req.tx_result.send(Ok(()));
 569                             } else {
 570                                 log::warn!("WARNING: ACTIVE_REQUEST was None inside AuthDone(Success)!");
 571                             }
 572                             let tx = self.tx_auth.clone();
 573                             tokio::spawn(async move {
 574                                 log::debug!("Exit timer task spawned, sleeping 800ms...");
 575                                 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
 576                                 log::debug!("Exit timer slept 800ms. Sending ExitWindow to tx.");
 577                                 let _ = tx.send(AuthResult::ExitWindow);
 578                             });
 579                         } else {
 580                             log::info!("AuthResult::Success in standalone mode. Exiting process in 1000ms.");
 581                             tokio::spawn(async move {
 582                                 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
 583                                 std::process::exit(0);
 584                             });
 585                         }
 586                     }
 587                     AuthResult::ExitWindow => {
 588                         log::info!("AuthResult::ExitWindow received in update. Setting exit = true.");
 589                         *exit = true;
 590                     }
 591                     AuthResult::Failure(err) => {
 592                         log::error!("AuthResult::Failure received: {}", err);
 593                         self.status_is_error = true;
 594                         self.status_msg = err;
 595 
 596                         // The helper has exited — it runs one PAM conversation per
 597                         // process — so the stdin we still hold is a closed pipe and
 598                         // Verify would write into nothing. A retry needs a fresh one.
 599                         if self.polkit_mode {
 600                             self.helper_stdin = None;
 601                             self.shared_child = None;
 602                             self.ui_context[self.password_box].text.clear();
 603 
 604                             if self.retries_left == 0 {
 605                                 log::warn!("no attempts left for cookie {}", cookie_tag(&self.cookie));
 606                                 self.status_msg =
 607                                     format!("{} — press Escape to cancel", self.status_msg);
 608                             } else {
 609                                 self.retries_left -= 1;
 610                                 match spawn_helper(&self.username, &self.cookie, &self.sender) {
 611                                     Ok((stdin, child)) => {
 612                                         log::info!(
 613                                             "restarted helper for another attempt ({} left after this)",
 614                                             self.retries_left
 615                                         );
 616                                         self.helper_stdin = Some(stdin);
 617                                         self.shared_child = Some(child);
 618                                     }
 619                                     Err(e) => {
 620                                         log::error!("could not restart helper: {}", e);
 621                                         self.status_msg =
 622                                             format!("Could not restart helper: {}", e);
 623                                     }
 624                                 }
 625                             }
 626                         }
 627                     }
 628                     AuthResult::FingerprintStatus(status) => {
 629                         log::info!("AuthResult::FingerprintStatus received: {}", status);
 630                         if !self.polkit_mode
 631                             && (status.contains("Simulation mode active")
 632                                 || status.contains("No reader"))
 633                         {
 634                             self.simulate_mode = true;
 635                         }
 636                         self.fingerprint_msg = status;
 637                     }
 638                 }
 639             }
 640         }
 641     }
 642 
 643     /// `tick` drains `rx_auth`, a std channel the runner cannot see; without
 644     /// this the password verdict would wait for the next unrelated event.
 645     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
 646         Some(std::time::Duration::from_millis(50))
 647     }
 648 
 649     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 650         while let Ok(res) = self.rx_auth.try_recv() {
 651             let _ = self.sender.send(AppMessage::AuthDone(res));
 652         }
 653         
 654         if self.fingerprint_active {
 655             self.glow_timer += dt * 4.0;
 656             *needs_rebuild = true;
 657         }
 658     }
 659 
 660     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 661         // Id-rooted router: dispatch roots resolve through the registry — keep the
 662         // four roots' registrations fresh each frame (idempotent; the dialog assembles
 663         // its frame by hand, so nothing else registers them).
 664         {
 665         }
 666         // Phase 6ag single paint path: the whole frame — card, columns, widgets, and all
 667         // text — is this one list. NOTE this migration is a FIX, not a match: the app's old
 668         // FontSystem shaped buffers whose fontdb face IDs did not resolve in the engine's
 669         // render FontSystem, so ALL of this dialog's text was silently invisible (the 6e
 670         // class). Shaped as display-list Text prims through the engine cache, it renders.
 671         use cce_ui::scene::layout::Rect;
 672         cce_ui::scale::set_scale_factor(scale as f32);
 673         let sw = size.width as f32;
 674         let sh = size.height as f32;
 675         self.width = sw;
 676         self.height = sh;
 677 
 678         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 679 
 680         // ── The window plate ──
 681         //
 682         // The window IS the dialog: the standard root plate (cce-ui
 683         // `PlateSpec::window`), one lit slab whose rolled perimeter reads as
 684         // the physical edge the silhouette already implies. It replaced a
 685         // dimmed surface with a 540x320 "card" outlined in four square quads.
 686         pc.root_plate(sw, sh);
 687 
 688         // ── Layout ──
 689         //
 690         // Spacing comes off the toolkit's ladder, never a literal: the window
 691         // inset for anything against the window edge, the root gap between the
 692         // dialog's parts (the two columns, the wells and the status band), the
 693         // pane rung inside each well.
 694         let pad = cce_ui::layout::root_plate_inset();
 695         let status_h = 40.0f32;
 696         let gutter = cce_ui::layout::root_plate_gap();
 697         let caption_h = 22.0f32;
 698         // TODO(style): the title row — a 15pt line plus its run down to the
 699         // captions folded into one number; not a rung, so it stays a height.
 700         let title_h = 34.0f32;
 701 
 702         let status_y = sh - status_h;
 703         let content_y = pad + title_h;
 704         let col_w = ((sw - pad * 2.0 - gutter) / 2.0).max(140.0);
 705         let fp_col_x = pad;
 706         let pw_col_x = pad + col_w + gutter;
 707         let well_y = content_y + caption_h;
 708         let well_h = (status_y - gutter - well_y).max(90.0);
 709         let well_r = cce_ui::layout::plate_corner_radius();
 710         let well_depth = cce_ui::layout::bevel_width().min(well_h * 0.2);
 711 
 712         // Both columns are wells carved into the plate — the captions label a real
 713         // recess instead of floating over an undifferentiated fill.
 714         for x in [fp_col_x, pw_col_x] {
 715             pc.recess(
 716                 Rect { x, y: well_y, width: col_w, height: well_h },
 717                 (well_r, well_r, well_r, well_r),
 718                 well_depth,
 719             );
 720         }
 721 
 722         // The status line gets the statusbar treatment: a band carved across the foot
 723         // of the plate, top wall only so the seam reads as a shelf rather than a box
 724         // inset from edges the window already rounds.
 725         pc.recess_edges(
 726             Rect { x: 0.0, y: status_y, width: sw, height: status_h },
 727             (0.0, 0.0, 0.0, 0.0),
 728             cce_ui::layout::bar_wall_width(),
 729             (true, false, false, false),
 730         );
 731 
 732         // ── Widget geometry ──
 733         //
 734         // The two columns fill their wells differently because their contents differ:
 735         // the reader is one target, so it centers; the password column is a form, so
 736         // it runs input at the top and actions at the foot.
 737         // Each well is the dialog's pane: its rim-to-content inset and the gap
 738         // between the things inside it are the pane rung.
 739         let inset = cce_ui::layout::plate_padding();
 740         let gap = cce_ui::layout::plate_gap();
 741         let cap_h = 34.0f32; // two lines at 9pt, the longest PAM/fprintd captions
 742         // TODO(style): 78 is the vertical room the caption block reserves under
 743         // the reader (gap + cap_h + slack), pinned as one number when the reader
 744         // was sized; a size, not a rung.
 745         let fp_btn_w = 150.0f32.min(col_w - inset * 2.0).min(well_h - 78.0).max(64.0);
 746         let fp_btn_h = fp_btn_w;
 747         let fp_btn_x = fp_col_x + (col_w - fp_btn_w) / 2.0;
 748         // Target + caption ride as one block centered in the well. Top-anchored, the
 749         // block left a third of the column empty under it and the column read as
 750         // unfinished rather than as a target with room around it.
 751         let fp_block_h = fp_btn_h + gap + cap_h;
 752         let fp_btn_y = well_y + ((well_h - fp_block_h) / 2.0).max(inset);
 753         self.ui_context[self.fingerprint_btn].set_rect(fp_btn_x, fp_btn_y, fp_btn_w, fp_btn_h);
 754 
 755         // The reader's state color rides on the widget so the plate path paints it.
 756         // It used to be a quad drawn UNDER the widget loop's `quad(w.rect(), w.color())`
 757         // on the identical rect — so every state (the success accent, the scanning
 758         // glow, the dimmed-inert fill) was overpainted by the button's flat default
 759         // and none of them ever reached the screen.
 760         self.ui_context[self.fingerprint_btn].bg = Some(if self.fingerprint_success {
 761             ACCENT
 762         } else if self.fingerprint_active {
 763             let alpha = 0.4 + 0.3 * self.glow_timer.sin();
 764             [0.16, 0.41, 0.18, alpha]
 765         } else if self.fingerprint_interactive {
 766             TOGGLE_OFF
 767         } else {
 768             // PAM owns the reader here, and the click handler drops presses on the
 769             // floor — so don't paint this like something that responds to one.
 770             TOGGLE_INERT
 771         });
 772 
 773         let pw_inner_x = pw_col_x + inset;
 774         let pw_inner_w = col_w - inset * 2.0;
 775         // TODO(style): 40 places the entry below the well's top lip — more than
 776         // the pane inset, less than a control gap; a placement, not a rung.
 777         // The rect carries the PASSWORD label's strip above the box itself.
 778         let pw_box_h = cce_ui::layout::textbox_height() + self.ui_context[self.password_box].label_strip();
 779         self.ui_context[self.password_box].set_rect(pw_inner_x, well_y + 40.0, pw_inner_w, pw_box_h);
 780 
 781         // The two actions split the column. They were a fixed 100px, which "Verify
 782         // Password" overran on both sides at the DE's 14pt control font — the label
 783         // is "Verify" now, and the width follows the column instead of a constant.
 784         let btn_w = ((pw_inner_w - gap) / 2.0).max(72.0);
 785         let btn_h = cce_ui::layout::button_height();
 786         let btn_y = well_y + well_h - inset - btn_h;
 787         self.ui_context[self.verify_btn].set_rect(pw_inner_x, btn_y, btn_w, btn_h);
 788         self.ui_context[self.cancel_btn].set_rect(pw_inner_x + pw_inner_w - btn_w, btn_y, btn_w, btn_h);
 789 
 790         // Run each control through the real paint walk, which is how every other cce
 791         // app draws its widgets: the widget's own `Paint` impl, so a Button emits the
 792         // sunken `inset_plate` its `raised` style means and a TextBox its recessed
 793         // well, along with hover/press/focus state and its text.
 794         //
 795         // NOT `append_widget_plate` — that is the designer's escape hatch, and it
 796         // resolves a plate through `plate_bevel()`/`solid_border()`, neither of which
 797         // `Adapted` forwards from `Button`. Every control came out as a bevel filled
 798         // with the configured button face, which this DE sets to #00000000: invisible.
 799         for w in self.widgets_iter() {
 800             cce_ui::scene::painter::paint_root_into(&self.ui_context, w, &mut pc);
 801         }
 802 
 803         if self.fingerprint_active {
 804             // style: deliberate — the scan line's 10px stand-off inside the
 805             // reader target is the glyph's own geometry, not a layout gap.
 806             let scan_y = fp_btn_y + 10.0
 807                 + (50.0 + 50.0 * self.glow_timer.sin()).clamp(0.0, fp_btn_h - 20.0);
 808             pc.quad(
 809                 Rect { x: fp_btn_x + 10.0, y: scan_y, width: fp_btn_w - 20.0, height: 2.0 },
 810                 [0.30, 0.90, 0.32, 0.8],
 811             );
 812         }
 813 
 814         // ── Text ──
 815         let caption = cce_ui::color::control_label_color_detached_u8();
 816         pc.text_with(
 817             "CCE AUTHENTICATOR".to_string(),
 818             pad,
 819             pad,
 820             15.0,
 821             text_rgb(cce_ui::color::TEXT_HEADER),
 822             None,
 823             None,
 824         );
 825         pc.text_with("FINGERPRINT AUTHENTICATION".to_string(), fp_col_x, content_y, 10.0, caption, None, None);
 826         pc.text_with("PASSWORD AUTHENTICATION".to_string(), pw_col_x, content_y, 10.0, caption, None, None);
 827 
 828         let fp_msg_color = if self.fingerprint_success {
 829             [0xa0, 0xee, 0xa0]
 830         } else if self.fingerprint_interactive || self.fingerprint_active {
 831             text_rgb(cce_ui::color::TEXT_FG)
 832         } else {
 833             caption
 834         };
 835         let fp_msg_x = fp_col_x + inset;
 836         let fp_msg_w = col_w - inset * 2.0;
 837         let fp_msg_y = fp_btn_y + fp_btn_h + gap;
 838         pc.text_with(
 839             fit_column(&self.fingerprint_msg, fp_msg_w),
 840             fp_msg_x,
 841             fp_msg_y,
 842             9.0,
 843             fp_msg_color,
 844             None,
 845             Some([fp_msg_x, fp_msg_y, fp_msg_x + fp_msg_w, fp_msg_y + cap_h]),
 846         );
 847 
 848         let status_color = if self.status_is_success {
 849             [0xa0, 0xee, 0xa0]
 850         } else if self.status_is_error {
 851             [0xee, 0x5c, 0x5c]
 852         } else {
 853             text_rgb(cce_ui::color::TEXT_FG)
 854         };
 855         let status_text_y = status_y + (status_h - 12.0) / 2.0;
 856         pc.text_with(
 857             self.status_msg.clone(),
 858             pad,
 859             status_text_y,
 860             10.0,
 861             status_color,
 862             None,
 863             Some([pad, status_y, sw - pad, sh]),
 864         );
 865 
 866         Some(pc.finish())
 867     }
 868 
 869     fn display_list_text(&self) -> bool {
 870         true
 871     }
 872 
 873     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 874         // The shared context menu (the password box's) gets the pointer to itself
 875         // while open: its row highlight.
 876         if cce_ui::widget::context_menu::is_visible() {
 877             if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
 878                 *needs_rebuild = true;
 879             }
 880             return;
 881         }
 882         // Routed dispatch (6bd shrink): one Event per widget root through the router.
 883         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
 884         let ctx = &mut self.ui_context;
 885         // `bg` is deliberately absent: `ContentBg::hit` is unconditionally false, so it
 886         // can never consume a pointer event, and it is the one root this dialog paints
 887         // without registering — routing to it logged "unregistered/stale root … event
 888         // dropped" on every motion event for the life of the daemon.
 889         if ctx.propagate_event(&mv, self.password_box.id()) { *needs_rebuild = true; }
 890         if ctx.propagate_event(&mv, self.verify_btn.id()) { *needs_rebuild = true; }
 891         if ctx.propagate_event(&mv, self.cancel_btn.id()) { *needs_rebuild = true; }
 892         if ctx.propagate_event(&mv, self.fingerprint_btn.id()) { *needs_rebuild = true; }
 893     }
 894 
 895     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
 896         let (lx, ly) = (pos.x, pos.y);
 897 
 898         // The shared context menu a right-click on the password box opens takes
 899         // every click while open: a row runs, a press anywhere else dismisses it.
 900         // The toolkit leaves this routing to the app; without it the menu could
 901         // not be closed by clicking outside it, and its rows did nothing.
 902         if cce_ui::widget::context_menu::is_visible() {
 903             if cce_ui::widget::context_menu::mouse_input(button, state, lx, ly, Some(&mut self.ui_context)) {
 904                 *needs_rebuild = true;
 905             }
 906             return None;
 907         }
 908 
 909         let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
 910 
 911         if { let root = self.verify_btn.id(); self.ui_context.propagate_event(&ev, root) } {
 912             *needs_rebuild = true;
 913         }
 914         if self.ui_context[self.verify_btn].take_click() {
 915             return Some(AppMessage::PasswordVerify);
 916         }
 917         
 918         if { let root = self.cancel_btn.id(); self.ui_context.propagate_event(&ev, root) } {
 919             *needs_rebuild = true;
 920         }
 921         if self.ui_context[self.cancel_btn].take_click() {
 922             return Some(AppMessage::Cancel);
 923         }
 924         
 925         if { let root = self.fingerprint_btn.id(); self.ui_context.propagate_event(&ev, root) } {
 926             *needs_rebuild = true;
 927         }
 928         if self.ui_context[self.fingerprint_btn].take_click() {
 929             return Some(AppMessage::FingerprintScanStart);
 930         }
 931         
 932         let hit = self.ui_context[self.password_box].hit_test(lx, ly, &self.ui_context);
 933         if state == ElementState::Pressed && !hit {
 934             self.ui_context[self.password_box].unfocus();
 935         }
 936         if self.ui_context.propagate_event(&ev, self.password_box.id()) {
 937             *needs_rebuild = true;
 938         }
 939         
 940         None
 941     }
 942 
 943     fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
 944 
 945     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
 946         if event.state == ElementState::Pressed && !event.repeat {
 947             if let Key::Named(NamedKey::Tab) = event.logical_key {
 948                 if self.ui_context[self.password_box].focused(&self.ui_context) {
 949                     self.ui_context[self.password_box].unfocus();
 950                     self.ui_context[self.verify_btn].focus();
 951                 } else if self.ui_context[self.verify_btn].focused(&self.ui_context) {
 952                     self.ui_context[self.verify_btn].unfocus();
 953                     self.ui_context[self.cancel_btn].focus();
 954                 } else {
 955                     self.ui_context[self.cancel_btn].unfocus();
 956                     self.ui_context[self.password_box].focus();
 957                 }
 958                 *needs_rebuild = true;
 959                 return None;
 960             }
 961             
 962             if let Key::Named(NamedKey::Escape) = event.logical_key {
 963                 return Some(AppMessage::Cancel);
 964             }
 965             
 966             if let Key::Named(NamedKey::Enter) = event.logical_key {
 967                 if self.ui_context[self.password_box].focused(&self.ui_context) {
 968                     return Some(AppMessage::PasswordVerify);
 969                 }
 970             }
 971         }
 972         
 973         let kev = cce_ui::widget::Event::KeyInput(event.clone());
 974         let root = self.password_box.id();
 975         if self.ui_context.propagate_event(&kev, root) {
 976             *needs_rebuild = true;
 977         }
 978         
 979         None
 980     }
 981 }
 982 
 983 impl AuthenticatorApp {
 984     /// The dialog's four real controls, in paint order.
 985     ///
 986     /// A full-window `ContentBg` used to lead this list. It was the flat backdrop the
 987     /// window plate now is, and once the widgets paint as plates it became actively
 988     /// destructive: `append_widget_plate` would have drawn its fill over the plate,
 989     /// erasing the lit edge and every carve under it.
 990     fn widgets_iter(&self) -> Vec<&dyn WidgetHost> {
 991         vec![
 992             &self.ui_context[self.password_box],
 993             &self.ui_context[self.verify_btn],
 994             &self.ui_context[self.cancel_btn],
 995             &self.ui_context[self.fingerprint_btn],
 996         ]
 997     }
 998 }
 999 
1000 fn run_pam_auth(username: &str, password: &str) -> Result<(), String> {
1001     unsafe {
1002         let service = PAM_SERVICE;
1003         let pass_c = std::ffi::CString::new(password).map_err(|e| e.to_string())?;
1004         
1005         extern "C" fn pam_conv_simple(
1006             _num_msg: libc::c_int,
1007             _msg: *mut *mut pam_sys::PamMessage,
1008             resp: *mut *mut pam_sys::PamResponse,
1009             appdata_ptr: *mut libc::c_void,
1010         ) -> libc::c_int {
1011             unsafe {
1012                 let password = appdata_ptr as *const libc::c_char;
1013                 let resp_size = std::mem::size_of::<pam_sys::PamResponse>();
1014                 let calloc_resp = libc::calloc(1, resp_size) as *mut pam_sys::PamResponse;
1015                 (*calloc_resp).resp = libc::strdup(password);
1016                 (*calloc_resp).resp_retcode = 0;
1017                 *resp = calloc_resp;
1018                 pam_sys::PamReturnCode::SUCCESS as libc::c_int
1019             }
1020         }
1021         
1022         let mut handle: *mut pam_sys::PamHandle = std::ptr::null_mut();
1023         let conv = pam_sys::PamConversation {
1024             conv: Some(pam_conv_simple),
1025             data_ptr: pass_c.as_ptr() as *mut libc::c_void,
1026         };
1027         
1028         let rc = pam_sys::start(service, Some(username), &conv, &mut handle);
1029         if rc != pam_sys::PamReturnCode::SUCCESS {
1030             return Err("Failed to start PAM".to_string());
1031         }
1032         
1033         let rc = pam_sys::authenticate(&mut *handle, pam_sys::PamFlag::NONE);
1034         pam_sys::end(&mut *handle, rc);
1035         
1036         if rc == pam_sys::PamReturnCode::SUCCESS {
1037             Ok(())
1038         } else {
1039             Err(format!("Incorrect password (PAM: {:?})", rc))
1040         }
1041     }
1042 }
1043 
1044 async fn run_dbus_fingerprint(username: String, tx: std::sync::mpsc::Sender<AuthResult>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1045     let connection = zbus::Connection::system().await?;
1046     
1047     let reply = connection.call_method(
1048         Some("net.reactivated.Fprint"),
1049         "/net/reactivated/Fprint/Manager",
1050         Some("net.reactivated.Fprint.Manager"),
1051         "GetDefaultDevice",
1052         &(),
1053     ).await?;
1054     
1055     let device_path: zbus::zvariant::OwnedObjectPath = reply.body().deserialize()?;
1056     let device_path_str = device_path.as_str();
1057     
1058     connection.call_method(
1059         Some("net.reactivated.Fprint"),
1060         device_path_str,
1061         Some("net.reactivated.Fprint.Device"),
1062         "Claim",
1063         &(username,),
1064     ).await?;
1065     
1066     let _ = tx.send(AuthResult::FingerprintStatus("Reader claimed. Scan finger...".to_string()));
1067     
1068     connection.call_method(
1069         Some("net.reactivated.Fprint"),
1070         device_path_str,
1071         Some("net.reactivated.Fprint.Device"),
1072         "VerifyStart",
1073         &("any",),
1074     ).await?;
1075     
1076     let mut stream = zbus::MessageStream::for_match_rule(
1077         zbus::MatchRule::builder()
1078             .msg_type(zbus::message::Type::Signal)
1079             .sender("net.reactivated.Fprint")?
1080             .interface("net.reactivated.Fprint.Device")?
1081             .member("VerifyStatus")?
1082             .path(device_path_str)?
1083             .build(),
1084         &connection,
1085         None,
1086     ).await?;
1087     
1088     while let Some(msg) = stream.next().await {
1089         if let Ok(msg) = msg {
1090             if let Ok((result, keep_going)) = msg.body().deserialize::<(String, bool)>() {
1091                 if result == "verify-match" {
1092                     let _ = tx.send(AuthResult::Success);
1093                     break;
1094                 } else if result == "verify-no-match" {
1095                     let _ = tx.send(AuthResult::FingerprintStatus("Failed match. Try again.".to_string()));
1096                 } else if result == "verify-swipe-too-short" {
1097                     let _ = tx.send(AuthResult::FingerprintStatus("Swipe too short. Try again.".to_string()));
1098                 } else {
1099                     let _ = tx.send(AuthResult::FingerprintStatus(format!("Retry scan: {}", result)));
1100                 }
1101                 if !keep_going {
1102                     break;
1103                 }
1104             }
1105         }
1106     }
1107     
1108     let _ = connection.call_method(
1109         Some("net.reactivated.Fprint"),
1110         device_path_str,
1111         Some("net.reactivated.Fprint.Device"),
1112         "Release",
1113         &(),
1114     ).await;
1115     
1116     Ok(())
1117 }
1118 
1119 struct PolkitAgent {
1120     tx_gui_req: std::sync::mpsc::Sender<GuiRequest>,
1121 }
1122 
1123 #[zbus::interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
1124 impl PolkitAgent {
1125     async fn begin_authentication(
1126         &self,
1127         _action_id: String,
1128         message: String,
1129         _icon_name: String,
1130         _details: std::collections::HashMap<String, String>,
1131         cookie: String,
1132         identities: Vec<(String, std::collections::HashMap<String, zbus::zvariant::OwnedValue>)>,
1133     ) -> zbus::fdo::Result<()> {
1134         log::info!("begin_authentication called! message = {:?}, cookie = {}", message, cookie_tag(&cookie));
1135         let mut username = String::new();
1136         if let Some((kind, details)) = identities.first() {
1137             if kind == "unix-user" {
1138                 if let Some(uid_val) = details.get("uid") {
1139                     let uid = match uid_val.deref() {
1140                         zbus::zvariant::Value::U32(u) => Some(*u),
1141                         zbus::zvariant::Value::I32(i) => Some(*i as u32),
1142                         zbus::zvariant::Value::U64(u) => Some(*u as u32),
1143                         zbus::zvariant::Value::I64(i) => Some(*i as u32),
1144                         _ => None,
1145                     };
1146                     if let Some(uid) = uid {
1147                         if let Some(user) = users::get_user_by_uid(uid) {
1148                             username = user.name().to_string_lossy().into_owned();
1149                         }
1150                     }
1151                 }
1152             }
1153         }
1154         // polkit names the identity it wants authenticated. If it named one we could
1155         // not resolve, fall back to our own — but refuse rather than guess a name,
1156         // because the wrong identity here means prompting for a password that cannot
1157         // authorize the action.
1158         if username.is_empty() {
1159             username = current_username().ok_or_else(|| {
1160                 zbus::fdo::Error::Failed("no resolvable unix-user identity".to_string())
1161             })?;
1162             log::warn!("no unix-user identity in the request; falling back to {}", username);
1163         }
1164 
1165 
1166         let (tx_result, rx_result) = tokio::sync::oneshot::channel();
1167         let req = GuiRequest {
1168             username,
1169             message,
1170             cookie: cookie.clone(),
1171             tx_result,
1172         };
1173         
1174         self.tx_gui_req.send(req).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
1175         
1176         match rx_result.await {
1177             Ok(Ok(())) => Ok(()),
1178             Ok(Err(err)) => Err(zbus::fdo::Error::Failed(err)),
1179             Err(_) => Err(zbus::fdo::Error::Failed("GUI closed".to_string())),
1180         }
1181     }
1182 
1183     async fn cancel_authentication(&self, cookie: String) -> zbus::fdo::Result<()> {
1184         log::info!("cancel_authentication called for cookie {}", cookie_tag(&cookie));
1185 
1186         // Record the cancellation for *any* cookie we have been handed, then try to
1187         // deliver it. Whoever owns this cookie consumes the record: the main loop
1188         // before opening its window, or `new()` once it has a sender. Recording
1189         // unconditionally is what makes the queued and still-starting cases work.
1190         let is_active = {
1191             let mut st = COOKIES.lock().unwrap();
1192             if !st.cancelled.iter().any(|c| c == &cookie) {
1193                 st.cancelled.push(cookie.clone());
1194             }
1195             st.active.as_deref() == Some(cookie.as_str())
1196         };
1197 
1198         if is_active {
1199             let sender_lock = ACTIVE_SENDER.lock().unwrap();
1200             if let Some(ref sender) = *sender_lock {
1201                 let _ = sender.send(AppMessage::Cancel);
1202             }
1203         }
1204         Ok(())
1205     }
1206 }
1207 
1208 async fn get_system_session_id() -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
1209     if let Ok(id) = std::env::var("XDG_SESSION_ID") {
1210         return Ok(id);
1211     }
1212     
1213     if let Ok(id_str) = std::fs::read_to_string("/proc/self/sessionid") {
1214         let id_trimmed = id_str.trim();
1215         if !id_trimmed.is_empty() && id_trimmed != "4294967295" {
1216             return Ok(id_trimmed.to_string());
1217         }
1218     }
1219     
1220     let connection = zbus::Connection::system().await?;
1221     let reply: zbus::zvariant::OwnedObjectPath = connection.call_method(
1222         Some("org.freedesktop.login1"),
1223         "/org/freedesktop/login1",
1224         Some("org.freedesktop.login1.Manager"),
1225         "GetSessionByPID",
1226         &(std::process::id() as u32,),
1227     ).await?.body().deserialize()?;
1228     
1229     if let Some(pos) = reply.as_str().rfind('/') {
1230         let id = reply.as_str()[pos + 1..].to_string();
1231         let id = if id.starts_with('_') { id[1..].to_string() } else { id };
1232         return Ok(id);
1233     }
1234     
1235     Err("Session ID not found".into())
1236 }
1237 
1238 async fn run_polkit_agent_daemon(tx_gui_req: std::sync::mpsc::Sender<GuiRequest>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1239     let connection = zbus::Connection::system().await?;
1240     let session_id = get_system_session_id().await?;
1241     
1242     let agent = PolkitAgent { tx_gui_req };
1243     connection.object_server().at("/org/cce/AuthenticatorAgent", agent).await?;
1244     
1245     let mut details = std::collections::HashMap::new();
1246     details.insert("session-id".to_string(), zbus::zvariant::Value::from(session_id.clone()));
1247     let subject = (
1248         "unix-session".to_string(),
1249         details,
1250     );
1251         let object_path = zbus::zvariant::ObjectPath::try_from("/org/cce/AuthenticatorAgent")?;
1252     
1253     log::info!("Registering CCE Authenticator agent for session {}", session_id);
1254     connection.call_method(
1255         Some("org.freedesktop.PolicyKit1"),
1256         "/org/freedesktop/PolicyKit1/Authority",
1257         Some("org.freedesktop.PolicyKit1.Authority"),
1258         "RegisterAuthenticationAgent",
1259         &(subject.clone(), "en_US.UTF-8", object_path.as_str()),
1260     ).await?;
1261     log::info!("Successfully registered CCE Authenticator agent!");
1262     
1263     #[cfg(unix)]
1264     {
1265         use tokio::signal::unix::{signal, SignalKind};
1266         let mut sigterm = signal(SignalKind::terminate())?;
1267         tokio::select! {
1268             _ = tokio::signal::ctrl_c() => {}
1269             _ = sigterm.recv() => {}
1270         }
1271     }
1272     #[cfg(not(unix))]
1273     {
1274         let _ = tokio::signal::ctrl_c().await;
1275     }
1276     
1277     log::info!("Unregistering CCE Authenticator agent...");
1278     let _ = connection.call_method(
1279         Some("org.freedesktop.PolicyKit1"),
1280         "/org/freedesktop/PolicyKit1/Authority",
1281         Some("org.freedesktop.PolicyKit1.Authority"),
1282         "UnregisterAuthenticationAgent",
1283         &(subject, object_path.as_str()),
1284     ).await;
1285     
1286     Ok(())
1287 }
1288 
1289 #[cfg(test)]
1290 mod tests {
1291     use super::*;
1292 
1293     /// Record a cancellation the way the D-Bus handler does, reporting whether it
1294     /// would have been delivered to a live window.
1295     fn cancel(cookie: &str) -> bool {
1296         let mut st = COOKIES.lock().unwrap();
1297         if !st.cancelled.iter().any(|c| c == cookie) {
1298             st.cancelled.push(cookie.to_string());
1299         }
1300         st.active.as_deref() == Some(cookie)
1301     }
1302 
1303     fn claim(cookie: &str) {
1304         COOKIES.lock().unwrap().active = Some(cookie.to_string());
1305     }
1306 
1307     fn finish(cookie: &str) {
1308         let mut st = COOKIES.lock().unwrap();
1309         st.active = None;
1310         st.cancelled.retain(|c| c != cookie);
1311     }
1312 
1313     /// Exhaustive over the gate's inputs, because this is the one invariant whose
1314     /// failure grants root. Checking it live would mean standing up a working
1315     /// authentication bypass and confirming it doesn't fire — the test settles it
1316     /// without ever putting the machine in that state.
1317     #[test]
1318     fn a_live_request_vetoes_simulation() {
1319         for &env_requested in &[true, false] {
1320             for &uid in &[0u32, 1000] {
1321                 assert!(
1322                     !simulate_allowed(true, env_requested, uid),
1323                     "polkit mode must veto simulation (env={env_requested}, uid={uid}): \
1324                      a simulated success answers polkitd with Ok(()) and grants the action"
1325                 );
1326             }
1327         }
1328 
1329         // Outside polkit mode simulation must still work, or --standalone stops being
1330         // a usable test window and the veto above is untestable in practice.
1331         assert!(simulate_allowed(false, true, 1000), "CCE_AUTH_SIMULATE drives standalone");
1332         assert!(simulate_allowed(false, false, 0), "root standalone simulates without the var");
1333         assert!(!simulate_allowed(false, false, 1000), "no request, no var, not root: real PAM");
1334     }
1335 
1336     #[test]
1337     fn column_captions_never_cut_mid_word() {
1338         // Roughly the interior of a column in the default 640px-wide window.
1339         const W: f32 = 245.0;
1340 
1341         // The message that exposed this — clipping rendered "…on the fingerprint
1342         // read" — now fits whole: the column grew from a hardcoded 220px to its
1343         // share of the window. Asserted, because it is the reason the budget had
1344         // to stop being a constant.
1345         let pam = "Place your right middle finger on the fingerprint reader";
1346         assert_eq!(fit_column(pam, W), pam, "the column is wide enough for PAM's wording now");
1347         assert!(fit_column(pam, 220.0).ends_with('…'), "…but not at the old width");
1348 
1349         // The genuinely unbounded captions are the D-Bus errors.
1350         let err = "No reader: org.freedesktop.DBus.Error.ServiceUnknown: \
1351                    The name net.reactivated.Fprint was not provided by any .service files";
1352         let fitted = fit_column(err, W);
1353         assert!(fitted.ends_with('…'), "long captions must show they were cut");
1354         let kept = fitted.trim_end_matches('…');
1355         assert!(err.starts_with(kept), "the kept head must be a real prefix: {fitted}");
1356         // A word boundary means the character the cut dropped was a space — that is
1357         // the whole difference between this and the clip rect it replaced.
1358         assert_eq!(
1359             err[kept.len()..].chars().next(),
1360             Some(' '),
1361             "cut fell mid-word: {fitted}"
1362         );
1363 
1364         // Short enough to stand as-is, ellipsis included or not.
1365         assert_eq!(fit_column("Waiting for finger…", W), "Waiting for finger…");
1366         assert_eq!(fit_column("", W), "");
1367 
1368         // No spaces to break on, and multi-byte characters: must not panic or slice
1369         // through a char boundary.
1370         let unbroken = "x".repeat(200);
1371         assert!(fit_column(&unbroken, W).ends_with('…'));
1372         assert!(fit_column(&"é".repeat(200), W).ends_with('…'));
1373 
1374         // A window dragged to its minimum still has to produce something, not panic
1375         // on an underflowing budget — the width is a layout value now, not a constant.
1376         assert!(!fit_column(pam, 1.0).is_empty());
1377         assert!(!fit_column(pam, 0.0).is_empty());
1378     }
1379 
1380     /// The orderings that a single active-cookie slot got wrong. One test, run in
1381     /// sequence, because COOKIES is process-global.
1382     #[test]
1383     fn cancellation_survives_every_ordering() {
1384         // Cancel lands before the main loop claims the cookie: not deliverable, but
1385         // the record is waiting when the loop looks, so the window never opens.
1386         assert!(!cancel("early"));
1387         claim("early");
1388         assert!(take_cancelled("early"), "cancel before claim must be seen");
1389         finish("early");
1390 
1391         // Cancel lands after the claim but before the window has a sender. It reads
1392         // as deliverable, yet there is nothing to deliver to — new() consumes it.
1393         claim("starting");
1394         assert!(cancel("starting"), "cancel for the claimed cookie is active");
1395         assert!(take_cancelled("starting"), "new() must still find it");
1396         finish("starting");
1397 
1398         // Cancel for a queued cookie while another window is up. It must not be
1399         // mistaken for the active one, and must survive that window closing.
1400         claim("open");
1401         assert!(!cancel("queued"), "a queued cookie is not the active one");
1402         assert!(!take_cancelled("open"), "the open window was never cancelled");
1403         finish("open");
1404         claim("queued");
1405         assert!(
1406             take_cancelled("queued"),
1407             "a queued cancel must outlive the window ahead of it"
1408         );
1409         finish("queued");
1410 
1411         // Nothing left behind.
1412         let st = COOKIES.lock().unwrap();
1413         assert!(st.active.is_none());
1414         assert!(st.cancelled.is_empty(), "cancelled cookies leaked: {:?}", st.cancelled);
1415     }
1416 }
1417 
1418 fn main() {
1419     env_logger::init();
1420     let args: Vec<String> = std::env::args().collect();
1421     let standalone = args.contains(&"--standalone".to_string()) || args.contains(&"-s".to_string());
1422     
1423     // Two workers: the agent's D-Bus tasks are a handful of awaits, and a
1424     // runtime sized to the CPU count (20 here) parked 20 threads all session
1425     // for them.
1426     let rt = tokio::runtime::Builder::new_multi_thread()
1427         .worker_threads(2)
1428         .enable_all()
1429         .build()
1430         .expect("tokio runtime");
1431     let _guard = rt.enter();
1432     
1433     if standalone {
1434         cce_ui::engine::run::<AuthenticatorApp>();
1435     } else {
1436         let (tx_gui_req, rx_gui_req) = std::sync::mpsc::channel::<GuiRequest>();
1437         
1438         rt.spawn(async move {
1439             if let Err(e) = run_polkit_agent_daemon(tx_gui_req).await {
1440                 log::error!("Error starting Polkit agent: {}", e);
1441                 std::process::exit(1);
1442             }
1443         });
1444         
1445         while let Ok(req) = rx_gui_req.recv() {
1446             log::info!("rx_gui_req received a request for user: {}, message: {}", req.username, req.message);
1447             let cookie = req.cookie.clone();
1448 
1449             // Claim the cookie before checking, so a cancel racing this point either
1450             // finds it active (and delivers, or is consumed by `new()`) or lands in
1451             // `cancelled` in time to be seen right here. Requests wait their turn in
1452             // the channel, and polkitd may well give up on one before its turn comes.
1453             COOKIES.lock().unwrap().active = Some(cookie.clone());
1454             if take_cancelled(&cookie) {
1455                 log::info!("cookie {} was cancelled before its window opened", cookie_tag(&cookie));
1456                 COOKIES.lock().unwrap().active = None;
1457                 let _ = req.tx_result.send(Err("Authentication cancelled".to_string()));
1458                 continue;
1459             }
1460 
1461             *ACTIVE_REQUEST.lock().unwrap() = Some(req);
1462 
1463             log::info!("Starting cce_ui::engine::run...");
1464             cce_ui::engine::run::<AuthenticatorApp>();
1465             log::info!("cce_ui::engine::run returned/exited!");
1466 
1467             *ACTIVE_SENDER.lock().unwrap() = None;
1468             {
1469                 let mut st = COOKIES.lock().unwrap();
1470                 st.active = None;
1471                 st.cancelled.retain(|c| c != &cookie);
1472             }
1473             if let Some(req) = ACTIVE_REQUEST.lock().unwrap().take() {
1474                 log::info!("ACTIVE_REQUEST still present, sending Cancelled to tx_result");
1475                 let _ = req.tx_result.send(Err("Authentication cancelled".to_string()));
1476             } else {
1477                 log::info!("ACTIVE_REQUEST was already taken (success/done).");
1478             }
1479             log::info!("Waiting for next rx_gui_req...");
1480         }
1481     }
1482 }