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 }