iOS安全开发:保护用户数据的最佳实践 —— 一个转行老新人的血泪总结

罗芳·
2025-12-18 09:49
阅读 4035

大家好,我是阿凯,坐标杭州,前广告公司策划,现“半路出家”的iOS程序员。去年裸辞后花了半年啃Swift、刷LeetCode、肝项目,终于在今年春招上岸了一家做To B SaaS的小厂(别问,问就是阿里网易投了30+简历石沉大海 😭)。虽然现在每天还在被Xcode报错和产品经理需求折磨得怀疑人生,但不得不说——安全开发这事儿,真是不碰不知道,一碰吓一跳。

今天这篇不是什么高深理论,而是我最近在公司主导的一个“数据安全加固”项目的实战复盘。起因?很简单:我们App差点因为用户数据泄露被客户投诉到下架。更尴尬的是,这事就发生在我入职不到三个月的时候——当时真的想原地辞职跑路。


事情是怎么搞砸的?

我们做的是一款面向企业客户的CRM工具,用户会上传大量客户联系方式、交易记录等敏感信息。本来这些数据都是通过HTTPS传给后端,看起来“挺安全”。结果上个月,有个技术较真的客户做了个简单的越狱手机抓包测试,发现我们App本地缓存里居然明文存了用户的API Token和部分客户手机号!

📌 致命问题:

  • 用户Token存在UserDefaults里(是的,你没看错)
  • 某些临时文件用FileManager直接写到Documents目录
  • 后端返回的JSON没做任何脱敏处理,前端照单全收

更离谱的是,当初写这段代码的同事(已离职)注释里还写着:“先这么搞,后面再优化”……结果“后面”一直没来。

产品经理一听要重构数据存储逻辑,第一反应是:“能不能下周上线?客户等着用。” 我:???兄弟,这是安全漏洞,不是加个按钮啊!


被逼上梁山:从零学习iOS安全开发

说实话,转行时教程里讲过Keychain,但真正在项目里用?完全没经验。为了不背锅(也为了保住饭碗),我花了整整一周时间恶补iOS安全开发知识,翻Apple官方文档、看WWDC视频、甚至混进了几个杭州本地的安全技术分享会(感谢滨江那家咖啡馆每周四的Meetup!)。

核心目标很明确:确保用户数据在传输、存储、展示三个环节都安全可控。下面是我踩坑后总结的几条最佳实践,附带真实代码。


实践一:别再用UserDefaults存敏感信息了!

我知道很多新手(包括曾经的我)觉得UserDefaults方便又快捷,但它本质上就是个plist文件,放在沙盒里谁都能读。越狱设备直接挂载就能看到内容,非越狱设备用iTunes备份也能导出。

✅ 正确姿势:用Keychain Services

Apple提供的Keychain是加密存储的,即使设备被物理访问,没有用户授权也无法解密。Swift封装后其实用起来并不复杂:

import Security

enum KeychainError: Error {
    case noPassword
    case unexpectedPasswordData
    case unhandledError(status: OSStatus)
}

func saveToken(_ token: String, forKey key: String) throws {
    let data = token.data(using: .utf8)!
    
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data
    ]
    
    SecItemDelete(query as CFDictionary) // 先删旧的
    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
}

func getToken(forKey key: String) throws -> String? {
    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)
    
    guard status != errSecItemNotFound else { return nil }
    guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
    
    guard let data = result as? Data else { throw KeychainError.unexpectedPasswordData }
    return String(data: data, encoding: .utf8)
}

💡 小贴士:

  • 可以用kSecAttrAccessibleAfterFirstUnlock保证后台也能访问(适合需要后台同步的场景)
  • 别忘了处理errSecItemNotFound,否则取不到值会crash
  • 测试时记得在模拟器和真机都跑一遍,Keychain行为略有不同

实践二:本地缓存?先问问Data Protection同不同意

我们App有个“离线模式”,需要缓存部分数据到本地。之前直接写到Documents目录,结果被客户一抓一个准。

Apple其实在iOS 4就引入了Data Protection机制,只要在App Capabilities里开启,系统就会自动对沙盒里的文件加密——前提是设备设置了密码锁屏。

但光开开关还不够!你得在写文件时指定正确的保护级别:

let fileURL = FileManager.default
    .urls(for: .documentDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("sensitive_data.json")

// 关键:设置文件属性为 .completeUnlessOpen
try data.write(to: fileURL, options: .completeFileProtection)

或者更细粒度地控制:

let attrs = [FileAttributeKey.protectionKey: FileProtectionType.complete]
try FileManager.default.setAttributes(attrs, ofItemAtPath: fileURL.path)
保护类型 描述 适用场景
none 不加密 ❌ 别用
complete 锁屏即加密,解锁才可读 敏感配置、Token
completeUnlessOpen 文件打开期间可读写,锁屏后加密 数据库、日志文件
completeUntilFirstUserAuthentication 首次解锁后永久可读 后台任务需要访问的数据

🙈 血泪教训:
上次双11前夜,我误用了complete,结果App进后台后收不到推送——因为APNs回调时设备可能锁屏,读不了Keychain!最后改成completeUntilFirstUserAuthentication才搞定。


实践三:网络传输不能只靠HTTPS

是的,我们用了HTTPS。但你以为这就够了?Too young!

去年某大厂App就因为证书固定(Certificate Pinning)没做,被中间人攻击窃取了用户数据。虽然普通用户很难遇到,但企业客户(尤其金融、政务类)一定会要求。

使用URLSession + SSL Pinning

我选了开源库 TrustKit,集成简单,支持动态更新公钥:

// Info.plist 添加
<key>TSKPublicKeyAlgorithms</key>
<array>
    <string>TSKAlgorithmRsa2048</string>
</array>
<key>TSKPublicKeyHashes</key>
<array>
    <string>YOUR_PUBLIC_KEY_HASH_HERE</string>
</array>

然后初始化:

import TrustKit

func setupTrustKit() {
    let config = [
        kTSKSwizzleNetworkDelegates: false,
        kTSKPinnedDomains: [
            "api.yourcompany.com": [
                kTSKPublicKeyAlgorithms: [kTSKAlgorithmRsa2048],
                kTSKPublicKeyHashes: ["YOUR_HASH"],
                kTSKIncludeSubdomains: true,
                kTSKEnforcePinning: true
            ]
        ]
    ] as [String : Any]
    
    TrustKit.initSharedInstance(withConfiguration: config)
}

⚠️ 注意:

  • 千万别把公钥硬编码在客户端!建议配合后端动态下发
  • 测试环境要关闭Pinning,否则CI/CD会失败
  • App Store审核时如果用了Pinning,可能要额外说明(我们被问过一次)

实践四:前端也要做数据脱敏!

以前我以为“脱敏是后端的事”,直到看到测试同学用Charles抓包,直接看到了用户身份证号……那一刻我悟了:前端必须对展示层数据二次过滤。

比如后端返回:

{
  "phone": "138****5678",
  "id_card": "3101011990******1234"
}

但万一后端疏忽没脱敏?前端得兜底:

extension String {
    func maskPhone() -> String {
        guard count == 11 else { return self }
        let start = index(startIndex, offsetBy: 3)
        let end = index(endIndex, offsetBy: -4)
        return prefix(3) + "****" + suffix(4)
    }
    
    func maskIDCard() -> String {
        guard count >= 14 else { return self }
        let visiblePrefix = prefix(6)
        let visibleSuffix = suffix(4)
        return "\(visiblePrefix)******\(visibleSuffix)"
    }
}

在ViewModel里统一处理:

class UserProfileViewModel: ObservableObject {
    @Published var maskedPhone: String = ""
    @Published var maskedIDCard: String = ""
    
    func loadUserData(from user: User) {
        DispatchQueue.global().async {
            // 模拟耗时操作
            let maskedPhone = user.phone.maskPhone()
            let maskedIDCard = user.idCard.maskIDCard()
            
            DispatchQueue.main.async {
                self.maskedPhone = maskedPhone
                self.maskedIDCard = maskedIDCard
            }
        }
    }
}

🤦‍♂️ 自嘲一下:
这段代码其实可以更优雅(比如用Combine),但考虑到团队里还有Objective-C老代码,先保证可维护性吧。毕竟我们Leader说过:“能跑的代码才是好代码,除非它危及用户安全”。


实践五:别让调试信息成为突破口

上线前,我干了一件特别“菜鸟”的事:把print()和NSLog()全删了。为什么?

因为有些日志会不小心打印用户信息!比如:

// 千万别这么写!
print("User login: \(user.email), token: \(token)")

即使Release模式下日志不输出,但符号表里可能残留字符串常量,反编译一下就露馅。

解决方案:

  1. 用#if DEBUG包裹所有调试日志
  2. 上线前运行otool -v -s __TEXT __cstring yourApp检查敏感字符串
  3. 启用Bitcode(虽然现在Apple默认关了),至少增加反编译难度

效果如何?求职时能吹吗?

重构完这套方案后,我们顺利通过了客户的安全审计,还拿到了一个政府项目的POC机会(钱!💰)。更重要的是——我在周会上提了“安全开发规范”,居然被采纳成了团队标准!虽然只是个小厂,但那种“我的代码真的保护了用户”的成就感,比涨薪还爽。

至于求职?上周面试网易时,面试官专门问了Keychain和Data Protection的区别,我直接掏出手机给他演示了我们App的加密逻辑(提前跟公司打过招呼)。他笑着说:“看来你是真做过,不是背答案。”


写在最后:安全不是功能,是责任

作为转行者,我深知自己基础不如科班出身的同学。但正因为“半路出家”,反而更珍惜每一次写代码的机会——每一行代码背后,都是真实的人和他们的信任。

如果你也在杭州做iOS开发,欢迎来滨江咖啡馆找我唠嗑(暗号:“Keychain怎么用?”)。顺便,我们团队还在招人,要求不高:懂安全、爱代码、能抗住产品经理的deadline轰炸就行。

记住:在iOS的世界里,用户的数据安全,永远比你的KPI重要。


📝 附:自查清单(上线前必看)

  • 敏感信息是否全部迁移到Keychain?
  • 本地文件是否启用Data Protection?
  • HTTPS是否配合证书固定?
  • 前端是否对展示数据二次脱敏?
  • 所有日志是否移除敏感字段?
  • App Transport Security (ATS) 是否强制开启?
  • 是否禁用UIPasteboard持久化?(防止剪贴板泄露)
  • 是否在Info.plist中声明隐私用途?(如NSPhotoLibraryUsageDescription)

共勉。

评论 0

最热最新
暂无评论
罗芳·Lv.1
0
影响力
0
文章
0
粉丝