From cfea841c5cb4955b6c7447de5001da32bdeeb3d0 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:49:39 -0700 Subject: [PATCH 1/6] docs: propose OS-authenticated approval for desktop writes --- .../0013-os-authenticated-write-approval.md | 43 +++++++++++++++++++ ...3-os-authenticated-write-approval.zh-CN.md | 43 +++++++++++++++++++ docs/adr/README.md | 1 + docs/adr/README.zh-CN.md | 1 + 4 files changed, 88 insertions(+) create mode 100644 docs/adr/0013-os-authenticated-write-approval.md create mode 100644 docs/adr/0013-os-authenticated-write-approval.zh-CN.md 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..eff34395 --- /dev/null +++ b/docs/adr/0013-os-authenticated-write-approval.md @@ -0,0 +1,43 @@ +# ADR 0013: OS-authenticated approval for reviewed writes + +English | [简体中文](0013-os-authenticated-write-approval.zh-CN.md) + +Status: proposed implementation; native 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. 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..666ac3ea --- /dev/null +++ b/docs/adr/0013-os-authenticated-write-approval.zh-CN.md @@ -0,0 +1,43 @@ +# ADR 0013:由操作系统认证的写入审批 + +[English](0013-os-authenticated-write-approval.md) | 简体中文 + +状态:拟实施,原生验收待完成。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 和凭据存储通过不能证明用户审批。物理输入正向通过需要实际用户/设备证据。原生交互完成前,审批与业务写入验收仍未验证。 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 From 6087a5200a2b6312274902befcc78781cfd2b043 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:39:10 -0700 Subject: [PATCH 2/6] feat(auth): add owner-window Windows Hello review adapter --- crates/oauth-service/src/approval/windows.rs | 426 +++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 crates/oauth-service/src/approval/windows.rs diff --git a/crates/oauth-service/src/approval/windows.rs b/crates/oauth-service/src/approval/windows.rs new file mode 100644 index 00000000..b92d0358 --- /dev/null +++ b/crates/oauth-service/src/approval/windows.rs @@ -0,0 +1,426 @@ +//! 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 + .GetResults() + .map_err(|_| "Windows Hello availability returned no valid result".to_owned()) + .and_then(availability_result); + 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 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()); + } + + #[test] + fn missing_owned_ui_window_is_refused() { + assert!(owned_handle(WindowId(0, 0)).is_err()); + } +} From 85cd48f97f4b06cb007a1036865d6eb099901e9e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:46:00 -0700 Subject: [PATCH 3/6] test(auth): probe native Windows Hello availability without prompting --- crates/oauth-service/src/approval/windows.rs | 81 +++++++++++++++++++- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/oauth-service/src/approval/windows.rs b/crates/oauth-service/src/approval/windows.rs index b92d0358..0c4e3720 100644 --- a/crates/oauth-service/src/approval/windows.rs +++ b/crates/oauth-service/src/approval/windows.rs @@ -245,10 +245,7 @@ pub(super) fn pump(challenge_id: &str) { return; } } - let available = availability - .GetResults() - .map_err(|_| "Windows Hello availability returned no valid result".to_owned()) - .and_then(availability_result); + let available = availability_query_result(availability.GetResults()); if let Err(error) = available { request.completion.finish(Err(error)); return; @@ -281,6 +278,14 @@ fn availability_result(result: UserConsentVerifierAvailability) -> Result<(), St } } +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> { @@ -419,6 +424,74 @@ mod tests { 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()); From 4e9bf4a122696b8b304d256b77fe3e8910d867d4 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:52:05 -0700 Subject: [PATCH 4/6] feat(auth): bind desktop writes to fresh OS-authenticated review --- .github/workflows/os-approval.yml | 37 ++ Cargo.lock | 3 + crates/oauth-service/Cargo.toml | 5 +- crates/oauth-service/README.md | 5 + crates/oauth-service/README.zh-CN.md | 4 + crates/oauth-service/src/approval/linux.rs | 253 ++++++++ crates/oauth-service/src/approval/mod.rs | 557 ++++++++++++++++++ crates/oauth-service/src/host.rs | 66 ++- crates/oauth-service/src/host_api.rs | 135 ++++- crates/oauth-service/src/host_backend.rs | 14 +- crates/oauth-service/src/host_inbox.rs | 71 ++- crates/oauth-service/src/inbox.rs | 36 +- crates/oauth-service/src/lib.rs | 2 + crates/shell/src/connected_review.rs | 158 ++++- desktop/packaging/release.json | 2 +- desktop/resources/org.octosense.policy | 16 + desktop/scripts/package.py | 6 + desktop/scripts/test_package.py | 8 + .../0013-os-authenticated-write-approval.md | 5 +- ...3-os-authenticated-write-approval.zh-CN.md | 4 +- docs/os-authenticated-approval.md | 78 +++ docs/os-authenticated-approval.zh-CN.md | 56 ++ tools/ci_local.py | 2 + 23 files changed, 1507 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/os-approval.yml create mode 100644 crates/oauth-service/src/approval/linux.rs create mode 100644 crates/oauth-service/src/approval/mod.rs create mode 100644 desktop/resources/org.octosense.policy create mode 100644 docs/os-authenticated-approval.md create mode 100644 docs/os-authenticated-approval.zh-CN.md 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 f4c8f3a7..cebec3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8129,6 +8129,7 @@ dependencies = [ "base64 0.22.1", "chrono", "chrono-tz", + "dbus", "keyring", "makepad-widgets", "oauth2", @@ -8140,6 +8141,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..804b9419 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 diff --git a/crates/oauth-service/README.zh-CN.md b/crates/oauth-service/README.zh-CN.md index 18ff515b..4cd66643 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 原生宿主与 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/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_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 index eff34395..fd76930c 100644 --- a/docs/adr/0013-os-authenticated-write-approval.md +++ b/docs/adr/0013-os-authenticated-write-approval.md @@ -2,7 +2,7 @@ English | [简体中文](0013-os-authenticated-write-approval.zh-CN.md) -Status: proposed implementation; native acceptance pending. Linux/Windows physical-pointer provenance remains unsupported. Browser and vault tests do not establish write approval. +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 @@ -41,3 +41,6 @@ Use `UserConsentVerifier.CheckAvailabilityAsync` and desktop `IUserConsentVerifi 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 index 666ac3ea..26476160 100644 --- a/docs/adr/0013-os-authenticated-write-approval.zh-CN.md +++ b/docs/adr/0013-os-authenticated-write-approval.zh-CN.md @@ -2,7 +2,7 @@ [English](0013-os-authenticated-write-approval.md) | 简体中文 -状态:拟实施,原生验收待完成。Linux/Windows 的物理指针来源验证仍不受支持。浏览器和凭据库测试不能证明写入审批可用。 +状态:实现审阅中,OS 认证用户验收待完成。Linux/Windows 的物理指针来源验证仍不受支持。浏览器和凭据库测试不能证明写入审批可用。 ## 问题与决定 @@ -41,3 +41,5 @@ 5. 测试账户/摘要/窗口变更、重放、迟到结果、取消、超时。真实原生负向测试须拒绝缺少 policy/Hello 的环境。随后用独立测试账户验证 OS 认证后的写入,并证明只有合成输入时不能提交。 登录、原生凭据库、普通嵌入浏览器、OS 认证审批和最终远程写入分别记录。编译、单元、Xvfb 和凭据存储通过不能证明用户审批。物理输入正向通过需要实际用户/设备证据。原生交互完成前,审批与业务写入验收仍未验证。 + +已实现边界、安装包前提和未验证的原生正向验收,见[桌面写入审批](../os-authenticated-approval.zh-CN.md)。 diff --git a/docs/os-authenticated-approval.md b/docs/os-authenticated-approval.md new file mode 100644 index 00000000..c1e14ae9 --- /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 opt-in Windows 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..56286358 --- /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 原生测试还验证实际适配器在缺少系统策略时 +不能启动审批;测试不安装系统策略。 + +可选 Windows 可用性测试调用真实 OS API,但不请求身份验证,只记录可用性或 API 拒绝; +`Available` **不代表**认证审批通过。Windows 工作流执行该测试及适配器拒绝/取消测试。 + +仍**未经验证**:用户完成 polkit 或 Windows Hello 对话框、成功执行准确审阅的合成业务写入、 +在对话框显示时由用户取消。这些检查需要正确配置的桌面与实际用户。浏览器登录、凭据库 +持久化和平台输入来源验证分别记录验收结果。 diff --git a/tools/ci_local.py b/tools/ci_local.py index e0a3371d..360c8a9c 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", "embedded-browser.yml": "native Windows WebView2 acceptance needs a Windows desktop and installed engine; " "ci-local-merge requires a successful GitHub run on the exact PR head", "release-desktop.yml": "a release workflow (tag push or manual run): signed packages for three OSes, " From 8b8a4a36d3116e18cc94761c8bbef46752daad4b Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:54:48 -0700 Subject: [PATCH 5/6] fix(auth): discover desktop reviews with explicit OS approval requirements --- crates/oauth-service/README.md | 20 +++++++------- crates/oauth-service/README.zh-CN.md | 13 ++++----- crates/oauth-service/src/host_catalog.rs | 34 ++++++++---------------- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/crates/oauth-service/README.md b/crates/oauth-service/README.md index 804b9419..d4f24b08 100644 --- a/crates/oauth-service/README.md +++ b/crates/oauth-service/README.md @@ -44,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 @@ -200,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 @@ -232,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, @@ -337,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 4cd66643..dfe90978 100644 --- a/crates/oauth-service/README.zh-CN.md +++ b/crates/oauth-service/README.zh-CN.md @@ -36,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`)的发布版本; @@ -156,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 请求一旦开始,取消不能承诺撤销服务端结果,错误也不会自动重试。 @@ -176,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 回环仅在非默认 验收构建中通过显式隔离注册开放,不是发行版本的配置开关。参阅 @@ -255,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/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; } From ad66a4c9a00e9426b917fa9e575068f66497ebc5 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:06:47 -0700 Subject: [PATCH 6/6] docs: record native Windows approval refusal evidence --- docs/os-authenticated-approval.md | 2 +- docs/os-authenticated-approval.zh-CN.md | 2 +- .../evidence/os-approval-native.json | 36 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tools/connected-e2e/evidence/os-approval-native.json diff --git a/docs/os-authenticated-approval.md b/docs/os-authenticated-approval.md index c1e14ae9..ebc54e03 100644 --- a/docs/os-authenticated-approval.md +++ b/docs/os-authenticated-approval.md @@ -66,7 +66,7 @@ revocation while queued behind another account operation. Linux native tests als verify that the real adapter refuses to start when the required system policy is absent. No system policy is installed for that test. -The opt-in Windows availability test calls the actual OS API without requesting +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. diff --git a/docs/os-authenticated-approval.zh-CN.md b/docs/os-authenticated-approval.zh-CN.md index 56286358..fd595245 100644 --- a/docs/os-authenticated-approval.zh-CN.md +++ b/docs/os-authenticated-approval.zh-CN.md @@ -48,7 +48,7 @@ OctoSense 先检查可用性,再将提示绑定到准确的宿主 HWND;只 等待账户操作锁期间超时或撤销的审批。Linux 原生测试还验证实际适配器在缺少系统策略时 不能启动审批;测试不安装系统策略。 -可选 Windows 可用性测试调用真实 OS API,但不请求身份验证,只记录可用性或 API 拒绝; +[原生拒绝记录](../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/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." +}