diff --git a/.github/workflows/linux-private-build.yml b/.github/workflows/linux-private-build.yml new file mode 100644 index 00000000..e73d6a76 --- /dev/null +++ b/.github/workflows/linux-private-build.yml @@ -0,0 +1,511 @@ +name: Linux Private Build + +# Linux 的发布构建。与 macOS 的 macos-private-build.yml 对齐,但签名模型不同: +# Linux 没有代码签名,原生组件的身份 = 内容哈希(linuxIntegrityMode: content-hash-pin)。 +# +# 整个链路只有两个外部输入: +# 1. 私藏仓 `2977094657/WCDB` 的 main revision(发版当下由 +# tools/rebuild_wcdb_release.py 现产一份 source-public 原生核心); +# 2. 同一个 revision 里的 private/wce_integrity 源码(用于编译导出完整性模块)。 +# +# 也就是说,与 Windows / macOS 同一条路线:**不需要任何仓库变量或额外的读取 +# secret**,只复用发版已有的 `WCE_NATIVE_CORE_PRODUCER_TOKEN`。45 天有效期由 +# 「每次发版重建」自然续上,不再有手工 pin 会过期。 +# +# 产物形态刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg, +# Linux 走「用户级、免 root 的 tar.gz + install.sh」。electron-builder 只出 dir 目标。 + +on: + workflow_call: + inputs: + version: + description: Package version; omitted callers derive it from a v* tag + required: false + type: string + workflow_dispatch: + inputs: + version: + description: Package version (for example 2.5.2) + required: true + type: string + +permissions: + actions: read + contents: read + +jobs: + build-linux-x64: + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-22.04 + timeout-minutes: 150 + env: + UV_MANAGED_PYTHON: "true" + # 原生核心的 producer 仓是常量;发版当下由 rebuild_wcdb_release.py 现产一份。 + WCE_LINUX_NATIVE_REPOSITORY: "2977094657/WCDB" + WCE_NATIVE_CORE_REQUIRED: "1" + WCE_NATIVE_CORE_ALLOW_DEVELOPMENT_ARTIFACTS: "0" + CI: "true" + PACKAGE_VERSION_INPUT: ${{ inputs.version }} + steps: + - name: Checkout exact release revision + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Verify immutable source and release coordinates + shell: bash + env: + WORKFLOW_REF: ${{ github.ref }} + WORKFLOW_REVISION: ${{ github.sha }} + run: | + set -euo pipefail + [[ "$WORKFLOW_REVISION" =~ ^[0-9a-f]{40}$ ]] + test "$(git rev-parse HEAD)" = "$WORKFLOW_REVISION" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + requested_version="${PACKAGE_VERSION_INPUT#v}" + case "$WORKFLOW_REF" in + refs/heads/main) + test "$(git rev-parse origin/main)" = "$WORKFLOW_REVISION" + package_version="$requested_version" + ;; + refs/tags/v*) + tag="${WORKFLOW_REF#refs/tags/}" + tag_version="${tag#v}" + test "$tag" = "$GITHUB_REF_NAME" + test "$(git rev-parse --verify "${WORKFLOW_REF}^{commit}")" = "$WORKFLOW_REVISION" + git rev-parse --verify origin/main + git merge-base --is-ancestor "$WORKFLOW_REF" origin/main + if [[ -n "$requested_version" ]]; then + test "$requested_version" = "$tag_version" + fi + package_version="$tag_version" + ;; + *) + echo "Linux packages may only be built from main or a v* release tag" >&2 + exit 1 + ;; + esac + [[ "$package_version" =~ ^[0-9]+(\.[0-9]+)+([.-][A-Za-z0-9.-]+)?$ ]] + printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" + + # 原生核心与 integrity 源码都来自同一个 producer 仓,不需要任何仓库变量; + # 下面这一步会现产一份 source-public 核心并把五元组写进 GITHUB_ENV。 + # 生产仓必须是 rebuild 脚本里写死的那个,不允许被变量悄悄换掉。 + [[ "$WCE_LINUX_NATIVE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] + script_repository="$( + python3 -c 'import re,sys;print(re.search(r"^REPOSITORY = \"([^\"]+)\"", open(sys.argv[1], encoding="utf-8").read(), re.M).group(1))' \ + tools/rebuild_wcdb_release.py + )" + test "$script_repository" = "$WCE_LINUX_NATIVE_REPOSITORY" + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: | + frontend/package-lock.json + desktop/package-lock.json + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version-file: .python-version + + - name: Install build dependencies + shell: bash + run: | + set -euo pipefail + python -m pip install uv + uv python install 3.11 + npm ci --prefix frontend + npm ci --prefix desktop + + - name: Rebuild the Linux native core for this release + timeout-minutes: 45 + shell: bash + env: + GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "WCE_NATIVE_CORE_PRODUCER_TOKEN is required to rebuild the WCDB native core." >&2 + exit 1 + fi + # 与 Windows / macOS 同一条路线:现产一份 source-public 原生核心,把五元组写进 + # GITHUB_ENV(WCE_NATIVE_CORE_ARTIFACT_REPOSITORY / _RUN_ID / _SHA256 / + # _SOURCE_REVISION / _BUILD_ID / _CLIENT_SHA256 / _BROKER_SHA256 / + # _ARTIFACT_DIR),供后面的策略校验与打包步骤核对。这里不依赖任何仓库变量, + # 也不依赖 Actions artifact 存储(资产只走不可变 Release)。 + python3 tools/rebuild_wcdb_release.py \ + --component linux-native \ + --output-root "$RUNNER_TEMP" + # 注意:脚本写进 GITHUB_ENV 的变量只对**后续 step** 可见,本 step 的 shell + # 读不到,所以这里只用常量拼路径;其余坐标交给后面的步骤用。 + core_dir="$RUNNER_TEMP/wechatdb-native-linux-x64-source-public" + test -f "$core_dir/wechatdb_native_build.json" + + - name: Validate the pinned native core against the production policy + shell: bash + env: + WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public + run: | + set -euo pipefail + node -e "require('./desktop/scripts/linux-native-core-packaging.cjs').resolveLinuxNativeCoreArtifacts({ platform: 'linux' })" + node -e " + const resolved = require('./desktop/scripts/linux-native-core-packaging.cjs') + .resolveLinuxNativeCoreArtifacts({ platform: 'linux' }); + const manifest = resolved.manifest; + console.log(JSON.stringify({ + buildId: manifest.buildId, + expiresAtUnix: manifest.buildExpiresAtUnix, + clientSha256: manifest.linuxClientSha256, + brokerSha256: manifest.linuxBrokerSha256, + hostVerification: manifest.linuxHostVerification, + sourceRuntime: manifest.sourceRuntime === true, + }, null, 2)); + " + printf 'WCE_NATIVE_CORE_ARTIFACT_DIR=%s\n' "$WCE_NATIVE_CORE_ARTIFACT_DIR" >> "$GITHUB_ENV" + + - name: Checkout the private integrity source at the producer revision + timeout-minutes: 15 + shell: bash + env: + GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "WCE_NATIVE_CORE_PRODUCER_TOKEN is required to fetch the private integrity source." >&2 + exit 1 + fi + # wce_integrity 把 Nuxt 的 CSS 编进导出物里,所以它必须在 UI 构建之后、 + # 在同一个工作目录里编译(macOS 那份 prebuilt dylib 之所以要记 + # uiSourceRevision,就是因为这个耦合)。这里改为按 revision 取源码, + # 用构建密钥(一次性 P-256 私钥)现编,语义与官方 + # `-GenerateEphemeralSigningKey` 一致:该密钥只用于导出物自身封签, + # 权威封印是原生核心产出的 WES2 sidecar。 + work="$RUNNER_TEMP/wce-integrity-source" + archive="$RUNNER_TEMP/wce-integrity-source.tar.gz" + rm -rf "$work" + mkdir -p "$work" + rm -f "$archive" + # integrity 源码与当次被钉的核心同源同 revision(都由重建步骤写进环境)。 + gh api "repos/$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY/tarball/$WCE_NATIVE_CORE_SOURCE_REVISION" > "$archive" + test -s "$archive" + tar -xzf "$archive" -C "$work" + root="$(find "$work" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + test -n "$root" + source_dir="$root/private/wce_integrity" + test -f "$source_dir/Cargo.toml" + test -f "$source_dir/build.rs" + rm -rf native/wce_integrity + mkdir -p native + mv "$source_dir" native/wce_integrity + rm -rf "$work" "$archive" + test -f native/wce_integrity/Cargo.toml + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + toolchain: stable + + - name: Run focused Python release tests + shell: bash + env: + PYTHONPATH: src + run: | + set -euo pipefail + # 只跑在 Linux 上成立的用例:Windows 专属的原生核心用例( + # test_wcdb_realtime_native_core_required / test_native_core_broker_lifecycle) + # 依赖 win32 的 PE 与 trust-mode 语义,在这里必然失败,不应作为门禁。 + # test_linux_native_core_policy.py 钉住 Linux 的授权矩阵:冻结应用必须接受 + # 发布工作流实际发的 source-public 产物(与 Windows 同一原则)。 + uv run pytest -q \ + tests/test_linux_db_key_flow.py \ + tests/test_linux_db_key_frontend.py \ + tests/test_linux_native_core_policy.py \ + tests/test_native_core_device_credential.py + + - name: Run focused desktop release tests + working-directory: desktop + shell: bash + run: | + set -euo pipefail + # 只跑与 Linux 打包契约直接相关的用例。windows-* 那几个在 Linux 上必然 + # 失败(依赖 PE 与 Windows trust-mode 语义),不能当门禁。 + # native-core-runtime 是桌面启动后端的门禁:Linux 的 schema v4 判定错了, + # 打出来的包一启动就会崩,所以它必须在这里跑。 + node --test \ + tests/native-core-runtime.test.cjs \ + tests/native-core-packaging.test.cjs \ + tests/native-core-before-pack.test.cjs \ + tests/package-config.test.cjs + + - name: Set desktop app version + working-directory: desktop + shell: bash + run: npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version + + - name: Build the Linux package + working-directory: desktop + shell: bash + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + run: | + set -euo pipefail + npm run dist:linux + + - name: Verify the packaged Linux runtime + working-directory: desktop + shell: bash + env: + WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public + run: | + set -euo pipefail + node - <<'NODE' + const childProcess = require("node:child_process"); + const fs = require("node:fs"); + const path = require("node:path"); + const { + inspectElf, + linuxContentPinErrors, + } = require("./scripts/linux-native-core-packaging.cjs"); + + const artifactDir = process.env.WCE_NATIVE_CORE_ARTIFACT_DIR; + const payload = path.resolve("dist", "linux-unpacked"); + const backendRoot = path.join(payload, "resources", "backend"); + const nativeDir = path.join(backendRoot, "native"); + + const requireFile = (filePath, { executable = false } = {}) => { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size <= 0) throw new Error(`not a file: ${filePath}`); + if (executable && (stat.mode & 0o111) === 0) throw new Error(`not executable: ${filePath}`); + return stat; + }; + const digest = (filePath) => + require("node:crypto").createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + + // 应用本体与后端可执行文件。 + requireFile(path.join(payload, "wechat-data-analysis"), { executable: true }); + requireFile(path.join(backendRoot, "wechat-backend"), { executable: true }); + + // 原生三件套必须与 pin 过的产物逐字节一致:打包过程不允许「顺手重编」。 + for (const name of ["libwechatdb_client.so", "wechatdb_broker", "wechatdb_native_build.json"]) { + const packaged = path.join(nativeDir, name); + requireFile(packaged); + const expected = digest(path.join(artifactDir, name)); + const actual = digest(packaged); + if (actual !== expected) { + throw new Error(`packaged ${name} differs from the reviewed native artifact`); + } + } + + // manifest 声明的内容哈希必须描述打包后的这两个文件本身。 + const manifest = JSON.parse( + fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8") + ); + const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest }); + if (pinErrors.length > 0) { + throw new Error(`packaged native core failed its own content pins: ${pinErrors.join("; ")}`); + } + if (manifest.sourceRuntime !== true || manifest.linuxHostVerification !== "same-user-direct-parent") { + throw new Error("packaged native core is not the source-public profile"); + } + if (manifest.buildExpiresAtUnix * 1000 <= Date.now()) { + throw new Error("packaged native core build window has already expired"); + } + + // 桌面应用启动后端时要走的同一条策略判定,必须在**打包后的产物**上通过: + // 这里是「装完能用」的唯一自动化门禁(Release 发布前就拦下 schema 漂移)。 + const { resolveNativeCoreRuntimePolicy, applyNativeCoreRuntimePolicy } = + require("./src/native-core-runtime.cjs"); + const packagedPolicy = resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir, + platform: "linux", + }); + if (packagedPolicy.mode !== "required" || packagedPolicy.artifactState !== "production") { + throw new Error( + `packaged native core did not resolve a required production runtime policy: ${JSON.stringify({ mode: packagedPolicy.mode, artifactState: packagedPolicy.artifactState })}` + ); + } + const backendEnv = {}; + applyNativeCoreRuntimePolicy(backendEnv, { + isPackaged: true, + nativeDir, + platform: "linux", + }); + if (backendEnv.WECHAT_TOOL_NATIVE_CORE_MODE !== "required") { + throw new Error("packaged native core did not enforce the required native-core mode"); + } + + // ELF 身份:客户端/完整性模块是共享对象,broker 是 x86-64 可执行文件。 + const clientElf = inspectElf(path.join(nativeDir, "libwechatdb_client.so")); + if (!clientElf.isSharedObject) throw new Error("packaged native client is not an ELF shared object"); + const brokerElf = inspectElf(path.join(nativeDir, "wechatdb_broker")); + if (!brokerElf.isExecutable) throw new Error("packaged broker is not an ELF executable"); + const integrity = path.join(nativeDir, "libwce_integrity.so"); + requireFile(integrity); + if (!inspectElf(integrity).isSharedObject) { + throw new Error("packaged wce_integrity module is not an ELF shared object"); + } + + // 一键安装素材:归档里必须有应用本体与原生核心,install.sh 必须内嵌归档摘要。 + const productName = JSON.parse(fs.readFileSync("package.json", "utf8")).build.productName; + const version = JSON.parse(fs.readFileSync("package.json", "utf8")).version; + const archiveName = `${productName}-${version}-linux-x86_64.tar.gz`; + const archivePath = path.join("dist", archiveName); + requireFile(archivePath); + const installerPath = path.join("dist", "install.sh"); + requireFile(installerPath, { executable: true }); + const installer = fs.readFileSync(installerPath, "utf8"); + const archiveDigest = digest(archivePath); + if (!installer.includes(archiveDigest)) { + throw new Error("install.sh does not embed the payload archive digest"); + } + const members = childProcess + .execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" }) + .split("\n") + .map((name) => name.replace(/^\.\//, "")) + .filter(Boolean); + for (const name of [ + "wechat-data-analysis", + "resources/backend/wechat-backend", + "resources/backend/native/libwechatdb_client.so", + "resources/backend/native/wechatdb_broker", + ]) { + if (!members.includes(name)) throw new Error(`payload archive is missing ${name}`); + } + console.log(`verified Linux payload ${archiveName} (${archiveDigest})`); + NODE + + - name: Prepare Linux release checksums and provenance + working-directory: desktop + shell: bash + env: + WDA_REPOSITORY: ${{ github.repository }} + WDA_REVISION: ${{ github.sha }} + WDA_TAG: ${{ github.ref_name }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + # 原生核心坐标来自上面重建步骤写进 GITHUB_ENV 的变量(不是仓库变量); + # 也不能写进 step 的 env: 里用表达式读,那种展开发生在解析期,看不到本 + # 作业运行时才写入的值。 + export NATIVE_REPOSITORY="$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY" + export NATIVE_RUN_ID="$WCE_NATIVE_CORE_ARTIFACT_RUN_ID" + export NATIVE_REVISION="$WCE_NATIVE_CORE_SOURCE_REVISION" + export NATIVE_BUILD_ID="$WCE_NATIVE_CORE_BUILD_ID" + export NATIVE_ASSET_SHA256="$WCE_NATIVE_CORE_ARTIFACT_SHA256" + # integrity 源码来自同一个 producer revision,所以溯源字段与原生核心同源。 + export LINUX_INTEGRITY_SOURCE_REPOSITORY="$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY" + export LINUX_INTEGRITY_SOURCE_REVISION="$WCE_NATIVE_CORE_SOURCE_REVISION" + cd dist + # Windows 那份叫 SHA256SUMS.txt / release-provenance.json;Linux 用带后缀的 + # 名字,避免两个作业的产物在 merge-multiple 下载时互相覆盖。 + test -f SHA256SUMS.txt + mv SHA256SUMS.txt SHA256SUMS-linux.txt + sha256sum -c SHA256SUMS-linux.txt + + node - <<'NODE' + const crypto = require("node:crypto"); + const fs = require("node:fs"); + const path = require("node:path"); + + const digest = (filePath) => + crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); + const positiveInteger = (name) => { + const value = Number(process.env[name]); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} is not a positive integer`); + } + return value; + }; + + const nativeDir = path.resolve( + "linux-unpacked", + "resources", + "backend", + "native" + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8") + ); + + const assets = fs + .readdirSync(".") + .filter((name) => name.endsWith("-linux-x86_64.tar.gz") || name === "install.sh") + .sort(); + if (assets.length !== 2) { + throw new Error(`expected exactly one payload archive and install.sh: ${assets.join(", ")}`); + } + const artifacts = assets.map((name) => ({ + path: name, + sha256: digest(name), + size: fs.statSync(name).size, + })); + + const provenance = { + schemaVersion: 1, + artifactName: "release-linux-x64", + source: { + repository: process.env.WDA_REPOSITORY, + revision: process.env.WDA_REVISION, + tag: process.env.WDA_TAG, + }, + native: { + repository: process.env.NATIVE_REPOSITORY, + workflowRunId: positiveInteger("NATIVE_RUN_ID"), + sourceRevision: process.env.NATIVE_REVISION, + buildId: process.env.NATIVE_BUILD_ID, + artifactSha256: process.env.NATIVE_ASSET_SHA256, + distributionMode: manifest.distributionMode, + integrityMode: manifest.linuxIntegrityMode, + linuxClientSha256: manifest.linuxClientSha256, + linuxBrokerSha256: manifest.linuxBrokerSha256, + linuxPeerVerification: manifest.linuxPeerVerification, + linuxHostVerification: manifest.linuxHostVerification, + sourceRuntime: manifest.sourceRuntime === true, + offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits, + offlineExportSealFormat: manifest.offlineExportSealFormat, + securityNoticeId: manifest.securityNoticeId, + securityNoticeSha256: manifest.securityNoticeSha256, + securityCheckpointSetId: manifest.securityCheckpointSetId, + securityCheckpointCount: manifest.securityCheckpointCount, + securityCheckpointSetSha256: manifest.securityCheckpointSetSha256, + }, + integrity: { + sourceRepository: process.env.LINUX_INTEGRITY_SOURCE_REPOSITORY, + sourceRevision: process.env.LINUX_INTEGRITY_SOURCE_REVISION, + binarySha256: digest(path.join(nativeDir, "libwce_integrity.so")), + signingKey: "ephemeral-build-key", + }, + build: { + workflowRunId: positiveInteger("WORKFLOW_RUN_ID"), + workflowRunAttempt: positiveInteger("WORKFLOW_RUN_ATTEMPT"), + }, + artifacts, + }; + fs.writeFileSync( + "release-provenance-linux.json", + `${JSON.stringify(provenance, null, 2)}\n` + ); + console.log(JSON.stringify(provenance, null, 2)); + NODE + + - name: Upload Linux release files + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-linux-x64 + if-no-files-found: error + retention-days: 14 + path: | + desktop/dist/*-linux-x86_64.tar.gz + desktop/dist/install.sh + desktop/dist/SHA256SUMS-linux.txt + desktop/dist/release-provenance-linux.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cc3bd5d..b1b10f11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release (Windows and macOS ARM64) +name: Release (Windows, macOS ARM64 and Linux x64) on: push: @@ -711,10 +711,17 @@ jobs: uses: ./.github/workflows/macos-private-build.yml secrets: inherit + # Linux 与 Windows/macOS 同为必需平台:pin 没配齐就整个 release 失败(fail closed), + # 不允许「静默少发一个平台」。准备步骤见 linux-private-build.yml 顶部的注释。 + build-linux-x64: + uses: ./.github/workflows/linux-private-build.yml + secrets: inherit + publish-release: needs: - build-windows - build-macos-arm64 + - build-linux-x64 runs-on: ubuntu-latest steps: - name: Checkout release history @@ -842,84 +849,3 @@ jobs: subprocess.run(["gh", "run", "watch", str(match["id"]), "--repo", repository, "--exit-status"], check=True) PY - # ========================== QQ 群通知 ========================== - # 等 Release 发布完成后,发送 QQ 群通知。 - # 消息内容取最后一次 commit 正文(用户约定在此写本次更新说明)。 - # Windows exe 通过分块上传直传 QQ(GitHub CDN 在大陆不可访问)。 - qq-notify: - needs: [publish-release] - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Prepare release info & download artifacts - id: prep - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ github.ref_name }}" - VER="${VERSION#v}" - EXE="WeChatDataAnalysis-${VER}-Setup.exe" - DMG="WeChatDataAnalysis-${VER}-mac-arm64.dmg" - EXE_7Z="${EXE}.7z" - DMG_7Z="${DMG}.7z" - BASE_URL="https://github.com/${{ github.repository }}/releases/download/${VERSION}" - EXE_URL="${BASE_URL}/${EXE}" - MAC_ARM64_URL="${BASE_URL}/${DMG}" - - gh release download "${VERSION}" --pattern "${EXE}" --pattern "${DMG}" --dir /tmp/release - - 7z a /tmp/release/${EXE_7Z} /tmp/release/${EXE} - 7z a /tmp/release/${DMG_7Z} /tmp/release/${DMG} - - BODY=$(git log -1 --pretty=format:%b) - [ -z "$BODY" ] && BODY=$(git log -1 --pretty=format:%s) - - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "exe_url=${EXE_URL}" >> $GITHUB_OUTPUT - echo "mac_arm64_url=${MAC_ARM64_URL}" >> $GITHUB_OUTPUT - { echo "body<> $GITHUB_OUTPUT - - MAX_BYTES=209715200 - EXE_7Z_SIZE=$(stat -c%s /tmp/release/${EXE_7Z}) - DMG_7Z_SIZE=$(stat -c%s /tmp/release/${DMG_7Z}) - - { - echo "file_path_list<> $GITHUB_OUTPUT - - { - echo "file_name_list<> $GITHUB_OUTPUT - - - name: Send QQ notification - uses: H3CoF6/qq-notify-action@50d180981e7c7b8552a3331b981e3f8cfcf40c44 - with: - appid: ${{ secrets.QQ_APPID }} - secret: ${{ secrets.QQ_SECRET }} - group_openid: ${{ secrets.QQ_GROUP_OPENID }} - message: | - ## WeChatDataAnalysis 新版本 ${{ steps.prep.outputs.version }} 发布 - - ==详细更改如下:== - ${{ steps.prep.outputs.body }} - - --- - - - Windows 安装包 [下载链接](${{ steps.prep.outputs.exe_url }}) - - macOS arm64 [下载链接](${{ steps.prep.outputs.mac_arm64_url }}) - - 欢迎大家使用和测试~ - file_path: ${{ steps.prep.outputs.file_path_list }} - file_type: file - file_name: ${{ steps.prep.outputs.file_name_list }} diff --git a/.gitignore b/.gitignore index 160c0887..fb895e01 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ wheels/ .ace-tool/ pnpm-lock.yaml /tools/tmp_isaac64_compare.js -/native/wce_integrity/ +/native/wce_integrity /.claude/settings.local.json .env .env.* @@ -87,6 +87,7 @@ pnpm-lock.yaml /src/wechat_decrypt_tool/native/wechatdb_client.dll /src/wechat_decrypt_tool/native/wechatdb_broker.exe /src/wechat_decrypt_tool/native/libwechatdb_client.dylib +/src/wechat_decrypt_tool/native/libwechatdb_client.so /src/wechat_decrypt_tool/native/wechatdb_broker /src/wechat_decrypt_tool/native/wechatdb_native_build.json /src/wechat_decrypt_tool/native/macos/db-key/ diff --git a/desktop/package.json b/desktop/package.json index f07fbcd5..6c6744ef 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -20,6 +20,7 @@ "smoke:win:real": "node scripts/smoke-windows-real-database.cjs", "dist": "npm run dist:win", "dist:win": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never", + "dist:linux": "npm run build:ui && npm run build:backend && electron-builder --linux dir --x64 --publish never && node scripts/build-linux-installer.cjs", "dist:mac": "npm run dist:mac:arm64", "dist:mac:arm64": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --mac dmg zip --arm64 --publish never", "dist:mac:arm64:release": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && cross-env MACOS_DISTRIBUTION_BUILD=1 electron-builder --mac dmg zip --arm64 --publish never --config.forceCodeSigning=true" @@ -101,6 +102,14 @@ ] } ], + "linux": { + "icon": "src/icon.png", + "category": "Utility", + "executableName": "wechat-data-analysis", + "target": [ + "dir" + ] + }, "win": { "icon": "build/icon.ico", "forceCodeSigning": true, diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index a0bb2797..82e68b5b 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -1,4 +1,5 @@ const { aiPackagingArgs, runPackagedAiSmoke } = require('./ai-packaging.cjs'); +const crypto = require("crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -11,6 +12,10 @@ const { macosNativeManifestErrors, resolveMacosNativeCoreArtifacts, } = require("./macos-native-core-packaging.cjs"); +const { + linuxNativeManifestErrors, + resolveLinuxNativeCoreArtifacts, +} = require("./linux-native-core-packaging.cjs"); const { resolveIntegrityNativeArtifact, } = require("./integrity-native-packaging.cjs"); @@ -40,6 +45,8 @@ const NATIVE_CORE_MANIFEST = "wechatdb_native_build.json"; const NATIVE_CORE_ARTIFACTS = Object.freeze({ win32: ["wechatdb_client.dll", "wechatdb_broker.exe", NATIVE_CORE_MANIFEST], darwin: ["libwechatdb_client.dylib", "wechatdb_broker", NATIVE_CORE_MANIFEST], + // Linux 与 macOS 共用同名 broker,客户端是 ELF 共享库;身份靠内容哈希而不是代码签名。 + linux: ["libwechatdb_client.so", "wechatdb_broker", NATIVE_CORE_MANIFEST], }); const NATIVE_CORE_FILE_NAMES = new Set(Object.values(NATIVE_CORE_ARTIFACTS).flat()); const LEGACY_WCDB_FILE_NAMES = new Set([ @@ -90,12 +97,15 @@ function nativeCoreManifestErrors(manifest) { if (!manifest || Array.isArray(manifest) || typeof manifest !== "object") { return ["manifest must be a JSON object"]; } - if (!new Set([2, 3]).has(manifest.schemaVersion)) { - errors.push("schemaVersion must equal 2 or 3"); + if (!new Set([2, 3, 4]).has(manifest.schemaVersion)) { + errors.push("schemaVersion must equal 2, 3 or 4"); } if (manifest.schemaVersion === 3 && manifest.platform !== "macos") { errors.push("schemaVersion 3 requires platform macos"); } + if (manifest.schemaVersion === 4 && manifest.platform !== "linux") { + errors.push("schemaVersion 4 requires platform linux"); + } if (manifest.schemaVersion === 2 && Object.prototype.hasOwnProperty.call(manifest, "platform")) { errors.push("schemaVersion 2 must not declare platform"); } @@ -145,6 +155,10 @@ function nativeCoreProductionManifestErrors( if (manifest?.schemaVersion === 3) { return macosNativeManifestErrors(manifest, { nowUnix }); } + // schema v4 是 Linux 的完整契约(含内容哈希 pin 与 45 天窗口),不能走下面 Windows 那套。 + if (manifest?.schemaVersion === 4) { + return linuxNativeManifestErrors(manifest, { nowUnix }); + } const errors = nativeCoreManifestErrors(manifest); const buildIssuedAtUnix = manifest?.buildIssuedAtUnix; const buildExpiresAtUnix = manifest?.buildExpiresAtUnix; @@ -271,6 +285,11 @@ function resolveNativeCoreArtifacts({ env = process.env, platform = process.plat return { ...resolved, allowDevelopment: false, required: true }; } + if (platform === "linux" && !allowDevelopment) { + const resolved = resolveLinuxNativeCoreArtifacts({ env, platform }); + return { ...resolved, allowDevelopment: false, required: true }; + } + const artifactDir = path.resolve(explicitValue); let directoryStat; try { @@ -370,6 +389,17 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat } const integrityTargetDir = path.join(repoRoot, "native", "wce_integrity", "target", "release"); const fileName = platform === "darwin" ? "libwce_integrity.dylib" : "libwce_integrity.so"; + // 构建密钥 = 编译 wce_integrity 时注入的 P-256 私钥(WCE_SIGNING_KEY_HEX),只用来给导出物封签, + // 公钥随模块一起编译进去,没有任何外部预注册,所以「每次构建现生成一把」是安全的。 + // 这与 Windows 官方入口 tools/build_wce_integrity.ps1 -GenerateEphemeralSigningKey 语义一致: + // 有注入就用注入的(可复现),没注入就现生成一把临时的(Linux/macOS 本地构建的默认)。 + const providedSigningKey = String(env.WCE_SIGNING_KEY_HEX || "").trim(); + const signingKeyHex = providedSigningKey || crypto.randomBytes(32).toString("hex"); + if (!providedSigningKey) { + process.stdout.write( + `wce_integrity: generated an ephemeral build signing key for ${platform} (set WCE_SIGNING_KEY_HEX to pin it)\n` + ); + } const result = spawnSync( "cargo", ["build", "--manifest-path", integrityManifest, "--release"], @@ -377,6 +407,7 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat cwd: repoRoot, env: { ...env, + WCE_SIGNING_KEY_HEX: signingKeyHex, WCE_UI_PUBLIC_DIR: path.join(repoRoot, "frontend", ".output", "public"), }, stdio: "inherit", diff --git a/desktop/scripts/build-linux-installer.cjs b/desktop/scripts/build-linux-installer.cjs new file mode 100644 index 00000000..536edcf3 --- /dev/null +++ b/desktop/scripts/build-linux-installer.cjs @@ -0,0 +1,185 @@ +"use strict"; + +// 把 electron-builder 的 Linux 解包产物(dist/linux-unpacked)打成「一键安装」素材: +// +// dist/WeChatDataAnalysis--linux-x86_64.tar.gz 负载 +// dist/install.sh 一键安装/卸载脚本(内嵌负载 SHA-256) +// dist/SHA256SUMS.txt 给人工核对用 +// +// 刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg, +// Linux 走「用户级、免 root 的 tar.gz + install.sh」这条路。 + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const desktopRoot = path.resolve(__dirname, ".."); +const DEFAULT_PAYLOAD_DIR = path.join(desktopRoot, "dist", "linux-unpacked"); +const DEFAULT_OUTPUT_DIR = path.join(desktopRoot, "dist"); +const TEMPLATE_PATH = path.join(__dirname, "linux-installer-template.sh"); +const ICON_SOURCE = path.join(desktopRoot, "src", "icon.png"); +const ICON_NAME = "wechat-data-analysis.png"; +const ARCH = "x86_64"; + +function readPackageMetadata() { + const packageJson = JSON.parse( + fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8") + ); + const productName = String(packageJson.build?.productName || packageJson.name || "").trim(); + const version = String(packageJson.version || "").trim(); + const executableName = String(packageJson.build?.linux?.executableName || "").trim(); + if (!productName || !version || !executableName) { + throw new Error( + "package.json must declare build.productName, version and build.linux.executableName" + ); + } + return { productName, version, executableName }; +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function runTarCreate(payloadDir, archivePath) { + // 用系统 tar 而不是 Node 第三方库:保留权限位/符号链接,且 CI 与本机一致。 + const result = spawnSync("tar", ["-czf", archivePath, "-C", payloadDir, "."], { + stdio: "inherit", + }); + if (result.error) throw result.error; + if ((result.status ?? 1) !== 0) { + throw new Error(`tar failed with exit code ${result.status}`); + } +} + +function renderInstallerTemplate({ productName, version, executableName, payloadName, sha256 }) { + const template = fs.readFileSync(TEMPLATE_PATH, "utf8"); + const replacements = { + "@@PRODUCT@@": productName, + "@@VERSION@@": version, + "@@ARCH@@": ARCH, + "@@EXECUTABLE@@": executableName, + "@@PAYLOAD@@": payloadName, + "@@SHA256@@": sha256, + }; + let rendered = template; + for (const [token, value] of Object.entries(replacements)) { + rendered = rendered.split(token).join(value); + } + const leftover = rendered.match(/@@[A-Z_]+@@/); + if (leftover) throw new Error(`installer template still contains ${leftover[0]}`); + return rendered; +} + +function buildLinuxInstaller({ + payloadDir = DEFAULT_PAYLOAD_DIR, + outputDir = DEFAULT_OUTPUT_DIR, + metadata = readPackageMetadata(), + skipArchive = false, +} = {}) { + const { productName, version, executableName } = metadata; + const payloadStat = (() => { + try { + return fs.statSync(payloadDir); + } catch { + throw new Error(`Linux payload directory not found: ${payloadDir}`); + } + })(); + if (!payloadStat.isDirectory()) { + throw new Error(`Linux payload is not a directory: ${payloadDir}`); + } + const executable = path.join(payloadDir, executableName); + try { + const stat = fs.statSync(executable); + if (!stat.isFile()) throw new Error("not a file"); + } catch { + throw new Error( + `Linux payload is missing the application executable: ${executable}. ` + + "Run `npm run dist:linux` first." + ); + } + + // 桌面项要用的图标随包一起走,避免安装后引用仓库里的路径。 + const iconDestination = path.join(payloadDir, "resources", ICON_NAME); + fs.mkdirSync(path.dirname(iconDestination), { recursive: true }); + fs.copyFileSync(ICON_SOURCE, iconDestination); + + fs.mkdirSync(outputDir, { recursive: true }); + const payloadName = `${productName}-${version}-linux-${ARCH}.tar.gz`; + const archivePath = path.join(outputDir, payloadName); + if (!skipArchive) { + fs.rmSync(archivePath, { force: true }); + runTarCreate(payloadDir, archivePath); + } + if (!fs.existsSync(archivePath)) { + throw new Error(`Linux payload archive was not produced: ${archivePath}`); + } + const digest = sha256File(archivePath); + + const installerPath = path.join(outputDir, "install.sh"); + fs.writeFileSync( + installerPath, + renderInstallerTemplate({ + productName, + version, + executableName, + payloadName, + sha256: digest, + }), + { mode: 0o755 } + ); + fs.chmodSync(installerPath, 0o755); + + const checksumsPath = path.join(outputDir, "SHA256SUMS.txt"); + fs.writeFileSync( + checksumsPath, + `${digest} ${payloadName}\n${sha256File(installerPath)} install.sh\n` + ); + + return { archivePath, installerPath, checksumsPath, payloadName, sha256: digest }; +} + +function parseCliArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--payload-dir") options.payloadDir = path.resolve(argv[++index]); + else if (argument === "--output-dir") options.outputDir = path.resolve(argv[++index]); + else if (argument === "--skip-archive") options.skipArchive = true; + else if (argument === "--help" || argument === "-h") options.help = true; + else throw new Error(`Unknown argument: ${argument}`); + } + return options; +} + +function main(argv = process.argv.slice(2)) { + const options = parseCliArguments(argv); + if (options.help) { + process.stdout.write( + "Usage: node scripts/build-linux-installer.cjs [--payload-dir DIR] [--output-dir DIR] [--skip-archive]\n" + ); + return 0; + } + const result = buildLinuxInstaller(options); + process.stdout.write(`Linux payload: ${result.archivePath}\n`); + process.stdout.write(`Installer: ${result.installerPath}\n`); + process.stdout.write(`SHA-256: ${result.sha256}\n`); + return 0; +} + +if (require.main === module) { + try { + process.exitCode = main(); + } catch (error) { + process.stderr.write(`${error?.message || error}\n`); + process.exitCode = 1; + } +} + +module.exports = { + ARCH, + buildLinuxInstaller, + parseCliArguments, + readPackageMetadata, + renderInstallerTemplate, +}; diff --git a/desktop/scripts/linux-installer-template.sh b/desktop/scripts/linux-installer-template.sh new file mode 100644 index 00000000..9f8eee69 --- /dev/null +++ b/desktop/scripts/linux-installer-template.sh @@ -0,0 +1,162 @@ +#!/bin/sh +# @@PRODUCT@@ @@VERSION@@ (linux-@@ARCH@@) 一键安装脚本 —— 由 Build-LinuxInstaller 生成,请勿手改。 +# +# 设计取舍(Linux 没有安装包是刻意的): +# * 不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg。 +# * 所以这里给一个「用户级、免 root」的安装脚本:解包到用户目录 + 桌面项 + 启动器。 +# * 产物身份靠内容哈希:脚本里内嵌 tarball 的 SHA-256,装之前先校验。 +# +# 用法: +# ./install.sh # 装到 ${XDG_DATA_HOME:-~/.local/share}/wechat-data-analysis +# ./install.sh --prefix /opt/x # 自定义前缀 +# ./install.sh --uninstall # 卸载 +set -eu + +PRODUCT='@@PRODUCT@@' +VERSION='@@VERSION@@' +ARCH='@@ARCH@@' +PAYLOAD_NAME='@@PAYLOAD@@' +PAYLOAD_SHA256='@@SHA256@@' + +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +BIN_HOME="${XDG_BIN_HOME:-$HOME/.local/bin}" +DEFAULT_PREFIX="$DATA_HOME/wechat-data-analysis" +PREFIX="$DEFAULT_PREFIX" +UNINSTALL=0 + +die() { printf '错误: %s\n' "$1" >&2; exit 1; } +info() { printf '%s\n' "$1"; } + +usage() { + cat </dev/null 2>&1; then + actual=$(sha256sum "$PAYLOAD" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$PAYLOAD" | awk '{print $1}') + else + die "找不到 sha256sum 或 shasum,无法校验安装包完整性" + fi + [ "$actual" = "$PAYLOAD_SHA256" ] || die "安装包校验失败:期望 $PAYLOAD_SHA256,实际 $actual" +} + +verify_hash +info "校验通过: $PAYLOAD_NAME" + +TARGET="$PREFIX/$VERSION" +[ "$TARGET" != "$PREFIX" ] || die "安装目标解析异常: $TARGET" + +mkdir -p "$PREFIX" "$BIN_HOME" +STAGING="$PREFIX/.staging-$$" +rm -rf "$STAGING" +mkdir -p "$STAGING" + +cleanup() { rm -rf "$STAGING"; } +trap cleanup EXIT HUP INT TERM + +info "解包到 $TARGET ..." +tar -xzf "$PAYLOAD" -C "$STAGING" || die "解包失败" +[ -x "$STAGING/@@EXECUTABLE@@" ] || die "安装包里找不到可执行文件 @@EXECUTABLE@@" + +rm -rf "$TARGET" +# staging 与 TARGET 同处 $PREFIX 下,rename 是原子的:不会留下半新半旧的目录。 +mv "$STAGING" "$TARGET" +cleanup +trap - EXIT HUP INT TERM + +# current 是原子切换的指针,升级时不会留下半新半旧的目录。 +ln -sfn "$TARGET" "$PREFIX/current" + +LAUNCHER="$PREFIX/bin/wechat-data-analysis" +mkdir -p "$PREFIX/bin" +cat > "$LAUNCHER" < "$DESKTOP_FILE" +chmod 0644 "$DESKTOP_FILE" + +info "已安装: $TARGET" +info "启动器: $LAUNCHER" +info "桌面项: $DESKTOP_FILE" +case ":$PATH:" in + *":$BIN_HOME:"*) info "命令行可用: wechat-data-analysis" ;; + *) info "提示: 把 $BIN_HOME 加进 PATH 后可直接用 wechat-data-analysis" ;; +esac + +# Electron 在 Linux 上依赖「非特权用户命名空间」来开沙箱;内核关掉它时应用会起不来。 +# 这里只做提示,不替用户改内核参数,也不默认加 --no-sandbox(那会削弱沙箱)。 +if [ -r /proc/sys/user/max_user_namespaces ] && [ "$(cat /proc/sys/user/max_user_namespaces)" = "0" ]; then + info "警告: 当前内核禁用了非特权用户命名空间,Electron 沙箱无法启动。" + info " 可用 sysctl user.max_user_namespaces=10000 打开,或自行以 --no-sandbox 运行(不推荐)。" +fi + +info "卸载: $SCRIPT_DIR/install.sh --uninstall --prefix $PREFIX" diff --git a/desktop/scripts/linux-native-core-packaging.cjs b/desktop/scripts/linux-native-core-packaging.cjs new file mode 100644 index 00000000..9e2604c3 --- /dev/null +++ b/desktop/scripts/linux-native-core-packaging.cjs @@ -0,0 +1,502 @@ +"use strict"; + +// Linux 的 native core 消费校验。 +// +// 与 macOS 那套(macos-native-core-packaging.cjs)对齐,但签名模型完全不同: +// Linux 没有代码签名,产物身份 = **内容哈希**(linuxIntegrityMode: content-hash-pin)。 +// 所以这里把 manifest 里的 linuxClientSha256 / linuxBrokerSha256 当成身份声明, +// 逐字节比对实际文件,再用 SHA256SUMS.txt + provenance.json 把来源钉到某个 WCDB revision。 +// 一旦内容被替换,哈希必然对不上,直接 fail closed。 + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const CLIENT_NAME = "libwechatdb_client.so"; +const BROKER_NAME = "wechatdb_broker"; +const MANIFEST_NAME = "wechatdb_native_build.json"; +const CHECKSUMS_NAME = "SHA256SUMS.txt"; +const PROVENANCE_NAME = "provenance.json"; +const ARTIFACT_TEST_NAME = "Test-LinuxNativeProductionArtifact.py"; + +const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const BUILD_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/; +const REVISION_PATTERN = /^[0-9a-f]{40}$/; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const NON_PRODUCTION_BUILD_ID_PATTERN = + /(^|[._-])(dev|debug|test|local|snapshot|staging)([._-]|$)/i; + +const INTEGRITY_MODE = "content-hash-pin"; +// 产出这份产物的工作流路径。它也是身份的一部分:只有被审阅过的 producer 才允许 +// 产出发布路径会接受的产物(artifact 内部的 Python 校验器断言同一个常量)。 +const PRODUCER_WORKFLOW = ".github/workflows/linux-native-production.yml"; +const PEER_VERIFICATION = "same-user-peer-credentials"; +const HOST_VERIFICATION = Object.freeze({ + production: "content-hash-pin", + sourceRuntime: "same-user-direct-parent", +}); +const ARTIFACT_NAME_PATTERN = /^wechatdb-native-linux-x64-(production|source-public)$/; +const PRODUCERS = new Set(["github-actions", "manual"]); + +// 校验集只覆盖「运行时真正要用的四个文件」;SHA256SUMS.txt / provenance.json 是自证材料。 +const CHECKSUM_FILE_NAMES = Object.freeze([ + ARTIFACT_TEST_NAME, + CLIENT_NAME, + BROKER_NAME, + MANIFEST_NAME, +]); +const ARTIFACT_FILE_NAMES = Object.freeze([ + ...CHECKSUM_FILE_NAMES, + CHECKSUMS_NAME, + PROVENANCE_NAME, +]); +const RUNTIME_FILE_NAMES = Object.freeze([CLIENT_NAME, BROKER_NAME, MANIFEST_NAME]); + +const MANIFEST_REQUIRED_FIELDS = Object.freeze([ + "schemaVersion", + "platform", + "distributionMode", + "buildId", + "buildIssuedAtUnix", + "buildExpiresAtUnix", + "developmentBuild", + "offlineBootstrapFeatureBits", + "offlineExportSealFormat", + "codeSignatureEnforced", + "rootPublicKeyCompiled", + "testHooksEnabled", + "stagingPinnedSignerTrust", + "linuxIntegrityMode", + "linuxClientSha256", + "linuxBrokerSha256", + "linuxPeerVerification", + "linuxHostVerification", + "securityNoticeId", + "securityNoticeSha256", + "securityCheckpointSetId", + "securityCheckpointCount", + "securityCheckpointSetSha256", +]); +const MANIFEST_OPTIONAL_FIELDS = Object.freeze(["sourceRuntime"]); +const PROVENANCE_FIELDS = Object.freeze([ + "schemaVersion", + "artifactName", + "producer", + "workflow", + "repository", + "runId", + "runAttempt", + "sourceRevision", + "build", + "manifestSha256", + "checksumsSha256", + "artifacts", +]); + +function exactKeys(value, required, optional = []) { + if (!value || Array.isArray(value) || typeof value !== "object") return false; + const allowed = new Set([...required, ...optional]); + const actual = Object.keys(value); + if (actual.some((name) => !allowed.has(name))) return false; + return required.every((name) => Object.prototype.hasOwnProperty.call(value, name)); +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function isNonZeroSha256(value) { + const text = String(value || ""); + return SHA256_PATTERN.test(text) && !/^0{64}$/.test(text); +} + +function readJson(filePath, label, maximum = 64 * 1024) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size <= 0 || stat.size > maximum) throw new Error("invalid size"); + const value = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (!value || Array.isArray(value) || typeof value !== "object") { + throw new Error("root must be an object"); + } + return value; + } catch (error) { + throw new Error(`Invalid ${label} at ${filePath}: ${error.message}`); + } +} + +function requiredEnv(env, name, pattern) { + const value = String(env[name] || "").trim(); + if (!value || (pattern && !pattern.test(value))) { + throw new Error(`Missing or invalid ${name}`); + } + return value; +} + +function optionalEnvPin(env, name) { + const value = String(env[name] || "").trim(); + if (!value) return null; + if (!isNonZeroSha256(value)) { + throw new Error(`${name} must be a non-zero lowercase SHA-256 digest`); + } + return value; +} + +function parseChecksums(filePath) { + const records = new Map(); + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean); + for (const line of lines) { + const match = /^([0-9a-f]{64}) {2}([A-Za-z0-9._-]+)$/.exec(line); + if (!match || records.has(match[2])) throw new Error("SHA256SUMS.txt has an invalid record"); + records.set(match[2], match[1]); + } + return records; +} + +function linuxNativeManifestErrors(manifest, { nowUnix = Math.floor(Date.now() / 1000) } = {}) { + const errors = []; + if (!exactKeys(manifest, MANIFEST_REQUIRED_FIELDS, MANIFEST_OPTIONAL_FIELDS)) { + errors.push("manifest fields must match Linux schema v4 exactly"); + return errors; + } + if (manifest.schemaVersion !== 4) errors.push("schemaVersion must equal 4"); + if (manifest.platform !== "linux") errors.push("platform must equal linux"); + if (manifest.distributionMode !== "public") errors.push("distributionMode must equal public"); + if ( + !BUILD_ID_PATTERN.test(String(manifest.buildId || "")) || + NON_PRODUCTION_BUILD_ID_PATTERN.test(String(manifest.buildId || "")) + ) { + errors.push("buildId must be an immutable production identity"); + } + const issued = manifest.buildIssuedAtUnix; + const expires = manifest.buildExpiresAtUnix; + if ( + !Number.isSafeInteger(issued) || + issued <= 0 || + !Number.isSafeInteger(expires) || + expires !== issued + BUILD_LIFETIME_SECONDS + ) { + errors.push("build validity window must equal exactly 45 days"); + } else if (!Number.isSafeInteger(nowUnix) || nowUnix < 0 || nowUnix >= expires) { + errors.push("build has reached its fixed expiration time"); + } + if ( + manifest.developmentBuild !== false || + manifest.offlineBootstrapFeatureBits !== 3 || + manifest.offlineExportSealFormat !== "WES2" || + manifest.codeSignatureEnforced !== true || + manifest.rootPublicKeyCompiled !== true || + manifest.testHooksEnabled !== false || + manifest.stagingPinnedSignerTrust !== false + ) { + errors.push("native production security fields do not match policy"); + } + if (manifest.linuxIntegrityMode !== INTEGRITY_MODE) { + errors.push(`linuxIntegrityMode must equal ${INTEGRITY_MODE}`); + } + if (manifest.linuxPeerVerification !== PEER_VERIFICATION) { + errors.push(`linuxPeerVerification must equal ${PEER_VERIFICATION}`); + } + // 两个 profile 的 host 校验强度不同,必须自洽:源码分发用「直接父进程」, + // 否则用「内容哈希 pin」。声明 sourceRuntime 就只能是前者。 + const sourceRuntime = manifest.sourceRuntime === true; + if ( + Object.prototype.hasOwnProperty.call(manifest, "sourceRuntime") && + manifest.sourceRuntime !== true + ) { + errors.push("sourceRuntime must be true when present"); + } + const expectedHostVerification = sourceRuntime + ? HOST_VERIFICATION.sourceRuntime + : HOST_VERIFICATION.production; + if (manifest.linuxHostVerification !== expectedHostVerification) { + errors.push(`linuxHostVerification must equal ${expectedHostVerification}`); + } + const pins = [manifest.linuxClientSha256, manifest.linuxBrokerSha256]; + if (pins.some((value) => !isNonZeroSha256(value))) { + errors.push("linux client and broker content pins must be non-zero SHA-256 digests"); + } else if (pins[0] === pins[1]) { + errors.push("linux client and broker content pins must be distinct"); + } + if ( + manifest.securityNoticeId !== "WCE-AUTOMATED-ANALYSIS-NOTICE-V2" || + !SHA256_PATTERN.test(String(manifest.securityNoticeSha256 || "")) || + manifest.securityCheckpointSetId !== "WCE-AI-CHECKPOINT-SET-V3" || + manifest.securityCheckpointCount !== 7 || + !SHA256_PATTERN.test(String(manifest.securityCheckpointSetSha256 || "")) + ) { + errors.push("native security checkpoint contract mismatch"); + } + return errors; +} + +// 内容哈希就是 Linux 的身份。manifest 声明什么,盘上就必须是什么。 +function linuxContentPinErrors({ directory, manifest }) { + const errors = []; + const expectations = [ + [CLIENT_NAME, manifest?.linuxClientSha256], + [BROKER_NAME, manifest?.linuxBrokerSha256], + ]; + for (const [name, expected] of expectations) { + const filePath = path.join(directory, name); + try { + if (!fs.statSync(filePath).isFile()) throw new Error("not a regular file"); + } catch { + errors.push(`missing native component ${name}`); + continue; + } + const actual = sha256File(filePath); + if (actual !== expected) { + errors.push(`content hash mismatch for ${name}: expected ${expected}, received ${actual}`); + } + } + return errors; +} + +// 极简 ELF 头解析:不依赖 readelf/file,Linux 与 macOS 主机上都能跑。 +// 只断言「身份声明」需要的部分:64 位、小端、x86-64、以及可执行类别。 +function inspectElf(filePath) { + const header = Buffer.alloc(20); + const handle = fs.openSync(filePath, "r"); + try { + fs.readSync(handle, header, 0, 20, 0); + } finally { + fs.closeSync(handle); + } + if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) { + throw new Error(`not an ELF file: ${filePath}`); + } + const elfClass = header[4]; + const dataEncoding = header[5]; + if (elfClass !== 2) throw new Error(`ELF is not 64-bit: ${filePath}`); + if (dataEncoding !== 1) throw new Error(`ELF is not little-endian: ${filePath}`); + const type = header.readUInt16LE(16); + const machine = header.readUInt16LE(18); + if (machine !== 0x3e) throw new Error(`ELF is not x86-64: ${filePath}`); + return { type, machine, isSharedObject: type === 3, isExecutable: type === 2 || type === 3 }; +} + +function resolveLinuxNativeCoreArtifacts({ + env = process.env, + platform = process.platform, + nowUnix = Math.floor(Date.now() / 1000), +} = {}) { + if (platform !== "linux") { + throw new Error(`Linux native-core artifacts cannot be resolved on platform: ${platform}`); + } + const artifactDirValue = String(env.WCE_NATIVE_CORE_ARTIFACT_DIR || "").trim(); + if (!artifactDirValue) { + throw new Error( + "Missing WCE_NATIVE_CORE_ARTIFACT_DIR. Expected a directory containing: " + + RUNTIME_FILE_NAMES.join(", ") + ); + } + const artifactDir = path.resolve(artifactDirValue); + let stat; + try { + stat = fs.statSync(artifactDir); + } catch { + throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not readable: ${artifactDir}`); + } + if (!stat.isDirectory()) { + throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not a directory: ${artifactDir}`); + } + const entries = fs.readdirSync(artifactDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + const wanted = [...ARTIFACT_FILE_NAMES].sort(); + if (entries.some((entry) => !entry.isFile()) || files.join("\n") !== wanted.join("\n")) { + throw new Error( + `Linux native-core artifact allowlist mismatch. Expected ${wanted.join(", ")}, received ${files.join(", ")}` + ); + } + + const repository = requiredEnv(env, "WCE_NATIVE_CORE_ARTIFACT_REPOSITORY", REPOSITORY_PATTERN); + const sourceRevision = requiredEnv(env, "WCE_NATIVE_CORE_SOURCE_REVISION", REVISION_PATTERN); + const buildId = requiredEnv(env, "WCE_NATIVE_CORE_BUILD_ID", BUILD_ID_PATTERN); + const clientPin = optionalEnvPin(env, "WCE_NATIVE_CORE_CLIENT_SHA256"); + const brokerPin = optionalEnvPin(env, "WCE_NATIVE_CORE_BROKER_SHA256"); + + const manifestPath = path.join(artifactDir, MANIFEST_NAME); + const manifest = readJson(manifestPath, "Linux native-core manifest", 16 * 1024); + const manifestErrors = linuxNativeManifestErrors(manifest, { nowUnix }); + if (manifestErrors.length > 0) { + throw new Error(`Refusing Linux native-core artifact: ${manifestErrors.join("; ")}`); + } + if (manifest.buildId !== buildId) { + throw new Error("Linux native-core manifest does not match the protected build id pin"); + } + if (clientPin && manifest.linuxClientSha256 !== clientPin) { + throw new Error("Linux native-core manifest does not match the protected client content pin"); + } + if (brokerPin && manifest.linuxBrokerSha256 !== brokerPin) { + throw new Error("Linux native-core manifest does not match the protected broker content pin"); + } + + const checksumsPath = path.join(artifactDir, CHECKSUMS_NAME); + const checksums = parseChecksums(checksumsPath); + if ( + checksums.size !== CHECKSUM_FILE_NAMES.length || + CHECKSUM_FILE_NAMES.some( + (name) => checksums.get(name) !== sha256File(path.join(artifactDir, name)) + ) + ) { + throw new Error("Linux native-core checksum set does not match the artifact allowlist"); + } + + const provenance = readJson(path.join(artifactDir, PROVENANCE_NAME), "Linux native-core provenance"); + if (!exactKeys(provenance, PROVENANCE_FIELDS)) { + throw new Error("Linux native-core provenance fields do not match schema v1 exactly"); + } + const producer = String(provenance.producer || ""); + if (!PRODUCERS.has(producer)) { + throw new Error("Linux native-core provenance must come from github-actions or a manual producer"); + } + if (provenance.schemaVersion !== 1) { + throw new Error("Linux native-core provenance schemaVersion must equal 1"); + } + if (!ARTIFACT_NAME_PATTERN.test(String(provenance.artifactName || ""))) { + throw new Error("Linux native-core provenance artifactName is not a Linux x64 profile"); + } + if (provenance.repository !== repository) { + throw new Error("Linux native-core provenance repository does not match the protected pin"); + } + if (provenance.sourceRevision !== sourceRevision) { + throw new Error("Linux native-core provenance revision does not match the protected pin"); + } + const expectedRunId = String(env.WCE_NATIVE_CORE_ARTIFACT_RUN_ID || "").trim(); + if (producer === "github-actions") { + if (!/^[1-9][0-9]*$/.test(expectedRunId) || Number(provenance.runId) !== Number(expectedRunId)) { + throw new Error("Linux native-core provenance run id does not match the protected pin"); + } + if (!Number.isSafeInteger(provenance.runAttempt) || provenance.runAttempt <= 0) { + throw new Error("Linux native-core provenance runAttempt must be a positive integer"); + } + if (provenance.workflow !== PRODUCER_WORKFLOW) { + throw new Error( + `Linux native-core provenance must come from ${PRODUCER_WORKFLOW}` + ); + } + } else { + if (expectedRunId !== "" || provenance.runId !== 0 || provenance.runAttempt !== 0) { + throw new Error("Manual Linux native-core provenance must not claim a CI run"); + } + if (String(provenance.workflow || "") !== "manual") { + throw new Error("Manual Linux native-core provenance must declare workflow manual"); + } + } + if (provenance.manifestSha256 !== sha256File(manifestPath)) { + throw new Error("Linux native-core provenance manifest hash mismatch"); + } + if (provenance.checksumsSha256 !== sha256File(checksumsPath)) { + throw new Error("Linux native-core provenance checksums hash mismatch"); + } + const expectedInventory = CHECKSUM_FILE_NAMES.map((name) => ({ + path: name, + sha256: sha256File(path.join(artifactDir, name)), + size: fs.statSync(path.join(artifactDir, name)).size, + })); + if (JSON.stringify(provenance.artifacts) !== JSON.stringify(expectedInventory)) { + throw new Error("Linux native-core provenance artifact inventory mismatch"); + } + const build = provenance.build; + // linuxHostVerification / sourceRuntime 只出现在源码分发(-sp)那份 provenance 里, + // 所以它们是「可选的,但出现就必须与 manifest 一致」。 + if ( + !exactKeys( + build, + [ + "architecture", + "distributionMode", + "expiresAtUnix", + "id", + "integrityMode", + "issuedAtUnix", + "linuxBrokerSha256", + "linuxClientSha256", + "offlineBootstrapFeatureBits", + "offlineExportSealFormat", + "platform", + "readOnlyBuild", + "securityCheckpointCount", + "securityCheckpointSetId", + "securityCheckpointSetSha256", + "securityNoticeId", + "securityNoticeSha256", + ], + ["linuxHostVerification", "sourceRuntime"] + ) || + build.id !== manifest.buildId || + build.platform !== "linux" || + build.architecture !== "x64" || + build.distributionMode !== manifest.distributionMode || + build.integrityMode !== manifest.linuxIntegrityMode || + build.readOnlyBuild !== true || + build.issuedAtUnix !== manifest.buildIssuedAtUnix || + build.expiresAtUnix !== manifest.buildExpiresAtUnix || + build.linuxClientSha256 !== manifest.linuxClientSha256 || + build.linuxBrokerSha256 !== manifest.linuxBrokerSha256 || + build.offlineBootstrapFeatureBits !== manifest.offlineBootstrapFeatureBits || + build.offlineExportSealFormat !== manifest.offlineExportSealFormat || + build.securityCheckpointCount !== manifest.securityCheckpointCount || + build.securityCheckpointSetId !== manifest.securityCheckpointSetId || + build.securityCheckpointSetSha256 !== manifest.securityCheckpointSetSha256 || + build.securityNoticeId !== manifest.securityNoticeId || + build.securityNoticeSha256 !== manifest.securityNoticeSha256 + ) { + throw new Error("Linux native-core provenance build record does not match the manifest"); + } + if ( + (Object.prototype.hasOwnProperty.call(build, "linuxHostVerification") && + build.linuxHostVerification !== manifest.linuxHostVerification) || + (Object.prototype.hasOwnProperty.call(build, "sourceRuntime") && + build.sourceRuntime !== manifest.sourceRuntime) + ) { + throw new Error("Linux native-core provenance build record does not match the manifest"); + } + + const pinErrors = linuxContentPinErrors({ directory: artifactDir, manifest }); + if (pinErrors.length > 0) { + throw new Error(`Refusing Linux native-core artifact: ${pinErrors.join("; ")}`); + } + const clientElf = inspectElf(path.join(artifactDir, CLIENT_NAME)); + const brokerElf = inspectElf(path.join(artifactDir, BROKER_NAME)); + if (!clientElf.isSharedObject) { + throw new Error("Linux native client must be an x86-64 ELF shared object"); + } + if (!brokerElf.isExecutable) { + throw new Error("Linux native broker must be an x86-64 ELF executable"); + } + + return { + artifactDir, + manifest, + provenance, + repository, + sourceRevision, + buildId, + clientPin: manifest.linuxClientSha256, + brokerPin: manifest.linuxBrokerSha256, + names: [...RUNTIME_FILE_NAMES], + required: true, + }; +} + +module.exports = { + ARTIFACT_FILE_NAMES, + BROKER_NAME, + CHECKSUM_FILE_NAMES, + CLIENT_NAME, + HOST_VERIFICATION, + INTEGRITY_MODE, + MANIFEST_NAME, + PEER_VERIFICATION, + PRODUCER_WORKFLOW, + RUNTIME_FILE_NAMES, + inspectElf, + linuxContentPinErrors, + linuxNativeManifestErrors, + resolveLinuxNativeCoreArtifacts, +}; diff --git a/desktop/scripts/native-core-before-pack.cjs b/desktop/scripts/native-core-before-pack.cjs index 0137f44e..f728f0a8 100644 --- a/desktop/scripts/native-core-before-pack.cjs +++ b/desktop/scripts/native-core-before-pack.cjs @@ -9,6 +9,9 @@ const { const { assertWindowsNativeAsrCapability, } = require("../src/windows-native-asr-capability.cjs"); +const { + linuxContentPinErrors, +} = require("./linux-native-core-packaging.cjs"); const desktopRoot = path.resolve(__dirname, ".."); const LEGACY_WCDB_PATHS = [ @@ -185,6 +188,13 @@ function validatePackagedBackend({ if (platform === "win32") { assertWindowsNativeAsrCapability({ nativeDir, manifest }); } + if (platform === "linux") { + // 打包后再验一次「内容哈希 pin」:这是 Linux 唯一的产物身份,必须逐字节站得住。 + const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest }); + if (pinErrors.length > 0) { + throw new Error(`Packaged Linux native core failed content verification: ${pinErrors.join("; ")}`); + } + } return { backendDir, manifest, nativeDir, platform }; } diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index 9f6983f2..61e0c99d 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -1759,15 +1759,17 @@ function checkForUpdatesOnStartup() { } function getTrayIconPath() { - if (process.platform === "darwin") { + // Linux 的 nativeImage 解不了 .ico(那是 Windows 的容器),托盘只能吃 PNG, + // 否则 createTray 直接报 Failed to load image。macOS / Linux 共用同一张 icon.png。 + if (process.platform === "darwin" || process.platform === "linux") { const packaged = path.join(process.resourcesPath, "icon.png"); try { if (app.isPackaged && fs.existsSync(packaged)) return packaged; } catch {} - const devMac = path.resolve(__dirname, "..", "src", "icon.png"); + const devPng = path.resolve(__dirname, "..", "src", "icon.png"); try { - if (fs.existsSync(devMac)) return devMac; + if (fs.existsSync(devPng)) return devPng; } catch {} } diff --git a/desktop/src/native-core-runtime.cjs b/desktop/src/native-core-runtime.cjs index b0f88179..e00b1551 100644 --- a/desktop/src/native-core-runtime.cjs +++ b/desktop/src/native-core-runtime.cjs @@ -15,6 +15,44 @@ const NATIVE_CORE_MODES = new Set(["required"]); const NATIVE_CORE_SECURITY_NOTICE_ID = "WCE-AUTOMATED-ANALYSIS-NOTICE-V2"; const NATIVE_CORE_SECURITY_CHECKPOINT_SET_ID = "WCE-AI-CHECKPOINT-SET-V3"; const NATIVE_CORE_SECURITY_CHECKPOINT_COUNT = 7; +const ZERO_SHA256_HEX = "0".repeat(64); +const LINUX_MANIFEST_FIELDS = [ + "linuxIntegrityMode", + "linuxClientSha256", + "linuxBrokerSha256", + "linuxPeerVerification", + "linuxHostVerification", +]; +// Linux 清单是纯内容哈希身份:不得夹带任何代码签名身份字段,与 native_core_client +// 的字段隔离约束一致(在那里由 NativeCoreProtocolError 拒绝)。 +const CODE_SIGNING_IDENTITY_FIELDS = [ + "windowsSignerTrustMode", + "windowsPrivatePkiLeafRevocation", + "windowsClientSignerSha256", + "windowsBrokerSignerSha256", + "windowsPrivateRootSha256", + "windowsHostVerification", + "macosSigningMode", + "macosSignerTrustMode", + "macosPrivatePkiLeafRevocation", + "macosClientSigningIdentifier", + "macosBrokerSigningIdentifier", + "macosHostSigningIdentifier", + "macosClientSignerSha256", + "macosBrokerSignerSha256", + "macosHostSignerSha256", + "macosPrivateRootSha256", + "macosHostVerification", +]; +// Linux 的发布形态(schema v4)没有代码签名:身份 = 两组内容哈希 pin + 直接父进程的 +// 宿主校验。发布工作流发的就是这一份受限 source-public 产物,所以冻结应用必须消费它 +// ——与 Windows(schema v2)同一原则。macOS(schema v3)走真正的签名 production, +// 冻结态只认 production。 +const PACKAGED_SOURCE_PUBLIC_SCHEMAS = new Set([2, 4]); + +function hasOwnField(value, name) { + return Object.prototype.hasOwnProperty.call(value || {}, name); +} function isNonZeroSha256(value) { const text = String(value || ""); @@ -28,6 +66,9 @@ function nativeCoreArtifactNames(platform = process.platform) { if (platform === "darwin") { return ["libwechatdb_client.dylib", "wechatdb_broker", NATIVE_CORE_MANIFEST]; } + if (platform === "linux") { + return ["libwechatdb_client.so", "wechatdb_broker", NATIVE_CORE_MANIFEST]; + } return []; } @@ -56,14 +97,53 @@ function hasCompleteNativeCore(nativeDir, platform = process.platform, fsImpl = } function hasValidManifestIdentity(manifest) { + const identityMatches = + (manifest?.schemaVersion === 2 && !hasOwnField(manifest, "platform")) || + (manifest?.schemaVersion === 3 && manifest?.platform === "macos") || + (manifest?.schemaVersion === 4 && manifest?.platform === "linux"); + if (!identityMatches) return false; + if (typeof manifest.buildId !== "string" || !BUILD_ID_PATTERN.test(manifest.buildId)) { + return false; + } + // Linux 的内容哈希身份字段属于 Linux 清单专有:schema v2/v3 不得夹带, + // schema v4 必须完整声明且不得混入签名身份字段。与 native_core_client 的字段 + // 隔离约束一致,避免出现「桌面放行、后端拒绝」的半可用状态。 + const declaredLinuxFields = LINUX_MANIFEST_FIELDS.filter((name) => + hasOwnField(manifest, name) + ).length; + if (manifest.schemaVersion === 4) { + return ( + declaredLinuxFields === LINUX_MANIFEST_FIELDS.length && + !CODE_SIGNING_IDENTITY_FIELDS.some((name) => hasOwnField(manifest, name)) + ); + } + return declaredLinuxFields === 0; +} + +function hasLinuxContentHashIdentity(manifest) { + const clientPin = String(manifest?.linuxClientSha256 || "").toLowerCase(); + const brokerPin = String(manifest?.linuxBrokerSha256 || "").toLowerCase(); return ( - ((manifest?.schemaVersion === 2 && !Object.prototype.hasOwnProperty.call(manifest, "platform")) || - (manifest?.schemaVersion === 3 && manifest?.platform === "macos")) && - typeof manifest?.buildId === "string" && - BUILD_ID_PATTERN.test(manifest.buildId) + manifest?.platform === "linux" && + manifest.linuxIntegrityMode === "content-hash-pin" && + manifest.linuxPeerVerification === "same-user-peer-credentials" && + isNonZeroSha256(clientPin) && + isNonZeroSha256(brokerPin) && + clientPin !== brokerPin ); } +// 宿主校验强度必须与 sourceRuntime 自洽(与 native_core_client 的授权矩阵同一条规则): +// 源码分发只认「直接父进程」,其余情况用「内容哈希 pin」;development 构建不带 +// sourceRuntime,所以这里只覆盖两种签发态。 +function hasLinuxHostVerificationPairing(manifest) { + const expected = + manifest?.sourceRuntime === true + ? "same-user-direct-parent" + : "content-hash-pin"; + return manifest?.linuxHostVerification === expected; +} + function hasActiveProductionBuildWindow( manifest, { nowUnix = Math.floor(Date.now() / 1000) } = {} @@ -156,6 +236,9 @@ function isProductionNativeCoreManifestBase(manifest, options = {}) { new Set(pins.map((value) => String(value).toLowerCase())).size === 4 ); } + if (manifest.schemaVersion === 4) { + return hasLinuxContentHashIdentity(manifest) && hasLinuxHostVerificationPairing(manifest); + } return ( isNonZeroSha256(manifest.windowsClientSignerSha256) && isNonZeroSha256(manifest.windowsBrokerSignerSha256) && @@ -195,6 +278,9 @@ function isSourcePublicNativeCoreManifest(manifest, options = {}) { if (manifest.schemaVersion === 3) { return manifest.macosHostVerification === "same-user-direct-parent"; } + if (manifest.schemaVersion === 4) { + return manifest.linuxHostVerification === "same-user-direct-parent"; + } return ( manifest.schemaVersion === 2 && manifest.windowsHostVerification === "same-user-direct-parent" @@ -240,7 +326,21 @@ function isDevelopmentNativeCoreManifest(manifest) { hasExpectedLeafRevocation(manifest) && identifiers.every((value) => /^[A-Za-z0-9.-]+$/.test(String(value || ""))) && new Set(identifiers).size === 3 && - pins.every((value) => String(value || "") === "0".repeat(64)) + pins.every((value) => String(value || "") === ZERO_SHA256_HEX) + ); + } + if (manifest.schemaVersion === 4) { + // dev-local 的 Linux 构建不携带任何内容哈希身份,且不声明 sourceRuntime。 + return ( + manifest.platform === "linux" && + manifest.linuxIntegrityMode === "development" && + manifest.linuxPeerVerification === "same-user-peer-credentials" && + manifest.sourceRuntime !== true && + new Set(["content-hash-pin", "same-user-direct-parent"]).has( + manifest.linuxHostVerification + ) && + String(manifest.linuxClientSha256 || "") === ZERO_SHA256_HEX && + String(manifest.linuxBrokerSha256 || "") === ZERO_SHA256_HEX ); } return manifest.windowsSignerTrustMode === "public" && hasExpectedLeafRevocation(manifest); @@ -249,7 +349,8 @@ function isDevelopmentNativeCoreManifest(manifest) { function manifestMatchesPlatform(manifest, platform) { return ( (platform === "win32" && manifest?.schemaVersion === 2) || - (platform === "darwin" && manifest?.schemaVersion === 3 && manifest?.platform === "macos") + (platform === "darwin" && manifest?.schemaVersion === 3 && manifest?.platform === "macos") || + (platform === "linux" && manifest?.schemaVersion === 4 && manifest?.platform === "linux") ); } @@ -272,7 +373,8 @@ function resolveNativeCoreRuntimePolicy({ const platformMatch = complete && manifestMatchesPlatform(manifest, platform); const production = platformMatch && ( isProductionNativeCoreManifest(manifest, { nowUnix }) || - (isPackaged && manifest?.schemaVersion === 2 && + (isPackaged && + PACKAGED_SOURCE_PUBLIC_SCHEMAS.has(manifest?.schemaVersion) && isSourcePublicNativeCoreManifest(manifest, { nowUnix })) ); const sourcePublic = @@ -299,7 +401,15 @@ function resolveNativeCoreRuntimePolicy({ "Source WeChatDataAnalysis on macOS requires the exact restricted source-public wechatdb native core" ); } - if (!isPackaged && platform !== "darwin" && !sourcePublic && !development) { + // Linux 与 macOS 同一条规则:源码态只接受受限 source-public 产物(发布工作流发的就是 + // 这一份)。后端 native_core_client 在 Linux 上同样只授权 source-public,两边必须一致, + // 否则出现「桌面放行、后端拒绝」的半可用状态。 + if (!isPackaged && platform === "linux" && !sourcePublic) { + throw new Error( + "Source WeChatDataAnalysis on Linux requires the exact restricted source-public wechatdb native core" + ); + } + if (!isPackaged && platform === "win32" && !sourcePublic && !development) { throw new Error( "Source WeChatDataAnalysis on Windows requires the exact restricted source-public or dev-local wechatdb native core" ); diff --git a/desktop/tests/macos-xkey-signing.test.cjs b/desktop/tests/macos-xkey-signing.test.cjs index c3220602..015212da 100644 --- a/desktop/tests/macos-xkey-signing.test.cjs +++ b/desktop/tests/macos-xkey-signing.test.cjs @@ -168,7 +168,8 @@ test("macOS private workflow verifies the pinned integrity Release before extrac /"macos-integrity":\s*\(\s*"macos-integrity-production\.yml",\s*"wce-integrity-macos-arm64-production"/ ); assert.match(rebuildRelease, /tag = f"\{component\}-\{build_id\}"/); - assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\.zip"/); + // 资产扩展名按组件区分:macOS/Windows 是 zip,Linux 是可复现 tar.gz。 + assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\{suffix\}"/); assert.match(rebuildRelease, /release = api\(f"releases\/tags\/\{tag\}"\)/); assert.match(rebuildRelease, /release\.get\("target_commitish"\) != revision/); assert.match(rebuildRelease, /releases\/assets\/\{asset\['id'\]\}/); diff --git a/desktop/tests/native-core-packaging.test.cjs b/desktop/tests/native-core-packaging.test.cjs index 082f64af..d7273dbf 100644 --- a/desktop/tests/native-core-packaging.test.cjs +++ b/desktop/tests/native-core-packaging.test.cjs @@ -1,5 +1,6 @@ const test = require("node:test"); const assert = require("node:assert/strict"); +const crypto = require("crypto"); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -19,6 +20,7 @@ const { WINDOWS_NATIVE_ASR_TARGET, } = require("../src/windows-native-asr-capability.cjs"); const { buildWindowsPeWithExports } = require("./pe-export-fixture.cjs"); +const { PRODUCER_WORKFLOW } = require("../scripts/linux-native-core-packaging.cjs"); const BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60; const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60; @@ -138,6 +140,155 @@ function quietLogger() { return { log() {}, warn() {} }; } +// ---- Linux(schema v4)固定件 ------------------------------------------------- +// Linux 没有代码签名,产物身份 = 内容哈希,所以固定件必须把哈希算对, +// 否则测的就不是「校验逻辑」而是「固定件写错了」。 +const LINUX_BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60; +const LINUX_REPOSITORY = "LifeArchiveProject/WCDB"; +const LINUX_SOURCE_REVISION = "a8f42de851a34365834e566bf587089af5df7c19"; +const LINUX_BUILD_ID = "linux-x64-release-2026.09.16"; + +function sha256Hex(buffer) { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +// 最小可用 ELF 头:测试只需要 64 位 / 小端 / x86-64 / 类型正确。 +function linuxElfBytes(type) { + const buffer = Buffer.alloc(64); + buffer.write("\x7fELF", 0, "latin1"); + buffer[4] = 2; + buffer[5] = 1; + buffer.writeUInt16LE(type, 16); + buffer.writeUInt16LE(0x3e, 18); + return buffer; +} + +function linuxManifest({ sourceRuntime = true, overrides = {} } = {}) { + return { + schemaVersion: 4, + platform: "linux", + distributionMode: "public", + buildId: LINUX_BUILD_ID, + buildIssuedAtUnix: LINUX_BUILD_ISSUED_AT_UNIX, + buildExpiresAtUnix: LINUX_BUILD_ISSUED_AT_UNIX + BUILD_LIFETIME_SECONDS, + developmentBuild: false, + offlineBootstrapFeatureBits: 3, + offlineExportSealFormat: "WES2", + codeSignatureEnforced: true, + rootPublicKeyCompiled: true, + testHooksEnabled: false, + stagingPinnedSignerTrust: false, + linuxIntegrityMode: "content-hash-pin", + linuxClientSha256: "", + linuxBrokerSha256: "", + linuxPeerVerification: "same-user-peer-credentials", + linuxHostVerification: sourceRuntime ? "same-user-direct-parent" : "content-hash-pin", + securityNoticeId: "WCE-AUTOMATED-ANALYSIS-NOTICE-V2", + securityNoticeSha256: "aa".repeat(32), + securityCheckpointSetId: "WCE-AI-CHECKPOINT-SET-V3", + securityCheckpointCount: 7, + securityCheckpointSetSha256: "bb".repeat(32), + ...(sourceRuntime ? { sourceRuntime: true } : {}), + ...overrides, + }; +} + +const LINUX_CHECKSUM_FILE_NAMES = [ + "Test-LinuxNativeProductionArtifact.py", + "libwechatdb_client.so", + "wechatdb_broker", + "wechatdb_native_build.json", +]; + +function writeLinuxArtifactSet( + root, + { sourceRuntime = true, manifestOverrides = {}, provenanceOverrides = {}, tamperClient = false } = {} +) { + fs.mkdirSync(root, { recursive: true }); + const clientName = "libwechatdb_client.so"; + const brokerName = "wechatdb_broker"; + const manifestName = "wechatdb_native_build.json"; + const clientBytes = linuxElfBytes(3); + const brokerBytes = linuxElfBytes(2); + + const manifest = linuxManifest({ sourceRuntime, overrides: manifestOverrides }); + manifest.linuxClientSha256 = sha256Hex(clientBytes); + manifest.linuxBrokerSha256 = sha256Hex(brokerBytes); + Object.assign(manifest, manifestOverrides); + + fs.writeFileSync(path.join(root, clientName), clientBytes); + fs.writeFileSync(path.join(root, brokerName), brokerBytes); + fs.writeFileSync(path.join(root, "Test-LinuxNativeProductionArtifact.py"), "# fixture\n"); + fs.writeFileSync(path.join(root, manifestName), JSON.stringify(manifest, null, 2)); + + const checksums = LINUX_CHECKSUM_FILE_NAMES.map( + (name) => `${sha256Hex(fs.readFileSync(path.join(root, name)))} ${name}` + ).join("\n") + "\n"; + fs.writeFileSync(path.join(root, "SHA256SUMS.txt"), checksums); + + const provenance = { + schemaVersion: 1, + artifactName: "wechatdb-native-linux-x64-source-public", + producer: "manual", + workflow: "manual", + repository: LINUX_REPOSITORY, + runId: 0, + runAttempt: 0, + sourceRevision: LINUX_SOURCE_REVISION, + build: { + architecture: "x64", + distributionMode: manifest.distributionMode, + expiresAtUnix: manifest.buildExpiresAtUnix, + id: manifest.buildId, + integrityMode: manifest.linuxIntegrityMode, + issuedAtUnix: manifest.buildIssuedAtUnix, + linuxBrokerSha256: manifest.linuxBrokerSha256, + linuxClientSha256: manifest.linuxClientSha256, + offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits, + offlineExportSealFormat: manifest.offlineExportSealFormat, + platform: "linux", + readOnlyBuild: true, + securityCheckpointCount: manifest.securityCheckpointCount, + securityCheckpointSetId: manifest.securityCheckpointSetId, + securityCheckpointSetSha256: manifest.securityCheckpointSetSha256, + securityNoticeId: manifest.securityNoticeId, + securityNoticeSha256: manifest.securityNoticeSha256, + ...(sourceRuntime + ? { linuxHostVerification: manifest.linuxHostVerification, sourceRuntime: true } + : {}), + ...(provenanceOverrides.build || {}), + }, + manifestSha256: sha256Hex(fs.readFileSync(path.join(root, manifestName))), + checksumsSha256: sha256Hex(fs.readFileSync(path.join(root, "SHA256SUMS.txt"))), + artifacts: LINUX_CHECKSUM_FILE_NAMES.map((name) => ({ + path: name, + sha256: sha256Hex(fs.readFileSync(path.join(root, name))), + size: fs.statSync(path.join(root, name)).size, + })), + }; + const { build: _ignoredBuild, ...provenanceTopLevel } = provenanceOverrides; + Object.assign(provenance, provenanceTopLevel); + fs.writeFileSync(path.join(root, "provenance.json"), JSON.stringify(provenance, null, 2)); + + if (tamperClient) { + // 密封之后再改字节:SHA256SUMS / provenance 仍然声称原始哈希。 + const bytes = Buffer.from(fs.readFileSync(path.join(root, clientName))); + bytes[40] ^= 0xff; + fs.writeFileSync(path.join(root, clientName), bytes); + } + return { manifest, provenance }; +} + +function linuxEnv(artifactDir, overrides = {}) { + return { + WCE_NATIVE_CORE_ARTIFACT_DIR: artifactDir, + WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: LINUX_REPOSITORY, + WCE_NATIVE_CORE_SOURCE_REVISION: LINUX_SOURCE_REVISION, + WCE_NATIVE_CORE_BUILD_ID: LINUX_BUILD_ID, + ...overrides, + }; +} + test("artifact names are platform-specific and complete", () => { assert.deepEqual(nativeCoreArtifactNames("win32"), [ "wechatdb_client.dll", @@ -149,7 +300,11 @@ test("artifact names are platform-specific and complete", () => { "wechatdb_broker", "wechatdb_native_build.json", ]); - assert.deepEqual(nativeCoreArtifactNames("linux"), []); + assert.deepEqual(nativeCoreArtifactNames("linux"), [ + "libwechatdb_client.so", + "wechatdb_broker", + "wechatdb_native_build.json", + ]); }); test("runtime staging filters checked-out native and legacy WCDB files", () => { @@ -599,20 +754,200 @@ test("malformed and structurally invalid manifests fail even with a development ); assert.throws( () => resolveNativeCoreArtifacts({ env, platform: "win32" }), - /schemaVersion must equal 2 or 3; buildId must be a non-empty string/ + /schemaVersion must equal 2, 3 or 4; buildId must be a non-empty string/ ); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); -test("unsupported platforms stay optional but fail closed when configured", () => { - const optional = resolveNativeCoreArtifacts({ env: {}, platform: "linux" }); - assert.equal(optional.artifactDir, null); +test("Linux native core is a required closed artifact set", () => { assert.throws( - () => resolveNativeCoreArtifacts({ env: { WCE_NATIVE_CORE_REQUIRED: "yes" }, platform: "linux" }), - /unsupported on platform: linux/ + () => resolveNativeCoreArtifacts({ env: {}, platform: "linux" }), + /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/ ); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: { WCE_NATIVE_CORE_REQUIRED: "yes" }, + platform: "linux", + }), + /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/ + ); +}); + +test("Linux source-public and production profiles both resolve from sealed artifacts", () => { + const root = makeTempDir(); + try { + for (const sourceRuntime of [true, false]) { + const artifactDir = path.join(root, sourceRuntime ? "sp" : "prod"); + writeLinuxArtifactSet(artifactDir, { sourceRuntime }); + const resolved = resolveNativeCoreArtifacts({ + env: linuxEnv(artifactDir), + platform: "linux", + }); + assert.equal(resolved.required, true); + assert.equal(resolved.allowDevelopment, false); + assert.deepEqual(resolved.names, [ + "libwechatdb_client.so", + "wechatdb_broker", + "wechatdb_native_build.json", + ]); + assert.equal( + resolved.manifest.linuxHostVerification, + sourceRuntime ? "same-user-direct-parent" : "content-hash-pin" + ); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux content-hash pins reject any post-seal tampering", () => { + const root = makeTempDir(); + try { + const artifactDir = path.join(root, "tampered"); + writeLinuxArtifactSet(artifactDir, { tamperClient: true }); + assert.throws( + () => resolveNativeCoreArtifacts({ env: linuxEnv(artifactDir), platform: "linux" }), + /checksum set does not match the artifact allowlist/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux protected pins fail closed on build id, revision and repository drift", () => { + const root = makeTempDir(); + try { + const artifactDir = path.join(root, "pinned"); + writeLinuxArtifactSet(artifactDir); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_BUILD_ID: "linux-x64-other-2026.09.16" }), + platform: "linux", + }), + /does not match the protected build id pin/ + ); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_SOURCE_REVISION: "0".repeat(40) }), + platform: "linux", + }), + /provenance revision does not match the protected pin/ + ); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: "evil/fork" }), + platform: "linux", + }), + /provenance repository does not match the protected pin/ + ); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "123" }), + platform: "linux", + }), + /must not claim a CI run/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux artifacts are only accepted from the reviewed producer workflow", () => { + const root = makeTempDir(); + try { + // A workflow-produced artifact has to come from the reviewed producer, not + // from any workflow that happens to know the pin format. + const rogueDir = path.join(root, "rogue"); + writeLinuxArtifactSet(rogueDir, { + provenanceOverrides: { + producer: "github-actions", + workflow: ".github/workflows/rogue-production.yml", + runId: 4242, + runAttempt: 1, + }, + }); + assert.throws( + () => + resolveNativeCoreArtifacts({ + env: linuxEnv(rogueDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }), + platform: "linux", + }), + /must come from \.github\/workflows\/linux-native-production\.yml/ + ); + + const reviewedDir = path.join(root, "reviewed"); + writeLinuxArtifactSet(reviewedDir, { + provenanceOverrides: { + producer: "github-actions", + workflow: PRODUCER_WORKFLOW, + runId: 4242, + runAttempt: 1, + }, + }); + const resolved = resolveNativeCoreArtifacts({ + env: linuxEnv(reviewedDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }), + platform: "linux", + }); + assert.equal(resolved.provenance.runId, 4242); + assert.equal(resolved.provenance.workflow, PRODUCER_WORKFLOW); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("Linux profile self-consistency and binary identity are enforced", () => { + const root = makeTempDir(); + try { + // 声明 sourceRuntime 却用 production 强度的 host 校验:必须拒绝。 + const inconsistent = path.join(root, "inconsistent"); + writeLinuxArtifactSet(inconsistent, { + manifestOverrides: { linuxHostVerification: "content-hash-pin" }, + }); + assert.throws( + () => resolveNativeCoreArtifacts({ env: linuxEnv(inconsistent), platform: "linux" }), + /linuxHostVerification must equal same-user-direct-parent/ + ); + + // 客户端不是 ELF:必须拒绝。 + const notElf = path.join(root, "not-elf"); + writeLinuxArtifactSet(notElf); + fs.writeFileSync(path.join(notElf, "libwechatdb_client.so"), "not an elf at all"); + assert.throws( + () => resolveNativeCoreArtifacts({ env: linuxEnv(notElf), platform: "linux" }), + /checksum set does not match the artifact allowlist/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("packaged Linux native core is re-hashed before packing", () => { + const root = makeTempDir(); + try { + const { validatePackagedBackend } = require("../scripts/native-core-before-pack.cjs"); + const nativeDir = path.join(root, "native"); + writeLinuxArtifactSet(nativeDir); + fs.writeFileSync(path.join(root, "wechat-backend"), "# packaged backend\n"); + + const validated = validatePackagedBackend({ backendDir: root, platform: "linux" }); + assert.equal(validated.platform, "linux"); + + // 打包后再被替换一个字节 → 内容哈希必须拦住。 + fs.writeFileSync(path.join(nativeDir, "wechatdb_broker"), linuxElfBytes(3)); + assert.throws( + () => validatePackagedBackend({ backendDir: root, platform: "linux" }), + /Packaged Linux native core failed content verification: content hash mismatch for wechatdb_broker/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); test("boolean packaging flags reject ambiguous values", () => { diff --git a/desktop/tests/native-core-runtime.test.cjs b/desktop/tests/native-core-runtime.test.cjs index 4d75f9c2..47dd2c76 100644 --- a/desktop/tests/native-core-runtime.test.cjs +++ b/desktop/tests/native-core-runtime.test.cjs @@ -8,6 +8,7 @@ const { ENV_NATIVE_CORE_ALLOW_DEVELOPMENT_BUILD, ENV_NATIVE_CORE_MODE, applyNativeCoreRuntimePolicy, + isDevelopmentNativeCoreManifest, isProductionNativeCoreManifest, isSourcePublicNativeCoreManifest, nativeCoreArtifactNames, @@ -127,6 +128,57 @@ const MACOS_SOURCE_PUBLIC_MANIFEST = Object.freeze({ macosHostVerification: "same-user-direct-parent", }); +// Linux(schema v4)没有代码签名:身份 = 两组内容哈希 pin + 宿主校验策略。 +const ZERO_SHA256 = "00".repeat(32); +const LINUX_PRODUCTION_MANIFEST = Object.freeze({ + schemaVersion: 4, + platform: "linux", + distributionMode: "public", + buildId: "linux-x64-20260915-abcd1234", + buildIssuedAtUnix: BUILD_ISSUED_AT_UNIX, + buildExpiresAtUnix: BUILD_ISSUED_AT_UNIX + BUILD_LIFETIME_SECONDS, + developmentBuild: false, + offlineBootstrapFeatureBits: 3, + offlineExportSealFormat: "WES2", + codeSignatureEnforced: true, + rootPublicKeyCompiled: true, + testHooksEnabled: false, + stagingPinnedSignerTrust: false, + linuxIntegrityMode: "content-hash-pin", + linuxClientSha256: "11".repeat(32), + linuxBrokerSha256: "22".repeat(32), + linuxPeerVerification: "same-user-peer-credentials", + linuxHostVerification: "content-hash-pin", + securityNoticeId: "WCE-AUTOMATED-ANALYSIS-NOTICE-V2", + securityNoticeSha256: "aa".repeat(32), + securityCheckpointSetId: "WCE-AI-CHECKPOINT-SET-V3", + securityCheckpointCount: 7, + securityCheckpointSetSha256: "bb".repeat(32), +}); + +// 发布工作流(linux-private-build.yml)只收这一份受限 source-public 产物。 +const LINUX_SOURCE_PUBLIC_MANIFEST = Object.freeze({ + ...LINUX_PRODUCTION_MANIFEST, + sourceRuntime: true, + linuxHostVerification: "same-user-direct-parent", +}); + +const LINUX_DEVELOPMENT_MANIFEST = Object.freeze({ + ...LINUX_PRODUCTION_MANIFEST, + buildId: "dev-local", + buildIssuedAtUnix: 0, + buildExpiresAtUnix: 0, + developmentBuild: true, + offlineBootstrapFeatureBits: 0, + offlineExportSealFormat: "none", + codeSignatureEnforced: false, + rootPublicKeyCompiled: false, + testHooksEnabled: true, + linuxIntegrityMode: "development", + linuxClientSha256: ZERO_SHA256, + linuxBrokerSha256: ZERO_SHA256, +}); + function makeArtifacts(platform, manifest, { omit = [] } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-native-runtime-")); for (const name of nativeCoreArtifactNames(platform)) { @@ -423,6 +475,163 @@ test("Windows source-public artifacts are accepted for the read-only packaged ru } }); +test("Linux source-public artifacts are accepted for the packaged runtime", () => { + // 回归:Linux 的发布形态就是 source-public,冻结应用消费不了它就等于发不了版。 + const sourceDir = makeArtifacts("linux", LINUX_SOURCE_PUBLIC_MANIFEST); + const productionDir = makeArtifacts("linux", LINUX_PRODUCTION_MANIFEST); + try { + assert.equal(isSourcePublicNativeCoreManifest(LINUX_SOURCE_PUBLIC_MANIFEST), true); + assert.equal(isProductionNativeCoreManifest(LINUX_SOURCE_PUBLIC_MANIFEST), false); + assert.equal(isProductionNativeCoreManifest(LINUX_PRODUCTION_MANIFEST), true); + + const sourcePolicy = applyNativeCoreRuntimePolicy({}, { + isPackaged: false, + nativeDir: sourceDir, + platform: "linux", + }); + assert.equal(sourcePolicy.artifactState, "source-public"); + assert.equal(sourcePolicy.reason, "source-public-artifacts"); + assert.equal(sourcePolicy.enableDevelopmentOverride, false); + + const packagedSource = resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: sourceDir, + platform: "linux", + }); + assert.equal(packagedSource.artifactState, "production"); + assert.equal(packagedSource.mode, "required"); + + const packagedProduction = resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: productionDir, + platform: "linux", + }); + assert.equal(packagedProduction.artifactState, "production"); + } finally { + cleanup(sourceDir); + cleanup(productionDir); + } +}); + +test("source and packaged Linux artifact profiles cannot be swapped", () => { + const productionDir = makeArtifacts("linux", LINUX_PRODUCTION_MANIFEST); + const developmentDir = makeArtifacts("linux", LINUX_DEVELOPMENT_MANIFEST); + try { + // 源码态只接受受限 source-public(与 macOS 同一原则,也与 native_core_client 一致)。 + assert.throws( + () => applyNativeCoreRuntimePolicy({}, { + isPackaged: false, + nativeDir: productionDir, + platform: "linux", + }), + /requires the exact restricted source-public/ + ); + assert.throws( + () => resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: developmentDir, + platform: "linux", + }), + /requires an approved production/ + ); + assert.equal(isDevelopmentNativeCoreManifest(LINUX_DEVELOPMENT_MANIFEST), true); + } finally { + cleanup(productionDir); + cleanup(developmentDir); + } +}); + +test("Linux content-hash identity substitution fails closed", () => { + const rejected = [ + { ...LINUX_PRODUCTION_MANIFEST, linuxClientSha256: ZERO_SHA256 }, + { ...LINUX_PRODUCTION_MANIFEST, linuxBrokerSha256: ZERO_SHA256 }, + { + ...LINUX_PRODUCTION_MANIFEST, + linuxBrokerSha256: LINUX_PRODUCTION_MANIFEST.linuxClientSha256, + }, + { ...LINUX_PRODUCTION_MANIFEST, linuxIntegrityMode: "development" }, + { ...LINUX_PRODUCTION_MANIFEST, linuxPeerVerification: "same-user-any-person" }, + // 宿主校验强度必须与 sourceRuntime 配对。 + { ...LINUX_SOURCE_PUBLIC_MANIFEST, sourceRuntime: false }, + { + ...LINUX_SOURCE_PUBLIC_MANIFEST, + linuxHostVerification: "content-hash-pin", + }, + { ...LINUX_PRODUCTION_MANIFEST, sourceRuntime: true }, + // Linux 清单不得夹带代码签名身份字段(后端会直接拒绝)。 + { ...LINUX_PRODUCTION_MANIFEST, windowsClientSignerSha256: "11".repeat(32) }, + { ...LINUX_PRODUCTION_MANIFEST, macosSignerTrustMode: "private-pki" }, + // v2/v3 不得夹带 Linux 内容哈希字段。 + { ...PRODUCTION_MANIFEST, linuxClientSha256: "11".repeat(32) }, + { ...MACOS_PRODUCTION_MANIFEST, linuxPeerVerification: "same-user-peer-credentials" }, + ]; + for (const manifest of rejected) { + assert.equal(isProductionNativeCoreManifest(manifest), false); + assert.equal(isSourcePublicNativeCoreManifest(manifest), false); + const nativeDir = makeArtifacts("linux", manifest); + try { + assert.throws( + () => resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir, + platform: "linux", + }), + /requires an approved production/ + ); + } finally { + cleanup(nativeDir); + } + } +}); + +test("Linux manifest schemas cannot cross platform boundaries", () => { + const linuxWithWindowsManifest = makeArtifacts("linux", PRODUCTION_MANIFEST); + const windowsWithLinuxManifest = makeArtifacts("win32", LINUX_SOURCE_PUBLIC_MANIFEST); + const macWithLinuxManifest = makeArtifacts("darwin", LINUX_SOURCE_PUBLIC_MANIFEST); + try { + assert.throws( + () => resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: linuxWithWindowsManifest, + platform: "linux", + }), + /requires an approved production/ + ); + assert.throws( + () => resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: windowsWithLinuxManifest, + platform: "win32", + }), + /requires an approved production/ + ); + assert.throws( + () => resolveNativeCoreRuntimePolicy({ + env: {}, + isPackaged: true, + nativeDir: macWithLinuxManifest, + platform: "darwin", + }), + /requires an approved production/ + ); + assert.deepEqual(nativeCoreArtifactNames("linux"), [ + "libwechatdb_client.so", + "wechatdb_broker", + "wechatdb_native_build.json", + ]); + } finally { + cleanup(linuxWithWindowsManifest); + cleanup(windowsWithLinuxManifest); + cleanup(macWithLinuxManifest); + } +}); + test("every production manifest gate fails closed", () => { const missingStagingTrust = { ...PRODUCTION_MANIFEST }; delete missingStagingTrust.stagingPinnedSignerTrust; diff --git a/desktop/tests/package-config.test.cjs b/desktop/tests/package-config.test.cjs index 43eed0f8..c319fd4c 100644 --- a/desktop/tests/package-config.test.cjs +++ b/desktop/tests/package-config.test.cjs @@ -10,6 +10,41 @@ const desktopRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(desktopRoot, ".."); const packageJson = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")); +// Every remote action a release workflow may reference, pinned to an approved +// commit. Both the tag-triggered release workflow and the platform build +// workflows it calls are checked against this single list. +const APPROVED_ACTIONS = new Map([ + ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"], + ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"], + ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"], + ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"], + ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"], + ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"], + ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"], + ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"], +]); + +function assertRemoteActionsPinned(workflow) { + const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)] + .map((match) => match[1]) + .filter((use) => !use.startsWith("./")); + assert.ok(remoteUses.length > 0); + for (const use of remoteUses) { + const separator = use.lastIndexOf("@"); + const action = use.slice(0, separator); + const revision = use.slice(separator + 1); + assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`); + assert.equal(revision, APPROVED_ACTIONS.get(action), `${action} uses an unapproved commit`); + } + return remoteUses; +} + +function readWorkflow(name) { + return fs + .readFileSync(path.join(repoRoot, ".github", "workflows", name), "utf8") + .replace(/\r\n/g, "\n"); +} + test("desktop package excludes the retired Koffi and WCDB sidecar runtime", () => { const nodeModulesRule = packageJson.build.files.find( (item) => item && typeof item === "object" && item.from === "node_modules" @@ -189,9 +224,15 @@ test("Windows release uses protected cloud private-PKI signing and installer smo /"windows-native":\s*\(\s*"windows-native-production\.yml",\s*"wechatdb-native-windows-x64-source-public"/ ); assert.match(rebuildRelease, /tag = f"\{component\}-\{build_id\}"/); - assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\.zip"/); + assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\{suffix\}"/); assert.match(rebuildRelease, /release\.get\("target_commitish"\) != revision/); assert.match(rebuildRelease, /expected_digest = asset\.get\("digest"\)/); + // Linux 走同一条自动重建路线,只是资产换成可复现的 tar.gz。 + assert.match( + rebuildRelease, + /"linux-native":\s*\(\s*"linux-native-production\.yml",\s*"wechatdb-native-linux-x64-source-public"/ + ); + assert.match(rebuildRelease, /WCE_NATIVE_CORE_CLIENT_SHA256|f"\{prefix\}_CLIENT_SHA256"/); assert.match(windowsJob, /WCE_WINDOWS_PRIVATE_ROOT_CERT_PATH/); assert.match(windowsJob, /WCE_WINDOWS_PRIVATE_ROOT_SHA256/); assert.match(windowsJob, /WCE_RFC3161_TIMESTAMP_URL/); @@ -295,32 +336,7 @@ test("Windows release uses protected cloud private-PKI signing and installer smo }); test("release workflow pins every remote action to an approved commit", () => { - const workflow = fs - .readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8") - .replace(/\r\n/g, "\n"); - const approved = new Map([ - ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"], - ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"], - ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"], - ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"], - ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"], - ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"], - ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"], - ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"], - ["H3CoF6/qq-notify-action", "50d180981e7c7b8552a3331b981e3f8cfcf40c44"], - ]); - const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)] - .map((match) => match[1]) - .filter((use) => !use.startsWith("./")); - - assert.ok(remoteUses.length > 0); - for (const use of remoteUses) { - const separator = use.lastIndexOf("@"); - const action = use.slice(0, separator); - const revision = use.slice(separator + 1); - assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`); - assert.equal(revision, approved.get(action), `${action} uses an unapproved commit`); - } + const remoteUses = assertRemoteActionsPinned(readWorkflow("release.yml")); for (const action of [ "actions/checkout", "actions/setup-node", @@ -329,7 +345,107 @@ test("release workflow pins every remote action to an approved commit", () => { "actions/upload-artifact", "softprops/action-gh-release", ]) { - assert.ok(remoteUses.includes(`${action}@${approved.get(action)}`), `${action} is missing`); + assert.ok( + remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`), + `${action} is missing` + ); + } +}); + +test("the tag release requires and publishes the Linux x64 package", () => { + const workflow = readWorkflow("release.yml"); + const releaseJob = workflow.match( + /\n build-linux-x64:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/ + )?.[1] || ""; + assert.match(releaseJob, /uses:\s*\.\/\.github\/workflows\/linux-private-build\.yml/); + assert.match(releaseJob, /secrets:\s*inherit/); + + const publishJob = workflow.match( + /\n publish-release:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/ + )?.[1] || ""; + assert.match(publishJob, /- build-windows/); + assert.match(publishJob, /- build-macos-arm64/); + // Linux is a required platform: a missing native-core pin fails the release + // instead of silently publishing without it. + assert.match(publishJob, /- build-linux-x64/); +}); + +test("Linux release workflow rebuilds the native core and publishes the unrooted payload", () => { + const workflow = readWorkflow("linux-private-build.yml"); + const job = workflow.match( + /\n build-linux-x64:\n([\s\S]*?)$/ + )?.[1] || ""; + assert.ok(job, "build-linux-x64 job is missing"); + assert.match(workflow, /workflow_call:/); + assert.match(workflow, /workflow_dispatch:/); + assert.match(job, /runs-on:\s*ubuntu-22\.04/); + assert.match(job, /if:\s*github\.ref == 'refs\/heads\/main' \|\| startsWith\(github\.ref, 'refs\/tags\/v'\)/); + + // Linux 与 Windows / macOS 同一条自动重建路线:发版当下现产 source-public 原生核心, + // 所以消费工作流里不许再出现任何仓库变量 pin,也不再需要额外的读取 secret—— + // 只用发版已有的 WCE_NATIVE_CORE_PRODUCER_TOKEN。 + assert.doesNotMatch(job, /\$\{\{\s*vars\.WCE_LINUX_/); + assert.doesNotMatch(job, /WCE_LINUX_PRODUCER_READ_TOKEN/); + assert.match(job, /python3 tools\/rebuild_wcdb_release\.py/); + assert.match(job, /--component linux-native/); + assert.match(job, /secrets\.WCE_NATIVE_CORE_PRODUCER_TOKEN/); + assert.doesNotMatch(job, /WCE_INTEGRITY_ARTIFACT_DIR/); + + const order = [ + "Verify immutable source and release coordinates", + "Rebuild the Linux native core for this release", + "Validate the pinned native core against the production policy", + "Checkout the private integrity source at the producer revision", + "Build the Linux package", + "Verify the packaged Linux runtime", + "Prepare Linux release checksums and provenance", + "Upload Linux release files", + ]; + let previous = -1; + for (const step of order) { + const index = job.indexOf(step); + assert.ok(index >= 0, `${step} is missing`); + assert.ok(index > previous, `${step} is out of order`); + previous = index; + } + + // 重建脚本自己核对不可变 Release 资产摘要、revision 与 45 天窗口, + // 工作流不再有 Download / 回退到 Actions artifact 的分支。 + assert.doesNotMatch(job, /gh release download/); + assert.doesNotMatch(job, /gh run download/); + assert.match(job, /resolveLinuxNativeCoreArtifacts\(\{ platform: 'linux' \}\)/); + // integrity 源码按当次重建出来的 producer revision 取,不再依赖仓库变量。 + assert.match( + job, + /repos\/\$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY\/tarball\/\$WCE_NATIVE_CORE_SOURCE_REVISION/ + ); + assert.match(job, /native\/wce_integrity\/Cargo\.toml/); + assert.doesNotMatch(job, /cargo build/); + assert.match(job, /tests\/test_linux_db_key_flow\.py/); + assert.match(job, /tests\/test_linux_native_core_policy\.py/); + assert.doesNotMatch(job, /test_wcdb_realtime_native_core_required\.py/); + assert.doesNotMatch(job, /test_native_core_broker_lifecycle\.py/); + // 桌面门禁必须覆盖「启动后端」那一步的策略判定:曾经它只认 win32/darwin, + // 于是 Linux 包能出包、一启动就崩。 + assert.match(job, /tests\/native-core-runtime\.test\.cjs/); + assert.match(job, /resolveNativeCoreRuntimePolicy/); + assert.match(job, /WECHAT_TOOL_NATIVE_CORE_MODE/); + assert.match(job, /npm run dist:linux/); + assert.match(job, /differs from the reviewed native artifact/); + assert.match(job, /linuxContentPinErrors/); + assert.match(job, /SHA256SUMS-linux\.txt/); + assert.match(job, /release-provenance-linux\.json/); + assert.match(job, /desktop\/dist\/\*-linux-x86_64\.tar\.gz/); + assert.match(job, /name:\s*release-linux-x64/); +}); + +test("the Linux release workflow pins every remote action to an approved commit", () => { + const remoteUses = assertRemoteActionsPinned(readWorkflow("linux-private-build.yml")); + for (const action of ["actions/checkout", "actions/upload-artifact"]) { + assert.ok( + remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`), + `${action} is missing` + ); } }); @@ -643,13 +759,13 @@ test("macOS DMG cleanup preserves both detach failures", () => { ); }); -test("tag release reuses the protected macOS build and publishes both platforms", () => { +test("tag release reuses the protected platform builds and publishes every platform", () => { const workflow = fs .readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8") .replace(/\r\n/g, "\n"); const publishJob = workflow.split("\n publish-release:\n", 2)[1] || ""; - assert.match(workflow, /^name: Release \(Windows and macOS ARM64\)$/m); + assert.match(workflow, /^name: Release \(Windows, macOS ARM64 and Linux x64\)$/m); assert.match( workflow, /\n build-macos-arm64:\n\s+uses: \.\/\.github\/workflows\/macos-private-build\.yml\n\s+secrets: inherit/ @@ -704,3 +820,40 @@ test("frontend joins copied output paths using the native path style", async () assert.equal(joinNativePath("D:\\wechat\\output\\", "wxid_demo"), "D:\\wechat\\output\\wxid_demo"); assert.equal(joinNativePath("\\\\server\\share\\output", "wxid_demo"), "\\\\server\\share\\output\\wxid_demo"); }); + +test("Linux ships as an unpacked directory plus a checksum-verified install script", async () => { + // 刻意不做 AppImage / deb:Linux 的形态是 dist/linux-unpacked + install.sh。 + assert.deepEqual(packageJson.build.linux.target, ["dir"]); + assert.equal(packageJson.build.linux.executableName, "wechat-data-analysis"); + assert.equal(packageJson.build.linux.icon, "src/icon.png"); + assert.match(packageJson.scripts["dist:linux"], /electron-builder --linux dir --x64/); + assert.match(packageJson.scripts["dist:linux"], /build-linux-installer\.cjs/); + + const os = require("os"); + const { spawnSync } = require("child_process"); + const { buildLinuxInstaller } = require("../scripts/build-linux-installer.cjs"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-linux-installer-")); + try { + const payloadDir = path.join(root, "linux-unpacked"); + fs.mkdirSync(path.join(payloadDir, "resources"), { recursive: true }); + fs.writeFileSync(path.join(payloadDir, "wechat-data-analysis"), "#!/bin/sh\nexit 0\n"); + fs.chmodSync(path.join(payloadDir, "wechat-data-analysis"), 0o755); + + const result = buildLinuxInstaller({ payloadDir, outputDir: path.join(root, "dist") }); + assert.ok(fs.existsSync(result.archivePath)); + assert.ok(fs.existsSync(result.installerPath)); + assert.equal(result.sha256, crypto.createHash("sha256").update(fs.readFileSync(result.archivePath)).digest("hex")); + + const installer = fs.readFileSync(result.installerPath, "utf8"); + assert.equal(installer.includes("@@"), false, "installer must not keep template placeholders"); + assert.match(installer, new RegExp(result.sha256)); + assert.match(installer, /PAYLOAD_SHA256=/); + assert.match(installer, /--uninstall/); + assert.match(installer, /wechat-data-analysis\.desktop/); + + const syntax = spawnSync("sh", ["-n", result.installerPath], { encoding: "utf8" }); + assert.equal(syntax.status, 0, syntax.stderr); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/docs/linux-release.md b/docs/linux-release.md new file mode 100644 index 00000000..0d78ab87 --- /dev/null +++ b/docs/linux-release.md @@ -0,0 +1,101 @@ +# Linux 发布流程(x64) + +Linux 与 Windows / macOS 一起发在同一个 tag Release 里,并且是**必需平台**:原生组件重建 +失败时,`release.yml` 会直接失败,而不是静默少发一个平台。 + +Linux 与 Windows / macOS 走**同一条**原生组件路线:发版当下现产一份 source-public 原生核心, +所以不需要任何仓库变量 pin,也不需要额外 secret(只复用发版已有的 +`WCE_NATIVE_CORE_PRODUCER_TOKEN`)。45 天有效期由「每次发版重建」自然续上。 + +产物形态刻意不做 AppImage / deb:Linux 走「用户级、免 root 的 `tar.gz` + `install.sh`」。 + +## 涉及的三个工作流 + +| 工作流 | 位置 | 作用 | +| --- | --- | --- | +| `linux-native-production.yml` | **WCDB**(私藏 producer 仓) | 被 `rebuild_wcdb_release.py` dispatch:构建 + 自检 + 把原生核心发成不可变 Release 资产 | +| `tools/rebuild_wcdb_release.py` | 本仓 | 发版当下 dispatch producer,等它跑完,按 Release 摘要下载并核对 45 天窗口 | +| `linux-private-build.yml` | 本仓 | 可复用构建:重建原生核心 → 校验 → 编译 integrity → `dist:linux` → 打包校验 → 上传 | +| `release.yml` | 本仓 | `push tag v*` 触发;`build-linux-x64` 调用上面的可复用工作流,`publish-release` 汇总三个平台 | + +## 操作顺序 + +1. **配 producer**(WCDB 私藏仓,一次性): + + - 仓库级 variable `WCE_ROOT_PUBLIC_KEY_HEX`:128 hex,P-256 **公钥**(不是私钥), + 与 macOS / Windows environment 里那把一致。 + - 其余什么都不用配:Linux 没有代码签名,不需要任何私钥 / 证书 / 时间戳 secret + (Windows 的 PFX、macOS 的 P12 在 Linux 上都不存在)。 + +2. **发版**:推 tag `v*`。tag 必须在 `origin/main` 上。 + + 发版里 `build-linux-x64` 会自动 `tools/rebuild_wcdb_release.py --component linux-native`: + dispatch WCDB 的 `Linux native production`,等它产出一份带唯一 build id 的不可变 Release, + 核对 Release target / 资产摘要 / 45 天窗口后才继续打包。 + +3. **本仓需要的唯一配置**:仓库级 secret `WCE_NATIVE_CORE_PRODUCER_TOKEN` + (对 `2977094657/WCDB` 有 Actions read/write 与 Contents read)。Windows / macOS 发版 + 已经在用同一个 secret,Linux 直接复用,**不需要新增任何配置**。 + +## 为什么可以不做代码签名 + +Linux 没有 Authenticode / codesign 的等价物,所以身份改成**内容哈希**,方向是单向的: + +- broker 里编死了「随包 client 的哈希」; +- broker 自己的哈希由 manifest 声明、由安装方 pin。 + +`sha256(file) == pin(file)` 无解,所以「组件自带自身哈希」这种不可判定的方向被显式禁止: +`Test-LinuxNativeProductionArtifact.py` 与 `desktop/scripts/linux-native-core-packaging.cjs` +两头都断言了这一点。消费侧还会在打包后再哈希一次(`packaged ... differs from the reviewed +native artifact`),确保打包过程没有顺手重编。 + +导出完整性模块 `libwce_integrity.so` 由本仓在发布时用**一次性构建密钥**现编(语义等同于 +官方的 `-GenerateEphemeralSigningKey`):该密钥只用于导出物自身封签,权威封印是原生核心产出的 +WES2 sidecar。之所以不在 producer 侧预编,是因为 `wce_integrity` 会把 Nuxt 的 CSS 编进去, +必须和当次 UI 构建同源。 + +## 会踩的坑 + +- **45 天有效期**:manifest 固定 45 天窗口。因为每次发版都重建,安装包自带的组件始终是 + 当次构建;旧安装包到期后需要装新版本(与 Windows / macOS 同一行为)。 +- **构建 ID 不可复用**:producer 在发布前会检查 `linux-native-` 是否已存在,存在即拒绝。 +- **校验失败就是失败**:`build-linux-x64` 不设 `continue-on-error`,`publish-release.needs` 包含它, + 所以重建失败 / 摘要不符 / 哈希漂移都会让 release 停在半路而不是发出去。 +- **重跑要用新的 run attempt**:build id 由 WCDA 的 run id + attempt 派生,同一次发版重跑 + 会拿到新 id,不会撞上已发布的 tag。 +- **产物名不能重**:Linux 用 `SHA256SUMS-linux.txt` / `release-provenance-linux.json`, + 避免与 Windows 的 `SHA256SUMS.txt` / `release-provenance.json` 在 `merge-multiple` 下载时互相覆盖。 +- **producer 不占用 Actions artifact 配额**:Linux producer 只发不可变 Release 资产, + 没有 `upload-artifact` 步骤,所以 artifact 配额爆掉不会影响发版。 + +## 桌面运行时的 Linux 判定(已修,别再回退) + +`desktop/src/native-core-runtime.cjs` 现在完整支持 Linux 的 schema v4,规则和 +Windows / macOS 对齐: + +| 运行形态 | 接受的产物 | 说明 | +| --- | --- | --- | +| 打包(冻结) | production 或受限 source-public | 与 Windows 同一原则:发布工作流发的就是 source-public | +| 源码 checkout | 只接受受限 source-public | 与 macOS 同一原则(`dev-local` 不授权) | + +Linux 没有代码签名,身份 = `linuxClientSha256` / `linuxBrokerSha256` 两组内容哈希 +pin;`linuxHostVerification` 必须与 `sourceRuntime` 配对(源码分发 = 直接父进程, +其余 = 内容哈希 pin),且 Linux 清单不得夹带任何 Windows / macOS 的签名身份字段。 +这四条在**两侧**都要成立,缺一就会出现「桌面放行、后端拒绝」的半可用状态: + +- 桌面:`desktop/src/native-core-runtime.cjs` + `desktop/tests/native-core-runtime.test.cjs` +- 后端:`src/wechat_decrypt_tool/native_core_client.py` + `tests/test_linux_native_core_policy.py` + +两条都被 `build-linux-x64` 当门禁跑,所以「能出包」和「能用」之间不再有缝。 + +## 还没做的验证 + +- **没有 GUI 冒烟**:Windows 有 `smoke:win`、macOS 有 `smoke:mac`,Linux 侧只有 + 「打包产物上的原生核心策略判定」(`Verify the packaged Linux runtime` 步骤)加 + `install.sh` 的摘要校验,没有真的启动过界面。 +- **Ubuntu 24.04 的沙箱限制**:用户级安装没法给 `chrome-sandbox` 置 setuid root, + 而 24.04 起 AppArmor 会限制非特权 user namespace —— 真机验证时若起不来,优先查 + 这一条(需要 AppArmor profile 或 `--no-sandbox` 的取舍)。 +- **`dev-local` 在 Linux 上不授权**:本地自建开发核心(`WCE_DEVELOPMENT_BUILD=ON` + 产出的 `linuxIntegrityMode: development` 清单)不会被后端接受,本地联调需要用 + producer 产的 source-public 产物(与 macOS 现状一致)。 diff --git a/docs/release-native-build.md b/docs/release-native-build.md index 8a6830ec..5b4f4b2f 100644 --- a/docs/release-native-build.md +++ b/docs/release-native-build.md @@ -1,8 +1,8 @@ # Release native builds -Windows and macOS packaging rebuild their WCDB components from the current -`2977094657/WCDB` main revision. Each producer receives a unique build ID and -the current UTC time. The signed components and their manifests expire exactly +Windows, macOS and Linux packaging rebuild their WCDB components from the +current `2977094657/WCDB` main revision. Each producer receives a unique build +ID and the current UTC time. The components and their manifests expire exactly 45 days after that time. A failed producer stops packaging. `tools/rebuild_wcdb_release.py` downloads the exact Release asset for each @@ -14,7 +14,9 @@ the existing signature and provenance checks. It does not use Actions artifact storage or an older Release as a fallback. The macOS native core, key helper and export-integrity module are built in parallel. The integrity module uses the exact WeChatDataAnalysis revision being -packaged. +packaged. The Linux native core is the source-public profile: it is hash +enforced rather than signed, so its producer needs no signing identities and +its Release asset is a reproducible `tar.gz` instead of a `zip`. ## GitHub configuration @@ -24,6 +26,7 @@ Deploy these production workflows to the main branch of `2977094657/WCDB`: - `macos-native-production.yml` - `macos-key-capture-production.yml` - `macos-integrity-production.yml` +- `linux-native-production.yml` Configure their protected environments with the existing signing identities: `windows-native-production`, `macos-native-production` and @@ -36,6 +39,10 @@ In `LifeArchiveProject/WeChatDataAnalysis`, add repository secret `2977094657/WCDB`, Actions read/write and Contents read. Store it directly in GitHub Actions secrets; do not place it in source files or build arguments. +`linux-native-production.yml` does not use an environment (it has no signing +material to protect); it reads the repository-level `WCE_ROOT_PUBLIC_KEY_HEX` +that is also compiled into the Windows and macOS components. + Keep the trusted signing pins and host signing secrets in `windows-private-pki-production` and `macos-private-pki-production`. The macOS environment requires the native client, broker, host and root pins, diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index f8826f94..54c2571d 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -59,6 +59,7 @@ export default defineNuxtConfig({ // 「高级功能」弹窗复用官网的 pro-demos 演示引擎(website/assets 下),跨根导入需要别名, // 并让 dev server 额外放行 website/assets(保留 Vite 默认推断的工作区根,不把整个仓库暴露给 /@fs/) vite: { + ssr: { noExternal: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue'] }, plugins: [tailwindcss()], resolve: { alias: [{ find: '@website', replacement: websiteAssetsDir }] diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue index 5d799ab3..f9028f5b 100644 --- a/frontend/pages/decrypt.vue +++ b/frontend/pages/decrypt.vue @@ -82,9 +82,11 @@ {{ isMacos ? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性本机调试兜底。获取接口仅允许本机访问。' + : isLinux + ? '点击按钮将由 wx_key 拉起微信并在弹出的窗口中完成登录以获取【数据库解密密钥】;Linux 不执行内存扫描。您也可以手动输入已知的64位密钥。' : '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}

-

+

@@ -119,7 +121,7 @@ id="dbPath" v-model="formData.db_storage_path" type="text" - :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'" + :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : isLinux ? '例如: /home/你的用户名/Documents/xwechat_files/wxid_xxx/db_storage' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'" class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200" :class="{ 'border-red-500': formErrors.db_storage_path }" required @@ -1140,6 +1142,9 @@ const macosKeyCaptureCleanupInFlight = ref(false) const platformCapabilities = ref({ platform: '' }) const platformCapabilitiesLoaded = ref(false) const isMacos = computed(() => platformCapabilities.value?.platform === 'macos') +const isLinux = computed(() => platformCapabilities.value?.platform === 'linux') +// 路径分隔符:只有 Windows 用反斜杠,Linux 与 macOS 一样是正斜杠。 +const pathSeparator = computed(() => (platformCapabilities.value?.platform === 'windows' ? '\\' : '/')) const imageKeyMemoryScanChecking = computed(() => !platformCapabilitiesLoaded.value) const imageKeyMemoryScanSupported = computed(() => { if (!platformCapabilitiesLoaded.value) return false @@ -2262,20 +2267,40 @@ const handleGetDbKey = async () => { return } - const shouldContinue = await requestGuideDialog({ - eyebrow: '密钥获取提示', - title: '获取前请确认微信已登录', - description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。', - details: [ - '保持电脑版微信运行,并登录需要解密的账号', - '确认下方数据库路径属于同一个微信账号', - '获取期间不要退出微信或切换到其他账号' - ], - note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。', - primaryLabel: '准备好了,开始获取', - secondaryLabel: '暂不获取', - tone: 'guide' - }) + // Linux 没有 V4 内存扫描这一套逻辑(见 platform_support / key_service 的说明): + // wx_key 采用 fork + TRACEME 自己拉起微信,一次到位,不存在「先扫内存、失败再改用 Hook」。 + // 因此这里不能走 Windows 的提示与兜底流程,否则会先误导用户「正在扫描内存」, + // 再弹一次永远不可能成功的「内存扫描失败,是否改用 Hook?」。 + const shouldContinue = await requestGuideDialog(isLinux.value + ? { + eyebrow: '密钥获取提示', + title: '获取前请确认微信已登录', + description: '获取密钥时会由 wx_key 拉起微信,请在它弹出的微信窗口里完成登录;Linux 不执行内存扫描。', + details: [ + '获取时会先关闭正在运行的微信,再由 wx_key 重新拉起', + '请关闭微信的「自动登录」,在弹出的窗口里手动登录同一个账号', + '程序不能以 root 运行,否则 AppImage 版微信没有窗口', + '获取期间不要退出微信或切换到其他账号' + ], + note: '这里只做操作提醒,不会强制检查登录状态。', + primaryLabel: '准备好了,开始获取', + secondaryLabel: '暂不获取', + tone: 'guide' + } + : { + eyebrow: '密钥获取提示', + title: '获取前请确认微信已登录', + description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。', + details: [ + '保持电脑版微信运行,并登录需要解密的账号', + '确认下方数据库路径属于同一个微信账号', + '获取期间不要退出微信或切换到其他账号' + ], + note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。', + primaryLabel: '准备好了,开始获取', + secondaryLabel: '暂不获取', + tone: 'guide' + }) if (!shouldContinue) return const requestRevision = ++dbKeyRequestRevision @@ -2330,7 +2355,11 @@ const handleGetDbKey = async () => { } let res = null - if (dbStoragePath) { + if (isLinux.value) { + // Linux 直接走 Hook:没有内存扫描可尝试,后端也会拒绝 key_v4 模式。 + res = await fetchByHook() + if (!isDbKeyRequestActive(requestRevision, requestController)) return + } else if (dbStoragePath) { warning.value = '正在优先尝试 V4 内存扫描获取数据库密钥。' res = await getKeys({ wechat_install_path: wechatInstallPath, @@ -3610,8 +3639,9 @@ onMounted(async () => { platformCapabilities.value = await getPlatformCapabilities() } catch { const macos = /Macintosh|Mac OS X/i.test(String(navigator.userAgent || '')) + const linux = !macos && /Linux/i.test(String(navigator.userAgent || '')) platformCapabilities.value = { - platform: macos ? 'macos' : 'windows', + platform: macos ? 'macos' : linux ? 'linux' : 'windows', database_key_extraction: !macos, database_key_guidance: macos ? '未能确认 macOS 数据库密钥组件,请检查本地服务或更新完整应用。' @@ -3619,6 +3649,8 @@ onMounted(async () => { image_key_memory_scan: !macos, image_key_memory_scan_note: macos ? '未能确认 macOS 图片密钥扫描资源,请检查本地服务后重试。' + : linux + ? '未能确认 Linux 平台的 wx_key 组件,请检查本地服务后重试。' : '' } } finally { @@ -3633,7 +3665,7 @@ onMounted(async () => { const account = JSON.parse(selectedAccount) // 填充数据路径 if (account.data_dir) { - const separator = isMacos.value ? '/' : '\\' + const separator = pathSeparator.value formData.db_storage_path = String(account.data_dir).replace(/[\\/]+$/, '') + separator + 'db_storage' } if (account.account_name) { diff --git a/frontend/tests/assistant-ui-ssr-external.test.js b/frontend/tests/assistant-ui-ssr-external.test.js new file mode 100644 index 00000000..dfe15bdd --- /dev/null +++ b/frontend/tests/assistant-ui-ssr-external.test.js @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// 这个契约靠注释守不住,必须由测试守: +// @assistant-ui/tap 的 react-shim 会 `import ... from "react"`,而 react 只是它的可选 peer, +// 前端用 lib/assistant-ui-aliases.js 把 react 指到 standalone-shim。一旦这些包在 SSR 里被判为 +// external,就交给 Node 原生加载,Node 解析不到 react,/chat/[username] 直接 500。 +// +// 实测坑:写成正则(/^@assistant-ui\//)会静默失效 —— Nuxt 把用户提供的 RegExp 序列化成字符串, +// 于是它变成字面量 glob、永远匹配不上;同一条配置里 Nuxt 自带的条目仍然是 RegExp。所以这里 +// 必须钉住「包名字符串」这种写法。 +const configSource = readFileSync(resolve(process.cwd(), 'nuxt.config.ts'), 'utf8') + +function readNoExternalEntries() { + const match = configSource.match(/ssr:\s*\{\s*noExternal:\s*\[([^\]]*)\]/) + expect(match, 'nuxt.config.ts 里必须有 vite.ssr.noExternal').not.toBeNull() + return match[1] + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +describe('assistant-ui 的 SSR 内联契约', () => { + it('noExternal 覆盖四个 assistant-ui 包,且写成包名字符串', () => { + const entries = readNoExternalEntries() + for (const name of ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue']) { + expect(entries, `${name} 必须出现在 noExternal 里`).toContain(`'${name}'`) + } + }) + + it('noExternal 不允许出现正则字面量(RegExp 会被序列化成字符串而静默失效)', () => { + const entries = readNoExternalEntries() + const regexLike = entries.filter((entry) => entry.startsWith('/') || entry.startsWith('(')) + expect(regexLike, `noExternal 里不能写正则: ${regexLike.join(', ')}`).toEqual([]) + }) + + it('react 仍然通过别名指向 standalone-shim(配合 noExternal 一起生效)', () => { + const aliases = readFileSync(resolve(process.cwd(), 'lib/assistant-ui-aliases.js'), 'utf8') + expect(aliases).toContain("^react$") + expect(aliases).toContain('@assistant-ui/tap/standalone-shim') + }) +}) diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js index 8b19499c..12e8b3a0 100644 --- a/frontend/vitest.config.js +++ b/frontend/vitest.config.js @@ -12,6 +12,11 @@ export default defineConfig({ }, test: { environment: 'happy-dom', - include: ['tests/**/*.test.js'] + include: ['tests/**/*.test.js'], + server: { + deps: { + inline: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue'] + } + } } }) diff --git a/pyproject.toml b/pyproject.toml index 10225c57..92dd760b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "pilk>=0.2.4", "pypinyin>=0.53.0", "jieba>=0.42.1", - "wx_key>=2.0.1; sys_platform == 'win32'", + "wx_key>=2.1.1; sys_platform == 'win32' or sys_platform == 'linux'", "pefile>=2024.8.26; sys_platform == 'win32'", "pymem>=1.14.0; sys_platform == 'win32'", "yara-python>=4.5.2; sys_platform == 'win32'", diff --git a/src/wechat_decrypt_tool/key_service.py b/src/wechat_decrypt_tool/key_service.py index 872cd578..ff77602d 100644 --- a/src/wechat_decrypt_tool/key_service.py +++ b/src/wechat_decrypt_tool/key_service.py @@ -1,7 +1,7 @@ # import sys # import requests -from .platform_support import is_macos, is_windows +from .platform_support import is_linux, is_macos, is_windows try: import wx_key @@ -49,6 +49,19 @@ logger = logging.getLogger(__name__) WECHAT_EXECUTABLE_NAMES = ("Weixin.exe", "WeChat.exe") +# Linux 版微信的可执行文件名:发行版包一般是 /usr/bin/wechat(符号链接到 +# /opt/wechat/wechat),本地安装则可能在 ~/.local/bin,AppImage 用户是 *.AppImage。 +LINUX_WECHAT_EXECUTABLE_NAMES = ("wechat", "wechat-bin") +LINUX_WECHAT_EXECUTABLE_PATHS = ( + "/usr/bin/wechat", + "/opt/wechat/wechat", + "/usr/local/bin/wechat", + "~/.local/bin/wechat", +) + + +def _wechat_executable_names() -> tuple[str, ...]: + return LINUX_WECHAT_EXECUTABLE_NAMES if is_linux() else WECHAT_EXECUTABLE_NAMES KEY_SIZE = 32 V4_DB_NAME_PRIORITY = ( "msg0.db", @@ -170,6 +183,10 @@ def _read_wechat_version_from_exe(exe_path: str) -> str: normalized = _normalize_user_path(exe_path) if not normalized: return "" + if is_linux(): + # Linux 侧没有 PE 版本资源可读,版本号不是必需信息(只用于展示/日志), + # 因此这里不编造,调用方按"未知版本"处理。 + return "" try: import win32api @@ -189,6 +206,27 @@ def _resolve_manual_wechat_exe_path(wechat_install_path: Optional[str] = None) - if not normalized: return "" + if is_linux(): + # Linux 上"安装目录"这个概念很弱(发行版包 / AppImage / 解包目录都行), + # 因此只要求:是个可执行文件,或者目录里能找到标准的 wechat 可执行文件。 + candidate = Path(normalized).expanduser() + if candidate.is_file(): + if not os.access(candidate, os.X_OK): + raise RuntimeError(f"手动指定的微信文件不可执行: {candidate}") + return str(candidate) + if candidate.is_dir(): + for exe_name in LINUX_WECHAT_EXECUTABLE_NAMES: + exe_path = candidate / exe_name + if exe_path.is_file(): + return str(exe_path) + for exe_path in sorted(candidate.glob("*.AppImage")): + if exe_path.is_file(): + return str(exe_path) + raise RuntimeError( + f"手动指定的目录中没有可用的微信可执行文件: {candidate}" + ) + raise RuntimeError(f"手动指定的微信路径不存在: {candidate}") + candidate = Path(normalized).expanduser() executable_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES} if candidate.is_file(): @@ -546,7 +584,7 @@ def _try_recover(candidate_internal_db_key: bytes, source: str) -> str: class WeChatKeyFetcher: def __init__(self): - self.process_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES} + self.process_names = {name.lower() for name in _wechat_executable_names()} self.timeout_seconds = 60 def _is_wechat_process(self, name: Any) -> bool: @@ -629,12 +667,33 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict: logger.info(f"Detect WeChat: {version or 'unknown'} at {exe_path}") - self.kill_wechat() - pid = self.launch_wechat(exe_path) - logger.info(f"WeChat launched, PID: {pid}") + if is_linux(): + # Linux 的 wx_key ABI 与 Windows 不同:第一个参数是**微信可执行文件路径**, + # 由 wx_key 自己 fork + PTRACE_TRACEME 拉起微信,我们绝不能先自己 launch。 + # + # 为什么能免提权:TRACEME 场景下 ptrace 的规则是"父进程追踪自己的子进程", + # Yama/ptrace_scope=1 也放行;而 attach 一个已在运行的微信则会被拦下、必须 + # 提权(这也正是 Linux 不提供 v4 内存扫描的原因)。 + # + # 提权边界必须干净:本进程若以 root 运行,被拉起的 AppImage 会因为 + # **FUSE 对 root 不可见**而挂载失败(表现是"微信没有窗口"),所以在提权 + # 发生之前就拒绝,而不是让用户面对一个静默失败的微信。 + if os.geteuid() == 0: + raise RuntimeError( + "请以普通用户身份运行本程序后再获取密钥:root 环境无法挂载 " + "AppImage 版微信(FUSE 对 root 不可见),会表现为微信没有窗口。" + ) + self.kill_wechat() + logger.info("[db_key] Linux hook:交给 wx_key 拉起微信: %s", exe_path) + armed = wx_key.initialize_hook(exe_path) + else: + self.kill_wechat() + pid = self.launch_wechat(exe_path) + logger.info(f"WeChat launched, PID: {pid}") + # 仅传入 PID,触发数据库密钥自动 Hook + armed = wx_key.initialize_hook(pid) - # 仅传入 PID,触发数据库密钥自动 Hook - if not wx_key.initialize_hook(pid): + if not armed: err = wx_key.get_last_error_msg() raise RuntimeError(f"数据库 Hook 初始化失败: {err}") @@ -647,9 +706,21 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict: raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。") key_data = wx_key.poll_key_data() - if key_data and 'key' in key_data: - found_db_key = key_data['key'] - break + # 注意:wx_key 布防成功后可能先返回"带空 key 的占位结构"(Linux 上 + # 实测如此),因此必须要求 key 非空;用 `'key' in key_data` 会把空值 + # 当成结果立刻返回。py_wx_key 自己的 Linux 自测同样是判非空。 + candidate_key = "" + if isinstance(key_data, dict): + candidate_key = str(key_data.get("key") or "").strip() + if candidate_key: + if not re.fullmatch(r"[0-9a-fA-F]{64}", candidate_key): + logger.warning( + "[db_key] hook 返回了非 64-hex 的候选密钥(len=%s),继续等待", + len(candidate_key), + ) + else: + found_db_key = candidate_key + break while True: msg, level = wx_key.get_status_message() @@ -717,6 +788,24 @@ def get_db_key_workflow( dict(validation.get("modes") or {}), ) return result + if is_linux(): + # Linux 只提供 Hook 模式:wx_key 的 fork + TRACEME 免提权路径。 + # Windows 那套 v4 内存扫描需要 attach 已运行的微信进程,在 Linux 上会被 + # Yama/ptrace_scope 拦下、必须提权,且稳定性不如 fork 路径,因此不提供 + # (与 py_wx_key 的 Linux 能力保持一致)。 + mode = str(key_mode or "auto").strip().lower() + if mode in {"v4", "key_v4", "memory", "memory_scan"}: + raise RuntimeError( + "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程)," + "请使用 hook 模式。" + ) + if mode not in {"auto", "hook"}: + raise RuntimeError(f"未知密钥获取模式: {key_mode}") + fetcher = WeChatKeyFetcher() + result = fetcher.fetch_db_key(wechat_install_path=wechat_install_path) + result["method"] = "hook" + return result + if not is_windows(): raise RuntimeError("当前平台不支持自动获取数据库密钥,请使用同类工具获取后手动填写。") @@ -892,6 +981,29 @@ def _get_image_key_kvcomm_dirs(account_dir: Optional[Path] = None) -> tuple[Path if cursor.parent == cursor: break cursor = cursor.parent + elif is_linux(): + # Linux 版微信的 kvcomm 在 ~/.xwechat/net/kvcomm;换网络/重启后会留下 + # net_1 / net_2 / net_3 … 历史目录,它们同样可能有可用的 code,因此都作为 + # 候选(当前 net/ 优先,其余按 mtime 从新到旧)。 + xwechat_root = Path.home() / ".xwechat" + candidates = [xwechat_root / "net" / "kvcomm"] + try: + historical = sorted( + (item for item in xwechat_root.glob("net_*") if item.is_dir()), + key=lambda item: item.stat().st_mtime_ns, + reverse=True, + ) + except OSError: + historical = [] + candidates.extend(item / "kvcomm" for item in historical) + + if account_dir is not None: + cursor = Path(account_dir).expanduser() + for _ in range(6): + candidates.append(cursor / "net" / "kvcomm") + if cursor.parent == cursor: + break + cursor = cursor.parent else: appdata = str(os.environ.get("APPDATA") or "").strip() appdata_root = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming" diff --git a/src/wechat_decrypt_tool/native_core_broker.py b/src/wechat_decrypt_tool/native_core_broker.py index 1a60ca3b..f0e030eb 100644 --- a/src/wechat_decrypt_tool/native_core_broker.py +++ b/src/wechat_decrypt_tool/native_core_broker.py @@ -10,6 +10,7 @@ import time from pathlib import Path +from .app_paths import get_data_dir from .native_core_client import ( ENV_NATIVE_CORE_ENDPOINT, ENV_NATIVE_CORE_LIBRARY, @@ -117,13 +118,19 @@ def __exit__(self, _exc_type, _exc, _traceback) -> None: def _broker_name() -> str: if sys.platform.startswith("win"): return "wechatdb_broker.exe" - if sys.platform == "darwin": + if sys.platform == "darwin" or sys.platform.startswith("linux"): return "wechatdb_broker" - raise NativeCoreComponentMissingError("wechatdb native broker supports Windows and macOS only.") + raise NativeCoreComponentMissingError( + "wechatdb native broker supports Windows, macOS and Linux only." + ) def _client_name() -> str: - return "wechatdb_client.dll" if sys.platform.startswith("win") else "libwechatdb_client.dylib" + if sys.platform.startswith("win"): + return "wechatdb_client.dll" + if sys.platform.startswith("linux"): + return "libwechatdb_client.so" + return "libwechatdb_client.dylib" def _candidate_broker_paths() -> tuple[Path, ...]: @@ -143,6 +150,8 @@ def _candidate_broker_paths() -> tuple[Path, ...]: repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / name, repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / name, repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / name, + repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / name, + repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / name, ) ) result: list[Path] = [] @@ -169,10 +178,61 @@ def _new_endpoint() -> str: token = secrets.token_hex(12) if sys.platform.startswith("win"): return rf"\\.\pipe\LifeArchiveProject.WeChatDB.Native.{os.getpid()}.{token}" - directory = tempfile.gettempdir().rstrip("/\\") + directory = os.fspath(_endpoint_directory()).rstrip("/\\") return f"{directory}/lap-wce-{os.getpid()}-{token}.sock" +def _endpoint_directory() -> Path: + """broker 会校验 socket 所在目录必须"本人拥有 + 组/他人不可写"。 + + macOS 的 TMPDIR 天生是 per-user 0700 目录,所以历史上直接用 gettempdir(); + Linux 的 /tmp 是 sticky + world-writable(任何人都能占位这个 socket 名), + 原生侧会直接判 tamper 拒服务,因此优先用 $XDG_RUNTIME_DIR + (systemd 登录会话的 /run/user/,0700),缺失时退回一个自建 0700 目录。 + """ + if sys.platform == "darwin" or sys.platform.startswith("win"): + return Path(tempfile.gettempdir()) + candidates: list[Path] = [] + runtime_dir = str(os.environ.get("XDG_RUNTIME_DIR", "") or "").strip() + if runtime_dir: + candidates.append(Path(runtime_dir)) + candidates.append(Path(get_data_dir()) / "native-core-run") + euid = os.geteuid() + for candidate in candidates: + # sun_path 只有 108 字节,给 socket 文件名(lap-wce--.sock)留余量。 + if len(os.fspath(candidate)) > 60: + continue + try: + candidate.mkdir(parents=True, exist_ok=True) + os.chmod(candidate, 0o700) + status = candidate.stat() + except OSError: + continue + if status.st_uid != euid or (status.st_mode & 0o022) != 0: + continue + if (status.st_mode & 0o300) != 0o300: + continue + return candidate + raise NativeCoreUnavailableError( + "Cannot locate a private directory for the native core broker socket." + ) + + +def _unlink_unix_socket(endpoint: str) -> None: + """清理 unix socket 与它的 flock 锁文件(broker 用 .lock 互斥)。 + + 只应在确认 broker 进程已退出后调用(否则会破坏原生侧的单实例互斥)。 + Windows 用的是命名管道,没有文件系统路径要删。 + """ + if not endpoint or sys.platform.startswith("win"): + return + for suffix in ("", ".lock"): + try: + Path(endpoint + suffix).unlink(missing_ok=True) + except OSError: + continue + + def _startup_timeout_seconds() -> float: default_timeout_ms = ( "60000" if sys.platform == "darwin" or sys.platform.startswith("win") else "5000" @@ -521,8 +581,9 @@ def ensure_native_core_broker( except subprocess.TimeoutExpired: process.kill() process.wait(timeout=2) - if sys.platform == "darwin": - Path(endpoint).unlink(missing_ok=True) + # broker 用的是 unix socket(macOS/Linux),失败路径也要清掉这个 socket 文件, + # 否则下次启动会撞上残留路径。Windows 是命名管道,没有文件要清。 + _unlink_unix_socket(endpoint) if isinstance(exc, NativeCoreUnavailableError) and log_path is not None: tail = _broker_log_tail(log_path, log_start_offset) detail = f" Broker log: {log_path}." @@ -594,8 +655,7 @@ def stop_native_core_broker(*, _force: bool = False) -> None: except subprocess.TimeoutExpired: process.kill() process.wait(timeout=3) - if endpoint and sys.platform == "darwin": - Path(endpoint).unlink(missing_ok=True) + _unlink_unix_socket(endpoint) atexit.register(stop_native_core_broker, _force=True) diff --git a/src/wechat_decrypt_tool/native_core_client.py b/src/wechat_decrypt_tool/native_core_client.py index 1c3234b8..ac908a79 100644 --- a/src/wechat_decrypt_tool/native_core_client.py +++ b/src/wechat_decrypt_tool/native_core_client.py @@ -545,11 +545,19 @@ class NativeCoreBuildManifest: source_runtime: bool = False windows_host_verification: str = "" macos_host_verification: str = "" + linux_client_sha256: bytes = field(default=b"\0" * 32, repr=False) + linux_broker_sha256: bytes = field(default=b"\0" * 32, repr=False) + linux_integrity_mode: str = "" + linux_peer_verification: str = "" + linux_host_verification: str = "" @property def client_signer_sha256(self) -> bytes: if self.platform == "macos": return self.macos_client_signer_sha256 + if self.platform == "linux": + # Linux 没有签名者,身份即 client 的内容哈希 pin。 + return self.linux_client_sha256 return self.windows_client_signer_sha256 @@ -951,7 +959,13 @@ def _load_native_core_build_manifest( root_public_key_compiled = payload.get("rootPublicKeyCompiled") test_hooks_enabled = payload.get("testHooksEnabled") staging_pinned_signer_trust = payload.get("stagingPinnedSignerTrust") - manifest_platform = "macos" if schema_version == 3 else "windows" + # schema 2 = Windows(历史形态,不带 platform 字段)、3 = macOS、4 = Linux。 + if schema_version == 4: + manifest_platform = "linux" + elif schema_version == 3: + manifest_platform = "macos" + else: + manifest_platform = "windows" windows_client_signer_sha256 = payload.get("windowsClientSignerSha256") offline_bootstrap_feature_bits_value = payload.get( "offlineBootstrapFeatureBits" @@ -965,7 +979,7 @@ def _load_native_core_build_manifest( offline_export_seal_format = payload.get("offlineExportSealFormat") distribution_mode_value = payload.get("distributionMode") distribution_capsule_value = payload.get("distributionCapsule") - if type(schema_version) is not int or schema_version not in {2, 3}: + if type(schema_version) is not int or schema_version not in {2, 3, 4}: raise NativeCoreProtocolError( "wechatdb native build manifest has an unsupported schemaVersion." ) @@ -973,6 +987,10 @@ def _load_native_core_build_manifest( raise NativeCoreProtocolError( "wechatdb native schemaVersion 3 requires platform macos." ) + if schema_version == 4 and payload.get("platform") != "linux": + raise NativeCoreProtocolError( + "wechatdb native schemaVersion 4 requires platform linux." + ) if schema_version == 2 and "platform" in payload: raise NativeCoreProtocolError( "wechatdb native schemaVersion 2 must not declare a platform." @@ -985,7 +1003,9 @@ def _load_native_core_build_manifest( raise NativeCoreProtocolError( "Windows wechatdb native build manifest must declare readOnlyBuild=true and no WeChat actions." ) - if schema_version == 3: + # macOS(v3) 与 Linux(v4) 的 manifest 不带 readOnlyBuild 字段:两者恒为只读 runtime, + # 不能让它留成 None(None 会让后面的判定链静默短路成 None)。 + if schema_version in {3, 4}: read_only_build = True source_runtime = False windows_host_verification = "" @@ -1038,6 +1058,136 @@ def _load_native_core_build_manifest( ) source_runtime = True macos_host_verification = "same-user-direct-parent" + linux_client_sha256 = bytes(32) + linux_broker_sha256 = bytes(32) + linux_integrity_mode = "" + linux_peer_verification = "" + linux_host_verification = "" + if schema_version == 4: + # Linux 没有代码签名 / 签名者证书,身份由两组内容哈希 pin 承担: + # broker 钉 client 的文件 SHA-256(单向,build 脚本两遍构建保证可解), + # 进程间再靠 SO_PEERCRED + 直接父进程关系互认。 + foreign_fields = ( + "windowsClientSignerSha256", + "windowsBrokerSignerSha256", + "windowsPrivateRootSha256", + "windowsSignerTrustMode", + "windowsPrivatePkiLeafRevocation", + "windowsHostVerification", + "macosClientSignerSha256", + "macosBrokerSignerSha256", + "macosHostSignerSha256", + "macosPrivateRootSha256", + "macosClientSigningIdentifier", + "macosBrokerSigningIdentifier", + "macosHostSigningIdentifier", + "macosSigningMode", + "macosSignerTrustMode", + "macosPrivatePkiLeafRevocation", + "macosHostVerification", + ) + declared = sorted(name for name in foreign_fields if name in payload) + if declared: + raise NativeCoreProtocolError( + "Linux wechatdb native manifests must not declare Windows or macOS " + "signing fields: " + ", ".join(declared) + ) + if "linuxHostVerification" not in payload: + raise NativeCoreProtocolError( + "Linux native manifests must declare linuxHostVerification." + ) + if "sourceRuntime" in payload and payload.get("sourceRuntime") is not True: + raise NativeCoreProtocolError( + "Linux source-runtime manifests must declare sourceRuntime=true." + ) + linux_integrity_mode = payload.get("linuxIntegrityMode") + if linux_integrity_mode not in {"content-hash-pin", "development"}: + raise NativeCoreProtocolError( + "Linux wechatdb native manifests must declare linuxIntegrityMode " + "content-hash-pin or development." + ) + if development_build != (linux_integrity_mode == "development"): + raise NativeCoreProtocolError( + "Linux wechatdb native manifests must pair the development integrity " + "mode with developmentBuild." + ) + linux_pin_values = ( + payload.get("linuxClientSha256"), + payload.get("linuxBrokerSha256"), + ) + if any( + not isinstance(value, str) + or re.fullmatch(r"[0-9a-f]{64}", value) is None + for value in linux_pin_values + ): + raise NativeCoreProtocolError( + "wechatdb native build manifest contains invalid Linux content-hash pins." + ) + linux_client_sha256, linux_broker_sha256 = ( + bytes.fromhex(value) for value in linux_pin_values + ) + if development_build and ( + any(linux_client_sha256) or any(linux_broker_sha256) + ): + raise NativeCoreProtocolError( + "Development Linux native builds must not carry production content-hash pins." + ) + if not development_build and not ( + any(linux_client_sha256) and any(linux_broker_sha256) + ): + raise NativeCoreProtocolError( + "Linux wechatdb native content-hash pins must be non-zero." + ) + # 两侧 pin 兼做进程互认的参照物,撞哈希就失去了区分能力:producer 的 + # Test-LinuxNativeProductionArtifact.py 与消费侧的 linux-native-core-packaging.cjs + # 都断言了这一点,这里必须同样拒绝。 + if not development_build and linux_client_sha256 == linux_broker_sha256: + raise NativeCoreProtocolError( + "Linux wechatdb native content-hash pins must be distinct." + ) + linux_peer_verification = payload.get("linuxPeerVerification") + if linux_peer_verification != "same-user-peer-credentials": + raise NativeCoreProtocolError( + "Linux wechatdb native manifests must declare the same-user peer " + "credential policy." + ) + linux_host_verification = payload.get("linuxHostVerification") + if linux_host_verification not in { + "content-hash-pin", + "same-user-direct-parent", + }: + raise NativeCoreProtocolError( + "Linux wechatdb native manifests must declare a supported host " + "verification policy." + ) + # 三份 profile:development(developmentBuild,无 sourceRuntime)、 + # production(无 sourceRuntime,宿主校验=内容哈希)、 + # source-public(sourceRuntime=true,宿主校验=直接父进程)。 + source_runtime = bool(payload.get("sourceRuntime")) + if not development_build and ( + linux_host_verification == "same-user-direct-parent" + ) != source_runtime: + raise NativeCoreProtocolError( + "Linux host verification must be paired with sourceRuntime outside " + "development builds." + ) + if source_runtime and linux_integrity_mode != "content-hash-pin": + raise NativeCoreProtocolError( + "Linux source-runtime manifests must keep the production integrity mode." + ) + if schema_version in {2, 3} and any( + name in payload + for name in ( + "linuxIntegrityMode", + "linuxClientSha256", + "linuxBrokerSha256", + "linuxPeerVerification", + "linuxHostVerification", + ) + ): + raise NativeCoreProtocolError( + "Only Linux wechatdb native manifests may declare Linux integrity fields." + ) if ( not isinstance(build_id, str) or not _NATIVE_CORE_BUILD_ID_PATTERN.fullmatch(build_id) @@ -1091,6 +1241,9 @@ def _load_native_core_build_manifest( raise NativeCoreProtocolError( "wechatdb native build manifest contains an invalid windowsClientSignerSha256." ) + elif manifest_platform == "linux": + # Linux 没有签名者证书;承载"客户端身份"的就是内容哈希 pin。 + signer_digest = linux_client_sha256 else: signer_digest = bytes(32) macos_identifiers = ( @@ -1315,6 +1468,11 @@ def _load_native_core_build_manifest( source_runtime=source_runtime, windows_host_verification=windows_host_verification, macos_host_verification=macos_host_verification, + linux_client_sha256=linux_client_sha256, + linux_broker_sha256=linux_broker_sha256, + linux_integrity_mode=linux_integrity_mode, + linux_peer_verification=linux_peer_verification, + linux_host_verification=linux_host_verification, ) @@ -1333,6 +1491,32 @@ def _required_native_core_build_manifest( "wechatdb native build manifest does not match the current platform." ) frozen = bool(getattr(sys, "frozen", False)) + if manifest.platform == "linux": + from .native_core_lease import validate_native_core_authorization_policy + + # Linux 没有代码签名,身份是内容哈希 pin + 直接父进程的宿主校验;发布工作流发的 + # 就是这一份受限 source-public 产物(linux-private-build.yml 只收 source-public)。 + # 所以冻结应用必须消费 source-public —— 与 Windows 同一原则(Windows 的发布形态 + # 同样是受限 source-public)。macOS 走真正的签名 production,冻结态仍只认 production。 + if frozen and ( + _is_production_native_core_build_manifest(manifest) + or _is_source_public_native_core_build_manifest(manifest) + ): + validate_native_core_authorization_policy(manifest) + return manifest + # 源码 checkout 只接受受限 source-public;production 与 dev-local 都不授权 + # (与 macOS 同一原则)。 + if not frozen and _is_source_public_native_core_build_manifest(manifest): + validate_native_core_authorization_policy(manifest) + return manifest + if not frozen: + raise NativeCoreProtocolError( + "Source WeChatDataAnalysis on Linux requires the exact restricted " + "source-public native core." + ) + raise NativeCoreProtocolError( + "Frozen WeChatDataAnalysis requires a production wechatdb native core." + ) if manifest.platform == "macos": if ( frozen @@ -1439,6 +1623,11 @@ def _is_production_native_core_build_manifest_base( == _NATIVE_CORE_OFFLINE_BOOTSTRAP_FEATURES and manifest.offline_export_seal_format == "WES2" and not _NATIVE_CORE_NON_PRODUCTION_BUILD_ID_PATTERN.search(manifest.build_id) + # Linux 用内容哈希 pin 代替签名者摘要,但 manifest 必须声明签发态。 + and ( + manifest.platform != "linux" + or manifest.linux_integrity_mode == "content-hash-pin" + ) ) @@ -1490,6 +1679,8 @@ def _is_source_public_native_core_build_manifest( return False if manifest.platform == "macos": return manifest.macos_host_verification == "same-user-direct-parent" + if manifest.platform == "linux": + return manifest.linux_host_verification == "same-user-direct-parent" return ( manifest.platform == "windows" and manifest.windows_host_verification == "same-user-direct-parent" @@ -1501,8 +1692,10 @@ def _manifest_matches_runtime_platform( runtime_platform: str | None = None, ) -> bool: current = sys.platform if runtime_platform is None else runtime_platform - return (current.startswith("win") and manifest.platform == "windows") or ( - current == "darwin" and manifest.platform == "macos" + return ( + (current.startswith("win") and manifest.platform == "windows") + or (current == "darwin" and manifest.platform == "macos") + or (current.startswith("linux") and manifest.platform == "linux") ) @@ -1534,7 +1727,11 @@ def _native_library_name() -> str: return "wechatdb_client.dll" if sys.platform == "darwin": return "libwechatdb_client.dylib" - raise NativeCoreComponentMissingError("wechatdb native core supports Windows and macOS only.") + if sys.platform.startswith("linux"): + return "libwechatdb_client.so" + raise NativeCoreComponentMissingError( + "wechatdb native core supports Windows, macOS and Linux only." + ) def _candidate_library_paths() -> tuple[Path, ...]: @@ -1559,6 +1756,8 @@ def _candidate_library_paths() -> tuple[Path, ...]: repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / file_name, repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / file_name, repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / file_name, + repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / file_name, + repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / file_name, ) ) @@ -1589,17 +1788,18 @@ def resolve_native_core_library() -> Path: def _native_core_broker_name() -> str: if sys.platform.startswith("win"): return "wechatdb_broker.exe" - if sys.platform == "darwin": + # macOS 与 Linux 共用同一个可执行文件名(两者都是 ELF/Mach-O 裸二进制)。 + if sys.platform == "darwin" or sys.platform.startswith("linux"): return "wechatdb_broker" raise NativeCoreComponentMissingError( - "wechatdb native broker supports Windows and macOS only." + "wechatdb native broker supports Windows, macOS and Linux only." ) def _native_core_entrypoint_directory() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent / "native" - if sys.platform in {"darwin", "win32"}: + if sys.platform in {"darwin", "win32"} or sys.platform.startswith("linux"): configured = str(os.environ.get(ENV_SOURCE_NATIVE_CORE_DIR, "") or "").strip() if configured: try: diff --git a/src/wechat_decrypt_tool/native_core_device_credential.py b/src/wechat_decrypt_tool/native_core_device_credential.py index d7d2c53b..0bf48123 100644 --- a/src/wechat_decrypt_tool/native_core_device_credential.py +++ b/src/wechat_decrypt_tool/native_core_device_credential.py @@ -12,6 +12,10 @@ from pathlib import Path from typing import Callable +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from .app_paths import get_data_dir from .native_core_client import NativeCoreProtocolError, NativeCoreUnavailableError @@ -27,6 +31,12 @@ _ENTROPY_DOMAIN = b"WeChatDataAnalysis/native-core/device-credential/v2\0" _MACOS_KEYCHAIN_MAGIC = b"WCEKC002" _MACOS_KEYCHAIN_SERVICE = "com.lifearchive.wechatdataanalysis.native-core-credential.v2" +# Linux 没有系统级 per-user keystore(DPAPI/Keychain 对应物),所以采用两种 +# Unix 惯例的组合:文件 0600(等同 SSH 私钥的卫生标准)+ 用 machine-id + uid +# 派生密钥的 AEAD 信封(这样把文件拷到另一台机器/另一个用户下也解不开)。 +_LINUX_MAGIC = b"WCELDC1" +_LINUX_AAD = b"WeChatDataAnalysis/native-core/device-credential/linux/v1" +_LINUX_NONCE_BYTES = 12 CredentialTransform = Callable[[bytes, bytes], bytes] BytesLike = bytes | bytearray | memoryview @@ -203,6 +213,30 @@ def _parse_record(plaintext: bytes, *, expected_schema: int) -> StoredDeviceCred raise NativeCoreProtocolError("Native core device credential is invalid.") +def _linux_machine_identity() -> bytes: + """machine-id + uid:把凭据绑定到"这台机器上的这个用户"。""" + for candidate in ("/etc/machine-id", "/var/lib/dbus/machine-id"): + try: + value = Path(candidate).read_text(encoding="ascii").strip() + except OSError: + continue + if value: + return f"{value}:{os.getuid()}".encode("utf-8") + raise NativeCoreUnavailableError( + "Cannot determine the Linux machine identity for the native core device credential." + ) + + +def _linux_credential_key(entropy: bytes) -> bytes: + """entropy 作为 salt/AAD 绑定 device/build/service,拷到别处失效。""" + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=entropy, + info=_LINUX_AAD, + ).derive(_linux_machine_identity()) + + def _protect_current_user(payload: bytes, entropy: bytes) -> bytes: if sys.platform == "darwin": account_digest = hashlib.sha256( @@ -235,8 +269,15 @@ def _protect_current_user(payload: bytes, entropy: bytes) -> bytes: from .native_core_raw_key_cache import _dpapi_transform return _dpapi_transform(payload, entropy=entropy, protect=True) + if sys.platform.startswith("linux"): + nonce = os.urandom(_LINUX_NONCE_BYTES) + sealed = AESGCM(_linux_credential_key(entropy)).encrypt( + nonce, payload, _LINUX_AAD + ) + return _LINUX_MAGIC + nonce + sealed raise NativeCoreUnavailableError( - "Native core device credentials require Windows DPAPI or macOS Keychain." + "Native core device credentials require Windows DPAPI, macOS Keychain or " + "the Linux machine-bound credential store." ) @@ -288,8 +329,30 @@ def _unprotect_current_user(payload: bytes, entropy: bytes) -> bytes: from .native_core_raw_key_cache import _dpapi_transform return _dpapi_transform(payload, entropy=entropy, protect=False) + if sys.platform.startswith("linux"): + offset = len(_LINUX_MAGIC) + if ( + len(payload) <= offset + _LINUX_NONCE_BYTES + or not payload.startswith(_LINUX_MAGIC) + ): + raise NativeCoreProtocolError( + "Native core Linux credential binding is invalid." + ) + nonce = payload[offset : offset + _LINUX_NONCE_BYTES] + try: + return AESGCM(_linux_credential_key(entropy)).decrypt( + nonce, payload[offset + _LINUX_NONCE_BYTES :], _LINUX_AAD + ) + except Exception as exc: + # 解不开通常意味着换机器/换用户/换 device-build-service 绑定, + # 与 macOS Keychain 不一致的情形等价:当作凭据失效处理。 + raise NativeCoreProtocolError( + "Native core Linux device credential cannot be decrypted on this " + "machine or user." + ) from exc raise NativeCoreUnavailableError( - "Native core device credentials require Windows DPAPI or macOS Keychain." + "Native core device credentials require Windows DPAPI, macOS Keychain or " + "the Linux machine-bound credential store." ) diff --git a/src/wechat_decrypt_tool/native_core_lease.py b/src/wechat_decrypt_tool/native_core_lease.py index ef8d20df..c513db16 100644 --- a/src/wechat_decrypt_tool/native_core_lease.py +++ b/src/wechat_decrypt_tool/native_core_lease.py @@ -48,6 +48,7 @@ _PRODUCTION_APP_IDS = { "windows": "wechat-data-analysis.windows", "macos": "wechat-data-analysis.macos", + "linux": "wechat-data-analysis.linux", } _LICENSE_PROTOCOL_VERSION = 2 _MAX_RESPONSE_BYTES = 64 * 1024 diff --git a/src/wechat_decrypt_tool/platform_support.py b/src/wechat_decrypt_tool/platform_support.py index 32c0d871..5c310f86 100644 --- a/src/wechat_decrypt_tool/platform_support.py +++ b/src/wechat_decrypt_tool/platform_support.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import json import os import platform @@ -33,6 +34,10 @@ def is_windows() -> bool: return current_platform() == "windows" +def is_linux() -> bool: + return current_platform() == "linux" + + def _native_root() -> Path: return Path(__file__).resolve().parent / "native" @@ -133,6 +138,39 @@ def mac_native_core_paths() -> tuple[Path, Path, Path]: ) +def linux_native_core_paths() -> tuple[Path, Path, Path]: + """Linux 的 client 是 .so,broker 与 macOS 同名(同为裸可执行文件)。""" + return ( + _first_existing_native_resource( + Path("libwechatdb_client.so"), + explicit=str( + os.environ.get("WECHAT_TOOL_NATIVE_CORE_LIBRARY", "") or "" + ).strip(), + ), + _first_existing_native_resource( + Path("wechatdb_broker"), + explicit=str( + os.environ.get("WECHAT_TOOL_NATIVE_CORE_BROKER", "") or "" + ).strip(), + ), + _first_existing_native_resource(Path("wechatdb_native_build.json")), + ) + + +def _linux_native_core_manifest_ready(manifest: dict[str, Any]) -> bool: + """Linux 只认 source-public profile(与 Windows/macOS 同一原则)。 + + 必须与 native_core_client 的授权策略保持一致:那边会拒掉 production + profile,这里就不能报"可用",否则界面说可用、一调用就报错。 + """ + return ( + manifest.get("linuxIntegrityMode") == "content-hash-pin" + and manifest.get("linuxPeerVerification") == "same-user-peer-credentials" + and manifest.get("sourceRuntime") is True + and manifest.get("linuxHostVerification") == "same-user-direct-parent" + ) + + def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool: client, broker, manifest_path = paths try: @@ -153,6 +191,8 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool: return False if manifest.get("schemaVersion") == 2 and "platform" not in manifest: return True + if manifest.get("schemaVersion") == 4 and manifest.get("platform") == "linux": + return _linux_native_core_manifest_ready(manifest) if manifest.get("schemaVersion") != 3 or manifest.get("platform") != "macos": return False source_fields = { @@ -167,13 +207,31 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool: ) +def _linux_wx_key_available() -> bool: + """Linux 的密钥获取全部依赖 wx_key(hook 模式,由它 fork 拉起微信)。 + + 不 import,只用 find_spec 探测,避免能力查询带起原生模块加载。 + """ + try: + return importlib.util.find_spec("wx_key") is not None + except (ImportError, ValueError): + return False + + def runtime_capabilities() -> dict[str, Any]: system = current_platform() architecture = (platform.machine() or "unknown").lower() apple_silicon = system == "macos" and architecture in {"arm64", "aarch64"} + linux_key_ready = system == "linux" and _linux_wx_key_available() helper = mac_image_scan_helper_path() if system == "macos" else None image_scan_library = mac_image_scan_library_path() if system == "macos" else None - native_core_paths = mac_native_core_paths() if system == "macos" else None + native_core_paths = ( + mac_native_core_paths() + if system == "macos" + else linux_native_core_paths() + if system == "linux" + else None + ) image_scan_ready = bool( helper and image_scan_library @@ -181,8 +239,9 @@ def runtime_capabilities() -> dict[str, Any]: and image_scan_library.is_file() and helper.parent.resolve() == image_scan_library.parent.resolve() ) + # macOS 的实时 WCDB 仅支持 Apple Silicon;Linux 没有架构门槛(x86_64 基线)。 realtime_ready = bool( - apple_silicon + (system != "macos" or apple_silicon) and native_core_paths and _native_core_resources_ready(native_core_paths) ) @@ -209,7 +268,11 @@ def runtime_capabilities() -> dict[str, Any]: "platform_release": platform.release(), "architecture": architecture, "apple_silicon": apple_silicon, - "database_key_extraction": system == "windows" or bool(mac_db_key_status["available"]), + "database_key_extraction": ( + system == "windows" + or bool(mac_db_key_status["available"]) + or linux_key_ready + ), "macos_lldb_fallback": macos_lldb_fallback, "macos_lldb_fallback_note": ( "实验性本机调试兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。" @@ -218,10 +281,12 @@ def runtime_capabilities() -> dict[str, Any]: ), "database_key_manual_input": True, "database_decryption": True, - "image_key_memory_scan": system == "windows" or image_scan_ready, + "image_key_memory_scan": system == "windows" or image_scan_ready or linux_key_ready, "image_key_memory_scan_note": ( "macOS 图片密钥扫描原生资源缺失或安装不完整,请重新安装完整发行包。" if system == "macos" and not image_scan_ready + else "Linux 图片密钥获取依赖 wx_key(本地算法),未检测到该模块。" + if system == "linux" and not linux_key_ready else "" ), "realtime_wcdb": system == "windows" or realtime_ready, @@ -230,6 +295,8 @@ def runtime_capabilities() -> dict[str, Any]: if system == "macos" and not apple_silicon else "macOS 实时 WCDB 原生资源缺失,请重新安装完整发行包。" if system == "macos" and not realtime_ready + else "Linux 实时 WCDB 需要受限 source-public 原生组件(内容哈希 pin + 构建有效期),组件缺失或不是该 profile 时就不可用。" + if system == "linux" and not realtime_ready else "" ), "wechat_process_media_hook": system == "windows", @@ -239,6 +306,11 @@ def runtime_capabilities() -> dict[str, Any]: "database_key_guidance": ( str(mac_db_key_status.get("note") or MAC_DB_KEY_GUIDANCE) if system == "macos" + else "Linux 取密钥时会由 wx_key 拉起微信(免提权,走 fork + TRACEME)," + "请在弹出的微信里完成登录;Linux 不提供 V4 内存扫描(需要提权 attach)," + "取密钥只有 Hook 一条路。程序不能以 root 运行,否则 AppImage 版微信" + "会因 FUSE 对 root 不可见而打不开窗口。" + if system == "linux" else "" ), "database_key_build_id": ( @@ -255,11 +327,13 @@ def runtime_capabilities() -> dict[str, Any]: __all__ = [ "MAC_DB_KEY_GUIDANCE", "current_platform", + "is_linux", "is_macos", "is_windows", "mac_image_scan_helper_path", "mac_image_scan_library_path", "mac_db_key_bundle_dir", "mac_native_core_paths", + "linux_native_core_paths", "runtime_capabilities", ] diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py index 3e9b1a4f..b8bfa48f 100644 --- a/src/wechat_decrypt_tool/routers/keys.py +++ b/src/wechat_decrypt_tool/routers/keys.py @@ -28,7 +28,7 @@ ) from ..media_helpers import _load_media_keys, _resolve_account_dir from ..path_fix import PathFixRoute -from ..platform_support import current_platform, is_macos, runtime_capabilities +from ..platform_support import current_platform, is_macos, is_windows, runtime_capabilities router = APIRouter(route_class=PathFixRoute) logger = get_logger(__name__) @@ -573,7 +573,10 @@ async def watch_disconnect() -> None: }, } mode = str(key_mode or "auto").strip().lower() - if mode in {"v4", "key_v4", "memory", "memory_scan"}: + # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权), + # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回 + # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。 + if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}: return { "status": -2, "errmsg": f"扫内存失败: {str(e)}", @@ -613,7 +616,10 @@ async def watch_disconnect() -> None: }, } mode = str(key_mode or "auto").strip().lower() - if mode in {"v4", "key_v4", "memory", "memory_scan"}: + # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权), + # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回 + # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。 + if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}: return { "status": -2, "errmsg": f"扫内存失败: {str(e)}", diff --git a/src/wechat_decrypt_tool/wcdb_realtime.py b/src/wechat_decrypt_tool/wcdb_realtime.py index 64bde3dd..07d8cab7 100644 --- a/src/wechat_decrypt_tool/wcdb_realtime.py +++ b/src/wechat_decrypt_tool/wcdb_realtime.py @@ -475,7 +475,12 @@ def get_status(self, account_dir: Path) -> dict[str, Any]: client_path = native_dir / "wechatdb_client.dll" broker_path = native_dir / "wechatdb_broker.exe" else: - client_path = native_dir / "libwechatdb_client.dylib" + client_path = native_dir / ( + "libwechatdb_client.so" + if sys.platform.startswith("linux") + else "libwechatdb_client.dylib" + ) + # macOS 与 Linux 的 broker 可执行文件名相同。 broker_path = native_dir / "wechatdb_broker" manifest_path = client_path.with_name("wechatdb_native_build.json") components_present = all( diff --git a/src/wechat_decrypt_tool/wechat_detection.py b/src/wechat_decrypt_tool/wechat_detection.py index bda63d25..f0ad5940 100644 --- a/src/wechat_decrypt_tool/wechat_detection.py +++ b/src/wechat_decrypt_tool/wechat_detection.py @@ -356,7 +356,22 @@ def get_process_list(): def _wechat_process_targets() -> set[str]: - return {"wechat"} if sys.platform == "darwin" else {"weixin.exe", "wechat.exe"} + if sys.platform == "darwin": + return {"wechat"} + if sys.platform.startswith("linux"): + # Linux 版微信的进程名就是 wechat(AppImage 解包后同样如此)。 + return {"wechat", "wechat-bin"} + return {"weixin.exe", "wechat.exe"} + + +# Linux 微信可执行文件的标准位置:发行版包是 /usr/bin/wechat(符号链接到 +# /opt/wechat/wechat),手工安装可能在 ~/.local/bin。 +_LINUX_WECHAT_EXECUTABLE_PATHS = ( + "/usr/bin/wechat", + "/opt/wechat/wechat", + "/usr/local/bin/wechat", + "~/.local/bin/wechat", +) def _is_wechat_dir_candidate_name(name: str) -> bool: @@ -444,6 +459,18 @@ def add(path_value: str | None) -> None: add(str(container_root / "Documents" / "xwechat_files")) return scan_paths + if sys.platform.startswith("linux"): + # Linux 版微信 4.x 的数据落在“文档目录”下的 xwechat_files//db_storage。 + # 除 XDG 文档目录外,也兼容解包目录/自定义安装把数据放到家目录或 + # ~/.local/share 的情形。 + add(os.path.join(home_dir, "Documents", "xwechat_files")) + add(os.path.join(home_dir, "xwechat_files")) + add(os.path.join(home_dir, ".local", "share", "xwechat_files")) + xdg_documents = str(os.environ.get("XDG_DOCUMENTS_DIR") or "").strip() + if xdg_documents: + add(os.path.join(xdg_documents, "xwechat_files")) + return scan_paths + user_profile = str(os.environ.get("USERPROFILE") or "").strip() if user_profile: add(user_profile) @@ -1089,7 +1116,11 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A # 尝试获取版本信息 try: - if sys.platform == "darwin": + if sys.platform.startswith("linux"): + # Linux 上没有 PE 版本资源/Info.plist 可读,版本号只用于 + # 展示,留空即可(不要走到 win32api 那支去制造噪音)。 + version = "" + elif sys.platform == "darwin": info_plist = Path(result["wechat_install_path"]) / "Contents" / "Info.plist" with info_plist.open("rb") as stream: info = plistlib.load(stream) @@ -1128,6 +1159,19 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A except (OSError, ValueError): pass break + elif sys.platform.startswith("linux"): + # 未运行时按标准位置兜底;/usr/bin/wechat 是符号链接,resolve() 后 + # 取父目录就是真正的安装目录(例如 /opt/wechat)。 + for candidate in _LINUX_WECHAT_EXECUTABLE_PATHS: + executable = Path(candidate).expanduser() + if not executable.is_file(): + continue + result["wechat_exe_path"] = str(executable) + result["wechat_install_path"] = str(executable.resolve().parent) + result["detection_methods"].append( + f"标准位置检测到微信: {executable}" + ) + break # 2. 使用新的账号检测逻辑:同时支持 Backup 与登录信息目录,并合并结果 result["detection_methods"].append("多账户检测(多来源合并)") diff --git a/tests/test_linux_db_key_flow.py b/tests/test_linux_db_key_flow.py new file mode 100644 index 00000000..475e3581 --- /dev/null +++ b/tests/test_linux_db_key_flow.py @@ -0,0 +1,98 @@ +import asyncio +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool import key_service +from wechat_decrypt_tool.routers import keys as keys_router + + +V4_MODE_ERROR = "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程),请使用 hook 模式。" + + +def _as_linux(test_case) -> None: + """把平台判定固定成 Linux,使断言不依赖跑测试的机器。""" + for entry in ( + patch.object(key_service, "is_macos", return_value=False), + patch.object(key_service, "is_linux", return_value=True), + patch.object(key_service, "is_windows", return_value=False), + patch.object(keys_router, "is_macos", return_value=False), + # keys 路由没有导入 is_linux:它只区分「是不是 macOS」与「是不是 Windows」。 + patch.object(keys_router, "is_windows", return_value=False), + ): + test_case.enterContext(entry) + + +class TestLinuxDbKeyFlow(unittest.TestCase): + def test_linux_key_v4_request_never_offers_a_hook_fallback_dialog(self) -> None: + """Linux 没有 V4 内存扫描:不得回报 can_fallback_to_hook。 + + 该字段是前端「内存扫描失败,是否改用 Hook?」弹窗的唯一触发条件;Linux 的 + Hook(fork + TRACEME)并不需要这种两段式兜底,回报它会让用户看到一次 + 永远不可能成功的引导。 + """ + _as_linux(self) + with patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError(V4_MODE_ERROR)): + result = asyncio.run( + keys_router.get_wechat_db_key( + request=None, + db_storage_path="/tmp/db_storage", + key_mode="key_v4", + ) + ) + + self.assertEqual(result["status"], -1) + self.assertNotIn("can_fallback_to_hook", result["data"]) + self.assertIn("hook", result["errmsg"]) + + def test_windows_key_v4_request_keeps_the_hook_fallback_dialog(self) -> None: + """Windows 的两段式流程必须保持不变。""" + with ( + patch.object(keys_router, "is_macos", return_value=False), + patch.object(keys_router, "is_windows", return_value=True), + patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError("scan failed")), + ): + result = asyncio.run( + keys_router.get_wechat_db_key( + request=None, + db_storage_path="D:/xwechat_files/wxid/db_storage", + key_mode="key_v4", + ) + ) + + self.assertEqual(result["status"], -2) + self.assertTrue(result["data"]["can_fallback_to_hook"]) + self.assertEqual(result["data"]["method"], "key_v4") + + def test_linux_key_v4_mode_is_rejected_before_any_memory_scan(self) -> None: + """core 层也必须拒绝 v4:Linux 只有 hook 一条路。""" + _as_linux(self) + with self.assertRaises(RuntimeError) as context: + key_service.get_db_key_workflow(key_mode="key_v4") + + self.assertIn("hook", str(context.exception)) + + def test_linux_auto_mode_goes_straight_to_hook(self) -> None: + _as_linux(self) + fetcher = MagicMock() + fetcher.fetch_db_key.return_value = {"db_key": "a" * 64} + with patch.object(key_service, "WeChatKeyFetcher", return_value=fetcher): + result = key_service.get_db_key_workflow(key_mode="auto") + + fetcher.fetch_db_key.assert_called_once() + self.assertEqual(result["method"], "hook") + self.assertEqual(result["db_key"], "a" * 64) + + def test_linux_unknown_mode_is_rejected(self) -> None: + _as_linux(self) + with self.assertRaises(RuntimeError): + key_service.get_db_key_workflow(key_mode="not-a-mode") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_linux_db_key_frontend.py b/tests/test_linux_db_key_frontend.py new file mode 100644 index 00000000..f4cfe4a7 --- /dev/null +++ b/tests/test_linux_db_key_frontend.py @@ -0,0 +1,48 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def read_frontend(path: str) -> str: + return (ROOT / "frontend" / path).read_text(encoding="utf-8") + + +def _linux_hook_branch(source: str) -> str: + """截出 handleGetDbKey 里 Linux 的那一段分支(不含后续 Windows 分支)。""" + branch = source.split("if (isLinux.value) {", 1)[1] + return branch.split("} else if (dbStoragePath) {", 1)[0] + + +def test_decrypt_page_skips_v4_memory_scan_on_linux() -> None: + """Linux 取密钥不尝试内存扫描:直接走 Hook,也不提示「内存扫描失败」。""" + source = read_frontend("pages/decrypt.vue") + + linux_branch = _linux_hook_branch(source) + assert "await fetchByHook()" in linux_branch + # 不给后端发 V4 请求('key_v4' 才是请求体里的字面量),也不需要数据库路径来验证候选。 + assert "'key_v4'" not in linux_branch + assert "dbStoragePath" not in linux_branch + + +def test_decrypt_page_guide_dialog_tells_linux_users_the_truth() -> None: + source = read_frontend("pages/decrypt.vue") + + linux_dialog = source.split("await requestGuideDialog(isLinux.value", 1)[1].split(": {", 1)[0] + assert "Linux 不执行内存扫描" in linux_dialog + # Linux 分支不能承诺「先扫内存、失败再改用 Hook」。 + assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" not in linux_dialog + # 该文案必须仍然保留给 Windows 分支。 + assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" in source + + +def test_v4_copy_and_attribution_are_hidden_where_memory_scan_does_not_exist() -> None: + source = read_frontend("pages/decrypt.vue") + + # 「优先使用 V4 内存扫描」的按钮提示只在 Windows 显示。 + assert ( + "'点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。" + "您也可以手动输入已知的64位密钥。'" + ) in source + # V4 扫内存的技术出处说明不适用于 Linux。 + assert 'v-if="!isMacos && !isLinux"' in source diff --git a/tests/test_linux_native_core_policy.py b/tests/test_linux_native_core_policy.py new file mode 100644 index 00000000..b7de2d2e --- /dev/null +++ b/tests/test_linux_native_core_policy.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from wechat_decrypt_tool import native_core_client, native_core_lease + + +ZERO = "0" * 64 + + +def linux_manifest( + *, + development: bool = False, + source_runtime: bool = False, + host_verification: str | None = None, +) -> dict[str, object]: + """Linux 的 schema v4 清单(与 WCDB producer 的 CMake 模板同字段集)。""" + issued = int(time.time()) - 60 + manifest: dict[str, object] = { + "schemaVersion": 4, + "platform": "linux", + "distributionMode": "public", + "buildId": "dev-local" if development else "linux-x64-20260915-abcd1234", + "buildIssuedAtUnix": 0 if development else issued, + "buildExpiresAtUnix": 0 if development else issued + 45 * 24 * 60 * 60, + "developmentBuild": development, + "offlineBootstrapFeatureBits": 0 if development else 3, + "offlineExportSealFormat": "none" if development else "WES2", + "codeSignatureEnforced": not development, + "rootPublicKeyCompiled": not development, + "testHooksEnabled": development, + "stagingPinnedSignerTrust": False, + "linuxIntegrityMode": "development" if development else "content-hash-pin", + "linuxClientSha256": ZERO if development else "aa" * 32, + "linuxBrokerSha256": ZERO if development else "bb" * 32, + "linuxPeerVerification": "same-user-peer-credentials", + "linuxHostVerification": host_verification + or ("same-user-direct-parent" if source_runtime else "content-hash-pin"), + "securityNoticeId": "WCE-AUTOMATED-ANALYSIS-NOTICE-V2", + "securityNoticeSha256": "55" * 32, + "securityCheckpointSetId": "WCE-AI-CHECKPOINT-SET-V3", + "securityCheckpointCount": 7, + "securityCheckpointSetSha256": "66" * 32, + } + if source_runtime: + manifest["sourceRuntime"] = True + return manifest + + +def load_manifest(tmp_path: Path, payload: dict[str, object]): + component = tmp_path / "libwechatdb_client.so" + component.write_bytes(b"client") + component.with_name("wechatdb_native_build.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + return native_core_client._load_native_core_build_manifest(component) + + +def authorize(tmp_path: Path, payload: dict[str, object], monkeypatch: pytest.MonkeyPatch, *, frozen: bool): + component = tmp_path / "libwechatdb_client.so" + component.write_bytes(b"client") + component.with_name("wechatdb_native_build.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + monkeypatch.setattr(native_core_client.sys, "platform", "linux") + monkeypatch.setattr( + native_core_lease, + "validate_native_core_authorization_policy", + lambda _manifest: None, + ) + monkeypatch.setattr(native_core_client.sys, "frozen", frozen, raising=False) + return native_core_client._required_native_core_build_manifest(component) + + +def test_linux_production_manifest_uses_content_hash_pins(tmp_path: Path) -> None: + manifest = load_manifest(tmp_path, linux_manifest()) + + assert manifest.platform == "linux" + assert manifest.linux_integrity_mode == "content-hash-pin" + assert manifest.linux_peer_verification == "same-user-peer-credentials" + # Linux 没有签名者证书,client_signer_sha256 就是 client 的内容哈希。 + assert manifest.client_signer_sha256 == bytes.fromhex("aa" * 32) + assert manifest.linux_broker_sha256 == bytes.fromhex("bb" * 32) + assert native_core_client._is_production_native_core_build_manifest(manifest) + assert not native_core_client._is_source_public_native_core_build_manifest(manifest) + + +def test_linux_source_public_manifest_retains_production_security(tmp_path: Path) -> None: + manifest = load_manifest(tmp_path, linux_manifest(source_runtime=True)) + + assert manifest.source_runtime is True + assert manifest.linux_host_verification == "same-user-direct-parent" + assert native_core_client._is_source_public_native_core_build_manifest(manifest) + assert not native_core_client._is_production_native_core_build_manifest(manifest) + + +def test_linux_development_manifest_has_no_production_pins(tmp_path: Path) -> None: + manifest = load_manifest(tmp_path, linux_manifest(development=True)) + + assert manifest.linux_integrity_mode == "development" + assert manifest.client_signer_sha256 == bytes(32) + assert native_core_client._is_development_native_core_build_manifest(manifest) + + +def test_linux_release_ships_source_public_and_the_frozen_app_consumes_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """发布工作流只发 source-public,冻结应用必须能消费它(与 Windows 同一原则)。""" + payload = linux_manifest(source_runtime=True) + + frozen = authorize(tmp_path, payload, monkeypatch, frozen=True) + assert frozen.source_runtime is True + + source = authorize(tmp_path, payload, monkeypatch, frozen=False) + assert source.source_runtime is True + + +def test_linux_runtime_authorization_matrix_is_bound_to_frozen_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # production 只在冻结态被授权:源码 checkout 必须用受限 source-public。 + with pytest.raises( + native_core_client.NativeCoreProtocolError, + match="requires the exact restricted source-public", + ): + authorize(tmp_path, linux_manifest(), monkeypatch, frozen=False) + + # 冻结态接受 production(第二条签发路径)。 + assert authorize(tmp_path, linux_manifest(), monkeypatch, frozen=True) is not None + + # dev-local 在两种状态下都不授权(与 macOS 同一原则)。 + with pytest.raises( + native_core_client.NativeCoreProtocolError, + match="requires the exact restricted source-public", + ): + authorize(tmp_path, linux_manifest(development=True), monkeypatch, frozen=False) + with pytest.raises( + native_core_client.NativeCoreProtocolError, + match="requires a production wechatdb native core", + ): + authorize(tmp_path, linux_manifest(development=True), monkeypatch, frozen=True) + + +def test_linux_manifest_platform_cannot_cross_runtime_boundaries(tmp_path: Path) -> None: + linux = load_manifest(tmp_path, linux_manifest()) + + assert native_core_client._manifest_matches_runtime_platform(linux, "linux") + assert not native_core_client._manifest_matches_runtime_platform(linux, "darwin") + assert not native_core_client._manifest_matches_runtime_platform(linux, "win32") + + +@pytest.mark.parametrize( + ("field", "value"), + ( + # 宿主校验强度必须与 sourceRuntime 配对。 + ("linuxHostVerification", "content-hash-pin"), + # 内容哈希不得为零,也不得让 client 与 broker 撞哈希。 + ("linuxClientSha256", ZERO), + ("linuxBrokerSha256", "aa" * 32), + # Linux 清单不得夹带 Windows / macOS 的签名身份字段。 + ("windowsClientSignerSha256", "11" * 32), + ("macosClientSignerSha256", "11" * 32), + ("platform", "macos"), + ), +) +def test_linux_source_public_manifest_rejects_identity_substitution( + tmp_path: Path, field: str, value: object +) -> None: + payload = linux_manifest(source_runtime=True) + payload[field] = value + with pytest.raises(native_core_client.NativeCoreProtocolError): + load_manifest(tmp_path, payload) + + +def test_linux_manifest_rejects_development_integrity_with_production_pins( + tmp_path: Path, +) -> None: + payload = linux_manifest(development=True) + payload["linuxClientSha256"] = "aa" * 32 + with pytest.raises(native_core_client.NativeCoreProtocolError): + load_manifest(tmp_path, payload) diff --git a/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl deleted file mode 100644 index c3b8f4bc..00000000 Binary files a/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl and /dev/null differ diff --git a/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl deleted file mode 100644 index bf110df5..00000000 Binary files a/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl and /dev/null differ diff --git a/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl deleted file mode 100644 index 9fcdd3fe..00000000 Binary files a/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl and /dev/null differ diff --git a/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl deleted file mode 100644 index d0500ad3..00000000 Binary files a/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl and /dev/null differ diff --git a/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl deleted file mode 100644 index 07c1a0ff..00000000 Binary files a/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl and /dev/null differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl new file mode 100644 index 00000000..2d17de1f Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl new file mode 100644 index 00000000..df16df52 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl new file mode 100644 index 00000000..74b426a4 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl new file mode 100644 index 00000000..a3485f03 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl new file mode 100644 index 00000000..b3e673ae Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl new file mode 100644 index 00000000..6e029023 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl new file mode 100644 index 00000000..797be581 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl new file mode 100644 index 00000000..92634d97 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl new file mode 100644 index 00000000..1706f134 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl differ diff --git a/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl new file mode 100644 index 00000000..121c8c89 Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl differ diff --git a/tools/rebuild_wcdb_release.py b/tools/rebuild_wcdb_release.py index b8ebf3e6..a5c610a9 100644 --- a/tools/rebuild_wcdb_release.py +++ b/tools/rebuild_wcdb_release.py @@ -10,6 +10,7 @@ from pathlib import Path import re import subprocess +import tarfile import tempfile import time import zipfile @@ -17,6 +18,8 @@ REPOSITORY = "2977094657/WCDB" LIFETIME_SECONDS = 45 * 24 * 60 * 60 +# component -> (workflow, artifact name, directory name, env prefix, manifest name, +# archive suffix) COMPONENTS = { "windows-native": ( "windows-native-production.yml", @@ -24,6 +27,7 @@ "wechatdb-native-windows-x64-source-public", "WCE_NATIVE_CORE", "wechatdb_native_build.json", + ".zip", ), "macos-native": ( "macos-native-production.yml", @@ -31,6 +35,7 @@ "wechatdb-native-macos-arm64-production", "WCE_NATIVE_CORE", "wechatdb_native_build.json", + ".zip", ), "macos-xkey": ( "macos-key-capture-production.yml", @@ -38,6 +43,7 @@ "wda-xkey", "WCE_MACOS_XKEY", "wda_xkey_build.json", + ".zip", ), "macos-integrity": ( "macos-integrity-production.yml", @@ -45,6 +51,17 @@ "wce-integrity-macos-arm64-production", "WCE_INTEGRITY", "wce_integrity_build.json", + ".zip", + ), + # Linux 没有代码签名,身份是内容哈希;发布形态是 source-public tar.gz, + # 与 Windows/macOS 的 source-public zip 同一条自动重建路线。 + "linux-native": ( + "linux-native-production.yml", + "wechatdb-native-linux-x64-source-public", + "wechatdb-native-linux-x64-source-public", + "WCE_NATIVE_CORE", + "wechatdb_native_build.json", + ".tar.gz", ), } @@ -118,10 +135,10 @@ def wait_for_build(build: dict, revision: str) -> int: def download(build: dict, run_id: int, revision: str, issued_at: int, output_root: Path) -> dict: component = build["component"] - _, artifact_name, directory_name, prefix, manifest_name = COMPONENTS[component] + _, artifact_name, directory_name, prefix, manifest_name, suffix = COMPONENTS[component] build_id = build["build_id"] tag = f"{component}-{build_id}" - asset_name = f"{artifact_name}-{build_id}.zip" + asset_name = f"{artifact_name}-{build_id}{suffix}" release = api(f"releases/tags/{tag}") if release.get("target_commitish") != revision: raise RuntimeError(f"Producer Release target does not match {revision}: {tag}") @@ -151,10 +168,24 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo if f"sha256:{digest}" != expected_digest: raise RuntimeError(f"Producer Release asset digest mismatch: {asset_name}") archive.seek(0) - with zipfile.ZipFile(archive) as package: - package.extractall(destination) - if component in ("macos-native", "macos-xkey"): - executable = "wechatdb_broker" if component == "macos-native" else "wda_xkey_helper" + if suffix == ".tar.gz": + # Linux 的 producer 用可复现的 tar.gz 封装同一份严格目录, + # 所以解包时沿用 tar 里记录的成员权限。 + expand = getattr(tarfile, "data_filter", None) + with tarfile.open(fileobj=archive, mode="r:gz") as package: + if expand is None: + package.extractall(destination) + else: + package.extractall(destination, filter="data") + else: + with zipfile.ZipFile(archive) as package: + package.extractall(destination) + if component in ("macos-native", "macos-xkey", "linux-native"): + executable = { + "macos-native": "wechatdb_broker", + "macos-xkey": "wda_xkey_helper", + "linux-native": "wechatdb_broker", + }[component] (destination / executable).chmod(0o755) manifest = json.loads((destination / manifest_name).read_text(encoding="utf-8")) if component.endswith("native"): @@ -166,6 +197,20 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo or manifest.get("databaseWriteBuild") is not False or manifest.get("wechatActions") != []): raise RuntimeError("Release native core must be read-only") + if component == "linux-native": + # Linux 的唯一产物身份是这两组内容哈希,必须由 manifest 声明并逐字节成立。 + for field in ("linuxClientSha256", "linuxBrokerSha256"): + if not re.fullmatch(r"[0-9a-f]{64}", str(manifest.get(field) or "")): + raise RuntimeError(f"Release Linux native core has no {field}") + for name, field in ( + ("libwechatdb_client.so", "linuxClientSha256"), + ("wechatdb_broker", "linuxBrokerSha256"), + ): + with (destination / name).open("rb") as binary: + if hashlib.file_digest(binary, "sha256").hexdigest() != manifest[field]: + raise RuntimeError(f"Release Linux native core failed its {field} pin") + if manifest.get("linuxIntegrityMode") != "content-hash-pin": + raise RuntimeError("Release Linux native core is not content-hash-pin") elif component == "macos-xkey": identity = manifest["build"]["id"] issued = manifest["build"]["issuedAtUnix"] @@ -188,6 +233,11 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo f"{prefix}_BUILD_ID": identity, f"{prefix}_ARTIFACT_DIR": str(destination), } + if component == "linux-native": + # 消费方按平台顺序重新解析这两份内容哈希(linux-private-build.yml 也把它们 + # 当成受保护 pin 再核一遍)。 + values[f"{prefix}_CLIENT_SHA256"] = manifest["linuxClientSha256"] + values[f"{prefix}_BROKER_SHA256"] = manifest["linuxBrokerSha256"] if component == "macos-integrity": with (destination / "libwce_integrity.dylib").open("rb") as binary: values["WCE_INTEGRITY_BINARY_SHA256"] = hashlib.file_digest(binary, "sha256").hexdigest() diff --git a/uv.lock b/uv.lock index 97210367..62b9cd1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -3189,7 +3189,7 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"] }, { name = "watchfiles" }, - { name = "wx-key", marker = "sys_platform == 'win32'" }, + { name = "wx-key", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "yara-python", marker = "sys_platform == 'win32'" }, { name = "zstandard" }, ] @@ -3261,7 +3261,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.8.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, { name = "watchfiles", specifier = ">=1.1.0" }, - { name = "wx-key", marker = "sys_platform == 'win32'", specifier = ">=2.0.1" }, + { name = "wx-key", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=2.1.1" }, { name = "yara-python", marker = "sys_platform == 'win32'", specifier = ">=4.5.2" }, { name = "zstandard", specifier = ">=0.23.0" }, ] @@ -3281,13 +3281,17 @@ wheels = [ [[package]] name = "wx-key" -version = "2.0.1" +version = "2.1.1" source = { registry = "tools/key_wheels" } wheels = [ - { path = "wx_key-2.0.1-cp311-cp311-win_amd64.whl" }, - { path = "wx_key-2.0.1-cp312-cp312-win_amd64.whl" }, - { path = "wx_key-2.0.1-cp313-cp313-win_amd64.whl" }, - { path = "wx_key-2.0.1-cp314-cp314-win_amd64.whl" }, + { path = "wx_key-2.1.1-cp311-cp311-linux_x86_64.whl" }, + { path = "wx_key-2.1.1-cp311-cp311-win_amd64.whl" }, + { path = "wx_key-2.1.1-cp312-cp312-linux_x86_64.whl" }, + { path = "wx_key-2.1.1-cp312-cp312-win_amd64.whl" }, + { path = "wx_key-2.1.1-cp313-cp313-linux_x86_64.whl" }, + { path = "wx_key-2.1.1-cp313-cp313-win_amd64.whl" }, + { path = "wx_key-2.1.1-cp314-cp314-linux_x86_64.whl" }, + { path = "wx_key-2.1.1-cp314-cp314-win_amd64.whl" }, ] [[package]]