diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a17b43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + protocol-test: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout without persistent credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Temurin 25.0.4.1+1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '25.0.4+1.1' + - name: Assert JDK version + shell: bash + run: | + set -euo pipefail + version="$(java -version 2>&1)" + printf '%s\n' "$version" + grep -F '25.0.4.1+1' <<<"$version" + - name: Compile and run ProtocolTest + shell: bash + run: | + set -euo pipefail + out="$RUNNER_TEMP/prototest" + javac --release 17 -encoding UTF-8 -Xlint:all -Werror -d "$out" \ + ime/src/org/modavd/ime/HostProtocol.java ime/test/ProtocolTest.java + java -cp "$out" org.modavd.ime.ProtocolTest + + apk-build: + runs-on: windows-2025 + timeout-minutes: 20 + steps: + - name: Checkout without persistent credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Temurin 25.0.4.1+1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '25.0.4+1.1' + - name: Assert JDK version + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = (& java -version 2>&1 | Out-String) + $version + if ($version -notmatch '25\.0\.4\.1\+1') { throw "需要 Temurin 25.0.4.1+1,实际是:$version" } + - name: Check android.jar and build APKs + shell: pwsh + env: + TZ: CET-1 + run: | + $ErrorActionPreference = 'Stop' + $jar = Join-Path $env:ANDROID_HOME 'platforms\android-36\android.jar' + if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { throw "找不到 $jar" } + $hash = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne 'd9eb9da824d9e247a352f570f01e1169e725b2954bca9e283a71786c59b59f9a') { + throw "android.jar SHA-256 不符:$hash" + } + pwsh -NoProfile -File .\build.ps1 -BuildTools (Join-Path $env:ANDROID_HOME 'build-tools\36.1.0') -AndroidJar $jar + + privacy: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout without persistent credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Scan tracked files for private data + shell: bash + run: python3 -B -I -X utf8 scripts/privacy.py . --allowlist scripts/privacy-allowlist.json + + authors: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + CHECK_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }} + CHECK_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + CHECK_MANUAL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }} + steps: + - name: Checkout full history without persistent credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Run repo checks unit tests + shell: bash + run: python3 -B -I -X utf8 scripts/test_repo_checks.py -v + - name: Check commit authors and trailers + shell: bash + run: python3 -B -I -X utf8 scripts/repo_checks.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index dcd98d5..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Protocol self-test - -on: - push: - pull_request: - -permissions: - contents: read - -jobs: - protocol-test: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout without persistent credentials - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - name: Set up Temurin 25 - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: temurin - java-version: '25' - # ProtocolTest 只依赖 HostProtocol(只用 JDK 的类),不需要 Android SDK;编法和 build.ps1 一样 - - name: Compile and run ProtocolTest - run: | - out="$RUNNER_TEMP/prototest" - javac --release 17 -encoding UTF-8 -Xlint:all -d "$out" \ - ime/src/org/modavd/ime/HostProtocol.java ime/test/ProtocolTest.java - java -cp "$out" org.modavd.ime.ProtocolTest diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 0000000..5a9a551 --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,131 @@ +name: Verify release APK + +on: + workflow_dispatch: + inputs: + tag: + description: Tag whose draft release should be checked (for example v1.0.0) + required: true + type: string + release: + types: [published] + +permissions: + contents: read + +jobs: + fetch-release: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'release' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + steps: + - name: Validate tag input + shell: bash + run: | + set -euo pipefail + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "非法 tag:$TAG" >&2 + exit 1 + fi + - name: Download release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/release-assets" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern modavd-ime.apk --pattern SHA256SUMS.txt \ + --dir "$RUNNER_TEMP/release-assets" + - name: Upload release assets for read-only job + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-assets + path: ${{ runner.temp }}/release-assets + if-no-files-found: error + + verify: + needs: fetch-release + runs-on: windows-2025 + timeout-minutes: 25 + permissions: + contents: read + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + steps: + - name: Validate tag input + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + if ($env:TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$') { throw "非法 tag:$env:TAG" } + - name: Checkout tag without persistent credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.TAG }} + fetch-depth: 0 + persist-credentials: false + - name: Set up Temurin 25.0.4.1+1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '25.0.4+1.1' + - name: Assert JDK version + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = (& java -version 2>&1 | Out-String) + $version + if ($version -notmatch '25\.0\.4\.1\+1') { throw "需要 Temurin 25.0.4.1+1,实际是:$version" } + - name: Build unsigned APK with the historical timezone + shell: pwsh + env: + TZ: CET-1 + run: | + $ErrorActionPreference = 'Stop' + $jar = Join-Path $env:ANDROID_HOME 'platforms\android-36\android.jar' + $hash = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant() + if ($hash -ne 'd9eb9da824d9e247a352f570f01e1169e725b2954bca9e283a71786c59b59f9a') { throw "android.jar SHA-256 不符:$hash" } + pwsh -NoProfile -File .\build.ps1 -BuildTools (Join-Path $env:ANDROID_HOME 'build-tools\36.1.0') -AndroidJar $jar + - name: Sign the build with a one-time RSA-4096 key + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dir = Join-Path $env:RUNNER_TEMP 'verify-release-key' + New-Item -ItemType Directory -Force $dir | Out-Null + $pass = Join-Path $dir 'pass.txt' + Set-Content -LiteralPath $pass -Value 'temporary-ci-password' -NoNewline -Encoding ascii + $ks = Join-Path $dir 'temporary-ci.p12' + # pwsh does not stop on a native command's exit code; check each one. + & (Join-Path $env:JAVA_HOME 'bin\keytool.exe') -genkeypair -noprompt -keystore $ks -storetype PKCS12 -alias ci -keyalg RSA -keysize 4096 -validity 1 -dname 'CN=Temporary CI' -storepass:file $pass -keypass:file $pass + if ($LASTEXITCODE -ne 0) { throw "keytool 失败(退出码 $LASTEXITCODE)" } + $apksigner = Join-Path $env:ANDROID_HOME 'build-tools\36.1.0\apksigner.bat' + $aligned = Join-Path $PWD 'out\obj\ime\aligned.apk' + $signed = Join-Path $dir 'ci-signed.apk' + & $apksigner sign --ks $ks --ks-type PKCS12 --ks-key-alias ci --ks-pass "file:$pass" --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false --out $signed $aligned + if ($LASTEXITCODE -ne 0) { throw "apksigner sign 失败(退出码 $LASTEXITCODE)" } + "BUILT_SIGNED=$signed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "APKSIGNER=$apksigner" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "KEYTOOL=$(Join-Path $env:JAVA_HOME 'bin\keytool.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Download release assets + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-assets + path: ${{ runner.temp }}/release-assets + - name: Check release checksum and APK provenance + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $asset = Join-Path $env:RUNNER_TEMP 'release-assets\modavd-ime.apk' + $sums = Join-Path $env:RUNNER_TEMP 'release-assets\SHA256SUMS.txt' + $expected = ((Get-Content -LiteralPath $sums | Where-Object { $_ -match '\smodavd-ime\.apk$' } | Select-Object -First 1) -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected -ne $actual) { throw "Release SHA256SUMS 不符:$expected != $actual" } + # pwsh does not stop on a native command's exit code; check each one. + python -B -I -X utf8 scripts/test_verify_release_apk.py -v + if ($LASTEXITCODE -ne 0) { throw "verify_release_apk 自测失败(退出码 $LASTEXITCODE)" } + python -B -I -X utf8 scripts/verify_release_apk.py --built-apk $env:BUILT_SIGNED --release-apk $asset --apksigner $env:APKSIGNER --certificate-sha256 d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823 + if ($LASTEXITCODE -ne 0) { throw "发布 APK 核对失败(退出码 $LASTEXITCODE)" } diff --git a/README.md b/README.md index 94fd177..b5302e2 100644 --- a/README.md +++ b/README.md @@ -14,17 +14,64 @@ - **ModAVD 0.35.0 或更新**:这一版起模拟器才有 `modavd_ime` 通道。更旧的模拟器上装了也不起作用(只在后台退避重连)。 - 客体 Android 11(API 30)以上;ModAVD 现用的是 Android 14 镜像。 +## 版本 + +宿主输入法以本仓库为准:在这里开发,单独编号,单独发 GitHub Release。 + +- tag 用 `v1.x.y`,APK 的 `versionName` 写成同样的 `1.x.y`。只有 v1.0.0 例外,保持 `versionName 1.0`、`versionCode 1`。 +- APK 内容一变,`versionCode` 就加一。 +- 第一个独立版本 [v1.0.0](https://github.com/ModAVD/ModAVDHostIME/releases/tag/v1.0.0) 和 ModAVD 0.36.0 包里的 + `components\modavd-ime.apk` 逐字节相同。 + ## 随 ModAVD 分发 -ModAVD 0.36.0 起,发布包里带 `components\modavd-ime.apk`,ModAVD 管理器每次开机自动处理: +ModAVD 发布包照旧带着 `components\modavd-ime.apk`,ModAVD 发版时钉住它的 SHA-256。ModAVD 0.36.0 起,ModAVD 管理器每次开机自动处理: - 没装、已装的版本号(`versionCode`)比包里的旧,或版本号相同但内容和包里的不同(`base.apk` 的 SHA-256 不同)时装,不降级; + 读不出客体里 APK 的哈希时,版本号相同就当作已是最新; - 新装后启用,并在同一份客体数据上只决定一次是否设成默认输入法:当时默认的还是镜像自带的键盘(或没设)才设成它, 之后你切到别的输入法就不再改回来。 用 ModAVD 的发布包就不需要手动装。包里的 APK 用维护者的密钥签名,证书见下一节。 要一直用自己编的版本,把 `ime/AndroidManifest.xml` 的 `versionCode` 设得比发布包里的高;不高于它(包括相同)的话,管理器开机时会换回官方版本。 +## 发版和核对 + +正式签名只在维护者本机做,正式密钥不进 CI。所以这里的发版不是「打 tag 自动出包」: + +1. 合并、打 tag; +2. 维护者本机跑 `scripts/release-local.ps1`,建草稿 Release,附件是 `modavd-ime.apk` 和 `SHA256SUMS.txt`; +3. 手动触发 `verify-release` workflow(输入 tag),证明草稿里的 APK 是从 tag 上的源码编出来的; +4. 核对通过再发布。发布后附件不能再换,核对要在发布前做完;发布时 `verify-release` 会再跑一遍复核。 + +`release-local.ps1`: + +- 一般的版本:从 tag `git archive` 出源码,用临时开发密钥跑 `build.ps1`,只取未签名的 `out\obj\ime\aligned.apk`, + 再用正式密钥单独签这一个文件(`-FormalKeyStore`、`-FormalKeyAlias`、`-FormalKeyPassFile`)。密钥库和口令文件不存在就报错, + 绝不生成新钥。测试 APK 不用正式密钥签。 +- v1.0.0 不重新签名,直接用 0.36.0 包里那份,脚本先核对它等于 [release/1.0.0.SHA256SUMS.txt](release/1.0.0.SHA256SUMS.txt): + + ```powershell + pwsh -NoProfile -File .\scripts\release-local.ps1 -Tag v1.0.0 ` + -UseExistingApk <0.36.0 包里的 modavd-ime.apk> -BuildTools \build-tools\36.1.0 + ``` + +- 没设 `ANDROID_HOME` 就要用 `-BuildTools` 指定 build-tools(签名、核对证书都要 apksigner)。`-WorkDir` 要给新目录或空目录。 + `-DryRun` 只打印要执行的 `gh release create`,不调用 gh。 +- `SHA256SUMS.txt` 只有 v1.0.0 预先提交进仓库(它的 APK 在打 tag 之前就有了)。以后的版本只作 Release 附件,不补进仓库: + 哈希要等打 tag、签名之后才有,发布后附件又不能再换,以 Release 为准。 + +核对的办法(`verify-release` 就是这么做的,也可以自己照做):正式签名的 APK 和 CI 编出的不可能逐字节相同,比的是签名以外的部分。 + +- 用一次性 RSA-4096 密钥给 tag 源码编出的 `aligned.apk` 签名(v1 关,v2、v3 开,v4 关,和正式签名一样)。 + RSA-4096 的签名块和正式版一样是 8192 字节,换别的长度偏移就对不上。 +- `scripts/verify_release_apk.py` 比较两边签名块之前的全部字节和中央目录,再用 `apksigner` 核对发布的 APK: + 只有一个签名者、v3 验证通过、证书摘要是下一节的值。 +- 不能直接拿未签名的 `aligned.apk` 比:apksigner 签名时会改写条目对齐,后面条目的偏移都会变。 + +CI(`ci.yml`)只在 Pull Request 和手动触发时跑:协议自测、在 Windows 上编两个 APK、隐私扫描、提交作者检查。 +构建钉住 Temurin 25.0.4.1+1、build-tools 36.1.0 和 android-36(核对 `android.jar` 的 SHA-256)。 + ## 官方 APK 的签名证书 - 证书 SHA-256:`d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823` @@ -42,6 +89,8 @@ ModAVD 0.36.0 起,发布包里带 `components\modavd-ime.apk`,ModAVD 管理 | `testapp/` | 测试 APK `org.modavd.ime.testapp`,不随产品分发 | | `docs/protocol-v1.md` | 通道协议 v1 | | `build.ps1` | 构建脚本(Windows,pwsh 7.2 以上) | +| `scripts/` | CI 的隐私扫描、作者检查,发版和核对脚本 | +| `release/` | 各版本的发布说明;v1.0.0 还有预先提交的 `SHA256SUMS.txt` | ## 构建 @@ -67,6 +116,10 @@ pwsh -NoProfile -File .\build.ps1 - **密钥绝不进 git**:`out/`、`*.p12`、`*.pass` 都在 `.gitignore` 里。 - 代码只用 API 34 及以下的接口;编译用 android-36 的 `android.jar`,它只在编译时用,不进 APK。 +v1.0.0 为了逐字节复现历史 APK,构建时仍依赖本地时区;CI 的 APK job 使用 `TZ=CET-1`。以后改 APK +时要在构建里固定 ZIP 条目时间并去掉这个依赖,同时把 `versionCode` 加一、`versionName` 改成新的 +`1.x.y`(例如 `1.1.0`)。 + 只跑协议自测(任何系统都行,只要 JDK 17 以上): ```sh diff --git a/release/1.0.0.SHA256SUMS.txt b/release/1.0.0.SHA256SUMS.txt new file mode 100644 index 0000000..de47d8a --- /dev/null +++ b/release/1.0.0.SHA256SUMS.txt @@ -0,0 +1 @@ +912c9c9724e63fa606fa5d4e4212cef0e24148b013923ba49d1ebf8cfd38e0e2 modavd-ime.apk diff --git a/release/1.0.0.md b/release/1.0.0.md new file mode 100644 index 0000000..684a74c --- /dev/null +++ b/release/1.0.0.md @@ -0,0 +1,24 @@ +# ModAVDHostIME v1.0.0 + +这是 ModAVDHostIME 的第一个独立版本。APK 与 ModAVD 0.36.0 包里的 +`components\modavd-ime.apk` 逐字节相同: + +- SHA-256:`912c9c9724e63fa606fa5d4e4212cef0e24148b013923ba49d1ebf8cfd38e0e2` +- versionName:`1.0` +- versionCode:`1` +- 签名证书 SHA-256:`d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823` + +来源包:[ModAVD 0.36.0](https://github.com/ModAVD/ModAVD/releases/tag/v0.36.0)。核对发布 APK 时, +先用一次性 RSA-4096 密钥签 tag 上源码编出的 `aligned.apk`,再运行仓库里的 +`scripts/verify_release_apk.py`;它逐字节比较签名块之前的条目区和中央目录,并用 `apksigner` 核对 +唯一签名者、v3 验证和证书摘要。 + +单独使用时: + +```sh +adb install modavd-ime.apk +adb shell ime enable org.modavd.ime/.HostImeService +adb shell ime set org.modavd.ime/.HostImeService +``` + +使用 ModAVD 时,管理器会在开机时按版本号、内容哈希和签名规则自动安装并启用它。 diff --git a/scripts/privacy-allowlist.json b/scripts/privacy-allowlist.json new file mode 100644 index 0000000..1d8946d --- /dev/null +++ b/scripts/privacy-allowlist.json @@ -0,0 +1 @@ +{"exceptions": []} diff --git a/scripts/privacy.py b/scripts/privacy.py new file mode 100644 index 0000000..d6441ad --- /dev/null +++ b/scripts/privacy.py @@ -0,0 +1,141 @@ +"""Fail-closed privacy scanner shared by release packaging and public CI. + +Exceptions pin a complete printable string and either the file SHA-256 or the +exact upstream file path. New strings still fail; no surname-wide exemption. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tarfile + +# Only generic rules live in this file, because it is copied into the public +# repository. Rules that name private identifiers (real name, private email, +# hostname, local directories) sit in privacy-private-rules.json next to it, +# which stays in the maintainer repository: public CI runs without them, and +# the release entry point refuses to scan unless they were loaded. +PUBLIC_RULES = { + # name: (pattern, UTF-16 prefilter prefixes) + "user-path": (r"[a-z]:[\\/]+Users[\\/]+[^\\/\s\"'<>|]+", ["Users"]), +} +PRIVATE_RULES_FILE = Path(__file__).with_name("privacy-private-rules.json") + + +def load_rule_specs(private_file=PRIVATE_RULES_FILE): + specs = dict(PUBLIC_RULES) + loaded = private_file.exists() + if loaded: + for name, spec in json.loads(private_file.read_text(encoding="utf-8"))["rules"].items(): + specs[name] = (spec["pattern"], spec["prefixes"]) + return specs, loaded + + +RULE_SPECS, PRIVATE_RULES_LOADED = load_rule_specs() +RULES = {name: re.compile(pattern, re.I) for name, (pattern, _) in RULE_SPECS.items()} +ANY_RULE = re.compile("|".join(rx.pattern for rx in RULES.values()), re.I) +ASCII_CANDIDATE = re.compile(ANY_RULE.pattern.encode("ascii"), re.I) +# UTF-16 prefix prefilter; full matching still uses RULES on decoded printable strings. +PREFIXES = [prefix for _, prefixes in RULE_SPECS.values() for prefix in prefixes] +U16_CANDIDATE = re.compile(b"|".join(re.escape(s.encode("utf-16le")) for s in PREFIXES), re.I) +NEEDLES = [s.lower().encode(e) for s in PREFIXES for e in ["ascii", "utf-16le"]] + + +def strings(data): + # Find candidate strings directly, without allocating every string in large executables. + for rx, encoding, width in [(ASCII_CANDIDATE, "ascii", 1), (U16_CANDIDATE, "utf-16le", 2)]: + seen = set() + for match in rx.finditer(data): + lo, hi = match.span() + if width == 1: + while lo > 0 and 32 <= data[lo-1] <= 126: + lo -= 1 + while hi < len(data) and 32 <= data[hi] <= 126: + hi += 1 + else: + while lo >= 2 and data[lo-1] == 0 and 32 <= data[lo-2] <= 126: + lo -= 2 + while hi+1 < len(data) and data[hi+1] == 0 and 32 <= data[hi] <= 126: + hi += 2 + if (lo, hi) not in seen: + seen.add((lo, hi)) + yield data[lo:hi].decode(encoding) + try: + text = data.decode("utf-8-sig") + except UnicodeDecodeError: + return + for line in text.splitlines(): + if any(ord(c) > 127 for c in line): + yield line + + +def scan_files(files, allowlist): + rules = json.loads(Path(allowlist).read_text(encoding="utf-8")) + allowed_keys = {(e["file_sha256"], e["rule"], e["string_sha256"]) for e in rules["exceptions"] if "file_sha256" in e} + allowed_paths = {(e["path"], e["rule"], e["string_sha256"]) for e in rules["exceptions"] if "path" in e} + report = {"files_scanned": 0, "failures": [], "allowed": []} + def blob(name, data): + report["files_scanned"] += 1 + lower = data.lower() + if not any(needle in lower for needle in NEEDLES): + return + file_sha = None + logical_path = name.split("!", 1)[-1] + if logical_path.startswith("ModAVD-") and "/" in logical_path: + logical_path = logical_path.split("/", 1)[1] + seen = set() + for string in strings(data): + if not ANY_RULE.search(string): + continue + for rule, rx in RULES.items(): + if not rx.search(string): + continue + digest = hashlib.sha256(string.encode("utf-8")).hexdigest() + if (rule, digest) in seen: + continue + seen.add((rule, digest)) + if file_sha is None: + file_sha = hashlib.sha256(data).hexdigest() + entry = {"file": name, "path": logical_path, "rule": rule, "file_sha256": file_sha, "string_sha256": digest} + if (file_sha, rule, digest) in allowed_keys or (logical_path, rule, digest) in allowed_paths: + report["allowed"].append(entry) + else: + matches = list(rx.finditer(string)) + entry["sample"] = " | ".join(string[max(0, m.start()-80):m.end()+120] for m in matches) + report["failures"].append(entry) + for path, name in files: + path = Path(path) + if name.endswith((".tar.xz", ".tar.gz")): + print(f"Scanning source members: {name}", flush=True) + with tarfile.open(path, "r|*") as archive: + for member in archive: + if member.isfile(): + blob(name + "!" + member.name, archive.extractfile(member).read()) + else: + blob(name, path.read_bytes()) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument("--allowlist", type=Path, default=Path(__file__).with_name("privacy-allowlist.json")) + parser.add_argument("--report", type=Path) + args = parser.parse_args() + # CI scans tracked repository files, never .git internals or arbitrary local artifacts. + names = subprocess.check_output(["git", "-C", str(args.root), "ls-files", "-z"]).decode("utf-8").split("\0") + files = [(args.root / name, name) for name in names if name] + report = scan_files(files, args.allowlist) + if args.report: + args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Privacy: {report['files_scanned']} files; {len(report['allowed'])} allowed; {len(report['failures'])} failures") + for item in report["failures"][:20]: + print(f" {item['file']}: {item['rule']}: {item['sample'][:120]}") + return bool(report["failures"]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release-local.ps1 b/scripts/release-local.ps1 new file mode 100644 index 0000000..2adf53a --- /dev/null +++ b/scripts/release-local.ps1 @@ -0,0 +1,114 @@ +#requires -Version 7.2 +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^v[0-9]+\.[0-9]+\.[0-9]+$')] + [string]$Tag, + [string]$FormalKeyStore, + [string]$FormalKeyAlias, + [string]$FormalKeyPassFile, + [string]$BuildTools, + [string]$AndroidJar, + [string]$JavaHome, + [string]$WorkDir = (Join-Path ([IO.Path]::GetTempPath()) ("modavd-hostime-release-" + $Tag)), + [string]$UseExistingApk, + [string]$ExpectedCertificateSha256 = 'd18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823', + [switch]$DryRun +) +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false + +function Invoke-Checked([string]$Exe, [string[]]$Arguments) { + & $Exe @Arguments | Out-Host + if ($LASTEXITCODE -ne 0) { throw "$([IO.Path]::GetFileName($Exe)) 失败(退出码 $LASTEXITCODE)" } +} + +function Quote-Arg([string]$Value) { + if ($Value -match '[\s"]') { return '"' + ($Value -replace '"', '\"') + '"' } + return $Value +} + +$repoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$version = $Tag.Substring(1) +$notes = Join-Path $repoRoot ("release\$version.md") +$knownSums = Join-Path $repoRoot ("release\$version.SHA256SUMS.txt") +if (-not (Test-Path -LiteralPath $notes -PathType Leaf)) { throw "找不到发布说明:$notes" } +$WorkDir = [IO.Path]::GetFullPath($WorkDir) +if (Test-Path -LiteralPath $WorkDir) { + if ((Get-ChildItem -LiteralPath $WorkDir -Force | Measure-Object).Count -ne 0) { throw "工作目录不是空目录:$WorkDir" } +} else { + New-Item -ItemType Directory -Force $WorkDir | Out-Null +} + +$releaseApk = Join-Path $WorkDir 'modavd-ime.apk' +if ($UseExistingApk) { + if (-not (Test-Path -LiteralPath $UseExistingApk -PathType Leaf)) { throw "找不到输入 APK:$UseExistingApk" } + if (-not (Test-Path -LiteralPath $knownSums -PathType Leaf)) { throw "没有预提交的校验文件:$knownSums" } + $line = Get-Content -LiteralPath $knownSums | Where-Object { $_ -match '\smodavd-ime\.apk$' } | Select-Object -First 1 + if (-not $line) { throw "$knownSums 没有 modavd-ime.apk 条目" } + $expectedHash = (($line -split '\s+')[0]).ToLowerInvariant() + $actualHash = (Get-FileHash -LiteralPath $UseExistingApk -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $expectedHash) { throw "输入 APK SHA-256 不符:$actualHash != $expectedHash" } + Copy-Item -LiteralPath $UseExistingApk -Destination $releaseApk +} else { + foreach ($path in @($FormalKeyStore, $FormalKeyPassFile)) { + if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "正式密钥或口令文件不存在;脚本绝不生成正式密钥" } + } + if (-not $FormalKeyAlias) { throw '必须用 -FormalKeyAlias 指定正式密钥别名' } + $archive = Join-Path $WorkDir 'source.tar' + $source = Join-Path $WorkDir 'source' + New-Item -ItemType Directory -Force $source | Out-Null + Invoke-Checked 'git' @('-C', $repoRoot, 'archive', '--format=tar', '--output', $archive, $Tag) + Invoke-Checked 'tar' @('-xf', $archive, '-C', $source) + $buildOut = Join-Path $source 'out' + $buildArgs = @('-NoProfile', '-File', (Join-Path $source 'build.ps1'), '-OutDir', $buildOut) + if ($BuildTools) { $buildArgs += @('-BuildTools', $BuildTools) } + if ($AndroidJar) { $buildArgs += @('-AndroidJar', $AndroidJar) } + if ($JavaHome) { $buildArgs += @('-JavaHome', $JavaHome) } + # Same TZ as verify-release: aapt add converts the classes.dex entry time with the local time zone. + $savedTz = $env:TZ + $env:TZ = 'CET-1' + try { Invoke-Checked 'pwsh' $buildArgs } finally { $env:TZ = $savedTz } + $aligned = Join-Path $buildOut 'obj\ime\aligned.apk' + if (-not (Test-Path -LiteralPath $aligned -PathType Leaf)) { throw "构建没有生成 $aligned" } + if (-not $BuildTools) { + $sdk = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { throw '请用 -BuildTools 或设置 ANDROID_HOME' } + $BuildTools = Join-Path $sdk 'build-tools\36.1.0' + } + $apksigner = Join-Path $BuildTools 'apksigner.bat' + if (-not (Test-Path -LiteralPath $apksigner -PathType Leaf)) { throw "找不到 $apksigner" } + Invoke-Checked $apksigner @('sign', '--ks', (Resolve-Path $FormalKeyStore), '--ks-type', 'PKCS12', '--ks-key-alias', $FormalKeyAlias, + '--ks-pass', "file:$([IO.Path]::GetFullPath($FormalKeyPassFile))", '--v1-signing-enabled', 'false', '--v2-signing-enabled', 'true', + '--v3-signing-enabled', 'true', '--v4-signing-enabled', 'false', '--out', $releaseApk, $aligned) +} + +if (-not (Test-Path -LiteralPath $releaseApk -PathType Leaf)) { throw "没有发布 APK:$releaseApk" } +$hash = (Get-FileHash -LiteralPath $releaseApk -Algorithm SHA256).Hash.ToLowerInvariant() +$sums = Join-Path $WorkDir 'SHA256SUMS.txt' +Set-Content -LiteralPath $sums -Value "$hash modavd-ime.apk" -Encoding ascii + +if (-not $BuildTools) { + if ($env:ANDROID_HOME) { + $BuildTools = Join-Path $env:ANDROID_HOME 'build-tools\36.1.0' + } elseif ($env:ANDROID_SDK_ROOT) { + $BuildTools = Join-Path $env:ANDROID_SDK_ROOT 'build-tools\36.1.0' + } else { + throw '请用 -BuildTools 或设置 ANDROID_HOME 指定 apksigner' + } +} +$apksigner = Join-Path $BuildTools 'apksigner.bat' +if (-not (Test-Path -LiteralPath $apksigner -PathType Leaf)) { throw "找不到 $apksigner" } +$verifyOutput = (& $apksigner verify '--verbose' '--print-certs' '--min-sdk-version' '30' $releaseApk 2>&1 | Out-String) +if ($LASTEXITCODE -ne 0) { throw "apksigner verify 失败:$verifyOutput" } +if ($verifyOutput -notmatch [regex]::Escape($ExpectedCertificateSha256)) { throw "正式证书摘要不符:$verifyOutput" } + +if ($UseExistingApk -and $Tag -ne 'v1.0.0') { + throw '-UseExistingApk 只适用于 v1.0.0' +} + +$ghArgs = @('release', 'create', $Tag, '--repo', 'ModAVD/ModAVDHostIME', '--draft', '--verify-tag', '--notes-file', $notes, $releaseApk, $sums) +if ($DryRun) { + Write-Host ('DryRun: gh ' + (($ghArgs | ForEach-Object { Quote-Arg $_ }) -join ' ')) +} else { + Invoke-Checked 'gh' $ghArgs +} diff --git a/scripts/repo_checks.py b/scripts/repo_checks.py new file mode 100644 index 0000000..a29e0d3 --- /dev/null +++ b/scripts/repo_checks.py @@ -0,0 +1,69 @@ +"""Check commit authors and Co-authored-by trailers for public history.""" +from __future__ import annotations + +import os +import re +import subprocess +import sys + +EXPECTED = "qiyinxi " +NOREPLY = re.compile(r"^(?:[0-9]+\+[A-Za-z0-9-]+(?:\[bot\])?|[A-Za-z0-9-]+)@users\.noreply\.github\.com$") +TRAILER = re.compile(r"^\s*Co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$", re.I | re.M) + + +def identity_allowed(name: str, email: str) -> bool: + identity = f"{name} <{email}>" + if identity == EXPECTED: + return True + blocked_ai_emails = {"noreply@" + "anthropic.com", "noreply@" + "openai.com"} + if email.lower() in blocked_ai_emails: + return False + return bool(NOREPLY.fullmatch(email)) + + +def commits_in_range(base: str, head: str): + raw = subprocess.check_output( + ["git", "log", "--no-merges", "--format=%H%x00%an%x00%ae%x00%B%x1e", f"{base}..{head}"], + text=True, + encoding="utf-8", + ) + commits = [] + for record in raw.split("\x1e"): + record = record.strip("\n") + if not record: + continue + sha, name, email, message = record.split("\x00", 3) + commits.append((sha, name, email, message)) + return commits + + +def check_commits(commits): + failures = [] + for sha, name, email, message in commits: + if not identity_allowed(name, email): + failures.append(f"{sha}: author {name} <{email}> is not allowed") + for trailer_name, trailer_email in TRAILER.findall(message): + if not identity_allowed(trailer_name, trailer_email): + failures.append(f"{sha}: Co-authored-by {trailer_name} <{trailer_email}> is not allowed") + return failures + + +def main() -> int: + base = os.environ.get("CHECK_BASE_SHA") + head = os.environ.get("CHECK_HEAD_SHA") or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if os.environ.get("CHECK_MANUAL") == "1" or not base: + base = "origin/main" + try: + commits = commits_in_range(base, head) + except subprocess.CalledProcessError as exc: + print(f"无法读取提交范围 {base}..{head}: {exc}", file=sys.stderr) + return 2 + failures = check_commits(commits) + print(f"检查 {len(commits)} 个非合并提交") + for failure in failures: + print(f"FAIL: {failure}") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_repo_checks.py b/scripts/test_repo_checks.py new file mode 100644 index 0000000..e854e16 --- /dev/null +++ b/scripts/test_repo_checks.py @@ -0,0 +1,29 @@ +from pathlib import Path +import sys +import unittest + +# CI runs this file with python -I, which leaves the script directory off sys.path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import repo_checks # noqa: E402 + + +class RepoChecksTest(unittest.TestCase): + def test_allowed_identities(self): + self.assertTrue(repo_checks.identity_allowed("qiyinxi", "qiyinxi@foxmail.com")) + self.assertTrue(repo_checks.identity_allowed("Bot", "123+bot[bot]@users.noreply.github.com")) + self.assertTrue(repo_checks.identity_allowed("Old", "octocat@users.noreply.github.com")) + + def test_rejects_private_or_ai_identities(self): + self.assertFalse(repo_checks.identity_allowed("Someone", "someone@example.com")) + self.assertFalse(repo_checks.identity_allowed("Codex", "noreply@openai.com")) + self.assertFalse(repo_checks.identity_allowed("Claude", "noreply@" + "anthropic.com")) + + def test_commit_and_trailer_checks(self): + good = [("a" * 40, "qiyinxi", "qiyinxi@foxmail.com", "说明\n\nCo-authored-by: Bot <123+bot[bot]@users.noreply.github.com>\n")] + self.assertEqual(repo_checks.check_commits(good), []) + bad = [("b" * 40, "qiyinxi", "qiyinxi@foxmail.com", "说明\n\nCo-authored-by: AI \n")] + self.assertEqual(len(repo_checks.check_commits(bad),), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_verify_release_apk.py b/scripts/test_verify_release_apk.py new file mode 100644 index 0000000..909f7b6 --- /dev/null +++ b/scripts/test_verify_release_apk.py @@ -0,0 +1,93 @@ +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + +# CI runs this file with python -I, which leaves the script directory off sys.path. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_release_apk as verify # noqa: E402 + + +class VerifyReleaseApkTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.apksigner = os.environ.get("APKSIGNER") or shutil.which("apksigner") + cls.keytool = os.environ.get("KEYTOOL") or shutil.which("keytool") + if not cls.apksigner or not cls.keytool: + raise unittest.SkipTest("需要 APKSIGNER 和 KEYTOOL 才能造临时签名样本") + cls.tmp = tempfile.TemporaryDirectory() + root = Path(cls.tmp.name) + unsigned = root / "unsigned.apk" + with zipfile.ZipFile(unsigned, "w", compression=zipfile.ZIP_STORED) as zf: + zf.writestr("AndroidManifest.xml", b"\x03\x00\x08\x00") + zf.writestr("classes.dex", b"dex\n035\x00sample") + cls.password = root / "pass.txt" + cls.password.write_text("temporary-test-password\n", encoding="ascii") + cls.keystore_a = root / "a.p12" + cls.keystore_b = root / "b.p12" + for ks in (cls.keystore_a, cls.keystore_b): + subprocess.run( + [cls.keytool, "-genkeypair", "-noprompt", "-keystore", str(ks), "-storetype", "PKCS12", + "-alias", "test", "-keyalg", "RSA", "-keysize", "4096", "-validity", "1", + "-dname", "CN=temporary-test", "-storepass:file", str(cls.password), "-keypass:file", str(cls.password)], + check=True, capture_output=True, + ) + cls.signed_a = root / "a.apk" + cls.signed_b = root / "b.apk" + # The sample manifest is not binary XML, so apksigner cannot read minSdkVersion from it. + for ks, out in ((cls.keystore_a, cls.signed_a), (cls.keystore_b, cls.signed_b)): + subprocess.run( + [cls.apksigner, "sign", "--min-sdk-version", "30", + "--ks", str(ks), "--ks-type", "PKCS12", "--ks-key-alias", "test", + "--ks-pass", f"file:{cls.password}", "--v1-signing-enabled", "false", + "--v2-signing-enabled", "true", "--v3-signing-enabled", "true", "--v4-signing-enabled", "false", + "--out", str(out), str(unsigned)], + check=True, capture_output=True, + ) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "tmp"): + cls.tmp.cleanup() + + def test_different_signatures_have_same_unsigned_content(self): + verify.compare_apks(self.signed_a.read_bytes(), self.signed_b.read_bytes()) + + def test_signature_block_tamper_is_rejected(self): + layout = verify.parse_layout(self.signed_a.read_bytes()) + # Magic, leading size field, trailing size field. + for offset in (layout.central_directory - 16, layout.block_start, layout.central_directory - 24): + with self.subTest(offset=offset): + data = bytearray(self.signed_a.read_bytes()) + data[offset] ^= 1 + with self.assertRaises(ValueError): + verify.parse_layout(bytes(data)) + + def test_entry_tamper_is_rejected(self): + data = bytearray(self.signed_b.read_bytes()) + data[data.index(b"dex\n035")] ^= 1 + with self.assertRaises(ValueError): + verify.compare_apks(self.signed_a.read_bytes(), bytes(data)) + + def test_padding_tamper_is_rejected(self): + data = bytearray(self.signed_b.read_bytes()) + layout = verify.parse_layout(data) + data[layout.block_start - 1] ^= 1 + with self.assertRaises(ValueError): + verify.compare_apks(self.signed_a.read_bytes(), bytes(data)) + + def test_central_directory_tamper_is_rejected(self): + data = bytearray(self.signed_b.read_bytes()) + layout = verify.parse_layout(data) + # First byte of the first central-directory file name. + data[layout.central_directory + 46] ^= 1 + with self.assertRaises(ValueError): + verify.compare_apks(self.signed_a.read_bytes(), bytes(data)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_release_apk.py b/scripts/verify_release_apk.py new file mode 100644 index 0000000..1af4d5f --- /dev/null +++ b/scripts/verify_release_apk.py @@ -0,0 +1,123 @@ +"""Compare a release APK with a tag build outside the APK signing block.""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import re +import struct +import subprocess +from pathlib import Path + +MAGIC = b"APK Sig Block 42" +V2_ID = 0x7109871A +V3_ID = 0xF05368C0 +VERITY_ID = 0x42726577 + + +@dataclass(frozen=True) +class Layout: + block_start: int + block_end: int + central_directory: int + eocd: int + + +def parse_layout(data: bytes) -> Layout: + eocd = data.rfind(b"PK\x05\x06") + if eocd < 0 or eocd + 22 > len(data): + raise ValueError("找不到 EOCD") + disk, cd_disk, disk_entries, entries, cd_size, cd_offset = struct.unpack_from(" cd_offset - 24: + raise ValueError("APK 签名块长度不对") + first_size = struct.unpack_from(" pair_end: + raise ValueError("APK 签名块 pair 长度缺失") + pair_size = struct.unpack_from(" pair_end or pair_size < 4: + raise ValueError("APK 签名块 pair 越界") + ids.add(struct.unpack_from(" bytes: + tail = bytearray(data[layout.central_directory:]) + eocd_in_tail = layout.eocd - layout.central_directory + # The EOCD central-directory offset necessarily changes with the signing block. + struct.pack_into(" None: + built_layout = parse_layout(built_signed) + release_layout = parse_layout(release) + built_block_length = built_layout.block_end - built_layout.block_start + release_block_length = release_layout.block_end - release_layout.block_start + if built_block_length != release_block_length or built_block_length != 8192: + raise ValueError("RSA-4096 签名块长度不是双方相同的 8192 字节") + if built_signed[:built_layout.block_start] != release[:release_layout.block_start]: + raise ValueError("签名块之前的 APK 条目字节不同") + if normalized_central_and_eocd(built_signed, built_layout) != normalized_central_and_eocd(release, release_layout): + raise ValueError("APK 中央目录或 EOCD 不同") + + +def verify_certificate(apksigner: str, apk: Path, expected_sha256: str) -> str: + result = subprocess.run( + [apksigner, "verify", "--verbose", "--print-certs", "--min-sdk-version", "30", str(apk)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + output = result.stdout + result.stderr + if result.returncode != 0: + raise ValueError(f"apksigner verify 失败:\n{output}") + if not re.search(r"Verified using v3 scheme \(APK Signature Scheme v3\): true", output, re.I): + raise ValueError("发布 APK 没有通过 v3 验证") + digests = [m.replace(":", "").lower() for m in re.findall(r"Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f:]+)", output, re.I)] + if len(digests) != 1: + raise ValueError(f"发布 APK 的签名者数量不是 1:{digests}") + expected = expected_sha256.replace(":", "").lower() + if digests[0] != expected: + raise ValueError(f"证书摘要不符:{digests[0]} != {expected}") + return digests[0] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--built-apk", type=Path, required=True) + parser.add_argument("--release-apk", type=Path, required=True) + parser.add_argument("--apksigner", required=True) + parser.add_argument("--certificate-sha256", required=True) + args = parser.parse_args() + compare_apks(args.built_apk.read_bytes(), args.release_apk.read_bytes()) + digest = verify_certificate(args.apksigner, args.release_apk, args.certificate_sha256) + print(f"签名以外的 APK 字节和中央目录一致;证书 SHA-256 {digest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())