diff --git a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md index 11fda5f87..e7de6bdfe 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md +++ b/.agents/notes/implemented/architecture/2026-09-18-php-support-plugin-ownership-and-plans.md @@ -25,7 +25,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ### 能力与生命周期 - PHP 的 LSP 使用现有 Rust Core 会话,以 `intelephense --stdio` 启动。符号、类型和诊断仍由上游服务拥有;主机不实现第二套 PHP 语义分析。 -- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 同时验证插件 manifest、签名和语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 +- macOS 插件管理页是 PHP 包和 Intelephense 的生命周期唯一入口:构建阶段按 `language-server.json` 下载并校验 npm tarball,把 launcher 和运行包放入插件 bundle;下载器再下载完整插件 zip,`MacPluginPackageStore` 先按宿主内置 `OfficialPluginCatalog` 固定官方插件的签名策略,拒绝包内 manifest 将 `publisherPackage` 降级为 `sameTeamAsHost`,随后验证原生 bundle,并用内置 publisher Ed25519 公钥验证完整包清单、插件 ID、版本和文件 SHA-256,最后验证语言服务器 launcher。安装、重装、回滚和卸载都针对同一个插件版本目录执行,因此不会留下脱离插件的 LSP。LSP 控制中心只显示当前项目的 PHP 语言服务器开关和运行状态,发现未安装、未启用或待重启时引导回插件管理页,不提供包操作按钮。 - macOS 运行和测试使用插件模块持有的执行 session。相对文件名不做 trim,以 `-` 开头时加 `./`,避免把文件名当作命令选项。 - Windows 只有已安装且启用 PHP 扩展时才读取 Composer/PHPUnit 清单、展示运行入口;执行前再次检查开关。Composer 的字符串和字符串数组均交给 `composer run -- ` 执行,不在主机模拟脚本语义。 - Windows PHP 运行复用 Run 的输出面板和 native 进程启动能力,通用宿主服务在首个 await 之前按插件 ID 和工作区登记会话。禁用或关闭工作区时等待在途启动,再停止其拥有的 execution ID;自然结束释放所有权。不得通过普通终端事件绕过这个流程。 @@ -57,7 +57,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No ## 后果 -不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包必须使用与宿主一致的签名,未配置 Developer ID 的调试或预览构建仍只能使用本地导入进行测试。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 +不使用 PHP 的用户不承担语言服务器下载、索引和进程成本。代价是首次使用需要显式安装插件和 Node.js;macOS 在线包需要 Lithe publisher Ed25519 签名,但不要求发布者持有 Developer ID;未配置 publisher secret 的 debug 包不会生成可安装的 PHP 包。Windows 目前提供 Composer 脚本及整套 PHPUnit,未声明支持 macOS 已有的单方法测试发现。Windows 本地包暂不提供在线分发、自动更新或签名身份验证,替换版本需先卸载再导入;包格式仅用于小型 Worker 语言插件。目标平台运行验证未完成前,功能矩阵保持 pending。 插件构建产物、PHPUnit 的 vendor 和应用语言工具缓存没有可靠的跨工作树身份标记,均在 `scripts/worktree-resources.json` 的 excludedResources 中排除。不得把它们共享为可变缓存。 @@ -73,7 +73,7 @@ PHP 支持由用户选择安装和启用,主程序不携带 PHP 插件包、No - `MacRuntimeToolDiscoveryTests`:验证启用的 PHP 插件版本目录优先提供 Intelephense launcher。 - `PluginPackageStoreTests/reinstallCanReplaceTheActiveVersionOnlyAfterValidation`:验证重装不会绕过签名校验,并在校验完成后替换当前版本。 - `prepare-php-language-server.sh`:按 JSON 清单下载、校验并组装 Intelephense 运行包;插件版本目录删除时一并删除 launcher 和缓存文件。 -- `.github/workflows/release-macos.yml`:Developer ID 构建额外发布架构对应的 PHP 插件 zip;未配置 Developer ID 时不发布可在线安装的独立包。 +- `.github/workflows/release-macos.yml` 和 `.github/workflows/release-preview-macos.yml`:每个架构都发布 PHP 插件 zip;`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 缺失或不匹配时工作流失败,避免发布无法被客户端识别的包。 - `./scripts/test-macos.sh --filter LithePhpSupportModuleTests`:模块、路径与禁用清理测试。 - `LITHE_RUN_PHP_INTEGRATION=1 ./scripts/test-macos.sh --filter RealPhpIntegrationTests`:真实工具测试;先在 `shared/fixtures/phpunit-project` 执行 `composer install`,并提供 Node.js 与插件组装出的 Intelephense launcher。 - Windows 前端测试包含禁用时不扫描、Composer 数组、下载取消、在途启动后禁用及跨工作区进程隔离。Windows native 测试与实际应用启动必须在 Windows 环境执行;Linux 交叉编译不等于运行验收。 diff --git a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md index 0d1c9b56f..ca07a388f 100644 --- a/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-macos-update-signing-and-release-strategy.md @@ -4,7 +4,7 @@ ## 先说结论 -更新包的真实性由 Sparkle 的 EdDSA 签名保证,Developer ID 签名是可选的分发能力,不是更新信任的唯一依据。stable 和 preview 完全隔离,回滚只接受内置公钥信任的更新,避免测试版本或伪造清单影响正式用户。 +更新包和独立官方插件包的真实性分别由 Sparkle EdDSA 与 Lithe Ed25519 签名保证,Developer ID 签名是可选的分发能力,不是这两类内容信任的唯一依据。stable 和 preview 完全隔离,回滚和插件安装只接受内置公钥信任的内容,避免测试版本或伪造清单影响正式用户。 ## 问题 @@ -31,6 +31,21 @@ runner keychain,用后即删。Developer ID 签名不等于公证,也不保 Gatekeeper 首次运行警告消失——这些是独立的发布关注点,更新器不自动 清除 quarantine。 +### 独立官方插件包 + +PHP Support 作为独立 GitHub Release asset 发布,不再把“能否使用 Developer ID +证书”当成是否生成插件包的条件。构建阶段先完成原生 bundle 的代码签名,再由 +`LithePluginPackageSigner` 使用 `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY` 为包内每个文件 +生成 SHA-256 清单和 Ed25519 签名;签名文档本身不进入被签名文件列表,避免验证时 +出现自引用。Lithe 内置 `lithe-official-plugins-v1` 公钥,安装时先验证原生 bundle, +再验证完整包清单、插件 ID 和版本,任一文件被替换都会拒绝安装。 + +stable 和 preview 工作流都必须配置同一 repository secret +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`。它是与客户端内置公钥匹配的 base64 编码 32 字节 +Ed25519 私钥;可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 写入 GitHub,私钥只在构建 runner 的标准输入中使用,不能写入 +仓库、release asset 或日志。公钥轮换需要先发布能识别新 key ID 的客户端,再更新 +secret 并重新生成包,不能只替换 GitHub secret。 + ### 全量与差分双轨发布 stable 工作流继续发布 DMG + SHA-256 + `latest-macos.json` 供旧客户端 @@ -119,6 +134,10 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 少点一次 Gatekeeper 提示。但这本质上是绕过 macOS 的下载来源追踪 机制,属于安全职责之外的东西,因此明确不做,交由既有的可信来源 恢复步骤处理。 +- **要求插件使用 Developer ID 或与宿主同 Team ID 签名**:能复用 Apple + 的代码签名身份,但会让没有证书的发布环境无法生成独立包,也无法表达“包由 + Lithe 发布者签发”的信任关系,因此改用包级 Ed25519 清单,同时保留 bundle + 的原生代码签名校验。 ## 后果 @@ -135,6 +154,11 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 - 回滚路径的安全性依赖客户端内置公钥永不改变;如果需要轮换 `SPARKLE_PRIVATE_KEY`,替换 repository secret 本身不够,必须单独 规划迁移(重签或双签过渡期),否则旧客户端会拒绝新签名的更新。 +- 独立插件包的安装安全性同样依赖客户端内置的 publisher 公钥;没有匹配 secret + 的 release 构建会失败,普通 debug 构建会跳过需要 publisher 签名的 PHP 包, + 指定单个 PHP 包构建时也会失败。这样构建目录中不会留下缺少 + `lithe-plugin-signature.json` 的伪成功包;只有提供私钥的构建才会生成可交给 + 包级验签路径的产物。 - 需要重新评估的触发条件:如果差分更新的资产数量随架构或渠道增多 逼近 900 的清理阈值,或者需要支持两个以上的更新渠道,当前基于 "30 个 build + 3 个 zip 基线"的保留规则需要重新设计。 @@ -146,6 +170,7 @@ bundle identifier、展示版本、可执行文件架构、渠道和最低系统 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh --max-seconds 60 -- --filter StableRollbackDiskImageIntegrationTests ./scripts/test-macos.sh ./scripts/verify-macos-package.sh +./scripts/verify-official-plugins.sh sparkle_tools=$(zsh scripts/prepare-sparkle-tools.sh) ruby scripts/test-sparkle-update.rb "$sparkle_tools" node scripts/test-download-sparkle-baseline.mjs @@ -174,4 +199,7 @@ Sparkle 版本间升级、缺失/损坏的 delta、下载中断、权限不足 - `scripts/create-macos-update-manifest.rb` - `.github/workflows/release-macos.yml` - `.github/workflows/release-preview-macos.yml` +- `scripts/build-official-plugins.sh` +- `macos/Sources/LithePluginPackageSigning/` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift` - `docs/architecture/macos-updates.md` diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 0ef3f1024..387e6a4f6 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -117,32 +117,30 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; using ad-hoc app signing and Sparkle EdDSA update signatures." + echo "Developer ID is not configured; using ad-hoc app signing. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the App timeout-minutes: 35 env: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ needs.prepare.outputs.version }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build_number }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before releasing"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before releasing"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -171,9 +169,7 @@ jobs: "$app_path/Contents/Info.plist" done plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - if [[ -e "$plugin_archive" ]]; then - test -s "$plugin_archive" - fi + test -s "$plugin_archive" lipo "$app_path/Contents/MacOS/Lithe" -verify_arch "$LITHE_ARCH" hdiutil imageinfo "$dmg_path" > /dev/null test -s "$dmg_path" @@ -201,8 +197,7 @@ jobs: if-no-files-found: error retention-days: 7 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', needs.prepare.outputs.version, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -292,9 +287,8 @@ jobs: for architecture in arm64 x86_64; do upload_args+=(dist/sparkle-"$architecture"/*) plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - upload_args+=("$plugin_archive") - fi + test -s "$plugin_archive" + upload_args+=("$plugin_archive") done create_args+=(--notes-file "$notes_file") diff --git a/.github/workflows/release-preview-macos.yml b/.github/workflows/release-preview-macos.yml index 3598a111b..ad4cd7117 100644 --- a/.github/workflows/release-preview-macos.yml +++ b/.github/workflows/release-preview-macos.yml @@ -106,7 +106,7 @@ jobs: echo "Incomplete Developer ID configuration: provide the certificate or remove the optional identity and password." exit 1 else - echo "Developer ID is not configured; the preview app will be ad-hoc signed and no externally installable PHP plugin archive will be published." + echo "Developer ID is not configured; the preview app will be ad-hoc signed. Official plugin packages use Lithe Ed25519 signatures." fi - name: Build and package the preview app @@ -114,6 +114,7 @@ jobs: LITHE_ARCH: ${{ matrix.architecture }} LITHE_VERSION: ${{ env.PREVIEW_VERSION }} LITHE_BUILD_NUMBER: ${{ needs.prepare.outputs.build }} + LITHE_PLUGIN_PACKAGE_PRIVATE_KEY: ${{ secrets.LITHE_PLUGIN_PACKAGE_PRIVATE_KEY }} LITHE_BUILD_TIMESTAMP: ${{ needs.prepare.outputs.timestamp }} LITHE_BUILD_GIT_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.source_branch || env.PREVIEW_BRANCH }} LITHE_UPDATE_CHANNEL: preview @@ -121,20 +122,17 @@ jobs: LITHE_SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} run: | test -n "$LITHE_SPARKLE_PUBLIC_KEY" || { echo "Configure SPARKLE_PUBLIC_KEY before publishing preview updates"; exit 1; } + test -n "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" || { echo "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY before publishing preview updates"; exit 1; } ./scripts/package-app.sh - if [[ -n "${LITHE_CODESIGN_IDENTITY:-}" ]]; then - plugin_root=$(LITHE_CODESIGN_IDENTITY="$LITHE_CODESIGN_IDENTITY" \ - scripts/build-official-plugins.sh \ - --configuration release \ - --triple "${LITHE_ARCH}-apple-macosx" \ - --plugin-id dev.lithe.plugin.php-support \ - --output "dist/official-plugins/${LITHE_ARCH}") - ditto -c -k --sequesterRsrc --keepParent \ - "$plugin_root/dev.lithe.plugin.php-support" \ - "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" - else - echo "Developer ID is not configured; skipping externally installable PHP plugin archive." - fi + plugin_root=$(LITHE_CODESIGN_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" \ + scripts/build-official-plugins.sh \ + --configuration release \ + --triple "${LITHE_ARCH}-apple-macosx" \ + --plugin-id dev.lithe.plugin.php-support \ + --output "dist/official-plugins/${LITHE_ARCH}") + ditto -c -k --sequesterRsrc --keepParent \ + "$plugin_root/dev.lithe.plugin.php-support" \ + "dist/Lithe-PHP-Support-${LITHE_VERSION}-${LITHE_ARCH}.zip" ./scripts/create-dmg.sh dmg_name="Lithe-${LITHE_VERSION}-${LITHE_ARCH}.dmg" (cd dist && shasum -a 256 "$dmg_name" > "$dmg_name.sha256") @@ -179,8 +177,7 @@ jobs: compression-level: 0 retention-days: 14 - - name: Upload optional PHP plugin archive - if: ${{ hashFiles(format('dist/Lithe-PHP-Support-{0}-{1}.zip', env.PREVIEW_VERSION, matrix.architecture)) != '' }} + - name: Upload PHP plugin archive uses: actions/upload-artifact@v7 with: name: macos-php-plugin-${{ matrix.architecture }} @@ -282,10 +279,9 @@ jobs: for architecture in arm64 x86_64; do plugin_archive="dist/Lithe-PHP-Support-${LITHE_VERSION}-${architecture}.zip" - if [[ -e "$plugin_archive" ]]; then - gh release upload "$RELEASE_TAG" "$plugin_archive" \ - --repo "$GITHUB_REPOSITORY" --clobber - fi + test -s "$plugin_archive" + gh release upload "$RELEASE_TAG" "$plugin_archive" \ + --repo "$GITHUB_REPOSITORY" --clobber done # Publish every archive before replacing either feed. Retention protects diff --git a/Package.swift b/Package.swift index 234edc6c7..5f5fcd9cb 100644 --- a/Package.swift +++ b/Package.swift @@ -25,10 +25,12 @@ let package = Package( .library(name: "LitheWorkspaceModule", targets: ["LitheWorkspaceModule"]), .library(name: "LitheGoSupportModule", targets: ["LitheGoSupportModule"]), .library(name: "LithePhpSupportModule", targets: ["LithePhpSupportModule"]), + .library(name: "LithePluginPackageSigning", targets: ["LithePluginPackageSigning"]), .executable(name: "LitheCoreVerifier", targets: ["LitheCoreVerifier"]), .executable(name: "LitheGitGraphVerifier", targets: ["LitheGitGraphVerifier"]), .executable(name: "LitheGitPerformanceVerifier", targets: ["LitheGitPerformanceVerifier"]), - .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) + .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]), + .executable(name: "LithePluginPackageSigner", targets: ["LithePluginPackageSigner"]) ], dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.10.0"), @@ -89,6 +91,11 @@ let package = Package( .target(name: "LitheWorkspaceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheWorkspaceModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheGoSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/GoSupport/Sources/LitheGoSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LithePhpSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Plugins/mac/Official/PhpSupport/Sources/LithePhpSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target( + name: "LithePluginPackageSigning", + path: "macos/Sources/LithePluginPackageSigning", + swiftSettings: [.swiftLanguageMode(.v6)] + ), .target( name: "LitheRustCore", path: "macos/Sources/LitheRustCore", @@ -111,6 +118,7 @@ let package = Package( "LitheDebugModule", "LitheLanguageIntelligenceModule", "LitheWorkspaceModule", + "LithePluginPackageSigning", "LitheRustCore", .product(name: "SwiftTerm", package: "SwiftTerm"), .product(name: "Sparkle", package: "Sparkle") @@ -131,7 +139,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", .product(name: "Testing", package: "swift-testing")], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", "LithePluginPackageSigning", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -271,6 +279,12 @@ let package = Package( dependencies: ["LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts"], path: "macos/Tests/LitheOfficialPluginVerifier", swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LithePluginPackageSigner", + dependencies: ["LithePluginPackageSigning"], + path: "macos/Tools/LithePluginPackageSigner", + swiftSettings: [.swiftLanguageMode(.v6)] ) ] ) diff --git a/Plugins/mac/Official/PhpSupport/plugin.json b/Plugins/mac/Official/PhpSupport/plugin.json index 3aad8713a..d8527d201 100644 --- a/Plugins/mac/Official/PhpSupport/plugin.json +++ b/Plugins/mac/Official/PhpSupport/plugin.json @@ -11,7 +11,7 @@ "vendor": { "id": "dev.lithe", "displayName": "Lithe", - "signatureRequirement": "sameTeamAsHost" + "signatureRequirement": "publisherPackage" }, "entrypoint": { "kind": "nativeBundle", diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 0fba03e24..fb1784132 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -11,6 +11,7 @@ building the host API for the same configuration and architecture: ```sh LITHE_CODESIGN_IDENTITY="" \ +LITHE_PLUGIN_PACKAGE_PRIVATE_KEY="$(cat /secure/path/lithe-plugin-package-private-key.base64)" \ scripts/build-official-plugins.sh --configuration release \ --triple arm64-apple-macosx --plugin-id dev.lithe.plugin.php-support ``` @@ -20,8 +21,12 @@ Use `x86_64-apple-macosx` for Intel. Keep the resulting users download, install, reinstall, and uninstall PHP Support from Plugin Management. After installation and restart, the LSP settings page only controls the current project's PHP language server. Native package verification still -requires the host's signing team. Debug/ad-hoc CI packages are for local testing -and are not production distribution artifacts. +requires the host's signing team. A PHP package also requires +`LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`, a base64-encoded 32-byte Ed25519 private key +whose public key matches the embedded publisher key; debug builds skip PHP when +the key is absent, and a direct PHP package build fails instead of producing an +unsigned package. Keep the key in a protected file or environment variable and +never commit it. The PHP plugin archive carries a pinned Intelephense package described by `language-server.json`. Plugin Management downloads and verifies that tool as diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 9fb21a2f3..f5a0d4232 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -230,7 +230,7 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 -PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 +PHP 插件包在 `.build///OfficialPlugins` 中独立构建,绑定宿主 API、Swift 工具链、架构和签名,通过 `LitheOfficialPluginVerifier` 验证;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 PHP 包,指定 PHP 包 ID 的构建会失败,不会留下缺少签名文档的目录。无可靠 identity stamp,不跨工作树复制。插件安装后的 Intelephense 位于 `/Lithe/Plugins//versions//PhpSupport.bundle/Contents/Resources/LanguageServers/php`,由插件版本目录拥有,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 如果后续新增可复用资源,必须同步更新注册表、校验器、脚本测试和本节说明。 diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index ca22fcf71..83d43611e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -1,6 +1,8 @@ +import CryptoKit import Foundation import LitheApplicationKernel import LitheModuleAPI +import LithePluginPackageSigning import Security protocol PluginPackageSignatureVerifying { @@ -20,6 +22,9 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case unsignedCode(URL) case invalidCodeSignature(URL) case signingTeamMismatch + case missingPackageSignature + case invalidPackageSignature(String) + case officialSignatureRequirementMismatch(PluginID) case retiredPlugin(PluginID) case invalidInstalledPlugin(PluginID?, String) @@ -37,6 +42,10 @@ enum PluginPackageStoreError: Error, Equatable, LocalizedError { case .unsignedCode(let url): "Plugin code is not signed: \(url.lastPathComponent)." case .invalidCodeSignature(let url): "Plugin signature is invalid: \(url.lastPathComponent)." case .signingTeamMismatch: "Plugin and host application signing teams do not match." + case .missingPackageSignature: "The official plugin package signature is missing." + case .invalidPackageSignature(let detail): "The official plugin package signature is invalid: \(detail)" + case .officialSignatureRequirementMismatch(let id): + "Official plugin \(id) declares a signature policy that does not match the host trust policy." case .retiredPlugin(let id): "Plugin \(id) has been removed from Lithe. Uninstall the old package." case .invalidInstalledPlugin(let id, let message): if let id { @@ -177,6 +186,7 @@ final class MacPluginPackageStore { manifest.version == record.activeVersion else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) _ = try ValidatedPluginCatalog(manifests: [manifest], hostVersion: hostVersion) try verifier.verify(packageAt: packageURL, manifest: manifest) try MacPluginLanguageServerPackageValidator.validate( @@ -235,6 +245,7 @@ final class MacPluginPackageStore { guard packageURL.lastPathComponent == manifest.id.rawValue else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) _ = try ValidatedPluginCatalog( manifests: installed.map(\.manifest) + [manifest], hostVersion: hostVersion @@ -282,6 +293,7 @@ final class MacPluginPackageStore { guard !Self.retiredPluginIDs.contains(sourceManifest.id) else { throw PluginPackageStoreError.retiredPlugin(sourceManifest.id) } + try validateOfficialTrustPolicy(sourceManifest) _ = try ValidatedPluginCatalog(manifests: [sourceManifest], hostVersion: hostVersion) try validatePathComponent(sourceManifest.id.rawValue) @@ -296,6 +308,7 @@ final class MacPluginPackageStore { guard manifest == sourceManifest else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) try verifier.verify(packageAt: stagedURL, manifest: manifest) do { try MacPluginLanguageServerPackageValidator.validate( @@ -385,6 +398,7 @@ final class MacPluginPackageStore { guard manifest.id == pluginID, manifest.version == previousVersion else { throw PluginPackageStoreError.manifestDoesNotMatchInstallation } + try validateOfficialTrustPolicy(manifest) try verifier.verify(packageAt: previousPackageURL, manifest: manifest) try MacPluginLanguageServerPackageValidator.validate( packageAt: previousPackageURL, @@ -459,6 +473,15 @@ final class MacPluginPackageStore { ), to: pluginDirectory.appendingPathComponent("installation.json")) } + private func validateOfficialTrustPolicy(_ manifest: PluginManifest) throws { + guard let trustedManifest = OfficialPluginCatalog.manifests.first(where: { + $0.id == manifest.id + }) else { return } + guard manifest.vendor.signatureRequirement == trustedManifest.vendor.signatureRequirement else { + throw PluginPackageStoreError.officialSignatureRequirementMismatch(manifest.id) + } + } + private func loadManifest(at packageURL: URL) throws -> PluginManifest { let values = try packageURL.resourceValues(forKeys: [.isDirectoryKey]) guard values.isDirectory == true else { @@ -532,6 +555,13 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { guard SecStaticCodeCheckValidity(pluginCode, validationFlags, nil) == errSecSuccess else { throw PluginPackageStoreError.invalidCodeSignature(pluginBundleURL) } + let signatureRequirement = OfficialPluginCatalog.manifests.first(where: { + $0.id == manifest.id + })?.vendor.signatureRequirement ?? manifest.vendor.signatureRequirement + if signatureRequirement == .publisherPackage { + try verifyPublisherPackageSignature(packageAt: packageURL, manifest: manifest) + return + } let pluginTeam = try teamIdentifier(for: pluginCode) let hostTeam = try teamIdentifier(for: hostCode) if let pluginTeam, let hostTeam { @@ -548,6 +578,37 @@ struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { } } + private func verifyPublisherPackageSignature( + packageAt packageURL: URL, + manifest: PluginManifest + ) throws { + let signatureURL = packageURL.appendingPathComponent(PluginPackageSignature.signatureFileName) + guard FileManager.default.fileExists(atPath: signatureURL.path) else { + throw PluginPackageStoreError.missingPackageSignature + } + let document: PluginPackageSignature.Document + do { + document = try PluginPackageSignature.read(from: packageURL) + } catch { + throw PluginPackageStoreError.invalidPackageSignature("The signature document could not be decoded.") + } + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw PluginPackageStoreError.invalidPackageSignature("The embedded public key is invalid.") + } + do { + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.id.rawValue, + pluginVersion: manifest.version.description, + document: document, + publicKey: publicKey + ) + } catch { + throw PluginPackageStoreError.invalidPackageSignature(error.localizedDescription) + } + } + private func staticCode(at url: URL) throws -> SecStaticCode { var code: SecStaticCode? guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index f579620f3..ebf5a9d21 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -212,6 +212,11 @@ public enum OfficialPluginCatalog { private static let goLanguageID = "go" private static let phpLanguageID = "php" public static let phpPluginID = PluginID("dev.lithe.plugin.php-support") + public static let publisherPackageVendor = PluginVendor( + id: BuiltInPluginCatalog.vendor.id, + displayName: BuiltInPluginCatalog.vendor.displayName, + signatureRequirement: .publisherPackage + ) public static let manifests: [PluginManifest] = [ PluginManifest( @@ -276,7 +281,7 @@ public enum OfficialPluginCatalog { minimum: BuiltInPluginCatalog.hostVersion, maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) ), - vendor: BuiltInPluginCatalog.vendor, + vendor: publisherPackageVendor, entrypoint: PluginEntrypoint( kind: .nativeBundle, bundleIdentifier: "dev.lithe.plugin.php-support.bundle", diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 811a1080e..c897a5f75 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -99,6 +99,7 @@ public struct PluginHostCompatibility: Equatable, Codable, Sendable { public enum PluginSignatureRequirement: String, Codable, Sendable { case sameTeamAsHost + case publisherPackage } public struct PluginVendor: Equatable, Codable, Sendable { diff --git a/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift new file mode 100644 index 000000000..8d8a2ff94 --- /dev/null +++ b/macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift @@ -0,0 +1,242 @@ +import CryptoKit +import Foundation + +/// Signs and verifies the complete file tree of an official native plugin package. +/// +/// The package signature authenticates the release artifact independently from +/// Apple's code-signing identity. Native bundle validation remains required so +/// malformed or unsigned bundles cannot be loaded. +public enum PluginPackageSignature { + public static let schemaVersion = 1 + public static let algorithm = "ed25519" + public static let keyID = "lithe-official-plugins-v1" + /// Base64-encoded public key trusted for official separately distributed plugins. + public static let publisherPublicKeyBase64 = "5g83oIZu4TjOQr5g9KJcrNd2pgdXyEnvhJIXtrPoPyw=" + public static let signatureFileName = "lithe-plugin-signature.json" + + public struct Document: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let algorithm: String + public let keyID: String + public let pluginID: String + public let pluginVersion: String + public let files: [String: String] + public let signature: String + + public init( + schemaVersion: Int = PluginPackageSignature.schemaVersion, + algorithm: String = PluginPackageSignature.algorithm, + keyID: String = PluginPackageSignature.keyID, + pluginID: String, + pluginVersion: String, + files: [String: String], + signature: String + ) { + self.schemaVersion = schemaVersion + self.algorithm = algorithm + self.keyID = keyID + self.pluginID = pluginID + self.pluginVersion = pluginVersion + self.files = files + self.signature = signature + } + } + + public enum Error: Swift.Error, Equatable, LocalizedError { + case invalidPackageRoot + case missingManifest + case invalidManifest + case unsupportedFile(String) + case fileListMismatch + case fileDigestMismatch(String) + case invalidDocument + case invalidSignature + case publicKeyUnavailable + + public var errorDescription: String? { + switch self { + case .invalidPackageRoot: + return "The plugin package root is invalid." + case .missingManifest: + return "The plugin package manifest is missing." + case .invalidManifest: + return "The plugin package manifest is invalid." + case .unsupportedFile(let path): + return "The plugin package contains an unsupported file: \(path)." + case .fileListMismatch: + return "The plugin package file list does not match its signature." + case .fileDigestMismatch(let path): + return "The plugin package file was changed after signing: \(path)." + case .invalidDocument: + return "The plugin package signature document is invalid." + case .invalidSignature: + return "The plugin package signature is invalid." + case .publicKeyUnavailable: + return "The official plugin signing key is unavailable." + } + } + } + + public static func makeDocument( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + privateKey: Curve25519.Signing.PrivateKey, + fileManager: FileManager = .default + ) throws -> Document { + let files = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + let payload = canonicalPayload( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files + ) + return Document( + pluginID: pluginID, + pluginVersion: pluginVersion, + files: files, + signature: try privateKey.signature(for: payload).base64EncodedString() + ) + } + + public static func verify( + packageAt packageURL: URL, + pluginID: String, + pluginVersion: String, + document: Document, + publicKey: Curve25519.Signing.PublicKey, + fileManager: FileManager = .default + ) throws { + guard document.schemaVersion == schemaVersion, + document.algorithm == algorithm, + document.keyID == keyID, + document.pluginID == pluginID, + document.pluginVersion == pluginVersion, + let signature = Data(base64Encoded: document.signature), + signature.count == 64 else { + throw Error.invalidDocument + } + + let actualFiles = try fileDigests( + packageAt: packageURL, + fileManager: fileManager + ) + guard Set(actualFiles.keys) == Set(document.files.keys) else { + throw Error.fileListMismatch + } + for path in actualFiles.keys.sorted() { + guard actualFiles[path] == document.files[path] else { + throw Error.fileDigestMismatch(path) + } + } + + let payload = canonicalPayload( + pluginID: document.pluginID, + pluginVersion: document.pluginVersion, + files: document.files + ) + guard publicKey.isValidSignature(signature, for: payload) else { + throw Error.invalidSignature + } + } + + public static func write( + _ document: Document, + to packageURL: URL, + fileManager: FileManager = .default + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(document) + try data.write( + to: packageURL.appendingPathComponent(signatureFileName), + options: [.atomic] + ) + } + + public static func read( + from packageURL: URL, + fileManager: FileManager = .default + ) throws -> Document { + let signatureURL = packageURL.appendingPathComponent(signatureFileName) + guard fileManager.fileExists(atPath: signatureURL.path) else { + throw Error.invalidDocument + } + do { + return try JSONDecoder().decode(Document.self, from: Data(contentsOf: signatureURL)) + } catch { + throw Error.invalidDocument + } + } + + private static func fileDigests( + packageAt packageURL: URL, + fileManager: FileManager + ) throws -> [String: String] { + let root = packageURL.standardizedFileURL.resolvingSymlinksInPath() + guard (try? root.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { + throw Error.invalidPackageRoot + } + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) else { + throw Error.invalidPackageRoot + } + + var digests: [String: String] = [:] + for case let candidate as URL in enumerator { + let values = try candidate.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + let resolvedCandidate = candidate.resolvingSymlinksInPath() + let relativePath = resolvedCandidate.path.replacingOccurrences(of: root.path + "/", with: "") + guard !relativePath.isEmpty else { continue } + guard values.isSymbolicLink != true else { + throw Error.unsupportedFile(relativePath) + } + if values.isDirectory == true { continue } + guard relativePath != signatureFileName else { continue } + guard !relativePath.hasPrefix("/"), + !relativePath.split(separator: "/", omittingEmptySubsequences: false).contains(".."), + !relativePath.contains("\0"), + !relativePath.contains("\n"), + !relativePath.contains("\r") else { + throw Error.unsupportedFile(relativePath) + } + let data = try Data(contentsOf: resolvedCandidate, options: [.mappedIfSafe]) + let digest = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + digests[relativePath] = digest + } + return digests + } + + private static func canonicalPayload( + pluginID: String, + pluginVersion: String, + files: [String: String] + ) -> Data { + var lines = [ + "schemaVersion=\(schemaVersion)", + "algorithm=\(algorithm)", + "keyID=\(keyID)", + "pluginID=\(token(pluginID))", + "pluginVersion=\(token(pluginVersion))" + ] + lines.append(contentsOf: files.keys.sorted().map { path in + "file=\(token(path))=\(files[path]!)" + }) + return Data((lines.joined(separator: "\n") + "\n").utf8) + } + + private static func token(_ value: String) -> String { + value.data(using: .utf8)! + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/macos/Tests/LitheTests/PluginPackageSignatureTests.swift b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift new file mode 100644 index 000000000..b43925877 --- /dev/null +++ b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift @@ -0,0 +1,101 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning +import Testing + +struct PluginPackageSignatureTests { + @Test + func signatureCoversEveryPackageFile() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 1, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + + @Test + func tamperingWithAPluginFileIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 2, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("changed".utf8).write( + to: root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + ) + + #expect(throws: PluginPackageSignature.Error.fileDigestMismatch( + "Example.bundle/Contents/MacOS/plugin" + )) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + @Test + func addingAFileAfterSigningIsRejected() throws { + let root = try makePackage() + defer { try? FileManager.default.removeItem(at: root) } + let privateKey = try #require( + try? Curve25519.Signing.PrivateKey(rawRepresentation: Data(repeating: 3, count: 32)) + ) + let document = try PluginPackageSignature.makeDocument( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: root) + try Data("unexpected".utf8).write(to: root.appendingPathComponent("unexpected.txt")) + + #expect(throws: PluginPackageSignature.Error.fileListMismatch) { + try PluginPackageSignature.verify( + packageAt: root, + pluginID: "dev.example.plugin", + pluginVersion: "0.3.0", + document: document, + publicKey: privateKey.publicKey + ) + } + } + + private func makePackage() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-signature-\(UUID().uuidString)", isDirectory: true) + let executable = root.appendingPathComponent("Example.bundle/Contents/MacOS/plugin") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("manifest".utf8).write(to: root.appendingPathComponent("plugin.json")) + try Data("binary".utf8).write(to: executable) + return root + } +} diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 120b5d859..a076ddbdc 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -256,6 +256,53 @@ struct PluginPackageStoreTests { #expect(try store.installedPlugins().first?.manifest.version == version) } + @Test + func officialPublisherPackageCannotDowngradeSignatureRequirement() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-php-plugin-policy-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let verifier = TestPluginSignatureVerifier() + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: verifier + ) + let package = try makePHPPluginPackage( + root: root, + name: "downgraded-policy", + version: BuiltInPluginCatalog.hostVersion + ) + let manifestURL = package.appendingPathComponent("plugin.json") + let decoder = JSONDecoder() + let manifest = try decoder.decode( + PluginManifest.self, + from: Data(contentsOf: manifestURL) + ) + let downgradedManifest = PluginManifest( + id: manifest.id, + displayName: manifest.displayName, + version: manifest.version, + hostCompatibility: manifest.hostCompatibility, + vendor: PluginVendor( + id: manifest.vendor.id, + displayName: manifest.vendor.displayName, + signatureRequirement: .sameTeamAsHost + ), + entrypoint: manifest.entrypoint, + modules: manifest.modules, + languageSupports: manifest.languageSupports ?? [] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(downgradedManifest).write(to: manifestURL, options: .atomic) + + #expect(throws: PluginPackageStoreError.officialSignatureRequirementMismatch( + OfficialPluginCatalog.phpPluginID + )) { + _ = try store.installPackage(from: package) + } + #expect(verifier.verifiedVersions.isEmpty) + } + @Test func requiredPluginCannotBeUninstalled() throws { let root = FileManager.default.temporaryDirectory diff --git a/macos/Tools/LithePluginPackageSigner/main.swift b/macos/Tools/LithePluginPackageSigner/main.swift new file mode 100644 index 000000000..a5002e17f --- /dev/null +++ b/macos/Tools/LithePluginPackageSigner/main.swift @@ -0,0 +1,86 @@ +import CryptoKit +import Foundation +import LithePluginPackageSigning + +@main +struct LithePluginPackageSigner { + static func main() throws { + guard CommandLine.arguments.count == 2 || + (CommandLine.arguments.count == 3 && CommandLine.arguments[1] == "--verify") else { + throw SignerError.usage + } + let isVerification = CommandLine.arguments.count == 3 + let packageURL = URL( + fileURLWithPath: CommandLine.arguments[isVerification ? 2 : 1], + isDirectory: true + ) + let manifest = try loadManifest(from: packageURL) + + if isVerification { + let document = try PluginPackageSignature.read(from: packageURL) + guard let publicKeyData = Data(base64Encoded: PluginPackageSignature.publisherPublicKeyBase64), + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData) else { + throw SignerError.invalidPublicKey + } + try PluginPackageSignature.verify( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + document: document, + publicKey: publicKey + ) + writeStatus("Verified publisher signature for \(manifest.pluginID) \(manifest.pluginVersion)") + return + } + + let keyText = String( + data: FileHandle.standardInput.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let keyText, + let keyData = Data(base64Encoded: keyText), + let privateKey = try? Curve25519.Signing.PrivateKey(rawRepresentation: keyData) else { + throw SignerError.invalidPrivateKey + } + guard privateKey.publicKey.rawRepresentation.base64EncodedString() + == PluginPackageSignature.publisherPublicKeyBase64 else { + throw SignerError.untrustedPrivateKey + } + + let document = try PluginPackageSignature.makeDocument( + packageAt: packageURL, + pluginID: manifest.pluginID, + pluginVersion: manifest.pluginVersion, + privateKey: privateKey + ) + try PluginPackageSignature.write(document, to: packageURL) + writeStatus("Signed publisher package \(manifest.pluginID) \(manifest.pluginVersion)") + } + + /// Keep stdout available for machine-readable command results. + /// Build scripts capture stdout as the package path, so status messages belong on stderr. + private static func writeStatus(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } + + private static func loadManifest(from packageURL: URL) throws -> (pluginID: String, pluginVersion: String) { + let manifestURL = packageURL.appendingPathComponent("plugin.json") + guard let manifestData = try? Data(contentsOf: manifestURL), + let manifest = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + let pluginID = manifest["id"] as? String, + let pluginVersion = manifest["version"] as? String, + !pluginID.isEmpty, + !pluginVersion.isEmpty else { + throw SignerError.invalidManifest + } + return (pluginID, pluginVersion) + } +} + +private enum SignerError: Error { + case usage + case invalidPrivateKey + case untrustedPrivateKey + case invalidPublicKey + case invalidManifest +} diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh index f0a051964..6a10a70a1 100755 --- a/scripts/build-official-plugins.sh +++ b/scripts/build-official-plugins.sh @@ -31,13 +31,17 @@ case "$TRIPLE" in *) print -u2 -- "Unsupported macOS Swift triple: $TRIPLE"; exit 2 ;; esac -BUILD_DIR="$ROOT_DIR/.build/$TRIPLE/$CONFIGURATION" -if [[ -e "$BUILD_DIR/Modules/LitheModuleAPI.swiftmodule" && \ - -e "$BUILD_DIR/Modules/LitheCoreContracts.swiftmodule" ]]; then - MODULE_DIR="$BUILD_DIR/Modules" +SWIFT_LAYOUT_ARGS=( + --triple "$TRIPLE" + --configuration "$CONFIGURATION" +) +SWIFT_BIN_PATH=$(swift build --show-bin-path "${SWIFT_LAYOUT_ARGS[@]}") +if [[ -e "$SWIFT_BIN_PATH/Modules/LitheModuleAPI.swiftmodule" && \ + -e "$SWIFT_BIN_PATH/Modules/LitheCoreContracts.swiftmodule" ]]; then + MODULE_DIR="$SWIFT_BIN_PATH/Modules" else # SwiftPM 6.4 places package modules directly beside the executable. - MODULE_DIR="$BUILD_DIR" + MODULE_DIR="$SWIFT_BIN_PATH" fi if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCoreContracts.swiftmodule" ]]; then print -u2 -- "Build Lithe for $TRIPLE ($CONFIGURATION) before packaging official plugins" @@ -45,7 +49,7 @@ if [[ ! -e "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -e "$MODULE_DIR/LitheCo fi if [[ -z "$OUTPUT_DIR" ]]; then - OUTPUT_DIR="$BUILD_DIR/OfficialPlugins" + OUTPUT_DIR="$SWIFT_BIN_PATH/OfficialPlugins" fi # Match the host build's explicit SDK when multiple SDKs are installed. SDK_PATH="${SDKROOT:-$(/usr/bin/xcrun --sdk macosx --show-sdk-path)}" @@ -60,6 +64,7 @@ for stale_package in "$OUTPUT_DIR"/*(/N); do rm -rf "$stale_package" done matched=0 +signer_binary="" for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do manifest="$plugin_source/plugin.json" info_plist="$plugin_source/Info.plist" @@ -71,6 +76,15 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do if [[ "$BUNDLED_ONLY" == true ]] && ! node "$ROOT_DIR/scripts/official-plugin-distribution.mjs" "$package_id"; then continue fi + signature_requirement=$(/usr/bin/plutil -extract vendor.signatureRequirement raw "$manifest") + if [[ "$signature_requirement" == "publisherPackage" && -z "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then + if [[ "$CONFIGURATION" == "release" ]]; then + print -u2 -- "Configure LITHE_PLUGIN_PACKAGE_PRIVATE_KEY for publisher-signed plugin packages" + exit 1 + fi + print -u2 -- "Skipping publisher-signed debug plugin package $package_id; set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY to build it" + continue + fi matched=$((matched + 1)) module_suffix="${plugin_source:t}" source_dir="$plugin_source/Sources/Lithe${module_suffix}Module" @@ -118,6 +132,24 @@ for plugin_source in "$ROOT_DIR"/Plugins/mac/Official/*(/N); do fi /usr/bin/codesign --force --sign "$SIGNING_IDENTITY" "$bundle_dir" + + if [[ "$signature_requirement" == "publisherPackage" ]]; then + if [[ -z "$signer_binary" ]]; then + swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --product LithePluginPackageSigner >&2 + signer_bin_dir=$(swift build \ + "${SWIFT_LAYOUT_ARGS[@]}" \ + --show-bin-path) + signer_binary="$signer_bin_dir/LithePluginPackageSigner" + [[ -x "$signer_binary" ]] || { + print -u2 -- "Plugin package signer was not built: $signer_binary" + exit 1 + } + fi + print -rn -- "$LITHE_PLUGIN_PACKAGE_PRIVATE_KEY" | "$signer_binary" "$package_dir" + "$signer_binary" --verify "$package_dir" + fi done if (( matched == 0 )); then diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh index 8087e744d..e3bf467c4 100755 --- a/scripts/verify-official-plugins.sh +++ b/scripts/verify-official-plugins.sh @@ -24,7 +24,18 @@ swift build "${SWIFT_BUILD_ARGS[@]}" --product LitheOfficialPluginVerifier PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ --configuration debug \ --triple "$TRIPLE") +[[ -d "$PLUGIN_ROOT" ]] || { + print -u2 -- "Official plugin build returned an invalid package root: $PLUGIN_ROOT" + exit 1 +} +if [[ -n "${LITHE_PLUGIN_PACKAGE_PRIVATE_KEY:-}" ]]; then + [[ -d "$PLUGIN_ROOT/dev.lithe.plugin.php-support" ]] || { + print -u2 -- "Publisher key was provided but the PHP plugin package was not built" + exit 1 + } +fi plugins=("$PLUGIN_ROOT"/*(/N)) +package_signer_binary="" for plugin in "${plugins[@]}"; do swift run "${SWIFT_BUILD_ARGS[@]}" --skip-build LitheOfficialPluginVerifier "$plugin" if [[ "$plugin:t" == "dev.lithe.plugin.php-support" ]]; then @@ -38,6 +49,16 @@ for plugin in "${plugins[@]}"; do exit 1 } /usr/bin/codesign --verify --deep --strict "$plugin/PhpSupport.bundle" + if [[ -z "$package_signer_binary" ]]; then + swift build "${SWIFT_BUILD_ARGS[@]}" --product LithePluginPackageSigner + signer_bin_dir=$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path) + package_signer_binary="$signer_bin_dir/LithePluginPackageSigner" + [[ -x "$package_signer_binary" ]] || { + print -u2 -- "Plugin package signer was not built: $package_signer_binary" + exit 1 + } + fi + "$package_signer_binary" --verify "$plugin" fi done -print "Verified ${#plugins[@]} released official native plugin package(s)" +print "Verified ${#plugins[@]} official native plugin package(s)" diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 007188b89..925f6b0a7 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -143,7 +143,7 @@ fi abort "plugin module IDs must be unique" unless owned_modules.uniq.length == owned_modules.length entries.each do |plugin| abort "plugin API mismatch" unless plugin.fetch("apiVersion") == plugins.fetch("pluginAPIVersion") - abort "official plugin signature policy mismatch" unless plugin.fetch("vendor").fetch("signatureRequirement") == "sameTeamAsHost" + abort "official plugin signature policy mismatch" unless %w[publisherPackage sameTeamAsHost].include?(plugin.fetch("vendor").fetch("signatureRequirement")) abort "plugin module IDs must be sorted" unless plugin.fetch("moduleIDs") == plugin.fetch("moduleIDs").sort end ' "$plugin_fixture" diff --git a/shared/platform-feature-matrix/features/php-optional-plugin.json b/shared/platform-feature-matrix/features/php-optional-plugin.json index 3543dcb44..27b9e6899 100644 --- a/shared/platform-feature-matrix/features/php-optional-plugin.json +++ b/shared/platform-feature-matrix/features/php-optional-plugin.json @@ -10,7 +10,9 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins", "macos/Sources/Lithe/Views/App/PluginManagementView.swift", "macos/Sources/Lithe/Views/Language/LSPControlCenterView.swift", - ".github/workflows/release-macos.yml" + ".github/workflows/release-macos.yml", + "macos/Sources/LithePluginPackageSigning/PluginPackageSignature.swift", + "macos/Tests/LitheTests/PluginPackageStoreTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -27,5 +29,5 @@ "verificationStatus": "pending" }, "owner": "PHP Support", - "verification": "macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" + "verification": "macOS 在干净安装上打开插件管理,确认可从当前发行渠道下载并安装 PHP、失败时可从磁盘导入;安装后重启,在 LSP 界面只控制当前项目开关和状态,验证重装、卸载均在重启后生效且用户工具保留。对官方 PHP 包将 plugin.json 的 signatureRequirement 从 publisherPackage 篡改为 sameTeamAsHost,确认 MacPluginPackageStore 在调用包 verifier 前按宿主 OfficialPluginCatalog 拒绝策略降级;删除或篡改 lithe-plugin-signature.json、manifest、版本或任意签名覆盖文件时也必须拒绝。Windows 从独立 .lithe-extension 文件导入,默认禁用;启用后重启确认能恢复,卸载后重启确认不恢复。" }