diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index f0c4fc7f..34e5a8b7 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -40,6 +40,7 @@ jobs: pnpm exec tsx --test packages/desktop/test/ccbuddy-update-policy.test.mjs packages/desktop/test/finalize-macos-release.test.mjs + packages/desktop/test/legacy-native-update.test.mjs packages/desktop/test/prepare-main-release.test.mjs macos-package: @@ -79,3 +80,6 @@ jobs: test -s packages/desktop/dist/latest-mac.yml test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' packages/desktop/dist/mac-arm64/CCbuddy.app/Contents/Info.plist)" = "$version" test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' packages/desktop/dist/mac-arm64/CCbuddy.app/Contents/Info.plist)" = app.ccbuddy.desktop + # The legacy native updater rejects any symlink that resolves outside the app. + node packages/desktop/scripts/legacy-native-update.mjs check-symlinks \ + packages/desktop/dist/mac-arm64/CCbuddy.app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3df26953..a2980475 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,6 +176,28 @@ jobs: node packages/desktop/scripts/finalize-macos-release.mjs packages/desktop/dist version="${{ needs.verify.outputs.version }}" unzip -t "packages/desktop/dist/CCbuddy-${version}-mac-arm64.zip" + - name: Package, notarize, and sign the legacy native update bridge + shell: bash + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + # An unencrypted Tauri key legitimately has an empty password. + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + umask 077 + test -n "$APPLE_API_KEY_P8" + test -n "$TAURI_SIGNING_PRIVATE_KEY" + key="$RUNNER_TEMP/ccbuddy-legacy-notary.p8" + trap 'rm -f "$key"' EXIT + printf '%s' "$APPLE_API_KEY_P8" | openssl base64 -d -A -out "$key" + unset APPLE_API_KEY_P8 + test -s "$key" + APPLE_API_KEY_PATH="$key" packages/desktop/scripts/package-legacy-native-bridge.sh \ + packages/desktop/dist/mac-arm64/CCbuddy.app "${{ needs.verify.outputs.version }}" \ + packages/desktop/dist "$GITHUB_REPOSITORY" - name: Upload complete release inputs uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: @@ -185,12 +207,16 @@ jobs: packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-mac-arm64.zip.blockmap packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-mac-arm64.dmg packages/desktop/dist/latest-mac.yml + packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-legacy-mac-arm64.app.tar.gz + packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-legacy-mac-arm64.app.tar.gz.sig + packages/desktop/dist/latest.json if-no-files-found: error retention-days: 14 - name: Remove temporary signing keychain if: always() run: | - rm -f "$RUNNER_TEMP/ccbuddy-developer-id.p12" "$RUNNER_TEMP/ccbuddy-notary.p8" + rm -f "$RUNNER_TEMP/ccbuddy-developer-id.p12" "$RUNNER_TEMP/ccbuddy-notary.p8" \ + "$RUNNER_TEMP/ccbuddy-legacy-notary.p8" if [ -f "$RUNNER_TEMP/ccbuddy-signing.keychain-db" ]; then security delete-keychain "$RUNNER_TEMP/ccbuddy-signing.keychain-db" fi @@ -249,7 +275,9 @@ jobs: fi zip="CCbuddy-${VERSION}-mac-arm64.zip" dmg="CCbuddy-${VERSION}-mac-arm64.dmg" - expected="$(printf '%s\n' "$zip" "$zip.blockmap" "$dmg" latest-mac.yml | sort)" + legacy="CCbuddy-${VERSION}-legacy-mac-arm64.app.tar.gz" + expected="$(printf '%s\n' "$zip" "$zip.blockmap" "$dmg" latest-mac.yml \ + "$legacy" "$legacy.sig" latest.json | sort)" actual="$(find dist -maxdepth 1 -type f -printf '%f\n' | sort)" test "$actual" = "$expected" sha="$(openssl dgst -sha512 -binary "dist/$zip" | openssl base64 -A)" @@ -258,6 +286,12 @@ jobs: grep -Fx "path: $zip" dist/latest-mac.yml grep -Fx "sha512: $sha" dist/latest-mac.yml test "$(grep -c '^ - url:' dist/latest-mac.yml)" = 1 + if grep -Fq legacy dist/latest-mac.yml; then + echo "latest-mac.yml must not reference the legacy bridge" >&2 + exit 1 + fi + # Old native/Tauri clients read latest.json; re-verify it against the downloaded bytes. + node packages/desktop/scripts/legacy-native-update.mjs verify dist "$VERSION" "$GITHUB_REPOSITORY" - name: Create or reuse draft and upload all assets shell: bash run: | @@ -277,6 +311,7 @@ jobs: test "$actual" = "$expected" mkdir "$RUNNER_TEMP/release-check" gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ - --pattern latest-mac.yml --dir "$RUNNER_TEMP/release-check" + --pattern latest-mac.yml --pattern latest.json --dir "$RUNNER_TEMP/release-check" cmp dist/latest-mac.yml "$RUNNER_TEMP/release-check/latest-mac.yml" + cmp dist/latest.json "$RUNNER_TEMP/release-check/latest.json" gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest=true diff --git a/docs/specs/legacy-native-update-bridge.md b/docs/specs/legacy-native-update-bridge.md new file mode 100644 index 00000000..7c403914 --- /dev/null +++ b/docs/specs/legacy-native-update-bridge.md @@ -0,0 +1,105 @@ +# Legacy native update bridge + +## Problem and product rule + +CCbuddy 2.0.3–2.0.9 shipped as a native Swift macOS app, and Tauri 1.3.9 shipped +before that. Those installed apps cannot be changed; their updater reads only +`https://github.com/ccbud/ccbud/releases/latest/download/latest.json` (GitHub +redirects the old repository name to `ccbud/CCBuddy`). The Electron releases +since 2.0.12 publish only `latest-mac.yml`. So `latest.json` returned 404 and +2.0.5 could not update. + +Every production Electron release therefore also publishes a **legacy bridge**: +the same Electron app, re-signed under the old native identity, plus the +`latest.json` contract the old updater understands. After the bridge is +installed, it updates through `latest-mac.yml` like any other Electron build and +becomes `app.ccbuddy.desktop` on its next update. + +## Frozen legacy client contract (from the v2.0.5 sources) + +The old clients cannot change, so the bridge must meet every check below. + +| Check | Required value | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Manifest | JSON `{version, pub_date, platforms}`; `version` is semver and newer than the installed version | +| Platform keys | arm64: `darwin-aarch64-app`, then `darwin-aarch64`; Intel: `darwin-x86_64-app`, then `darwin-x86_64` | +| Artifact entry | `url` (HTTPS, GitHub hosts), `signature`, `sha256` (lowercase hex) | +| Signature | Tauri minisign format: Base64 of the four-line `.sig` text, prehashed Ed25519 (`ED`, BLAKE2b-512) by key id `FB130F2908B61575` | +| Archive | gzip tar with exactly one top-level `*.app`, no absolute paths or `..`, no symlink resolving outside the app, at most 512 MiB | +| Bundle | `CFBundleIdentifier = dev.ccbud.gateway`, `CFBundleShortVersionString` equals manifest `version` | +| Code signature | satisfies `anchor apple generic and certificate leaf[subject.OU] = "2CGR266XD2" and identifier "dev.ccbud.gateway"` with strict nested validation, and carries a stapled notarization ticket | + +The Electron app itself uses `app.ccbuddy.desktop`, which fails the bundle and +code-signature checks. The bridge therefore needs its own identity. + +## Bridge identity and handover to Electron updates + +- The bridge is a copy of the signed, production `CCbuddy.app` whose outer + `CFBundleIdentifier` and signing identifier are `dev.ccbud.gateway`. The product + name, `userData` path, update feed URL, helpers, and nested signatures stay + unchanged, so a bridge install and a normal install share the same data + directory. +- Squirrel.Mac accepts an update only if it satisfies the running app's + **designated requirement**. The bridge's designated requirement is therefore + team-only: `anchor apple generic and certificate leaf[subject.OU] = "2CGR266XD2"`. + This lets the bridge accept the next normal `app.ccbuddy.desktop` ZIP. Normal + builds keep electron-builder's default designated requirement. +- The Developer ID team must be `2CGR266XD2`; a release signed by any other team + fails before publication, because no old client could install it. + +## Architecture scope + +Electron releases are arm64 only. `latest.json` lists only the two +`darwin-aarch64*` keys. On Intel, the native 2.x updater finds no key and shows +its manual-download state, which links to the release page. Tauri 1.3.9 on +Intel reports a failed check. An arm64-only app is never offered to an Intel +Mac. Adding an Intel or universal Electron build later adds the `darwin-x86_64*` +keys. + +## Owners and order + +The release workflow owns the bridge; the app has no runtime code for it. + +```text +build job (macOS, release environment) + electron-builder → CCbuddy.app (app.ccbuddy.desktop) → notarize ZIP/DMG + ditto copy → set CFBundleIdentifier → re-sign outer bundle (team-only DR) + → verify legacy requirement + nested/strict + symlinks + normal app satisfies bridge DR + → notarize bridge → staple → re-verify stapled + → tar (single .app root) → Tauri signer (.sig) → latest.json (signature re-verified with the embedded public key) +publish job + verify latest-mac.yml and latest.json against the downloaded bytes + → draft release → upload all → compare inventory → publish as latest +old native / Tauri client + GET releases/latest/download/latest.json → download bridge tar.gz → verify → install → relaunch +bridge (Electron, dev.ccbud.gateway) + GET releases/latest/download/latest-mac.yml → Squirrel update → app.ccbuddy.desktop +``` + +`latest.json` is uploaded only in the same draft as its archive and becomes +visible only when the complete release is published as latest, so an old client +never sees a manifest without its archive. + +The workflow needs the `TAURI_SIGNING_PRIVATE_KEY` secret (and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, empty for an unencrypted key) in the +`release` environment. The signer is `@tauri-apps/cli` 2.11.3, the version the +native releases used. + +## Acceptance + +1. `releases/latest/download/latest.json` resolves for every new release. Its + `version` equals the release version, it has exactly the two arm64 keys, and + both point at the same archive URL in that release with the archive's SHA-256. +2. The signature decodes to four minisign lines, uses key id + `FB130F2908B61575`, and verifies against the archive bytes with the public key + embedded in the old clients. A wrong key, tampered archive, or non-canonical + Base64 fails the build. +3. The archive has one `.app` root. The app inside is `dev.ccbud.gateway`, has the + release version, satisfies the legacy code requirement, and carries a stapled + ticket. +4. The normal `app.ccbuddy.desktop` build satisfies the bridge's designated + requirement, so the bridge's first Electron update succeeds. +5. The normal ZIP, DMG, and `latest-mac.yml` are unchanged. `latest-mac.yml` + never references a bridge asset. +6. Manual acceptance on a real Mac: 2.0.5 (arm64) installs the bridge and + relaunches it; a newer release then updates the bridge to `app.ccbuddy.desktop`. diff --git a/docs/specs/macos-release-workflow.md b/docs/specs/macos-release-workflow.md index a57d7f17..f2bf9459 100644 --- a/docs/specs/macos-release-workflow.md +++ b/docs/specs/macos-release-workflow.md @@ -38,6 +38,12 @@ public only after the complete asset inventory is verified. Existing public releases are immutable; reruns cannot replace their assets or move `latest` backwards to an older version. +Each release also carries the legacy native update bridge +(`docs/specs/legacy-native-update-bridge.md`): `latest.json`, a minisign-signed +`CCbuddy--legacy-mac-arm64.app.tar.gz` and its `.sig`. These let the +frozen native 2.0.3–2.0.9 and Tauri 1.3.9 updaters install the Electron app. +`latest-mac.yml` never references them. + ## Flow and failure semantics ```text @@ -45,17 +51,20 @@ PR → pnpm checks + unsigned arm64 package → review and merge main push → allocate annotated tag (main commit or version snapshot) annotated tag → version/ancestry check → signed arm64 build → notarize ZIP and DMG → staple DMG → finalize YAML and verify assets + → re-sign, notarize, staple, and sign the legacy bridge → latest.json → draft GitHub Release → upload and compare inventory → publish as latest ``` The GitHub `release` environment owns access to the Developer ID certificate and Apple notary API key. Its required secrets are `MAC_CSC_LINK`, `MAC_CSC_KEY_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_TEAM_ID`, -`APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Missing or invalid +`APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and +`TAURI_SIGNING_PRIVATE_KEY` (with `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, empty for +an unencrypted key) for the legacy bridge signature. Missing or invalid credentials fail before publication. A temporary keychain and API key file live only on the release runner and are removed afterward. The workflow never -generates a signing identity, publishes an ad hoc signed package, or uses the -removed native/Tauri release path. +generates a signing identity, publishes an ad hoc signed package, or builds the +removed native/Tauri app; it only reuses the legacy `latest.json` update contract. If any signing, notarization, manifest, archive, or upload check fails, no public release is created. A draft can be retried for the same tag. Publication uses the @@ -74,5 +83,7 @@ and no release credentials. 3. A valid tagged build embeds the CCbuddy latest manifest URL and emits signed, notarized artifacts whose ZIP hash matches the published YAML. The manifest contains no DMG or foreign update source. + The same release carries a `latest.json` that meets the legacy bridge + acceptance criteria. 4. Failed publication leaves only a draft. An existing public release and a newer latest version are never overwritten by a rerun or older tag. diff --git a/packages/desktop/scripts/legacy-native-update.mjs b/packages/desktop/scripts/legacy-native-update.mjs new file mode 100644 index 00000000..b0fdcbb2 --- /dev/null +++ b/packages/desktop/scripts/legacy-native-update.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +// 修复原因:2.0.3–2.0.9 原生版与 Tauri 1.3.9 的更新器已冻结,只读取 +// releases/latest/download/latest.json;Electron 发布只产出 latest-mac.yml, +// 旧客户端因此 404 无法升级。本脚本为每个 Electron 发布生成旧更新器能验证的 +// latest.json(契约见 docs/specs/legacy-native-update-bridge.md)。 + +import { createHash, createPublicKey, verify } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { lstat, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +// 与旧客户端 v2.0.5 源码中硬编码的更新公钥一致(bundle id 与 Team 校验见 +// package-legacy-native-bridge.sh),不能随新应用身份变化。 +export const LEGACY_UPDATER_PUBLIC_KEY = + "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCMTMwRjI5MDhCNjE1NzUKUldSMUZiWUlLUThUK3kybFBUU3ljMWUyenAwR3U1NjdPZm1jM25ocndIclhLYUFGTU92KzJXRFQK"; +const LEGACY_NATIVE_MANIFEST_NAME = "latest.json"; +// Electron 发布只有 arm64;不写 x86_64 键,Intel 旧客户端进入手动下载而不是装上无法运行的包。 +export const LEGACY_NATIVE_PLATFORM_KEYS = Object.freeze(["darwin-aarch64-app", "darwin-aarch64"]); + +const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; +const MAX_SIGNATURE_BYTES = 16 * 1024; +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export function legacyBridgeArchiveName(version) { + assertVersion(version); + return `CCbuddy-${version}-legacy-mac-arm64.app.tar.gz`; +} + +function assertVersion(version) { + if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) { + throw new Error(`Invalid release version: ${version}`); + } +} + +function assertRepository(repository) { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository ?? "")) { + throw new Error(`Invalid repository: ${repository}`); + } +} + +// Swift 的 Data(base64Encoded:) 要求补齐 padding,这里只接受规范 Base64, +// 避免 Node 宽松解码通过、旧客户端却解码失败。 +function decodeCanonicalBase64(value, label) { + const decoded = Buffer.from(value, "base64"); + if (decoded.length === 0 || decoded.toString("base64") !== value) { + throw new Error(`${label} is not canonical Base64`); + } + return decoded; +} + +// 与 Swift 的 split(whereSeparator: \.isNewline) 一致:丢弃空行。 +function splitLines(text) { + return text.split(/[\r\n]+/).filter((line) => line.length > 0); +} + +export function parseMinisignPublicKey(encodedPublicKey) { + const text = decodeCanonicalBase64(encodedPublicKey, "Updater public key").toString("utf8"); + const lines = splitLines(text); + const binary = decodeCanonicalBase64(lines[1] ?? "", "Updater public key body"); + if (binary.length !== 42 || binary.subarray(0, 2).toString("latin1") !== "Ed") { + throw new Error("Updater public key is not a minisign Ed25519 key"); + } + return { + keyId: binary.subarray(2, 10), + key: createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, binary.subarray(10, 42)]), + format: "der", + type: "spki", + }), + }; +} + +/** minisign 在界面上把 key id 按小端 u64 显示,例如 FB130F2908B61575。 */ +export function formatMinisignKeyId(keyId) { + return Buffer.from(keyId).reverse().toString("hex").toUpperCase(); +} + +function parseTauriSignature(rawSignature) { + const encoded = rawSignature.replace(/[\r\n]/g, "").trim(); + if (Buffer.byteLength(encoded) > MAX_SIGNATURE_BYTES) { + throw new Error("Updater signature is too large"); + } + const lines = splitLines(decodeCanonicalBase64(encoded, "Updater signature").toString("utf8")); + if ( + lines.length !== 4 || + !lines[0].startsWith("untrusted comment: ") || + !lines[2].startsWith("trusted comment: ") + ) { + throw new Error("Updater signature must decode to the four-line minisign format"); + } + const envelope = decodeCanonicalBase64(lines[1], "Signature envelope"); + const globalSignature = decodeCanonicalBase64(lines[3], "Global signature"); + // 旧客户端只接受预哈希(ED)签名。 + if ( + envelope.length !== 74 || + envelope.subarray(0, 2).toString("latin1") !== "ED" || + globalSignature.length !== 64 + ) { + throw new Error("Updater signature is not a prehashed minisign Ed25519 signature"); + } + return { + encoded, + keyId: envelope.subarray(2, 10), + signature: envelope.subarray(10, 74), + trustedComment: lines[2].slice("trusted comment: ".length), + globalSignature, + }; +} + +async function digestArchive(archivePath) { + const metadata = await stat(archivePath); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error(`Missing or empty legacy archive: ${archivePath}`); + } + if (metadata.size > MAX_ARCHIVE_BYTES) { + throw new Error(`Legacy archive exceeds the old updater's 512 MiB limit: ${archivePath}`); + } + const blake2b = createHash("blake2b512"); + const sha256 = createHash("sha256"); + for await (const chunk of createReadStream(archivePath)) { + blake2b.update(chunk); + sha256.update(chunk); + } + return { blake2b512: blake2b.digest(), sha256: sha256.digest("hex"), size: metadata.size }; +} + +/** 按旧客户端 MinisignUpdateVerifier 的同一规则验证归档签名。 */ +export async function verifyLegacyArchiveSignature( + archivePath, + rawSignature, + encodedPublicKey = LEGACY_UPDATER_PUBLIC_KEY, +) { + const publicKey = parseMinisignPublicKey(encodedPublicKey); + const parsed = parseTauriSignature(rawSignature); + if (!parsed.keyId.equals(publicKey.keyId)) { + throw new Error( + `Updater signature key ${formatMinisignKeyId(parsed.keyId)} is not the legacy key ${formatMinisignKeyId(publicKey.keyId)}`, + ); + } + const digest = await digestArchive(archivePath); + const globalPayload = Buffer.concat([parsed.signature, Buffer.from(parsed.trustedComment)]); + if ( + !verify(null, digest.blake2b512, publicKey.key, parsed.signature) || + !verify(null, globalPayload, publicKey.key, parsed.globalSignature) + ) { + throw new Error("Updater signature does not verify against the legacy archive"); + } + return { signature: parsed.encoded, sha256: digest.sha256, size: digest.size }; +} + +function legacyArchiveUrl({ repository, version, archiveName }) { + return `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(archiveName)}`; +} + +function buildLegacyNativeManifest({ + version, + repository, + archiveName, + signature, + sha256, + pubDate, +}) { + assertVersion(version); + assertRepository(repository); + const artifact = { + url: legacyArchiveUrl({ repository, version, archiveName }), + signature, + sha256, + }; + const platforms = {}; + for (const key of LEGACY_NATIVE_PLATFORM_KEYS) platforms[key] = { ...artifact }; + return { version, pub_date: pubDate, platforms }; +} + +function legacyPaths(distDir, version) { + const archiveName = legacyBridgeArchiveName(version); + return { + archiveName, + archivePath: join(distDir, archiveName), + signaturePath: join(distDir, `${archiveName}.sig`), + manifestPath: join(distDir, LEGACY_NATIVE_MANIFEST_NAME), + }; +} + +export async function writeLegacyNativeManifest({ + distDir, + version, + repository, + pubDate = new Date().toISOString(), + publicKey = LEGACY_UPDATER_PUBLIC_KEY, +}) { + const paths = legacyPaths(distDir, version); + const verified = await verifyLegacyArchiveSignature( + paths.archivePath, + await readFile(paths.signaturePath, "utf8"), + publicKey, + ); + const manifest = buildLegacyNativeManifest({ + version, + repository, + archiveName: paths.archiveName, + signature: verified.signature, + sha256: verified.sha256, + pubDate, + }); + await writeFile(paths.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await verifyLegacyNativeManifest({ distDir, version, repository, publicKey }); + return { ...paths, sha256: verified.sha256 }; +} + +/** 发布前对下载回来的字节重新校验,保证 latest.json、归档和签名三者一致。 */ +export async function verifyLegacyNativeManifest({ + distDir, + version, + repository, + publicKey = LEGACY_UPDATER_PUBLIC_KEY, +}) { + assertVersion(version); + assertRepository(repository); + const paths = legacyPaths(distDir, version); + const manifest = JSON.parse(await readFile(paths.manifestPath, "utf8")); + if (!manifest || manifest.version !== version || typeof manifest.platforms !== "object") { + throw new Error("Legacy manifest has a missing or mismatched version/platforms"); + } + if (Number.isNaN(Date.parse(manifest.pub_date))) { + throw new Error("Legacy manifest pub_date is not an ISO 8601 date"); + } + const keys = Object.keys(manifest.platforms).sort(); + if (keys.join(",") !== [...LEGACY_NATIVE_PLATFORM_KEYS].sort().join(",")) { + throw new Error(`Legacy manifest must list exactly ${LEGACY_NATIVE_PLATFORM_KEYS.join(", ")}`); + } + const verified = await verifyLegacyArchiveSignature( + paths.archivePath, + await readFile(paths.signaturePath, "utf8"), + publicKey, + ); + const expectedUrl = legacyArchiveUrl({ repository, version, archiveName: paths.archiveName }); + for (const key of keys) { + const entry = manifest.platforms[key]; + if ( + entry?.url !== expectedUrl || + entry.signature !== verified.signature || + entry.sha256 !== verified.sha256 + ) { + throw new Error(`Legacy manifest entry ${key} does not match the signed archive`); + } + } + return { ...paths, sha256: verified.sha256 }; +} + +/** + * 旧客户端解包后拒绝任何解析到应用包外的符号链接(悬空链接同样无法解析到包内)。 + * 返回违规链接的相对路径,供发布前在打包产物上提前失败。 + */ +export async function findEscapingSymlinks(appPath) { + const root = await realpath(appPath); + const violations = []; + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isSymbolicLink()) { + const target = await realpath(entryPath).catch(() => null); + if (!target || !target.startsWith(`${root}${sep}`)) { + violations.push(entryPath.slice(appPath.length + 1)); + } + } else if (entry.isDirectory()) { + await walk(entryPath); + } + } + } + if (!(await lstat(appPath)).isDirectory()) throw new Error(`Not an app directory: ${appPath}`); + await walk(appPath); + return violations.sort(); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const [command, distDir, version, repository] = process.argv.slice(2); + const options = { distDir: resolve(distDir ?? ""), version, repository }; + if (command === "write") { + const result = await writeLegacyNativeManifest(options); + process.stdout.write(`Wrote ${result.manifestPath} for ${result.archiveName}\n`); + } else if (command === "verify") { + const result = await verifyLegacyNativeManifest(options); + process.stdout.write(`Verified ${result.manifestPath} against ${result.archiveName}\n`); + } else if (command === "check-symlinks") { + const violations = await findEscapingSymlinks(resolve(distDir ?? "")); + if (violations.length > 0) { + process.stderr.write(`Symlinks escape the app bundle:\n ${violations.join("\n ")}\n`); + process.exit(1); + } + process.stdout.write("No symlink escapes the app bundle\n"); + } else { + process.stderr.write( + "usage: legacy-native-update.mjs \n" + + " legacy-native-update.mjs check-symlinks \n", + ); + process.exit(2); + } +} diff --git a/packages/desktop/scripts/package-legacy-native-bridge.sh b/packages/desktop/scripts/package-legacy-native-bridge.sh new file mode 100755 index 00000000..32652507 --- /dev/null +++ b/packages/desktop/scripts/package-legacy-native-bridge.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# 修复原因:2.0.3–2.0.9 原生版只安装 bundle id 为 dev.ccbud.gateway、Team 2CGR266XD2、 +# 已 staple 公证票据、并带 Tauri minisign 签名的 .app.tar.gz;Electron 正式包 +# (app.ccbuddy.desktop)不满足这些条件,旧客户端无法热更。这里把已签名的正式包复制成 +# 旧身份的桥接包,并把指定要求(designated requirement)放宽到只校验 Team, +# 让桥接包之后能通过 Squirrel 接受正常的 app.ccbuddy.desktop 更新。 +# 契约与验收见 docs/specs/legacy-native-update-bridge.md。 +set -euo pipefail + +readonly SOURCE_APP="${1:-}" +readonly VERSION="${2:-}" +[[ -d "${3:-}" ]] || { echo "legacy native bridge packaging failed: dist directory is missing: ${3:-}" >&2; exit 1; } +# 签名步骤会切换工作目录,统一使用绝对路径。 +DIST_DIR="$(cd "$3" && pwd)" +readonly DIST_DIR +readonly REPOSITORY="${4:-}" +readonly LEGACY_BUNDLE_ID="dev.ccbud.gateway" +readonly LEGACY_TEAM_ID="2CGR266XD2" +readonly TAURI_CLI_VERSION="2.11.3" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +fail() { echo "legacy native bridge packaging failed: $*" >&2; exit 1; } + +# 只在需要的命令上显式传递凭据,不让 npx 下载的签名工具看到 Apple 凭据。 +readonly signing_identity="${APPLE_SIGNING_IDENTITY:-}" +readonly signing_keychain="${CSC_KEYCHAIN:-}" +readonly team_id="${APPLE_TEAM_ID:-}" +readonly notary_key_path="${APPLE_API_KEY_PATH:-}" +readonly notary_key_id="${APPLE_API_KEY_ID:-}" +readonly notary_issuer="${APPLE_API_ISSUER:-}" +readonly updater_key="${TAURI_SIGNING_PRIVATE_KEY:-}" +# 未加密的 Tauri 私钥密码合法为空。 +readonly updater_password="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" +unset APPLE_SIGNING_IDENTITY APPLE_TEAM_ID APPLE_API_KEY_PATH APPLE_API_KEY_ID APPLE_API_ISSUER +unset TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD + +[[ "$(uname -s)" == Darwin ]] || fail "macOS is required" +[[ -d "$SOURCE_APP" ]] || fail "signed app is missing: $SOURCE_APP" +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid version: $VERSION" +[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "invalid repository" +for name in signing_identity signing_keychain notary_key_path notary_key_id notary_issuer updater_key; do + [[ -n "${!name}" ]] || fail "missing required input: $name" +done +[[ -f "$notary_key_path" ]] || fail "notary API key is not a file" +# 旧客户端硬编码了 Team ID;换了 Team 的签名无论如何都装不上,发布前直接失败。 +[[ "$team_id" == "$LEGACY_TEAM_ID" ]] \ + || fail "APPLE_TEAM_ID must be $LEGACY_TEAM_ID for the legacy native updater" + +readonly ARCHIVE_NAME="CCbuddy-${VERSION}-legacy-mac-arm64.app.tar.gz" +readonly ARCHIVE="$DIST_DIR/$ARCHIVE_NAME" +for output in "$ARCHIVE" "$ARCHIVE.sig" "$DIST_DIR/latest.json"; do + [[ ! -e "$output" ]] || fail "output already exists: $output" +done + +WORK_ROOT="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ccbuddy-legacy-bridge.XXXXXX")" +readonly WORK_ROOT +trap 'rm -rf -- "$WORK_ROOT"' EXIT +readonly APP="$WORK_ROOT/CCbuddy.app" + +readonly TEAM_REQUIREMENT="anchor apple generic and certificate leaf[subject.OU] = \"$LEGACY_TEAM_ID\"" +# 与 v2.0.5 DeveloperIDUpdateVerifier 使用的发布者要求逐字一致。 +readonly LEGACY_CLIENT_REQUIREMENT="$TEAM_REQUIREMENT and identifier \"$LEGACY_BUNDLE_ID\"" + +verify_bridge_app() { + local app="$1" + local info="$app/Contents/Info.plist" + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$info")" == "$LEGACY_BUNDLE_ID" ]] \ + || fail "bridge bundle id mismatch in $app" + [[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$info")" == "$VERSION" ]] \ + || fail "bridge version mismatch in $app" + codesign --verify --deep --strict --verbose=2 "$app" + codesign --verify --strict --test-requirement="=$LEGACY_CLIENT_REQUIREMENT" "$app" \ + || fail "bridge does not satisfy the legacy client code requirement" + node "$SCRIPT_DIR/legacy-native-update.mjs" check-symlinks "$app" +} + +ditto "$SOURCE_APP" "$APP" +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $LEGACY_BUNDLE_ID" "$APP/Contents/Info.plist" +# 只重签外层 bundle:内层 Helper、框架和预签名工具保持原签名; +# 外层签名重新封存修改后的 Info.plist,并写入只校验 Team 的指定要求。 +codesign --force --timestamp --options runtime --preserve-metadata=entitlements \ + --identifier "$LEGACY_BUNDLE_ID" \ + --requirements "=designated => $TEAM_REQUIREMENT" \ + --sign "$signing_identity" --keychain "$signing_keychain" "$APP" +codesign -dvv "$APP" 2>&1 | grep -Fx "TeamIdentifier=$LEGACY_TEAM_ID" >/dev/null \ + || fail "bridge Team ID mismatch" +verify_bridge_app "$APP" + +# Squirrel.Mac 用运行中应用的指定要求校验更新包:确认桥接包的指定要求不绑定 identifier, +# 且正式包满足它,桥接包的第一次 Electron 更新才能成功。 +bridge_requirement="$(codesign -d -r- "$APP" 2>&1 | sed -n 's/^designated => //p')" +[[ -n "$bridge_requirement" ]] || fail "bridge has no designated requirement" +[[ "$bridge_requirement" != *identifier* ]] \ + || fail "bridge designated requirement must not pin an identifier: $bridge_requirement" +codesign --verify --strict --test-requirement="=$bridge_requirement" "$SOURCE_APP" \ + || fail "the normal app does not satisfy the bridge's designated requirement" + +readonly NOTARY_ZIP="$WORK_ROOT/CCbuddy-legacy-bridge.zip" +readonly NOTARY_RESULT="$WORK_ROOT/notary.json" +ditto -c -k --keepParent "$APP" "$NOTARY_ZIP" +xcrun notarytool submit "$NOTARY_ZIP" --key "$notary_key_path" --key-id "$notary_key_id" \ + --issuer "$notary_issuer" --wait --timeout 30m --output-format json | tee "$NOTARY_RESULT" +[[ "$(/usr/bin/plutil -extract status raw -o - "$NOTARY_RESULT")" == Accepted ]] \ + || fail "bridge notarization was not accepted" +# 旧客户端要求应用内随附公证票据。 +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl -a -vv -t exec "$APP" +verify_bridge_app "$APP" + +# COPYFILE_DISABLE 避免 bsdtar 在顶层写入 ._ AppleDouble 条目,旧客户端要求单一 .app 根。 +COPYFILE_DISABLE=1 /usr/bin/tar -czf "$ARCHIVE" -C "$WORK_ROOT" CCbuddy.app +archive_roots="$(/usr/bin/tar -tzf "$ARCHIVE" | sed 's#^\./##' | awk -F/ 'NF {print $1}' | LC_ALL=C sort -u)" +[[ "$archive_roots" == CCbuddy.app ]] || fail "archive must have the single CCbuddy.app root" +if /usr/bin/tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then + fail "archive contains a path the legacy updater rejects" +fi +readonly EXTRACTED="$WORK_ROOT/extracted" +mkdir "$EXTRACTED" +/usr/bin/tar -xzf "$ARCHIVE" -C "$EXTRACTED" +verify_bridge_app "$EXTRACTED/CCbuddy.app" +xcrun stapler validate "$EXTRACTED/CCbuddy.app" + +( + cd "$WORK_ROOT" + TAURI_SIGNING_PRIVATE_KEY="$updater_key" \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$updater_password" \ + npx --yes "@tauri-apps/cli@$TAURI_CLI_VERSION" signer sign "$ARCHIVE" +) +[[ -s "$ARCHIVE.sig" ]] || fail "Tauri signer did not create $ARCHIVE.sig" +# 生成 latest.json 前用旧客户端内置公钥重新验签,签错钥匙会在这里失败。 +node "$SCRIPT_DIR/legacy-native-update.mjs" write "$DIST_DIR" "$VERSION" "$REPOSITORY" +echo "packaged legacy native bridge $ARCHIVE_NAME" diff --git a/packages/desktop/test/legacy-native-update.test.mjs b/packages/desktop/test/legacy-native-update.test.mjs new file mode 100644 index 00000000..a5fd2ec8 --- /dev/null +++ b/packages/desktop/test/legacy-native-update.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + LEGACY_NATIVE_PLATFORM_KEYS, + findEscapingSymlinks, + LEGACY_UPDATER_PUBLIC_KEY, + formatMinisignKeyId, + legacyBridgeArchiveName, + parseMinisignPublicKey, + verifyLegacyArchiveSignature, + verifyLegacyNativeManifest, + writeLegacyNativeManifest, +} from "../scripts/legacy-native-update.mjs"; + +const version = "2.0.13"; +const repository = "ccbud/CCBuddy"; +const archiveName = legacyBridgeArchiveName(version); +const archiveBytes = Buffer.from("signed, notarized, stapled bridge app tar.gz"); + +function createMinisignKey(keyId = randomBytes(8)) { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const raw = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url"); + const body = Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64"); + const text = `untrusted comment: minisign public key: ${formatMinisignKeyId(keyId)}\n${body}\n`; + return { keyId, privateKey, encodedPublicKey: Buffer.from(text).toString("base64") }; +} + +// 与 Tauri signer 输出一致:对 BLAKE2b-512 预哈希签名,再签 signature || trusted comment。 +function signLikeTauri(bytes, { keyId, privateKey }) { + const digest = createHash("blake2b512").update(bytes).digest(); + const signature = sign(null, digest, privateKey); + const trusted = `timestamp:1790000000\tfile:${archiveName}`; + const global = sign(null, Buffer.concat([signature, Buffer.from(trusted)]), privateKey); + const envelope = Buffer.concat([Buffer.from("ED"), keyId, signature]).toString("base64"); + const text = [ + "untrusted comment: signature from tauri secret key", + envelope, + `trusted comment: ${trusted}`, + global.toString("base64"), + "", + ].join("\n"); + return Buffer.from(text).toString("base64"); +} + +async function withDist(key, run, { bytes = archiveBytes, signature } = {}) { + const distDir = await mkdtemp(join(tmpdir(), "ccbuddy-legacy-update-test-")); + try { + await writeFile(join(distDir, archiveName), bytes); + await writeFile(join(distDir, `${archiveName}.sig`), signature ?? signLikeTauri(bytes, key)); + await run(distDir); + } finally { + await rm(distDir, { recursive: true, force: true }); + } +} + +test("embedded public key is the minisign key the old native and Tauri clients trust", () => { + const { keyId } = parseMinisignPublicKey(LEGACY_UPDATER_PUBLIC_KEY); + assert.equal(formatMinisignKeyId(keyId), "FB130F2908B61575"); +}); + +test("writes latest.json with only arm64 keys pointing at the signed archive", async () => { + const key = createMinisignKey(); + await withDist(key, async (distDir) => { + await writeLegacyNativeManifest({ + distDir, + version, + repository, + publicKey: key.encodedPublicKey, + pubDate: "2026-09-24T00:00:00.000Z", + }); + const manifest = JSON.parse(await readFile(join(distDir, "latest.json"), "utf8")); + const sha256 = createHash("sha256").update(archiveBytes).digest("hex"); + const signature = signLikeTauri(archiveBytes, key); + assert.equal(manifest.version, version); + assert.equal(manifest.pub_date, "2026-09-24T00:00:00.000Z"); + assert.deepEqual(Object.keys(manifest.platforms), [...LEGACY_NATIVE_PLATFORM_KEYS]); + assert.equal(manifest.platforms["darwin-x86_64"], undefined); + for (const entry of Object.values(manifest.platforms)) { + assert.deepEqual(entry, { + url: `https://github.com/${repository}/releases/download/v${version}/${archiveName}`, + signature, + sha256, + }); + } + }); +}); + +test("rejects a signature from any key other than the legacy updater key", async () => { + const trusted = createMinisignKey(); + const foreign = createMinisignKey(); + await withDist(foreign, async (distDir) => { + await assert.rejects( + writeLegacyNativeManifest({ + distDir, + version, + repository, + publicKey: trusted.encodedPublicKey, + }), + /is not the legacy key/, + ); + }); +}); + +test("rejects a same-key-id signature that does not verify", async () => { + const trusted = createMinisignKey(); + const impostor = createMinisignKey(trusted.keyId); + await withDist(impostor, async (distDir) => { + await assert.rejects( + writeLegacyNativeManifest({ + distDir, + version, + repository, + publicKey: trusted.encodedPublicKey, + }), + /does not verify/, + ); + }); +}); + +test("rejects an archive modified after signing", async () => { + const key = createMinisignKey(); + await withDist( + key, + async (distDir) => { + await assert.rejects( + verifyLegacyArchiveSignature( + join(distDir, archiveName), + await readFile(join(distDir, `${archiveName}.sig`), "utf8"), + key.encodedPublicKey, + ), + /does not verify/, + ); + }, + { bytes: Buffer.from("tampered"), signature: signLikeTauri(archiveBytes, key) }, + ); +}); + +test("rejects non-canonical Base64 that the Swift client cannot decode", async () => { + const key = createMinisignKey(); + // Node 会宽松解码多余字符,Swift 的 Data(base64Encoded:) 则直接失败。 + const nonCanonical = `${signLikeTauri(archiveBytes, key)}A`; + await withDist( + key, + async (distDir) => { + await assert.rejects( + writeLegacyNativeManifest({ + distDir, + version, + repository, + publicKey: key.encodedPublicKey, + }), + /canonical Base64/, + ); + }, + { signature: nonCanonical }, + ); +}); + +test("verification rejects a manifest that drifts from the archive or lists Intel keys", async () => { + const key = createMinisignKey(); + await withDist(key, async (distDir) => { + await writeLegacyNativeManifest({ + distDir, + version, + repository, + publicKey: key.encodedPublicKey, + }); + const manifestPath = join(distDir, "latest.json"); + const original = JSON.parse(await readFile(manifestPath, "utf8")); + + const withIntel = structuredClone(original); + withIntel.platforms["darwin-x86_64"] = withIntel.platforms["darwin-aarch64"]; + await writeFile(manifestPath, JSON.stringify(withIntel)); + await assert.rejects( + verifyLegacyNativeManifest({ distDir, version, repository, publicKey: key.encodedPublicKey }), + /must list exactly/, + ); + + const wrongDigest = structuredClone(original); + wrongDigest.platforms["darwin-aarch64"].sha256 = "0".repeat(64); + await writeFile(manifestPath, JSON.stringify(wrongDigest)); + await assert.rejects( + verifyLegacyNativeManifest({ distDir, version, repository, publicKey: key.encodedPublicKey }), + /darwin-aarch64 does not match/, + ); + + await writeFile(manifestPath, JSON.stringify({ ...original, version: "2.0.12" })); + await assert.rejects( + verifyLegacyNativeManifest({ distDir, version, repository, publicKey: key.encodedPublicKey }), + /mismatched version/, + ); + }); +}); + +test("reports symlinks the old client's extractor would reject", async () => { + const root = await mkdtemp(join(tmpdir(), "ccbuddy-legacy-symlink-test-")); + try { + const app = join(root, "CCbuddy.app"); + const versions = join(app, "Contents/Frameworks/Electron Framework.framework/Versions"); + await mkdir(join(versions, "A"), { recursive: true }); + await writeFile(join(root, "outside"), "x"); + // Electron 框架内部的相对链接合法。 + await symlink("A", join(versions, "Current")); + await symlink("../outside", join(app, "Contents/escape")); + await symlink("missing", join(app, "Contents/dangling")); + assert.deepEqual(await findEscapingSymlinks(app), ["Contents/dangling", "Contents/escape"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +});