· engineering· 约 26 分钟精读
端侧离线台账的静态数据保护与系统备份出口工程:沙盒落点、文件保护等级与"数据不出设备"的能力边界
关掉 Wi-Fi、不挂账号、代码里没有任何网络调用,一台 iPad 上的电气安全台账就真的没有出口了吗?本文以密旋科技 TagWorks、SlingGuard 与 QubitLab 的真实工程为样本,逐个目录盘点数据落在哪里、以什么格式落盘、有没有声明保护等级、会不会被系统备份带走,并给出目录分级、显式保护、备份面取舍与恢复副本轮转四条可落地的工程线。
一、配电间里的那台 iPad
墨尔本西郊一间仓库的年度复检,检验员用的是同一台 iPad:没有插卡,仓库里也没有访客网络,TagWorks 从早到晚都在断网状态。三年下来,这台设备里躺着约一千二百件受检设备的台账、近八百张证据照片,以及一枚手写签名位图。
这台设备后来换了。店员没有做任何迁移操作,只是在新机开机时选了”从 iCloud 备份恢复”。二十分钟后,台账、照片、签名一起出现在新机上,一条不少。
事后复盘,没有人做错什么:应用里确实没有网络调用,用户也没有输入过账号。数据没有经过开发者的服务器,但它离开了这台设备。应用的出口和系统层的出口是两件事:前者可以用代码检索证明,后者只能被声明、被配置、被审计。
本文把三款真实端侧应用的数据落点逐个翻一遍:密旋科技的 TagWorks(AS/NZS 3760 现场电气安全台账)、SlingGuard(LOLER 1998 与 OSHA 29 CFR 1910.184 吊索具核验台账)、QubitLab(面向 App Store 的量子物理启蒙沙盒)。要回答的问题只有四个:数据写在哪、以什么格式写、有没有保护声明、会不会被系统带走。
二、四个落点:数据到底写在哪
三款应用共五个持久化落点,全在应用沙盒内部,没有一处指向共享容器、App Group 或外部目录:
| 数据项 | 落盘位置 | 序列化形态 | 保护声明 | 系统备份收录 |
|---|---|---|---|---|
| 设备台账快照 | Documents/TagWorks/register.json | JSON 明文 | 无 | 是 |
| 现场证据照片 | Documents/Photos/*.jpg | JPEG,质量 0.8 | 无 | 是 |
| 检验员签名 | Documents/TagWorks/inspector_signature.png | PNG | 无 | 是 |
| 公司/检验员/资质号 | Library/Preferences 下的偏好 plist | plist 键值 | 无 | 是 |
| 恢复前安全点 | Documents/TagWorks/Recovery/*.json | JSON,内嵌照片 base64 | 无 | 是 |
| 吊装台账与签名 | Documents/SlingGuard/rigging_register.json | JSON,签名内嵌 base64 | 无 | 是 |
| 学习进度 | 偏好 plist 中的 qubitlab_progress_data | JSON 编码后存为 Data | 无 | 是 |
| 分享中转产物 | tmp/Tag-*.pdf、*.csv、*-backup.json | PDF / CSV / JSON | 无 | 否 |
台账快照是一个整体。 TagWorks 的 RegisterStore.write 每次把 assets 与 records 编码成一个 Snapshot 整体落盘。文件因此自洽:任何一次写入之后,磁盘上要么是完整的旧快照,要么是完整的新快照。代价是体积随记录数线性增长,每次落盘都要重写全部内容。
照片是文件名引用,不进 JSON。 Asset.photoFile 与 TestRecord.photoFiles 存的是文件名,照片本体在 Documents/Photos 下按 UUID 命名。台账 JSON 因此能压在几十到几百 KB,备份归档才有条件把两者一起打包。
SlingGuard 没有照片通道。 检索 photo、PhotosPicker 命中数为零,证据面只有文字记录、目视检查项与签名。台账文件因此承载全部证据:CompanyProfile.signatureImageData 是 Codable 的 Data 字段,随 rigging_register.json 一起被编码成 base64。一个台账文件,等于全部记录加一枚签名。
QubitLab 一个文件都不写。 它的 Sources 目录里 FileManager 的命中数是零,全部状态压在一个 @AppStorage("qubitlab_progress_data") 的 Data 上,由 JSONEncoder 编码 LabProgress 得到。也就是说,九十六关的星级、章节解锁、每日挑战的连续天数,都躺在一个偏好 plist 的单个键里,形态是一长串 base64。
三、为什么是 Documents,而不是 Caches 或 Application Support
iOS 给应用准备了几个语义不同的目录,选错一个,行为会完全不同。
Library/Caches 的语义是”可以随时重建的缓存”。系统在存储紧张时会主动清理它,应用也不能假设它跨启动一定还在。台账不能重建——一条三年前的接地连续性读数一旦丢了,就没有第二份。所以 Caches 从语义上就不该承载台账。
tmp 是本次使用期间的临时目录,系统回收更积极,也不参与备份。三款应用里 tmp 只承担一件事:把内存里生成的 PDF、CSV、备份 JSON 落成临时文件,交给分享面板;分享后不会主动删除,靠系统回收。
Library/Application Support 面向”应用私有、用户不该直接管理”的持久数据,备份口径与 Documents 一致,区别主要在语义与可见性。
Documents 的语义是”用户创建、用户拥有的文档”。台账与证据照片恰好符合这个语义:它们是检验员为业主留下的记录,业主有权索要。三款应用都选了这一层,理由是语义对齐,而不是技术必需。
这里有一条容易被忽略的正面事实:Documents 并不自动对用户可见。只有当应用在 Info.plist 里声明了 UIFileSharingEnabled(Finder 文件共享)或 LSSupportsOpeningDocumentsInPlace(在”文件”App 中就地打开)时,这个容器才会露出来。TagWorks 与 SlingGuard 的 project.yml 里这两个键都没有声明,仓库里也没有任何 .entitlements 文件。所以在当前构建下,用户无法通过”文件”App 或 Finder 直接浏览 Documents。
这条事实要标注性质:它是一行配置的当前取值,不是架构保证。哪天为了支持”把台账放进 iCloud Drive”而打开 UIFileSharingEnabled,整个容器会同时暴露照片与签名。可见性由配置决定,配置会变。
四、原子写能保证什么,不能保证什么
三款应用的所有写盘动作都是同一个模式:try data.write(to: url, options: .atomic)。这个选项的含义是先把内容写到同目录的辅助文件,再用一次重命名替换目标文件。
它保证的是:目标文件不会出现”写了一半”的撕裂状态。对于台账这种整体快照,这条保证很关键——掉电、崩溃、被系统杀死,最多让这次修改丢失,不会让文件变成无法解码的字节流。TagWorks 在解码失败时会置 requiresRecovery,并在恢复之前拒绝任何覆盖写;这个保护的成立前提,正是文件要么完整要么不存在。
它不保证三件事。
第一,跨文件的一致性。 一次检验记录写入会同时改动 register.json 与新增一张 Photos/*.jpg,两者是两次独立的原子写,中间没有两阶段提交。进程若在两次写之间终止,可能出现”记录引用了一张不存在的照片”,读取侧表现为 PhotoStore.load 返回 nil、界面留一张空白占位;也可能是孤儿文件,长期占空间。
第二,掉电后的介质层落稳。 .atomic 只处理文件系统层面的替换语义,不负责把数据刷到闪存颗粒,这个窗口极短但不为零。
第三,失败语义。 SlingGuard 的 save() 是 try? data.write(..., options: .atomic),错误被静默吞掉;TagWorks 的 save() 返回 Bool 并置 persistenceIssue,界面能提示”未保存”。同一动作两种交代,这是自检清单要固化的第一项差异。
在备份归档这一层,完整性反而做得比落盘层更严:BackupService.makeArchive 只要有一张被引用的照片读不出来,就拒绝出包;恢复前先写安全检查点,再校验归档里的照片名,任何一项不合规就整体不恢复。但这些都是归档边界上的闸门,不是落盘边界上的。
五、零 Keychain:签名位图的三条路
在这三个仓库的 Sources 目录里检索 Keychain、SecItem、kSecClass,命中数为零:三款应用都没有使用系统钥匙串,也就没有一条路径能把凭据绑定到”这台设备”。
于是签名位图走了三条不同的路:
TagWorks 把它存成 Documents/TagWorks/inspector_signature.png,一个标准 PNG,明文,原子写。备份归档时,BackupInspectorProfile.signaturePNG 会把它作为 Data 再复制一份进归档 JSON,编码成 base64。所以签名在设备上同时存在两份:一份独立文件,一份在每一个备份包里。
SlingGuard 把它放进了 CompanyProfile,随台账一起落盘。这意味着签名与台账永远同步——换机时不会出现”有台账没签名”,但也意味着台账文件的任何一次外发都带着签名。
QubitLab 没有签名概念,它的凭据面是 StoreKit 的交易凭据,由系统管理,应用侧不接触。
值得对照的是钥匙串能提供什么。存进钥匙串的条目,其可及性类别如果带 ThisDeviceOnly 后缀(如 kSecAttrAccessibleWhenUnlockedThisDeviceOnly),这份条目不会被 iCloud 备份带走,也不会随设备迁移到新机,且其加密密钥受硬件隔离区保护。这三条性质正好对应签名这类凭据真正需要的语义:属于这台设备、属于这个使用者、不因备份而复制。
不用钥匙串的代价是明确的:没有”只属于这台设备”的手段可用。签名只能和普通文件一样,跟着容器走、跟着备份走、跟着迁移走。
要说清的是,签名在这里是署名,不是认证:一张位图,谁拿到都能贴到另一份报告上。挪进钥匙串能收窄副本扩散面,改变不了”位图不是密钥”。两件事不能混谈,也不能互相替代。
六、文件保护等级:默认那一档的边界
iOS 的 Data Protection 给文件分了四个保护类别,差别在于”文件加密密钥什么时候从内存里消失”:
| 类别 | 设备锁屏后的可读性 | 典型适用 | 本项目现状 |
|---|---|---|---|
NSFileProtectionComplete | 锁屏后密钥从内存清除,不可读 | 高敏感、仅前台访问 | 未使用 |
NSFileProtectionCompleteUnlessOpen | 已打开的文件在锁屏后仍可继续写 | 长时上传、后台写 | 未使用 |
NSFileProtectionCompleteUntilFirstUserAuthentication | 开机后首次解锁起,到下次重启前都可读 | 系统默认档 | 全部数据 |
NSFileProtectionNone | 始终可读 | 无 | 未使用 |
在这三个仓库里检索 FileProtection、NSFileProtection、protectionKey,命中数为零;.entitlements 文件数量也是零。没有 com.apple.developer.default-data-protection 这个 entitlement,新建文件就落进系统默认档:首次解锁之后、下次重启之前,文件加密密钥常驻内存,任何有权限访问该容器的进程都能读。
这一档的含义是:设备被拿走、处于关机或刚开机未解锁时,闪存上的数据加密且不可解;一旦解锁过,这道保护就不再阻挡本机访问。
它挡不住什么:
- 设备已解锁、应用在前台或后台,同机进程(包括调试通道、越狱环境、取证工具挂载的通道)读取该容器,不受保护类别约束;
- 应用自身不校验调用方,因为 iOS 的沙盒已经假定了”同容器即同信任域”;
- 签名位图与证据照片同处默认档,无法按敏感度区分——这是”没有显式声明”带来的直接后果,不是某一条数据的特殊问题。
不选完全保护的现实原因是时序冲突:应用可能在后台被唤醒写台账,也可能在分享扩展里被系统调起读照片,而这些时刻设备往往处于锁屏。选了 Complete,这些路径会直接失败,需要额外的”前台才允许写”纪律。这是取舍,不是疏忽,但应写下来,而不是留在默认值里。
七、系统备份:一条应用关不掉的出口
回到开头那台 iPad。它的数据之所以能到新机上,是因为 Documents 与 Library/Preferences 都属于系统备份的收录范围。
| 目录 | iCloud 备份 | Finder / 本地备份 | 系统清理 | 换机迁移 |
|---|---|---|---|---|
Documents | 收录 | 收录 | 不清理 | 随容器迁移 |
Library/Preferences | 收录 | 收录 | 不清理 | 随容器迁移 |
Library/Caches | 不收录 | 不收录 | 会清理 | 不迁移 |
tmp | 不收录 | 不收录 | 会清理 | 不迁移 |
这张表解释了为什么”把台账放进 Caches 就能避免上云”是个坏主意:能避免上云,也能避免被备份,还能被系统随时清掉——把合规记录放进一个”可以重建”的目录,等于把记录本身降级成缓存。
系统备份这条通道有几个性质,值得在文档里写明白:
它由系统开关控制,不由应用控制。 用户在设置里打开 iCloud 备份,容器就被收录;关闭则不被收录。应用侧能做的只是文件级标注”排除备份”,而这三个仓库里检索 isExcludedFromBackup 与 URLResourceValues 命中数为零。
iCloud 备份默认不是端到端加密。 标准配置下备份在传输与静态存储时都加密,但密钥由平台侧持有;只有用户开启”高级数据保护”后才纳入端到端加密。所以”备份到 iCloud”与”只有我能解开”不是同一件事,且应用无法感知这个账户设置。
换机迁移会把容器原样搬走。 这是与备份不同的路径,结果相同:新设备上出现一份完整副本,包括签名。
企业场景还有第三条路。 受管设备可由 MDM 做整机备份或数据提取,这类通道完全在应用可见范围之外。
因此,一款端侧应用能诚实声明的只有”零网络出口”——这一条可以用代码检索证明,也可以用抓包复核。它不能声明”零系统出口”。把两者混为一谈写进宣传语,就是过度承诺;在合规语境里,这比不做承诺更麻烦,因为它把一条本可解释的技术边界,变成了一句说不清的陈述。
八、Recovery 目录:只写不删的存储放大
TagWorks 每次恢复前先写一份”恢复前安全点”到 Documents/TagWorks/Recovery/:完整的 BackupArchive JSON,含台账快照与全部照片的 base64。这是恢复事务的兜底,失败时可回到恢复前状态。
问题在清理。检索 removeItem,四处命中分别是:恢复时替换照片、删除签名、删除照片、测试用的清空快照。Recovery 目录没有任何清理逻辑:没有份数上限、没有时间轮转、没有体积上限,界面上也没有入口显示这些文件占了多少空间。
存储账很容易算。假设八百张证据照片,平均 1.6 MB,那么一份安全点的体积约为:
即约 1.28 GB 的原始照片,经 base64 编码后膨胀到约 1.7 GB。恢复十次,就是十七 GB 量级的副本,而且每一份都在备份收录范围内。设备存储被这些副本吃掉一半时,用户看到的现象是”应用占了很大空间”,但界面里没有任何东西指向它们。
这不是正确性问题,是生命周期问题:一份为兜底而生的副本,没有出口也没有过期时间。对要在现场用三五年的工具,这类只增不减的目录迟早变成故障源。
九、工程线:六件可以落地的事
一、按敏感度分级目录。 凭据类数据(签名位图、公司主体信息、资质编号)从 Documents 移出,改存钥匙串并指定 ThisDeviceOnly;或退一步,存进 Library/Application Support 并显式设置 Complete 保护。证据照片与台账留在 Documents。分级的判据是”被复制走会造成什么”,不是”重不重要”。
二、把保护等级写进写入调用,而不是写在文档里。 write(to:options:) 可以带 completeFileProtection,目录级用 setResourceValue 设 protectionKey。这需要同步确立时序纪律:哪些写必须在前台完成、锁屏失败如何提示、后台唤醒是否允许写。三款应用目前都假设”随时可写”,改成完全保护就要把这个假设换成显式状态机。
三、做一次备份面的双向取舍。 照片体积大,排除出备份能省配额,但换机会丢证据;签名体积小、敏感度高,排除可避免副本扩散,但换机后要重新采集。两者结论不同,不能一刀切。可行默认:照片留在备份内(合规记录可用性优先),签名排除,并在设置页写清楚换机后要做什么。
四、给 Recovery 目录定保留策略。 保留最近三份、按时间轮转、设体积上限(例如不超过台账体积三倍),超出后从最旧开始删。删除要留痕:写一条本地记录,说明删了几份、何时删的,避免”证据链副本被悄悄清掉”的质疑。
五、归档包要么加密,要么明说。 现在的归档是明文 JSON,签名与照片都在里面。最小可行方案是 AES-GCM 加口令派生密钥(PBKDF2 或 Argon2),口令不落盘;代价是丢口令即丢数据,须在导出界面写明。不加密也可以,但要明确告知”这个文件含全部证据与签名”。
六、把现状固化成可回归的检查。 下面这组检索口径可直接放进 CI,作为发版前的静态普查:
- 网络 API(
URLSession、NWConnection、http字面量):应为 0,当前为 0; Keychain、SecItem:当前为 0,若引入须同时评估ThisDeviceOnly;FileProtection、protectionKey:当前为 0,引入后应覆盖签名与台账目录;isExcludedFromBackup:当前为 0,引入后应能列出被排除文件清单;.entitlements数量:当前为 0,若引入default-data-protection需记录取值与理由;Documents目录清单:与预期清单做差集,新增目录须有归属说明。
这些零不是罪状,是基线。基线的价值在于变化可见:下次有人加了 SDK、开了文件共享开关、往 Documents 塞了新目录,检查会当场指出来。
十、能力边界
- 零网络出口可以证明,零系统出口不能。 前者是代码属性,后者是设备与账户配置的组合状态,应用只能影响其中一小部分。
- 文件保护等级不是访问控制。 它只约束”介质离开设备、或设备未解锁”这一窗口;设备解锁后,同容器内的访问不受阻挡。
- 原子写不是事务。 它保证单文件不撕裂,不保证
register.json与照片一致,也不保证掉电后介质层落稳。跨文件一致性需要额外的提交协议,目前没有。 - 偏好 plist 不是秘密存储。 它随备份走、随迁移走、可被工具读取。把学习进度放进去没有问题,把凭据放进去就是错配。
- 位图签名不是密码学签名。 无论存在哪里,它都只能表达”署名人同意”,不能证明”这份文件未被改动”。
- 本文的普查是静态的。 检索的是源码调用点与配置键,不含运行时行为、系统版本差异与账户设置的实际取值,这些需在真机上另行复核。
十一、结语
那台 iPad 的故事里没有坏人。应用没联网,用户没登录,数据没经过任何服务器。它只是被系统备份带走了,而备份是设备的一项正常功能。
端侧安全真正难的其实不是”不联网”,而是把”数据在哪里、什么形态、受什么保护、会被谁带走”逐条写清楚,并让每一条都能被检索、被复核、被回归。做到这一步,“离线”才是工程属性;做不到,它只是一句宣传语。
对使用者而言,这也给出一个可操作的判断方法:拿到一款号称离线的工具,先问它的台账放在哪个目录、备份关掉之后文件还在不在、签名是不是和台账装在同一份文件里。三个问题问完,安全边界基本就清楚了。
参考文献
[1] Apple Inc. Data Protection classes — Apple Platform Security Guide. https://support.apple.com/guide/security/data-protection-classes-secb010e978a/web
[2] Apple Inc. iCloud backup security — Apple Platform Security Guide. https://support.apple.com/guide/security/icloud-backup-sec8a8bb4d4b/web
[3] Apple Inc. Advanced Data Protection for iCloud. https://support.apple.com/en-us/HT212520
[4] Apple Inc. Keychain Services — Restricting keychain item accessibility. https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility
[5] Apple Inc. FileManager — Protecting files with data protection classes. https://developer.apple.com/documentation/foundation/filemanager
[6] Apple Inc. Information Property List — UIFileSharingEnabled. https://developer.apple.com/documentation/bundleresources/information-property-list/uifilesharingenabled
[7] Standards Australia. AS/NZS 3760:2022 In-service safety inspection and testing of electrical equipment. https://www.standards.org.au/
[8] UK Health and Safety Executive. Lifting Operations and Lifting Equipment Regulations 1998 (LOLER). https://www.hse.gov.uk/work-equipment-machinery/loler.htm
[9] U.S. Occupational Safety and Health Administration. 29 CFR 1910.184 — Slings. https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.184
[10] ISO/IEC 27001:2022 Information security management systems — Requirements. https://www.iso.org/standard/27001