· engineering· 约 25 分钟精读

Offline App Verification Surface No Server Logs — Technical Analysis | KeyRotate Technology

核心要点速览 · TL;DR 概要

TagWorks、SlingGuard、QubitLab 三个 App 都没有服务端:没有日志可查、没有灰度可收、没有崩溃上报可等。本文清点它们留在设备这一侧的整套验证面:9 个启动参数端口、176 个测试函数、4 台实验模型的 25 个不变量测试、一测两用的截图流水线,以及把失败也一并归档的证据包。

Offline App Verification Surface No Server Logs — Technical Analysis | KeyRotate Technology

2026 年 11 月 10 日有一则消息:Google DeepMind 宣布用 AI 完成了 Signal 协议的形式化验证。协议是数学对象,可以被证明;一个承诺”数据不出设备”的应用却不是,它没有可以被证明的那条性质,只剩下一侧可以反复执行的验证面。

密旋科技的三款产品——QubitLab(iOS 量子物理教育沙盒)、TagWorks(AS/NZS 3760 电气合规工具)、SlingGuard(LOLER/OSHA 吊索具核验工具)——共享同一组架构约束:无账号、无服务端、无遥测。这组约束换来隐私,也拿走了一整类工程手段:线上问题没有日志可回查,新版本没有灰度可回滚,崩溃不会自己上报。验证只能长在设备与构建主机这一侧。

本文不做方法论探讨,只盘点这台机器上真实存在的东西:三个工程合计 176 个测试函数、6,581 行测试代码、9 个启动参数端口、789 处断言,外加 57 帧走查录像与 31 份执行结果包。

三类注入端口,三条证据出口上:把确定状态灌进 App 的入口;下:App 吐出可复查证据的出口。中间没有任何网络调用。注入 · 三扇门启动参数(9 个)wipe / seed / seedFull / pro …目录注入(TagWorks)storageDirectory = tmp扫码载荷注入:一行 argv 顶掉相机被测层 · 两个可离线求值的结构纯函数规则层Rules.evaluate / status(now:)归一 · 钳位 · 冻结 · 双记录闸门不变量断言(25 个关卡模型测试)swift test 在 macOS 原生可跑不依赖模拟器 · 不依赖签名App 沙盒 · 无账号 · 无网络 · 无遥测 · 无服务端校验9 个参数端口里,只有 SlingGuard 的权益端口被 #if DEBUG 包住出口一 · 断言结果789 XCTAssert / 176 函数xcresult 31 份 · 日志 32 份失败记录与通过同包归档出口二 · 界面截图XCTAttachment keepAlways附件名 = 商店素材文件名一次运行同时干两件事出口三 · 走查录像recordVideo 包住测试57 帧 · 1640 × 2360抽帧目录按版本号命名验证面全部长在构建主机这一侧:数据从参数进,证据以文件出,中间不经过任何服务器。

左右滑动查看完整图表

一、总账:验证代码有多少,长在哪

工程源代码行测试行单测函数UI 测试函数测试占比
TagWorks4,5911,740511737.9%
SlingGuard4,5311,49828733.1%
QubitLab32,0473,343492410.4%
合计41,1696,5811284816.0%

两个比例值得解释。TagWorks 与 SlingGuard 的测试占比超过三分之一,因为它们的价值集中在判定逻辑:一个阈值算错就是合规事故,测试预算优先砸在纯函数层。QubitLab 只有 10.4%,因为它的体量大头是内容——96 个关卡的视图、1,900 多个本地化键、192 支内置课程视频;内容靠目录不变量测试和发布前的资源审计脚本兜底,不靠逐关断言。

断言的分布也说明同一件事:176 个测试函数里,单元测试写了 651 处 XCTAssert,UI 测试只有 138 处。真正的判定回归在模型层跑,UI 层主要证明”流程走得通、元素看得见”。

二、唯一的数据进门方式:9 个启动参数

一个不联网、不读外部文件的 App,测试数据怎么进去?三个工程给出的答案一致:启动参数。读取方式全部是同一行 CommandLine.arguments.contains(...),全项目合计 9 个参数名。

参数谁在读消费方作用
-uiTestWipeTG / SG两个存储的 init先删落盘文件再决定装载什么
-uiTestSeedTG / SG同上灌入 3 件小样本
-uiTestSeedFullTG / SG同上 + TG 付费墙灌到免费额度顶格(25 / 20 件)
-uiTestPro三款三个 ProAccess.init本地直接判 Pro,绕开商店
-uiTestTG付费墙测试分支显示假购买按钮,点了就解锁
-uiTestMockCameraTGScanView不建采集会话,深色底上搭取景框
-uiTestMockScanPayloadTGScannerSession取下一个参数当二维码载荷
-uiTestWalkthroughQL模型 2 处、奥德赛、家长门、语言、1 台引擎全解锁与自动放行
-appLanguageQLLanguageController手工指定语言,优先于系统

对完这份清单,再数一遍测试脚本实际传的参数,会得到另一个 9 个。两份清单各有一个对方没有的名字:应用侧读 -uiTest,但没有任何测试传它——付费墙那个分支实际是被 -uiTestSeedFull 触发的;测试侧传 -uiTestMode,全工程零处读取,是早期走查脚本留下的死参数。参数清单与测试清单各自演化了半年,交叉对账一次只暴露出这两个缺口,但这说明一件事:注入端口本身也需要一道盘点测试,目前它还不在。

三个执行顺序细节值得记下。第一,-uiTestWipe 在两个存储的 init 里排在播种之前,先删后灌,保证走查起点确定。第二,参数是三档的:无参数读真实台账,-uiTestSeed 给 3 件样本,-uiTestSeedFull 顶格——走查用中间档,额度边界用顶格档,两条路径各要各的数据量。第三,二维码载荷不是布尔开关,而是 firstIndex(of:) 之后取下一个元素,所以一行参数就能伪造任意一条识别结果。

种子数据的选题暴露了另一个意图。TagWorks 的 3 件样本分别是:冲击钻(62 mΩ 接地 / 120 MΩ 绝缘,PASS,200 天前检过)、休息室水壶(145 mΩ / 0.4 MΩ,FAIL,400 天前)、前台笔记本(Class II,200 MΩ,30 天前)。三件样本点亮登记册的全部检索分面:合格、失败、临期、超期、一类设备、二类设备、有无读数记录。夹具是照着分面反推出来的——走查每开一次列表,等于顺手回归了一次筛选器。SlingGuard 的种子连报告页眉都替走查备好了:公司名写进档案,每一帧录像里的 PDF 都有署名主体。

三、模型层留没留缝,是两种命运

TagWorks 的 RegisterStore.init 带了三个可注入参数:fileManager、storageDirectory、loadsPersistedRegister。就这三个参数,让 6 个持久化测试可以完全绕开真实沙盒:每个测试拿一个 UUID 后缀的临时目录建 store,tearDown 里统一删除。

其中一条测试值得抄下来。往 register.json 写进一截坏 JSON,然后做三件事:断言 store 报告 invalidRegister 状态;断言 flush() 返回假;最后把盘上字节整个读回来,与当初写进去的那截坏字节做逐字节相等比较。也就是说,“解码失败不许覆盖原文件”这条 fail-closed 规则,不是靠评审承诺,而是被一个字节级断言钉死。同一文件里还有一条:标识 " TST-001 " 带空格入库,扫码查询能命中,但测试同时断言存储里那个字段一个字符都没被改写——归一化只发生在读取面,不回写存储。

SlingGuard 是另一种活法:RiggingStore.init() 不带任何参数。它的 4 个备份测试直接 RiggingStore() 再 seedSmallDemoData(),而每次 seed 都会 save() 落盘。缝没留,seed 函数就身兼两职——既是测试夹具,也是真实沙盒的写入者。它的 DomainTests 之所以还能干净地跑,靠的是把判定全部收在 asset.status(now:) 这类纯函数上:时钟从参数进,不碰存储。没有缝的代价不是测不了,而是测的地方和落盘的地方错开了。

QubitLab 则根本没有落盘可测:全工程零处 FileManager,进度走 @AppStorage,所以 49 个单元测试全部在模型层求值,没有持久化测试文件。三个工程放在一起,恰好是”存储可测性”的三档光谱。

四、物理有自己的验收口径:25 个不变量测试

QubitLab 有 4 台实验模型各配了一个测试文件:放射性时钟 10 个、康普顿散射 7 个、经典势垒 4 个、波包碰撞 4 个,合计 25 个。数量不多,但四台引擎重复的是同一族不变量,这是刻意的。

第一类是概率守恒:衰变概率与存活概率必须归一,波包的反射与透射必须互补;贝尔态预置后四格概率应约为 0.5 / 0 / 0 / 0.5,断言容差写的是 0.05。第二类是钳位:观测窗口传 −4 会被夹到下界,传 99 会被夹到上界,越界输入不允许穿透到模型深处。第三类是冻结:recordObservation 记下一次观测之后,再拧控制量,latestObservation 必须原样不动,hasPendingControlChange 转真,预测值继续动而记录值不许动。第四类是闸门与复位:比较结论需要两份不同参数下的记录,同参数记两次不算数;reset 必须清空全部证据。

“冻结”这一族最值得多看一眼。这四个测试文件逐字重复的其实是同一句产品承诺——预测与证据分离:滑杆改的是”将会看到什么”,记录下来的必须是”当时看到了什么”。教学语义写进契约,回归网才有得可捞:任何一次重构只要让记录值跟着控制量漂移,25 个测试里至少一半当场变红。

这一层还有一项结构红利:纯函数不依赖 UIKit,也不依赖模拟器。这些测试可以用 swift test 在 macOS 上原生跑完,2026-10-05 的交付证据 README 就记着这样一行:内核测试 31 通过、0 失败,执行时间凌晨 02:33,日志留在 kernel_swift_test_full.log。物理断言的整条验证链路,从头到尾没碰过一台 iPhone。

五、判定层用同一把尺:门限、结论字段与跨层一致

TagWorks 的 ThresholdTests 把门限钉在六个具体数字上:接地 120 mΩ 判失败,绝缘 0.5 MΩ 判失败,80 mΩ 配 1.5 MΩ 判通过;一类设备缺接地读数且 requiresEarth 为真时,结论不是失败而是 notMeasured——没有测就不给结论。脏数据三条一并列出:负 1、NaN、正无穷,全部判失败,不抛异常也不回落到”未测量”。

更硬的一条是结论字段与测量事实的优先级。测试造了一条人签为 pass、实测 150 mΩ 的记录,然后同时断言两件事:这条记录的测量状态是”已记录”,而 establishesPass 必须为假。人写了 PASS,机器不认 PASS——这条规则在判定链里读起来只是几行代码,在测试里读起来就是一句可对质承诺。

跨层一致性也有对应测试:一条读数齐全但记录不完整的资产,登记册状态必须算出 incomplete,且报告导出层必须落在同一个口径上(测试名直译就是”状态与报告导出器保持一致”)。孤儿记录同样有归宿测试:资产删掉之后,历史记录仍须出现在历史 CSV 里并按资产、日期、编号排序。SlingGuard 侧的终态测试是同一思路:报废之后任何一次合格检验都不能复活它;隔离未经显式放行,后续 PASS 也照旧关着。

六、一张截图的两份工

回归测试产出的不只是绿条,还有图。TagWorks 的 UITests.swift 在流程节点上调用 capture("01-register")、capture("09-settings")、capture("10-paywall")——这些附件名与对外素材目录 shots-store/ 里的文件名一一对应。三个 App Store 商店图里的大部分”真机截图”,就是这么在测试跑动中用 XCTAttachment(lifetime = .keepAlways)随断言一起产出来的;QubitLab 的三联截图测试则走另一条路,直接把 PNG 写进 /tmp 的指定目录。

一份代码同时干两份工,带来一个直接的运维后果:界面重构之后,素材必须跟着重拍。备份与恢复重构改掉了设置页和付费墙,于是有了专门只拍这两张的 StoreShots 回归。付费墙那条测试还留了一个诚实的细节:按钮要等 CONTAINS '44.99' 出现,超时给了 15 秒——纯离线 App 的走查界面,也得等一次 StoreKit 目录在线取回,价格不是写死的。

走查家族与回归家族的行为准则也不一样,分野在 continueAfterFailure。回归测试全部为假:第一步失败立刻停,绿就是绿。走查与素材捕获全部为真(15 处,清一色在走查文件里):某一步点不到也要把后面的屏幕截完,宁可带病交图,不能空着手回来。

定位器层的账目也值得摊开。三个工程的 UI 测试里出现过 128 个按钮定位符,115 个是单 token(走无障碍标识符),13 个是带空格的英文可见文本("Record Test"、"Save"、"Restore purchases");此外还有 44 处 NSPredicate 的 CONTAINS 模糊匹配,签名笔画靠 0.25 到 0.75 的归一化坐标拖拽完成。标识符定位稳、文本定位脆,所以每一条走查都把 -AppleLanguages 钉成 (en-US)——语言一漂移,13 个文本定位符就可能集体失灵;想截中文版界面,就另派一支 zh-Hans 的 tour。

同步的地基则是半新半旧:274 处 waitForExistence 条件等待之外,还有 152 处按秒计数的硬睡眠(25 处 Thread.sleep 加 127 处裸 sleep)。条件等待负责正确性,硬睡眠负责让录像里的镜头稳一点——它同时也是走查脚本兼任提词器的那部分节拍。

七、把测试当提词器

App 更新被审核打回时,苹果要的往往是一台真实 iPhone 上的完整演示。这套流程里 UI 测试换了工种。

Apple Review 走查文件把六个步骤写成一镜到底:冷启动进登记册、滑动展示合格、失败、未检三种状态、录制一次真实检验、看仪表盘、导出 A4 报告、走一遍付费与解锁。它不追速度,pause 全用固定睡眠撑节拍,因为它真正的观众是录像回放。对应的录制管道在 SlingGuard 仓库里:simctl io booted recordVideo 开录,xcodebuild test-without-building -only-testing: 精确点一个走查函数当提词器,免费档与 Pro 档各跑一遍、各出一支 1080p。TagWorks 留下的 57 帧画面是同类产物:目录名叫 audit_102_frames,102 指的是版本 1.0.2 而不是帧数;每帧 1640 × 2360,是 iPad 形态模拟器整屏,里面有额度顶格的列表页、SAMPLE 水印压着的报告预览、检查员签名弹窗。

证据一旦成为发布物的一部分,生产线就要亲手锁掉。TagWorks 那个录制脚本后来被掏空成两行:一句注释写明”仅存档,不得再录制或覆盖发布证据”,一条 exit 1。给审核写的那份回复稿,抬头同样压着一块警示:这是历史构建的草稿,不得原样复用,提交前必须逐句换成当前归档的证据。产品边界的措辞也被刻意收窄成一句话——TagWorks 记录操作员输入的本机证据,不测试设备、不验证资质、不判定法律责任。说明证据的那段文字,自己也带着保质期。

八、证据包连失败一起存

2026-10-05 的 QubitLab 交付证据包有 42 行文件:32 份 xcodebuild 日志、31 个 .xcresult、一份校验和清单、一份 README。按直觉,这种目录该是给”全绿”留的;翻 README 会发现正相反,它最详尽的部分在记录失败。

三联截图的 UI 测试十次运行,没有一次进入测试阶段。triptych7 卡在签名素材收集:同一台 Mac 上另一个项目的钥匙串查询进程挂起两天,把整条构建拖死。triptych8 与 triptych10 的失败签名逐字相同:构建成功推进,模拟器启动等待 60 秒超时,快速失败退出;排查时用 sample 采了 1,498 次栈,全部命中同一个启动请求队列的楔点,且图形界面里的模拟器恰好对同一台设备持有第二个 boot 请求。更早的 triptych1 到 6 则是命令行用法错误——参数误用导致根本没跑起来。README 逐次记录了时间、进程号与日志名,然后写下一句结论:这三张三联截图里,01 是人工显示截图(文件扩展属性里带着屏幕捕获标记),02 是渲染器直出的真实游戏视图(与游戏内胜利判定同一条代码路径),03 只是文案与控件逐项一致的复刻卡,“在重拍之前按待补对待”。

交付备注里还有两个被反复使用的句式:** TEST BUILD SUCCEEDED ** 只说明测试代码编译通过,同一天另一台没有可用模拟器的构建主机上,它之后的标准后半句是”测试被编译但未被执行”;TAGWORKS_103_READINESS 里那句更直白——build-for-testing 成功,不等于设备验证,也不等于审核通过。编译绿、执行绿、审核过,三个状态在三份文件里分开记账,谁也不给谁背书。ISO/IEC/IEEE 29119-1 给这类记录留的名字叫测试状态记录;在这台机器上,它长得像一个装满失败日志的目录。

九、9 个端口里,只有 1 个被打包配置包住

这些注入端口会不会跟着上架包进入生产?逐端口对质,答案是悲观的。三个工程的 #if DEBUG 普查显示:只有 SlingGuard 的权益端口被编译配置包住,Release 构建里 -uiTestPro 会随整段分支一起被裁掉;其余全部没有——两个存储的播种与擦除、TagWorks 与 QubitLab 的权益端口、付费墙假按钮、扫码模拟、语言指定、家长门的 -uiTestWalkthrough 放行、目标射击关里那个”替玩家按下验证按钮”的自动触发钩子,都原样编译进了上架二进制。家长门文件里的分工很说明问题:DEBUG 包住的只是”预填答案”这一小截,|| 后面的旁路判断在两个配置里都活着。

给这个事实划一条克制的边界:启动参数只能从连接主机的工具链注入,App 内部没有任何代码路径能自己读到它,所以它不构成远程攻击面,正常用户也不可能误触。但两件事仍然成立——任何一台能连上手机的 Mac,都可以让一份正式签名的 App 表演出解锁后的样子;而”测试端口在发布时已剥离”这句话,目前对 9 个端口里的 8 个不成立,因此它不能出现在任何对外文档里。处置也简单:把每个端口逐个包进 #if DEBUG,或者整段从 Release 目标里裁掉,再给这件事补一条专门的盘点测试。

十、能力边界:这套验证面不证明什么

一、它不证明判定正确性。138 处 UI 断言绝大多数只检查元素存在与文本可见;判定的对错由纯函数层的 651 处断言负责,两层之间目前靠少数跨层一致性测试(状态与报告同口径)搭桥。二、它没有像素级回归。截图与断言同一次运行产出,界面上”不对但没坏”的劣化,只能靠人工翻图发现。三、它的同步地基是借来的。152 处硬睡眠把稳定性押在宿主性能上,证据包里十次连败没有一次是产品问题。四、它没有持续集成。所有执行记录来自一台 Mac 的手工作业,xcodebuild 命令、日志与校验和都在,但没有第二台机器会替谁再跑一遍。五、它的夹具会自我蒙蔽。顶格种子恰好 25 / 20 件、日期全按当天倒推,真实台账里 400 件规模的检索代价与陈年脏数据,走查一概看不见。六、它的端口自己没被测试。两份九参数清单的错位,就是没人盘点过一次端口的直接产物。

三条 App 加起来 41,169 行源码、6,581 行测试、9 个端口、31 份执行记录,全部服务于同一件朴素的事:让”数据不出设备”这句承诺,在每一次发版之前,被一台不联网的机器重新检查一遍。没有日志可查的产品,唯一能信任的就是自家目录里那批跑过、跑挂过、并且连跑挂都留了档的文件。

参考资料

  • [1] Cohn-Gordon, Cremers, Dowling, Garratt, Stebila. A Formal Security Analysis of the Signal Messaging Protocol. signal-formal
  • [2] Cohn-Gordon, Cremers. Mind the Gap: Where Provable Security and Real-World Messaging Don’t Quite Meet. mind-the-gap
  • [3] ISO. ISO/IEC/IEEE 29119-1:2022 Software testing — Part 1: General concepts. iso-29119-1
  • [4] Apple. XCTest. xctest
  • [5] Apple. XCTAttachment. xctattachment
  • [6] Apple. App Store Review Guidelines(2.1 Functionality). review-guidelines
Share:
Back to Blog