· engineering· 约 26 分钟精读

端侧离线台账的静态数据保护与系统备份出口工程:沙盒落点、文件保护等级与"数据不出设备"的能力边界

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

关掉 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.jsonJSON 明文无是
现场证据照片Documents/Photos/*.jpgJPEG,质量 0.8无是
检验员签名Documents/TagWorks/inspector_signature.pngPNG无是
公司/检验员/资质号Library/Preferences 下的偏好 plistplist 键值无是
恢复前安全点Documents/TagWorks/Recovery/*.jsonJSON,内嵌照片 base64无是
吊装台账与签名Documents/SlingGuard/rigging_register.jsonJSON,签名内嵌 base64无是
学习进度偏好 plist 中的 qubitlab_progress_dataJSON 编码后存为 Data无是
分享中转产物tmp/Tag-*.pdf、*.csv、*-backup.jsonPDF / CSV / JSON无否
沙盒里的四个落点,与两条方向相反的出口左:数据在本机写到哪里;右:哪些出口由代码决定,哪些由系统决定应用沙盒:单台设备,无服务器副本Documents / TagWorksregister.json · Photos / *.jpg · signature.png进备份Library / PreferencesTagWorks.plist · qubitlab_progress_data进备份Documents / TagWorks / Recoverybefore-restore-*.json,内嵌全部照片只写不删tmpTag-*.pdf · *.csv · *-backup.json(分享用)不进备份应用层出口:静态普查为 0 处三款应用内无 URLSession、无第三方数据 SDK这一层可以用代码检索直接核对系统层出口:应用关不掉iCloud 备份:随系统开关,默认非端到端Finder 备份:整机打包,含应用容器换机迁移:容器原样搬到另一台设备文件保护等级决定"介质被拆走时读不读得开",备份策略决定"数据副本有没有第二份"

台账快照是一个整体。 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,那么一份安全点的体积约为:

Ssnap≈Sreg+43∑i=1nSphoto,iS_{\mathrm{snap}} \approx S_{\mathrm{reg}} + \frac{4}{3}\sum_{i=1}^{n} S_{\mathrm{photo},i}

即约 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 塞了新目录,检查会当场指出来。

十、能力边界

  1. 零网络出口可以证明,零系统出口不能。 前者是代码属性,后者是设备与账户配置的组合状态,应用只能影响其中一小部分。
  2. 文件保护等级不是访问控制。 它只约束”介质离开设备、或设备未解锁”这一窗口;设备解锁后,同容器内的访问不受阻挡。
  3. 原子写不是事务。 它保证单文件不撕裂,不保证 register.json 与照片一致,也不保证掉电后介质层落稳。跨文件一致性需要额外的提交协议,目前没有。
  4. 偏好 plist 不是秘密存储。 它随备份走、随迁移走、可被工具读取。把学习进度放进去没有问题,把凭据放进去就是错配。
  5. 位图签名不是密码学签名。 无论存在哪里,它都只能表达”署名人同意”,不能证明”这份文件未被改动”。
  6. 本文的普查是静态的。 检索的是源码调用点与配置键,不含运行时行为、系统版本差异与账户设置的实际取值,这些需在真机上另行复核。

十一、结语

那台 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

Share:
Back to Blog