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 }