diff --git a/.github/workflows/native-activity.yml b/.github/workflows/native-activity.yml new file mode 100644 index 00000000..45221b6a --- /dev/null +++ b/.github/workflows/native-activity.yml @@ -0,0 +1,56 @@ +name: Native activity verification contract +on: + push: + branches: [native-activity-verification] + pull_request: + paths: + - 'crates/shell/src/native_activity.rs' + - 'tools/check_native_activity.py' + - '.github/workflows/native-activity.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + contract: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: rustup toolchain install 1.98.0 --profile minimal + - name: Test exact production verification contract + env: + RUSTUP_TOOLCHAIN: 1.98.0 + run: python tools/check_native_activity.py + - uses: actions/upload-artifact@v4 + with: + name: native-activity-contract-${{ matrix.os }} + path: target/native-activity/result.json + if-no-files-found: error + shell-packagings: + runs-on: macos-14 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - name: Prepare official pinned framework sources + run: python3 tools/setup.py + - uses: Swatinem/rust-cache@v2 + - name: Check desktop and Home against the same verification module + run: | + cargo check --locked -p octosense + cargo check --locked -p octosense --features mobile-apps + cargo check --locked -p octosense-home --features mobile-apps + cargo test --locked -p octosense-shell --features mobile-apps native_activity diff --git a/README.md b/README.md index 90180f09..4a888043 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ English | [简体中文](README.zh-CN.md) [Capabilities and execution boundaries](docs/capabilities.md) explains declaration-only APIs, consent, accounts and app isolation. +[Native activity open proposal](docs/adr/0015-native-activity-open.md) describes the proposed SDK verification seam; production routing is not connected. + **Desktop download:** [OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) supports the current App Hub catalog, GitHub-attested app releases and the public host APIs declared by SDK 1.10.0. Choose the asset for your operating system and architecture; the release notes record prerequisites, signing status and platform validation. This desktop release does not update Android Home. Run the [live Mail → card → Calendar demo](docs/demos/mail-calendar/README.md) with your own mailbox: pinned source, selective policy, fictional test inputs, human draft edits and Calendar navigation. diff --git a/README.zh-CN.md b/README.zh-CN.md index 78f78906..6b2b30c0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,8 @@ [能力声明与执行边界](docs/capabilities.zh-CN.md)说明声明式 API、用户同意、账户和应用隔离。 +[原生活动打开提案](docs/adr/0015-native-activity-open.zh-CN.md)说明拟议的 SDK 核验接口;生产路由尚未接线。 + **桌面版下载**:[OctoSense 0.1.0-rc.2](https://github.com/OctoSense-org/OctoSense/releases/tag/desktop-v0.1.0-rc.2) 支持当前的 App Hub 签名目录、带 GitHub 证明的应用 Release 及 SDK 1.10.0 声明的公开宿主 API。请选择与操作系统和架构对应的文件;运行前置条件、签名状态和各平台验收范围见发行说明。此桌面版本不会更新 Android Home。 用自己的邮箱[复现真实邮件 → 卡片 → Calendar 演示](docs/demos/mail-calendar/README.zh-CN.md):固定源码版本、重要邮件策略、虚构测试输入、人工修改草稿和日历跳转。 diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index cb9cb2f5..3539a7ad 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -57,6 +57,7 @@ pub mod kernel_port; pub mod layout; pub mod octosense; pub mod module_host; +pub mod native_activity; #[cfg(test)] mod module_close_tests; #[cfg(test)] diff --git a/crates/shell/src/native_activity.rs b/crates/shell/src/native_activity.rs new file mode 100644 index 00000000..a0a4f027 --- /dev/null +++ b/crates/shell/src/native_activity.rs @@ -0,0 +1,334 @@ +//! Host verification seam for native activity locators. No kernel or write grants. + +fn matrix_id(value: &str, sigil: char) -> bool { + value.len() <= 255 && !value.chars().any(|c| c.is_whitespace() || c.is_control()) + && value.strip_prefix(sigil).and_then(|rest| rest.split_once(':')) + .is_some_and(|(local, server)| !local.is_empty() && !server.is_empty() && !server.contains(['/', '?', '#', '\\'])) +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pointer { + pub version: u8, + pub app: String, + pub resource: String, + pub room: String, +} +impl Pointer { + fn check(&self) -> Result<(), String> { + let id = |s: &str| { + !s.is_empty() + && s.len() <= 128 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }; + if self.version != 1 + || !id(&self.app) + || !id(&self.resource) + || !matrix_id(&self.room, '!') + { + return Err("Unsupported activity locator".into()); + } + Ok(()) + } +} + +/// The native host obtains this from the SDK, never card JSON or script arguments. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Session { + pub account: String, + pub generation: u64, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Source { + pub room: String, + pub event: String, + pub sender: String, +} +#[derive(Clone, Debug)] +pub struct SourceEvent { + pub source: Source, + pub pointer: Pointer, +} + +/// Implemented by the host's verified SDK adapter and registered native apps. +/// `read_event` fetches the exact server event; `member` and `allows_share` +/// use current verified state. Call this on the host worker, not the UI thread. +pub trait ActivityBackend { + fn session(&self) -> Option; + fn read_event(&self, session: &Session, room: &str, event: &str) + -> Result; + fn member(&self, session: &Session, room: &str) -> bool; + fn registered(&self, app: &str) -> bool; + fn allows_share(&self, session: &Session, pointer: &Pointer, sender: &str) -> bool; + /// Display/focus only. Enrollment, room joins and sends remain separate. + fn open_or_focus(&mut self, key: &OpenKey) -> Result; +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Opened { + Created, + Focused, +} + +/// The focus key includes the verified account and session generation, so +/// a different account can never focus another account's resource view. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct OpenKey { + session: Session, + pointer: Pointer, +} +impl OpenKey { + pub fn session(&self) -> &Session { + &self.session + } + pub fn pointer(&self) -> &Pointer { + &self.pointer + } +} + +/// Private fields prevent deserializing an untrusted card into a verified open. +#[derive(Debug)] +pub struct VerifiedOpen { + session: Session, + pointer: Pointer, + source: Source, + until: u64, + issued_at: u64, +} + +fn verify( + b: &impl ActivityBackend, + session: &Session, + source: &Source, + pointer: &Pointer, +) -> Result<(), String> { + pointer.check()?; + if !matrix_id(&session.account, '@') || b.session().as_ref() != Some(session) { + return Err("Activity account changed".into()); + } + if source.event.len() < 2 + || source.event.len() > 255 + || !source.event.starts_with('$') + || source.event.chars().any(|c| c.is_whitespace() || c.is_control()) + || !matrix_id(&source.sender, '@') + || !matrix_id(&source.room, '!') + { + return Err("Invalid activity source".into()); + } + let actual = b.read_event(session, &source.room, &source.event)?; + if actual.source != *source || actual.pointer != *pointer { + return Err("Activity source or pointer differs".into()); + } + if !b.registered(&pointer.app) + || !b.member(session, &source.room) + || !b.member(session, &pointer.room) + || !b.allows_share(session, pointer, &actual.source.sender) + { + return Err("Activity source, membership or application unavailable".into()); + } + if b.session().as_ref() != Some(session) { + return Err("Activity account changed".into()); + } + Ok(()) +} + +pub fn prepare( + b: &impl ActivityBackend, + session: Session, + source: Source, + pointer: Pointer, + now: u64, +) -> Result { + verify(b, &session, &source, &pointer)?; + Ok(VerifiedOpen { + session, + source, + pointer, + until: now.checked_add(120).ok_or("Invalid activity clock")?, + issued_at: now, + }) +} + +/// Rechecks queued work against the current account, source and membership. +/// The host adapter queues only display/focus for its native UI after this. +pub fn complete( + b: &mut impl ActivityBackend, + open: VerifiedOpen, + now: u64, +) -> Result { + if now < open.issued_at || now >= open.until { + return Err("Activity open expired".into()); + } + verify(b, &open.session, &open.source, &open.pointer)?; + b.open_or_focus(&OpenKey { + session: open.session, + pointer: open.pointer, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + #[test] + fn malformed_source_and_empty_session_are_refused() { + for account in ["", "@", "@:example.org", "@person:"] { + let mut h = host(); h.session.account = account.into(); + assert!(pending(&h).is_err()); assert_eq!(h.opens, 0); + } + for sender in ["@", "@:example.org", "@person:"] { + let mut h = host(); h.event.source.sender = sender.into(); + assert!(pending(&h).is_err()); assert_eq!(h.opens, 0); + } + for room in ["!", "!:example.org", "!room:", "!room:example.org/path"] { + let mut h = host(); h.event.source.room = room.into(); + assert!(pending(&h).is_err()); assert_eq!(h.opens, 0); + } + let mut h = host(); h.event.source.event = "$".into(); + assert!(pending(&h).is_err()); assert_eq!(h.opens, 0); + } + #[test] + fn clock_rollback_cannot_extend_a_queued_open() { + let mut h = host(); let open = pending(&h).unwrap(); + assert!(complete(&mut h, open, 99).is_err()); assert_eq!(h.opens, 0); + } + struct Host { + session: Session, + event: SourceEvent, + joined: bool, + authorized: bool, + seen: BTreeSet, + opens: usize, + } + impl ActivityBackend for Host { + fn session(&self) -> Option { + Some(self.session.clone()) + } + fn read_event(&self, _: &Session, _: &str, _: &str) -> Result { + Ok(self.event.clone()) + } + fn member(&self, _: &Session, _: &str) -> bool { + self.joined + } + fn registered(&self, app: &str) -> bool { + app == "org.example.bookclub" + } + fn allows_share(&self, _: &Session, _: &Pointer, _: &str) -> bool { + self.authorized + } + fn open_or_focus(&mut self, key: &OpenKey) -> Result { + self.opens += 1; + Ok(if self.seen.insert(key.clone()) { + Opened::Created + } else { + Opened::Focused + }) + } + } + fn host() -> Host { + Host { + session: Session { + account: "@member:example.org".into(), + generation: 1, + }, + event: SourceEvent { + source: Source { + room: "!chat:example.org".into(), + event: "$server-event".into(), + sender: "@owner:example.org".into(), + }, + pointer: Pointer { + version: 1, + app: "org.example.bookclub".into(), + resource: "reading-42".into(), + room: "!activity:example.org".into(), + }, + }, + joined: true, + authorized: true, + seen: BTreeSet::new(), + opens: 0, + } + } + fn pending(h: &Host) -> Result { + prepare( + h, + h.session.clone(), + h.event.source.clone(), + h.event.pointer.clone(), + 100, + ) + } + #[test] + fn repeated_card_open_focuses_same_resource() { + let mut h = host(); + let o = pending(&h).unwrap(); + assert_eq!(complete(&mut h, o, 101).unwrap(), Opened::Created); + let o = pending(&h).unwrap(); + assert_eq!(complete(&mut h, o, 102).unwrap(), Opened::Focused); + assert_eq!(h.seen.len(), 1) + } + #[test] + fn different_accounts_get_separate_views() { + let mut h = host(); + let o = pending(&h).unwrap(); + assert_eq!(complete(&mut h, o, 101).unwrap(), Opened::Created); + h.session.account = "@other:example.org".into(); + h.session.generation += 1; + let o = pending(&h).unwrap(); + assert_eq!(complete(&mut h, o, 102).unwrap(), Opened::Created); + assert_eq!(h.seen.len(), 2); + } + #[test] + fn forged_source_and_room_cannot_open() { + let h = host(); + let mut s = h.event.source.clone(); + s.sender = "@forged:example.org".into(); + assert!(prepare(&h, h.session.clone(), s, h.event.pointer.clone(), 100).is_err()); + let mut p = h.event.pointer.clone(); + p.room = "!wrong:example.org".into(); + assert!(prepare(&h, h.session.clone(), h.event.source.clone(), p, 100).is_err()); + assert_eq!(h.opens, 0) + } + #[test] + fn unknown_version_cannot_open() { + let mut h = host(); + h.event.pointer.version = 2; + assert!(pending(&h).is_err()); + assert_eq!(h.opens, 0) + } + #[test] + fn account_switch_invalidates_queued_open() { + let mut h = host(); + let o = pending(&h).unwrap(); + h.session.account = "@other:example.org".into(); + assert!(complete(&mut h, o, 101).is_err()); + assert_eq!(h.opens, 0) + } + #[test] + fn same_account_relogin_invalidates_generation() { + let mut h = host(); + let o = pending(&h).unwrap(); + h.session.generation += 1; + assert!(complete(&mut h, o, 101).is_err()); + assert_eq!(h.opens, 0) + } + #[test] + fn membership_and_authority_are_rechecked() { + let mut h = host(); + let o = pending(&h).unwrap(); + h.joined = false; + assert!(complete(&mut h, o, 101).is_err()); + h.joined = true; + let o = pending(&h).unwrap(); + h.authorized = false; + assert!(complete(&mut h, o, 101).is_err()); + assert_eq!(h.opens, 0) + } + #[test] + fn expired_open_has_no_effect() { + let mut h = host(); + let o = pending(&h).unwrap(); + assert!(complete(&mut h, o, 220).is_err()); + assert_eq!(h.opens, 0) + } +} diff --git a/docs/adr/0015-native-activity-open.md b/docs/adr/0015-native-activity-open.md new file mode 100644 index 00000000..05c38319 --- /dev/null +++ b/docs/adr/0015-native-activity-open.md @@ -0,0 +1,38 @@ +# Native activity verification and open proposal + +English | [简体中文](0015-native-activity-open.zh-CN.md) + +Status: proposed verification seam. Rinx [PR 89](https://github.com/hagency-org/Rinx/pull/89) +supplies a generic locator and SDK source context. This does not install BuWei, +enable an agent or grant membership. It does not change external pins. + +`crates/shell/src/native_activity.rs` checks a host-issued SDK account/generation, +re-fetches the source event, compares room, event, sender and pointer, checks +current source/target membership, then asks the registered application's +authoritative resource adapter whether that sender may share the resource. +Queued work is checked again before display. Expiry is two minutes. Focus keys +include account, generation, application, room and resource; repeated cards +focus the same view without mixing accounts. + +The host implements `ActivityBackend` on its worker using the Rinx SDK and native +application registry. `open_or_focus` schedules display/focus through the +existing `ModuleHost`, using only the verified key. It must not join rooms, +enroll, send messages or issue a lease. An existing module receives navigation +through `ModuleHost::send_custom`; the module validates its authoritative +resource again. A new module uses its registered `OpenSchema`. SDK identity +is assigned outside deserialized open arguments. There is no provider key, +kernel protocol, callback URL or arbitrary process command in this interface. + +BuWei is the motivating example: `app=buwei`, opaque resource=activity ID, +target=activity room. The app checks its verified activity owner and state +before presenting enrollment; actual enrollment remains separately confirmed +through its prepare/confirm/execute/reconcile receipt path. Its existing v2 +activity event protocol remains compatible until the generic carrier is adopted. + +Validation: the portable seam tests were compiled with Rust 1.98 on Windows +GNU. They cover forged source, wrong room, unknown version, account switch, +same-account login generation change, revoked membership/authority, expiry, +repeat focus and separate account views. These use a fake SDK adapter, not real +network identity evidence. Full shell desktop/phone compilation and a concrete +Rinx SDK/ModuleHost adapter are unverified. Production routing stays unwired; +the draft requests review of this boundary before an external pin update. diff --git a/docs/adr/0015-native-activity-open.zh-CN.md b/docs/adr/0015-native-activity-open.zh-CN.md new file mode 100644 index 00000000..ec5c1947 --- /dev/null +++ b/docs/adr/0015-native-activity-open.zh-CN.md @@ -0,0 +1,14 @@ +# 原生活动来源核验与打开提案 + +[English](0015-native-activity-open.md) | 简体中文 + +状态:拟议的核验接口。Rinx [PR 89](https://github.com/hagency-org/Rinx/pull/89) +提供通用定位卡片和 SDK 来源上下文。本提案不安装补位、不启用 Agent、不授予成员资格,也不修改外部依赖提交号。 + +`crates/shell/src/native_activity.rs` 绑定宿主取得的 SDK 账号与会话代次,重新读取来源事件,核对房间、事件、发送者和定位信息,检查当前来源与目标房间成员资格,再由已注册应用的权威资源适配器判断发送者能否分享该资源。排队后打开前再次核验,准备结果两分钟过期。聚焦键包含账号、代次、应用、房间与资源,重复点击聚焦同一视图,不同账号独立。 + +宿主在工作线程实现 `ActivityBackend`,读取 Rinx SDK 与原生应用注册表。`open_or_focus` 仅通过已有 `ModuleHost` 排队显示或聚焦经过核验的键,不加入房间、不报名、不发送、不签发授权。已有模块用 `ModuleHost::send_custom` 接收导航,重新检查权威资源;新模块使用注册的 `OpenSchema`。SDK 身份不来自反序列化的打开参数。接口不携带模型密钥、内核协议、回调 URL 或任意进程命令。 + +补位接入示例:`app=buwei`,资源为不透明活动编号,目标为活动房间。补位先核对活动组织者和权威状态,再显示报名;实际报名仍分别确认并进入既有执行与回执流程。采用通用载体前,现有 v2 活动协议保持兼容。 + +验证:便携接口测试已用 Rust 1.98 在 Windows GNU 编译运行,覆盖伪造来源、错误房间、未知版本、账号切换、同账号重新登录、成员与分享资格撤销、过期、重复聚焦和账号视图隔离。测试使用模拟 SDK 适配器,不能代替真实身份联调。桌面与手机完整 Shell 编译,以及具体 Rinx SDK/ModuleHost 适配器仍未验证。生产路由保持未接线,草稿先请求接口边界评审,再决定外部依赖更新。 diff --git a/tools/check_native_activity.py b/tools/check_native_activity.py new file mode 100644 index 00000000..7179bd43 --- /dev/null +++ b/tools/check_native_activity.py @@ -0,0 +1,25 @@ +"""Test the exact portable host contract; the SDK adapter is scripted here.""" +import hashlib +import json +import os +import pathlib +import platform +import re +import subprocess +import sys + +root = pathlib.Path(__file__).resolve().parents[1] +source = root / "crates/shell/src/native_activity.rs" +target = pathlib.Path(os.environ.get("CARGO_TARGET_DIR", root / "target")) / "native-activity" +target.mkdir(parents=True, exist_ok=True) +binary = target / ("native-activity-tests.exe" if sys.platform == "win32" else "native-activity-tests") +flags = [value for value in os.environ.get("CARGO_ENCODED_RUSTFLAGS", "").split(chr(31)) if value] +subprocess.run(["rustc", "--edition=2024", "--test", str(source), "-o", str(binary), *flags], check=True) +result = subprocess.run([str(binary)], text=True, capture_output=True, encoding="utf-8") +print(result.stdout) +result.check_returncode() +match = re.search(r"test result: ok\. (\d+) passed; 0 failed;", result.stdout) +if not match: raise RuntimeError("Complete passing contract result missing") +report = dict(passed=True, scope="native activity contract with scripted SDK backend", tests_passed=int(match[1]), source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), source_commit=subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(), os=platform.system(), live_sdk_verified=False, desktop_integration_verified=False) +(target / "result.json").write_text(json.dumps(report, indent=2)+"\n", encoding="utf-8") +print(json.dumps(report))