diff --git a/Cargo.lock b/Cargo.lock index 6960b91f..3adaf555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,7 @@ dependencies = [ "eyre", "log", "serde", + "uuid", "windows 0.62.2", "windows-core 0.62.2", "winreg 0.56.0", @@ -203,10 +204,9 @@ dependencies = [ "ak-meta", "ak-platform", "cef", - "eyre", "log", "sentry", - "url", + "uuid", "windows 0.62.2", ] @@ -231,6 +231,7 @@ name = "ak-ee-wcp-wire" version = "0.60.1" dependencies = [ "prost", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 520bc358..276a883c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,13 +84,17 @@ windows = { version = "0.62", features = [ "Win32_System_Com", "Win32_System_LibraryLoader", "Win32_System_Pipes", + "Win32_System_Console", + "Win32_System_Environment", "Win32_System_IO", "Win32_Storage_FileSystem", "Win32_Security", + "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_Security_Credentials", "Win32_System_Diagnostics_ToolHelp", "Win32_System_RemoteDesktop", + "Win32_System_StationsAndDesktops", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", @@ -99,6 +103,12 @@ windows = { version = "0.62", features = [ "Win32_NetworkManagement_NetManagement", "Win32_System_SystemInformation", "Win32_Storage_EnhancedStorage", + # `NtOpenDirectoryObject` — no Win32 wrapper exists for opening an + # arbitrary Object Manager directory (`BaseNamedObjects`) the way + # `OpenDesktopW` does for desktops. + "Wdk_Foundation", + "Wdk_Storage_FileSystem", + "Wdk_System_SystemServices", # `ICredentialProviderUser::GetValue` returns a PROPVARIANT, so the e2e # harness needs these to implement that interface for `SetUserArray`. "Win32_System_Com_StructuredStorage", diff --git a/ee/wcp/cef-host/Cargo.toml b/ee/wcp/cef-host/Cargo.toml index 5fbbdd80..7912da54 100644 --- a/ee/wcp/cef-host/Cargo.toml +++ b/ee/wcp/cef-host/Cargo.toml @@ -18,6 +18,5 @@ ak-platform = { path = "../../../ak-platform" } log = { workspace = true } sentry = { workspace = true } windows = { workspace = true } -eyre = { workspace = true } -url = { workspace = true } +uuid = { workspace = true } cef = { version = "151.4.0", features = ["build-util"] } diff --git a/ee/wcp/cef-host/src/app.rs b/ee/wcp/cef-host/src/app.rs index 70d31f4a..23ec41d1 100644 --- a/ee/wcp/cef-host/src/app.rs +++ b/ee/wcp/cef-host/src/app.rs @@ -1,28 +1,36 @@ -//! CEF app/browser-process handler: on context init, fetches the sign-in -//! URL and opens the browser window. +//! CEF app/browser-process handler: on context init, opens the sign-in +//! window at the URL `credprovider` already resolved before spawning this +//! process. + +use std::fs::File; +use std::os::windows::io::FromRawHandle; use cef::*; +use windows::Win32::System::Console::{GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}; -use crate::handler::{SignInClient, SignInHandler, file_from_raw_handle}; +use crate::handler::{SignInClient, SignInHandler}; use crate::window::SignInWindowDelegate; wrap_app! { pub struct HostApp { - result_pipe: usize, - cancel_pipe: Option, + sign_in_url: String, + header_token: String, } impl App { fn browser_process_handler(&self) -> Option { - Some(HostBrowserProcessHandler::new(self.result_pipe, self.cancel_pipe)) + Some(HostBrowserProcessHandler::new( + self.sign_in_url.clone(), + self.header_token.clone(), + )) } } } wrap_browser_process_handler! { struct HostBrowserProcessHandler { - result_pipe: usize, - cancel_pipe: Option, + sign_in_url: String, + header_token: String, } impl BrowserProcessHandler { @@ -31,10 +39,9 @@ wrap_browser_process_handler! { // frames below the window creation. The C++ never built the browser // there: it only flagged the context as ready and created the window // later, once the loop was pumping. Post the work instead of doing it - // re-entrantly, which also keeps a blocking gRPC call out of - // `CefInitialize`. + // re-entrantly. fn on_context_initialized(&self) { - let mut task = OpenSignInWindow::new(self.result_pipe, self.cancel_pipe); + let mut task = OpenSignInWindow::new(self.sign_in_url.clone(), self.header_token.clone()); post_task(ThreadId::UI, Some(&mut task)); } } @@ -42,47 +49,41 @@ wrap_browser_process_handler! { wrap_task! { struct OpenSignInWindow { - result_pipe: usize, - cancel_pipe: Option, + sign_in_url: String, + header_token: String, } impl Task { fn execute(&self) { - open_sign_in_window(self.result_pipe, self.cancel_pipe); + open_sign_in_window(self.sign_in_url.clone(), self.header_token.clone()); } } } -fn open_sign_in_window(result_pipe: usize, cancel_pipe: Option) { - // Most of the gap between the spawn and a window existing is spent here — - // a named-pipe round trip to `ak-sysd` and, behind it, a live call out to - // the authentik API. That gap is what the foreground grant issued at spawn - // has to survive, so it is worth knowing how long it actually was. - let started = std::time::Instant::now(); - let start = match crate::sysd::sys_auth_start_async() { - Ok(s) => s, +fn open_sign_in_window(sign_in_url: String, header_token: String) { + // Both handles were already open and access-checked in `credprovider` + // (running as SYSTEM on the real logon scenarios) before this process + // even existed — inherited via `STARTUPINFOW`, not opened by this + // process's own (low-privilege) token (`BROWSER_PRIVILEGE.md`'s "Roads + // not taken"). + let result_pipe = match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } { + Ok(h) => unsafe { File::from_raw_handle(h.0) }, Err(e) => { - log::error!("sys_auth_start_async failed: {e}"); - let mut pipe = file_from_raw_handle(result_pipe); - let _ = ak_ee_wcp_wire::write_auth_result( - &mut pipe, - &ak_ee_wcp_wire::AuthResult::Failed { - reason: e.to_string(), - }, - ); + log::error!("could not get the inherited result pipe: {e}"); quit_message_loop(); return; } }; - - log::info!( - "got the sign-in URL after {}ms", - started.elapsed().as_millis() - ); - - let result_pipe = file_from_raw_handle(result_pipe); - let cancel_pipe = cancel_pipe.map(file_from_raw_handle); - let inner = SignInHandler::new(start.header_token, result_pipe, cancel_pipe); + // Best-effort: no way left to report a result at all if this fails, but + // the sign-in itself does not depend on cancellation working. + let cancel_pipe = match unsafe { GetStdHandle(STD_INPUT_HANDLE) } { + Ok(h) => Some(unsafe { File::from_raw_handle(h.0) }), + Err(e) => { + log::error!("could not get the inherited cancel pipe: {e}"); + None + } + }; + let inner = SignInHandler::new(header_token, result_pipe, cancel_pipe); let mut client = SignInClient::new(inner); let browser_settings = BrowserSettings::default(); @@ -107,7 +108,7 @@ fn open_sign_in_window(result_pipe: usize, cancel_pipe: Option) { log::info!("creating the top-level window"); let mut delegate = SignInWindowDelegate::new( std::cell::RefCell::new(browser_view), - start.url, + sign_in_url, std::rc::Rc::new(std::cell::Cell::new(false)), ); window_create_top_level(Some(&mut delegate)); diff --git a/ee/wcp/cef-host/src/handler.rs b/ee/wcp/cef-host/src/handler.rs index 6b366ddb..1193b858 100644 --- a/ee/wcp/cef-host/src/handler.rs +++ b/ee/wcp/cef-host/src/handler.rs @@ -4,10 +4,9 @@ use std::fs::File; use std::io::Read; -use std::os::windows::io::FromRawHandle; use std::sync::{Arc, Mutex, Weak}; -use ak_ee_wcp_wire::AuthResult; +use ak_ee_wcp_wire::HostReport; use cef::*; pub struct SignInHandler { @@ -56,7 +55,7 @@ impl SignInHandler { } if self.browser_list.is_empty() { log::info!("last browser closed; shutting the sign-in window down"); - signal_if_unsent(&self.result_pipe, AuthResult::Cancelled); + signal_if_unsent(&self.result_pipe, HostReport::Cancelled); quit_message_loop(); } } @@ -70,9 +69,9 @@ impl SignInHandler { } /// Sends the sign-in outcome once, then closes the window. Safe to call - /// more than once — only the first call's result is sent. - fn complete(&self, result: AuthResult) { - signal_if_unsent(&self.result_pipe, result); + /// more than once — only the first call's report is sent. + fn complete(&self, report: HostReport) { + signal_if_unsent(&self.result_pipe, report); let Some(this) = self.weak_self.upgrade() else { return; }; @@ -90,10 +89,10 @@ fn close_all_browsers(handler: &Arc>) { inner.close_all_browsers(); } -fn signal_if_unsent(result_pipe: &Mutex>, result: AuthResult) { +fn signal_if_unsent(result_pipe: &Mutex>, report: HostReport) { let mut guard = result_pipe.lock().unwrap_or_else(|e| e.into_inner()); if let Some(mut file) = guard.take() - && let Err(e) = ak_ee_wcp_wire::write_auth_result(&mut file, &result) + && let Err(e) = ak_ee_wcp_wire::write_host_report(&mut file, &report) { log::error!("failed to write result to pipe: {e}"); } @@ -125,7 +124,7 @@ fn watch_cancel_pipe(mut pipe: File, handler: Arc>) { Err(e) => log::error!("control pipe read failed ({e}); cancelling"), } let inner = handler.lock().unwrap_or_else(|e| e.into_inner()); - inner.complete(AuthResult::Cancelled); + inner.complete(HostReport::Cancelled); }); } @@ -213,25 +212,11 @@ wrap_resource_request_handler! { return ReturnValue::CONTINUE; } - let result = match crate::sysd::sys_auth_url(&url) { - Ok(Some(token)) => AuthResult::Completed { - username: token.username, - }, - Ok(None) => AuthResult::Failed { - reason: "token validation failed".to_string(), - }, - Err(e) => AuthResult::Failed { - reason: e.to_string(), - }, - }; - inner.complete(result); + // Validating the token needs `ak-sysd`, which this process has no + // access to (`BROWSER_PRIVILEGE.md`) — `credprovider` does it once + // this reaches the result pipe. + inner.complete(HostReport::Redirected { url }); ReturnValue::CANCEL } } } - -/// Wraps an inherited raw pipe handle as an owned `File`. Called once at -/// startup for each of the two pipes this process was handed. -pub fn file_from_raw_handle(handle: usize) -> File { - unsafe { File::from_raw_handle(handle as *mut std::ffi::c_void) } -} diff --git a/ee/wcp/cef-host/src/identity.rs b/ee/wcp/cef-host/src/identity.rs new file mode 100644 index 00000000..c080b9ce --- /dev/null +++ b/ee/wcp/cef-host/src/identity.rs @@ -0,0 +1,87 @@ +//! One diagnostic: which account and SID this process's own token actually +//! carries. Logged unconditionally at startup, next to the build hash — this +//! process only ever runs as SYSTEM or the dedicated service account +//! (`BROWSER_PRIVILEGE.md`), and confirming which one needs no separate, +//! correlated capture on the far end. + +use std::ffi::c_void; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows::Win32::Security::{ + GetTokenInformation, LookupAccountSidW, SID_NAME_USE, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows::core::{PCWSTR, PWSTR}; + +/// `"DOMAIN\name (S-1-5-...)"`, or a placeholder describing which step +/// failed — every step here can fail independently, and which one did says +/// something different about what is actually running. +pub fn current_token_identity() -> String { + unsafe { + let mut token = HANDLE::default(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { + return "".to_string(); + } + + let mut buf = [0u8; 256]; + let mut ret_len = 0u32; + let got_user = GetTokenInformation( + token, + TokenUser, + Some(buf.as_mut_ptr() as *mut c_void), + buf.len() as u32, + &mut ret_len, + ); + + let identity = if got_user.is_ok() { + let sid = (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid; + + let mut name = [0u16; 256]; + let mut name_len = name.len() as u32; + let mut domain = [0u16; 256]; + let mut domain_len = domain.len() as u32; + let mut use_ = SID_NAME_USE::default(); + let account = if LookupAccountSidW( + PCWSTR::null(), + sid, + Some(PWSTR(name.as_mut_ptr())), + &mut name_len, + Some(PWSTR(domain.as_mut_ptr())), + &mut domain_len, + &mut use_, + ) + .is_ok() + { + format!( + "{}\\{}", + String::from_utf16_lossy(&domain[..domain_len as usize]), + String::from_utf16_lossy(&name[..name_len as usize]) + ) + } else { + "".to_string() + }; + + let sid_string = { + let mut wide_sid = PWSTR(std::ptr::null_mut()); + if ConvertSidToStringSidW(sid, &mut wide_sid).is_ok() { + let len = (0..).take_while(|&i| *wide_sid.0.add(i) != 0).count(); + let s = String::from_utf16_lossy(std::slice::from_raw_parts(wide_sid.0, len)); + let _ = windows::Win32::Foundation::LocalFree(Some( + windows::Win32::Foundation::HLOCAL(wide_sid.0 as *mut c_void), + )); + s + } else { + "".to_string() + } + }; + + format!("{account} ({sid_string})") + } else { + "".to_string() + }; + + let _ = CloseHandle(token); + identity + } +} diff --git a/ee/wcp/cef-host/src/main.rs b/ee/wcp/cef-host/src/main.rs index a54b2039..efc427fc 100644 --- a/ee/wcp/cef-host/src/main.rs +++ b/ee/wcp/cef-host/src/main.rs @@ -1,7 +1,8 @@ //! CEF's own multi-process machinery re-execs this same binary with a //! `--type=...` switch for renderer/GPU/utility roles; only the invocation -//! from `credprovider` (carrying `--result-pipe`/`--cancel-pipe`) becomes -//! the browser-process host that opens the sign-in window. +//! from `credprovider` (carrying `--sign-in-url`/`--header-token`, and its +//! inherited stdin/stdout as the IPC channel) becomes the browser-process +//! host that opens the sign-in window. // Logging goes to the platform log, never stdout (see `allow_stdout(false)` // below), so nothing needs a console. Without this the binary links as a @@ -13,19 +14,23 @@ mod app; mod foreground; mod handler; mod icon; -mod sysd; +mod identity; mod window; use std::path::Path; use cef::*; -/// Chromium's on-disk state. `cache_path` is deliberately left unset so the -/// profile itself is in-memory, but this directory still accumulates state -/// across runs, and it has to be named explicitly — otherwise it lands in -/// `system32\config\systemprofile`, since the browser runs under a service -/// account at the logon screen. -const ROOT_CACHE_PATH: &str = r"C:\ProgramData\Authentik Security Inc\wcp-cache"; +/// Parent of Chromium's on-disk state. `cache_path` is deliberately left +/// unset so the profile itself is in-memory, but `root_cache_path` still +/// accumulates state across runs, and it has to be named explicitly — +/// otherwise it lands in `system32\config\systemprofile`, since the browser +/// runs under a service account at the logon screen. +/// +/// Never passed to CEF directly — see `browser_state_dir`. Sharing one fixed +/// `root_cache_path` across launches would let an overlapping or leftover +/// instance block every subsequent one via `ProcessSingleton`. +const CACHE_ROOT: &str = r"C:\ProgramData\Authentik Security Inc\wcp-cache"; /// Chromium reports a failed `CHECK()` by writing the file, line and message /// here and then executing an `int 3`, which surfaces to the credential @@ -44,49 +49,32 @@ fn arg_value(flag: &str) -> Option { None } -/// Every sign-in starts from an empty profile — the window is shown on a -/// shared logon screen, so one person's session must not linger for the next. -/// Called browser-process-side, before `initialize`, since the renderer/GPU -/// re-execs share this directory with an already-running browser. -/// -/// Clears the contents, not the directory itself: the installer creates it -/// under `ProgramData` with `CREATOR OWNER` full control, so deleting it would -/// let any standard user re-create and own the directory this SYSTEM process -/// is about to write a profile into. -/// -/// Logged rather than fatal — a stale cache is worth reporting, not worth -/// blocking a logon over. -fn wipe_browser_state(path: &Path) { - if let Err(e) = std::fs::create_dir_all(path) { - log::warn!("could not create {}: {e}", path.display()); - return; +/// A fresh, unique directory under `root` for exactly this run's +/// `root_cache_path` — see `CACHE_ROOT`'s doc for why it cannot be `root` +/// itself. Owned outright by this process (nothing pre-creates it the way +/// the installer pre-creates `root`), so `wipe_browser_state` can remove it +/// entirely once this run is done, rather than only clearing its contents. +fn browser_state_dir(root: &Path) -> std::path::PathBuf { + let path = root.join(uuid::Uuid::new_v4().to_string()); + match std::fs::create_dir_all(&path) { + Ok(()) => log::info!("using {} as this run's root_cache_path", path.display()), + Err(e) => log::warn!("could not create {}: {e}", path.display()), } + path +} - let entries = match std::fs::read_dir(path) { - Ok(entries) => entries, - Err(e) => { - log::warn!("could not read {}: {e}", path.display()); - return; - } - }; - - let mut removed = 0usize; - for entry in entries.flatten() { - let entry_path = entry.path(); - let result = if entry.file_type().is_ok_and(|t| t.is_dir()) { - std::fs::remove_dir_all(&entry_path) - } else { - std::fs::remove_file(&entry_path) - }; - match result { - Ok(()) => removed += 1, - Err(e) => log::warn!("could not remove {}: {e}", entry_path.display()), - } +/// Every sign-in starts from an empty profile, and none should linger on +/// disk once its window closes — the logon screen is shared. Only called +/// after a successful run: this run's directory is unique to it +/// (`browser_state_dir`), so there is nothing to clear before starting, and +/// a directory `CefInitialize` never got to use is left in place +/// deliberately, as the only record of what that run looked like when it +/// failed. Also skipped if the credential provider has to kill this process +/// for never responding — logged, not fatal, like any other cleanup here. +fn wipe_browser_state(path: &Path) { + if let Err(e) = std::fs::remove_dir_all(path) { + log::warn!("could not remove {}: {e}", path.display()); } - log::info!( - "cleared {removed} entries of browser state in {}", - path.display() - ); } /// Identifies the sign-in window to authentik. Matches what the C++ credential @@ -119,6 +107,16 @@ fn main() { .allow_stdout(false) .enable(); + // First line out, before anything else can fail: which exact commit this + // binary was built from, so a real-install log can be matched against + // the source rather than assumed. + log::info!( + "ak_cef.exe {} (build {}), running as {}", + ak_meta::full_version(), + ak_meta::build_hash(), + identity::current_token_identity() + ); + let _ = api_hash(sys::CEF_API_VERSION_LAST, 0); let cef_args = args::Args::new(); @@ -138,23 +136,26 @@ fn main() { "browser process must not be handled by execute_process" ); - let Some(result_pipe) = arg_value("--result-pipe").and_then(|s| s.parse::().ok()) else { - log::error!("missing --result-pipe argument"); + let Some(sign_in_url) = arg_value("--sign-in-url") else { + log::error!("missing --sign-in-url argument"); + return; + }; + let Some(header_token) = arg_value("--header-token") else { + log::error!("missing --header-token argument"); return; }; - let cancel_pipe = arg_value("--cancel-pipe").and_then(|s| s.parse::().ok()); - wipe_browser_state(Path::new(ROOT_CACHE_PATH)); + let cache_path = browser_state_dir(Path::new(CACHE_ROOT)); let settings = Settings { no_sandbox: 1, - root_cache_path: CefString::from(ROOT_CACHE_PATH), + root_cache_path: CefString::from(cache_path.to_string_lossy().as_ref()), user_agent: CefString::from(user_agent().as_str()), log_file: CefString::from(CHROMIUM_LOG_PATH), log_severity: LogSeverity::VERBOSE, ..Default::default() }; - let mut app = app::HostApp::new(result_pipe, cancel_pipe); + let mut app = app::HostApp::new(sign_in_url, header_token); let initialized = initialize( Some(cef_args.as_main_args()), Some(&settings), @@ -162,12 +163,16 @@ fn main() { sandbox_info, ); if initialized != 1 { - log::error!("CefInitialize failed"); + log::error!( + "CefInitialize failed; leaving {} in place for inspection", + cache_path.display() + ); return; } run_message_loop(); shutdown(); + wipe_browser_state(&cache_path); } #[cfg(test)] @@ -182,10 +187,13 @@ mod tests { } /// A session left behind by the last person at the logon screen is the - /// thing this is here to remove, so "mostly cleared" is not good enough: + /// thing this is here to remove, so a real recursive remove is required — /// nested directories are where Chromium keeps cookies and local storage. + /// Unlike the old shared-directory design, the directory itself goes too: + /// each run owns its own (`browser_state_dir`), so there is nothing else + /// that still needs it to exist afterwards. #[test] - fn clears_nested_state_but_keeps_the_directory() { + fn removes_a_populated_directory_entirely() { let dir = scratch_dir("wipe"); std::fs::create_dir_all(dir.join("Default/Local Storage")).expect("seed nested state"); std::fs::write(dir.join("Default/Cookies"), b"session").expect("seed a cookie jar"); @@ -193,26 +201,34 @@ mod tests { wipe_browser_state(&dir); - assert!(dir.is_dir(), "the directory itself must survive the wipe"); - let left: Vec<_> = std::fs::read_dir(&dir) - .expect("read the wiped directory") - .flatten() - .map(|e| e.file_name()) - .collect(); - assert!( - left.is_empty(), - "expected an empty directory, found {left:?}" - ); - - let _ = std::fs::remove_dir_all(&dir); + assert!(!dir.exists(), "the directory itself should be gone"); } + /// Reached whenever `ak_cef.exe` exits before ever creating its own + /// directory (e.g. a missing `--sign-in-url` argument) — must not panic. #[test] - fn creates_the_directory_when_it_is_missing() { + fn tolerates_a_directory_that_is_already_gone() { let dir = scratch_dir("missing"); wipe_browser_state(&dir); - assert!(dir.is_dir()); - let _ = std::fs::remove_dir_all(&dir); + assert!(!dir.exists()); + } + + /// A shared, unchanging `root_cache_path` is what let one leftover + /// process's `ProcessSingleton` lock fail every subsequent launch — the + /// whole reason each run gets its own directory instead of reusing + /// `root` directly. + #[test] + fn each_call_gets_its_own_directory() { + let root = scratch_dir("state_dir"); + + let first = browser_state_dir(&root); + let second = browser_state_dir(&root); + + assert_ne!(first, second, "each launch must get a distinct directory"); + assert!(first.is_dir()); + assert!(second.is_dir()); + + let _ = std::fs::remove_dir_all(&root); } /// authentik may key on this server-side, so the shape is part of the diff --git a/ee/wcp/cef-host/src/sysd.rs b/ee/wcp/cef-host/src/sysd.rs deleted file mode 100644 index df26a366..00000000 --- a/ee/wcp/cef-host/src/sysd.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! The two `ak-sysd` calls the browser host makes: start an interactive -//! sign-in (yielding the URL to open and the header token to inject), and -//! validate the token the `goauthentik.io://` redirect returns. - -use eyre::Result; -use std::collections::HashMap; -use url::Url; - -use ak_ee_wcp_wire::TOKEN_QUERY_PARAM; -use ak_platform::generated::sys_auth::TokenAuthRequest; -use ak_platform::generated::sys_auth::system_auth_interactive_client::SystemAuthInteractiveClient; -use ak_platform::generated::sys_auth::system_auth_token_client::SystemAuthTokenClient; -use ak_platform::grpc::grpc_request; - -pub struct AuthStartAsync { - pub url: String, - pub header_token: String, -} - -pub struct TokenResponse { - pub username: String, -} - -pub fn sys_auth_start_async() -> Result { - let response = grpc_request(async |ch| { - Ok(SystemAuthInteractiveClient::new(ch) - .interactive_auth_async(()) - .await?) - })? - .into_inner(); - Ok(AuthStartAsync { - url: response.url, - header_token: response.header_token, - }) -} - -pub fn sys_auth_url(url: &str) -> Result> { - let raw_token = extract_token(url)?; - sys_auth_token_validate(&raw_token) -} - -fn extract_token(url: &str) -> Result { - let parsed = Url::parse(url)?; - let qm: HashMap<_, _> = parsed.query_pairs().into_owned().collect(); - qm.get(TOKEN_QUERY_PARAM) - .cloned() - .ok_or_else(|| eyre::eyre!("failed to get token from URL")) -} - -fn sys_auth_token_validate(raw_token: &str) -> Result> { - let response = grpc_request(async |ch| { - Ok(SystemAuthTokenClient::new(ch) - .token_auth(TokenAuthRequest { - username: String::new(), - token: raw_token.to_owned(), - }) - .await?) - })? - .into_inner(); - - if !response.successful { - return Ok(None); - } - Ok(Some(TokenResponse { - username: response - .token - .map(|t| t.preferred_username) - .unwrap_or_default(), - })) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn extracts_token_from_a_redirect_url() { - let url = format!( - "{}callback?{TOKEN_QUERY_PARAM}=abc123", - ak_ee_wcp_wire::REDIRECT_PREFIX - ); - assert_eq!(extract_token(&url).unwrap(), "abc123"); - } - - #[test] - fn extracts_token_alongside_other_query_params() { - let url = format!( - "{}callback?state=xyz&{TOKEN_QUERY_PARAM}=abc123&code=9", - ak_ee_wcp_wire::REDIRECT_PREFIX - ); - assert_eq!(extract_token(&url).unwrap(), "abc123"); - } - - #[test] - fn errors_when_the_token_param_is_absent() { - let url = format!("{}callback?state=xyz", ak_ee_wcp_wire::REDIRECT_PREFIX); - assert!(extract_token(&url).is_err()); - } - - #[test] - fn errors_on_an_unparseable_url() { - assert!(extract_token("not a url").is_err()); - } -} diff --git a/ee/wcp/credprovider/Cargo.toml b/ee/wcp/credprovider/Cargo.toml index 03498f69..38bb0ee8 100644 --- a/ee/wcp/credprovider/Cargo.toml +++ b/ee/wcp/credprovider/Cargo.toml @@ -21,6 +21,7 @@ windows-core = { workspace = true } eyre = { workspace = true } serde = { workspace = true } winreg = { workspace = true } +uuid = { workspace = true } [build-dependencies] embed-resource = "3" diff --git a/ee/wcp/credprovider/src/ipc.rs b/ee/wcp/credprovider/src/ipc.rs index 0d475265..c47cd74c 100644 --- a/ee/wcp/credprovider/src/ipc.rs +++ b/ee/wcp/credprovider/src/ipc.rs @@ -1,7 +1,11 @@ //! Spawns `ak_cef.exe` in the interactive session and exchanges -//! `wire`-framed messages with it over a duplex pair of anonymous pipes: a -//! result pipe it writes to, and a control pipe this process writes a -//! cancel signal to. +//! `wire`-framed messages with it over its inherited standard handles: it +//! writes its result to stdout, and reads a cancel signal from stdin. +//! Anonymous, inherited pipes rather than named ones with a custom DACL — +//! matching GCPW's own approach (`CreatePipeForChildProcess`, +//! `gcp_utils.cc`) — because the child never opens anything by name at all, +//! so there is no DACL for a hardened box's Object Manager namespace to +//! disagree with. See `BROWSER_PRIVILEGE.md`'s "Roads not taken". use std::ffi::c_void; use std::fs::File; @@ -15,13 +19,13 @@ use windows::{ CloseHandle, E_FAIL, HANDLE, HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation, WAIT_OBJECT_0, }, - Security::SECURITY_ATTRIBUTES, + Security::{SE_IMPERSONATE_NAME, SECURITY_ATTRIBUTES}, + System::Environment::{CreateEnvironmentBlock, DestroyEnvironmentBlock}, System::Pipes::CreatePipe, System::Threading::{ - CreateProcessAsUserW, CreateProcessW, DeleteProcThreadAttributeList, - EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, InitializeProcThreadAttributeList, - LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION, - STARTUPINFOEXW, UpdateProcThreadAttribute, WaitForSingleObject, + CREATE_UNICODE_ENVIRONMENT, CreateProcessW, CreateProcessWithTokenW, + GetExitCodeProcess, LOGON_WITH_PROFILE, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, + STARTF_USESTDHANDLES, STARTUPINFOW, WaitForSingleObject, }, UI::Shell::{CPUS_CREDUI, CREDENTIAL_PROVIDER_USAGE_SCENARIO}, UI::WindowsAndMessaging::AllowSetForegroundWindow, @@ -29,8 +33,9 @@ use windows::{ core::{PCWSTR, PWSTR}, }; -use crate::syscalls::{self, ForegroundControl, acquire_interactive_token}; -use ak_ee_wcp_wire::AuthResult; +use crate::syscalls::{self, ForegroundControl}; +use crate::sysd; +use ak_ee_wcp_wire::{AuthResult, HostReport}; /// Spawns `ak_cef.exe` and waits for its result. `should_continue` is polled /// while waiting, so LogonUI cancelling (the user backing out of the tile) @@ -51,10 +56,9 @@ impl AuthFlow for CefAuthFlow { } /// Only `CPUS_CREDUI` may fall back to launching in the caller's own session. -/// It is debug-gated and runs on an ordinary desktop, where the caller is -/// already the interactive user and holds no `SE_TCB_NAME`. The logon -/// scenarios must never take it: they run as SYSTEM under LogonUI, so it -/// would put Chromium on the secure desktop with SYSTEM's token. +/// It is debug-gated and runs on an ordinary desktop; the logon scenarios +/// must never take this fallback, or Chromium ends up on the secure desktop +/// with this process's own SYSTEM token instead of the service account's. fn may_launch_in_current_session(cpus: CREDENTIAL_PROVIDER_USAGE_SCENARIO) -> bool { cpus == CPUS_CREDUI } @@ -63,61 +67,82 @@ fn may_launch_in_current_session(cpus: CREDENTIAL_PROVIDER_USAGE_SCENARIO) -> bo /// same window station is fully functional but invisible to the person signing /// in, so the logon scenarios have to name it: with `lpDesktop` left NULL, /// `CreateProcess*` gives the child whichever desktop the caller happens to be -/// on, which is only incidentally the right one. +/// on, which is only incidentally the right one. `CPUS_CREDUI` keeps it NULL +/// and inherits the ordinary interactive desktop instead. const SECURE_DESKTOP: &str = r"WinSta0\Winlogon"; -/// `CPUS_CREDUI` is the debug-gated scenario that runs on the ordinary -/// interactive desktop, so it keeps `lpDesktop` NULL and inherits it. -fn desktop_for(cpus: CREDENTIAL_PROVIDER_USAGE_SCENARIO) -> Option> { - if may_launch_in_current_session(cpus) { - return None; - } - Some( - SECURE_DESKTOP - .encode_utf16() - .chain(std::iter::once(0)) - .collect(), - ) -} - -struct DuplexPipes { +struct StdPipes { + /// This process's own ends, read/written after the child is spawned. result_read: HANDLE, - result_write_inheritable: HANDLE, cancel_write: HANDLE, - cancel_read_inheritable: HANDLE, + /// The child's ends, handed off via `STARTUPINFOW`'s `hStdOutput`/ + /// `hStdInput` and closed here once the child has its own inherited + /// copies. + child_stdout: HANDLE, + child_stdin: HANDLE, } -/// One inheritable pipe pair each way. Our own end of each is marked -/// non-inheritable so the child can't hold it open and mask an EOF. -fn create_duplex_pipes() -> windows::core::Result { +/// One anonymous pipe, both ends inheritable — `CreatePipe` has no way to +/// mark just one. `keep_private` clears it on whichever end the caller keeps +/// for itself, or that copy leaks into every future child this process +/// spawns, not just this one. +fn create_inherited_pipe() -> windows::core::Result<(HANDLE, HANDLE)> { let sa = SECURITY_ATTRIBUTES { nLength: size_of::() as u32, lpSecurityDescriptor: std::ptr::null_mut(), bInheritHandle: true.into(), }; + let mut read = HANDLE::default(); + let mut write = HANDLE::default(); + unsafe { CreatePipe(&mut read, &mut write, Some(&sa), 0)? }; + Ok((read, write)) +} - // Returns (ours, child's) for a pipe flowing in the given direction. - let pipe = |ours_reads: bool| -> windows::core::Result<(HANDLE, HANDLE)> { - let mut read = HANDLE::default(); - let mut write = HANDLE::default(); - unsafe { CreatePipe(&mut read, &mut write, Some(&sa), 0) }?; - let (ours, theirs) = if ours_reads { - (read, write) - } else { - (write, read) - }; - unsafe { SetHandleInformation(ours, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) }?; - Ok((ours, theirs)) - }; +fn keep_private(handle: HANDLE) -> windows::core::Result<()> { + unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT.0, HANDLE_FLAGS(0)) } +} + +/// Two anonymous pipes: the child reads the cancel signal from its inherited +/// stdin and writes its result to its inherited stdout. An *inherited* +/// handle is a duplicate of one this process (SYSTEM on the real logon +/// scenarios) already opened and validated — the child's own, low-privilege +/// token is never consulted at all, unlike a named pipe it has to open by +/// path itself. +fn create_std_pipes() -> windows::core::Result { + let (child_stdin, cancel_write) = create_inherited_pipe()?; + if let Err(e) = keep_private(cancel_write) { + unsafe { + let _ = CloseHandle(child_stdin); + let _ = CloseHandle(cancel_write); + } + return Err(e); + } - let (result_read, result_write_inheritable) = pipe(true)?; - let (cancel_write, cancel_read_inheritable) = pipe(false)?; + let (result_read, child_stdout) = match create_inherited_pipe() { + Ok(p) => p, + Err(e) => { + unsafe { + let _ = CloseHandle(child_stdin); + let _ = CloseHandle(cancel_write); + } + return Err(e); + } + }; + if let Err(e) = keep_private(result_read) { + unsafe { + let _ = CloseHandle(child_stdin); + let _ = CloseHandle(cancel_write); + let _ = CloseHandle(result_read); + let _ = CloseHandle(child_stdout); + } + return Err(e); + } - Ok(DuplexPipes { + Ok(StdPipes { result_read, - result_write_inheritable, cancel_write, - cancel_read_inheritable, + child_stdout, + child_stdin, }) } @@ -126,7 +151,22 @@ fn run_cef_host( cpus: CREDENTIAL_PROVIDER_USAGE_SCENARIO, should_continue: &mut dyn FnMut() -> bool, ) -> AuthResult { - let pipes = match create_duplex_pipes() { + // Fetched here, not by `ak_cef.exe` itself: the service account it runs + // as has no access to `ak-sysd`'s pipe (`BROWSER_PRIVILEGE.md`). Doing + // this before the pipes/spawn also means a failure here costs nothing + // beyond the round trip itself, rather than a spawned window that can + // never load anything. + let start = match sysd::sys_auth_start_async() { + Ok(s) => s, + Err(e) => { + log::error!("sys_auth_start_async failed: {e}"); + return AuthResult::Failed { + reason: e.to_string(), + }; + } + }; + + let pipes = match create_std_pipes() { Ok(p) => p, Err(e) => { log::error!("failed to create IPC pipes: {e}"); @@ -136,12 +176,14 @@ fn run_cef_host( } }; - let spawn = spawn_cef_host(cef_exe, &pipes, cpus); + let spawn = spawn_cef_host(cef_exe, &pipes, cpus, &start.url, &start.header_token); + // Our copies of the child's ends are only needed up to the spawn call, + // which duplicates them into the child's own handle table (or fails, + // in which case there is no child to hold them at all either way). unsafe { - let _ = CloseHandle(pipes.result_write_inheritable); - let _ = CloseHandle(pipes.cancel_read_inheritable); + let _ = CloseHandle(pipes.child_stdin); + let _ = CloseHandle(pipes.child_stdout); } - let process = match spawn { Ok(p) => p, Err(e) => { @@ -259,12 +301,30 @@ enum PipeOutcome { Error(String), } +/// Turns the sign-in redirect's URL into a real outcome by validating its +/// token against `ak-sysd` — the one step `ak_cef.exe` cannot do itself +/// (`BROWSER_PRIVILEGE.md`). Runs on the result-pipe reader thread, not the +/// thread LogonUI called `Connect` on, so this blocking round trip does not +/// stall `should_continue` polling or the foreground nudge. +fn auth_result_for(url: &str) -> AuthResult { + match sysd::sys_auth_validate(url) { + Ok(Some(username)) => AuthResult::Completed { username }, + Ok(None) => AuthResult::Failed { + reason: "token validation failed".to_string(), + }, + Err(e) => AuthResult::Failed { + reason: e.to_string(), + }, + } +} + /// Polls in short slices so `should_continue` gets a turn. On cancellation it /// asks `ak_cef.exe` to close over the control pipe rather than killing it. /// /// Every route out of here other than a real `AuthResult` looks identical to -/// the user ("Login attempt cancelled"), so each one logs why: a silent -/// cancellation is indistinguishable from the sign-in window never appearing. +/// the user ("Login attempt cancelled"), so each one logs why — including a +/// crash before the child sends anything, which just surfaces as a plain EOF +/// once its inherited stdout closes. fn wait_for_result( result_read: HANDLE, cancel_write: HANDLE, @@ -275,8 +335,9 @@ fn wait_for_result( let mut result_file = unsafe { File::from_raw_handle(result_read.0) }; let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let outcome = match ak_ee_wcp_wire::read_auth_result(&mut result_file) { - Ok(Some(result)) => PipeOutcome::Result(result), + let outcome = match ak_ee_wcp_wire::read_host_report(&mut result_file) { + Ok(Some(HostReport::Redirected { url })) => PipeOutcome::Result(auth_result_for(&url)), + Ok(Some(HostReport::Cancelled)) => PipeOutcome::Result(AuthResult::Cancelled), Ok(None) => PipeOutcome::Eof, Err(e) => PipeOutcome::Error(e.to_string()), }; @@ -311,7 +372,11 @@ fn wait_for_result( if !cancel_signalled && !should_continue() { log::info!("LogonUI withdrew the sign-in; asking the window to close"); cancel_signalled = true; - signal_cancel(cancel_write); + // `cancel_write` stays owned by the caller, closed once + // this function returns — wrap it without taking that. + let mut f = unsafe { File::from_raw_handle(cancel_write.0) }; + let _ = ak_ee_wcp_wire::write_frame(&mut f, &ak_ee_wcp_wire::CancelSignal {}); + std::mem::forget(f); } // Host exited without sending a result. if unsafe { WaitForSingleObject(process, 0) } == WAIT_OBJECT_0 { @@ -346,75 +411,84 @@ fn describe_exit(process: HANDLE) -> String { format!("exit code {code:#010x}") } -fn signal_cancel(cancel_write: HANDLE) { - let mut f = unsafe { File::from_raw_handle(cancel_write.0) }; - let _ = ak_ee_wcp_wire::write_frame(&mut f, &ak_ee_wcp_wire::CancelSignal {}); - std::mem::forget(f); +/// Gets `ak_cef.exe` a token for the dedicated service account rather than +/// SYSTEM (`BROWSER_PRIVILEGE.md`), the same way for both logon and unlock. +/// Account-hardening is best-effort and only logged on failure — it is +/// idempotent, so a transient failure just costs a retry next time, and +/// does not block the token mint that follows. The password is not: a +/// broken keyring here means no way to log the account on at all. +fn acquire_service_account_token() -> windows::core::Result { + let password = syscalls::service_account_password().map_err(|e| { + log::error!("could not establish the service account's password: {e}"); + windows::core::Error::from(E_FAIL) + })?; + + let sid = syscalls::account_sid(syscalls::SERVICE_ACCOUNT_NAME)?; + + if let Err(e) = syscalls::deny_interactive_and_network_logon(&sid) { + log::warn!("could not deny the service account interactive/network logon: {e}"); + } + if let Err(e) = syscalls::ensure_desktop_access(&sid) { + log::warn!("could not grant the service account secure-desktop access: {e}"); + } + if let Err(e) = syscalls::ensure_base_named_objects_access(&sid) { + log::warn!("could not grant the service account BaseNamedObjects access: {e}"); + } + + syscalls::service_account_token(syscalls::SERVICE_ACCOUNT_NAME, &password) +} + +/// Minimal Windows command-line quoting: wraps `s` in quotes and escapes any +/// embedded ones, so `CommandLineToArgvW` (what `std::env::args()` on the +/// far end is built on) sees it as a single argument. Neither a URL nor an +/// opaque token legitimately contains the backslash-before-quote sequence +/// the full algorithm exists to handle. +fn quote_arg(s: &str) -> String { + format!("\"{}\"", s.replace('"', "\\\"")) } fn spawn_cef_host( cef_exe: &Path, - pipes: &DuplexPipes, + pipes: &StdPipes, cpus: CREDENTIAL_PROVIDER_USAGE_SCENARIO, + sign_in_url: &str, + header_token: &str, ) -> windows::core::Result { let cmdline = format!( - "\"{}\" --result-pipe {} --cancel-pipe {}", + "\"{}\" --sign-in-url {} --header-token {}", cef_exe.display(), - pipes.result_write_inheritable.0 as usize, - pipes.cancel_read_inheritable.0 as usize + quote_arg(sign_in_url), + quote_arg(header_token), ); - let mut cmdline_wide: Vec = cmdline.encode_utf16().chain(std::iter::once(0)).collect(); - - let mut attr_size = 0usize; - unsafe { - let _ = InitializeProcThreadAttributeList(None, 1, Some(0), &mut attr_size); - } - let mut attr_buf = vec![0u8; attr_size]; - let attr_list = LPPROC_THREAD_ATTRIBUTE_LIST(attr_buf.as_mut_ptr() as *mut c_void); - unsafe { InitializeProcThreadAttributeList(Some(attr_list), 1, Some(0), &mut attr_size) }?; - - let inherit_handles = [ - pipes.result_write_inheritable, - pipes.cancel_read_inheritable, - ]; - let update = unsafe { - UpdateProcThreadAttribute( - attr_list, - 0, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize, - Some(inherit_handles.as_ptr() as *const c_void), - size_of::<[HANDLE; 2]>(), - None, - None, - ) - }; - if update.is_err() { - unsafe { DeleteProcThreadAttributeList(attr_list) }; - return Err(windows::core::Error::from(E_FAIL)); - } - let mut si = STARTUPINFOEXW { - lpAttributeList: attr_list, + let mut si = STARTUPINFOW { + cb: size_of::() as u32, + dwFlags: STARTF_USESTDHANDLES, + hStdInput: pipes.child_stdin, + hStdOutput: pipes.child_stdout, ..Default::default() }; - si.StartupInfo.cb = size_of::() as u32; // Outlives every `CreateProcess*` call below; `lpDesktop` borrows it. - let mut desktop = desktop_for(cpus); + let mut desktop = (!may_launch_in_current_session(cpus)).then(|| { + SECURE_DESKTOP + .encode_utf16() + .chain([0]) + .collect::>() + }); if let Some(desktop) = desktop.as_mut() { - si.StartupInfo.lpDesktop = PWSTR(desktop.as_mut_ptr()); + si.lpDesktop = PWSTR(desktop.as_mut_ptr()); } let mut pi = PROCESS_INFORMATION::default(); - let token = match acquire_interactive_token() { - Ok(token) => Some(token), - Err(e) if may_launch_in_current_session(cpus) => { - log::debug!("no interactive-session token ({e}); launching in the current session"); - None - } - Err(e) => { - log::error!("could not acquire an interactive-session token: {e}"); - unsafe { DeleteProcThreadAttributeList(attr_list) }; - return Err(e); + let token = if may_launch_in_current_session(cpus) { + None + } else { + match acquire_service_account_token() { + Ok(token) => Some(token), + Err(e) => { + log::error!("could not acquire the service account's token: {e}"); + return Err(e); + } } }; log::info!( @@ -425,51 +499,30 @@ fn spawn_cef_host( .map(|_| SECURE_DESKTOP) .unwrap_or(""), if token.is_some() { - "an interactive-session" + "the service account's" } else { "the caller's own" } ); - let mut spawned = match token { - Some(token) => unsafe { - CreateProcessAsUserW( - Some(token), - PCWSTR::null(), - Some(PWSTR(cmdline_wide.as_mut_ptr())), - None, - None, - true, - EXTENDED_STARTUPINFO_PRESENT, - None, - PCWSTR::null(), - &si.StartupInfo, - &mut pi, - ) - }, - None => spawn_in_current_session(&cmdline, &si.StartupInfo, &mut pi), - }; - - // Holding a token is not the same as being allowed to assign it: without - // SE_ASSIGNPRIMARYTOKEN/SE_INCREASE_QUOTA, `CreateProcessAsUserW` fails - // even though a plain `CreateProcessW` in this session would work. Under - // `CPUS_CREDUI` that is still the right outcome, so retry rather than - // failing the whole flow. Never reached for the real logon scenarios. - if let Err(e) = spawned.as_ref() - && token.is_some() - && may_launch_in_current_session(cpus) - { - log::debug!("CreateProcessAsUserW failed ({e}); retrying in the current session"); - spawned = spawn_in_current_session(&cmdline, &si.StartupInfo, &mut pi); - } - - unsafe { - DeleteProcThreadAttributeList(attr_list); - if let Some(token) = token { - let _ = CloseHandle(token); + let spawned = match token { + Some(token) => { + // Confirmed enabled on the test box, but `SE_TCB_NAME` looked + // that way too until it turned out not to be held at all — + // enable it defensively rather than trust the default. + if let Err(e) = + syscalls::enable_privilege(SE_IMPERSONATE_NAME, "SeImpersonatePrivilege") + { + log::warn!("could not enable SeImpersonatePrivilege: {e}"); + } + let result = spawn_with_token(token, &cmdline, &si, &mut pi); + unsafe { + let _ = CloseHandle(token); + } + result } - } - + None => spawn_in_current_session(&cmdline, &si, &mut pi), + }; spawned?; // A freshly spawned process may not bring its own window forward without @@ -491,11 +544,73 @@ fn spawn_cef_host( Ok(pi) } +/// Brokered through the Secondary Logon service, needing only +/// `SE_IMPERSONATE_NAME` where `CreateProcessAsUserW` would need +/// `SE_ASSIGNPRIMARYTOKEN_NAME`/`SE_INCREASE_QUOTA_NAME` — both absent from +/// LogonUI's token. Has no `bInheritHandles` parameter, but does honor `si`'s +/// inheritable `hStdInput`/`hStdOutput` regardless (`BROWSER_PRIVILEGE.md`'s +/// "Roads not taken"). `LOGON_WITH_PROFILE` loads the account's registry hive +/// but not its environment block, hence building one explicitly below. +fn spawn_with_token( + token: HANDLE, + cmdline: &str, + si: &STARTUPINFOW, + pi: &mut PROCESS_INFORMATION, +) -> windows::core::Result<()> { + let mut cmdline_wide: Vec = cmdline.encode_utf16().chain(std::iter::once(0)).collect(); + + // Best-effort: on this account's very first ever launch its profile may + // not exist on disk yet — only `LOGON_WITH_PROFILE` below creates it — + // so `CreateEnvironmentBlock` can fail here. Falling back to this + // process's own environment rather than refusing the spawn entirely + // matches every other "logged, not fatal" cleanup/setup step in this + // file; the spawn is still worth attempting either way. + let mut env_block: *mut c_void = std::ptr::null_mut(); + let has_env = unsafe { CreateEnvironmentBlock(&mut env_block, Some(token), false) }.is_ok(); + if !has_env { + log::warn!( + "could not build an environment block for the service account; \ + falling back to this process's own" + ); + } + let (creation_flags, environment) = if has_env { + ( + PROCESS_CREATION_FLAGS(CREATE_UNICODE_ENVIRONMENT.0), + Some(env_block as *const c_void), + ) + } else { + (PROCESS_CREATION_FLAGS(0), None) + }; + + let result = unsafe { + CreateProcessWithTokenW( + token, + LOGON_WITH_PROFILE, + PCWSTR::null(), + Some(PWSTR(cmdline_wide.as_mut_ptr())), + creation_flags, + environment, + PCWSTR::null(), + si, + pi, + ) + }; + + if has_env { + unsafe { + let _ = DestroyEnvironmentBlock(env_block); + } + } + + result +} + /// `CreateProcessW` may write into the command-line buffer it is handed, so -/// each attempt gets a fresh copy. +/// each attempt gets a fresh copy. `bInheritHandles` is `true` so the child +/// picks up `si`'s `hStdInput`/`hStdOutput`. fn spawn_in_current_session( cmdline: &str, - startup_info: &windows::Win32::System::Threading::STARTUPINFOW, + startup_info: &STARTUPINFOW, pi: &mut PROCESS_INFORMATION, ) -> windows::core::Result<()> { let mut cmdline_wide: Vec = cmdline.encode_utf16().chain(std::iter::once(0)).collect(); @@ -506,7 +621,7 @@ fn spawn_in_current_session( None, None, true, - EXTENDED_STARTUPINFO_PRESENT, + PROCESS_CREATION_FLAGS(0), None, PCWSTR::null(), startup_info, @@ -520,6 +635,7 @@ fn spawn_in_current_session( mod tests { use super::*; use std::cell::{Cell, RefCell}; + use windows::Win32::System::Threading::TerminateProcess; use windows::Win32::UI::Shell::{CPUS_CHANGE_PASSWORD, CPUS_LOGON, CPUS_UNLOCK_WORKSTATION}; const CHILD: u32 = 4242; @@ -686,14 +802,14 @@ mod tests { ); } - /// Exercises the real attribute-list / handle-inheritance / CreateProcess - /// machinery against a throwaway target, without needing an interactive - /// token, elevation, or anything listening on the `ak-sysd` pipe. A - /// failure here means `Connect` can never launch the sign-in window, - /// which otherwise only surfaces as one generic "Sign-in failed" string. + /// Exercises the real inherited-pipe / `CreateProcessW` machinery against + /// a throwaway target, without needing an interactive token, elevation, + /// or anything listening on the `ak-sysd` pipe. A failure here means + /// `Connect` can never launch the sign-in window, which otherwise only + /// surfaces as one generic "Sign-in failed" string. #[test] fn credui_spawn_succeeds_without_an_interactive_token() { - let pipes = create_duplex_pipes().expect("create duplex pipes"); + let pipes = create_std_pipes().expect("create std pipes"); // Any real executable will do: this asserts the process is created, // not what it does. It exits immediately on the unknown arguments. @@ -701,18 +817,18 @@ mod tests { std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()), ); - let spawned = spawn_cef_host(&exe, &pipes, CPUS_CREDUI); + let spawned = spawn_cef_host(&exe, &pipes, CPUS_CREDUI, "https://example.com", "token"); unsafe { + let _ = CloseHandle(pipes.child_stdin); + let _ = CloseHandle(pipes.child_stdout); let _ = CloseHandle(pipes.result_read); - let _ = CloseHandle(pipes.result_write_inheritable); let _ = CloseHandle(pipes.cancel_write); - let _ = CloseHandle(pipes.cancel_read_inheritable); } match spawned { Ok(pi) => unsafe { - let _ = windows::Win32::System::Threading::TerminateProcess(pi.hProcess, 0); + let _ = TerminateProcess(pi.hProcess, 0); let _ = CloseHandle(pi.hProcess); let _ = CloseHandle(pi.hThread); }, diff --git a/ee/wcp/credprovider/src/syscalls.rs b/ee/wcp/credprovider/src/syscalls.rs index 68278250..fe853754 100644 --- a/ee/wcp/credprovider/src/syscalls.rs +++ b/ee/wcp/credprovider/src/syscalls.rs @@ -13,30 +13,36 @@ use windows::Win32::UI::WindowsAndMessaging::{ use windows::{ Win32::{ Foundation::{ - E_FAIL, ERROR_LOGON_FAILURE, ERROR_PASSWORD_EXPIRED, ERROR_PASSWORD_MUST_CHANGE, + ERROR_LOGON_FAILURE, ERROR_NOT_ALL_ASSIGNED, ERROR_PASSWORD_EXPIRED, + ERROR_PASSWORD_MUST_CHANGE, GENERIC_ALL, GetLastError, HLOCAL, LUID, LocalFree, }, NetworkManagement::NetManagement::{NetUserChangePassword, NetUserSetInfo, USER_INFO_1003}, Security::{ + ACL, AdjustTokenPrivileges, Authentication::Identity::{ - LSA_STRING, LsaConnectUntrusted, LsaDeregisterLogonProcess, - LsaLookupAuthenticationPackage, + LSA_HANDLE, LSA_OBJECT_ATTRIBUTES, LSA_STRING, LSA_UNICODE_STRING, + LsaAddAccountRights, LsaClose, LsaConnectUntrusted, LsaDeregisterLogonProcess, + LsaLookupAuthenticationPackage, LsaOpenPolicy, POLICY_CREATE_ACCOUNT, }, - DuplicateTokenEx, LOGON32_LOGON_NETWORK, LOGON32_PROVIDER_DEFAULT, LogonUserW, - SecurityImpersonation, TOKEN_ACCESS_MASK, TOKEN_ALL_ACCESS, TOKEN_ASSIGN_PRIMARY, - TOKEN_DUPLICATE, TOKEN_QUERY, TokenPrimary, - }, - System::Diagnostics::ToolHelp::{ - CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, - TH32CS_SNAPPROCESS, - }, - System::RemoteDesktop::{ - ProcessIdToSessionId, WTSGetActiveConsoleSessionId, WTSQueryUserToken, + Authorization::{ + ConvertSidToStringSidW, EXPLICIT_ACCESS_W, GetSecurityInfo, NO_MULTIPLE_TRUSTEE, + SE_OBJECT_TYPE, SE_WINDOW_OBJECT, SET_ACCESS, SetEntriesInAclW, SetSecurityInfo, + TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, + }, + CreateRestrictedToken, DACL_SECURITY_INFORMATION, DISABLE_MAX_PRIVILEGE, + GetTokenInformation, LOGON32_LOGON_NETWORK, LOGON32_LOGON_SERVICE, + LOGON32_PROVIDER_DEFAULT, LUID_AND_ATTRIBUTES, LogonUserW, LookupAccountNameW, + LookupAccountSidW, LookupPrivilegeValueW, NO_INHERITANCE, PSECURITY_DESCRIPTOR, PSID, + SE_PRIVILEGE_ENABLED, SID_NAME_USE, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, + TOKEN_QUERY, TOKEN_USER, TokenUser, }, - System::Threading::{ - GetCurrentProcessId, OpenProcess, OpenProcessToken, PROCESS_QUERY_INFORMATION, + Storage::FileSystem::{READ_CONTROL, WRITE_DAC}, + System::StationsAndDesktops::{ + DESKTOP_CONTROL_FLAGS, GetProcessWindowStation, OpenDesktopW, }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, }, - core::{HRESULT, PCWSTR, PSTR, w}, + core::{HRESULT, PCWSTR, PSTR, PWSTR, w}, }; use windows_core::BOOL; @@ -93,6 +99,18 @@ fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } +/// `LSA_STRING` is counted, but `MaximumLength` is expected to cover a +/// trailing NUL — that is what `LsaInitString` produces for a C literal. +/// Claiming `len + 1` over a buffer that has no terminator overruns it, so +/// `name` must end in `\0`. +fn lsa_string(name: &'static [u8]) -> LSA_STRING { + LSA_STRING { + Length: (name.len() - 1) as u16, + MaximumLength: name.len() as u16, + Buffer: PSTR(name.as_ptr() as *mut u8), + } +} + impl ForegroundControl for RealSyscalls { fn foreground_pid(&self) -> Option { let hwnd = unsafe { GetForegroundWindow() }; @@ -169,16 +187,7 @@ impl AuthPackageLookup for RealSyscalls { let mut lsa_handle = HANDLE::default(); unsafe { LsaConnectUntrusted(&mut lsa_handle) }.ok()?; - // `LSA_STRING` is counted, but `MaximumLength` is expected to cover a - // trailing NUL — that is what `LsaInitString` produces for a C literal. - // Claiming `len + 1` over a buffer that has no terminator overruns it. - let name = b"Negotiate\0"; - let lsa_name = LSA_STRING { - Length: (name.len() - 1) as u16, - MaximumLength: name.len() as u16, - Buffer: PSTR(name.as_ptr() as *mut u8), - }; - + let lsa_name = lsa_string(b"Negotiate\0"); let mut auth_package = 0u32; let status = unsafe { LsaLookupAuthenticationPackage(lsa_handle, &lsa_name, &mut auth_package) }; @@ -327,129 +336,459 @@ impl PasswordStore for KeyringPasswordStore { } } -/// Acquire a primary token for the active console (interactive) session, so -/// `ak_cef.exe` can be launched there rather than in LogonUI's Session 0. -/// -/// `WTSQueryUserToken` works once a user token exists (unlock scenario); on -/// a fresh logon no such token exists yet, so this falls back to duplicating -/// `winlogon.exe`'s own token in that session. -pub fn acquire_interactive_token() -> windows::core::Result { +/// Name of the dedicated local account `ak_cef.exe` runs as instead of +/// SYSTEM. Created by the installer (`vpkg/windows/Package.wxs`'s +/// `util:User`) — keep this in step with that element's `Name` attribute. +pub const SERVICE_ACCOUNT_NAME: &str = "ak-wcp-browser"; + +/// Resolves the service account's name to a SID: both `LsaAddAccountRights` +/// and the desktop ACL grant below want one, and a name is all the installer +/// leaves behind. +pub fn account_sid(username: &str) -> windows::core::Result> { + let username_wide = wide(username); + // Large enough for any SID Windows issues (the practical maximum is well + // under 68 bytes) and any domain name `LookupAccountNameW` might report. + let mut sid = vec![0u8; 256]; + let mut sid_len = sid.len() as u32; + let mut domain = [0u16; 256]; + let mut domain_len = domain.len() as u32; + let mut use_ = SID_NAME_USE::default(); unsafe { - let session = WTSGetActiveConsoleSessionId(); - if session == 0xFFFF_FFFF { - log::error!("no active console session"); - return Err(windows::core::Error::from(E_FAIL)); - } + LookupAccountNameW( + PCWSTR::null(), + PCWSTR(username_wide.as_ptr()), + Some(PSID(sid.as_mut_ptr() as *mut _)), + &mut sid_len, + Some(PWSTR(domain.as_mut_ptr())), + &mut domain_len, + &mut use_, + )?; + } + sid.truncate(sid_len as usize); + Ok(sid) +} + +fn lsa_unicode_string(wide: &[u16]) -> LSA_UNICODE_STRING { + let bytes = (wide.len() * 2) as u16; + LSA_UNICODE_STRING { + Length: bytes, + MaximumLength: bytes, + Buffer: PWSTR(wide.as_ptr() as *mut u16), + } +} +/// Best-effort "who are we actually running as" for the log line right +/// before a privilege-enable failure. A privilege can be missing either +/// because policy genuinely denies it to this account, or because the +/// token isn't the SYSTEM token this whole design assumes it is — those +/// need different fixes, and the log line otherwise can't tell them apart. +fn current_token_identity() -> String { + unsafe { let mut token = HANDLE::default(); - if WTSQueryUserToken(session, &mut token).is_ok() { - return Ok(token); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token).is_err() { + return "".to_string(); } - // This provider is loaded into the process drawing the logon UI, so its - // own session is the one the person is signing in to. That should be - // the console session; log it when it is not, because then the console - // session is the wrong thing to be looking for winlogon in. - let mut own_session = 0u32; - if ProcessIdToSessionId(GetCurrentProcessId(), &mut own_session).is_ok() - && own_session != session - { - log::warn!( - "console session is {session} but this provider is in session {own_session}" - ); - } + let mut buf = [0u8; 256]; + let mut ret_len = 0u32; + let got_user = GetTokenInformation( + token, + TokenUser, + Some(buf.as_mut_ptr() as *mut c_void), + buf.len() as u32, + &mut ret_len, + ); - winlogon_token_for_session(session) + let identity = if got_user.is_ok() { + let sid = (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid; + let mut name = [0u16; 256]; + let mut name_len = name.len() as u32; + let mut domain = [0u16; 256]; + let mut domain_len = domain.len() as u32; + let mut use_ = SID_NAME_USE::default(); + if LookupAccountSidW( + PCWSTR::null(), + sid, + Some(PWSTR(name.as_mut_ptr())), + &mut name_len, + Some(PWSTR(domain.as_mut_ptr())), + &mut domain_len, + &mut use_, + ) + .is_ok() + { + format!( + "{}\\{}", + String::from_utf16_lossy(&domain[..domain_len as usize]), + String::from_utf16_lossy(&name[..name_len as usize]) + ) + } else { + "".to_string() + } + } else { + "".to_string() + }; + + let _ = CloseHandle(token); + identity } } -/// There is one `winlogon.exe` per session, so the snapshot has to be searched -/// to the end: stopping at the first one found gives up as soon as the -/// enumeration happens to reach another session's copy first, which is what a -/// logoff/logon cycle produces once the console session id has moved on. -fn winlogon_token_for_session(session_id: u32) -> windows::core::Result { +/// SYSTEM's token holds every privilege this file needs, but — like any +/// privilege not `SE_PRIVILEGE_ENABLED_BY_DEFAULT` — disabled until asked +/// for; the APIs that need one check it as active, not merely present. +/// `display` is only for the log line on failure — `AdjustTokenPrivileges` +/// doesn't say which of the (here, always one) privileges it couldn't grant. +pub fn enable_privilege(name: PCWSTR, display: &str) -> windows::core::Result<()> { unsafe { - let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)?; + let mut token = HANDLE::default(); + OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &mut token)?; + + let mut luid = LUID::default(); + let result = (|| { + LookupPrivilegeValueW(PCWSTR::null(), name, &mut luid)?; + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + AdjustTokenPrivileges(token, false, Some(&privileges), 0, None, None)?; + // The call above reports success even when the privilege was not + // actually held to enable — a classic trap, and indistinguishable + // from a real success without this check. + if GetLastError() == ERROR_NOT_ALL_ASSIGNED { + log::error!( + "{display} is not held by this token at all (running as {})", + current_token_identity() + ); + return Err(windows::core::Error::from(HRESULT::from_win32( + ERROR_NOT_ALL_ASSIGNED.0, + ))); + } + Ok(()) + })(); + + let _ = CloseHandle(token); + result + } +} + +/// Mints a primary token for the service account by logging it on with its +/// stored password, then strips every privilege from the result — the same +/// pattern GCPW uses for its own LogonUI-hosted sign-in UI (`CreateLogonToken` +/// in `chrome/credential_provider/gaiacp/gcp_utils.cc`). `LOGON32_LOGON_SERVICE`, +/// not `_BATCH`: a batch-logon token cannot create named synchronization +/// objects, fatal to Chromium's own `ProcessSingleton` — see +/// `BROWSER_PRIVILEGE.md`'s "Roads not taken" for why. +pub fn service_account_token(username: &str, password: &str) -> windows::core::Result { + let username_wide = wide(username); + let password_wide = wide(password); + + let mut primary = HANDLE::default(); + unsafe { + LogonUserW( + PCWSTR(username_wide.as_ptr()), + w!("."), + PCWSTR(password_wide.as_ptr()), + LOGON32_LOGON_SERVICE, + LOGON32_PROVIDER_DEFAULT, + &mut primary, + )?; + } + + let mut restricted = HANDLE::default(); + let result = unsafe { + CreateRestrictedToken( + primary, + DISABLE_MAX_PRIVILEGE, + None, + None, + None, + &mut restricted, + ) + }; + unsafe { + let _ = CloseHandle(primary); + } + result?; + Ok(restricted) +} + +/// String form of a SID, for the keyring's `sid` key. `account_sid` returns +/// raw bytes for the Win32 calls that want a `PSID`; the keyring store +/// (like the interactive user's, `credential.rs`) is keyed by the string +/// form instead. +pub(crate) fn sid_to_string(sid: &[u8]) -> windows::core::Result { + unsafe { + let mut wide_sid = PWSTR(std::ptr::null_mut()); + ConvertSidToStringSidW(PSID(sid.as_ptr() as *mut _), &mut wide_sid)?; + let len = (0..).take_while(|&i| *wide_sid.0.add(i) != 0).count(); + let result = String::from_utf16_lossy(std::slice::from_raw_parts(wide_sid.0, len)); + let _ = LocalFree(Some(HLOCAL(wide_sid.0 as *mut c_void))); + Ok(result) + } +} - let mut entry = PROCESSENTRY32W { - dwSize: std::mem::size_of::() as u32, - ..Default::default() +/// Adds `sid` to `handle`'s DACL with `access_mask`, preserving every +/// existing entry — `SetEntriesInAclW` merges onto `old_dacl` rather than +/// replacing it, which matters here: replacing outright would drop +/// SYSTEM/Administrators access to the object this process itself needs. +unsafe fn grant_access( + handle: HANDLE, + object_type: SE_OBJECT_TYPE, + sid: &[u8], + access_mask: u32, +) -> windows::core::Result<()> { + unsafe { + let mut old_dacl: *mut ACL = std::ptr::null_mut(); + let mut sd = PSECURITY_DESCRIPTOR::default(); + GetSecurityInfo( + handle, + object_type, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut old_dacl), + None, + Some(&mut sd), + ) + .ok()?; + + let trustee = TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(sid.as_ptr() as *mut u16), + }; + let entry = EXPLICIT_ACCESS_W { + grfAccessPermissions: access_mask, + grfAccessMode: SET_ACCESS, + grfInheritance: NO_INHERITANCE, + Trustee: trustee, }; - let mut result = Err(windows::core::Error::from(E_FAIL)); - let mut sessions_seen = Vec::new(); - - if Process32FirstW(snap, &mut entry).is_ok() { - loop { - let nul = entry - .szExeFile - .iter() - .position(|&c| c == 0) - .unwrap_or(entry.szExeFile.len()); - let name = String::from_utf16_lossy(&entry.szExeFile[..nul]); - - if name.eq_ignore_ascii_case("winlogon.exe") { - let mut proc_session = 0u32; - if ProcessIdToSessionId(entry.th32ProcessID, &mut proc_session).is_ok() { - sessions_seen.push(proc_session); - if proc_session == session_id { - match duplicate_process_primary_token(entry.th32ProcessID) { - Ok(dup) => { - result = Ok(dup); - break; - } - // Keep looking: another instance in the same - // session may still hand one over. - Err(e) => log::warn!( - "could not duplicate the token of winlogon.exe \ - (pid {}, session {proc_session}): {e}", - entry.th32ProcessID - ), - } - } - } - } + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + let entries_result = SetEntriesInAclW(Some(&[entry]), Some(old_dacl), &mut new_dacl); + let set_result = if entries_result.is_ok() { + SetSecurityInfo( + handle, + object_type, + DACL_SECURITY_INFORMATION, + None, + None, + Some(new_dacl), + None, + ) + } else { + entries_result + }; - if Process32NextW(snap, &mut entry).is_err() { - break; - } - } + let _ = LocalFree(Some(HLOCAL(sd.0))); + if !new_dacl.is_null() { + let _ = LocalFree(Some(HLOCAL(new_dacl as *mut std::ffi::c_void))); } - let _ = CloseHandle(snap); - if result.is_err() { - log::warn!( - "no usable winlogon.exe token for console session {session_id}; \ - saw winlogon in sessions {sessions_seen:?}" - ); - } + set_result.ok() + } +} + +/// Grants the service account's SID access to the secure desktop — a +/// non-SYSTEM token has none by default. Deliberate; see `BROWSER_PRIVILEGE.md` +/// for the tradeoff. Idempotent, and only correct when called from inside +/// LogonUI's own process: `GetProcessWindowStation`/`OpenDesktopW` resolve +/// relative to the *caller's* window station, `WinSta0` here. +pub fn ensure_desktop_access(sid: &[u8]) -> windows::core::Result<()> { + unsafe { + let winsta = GetProcessWindowStation()?; + grant_access(HANDLE(winsta.0), SE_WINDOW_OBJECT, sid, GENERIC_ALL.0)?; + + let desktop = OpenDesktopW( + w!("Winlogon"), + DESKTOP_CONTROL_FLAGS(0), + false, + (READ_CONTROL | WRITE_DAC).0, + )?; + grant_access(HANDLE(desktop.0), SE_WINDOW_OBJECT, sid, GENERIC_ALL.0) + } +} + +/// Grants the service account's SID rights to create objects in this +/// session's `BaseNamedObjects` — the Object Manager directory Windows +/// resolves `Local\`-prefixed names into. No Win32 wrapper exists for +/// opening an arbitrary Object Manager directory the way `OpenDesktopW` +/// does for desktops, hence the native `NtOpenDirectoryObject` call. +/// Mirrors GCPW's own `AllowLogonSIDOnLocalBasedNamedObjects` +/// (`chrome/credential_provider/gaiacp/os_process_manager.cc`), including +/// its narrower-than-`GENERIC_ALL` mask — see `BROWSER_PRIVILEGE.md`. +pub fn ensure_base_named_objects_access(sid: &[u8]) -> windows::core::Result<()> { + use windows::Wdk::Foundation::OBJECT_ATTRIBUTES; + use windows::Wdk::Storage::FileSystem::NtOpenDirectoryObject; + use windows::Wdk::System::SystemServices::{ + DIRECTORY_CREATE_OBJECT, DIRECTORY_CREATE_SUBDIRECTORY, DIRECTORY_QUERY, DIRECTORY_TRAVERSE, + }; + use windows::Win32::Foundation::UNICODE_STRING; + use windows::Win32::System::RemoteDesktop::ProcessIdToSessionId; + use windows::Win32::System::Threading::GetCurrentProcessId; + + let mut session_id = 0u32; + unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut session_id)? }; + + let path = if session_id == 0 { + r"\BaseNamedObjects".to_string() + } else { + format!(r"\Sessions\{session_id}\BaseNamedObjects") + }; + let path_wide = wide(&path); + let mut name = UNICODE_STRING { + Length: ((path_wide.len() - 1) * 2) as u16, + MaximumLength: (path_wide.len() * 2) as u16, + Buffer: PWSTR(path_wide.as_ptr() as *mut u16), + }; + let object_attributes = OBJECT_ATTRIBUTES { + Length: size_of::() as u32, + ObjectName: &mut name, + ..Default::default() + }; + + unsafe { + let mut directory = HANDLE::default(); + NtOpenDirectoryObject( + &mut directory, + DIRECTORY_TRAVERSE | READ_CONTROL.0 | WRITE_DAC.0, + &object_attributes, + ) + .ok()?; + + let result = grant_access( + directory, + SE_WINDOW_OBJECT, + sid, + DIRECTORY_QUERY + | DIRECTORY_TRAVERSE + | DIRECTORY_CREATE_OBJECT + | DIRECTORY_CREATE_SUBDIRECTORY, + ); + let _ = CloseHandle(directory); result } } -fn duplicate_process_primary_token(pid: u32) -> windows::core::Result { +/// Denies the service account the logon types that would let it sign someone +/// in — it must not be usable at the very screen it serves. `Service`, what +/// `service_account_token` uses (`LOGON32_LOGON_SERVICE`), is deliberately +/// not among these. `LsaAddAccountRights` is itself idempotent, so this is +/// safe on every load. +pub fn deny_interactive_and_network_logon(sid: &[u8]) -> windows::core::Result<()> { + const RIGHTS: [&str; 3] = [ + "SeDenyInteractiveLogonRight", + "SeDenyNetworkLogonRight", + "SeDenyRemoteInteractiveLogonRight", + ]; + let wide_rights: Vec> = RIGHTS.iter().map(|r| r.encode_utf16().collect()).collect(); + let lsa_rights: Vec = + wide_rights.iter().map(|w| lsa_unicode_string(w)).collect(); + unsafe { - let hproc = OpenProcess(PROCESS_QUERY_INFORMATION, false, pid)?; - let access = TOKEN_ACCESS_MASK(TOKEN_DUPLICATE.0 | TOKEN_QUERY.0 | TOKEN_ASSIGN_PRIMARY.0); - let mut raw = HANDLE::default(); - let opened = OpenProcessToken(hproc, access, &mut raw); - if opened.is_err() { - let _ = CloseHandle(hproc); - return Err(windows::core::Error::from(E_FAIL)); + let mut policy_handle = LSA_HANDLE::default(); + let object_attrs = LSA_OBJECT_ATTRIBUTES::default(); + LsaOpenPolicy( + None, + &object_attrs, + POLICY_CREATE_ACCOUNT as u32, + &mut policy_handle, + ) + .ok()?; + + let status = LsaAddAccountRights(policy_handle, PSID(sid.as_ptr() as *mut _), &lsa_rights); + let _ = LsaClose(policy_handle); + status.ok() + } +} + +/// The service account's password: established once and reused after that, +/// same state machine and same reasoning as the interactive user's own +/// account (`credential.rs::account_password`, `LOCAL_PASSWORD.md`) — an +/// administrative reset orphans the DPAPI master key, so once a password is +/// known, only `change` touches the account again. That reasoning is about +/// DPAPI survival, which does not apply to an account nothing ever signs +/// into interactively; kept anyway; there is no reason to churn the account +/// on every logon when reuse costs nothing. +pub fn service_account_password() -> eyre::Result { + let sid = account_sid(SERVICE_ACCOUNT_NAME).map_err(|e| eyre::eyre!("{e}"))?; + let sid = sid_to_string(&sid).map_err(|e| eyre::eyre!("{e}"))?; + let store = KeyringPasswordStore::new(); + + if let Some(stored) = store.load(&sid)? { + match RealSyscalls.validate(SERVICE_ACCOUNT_NAME, &stored) { + Ok(PasswordCheck::Valid) => return Ok(stored), + Ok(PasswordCheck::Expired) => { + let new = + crate::helpers::generate_random_password().map_err(|e| eyre::eyre!("{e}"))?; + if RealSyscalls + .change(SERVICE_ACCOUNT_NAME, &stored, &new) + .is_ok() + { + let _ = store.save(&sid, &new); + return Ok(new); + } + } + // Changed out of band; fall through to a reset. + Ok(PasswordCheck::Rejected) => {} + // Inconclusive, not wrong — see credential.rs::stored_password. + Err(e) => { + log::warn!( + "could not verify the service account's stored password ({e}); using it anyway" + ); + return Ok(stored); + } } + } - let mut dup = HANDLE::default(); - let result = DuplicateTokenEx( - raw, - TOKEN_ALL_ACCESS, - None, - SecurityImpersonation, - TokenPrimary, - &mut dup, - ); - let _ = CloseHandle(raw); - let _ = CloseHandle(hproc); - result?; - Ok(dup) + let password = crate::helpers::generate_random_password().map_err(|e| eyre::eyre!("{e}"))?; + RealSyscalls + .reset(SERVICE_ACCOUNT_NAME, &password) + .map_err(|e| eyre::eyre!("{e}"))?; + if let Err(e) = store.save(&sid, &password) { + log::error!("could not store the service account's password: {e}"); + } + Ok(password) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod byte_layout_tests { + use super::*; + + /// `MaximumLength` must cover the trailing NUL while `Length` excludes + /// it — get this backwards and `LsaLookupAuthenticationPackage` either + /// truncates the last real character or reads one byte past the buffer. + #[test] + fn lsa_string_length_excludes_the_trailing_nul() { + let s = lsa_string(b"Negotiate\0"); + assert_eq!(s.Length, 9); + assert_eq!(s.MaximumLength, 10); + let bytes = unsafe { std::slice::from_raw_parts(s.Buffer.0, s.Length as usize) }; + assert_eq!(bytes, b"Negotiate"); + } + + /// Unlike `LSA_STRING`, `deny_interactive_and_network_logon`'s rights + /// list carries no NUL at all — `Length`/`MaximumLength` are both the + /// exact UTF-16 byte count. + #[test] + fn lsa_unicode_string_round_trips_without_a_nul() { + let wide: Vec = "ak-wcp-browser".encode_utf16().collect(); + let s = lsa_unicode_string(&wide); + assert_eq!(s.Length as usize, wide.len() * 2); + assert_eq!(s.MaximumLength, s.Length); + let read = unsafe { + String::from_utf16_lossy(std::slice::from_raw_parts( + s.Buffer.0, + (s.Length / 2) as usize, + )) + }; + assert_eq!(read, "ak-wcp-browser"); } } diff --git a/ee/wcp/credprovider/src/sysd.rs b/ee/wcp/credprovider/src/sysd.rs index 2989992e..3c8c88e9 100644 --- a/ee/wcp/credprovider/src/sysd.rs +++ b/ee/wcp/credprovider/src/sysd.rs @@ -1,5 +1,9 @@ -//! The one `ak-sysd` call this DLL makes. Cached in HKLM so -//! `SetUsageScenario` doesn't need the daemon on every logon-screen paint. +//! The `ak-sysd` calls this DLL makes. `sys_caps` is cached in HKLM so +//! `SetUsageScenario` doesn't need the daemon on every logon-screen paint; +//! `sys_auth_start_async`/`sys_auth_validate` are the interactive sign-in +//! calls `ak_cef.exe` used to make itself, moved here because the service +//! account it now runs as has no access to `ak-sysd`'s pipe +//! (`BROWSER_PRIVILEGE.md`). use eyre::Result; use serde::{Deserialize, Serialize}; @@ -7,6 +11,9 @@ use winreg::enums::HKEY_LOCAL_MACHINE; use ak_platform::generated::ping::capabilities_response::Capability; use ak_platform::generated::ping::ping_client::PingClient; +use ak_platform::generated::sys_auth::TokenAuthRequest; +use ak_platform::generated::sys_auth::system_auth_interactive_client::SystemAuthInteractiveClient; +use ak_platform::generated::sys_auth::system_auth_token_client::SystemAuthTokenClient; use ak_platform::grpc::grpc_request; /// `ak_ee_wcp_e2e::harness` seeds this same key to turn on `debug`; keep the name and @@ -37,3 +44,53 @@ pub fn sys_caps() -> Result { key.encode(&caps)?; Ok(caps) } + +pub struct AuthStartAsync { + pub url: String, + pub header_token: String, +} + +/// Starts an interactive sign-in: `url` is what `ak_cef.exe` opens, and +/// `header_token` is what it injects on every request that page makes, so +/// the backend can tie them back to this one session. +pub fn sys_auth_start_async() -> Result { + let response = grpc_request(async |ch| { + Ok(SystemAuthInteractiveClient::new(ch) + .interactive_auth_async(()) + .await?) + })? + .into_inner(); + Ok(AuthStartAsync { + url: response.url, + header_token: response.header_token, + }) +} + +/// Validates the token embedded in the sign-in redirect's URL, returning +/// the username on success. `None` covers both an unextractable token and +/// one `ak-sysd` rejects — either way the sign-in did not complete. +pub fn sys_auth_validate(url: &str) -> Result> { + let Some(raw_token) = ak_ee_wcp_wire::extract_token(url) else { + return Ok(None); + }; + + let response = grpc_request(async |ch| { + Ok(SystemAuthTokenClient::new(ch) + .token_auth(TokenAuthRequest { + username: String::new(), + token: raw_token.clone(), + }) + .await?) + })? + .into_inner(); + + if !response.successful { + return Ok(None); + } + Ok(Some( + response + .token + .map(|t| t.preferred_username) + .unwrap_or_default(), + )) +} diff --git a/ee/wcp/e2e/README.md b/ee/wcp/e2e/README.md index b34b8ab0..8feef1c8 100644 --- a/ee/wcp/e2e/README.md +++ b/ee/wcp/e2e/README.md @@ -90,13 +90,19 @@ logon/unlock/lock-screen prompt. After the automated tests pass: production; for manual testing, add the registry entries under `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\{7BCC7941-18BA-4A8E-8E0A-1D0F8E73577A}` and - `HKCR\CLSID\{7BCC7941-18BA-4A8E-8E0A-1D0F8E73577A}\InprocServer32`). + `HKCR\CLSID\{7BCC7941-18BA-4A8E-8E0A-1D0F8E73577A}\InprocServer32`). This + skips the MSI's `util:User`, though, so `ak_cef.exe` now also needs the + dedicated service account (`BROWSER_PRIVILEGE.md`) to exist for logon and + unlock to work — either run the real MSI once first, or create it by hand + to match `credprovider::syscalls::SERVICE_ACCOUNT_NAME`. 4. Lock the machine (Win+L) and confirm the tile appears with the expected icon/text, opens the sign-in window on Submit at the expected size, and that completing/cancelling sign-in behaves as expected. -5. Confirm a **fresh logon** (not just unlock) also works — that path has no - existing user token, so it exercises the winlogon-token-duplication - fallback in `syscalls::acquire_interactive_token`. +5. Confirm a **fresh logon** (not just unlock) also works — that path used + to have no existing user token to fall back to, which is what made it + worth checking separately from unlock; now both scenarios go through the + same `syscalls::service_account_token` logon, so this step mainly + confirms the secure-desktop ACL grant actually holds on real hardware. 6. Sign in **three times in a row**, not once. The window has come to the front on the first authentication after an install and stayed behind LogonUI on every one after it, so a single sign-in passes with that bug diff --git a/ee/wcp/wire/Cargo.toml b/ee/wcp/wire/Cargo.toml index 67e09445..6436eeba 100644 --- a/ee/wcp/wire/Cargo.toml +++ b/ee/wcp/wire/Cargo.toml @@ -9,3 +9,4 @@ workspace = true [dependencies] prost = "0.14" +url = { workspace = true } diff --git a/ee/wcp/wire/src/lib.rs b/ee/wcp/wire/src/lib.rs index 5564f36a..a73bdc89 100644 --- a/ee/wcp/wire/src/lib.rs +++ b/ee/wcp/wire/src/lib.rs @@ -4,9 +4,10 @@ use std::io::{self, Read, Write}; -/// Result of the browser sign-in flow, sent from `cef-host` to `credprovider` -/// over the result pipe. The public shape stays a plain enum; wire encoding -/// goes through `AuthResultProto` below. +/// Outcome `credprovider` hands back from the sign-in flow. Built entirely +/// on the `credprovider` side of the pipe (from a [`HostReport`] plus, for +/// `Redirected`, a validation call to `ak-sysd` that only `credprovider` can +/// reach) — never sent over the wire itself. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthResult { Completed { username: String }, @@ -14,43 +15,53 @@ pub enum AuthResult { Failed { reason: String }, } +/// Sent from `cef-host` to `credprovider` over the result pipe once the +/// sign-in flow reaches an end state `cef-host` cannot itself resolve: +/// validating the redirect's token needs `ak-sysd`, which only +/// `credprovider` has access to (`BROWSER_PRIVILEGE.md`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostReport { + /// The sign-in redirect fired; here is the full callback URL to extract + /// and validate the token from. + Redirected { url: String }, + /// The window closed — or the provider asked it to — without ever + /// reaching the redirect. + Cancelled, +} + #[derive(Clone, PartialEq, prost::Message)] -struct AuthResultProto { - #[prost(oneof = "AuthOutcome", tags = "1, 2, 3")] - outcome: Option, +struct HostReportProto { + #[prost(oneof = "HostOutcome", tags = "1, 2")] + outcome: Option, } #[derive(Clone, PartialEq, prost::Oneof)] -enum AuthOutcome { +enum HostOutcome { #[prost(string, tag = "1")] - Completed(String), + Redirected(String), #[prost(bool, tag = "2")] Cancelled(bool), - #[prost(string, tag = "3")] - Failed(String), } -impl From<&AuthResult> for AuthResultProto { - fn from(r: &AuthResult) -> Self { +impl From<&HostReport> for HostReportProto { + fn from(r: &HostReport) -> Self { let outcome = match r { - AuthResult::Completed { username } => AuthOutcome::Completed(username.clone()), - AuthResult::Cancelled => AuthOutcome::Cancelled(true), - AuthResult::Failed { reason } => AuthOutcome::Failed(reason.clone()), + HostReport::Redirected { url } => HostOutcome::Redirected(url.clone()), + HostReport::Cancelled => HostOutcome::Cancelled(true), }; - AuthResultProto { + HostReportProto { outcome: Some(outcome), } } } -impl TryFrom for AuthResult { +impl TryFrom for HostReport { type Error = WireError; - fn try_from(p: AuthResultProto) -> Result { + fn try_from(p: HostReportProto) -> Result { match p.outcome { - Some(AuthOutcome::Completed(username)) => Ok(AuthResult::Completed { username }), - Some(AuthOutcome::Cancelled(_)) => Ok(AuthResult::Cancelled), - Some(AuthOutcome::Failed(reason)) => Ok(AuthResult::Failed { reason }), + Some(HostOutcome::Redirected(url)) => Ok(HostReport::Redirected { url }), + Some(HostOutcome::Cancelled(_)) => Ok(HostReport::Cancelled), None => Err(WireError::MissingOutcome), } } @@ -87,7 +98,7 @@ impl std::fmt::Display for WireError { WireError::Io(e) => write!(f, "pipe I/O error: {e}"), WireError::Decoding(e) => write!(f, "frame decoding error: {e}"), WireError::FrameTooLarge(n) => write!(f, "frame of {n} bytes exceeds limit"), - WireError::MissingOutcome => write!(f, "AuthResult frame had no outcome set"), + WireError::MissingOutcome => write!(f, "HostReport frame had no outcome set"), } } } @@ -143,20 +154,32 @@ pub fn read_frame(r: &mut R) -> Result(w: &mut W, result: &AuthResult) -> Result<(), WireError> { - write_frame(w, &AuthResultProto::from(result)) +/// Write a `HostReport` over the result pipe. +pub fn write_host_report(w: &mut W, report: &HostReport) -> Result<(), WireError> { + write_frame(w, &HostReportProto::from(report)) } -/// Read an `AuthResult` from the result pipe. See [`read_frame`] for EOF +/// Read a `HostReport` from the result pipe. See [`read_frame`] for EOF /// handling. -pub fn read_auth_result(r: &mut R) -> Result, WireError> { - match read_frame::(r)? { - Some(proto) => Ok(Some(AuthResult::try_from(proto)?)), +pub fn read_host_report(r: &mut R) -> Result, WireError> { + match read_frame::(r)? { + Some(proto) => Ok(Some(HostReport::try_from(proto)?)), None => Ok(None), } } +/// Pulls the interactive-auth token out of the sign-in redirect's query +/// string. `None` covers both an unparseable URL and a well-formed one +/// missing the parameter — `credprovider` treats either the same way, as a +/// failed validation. +pub fn extract_token(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + parsed + .query_pairs() + .find(|(k, _)| k == TOKEN_QUERY_PARAM) + .map(|(_, v)| v.into_owned()) +} + /// The four credential-provider tile fields, in display order. Field IDs are /// their index in this slice. pub const TILE_FIELDS: &[TileField] = &[ @@ -213,38 +236,31 @@ mod tests { use super::*; #[test] - fn round_trips_completed_through_a_stream() { - let msg = AuthResult::Completed { - username: "jdoe".to_string(), + fn round_trips_redirected_through_a_stream() { + let msg = HostReport::Redirected { + url: format!("{}callback?state=xyz", REDIRECT_PREFIX), }; let mut buf = Vec::new(); - write_auth_result(&mut buf, &msg).unwrap(); + write_host_report(&mut buf, &msg).unwrap(); let mut cursor = io::Cursor::new(buf); - let decoded = read_auth_result(&mut cursor).unwrap().unwrap(); + let decoded = read_host_report(&mut cursor).unwrap().unwrap(); assert_eq!(msg, decoded); } #[test] - fn round_trips_cancelled_and_failed() { - for msg in [ - AuthResult::Cancelled, - AuthResult::Failed { - reason: "token validation failed".to_string(), - }, - ] { - let mut buf = Vec::new(); - write_auth_result(&mut buf, &msg).unwrap(); - let mut cursor = io::Cursor::new(buf); - let decoded = read_auth_result(&mut cursor).unwrap().unwrap(); - assert_eq!(msg, decoded); - } + fn round_trips_cancelled() { + let mut buf = Vec::new(); + write_host_report(&mut buf, &HostReport::Cancelled).unwrap(); + let mut cursor = io::Cursor::new(buf); + let decoded = read_host_report(&mut cursor).unwrap().unwrap(); + assert_eq!(HostReport::Cancelled, decoded); } #[test] fn read_frame_reports_clean_eof_as_none() { let mut cursor = io::Cursor::new(Vec::::new()); - let decoded = read_auth_result(&mut cursor).unwrap(); + let decoded = read_host_report(&mut cursor).unwrap(); assert!(decoded.is_none()); } @@ -253,10 +269,33 @@ mod tests { let mut buf = Vec::new(); buf.extend_from_slice(&(MAX_FRAME_BYTES + 1).to_le_bytes()); let mut cursor = io::Cursor::new(buf); - let result = read_auth_result(&mut cursor); + let result = read_host_report(&mut cursor); assert!(matches!(result, Err(WireError::FrameTooLarge(_)))); } + #[test] + fn extracts_token_from_a_redirect_url() { + let url = format!("{REDIRECT_PREFIX}callback?{TOKEN_QUERY_PARAM}=abc123"); + assert_eq!(extract_token(&url), Some("abc123".to_string())); + } + + #[test] + fn extracts_token_alongside_other_query_params() { + let url = format!("{REDIRECT_PREFIX}callback?state=xyz&{TOKEN_QUERY_PARAM}=abc123&code=9"); + assert_eq!(extract_token(&url), Some("abc123".to_string())); + } + + #[test] + fn extract_token_is_none_when_the_param_is_absent() { + let url = format!("{REDIRECT_PREFIX}callback?state=xyz"); + assert_eq!(extract_token(&url), None); + } + + #[test] + fn extract_token_is_none_for_an_unparseable_url() { + assert_eq!(extract_token("not a url"), None); + } + #[test] fn cancel_signal_round_trips() { let mut buf = Vec::new(); diff --git a/vpkg/windows/Package.wxs b/vpkg/windows/Package.wxs index 4a57c95b..94879d7e 100644 --- a/vpkg/windows/Package.wxs +++ b/vpkg/windows/Package.wxs @@ -126,8 +126,49 @@ Name="authentik Credential Provider (CEF)" EventMessageFile="[#wcp_ak_cef_exe]"/> - - + + + + + + + + + +