From ffebabd81a64916f550f542b39a45e6d7e151233 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 09:32:48 +0800 Subject: [PATCH 01/10] docs(distribution): define package signing trust Accept ADR-0005 to fix the detached DSSE and Ed25519 envelope, host-owned publisher trust, independent permission decisions, anti-rollback state, and explicit rollback semantics before implementation. Refs: PP-M8, PP-G2, PP-G3, PP-G10, NFR-SEC-02, NFR-LEGAL-01, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check Unverified: cryptographic implementation, trust persistence, install/upgrade integration, Windows/macOS/Linux behavior, license gate --- docs/README.md | 1 + .../0005-package-signing-and-trust.md | 114 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/risks.md | 8 ++ docs/plugin-sdk/manifest-v1.md | 4 +- docs/product/plugin-platform-roadmap.md | 2 +- 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/decisions/0005-package-signing-and-trust.md diff --git a/docs/README.md b/docs/README.md index 263e37e..a243972 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ | 技术选型与版本 | `architecture/technology-stack.md` | 引入/替换关键工具或版本策略变化 | | 不可逆决策 | `architecture/decisions/` | 兼容性、安全、持久化、许可或平台承诺变化 | | 风险与假设 | `architecture/risks.md` | 发现新风险或获得验证结论 | +| 分发与信任 | `architecture/decisions/0005-package-signing-and-trust.md`、`plugin-sdk/manifest-v1.md` | 签名格式、publisher trust、撤销、更新或回滚策略变化 | | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | | 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | diff --git a/docs/architecture/decisions/0005-package-signing-and-trust.md b/docs/architecture/decisions/0005-package-signing-and-trust.md new file mode 100644 index 0000000..44d1154 --- /dev/null +++ b/docs/architecture/decisions/0005-package-signing-and-trust.md @@ -0,0 +1,114 @@ +# ADR-0005:插件包签名、发布者信任与更新防降级 + +> 状态:Accepted +> 日期:2026-08-28 +> 决策者:Floatile 项目 + +## 背景与需求 + +PP-M8 要求安装与升级能够解释来源、发布者、权限变化和失败原因,并覆盖篡改、降级、撤销与回滚。 +现有 `.floatile` 校验和 `content_digest` 能发现安装前后内容变化,但不能证明发布者身份,也不能阻止 +攻击者用签名正确的旧版本替换新版本。签名格式、摘要文件集合和信任锚一旦发布就形成长期兼容承诺, +必须先于实现固定边界。 + +本 ADR 不解除 NFR-LEGAL-01:许可 ADR 通过前仍不得对外分发宿主、SDK 或插件包。 + +## 候选方案 + +### A. manifest 内嵌裸 Ed25519 签名 + +拒绝。签名字段会造成自引用摘要问题;裸签名没有 payload type 域分离,容易被跨协议误用;对密钥轮换 +和多签名也缺少清晰扩展边界。 + +### B. detached DSSE envelope + Ed25519 + +采用。DSSE 的 PAE 将 payload type 与 payload bytes 一起认证,不依赖 JSON canonicalization;Ed25519 +使用固定 32 字节公钥和 64 字节签名。`signature.json` 只承载 envelope,不参与被签名内容摘要。 + +参考规范: + +- RFC 8032: +- DSSE protocol: +- TUF rollback guidance: + +### C. 直接采用完整 TUF/Sigstore 在线基础设施 + +当前不采用。它们适合仓库元数据、透明日志和在线发布,但会把网络、PKI、账户和运营体系同时带入首个 +本地验证切片。格式保留未来把 trusted key 来源升级为 TUF/Sigstore 的空间,不在包校验器中硬编码服务。 + +## 决策 + +### 1. 签名文件与摘要集合 + +- 包内签名文件固定为根路径 `signature.json`;manifest v1 不增加 `signature` 字段。 +- `content_digest` 的签名输入是通过全部包安全校验的规范普通文件集合,唯一排除 + `signature.json`。任何其他文件的增删、重命名或字节变化都改变摘要。 +- 安装完整性摘要继续覆盖落盘的全部普通文件,包括 `signature.json`;签名摘要与安装摘要是两个不同 + 目的的值,不得混用。 +- 签名必须在解析 manifest、UI IR 或 WASM 之前可独立验证,但 zip 路径、大小、重复和压缩预算必须先 + 通过,避免签名解析绕过不受信输入预算。 + +### 2. Envelope v1 + +`signature.json` 使用有界 DSSE JSON envelope: + +```json +{ + "payloadType": "application/vnd.floatile.package-digest.v1", + "payload": "", + "signatures": [ + { "keyid": "", "sig": "" } + ] +} +``` + +- verifier 必须严格要求上述 payload type、32 字节 payload、至少一个且有上限的 signature;未知字段 + 默认拒绝,格式升级使用新的 payload type。 +- Ed25519 签名对象是 DSSE PAE bytes,不是 payload、base64 文本或 JSON envelope。 +- `keyid` 只是候选密钥查找提示,不是身份或授权事实;它必须等于公钥原始 32 字节的 SHA-256 hex, + 但只有宿主信任存储中的公钥可以建立 trust。 +- v1 只接受 Ed25519;不从不受信 envelope 读取或协商 `alg`。 + +### 3. 发布者与信任 + +- manifest `publisher.id` 是被摘要覆盖的声明,不是自证身份。 +- 宿主信任存储维护 `publisher id → trusted public keys + state`;包内公钥、证书或 keyid 不得自动成为 + trust anchor。 +- 至少一个有效签名必须来自该 publisher 当前 trusted key,且 manifest publisher 与 trust binding + 精确匹配,才能得到 `trusted`。 +- trust outcome 使用 `unsigned | untrusted | trusted | revoked` 显式表达。开发策略可以允许 + `unsigned/untrusted` 安装,但必须标记来源并禁止静默提升为生产信任;分发策略默认拒绝。 +- 密钥轮换由宿主信任存储更新,或由旧、新 trusted key 的重叠签名证明;仅由新 key 自签不能轮换。 + +### 4. 权限、升级与回滚 + +- 签名只证明完整性和受信发布者,不授予 capability。Permission Broker 和用户 grant 继续独立、 + deny-by-default。 +- 更新计划必须比较精确插件 id、publisher、semver、内容摘要、engine/UI 兼容性和权限集合。 +- 新增 capability、扩大 scope/配额或改变 Connection 绑定必须重新确认;降权可以自动接受但要审计。 +- 宿主按 `publisher + plugin id` 记录最高已接受版本及摘要。较低版本、同版本不同摘要、已撤销 key 或 + publisher 改变默认拒绝。 +- 显式回滚必须引用已验证的历史 Installation、记录原因并保持数据 migration 兼容;回滚不是删除 + anti-rollback 状态,也不能让旧权限重新扩大。 + +### 5. 资源与失败语义 + +- envelope、字符串、签名数量和 base64 解码结果都有硬上限;解析失败不得回显签名字节、公钥或宿主 + trust-store 路径。 +- 稳定错误至少区分:missing、malformed、unsupported payload、digest mismatch、unknown key、 + invalid signature、publisher mismatch、revoked、rollback、permission confirmation required。 +- 任一失败必须在安装目录原子落盘前发生,并证明零残留;校验失败不能影响已安装版本。 + +## 后果 + +收益:签名无 JSON canonicalization、自引用或算法降级;publisher identity、权限和升级策略不会被一个 +“签名有效”布尔值混淆;未来可以替换 trust-key 配送方式而不改变包内容签名。 + +代价:需要新的纯领域 trust/signature 类型、Ed25519 依赖、host-owned trust store、anti-rollback +持久化和升级状态机。完整 TUF/Sigstore、在线撤销和透明日志留给后续 ADR/切片。 + +## 验证要求 + +首批实现必须覆盖已知 Ed25519 向量、DSSE PAE 域分离、签名/摘要/路径篡改、未知 key、publisher +错配、撤销、签名数量/大小预算、同版本不同摘要和降级拒绝。CLI 安装集成必须额外证明失败零残留与 +既有 unsigned dev policy 的显式结果。 diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 52882c3..40389ec 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -13,6 +13,7 @@ Wayland 产品承诺、许可与分发方式。普通实现细节不需要 ADR | [ADR-0002](0002-runtime-plugin-ui-rendering.md) | Implemented | 运行时第三方插件 UI 用 `slint-interpreter` 编译 renderer 生成的源码 | | [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | 当前 StarlingMonkey 资源门失败、QuickJS 契约门失败;暂不发布 TypeScript SDK | | [ADR-0004](0004-brokered-async-operations.md) | Implemented | 长任务采用 Broker 化 Operation ID、元数据完成信号与 capability-specific typed result;v1.1 已接入首个 `storage:read` typed contract | +| [ADR-0005](0005-package-signing-and-trust.md) | Accepted | detached DSSE/Ed25519 签名规范;宿主持有 publisher trust;权限授权与 anti-rollback 独立强制 | 文件名使用 `NNNN-short-title.md`,编号递增。Accepted ADR 不改写结论;需要改变时新增 ADR,并在 旧文件标记 `Superseded by ADR-NNNN`。 diff --git a/docs/architecture/risks.md b/docs/architecture/risks.md index 7f13432..3a8cbd7 100644 --- a/docs/architecture/risks.md +++ b/docs/architecture/risks.md @@ -108,6 +108,14 @@ 组件按 minor,破坏语义按 major;Custom UI 必须新 ADR。 - P0 验证:至少 clock、system monitor、countdown 三种内部 fixture 证明模型,不用预留未验证控件。 +### R17. 包签名与发布者信任混淆 — 高 +- 攻击者可能利用包内自带 key、`keyid`、合法旧版本或同版本替换包绕过来源判断;宿主也可能把 + “签名有效”错误等同于 capability 授权,造成静默提权或降级攻击。 +- 缓解:按 ADR-0005 使用 detached DSSE/Ed25519;信任锚只来自宿主持有的 publisher trust store; + Permission Broker 授权独立;持久化最高已接受版本/摘要并显式处理撤销、权限 diff 和回滚。 +- P0 验证:覆盖摘要、签名、路径和 publisher 篡改,未知/撤销 key、同版本不同摘要、降级、显式回滚、 + 权限扩大确认及失败零残留;在线撤销和透明日志在引入前另行 ADR。 + ## 2. 依赖选型 | 领域 | 选型 | 理由 | 风险备注 | diff --git a/docs/plugin-sdk/manifest-v1.md b/docs/plugin-sdk/manifest-v1.md index a977a14..6789ac3 100644 --- a/docs/plugin-sdk/manifest-v1.md +++ b/docs/plugin-sdk/manifest-v1.md @@ -92,7 +92,7 @@ manifest 是安装与运行时的显式事实,不是开发者主要编辑界 | `config.schema` | 否 | 包内规范路径;缺省表示无用户配置;只允许当前 schema 文档内 fragment 引用 | | `storage.migrationVersion` | 否 | 非负整数;只描述插件私有 KV 迁移 | | `build` | 否 | 诊断元数据,不参与信任/授权 | -| `signature` | P0 否 | 进入分发前另行 ADR 与 schema | +| `signature` | 不存在 | ADR-0005 使用 detached 根路径 `signature.json`;manifest 不内嵌自引用签名 | manifest 不重复存放 State/Event schema;它们属于 UI IR 并与组件树同 digest。宿主加载 UI IR 后 必须验证内嵌 schema 与 State bindings/event declarations 一致。 @@ -154,7 +154,7 @@ ui/widget.ftui logic/plugin.wasm assets/ config.schema.json # 可选 -signature.json # P0 不使用,未来分发版本可选/必需 +signature.json # ADR-0005 detached DSSE envelope;开发策略可缺省,分发策略必需 ``` P0 不允许 `.slint`、原生库、脚本 entrypoint、符号链接或额外可执行文件。即使文件未被 manifest diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index fadabe1..7bcd7e8 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -174,7 +174,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | | PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | -| PP-M8 | 分发与信任 | 规划中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | +| PP-M8 | 分发与信任 | 进行中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | From ca3c69241b6af4f4d2d14a97db3eeeae529874aa Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 09:39:41 +0800 Subject: [PATCH 02/10] feat(distribution): verify trusted package signatures Add bounded detached DSSE envelope parsing, Ed25519 verification, signable package digests, and host-owned publisher key binding in floatile-core. Keep signature trust separate from capability authorization. Refs: PP-M8, PP-G2, PP-G3, PP-G10, NFR-SEC-02, ADR-0005 Tests: cargo test -p floatile-core; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo deny --locked check advisories bans sources; cargo fmt --all -- --check; git diff --check Unverified: full workspace test and wasm32-wasip2 check could not complete because the 103G target directory exhausted disk space; license gate remains blocked by the existing Slint/licensing ADR; trust persistence, CLI install integration, anti-rollback, and three-host behavior --- Cargo.lock | 129 +++++++- Cargo.toml | 2 + crates/floatile-core/Cargo.toml | 2 + crates/floatile-core/src/distribution.rs | 375 +++++++++++++++++++++++ crates/floatile-core/src/lib.rs | 6 + 5 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 crates/floatile-core/src/distribution.rs diff --git a/Cargo.lock b/Cargo.lock index 7f4c94c..ce59507 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,6 +458,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "2.0.1" @@ -764,7 +770,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -864,6 +870,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -1189,6 +1201,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1204,6 +1243,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1386,6 +1435,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.17.0" @@ -1549,6 +1622,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1648,6 +1727,8 @@ dependencies = [ name = "floatile-core" version = "0.1.0" dependencies = [ + "base64", + "ed25519-dalek", "jsonschema", "schemars", "semver", @@ -2128,7 +2209,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -4369,6 +4450,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4599,7 +4690,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4631,7 +4722,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -4646,7 +4746,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -5267,6 +5367,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5565,6 +5674,16 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 67e456e..91dff15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,8 @@ wit-bindgen = "0.60" zip = { version = "2", default-features = false, features = ["deflate"] } parking_lot = "0.12" sha2 = "0.10" +base64 = "0.22" +ed25519-dalek = "2.2" [workspace.lints.rust] rust_2018_idioms = { level = "warn", priority = -1 } diff --git a/crates/floatile-core/Cargo.toml b/crates/floatile-core/Cargo.toml index 2c8669d..a47934a 100644 --- a/crates/floatile-core/Cargo.toml +++ b/crates/floatile-core/Cargo.toml @@ -11,6 +11,8 @@ publish = false workspace = true [dependencies] +base64 = { workspace = true } +ed25519-dalek = { workspace = true } jsonschema = { workspace = true } schemars = { workspace = true } semver = { workspace = true } diff --git a/crates/floatile-core/src/distribution.rs b/crates/floatile-core/src/distribution.rs new file mode 100644 index 0000000..ee549f0 --- /dev/null +++ b/crates/floatile-core/src/distribution.rs @@ -0,0 +1,375 @@ +//! PP-M8 package signature verification and publisher trust binding. +//! +//! The package remains untrusted input throughout this module. A valid signature proves that the +//! signable package bytes were authenticated by a host-trusted publisher key; it never grants a +//! capability and it does not replace install-time path or resource validation. + +use std::collections::BTreeMap; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::install::{content_digest, hex_encode}; + +pub const SIGNATURE_FILE: &str = "signature.json"; +pub const PACKAGE_DIGEST_PAYLOAD_TYPE: &str = "application/vnd.floatile.package-digest.v1"; +pub const MAX_SIGNATURE_ENVELOPE_BYTES: usize = 16 * 1024; +pub const MAX_PACKAGE_SIGNATURES: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustedPublisherState { + Active, + Revoked, +} + +/// Host-owned trust binding. Keys are raw Ed25519 public-key bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustedPublisher { + pub publisher_id: String, + pub state: TrustedPublisherState, + pub keys: Vec<[u8; 32]>, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SignatureVerificationError { + #[error("signature envelope exceeds the size limit")] + EnvelopeTooLarge, + #[error("signature envelope is malformed")] + MalformedEnvelope, + #[error("signature payload type is unsupported")] + UnsupportedPayloadType, + #[error("signature envelope contains an invalid signature count")] + InvalidSignatureCount, + #[error("signed package digest does not match package contents")] + DigestMismatch, + #[error("package publisher does not match the trust binding")] + PublisherMismatch, + #[error("package publisher is revoked")] + PublisherRevoked, + #[error("signature envelope does not reference a trusted publisher key")] + UnknownKey, + #[error("package signature is invalid")] + InvalidSignature, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SignatureEnvelope { + #[serde(rename = "payloadType")] + payload_type: String, + payload: String, + signatures: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct EnvelopeSignature { + keyid: String, + sig: String, +} + +/// Computes the digest authenticated by `signature.json`. +/// +/// The detached envelope is the sole excluded root file. Install integrity must separately use +/// `content_digest`, which includes every installed ordinary file. +pub fn signable_content_digest(files: &BTreeMap>) -> [u8; 32] { + let signable = files + .iter() + .filter(|(path, _)| path.as_str() != SIGNATURE_FILE) + .map(|(path, bytes)| (path.clone(), bytes.clone())) + .collect(); + content_digest(&signable) +} + +/// Returns the lowercase SHA-256 key identifier for raw Ed25519 public-key bytes. +pub fn publisher_key_id(public_key: &[u8; 32]) -> String { + hex_encode(&Sha256::digest(public_key)) +} + +/// DSSE v1 pre-authentication encoding. +pub fn dsse_pae(payload_type: &str, payload: &[u8]) -> Vec { + format!( + "DSSEv1 {} {payload_type} {} ", + payload_type.len(), + payload.len() + ) + .into_bytes() + .into_iter() + .chain(payload.iter().copied()) + .collect() +} + +/// Verifies a detached package signature against a host-owned publisher trust binding. +pub fn verify_signature_envelope( + envelope_json: &[u8], + files: &BTreeMap>, + manifest_publisher_id: &str, + trusted_publisher: &TrustedPublisher, +) -> Result<(), SignatureVerificationError> { + if envelope_json.len() > MAX_SIGNATURE_ENVELOPE_BYTES { + return Err(SignatureVerificationError::EnvelopeTooLarge); + } + if manifest_publisher_id != trusted_publisher.publisher_id { + return Err(SignatureVerificationError::PublisherMismatch); + } + if trusted_publisher.state == TrustedPublisherState::Revoked { + return Err(SignatureVerificationError::PublisherRevoked); + } + + let envelope: SignatureEnvelope = serde_json::from_slice(envelope_json) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if envelope.payload_type != PACKAGE_DIGEST_PAYLOAD_TYPE { + return Err(SignatureVerificationError::UnsupportedPayloadType); + } + if envelope.signatures.is_empty() || envelope.signatures.len() > MAX_PACKAGE_SIGNATURES { + return Err(SignatureVerificationError::InvalidSignatureCount); + } + + let payload = decode_canonical_base64(&envelope.payload)?; + if payload.len() != 32 { + return Err(SignatureVerificationError::MalformedEnvelope); + } + if payload.as_slice() != signable_content_digest(files) { + return Err(SignatureVerificationError::DigestMismatch); + } + let pae = dsse_pae(&envelope.payload_type, &payload); + let mut matched_trusted_key = false; + + for signature in &envelope.signatures { + for public_key in &trusted_publisher.keys { + if signature.keyid != publisher_key_id(public_key) { + continue; + } + matched_trusted_key = true; + let signature_bytes = decode_canonical_base64(&signature.sig)?; + let signature_bytes: [u8; 64] = signature_bytes + .try_into() + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + let verifying_key = VerifyingKey::from_bytes(public_key) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if verifying_key + .verify_strict(&pae, &Signature::from_bytes(&signature_bytes)) + .is_ok() + { + return Ok(()); + } + } + } + + if matched_trusted_key { + Err(SignatureVerificationError::InvalidSignature) + } else { + Err(SignatureVerificationError::UnknownKey) + } +} + +fn decode_canonical_base64(value: &str) -> Result, SignatureVerificationError> { + let decoded = STANDARD + .decode(value) + .map_err(|_| SignatureVerificationError::MalformedEnvelope)?; + if STANDARD.encode(&decoded) != value { + return Err(SignatureVerificationError::MalformedEnvelope); + } + Ok(decoded) +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + use serde_json::json; + + use super::*; + use crate::install::hex_decode; + + fn package_files() -> BTreeMap> { + BTreeMap::from([ + ("logic/plugin.wasm".to_owned(), vec![0, 97, 115, 109]), + ( + "manifest.json".to_owned(), + br#"{"id":"dev.floatile.clock"}"#.to_vec(), + ), + (SIGNATURE_FILE.to_owned(), b"detached envelope".to_vec()), + ]) + } + + fn signed_envelope(files: &BTreeMap>, signing_key: &SigningKey) -> Vec { + let payload = signable_content_digest(files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &payload)); + serde_json::to_vec(&json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(payload), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()), + }] + })) + .unwrap() + } + + fn trusted(signing_key: &SigningKey) -> TrustedPublisher { + TrustedPublisher { + publisher_id: "dev.floatile".to_owned(), + state: TrustedPublisherState::Active, + keys: vec![signing_key.verifying_key().to_bytes()], + } + } + + #[test] + fn verifies_trusted_dsse_signature() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[7; 32]); + let envelope = signed_envelope(&files, &signing_key); + + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &trusted(&signing_key)), + Ok(()) + ); + } + + #[test] + fn signature_file_is_detached_but_other_files_are_authenticated() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[8; 32]); + let envelope = signed_envelope(&files, &signing_key); + + let mut changed_envelope_file = files.clone(); + changed_envelope_file.insert(SIGNATURE_FILE.to_owned(), b"replacement".to_vec()); + assert_eq!( + verify_signature_envelope( + &envelope, + &changed_envelope_file, + "dev.floatile", + &trusted(&signing_key) + ), + Ok(()) + ); + + let mut changed_wasm = files.clone(); + changed_wasm.insert("logic/plugin.wasm".to_owned(), vec![1]); + assert_eq!( + verify_signature_envelope( + &envelope, + &changed_wasm, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::DigestMismatch) + ); + } + + #[test] + fn rejects_unknown_key_invalid_signature_and_publisher_states() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[9; 32]); + let envelope = signed_envelope(&files, &signing_key); + let other_key = SigningKey::from_bytes(&[10; 32]); + + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &trusted(&other_key)), + Err(SignatureVerificationError::UnknownKey) + ); + assert_eq!( + verify_signature_envelope(&envelope, &files, "other.publisher", &trusted(&signing_key)), + Err(SignatureVerificationError::PublisherMismatch) + ); + + let mut revoked = trusted(&signing_key); + revoked.state = TrustedPublisherState::Revoked; + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &revoked), + Err(SignatureVerificationError::PublisherRevoked) + ); + + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["signatures"][0]["sig"] = json!(STANDARD.encode([0; 64])); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::InvalidSignature) + ); + } + + #[test] + fn rejects_domain_changes_and_unbounded_or_noncanonical_envelopes() { + let files = package_files(); + let signing_key = SigningKey::from_bytes(&[11; 32]); + let envelope = signed_envelope(&files, &signing_key); + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["payloadType"] = json!("application/octet-stream"); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::UnsupportedPayloadType) + ); + + assert_eq!( + verify_signature_envelope( + &vec![b' '; MAX_SIGNATURE_ENVELOPE_BYTES + 1], + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::EnvelopeTooLarge) + ); + + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); + value["unexpected"] = json!(true); + assert_eq!( + verify_signature_envelope( + &serde_json::to_vec(&value).unwrap(), + &files, + "dev.floatile", + &trusted(&signing_key) + ), + Err(SignatureVerificationError::MalformedEnvelope) + ); + } + + #[test] + fn accepts_rfc8032_ed25519_test_vector() { + // RFC 8032 section 7.1, test 1: empty message. + let public_key: [u8; 32] = hex_decode( + "d75a980182b10ab7d54bfed3c964073a\ + 0ee172f3daa62325af021a68f707511a", + ) + .unwrap() + .try_into() + .unwrap(); + let signature: [u8; 64] = hex_decode( + "e5564300c360ac729086e2cc806e828a\ + 84877f1eb8e5d974d873e06522490155\ + 5fb8821590a33bacc61e39701cf9b46b\ + d25bf5f0595bbe24655141438e7a100b", + ) + .unwrap() + .try_into() + .unwrap(); + + assert!( + VerifyingKey::from_bytes(&public_key) + .unwrap() + .verify_strict(b"", &Signature::from_bytes(&signature)) + .is_ok() + ); + } + + #[test] + fn pae_matches_dsse_v1_encoding() { + assert_eq!( + dsse_pae("text/plain", b"hello"), + b"DSSEv1 10 text/plain 5 hello" + ); + } +} diff --git a/crates/floatile-core/src/lib.rs b/crates/floatile-core/src/lib.rs index cfe4bc3..0fe4163 100644 --- a/crates/floatile-core/src/lib.rs +++ b/crates/floatile-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod capability; pub mod connection; pub mod constants; +pub mod distribution; pub mod install; pub mod instance; pub mod layout; @@ -19,6 +20,11 @@ pub use connection::{ CredentialRef, MAX_CONNECTION_ACCOUNT_BYTES, MAX_CONNECTION_PROVIDER_BYTES, MAX_CREDENTIAL_REF_BYTES, }; +pub use distribution::{ + PACKAGE_DIGEST_PAYLOAD_TYPE, SIGNATURE_FILE, SignatureVerificationError, TrustedPublisher, + TrustedPublisherState, dsse_pae, publisher_key_id, signable_content_digest, + verify_signature_envelope, +}; pub use instance::{ InstallationDigest, InstallationRef, InstanceConfig, InstanceDesiredState, InstanceModelError, MAX_INSTANCE_CONFIG_BYTES, MAX_INSTANCE_CONFIG_DEPTH, PluginInstance, From 6503abd8fc2d6413c75a5935b98aec6dc3605c96 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 11:30:29 +0800 Subject: [PATCH 03/10] feat(distribution): persist publisher trust state Add the append-only v6 migration and transactional store APIs for host-owned publisher keys, publisher/key revocation, and the highest accepted package version and digest. Reject downgrade and same-version replacement without lowering the rollback high-water mark. Refs: PP-M8, PP-G2, PP-G3, PP-G10, NFR-SEC-02, ADR-0005 Tests: cargo test -p floatile-store --locked; cargo clippy -p floatile-store --all-targets --locked -- -D warnings; cargo check --workspace --all-targets --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo fmt --all -- --check; git diff --check Unverified: CLI install policy integration, explicit rollback orchestration, permission diff, Windows/macOS persistence behavior, full workspace tests, license gate --- crates/floatile-store/src/lib.rs | 86 ++++- crates/floatile-store/src/trust.rs | 570 +++++++++++++++++++++++++++++ docs/product/requirements.md | 2 +- 3 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 crates/floatile-store/src/trust.rs diff --git a/crates/floatile-store/src/lib.rs b/crates/floatile-store/src/lib.rs index 04ff52b..8be2a99 100644 --- a/crates/floatile-store/src/lib.rs +++ b/crates/floatile-store/src/lib.rs @@ -13,6 +13,7 @@ use floatile_core::{ use rusqlite::{Connection, OptionalExtension}; pub mod installation; +pub mod trust; /// 持久化错误。 #[derive(Debug, thiserror::Error)] @@ -28,7 +29,7 @@ pub enum StoreError { } /// 当前 schema 版本(与 migration 列表一一对应)。 -const SCHEMA_VERSION: u32 = 5; +const SCHEMA_VERSION: u32 = 6; /// 打开数据库并迁移到最新版本。 /// @@ -71,6 +72,9 @@ impl Store { if current < 5 { self.migration_v5()?; } + if current < 6 { + self.migration_v6()?; + } Ok(()) } @@ -213,6 +217,43 @@ impl Store { .map_err(|error| StoreError::Migration(format!("v5 提交失败: {error}"))) } + fn migration_v6(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS publisher_trust ( + publisher_id TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0) + ); + CREATE TABLE IF NOT EXISTS publisher_keys ( + publisher_id TEXT NOT NULL, + key_id TEXT NOT NULL, + public_key BLOB NOT NULL CHECK (length(public_key) = 32), + state TEXT NOT NULL CHECK (state IN ('active','revoked')), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + PRIMARY KEY (publisher_id, key_id), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + CREATE TABLE IF NOT EXISTS accepted_packages ( + publisher_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + digest BLOB NOT NULL CHECK (length(digest) = 32), + accepted_at INTEGER NOT NULL CHECK (accepted_at >= 0), + PRIMARY KEY (publisher_id, plugin_id), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + PRAGMA user_version = 6;", + ) + .map_err(|error| { + StoreError::Migration(format!( + "v6 建立 publisher trust 与 anti-rollback 表失败: {error}" + )) + })?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v6 提交失败: {error}"))) + } + /// 布局存储接口。 pub fn layout(&self) -> LayoutStore<'_> { LayoutStore { conn: &self.conn } @@ -235,6 +276,11 @@ impl Store { pub fn connections(&self) -> ConnectionStore<'_> { ConnectionStore { conn: &self.conn } } + + /// 宿主持有的 publisher trust、签名 key 与 anti-rollback 状态接口。 + pub fn trust(&self) -> trust::PublisherTrustStore<'_> { + trust::PublisherTrustStore::new(&self.conn) + } } pub struct ConnectionStore<'a> { @@ -1555,6 +1601,44 @@ mod tests { assert_eq!(allocator_exists, 0); } + fn v5_store() -> Store { + let conn = Connection::open_in_memory().unwrap(); + let mut store = Store { conn }; + store.migration_v1().unwrap(); + store.migration_v2().unwrap(); + store.migration_v3().unwrap(); + store.migration_v4().unwrap(); + store.migration_v5().unwrap(); + store + } + + #[test] + fn migration_v6_failure_rolls_back_all_trust_tables_and_version() { + let mut store = v5_store(); + store + .conn + .execute_batch("CREATE INDEX publisher_trust ON layout(instance_id);") + .unwrap(); + + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 5); + for table in ["publisher_trust", "accepted_packages"] { + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0, "{table} leaked from failed migration"); + } + } + #[test] fn connection_grants_are_instance_scoped_and_shared_connection_survives_deletion() { let store = open(":memory:").unwrap(); diff --git a/crates/floatile-store/src/trust.rs b/crates/floatile-store/src/trust.rs new file mode 100644 index 0000000..b72bf98 --- /dev/null +++ b/crates/floatile-store/src/trust.rs @@ -0,0 +1,570 @@ +//! Host-owned publisher trust persistence for PP-M8. +//! +//! Package-controlled data never creates a trust anchor. Callers must explicitly provision or revoke +//! publisher keys through this store, then pass only active keys to the core signature verifier. + +use floatile_core::distribution::{TrustedPublisher, TrustedPublisherState, publisher_key_id}; +use rusqlite::{Connection, OptionalExtension}; +use semver::Version; + +use crate::StoreError; + +const MAX_PUBLISHER_ID_BYTES: usize = 256; +const MAX_PLUGIN_ID_BYTES: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustState { + Active, + Revoked, +} + +impl TrustState { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Revoked => "revoked", + } + } + + fn parse(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "revoked" => Ok(Self::Revoked), + other => Err(StoreError::Corrupt(format!( + "publisher trust state 非法: {other}" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublisherKeyRecord { + pub key_id: String, + pub public_key: [u8; 32], + pub state: TrustState, + pub updated_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublisherTrustRecord { + pub publisher_id: String, + pub state: TrustState, + pub updated_at: u64, + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedPackage { + pub publisher_id: String, + pub plugin_id: String, + pub version: Version, + pub digest: [u8; 32], + pub accepted_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceptanceOutcome { + FirstAccepted, + NewerAccepted, + AlreadyAccepted, +} + +#[derive(Debug, thiserror::Error)] +pub enum TrustPolicyError { + #[error(transparent)] + Store(#[from] StoreError), + #[error("publisher trust 不存在")] + UnknownPublisher, + #[error("publisher trust 已撤销")] + RevokedPublisher, + #[error("拒绝低于最高已接受版本的包: {candidate} < {highest}")] + Rollback { + candidate: Version, + highest: Version, + }, + #[error("同版本包的内容摘要与最高已接受记录不同")] + SameVersionDifferentDigest, +} + +impl From for TrustPolicyError { + fn from(error: rusqlite::Error) -> Self { + Self::Store(StoreError::Open(error)) + } +} + +impl PublisherTrustRecord { + /// Produces a verifier binding containing only active host-trusted keys. + pub fn verifier_binding(&self) -> TrustedPublisher { + TrustedPublisher { + publisher_id: self.publisher_id.clone(), + state: match self.state { + TrustState::Active => TrustedPublisherState::Active, + TrustState::Revoked => TrustedPublisherState::Revoked, + }, + keys: self + .keys + .iter() + .filter(|key| key.state == TrustState::Active) + .map(|key| key.public_key) + .collect(), + } + } +} + +pub struct PublisherTrustStore<'a> { + conn: &'a Connection, +} + +impl<'a> PublisherTrustStore<'a> { + pub(crate) fn new(conn: &'a Connection) -> Self { + Self { conn } + } + + /// Provisions or replaces a publisher key and makes publisher trust explicit. + pub fn upsert_key( + &self, + publisher_id: &str, + public_key: [u8; 32], + state: TrustState, + updated_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + let updated_at = sqlite_time(updated_at)?; + let key_id = publisher_key_id(&public_key); + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO publisher_trust (publisher_id, state, updated_at) + VALUES (?1, 'active', ?2) + ON CONFLICT(publisher_id) DO UPDATE SET updated_at = excluded.updated_at", + rusqlite::params![publisher_id, updated_at], + )?; + tx.execute( + "INSERT INTO publisher_keys (publisher_id, key_id, public_key, state, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(publisher_id, key_id) DO UPDATE SET + public_key = excluded.public_key, + state = excluded.state, + updated_at = excluded.updated_at", + rusqlite::params![ + publisher_id, + key_id, + public_key.as_slice(), + state.as_str(), + updated_at + ], + )?; + tx.commit()?; + Ok(key_id) + } + + /// Revokes or reactivates an existing publisher without modifying its keys. + pub fn set_publisher_state( + &self, + publisher_id: &str, + state: TrustState, + updated_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + let changed = self.conn.execute( + "UPDATE publisher_trust SET state = ?2, updated_at = ?3 WHERE publisher_id = ?1", + rusqlite::params![publisher_id, state.as_str(), sqlite_time(updated_at)?], + )?; + Ok(changed > 0) + } + + pub fn get(&self, publisher_id: &str) -> Result, StoreError> { + validate_publisher_id(publisher_id)?; + let publisher = self + .conn + .query_row( + "SELECT state, updated_at FROM publisher_trust WHERE publisher_id = ?1", + [publisher_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + let Some((state, updated_at)) = publisher else { + return Ok(None); + }; + + let mut statement = self.conn.prepare( + "SELECT key_id, public_key, state, updated_at + FROM publisher_keys WHERE publisher_id = ?1 ORDER BY key_id", + )?; + let rows = statement.query_map([publisher_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + })?; + let mut keys = Vec::new(); + for row in rows { + let (key_id, public_key, key_state, key_updated_at) = row?; + let public_key: [u8; 32] = public_key.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "publisher public key 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?; + if publisher_key_id(&public_key) != key_id { + return Err(StoreError::Corrupt( + "publisher key_id 与 public_key 不匹配".to_owned(), + )); + } + keys.push(PublisherKeyRecord { + key_id, + public_key, + state: TrustState::parse(&key_state)?, + updated_at: read_time(key_updated_at, "publisher key updated_at")?, + }); + } + + Ok(Some(PublisherTrustRecord { + publisher_id: publisher_id.to_owned(), + state: TrustState::parse(&state)?, + updated_at: read_time(updated_at, "publisher trust updated_at")?, + keys, + })) + } + + /// Atomically advances the highest accepted version/digest for a trusted publisher/plugin. + /// + /// Explicit rollback does not call this method: it references a verified historical installation + /// while retaining this high-water mark. + pub fn accept_package( + &self, + publisher_id: &str, + plugin_id: &str, + version: &Version, + digest: [u8; 32], + accepted_at: u64, + ) -> Result { + validate_publisher_id(publisher_id)?; + validate_plugin_id(plugin_id)?; + let accepted_at = sqlite_time(accepted_at)?; + let tx = self.conn.unchecked_transaction()?; + let publisher_state = tx + .query_row( + "SELECT state FROM publisher_trust WHERE publisher_id = ?1", + [publisher_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + match publisher_state.as_deref() { + None => return Err(TrustPolicyError::UnknownPublisher), + Some("revoked") => return Err(TrustPolicyError::RevokedPublisher), + Some("active") => {} + Some(other) => { + return Err( + StoreError::Corrupt(format!("publisher trust state 非法: {other}")).into(), + ); + } + } + + let existing = tx + .query_row( + "SELECT version, digest, accepted_at FROM accepted_packages + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![publisher_id, plugin_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((highest_text, highest_digest, _)) = existing else { + tx.execute( + "INSERT INTO accepted_packages + (publisher_id, plugin_id, version, digest, accepted_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + publisher_id, + plugin_id, + version.to_string(), + digest.as_slice(), + accepted_at + ], + )?; + tx.commit()?; + return Ok(AcceptanceOutcome::FirstAccepted); + }; + let highest = Version::parse(&highest_text).map_err(|error| { + StoreError::Corrupt(format!("accepted package version 非法: {error}")) + })?; + let highest_digest: [u8; 32] = highest_digest.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "accepted package digest 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?; + + if version < &highest { + return Err(TrustPolicyError::Rollback { + candidate: version.clone(), + highest, + }); + } + if version == &highest { + if digest == highest_digest { + return Ok(AcceptanceOutcome::AlreadyAccepted); + } + return Err(TrustPolicyError::SameVersionDifferentDigest); + } + + tx.execute( + "UPDATE accepted_packages SET version = ?3, digest = ?4, accepted_at = ?5 + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![ + publisher_id, + plugin_id, + version.to_string(), + digest.as_slice(), + accepted_at + ], + )?; + tx.commit()?; + Ok(AcceptanceOutcome::NewerAccepted) + } + + pub fn accepted_package( + &self, + publisher_id: &str, + plugin_id: &str, + ) -> Result, StoreError> { + validate_publisher_id(publisher_id)?; + validate_plugin_id(plugin_id)?; + let row = self + .conn + .query_row( + "SELECT version, digest, accepted_at FROM accepted_packages + WHERE publisher_id = ?1 AND plugin_id = ?2", + rusqlite::params![publisher_id, plugin_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((version, digest, accepted_at)) = row else { + return Ok(None); + }; + Ok(Some(AcceptedPackage { + publisher_id: publisher_id.to_owned(), + plugin_id: plugin_id.to_owned(), + version: Version::parse(&version).map_err(|error| { + StoreError::Corrupt(format!("accepted package version 非法: {error}")) + })?, + digest: digest.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!( + "accepted package digest 必须为 32 字节,实际为 {}", + bytes.len() + )) + })?, + accepted_at: read_time(accepted_at, "accepted_at")?, + })) + } +} + +fn validate_publisher_id(publisher_id: &str) -> Result<(), StoreError> { + if publisher_id.is_empty() || publisher_id.len() > MAX_PUBLISHER_ID_BYTES { + return Err(StoreError::Corrupt( + "publisher id 为空或超过存储上限".to_owned(), + )); + } + Ok(()) +} + +fn validate_plugin_id(plugin_id: &str) -> Result<(), StoreError> { + if plugin_id.is_empty() || plugin_id.len() > MAX_PLUGIN_ID_BYTES { + return Err(StoreError::Corrupt( + "plugin id 为空或超过存储上限".to_owned(), + )); + } + Ok(()) +} + +fn sqlite_time(value: u64) -> Result { + i64::try_from(value).map_err(|_| StoreError::Corrupt("publisher trust 时间超出范围".to_owned())) +} + +fn read_time(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| StoreError::Corrupt(format!("{field} 不能为负数"))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::open; + + #[test] + fn publisher_keys_round_trip_and_revocation_is_enforced_in_binding() { + let store = open(":memory:").unwrap(); + let first = [7; 32]; + let second = [8; 32]; + let first_id = store + .trust() + .upsert_key("dev.floatile", first, TrustState::Active, 10) + .unwrap(); + store + .trust() + .upsert_key("dev.floatile", second, TrustState::Revoked, 11) + .unwrap(); + + let record = store.trust().get("dev.floatile").unwrap().unwrap(); + assert_eq!(record.keys.len(), 2); + assert!(record.keys.iter().any(|key| key.key_id == first_id)); + assert_eq!(record.verifier_binding().keys, vec![first]); + + assert!( + store + .trust() + .set_publisher_state("dev.floatile", TrustState::Revoked, 12) + .unwrap() + ); + assert_eq!( + store + .trust() + .get("dev.floatile") + .unwrap() + .unwrap() + .verifier_binding() + .state, + TrustedPublisherState::Revoked + ); + } + + #[test] + fn unknown_publisher_is_not_implicitly_created_by_reads_or_state_changes() { + let store = open(":memory:").unwrap(); + assert!(store.trust().get("unknown.publisher").unwrap().is_none()); + assert!( + !store + .trust() + .set_publisher_state("unknown.publisher", TrustState::Active, 1) + .unwrap() + ); + assert!(store.trust().get("unknown.publisher").unwrap().is_none()); + } + + #[test] + fn corrupt_key_id_is_rejected_on_load() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [9; 32], TrustState::Active, 1) + .unwrap(); + store + .conn + .execute( + "UPDATE publisher_keys SET key_id = ?1 WHERE publisher_id = ?2", + rusqlite::params!["00".repeat(32), "dev.floatile"], + ) + .unwrap(); + assert!(matches!( + store.trust().get("dev.floatile"), + Err(StoreError::Corrupt(_)) + )); + } + + #[test] + fn anti_rollback_high_water_mark_rejects_downgrade_and_replacement() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [3; 32], TrustState::Active, 1) + .unwrap(); + let v1 = Version::parse("1.0.0").unwrap(); + let v2 = Version::parse("2.0.0").unwrap(); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v1, [1; 32], 2) + .unwrap(), + AcceptanceOutcome::FirstAccepted + ); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [2; 32], 3) + .unwrap(), + AcceptanceOutcome::NewerAccepted + ); + assert_eq!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [2; 32], 4) + .unwrap(), + AcceptanceOutcome::AlreadyAccepted + ); + assert!(matches!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v1, [1; 32], 5), + Err(TrustPolicyError::Rollback { .. }) + )); + assert!(matches!( + store + .trust() + .accept_package("dev.floatile", "dev.floatile.clock", &v2, [9; 32], 6), + Err(TrustPolicyError::SameVersionDifferentDigest) + )); + + let accepted = store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, v2); + assert_eq!(accepted.digest, [2; 32]); + assert_eq!(accepted.accepted_at, 3); + } + + #[test] + fn anti_rollback_requires_active_explicit_publisher_trust() { + let store = open(":memory:").unwrap(); + let version = Version::parse("1.0.0").unwrap(); + assert!(matches!( + store.trust().accept_package( + "unknown.publisher", + "unknown.publisher.plugin", + &version, + [1; 32], + 1 + ), + Err(TrustPolicyError::UnknownPublisher) + )); + store + .trust() + .upsert_key("dev.floatile", [4; 32], TrustState::Active, 1) + .unwrap(); + store + .trust() + .set_publisher_state("dev.floatile", TrustState::Revoked, 2) + .unwrap(); + assert!(matches!( + store.trust().accept_package( + "dev.floatile", + "dev.floatile.clock", + &version, + [1; 32], + 3 + ), + Err(TrustPolicyError::RevokedPublisher) + )); + assert!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .is_none() + ); + } +} diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 45c29e6..bccb419 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -64,7 +64,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | | 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | -| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core::install` 提供 InstallMeta 与内容 digest 单源,`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名仍待做 | +| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier,`floatile-store` 已持久化宿主持有的 publisher/key trust 与 anti-rollback 最高版本/摘要;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名安装策略、权限 diff、更新与显式回滚仍待接入 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | ## 6. 阶段门槛与未决策 From a141ee16c8aa397c7df9f0d6457206bfe78bb3ef Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 11:41:53 +0800 Subject: [PATCH 04/10] feat(distribution): journal trusted installs Add the append-only v7 pending installation journal and bounded prepare, finalize, abort, and recovery queries. Preparing does not advance anti-rollback state; finalization rechecks trust and supports cleanup after acceptance committed before journal removal. Refs: PP-M8, PP-G3, PP-G10, NFR-SEC-02, ADR-0005 Tests: cargo test -p floatile-store --locked; cargo clippy -p floatile-store --all-targets --locked -- -D warnings; cargo check --workspace --all-targets --locked; cargo fmt --all -- --check; git diff --check Unverified: CLI filesystem recovery integration, crash injection around rename, Windows/macOS filesystem behavior, full workspace tests, license gate --- crates/floatile-store/src/lib.rs | 64 +++++- crates/floatile-store/src/trust.rs | 326 +++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+), 1 deletion(-) diff --git a/crates/floatile-store/src/lib.rs b/crates/floatile-store/src/lib.rs index 8be2a99..57cb9a1 100644 --- a/crates/floatile-store/src/lib.rs +++ b/crates/floatile-store/src/lib.rs @@ -29,7 +29,7 @@ pub enum StoreError { } /// 当前 schema 版本(与 migration 列表一一对应)。 -const SCHEMA_VERSION: u32 = 6; +const SCHEMA_VERSION: u32 = 7; /// 打开数据库并迁移到最新版本。 /// @@ -75,6 +75,9 @@ impl Store { if current < 6 { self.migration_v6()?; } + if current < 7 { + self.migration_v7()?; + } Ok(()) } @@ -254,6 +257,28 @@ impl Store { .map_err(|error| StoreError::Migration(format!("v6 提交失败: {error}"))) } + fn migration_v7(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS pending_installations ( + transaction_id TEXT PRIMARY KEY, + publisher_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + version TEXT NOT NULL, + signed_digest BLOB NOT NULL CHECK (length(signed_digest) = 32), + install_digest BLOB NOT NULL CHECK (length(install_digest) = 32), + staging_name TEXT NOT NULL, + final_relative TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at >= 0), + FOREIGN KEY (publisher_id) REFERENCES publisher_trust(publisher_id) + ); + PRAGMA user_version = 7;", + ) + .map_err(|error| StoreError::Migration(format!("v7 建立可恢复安装意图表失败: {error}")))?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v7 提交失败: {error}"))) + } + /// 布局存储接口。 pub fn layout(&self) -> LayoutStore<'_> { LayoutStore { conn: &self.conn } @@ -1639,6 +1664,43 @@ mod tests { } } + fn v6_store() -> Store { + let conn = Connection::open_in_memory().unwrap(); + let mut store = Store { conn }; + store.migration_v1().unwrap(); + store.migration_v2().unwrap(); + store.migration_v3().unwrap(); + store.migration_v4().unwrap(); + store.migration_v5().unwrap(); + store.migration_v6().unwrap(); + store + } + + #[test] + fn migration_v7_failure_rolls_back_pending_table_and_version() { + let mut store = v6_store(); + store + .conn + .execute_batch("CREATE INDEX pending_installations ON layout(instance_id);") + .unwrap(); + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 6); + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'pending_installations'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0); + } + #[test] fn connection_grants_are_instance_scoped_and_shared_connection_survives_deletion() { let store = open(":memory:").unwrap(); diff --git a/crates/floatile-store/src/trust.rs b/crates/floatile-store/src/trust.rs index b72bf98..70a6167 100644 --- a/crates/floatile-store/src/trust.rs +++ b/crates/floatile-store/src/trust.rs @@ -62,6 +62,19 @@ pub struct AcceptedPackage { pub accepted_at: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingInstallation { + pub transaction_id: String, + pub publisher_id: String, + pub plugin_id: String, + pub version: Version, + pub signed_digest: [u8; 32], + pub install_digest: [u8; 32], + pub staging_name: String, + pub final_relative: String, + pub created_at: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AcceptanceOutcome { FirstAccepted, @@ -370,6 +383,224 @@ impl<'a> PublisherTrustStore<'a> { accepted_at: read_time(accepted_at, "accepted_at")?, })) } + + /// Records a recoverable install intent without advancing the anti-rollback high-water mark. + pub fn prepare_install(&self, pending: &PendingInstallation) -> Result<(), TrustPolicyError> { + validate_pending(pending)?; + self.check_candidate( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + )?; + self.conn.execute( + "INSERT INTO pending_installations ( + transaction_id, publisher_id, plugin_id, version, signed_digest, + install_digest, staging_name, final_relative, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + pending.transaction_id, + pending.publisher_id, + pending.plugin_id, + pending.version.to_string(), + pending.signed_digest.as_slice(), + pending.install_digest.as_slice(), + pending.staging_name, + pending.final_relative, + sqlite_time(pending.created_at)? + ], + )?; + Ok(()) + } + + /// Rechecks policy, advances the high-water mark, then removes the recoverable intent. + /// + /// If the process stops after acceptance but before intent removal, recovery observes an exact + /// already-accepted version/digest and can safely remove the stale intent. + pub fn finalize_install( + &self, + transaction_id: &str, + accepted_at: u64, + ) -> Result { + let pending = self + .pending_install(transaction_id)? + .ok_or_else(|| StoreError::Corrupt("安装意图不存在".to_owned()))?; + let outcome = self.accept_package( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + accepted_at, + )?; + self.conn.execute( + "DELETE FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + )?; + Ok(outcome) + } + + pub fn abort_install(&self, transaction_id: &str) -> Result { + validate_transaction_id(transaction_id)?; + Ok(self.conn.execute( + "DELETE FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + )? > 0) + } + + pub fn pending_install( + &self, + transaction_id: &str, + ) -> Result, StoreError> { + validate_transaction_id(transaction_id)?; + let row = self + .conn + .query_row( + "SELECT publisher_id, plugin_id, version, signed_digest, install_digest, + staging_name, final_relative, created_at + FROM pending_installations WHERE transaction_id = ?1", + [transaction_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Vec>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, + )) + }, + ) + .optional()?; + row.map(|row| row_to_pending(transaction_id, row)) + .transpose() + } + + pub fn pending_installs(&self) -> Result, StoreError> { + let mut statement = self.conn.prepare( + "SELECT transaction_id, publisher_id, plugin_id, version, signed_digest, + install_digest, staging_name, final_relative, created_at + FROM pending_installations ORDER BY transaction_id", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Vec>(4)?, + row.get::<_, Vec>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, i64>(8)?, + )) + })?; + let mut result = Vec::new(); + for row in rows { + let (id, publisher, plugin, version, signed, install, staging, final_path, created) = + row?; + result.push(row_to_pending( + &id, + ( + publisher, plugin, version, signed, install, staging, final_path, created, + ), + )?); + } + Ok(result) + } + + fn check_candidate( + &self, + publisher_id: &str, + plugin_id: &str, + version: &Version, + digest: [u8; 32], + ) -> Result<(), TrustPolicyError> { + let publisher = self.get(publisher_id)?; + match publisher.map(|record| record.state) { + None => return Err(TrustPolicyError::UnknownPublisher), + Some(TrustState::Revoked) => return Err(TrustPolicyError::RevokedPublisher), + Some(TrustState::Active) => {} + } + if let Some(highest) = self.accepted_package(publisher_id, plugin_id)? { + if version < &highest.version { + return Err(TrustPolicyError::Rollback { + candidate: version.clone(), + highest: highest.version, + }); + } + if version == &highest.version && digest != highest.digest { + return Err(TrustPolicyError::SameVersionDifferentDigest); + } + } + Ok(()) + } +} + +type PendingRow = ( + String, + String, + String, + Vec, + Vec, + String, + String, + i64, +); + +fn row_to_pending( + transaction_id: &str, + row: PendingRow, +) -> Result { + let (publisher_id, plugin_id, version, signed, install, staging_name, final_relative, created) = + row; + let pending = PendingInstallation { + transaction_id: transaction_id.to_owned(), + publisher_id, + plugin_id, + version: Version::parse(&version) + .map_err(|error| StoreError::Corrupt(format!("pending version 非法: {error}")))?, + signed_digest: digest_array(signed, "pending signed_digest")?, + install_digest: digest_array(install, "pending install_digest")?, + staging_name, + final_relative, + created_at: read_time(created, "pending created_at")?, + }; + validate_pending(&pending)?; + Ok(pending) +} + +fn digest_array(bytes: Vec, field: &str) -> Result<[u8; 32], StoreError> { + bytes.try_into().map_err(|bytes: Vec| { + StoreError::Corrupt(format!("{field} 必须为 32 字节,实际为 {}", bytes.len())) + }) +} + +fn validate_pending(pending: &PendingInstallation) -> Result<(), StoreError> { + validate_transaction_id(&pending.transaction_id)?; + validate_publisher_id(&pending.publisher_id)?; + validate_plugin_id(&pending.plugin_id)?; + if pending.staging_name != format!(".staging-{}", pending.transaction_id) + || pending.final_relative != format!("{}/{}", pending.plugin_id, pending.version) + { + return Err(StoreError::Corrupt( + "安装意图包含非规范化相对路径".to_owned(), + )); + } + Ok(()) +} + +fn validate_transaction_id(transaction_id: &str) -> Result<(), StoreError> { + if transaction_id.is_empty() + || transaction_id.len() > 128 + || !transaction_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(StoreError::Corrupt("安装 transaction id 非法".to_owned())); + } + Ok(()) } fn validate_publisher_id(publisher_id: &str) -> Result<(), StoreError> { @@ -567,4 +798,99 @@ mod tests { .is_none() ); } + + fn pending(version: &str) -> PendingInstallation { + PendingInstallation { + transaction_id: "123-456".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse(version).unwrap(), + signed_digest: [5; 32], + install_digest: [6; 32], + staging_name: ".staging-123-456".to_owned(), + final_relative: format!("dev.floatile.clock/{version}"), + created_at: 10, + } + } + + #[test] + fn prepared_install_does_not_advance_watermark_until_finalize() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + assert!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .is_none() + ); + assert_eq!(store.trust().pending_installs().unwrap(), vec![pending]); + + assert_eq!( + store.trust().finalize_install("123-456", 11).unwrap(), + AcceptanceOutcome::FirstAccepted + ); + assert!(store.trust().pending_installs().unwrap().is_empty()); + assert_eq!( + store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap() + .digest, + [5; 32] + ); + } + + #[test] + fn recovery_clears_intent_if_acceptance_committed_before_cleanup() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + store + .trust() + .accept_package( + &pending.publisher_id, + &pending.plugin_id, + &pending.version, + pending.signed_digest, + 11, + ) + .unwrap(); + + assert_eq!( + store.trust().finalize_install("123-456", 12).unwrap(), + AcceptanceOutcome::AlreadyAccepted + ); + assert!(store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn invalid_install_paths_and_abort_are_bounded() { + let store = open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [5; 32], TrustState::Active, 1) + .unwrap(); + let mut invalid = pending("1.0.0"); + invalid.final_relative = "../escape".to_owned(); + assert!(matches!( + store.trust().prepare_install(&invalid), + Err(TrustPolicyError::Store(StoreError::Corrupt(_))) + )); + + let pending = pending("1.0.0"); + store.trust().prepare_install(&pending).unwrap(); + assert!(store.trust().abort_install("123-456").unwrap()); + assert!(store.trust().pending_installs().unwrap().is_empty()); + } } From cd643dd2e71521d02b3c5be6fbc0ef12a65b8d51 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 11:54:59 +0800 Subject: [PATCH 05/10] feat(cli): enforce trusted package installs Add host trust administration, --require-trusted install policy, detached signature and revocation diagnostics, anti-rollback enforcement, and recovery of crash-interrupted staging or renamed installs. Preserve the existing unsigned development path with an explicit trust result. Refs: PP-M8, PP-G2, PP-G3, PP-G10, NFR-SEC-02, ADR-0005 Tests: cargo test -p floatile-core distribution --locked; cargo test -p floatile-store trust::tests --locked; cargo test -p floatile-cli install::tests --locked; cargo test -p floatile-cli trust::tests --locked; cargo clippy --workspace --all-targets --locked -- -D warnings; cargo fmt --all -- --check; git diff --check Unverified: permission diff and confirmation, explicit rollback command, install trust metadata in shell, Windows/macOS crash recovery, full workspace tests, license gate --- Cargo.lock | 3 + crates/floatile-cli/Cargo.toml | 3 + crates/floatile-cli/src/install.rs | 419 +++++++++++++++++++- crates/floatile-cli/src/lib.rs | 6 +- crates/floatile-cli/src/main.rs | 114 +++++- crates/floatile-cli/src/trust.rs | 197 +++++++++ crates/floatile-core/src/distribution.rs | 22 + crates/floatile-store/src/trust.rs | 6 + docs/plugin-sdk/sdk-developer-experience.md | 9 +- 9 files changed, 768 insertions(+), 11 deletions(-) create mode 100644 crates/floatile-cli/src/trust.rs diff --git a/Cargo.lock b/Cargo.lock index ce59507..55f9909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,12 +1700,15 @@ dependencies = [ name = "floatile-cli" version = "0.1.0" dependencies = [ + "base64", + "ed25519-dalek", "floatile-core", "floatile-platform", "floatile-plugin-api", "floatile-runtime", "floatile-store", "floatile-ui-schema", + "semver", "serde", "serde_json", "thiserror 2.0.20", diff --git a/crates/floatile-cli/Cargo.toml b/crates/floatile-cli/Cargo.toml index 7eedc86..fa56b59 100644 --- a/crates/floatile-cli/Cargo.toml +++ b/crates/floatile-cli/Cargo.toml @@ -22,6 +22,7 @@ floatile-store = { workspace = true } floatile-ui-schema = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +semver = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } toml = { workspace = true } @@ -29,4 +30,6 @@ wasmtime = { workspace = true } zip = { workspace = true } [dev-dependencies] +base64 = { workspace = true } +ed25519-dalek = { workspace = true } floatile-plugin-api = { workspace = true } diff --git a/crates/floatile-cli/src/install.rs b/crates/floatile-cli/src/install.rs index 7059fd5..1239e36 100644 --- a/crates/floatile-cli/src/install.rs +++ b/crates/floatile-cli/src/install.rs @@ -14,8 +14,14 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use floatile_core::distribution::{ + SIGNATURE_FILE, SignatureVerificationError, signable_content_digest, verify_signature_envelope, +}; use floatile_core::install::{InstallMeta, content_digest, file_digest, hex_encode}; use floatile_core::manifest::Manifest; +use floatile_store::Store; +use floatile_store::trust::{PendingInstallation, TrustPolicyError}; +use semver::Version; use thiserror::Error; use crate::package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; @@ -35,6 +41,18 @@ pub enum InstallError { Commit(String), #[error("I/O 失败: {0}")] Io(String), + #[error("包缺少 detached signature.json")] + MissingSignature, + #[error("找不到 manifest publisher 的宿主信任记录")] + UnknownPublisher, + #[error("签名验证失败: {0}")] + Signature(#[from] SignatureVerificationError), + #[error("安装信任策略拒绝: {0}")] + TrustPolicy(#[from] TrustPolicyError), + #[error("安装已落盘但信任状态尚待恢复: transaction={0}")] + RecoveryRequired(String), + #[error("恢复安装事务失败: {0}")] + Recovery(String), } impl InstallError { @@ -46,6 +64,25 @@ impl InstallError { Self::StagingWrite(_) => "FINST_STAGING_WRITE", Self::Commit(_) => "FINST_COMMIT", Self::Io(_) => "FINST_IO", + Self::MissingSignature => "FINST_SIGNATURE_MISSING", + Self::UnknownPublisher => "FINST_PUBLISHER_UNKNOWN", + Self::Signature(error) => match error { + SignatureVerificationError::PublisherRevoked => "FINST_PUBLISHER_REVOKED", + SignatureVerificationError::KeyRevoked => "FINST_KEY_REVOKED", + SignatureVerificationError::UnknownKey => "FINST_KEY_UNKNOWN", + SignatureVerificationError::DigestMismatch => "FINST_SIGNATURE_DIGEST", + SignatureVerificationError::InvalidSignature => "FINST_SIGNATURE_INVALID", + _ => "FINST_SIGNATURE_MALFORMED", + }, + Self::TrustPolicy(error) => match error { + TrustPolicyError::UnknownPublisher => "FINST_PUBLISHER_UNKNOWN", + TrustPolicyError::RevokedPublisher => "FINST_PUBLISHER_REVOKED", + TrustPolicyError::Rollback { .. } => "FINST_ROLLBACK", + TrustPolicyError::SameVersionDifferentDigest => "FINST_VERSION_REPLACED", + TrustPolicyError::Store(_) => "FINST_TRUST_STORE", + }, + Self::RecoveryRequired(_) => "FINST_RECOVERY_REQUIRED", + Self::Recovery(_) => "FINST_RECOVERY_FAILED", } } @@ -59,6 +96,12 @@ impl InstallError { Self::StagingWrite(_) => Cow::Borrowed("无法写入安装暂存目录"), Self::Commit(_) => Cow::Borrowed("无法原子提交安装"), Self::Io(_) => Cow::Borrowed("插件包读取失败"), + Self::MissingSignature => Cow::Borrowed("插件包缺少签名"), + Self::UnknownPublisher => Cow::Borrowed("插件发布者不受信任"), + Self::Signature(_) => Cow::Borrowed("插件签名未通过验证"), + Self::TrustPolicy(_) => Cow::Borrowed("插件安装被信任或版本策略拒绝"), + Self::RecoveryRequired(_) => Cow::Borrowed("安装需要在下次运行时恢复"), + Self::Recovery(_) => Cow::Borrowed("无法安全恢复上次安装事务"), } } } @@ -71,6 +114,12 @@ pub struct InstalledPackage { pub meta: InstallMeta, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RecoveryReport { + pub finalized: usize, + pub aborted: usize, +} + /// 插件存储根目录下某插件的安装目录:`//`。 /// /// `id` 与 `version` 都经过 manifest 校验(反向域名 + 严格 semver),因此路径安全; @@ -93,6 +142,180 @@ pub fn install_package( limits: &PackageLimits, ) -> Result { let validated = validate_package(bytes, limits)?; + install_validated(&validated, store, source) +} + +/// Installs a package only after a detached signature verifies against host-owned publisher trust. +pub fn install_trusted_package( + bytes: &[u8], + plugin_store: &Path, + source: &str, + limits: &PackageLimits, + trust_store: &Store, +) -> Result { + let validated = validate_package(bytes, limits)?; + let publisher_id = validated.manifest.publisher.id.as_str(); + let trust = trust_store + .trust() + .get(publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let envelope = validated + .files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope( + envelope, + &validated.files, + publisher_id, + &trust.verifier_binding(), + )?; + + let transaction_id = nonce(); + let staging_name = format!(".staging-{transaction_id}"); + let staging = plugin_store.join(&staging_name); + let manifest = validated.manifest.clone(); + let final_dir = install_dir(plugin_store, &manifest.id.0, &manifest.version); + if final_dir.exists() { + return Err(InstallError::AlreadyInstalled { + id: manifest.id.0, + version: manifest.version, + }); + } + let parent = final_dir.parent().unwrap_or(plugin_store); + fs::create_dir_all(parent) + .map_err(|error| InstallError::StoreUnavailable(error.to_string()))?; + let meta = match write_staging(&staging, &validated, &manifest, source) { + Ok(meta) => meta, + Err(error) => { + let _ = fs::remove_dir_all(&staging); + return Err(error); + } + }; + let version = Version::parse(&manifest.version) + .map_err(|error| InstallError::Commit(format!("validated version: {error}")))?; + let pending = PendingInstallation { + transaction_id: transaction_id.clone(), + publisher_id: publisher_id.to_owned(), + plugin_id: manifest.id.0.clone(), + version, + signed_digest: signable_content_digest(&validated.files), + install_digest: content_digest(&validated.files), + staging_name, + final_relative: format!("{}/{}", manifest.id.0, manifest.version), + created_at: meta.installed_at, + }; + if let Err(error) = trust_store.trust().prepare_install(&pending) { + let _ = fs::remove_dir_all(&staging); + return Err(error.into()); + } + if let Err(error) = fs::rename(&staging, &final_dir) { + let _ = fs::remove_dir_all(&staging); + let _ = trust_store.trust().abort_install(&transaction_id); + return Err(InstallError::Commit(error.to_string())); + } + if trust_store + .trust() + .finalize_install(&transaction_id, meta.installed_at) + .is_err() + { + return Err(InstallError::RecoveryRequired(transaction_id)); + } + + Ok(InstalledPackage { + dir: final_dir, + manifest, + meta, + }) +} + +/// Reconciles crash-interrupted trusted installs before a new trusted install begins. +pub fn recover_trusted_installs( + plugin_store: &Path, + trust_store: &Store, +) -> Result { + let mut report = RecoveryReport::default(); + for pending in trust_store + .trust() + .pending_installs() + .map_err(TrustPolicyError::Store)? + { + let staging = plugin_store.join(&pending.staging_name); + let final_dir = plugin_store.join(&pending.final_relative); + match (staging.exists(), final_dir.exists()) { + (true, true) => { + return Err(InstallError::Recovery(format!( + "transaction {} 同时存在 staging 与 final", + pending.transaction_id + ))); + } + (true, false) => { + fs::remove_dir_all(&staging) + .map_err(|error| InstallError::Recovery(error.to_string()))?; + trust_store + .trust() + .abort_install(&pending.transaction_id) + .map_err(TrustPolicyError::Store)?; + report.aborted += 1; + } + (false, false) => { + trust_store + .trust() + .abort_install(&pending.transaction_id) + .map_err(TrustPolicyError::Store)?; + report.aborted += 1; + } + (false, true) => { + let installed = floatile_store::installation::load_exact( + plugin_store, + &pending.plugin_id, + &pending.version.to_string(), + ) + .map_err(|error| InstallError::Recovery(error.to_string()))? + .ok_or_else(|| InstallError::Recovery("final installation 消失".to_owned()))?; + let mut files = BTreeMap::new(); + for name in installed.meta.files.keys() { + let bytes = installed + .file(name) + .ok_or_else(|| InstallError::Recovery(format!("安装文件缺失: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + if content_digest(&files) != pending.install_digest + || signable_content_digest(&files) != pending.signed_digest + { + return Err(InstallError::Recovery( + "恢复安装摘要与 journal 不一致".to_owned(), + )); + } + let trust = trust_store + .trust() + .get(&pending.publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope( + envelope, + &files, + &installed.manifest.publisher.id, + &trust.verifier_binding(), + )?; + trust_store + .trust() + .finalize_install(&pending.transaction_id, now_secs())?; + report.finalized += 1; + } + } + } + Ok(report) +} + +fn install_validated( + validated: &ValidatedPackage, + store: &Path, + source: &str, +) -> Result { let manifest = validated.manifest.clone(); let id = manifest.id.0.clone(); let version = manifest.version.clone(); @@ -107,7 +330,7 @@ pub fn install_package( let staging = store.join(format!(".staging-{}", nonce())); - let meta = match write_staging(&staging, &validated, &manifest, source) { + let meta = match write_staging(&staging, validated, &manifest, source) { Ok(meta) => meta, Err(e) => { let _ = fs::remove_dir_all(&staging); @@ -213,6 +436,10 @@ fn nonce() -> String { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use ed25519_dalek::{Signer, SigningKey}; + use floatile_core::distribution::{PACKAGE_DIGEST_PAYLOAD_TYPE, dsse_pae, publisher_key_id}; + use floatile_store::trust::TrustState; use std::io::Cursor; fn build_zip(files: &[(&str, &[u8])]) -> Vec { @@ -289,6 +516,46 @@ mod tests { ]) } + fn signed_pkg_bytes(version: &str, signing_key: &SigningKey) -> Vec { + signed_pkg_with_manifest_versions(version, version, signing_key) + } + + fn signed_pkg_with_manifest_versions( + signed_version: &str, + packaged_version: &str, + signing_key: &SigningKey, + ) -> Vec { + let wasm = real_wasm(); + let mut manifest: serde_json::Value = serde_json::from_str(&valid_manifest_json()).unwrap(); + manifest["version"] = serde_json::json!(signed_version); + let signed_manifest = manifest.to_string().into_bytes(); + let ui = valid_ui_ir().into_bytes(); + let files = BTreeMap::from([ + ("manifest.json".to_owned(), signed_manifest), + ("ui/widget.ftui".to_owned(), ui.clone()), + ("logic/plugin.wasm".to_owned(), wasm.clone()), + ]); + let digest = content_digest(&files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &digest)); + let envelope = serde_json::to_vec(&serde_json::json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(digest), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()) + }] + })) + .unwrap(); + manifest["version"] = serde_json::json!(packaged_version); + let packaged_manifest = manifest.to_string().into_bytes(); + build_zip(&[ + ("manifest.json", packaged_manifest.as_slice()), + ("ui/widget.ftui", ui.as_slice()), + ("logic/plugin.wasm", wasm.as_slice()), + (SIGNATURE_FILE, envelope.as_slice()), + ]) + } + fn temp_store(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("floatile-install-{}-{}", tag, std::process::id())); @@ -424,4 +691,154 @@ mod tests { )); assert_eq!(fs::read_dir(&store).unwrap().count(), 0, "安装失败应无残留"); } + + #[test] + fn trusted_install_verifies_signature_and_advances_watermark() { + let plugin_store = temp_store("trusted"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[21; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + + let installed = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "signed.floatile", + &Default::default(), + &trust_store, + ) + .unwrap(); + assert!(installed.dir.join(SIGNATURE_FILE).is_file()); + let accepted = trust_store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, Version::parse("1.0.0").unwrap()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn trusted_install_rejects_unsigned_tampered_and_downgrade_without_residue() { + let plugin_store = temp_store("trusted-reject"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[22; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + assert!(matches!( + install_trusted_package( + &valid_pkg_bytes(), + &plugin_store, + "unsigned.floatile", + &Default::default(), + &trust_store + ), + Err(InstallError::MissingSignature) + )); + + let tampered = signed_pkg_with_manifest_versions("1.0.0", "1.0.1", &signing_key); + let tampered_error = install_trusted_package( + &tampered, + &plugin_store, + "tampered.floatile", + &Default::default(), + &trust_store, + ) + .unwrap_err(); + assert_eq!(tampered_error.code(), "FINST_SIGNATURE_DIGEST"); + + install_trusted_package( + &signed_pkg_bytes("2.0.0", &signing_key), + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + ) + .unwrap(); + let downgrade = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + ) + .unwrap_err(); + assert_eq!(downgrade.code(), "FINST_ROLLBACK"); + assert!(!install_dir(&plugin_store, "dev.floatile.clock", "1.0.0").exists()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + } + + #[test] + fn recovery_aborts_staging_and_finalizes_verified_renamed_install() { + let plugin_store = temp_store("trusted-recovery"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[23; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + + let staged = PendingInstallation { + transaction_id: "staged-1".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse("0.5.0").unwrap(), + signed_digest: [1; 32], + install_digest: [2; 32], + staging_name: ".staging-staged-1".to_owned(), + final_relative: "dev.floatile.clock/0.5.0".to_owned(), + created_at: 2, + }; + fs::create_dir_all(plugin_store.join(&staged.staging_name)).unwrap(); + trust_store.trust().prepare_install(&staged).unwrap(); + let report = recover_trusted_installs(&plugin_store, &trust_store).unwrap(); + assert_eq!(report.aborted, 1); + assert!(!plugin_store.join(&staged.staging_name).exists()); + + let package = signed_pkg_bytes("1.0.0", &signing_key); + let installed = install_trusted_package( + &package, + &plugin_store, + "signed.floatile", + &Default::default(), + &trust_store, + ) + .unwrap(); + let validated = validate_package(&package, &Default::default()).unwrap(); + let renamed = PendingInstallation { + transaction_id: "renamed-1".to_owned(), + publisher_id: "dev.floatile".to_owned(), + plugin_id: "dev.floatile.clock".to_owned(), + version: Version::parse("1.0.0").unwrap(), + signed_digest: signable_content_digest(&validated.files), + install_digest: content_digest(&validated.files), + staging_name: ".staging-renamed-1".to_owned(), + final_relative: "dev.floatile.clock/1.0.0".to_owned(), + created_at: installed.meta.installed_at, + }; + trust_store.trust().prepare_install(&renamed).unwrap(); + let report = recover_trusted_installs(&plugin_store, &trust_store).unwrap(); + assert_eq!(report.finalized, 1); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + assert!(installed.dir.exists()); + } } diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index b8ff529..f1925cf 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -16,13 +16,17 @@ pub mod preview; pub mod project; pub mod run; pub mod test; +pub mod trust; pub use build::{BuildError, build_project, package}; pub use check::{CheckError, CheckPhases, CheckReport, CheckWarning, check_project}; pub use conformance::{ConformanceError, ConformanceReport, lifecycle_report}; pub use dev::{BuildStatus, build_once, dev_loop, ensure_project}; pub use inspect::{InspectError, InspectReport, inspect_package, inspect_package_bytes}; -pub use install::{InstallError, InstalledPackage, install_dir, install_package}; +pub use install::{ + InstallError, InstalledPackage, RecoveryReport, install_dir, install_package, + install_trusted_package, recover_trusted_installs, +}; pub use instance::{ InstanceCommandError, InstanceView, configure_instance, create_instance, delete_instance, get_instance, list_instances, set_instance_desired_state, diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index e755e77..693fda8 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, - preview, project, run, test, + preview, project, run, test, trust, }; use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId}; @@ -14,7 +14,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -25,6 +25,7 @@ fn main() -> ExitCode { "inspect" => cmd_inspect(&args[2..]), "build" => cmd_build(&args[2..]), "install" => cmd_install(&args[2..]), + "trust" => cmd_trust(&args[2..]), "instance" => cmd_instance(&args[2..]), "dev" => cmd_dev(&args[2..]), "test" => cmd_test(&args[2..]), @@ -39,6 +40,52 @@ fn main() -> ExitCode { } } +fn cmd_trust(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + let positionals = match author_positionals(args, &["--db"], &[], 3) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FTRUST_ARGUMENT", &detail, json, true), + }; + let database = match opts_database(args) { + Ok(database) => database, + Err(detail) => return render_basic_error("FTRUST_ARGUMENT", &detail, json, true), + }; + let result = match positionals.as_slice() { + ["show", publisher] => trust::show(&database, publisher), + ["add-key", publisher, public_key] => { + trust::add_key(&database, publisher, public_key, unix_timestamp()) + } + ["revoke-key", publisher, public_key] => { + trust::revoke_key(&database, publisher, public_key, unix_timestamp()) + } + ["revoke-publisher", publisher] => { + trust::revoke_publisher(&database, publisher, unix_timestamp()) + } + _ => { + return render_basic_error( + "FTRUST_ARGUMENT", + "用法: trust [public-key-hex] [--db PATH]", + json, + true, + ); + } + }; + match result { + Ok(view) => { + if json { + println!("{}", serialize_json(&view)); + } else { + println!("trust: {} state={}", view.publisher_id, view.state); + for key in view.keys { + println!(" key={} state={}", key.key_id, key.state); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_conformance(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); if let Err(detail) = author_positionals(args, &[], &[], 0) { @@ -823,10 +870,12 @@ fn cmd_test(args: &[String]) -> ExitCode { fn cmd_install(args: &[String]) -> ExitCode { let json = args.iter().any(|a| a == "--json"); - let positionals = match author_positionals(args, &["--store"], &[], 1) { - Ok(positionals) => positionals, - Err(detail) => return render_basic_error("FINST_ARGUMENT", &detail, json, true), - }; + let require_trusted = args.iter().any(|argument| argument == "--require-trusted"); + let positionals = + match author_positionals(args, &["--store", "--db"], &["--require-trusted"], 1) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FINST_ARGUMENT", &detail, json, true), + }; let Some(path) = positionals.first().map(PathBuf::from) else { return render_basic_error("FINST_ARGUMENT", "install 需要包路径", json, true); }; @@ -844,7 +893,37 @@ fn cmd_install(args: &[String]) -> ExitCode { .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "plugin.floatile".to_owned()); - match install::install_package(&bytes, &store, &source, &package::PackageLimits::default()) { + let result = if require_trusted { + let database = match opts_database(args) { + Ok(database) => database, + Err(message) => return render_basic_error("FINST_ARGUMENT", &message, json, true), + }; + if let Some(parent) = database.parent() + && !parent.as_os_str().is_empty() + && std::fs::create_dir_all(parent).is_err() + { + return render_basic_error("FINST_TRUST_STORE", "无法创建信任数据库目录", json, false); + } + let trust_store = match floatile_store::open(&database) { + Ok(store) => store, + Err(_) => { + return render_basic_error("FINST_TRUST_STORE", "无法打开信任数据库", json, false); + } + }; + if let Err(error) = install::recover_trusted_installs(&store, &trust_store) { + return render_basic_error(error.code(), error.public_detail().as_ref(), json, false); + } + install::install_trusted_package( + &bytes, + &store, + &source, + &package::PackageLimits::default(), + &trust_store, + ) + } else { + install::install_package(&bytes, &store, &source, &package::PackageLimits::default()) + }; + match result { Ok(installed) => { if json { println!( @@ -857,16 +936,22 @@ fn cmd_install(args: &[String]) -> ExitCode { "version": installed.meta.version, "dir": installed.dir.display().to_string(), "digest": installed.meta.digest, + "trust": if require_trusted { "trusted" } else { "unsigned" }, } }) ); } else { println!( - "已安装 {} {} -> {} (digest {})", + "已安装 {} {} -> {} (digest {}, trust={})", installed.manifest.id.0, installed.meta.version, installed.dir.display(), &installed.meta.digest[..12], + if require_trusted { + "trusted" + } else { + "unsigned" + }, ); } ExitCode::SUCCESS @@ -1046,3 +1131,16 @@ fn opts_store(args: &[String]) -> Result { } Err("未指定插件存储:请用 `--store PATH` 或设置环境变量 FLOATTILE_PLUGIN_DIR".to_owned()) } + +/// 解析宿主持久化数据库:优先 `--db PATH`,其次环境变量,最后平台数据目录。 +fn opts_database(args: &[String]) -> Result { + if let Some(path) = option_path(args, "--db") { + return Ok(path); + } + if let Some(path) = std::env::var_os("FLOATTILE_DB_PATH").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path)); + } + floatile_platform::data_dir() + .map(|path| path.join("layout.db")) + .map_err(|error| error.to_string()) +} diff --git a/crates/floatile-cli/src/trust.rs b/crates/floatile-cli/src/trust.rs new file mode 100644 index 0000000..34e97c6 --- /dev/null +++ b/crates/floatile-cli/src/trust.rs @@ -0,0 +1,197 @@ +//! Host-owned publisher trust administration for PP-M8. + +use std::path::Path; + +use floatile_core::install::hex_decode; +use floatile_store::trust::{PublisherTrustRecord, TrustState}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum TrustCommandError { + #[error("publisher id 或 Ed25519 public key 参数非法")] + InvalidArgument, + #[error("无法创建数据库目录: {0}")] + DatabaseDirectory(String), + #[error("trust store 失败: {0}")] + Store(#[from] floatile_store::StoreError), + #[error("publisher trust 不存在")] + NotFound, +} + +impl TrustCommandError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidArgument => "FTRUST_ARGUMENT", + Self::DatabaseDirectory(_) => "FTRUST_DATABASE", + Self::Store(_) => "FTRUST_STORE", + Self::NotFound => "FTRUST_NOT_FOUND", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrustView { + pub publisher_id: String, + pub state: String, + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TrustKeyView { + pub key_id: String, + pub state: String, +} + +pub fn add_key( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + updated_at: u64, +) -> Result { + set_key_state( + database, + publisher_id, + public_key_hex, + TrustState::Active, + updated_at, + ) +} + +pub fn revoke_key( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + updated_at: u64, +) -> Result { + set_key_state( + database, + publisher_id, + public_key_hex, + TrustState::Revoked, + updated_at, + ) +} + +pub fn revoke_publisher( + database: &Path, + publisher_id: &str, + updated_at: u64, +) -> Result { + let store = open_database(database)?; + if !store + .trust() + .set_publisher_state(publisher_id, TrustState::Revoked, updated_at)? + { + return Err(TrustCommandError::NotFound); + } + view(&store, publisher_id) +} + +pub fn show(database: &Path, publisher_id: &str) -> Result { + let store = open_database(database)?; + view(&store, publisher_id) +} + +fn set_key_state( + database: &Path, + publisher_id: &str, + public_key_hex: &str, + state: TrustState, + updated_at: u64, +) -> Result { + let public_key: [u8; 32] = hex_decode(public_key_hex) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or(TrustCommandError::InvalidArgument)?; + let store = open_database(database)?; + store + .trust() + .upsert_key(publisher_id, public_key, state, updated_at)?; + view(&store, publisher_id) +} + +fn open_database(database: &Path) -> Result { + if let Some(parent) = database.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .map_err(|error| TrustCommandError::DatabaseDirectory(error.to_string()))?; + } + floatile_store::open(database).map_err(Into::into) +} + +fn view(store: &floatile_store::Store, publisher_id: &str) -> Result { + store + .trust() + .get(publisher_id)? + .map(TrustView::from) + .ok_or(TrustCommandError::NotFound) +} + +impl From for TrustView { + fn from(record: PublisherTrustRecord) -> Self { + Self { + publisher_id: record.publisher_id, + state: state_name(record.state).to_owned(), + keys: record + .keys + .into_iter() + .map(|key| TrustKeyView { + key_id: key.key_id, + state: state_name(key.state).to_owned(), + }) + .collect(), + } + } +} + +fn state_name(state: TrustState) -> &'static str { + match state { + TrustState::Active => "active", + TrustState::Revoked => "revoked", + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn database(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("floatile-trust-{tag}-{}.db", std::process::id())) + } + + #[test] + fn add_show_and_revoke_never_expose_public_key_bytes() { + let database = database("lifecycle"); + let _ = std::fs::remove_file(&database); + let key = "07".repeat(32); + let added = add_key(&database, "dev.floatile", &key, 1).unwrap(); + assert_eq!(added.state, "active"); + assert_eq!(added.keys[0].state, "active"); + assert!(!serde_json::to_string(&added).unwrap().contains(&key)); + + let revoked = revoke_key(&database, "dev.floatile", &key, 2).unwrap(); + assert_eq!(revoked.keys[0].state, "revoked"); + let publisher = revoke_publisher(&database, "dev.floatile", 3).unwrap(); + assert_eq!(publisher.state, "revoked"); + assert_eq!(show(&database, "dev.floatile").unwrap(), publisher); + let _ = std::fs::remove_file(&database); + } + + #[test] + fn rejects_noncanonical_key_length_and_unknown_publisher() { + let database = database("invalid"); + let _ = std::fs::remove_file(&database); + assert!(matches!( + add_key(&database, "dev.floatile", "00", 1), + Err(TrustCommandError::InvalidArgument) + )); + assert!(matches!( + show(&database, "unknown.publisher"), + Err(TrustCommandError::NotFound) + )); + let _ = std::fs::remove_file(&database); + } +} diff --git a/crates/floatile-core/src/distribution.rs b/crates/floatile-core/src/distribution.rs index ee549f0..0ac987f 100644 --- a/crates/floatile-core/src/distribution.rs +++ b/crates/floatile-core/src/distribution.rs @@ -31,6 +31,8 @@ pub struct TrustedPublisher { pub publisher_id: String, pub state: TrustedPublisherState, pub keys: Vec<[u8; 32]>, + /// Host-revoked key identifiers retained to distinguish revocation from an unknown key. + pub revoked_key_ids: Vec, } #[derive(Debug, Error, PartialEq, Eq)] @@ -49,6 +51,8 @@ pub enum SignatureVerificationError { PublisherMismatch, #[error("package publisher is revoked")] PublisherRevoked, + #[error("package signing key is revoked")] + KeyRevoked, #[error("signature envelope does not reference a trusted publisher key")] UnknownKey, #[error("package signature is invalid")] @@ -136,6 +140,13 @@ pub fn verify_signature_envelope( return Err(SignatureVerificationError::DigestMismatch); } let pae = dsse_pae(&envelope.payload_type, &payload); + if envelope + .signatures + .iter() + .any(|signature| trusted_publisher.revoked_key_ids.contains(&signature.keyid)) + { + return Err(SignatureVerificationError::KeyRevoked); + } let mut matched_trusted_key = false; for signature in &envelope.signatures { @@ -215,6 +226,7 @@ mod tests { publisher_id: "dev.floatile".to_owned(), state: TrustedPublisherState::Active, keys: vec![signing_key.verifying_key().to_bytes()], + revoked_key_ids: Vec::new(), } } @@ -284,6 +296,16 @@ mod tests { Err(SignatureVerificationError::PublisherRevoked) ); + let mut revoked_key = trusted(&signing_key); + revoked_key.keys.clear(); + revoked_key + .revoked_key_ids + .push(publisher_key_id(signing_key.verifying_key().as_bytes())); + assert_eq!( + verify_signature_envelope(&envelope, &files, "dev.floatile", &revoked_key), + Err(SignatureVerificationError::KeyRevoked) + ); + let mut value: serde_json::Value = serde_json::from_slice(&envelope).unwrap(); value["signatures"][0]["sig"] = json!(STANDARD.encode([0; 64])); assert_eq!( diff --git a/crates/floatile-store/src/trust.rs b/crates/floatile-store/src/trust.rs index 70a6167..e20efea 100644 --- a/crates/floatile-store/src/trust.rs +++ b/crates/floatile-store/src/trust.rs @@ -120,6 +120,12 @@ impl PublisherTrustRecord { .filter(|key| key.state == TrustState::Active) .map(|key| key.public_key) .collect(), + revoked_key_ids: self + .keys + .iter() + .filter(|key| key.state == TrustState::Revoked) + .map(|key| key.key_id.clone()) + .collect(), } } } diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index ff0cc07..0d249a3 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -210,6 +210,8 @@ Detected capabilities: | `floatile preview` | 固定 size/DPI/theme/locale 渲染与截图 | | `floatile build` | 可复现地产生 `.floatile`,默认不签名 | | `floatile inspect` | 显示 manifest、版本轴、权限、预算、entry digest | +| `floatile install [--require-trusted]` | 开发模式允许显式 unsigned 安装;分发模式强制 publisher trust、签名、撤销与 anti-rollback | +| `floatile trust add-key/revoke-key/revoke-publisher/show` | 管理宿主持有的 Ed25519 public key 信任;不得接收或显示 private key | | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | | `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | | `floatile instance create/list/get/configure/start/stop/delete` | 按精确安装版本管理持久实例与 desired state | @@ -224,7 +226,7 @@ Detected capabilities: 退出码与诊断 code 稳定;日志文本不是自动化接口。 -当前已落地:`new/validate/check/dev/test/preview/build/install/run/inspect/instance`;`test` 用 +当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance`;`test` 用 `floatile-runtime::harness` 对已构建包跑无头生命周期冒烟。`instance` 子命令提供单一 JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、`--store`、 `--config`/`--config-file`与 `--no-interactive`。`inspect` 在输出前复用完整包安全校验,提供 manifest、 @@ -233,6 +235,11 @@ JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、` `check` 在自动清理的临时目录中复用正式 build + package validation + inspect 链,返回 `metadata/wasm/ui/manifest/package` 五阶段、warning 数组和 inspection 结果;支持相同的三个自动化选项, JSON 失败使用稳定 `FBUILD_*`/`FPAK_*`/`FCHECK_*` code 和不含宿主路径/cargo stderr 的有界描述。 +`install --require-trusted` 从 `--db`/`FLOATTILE_DB_PATH`/平台数据目录读取宿主 trust,强制 detached +DSSE/Ed25519 验证、publisher/key 撤销和 anti-rollback;默认 `install` 保留 PP-M4 本地开发所需的 +`unsigned` 结果并明确输出 trust 状态。受信安装使用 SQLite pending intent 协调 staging/rename/最高版本 +水位,下一次受信安装会先复核并恢复中断事务。`trust` 命令只接收 32 字节 public key hex,输出 key id +和状态,不存储或回显 private key。 `check` 按 Component Model 实际保留的 Floatile interface/function imports 与 Capability Registry 比对: 使用声明能力但 manifest 未声明时以 `FCHECK_CAPABILITY_MISSING` 失败;已声明但未导入时产生 `FCHECK_CAPABILITY_UNUSED` warning,`--deny-warnings` 可将其提升为失败。固有能力无需写入 manifest; From a44275ae659fb13c27326881cb3fc3ed81124513 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 11:59:05 +0800 Subject: [PATCH 06/10] feat(distribution): plan permission-safe upgrades Build deterministic upgrade plans that reject identity and migration regressions, classify capability additions, removals, expansions, and reductions, and require confirmation whenever candidate authority grows. Refs: PP-M8, PP-G2, PP-G3, NFR-SEC-01, NFR-SEC-02, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check; cargo --config 'build.rustc-wrapper=""' check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo clippy --workspace --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo test -p floatile-core --locked Unverified: CLI permission confirmation, Connection binding diff, explicit rollback, Shell trust enforcement, three-host behavior, full workspace tests, and license gate --- crates/floatile-core/src/distribution.rs | 309 ++++++++++++++++++++++- crates/floatile-core/src/lib.rs | 5 +- docs/security/permission-model.md | 13 + 3 files changed, 324 insertions(+), 3 deletions(-) diff --git a/crates/floatile-core/src/distribution.rs b/crates/floatile-core/src/distribution.rs index 0ac987f..ee43960 100644 --- a/crates/floatile-core/src/distribution.rs +++ b/crates/floatile-core/src/distribution.rs @@ -4,7 +4,7 @@ //! signable package bytes were authenticated by a host-trusted publisher key; it never grants a //! capability and it does not replace install-time path or resource validation. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, VerifyingKey}; @@ -13,6 +13,7 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use crate::install::{content_digest, hex_encode}; +use crate::{CapabilityId, CapabilityParams, Manifest, parse_capability_params}; pub const SIGNATURE_FILE: &str = "signature.json"; pub const PACKAGE_DIGEST_PAYLOAD_TYPE: &str = "application/vnd.floatile.package-digest.v1"; @@ -59,6 +60,193 @@ pub enum SignatureVerificationError { InvalidSignature, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionChangeKind { + Added, + Removed, + Expanded, + Reduced, + Unchanged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionChange { + pub capability: CapabilityId, + pub kind: PermissionChangeKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradePlan { + pub current_version: semver::Version, + pub candidate_version: semver::Version, + pub permissions: Vec, + pub requires_confirmation: bool, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum UpgradePlanError { + #[error("upgrade candidate changes plugin id")] + PluginMismatch, + #[error("upgrade candidate changes publisher")] + PublisherMismatch, + #[error("upgrade candidate version must be newer")] + VersionNotNewer, + #[error("upgrade candidate decreases storage migration version")] + StorageMigrationRollback, + #[error("upgrade manifest contains invalid permission: {0}")] + InvalidPermission(String), +} + +/// Builds a deterministic permission and compatibility plan for an in-place upgrade. +pub fn plan_upgrade( + current: &Manifest, + candidate: &Manifest, +) -> Result { + if current.id != candidate.id { + return Err(UpgradePlanError::PluginMismatch); + } + if current.publisher.id != candidate.publisher.id { + return Err(UpgradePlanError::PublisherMismatch); + } + let current_version = semver::Version::parse(¤t.version) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + let candidate_version = semver::Version::parse(&candidate.version) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + if candidate_version <= current_version { + return Err(UpgradePlanError::VersionNotNewer); + } + let current_migration = current + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + let candidate_migration = candidate + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + if candidate_migration < current_migration { + return Err(UpgradePlanError::StorageMigrationRollback); + } + + let current_permissions = parsed_permissions(current)?; + let candidate_permissions = parsed_permissions(candidate)?; + let capabilities: BTreeSet<_> = current_permissions + .keys() + .chain(candidate_permissions.keys()) + .copied() + .collect(); + let mut permissions = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + let kind = match ( + current_permissions.get(&capability), + candidate_permissions.get(&capability), + ) { + (None, Some(_)) => PermissionChangeKind::Added, + (Some(_), None) => PermissionChangeKind::Removed, + (Some(current), Some(candidate)) if current == candidate => { + PermissionChangeKind::Unchanged + } + (Some(current), Some(candidate)) if params_within(candidate, current) => { + PermissionChangeKind::Reduced + } + (Some(_), Some(_)) => PermissionChangeKind::Expanded, + (None, None) => continue, + }; + permissions.push(PermissionChange { capability, kind }); + } + let requires_confirmation = permissions.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added | PermissionChangeKind::Expanded + ) + }); + Ok(UpgradePlan { + current_version, + candidate_version, + permissions, + requires_confirmation, + }) +} + +fn parsed_permissions( + manifest: &Manifest, +) -> Result>, UpgradePlanError> { + manifest + .permissions + .iter() + .map(|permission| { + let capability = CapabilityId::from_name(&permission.capability).ok_or_else(|| { + UpgradePlanError::InvalidPermission(permission.capability.clone()) + })?; + let params = parse_capability_params(capability, permission.params.as_ref()) + .map_err(|error| UpgradePlanError::InvalidPermission(error.to_string()))?; + Ok((capability, params)) + }) + .collect() +} + +fn params_within(candidate: &Option, current: &Option) -> bool { + match (candidate, current) { + (None, None) => true, + ( + Some(CapabilityParams::Storage { + keys: candidate_keys, + max_bytes: candidate_bytes, + }), + Some(CapabilityParams::Storage { + keys: current_keys, + max_bytes: current_bytes, + }), + ) => key_scope_within(candidate_keys, current_keys) && candidate_bytes <= current_bytes, + ( + Some(CapabilityParams::Timer { + max_per_minute: candidate_rate, + max_active: candidate_active, + }), + Some(CapabilityParams::Timer { + max_per_minute: current_rate, + max_active: current_active, + }), + ) => candidate_rate <= current_rate && candidate_active <= current_active, + ( + Some(CapabilityParams::Metrics { + sample_rate_hz: candidate_rate, + }), + Some(CapabilityParams::Metrics { + sample_rate_hz: current_rate, + }), + ) => candidate_rate <= current_rate, + ( + Some(CapabilityParams::Network { + origins: candidate_origins, + max_requests_per_minute: candidate_requests, + max_response_bytes: candidate_bytes, + max_timeout_ms: candidate_timeout, + }), + Some(CapabilityParams::Network { + origins: current_origins, + max_requests_per_minute: current_requests, + max_response_bytes: current_bytes, + max_timeout_ms: current_timeout, + }), + ) => { + candidate_origins + .iter() + .all(|origin| current_origins.contains(origin)) + && candidate_requests <= current_requests + && candidate_bytes <= current_bytes + && candidate_timeout <= current_timeout + } + _ => false, + } +} + +fn key_scope_within(candidate: &[String], current: &[String]) -> bool { + if current.is_empty() { + return true; + } + !candidate.is_empty() && candidate.iter().all(|key| current.contains(key)) +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct SignatureEnvelope { @@ -394,4 +582,123 @@ mod tests { b"DSSEv1 10 text/plain 5 hello" ); } + + fn upgrade_manifest( + version: &str, + permissions: serde_json::Value, + migration_version: u64, + ) -> Manifest { + serde_json::from_value(json!({ + "manifestVersion": 1, + "id": "dev.floatile.clock", + "name": "Clock", + "version": version, + "publisher": { "id": "dev.floatile", "name": "Floatile" }, + "engineApiVersion": "1.0.0", + "uiApiVersion": "1.0.0", + "type": "widget", + "entrypoints": { "ui": "ui/widget.ftui", "logic": "logic/plugin.wasm" }, + "sizes": { + "default": { "width": 240.0, "height": 120.0 }, + "min": { "width": 100.0, "height": 60.0 }, + "max": { "width": 800.0, "height": 600.0 }, + "resizable": true + }, + "permissions": permissions, + "storage": { "migration_version": migration_version } + })) + .unwrap() + } + + #[test] + fn upgrade_plan_requires_confirmation_for_added_or_expanded_permissions() { + let current = upgrade_manifest( + "1.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 10, "maxActive": 2 } + }]), + 1, + ); + let candidate = upgrade_manifest( + "2.0.0", + json!([ + { + "capability": "timer:schedule", + "params": { "maxPerMinute": 20, "maxActive": 2 } + }, + { "capability": "theme:subscribe" } + ]), + 2, + ); + let plan = plan_upgrade(¤t, &candidate).unwrap(); + assert!(plan.requires_confirmation); + assert_eq!( + plan.permissions, + vec![ + PermissionChange { + capability: CapabilityId::TimerSchedule, + kind: PermissionChangeKind::Expanded, + }, + PermissionChange { + capability: CapabilityId::ThemeSubscribe, + kind: PermissionChangeKind::Added, + } + ] + ); + } + + #[test] + fn upgrade_plan_allows_removed_and_strictly_reduced_permissions() { + let current = upgrade_manifest( + "1.0.0", + json!([ + { + "capability": "storage:read", + "params": { "keys": [], "maxBytes": 4096 } + }, + { "capability": "theme:subscribe" } + ]), + 2, + ); + let candidate = upgrade_manifest( + "1.1.0", + json!([{ + "capability": "storage:read", + "params": { "keys": ["clock"], "maxBytes": 1024 } + }]), + 2, + ); + let plan = plan_upgrade(¤t, &candidate).unwrap(); + assert!(!plan.requires_confirmation); + assert!(plan.permissions.iter().any(|change| { + change.capability == CapabilityId::StorageRead + && change.kind == PermissionChangeKind::Reduced + })); + assert!(plan.permissions.iter().any(|change| { + change.capability == CapabilityId::ThemeSubscribe + && change.kind == PermissionChangeKind::Removed + })); + } + + #[test] + fn upgrade_plan_rejects_identity_version_and_migration_regressions() { + let current = upgrade_manifest("2.0.0", json!([]), 2); + let old = upgrade_manifest("1.0.0", json!([]), 2); + assert_eq!( + plan_upgrade(¤t, &old), + Err(UpgradePlanError::VersionNotNewer) + ); + let migration = upgrade_manifest("3.0.0", json!([]), 1); + assert_eq!( + plan_upgrade(¤t, &migration), + Err(UpgradePlanError::StorageMigrationRollback) + ); + let mut publisher = upgrade_manifest("3.0.0", json!([]), 2); + publisher.publisher.id = "other.publisher".to_owned(); + assert_eq!( + plan_upgrade(¤t, &publisher), + Err(UpgradePlanError::PublisherMismatch) + ); + } } diff --git a/crates/floatile-core/src/lib.rs b/crates/floatile-core/src/lib.rs index 0fe4163..777830f 100644 --- a/crates/floatile-core/src/lib.rs +++ b/crates/floatile-core/src/lib.rs @@ -21,8 +21,9 @@ pub use connection::{ MAX_CREDENTIAL_REF_BYTES, }; pub use distribution::{ - PACKAGE_DIGEST_PAYLOAD_TYPE, SIGNATURE_FILE, SignatureVerificationError, TrustedPublisher, - TrustedPublisherState, dsse_pae, publisher_key_id, signable_content_digest, + PACKAGE_DIGEST_PAYLOAD_TYPE, PermissionChange, PermissionChangeKind, SIGNATURE_FILE, + SignatureVerificationError, TrustedPublisher, TrustedPublisherState, UpgradePlan, + UpgradePlanError, dsse_pae, plan_upgrade, publisher_key_id, signable_content_digest, verify_signature_envelope, }; pub use instance::{ diff --git a/docs/security/permission-model.md b/docs/security/permission-model.md index 871e23f..1d12229 100644 --- a/docs/security/permission-model.md +++ b/docs/security/permission-model.md @@ -68,6 +68,19 @@ pub struct Grants { - **实例级收窄**:安装时授予的是「上限」,运行时可被权限管理器收窄,不得放宽。 - **密钥/凭证永远不进入插件**:插件只能持有 credential reference(`cred://`),由宿主注入(见 HTTP Broker)。 +### 2.1 升级权限差异 + +宿主按 capability 的稳定名称和结构化参数比较当前 manifest 与候选 manifest,并输出确定性的 +`Added | Removed | Expanded | Reduced | Unchanged` 差异: + +- 新增 capability,或扩大 key/origin scope、频率、并发、响应大小、超时与存储配额,必须重新确认; +- 移除 capability 或严格收窄全部 scope/配额可以继续升级,不得借降权恢复旧 grant; +- 参数一部分收窄但另一部分扩大时,整体按 `Expanded` 处理;未知 capability 或非法参数直接拒绝; +- 插件 id、publisher 必须保持不变,候选 semver 必须更高,storage migration version 不得倒退。 + +该差异只决定安装/升级是否需要确认,不替代运行时 `PermissionBroker` 决策。Connection 绑定变化仍 +作为独立授权变化重新确认,不能由相同 manifest permission 掩盖。 + ## 3. 决策模型(Check → Decide → Execute) ``` From edb5c368956a10f64fbc5ab14b5485b66718d64f Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 12:03:57 +0800 Subject: [PATCH 07/10] feat(cli): confirm permission-expanding upgrades Compare trusted upgrade candidates with the highest verified installation before staging. Refuse added or expanded authority until the caller passes an explicit acceptance flag, while reporting deterministic permission changes and allowing pure reductions. Refs: PP-M8, PP-G2, PP-G3, NFR-SEC-01, NFR-SEC-02, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo test -p floatile-cli install --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test author_output --locked; RUSTC_WRAPPER= cargo clippy -p floatile-cli --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: interactive confirmation UI, Connection binding diff, explicit rollback, Shell trust enforcement, three-host behavior, full workspace tests, and license gate --- crates/floatile-cli/src/install.rs | 157 +++++++++++++++++++- crates/floatile-cli/src/main.rs | 71 ++++++++- docs/plugin-sdk/sdk-developer-experience.md | 6 +- docs/product/requirements.md | 2 +- 4 files changed, 224 insertions(+), 12 deletions(-) diff --git a/crates/floatile-cli/src/install.rs b/crates/floatile-cli/src/install.rs index 1239e36..7b27a4d 100644 --- a/crates/floatile-cli/src/install.rs +++ b/crates/floatile-cli/src/install.rs @@ -15,11 +15,13 @@ use std::io::Write; use std::path::{Path, PathBuf}; use floatile_core::distribution::{ - SIGNATURE_FILE, SignatureVerificationError, signable_content_digest, verify_signature_envelope, + SIGNATURE_FILE, SignatureVerificationError, UpgradePlan, UpgradePlanError, plan_upgrade, + signable_content_digest, verify_signature_envelope, }; use floatile_core::install::{InstallMeta, content_digest, file_digest, hex_encode}; use floatile_core::manifest::Manifest; use floatile_store::Store; +use floatile_store::installation::{InstallationCatalogError, load_highest}; use floatile_store::trust::{PendingInstallation, TrustPolicyError}; use semver::Version; use thiserror::Error; @@ -53,6 +55,12 @@ pub enum InstallError { RecoveryRequired(String), #[error("恢复安装事务失败: {0}")] Recovery(String), + #[error("升级兼容性检查失败: {0}")] + Upgrade(#[from] UpgradePlanError), + #[error("升级扩大权限,必须显式确认")] + PermissionConfirmationRequired(UpgradePlan), + #[error("读取当前安装失败: {0}")] + InstallationCatalog(#[from] InstallationCatalogError), } impl InstallError { @@ -83,6 +91,9 @@ impl InstallError { }, Self::RecoveryRequired(_) => "FINST_RECOVERY_REQUIRED", Self::Recovery(_) => "FINST_RECOVERY_FAILED", + Self::Upgrade(_) => "FINST_UPGRADE_INCOMPATIBLE", + Self::PermissionConfirmationRequired(_) => "FINST_PERMISSION_CONFIRMATION", + Self::InstallationCatalog(_) => "FINST_INSTALLED_INVALID", } } @@ -102,6 +113,11 @@ impl InstallError { Self::TrustPolicy(_) => Cow::Borrowed("插件安装被信任或版本策略拒绝"), Self::RecoveryRequired(_) => Cow::Borrowed("安装需要在下次运行时恢复"), Self::Recovery(_) => Cow::Borrowed("无法安全恢复上次安装事务"), + Self::Upgrade(_) => Cow::Borrowed("候选包与当前安装不兼容"), + Self::PermissionConfirmationRequired(_) => { + Cow::Borrowed("升级新增或扩大权限,需要显式确认") + } + Self::InstallationCatalog(_) => Cow::Borrowed("当前安装未通过完整性校验"), } } } @@ -112,6 +128,7 @@ pub struct InstalledPackage { pub dir: PathBuf, pub manifest: Manifest, pub meta: InstallMeta, + pub upgrade: Option, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -152,6 +169,7 @@ pub fn install_trusted_package( source: &str, limits: &PackageLimits, trust_store: &Store, + accept_permission_expansion: bool, ) -> Result { let validated = validate_package(bytes, limits)?; let publisher_id = validated.manifest.publisher.id.as_str(); @@ -182,6 +200,22 @@ pub fn install_trusted_package( version: manifest.version, }); } + let upgrade = match load_highest(plugin_store, &manifest.id.0)? { + Some(current) + if Version::parse(&manifest.version) + .map_err(|error| InstallError::Commit(format!("validated version: {error}")))? + > Version::parse(¤t.manifest.version).map_err(|error| { + InstallError::Commit(format!("installed version passed validation: {error}")) + })? => + { + let plan = plan_upgrade(¤t.manifest, &manifest)?; + if plan.requires_confirmation && !accept_permission_expansion { + return Err(InstallError::PermissionConfirmationRequired(plan)); + } + Some(plan) + } + _ => None, + }; let parent = final_dir.parent().unwrap_or(plugin_store); fs::create_dir_all(parent) .map_err(|error| InstallError::StoreUnavailable(error.to_string()))?; @@ -226,6 +260,7 @@ pub fn install_trusted_package( dir: final_dir, manifest, meta, + upgrade, }) } @@ -347,6 +382,7 @@ fn install_validated( dir: final_dir, manifest, meta, + upgrade: None, }) } @@ -520,6 +556,41 @@ mod tests { signed_pkg_with_manifest_versions(version, version, signing_key) } + fn signed_pkg_with_permissions( + version: &str, + permissions: serde_json::Value, + signing_key: &SigningKey, + ) -> Vec { + let wasm = real_wasm(); + let mut manifest: serde_json::Value = serde_json::from_str(&valid_manifest_json()).unwrap(); + manifest["version"] = serde_json::json!(version); + manifest["permissions"] = permissions; + let manifest = manifest.to_string().into_bytes(); + let ui = valid_ui_ir().into_bytes(); + let mut files = BTreeMap::from([ + ("manifest.json".to_owned(), manifest), + ("ui/widget.ftui".to_owned(), ui), + ("logic/plugin.wasm".to_owned(), wasm), + ]); + let digest = content_digest(&files); + let signature = signing_key.sign(&dsse_pae(PACKAGE_DIGEST_PAYLOAD_TYPE, &digest)); + let envelope = serde_json::to_vec(&serde_json::json!({ + "payloadType": PACKAGE_DIGEST_PAYLOAD_TYPE, + "payload": STANDARD.encode(digest), + "signatures": [{ + "keyid": publisher_key_id(signing_key.verifying_key().as_bytes()), + "sig": STANDARD.encode(signature.to_bytes()) + }] + })) + .unwrap(); + files.insert(SIGNATURE_FILE.to_owned(), envelope); + let entries: Vec<_> = files + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())) + .collect(); + build_zip(&entries) + } + fn signed_pkg_with_manifest_versions( signed_version: &str, packaged_version: &str, @@ -713,6 +784,7 @@ mod tests { "signed.floatile", &Default::default(), &trust_store, + false, ) .unwrap(); assert!(installed.dir.join(SIGNATURE_FILE).is_file()); @@ -745,7 +817,8 @@ mod tests { &plugin_store, "unsigned.floatile", &Default::default(), - &trust_store + &trust_store, + false ), Err(InstallError::MissingSignature) )); @@ -757,6 +830,7 @@ mod tests { "tampered.floatile", &Default::default(), &trust_store, + false, ) .unwrap_err(); assert_eq!(tampered_error.code(), "FINST_SIGNATURE_DIGEST"); @@ -767,6 +841,7 @@ mod tests { "v2.floatile", &Default::default(), &trust_store, + false, ) .unwrap(); let downgrade = install_trusted_package( @@ -775,6 +850,7 @@ mod tests { "v1.floatile", &Default::default(), &trust_store, + false, ) .unwrap_err(); assert_eq!(downgrade.code(), "FINST_ROLLBACK"); @@ -782,6 +858,82 @@ mod tests { assert!(trust_store.trust().pending_installs().unwrap().is_empty()); } + #[test] + fn trusted_upgrade_requires_explicit_permission_expansion_confirmation() { + let plugin_store = temp_store("trusted-permissions"); + let trust_store = floatile_store::open(":memory:").unwrap(); + let signing_key = SigningKey::from_bytes(&[24; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + + let expanded = signed_pkg_with_permissions( + "2.0.0", + serde_json::json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 120, "maxActive": 4 } + }]), + &signing_key, + ); + let rejected = install_trusted_package( + &expanded, + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap_err(); + assert_eq!(rejected.code(), "FINST_PERMISSION_CONFIRMATION"); + assert!(!install_dir(&plugin_store, "dev.floatile.clock", "2.0.0").exists()); + assert!(trust_store.trust().pending_installs().unwrap().is_empty()); + + let installed = install_trusted_package( + &expanded, + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + true, + ) + .unwrap(); + assert!(installed.upgrade.unwrap().requires_confirmation); + + let reduced = signed_pkg_with_permissions( + "3.0.0", + serde_json::json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 30, "maxActive": 1 } + }]), + &signing_key, + ); + let installed = install_trusted_package( + &reduced, + &plugin_store, + "v3.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + assert!(!installed.upgrade.unwrap().requires_confirmation); + } + #[test] fn recovery_aborts_staging_and_finalizes_verified_renamed_install() { let plugin_store = temp_store("trusted-recovery"); @@ -821,6 +973,7 @@ mod tests { "signed.floatile", &Default::default(), &trust_store, + false, ) .unwrap(); let validated = validate_package(&package, &Default::default()).unwrap(); diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index 693fda8..17d2c76 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -8,7 +8,7 @@ use floatile_cli::{ CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, preview, project, run, test, trust, }; -use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId}; +use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId, PermissionChangeKind}; fn main() -> ExitCode { let args: Vec = std::env::args().collect(); @@ -871,11 +871,18 @@ fn cmd_test(args: &[String]) -> ExitCode { fn cmd_install(args: &[String]) -> ExitCode { let json = args.iter().any(|a| a == "--json"); let require_trusted = args.iter().any(|argument| argument == "--require-trusted"); - let positionals = - match author_positionals(args, &["--store", "--db"], &["--require-trusted"], 1) { - Ok(positionals) => positionals, - Err(detail) => return render_basic_error("FINST_ARGUMENT", &detail, json, true), - }; + let accept_permissions = args + .iter() + .any(|argument| argument == "--accept-permissions"); + let positionals = match author_positionals( + args, + &["--store", "--db"], + &["--require-trusted", "--accept-permissions"], + 1, + ) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FINST_ARGUMENT", &detail, json, true), + }; let Some(path) = positionals.first().map(PathBuf::from) else { return render_basic_error("FINST_ARGUMENT", "install 需要包路径", json, true); }; @@ -919,12 +926,14 @@ fn cmd_install(args: &[String]) -> ExitCode { &source, &package::PackageLimits::default(), &trust_store, + accept_permissions, ) } else { install::install_package(&bytes, &store, &source, &package::PackageLimits::default()) }; match result { Ok(installed) => { + let upgrade = installed.upgrade.as_ref().map(upgrade_json); if json { println!( "{}", @@ -937,6 +946,7 @@ fn cmd_install(args: &[String]) -> ExitCode { "dir": installed.dir.display().to_string(), "digest": installed.meta.digest, "trust": if require_trusted { "trusted" } else { "unsigned" }, + "upgrade": upgrade, } }) ); @@ -953,13 +963,60 @@ fn cmd_install(args: &[String]) -> ExitCode { "unsigned" }, ); + if let Some(plan) = &installed.upgrade { + println!( + " upgrade {} -> {} permission_confirmation={}", + plan.current_version, plan.candidate_version, plan.requires_confirmation + ); + for change in &plan.permissions { + println!(" {} {:?}", change.capability.name(), change.kind); + } + } } ExitCode::SUCCESS } - Err(error) => render_basic_error(error.code(), error.public_detail().as_ref(), json, false), + Err(error) => { + if let install::InstallError::PermissionConfirmationRequired(plan) = &error { + let detail = error.public_detail(); + if json { + let report = CommandErrorReport::new( + error.code(), + detail.as_ref(), + serde_json::json!({ "upgrade": upgrade_json(plan) }), + ); + eprintln!("{}", serialize_json(&report)); + } else { + eprintln!("命令失败: code={} detail={detail}", error.code()); + for change in &plan.permissions { + eprintln!(" {} {:?}", change.capability.name(), change.kind); + } + } + ExitCode::FAILURE + } else { + render_basic_error(error.code(), error.public_detail().as_ref(), json, false) + } + } } } +fn upgrade_json(plan: &floatile_core::UpgradePlan) -> serde_json::Value { + serde_json::json!({ + "currentVersion": plan.current_version.to_string(), + "candidateVersion": plan.candidate_version.to_string(), + "requiresConfirmation": plan.requires_confirmation, + "permissions": plan.permissions.iter().map(|change| serde_json::json!({ + "capability": change.capability, + "change": match change.kind { + PermissionChangeKind::Added => "added", + PermissionChangeKind::Removed => "removed", + PermissionChangeKind::Expanded => "expanded", + PermissionChangeKind::Reduced => "reduced", + PermissionChangeKind::Unchanged => "unchanged", + } + })).collect::>() + }) +} + fn cmd_preview(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let positionals = match author_positionals(args, &["--duration-ms"], &[], 1) { diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 0d249a3..7b24fac 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -210,7 +210,7 @@ Detected capabilities: | `floatile preview` | 固定 size/DPI/theme/locale 渲染与截图 | | `floatile build` | 可复现地产生 `.floatile`,默认不签名 | | `floatile inspect` | 显示 manifest、版本轴、权限、预算、entry digest | -| `floatile install [--require-trusted]` | 开发模式允许显式 unsigned 安装;分发模式强制 publisher trust、签名、撤销与 anti-rollback | +| `floatile install [--require-trusted] [--accept-permissions]` | 开发模式允许显式 unsigned 安装;分发模式强制 publisher trust、签名、撤销、anti-rollback 与升级权限确认 | | `floatile trust add-key/revoke-key/revoke-publisher/show` | 管理宿主持有的 Ed25519 public key 信任;不得接收或显示 private key | | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | | `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | @@ -239,7 +239,9 @@ JSON 失败使用稳定 `FBUILD_*`/`FPAK_*`/`FCHECK_*` code 和不含宿主路 DSSE/Ed25519 验证、publisher/key 撤销和 anti-rollback;默认 `install` 保留 PP-M4 本地开发所需的 `unsigned` 结果并明确输出 trust 状态。受信安装使用 SQLite pending intent 协调 staging/rename/最高版本 水位,下一次受信安装会先复核并恢复中断事务。`trust` 命令只接收 32 字节 public key hex,输出 key id -和状态,不存储或回显 private key。 +和状态,不存储或回显 private key。受信升级会比较当前最高安装的 manifest:新增 capability 或扩大 +scope/配额返回 `FINST_PERMISSION_CONFIRMATION` 且零落盘;调用方必须显式传 +`--accept-permissions` 才能继续。纯移除/收窄无需该参数,成功输出包含逐 capability 的 upgrade diff。 `check` 按 Component Model 实际保留的 Floatile interface/function imports 与 Capability Registry 比对: 使用声明能力但 manifest 未声明时以 `FCHECK_CAPABILITY_MISSING` 失败;已声明但未导入时产生 `FCHECK_CAPABILITY_UNUSED` warning,`--deny-warnings` 可将其提升为失败。固有能力无需写入 manifest; diff --git a/docs/product/requirements.md b/docs/product/requirements.md index bccb419..5067e7c 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -64,7 +64,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | | 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | -| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier,`floatile-store` 已持久化宿主持有的 publisher/key trust 与 anti-rollback 最高版本/摘要;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名安装策略、权限 diff、更新与显式回滚仍待接入 | +| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier与确定性升级权限 diff,`floatile-store` 已持久化宿主持有的 publisher/key trust、anti-rollback 最高版本/摘要与安装 journal;CLI 受信安装已强制签名/撤销/anti-rollback,并要求显式接受权限扩大;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;显式回滚与 Shell 受信加载仍待接入 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | ## 6. 阶段门槛与未决策 From 4b98d25c7ab97afaa67233cd5044b30680e0c30a Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 12:09:59 +0800 Subject: [PATCH 08/10] feat(distribution): add explicit trusted rollback Rebind stopped instances to a currently trusted historical installation only after digest, signature, identity, migration, and permission checks. Persist the instance change and bounded rollback reason atomically while preserving the anti-rollback high-water mark. Refs: PP-M8, PP-G2, PP-G3, NFR-SEC-01, NFR-SEC-02, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo test -p floatile-core distribution --locked; RUSTC_WRAPPER= cargo test -p floatile-store --locked; RUSTC_WRAPPER= cargo test -p floatile-cli explicit_rollback --locked; RUSTC_WRAPPER= cargo clippy --workspace --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: Connection binding rollback diff, Shell trust enforcement, three-host behavior, full workspace tests, and license gate --- crates/floatile-cli/src/install.rs | 90 ++++++++++ crates/floatile-cli/src/instance.rs | 57 +++++- crates/floatile-cli/src/lib.rs | 4 +- crates/floatile-cli/src/main.rs | 22 ++- crates/floatile-core/src/distribution.rs | 142 +++++++++++++-- crates/floatile-core/src/lib.rs | 8 +- crates/floatile-store/src/lib.rs | 186 +++++++++++++++++++- docs/plugin-sdk/sdk-developer-experience.md | 4 + docs/product/requirements.md | 2 +- 9 files changed, 490 insertions(+), 25 deletions(-) diff --git a/crates/floatile-cli/src/install.rs b/crates/floatile-cli/src/install.rs index 7b27a4d..6f60c0f 100644 --- a/crates/floatile-cli/src/install.rs +++ b/crates/floatile-cli/src/install.rs @@ -21,6 +21,7 @@ use floatile_core::distribution::{ use floatile_core::install::{InstallMeta, content_digest, file_digest, hex_encode}; use floatile_core::manifest::Manifest; use floatile_store::Store; +use floatile_store::installation::InstalledInstallation; use floatile_store::installation::{InstallationCatalogError, load_highest}; use floatile_store::trust::{PendingInstallation, TrustPolicyError}; use semver::Version; @@ -264,6 +265,31 @@ pub fn install_trusted_package( }) } +/// Re-verifies an installed package against current host trust before rollback or execution. +pub fn verify_trusted_installation( + installed: &InstalledInstallation, + trust_store: &Store, +) -> Result<(), InstallError> { + let publisher_id = installed.manifest.publisher.id.as_str(); + let trust = trust_store + .trust() + .get(publisher_id) + .map_err(TrustPolicyError::Store)? + .ok_or(InstallError::UnknownPublisher)?; + let mut files = BTreeMap::new(); + for name in installed.meta.files.keys() { + let bytes = installed + .file(name) + .ok_or_else(|| InstallError::Commit(format!("installed file missing: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(InstallError::MissingSignature)?; + verify_signature_envelope(envelope, &files, publisher_id, &trust.verifier_binding())?; + Ok(()) +} + /// Reconciles crash-interrupted trusted installs before a new trusted install begins. pub fn recover_trusted_installs( plugin_store: &Path, @@ -934,6 +960,70 @@ mod tests { assert!(!installed.upgrade.unwrap().requires_confirmation); } + #[test] + fn explicit_rollback_rebinds_instance_without_lowering_watermark() { + let root = temp_store("explicit-rollback"); + let plugin_store = root.join("plugins"); + fs::create_dir_all(&plugin_store).unwrap(); + let database = root.join("floatile.db"); + let trust_store = floatile_store::open(&database).unwrap(); + let signing_key = SigningKey::from_bytes(&[25; 32]); + trust_store + .trust() + .upsert_key( + "dev.floatile", + signing_key.verifying_key().to_bytes(), + TrustState::Active, + 1, + ) + .unwrap(); + let v1 = install_trusted_package( + &signed_pkg_bytes("1.0.0", &signing_key), + &plugin_store, + "v1.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + let v2 = install_trusted_package( + &signed_pkg_bytes("2.0.0", &signing_key), + &plugin_store, + "v2.floatile", + &Default::default(), + &trust_store, + false, + ) + .unwrap(); + assert!(v1.upgrade.is_none()); + let instance = trust_store + .instances() + .create( + &floatile_core::InstallationRef::from_install_meta(&v2.meta).unwrap(), + &floatile_core::InstanceConfig::empty(), + floatile_core::InstanceDesiredState::Stopped, + 10, + ) + .unwrap(); + + let rolled_back = crate::instance::rollback_instance( + &database, + &plugin_store, + instance.id(), + "1.0.0", + "2.0.0 rendering regression", + 11, + ) + .unwrap(); + assert_eq!(rolled_back.version, "1.0.0"); + let accepted = trust_store + .trust() + .accepted_package("dev.floatile", "dev.floatile.clock") + .unwrap() + .unwrap(); + assert_eq!(accepted.version, Version::parse("2.0.0").unwrap()); + } + #[test] fn recovery_aborts_staging_and_finalizes_verified_renamed_install() { let plugin_store = temp_store("trusted-recovery"); diff --git a/crates/floatile-cli/src/instance.rs b/crates/floatile-cli/src/instance.rs index f06ddf0..7095e70 100644 --- a/crates/floatile-cli/src/instance.rs +++ b/crates/floatile-cli/src/instance.rs @@ -3,7 +3,8 @@ use std::path::Path; use floatile_core::{ - InstanceConfig, InstanceDesiredState, InstanceId, PluginInstance, instance::InstanceModelError, + InstanceConfig, InstanceDesiredState, InstanceId, PluginInstance, RollbackPlanError, + instance::InstanceModelError, plan_rollback, }; use floatile_store::installation::{ ConfigValidationError, InstallationCatalogError, InstalledInstallation, load_exact, @@ -71,6 +72,10 @@ pub enum InstanceCommandError { ConcurrentUpdate(u64), #[error("无法创建实例数据库目录: {0}")] DatabaseDirectory(String), + #[error("回滚策略拒绝: {0}")] + Rollback(#[from] RollbackPlanError), + #[error("历史安装信任校验失败: {0}")] + Trust(#[from] crate::install::InstallError), } impl InstanceCommandError { @@ -88,6 +93,8 @@ impl InstanceCommandError { Self::MustBeStopped(_) => "FINSTANCE_MUST_BE_STOPPED", Self::ConcurrentUpdate(_) => "FINSTANCE_CONCURRENT_UPDATE", Self::DatabaseDirectory(_) => "FINSTANCE_DATABASE_DIRECTORY", + Self::Rollback(_) => "FINSTANCE_ROLLBACK_REJECTED", + Self::Trust(error) => error.code(), } } } @@ -197,6 +204,54 @@ pub fn delete_instance( Ok(InstanceView::from(&instance)) } +/// Rebinds a stopped instance to a trusted, verified historical installation. +pub fn rollback_instance( + database: &Path, + plugin_store: &Path, + instance_id: InstanceId, + target_version: &str, + reason: &str, + unix_ts: u64, +) -> Result { + if reason.is_empty() || reason.len() > 512 { + return Err(InstanceCommandError::InvalidArguments( + "rollback 需要 1..=512 字节的 --reason".to_owned(), + )); + } + let store = open_database(database)?; + let instance = require_instance(&store, instance_id)?; + require_stopped(&instance)?; + let current = load_reference(plugin_store, instance.installation())?.ok_or_else(|| { + InstanceCommandError::InstallationMissing { + id: instance.installation().plugin().0.clone(), + version: instance.installation().version().to_owned(), + } + })?; + let target = load_exact( + plugin_store, + &instance.installation().plugin().0, + target_version, + )? + .ok_or_else(|| InstanceCommandError::InstallationMissing { + id: instance.installation().plugin().0.clone(), + version: target_version.to_owned(), + })?; + plan_rollback(¤t.manifest, &target.manifest)?; + validate_config(&target, instance.config())?; + crate::install::verify_trusted_installation(&target, &store)?; + let target_ref = target.reference()?; + if !store.instances().rollback_installation( + instance_id, + instance.installation(), + &target_ref, + reason, + unix_ts.max(instance.updated_at()), + )? { + return Err(InstanceCommandError::ConcurrentUpdate(instance_id.0)); + } + get_from_store(&store, instance_id) +} + fn validate_config( installation: &InstalledInstallation, config: &InstanceConfig, diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index f1925cf..0c08715 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -25,11 +25,11 @@ pub use dev::{BuildStatus, build_once, dev_loop, ensure_project}; pub use inspect::{InspectError, InspectReport, inspect_package, inspect_package_bytes}; pub use install::{ InstallError, InstalledPackage, RecoveryReport, install_dir, install_package, - install_trusted_package, recover_trusted_installs, + install_trusted_package, recover_trusted_installs, verify_trusted_installation, }; pub use instance::{ InstanceCommandError, InstanceView, configure_instance, create_instance, delete_instance, - get_instance, list_instances, set_instance_desired_state, + get_instance, list_instances, rollback_instance, set_instance_desired_state, }; pub use output::{CommandErrorReport, CommandWarning, OUTPUT_SCHEMA_VERSION}; pub use package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index 17d2c76..5e2e21f 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -359,6 +359,21 @@ fn cmd_instance(args: &[String]) -> ExitCode { }), "delete" => instance_id(&options) .and_then(|id| instance::delete_instance(&database, id).map(InstanceOutput::Deleted)), + "rollback" => instance_id(&options).and_then(|id| { + let version = options.version.as_deref().ok_or_else(|| { + instance::InstanceCommandError::InvalidArguments( + "rollback 需要 --version".to_owned(), + ) + })?; + let reason = options.reason.as_deref().ok_or_else(|| { + instance::InstanceCommandError::InvalidArguments( + "rollback 需要 --reason".to_owned(), + ) + })?; + let store = options.plugin_store()?; + instance::rollback_instance(&database, &store, id, version, reason, unix_timestamp()) + .map(InstanceOutput::One) + }), _ => { print_instance_usage(); return ExitCode::from(2); @@ -497,6 +512,7 @@ struct InstanceCliOptions { config_file: Option, database: Option, store: Option, + reason: Option, json: bool, start: bool, } @@ -510,7 +526,7 @@ impl InstanceCliOptions { "--json" => parsed.json = true, "--no-interactive" => {} "--start" => parsed.start = true, - "--version" | "--config" | "--config-file" | "--db" | "--store" => { + "--version" | "--config" | "--config-file" | "--db" | "--store" | "--reason" => { let name = args[index].as_str(); index += 1; let value = args.get(index).ok_or_else(|| format!("{name} 缺少值"))?; @@ -522,6 +538,8 @@ impl InstanceCliOptions { parsed.config_file = Some(PathBuf::from(value)); } else if name == "--db" { parsed.database = Some(PathBuf::from(value)); + } else if name == "--reason" { + parsed.reason = Some(value.clone()); } else { parsed.store = Some(PathBuf::from(value)); } @@ -602,7 +620,7 @@ fn unix_timestamp() -> u64 { fn print_instance_usage() { eprintln!( - "用法:\n floatile instance create --version [--config |--config-file ] [--start] [--db PATH] [--store PATH] [--json]\n floatile instance [instance-id] [--db PATH] [--json]\n floatile instance configure (--config |--config-file ) [--db PATH] [--store PATH] [--json]" + "用法:\n floatile instance create --version [--config |--config-file ] [--start] [--db PATH] [--store PATH] [--json]\n floatile instance [instance-id] [--db PATH] [--json]\n floatile instance configure (--config |--config-file ) [--db PATH] [--store PATH] [--json]\n floatile instance rollback --version --reason [--db PATH] [--store PATH] [--json]" ); } diff --git a/crates/floatile-core/src/distribution.rs b/crates/floatile-core/src/distribution.rs index ee43960..2fe44b3 100644 --- a/crates/floatile-core/src/distribution.rs +++ b/crates/floatile-core/src/distribution.rs @@ -83,6 +83,13 @@ pub struct UpgradePlan { pub requires_confirmation: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RollbackPlan { + pub current_version: semver::Version, + pub target_version: semver::Version, + pub permissions: Vec, +} + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum UpgradePlanError { #[error("upgrade candidate changes plugin id")] @@ -97,6 +104,22 @@ pub enum UpgradePlanError { InvalidPermission(String), } +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RollbackPlanError { + #[error("rollback target changes plugin id")] + PluginMismatch, + #[error("rollback target changes publisher")] + PublisherMismatch, + #[error("rollback target version must be older")] + VersionNotOlder, + #[error("rollback target cannot read the current storage migration")] + StorageMigrationIncompatible, + #[error("rollback would restore previously removed or reduced permissions")] + PermissionExpansion, + #[error("rollback manifest contains invalid permission: {0}")] + InvalidPermission(String), +} + /// Builds a deterministic permission and compatibility plan for an in-place upgrade. pub fn plan_upgrade( current: &Manifest, @@ -127,8 +150,74 @@ pub fn plan_upgrade( return Err(UpgradePlanError::StorageMigrationRollback); } - let current_permissions = parsed_permissions(current)?; - let candidate_permissions = parsed_permissions(candidate)?; + let permissions = + permission_changes(current, candidate).map_err(UpgradePlanError::InvalidPermission)?; + let requires_confirmation = permissions.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added | PermissionChangeKind::Expanded + ) + }); + Ok(UpgradePlan { + current_version, + candidate_version, + permissions, + requires_confirmation, + }) +} + +/// Plans an explicit rollback without lowering anti-rollback state or restoring old authority. +pub fn plan_rollback( + current: &Manifest, + target: &Manifest, +) -> Result { + if current.id != target.id { + return Err(RollbackPlanError::PluginMismatch); + } + if current.publisher.id != target.publisher.id { + return Err(RollbackPlanError::PublisherMismatch); + } + let current_version = semver::Version::parse(¤t.version) + .map_err(|error| RollbackPlanError::InvalidPermission(error.to_string()))?; + let target_version = semver::Version::parse(&target.version) + .map_err(|error| RollbackPlanError::InvalidPermission(error.to_string()))?; + if target_version >= current_version { + return Err(RollbackPlanError::VersionNotOlder); + } + let current_migration = current + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + let target_migration = target + .storage + .as_ref() + .map_or(0, |storage| storage.migration_version); + if target_migration != current_migration { + return Err(RollbackPlanError::StorageMigrationIncompatible); + } + let permissions = + permission_changes(current, target).map_err(RollbackPlanError::InvalidPermission)?; + if permissions.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added | PermissionChangeKind::Expanded + ) + }) { + return Err(RollbackPlanError::PermissionExpansion); + } + Ok(RollbackPlan { + current_version, + target_version, + permissions, + }) +} + +fn permission_changes( + current: &Manifest, + candidate: &Manifest, +) -> Result, String> { + let current_permissions = parsed_permissions(current).map_err(|error| error.to_string())?; + let candidate_permissions = parsed_permissions(candidate).map_err(|error| error.to_string())?; let capabilities: BTreeSet<_> = current_permissions .keys() .chain(candidate_permissions.keys()) @@ -153,18 +242,7 @@ pub fn plan_upgrade( }; permissions.push(PermissionChange { capability, kind }); } - let requires_confirmation = permissions.iter().any(|change| { - matches!( - change.kind, - PermissionChangeKind::Added | PermissionChangeKind::Expanded - ) - }); - Ok(UpgradePlan { - current_version, - candidate_version, - permissions, - requires_confirmation, - }) + Ok(permissions) } fn parsed_permissions( @@ -701,4 +779,40 @@ mod tests { Err(UpgradePlanError::PublisherMismatch) ); } + + #[test] + fn rollback_plan_requires_older_compatible_non_expanding_target() { + let current = upgrade_manifest( + "2.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 10, "maxActive": 1 } + }]), + 2, + ); + let safe_target = upgrade_manifest("1.0.0", json!([]), 2); + let plan = plan_rollback(¤t, &safe_target).unwrap(); + assert_eq!( + plan.target_version, + semver::Version::parse("1.0.0").unwrap() + ); + + let expanded_target = upgrade_manifest( + "1.0.0", + json!([{ + "capability": "timer:schedule", + "params": { "maxPerMinute": 60, "maxActive": 2 } + }]), + 2, + ); + assert_eq!( + plan_rollback(¤t, &expanded_target), + Err(RollbackPlanError::PermissionExpansion) + ); + let incompatible_storage = upgrade_manifest("1.0.0", json!([]), 1); + assert_eq!( + plan_rollback(¤t, &incompatible_storage), + Err(RollbackPlanError::StorageMigrationIncompatible) + ); + } } diff --git a/crates/floatile-core/src/lib.rs b/crates/floatile-core/src/lib.rs index 777830f..92e3b73 100644 --- a/crates/floatile-core/src/lib.rs +++ b/crates/floatile-core/src/lib.rs @@ -21,10 +21,10 @@ pub use connection::{ MAX_CREDENTIAL_REF_BYTES, }; pub use distribution::{ - PACKAGE_DIGEST_PAYLOAD_TYPE, PermissionChange, PermissionChangeKind, SIGNATURE_FILE, - SignatureVerificationError, TrustedPublisher, TrustedPublisherState, UpgradePlan, - UpgradePlanError, dsse_pae, plan_upgrade, publisher_key_id, signable_content_digest, - verify_signature_envelope, + PACKAGE_DIGEST_PAYLOAD_TYPE, PermissionChange, PermissionChangeKind, RollbackPlan, + RollbackPlanError, SIGNATURE_FILE, SignatureVerificationError, TrustedPublisher, + TrustedPublisherState, UpgradePlan, UpgradePlanError, dsse_pae, plan_rollback, plan_upgrade, + publisher_key_id, signable_content_digest, verify_signature_envelope, }; pub use instance::{ InstallationDigest, InstallationRef, InstanceConfig, InstanceDesiredState, InstanceModelError, diff --git a/crates/floatile-store/src/lib.rs b/crates/floatile-store/src/lib.rs index 57cb9a1..129dd75 100644 --- a/crates/floatile-store/src/lib.rs +++ b/crates/floatile-store/src/lib.rs @@ -29,7 +29,7 @@ pub enum StoreError { } /// 当前 schema 版本(与 migration 列表一一对应)。 -const SCHEMA_VERSION: u32 = 7; +const SCHEMA_VERSION: u32 = 8; /// 打开数据库并迁移到最新版本。 /// @@ -78,6 +78,9 @@ impl Store { if current < 7 { self.migration_v7()?; } + if current < 8 { + self.migration_v8()?; + } Ok(()) } @@ -279,6 +282,29 @@ impl Store { .map_err(|error| StoreError::Migration(format!("v7 提交失败: {error}"))) } + fn migration_v8(&mut self) -> Result<(), StoreError> { + let tx = self.conn.transaction()?; + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS installation_rollbacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + from_version TEXT NOT NULL, + from_digest BLOB NOT NULL CHECK (length(from_digest) = 32), + target_version TEXT NOT NULL, + target_digest BLOB NOT NULL CHECK (length(target_digest) = 32), + reason TEXT NOT NULL CHECK (length(reason) BETWEEN 1 AND 512), + rolled_back_at INTEGER NOT NULL CHECK (rolled_back_at >= 0) + ); + CREATE INDEX IF NOT EXISTS installation_rollbacks_instance + ON installation_rollbacks(instance_id, id); + PRAGMA user_version = 8;", + ) + .map_err(|error| StoreError::Migration(format!("v8 建立显式回滚审计表失败: {error}")))?; + tx.commit() + .map_err(|error| StoreError::Migration(format!("v8 提交失败: {error}"))) + } + /// 布局存储接口。 pub fn layout(&self) -> LayoutStore<'_> { LayoutStore { conn: &self.conn } @@ -629,6 +655,70 @@ impl<'a> InstanceStore<'a> { Ok(changed == 1) } + /// Atomically rebinds a stopped instance to a verified historical installation and audits it. + pub fn rollback_installation( + &self, + instance_id: InstanceId, + current: &InstallationRef, + target: &InstallationRef, + reason: &str, + updated_at: u64, + ) -> Result { + if reason.is_empty() || reason.len() > 512 { + return Err(StoreError::Corrupt( + "rollback reason 必须为 1..=512 字节".to_owned(), + )); + } + current + .validate() + .map_err(|error| StoreError::Corrupt(error.to_string()))?; + target + .validate() + .map_err(|error| StoreError::Corrupt(error.to_string()))?; + let instance_id_sql = sqlite_i64(instance_id.0, "instance_id")?; + let updated_at_sql = sqlite_i64(updated_at, "updated_at")?; + let tx = self.conn.unchecked_transaction()?; + let changed = tx.execute( + "UPDATE plugin_instances + SET installation_version = ?1, installation_digest = ?2, updated_at = ?3 + WHERE instance_id = ?4 + AND plugin_id = ?5 + AND installation_version = ?6 + AND installation_digest = ?7 + AND desired_state = 'stopped' + AND updated_at <= ?3", + rusqlite::params![ + target.version(), + target.digest().as_bytes().as_slice(), + updated_at_sql, + instance_id_sql, + current.plugin().0, + current.version(), + current.digest().as_bytes().as_slice(), + ], + )?; + if changed == 1 { + tx.execute( + "INSERT INTO installation_rollbacks ( + instance_id, plugin_id, from_version, from_digest, + target_version, target_digest, reason, rolled_back_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + instance_id_sql, + current.plugin().0, + current.version(), + current.digest().as_bytes().as_slice(), + target.version(), + target.digest().as_bytes().as_slice(), + reason, + updated_at_sql, + ], + )?; + } + tx.commit()?; + Ok(changed == 1) + } + /// 启动或重启前推进 generation;迟到异步结果必须匹配该值才能投递。 pub fn advance_generation( &self, @@ -1363,6 +1453,15 @@ mod tests { .unwrap() } + fn historical_installation() -> InstallationRef { + InstallationRef::new( + PluginId("dev.floatile.clock".into()), + "1.0.0", + InstallationDigest::from_bytes([0x19; 32]), + ) + .unwrap() + } + fn config(label: &str) -> InstanceConfig { InstanceConfig::new(serde_json::json!({ "label": label })).unwrap() } @@ -1406,6 +1505,65 @@ mod tests { assert_eq!(instance.id(), InstanceId(2)); } + #[test] + fn rollback_rebinds_stopped_instance_and_appends_audit_atomically() { + let store = open(":memory:").unwrap(); + let instance = store + .instances() + .create( + &installation(), + &config("rollback"), + InstanceDesiredState::Stopped, + 10, + ) + .unwrap(); + assert!( + store + .instances() + .rollback_installation( + instance.id(), + instance.installation(), + &historical_installation(), + "regression in 1.2.3", + 11, + ) + .unwrap() + ); + let restored = store.instances().get(instance.id()).unwrap().unwrap(); + assert_eq!(restored.installation(), &historical_installation()); + let count: u32 = store + .conn + .query_row("SELECT COUNT(*) FROM installation_rollbacks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + + store + .instances() + .set_desired_state(instance.id(), InstanceDesiredState::Running, 12) + .unwrap(); + assert!( + !store + .instances() + .rollback_installation( + instance.id(), + &historical_installation(), + &installation(), + "must stop first", + 13, + ) + .unwrap() + ); + let count: u32 = store + .conn + .query_row("SELECT COUNT(*) FROM installation_rollbacks", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 1); + } + #[test] fn migration_v4_failure_rolls_back_allocator_and_version() { let mut store = v3_store(); @@ -1701,6 +1859,32 @@ mod tests { assert_eq!(exists, 0); } + #[test] + fn migration_v8_failure_rolls_back_audit_table_and_version() { + let mut store = v6_store(); + store.migration_v7().unwrap(); + store + .conn + .execute_batch("CREATE INDEX installation_rollbacks ON layout(instance_id);") + .unwrap(); + assert!(matches!(store.migrate(), Err(StoreError::Migration(_)))); + let version: u32 = store + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, 7); + let exists: u32 = store + .conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'installation_rollbacks'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(exists, 0); + } + #[test] fn connection_grants_are_instance_scoped_and_shared_connection_survives_deletion() { let store = open(":memory:").unwrap(); diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 7b24fac..190b4a4 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -215,6 +215,7 @@ Detected capabilities: | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | | `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | | `floatile instance create/list/get/configure/start/stop/delete` | 按精确安装版本管理持久实例与 desired state | +| `floatile instance rollback` | 将 stopped 实例显式重绑到当前信任仍有效的历史 Installation,记录原因且不降低 anti-rollback 水位 | 所有命令必须支持: @@ -242,6 +243,9 @@ DSSE/Ed25519 验证、publisher/key 撤销和 anti-rollback;默认 `install` 和状态,不存储或回显 private key。受信升级会比较当前最高安装的 manifest:新增 capability 或扩大 scope/配额返回 `FINST_PERMISSION_CONFIRMATION` 且零落盘;调用方必须显式传 `--accept-permissions` 才能继续。纯移除/收窄无需该参数,成功输出包含逐 capability 的 upgrade diff。 +`instance rollback --version --reason ` 仅接受 stopped 实例;目标安装先复核 +install digest 与当前 publisher/key trust,且 storage migration 必须相等、旧权限不得重新扩大。实例 +重绑定与原因审计原子提交,最高已接受版本/摘要保持不变。 `check` 按 Component Model 实际保留的 Floatile interface/function imports 与 Capability Registry 比对: 使用声明能力但 manifest 未声明时以 `FCHECK_CAPABILITY_MISSING` 失败;已声明但未导入时产生 `FCHECK_CAPABILITY_UNUSED` warning,`--deny-warnings` 可将其提升为失败。固有能力无需写入 manifest; diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 5067e7c..0fb723b 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -64,7 +64,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | | 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | -| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier与确定性升级权限 diff,`floatile-store` 已持久化宿主持有的 publisher/key trust、anti-rollback 最高版本/摘要与安装 journal;CLI 受信安装已强制签名/撤销/anti-rollback,并要求显式接受权限扩大;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;显式回滚与 Shell 受信加载仍待接入 | +| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划,`floatile-store` 已持久化宿主持有的 publisher/key trust、anti-rollback 最高版本/摘要、安装 journal 与原子回滚审计;CLI 受信安装已强制签名/撤销/anti-rollback,并要求显式接受权限扩大;stopped 实例可显式回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation,同时保持最高水位;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;Shell 受信加载仍待接入 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | ## 6. 阶段门槛与未决策 From 64b3d82d7ac9322d1295237f8dc76b201fb33fe3 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 12:21:31 +0800 Subject: [PATCH 09/10] feat(shell): enforce installation trust at runtime Persist whether an installation used the unsigned development path or the trusted distribution path. Re-verify trusted installations against current publisher and key trust before persistent startup or retry, while preserving a visibly marked backwards-compatible unsigned development path. Refs: PP-M8, PP-G2, PP-G3, NFR-SEC-01, NFR-SEC-02, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo test -p floatile-core install --locked; RUSTC_WRAPPER= cargo test -p floatile-cli trusted_install --locked; RUSTC_WRAPPER= cargo test -p floatile-shell plugin_manager --locked; RUSTC_WRAPPER= cargo test -p floatile-shell instance_supervisor --locked; RUSTC_WRAPPER= cargo clippy --workspace --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: three-host signed runtime behavior, full workspace tests, and license gate; Cargo reports the pre-existing clock-wasm output filename collision warning --- crates/floatile-cli/src/install.rs | 23 +++- crates/floatile-cli/src/instance.rs | 1 + crates/floatile-core/src/install.rs | 17 +++ crates/floatile-shell/src/instance_control.rs | 1 + .../floatile-shell/src/instance_supervisor.rs | 5 +- crates/floatile-shell/src/plugin_manager.rs | 112 +++++++++++++++++- crates/floatile-shell/src/runtime_ui.rs | 1 + .../tests/persistent_instance_lifecycle.rs | 1 + crates/floatile-store/src/installation.rs | 1 + docs/plugin-sdk/sdk-developer-experience.md | 4 + docs/product/requirements.md | 2 +- 11 files changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/floatile-cli/src/install.rs b/crates/floatile-cli/src/install.rs index 6f60c0f..ab6a13f 100644 --- a/crates/floatile-cli/src/install.rs +++ b/crates/floatile-cli/src/install.rs @@ -18,7 +18,9 @@ use floatile_core::distribution::{ SIGNATURE_FILE, SignatureVerificationError, UpgradePlan, UpgradePlanError, plan_upgrade, signable_content_digest, verify_signature_envelope, }; -use floatile_core::install::{InstallMeta, content_digest, file_digest, hex_encode}; +use floatile_core::install::{ + InstallMeta, InstallationTrust, content_digest, file_digest, hex_encode, +}; use floatile_core::manifest::Manifest; use floatile_store::Store; use floatile_store::installation::InstalledInstallation; @@ -220,7 +222,13 @@ pub fn install_trusted_package( let parent = final_dir.parent().unwrap_or(plugin_store); fs::create_dir_all(parent) .map_err(|error| InstallError::StoreUnavailable(error.to_string()))?; - let meta = match write_staging(&staging, &validated, &manifest, source) { + let meta = match write_staging( + &staging, + &validated, + &manifest, + source, + InstallationTrust::Trusted, + ) { Ok(meta) => meta, Err(error) => { let _ = fs::remove_dir_all(&staging); @@ -391,7 +399,13 @@ fn install_validated( let staging = store.join(format!(".staging-{}", nonce())); - let meta = match write_staging(&staging, validated, &manifest, source) { + let meta = match write_staging( + &staging, + validated, + &manifest, + source, + InstallationTrust::Unsigned, + ) { Ok(meta) => meta, Err(e) => { let _ = fs::remove_dir_all(&staging); @@ -418,6 +432,7 @@ fn write_staging( validated: &ValidatedPackage, manifest: &Manifest, source: &str, + trust: InstallationTrust, ) -> Result { let installed_at = now_secs(); @@ -435,6 +450,7 @@ fn write_staging( ui_api_version: manifest.ui_api_version.clone(), installed_at, source: source.to_owned(), + trust, files: file_digests, digest: aggregate, }; @@ -814,6 +830,7 @@ mod tests { ) .unwrap(); assert!(installed.dir.join(SIGNATURE_FILE).is_file()); + assert_eq!(installed.meta.trust, InstallationTrust::Trusted); let accepted = trust_store .trust() .accepted_package("dev.floatile", "dev.floatile.clock") diff --git a/crates/floatile-cli/src/instance.rs b/crates/floatile-cli/src/instance.rs index 7095e70..0902f9c 100644 --- a/crates/floatile-cli/src/instance.rs +++ b/crates/floatile-cli/src/instance.rs @@ -379,6 +379,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-core/src/install.rs b/crates/floatile-core/src/install.rs index c6c7a7a..3668c1c 100644 --- a/crates/floatile-core/src/install.rs +++ b/crates/floatile-core/src/install.rs @@ -10,6 +10,14 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstallationTrust { + #[default] + Unsigned, + Trusted, +} + /// 一个已安装插件的可验证元数据(manifest 与摘要快照)。 /// /// 不冗余存放插件内容,只存每条文件的 SHA-256 与覆盖全部规范文件集合的内容摘要, @@ -28,6 +36,9 @@ pub struct InstallMeta { pub installed_at: u64, /// 来源包文件名(仅诊断,不参与信任)。 pub source: String, + /// Installation policy used at commit time; runtime re-verifies trusted signatures. + #[serde(default)] + pub trust: InstallationTrust, /// 每条允许文件相对路径 → SHA-256 hex。 pub files: BTreeMap, /// 覆盖全部规范文件集合的内容摘要(hex)。 @@ -149,11 +160,17 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 42, source: "clock.floatile".to_owned(), + trust: InstallationTrust::Unsigned, files, digest: hex_encode(&[9; 32]), }; let json = serde_json::to_string(&meta).unwrap(); let back: InstallMeta = serde_json::from_str(&json).unwrap(); assert_eq!(back, meta); + + let mut legacy: serde_json::Value = serde_json::from_str(&json).unwrap(); + legacy.as_object_mut().unwrap().remove("trust"); + let legacy: InstallMeta = serde_json::from_value(legacy).unwrap(); + assert_eq!(legacy.trust, InstallationTrust::Unsigned); } } diff --git a/crates/floatile-shell/src/instance_control.rs b/crates/floatile-shell/src/instance_control.rs index bb8d5b7..e9fcf3f 100644 --- a/crates/floatile-shell/src/instance_control.rs +++ b/crates/floatile-shell/src/instance_control.rs @@ -1213,6 +1213,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-shell/src/instance_supervisor.rs b/crates/floatile-shell/src/instance_supervisor.rs index 361fe9d..73538d2 100644 --- a/crates/floatile-shell/src/instance_supervisor.rs +++ b/crates/floatile-shell/src/instance_supervisor.rs @@ -18,7 +18,7 @@ use floatile_platform::PlatformCapabilities; use floatile_services::{AuditListener, CredentialVault, HttpsService, MemoryCredentialVault}; use slint::{Timer, TimerMode}; -use crate::plugin_manager::{RunnableInstance, load_runnable_instance}; +use crate::plugin_manager::{RunnableInstance, load_runnable_instance_with_trust}; use crate::runtime_ui::{ RuntimeUiLifecycleEvent, RuntimeUiSession, compose_instance_https, spawn_runtime_ui_with_https, }; @@ -404,7 +404,7 @@ fn prepare_actions( } }; let version = instance.installation().version().to_owned(); - match load_runnable_instance(plugin_store, instance) { + match load_runnable_instance_with_trust(plugin_store, store, instance) { Ok(Some(runnable)) => match compose_instance_https( store, runnable.instance.id(), @@ -637,6 +637,7 @@ mod tests { ui_api_version: "0.1.0".to_owned(), digest: InstallationDigest::from_bytes([id as u8; 32]).to_string(), source: "test.floatile".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, installed_at: 1, files: Default::default(), }) diff --git a/crates/floatile-shell/src/plugin_manager.rs b/crates/floatile-shell/src/plugin_manager.rs index 6bbadec..f47c8ec 100644 --- a/crates/floatile-shell/src/plugin_manager.rs +++ b/crates/floatile-shell/src/plugin_manager.rs @@ -10,7 +10,8 @@ use std::path::{Path, PathBuf}; -use floatile_core::install::InstallMeta; +use floatile_core::distribution::{SIGNATURE_FILE, verify_signature_envelope}; +use floatile_core::install::{InstallMeta, InstallationTrust}; use floatile_core::instance::InstallationRef; use floatile_core::manifest::Manifest; use floatile_core::{InstanceDesiredState, InstanceId, PluginInstance}; @@ -84,6 +85,14 @@ pub enum LoadError { InvalidManifest(#[from] floatile_core::manifest::ManifestError), #[error("实例 Config 契约校验失败: {0}")] InvalidConfig(#[from] ConfigValidationError), + #[error("受信安装缺少 detached signature.json")] + MissingSignature, + #[error("受信安装的 publisher 不在宿主 trust store 中")] + UnknownPublisher, + #[error("受信安装签名校验失败: {0}")] + Signature(#[from] floatile_core::SignatureVerificationError), + #[error("读取 publisher trust 失败: {0}")] + TrustStore(String), } impl LoadError { @@ -97,6 +106,23 @@ impl LoadError { Self::MissingEntrypoint(_) => "FLOAD_MISSING_ENTRYPOINT", Self::InvalidManifest(_) => "FLOAD_INVALID_MANIFEST", Self::InvalidConfig(error) => error.code(), + Self::MissingSignature => "FLOAD_SIGNATURE_MISSING", + Self::UnknownPublisher => "FLOAD_PUBLISHER_UNKNOWN", + Self::Signature(error) => match error { + floatile_core::SignatureVerificationError::PublisherRevoked => { + "FLOAD_PUBLISHER_REVOKED" + } + floatile_core::SignatureVerificationError::KeyRevoked => "FLOAD_KEY_REVOKED", + floatile_core::SignatureVerificationError::UnknownKey => "FLOAD_KEY_UNKNOWN", + floatile_core::SignatureVerificationError::DigestMismatch => { + "FLOAD_SIGNATURE_DIGEST" + } + floatile_core::SignatureVerificationError::InvalidSignature => { + "FLOAD_SIGNATURE_INVALID" + } + _ => "FLOAD_SIGNATURE_MALFORMED", + }, + Self::TrustStore(_) => "FLOAD_TRUST_STORE", } } } @@ -154,6 +180,30 @@ pub fn load_runnable_instance( Ok(Some(RunnableInstance { instance, plugin })) } +/// Production load path: trusted installations are re-verified against current host trust. +/// Explicit unsigned development installations remain marked and use the sandboxed dev path. +pub fn load_runnable_instance_with_trust( + plugin_store: &Path, + trust_store: &Store, + instance: PluginInstance, +) -> Result, LoadError> { + let reference = instance.installation(); + let installation = load_reference(plugin_store, reference).map_err(|error| match error { + InstallationCatalogError::MetadataMismatch => LoadError::InstallationMismatch { + id: reference.plugin().0.clone(), + version: reference.version().to_owned(), + }, + other => map_catalog_error(other), + })?; + let Some(installation) = installation else { + return Ok(None); + }; + installation.validate_config(instance.config())?; + verify_runtime_trust(&installation, trust_store)?; + let plugin = installed_plugin(installation)?; + Ok(Some(RunnableInstance { instance, plugin })) +} + /// 从持久记录恢复 desired-running 实例,并固定到精确 Installation。 /// /// 该函数执行 SQLite 与文件 I/O,必须在 Slint 事件循环启动前或后台线程调用。每次启动尝试先推进 @@ -219,7 +269,7 @@ pub fn plan_running_instances( }; let version = instance.installation().version().to_owned(); - match load_runnable_instance(plugin_store, instance) { + match load_runnable_instance_with_trust(plugin_store, store, instance) { Ok(Some(runnable)) => plan.ready.push(runnable), Ok(None) => plan.failures.push(InstanceLoadFailure { instance_id, @@ -238,6 +288,33 @@ pub fn plan_running_instances( Ok(plan) } +fn verify_runtime_trust( + installation: &InstalledInstallation, + store: &Store, +) -> Result<(), LoadError> { + if installation.meta.trust == InstallationTrust::Unsigned { + return Ok(()); + } + let publisher_id = installation.manifest.publisher.id.as_str(); + let trust = store + .trust() + .get(publisher_id) + .map_err(|error| LoadError::TrustStore(error.to_string()))? + .ok_or(LoadError::UnknownPublisher)?; + let mut files = std::collections::BTreeMap::new(); + for name in installation.meta.files.keys() { + let bytes = installation + .file(name) + .ok_or_else(|| LoadError::Read(format!("安装文件缺失: {name}")))?; + files.insert(name.clone(), bytes.to_vec()); + } + let envelope = files + .get(SIGNATURE_FILE) + .ok_or(LoadError::MissingSignature)?; + verify_signature_envelope(envelope, &files, publisher_id, &trust.verifier_binding())?; + Ok(()) +} + /// 枚举插件存储下全部已安装插件,每个 id 取最高已安装版本并逐一做 digest 复核。 /// /// 这是宿主运行多个插件的加载策略:返回稳定的已安装插件集合,供 UI 层按 id 创建 @@ -298,6 +375,7 @@ mod tests { use std::collections::BTreeMap; use floatile_core::install::{content_digest, file_digest, hex_encode}; + use floatile_store::trust::TrustState; fn manifest_json() -> String { serde_json::json!({ @@ -363,6 +441,7 @@ mod tests { ui_api_version: "1.0.0".into(), installed_at: 0, source: "x.floatile".into(), + trust: InstallationTrust::Unsigned, files: file_digests.clone(), digest: hex_encode(&content_digest(&files)), }; @@ -391,6 +470,35 @@ mod tests { InstallationRef::from_install_meta(&meta).unwrap() } + #[test] + fn runtime_refuses_trusted_marker_without_signature() { + let plugin_store = temp_store("runtime-trust"); + write_install(&plugin_store, "dev.floatile.clock", "0.2.0", None); + let meta_path = plugin_store.join("dev.floatile.clock/0.2.0/install.json"); + let mut meta: InstallMeta = + serde_json::from_slice(&std::fs::read(&meta_path).unwrap()).unwrap(); + meta.trust = InstallationTrust::Trusted; + std::fs::write(&meta_path, serde_json::to_vec(&meta).unwrap()).unwrap(); + let reference = InstallationRef::from_install_meta(&meta).unwrap(); + let store = floatile_store::open(":memory:").unwrap(); + store + .trust() + .upsert_key("dev.floatile", [31; 32], TrustState::Active, 1) + .unwrap(); + let instance = PluginInstance::restore( + InstanceId(9), + reference, + floatile_core::InstanceConfig::empty(), + InstanceDesiredState::Stopped, + 0, + 1, + 1, + ) + .unwrap(); + let error = load_runnable_instance_with_trust(&plugin_store, &store, instance).unwrap_err(); + assert!(matches!(error, LoadError::MissingSignature)); + } + #[test] fn loads_highest_version_and_verifies_digest() { let store = temp_store("load"); diff --git a/crates/floatile-shell/src/runtime_ui.rs b/crates/floatile-shell/src/runtime_ui.rs index d163917..732e172 100644 --- a/crates/floatile-shell/src/runtime_ui.rs +++ b/crates/floatile-shell/src/runtime_ui.rs @@ -1193,6 +1193,7 @@ mod grants_tests { ui_api_version: "1.0.0".into(), installed_at: 0, source: "clock.floatile".into(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: std::collections::BTreeMap::new(), digest: "00".repeat(32), }, diff --git a/crates/floatile-shell/tests/persistent_instance_lifecycle.rs b/crates/floatile-shell/tests/persistent_instance_lifecycle.rs index 60b0f4e..4b97062 100644 --- a/crates/floatile-shell/tests/persistent_instance_lifecycle.rs +++ b/crates/floatile-shell/tests/persistent_instance_lifecycle.rs @@ -86,6 +86,7 @@ fn write_clock_install(root: &Path, wasm: Vec) -> PathBuf { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "xvfb-evidence".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/crates/floatile-store/src/installation.rs b/crates/floatile-store/src/installation.rs index c178f28..9b40d5c 100644 --- a/crates/floatile-store/src/installation.rs +++ b/crates/floatile-store/src/installation.rs @@ -286,6 +286,7 @@ mod tests { ui_api_version: "1.0.0".to_owned(), installed_at: 1, source: "test".to_owned(), + trust: floatile_core::install::InstallationTrust::Unsigned, files: files .iter() .map(|(name, bytes)| (name.clone(), hex_encode(&file_digest(bytes)))) diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 190b4a4..d37fc98 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -246,6 +246,10 @@ scope/配额返回 `FINST_PERMISSION_CONFIRMATION` 且零落盘;调用方必 `instance rollback --version --reason ` 仅接受 stopped 实例;目标安装先复核 install digest 与当前 publisher/key trust,且 storage migration 必须相等、旧权限不得重新扩大。实例 重绑定与原因审计原子提交,最高已接受版本/摘要保持不变。 +`install.json.trust` 固定为 `unsigned | trusted`;旧元数据缺字段按 `unsigned` 读取,不允许静默提升。 +Shell 的持久实例启动与动态 retry 路径会对 `trusted` 安装使用当前 trust store 重新验签,并以稳定 +`FLOAD_*` code 隔离 publisher/key 撤销、未知 key、摘要变化或签名损坏;`unsigned` 仅保留明确标记的 +本地开发降级路径,仍经过 Installation digest、manifest、Config 与 runtime sandbox 校验。 `check` 按 Component Model 实际保留的 Floatile interface/function imports 与 Capability Registry 比对: 使用声明能力但 manifest 未声明时以 `FCHECK_CAPABILITY_MISSING` 失败;已声明但未导入时产生 `FCHECK_CAPABILITY_UNUSED` warning,`--deny-warnings` 可将其提升为失败。固有能力无需写入 manifest; diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 0fb723b..30dc8a1 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -64,7 +64,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | | 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | -| 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core` 已提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划,`floatile-store` 已持久化宿主持有的 publisher/key trust、anti-rollback 最高版本/摘要、安装 journal 与原子回滚审计;CLI 受信安装已强制签名/撤销/anti-rollback,并要求显式接受权限扩大;stopped 实例可显式回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation,同时保持最高水位;`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;Shell 受信加载仍待接入 | +| 包工具链 | 已实现,部分验证 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、原子安装与可恢复 journal;`floatile-core` 提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划;`floatile-store` 持久化 publisher/key trust、anti-rollback 水位与原子回滚审计。CLI 受信安装强制签名/撤销/anti-rollback,权限扩大需显式接受;stopped 实例可回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation并保持最高水位。`install.json` 明确记录 `unsigned|trusted`,Shell 对 trusted 安装在持久实例启动与 retry 前按当前 trust 重新验签,对 unsigned 保留显式本地开发降级路径;三宿主分发运行与公开发布仍受平台证据及许可门禁约束。 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | ## 6. 阶段门槛与未决策 From efe87f84ed9c9660a0a71d68f7b4b1e3dcbe8b82 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 12:28:58 +0800 Subject: [PATCH 10/10] docs(distribution): close PP-M8 milestone Record the completed automated distribution and trust contract, including signed install recovery, permission-safe upgrades, explicit rollback, runtime trust revalidation, unchanged Connection grants, and the remaining license and cross-platform release boundaries. Refs: PP-M8, PP-G2, PP-G3, NFR-LEGAL-01, ADR-0005 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo clippy --workspace --all-targets --locked -- -D warnings; RUSTC_WRAPPER= cargo test --workspace --all-targets --locked; RUSTC_WRAPPER= cargo check -p floatile-sdk --target wasm32-wasip2 --locked; RUSTC_WRAPPER= cargo deny --locked check advisories bans sources Unverified: Windows, macOS, and Wayland signed distribution runtime behavior; cargo deny --locked check licenses remains blocked by NFR-LEGAL-01 and rejected Slint/CDLA licenses --- docs/product/plugin-platform-roadmap.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index 7bcd7e8..174d429 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -174,7 +174,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | | PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | -| PP-M8 | 分发与信任 | 进行中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | +| PP-M8 | 分发与信任 | 已完成(自动化契约验证;公开分发受许可门阻断) | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -223,6 +223,14 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; +- PP-M8 已由 ADR-0005 固定 detached DSSE/Ed25519 与宿主持有的 publisher/key trust。CLI 受信安装 + 强制当前信任、撤销、anti-rollback 和权限扩大显式确认;SQLite journal 可恢复 rename 与水位之间的 + 崩溃窗口。显式回滚只重绑 stopped 实例到重新验签、migration 兼容且不恢复旧权限的历史 + Installation,原因审计与重绑定原子提交且最高水位不降低。Shell 在持久启动和 retry 前按 + `install.json.trust` 对 trusted 安装重新应用当前 trust,旧元数据默认 unsigned,不发生静默提权。 + Connection grant 是实例级宿主状态,安装与回滚均保持原绑定不变;任何后续换绑仍走 PP-M5 独立授权。 + 全 workspace 自动化、`wasm32-wasip2` SDK、advisories/bans/sources 已通过;Windows/macOS/Wayland + 分发实测属于 PP-M10,公开产物继续由 NFR-LEGAL-01 与 Slint license gate 阻断; - TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 @@ -243,7 +251,8 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 9. `feat(cli): complete the Rust plugin author loop`(PP-M4); 10. `feat(connections): add host-owned connection and credential references`(PP-M5); 11. `feat(http): implement the first bounded HTTPS Broker vertical slice`(PP-M5); -12. `feat(examples): add an AI balance monitor reference plugin`(PP-M5/PP-M6)。 +12. `feat(examples): add an AI balance monitor reference plugin`(PP-M5/PP-M6); +13. `feat(distribution): enforce package signing, trusted upgrades and explicit rollback`(PP-M8,已落地)。 这是依赖顺序,不是要求一个 PR 同时完成整个里程碑。每个 PR 必须是一条可审查、可回退、包含失败 路径和联动文档的纵向切片。