iOS安全开发:保护用户数据的最佳实践
上周五晚上十点半,我瘫在工位上盯着 Xcode 的 build log 发呆——又一个因为“不安全的数据存储”被 App Store 拒了。这已经是我们医疗 App 项目本月第三次被拒。产品经理在群里发了个“🙏”,测试同事默默截图了 rejection email 转发给我,运维老哥在旁边幽幽地说:“你是不是又把加密密钥硬编码在代码里了?”
说实话,作为一个主要用 Python 写后端服务的开发者(没错,就是那个每天靠 ChatGPT 和 Claude 续命、住在上海张江某合租房、公司楼下咖啡店比我家还熟的医疗软件工程师),突然要深度介入 iOS 客户端的安全架构设计,一开始我是拒绝的。但谁让我们的新项目是面向医院和患者的敏感健康数据平台呢?Apple 对 HealthKit 相关应用审核严得像海关查行李,稍有不慎就给你打回重做。
于是,我被迫翻遍了 Apple 官方文档、OWASP Mobile Top 10,甚至啃了几章《iOS Security Guide》这本书(纸质版还是从团队老大书架上“借”来的,至今没还)。今天这篇总结,既是给团队新人的踩坑指南,也算给自己这段“血泪史”做个复盘。
别再把 plist 当保险箱了!
很多刚转 iOS 的开发者(包括曾经的我)有个致命误区:以为 .plist 文件是“本地配置”,就默认它是安全的。结果呢?用 NSUserDefaults 存个 token,或者把用户 ID 直接写进 Info.plist,上线三天就被安全团队扫出高危漏洞。
真实事故:去年双11期间,我们某个内部测试版把医生的 API Key 硬编码在 plist 里,被 QA 用 plutil -convert xml1 xxx.plist && cat xxx.plist 一行命令扒得干干净净。当时真的想砸电脑。
正确姿势:敏感数据一律走 Keychain。别嫌麻烦,它才是 Apple 生态里真正的保险柜。
import Security
struct KeychainHelper {
static func save(_ data: Data, forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: k(secClassGenericPassword),
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 注意这个策略!
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
static func load(forKey key: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess ? result as? Data : nil
}
}
💡 小贴士:
kSecAttrAccessibleAfterFirstUnlock比kSecAttrAccessibleAlways安全得多——后者即使设备锁屏也能读取,容易被物理攻击利用。
网络传输:HTTPS 不是万能的,但没有它万万不能
我们后端团队(也就是我)天天喊“全站 HTTPS”,但客户端同学有时候为了调试方便,会在 Info.plist 里加一堆 NSAppTransportSecurity 的例外。结果某次灰度发布,一个测试人员用 Charles 抓包,直接看到明文传输的患者病历摘要。
教训:强制开启 ATS(App Transport Security),并且不要为任何域名开后门。如果后端还没配 HTTPS?那你该去催他们了(咳咳,比如我)。
此外,别忘了证书绑定(Certificate Pinning)!虽然 Apple 不强制要求,但在医疗这类高敏场景,防中间人攻击是基本操作。
我们用的是 Alamofire + 自定义 ServerTrustManager:
let evaluators: [String: ServerTrustEvaluating] = [
"api.yourmedicalapp.com": PublicKeysTrustEvaluator()
]
let serverTrustManager = ServerTrustManager(evaluators: evaluators)
let session = Session(serverTrustManager: serverTrustManager)
记得定期轮换公钥,并且在紧急情况下要有热更新机制——别等到证书过期才发现 App 全挂了。
本地数据库加密:SQLite 也得穿盔甲
我们的 App 用 Core Data 做本地缓存,存着大量脱敏后的就诊记录。起初觉得“反正设备有锁屏密码”,就没额外加密。直到某次安全审计,人家用越狱设备直接 dump 出 YourApp.sqlite 文件,数据一览无余。
后来我们上了 SQLCipher,对整个数据库文件加密:
// 在 persistentContainer 初始化时设置
let description = NSPersistentStoreDescription()
description.url = storeURL
description.setOption("your-strong-encryption-key".data(using: .utf8),
forKey: "key")
注意:加密密钥绝不能硬编码!我们把它存在 Keychain 里,并结合设备指纹(如 identifierForVendor)动态生成。
关于“区块链”的冷思考
最近产品经理疯狂迷恋“区块链+医疗”,说要把患者授权记录上链,显得高大上。我翻了三天白皮书,最后告诉他:在 iOS 客户端谈区块链存证,99% 是伪需求。
为什么?
- 区块链解决的是多方互信问题,而我们 App 的数据最终都汇总到自家服务器
- 客户端只是数据入口,真正的审计日志应该由后端写入联盟链
- 在手机上跑轻节点?耗电、占存储、体验差,Apple 审核还可能质疑“为何需要 P2P 网络权限”
最后我们达成共识:前端只负责收集用户授权动作,哈希值传给后端上链。客户端保持轻量,安全边界清晰。果然,这次提交一次过审。
审核避坑指南:那些 Apple 不说但会拒你的点
根据我们被拒 5 次的经验,整理出这份“隐形红线”清单:
| 风险行为 | 正确做法 |
|---|---|
使用 UIPasteboard 传递敏感信息(如身份证号) |
改用内存变量,或立即清空剪贴板 |
在 NSLog() 或 Crashlytics 中打印用户数据 |
过滤日志内容,禁用生产环境 debug log |
| 截图包含患者信息未做模糊处理 | 在 applicationDidEnterBackground 中遮挡窗口 |
| 使用第三方 SDK 未声明数据用途 | 在 PrivacyInfo.xcprivacy 中完整披露 |
特别提醒:从 iOS 17 开始,Apple 要求所有 App 提交 Privacy Manifest。我们第一次漏填了 Keychain 访问声明,直接被打回。现在每次提审前,我都让 CI 跑个脚本检查:
# 检查是否包含 PrivacyInfo.xcprivacy
if ! [ -f YourApp/PrivacyInfo.xcprivacy ]; then
echo "❌ 缺少隐私清单!"
exit 1
fi
写在最后:安全不是功能,是习惯
回到开头那个被拒的周五夜晚,其实解决方案很简单:把 UserDefaults 替换成 Keychain,删除 plist 里的临时 token,加上证书绑定。两小时搞定,周一顺利过审。
但这件事让我意识到:在医疗行业做软件,安全不是等审计来了才补的作业,而是写每一行代码时都要绷紧的弦。我现在甚至养成了“防御性编程”强迫症——看到 String 类型的密码字段都会下意识皱眉。
如果你也在做涉及用户隐私的 iOS 项目,别等被拒了才行动。花半天时间读完 Apple 的《Security Overview》,再翻翻那本被我翻烂的《iOS Security Guide》(书页边角都卷了),绝对值得。
毕竟,在这个数据即黄金的时代,我们写的不是代码,是信任。
P.S. 产品经理刚在群里问:“下个版本能不能加个指纹快捷登录?” —— 行,但先签个数据安全承诺书 😏

评论 0