diff --git a/.github/workflows/os-approval.yml b/.github/workflows/os-approval.yml new file mode 100644 index 00000000..a3aef677 --- /dev/null +++ b/.github/workflows/os-approval.yml @@ -0,0 +1,37 @@ +name: OS approval + +on: + pull_request: + paths: ['crates/oauth-service/**', 'crates/shell/src/connected_review.rs', 'Cargo.toml', 'Cargo.lock', '.github/workflows/os-approval.yml'] + push: + branches: [main] + paths: ['crates/oauth-service/**', 'crates/shell/src/connected_review.rs', 'Cargo.toml', 'Cargo.lock', '.github/workflows/os-approval.yml'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: os-approval-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + runs-on: windows-2022 + timeout-minutes: 45 + steps: + - name: Preserve runtime patch bytes + run: git config --global core.autocrlf false + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: python tools/setup.py + - uses: Swatinem/rust-cache@v2 + with: + shared-key: platform-accounts-windows + - name: Approval bindings and native adapter refusal tests + run: cargo test --locked -p octosense-oauth-service --features host --lib + - name: Actual Windows Hello availability without authentication + run: cargo test --locked -p octosense-oauth-service --features host native_windows_hello_availability_without_prompt -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index 731da9d2..621f49aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8142,6 +8142,7 @@ dependencies = [ "base64 0.22.1", "chrono", "chrono-tz", + "dbus", "keyring", "makepad-widgets", "oauth2", @@ -8153,6 +8154,8 @@ dependencies = [ "sha2 0.10.9", "url", "uuid", + "windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)", + "windows-future 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] diff --git a/crates/oauth-service/Cargo.toml b/crates/oauth-service/Cargo.toml index 03c825cd..d2e0c7b0 100644 --- a/crates/oauth-service/Cargo.toml +++ b/crates/oauth-service/Cargo.toml @@ -7,7 +7,7 @@ publish = false [features] default = [] -host = ["dep:octosense-appstore", "dep:octosense-mail-service", "dep:keyring", "dep:makepad-widgets"] +host = ["dep:octosense-appstore", "dep:octosense-mail-service", "dep:keyring", "dep:makepad-widgets", "dep:dbus", "dep:webview2-windows", "dep:windows-future"] # Native acceptance executables only; no environment/config switch in releases. acceptance-fixtures = ["host"] @@ -28,6 +28,9 @@ octosense-mail-service = { workspace = true, optional = true } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"], optional = true } +webview2-windows = { workspace = true, optional = true, features = ["Security_Credentials_UI", "Win32_System_WinRT", "Win32_System_Threading"] } +windows-future = { version = "=0.3.2", optional = true } [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3", features = ["sync-secret-service", "crypto-rust", "vendored"], optional = true } +dbus = { version = "=0.9.12", features = ["vendored"], optional = true } diff --git a/crates/oauth-service/README.md b/crates/oauth-service/README.md index 9b1a8ed0..d4f24b08 100644 --- a/crates/oauth-service/README.md +++ b/crates/oauth-service/README.md @@ -7,6 +7,11 @@ to GitHub or Google, approves that app's scopes, and gets an app-bound connectio There is no OctoSense account or central login backend. This implements the shared-service part of [ADR 0010](../../docs/adr/0010-shared-oauth-and-connected-apps.md). +The separate Linux/Windows OS-authenticated write approval implementation and +its remaining native acceptance requirements are described in +[desktop write approval](../../docs/os-authenticated-approval.md). It does not +upgrade synthetic input to trusted physical input. + ## Current delivery boundary The Rust protocol, connectors, native review, account lifecycle and sample UI @@ -39,11 +44,11 @@ editing, in a standalone test APK that left the regular Home in place ([OnePlus Notes check](../../tools/connected-e2e/evidence/notes-oneplus-20261006/README.md)); Inbox Assistant and Google Calendar have not run there. -| Platform | Provider authorization | Credential storage | Gmail send approval | +| Platform | Provider authorization | Credential storage | Native write approval | | --- | --- | --- | --- | | macOS | GitHub device flow; Google browser/PKCE loopback | Mail's platform Keychain adapter, separate OAuth namespace | Native pointer provenance; remote clicks refused; physical acceptance unverified | -| Windows | Same desktop flows; platform execution unverified | Windows Credential Manager; unverified on Windows | Unsupported: fails closed | -| Linux | Protocol tests and host compilation passed on Linux; browser login and GUI unverified | Secret Service; unlocked service required, no plaintext fallback; native vault test refused the locked/unavailable build-host store | Unsupported: fails closed | +| Windows | Desktop flows implemented; actual synthetic backend browser acceptance is pending | Native Windows Credential Manager persistence/reopen/logout tested | OS authentication adapter implemented; needs Windows Hello and build 22000+; user-positive write unverified | +| Linux | Synthetic backend registration/login, callback and two-app isolation passed with native host + real browser; see the exact source/lock limitation in the [receipt](../../tools/connected-e2e/evidence/linux-backend-native.json) | Real isolated Secret Service persistence/reopen/logout tested; no plaintext fallback | OS authentication adapter implemented; requires installed polkit policy and session agent; user-positive write unverified | | Android | GitHub flow present but unverified; **Google connection refused until its native adapter is implemented** | Mail's Android platform vault, separate namespace | Existing physical-touch provenance; this sample unverified | `desktop-v0.1.0-beta.2` is the first release with the connected-account @@ -195,7 +200,8 @@ Call the declared operations with an active connection: Both objects are arguments to `auth.backend.request`. GET operations execute on a worker. POST/PUT/PATCH/DELETE open the same native immutable review used for -GitHub/Calendar; a physical activation is required, and scripts/agents cannot +GitHub/Calendar; physical approval on macOS/Android or fresh OS-authenticated approval +on Linux/Windows is required. Scripts/agents cannot approve through `auth.backend.sheet.save`. Background mutations return a request to open the app. Cancellation or expiry before approval performs no write. Once an approved HTTP request begins, cancellation cannot promise to undo the server's @@ -227,7 +233,7 @@ registration requires reconnecting; it cannot redirect an existing token. Logout revokes the local handle before attempting remote logout and reports the remote result separately. Embedded callbacks retain caller, state, expiry and single-use checks. Android versions below 9 and iOS refuse embedded backend -login. Windows/Linux execution remains unverified. +login. Linux external-browser backend execution has a [native receipt](../../tools/connected-e2e/evidence/linux-backend-native.json); Windows browser/callback acceptance remains pending. The synthetic backend uses real browser forms, HTTP code exchange and protected requests. HTTP loopback is available only in the non-default acceptance build, @@ -332,10 +338,11 @@ host.request("auth.connect", { `github.review_save` freezes repository/branch/path/content/base SHA. `gcalendar.review_save` freezes calendar/event/ETag; a stale ETag is a conflict, not permission to overwrite. `gmail.draft.review` freezes the durable draft -revision, recipient and body. All three writes require a physical activation -of the native host review control, with native provenance checked on press and -release before the one-use capability crosses into the worker. Scripts, -agents, remote instrumentation and JSON flags cannot approve a save or send. +revision, recipient and body. macOS/Android require physical activation of the +native review control, checking provenance on press and release. Linux/Windows +require the distinct [OS-authenticated proof](../../docs/os-authenticated-approval.md). +Both routes consume a one-use capability; scripts, agents, remote clicks and JSON +flags cannot approve a save or send by themselves. Dismissal cancels an unsubmitted review; a selected-account change is checked again before writing. Unknown Gmail submission outcomes stay unknown and are not retried blindly. diff --git a/crates/oauth-service/README.zh-CN.md b/crates/oauth-service/README.zh-CN.md index 18ff515b..dfe90978 100644 --- a/crates/oauth-service/README.zh-CN.md +++ b/crates/oauth-service/README.zh-CN.md @@ -6,6 +6,10 @@ OctoSense 为已安装应用保存服务商凭据。用户登录 GitHub 或 Goog 应用得到绑定自身身份的连接句柄。无需 OctoSense 账户或中心登录后台。 本服务实现 [ADR 0010](../../docs/adr/0010-shared-oauth-and-connected-apps.zh-CN.md) 的共享服务部分。 +Linux/Windows 独立的 OS 认证写入审批实现及剩余原生验收要求,见 +[桌面写入审批](../../docs/os-authenticated-approval.zh-CN.md)。它不会将合成输入 +升级为可信的物理输入。 + ## 当前交付边界 Rust 授权协议、连接器、原生审批、账户生命周期和示例界面已实现。macOS 原生宿主与 @@ -32,8 +36,8 @@ Inbox Assistant 和 Google Calendar 尚未在该设备上运行。 | 平台 | 服务商授权 | 凭据保存 | Gmail 发信审批 | | --- | --- | --- | --- | | macOS | GitHub 设备授权;Google 浏览器/PKCE/回环回调 | 复用 Mail 的 Keychain 适配器,独立 OAuth 命名空间 | 原生鼠标来源校验;远程点击被拒绝,亲手点按未验证 | -| Windows | 同样的桌面流程,平台运行未验证 | Windows Credential Manager;未在 Windows 验证 | 不支持,明确拒绝 | -| Linux | 已在 Linux 通过协议测试和主机编译;浏览器登录和 GUI 未验证 | 需要解锁 Secret Service,不回退到明文;原生测试被构建主机未解锁/不可用的凭据库拒绝 | 不支持,明确拒绝 | +| Windows | 已实现桌面流程;真实合成后端浏览器验收待完成 | 原生 Windows Credential Manager 持久化、重开、退出已测试 | OS 认证适配器已实现;需要 Windows Hello 和 build 22000+;用户认证写入正向验收未验证 | +| Linux | 原生宿主与真实浏览器的合成后端注册/登录、回调、双应用隔离已通过;准确源码/锁文件限制见[记录](../../tools/connected-e2e/evidence/linux-backend-native.json) | 独立真实 Secret Service 的持久化、重开、退出已测试,不回退到明文 | OS 认证适配器已实现;需要已安装的 polkit 策略和会话认证 agent;用户认证写入正向验收未验证 | | Android | GitHub 流程存在但未验证;**Google 原生适配器完成前拒绝连接** | Mail 的 Android 凭据库,独立命名空间 | 现有的亲手点按来源校验;本示例未验证 | `desktop-v0.1.0-beta.2` 是第一个包含已连接账户服务(`auth`、`github`、`gmail`、`gcalendar`)的发布版本; @@ -152,7 +156,7 @@ shell 通过 `host::set_backend_resolver` 提供经过摘要验证的 manifest ` ``` GET 在工作线程执行;POST/PUT/PATCH/DELETE 复用 GitHub/Calendar 的原生不可变 -审核界面,须通过真实输入确认。脚本和 agent 不能通过 `auth.backend.sheet.save` +审核界面;macOS/Android 要求物理输入确认,Linux/Windows 要求新的 OS 认证证据。脚本和 agent 不能通过 `auth.backend.sheet.save` 批准发送。后台写操作要求先打开应用。批准前取消或超时不会发送;已批准的 HTTP 请求一旦开始,取消不能承诺撤销服务端结果,错误也不会自动重试。 @@ -172,7 +176,7 @@ GET 在工作线程执行;POST/PUT/PATCH/DELETE 复用 GitHub/Calendar 的原 URL 凭据及 HTTP 重定向。宿主把保存的连接绑定到规范化注册,修改注册后必须 重新连接,不能把旧令牌发送到新端点。退出先撤销本地句柄,再尝试远程退出, 并单独报告远程结果。嵌入回调仍检查调用者、state、有效期及单次使用。 -Android 9 以下与 iOS 拒绝嵌入后端登录;Windows/Linux 的运行尚未验证。 +Android 9 以下与 iOS 拒绝嵌入后端登录。Linux 外部浏览器后端流程已有[原生记录](../../tools/connected-e2e/evidence/linux-backend-native.json),Windows 浏览器/回调验收仍待完成。 合成后端使用真实浏览器表单、HTTP 代码交换及受保护请求。HTTP 回环仅在非默认 验收构建中通过显式隔离注册开放,不是发行版本的配置开关。参阅 @@ -251,8 +255,9 @@ Google 为 `openid`、`email`、`profile`、`calendar.list`、`calendar.events` GitHub 保存冻结仓库、分支、路径、内容及原 blob SHA。Calendar 保存冻结日历、事件和 ETag; 过期 ETag 会报冲突,不会静默覆盖。Gmail 原生审阅界面冻结持久化草稿版本、收件人及正文。 -这三种写入都必须由用户亲手点按宿主原生审阅界面上的控件;按下与释放时分别检查原生输入来源, -然后才把一次性能力交给工作线程。脚本、Agent、远程测试及 JSON 标记不能批准保存或发送。 +macOS/Android 要求用户亲手点按宿主原生审阅控件,按下与释放时分别检查输入来源。 +Linux/Windows 使用独立的 [OS 认证证据](../../docs/os-authenticated-approval.zh-CN.md)。 +两条路径都消费一次性能力;脚本、Agent、远程点击或 JSON 标记本身不能批准保存或发送。 关闭审阅界面会取消尚未提交的请求;写入前再次检查当前账户。结果不明的 Gmail 提交保持 不明状态,不会盲目重试。 diff --git a/crates/oauth-service/src/approval/linux.rs b/crates/oauth-service/src/approval/linux.rs new file mode 100644 index 00000000..333230f1 --- /dev/null +++ b/crates/oauth-service/src/approval/linux.rs @@ -0,0 +1,253 @@ +//! Same-user polkit authentication; never launches a privileged command. +use super::{Cancel, PlatformCompletion, WindowTarget}; +use dbus::{ + arg::{PropMap, Variant}, + blocking::SyncConnection, +}; +use std::{ + collections::HashMap, + os::unix::fs::MetadataExt, + path::Path, + sync::{Arc, Mutex}, + time::Duration, +}; + +const ACTION: &str = "org.octosense.approve-business-action"; +const POLICY: &str = "/usr/share/polkit-1/actions/org.octosense.policy"; +const POLICY_BYTES: &[u8] = include_bytes!("../../../../desktop/resources/org.octosense.policy"); + +fn check_policy(path: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(path).map_err(|_| { + "OS approval is unavailable: install OctoSense's administrator-owned polkit policy" + })?; + if !metadata.is_file() || metadata.uid() != 0 || metadata.mode() & 0o022 != 0 { + return Err( + "OS approval policy must be a root-owned regular file without group/other write access" + .into(), + ); + } + if std::fs::read(path).map_err(|_| "Cannot read OS approval policy")? != POLICY_BYTES { + return Err("OS approval policy differs from this build's reviewed policy".into()); + } + Ok(()) +} + +#[derive(Clone, Copy)] +struct Process { + pid: u32, + start: u64, + uid: u32, +} +impl Process { + fn current() -> Result { + let stat = std::fs::read_to_string("/proc/self/stat") + .map_err(|_| "Cannot identify this native process")?; + let start = stat + .rsplit_once(')') + .ok_or("Invalid native process identity")? + .1 + .split_whitespace() + .nth(19) + .ok_or("Missing process start time")? + .parse() + .map_err(|_| "Invalid process start time")?; + let status = std::fs::read_to_string("/proc/self/status") + .map_err(|_| "Cannot identify this native user")?; + let uid = status + .lines() + .find_map(|line| line.strip_prefix("Uid:")) + .ok_or("Missing native user identity")? + .split_whitespace() + .map(str::parse::) + .collect::, _>>() + .map_err(|_| "Invalid native user identity")?; + if uid.len() != 4 || uid.iter().any(|value| *value != uid[0]) { + return Err("OS approval is unavailable for a changed-identity process".into()); + } + Ok(Self { + pid: std::process::id(), + start, + uid: uid[0], + }) + } + fn subject(self) -> (&'static str, PropMap) { + let mut values = PropMap::new(); + values.insert("pid".into(), Variant(Box::new(self.pid))); + values.insert("start-time".into(), Variant(Box::new(self.start))); + values.insert("uid".into(), Variant(Box::new(self.uid))); + ("unix-process", values) + } +} + +type Check = (bool, bool, HashMap); +fn temporary(details: &HashMap) -> bool { + details.keys().any(|key| { + matches!( + key.as_str(), + "polkit.temporary_authorization_id" | "polkit.retains_authorization_after_challenge" + ) + }) +} +fn preflight(check: &Check) -> Result<(), String> { + if check.0 || !check.1 || temporary(&check.2) { + return Err("OS approval requires fresh authentication; cached or unavailable authorization is refused".into()); + } + Ok(()) +} +fn verified(check: &Check) -> Result<(), String> { + if !check.0 || check.1 || temporary(&check.2) { + return Err( + "OS authentication was denied, cancelled, or retained authorization unexpectedly" + .into(), + ); + } + Ok(()) +} + +pub(super) fn begin( + _: WindowTarget, + message: &str, + completion: PlatformCompletion, +) -> Result { + // File ownership/content checks are bounded and require no system mutation. + check_policy(Path::new(POLICY))?; + let process = Process::current()?; + let connection: Arc>>> = Arc::new(Mutex::new(None)); + let worker_connection = connection.clone(); + let cancellation = completion.challenge_id(); + let worker_id = cancellation.clone(); + let worker = completion.clone(); + let mut details = HashMap::new(); + details.insert("polkit.message".to_owned(), message.to_owned()); + details.insert( + "octosense.operation-sha256".to_owned(), + completion.operation_digest(), + ); + details.insert("octosense.challenge".to_owned(), cancellation.clone()); + std::thread::Builder::new() + .name("native-os-authentication".into()) + .spawn(move || { + let result = (|| { + if worker.is_cancelled() { + return Err("Approval cancelled".into()); + } + let connection = Arc::new( + SyncConnection::new_system() + .map_err(|_| "The OS authentication authority is unavailable")?, + ); + *worker_connection.lock().unwrap_or_else(|e| e.into_inner()) = + Some(connection.clone()); + if worker.is_cancelled() { + return Err("Approval cancelled".into()); + } + let proxy = connection.with_proxy( + "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", + Duration::from_secs(120), + ); + let (check,): (Check,) = proxy + .method_call( + "org.freedesktop.PolicyKit1.Authority", + "CheckAuthorization", + ( + process.subject(), + ACTION, + details.clone(), + 0u32, + format!("{worker_id}-preflight"), + ), + ) + .map_err(|_| "The OS authentication policy or agent is unavailable")?; + preflight(&check)?; + if worker.is_cancelled() { + return Err("Approval cancelled".into()); + } + let (check,): (Check,) = proxy + .method_call( + "org.freedesktop.PolicyKit1.Authority", + "CheckAuthorization", + (process.subject(), ACTION, details, 1u32, worker_id), + ) + .map_err(|_| "OS authentication was cancelled or unavailable")?; + verified(&check) + })(); + worker.finish(result); + }) + .map_err(|_| "Cannot start OS authentication")?; + Ok(Box::new(move || { + let connection = connection.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(connection) = connection { + let _ = std::thread::Builder::new() + .name("native-os-auth-cancel".into()) + .spawn(move || { + let proxy = connection.with_proxy( + "org.freedesktop.PolicyKit1", + "/org/freedesktop/PolicyKit1/Authority", + Duration::from_secs(5), + ); + for id in [format!("{cancellation}-preflight"), cancellation] { + let _: Result<(), dbus::Error> = proxy.method_call( + "org.freedesktop.PolicyKit1.Authority", + "CancelCheckAuthorization", + (id,), + ); + } + }); + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn cached_denied_and_retained_authorization_are_refused() { + assert!(preflight(&(false, true, HashMap::new())).is_ok()); + for pair in [(true, false), (true, true), (false, false)] { + assert!(preflight(&(pair.0, pair.1, HashMap::new())).is_err()); + } + assert!(verified(&(true, false, HashMap::new())).is_ok()); + for pair in [(false, false), (false, true), (true, true)] { + assert!(verified(&(pair.0, pair.1, HashMap::new())).is_err()); + } + let retained = + HashMap::from([("polkit.temporary_authorization_id".into(), "token".into())]); + assert!(preflight(&(false, true, retained.clone())).is_err()); + assert!(verified(&(true, false, retained)).is_err()); + } + #[test] + fn missing_policy_does_not_offer_an_authentication_bypass() { + assert!(check_policy(Path::new("/nonexistent/octosense-approval-policy")).is_err()); + let process = Process::current().unwrap(); + assert_eq!(process.pid, std::process::id()); + assert!(process.start > 0); + } + + /// Opt-in native negative acceptance. It must never run on an installed + /// system with a real policy, and it never opens an authentication prompt. + #[test] + #[ignore = "requires a test host without the installed OctoSense polkit policy"] + fn native_missing_policy_cannot_start_approval() { + assert!(!Path::new(POLICY).exists(), "use an isolated test host"); + let state = Arc::new(Mutex::new(super::super::State::Waiting)); + let completion = PlatformCompletion { + state: state.clone(), + nonce: uuid::Uuid::new_v4(), + digest: [0; 32], + }; + let outcome = begin( + WindowTarget { + slot: 1, + generation: 1, + native_handle: 0, + }, + "Synthetic acceptance: do not authorize a business operation", + completion, + ); + assert!(outcome.is_err()); + assert!(matches!( + *state.lock().unwrap(), + super::super::State::Waiting + )); + } +} diff --git a/crates/oauth-service/src/approval/mod.rs b/crates/oauth-service/src/approval/mod.rs new file mode 100644 index 00000000..770a23d0 --- /dev/null +++ b/crates/oauth-service/src/approval/mod.rs @@ -0,0 +1,557 @@ +//! Native OS-authenticated approval is distinct from physical pointer input. +//! Nothing in this module is registered as a script or agent host method. +use makepad_widgets::{Cx, SignalToUI, WindowId}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "windows")] +mod windows; + +type Admission = Arc Result + Send + Sync>; +fn admission() -> &'static Mutex> { + static CHECK: OnceLock>> = OnceLock::new(); + CHECK.get_or_init(|| Mutex::new(None)) +} + +/// Native shell callback must re-verify the admitted bundle and grant on each +/// call, returning its exact digest. Missing registration fails closed. +pub fn register_admission( + check: impl Fn(&Path, &str, &str) -> Result + Send + Sync + 'static, +) { + *admission().lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(check)); +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Binding { + root: PathBuf, + app: String, + family: String, + bundle: String, + connection: String, + account_epoch: u64, + review: String, + digest: [u8; 32], +} +impl Binding { + pub(crate) fn capture( + root: &Path, + app: &str, + family: &str, + connection: &str, + review: &str, + snapshot: &Value, + ) -> Result { + let check = admission() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .ok_or("OS approval is unavailable: this host has no admission verifier")?; + let bundle = check(root, app, family)?; + if bundle.is_empty() { + return Err("The app's current admitted identity is unavailable".into()); + } + let current = crate::host::active_connection(root, app) + .ok_or("The reviewed account is no longer connected")?; + if current.handle != connection { + return Err("The active account changed; review again".into()); + } + Ok(Self { + root: root.to_owned(), + app: app.to_owned(), + family: family.to_owned(), + bundle, + connection: connection.to_owned(), + account_epoch: crate::host::authorization_epoch(root, app), + review: review.to_owned(), + digest: canonical_digest(snapshot)?, + }) + } + pub(crate) fn revalidate(&self) -> Result<(), String> { + let check = admission() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .ok_or("Native admission verifier is unavailable")?; + if check(&self.root, &self.app, &self.family)? != self.bundle + || crate::host::authorization_epoch(&self.root, &self.app) != self.account_epoch + || !crate::host::active_connection(&self.root, &self.app) + .is_some_and(|account| account.handle == self.connection) + { + return Err("The reviewed app or account changed; review again".into()); + } + Ok(()) + } +} + +pub(crate) fn canonical_digest(value: &Value) -> Result<[u8; 32], String> { + fn ordered(value: &Value, depth: usize) -> Result { + if depth > 32 { + return Err("Review data is too deeply nested".into()); + } + Ok(match value { + Value::Object(map) => { + let sorted: std::collections::BTreeMap<_, _> = map.iter().collect(); + Value::Object( + sorted + .into_iter() + .map(|(k, v)| ordered(v, depth + 1).map(|v| (k.clone(), v))) + .collect::>()?, + ) + } + Value::Array(values) => Value::Array( + values + .iter() + .map(|v| ordered(v, depth + 1)) + .collect::>()?, + ), + other => other.clone(), + }) + } + let bytes = serde_json::to_vec(&ordered(value, 0)?) + .map_err(|_| "Cannot fingerprint the reviewed operation")?; + if bytes.len() > 2 * 1024 * 1024 { + return Err("Reviewed operation is too large".into()); + } + Ok(Sha256::digest(bytes).into()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct WindowTarget { + pub slot: usize, + pub generation: u64, + pub native_handle: usize, +} +fn target(cx: &Cx, id: WindowId) -> Result { + if !cx.windows.is_valid(id) || !cx.windows[id].is_created { + return Err("The review window is no longer available".into()); + } + #[cfg(target_os = "windows")] + let native_handle = windows::native_handle(cx, id)?; + #[cfg(not(target_os = "windows"))] + let native_handle = 0; + Ok(WindowTarget { + slot: id.0, + generation: id.1, + native_handle, + }) +} + +enum State { + Waiting, + Finished(Result<(), String>), + Claimed, + Cancelled, +} +#[derive(Clone)] +pub(super) struct PlatformCompletion { + state: Arc>, + nonce: uuid::Uuid, + digest: [u8; 32], +} +impl PlatformCompletion { + pub fn challenge_id(&self) -> String { + self.nonce.to_string() + } + pub fn operation_digest(&self) -> String { + self.digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + pub fn is_cancelled(&self) -> bool { + !matches!( + *self.state.lock().unwrap_or_else(|e| e.into_inner()), + State::Waiting + ) + } + /// Platform adapters call this only after an actual OS result. Neither the + /// message text nor a script/tool response is evidence of authentication. + pub fn finish(&self, outcome: Result<(), String>) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if matches!(*state, State::Waiting) { + *state = State::Finished(outcome); + SignalToUI::set_ui_signal(); + } + } +} +pub(super) type Cancel = Box; + +fn platform_begin( + window: WindowTarget, + message: &str, + completion: PlatformCompletion, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::begin(window, message, completion) + } + #[cfg(target_os = "windows")] + { + windows::begin(window, message, completion) + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + let _ = (window, message, completion); + Err("Use physical approval on this platform".into()) + } +} + +/// Held only by the owning native review. Dropping it cancels the OS request; +/// a late callback cannot authorize another review or a reused native window. +pub struct PendingApproval { + nonce: uuid::Uuid, + binding: Binding, + owner: String, + window: WindowTarget, + deadline: Instant, + state: Arc>, + cancel: Option, +} +impl PendingApproval { + pub(crate) fn begin( + cx: &Cx, + id: WindowId, + owner: &str, + binding: Binding, + message: &str, + ) -> Result { + if owner.is_empty() || message.is_empty() || message.len() > 160 { + return Err("Invalid native approval owner or message".into()); + } + let window = target(cx, id)?; + let nonce = uuid::Uuid::new_v4(); + let state = Arc::new(Mutex::new(State::Waiting)); + let completion = PlatformCompletion { + state: state.clone(), + nonce, + digest: binding.digest, + }; + let cancel = platform_begin(window, message, completion)?; + Ok(Self { + nonce, + binding, + owner: owner.to_owned(), + window, + deadline: Instant::now() + Duration::from_secs(120), + state, + cancel: Some(cancel), + }) + } + + pub(crate) fn poll( + &mut self, + cx: &Cx, + id: WindowId, + owner: &str, + binding: &Binding, + ) -> Result, String> { + let window = match target(cx, id) { + Ok(window) => window, + Err(error) => { + self.cancel(); + return Err(error); + } + }; + self.validate_context(window, owner, binding, Instant::now())?; + #[cfg(target_os = "windows")] + windows::pump(&self.nonce.to_string()); + self.claim(window, owner, binding, Instant::now()) + } + + fn validate_context( + &mut self, + window: WindowTarget, + owner: &str, + binding: &Binding, + now: Instant, + ) -> Result<(), String> { + if now >= self.deadline + || self.window != window + || self.owner != owner + || &self.binding != binding + { + self.cancel(); + return Err("The reviewed operation, account or window changed; review again".into()); + } + Ok(()) + } + + fn claim( + &mut self, + window: WindowTarget, + owner: &str, + binding: &Binding, + now: Instant, + ) -> Result, String> { + self.validate_context(window, owner, binding, now)?; + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + match &*state { + State::Waiting => Ok(None), + State::Finished(Ok(())) => { + *state = State::Claimed; + Ok(Some(AuthenticatedReview { + nonce: self.nonce, + binding: self.binding.clone(), + deadline: self.deadline, + })) + } + State::Finished(Err(error)) => { + let error = error.clone(); + *state = State::Cancelled; + Err(error) + } + State::Claimed | State::Cancelled => { + Err("This authentication request was already consumed or cancelled".into()) + } + } + } + + pub fn cancel(&mut self) { + { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + if !matches!(*state, State::Claimed) { + *state = State::Cancelled; + } + } + if let Some(cancel) = self.cancel.take() { + cancel(); + } + } +} +impl Drop for PendingApproval { + fn drop(&mut self) { + self.cancel(); + } +} + +/// Private, non-cloneable and non-serializable evidence. Consuming it checks +/// the immutable operation again immediately before its existing one-shot save. +pub(crate) struct AuthenticatedReview { + nonce: uuid::Uuid, + binding: Binding, + deadline: Instant, +} +impl AuthenticatedReview { + pub(crate) fn consume(self, binding: &Binding) -> Result { + if self.nonce.is_nil() || &self.binding != binding { + return Err("Authentication belongs to a different reviewed operation".into()); + } + Ok(ApprovedOperation { + binding: self.binding, + deadline: self.deadline, + }) + } +} +pub(crate) struct ApprovedOperation { + binding: Binding, + deadline: Instant, +} +impl ApprovedOperation { + /// Preserve the earlier of the authentication and original review limits. + pub(crate) fn limit_to(mut self, deadline: Instant) -> Self { + self.deadline = self.deadline.min(deadline); + self + } + pub(crate) fn revalidate(self) -> Result<(), String> { + self.revalidate_at(Instant::now()) + } + fn revalidate_at(&self, now: Instant) -> Result<(), String> { + if now >= self.deadline { + return Err("OS approval expired before execution; review again".into()); + } + self.binding.revalidate() + } + pub(crate) fn validate( + self, + app: &str, + connection: &str, + snapshot: &Value, + ) -> Result<(), String> { + self.revalidate_at(Instant::now())?; + if self.binding.app != app + || self.binding.connection != connection + || self.binding.digest != canonical_digest(snapshot)? + { + return Err("OS approval belongs to a different reply snapshot".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn pending() -> (PendingApproval, PlatformCompletion) { + let state = Arc::new(Mutex::new(State::Waiting)); + let binding = Binding { + root: PathBuf::from("fixture"), + app: "example.app".into(), + family: "github".into(), + bundle: "digest".into(), + connection: "synthetic".into(), + account_epoch: 1, + review: "review".into(), + digest: canonical_digest(&serde_json::json!({"body":"reviewed"})).unwrap(), + }; + let window = WindowTarget { + slot: 1, + generation: 2, + native_handle: 3, + }; + ( + PendingApproval { + nonce: uuid::Uuid::new_v4(), + binding, + owner: "host-sheet".into(), + window, + deadline: Instant::now() + Duration::from_secs(60), + state: state.clone(), + cancel: None, + }, + PlatformCompletion { + state, + nonce: uuid::Uuid::new_v4(), + digest: [0; 32], + }, + ) + } + #[test] + fn os_result_is_required_and_one_shot() { + let (mut p, done) = pending(); + let binding = p.binding.clone(); + assert!(p + .claim(p.window, "host-sheet", &binding, Instant::now()) + .unwrap() + .is_none()); + done.finish(Ok(())); + let proof = p + .claim(p.window, "host-sheet", &binding, Instant::now()) + .unwrap() + .unwrap(); + proof.consume(&binding).unwrap(); + assert!(p + .claim(p.window, "host-sheet", &binding, Instant::now()) + .is_err()); + } + #[test] + fn queued_approval_rechecks_auth_and_review_expiry_after_execution_lock() { + for expire_review_first in [false, true] { + let (mut p, done) = pending(); + let binding = p.binding.clone(); + let now = Instant::now(); + done.finish(Ok(())); + let review_deadline = if expire_review_first { + now + Duration::from_secs(1) + } else { + p.deadline + Duration::from_secs(1) + }; + let expected_deadline = p.deadline.min(review_deadline); + let approved = p + .claim(p.window, "host-sheet", &binding, now) + .unwrap() + .unwrap() + .consume(&binding) + .unwrap() + .limit_to(review_deadline); + assert_eq!(approved.deadline, expected_deadline); + let clock = Arc::new(Mutex::new(now)); + let worker_clock = clock.clone(); + let root = + std::env::temp_dir().join(format!("approval-expiry-{}", uuid::Uuid::new_v4())); + let app = "org.example.expiry"; + let lock = crate::host::operation_lock(&root, app); + let held = lock.lock().unwrap(); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + crate::host::with_provider_api_checked( + &root, + app, + || { + checked_tx.send(()).unwrap(); + approved.revalidate_at(*worker_clock.lock().unwrap()) + }, + |_| -> Result<(), String> { + panic!("Expired OS approval must not reach a provider") + }, + ) + }); + let join_deadline = Instant::now() + Duration::from_secs(5); + while Arc::strong_count(&lock) < 2 { + assert!(Instant::now() < join_deadline); + std::thread::yield_now(); + } + assert!(matches!( + checked_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + *clock.lock().unwrap() = expected_deadline; + drop(held); + assert_eq!( + worker.join().unwrap().unwrap_err(), + "OS approval expired before execution; review again" + ); + checked_rx.recv().unwrap(); + } + } + #[test] + fn changed_operation_account_bundle_and_owner_deny() { + for field in 0..6 { + let (mut p, done) = pending(); + let mut binding = p.binding.clone(); + let mut window = p.window; + let mut owner = "host-sheet"; + match field { + 0 => binding.digest[0] ^= 1, + 1 => binding.account_epoch += 1, + 2 => binding.connection.push('x'), + 3 => binding.bundle.push('x'), + 4 => window.generation += 1, + _ => owner = "another-sheet", + } + done.finish(Ok(())); + assert!(p.claim(window, owner, &binding, Instant::now()).is_err()); + } + } + #[test] + fn cancelled_expired_and_failed_callbacks_never_authorize() { + let (mut p, done) = pending(); + let binding = p.binding.clone(); + p.cancel(); + done.finish(Ok(())); + assert!(p + .claim(p.window, "host-sheet", &binding, Instant::now()) + .is_err()); + let (mut p, done) = pending(); + let binding = p.binding.clone(); + done.finish(Ok(())); + assert!(p + .claim(p.window, "host-sheet", &binding, p.deadline) + .is_err()); + let (mut p, done) = pending(); + let binding = p.binding.clone(); + done.finish(Err("Denied".into())); + assert!(p + .claim(p.window, "host-sheet", &binding, Instant::now()) + .is_err()); + } + #[test] + fn digest_covers_destinations_content_and_resource_version() { + let baseline = + canonical_digest(&serde_json::json!({"to":"first","body":"one","version":"a"})) + .unwrap(); + for value in [ + serde_json::json!({"to":"other","body":"one","version":"a"}), + serde_json::json!({"to":"first","body":"two","version":"a"}), + serde_json::json!({"to":"first","body":"one","version":"b"}), + ] { + assert_ne!(baseline, canonical_digest(&value).unwrap()); + } + } +} diff --git a/crates/oauth-service/src/approval/windows.rs b/crates/oauth-service/src/approval/windows.rs new file mode 100644 index 00000000..0c4e3720 --- /dev/null +++ b/crates/oauth-service/src/approval/windows.rs @@ -0,0 +1,499 @@ +//! Desktop Windows Hello consent is parented to the exact native review window. +//! The desktop interop API requires Windows build 22000 or later. Availability, +//! policy and cancellation failures never fall back to a script confirmation. +use super::{Cancel, PlatformCompletion, WindowTarget}; +use makepad_widgets::{ + makepad_platform::os::windows::{win32_app::WIN32_APP, win32_window::Win32Window}, + Cx, SignalToUI, WindowId, +}; +use std::{ + cell::OnceCell, + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, OnceLock, + }, +}; +use webview2_windows::{ + core::{factory, HSTRING}, + Security::Credentials::UI::{ + UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability, + }, + Win32::{ + Foundation::HWND, + System::{ + Threading::GetCurrentThreadId, + WinRT::{ + IUserConsentVerifierInterop, RoInitialize, RoUninitialize, RO_INIT_SINGLETHREADED, + }, + }, + UI::WindowsAndMessaging::{ + GetWindowLongPtrW, GetWindowThreadProcessId, IsWindow, GWLP_USERDATA, + }, + }, +}; +use windows_future::{AsyncOperationCompletedHandler, AsyncStatus, IAsyncOperation}; + +struct Apartment; +impl Drop for Apartment { + fn drop(&mut self) { + // This thread-local guard is dropped on the thread that initialized it. + unsafe { RoUninitialize() }; + } +} +thread_local! { + static APARTMENT: OnceCell = const { OnceCell::new() }; +} + +fn ensure_winrt() -> Result<(), String> { + APARTMENT.with(|apartment| { + if apartment.get().is_none() { + unsafe { RoInitialize(RO_INIT_SINGLETHREADED) } + .map_err(|_| "Windows verification cannot initialize on this thread")?; + let _ = apartment.set(Apartment); + } + Ok(()) + }) +} + +/// Inspect only Makepad's own windows on its UI thread. Never use the active +/// desktop window, an app-supplied integer, or an HWND from another thread. +fn owned_handle(id: WindowId) -> Result { + let handles = WIN32_APP + .try_with(|app| { + app.try_borrow() + .ok() + .and_then(|app| app.as_ref().map(|app| app.all_windows.clone())) + }) + .ok() + .flatten() + .ok_or("The native review window is unavailable on this thread")?; + for native in handles { + let hwnd = HWND(native.0); + // all_windows is maintained by Win32Window's UI-thread lifecycle. The + // thread and IsWindow checks precede dereferencing its own USERDATA. + if !unsafe { IsWindow(Some(hwnd)) }.as_bool() + || unsafe { GetWindowThreadProcessId(hwnd, None) } != unsafe { GetCurrentThreadId() } + { + continue; + } + let ptr = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const Win32Window; + let Some(window) = (unsafe { ptr.as_ref() }) else { + continue; + }; + if window.window_id == id && !window.is_closing.get() { + return Ok(hwnd.0 as usize); + } + } + Err("The exact native review window no longer exists".into()) +} + +pub(super) fn native_handle(cx: &Cx, id: WindowId) -> Result { + if !cx.windows.is_valid(id) || !cx.windows[id].is_created { + return Err("The review window is no longer available".into()); + } + owned_handle(id) +} + +fn verification_result(result: UserConsentVerificationResult) -> Result<(), String> { + match result { + UserConsentVerificationResult::Verified => Ok(()), + UserConsentVerificationResult::DeviceNotPresent => { + Err("Windows Hello verification is unavailable on this device".into()) + } + UserConsentVerificationResult::NotConfiguredForUser => { + Err("Set up Windows Hello in Windows Settings before approving this operation".into()) + } + UserConsentVerificationResult::DisabledByPolicy => { + Err("Windows policy has disabled Windows Hello verification".into()) + } + UserConsentVerificationResult::DeviceBusy => { + Err("Windows Hello is busy; review and try again".into()) + } + UserConsentVerificationResult::RetriesExhausted => { + Err("Windows Hello verification attempts were exhausted".into()) + } + UserConsentVerificationResult::Canceled => Err("Windows verification was cancelled".into()), + _ => Err("Windows did not verify this operation".into()), + } +} + +enum Operation { + Availability(IAsyncOperation), + Verification(IAsyncOperation), +} +impl Operation { + fn cancel(&self) { + match self { + Self::Availability(operation) => { + let _ = operation.Cancel(); + } + Self::Verification(operation) => { + let _ = operation.Cancel(); + } + } + } +} +struct Pending { + target: WindowTarget, + message: String, + completion: PlatformCompletion, + cancelled: Arc, + operation: Operation, +} +fn pending() -> &'static Mutex> { + static PENDING: OnceLock>> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(HashMap::new())) +} +fn retain(id: String, request: Pending) { + // Cancel sets the flag before taking this same lock. It therefore cannot + // miss a request temporarily removed while its UI-thread pump runs. + let mut requests = pending().lock().unwrap_or_else(|e| e.into_inner()); + if !request.cancelled.load(Ordering::Acquire) { + requests.insert(id, request); + return; + } + drop(requests); + request.operation.cancel(); +} + +pub(super) fn begin( + target: WindowTarget, + message: &str, + completion: PlatformCompletion, +) -> Result { + if completion.is_cancelled() { + return Err("The review was already cancelled".into()); + } + if target.native_handle == 0 + || owned_handle(WindowId(target.slot, target.generation))? != target.native_handle + { + return Err("The native review window changed; review again".into()); + } + ensure_winrt()?; + if pending().lock().unwrap_or_else(|e| e.into_inner()).len() >= 8 { + return Err("Too many Windows verification requests are pending".into()); + } + let operation = UserConsentVerifier::CheckAvailabilityAsync() + .map_err(|_| "Windows Hello availability could not be checked")?; + let ready = AsyncOperationCompletedHandler::::new(|_, _| { + SignalToUI::set_ui_signal(); + Ok(()) + }); + if operation.SetCompleted(&ready).is_err() { + let _ = operation.Cancel(); + return Err("Windows Hello availability callback could not be registered".into()); + } + let id = completion.challenge_id(); + let cancelled = Arc::new(AtomicBool::new(false)); + retain( + id.clone(), + Pending { + target, + message: message.to_owned(), + completion, + cancelled: cancelled.clone(), + operation: Operation::Availability(operation), + }, + ); + Ok(Box::new(move || { + cancelled.store(true, Ordering::Release); + let request = pending() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + if let Some(request) = request { + // Native review normally cancels on its STA. If teardown moves to + // another thread, initialize it before interacting with WinRT. The + // common cancellation state has already made all late results inert. + if ensure_winrt().is_ok() { + request.operation.cancel(); + } + } + })) +} + +/// Called on the UI thread only after common code revalidated this challenge's +/// app, bundle, account, review and window. An availability callback cannot open +/// a dialog, authorize an operation or pump any other challenge. +pub(super) fn pump(challenge_id: &str) { + let Some(mut request) = pending() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(challenge_id) + else { + return; + }; + if request.cancelled.load(Ordering::Acquire) || request.completion.is_cancelled() { + request.operation.cancel(); + return; + } + let Operation::Availability(availability) = &request.operation else { + retain(challenge_id.to_owned(), request); + return; + }; + match availability.Status() { + Ok(AsyncStatus::Started) => { + retain(challenge_id.to_owned(), request); + return; + } + Ok(AsyncStatus::Completed) => {} + _ => { + request.completion.finish(Err( + "Windows Hello availability check failed or was cancelled".into(), + )); + return; + } + } + let available = availability_query_result(availability.GetResults()); + if let Err(error) = available { + request.completion.finish(Err(error)); + return; + } + match request_verification(&request) { + Ok(operation) => { + request.operation = Operation::Verification(operation); + retain(challenge_id.to_owned(), request); + } + Err(error) => request.completion.finish(Err(error)), + } +} + +fn availability_result(result: UserConsentVerifierAvailability) -> Result<(), String> { + match result { + UserConsentVerifierAvailability::Available => Ok(()), + UserConsentVerifierAvailability::DeviceNotPresent => { + Err("Windows Hello verification is unavailable on this device".into()) + } + UserConsentVerifierAvailability::NotConfiguredForUser => { + Err("Set up Windows Hello in Windows Settings before approving this operation".into()) + } + UserConsentVerifierAvailability::DisabledByPolicy => { + Err("Windows policy has disabled Windows Hello verification".into()) + } + UserConsentVerifierAvailability::DeviceBusy => { + Err("Windows Hello is busy; review and try again".into()) + } + _ => Err("Windows Hello availability is unknown".into()), + } +} + +fn availability_query_result( + result: webview2_windows::core::Result, +) -> Result<(), String> { + result + .map_err(|_| "Windows Hello availability returned no valid result".to_owned()) + .and_then(availability_result) +} + +fn request_verification( + request: &Pending, +) -> Result, String> { + ensure_winrt()?; + let target = request.target; + if owned_handle(WindowId(target.slot, target.generation))? != target.native_handle { + return Err("The native review window changed; review again".into()); + } + if request.cancelled.load(Ordering::Acquire) || request.completion.is_cancelled() { + return Err("The review was cancelled".into()); + } + let verifier: IUserConsentVerifierInterop = + factory::().map_err(|_| { + "Desktop Windows verification is unavailable; Windows 11 or later is required" + })?; + let operation: IAsyncOperation = unsafe { + verifier.RequestVerificationForWindowAsync( + HWND(target.native_handle as *mut _), + &HSTRING::from(request.message.as_str()), + ) + } + .map_err(|_| "Windows could not start verification for the review window")?; + // Capture no operation, avoiding a COM reference cycle. Common state ignores + // late callbacks and consumes successful evidence only once after rechecking. + let completion = request.completion.clone(); + let handler = AsyncOperationCompletedHandler::::new( + move |sender, status| { + if completion.is_cancelled() { + return Ok(()); + } + let outcome = if status == AsyncStatus::Completed { + sender + .as_ref() + .ok_or_else(|| "Windows verification returned no operation".to_owned()) + .and_then(|operation| { + operation + .GetResults() + .map_err(|_| "Windows verification returned no valid result".to_owned()) + }) + .and_then(verification_result) + } else if status == AsyncStatus::Canceled { + Err("Windows verification was cancelled".into()) + } else { + Err("Windows verification failed".into()) + }; + completion.finish(outcome); + Ok(()) + }, + ); + if operation.SetCompleted(&handler).is_err() { + let _ = operation.Cancel(); + return Err("Windows verification could not register its completion handler".into()); + } + Ok(operation) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unavailable_request() -> (String, Pending) { + let completion = PlatformCompletion { + state: Arc::new(Mutex::new(super::super::State::Waiting)), + nonce: uuid::Uuid::new_v4(), + digest: [0; 32], + }; + ( + completion.challenge_id(), + Pending { + target: WindowTarget { + slot: 0, + generation: 0, + native_handle: 0, + }, + message: "Native adapter test".into(), + completion, + cancelled: Arc::new(AtomicBool::new(false)), + operation: Operation::Availability(IAsyncOperation::ready(Ok( + UserConsentVerifierAvailability::DeviceNotPresent, + ))), + }, + ) + } + + #[test] + fn availability_pump_processes_only_its_validated_challenge() { + let (first, first_request) = unavailable_request(); + let (second, second_request) = unavailable_request(); + let first_result = first_request.completion.clone(); + let second_result = second_request.completion.clone(); + retain(first.clone(), first_request); + retain(second.clone(), second_request); + pump(&first); + assert!(matches!( + *first_result.state.lock().unwrap(), + super::super::State::Finished(Err(_)) + )); + assert!(matches!( + *second_result.state.lock().unwrap(), + super::super::State::Waiting + )); + assert!(!pending().lock().unwrap().contains_key(&first)); + pump(&second); + assert!(!pending().lock().unwrap().contains_key(&second)); + } + + #[test] + fn cancellation_while_removed_for_pump_cannot_retain_request() { + let (id, request) = unavailable_request(); + let cancelled = request.cancelled.clone(); + retain(id.clone(), request); + let request = pending().lock().unwrap().remove(&id).unwrap(); + // Cancellation can occur while pump owns the request outside the lock. + cancelled.store(true, Ordering::Release); + retain(id.clone(), request); + assert!(!pending().lock().unwrap().contains_key(&id)); + } + + #[test] + fn only_native_verified_authorizes() { + assert!(verification_result(UserConsentVerificationResult::Verified).is_ok()); + for value in 1..=6 { + assert!(verification_result(UserConsentVerificationResult(value)).is_err()); + } + assert!(verification_result(UserConsentVerificationResult(-1)).is_err()); + assert!(verification_result(UserConsentVerificationResult(99)).is_err()); + } + + #[test] + fn unavailable_or_unknown_never_opens_verification() { + assert!(availability_result(UserConsentVerifierAvailability::Available).is_ok()); + for value in 1..=4 { + assert!(availability_result(UserConsentVerifierAvailability(value)).is_err()); + } + assert!(availability_result(UserConsentVerifierAvailability(-1)).is_err()); + assert!(availability_result(UserConsentVerifierAvailability(99)).is_err()); + } + + /// Opt in on a real Windows runner. This checks native availability only; + /// it never calls RequestVerificationForWindowAsync or authenticates anyone. + #[test] + #[ignore = "requires native Windows WinRT; availability only, no Hello prompt"] + fn native_windows_hello_availability_without_prompt() { + use std::time::{Duration, Instant}; + use webview2_windows::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE, + }; + ensure_winrt().expect("Initialize the availability probe STA"); + let operation = match UserConsentVerifier::CheckAvailabilityAsync() { + Ok(operation) => operation, + Err(error) => { + let code = error.code().0; + assert!(availability_query_result(Err(error)).is_err()); + println!( + "WINDOWS_HELLO_AVAILABILITY query_error={code} authentication_attempted=false" + ); + return; + } + }; + let deadline = Instant::now() + Duration::from_secs(15); + loop { + match operation.Status() { + Ok(AsyncStatus::Started) => {} + Ok(AsyncStatus::Completed) => { + let result = operation.GetResults(); + let available = result + .as_ref() + .is_ok_and(|value| *value == UserConsentVerifierAvailability::Available); + let code = result.as_ref().map(|value| value.0).unwrap_or(-1); + assert_eq!(availability_query_result(result).is_ok(), available); + println!("WINDOWS_HELLO_AVAILABILITY result={code} available={available} authentication_attempted=false"); + return; + } + Ok(status) => { + assert_ne!(status, AsyncStatus::Completed); + println!("WINDOWS_HELLO_AVAILABILITY terminal_status={} authentication_attempted=false", status.0); + return; + } + Err(error) => { + let code = error.code().0; + assert!(availability_query_result(Err(error)).is_err()); + println!("WINDOWS_HELLO_AVAILABILITY status_error={code} authentication_attempted=false"); + let _ = operation.Cancel(); + return; + } + } + if Instant::now() >= deadline { + let _ = operation.Cancel(); + panic!("Native Windows Hello availability did not finish within 15 seconds"); + } + // The bounded STA pump allows native completion messages through + // without creating a test window or showing any verification UI. + let mut message = MSG::default(); + for _ in 0..64 { + if !unsafe { PeekMessageW(&mut message, None, 0, 0, PM_REMOVE) }.as_bool() { + break; + } + unsafe { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn missing_owned_ui_window_is_refused() { + assert!(owned_handle(WindowId(0, 0)).is_err()); + } +} diff --git a/crates/oauth-service/src/host.rs b/crates/oauth-service/src/host.rs index cab82813..3b487537 100644 --- a/crates/oauth-service/src/host.rs +++ b/crates/oauth-service/src/host.rs @@ -49,7 +49,7 @@ static AUTHORIZATION_EPOCHS: std::sync::OnceLock> = fn authorization_epochs() -> &'static Mutex { AUTHORIZATION_EPOCHS.get_or_init(|| Mutex::new(AuthorizationEpochs::default())) } -fn authorization_epoch(root: &Path, app: &str) -> u64 { +pub(crate) fn authorization_epoch(root: &Path, app: &str) -> u64 { authorization_epochs() .lock() .unwrap_or_else(|e| e.into_inner()) @@ -255,9 +255,21 @@ pub(crate) fn with_provider_api( root: &Path, app: &str, run: impl FnOnce(&mut crate::api::Api<'_>) -> Result, +) -> Result { + with_provider_api_checked(root, app, || Ok(()), run) +} + +pub(crate) fn with_provider_api_checked( + root: &Path, + app: &str, + check: impl FnOnce() -> Result<(), String>, + run: impl FnOnce(&mut crate::api::Api<'_>) -> Result, ) -> Result { let operation = operation_lock(root, app); let _operation = operation.lock().unwrap_or_else(|e| e.into_inner()); + // Approval cannot be checked before this lock: a queued worker may wait + // while logout, account selection or admission revocation invalidates it. + check()?; let mut store = { let _metadata = STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); connections(root)? @@ -930,6 +942,58 @@ content mod lifecycle_tests { use super::*; #[test] + fn queued_os_approved_save_rechecks_revocation_after_operation_lock() { + let root = std::env::temp_dir().join(format!("approval-queued-{}", Uuid::new_v4())); + let app = "org.example.queued"; + let epoch = authorization_epoch(&root, app); + let lock = operation_lock(&root, app); + let held = lock.lock().unwrap(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (checked_tx, checked_rx) = std::sync::mpsc::channel(); + let worker_root = root.clone(); + let worker = std::thread::spawn(move || { + entered_tx.send(()).unwrap(); + with_provider_api_checked( + &worker_root, + app, + || { + checked_tx.send(()).unwrap(); + if authorization_epoch(&worker_root, app) != epoch { + Err("Approval revoked while queued".into()) + } else { + Ok(()) + } + }, + |_| -> Result<(), String> { + panic!("A revoked approval must never reach the provider") + }, + ) + }); + entered_rx.recv().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while Arc::strong_count(&lock) < 2 { + assert!( + Instant::now() < deadline, + "Worker did not reach the operation lock" + ); + std::thread::yield_now(); + } + assert!( + matches!( + checked_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + ), + "Approval was checked before acquiring its execution lock" + ); + invalidate_authorizations(&root, app); + drop(held); + assert_eq!( + worker.join().unwrap().unwrap_err(), + "Approval revoked while queued" + ); + checked_rx.recv().unwrap(); + } + #[test] fn removing_or_selecting_an_account_invalidates_only_its_authorization_scope() { let mut epochs = AuthorizationEpochs::default(); let root = Path::new("fixture-profile"); diff --git a/crates/oauth-service/src/host_api.rs b/crates/oauth-service/src/host_api.rs index 3e74f62b..d04dc622 100644 --- a/crates/oauth-service/src/host_api.rs +++ b/crates/oauth-service/src/host_api.rs @@ -36,6 +36,28 @@ struct Review { save: Arc, account: String, } + +/// Authentication binds the complete executable operation, independently of +/// the human-readable presentation. Include version/resource IDs that do not +/// necessarily appear verbatim in the summary shown by the native widget. +fn approval_snapshot(review: &Review) -> Value { + match &review.change { + Change::Github(file) => { + json!({"operation":"github.save","connection":review.connection,"file":file}) + } + Change::Calendar { + calendar, + event, + existing, + create_id, + } => json!({"operation":"gcalendar.save", + "connection":review.connection,"calendar":calendar,"event":event,"existing":existing,"create_id":create_id}), + Change::Backend(request) => { + json!({"operation":"backend.request","connection":review.connection, + "backend_binding":request.binding,"origin":request.origin,"method":request.method,"path":request.path,"request":request.request}) + } + } +} #[derive(Default)] struct SaveState { phase: AtomicU8, // 0 pending, 1 submitted, 2 cancelled @@ -69,6 +91,7 @@ pub struct ReviewRequest { family: &'static str, ticket: String, review: Arc, + os_binding: Result, } impl ReviewRequest { pub fn snapshot(&self) -> Value { @@ -86,6 +109,9 @@ impl ReviewRequest { "Saving requires a physical activation of the native Approve & Save control".into(), ); } + self.claim_once() + } + fn claim_once(&self) -> Result<(), String> { if Instant::now() >= self.review.deadline { self.cancel()?; return Err("Review expired; check the current draft again".into()); @@ -101,16 +127,68 @@ impl ReviewRequest { /// the worker. Rejected automation does not consume the pending review. pub fn approve(&mut self, down: bool, up: bool) -> Result<(), String> { self.claim_approval(down, up)?; + self.execute(None) + } + pub fn begin_os_approval( + &self, + cx: &makepad_widgets::Cx, + window: makepad_widgets::WindowId, + owner: &str, + ) -> Result { + self.os_pending_check()?; + let binding = self.os_binding.clone()?; + binding.revalidate()?; + crate::approval::PendingApproval::begin( + cx, + window, + owner, + binding, + "Approve the exact account change displayed by OctoSense", + ) + } + pub fn poll_os_approval( + &mut self, + cx: &makepad_widgets::Cx, + window: makepad_widgets::WindowId, + owner: &str, + pending: &mut crate::approval::PendingApproval, + ) -> Result { + self.os_pending_check()?; + let binding = self.os_binding.clone()?; + binding.revalidate()?; + let Some(evidence) = pending.poll(cx, window, owner, &binding)? else { + return Ok(false); + }; + let approved = evidence.consume(&binding)?.limit_to(self.review.deadline); + self.claim_once()?; + self.execute(Some(approved))?; + Ok(true) + } + fn os_pending_check(&self) -> Result<(), String> { + if self.review.save.phase.load(Ordering::Acquire) != 0 || !self.review.reply.is_pending() { + return Err("This native review was cancelled or its app is no longer waiting".into()); + } + Ok(()) + } + fn execute( + &self, + authenticated: Option, + ) -> Result<(), String> { let review = self.review.clone(); let fallback = review.clone(); if std::thread::Builder::new() .name("connector-reviewed-save".into()) .spawn(move || { let result = match &review.change { - Change::Backend(request) => request.execute(), - _ => with_provider_api(&review.root, &review.app, |api| { - execute_review(&review, api) + Change::Backend(request) => request.execute_checked(|| { + authenticated.map_or(Ok(()), |approval| approval.revalidate()) }), + _ => crate::host::with_provider_api_checked( + &review.root, + &review.app, + || authenticated.map_or(Ok(()), |approval| approval.revalidate()), + |api| execute_review(&review, api), + ), }; *review.save.result.lock().unwrap_or_else(|e| e.into_inner()) = Some(result.clone()); @@ -233,10 +311,19 @@ impl Connector { save: Arc::new(SaveState::default()), account, }); + let os_binding = crate::approval::Binding::capture( + &review.root, + &review.app, + self.family, + &review.connection, + &ticket, + &approval_snapshot(&review), + ); let request = ReviewRequest { family: self.family, ticket: ticket.clone(), review: review.clone(), + os_binding, }; let source = match hook(request) { Ok(source) => source, @@ -768,11 +855,53 @@ mod tests { family: "github", ticket: Uuid::new_v4().to_string(), review, + os_binding: Err("Physical-only test fixture".into()), }, heap, ) } + #[test] + fn operation_fingerprint_binds_github_revision_and_calendar_resource_ids() { + let (mut request, heap) = fixture(); + let digest = |review: &Review| { + crate::approval::canonical_digest(&approval_snapshot(review)).unwrap() + }; + let original = digest(&request.review); + if let Change::Github(file) = &mut Arc::get_mut(&mut request.review).unwrap().change { + file.sha = Some("new-sha".into()); + } + assert_ne!(original, digest(&request.review)); + Arc::get_mut(&mut request.review).unwrap().change = Change::Calendar { + calendar: "calendar-one".into(), + event: serde_json::from_value( + json!({"summary":"Meeting","description":"Review","location":"Office", + "start":{"date":"2026-10-08"},"end":{"date":"2026-10-09"}}), + ) + .unwrap(), + existing: Some(("event-one".into(), "etag-one".into())), + create_id: "create-one".into(), + }; + for field in 0..3 { + let before = digest(&request.review); + if let Change::Calendar { + existing, + create_id, + .. + } = &mut Arc::get_mut(&mut request.review).unwrap().change + { + match field { + 0 => existing.as_mut().unwrap().0.push('x'), + 1 => existing.as_mut().unwrap().1.push('x'), + _ => create_id.push('x'), + } + } + assert_ne!(before, digest(&request.review)); + } + drop(request); + services::cancel_heap(heap); + } + #[test] fn native_provenance_is_required_on_both_edges_and_approval_is_one_use() { let (mut request, heap) = fixture(); diff --git a/crates/oauth-service/src/host_backend.rs b/crates/oauth-service/src/host_backend.rs index a4239076..bdfa62a2 100644 --- a/crates/oauth-service/src/host_backend.rs +++ b/crates/oauth-service/src/host_backend.rs @@ -421,13 +421,25 @@ impl PreparedRequest { self.method != "GET" } pub fn execute(&self) -> Result { + self.execute_checked(|| Ok(())) + } + pub fn execute_checked( + &self, + check: impl FnOnce() -> Result<(), String>, + ) -> Result { with_session( &self.root, &self.app, &self.request.connection, &self.scope_check, Some((&self.binding, self.epoch)), - |backend, tokens, _| backend.request(&self.app, tokens, &self.request), + |backend, tokens, _| { + // with_session owns the per-app operation lock here, and has + // completed any refresh. Check the exact OS approval binding + // immediately before the declared business request. + check()?; + backend.request(&self.app, tokens, &self.request) + }, ) } } diff --git a/crates/oauth-service/src/host_catalog.rs b/crates/oauth-service/src/host_catalog.rs index 0495659d..8d2ae959 100644 --- a/crates/oauth-service/src/host_catalog.rs +++ b/crates/oauth-service/src/host_catalog.rs @@ -93,20 +93,9 @@ fn method( args, result, ) - // Native account/vault and external-browser routes exist on both desktop - // targets too. Do not hide read/local-draft methods from their agents. - // Write-review methods retain the physical-approval platform boundary; - // a separate OS-authenticated approval adapter must establish the others. - .with_platforms( - if matches!( - name, - "github.review_save" | "gcalendar.review_save" | "gmail.draft.review" - ) { - &["macos", "android"] - } else { - &["macos", "android", "linux", "windows"] - }, - ) + // Native write reviews on Linux/Windows require fresh OS authentication; + // runtime availability still depends on the OS policy or Windows Hello. + .with_platforms(&["macos", "android", "linux", "windows"]) .with_agent_access(agent) } fn result() -> Value { @@ -134,7 +123,7 @@ pub(crate) fn auth() -> Vec { method("auth.select", "Select an app-owned account and invalidate pending authorizations", input(&["connection"], &[]), connection(), ForegroundOnly), method("auth.disconnect", "Revoke the local connection, then attempt backend remote logout", input(&["connection"], &[]), result(), ForegroundOnly), method("auth.backend.me", "Read this app's active backend identity", input(&["connection"], &[]), object(json!({"connection":string(),"backend_id":string(),"identity":object(json!({"sub":string(),"label":string()}), &["sub","label"])}), &["connection","backend_id","identity"]), Allowed), - method("auth.backend.request", "Execute a declared backend operation; GET can run in background, mutations require native physical review", input(&["connection","operation"], &[ + method("auth.backend.request", "Execute a declared backend operation; GET can run in background, mutations require native review and physical or OS-authenticated approval", input(&["connection","operation"], &[ ("query",json!({"type":"object","maxProperties":32,"additionalProperties":{"type":"string","maxLength":2048}})), ("body",json!({"description":"Operation JSON body, at most 64 KiB; omitted for GET"}))]), json!({"description":"Declared endpoint JSON result, at most 64 KiB"}), Allowed), ] @@ -214,7 +203,7 @@ pub(crate) fn connector(family: &str, native_review: bool) -> Vec match family { "github" => methods.push(method( "github.review_save", - "Review an immutable Markdown commit; only physical native approval can save", + "Review an immutable Markdown commit; physical or OS-authenticated native approval is required to save", requiring( input(&["connection"], &[("file", github_file())]), &["file"], @@ -224,7 +213,7 @@ pub(crate) fn connector(family: &str, native_review: bool) -> Vec )), "gcalendar" => methods.push(method( "gcalendar.review_save", - "Review an immutable event create/update; only physical native approval can save", + "Review an immutable event create/update; physical or OS-authenticated native approval is required to save", requiring( input( &["connection", "calendar"], @@ -320,7 +309,7 @@ pub(crate) fn gmail(native_review: bool) -> Vec { if native_review { methods.push(method( "gmail.draft.review", - "Review the exact saved draft; physical native approval is required to send", + "Review the exact saved draft; physical or OS-authenticated native approval is required to send", requiring( input( &["connection", "draft"], @@ -339,7 +328,7 @@ pub(crate) fn gmail(native_review: bool) -> Vec { mod tests { use super::*; #[test] - fn desktop_accounts_reads_and_local_drafts_are_discoverable_without_widening_write_approval() { + fn desktop_reviews_are_discoverable_but_never_background_approvals() { let methods = auth() .into_iter() .chain(connector("github", true)) @@ -352,15 +341,14 @@ mod tests { "github.review_save" | "gcalendar.review_save" | "gmail.draft.review" ); for platform in ["linux", "windows"] { - assert_eq!( + assert!( method.platforms.iter().any(|p| p == platform), - !review, "{} on {platform}", method.name ); } - if method.name == "auth.backend.request" { - assert!(method.platforms.iter().any(|p| p == "linux")); + if review { + assert_eq!(method.agent_access, AgentAccess::ForegroundOnly); } count += 1; } diff --git a/crates/oauth-service/src/host_inbox.rs b/crates/oauth-service/src/host_inbox.rs index 24778190..45952a65 100644 --- a/crates/oauth-service/src/host_inbox.rs +++ b/crates/oauth-service/src/host_inbox.rs @@ -53,6 +53,7 @@ pub struct ReviewRequest { snapshot: Value, reply: Option, result: Arc>>>, + os_binding: Result, } impl ReviewRequest { /// {app, connection, draft, revision, operation, reply:{from,to,subject,body, @@ -73,6 +74,59 @@ impl ReviewRequest { .into(), ); } + self.execute(down_is_trusted, up_is_trusted, None) + } + pub fn begin_os_approval( + &self, + cx: &makepad_widgets::Cx, + window: makepad_widgets::WindowId, + owner: &str, + ) -> Result { + self.os_pending_check()?; + let binding = self.os_binding.clone()?; + binding.revalidate()?; + crate::approval::PendingApproval::begin( + cx, + window, + owner, + binding, + "Approve the exact email reply displayed by OctoSense", + ) + } + pub fn poll_os_approval( + &mut self, + cx: &makepad_widgets::Cx, + window: makepad_widgets::WindowId, + owner: &str, + pending: &mut crate::approval::PendingApproval, + ) -> Result { + self.os_pending_check()?; + let binding = self.os_binding.clone()?; + binding.revalidate()?; + let Some(evidence) = pending.poll(cx, window, owner, &binding)? else { + return Ok(false); + }; + let approved = evidence.consume(&binding)?; + self.execute(false, false, Some(approved))?; + Ok(true) + } + fn os_pending_check(&self) -> Result<(), String> { + let ticket = self + .ticket + .as_ref() + .ok_or("This reply review is no longer pending")?; + if !self.reply.as_ref().is_some_and(Replier::is_pending) { + return Err("The app is no longer waiting for this review".into()); + } + DraftStore::open(&self.root, &self.app, &self.connection)?.validate(ticket)?; + Ok(()) + } + fn execute( + &mut self, + down_is_trusted: bool, + up_is_trusted: bool, + authenticated: Option, + ) -> Result<(), String> { let ticket = self .ticket .take() @@ -93,7 +147,10 @@ impl ReviewRequest { }; let result = with_provider_api(&root, &app, |api| { let mut drafts = DraftStore::open(&root, &app, &connection)?; - let draft = drafts.submit(ticket, down_is_trusted, up_is_trusted, api)?; + let draft = match authenticated { + Some(approval) => drafts.submit_authenticated(ticket, approval, api)?, + None => drafts.submit(ticket, down_is_trusted, up_is_trusted, api)?, + }; serde_json::to_value(draft).map_err(|_| "Cannot serialize send receipt".into()) }); *output.lock().unwrap_or_else(|e| e.into_inner()) = Some(result.clone()); @@ -225,14 +282,24 @@ impl HostService for InboxService { )?; let ticket = DraftStore::open(&call.host_dir, &call.app_id, &connection)? .review(field(&call.args, "draft")?, revision(&call.args)?)?; + let snapshot = ticket.snapshot(); + let os_binding = crate::approval::Binding::capture( + &call.host_dir, + &call.app_id, + "gmail", + &connection, + snapshot["operation"].as_str().unwrap_or(""), + &snapshot, + ); Ok(ReviewRequest { root: call.host_dir, app: call.app_id, connection, - snapshot: ticket.snapshot(), + snapshot, ticket: Some(ticket), reply: Some(reply.clone()), result: Arc::new(Mutex::new(None)), + os_binding, }) })(); match pending { diff --git a/crates/oauth-service/src/inbox.rs b/crates/oauth-service/src/inbox.rs index 9e48df74..0497ddf2 100644 --- a/crates/oauth-service/src/inbox.rs +++ b/crates/oauth-service/src/inbox.rs @@ -639,7 +639,7 @@ impl DraftStore { self.save(next)?; Ok(draft) } - fn validate(&self, ticket: &ReviewTicket) -> Result { + pub(crate) fn validate(&self, ticket: &ReviewTicket) -> Result { if ticket.app != self.app || ticket.connection != self.connection || Instant::now() >= ticket.expires @@ -668,6 +668,28 @@ impl DraftStore { down_trusted: bool, up_trusted: bool, api: &mut Api<'_>, + ) -> Result { + if !down_trusted || !up_trusted { + return Err( + "Approve & Send requires a physical activation of the native host review".into(), + ); + } + self.submit_account_checked(ticket, api) + } + #[cfg(feature = "host")] + pub(crate) fn submit_authenticated( + &mut self, + ticket: ReviewTicket, + approval: crate::approval::ApprovedOperation, + api: &mut Api<'_>, + ) -> Result { + approval.validate(&self.app, &self.connection, &ticket.snapshot())?; + self.submit_account_checked(ticket, api) + } + fn submit_account_checked( + &mut self, + ticket: ReviewTicket, + api: &mut Api<'_>, ) -> Result { if !api .connections @@ -682,10 +704,9 @@ impl DraftStore { if sender != ticket.snapshot.from { return Err("Sending account changed; review again".into()); } - self.submit_with(ticket, down_trusted, up_trusted, |draft, attempt| { - api.send_claimed(draft, attempt) - }) + self.submit_claimed(ticket, |draft, attempt| api.send_claimed(draft, attempt)) } + #[cfg(test)] fn submit_with( &mut self, ticket: ReviewTicket, @@ -698,6 +719,13 @@ impl DraftStore { "Approve & Send requires a physical activation of the native host review".into(), ); } + self.submit_claimed(ticket, send) + } + fn submit_claimed( + &mut self, + ticket: ReviewTicket, + send: impl FnOnce(&Draft, &Attempt) -> SendOutcome, + ) -> Result { let mut draft = self.validate(&ticket)?; draft.status = Status::Sending; draft.attempts.last_mut().unwrap().status = Status::Sending; diff --git a/crates/oauth-service/src/lib.rs b/crates/oauth-service/src/lib.rs index bf42a881..81ad6278 100644 --- a/crates/oauth-service/src/lib.rs +++ b/crates/oauth-service/src/lib.rs @@ -23,6 +23,8 @@ mod host_catalog; #[cfg(feature = "host")] pub mod host; #[cfg(feature = "host")] +pub mod approval; +#[cfg(feature = "host")] pub mod host_api; #[cfg(feature = "host")] pub mod host_inbox; diff --git a/crates/shell/src/connected_review.rs b/crates/shell/src/connected_review.rs index d7530aaf..6512a1a6 100644 --- a/crates/shell/src/connected_review.rs +++ b/crates/shell/src/connected_review.rs @@ -7,6 +7,29 @@ enum ReviewRequest { Save(host_api::ReviewRequest), } impl ReviewRequest { + fn begin_os_approval( + &self, + cx: &Cx, + window: WindowId, + owner: &str, + ) -> Result { + match self { + Self::Mail(r) => r.begin_os_approval(cx, window, owner), + Self::Save(r) => r.begin_os_approval(cx, window, owner), + } + } + fn poll_os_approval( + &mut self, + cx: &Cx, + window: WindowId, + owner: &str, + pending: &mut octosense_oauth_service::approval::PendingApproval, + ) -> Result { + match self { + Self::Mail(r) => r.poll_os_approval(cx, window, owner, pending), + Self::Save(r) => r.poll_os_approval(cx, window, owner, pending), + } + } fn approve(&mut self, down: bool, up: bool) -> Result<(), String> { match self { Self::Mail(r) => r.approve(down, up), @@ -45,6 +68,27 @@ fn pending() -> &'static Mutex { } pub fn register() { + octosense_oauth_service::approval::register_admission(|root, app, family| { + let expected = + octosense_appstore::data_root_if_set().ok_or("App Hub has no active profile")?; + if root != expected.join(".host") { + return Err("Review belongs to another host profile".into()); + } + let family = if family == "auth.backend" { + "auth" + } else { + family + }; + let loaded = crate::host_tools::script_apps::guidance(app)?; + if !loaded.families.contains("auth") || !loaded.families.contains(family) { + return Err("The app no longer has this account-operation grant".into()); + } + loaded.manifest["integrity"]["bundle_blake3"] + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .ok_or_else(|| "The admitted bundle identity is unavailable".into()) + }); widget_async::register_splash_isolate_mod(|vm| { script_mod(vm); script_eval!(vm, {mod.prelude.widgets.ConnectedReplyReview = mod.widgets.ConnectedReplyReview}); @@ -125,9 +169,52 @@ pub struct ConnectedReplyReview { close_service: String, #[live] close_ticket: String, + #[rust] + os_pending: Option, + #[rust] + os_timer: Option, } impl ConnectedReplyReview { + fn review_window(&self, cx: &Cx) -> Result { + let area = self.view.area(); + if !area.is_valid(cx) { + return Err("The native review is no longer visible".into()); + } + let visible = area.clipped_rect(cx); + if visible.size.x <= 0.0 || visible.size.y <= 0.0 { + return Err("The native review is no longer visible".into()); + } + let draw = area + .draw_list_id() + .ok_or("The native review has no display owner")?; + let pass = cx.draw_lists[draw] + .draw_pass_id + .ok_or("The native review has no display pass")?; + cx.get_pass_window_id(pass) + .ok_or_else(|| "The native review has no window".into()) + } + fn approval_owner(&self) -> String { + format!("{}:{}", self.ticket, self.source.heap_key()) + } + fn stop_authentication(&mut self, cx: &mut Cx) { + self.os_pending.take(); + if let Some(timer) = self.os_timer.take() { + cx.stop_timer(timer); + } + } + fn submitting(&self, cx: &mut Cx) { + self.view.label(cx, ids!(status)).set_text( + cx, + if self.is_save { + "Saving this exact reviewed version…" + } else { + "Sending this exact reviewed reply…" + }, + ); + self.view.button(cx, ids!(approve)).set_enabled(cx, false); + self.view.button(cx, ids!(cancel)).set_enabled(cx, false); + } fn close_sheet(&self, cx: &mut Cx) { if makepad_widgets::splash_policy::is_enforced(self.source.heap_key()) { return; @@ -136,7 +223,10 @@ impl ConnectedReplyReview { // be resident in its ordinary window or another Glance workspace. if !matches!( self.close_service.as_str(), - "gmail.sheet.close" | "github.sheet.cancel" | "gcalendar.sheet.cancel" + "gmail.sheet.close" + | "github.sheet.cancel" + | "gcalendar.sheet.cancel" + | "auth.backend.sheet.cancel" ) { return; } @@ -219,6 +309,15 @@ impl ConnectedReplyReview { .label(cx, ids!(subject)) .set_visible(cx, !subject.is_empty()); self.view.label(cx, ids!(message)).set_text(cx, &body); + #[cfg(any(target_os = "linux", target_os = "windows"))] + self.view.button(cx, ids!(approve)).set_text( + cx, + if self.is_save { + "Authenticate & Save" + } else { + "Authenticate & Send" + }, + ); } else { self.view.label(cx, ids!(status)).set_text( cx, @@ -236,12 +335,47 @@ impl Widget for ConnectedReplyReview { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.initialize(cx); if matches!(event, Event::Pause | Event::Background) { + self.stop_authentication(cx); self.trusted_down = false; if let Some(mut request) = self.request.take() { let _ = request.cancel(); } return; } + if self.os_pending.is_some() + && (matches!(event, Event::Signal) + || self + .os_timer + .is_some_and(|timer| timer.is_event(event).is_some())) + { + if self + .os_timer + .is_some_and(|timer| timer.is_event(event).is_some()) + { + // One-shot polling avoids an orphan repeating timer when the + // owning widget is dropped while the OS dialog is open. + self.os_timer = Some(cx.start_timeout(0.2)); + } + let owner = self.approval_owner(); + let result = self.review_window(cx).and_then(|window| { + self.request + .as_mut() + .ok_or("The native review is no longer pending".to_owned())? + .poll_os_approval(cx, window, &owner, self.os_pending.as_mut().unwrap()) + }); + match result { + Ok(false) => {} + Ok(true) => { + self.stop_authentication(cx); + self.submitting(cx); + } + Err(error) => { + self.stop_authentication(cx); + self.view.label(cx, ids!(status)).set_text(cx, &error); + self.view.button(cx, ids!(approve)).set_enabled(cx, true); + } + } + } if !self.finished { if let Some(result) = self.request.as_ref().and_then(ReviewRequest::result) { self.finished = true; @@ -278,6 +412,7 @@ impl Widget for ConnectedReplyReview { self.trusted_down = makepad_platform::trusted_user_input(); } if self.view.button(cx, ids!(cancel)).clicked(&actions) { + self.stop_authentication(cx); if let Some(mut request) = self.request.take() { let _ = request.cancel(); } @@ -285,6 +420,27 @@ impl Widget for ConnectedReplyReview { } if approve.clicked(&actions) { let down = std::mem::take(&mut self.trusted_down); + #[cfg(any(target_os = "linux", target_os = "windows"))] + { + let _ = down; + let owner = self.approval_owner(); + let result = self.review_window(cx).and_then(|window| { + self.request + .as_ref() + .ok_or("The native review is no longer pending".to_owned())? + .begin_os_approval(cx, window, &owner) + }); + match result { + Ok(pending) => { + self.os_pending = Some(pending); + self.os_timer = Some(cx.start_timeout(0.2)); + self.view.label(cx,ids!(status)).set_text(cx,"Complete the operating system authentication prompt. The reviewed content cannot change."); + approve.set_enabled(cx, false); + } + Err(error) => self.view.label(cx, ids!(status)).set_text(cx, &error), + } + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] if let Some(request) = &mut self.request { match request.approve(down, makepad_platform::trusted_user_input()) { Ok(()) => { diff --git a/desktop/packaging/release.json b/desktop/packaging/release.json index 717966b3..55a7809c 100644 --- a/desktop/packaging/release.json +++ b/desktop/packaging/release.json @@ -26,7 +26,7 @@ }, "deb": { "section": "utils", - "depends": ["libgtk-3-0t64 | libgtk-3-0", "libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37"] + "depends": ["libgtk-3-0t64 | libgtk-3-0", "libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37", "polkitd | policykit-1"] }, "nsis": { "installMode": "currentUser" diff --git a/desktop/resources/org.octosense.policy b/desktop/resources/org.octosense.policy new file mode 100644 index 00000000..27029ed9 --- /dev/null +++ b/desktop/resources/org.octosense.policy @@ -0,0 +1,16 @@ + + + + OctoSense + https://github.com/OctoSense-org/OctoSense + + Approve a reviewed OctoSense account operation + Authenticate to approve the exact operation displayed by OctoSense + + no + no + auth_self + + + diff --git a/desktop/scripts/package.py b/desktop/scripts/package.py index 9f53b23e..f495c86d 100644 --- a/desktop/scripts/package.py +++ b/desktop/scripts/package.py @@ -246,6 +246,12 @@ def packager_config(base, *, version, binaries_dir, out_dir, resources, kernel): config["binariesDir"] = str(binaries_dir) config["outDir"] = str(out_dir) config["resources"] = [{"src": str(resources), "target": "."}] + # Debian installs this policy as a root-owned package payload. The app + # never writes a system policy itself; AppImage/source users must arrange + # an explicit administrator installation of the identical file. + config.setdefault("deb", {}).setdefault("files", {})[ + str(DESKTOP / "resources/org.octosense.policy") + ] = "usr/share/polkit-1/actions/org.octosense.policy" if kernel: # cargo-packager copies `-[.exe]` beside the # main binary as `[.exe]`. diff --git a/desktop/scripts/test_package.py b/desktop/scripts/test_package.py index e13d07e6..c18a8517 100644 --- a/desktop/scripts/test_package.py +++ b/desktop/scripts/test_package.py @@ -103,6 +103,14 @@ def test_the_release_config_is_filled_in_per_build(self): self.assertNotIn("signingIdentity", config["macos"], "the build is always unsigned") self.assertNotIn("windows", config) self.assertNotIn("version", base, "the base config is not modified") + policy=HERE.parent / "resources/org.octosense.policy" + self.assertEqual(config["deb"]["files"][str(policy)],"usr/share/polkit-1/actions/org.octosense.policy") + self.assertIn("polkitd | policykit-1",config["deb"]["depends"]) + import xml.etree.ElementTree as ET + action=ET.parse(policy).getroot().find('action') + self.assertEqual(action.attrib['id'],'org.octosense.approve-business-action') + self.assertEqual({node.tag:node.text for node in action.find('defaults')}, + {'allow_any':'no','allow_inactive':'no','allow_active':'auth_self'}) none = package.packager_config(base, version="0.2.0", binaries_dir=Path("/t"), out_dir=Path("/t"), resources=Path("/t"), kernel=None) self.assertNotIn("externalBinaries", none, "--no-kernel ships none") diff --git a/docs/adr/0013-os-authenticated-write-approval.md b/docs/adr/0013-os-authenticated-write-approval.md new file mode 100644 index 00000000..fd76930c --- /dev/null +++ b/docs/adr/0013-os-authenticated-write-approval.md @@ -0,0 +1,46 @@ +# ADR 0013: OS-authenticated approval for reviewed writes + +English | [简体中文](0013-os-authenticated-write-approval.zh-CN.md) + +Status: implementation under review; OS-authenticated user acceptance pending. Linux/Windows physical-pointer provenance remains unsupported. Browser and vault tests do not establish write approval. + +## Problem and decision + +The host-owned review in `crates/shell/src/connected_review.rs` displays the exact Gmail reply, GitHub change, Calendar event or declared backend mutation. Its native `oauth-service` capability requires trusted pointer down and up. The Android/macOS provenance adapters support this; Linux/Windows currently reject genuine users too. Synthetic input must not become trusted to make the buttons work. + +Keep existing physical approval unchanged. Add **OS-authenticated approval** for Linux/Windows: the person reviews the content, activates **Authenticate & Send/Save** in the native sheet, and completes the OS's authentication UI. A normal, remote or synthetic click alone can never submit a write. + +This evidence is distinct from `trusted_user_input()`. It must not set that flag, manufacture `down/up=true`, or enter script/tool arguments. Both evidence types feed one internal, single-use execution claim. Provider login, token storage and ordinary browser use remain separate operations. + +## Binding and lifecycle + +The native review owner creates a challenge containing a random nonce, short monotonic deadline, admitted app/bundle identity, connection and active-account generation, review ID, canonical SHA-256 digest of the fully displayed operation, and live native-window generation plus host-sheet/isolate owner. The digest includes destination, content and resource version. No app-supplied digest, command, callback or authentication result is trusted. + +Only the visible foreground native review can start authentication. Closing/replacing it, changing account/draft, expiry, disconnect, withdrawal or window destruction/reuse cancels the challenge. On completion, recheck every binding and the current admitted operation before atomically consuming its nonce. Late/repeated results and changed drafts fail. Existing provider/account/resource conflict checks still run when submitting. + +The state machine is `Reviewing → Authenticating → Claimed → Finished`; cancellation/expiry is terminal. Failure can return to Reviewing only with a fresh challenge. Authentication is asynchronous. Cancel never authorizes a write. The OS prompt uses a short host-generated description; full mail bodies and credentials do not enter polkit details or logs. + +## Linux + +Call the system polkit authority for a dedicated `org.octosense.approve-business-action` action. Supported installers ship its root-owned policy: `allow_any=no`, `allow_inactive=no`, `allow_active=auth_self`, with no retained authorization. No privileged command/helper is executed. This approves a same-user business action, not a privileged system operation. + +A noninteractive preflight must report a fresh challenge, not already-authorized/cached permission. Interactive `CheckAuthorization` binds the current process PID/start time/UID and unique cancellation ID. Reject retained/temporary authorization, dismissal, unavailable authority/agent/policy and unexpected replies. Cancel outstanding checks when their owner disappears. See [polkit policy semantics](https://polkit.pages.freedesktop.org/polkit/polkit.8.html) and the [authority interface](https://polkit.pages.freedesktop.org/polkit/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html). + +The machine administrator remains trusted. AppImage/source launches must explicitly report missing policy; they cannot silently install it or fall back to synthetic approval. This task does not authorize installing a system policy on the shared test host. + +## Windows + +Use `UserConsentVerifier.CheckAvailabilityAsync` and desktop `IUserConsentVerifierInterop::RequestVerificationForWindowAsync`, bound to the exact native HWND. Accept only `Verified`. Cancellation, busy/unconfigured/disabled devices, unavailable API, destroyed windows and HRESULT failures deny authorization. OctoSense receives a verification result, not the user's PIN or biometric data. Microsoft's [desktop guidance](https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.ui.userconsentverifier?view=winrt-26100) specifies this interface; its [requirements](https://learn.microsoft.com/en-us/windows/win32/api/userconsentverifierinterop/nf-userconsentverifierinterop-iuserconsentverifierinterop-requestverificationforwindowasync) list Windows build 22000 as the minimum. Older hosts report unavailable unless a separately reviewed adapter is added. + +## Implementation and acceptance + +1. Add a native-only `oauth-service` approval module: immutable bindings, challenge/cancellation state and opaque evidence without public construction or serialization. Refactor Gmail, connector and backend mutation claims to consume typed evidence while preserving physical-input semantics. +2. Put platform adapters behind that boundary. They mint evidence only for an outstanding bound challenge and never execute a business write directly. +3. Extend the existing native review widget with authentication state and availability messages, reusing its complete-content display and submission worker. Add no app-callable approval API. +4. Include the Linux action in supported package payloads and check policy/ownership. Missing registration stays unavailable. +5. Test changed account/digest/window, replay, late results, cancellation and expiry. Actual native negative tests must reject missing policy/Hello. Then use disposable accounts to verify OS-authenticated writes and rejection of synthetic-only attempts on each supported platform. + +Keep distinct receipts for login, native vault, ordinary embedded browser, OS-authenticated approval and the resulting remote write. Compile, unit, Xvfb and credential-storage passes do not prove user approval. A physical-positive claim needs an actual person/device. Approval and business-write acceptance remain unverified until those native interactions complete. + +The implemented boundary, package prerequisites and unverified native positives +are recorded in [desktop write approval](../os-authenticated-approval.md). diff --git a/docs/adr/0013-os-authenticated-write-approval.zh-CN.md b/docs/adr/0013-os-authenticated-write-approval.zh-CN.md new file mode 100644 index 00000000..26476160 --- /dev/null +++ b/docs/adr/0013-os-authenticated-write-approval.zh-CN.md @@ -0,0 +1,45 @@ +# ADR 0013:由操作系统认证的写入审批 + +[English](0013-os-authenticated-write-approval.md) | 简体中文 + +状态:实现审阅中,OS 认证用户验收待完成。Linux/Windows 的物理指针来源验证仍不受支持。浏览器和凭据库测试不能证明写入审批可用。 + +## 问题与决定 + +`crates/shell/src/connected_review.rs` 的宿主原生审阅页显示完整 Gmail 回复、GitHub 修改、Calendar 日程或已声明的后端变更。其原生 `oauth-service` 能力要求可信的按下与抬起事件。Android/macOS 适配器支持这种验证;Linux/Windows 当前也会拒绝真实用户。不能通过把合成输入标记为可信来修复按钮。 + +保留现有物理审批。Linux/Windows 增加 **OS 认证审批**:用户审阅内容后,点击原生页的 **认证并发送/保存**,再完成操作系统自己的认证。普通、远程或合成点击本身都不能提交写入。 + +该证据不同于 `trusted_user_input()`,不得设置该标志、伪造 `down/up=true`,也不能进入脚本/工具参数。两种证据使用同一个内部的一次性执行声明。提供商登录、令牌存储和普通浏览器使用仍是独立操作。 + +## 绑定与生命周期 + +原生审阅所有者创建挑战,包含随机 nonce、较短的单调时钟期限、已接纳应用/包身份、连接与活动账户代次、审阅 ID、完整显示操作的规范化 SHA-256 摘要,以及原生窗口代次与宿主页/isolate 所有者。摘要覆盖目的地、内容和资源版本。不信任应用传入的摘要、命令、回调或认证结果。 + +只有可见、前台的原生审阅页可以发起认证。关闭/替换、账户/草稿变更、超时、断开连接、撤回应用以及窗口销毁/复用都取消挑战。完成时再次检查全部绑定与当前接纳的操作,再原子消费 nonce。迟到或重复结果、修改后的草稿必须拒绝。提交时仍执行现有账户、权限和资源冲突检查。 + +状态机为 `Reviewing → Authenticating → Claimed → Finished`;取消/超时是终态。失败后只有生成新挑战才能返回 Reviewing。认证异步执行,取消永远不能授权写入。OS 弹窗仅使用宿主生成的简短说明,完整邮件正文和凭据不进入 polkit 参数或日志。 + +## Linux + +直接调用系统 polkit authority 的专用 `org.octosense.approve-business-action` action。受支持安装包携带 root 所有的策略:`allow_any=no`、`allow_inactive=no`、`allow_active=auth_self`,不保留授权。不执行特权命令/helper;这里批准的是同一用户的业务操作,不是特权系统操作。 + +非交互预检必须报告需要新的认证挑战,已经授权/缓存的结果不能通过。交互 `CheckAuthorization` 绑定当前进程 PID、启动时间、UID 与唯一取消 ID。保留/临时授权、取消、缺少 authority/agent/policy 或异常结果都应拒绝。所有者消失时取消检查。参见 [polkit 策略语义](https://polkit.pages.freedesktop.org/polkit/polkit.8.html)和 [authority 接口](https://polkit.pages.freedesktop.org/polkit/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html)。 + +系统管理员仍在信任边界内。AppImage/源码启动必须明确报告策略缺失,不能偷偷安装策略或退回合成审批。本任务没有授权在共享测试主机安装系统策略。 + +## Windows + +使用 `UserConsentVerifier.CheckAvailabilityAsync` 和桌面 `IUserConsentVerifierInterop::RequestVerificationForWindowAsync`,绑定准确的 HWND。只有 `Verified` 可以授权。取消、设备忙碌/未配置/禁用、API 不可用、窗口销毁与 HRESULT 错误均拒绝。OctoSense 接收验证结果,不接收 PIN 或生物识别数据。Microsoft 的[桌面用法](https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.ui.userconsentverifier?view=winrt-26100)指定此接口;[接口要求](https://learn.microsoft.com/en-us/windows/win32/api/userconsentverifierinterop/nf-userconsentverifierinterop-iuserconsentverifierinterop-requestverificationforwindowasync)列出的最低版本为 Windows build 22000。旧平台应显示不可用,除非另行实现并审阅适配器。 + +## 实施与验收 + +1. 在 `oauth-service` 增加仅供原生宿主使用的审批模块:不可变绑定、挑战/取消状态、没有公开构造器或序列化的不透明证据。Gmail、连接器和后端变更消费类型化证据,保持物理输入语义。 +2. 平台适配器放在该边界之后,只为活动且绑定的挑战生成证据,不直接执行写入。 +3. 扩展现有原生审阅控件的认证状态与可用性提示,复用完整内容审阅和提交线程,不增加应用可调用的审批 API。 +4. 在受支持安装包中包含 Linux action,检查策略与所有权;注册缺失时仍不可用。 +5. 测试账户/摘要/窗口变更、重放、迟到结果、取消、超时。真实原生负向测试须拒绝缺少 policy/Hello 的环境。随后用独立测试账户验证 OS 认证后的写入,并证明只有合成输入时不能提交。 + +登录、原生凭据库、普通嵌入浏览器、OS 认证审批和最终远程写入分别记录。编译、单元、Xvfb 和凭据存储通过不能证明用户审批。物理输入正向通过需要实际用户/设备证据。原生交互完成前,审批与业务写入验收仍未验证。 + +已实现边界、安装包前提和未验证的原生正向验收,见[桌面写入审批](../os-authenticated-approval.zh-CN.md)。 diff --git a/docs/adr/README.md b/docs/adr/README.md index d72d6b9b..79cf3a4f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ How these decisions fit together in the code on `main`, and which parts are stil | [0010](0010-shared-oauth-and-connected-apps.md) | Shared OAuth and independently installed connected apps | Implementation in progress; live sign-in passed on macOS; GitHub writes, Gmail sends and device acceptance pending | | [0011](0011-apps-own-functions-in-webassembly.md) | An app's own functions, in WebAssembly | Proposed; behind the `wasm-lab` feature, validated on macOS and an Android phone | | [0012](0012-app-host-api-discovery.md) | Discoverable host APIs, signed backend operations and live script tools | Implemented in source; contract 1.6.0 published; host release and phone acceptance pending | +| [0013](0013-os-authenticated-write-approval.md) | OS-authenticated approval for reviewed writes | Proposed implementation; native acceptance pending | ## Home (phone shell) decisions, 2026-09-16 to 2026-09-25 diff --git a/docs/adr/README.zh-CN.md b/docs/adr/README.zh-CN.md index 34478caf..f65491c4 100644 --- a/docs/adr/README.zh-CN.md +++ b/docs/adr/README.zh-CN.md @@ -19,6 +19,7 @@ OctoSense 仓库的决策:Shell、Shell 服务、系统应用,以及桌面 | [0010](0010-shared-oauth-and-connected-apps.zh-CN.md) | 共享 OAuth 与独立安装的连接账户应用 | 实现中;真实账户登录已在 macOS 上通过;GitHub 写入、Gmail 发信和设备验收待完成 | | [0011](0011-apps-own-functions-in-webassembly.zh-CN.md) | 应用自带的 WebAssembly 函数 | 提议中;在 `wasm-lab` 特性之后,已在 macOS 和一部 Android 手机上验证 | | [0012](0012-app-host-api-discovery.zh-CN.md) | 可发现宿主 API、签名后端操作与活动脚本工具 | 源码已实现;契约 1.6.0 已发布;宿主发布和手机验收待完成 | +| [0013](0013-os-authenticated-write-approval.zh-CN.md) | 由操作系统认证的写入审批 | 拟实施;原生验收待完成 | ## Home(手机 Shell)的决策,2026-09-16 至 2026-09-25 diff --git a/docs/os-authenticated-approval.md b/docs/os-authenticated-approval.md new file mode 100644 index 00000000..ebc54e03 --- /dev/null +++ b/docs/os-authenticated-approval.md @@ -0,0 +1,78 @@ +# Desktop approval of account writes + +English | [简体中文](os-authenticated-approval.zh-CN.md) + +Linux and Windows use a separate OS authentication step when the native review +asks to send Gmail, save a GitHub file or Calendar event, or execute a declared +backend mutation. This implements [ADR 0013](adr/0013-os-authenticated-write-approval.md). +Actual user authentication followed by a business write is **unverified** on both +platforms; a compiled adapter or a successful browser login is not that proof. + +The app prepares the change and opens the existing host-owned review. The person +checks its account, destination and complete content, then selects **Authenticate +& Send** or **Authenticate & Save**. The OS asks for authentication. OctoSense +receives a result, never a PIN, password or biometric. A synthetic click can open +the authentication request but cannot approve a write by itself. macOS and +Android keep their existing physical-input approval path. + +## Platform requirements + +On Linux, a running system polkit authority, an authentication agent in the user's +desktop session, and OctoSense's exact policy are required. The `.deb` packaging +configuration includes `desktop/resources/org.octosense.policy` at +`/usr/share/polkit-1/actions/org.octosense.policy`. It uses `auth_self`, without +retained authorization, for an active session and denies inactive/other sessions. +The host checks the file bytes, root ownership and absence of group/other write +permission. It rejects missing or changed policy, cached authorization, +cancellation and an unavailable agent. It never starts a privileged command. + +AppImage and source users need an administrator to install that identical policy +as a root-owned regular file with mode `0644` at the same system path. OctoSense +does not install it. This administrator installation and a successful polkit +dialog have **not been performed** on the shared acceptance machine. The package +mapping is tested; it does not prove that an installed desktop session can approve. + +On Windows, the desktop Windows Hello interface requires Windows build 22000 or +later and a configured, enabled verification method. OctoSense checks availability +before opening the prompt, binds it to the exact owning HWND and accepts only +the native `Verified` result. An older or unconfigured host reports unavailable; +it does not fall back to an ordinary confirmation button. + +## What the approval binds + +Only native Rust holds the one-shot proof. It binds the admitted bundle and grant, +connection and account generation, review ID, native window generation and sheet +owner. Its canonical operation digest includes the complete mutation, including +GitHub's expected file SHA and Calendar event IDs, etags and create IDs. The +authentication deadline is at most 120 seconds and cannot extend the original +review deadline. + +Cancellation, a changed draft/account/bundle, expiry, reused window or duplicate +result refuses the operation. The worker rechecks expiry and authorization after +acquiring its per-app execution lock; backend mutations recheck after token +refresh, immediately before their business request. Cancelling a review does not +undo a request that has already begun at the provider. + +The review checks its rendered bounds and native owner. Its pending checks run on +a timer or completion signal, not on every mouse movement. OS authentication can +take focus from the application, so focus loss alone is not evidence of approval +or grounds for treating synthetic input as trusted. + +## Acceptance boundaries + +The deterministic tests cover one-shot claims, late results, cancellation, changed +account/bundle/digest/window, original resource versions, and approval expiry or +revocation while queued behind another account operation. Linux native tests also +verify that the real adapter refuses to start when the required system policy is +absent. No system policy is installed for that test. + +The [native refusal receipt](../tools/connected-e2e/evidence/os-approval-native.json) records 97 passing Windows tests and the real OS result: Windows Hello was unavailable and no authentication was attempted. The opt-in availability test calls the actual OS API without requesting +verification. It records an availability result or API refusal; an `Available` +result is **not** authenticated approval. The Windows workflow runs this test and +the adapter's refusal/cancellation tests. + +Still **unverified**: a person completing the polkit or Windows Hello dialog, +successful execution of the exact reviewed synthetic business write, and physical +cancellation while that dialog is visible. Those checks need a suitably configured +desktop and a person. Browser login, credential-vault persistence and platform +input provenance have separate acceptance records. diff --git a/docs/os-authenticated-approval.zh-CN.md b/docs/os-authenticated-approval.zh-CN.md new file mode 100644 index 00000000..fd595245 --- /dev/null +++ b/docs/os-authenticated-approval.zh-CN.md @@ -0,0 +1,56 @@ +# 桌面账户写入审批 + +[English](os-authenticated-approval.md) | 简体中文 + +Linux 和 Windows 在原生审阅页发送 Gmail、保存 GitHub 文件或 Calendar 日程、执行已声明 +的后端变更时,增加独立的 OS 认证步骤,具体设计见 [ADR 0013](adr/0013-os-authenticated-write-approval.zh-CN.md)。 +这两个平台上,由用户实际完成认证再执行业务写入的正向验收仍**未经验证**。适配器编译或 +浏览器登录通过,不能替代这项证据。 + +应用准备修改并打开现有宿主原生审阅页。用户检查账户、目的地和完整内容,选择 +**Authenticate & Send(认证并发送)** 或 **Authenticate & Save(认证并保存)**,再完成 +OS 认证。OctoSense 只接收结果,不接收 PIN、密码或生物识别信息。合成点击可以发起认证 +请求,但自身不能批准写入。macOS 和 Android 保留现有的物理输入审批路径。 + +## 平台要求 + +Linux 需要系统 polkit authority、用户桌面会话中的认证 agent,以及完全匹配的 OctoSense +策略。`.deb` 打包配置将 `desktop/resources/org.octosense.policy` 放入 +`/usr/share/polkit-1/actions/org.octosense.policy`。活动会话使用不保留授权的 `auth_self`, +其他或非活动会话拒绝。宿主检查文件内容、root 所有权以及组/其他用户不可写;缺少或 +改变策略、缓存授权、取消、认证 agent 不可用均拒绝。它不启动特权命令。 + +AppImage 与源码用户需要管理员将完全相同的策略文件安装到该系统路径,所有者为 root, +普通文件权限为 `0644`。OctoSense 不执行这项安装。共享验收机**没有执行**管理员策略安装 +或成功的 polkit 对话框认证。打包映射已有测试,但这不能证明安装后的桌面会话能够批准。 + +Windows 桌面 Windows Hello 接口要求 build 22000 或更新版本,以及已配置并启用的验证方式。 +OctoSense 先检查可用性,再将提示绑定到准确的宿主 HWND;只接受原生 `Verified` 结果。 +旧系统或未配置的系统显示不可用,不退回普通确认按钮。 + +## 审批绑定内容 + +一次性证据仅由原生 Rust 持有,绑定已接纳的包与权限、连接与账户代次、审阅 ID、原生窗口 +代次和宿主页所有者。规范化操作摘要包含完整变更,包括 GitHub 预期文件 SHA、Calendar +事件 ID、etag 和创建 ID。认证期限最长 120 秒,不能延长原审阅期限。 + +取消、修改草稿/账户/包、超时、窗口复用或重复结果均拒绝。提交线程获得每应用执行锁后, +再次检查期限和授权;后端变更在令牌刷新后、业务请求前再次检查。取消审阅不会撤回已经 +开始执行的提供商请求。 + +审阅页检查绘制边界与原生所有者。待处理挑战仅在定时器或完成信号时检查,不在每次鼠标 +移动时重复检查。OS 认证可以从应用接管焦点,因此失去焦点既不是批准证据,也不能成为 +把合成输入视为可信输入的理由。 + +## 验收边界 + +确定性测试覆盖一次性声明、迟到结果、取消、账户/包/摘要/窗口变更、原资源版本,以及排队 +等待账户操作锁期间超时或撤销的审批。Linux 原生测试还验证实际适配器在缺少系统策略时 +不能启动审批;测试不安装系统策略。 + +[原生拒绝记录](../tools/connected-e2e/evidence/os-approval-native.json) 包含 97 项通过的 Windows 测试和真实 OS 结果:Windows Hello 不可用,未尝试身份验证。可选 Windows 可用性测试调用真实 OS API,但不请求身份验证,只记录可用性或 API 拒绝; +`Available` **不代表**认证审批通过。Windows 工作流执行该测试及适配器拒绝/取消测试。 + +仍**未经验证**:用户完成 polkit 或 Windows Hello 对话框、成功执行准确审阅的合成业务写入、 +在对话框显示时由用户取消。这些检查需要正确配置的桌面与实际用户。浏览器登录、凭据库 +持久化和平台输入来源验证分别记录验收结果。 diff --git a/tools/ci_local.py b/tools/ci_local.py index 23279605..ee443af2 100644 --- a/tools/ci_local.py +++ b/tools/ci_local.py @@ -71,6 +71,8 @@ # Workflows ci-local deliberately does not run, and why. --check-drift skips # them (an entry naming no workflow, or one ci-local runs, is drift). NOT_LOCAL = { + "os-approval.yml": "native Windows Hello availability needs Windows; local compile and mock results " + "do not substitute for the explicit non-authenticating Windows job", "platform-vault.yml": "actual Windows Credential Manager and backend browser acceptance need a Windows session; " "compilation or another OS is not native account evidence", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " diff --git a/tools/connected-e2e/evidence/os-approval-native.json b/tools/connected-e2e/evidence/os-approval-native.json new file mode 100644 index 00000000..5e3ffeda --- /dev/null +++ b/tools/connected-e2e/evidence/os-approval-native.json @@ -0,0 +1,36 @@ +{ + "schema": 1, + "case": "Native OS-authenticated approval refusal and availability", + "windows": { + "source_revision": "8b8a4a36", + "workflow_run": 37729789805, + "workflow_url": "https://github.com/OctoSense-org/OctoSense/actions/runs/37729789805", + "runner": "windows-2022", + "oauth_and_adapter_tests": { + "passed": 97, + "failed": 0, + "ignored": 2 + }, + "native_availability_probe": { + "passed": 1, + "api": "UserConsentVerifier.CheckAvailabilityAsync", + "result": 1, + "available": false, + "authentication_attempted": false + } + }, + "linux": { + "native_missing_policy_test": "approval::linux::tests::native_missing_policy_cannot_start_approval", + "passed": true, + "policy_installed": false, + "authentication_attempted": false, + "provider_write_attempted": false + }, + "not_verified": [ + "Human completion of a polkit or Windows Hello authentication dialog", + "OS-authenticated execution of the exact reviewed synthetic business write", + "Human cancellation while the native authentication dialog is visible", + "Administrator policy installation and a configured active Linux session" + ], + "scope": "Windows result is actual native API availability, not an authentication result; Linux missing-policy refusal does not invoke the authority or install system policy." +}