From 3776c892dd0ebf99f64d26f00c30a6194742ab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Wed, 16 Sep 2026 18:08:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(hid-acceptance):=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E5=99=A8=20app=20=E6=94=B9=E7=94=A8=E7=A8=B3=E5=AE=9A=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E8=BA=AB=E4=BB=BD=20+=20=E6=BA=90=20sha256=20?= =?UTF-8?q?=E6=88=B3=EF=BC=8C=E4=BF=AE=E4=BA=BA=E5=B7=A5=20TCC=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E8=B7=A8=E8=BF=90=E8=A1=8C=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #58:D 段("人工授权一次 → 之后都应可用")只在授权当次为真。根因用 TCC 库里的 csreq 钉死了——未签名 app 的授权要求是 **纯 cdhash**: com.vdev.hid.injector: csreq=40B → cdhash H"e7c71dc6a91a25c067943fd46bddb0774854efae" 而脚本每轮都把新的 vdev 二进制拷进 bundle,cdhash 一变授权立刻失配 (对照:签名版 VDCamera 的 csreq 是 identifier + anchor apple generic 的身份型要求)。 改法: - `codesign --force --sign "" -i com.vdev.hid.injector --options runtime --timestamp=none`:签名后 designated requirement 变成 `identifier "com.vdev.hid.injector" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: qingfeng gao (7L8FV63FAP)" and ...`,与二进制无关。 实测把可执行文件从 vdev 换成 /bin/echo 重签,DR **一字不变**(CDHash 变)。 - 只在源二进制变了才重写+重签:用 `~/.vdev-hid-injector/.injector-source.sha256` 记录源 sha256。**不能**用 `cmp` 比较 bundle 内二进制——codesign 会把签名嵌进 Mach-O,签过名的可执行文件永远不等于源文件(第一版就是这么写错的)。 - 签名不可用时明确 WARN:那种情况下 D 段无法跨运行复现(并提示列表里的旧条目是 cdhash 型失效授权,要先删掉再重加)。 - 脚本把 designated requirement 打出来,出现 `cdhash` 字样就告警。 顺带修一个把 B 段偶发打红的杂音:探针在写 READY 之前清零计数(开窗瞬间常有上一个 用例的迟到合成事件落进来,实测偶发一个前导空格);harness 在 READY 后加 0.3s 静默期。 macos-smoke.sh 补 4 条守卫(必须稳定签名、必须用 sha256 戳、探针必须清零), 删掉任一条修复都会红。 实测:连续 3 次运行全部 PASS、`重签=0`(不再无谓重写),A/A2/B/C 三次逐条一致。 D 段的"授权后跨运行持久"需要一次人工授权,见 PR 说明。 Refs #58 --- scripts/acceptance/macos-hid-access-verify.sh | 56 +++++++++++++++++-- scripts/acceptance/macos-hid-type-probe.swift | 8 ++- scripts/acceptance/macos-smoke.sh | 4 ++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/scripts/acceptance/macos-hid-access-verify.sh b/scripts/acceptance/macos-hid-access-verify.sh index 4929569..475f997 100644 --- a/scripts/acceptance/macos-hid-access-verify.sh +++ b/scripts/acceptance/macos-hid-access-verify.sh @@ -68,9 +68,26 @@ cat > "${PROBE_APP}/Contents/Info.plist" <<'PLIST' PLIST -# 2) 未授权注入器(全新 bundle id;LSUIElement 保证不抢探针焦点) -mkdir -p "${INJECTOR_APP}/Contents/MacOS" -cp "${VDEV}" "${INJECTOR_APP}/Contents/MacOS/VdevHidInjector" +# 2) 稳定注入器 app(固定 id + **稳定签名**,供人工授权一次后长期有效) +# +# 为什么要签名:未签名的 app,TCC 的授权要求是 `cdhash H"..."`(实测 csreq 40 字节), +# 而本脚本每轮都会把新的 vdev 二进制拷进 bundle —— cdhash 一变,人工授权立刻失配 +# (issue #58)。用 Apple Development/Developer ID 签名后,要求变成 +# `identifier "com.vdev.hid.injector" and anchor apple generic and certificate leaf[…]`, +# 与二进制内容无关,授权才能跨运行跨重建稳定。 +# +# 旁证:本机签名版 VDCamera.app 的 TCC csreq 就是这种身份型要求(160 字节)。 +INJECTOR_BIN="${INJECTOR_APP}/Contents/MacOS/VdevHidInjector" +# 戳记必须记"源二进制的 sha256",**不能**拿 bundle 内二进制去 cmp: +# codesign 会把签名嵌进 Mach-O 本身,签过名的可执行文件永远不等于源文件。 +INJECTOR_STAMP="${INJECTOR_DIR}/.injector-source.sha256" +mkdir -p "${INJECTOR_APP}/Contents/MacOS" "${INJECTOR_DIR}" +VDEV_SHA=$(shasum -a 256 "${VDEV}" | awk '{print $1}') +if [ ! -f "${INJECTOR_BIN}" ] || [ ! -f "${INJECTOR_STAMP}" ] \ + || [ "$(cat "${INJECTOR_STAMP}" 2>/dev/null)" != "${VDEV_SHA}" ]; then + cp "${VDEV}" "${INJECTOR_BIN}" + INJECTOR_NEEDS_SIGN=1 +fi cat > "${INJECTOR_APP}/Contents/Info.plist" <<'IPLIST' @@ -84,6 +101,32 @@ cat > "${INJECTOR_APP}/Contents/Info.plist" <<'IPLIST' IPLIST +# 2a) 签名(身份可复现时) +# 优先 Apple Development(与本仓 crates/vdev-camera/Makefile 的 SIGN_APP_IDENTITY 一致), +# 其次 Developer ID;都没有则退回 ad-hoc 并**明确警告**——那种情况下 D 段的授权 +# 每轮都会失效,必须让使用者知道。 +INJECTOR_SIGN_IDENTITY="${VDEV_HID_SIGN_IDENTITY:-Apple Development: qingfeng gao (7L8FV63FAP)}" +if [ -n "${INJECTOR_NEEDS_SIGN:-}" ]; then + if codesign --force --sign "${INJECTOR_SIGN_IDENTITY}" -i com.vdev.hid.injector \ + --options runtime --timestamp=none "${INJECTOR_APP}" >/dev/null 2>&1; then + echo " 注入器已用稳定身份签名:${INJECTOR_SIGN_IDENTITY}" + printf '%s' "${VDEV_SHA}" > "${INJECTOR_STAMP}" + else + echo "WARN: 用 '${INJECTOR_SIGN_IDENTITY}' 签名失败 → 退回 ad-hoc。" + # 没签成也记戳:至少避免每轮都无谓重写(重写会让 TCC 的 cdhash 型授权失效) + echo "WARN: ad-hoc 的 TCC 授权要求是 cdhash,二进制一变授权就失效(issue #58);" + echo "WARN: D 段(授权前报错 → 授权后可用)将无法跨运行复现。" + printf '%s' "${VDEV_SHA}" > "${INJECTOR_STAMP}" + fi +fi +# 把「TCC 会拿到的要求」打出来做证据:身份型要求里不该出现 cdhash +INJECTOR_DR=$(codesign -dr - "${INJECTOR_APP}" 2>&1 | tail -1) +echo " 注入器 designated requirement: ${INJECTOR_DR}" +case "${INJECTOR_DR}" in + *cdhash*) + echo "WARN: 注入器身份是 cdhash 型 —— 人工授权只对当前这份二进制有效(issue #58)" ;; +esac + # 2b) 未授权对照 app(bundle id 每次不同 → 不可能有历史授权) mkdir -p "${INJECTOR_APP_UNAUTH}/Contents/MacOS" cp "${VDEV}" "${INJECTOR_APP_UNAUTH}/Contents/MacOS/VdevHidUnauth" @@ -201,7 +244,10 @@ run_case() { "${app}" --args "$@" 2>/dev/null fi else - "$@" > "${RUN_DIR}/inject.out" 2>&1 + # READY 之后仍可能有极短的在途事件,注入前留一小段静默期(探针已在 READY + # 时清零计数,这里只是再降一点概率) + sleep 0.3 + "$@" > "${RUN_DIR}/inject.out" 2>&1 fi inject_out_text="$(cat "${RUN_DIR}/inject.out" "${RUN_DIR}/inject.err" 2>/dev/null)" echo " inject_out=$(echo "${inject_out_text}" | tr '\n' ' ' | tail -c 160)" @@ -307,6 +353,8 @@ if [ "${post_d}" = "true" ]; then else echo " D: 该身份尚未授权——在 系统设置 → 隐私与安全性 → 辅助功能 里加入" echo " ${INJECTOR_APP} 后重跑本脚本,可复现「授权前报错 → 授权后可用」" + echo " (若列表里已有同名旧条目:那是 cdhash 型的失效授权,先 \"-\" 删掉再重新添加," + echo " 否则会出现\"看着已勾选但 post_access 仍为 false\"的假象)" fi # ---- C) 安全输入 ---- diff --git a/scripts/acceptance/macos-hid-type-probe.swift b/scripts/acceptance/macos-hid-type-probe.swift index 9db4637..c133498 100644 --- a/scripts/acceptance/macos-hid-type-probe.swift +++ b/scripts/acceptance/macos-hid-type-probe.swift @@ -93,7 +93,13 @@ let deadline = Date().addingTimeInterval(4) while Date() < deadline && !(app.isActive && window.isKeyWindow) { RunLoop.current.run(until: Date().addingTimeInterval(0.05)) } -// tap 计数的基线放在 READY 行:验收脚本只应比较注入前后的增量, +// 写 READY **之前**清零:开窗瞬间常有上一个用例的迟到合成事件落进来(实测 +// 偶发一个前导空格,把"逐字一致"断言打红)。清零后 READY 的语义变成 +// "从这一刻起计数",验收脚本注入的字符数才等于增量。 +window.typed = "" +window.keys = 0 +tapCount = 0 +// tap 计数的基线仍放在 READY 行:验收脚本只应比较注入前后的增量, // 避免用户/系统在同一窗口内产生的真实键事件被误算成注入丢失。 report("READY active=\(app.isActive) key=\(window.isKeyWindow) tap_ok=\(tapOk) tap=\(tapCount)") diff --git a/scripts/acceptance/macos-smoke.sh b/scripts/acceptance/macos-smoke.sh index 12c6921..26e039c 100755 --- a/scripts/acceptance/macos-smoke.sh +++ b/scripts/acceptance/macos-smoke.sh @@ -42,6 +42,10 @@ require_grep 'unauth.$$.' scripts/acceptance/macos-hid-access-verify.sh '未授 require_grep 'EnableSecureEventInput' scripts/acceptance/macos-hid-access-verify.sh '安全输入必须用 EnableSecureEventInput 确定性开启,不能靠人工凑状态' require_grep 'secure_input=true' scripts/acceptance/macos-hid-access-verify.sh '安全输入用例必须先确认系统真的处于安全输入,再断言行为' require_grep 'HID_ACCESS_RESULT=NOT_RUN' scripts/acceptance/macos-hid-access-verify.sh 'HID 权限验收必须保留 NOT_RUN 出口(锁屏/无权限身份等环境不具备时不算通过)' +require_grep 'codesign --force --sign' scripts/acceptance/macos-hid-access-verify.sh '注入器 app 必须用稳定身份签名(否则 TCC 授权是 cdhash 型,二进制一变就失效,issue #58)' +require_grep 'INJECTOR_STAMP' scripts/acceptance/macos-hid-access-verify.sh '注入器必须用源二进制 sha256 戳判断是否重写(每轮重写会让 cdhash 型授权失效,issue #58)' +require_grep 'window.typed = ""' scripts/acceptance/macos-hid-type-probe.swift '探针必须在 READY 前清零计数(开窗瞬间的迟到事件会污染逐字断言)' + require_grep 'exit 2' scripts/acceptance/macos-hid-access-verify.sh 'HID 权限验收的环境不具备分支必须 exit 2(与用例失败区分)' require_grep '辅助功能' crates/vdev-hid/src/lib.rs '注入路径必须有可诊断的「辅助功能」权限报错文案' require_grep 'secure_input' crates/vdev-hid/src/lib.rs '注入路径必须报出安全输入状态' From 80ae5f1fec7cb3f4a65797d272b9a175877c8091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Wed, 16 Sep 2026 18:24:20 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(hid-acceptance):=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=20B1/B2=20=E6=94=B6=E5=8F=A3=20=E2=80=94=E2=80=94=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=88=B3+=E7=AD=BE=E5=90=8D=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81=E8=A1=8C=E4=B8=BA=E7=BA=A7=E8=87=AA=E6=B5=8B=E3=80=81?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审给了 3 条阻塞项,本提交收前两条(第三条 B3 是证据,已用真机三次连跑补齐,见 PR)。 B1 跳过决策只看源 sha,会把"签名坏了 / 身份降级了"永久固化: - 注入器准备抽成 `prepare_injector_app `,**复用条件 三者齐备**:源 sha 同名 + 期望身份一致 + `codesign --verify --strict` 通过。 - 戳记状态而不只是 sha(`\t`),身份恢复后能自动重签。 - **只有签名 + 校验都成功才写戳**:签名失败/校验失败一律不写,下次重试(此前 `else` 分支也写戳,会把"尝试过"记成"准备好了"——正是 #58 的失效模式在修复自身复现)。 - 身份解析改 `resolve_sign_identity()`:Apple Development 优先,Developer ID 兜底, 都没有才 ad-hoc 并 WARN。 - `Info.plist` 只在内容变了才写(无条件重写会让签名失效)。 - 加互斥锁(`~/.vdev-hid-injector/.lock`):实测 6 路并发会把 bundle 签成 `invalid Info.plist (plist or signature have been modified)`;现在并发直接 NOT_RUN。 - 打印 `codesign --verify --strict` 结果(此前只看 DR 里有没有 cdhash,看不出"签名已坏")。 B2 纯 grep 守卫挡不住"语义摘除": - 新增 `--self-test`(隔离临时目录,不需要证书/前台焦点):① 首跑写状态戳 ② 源与身份都没变 → **不重写**(mtime/inode 不变)③ 源换了 → 重签且戳更新 ④ bundle 被改坏 → 戳匹配也不复用、自动重做 ⑤ 身份变化 → 重签。 - `macos-smoke.sh` 直接跑这个自测,并补精确字符串守卫(调用点必须传 `"${INJECTOR_SIGN_IDENTITY}"`、必须 `security find-identity` 解析、必须有 `codesign --verify --strict`)。 - 阳性对照实测:① 身份传 `-` → smoke 红(字符串守卫)② 删掉写戳 → smoke 红 (自测报"首跑没有写戳")——后者正是旧守卫抓不到的那一类。 顺带(与 #58 无关但同属本 harness 稳定性,单列在此): - 探针在写 READY 前清零 typed/keys/tapCount,排除开窗瞬间的迟到合成事件 (此前偶发一个前导空格把 B 段"逐字一致"打红);注释同步改成"清零即基线"。 - READY 后的 0.3s 静默期对 app/direct 两种注入方式都生效(此前只覆盖 direct)。 - D 段未授权时记 `env_bad`(整体走 NOT_RUN/exit 2):"没验证 ≠ 通过", 否则未授权也会输出 PASS,把 #58 的验收读成已完成。 真机:注入器授权后 **3 次连跑 D 段均 `post_access=true` + 逐字送达 22 字符**, 全程 `fails=0 env_bad=0`;TCC 里该身份的授权要求已从 40B `cdhash H"e7c71dc6…"` 变成 164B 身份型(`identifier "com.vdev.hid.injector" and anchor apple generic and certificate leaf[subject.CN] = "Apple Development: qingfeng gao (7L8FV63FAP)"`)。 Refs #58 --- scripts/acceptance/macos-hid-access-verify.sh | 212 ++++++++++++++---- scripts/acceptance/macos-hid-type-probe.swift | 4 +- scripts/acceptance/macos-smoke.sh | 17 +- 3 files changed, 187 insertions(+), 46 deletions(-) diff --git a/scripts/acceptance/macos-hid-access-verify.sh b/scripts/acceptance/macos-hid-access-verify.sh index 475f997..5b9010b 100644 --- a/scripts/acceptance/macos-hid-access-verify.sh +++ b/scripts/acceptance/macos-hid-access-verify.sh @@ -16,15 +16,20 @@ # 退出码:0 = 全部通过;1 = 有用例失败;2 = 环境不具备(没验证,不算通过) set -u +# `--self-test` 可前置:它只跑"准备注入器 app"的行为自测,不需要 vdev / 探针 / 前台焦点 +SELF_TEST=0 +if [ "${1:-}" = "--self-test" ]; then SELF_TEST=1; shift; fi VDEV=${1:-target/release/vdev} -if [ ! -x "${VDEV}" ]; then +if [ "${SELF_TEST}" = "0" ] && [ ! -x "${VDEV}" ]; then echo "FAIL: vdev 二进制不可执行:${VDEV}(先 cargo build -p vdev-host --release)" exit 2 fi VDEV=$(cd "$(dirname "${VDEV}")" && pwd)/$(basename "${VDEV}") -for tool in swiftc python3 open; do - command -v "${tool}" >/dev/null 2>&1 || { echo "FAIL: 缺 ${tool}"; exit 2; } -done +if [ "${SELF_TEST}" = "0" ]; then + for tool in swiftc python3 open; do + command -v "${tool}" >/dev/null 2>&1 || { echo "FAIL: 缺 ${tool}"; exit 2; } + done +fi SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) RUN_DIR=$(mktemp -d -t vdev-hid-access.XXXXXX) @@ -35,6 +40,7 @@ RUN_DIR=$(mktemp -d -t vdev-hid-access.XXXXXX) # 作为 A / A2 的未授权对照(用固定 id 会被历史授权污染) INJECTOR_DIR="${HOME}/.vdev-hid-injector" INJECTOR_APP="${INJECTOR_DIR}/VdevHidInjector.app" +LOCK_DIR="${INJECTOR_DIR}/.lock" UNAUTH_ID="com.vdev.hid.injector.unauth.$$.$(date +%s)" INJECTOR_APP_UNAUTH="${RUN_DIR}/VdevHidUnauth.app" SECURE_HELPER_BIN="${RUN_DIR}/secure-input-hold" @@ -43,6 +49,8 @@ cleanup() { pkill -x VdevHidProbe 2>/dev/null || true pkill -x SecureInputHold 2>/dev/null || true rm -rf "${RUN_DIR}" + # 注入器目录的互斥锁也让位于同一个 EXIT trap(脚本里只能有一处 EXIT trap) + rmdir "${INJECTOR_DIR:-/nonexistent}/.lock" 2>/dev/null || true } trap cleanup EXIT @@ -68,27 +76,56 @@ cat > "${PROBE_APP}/Contents/Info.plist" <<'PLIST' PLIST -# 2) 稳定注入器 app(固定 id + **稳定签名**,供人工授权一次后长期有效) +# 2) 准备"稳定注入器 app"(固定 id + 稳定签名 + 只在必要时重写) # -# 为什么要签名:未签名的 app,TCC 的授权要求是 `cdhash H"..."`(实测 csreq 40 字节), -# 而本脚本每轮都会把新的 vdev 二进制拷进 bundle —— cdhash 一变,人工授权立刻失配 +# 为什么要签名:未签名的 app,TCC 的授权要求是 `cdhash H"..."`(实测 csreq 40B), +# 而脚本每轮都会把新的 vdev 二进制拷进 bundle —— cdhash 一变,人工授权立刻失配 # (issue #58)。用 Apple Development/Developer ID 签名后,要求变成 # `identifier "com.vdev.hid.injector" and anchor apple generic and certificate leaf[…]`, -# 与二进制内容无关,授权才能跨运行跨重建稳定。 +# 与二进制内容无关(实测把可执行文件换掉重签,DR 一字不变)。 # -# 旁证:本机签名版 VDCamera.app 的 TCC csreq 就是这种身份型要求(160 字节)。 -INJECTOR_BIN="${INJECTOR_APP}/Contents/MacOS/VdevHidInjector" -# 戳记必须记"源二进制的 sha256",**不能**拿 bundle 内二进制去 cmp: -# codesign 会把签名嵌进 Mach-O 本身,签过名的可执行文件永远不等于源文件。 -INJECTOR_STAMP="${INJECTOR_DIR}/.injector-source.sha256" -mkdir -p "${INJECTOR_APP}/Contents/MacOS" "${INJECTOR_DIR}" -VDEV_SHA=$(shasum -a 256 "${VDEV}" | awk '{print $1}') -if [ ! -f "${INJECTOR_BIN}" ] || [ ! -f "${INJECTOR_STAMP}" ] \ - || [ "$(cat "${INJECTOR_STAMP}" 2>/dev/null)" != "${VDEV_SHA}" ]; then - cp "${VDEV}" "${INJECTOR_BIN}" - INJECTOR_NEEDS_SIGN=1 -fi -cat > "${INJECTOR_APP}/Contents/Info.plist" <<'IPLIST' +# 准备策略(审查 B1 修正版):**只有"源 sha 同名 + 期望身份一致 + codesign 校验通过" +# 三者都满足才复用**;否则重写+重签。签名或校验失败时**不写戳**,避免把坏状态永久固化。 +resolve_sign_identity() { + # 优先 Apple Development(与本仓 crates/vdev-camera/Makefile 一致),其次 Developer ID + if [ -n "${VDEV_HID_SIGN_IDENTITY:-}" ]; then + printf '%s' "${VDEV_HID_SIGN_IDENTITY}"; return + fi + local pref="Apple Development: qingfeng gao (7L8FV63FAP)" + if security find-identity -v -p codesigning 2>/dev/null | grep -qF "${pref}"; then + printf '%s' "${pref}"; return + fi + local devid + devid=$(security find-identity -v -p codesigning 2>/dev/null | sed -nE 's/.*"(Developer ID Application: [^"]+)".*/\1/p' | head -1) + if [ -n "${devid}" ]; then printf '%s' "${devid}"; return; fi + printf '%s' "-" # 没有任何可用身份 → ad-hoc(并会在下面 WARN) +} + +# 返回 0=刚准备好(重写/重签过),1=复用了现有 bundle +prepare_injector_app() { + local src=$1 app=$2 stamp=$3 identity=$4 + local bin="${app}/Contents/MacOS/VdevHidInjector" + local want_state + if [ "${identity}" = "-" ]; then want_state="adhoc"; else want_state="signed:${identity}"; fi + local src_sha + src_sha=$(shasum -a 256 "${src}" | awk '{print $1}') + # 复用条件:三个都满足。只看 sha 会让"签名坏了/身份降级"永久固化(审查 B1)。 + if [ -f "${bin}" ] && [ -f "${stamp}" ]; then + local got_sha got_state + got_sha=$(cut -f1 "${stamp}" 2>/dev/null) + got_state=$(cut -f2 "${stamp}" 2>/dev/null) + if [ "${got_sha}" = "${src_sha}" ] && [ "${got_state}" = "${want_state}" ] \ + && codesign --verify --strict "${app}" >/dev/null 2>&1; then + return 1 + fi + fi + mkdir -p "${app}/Contents/MacOS" "$(dirname "${stamp}")" + cp "${src}" "${bin}" + # Info.plist 也只在内容变了才写:签名之后再改 plist 会让签名失效(审查 B1) + local plist="${app}/Contents/Info.plist" + local tmp_plist + tmp_plist=$(mktemp -t vdev-injector-plist) + cat > "${tmp_plist}" < @@ -99,27 +136,117 @@ cat > "${INJECTOR_APP}/Contents/Info.plist" <<'IPLIST' LSMinimumSystemVersion13.0 LSUIElement -IPLIST +PLIST + if [ ! -f "${plist}" ] || ! cmp -s "${tmp_plist}" "${plist}"; then + cp "${tmp_plist}" "${plist}" + fi + rm -f "${tmp_plist}" + if ! codesign --force --sign "${identity}" -i com.vdev.hid.injector \ + --options runtime --timestamp=none "${app}" >/dev/null 2>&1; then + echo "WARN: 签名失败(identity='${identity}'):本 bundle 不会被标记就绪,下次运行会重试。" >&2 + return 0 + fi + if ! codesign --verify --strict "${app}" >/dev/null 2>&1; then + echo "WARN: 签名后 codesign --verify --strict 失败:不写戳,下次运行会重试。" >&2 + return 0 + fi + # 只有"签名 + 校验"都过了才写戳 + printf '%s\t%s' "${src_sha}" "${want_state}" > "${stamp}" + return 0 +} -# 2a) 签名(身份可复现时) -# 优先 Apple Development(与本仓 crates/vdev-camera/Makefile 的 SIGN_APP_IDENTITY 一致), -# 其次 Developer ID;都没有则退回 ad-hoc 并**明确警告**——那种情况下 D 段的授权 -# 每轮都会失效,必须让使用者知道。 -INJECTOR_SIGN_IDENTITY="${VDEV_HID_SIGN_IDENTITY:-Apple Development: qingfeng gao (7L8FV63FAP)}" -if [ -n "${INJECTOR_NEEDS_SIGN:-}" ]; then - if codesign --force --sign "${INJECTOR_SIGN_IDENTITY}" -i com.vdev.hid.injector \ - --options runtime --timestamp=none "${INJECTOR_APP}" >/dev/null 2>&1; then - echo " 注入器已用稳定身份签名:${INJECTOR_SIGN_IDENTITY}" - printf '%s' "${VDEV_SHA}" > "${INJECTOR_STAMP}" +# ---- 自测模式:不需要证书、不需要前台焦点,供 CI/冒烟做**行为级**守卫 ---- +# 用法:macos-hid-access-verify.sh --self-test +# +# 为什么要它:只 grep 标识符的守卫抓不住"把修复摘掉"的写法(例如把签名换成 +# `--sign -`、删掉 INJECTOR_NEEDS_SIGN 的赋值、把写戳删掉)——审查 B2 实测那些 +# 变异都能骗过纯字符串守卫。这里直接跑 prepare_injector_app 的行为。 +if [ "${SELF_TEST}" = "1" ]; then + TMP_SELF=$(mktemp -d -t vdev-injector-selftest.XXXXXX) + SELF_OK=1 + SELF_SRC="${TMP_SELF}/src.bin" + SELF_APP="${TMP_SELF}/App.app" + SELF_STAMP="${TMP_SELF}/stamp" + SELF_BIN="${SELF_APP}/Contents/MacOS/VdevHidInjector" + # 夹具用**未被改动的** Mach-O:给 Apple 自带二进制追加字节会破坏它的签名, + # codesign 会以 "main executable failed strict validation" 拒签——那是夹具问题, + # 不是被测逻辑问题。所以"源变了"用**换一个源文件**表达,而不是改同一个文件。 + if [ -x "${VDEV}" ]; then SELF_SRC0="${VDEV}"; else SELF_SRC0="/bin/echo"; fi + cp "${SELF_SRC0}" "${SELF_SRC}" + # "源变了"必须换**内容不同**的文件:拿同一个 vdev 复制一份 sha 不变,会被正确复用 + SELF_SRC2="${TMP_SELF}/src2.bin" + cp /bin/ls "${SELF_SRC2}" + sha_of() { shasum -a 256 "$1" | awk '{print $1}'; } + stat_of() { stat -f "%m %i" "$1"; } + + echo "== 1) 首跑:应重写并写状态戳 ==" + prepare_injector_app "${SELF_SRC}" "${SELF_APP}" "${SELF_STAMP}" "-" >/dev/null || true + [ -f "${SELF_STAMP}" ] || { echo "FAIL: 首跑没有写戳"; SELF_OK=0; } + grep -q "adhoc" "${SELF_STAMP}" 2>/dev/null || { echo "FAIL: 戳里没有身份状态"; SELF_OK=0; } + SELF_M1=$(stat_of "${SELF_BIN}") + SELF_SHA1=$(sha_of "${SELF_SRC2}") + + echo "== 2) 源与身份都没变:应复用(不重写、不重签)==" + if prepare_injector_app "${SELF_SRC}" "${SELF_APP}" "${SELF_STAMP}" "-" >/dev/null 2>&1; then + echo "FAIL: 源没变却重写了 bundle(会打掉 cdhash 型授权)"; SELF_OK=0 + fi + [ "$(stat_of "${SELF_BIN}")" = "${SELF_M1}" ] || { echo "FAIL: 二进制 mtime/inode 变了"; SELF_OK=0; } + + echo "== 3) 源换了:应重签并更新戳 ==" + prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "-" >/dev/null || true + [ "$(stat_of "${SELF_BIN}")" != "${SELF_M1}" ] || { echo "FAIL: 源变了却没有重写"; SELF_OK=0; } + [ "$(cut -f1 "${SELF_STAMP}")" = "$(sha_of "${SELF_SRC2}")" ] || { echo "FAIL: 戳没有跟源更新"; SELF_OK=0; } + + echo "== 4) bundle 被改坏(签名失效):戳匹配也不该复用(审查 B1)==" + printf '' >> "${SELF_APP}/Contents/Info.plist" + if codesign --verify --strict "${SELF_APP}" >/dev/null 2>&1; then + echo "SKIP: 篡改未使 codesign 校验失败(环境差异),本段未验证" + else + SELF_M4=$(stat_of "${SELF_BIN}") + prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "-" >/dev/null || true + if codesign --verify --strict "${SELF_APP}" >/dev/null 2>&1; then + echo " 坏签名已被自动重做 ✓" + else + echo "FAIL: 校验失败的 bundle 被戳固化了(没有重做)"; SELF_OK=0 + fi + [ "$(stat_of "${SELF_BIN}")" != "${SELF_M4}" ] || { echo "FAIL: 没有真的重写二进制"; SELF_OK=0; } + fi + + echo "== 5) 身份变化:应重签(戳里记了身份,不只是 sha)==" + if prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "signed:fake-identity" >/dev/null 2>&1; then + echo " 身份变化已触发重做 ✓" else - echo "WARN: 用 '${INJECTOR_SIGN_IDENTITY}' 签名失败 → 退回 ad-hoc。" - # 没签成也记戳:至少避免每轮都无谓重写(重写会让 TCC 的 cdhash 型授权失效) + echo "FAIL: 身份变化没有触发重签(戳只比了 sha)"; SELF_OK=0 + fi + + rm -rf "${TMP_SELF}" + if [ "${SELF_OK}" = "1" ]; then echo HID_INJECTOR_SELFTEST=PASS; exit 0; fi + echo HID_INJECTOR_SELFTEST=FAIL + exit 1 +fi + +INJECTOR_STAMP="${INJECTOR_DIR}/.injector-source.sha256" +mkdir -p "${INJECTOR_DIR}" +# 并发保护:两份脚本同时改同一个 bundle 会把签名踩坏(审查 B1 实测) +if ! mkdir "${LOCK_DIR}" 2>/dev/null; then + echo "NOT_RUN: 另一个 ${0##*/} 实例正在准备同一个注入器(${LOCK_DIR});本脚本不支持并发运行。" + echo "HID_ACCESS_RESULT=NOT_RUN" + exit 2 +fi + +INJECTOR_SIGN_IDENTITY=$(resolve_sign_identity) +if prepare_injector_app "${VDEV}" "${INJECTOR_APP}" "${INJECTOR_STAMP}" "${INJECTOR_SIGN_IDENTITY}"; then + if [ "${INJECTOR_SIGN_IDENTITY}" = "-" ]; then + echo "WARN: 没有可用的签名身份 → 注入器退回 ad-hoc。" echo "WARN: ad-hoc 的 TCC 授权要求是 cdhash,二进制一变授权就失效(issue #58);" echo "WARN: D 段(授权前报错 → 授权后可用)将无法跨运行复现。" - printf '%s' "${VDEV_SHA}" > "${INJECTOR_STAMP}" + else + echo " 注入器已用稳定身份签名:${INJECTOR_SIGN_IDENTITY}" fi +else + echo " 注入器复用现有 bundle(源与身份都未变、签名校验通过)" fi -# 把「TCC 会拿到的要求」打出来做证据:身份型要求里不该出现 cdhash +echo " 注入器 codesign --verify --strict: $(codesign --verify --strict "${INJECTOR_APP}" >/dev/null 2>&1 && echo OK || echo FAILED)" INJECTOR_DR=$(codesign -dr - "${INJECTOR_APP}" 2>&1 | tail -1) echo " 注入器 designated requirement: ${INJECTOR_DR}" case "${INJECTOR_DR}" in @@ -169,7 +296,7 @@ fi # 还没真正拿到前台),先跑一个 throwaway 实例把它消化掉。 warm_out="${RUN_DIR}/warmup.out" : > "${warm_out}" -open -W -n "${PROBE_APP}" --args "${warm_out}" 1 >/dev/null 2>&1 || true +open -W -n "${PROBE_APP}" --args "${warm_out}" 1 >/dev/null || true # 锁屏前置判定:锁屏下任何 App 都无法成为前台/键窗口(探针会一直 READY # active=false),此时既不能验证也不该判红——按"没验证 ≠ 通过"退出 2。 @@ -207,7 +334,7 @@ run_case() { # 探针有 4s 等待窗口,所以要在它跑起来后立刻置前。 for _ in $(seq 1 30); do if pgrep -x VdevHidProbe >/dev/null 2>&1; then - osascript -e 'tell application "System Events" to set frontmost of process "VdevHidProbe" to true' >/dev/null 2>&1 || true + osascript -e 'tell application "System Events" to set frontmost of process "VdevHidProbe" to true' >/dev/null || true break fi sleep 0.1 @@ -232,6 +359,9 @@ run_case() { return fi : > "${RUN_DIR}/inject.out"; : > "${RUN_DIR}/inject.err" + # READY 之后仍可能有极短的在途事件(探针已在 READY 时清零计数,这里再降一点概率)。 + # 两种注入方式都要等,不能只等 direct —— app 分支正是 #58 的路径(审查 S2)。 + sleep 0.3 if [ "${mode}" = "app" ]; then local app=$1; shift # VDEV_HID_CASE_ENV:可选的 `open --env K=V`(阳性对照用,见 A2)。 @@ -244,10 +374,7 @@ run_case() { "${app}" --args "$@" 2>/dev/null fi else - # READY 之后仍可能有极短的在途事件,注入前留一小段静默期(探针已在 READY - # 时清零计数,这里只是再降一点概率) - sleep 0.3 - "$@" > "${RUN_DIR}/inject.out" 2>&1 + "$@" > "${RUN_DIR}/inject.out" 2>&1 fi inject_out_text="$(cat "${RUN_DIR}/inject.out" "${RUN_DIR}/inject.err" 2>/dev/null)" echo " inject_out=$(echo "${inject_out_text}" | tr '\n' ' ' | tail -c 160)" @@ -351,6 +478,7 @@ if [ "${post_d}" = "true" ]; then fail "D: 该身份已授权(post_access=true)却只送达 '${probe_typed}'" fi else + env_fail "D: 稳定注入器身份尚未授权 → #58 的\"授权跨运行持久\"这一段没有验证(整体 PASS 不能代表它已验收)" echo " D: 该身份尚未授权——在 系统设置 → 隐私与安全性 → 辅助功能 里加入" echo " ${INJECTOR_APP} 后重跑本脚本,可复现「授权前报错 → 授权后可用」" echo " (若列表里已有同名旧条目:那是 cdhash 型的失效授权,先 \"-\" 删掉再重新添加," diff --git a/scripts/acceptance/macos-hid-type-probe.swift b/scripts/acceptance/macos-hid-type-probe.swift index c133498..0b7ef72 100644 --- a/scripts/acceptance/macos-hid-type-probe.swift +++ b/scripts/acceptance/macos-hid-type-probe.swift @@ -99,8 +99,8 @@ while Date() < deadline && !(app.isActive && window.isKeyWindow) { window.typed = "" window.keys = 0 tapCount = 0 -// tap 计数的基线仍放在 READY 行:验收脚本只应比较注入前后的增量, -// 避免用户/系统在同一窗口内产生的真实键事件被误算成注入丢失。 +// 清零即基线:READY 行报出来的 tap=0 就是"从这一刻起"的计数,验收脚本比较的是 +// 注入前后的增量,因此用户/系统在同一窗口内产生的真实键事件不会被误算成注入丢失。 report("READY active=\(app.isActive) key=\(window.isKeyWindow) tap_ok=\(tapOk) tap=\(tapCount)") DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { diff --git a/scripts/acceptance/macos-smoke.sh b/scripts/acceptance/macos-smoke.sh index 26e039c..0aa4389 100755 --- a/scripts/acceptance/macos-smoke.sh +++ b/scripts/acceptance/macos-smoke.sh @@ -42,8 +42,12 @@ require_grep 'unauth.$$.' scripts/acceptance/macos-hid-access-verify.sh '未授 require_grep 'EnableSecureEventInput' scripts/acceptance/macos-hid-access-verify.sh '安全输入必须用 EnableSecureEventInput 确定性开启,不能靠人工凑状态' require_grep 'secure_input=true' scripts/acceptance/macos-hid-access-verify.sh '安全输入用例必须先确认系统真的处于安全输入,再断言行为' require_grep 'HID_ACCESS_RESULT=NOT_RUN' scripts/acceptance/macos-hid-access-verify.sh 'HID 权限验收必须保留 NOT_RUN 出口(锁屏/无权限身份等环境不具备时不算通过)' -require_grep 'codesign --force --sign' scripts/acceptance/macos-hid-access-verify.sh '注入器 app 必须用稳定身份签名(否则 TCC 授权是 cdhash 型,二进制一变就失效,issue #58)' -require_grep 'INJECTOR_STAMP' scripts/acceptance/macos-hid-access-verify.sh '注入器必须用源二进制 sha256 戳判断是否重写(每轮重写会让 cdhash 型授权失效,issue #58)' +# 注入器准备逻辑(#58):字符串守卫钉"用的是身份变量"(`--sign -` 会退化回 cdhash 型), +# 行为守卫交给脚本自带的 --self-test(下面直接跑),grep 挡不住的语义摘除由它兜住。 +require_grep 'prepare_injector_app "${VDEV}" "${INJECTOR_APP}" "${INJECTOR_STAMP}" "${INJECTOR_SIGN_IDENTITY}"' scripts/acceptance/macos-hid-access-verify.sh '注入器必须把解析出的稳定身份传进 prepare_injector_app,不能传 -(否则退回 cdhash 型授权,issue #58)' +require_grep 'security find-identity' scripts/acceptance/macos-hid-access-verify.sh '注入器签名身份必须真的从 keychain 里解析,不能写死一个可能不存在的名字' +require_grep 'INJECTOR_STAMP' scripts/acceptance/macos-hid-access-verify.sh '注入器必须用源二进制状态戳判断是否重写(每轮重写会让 cdhash 型授权失效,issue #58)' +require_grep 'codesign --verify --strict' scripts/acceptance/macos-hid-access-verify.sh '签名后必须 codesign --verify --strict,失败不能写戳(否则坏状态被固化,审查 B1)' require_grep 'window.typed = ""' scripts/acceptance/macos-hid-type-probe.swift '探针必须在 READY 前清零计数(开窗瞬间的迟到事件会污染逐字断言)' require_grep 'exit 2' scripts/acceptance/macos-hid-access-verify.sh 'HID 权限验收的环境不具备分支必须 exit 2(与用例失败区分)' @@ -54,6 +58,15 @@ require_grep 'secure_input' crates/vdev-hid/src/lib.rs '注入路径必须报出 require_grep 'let Some(pb) = acquire_pixel_buffer(w, h) else {' crates/vdev-camera-ext/src/main.rs 'send_bgra 必须走 acquire_pixel_buffer(像素池复用)' require_grep 'fn acquire_buffer_with' crates/vdev-camera-ext/src/main.rs '像素池必须有可注入的决策内核(复用/回落两条路径要能被单测钉住)' +# 行为级守卫:#58 的修复是一段"有条件重写"的状态机,纯 grep 挡不住语义摘除 +# (把签名换成 --sign -、删掉复用条件、删掉写戳……)。用隔离临时目录直接跑它的 +# 自测模式:首跑写戳 / 源不变不重写 / 源变则重签 / 坏签名不被戳固化 / 身份变化重签。 +if ! bash scripts/acceptance/macos-hid-access-verify.sh --self-test >/tmp/vdev-injector-selftest.log 2>&1; then + echo "FAIL: 注入器准备逻辑自测未通过(issue #58 的复用/重签状态机回归)" >&2 + cat /tmp/vdev-injector-selftest.log >&2 + exit 1 +fi + if ! command -v python3 >/dev/null 2>&1; then echo "FAIL: 未找到 python3(check-docs.py 需要)" >&2 exit 1 From 3d0ddb0b6da88736e03f848924e3ba00420899fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Wed, 16 Sep 2026 18:34:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(hid-acceptance):=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20L1/L2=20=E2=80=94=E2=80=94=20=E9=94=81=E5=8F=AA=E7=94=B1?= =?UTF-8?q?=E6=8C=81=E9=94=81=E8=80=85=E5=88=A0=E3=80=81=E6=88=B3=E8=AE=B0?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E7=AD=BE=E5=90=8D=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审第二轮抓到两条: L1 互斥锁形同虚设:`cleanup` 无条件 `rmdir` 锁目录,于是"拿到 NOT_RUN 的人重跑" 会把正在跑的实例的锁删掉,第三个实例就能与第一个并发改同一个 bundle(实测 6 路 并发能把 bundle 签成 `invalid Info.plist`);`--self-test` 同样会误删活锁。 改为 `LOCK_HELD` 标记,**只有持锁者**在 EXIT 里删锁;NOT_RUN 文案给出人工清锁命令。 L2 戳会撒谎:`want_state` 由**请求的**身份推导,而 `--sign -`(或证书不可用)时 codesign 仍会以 ad-hoc 成功 → 戳写成 `signed:<身份>`、复用条件把降级的 bundle 当"最新",永不自愈(这正是 #58 的失效模式换了个地方复现)。改为签完用实际结果 推导状态(`codesign -dv` 的 `Signature=adhoc` / `Authority=`):请求了稳定身份却 落成 ad-hoc 时**不写戳**并返回 2(下轮重试),写进戳的永远是盘上真实状态。 自测补第 6 步:用不可用身份签名后,戳里不得出现 `signed:<该身份>`。 smoke 再补一条字符串守卫钉住函数内必须 `--sign "${identity}"`(写成 `-` 会红)。 阳性对照(改完即验): - 有活跃锁时跑 `--self-test` → 锁存活 ✓;锁被占时整脚本 NOT_RUN 且退出后锁仍在 ✓ - 函数内签名改成 `--sign -` → smoke 红 ✓ - 删掉写戳 / 身份传 `-` → smoke 红 ✓(沿用上一轮的两条对照) 顺带:删掉未使用的 `SELF_SHA1`(shellcheck SC2034);签名失败时不再误报 "已用稳定身份签名"(按 prepare 的返回码 0/1/2 分别输出)。 真机:注入器准备逻辑重签一次后,连续两次整脚本 `fails=0 env_bad=0`、D 段 `post_access=true` 逐字送达;戳内容为 `\tsigned:Apple Development: qingfeng gao (7L8FV63FAP)`(实际签名状态)。 Refs #58 --- scripts/acceptance/macos-hid-access-verify.sh | 44 ++++++++++++++++--- scripts/acceptance/macos-smoke.sh | 1 + 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/scripts/acceptance/macos-hid-access-verify.sh b/scripts/acceptance/macos-hid-access-verify.sh index 5b9010b..786112c 100644 --- a/scripts/acceptance/macos-hid-access-verify.sh +++ b/scripts/acceptance/macos-hid-access-verify.sh @@ -49,8 +49,10 @@ cleanup() { pkill -x VdevHidProbe 2>/dev/null || true pkill -x SecureInputHold 2>/dev/null || true rm -rf "${RUN_DIR}" - # 注入器目录的互斥锁也让位于同一个 EXIT trap(脚本里只能有一处 EXIT trap) - rmdir "${INJECTOR_DIR:-/nonexistent}/.lock" 2>/dev/null || true + # 互斥锁只由**持锁者**删除:非持锁者也删锁会让"看到 NOT_RUN 再重跑"变成三实例并发(审查 L1) + if [ "${LOCK_HELD:-0}" = "1" ]; then + rmdir "${INJECTOR_DIR:-/nonexistent}/.lock" 2>/dev/null || true + fi } trap cleanup EXIT @@ -150,8 +152,21 @@ PLIST echo "WARN: 签名后 codesign --verify --strict 失败:不写戳,下次运行会重试。" >&2 return 0 fi - # 只有"签名 + 校验"都过了才写戳 - printf '%s\t%s' "${src_sha}" "${want_state}" > "${stamp}" + # 实际签成了什么?**不能拿请求值当结果**(审查 L2):`--sign -` 或身份失效时 + # codesign 仍可能以 ad-hoc 成功,此时戳若写 signed:<身份> 就是撒谎,复用条件 + # 会把降级的 bundle 当"最新",且永不自愈。 + local actual_state + if codesign -dv "${app}" 2>&1 | grep -q '^Signature=adhoc'; then + actual_state="adhoc" + else + actual_state="signed:$(codesign -dv --verbose=4 "${app}" 2>&1 | sed -n 's/^Authority=//p' | head -1)" + fi + if [ "${identity}" != "-" ] && [ "${actual_state}" = "adhoc" ]; then + echo "WARN: 请求用身份 '${identity}' 签名,实际落成 ad-hoc(证书不可用?):不写戳,下次重试。" >&2 + return 2 + fi + # 只有"签名 + 校验"都过了才写戳,且写的是**实际**状态 + printf '%s\t%s' "${src_sha}" "${actual_state}" > "${stamp}" return 0 } @@ -219,6 +234,16 @@ if [ "${SELF_TEST}" = "1" ]; then echo "FAIL: 身份变化没有触发重签(戳只比了 sha)"; SELF_OK=0 fi + echo "== 6) 请求一个不可用的身份:绝不能把 ad-hoc 结果记成 signed:(审查 L2)==" + prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "Apple Development: 不存在的证书 (XXXXXXXXXX)" >/dev/null 2>&1 || true + if grep -q "signed:Apple Development: 不存在的证书" "${SELF_STAMP}" 2>/dev/null; then + echo "FAIL: 戳把 ad-hoc 结果记成了 signed:<请求身份>(戳会撒谎,复用条件不再可信)"; SELF_OK=0 + elif codesign -dv "${SELF_APP}" 2>&1 | grep -q '^Signature=adhoc' && [ "$(cut -f2 "${SELF_STAMP}" 2>/dev/null)" != "adhoc" ]; then + echo "FAIL: 实际是 ad-hoc,戳状态却不是 adhoc($(cut -f2 "${SELF_STAMP}" 2>/dev/null))"; SELF_OK=0 + else + echo " 戳状态与实际签名一致 ✓" + fi + rm -rf "${TMP_SELF}" if [ "${SELF_OK}" = "1" ]; then echo HID_INJECTOR_SELFTEST=PASS; exit 0; fi echo HID_INJECTOR_SELFTEST=FAIL @@ -228,14 +253,21 @@ fi INJECTOR_STAMP="${INJECTOR_DIR}/.injector-source.sha256" mkdir -p "${INJECTOR_DIR}" # 并发保护:两份脚本同时改同一个 bundle 会把签名踩坏(审查 B1 实测) -if ! mkdir "${LOCK_DIR}" 2>/dev/null; then +if mkdir "${LOCK_DIR}" 2>/dev/null; then + LOCK_HELD=1 +else echo "NOT_RUN: 另一个 ${0##*/} 实例正在准备同一个注入器(${LOCK_DIR});本脚本不支持并发运行。" + echo " 若确认没有实例在跑(例如上次被 kill -9),手动清锁:rmdir '${LOCK_DIR}'" echo "HID_ACCESS_RESULT=NOT_RUN" exit 2 fi INJECTOR_SIGN_IDENTITY=$(resolve_sign_identity) -if prepare_injector_app "${VDEV}" "${INJECTOR_APP}" "${INJECTOR_STAMP}" "${INJECTOR_SIGN_IDENTITY}"; then +PREP_RC=0 +prepare_injector_app "${VDEV}" "${INJECTOR_APP}" "${INJECTOR_STAMP}" "${INJECTOR_SIGN_IDENTITY}" || PREP_RC=$? +if [ "${PREP_RC}" = "2" ]; then + echo "WARN: 注入器本次没有签成稳定身份(见上),下轮会重试;D 段可能因此无法持久。" +elif [ "${PREP_RC}" = "0" ]; then if [ "${INJECTOR_SIGN_IDENTITY}" = "-" ]; then echo "WARN: 没有可用的签名身份 → 注入器退回 ad-hoc。" echo "WARN: ad-hoc 的 TCC 授权要求是 cdhash,二进制一变授权就失效(issue #58);" diff --git a/scripts/acceptance/macos-smoke.sh b/scripts/acceptance/macos-smoke.sh index 0aa4389..4e31453 100755 --- a/scripts/acceptance/macos-smoke.sh +++ b/scripts/acceptance/macos-smoke.sh @@ -45,6 +45,7 @@ require_grep 'HID_ACCESS_RESULT=NOT_RUN' scripts/acceptance/macos-hid-access-ver # 注入器准备逻辑(#58):字符串守卫钉"用的是身份变量"(`--sign -` 会退化回 cdhash 型), # 行为守卫交给脚本自带的 --self-test(下面直接跑),grep 挡不住的语义摘除由它兜住。 require_grep 'prepare_injector_app "${VDEV}" "${INJECTOR_APP}" "${INJECTOR_STAMP}" "${INJECTOR_SIGN_IDENTITY}"' scripts/acceptance/macos-hid-access-verify.sh '注入器必须把解析出的稳定身份传进 prepare_injector_app,不能传 -(否则退回 cdhash 型授权,issue #58)' +require_grep 'codesign --force --sign "${identity}"' scripts/acceptance/macos-hid-access-verify.sh '签名必须用函数传入的身份变量(写成 --sign - 会静默退回 ad-hoc,戳还会撒谎,审查 L2)' require_grep 'security find-identity' scripts/acceptance/macos-hid-access-verify.sh '注入器签名身份必须真的从 keychain 里解析,不能写死一个可能不存在的名字' require_grep 'INJECTOR_STAMP' scripts/acceptance/macos-hid-access-verify.sh '注入器必须用源二进制状态戳判断是否重写(每轮重写会让 cdhash 型授权失效,issue #58)' require_grep 'codesign --verify --strict' scripts/acceptance/macos-hid-access-verify.sh '签名后必须 codesign --verify --strict,失败不能写戳(否则坏状态被固化,审查 B1)' From 31420ac6e75fd50fdfe5a524151375f5a3f0c07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E5=BA=86=E4=B8=B0?= Date: Wed, 16 Sep 2026 18:41:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(hid-acceptance):=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E9=9D=9E=E9=98=BB=E5=A1=9E=E9=A1=B9=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=BF=94=E5=9B=9E=E7=A0=81=E3=80=81=E4=B8=89?= =?UTF-8?q?=E6=AE=B5=E6=88=B3=EF=BC=88=E5=88=AB=E5=90=8D=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=AF=8F=E8=BD=AE=E9=87=8D=E7=AD=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审第三轮通过(无阻塞项),顺手收掉它列的两条非阻塞项 + 一条说明: - 失败路径返回 2:`codesign` 直接失败 / `verify --strict` 失败此前都 `return 0`, 调用点会把"没签成"打成"已用稳定身份签名:<身份>"(与紧邻的 `verify: FAILED` 同屏 自相矛盾)。现在返回 2 走"本次没签成稳定身份,下轮重试"分支。 - 戳记三段 `<源 sha>\t<请求身份>\t<实际签名状态>`,复用条件同时核三段 + verify: * 只比 sha → 坏签名/身份降级会被固化(上一轮 B1); * 只比"实际状态" → 传证书别名(如 SHA-1)时,请求串与 `Authority=` 叶子 CN 不等, 会变成"每轮都重签"(复审实测 inode 每轮都变),丢掉 #58 想保住的"必要时才重写"; * 三段分开存后:显式换身份 → 重签 ✓;别名写法 → 复用 ✓;盘上签名与戳不符 → 重做 ✓。 抽 `signature_state()` 统一"当前实际签名状态"口径(复用判定与写戳共用)。 - 脚本头补一条事实:真机注入的"逐字一致"断言在机器满载时会被系统丢事件打红 (实测 load 165 时 B/D 丢字、空载全绿)——先看 `uptime` 再判断是被测坏了还是负载拖的。 自测第 5 步改为按返回码语义断言(rc!=1 = 没复用;身份不可用时 rc=2 也算没复用)。 真机:整脚本一次 PASS(复用路径 logged「注入器复用现有 bundle」不重签); 戳为 `\tApple Development: qingfeng gao (7L8FV63FAP)\tsigned:Apple Development: qingfeng gao (7L8FV63FAP)`。 Refs #58 --- scripts/acceptance/macos-hid-access-verify.sh | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/scripts/acceptance/macos-hid-access-verify.sh b/scripts/acceptance/macos-hid-access-verify.sh index 786112c..8188d19 100644 --- a/scripts/acceptance/macos-hid-access-verify.sh +++ b/scripts/acceptance/macos-hid-access-verify.sh @@ -12,6 +12,10 @@ # C) 安全输入开启 → 必须报错(用 EnableSecureEventInput 确定性开启) # D) 稳定注入器身份 → 人工在系统设置里授权过的话,此时应当能送达(信息项) # +# 注意:本脚本注入的是真实合成键事件,"逐字一致"断言在**机器满载**时会因系统丢弃 +# 事件而失败(实测 load 165 时 B/D 段丢字、空载即全绿)。真机跑验收请在空闲机器上做, +# 失败时先看 `uptime` 再判断是被测功能坏了还是被负载拖了。 +# # 用法:macos-hid-access-verify.sh [path/to/vdev] # 退出码:0 = 全部通过;1 = 有用例失败;2 = 环境不具备(没验证,不算通过) set -u @@ -103,20 +107,35 @@ resolve_sign_identity() { printf '%s' "-" # 没有任何可用身份 → ad-hoc(并会在下面 WARN) } -# 返回 0=刚准备好(重写/重签过),1=复用了现有 bundle +# 盘上这份 bundle 当前的实际签名状态:`adhoc` 或 `signed:`。 +signature_state() { + local app=$1 + if codesign -dv "${app}" 2>&1 | grep -q '^Signature=adhoc'; then + printf 'adhoc' + else + printf 'signed:%s' "$(codesign -dv --verbose=4 "${app}" 2>&1 | sed -n 's/^Authority=//p' | head -1)" + fi +} + +# 返回 0=刚准备好(重写/重签过),1=复用了现有 bundle,2=没签成(下轮重试) prepare_injector_app() { local src=$1 app=$2 stamp=$3 identity=$4 local bin="${app}/Contents/MacOS/VdevHidInjector" - local want_state - if [ "${identity}" = "-" ]; then want_state="adhoc"; else want_state="signed:${identity}"; fi + # 戳里存三段:源 sha、**请求的**身份串、**实际**签名状态。 + # - 请求串用于"操作者显式换了身份就该重签"; + # - 实际状态用于"盘上是不是真的这个签名"(防 `--sign -` 悄悄降级); + # - 两者分开存,别名身份(如传证书 SHA-1)才不会被误判成"每轮都变了"而反复重签。 local src_sha src_sha=$(shasum -a 256 "${src}" | awk '{print $1}') - # 复用条件:三个都满足。只看 sha 会让"签名坏了/身份降级"永久固化(审查 B1)。 if [ -f "${bin}" ] && [ -f "${stamp}" ]; then - local got_sha got_state + local got_sha got_req got_actual actual_now got_sha=$(cut -f1 "${stamp}" 2>/dev/null) - got_state=$(cut -f2 "${stamp}" 2>/dev/null) - if [ "${got_sha}" = "${src_sha}" ] && [ "${got_state}" = "${want_state}" ] \ + got_req=$(cut -f2 "${stamp}" 2>/dev/null) + got_actual=$(cut -f3 "${stamp}" 2>/dev/null) + actual_now="$(signature_state "${app}")" + if [ "${got_sha}" = "${src_sha}" ] && [ "${got_req}" = "${identity}" ] \ + && [ "${got_actual}" = "${actual_now}" ] \ + && { [ "${identity}" = "-" ] || [ "${actual_now}" != "adhoc" ]; } \ && codesign --verify --strict "${app}" >/dev/null 2>&1; then return 1 fi @@ -146,27 +165,23 @@ PLIST if ! codesign --force --sign "${identity}" -i com.vdev.hid.injector \ --options runtime --timestamp=none "${app}" >/dev/null 2>&1; then echo "WARN: 签名失败(identity='${identity}'):本 bundle 不会被标记就绪,下次运行会重试。" >&2 - return 0 + return 2 fi if ! codesign --verify --strict "${app}" >/dev/null 2>&1; then echo "WARN: 签名后 codesign --verify --strict 失败:不写戳,下次运行会重试。" >&2 - return 0 + return 2 fi # 实际签成了什么?**不能拿请求值当结果**(审查 L2):`--sign -` 或身份失效时 # codesign 仍可能以 ad-hoc 成功,此时戳若写 signed:<身份> 就是撒谎,复用条件 # 会把降级的 bundle 当"最新",且永不自愈。 local actual_state - if codesign -dv "${app}" 2>&1 | grep -q '^Signature=adhoc'; then - actual_state="adhoc" - else - actual_state="signed:$(codesign -dv --verbose=4 "${app}" 2>&1 | sed -n 's/^Authority=//p' | head -1)" - fi + actual_state="$(signature_state "${app}")" if [ "${identity}" != "-" ] && [ "${actual_state}" = "adhoc" ]; then echo "WARN: 请求用身份 '${identity}' 签名,实际落成 ad-hoc(证书不可用?):不写戳,下次重试。" >&2 return 2 fi # 只有"签名 + 校验"都过了才写戳,且写的是**实际**状态 - printf '%s\t%s' "${src_sha}" "${actual_state}" > "${stamp}" + printf '%s\t%s\t%s' "${src_sha}" "${identity}" "${actual_state}" > "${stamp}" return 0 } @@ -197,7 +212,8 @@ if [ "${SELF_TEST}" = "1" ]; then echo "== 1) 首跑:应重写并写状态戳 ==" prepare_injector_app "${SELF_SRC}" "${SELF_APP}" "${SELF_STAMP}" "-" >/dev/null || true [ -f "${SELF_STAMP}" ] || { echo "FAIL: 首跑没有写戳"; SELF_OK=0; } - grep -q "adhoc" "${SELF_STAMP}" 2>/dev/null || { echo "FAIL: 戳里没有身份状态"; SELF_OK=0; } + grep -q "adhoc" "${SELF_STAMP}" 2>/dev/null || { echo "FAIL: 戳里没有实际签名状态"; SELF_OK=0; } + [ "$(cut -f3 "${SELF_STAMP}" 2>/dev/null)" = "adhoc" ] || { echo "FAIL: 第 3 字段应为实际状态 adhoc"; SELF_OK=0; } SELF_M1=$(stat_of "${SELF_BIN}") SELF_SHA1=$(sha_of "${SELF_SRC2}") @@ -228,18 +244,24 @@ if [ "${SELF_TEST}" = "1" ]; then fi echo "== 5) 身份变化:应重签(戳里记了身份,不只是 sha)==" - if prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "signed:fake-identity" >/dev/null 2>&1; then - echo " 身份变化已触发重做 ✓" + # 注意返回码:0=重做过,1=复用了,2=没签成(身份不可用时正是这条)。 + # "没有复用"才是本步要断言的(rc != 1)。 + set +e + prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "signed:fake-identity" >/dev/null 2>&1 + SELF_RC5=$? + set -e + if [ "${SELF_RC5}" != "1" ]; then + echo " 身份变化没有复用(rc=${SELF_RC5})✓" else - echo "FAIL: 身份变化没有触发重签(戳只比了 sha)"; SELF_OK=0 + echo "FAIL: 身份变化被复用了(戳只比了 sha)"; SELF_OK=0 fi echo "== 6) 请求一个不可用的身份:绝不能把 ad-hoc 结果记成 signed:(审查 L2)==" prepare_injector_app "${SELF_SRC2}" "${SELF_APP}" "${SELF_STAMP}" "Apple Development: 不存在的证书 (XXXXXXXXXX)" >/dev/null 2>&1 || true if grep -q "signed:Apple Development: 不存在的证书" "${SELF_STAMP}" 2>/dev/null; then echo "FAIL: 戳把 ad-hoc 结果记成了 signed:<请求身份>(戳会撒谎,复用条件不再可信)"; SELF_OK=0 - elif codesign -dv "${SELF_APP}" 2>&1 | grep -q '^Signature=adhoc' && [ "$(cut -f2 "${SELF_STAMP}" 2>/dev/null)" != "adhoc" ]; then - echo "FAIL: 实际是 ad-hoc,戳状态却不是 adhoc($(cut -f2 "${SELF_STAMP}" 2>/dev/null))"; SELF_OK=0 + elif codesign -dv "${SELF_APP}" 2>&1 | grep -q '^Signature=adhoc' && [ "$(cut -f3 "${SELF_STAMP}" 2>/dev/null)" != "adhoc" ]; then + echo "FAIL: 实际是 ad-hoc,戳的实际状态字段却不是 adhoc($(cut -f3 "${SELF_STAMP}" 2>/dev/null))"; SELF_OK=0 else echo " 戳状态与实际签名一致 ✓" fi