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

src/main.rs (61.7K)

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