本课目标:把 App 的安全和隐私做到上架级别------安全存储 token、Face ID 验证、数据加密、隐私合规。
前置知识:第8课网络层(token 存储会用到)、第2课依赖注入。
本课产出:Keychain 封装 + Face ID 锁定 + AES 加密 + 隐私清单。
一、先理解:安全是"分层"的
1.1 一个真实的安全事故
某 App 把用户的登录 token 存在 UserDefaults 里。上线后,攻击者写了一个工具,读取 App 沙盒里的 plist 文件,拿到了所有用户的 token。
问题出在哪?
swift
// ❌ 严重的安全漏洞
UserDefaults.standard.set(token, forKey: "authToken")
UserDefaults 是明文 plist 文件 ,存在 Library/Preferences/ 目录下。任何能访问沙盒的工具(越狱设备、备份工具)都能读取。
正确做法:存在 Keychain 里。
swift
// ✅
try KeychainStore.shared.saveString(token, for: "authToken")
1.2 安全的四个层次
┌─────────────────────────────────────────────────────────┐
│ 第 4 层:合规安全(隐私清单 + 权限最小化) │
│ PrivacyInfo.xcprivacy、权限说明、账号删除入口 │
├─────────────────────────────────────────────────────────┤
│ 第 3 层:访问安全(Face ID / Touch ID) │
│ LocalAuthentication、生物识别、回退密码 │
├─────────────────────────────────────────────────────────┤
│ 第 2 层:存储安全(Keychain + 加密) │
│ Keychain、CryptoKit、文件保护等级 │
├─────────────────────────────────────────────────────────┤
│ 第 1 层:传输安全(HTTPS + 证书绑定) │
│ App Transport Security、SSL Pinning │
└─────────────────────────────────────────────────────────┘
关键理解 :每层解决不同问题,缺一不可。
1.3 存储方案的安全对比
| 存储方式 | 安全性 | 适合 |
|---|---|---|
UserDefaults |
❌ 明文 plist | 非敏感设置 |
| SwiftData | ❌ 未加密数据库 | 普通数据 |
| 文件 | ❌ 明文 | 非敏感文件 |
| Keychain | ✅ 系统级加密 | token、密码、密钥 |
| 加密后存储 | ✅ 自己控制 | 敏感业务数据 |
关键理解 :敏感信息必须存 Keychain,没有例外。
二、Keychain:安全存储
2.1 Keychain 是什么
Keychain 是 iOS 的系统级加密存储:
- 加密:数据用系统密钥加密,存在安全的硬件区域。
- 隔离:每个 App 只能访问自己的 Keychain 数据。
- 持久:即使用户删除 App,Keychain 数据可能仍保留(取决于访问等级)。
- 系统管理:不需要手动管理加密密钥。
Keychain 的物理结构:
系统 Keychain 数据库
┌─────────────────────────────────────────┐
│ App A 的条目 │
│ ├── service: com.app.a │
│ ├── account: authToken │
│ └── data: <加密的 token> │
├─────────────────────────────────────────┤
│ App B 的条目 │
│ ├── service: com.app.b │
│ ├── account: password │
│ └── data: <加密的密码> │
└─────────────────────────────────────────┘
2.2 Keychain 的四个核心概念
| 概念 | 作用 | 示例 |
|---|---|---|
kSecClass |
条目类型 | kSecClassGenericPassword |
kSecAttrService |
服务标识 | Bundle ID |
kSecAttrAccount |
账户标识 | "authToken" |
kSecAttrAccessible |
访问等级 | WhenUnlockedThisDeviceOnly |
关键理解 :service + account 组合是唯一标识。同一个 service 下,不同 account 是不同的条目。
2.3 封装 KeychainStore
swift
import Foundation
import Security
@Observable
final class KeychainStore {
static let shared = KeychainStore()
private let service: String
private init() {
self.service = Bundle.main.bundleIdentifier ?? "com.example.app"
}
// MARK: - 保存
func save(_ data: Data, for key: String) throws {
// 先删除旧值,避免重复
try? delete(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
// 仅本设备可用,不参与 iCloud 同步
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
// MARK: - 读取
func read(for key: String) throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else {
throw KeychainError.readFailed(status)
}
return result as? Data
}
// MARK: - 删除
func delete(_ key: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
// MARK: - 便捷方法
func saveString(_ value: String, for key: String) throws {
guard let data = value.data(using: .utf8) else { return }
try save(data, for: key)
}
func readString(for key: String) throws -> String? {
guard let data = try read(for: key) else { return nil }
return String(data: data, encoding: .utf8)
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case readFailed(OSStatus)
case deleteFailed(OSStatus)
}
}
逐行讲解:
| 代码 | 含义 |
|---|---|
kSecClassGenericPassword |
通用密码类型(最常用) |
kSecAttrService |
服务标识,用 Bundle ID |
kSecAttrAccount |
账户标识,如 "authToken" |
kSecValueData |
要存的数据 |
kSecAttrAccessible |
访问等级 |
SecItemAdd |
添加条目 |
SecItemCopyMatching |
查询条目 |
SecItemDelete |
删除条目 |
关键理解:
- 保存前先删除:避免重复条目。
errSecItemNotFound是正常的 :表示条目不存在,返回nil。OSStatus是返回码 :errSecSuccess表示成功。
2.4 访问等级深入
kSecAttrAccessible 控制"什么时候可以读取":
| 等级 | 含义 | 适合 |
|---|---|---|
kSecAttrAccessibleWhenUnlocked |
解锁后可读,参与备份 | 通用 |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
解锁后可读,不备份不同步 | token |
kSecAttrAccessibleAfterFirstUnlock |
首次解锁后一直可读 | 后台任务 |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
首次解锁后可读,不备份 | 后台任务 |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly |
需要设备密码 | 高安全 |
关键理解:
- token 用
WhenUnlockedThisDeviceOnly:设备解锁后才能读,不参与 iCloud 备份和同步。 AfterFirstUnlock适合后台任务:App 在后台时也能读。ThisDeviceOnly后缀:数据不备份、不同步。
为什么 token 要用 ThisDeviceOnly?
- 不参与 iCloud 备份:换设备时不会把旧 token 带过去。
- 不参与 iCloud 同步:多设备各有各的 token。
2.5 Keychain 的常见坑
坑 1:忘记先删除旧值。
swift
// ❌ 重复保存会报错 errSecDuplicateItem
let status = SecItemAdd(query, nil)
// ✅ 先删除再保存
try? delete(key)
let status = SecItemAdd(query, nil)
坑 2:errSecItemNotFound 被当成错误。
swift
// ❌ 把"不存在"当成错误
guard status == errSecSuccess else { throw error }
// ✅ "不存在"返回 nil
if status == errSecItemNotFound { return nil }
坑 3:删除 App 后 Keychain 数据还在。
Keychain 数据不随 App 删除而删除。如果用户重装 App,旧的 token 可能还在。
解决:在 App 首次启动时清理旧数据:
swift
if !UserDefaults.standard.bool(forKey: "hasLaunched") {
// 首次启动,清理 Keychain
try? KeychainStore.shared.delete("authToken")
UserDefaults.standard.set(true, forKey: "hasLaunched")
}
三、LocalAuthentication:Face ID / Touch ID
3.1 生物识别的工作原理
App 请求验证
↓
系统弹出 Face ID / Touch ID
↓
用户验证(面部 / 指纹)
↓
系统返回成功 / 失败
↓
App 根据结果决定是否解锁
关键理解 :App 不接触生物数据。Face ID 数据存在 Secure Enclave 里,App 只拿到"成功/失败"。
3.2 生物识别管理器
swift
import SwiftUI
import LocalAuthentication
import Observation
@MainActor
@Observable
final class BiometricAuth {
var isAvailable = false
var biometryType: LABiometryType = .none
var errorMessage: String?
init() {
checkAvailability()
}
// 检查是否可用
func checkAvailability() {
let context = LAContext()
var error: NSError?
isAvailable = context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
biometryType = context.biometryType
}
// 进行验证
func authenticate(reason: String) async -> Bool {
let context = LAContext()
context.localizedCancelTitle = "取消"
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
return success
} catch {
errorMessage = error.localizedDescription
return false
}
}
}
逐行讲解:
| 代码 | 含义 |
|---|---|
LAContext() |
创建认证上下文 |
canEvaluatePolicy |
检查是否支持 |
biometryType |
Face ID / Touch ID / none |
evaluatePolicy |
弹出验证 |
localizedReason |
验证时的提示文字 |
3.3 Policy 对比
| Policy | 含义 | 回退 |
|---|---|---|
.deviceOwnerAuthenticationWithBiometrics |
只允许生物识别 | 无 |
.deviceOwnerAuthentication |
生物识别 + 设备密码 | 支持 |
关键理解 :推荐用 .deviceOwnerAuthentication------生物识别失败时可回退到设备密码,用户体验更好。
为什么?
- 用户戴口罩时 Face ID 可能失败。
- 手指湿润时 Touch ID 可能失败。
- 回退到设备密码让用户不会"卡住"。
3.4 生物识别的错误处理
swift
func authenticate(reason: String) async -> Bool {
let context = LAContext()
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: reason
)
} catch let error as LAError {
switch error.code {
case .userCancel:
errorMessage = "用户取消"
case .userFallback:
errorMessage = "用户选择密码"
case .biometryNotAvailable:
errorMessage = "生物识别不可用"
case .biometryNotEnrolled:
errorMessage = "未录入生物识别"
case .biometryLockout:
errorMessage = "生物识别被锁定"
case .authenticationFailed:
errorMessage = "验证失败"
default:
errorMessage = error.localizedDescription
}
return false
} catch {
errorMessage = error.localizedDescription
return false
}
}
常见错误码:
| 错误码 | 含义 | 处理 |
|---|---|---|
.userCancel |
用户取消 | 不提示 |
.userFallback |
用户选择密码 | 走密码流程 |
.biometryNotAvailable |
不可用 | 隐藏生物识别按钮 |
.biometryNotEnrolled |
未录入 | 引导去设置 |
.biometryLockout |
被锁定 | 需要密码解锁 |
3.5 锁定页面
swift
struct LockedView: View {
@State private var auth = BiometricAuth()
@State private var isUnlocked = false
var body: some View {
Group {
if isUnlocked {
VStack {
Image(systemName: "lock.open.fill")
.font(.system(size: 60))
.foregroundStyle(.green)
Text("已解锁").font(.title)
}
} else {
VStack(spacing: 20) {
Image(systemName: auth.biometryType == .faceID ? "faceid" : "touchid")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("需要验证身份").font(.headline)
Button("解锁") {
Task {
isUnlocked = await auth.authenticate(reason: "解锁私人书单")
}
}
.buttonStyle(.borderedProminent)
.disabled(!auth.isAvailable)
if let error = auth.errorMessage {
Text(error).font(.caption).foregroundStyle(.red)
}
}
}
}
.padding()
.task {
if auth.isAvailable {
isUnlocked = await auth.authenticate(reason: "解锁私人书单")
} else {
isUnlocked = true
}
}
}
}
四、CryptoKit:加密与哈希
4.1 四种加密算法
| 算法 | 用途 | 特点 |
|---|---|---|
SHA256 |
哈希校验 | 不可逆,固定长度 |
HMAC |
签名 | 带密钥,防篡改 |
AES.GCM |
对称加密 | 加密 + 认证,推荐 |
ChaChaPoly |
对称加密 | 移动设备快 |
关键理解 :AES-GCM 是首选------同时提供加密和完整性认证。
4.2 加密工具
swift
import Foundation
import CryptoKit
enum CryptoHelper {
// MARK: - SHA256 哈希(不可逆)
static func sha256(_ string: String) -> String {
let data = Data(string.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
// MARK: - HMAC(带密钥的签名)
static func hmac(_ string: String, key: SymmetricKey) -> String {
let data = Data(string.utf8)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: key)
return mac.compactMap { String(format: "%02x", $0) }.joined()
}
// MARK: - AES-GCM 加密
static func encrypt(_ string: String, key: SymmetricKey) throws -> Data {
let data = Data(string.utf8)
let sealed = try AES.GCM.seal(data, using: key)
return sealed.combined! // nonce + 密文 + 认证标签
}
// MARK: - AES-GCM 解密
static func decrypt(_ data: Data, key: SymmetricKey) throws -> String {
let sealed = try AES.GCM.SealedBox(combined: data)
let decrypted = try AES.GCM.open(sealed, using: key)
guard let string = String(data: decrypted, encoding: .utf8) else {
throw CryptoError.invalidData
}
return string
}
// MARK: - 生成随机密钥
static func generateKey() -> SymmetricKey {
SymmetricKey(size: .bits256)
}
// MARK: - 密钥存 Keychain
static func saveKey(_ key: SymmetricKey, name: String) throws {
let data = key.withUnsafeBytes { Data($0) }
try KeychainStore.shared.save(data, for: "crypto_key_\(name)")
}
static func loadKey(name: String) throws -> SymmetricKey? {
guard let data = try KeychainStore.shared.read(for: "crypto_key_\(name)") else {
return nil
}
return SymmetricKey(data: data)
}
enum CryptoError: Error {
case invalidData
}
}
4.3 AES-GCM 的工作原理
加密:
明文 + 密钥 + 随机 nonce
↓
AES-GCM 加密
↓
密文 + 认证标签
↓
组合成 combined 数据(nonce + 密文 + 标签)
解密:
combined 数据
↓
分离 nonce、密文、标签
↓
AES-GCM 解密 + 验证
↓
明文(如果认证通过)
关键理解:
- GCM 自带认证:如果密文被篡改,解密会失败。
- nonce 是随机的:每次加密都不同,避免重放攻击。
- combined 包含所有信息 :
nonce + 密文 + 认证标签。
4.4 密钥管理
核心原则 :密钥必须存 Keychain,不能硬编码。
swift
// ❌ 硬编码密钥(等于没加密)
let key = SymmetricKey(data: Data("my_secret_key".utf8))
// ✅ 从 Keychain 读取或生成
let key: SymmetricKey
if let existing = try CryptoHelper.loadKey(name: "notes") {
key = existing
} else {
key = CryptoHelper.generateKey()
try CryptoHelper.saveKey(key, name: "notes")
}
为什么硬编码等于没加密?
- 攻击者反编译 App 就能拿到密钥。
- 所有用户的密钥相同,一个泄露全部泄露。
- 密钥不能轮换。
4.5 加密笔记示例
swift
func saveEncryptedNote(_ note: String, to url: URL) throws {
// 从 Keychain 读取密钥,或生成新密钥
let key: SymmetricKey
if let existing = try CryptoHelper.loadKey(name: "notes") {
key = existing
} else {
key = CryptoHelper.generateKey()
try CryptoHelper.saveKey(key, name: "notes")
}
// 加密
let encrypted = try CryptoHelper.encrypt(note, key: key)
// 存到文件,设置最严格的保护等级
try encrypted.write(to: url, options: [.completeFileProtection])
}
五、传输安全:ATS 和证书绑定
5.1 App Transport Security
默认强制 HTTPS,例外需在 Info.plist 声明(仅调试用):
xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
关键理解 :生产环境必须全 HTTPS,不要用例外。
5.2 证书绑定
swift
import Foundation
import CryptoKit
final class PinningDelegate: NSObject, URLSessionDelegate {
private let allowedHashes: Set<String>
init(allowedHashes: Set<String>) {
self.allowedHashes = allowedHashes
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(trust, 0) else {
return (.performDefaultHandling, nil)
}
let certData = SecCertificateCopyData(certificate) as Data
let hash = SHA256.hash(data: certData)
.compactMap { String(format: "%02x", $0) }.joined()
if allowedHashes.contains(hash) {
return (.useCredential, URLCredential(trust: trust))
}
return (.cancelAuthenticationChallenge, nil)
}
}
关键理解 :证书绑定能防中间人攻击,但证书更新时需同步更新哈希,维护成本高。
六、数据保护:文件加密等级
swift
func saveSecureFile(_ data: Data, to url: URL) throws {
try data.write(to: url, options: [.completeFileProtection])
}
6.1 保护等级对比
| 等级 | 设备锁定时 | 适合 |
|---|---|---|
.complete |
完全不可读 | 最敏感 |
.completeUnlessOpen |
已打开仍可写 | 上传中 |
.completeUntilFirstUserAuthentication |
首次解锁后可读(默认) | 通用 |
.none |
无保护 | 非敏感 |
关键理解 :最敏感的数据用 .complete,设备锁定时完全不可读。
七、隐私清单:PrivacyInfo.xcprivacy
7.1 创建
Xcode:File → New → File → App Privacy ,命名 PrivacyInfo.xcprivacy。
7.2 需要声明的内容
| 类别 | 说明 | 示例 |
|---|---|---|
| 收集的数据 | 邮箱、用户 ID、使用数据 | NSPrivacyCollectedDataTypeEmailAddress |
| 使用的 API | UserDefaults、文件时间戳 |
NSPrivacyAccessedAPICategoryUserDefaults |
| 跟踪域名 | 跨 App 跟踪 | NSPrivacyTrackingDomains |
7.3 示例
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
关键理解 :从 2024 年起是上架必需,缺失会被拒审。
7.4 常见的 Required Reason API
| API | 原因代码 | 用途 |
|---|---|---|
UserDefaults |
CA92.1 |
读取 App 设置 |
| 文件时间戳 | C617.1 |
显示文件修改时间 |
| 磁盘空间 | E174.1 |
检查可用空间 |
| 系统启动时间 | 35F9.1 |
计算运行时长 |
八、权限最小化
8.1 权限说明必须完整
| 功能 | 权限键 |
|---|---|
| 位置 | NSLocationWhenInUseUsageDescription |
| 相机 | NSCameraUsageDescription |
| 相册 | NSPhotoLibraryUsageDescription |
| 麦克风 | NSMicrophoneUsageDescription |
| 生物识别 | NSFaceIDUsageDescription |
8.2 按需请求
swift
// ❌ 启动时请求所有权限
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) {
requestLocation()
requestCamera()
requestNotifications()
}
// ✅ 按需请求
Button("拍照") {
Task {
let granted = await AVCaptureDevice.requestAccess(for: .video)
if granted { showCamera = true }
}
}
关键理解 :按需请求能提高用户同意率,也符合隐私最小化原则。
九、完整实战:安全方案
swift
import SwiftUI
import SwiftData
struct SecureView: View {
@State private var auth = BiometricAuth()
@State private var isUnlocked = false
var body: some View {
Group {
if isUnlocked {
ContentView()
} else {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Text("已锁定").font(.headline)
Button("解锁") {
Task {
isUnlocked = await auth.authenticate(reason: "查看私人内容")
}
}
.buttonStyle(.borderedProminent)
.disabled(!auth.isAvailable)
}
}
}
.task {
// 检查是否有 token
let token = try? KeychainStore.shared.readString(for: "authToken")
// 自动解锁
if auth.isAvailable {
isUnlocked = await auth.authenticate(reason: "查看私人内容")
} else {
isUnlocked = true
}
}
}
}
十、调试排查指南
10.1 Keychain 读不到
排查步骤:
- 检查
kSecAttrAccessible等级。 - 检查
service和account是否一致。 - 检查保存时是否成功(
SecItemAdd返回码)。 - 检查是否是首次启动(Keychain 数据可能不存在)。
10.2 Face ID 不弹
排查步骤:
- 检查 Info.plist 是否有
NSFaceIDUsageDescription。 - 检查
canEvaluatePolicy是否返回true。 - 检查是否在真机上测试(模拟器支持有限)。
- 检查设备是否录入 Face ID。
10.3 加密后无法解密
排查步骤:
- 检查密钥是否一致。
- 检查密钥是否存到 Keychain。
- 检查密文是否完整(
combined数据)。 - 检查是否用了正确的密钥名称。
10.4 调试清单速查
| 现象 | 排查方向 |
|---|---|
| Keychain 读不到 | 访问等级、service/account |
| Face ID 不弹 | Info.plist、真机、录入 |
| 加密失败 | 密钥、密文完整性 |
| 解密失败 | 密钥是否一致 |
| 上架被拒 | 隐私清单、权限说明 |
十一、练习题
选择题
题1:存储 token 应该使用?
A. Keychain B. UserDefaults C. SwiftData D. 文件
参考答案:A
解读 :Keychain 是系统级加密存储,适合敏感信息。UserDefaults 是明文 plist,SwiftData 未加密,文件也是明文。
题2:弹出 Face ID 验证使用哪个框架?
A. LocalAuthentication B. CryptoKit C. Security D. AuthenticationServices
参考答案:A
解读 :LAContext.evaluatePolicy 弹出生物识别。CryptoKit 是加密,Security 是 Keychain。
题3:AES-GCM 加密使用哪个框架?
A. CryptoKit B. Security C. LocalAuthentication D. Foundation
参考答案:A
解读 :AES.GCM.seal 和 AES.GCM.open 来自 CryptoKit。它同时提供加密和完整性认证。
题4:隐私清单文件名是?
A. PrivacyInfo.xcprivacy B. Info.plist C. Privacy.plist D. Security.plist
参考答案:A
解读:Xcode 生成的隐私清单文件。从 2024 年起是上架必需。
题5:最严格的文件保护等级是?
A. .complete B. .completeUnlessOpen C. .completeUntilFirstUserAuthentication D. .none
参考答案:A
解读 :.complete 在设备锁定时完全不可读。最敏感的数据用这个等级。
题6:token 应该用哪个 Keychain 访问等级?
A. kSecAttrAccessibleWhenUnlockedThisDeviceOnly B. kSecAttrAccessibleWhenUnlocked C. kSecAttrAccessibleAlways D. kSecAttrAccessibleNever
参考答案:A
解读:仅本设备,不备份不同步。换设备时不会把旧 token 带过去。
题7:生物识别推荐用哪个 Policy?
A. .deviceOwnerAuthentication B. .deviceOwnerAuthenticationWithBiometrics C. .deviceOwnerAuthenticationWithWatch D. .none
参考答案:A
解读:支持回退到设备密码,用户体验更好。戴口罩 Face ID 失败时不会卡住。
题8:为什么硬编码密钥等于没加密?
A. 密钥太短 B. 反编译可拿到密钥 C. 性能差 D. 兼容性差
参考答案:B
解读:攻击者反编译 App 就能拿到密钥。所有用户密钥相同,一个泄露全部泄露。
判断题
题1 :UserDefaults 适合存储用户密码。
参考答案:错
解读 :UserDefaults 是明文 plist,容易被读取。密码必须存 Keychain。
题2:AES-GCM 同时提供加密和完整性认证。
参考答案:对
解读:GCM 模式自带认证标签。如果密文被篡改,解密会失败。
题3:SHA256 是可逆的,可以解密回原文。
参考答案:错
解读:哈希是单向的,不可逆。适合校验,不适合加密。
题4:App Transport Security 默认允许 HTTP。
参考答案:错
解读:ATS 默认强制 HTTPS。例外需要显式声明,仅调试用。
题5 :kSecAttrAccessibleWhenUnlockedThisDeviceOnly 的数据不参与 iCloud 备份。
参考答案:对
解读 :ThisDeviceOnly 后缀表示不备份、不同步。
题6:加密密钥可以硬编码在代码里。
参考答案:错
解读:密钥必须存 Keychain。硬编码等于没加密。
题7:隐私清单从 2024 年起是上架必需。
参考答案:对
解读:缺失会被拒审。需要声明收集的数据和使用的 API。
题8:删除 App 后 Keychain 数据一定被清除。
参考答案:错
解读:Keychain 数据不随 App 删除而删除。如果用户重装 App,旧数据可能还在。需要在首次启动时清理。
填空题
题1 :安全存储使用 ______。
参考答案 :Keychain
解读:系统级加密存储,适合 token、密码、密钥。
题2 :生物识别使用 ______ 框架。
参考答案 :LocalAuthentication
解读 :LAContext.evaluatePolicy 弹出生物识别。
题3 :AES-GCM 加密使用 ______ 框架。
参考答案 :CryptoKit
解读 :AES.GCM.seal 和 AES.GCM.open。
题4 :隐私清单文件名是 ______。
参考答案 :PrivacyInfo.xcprivacy
解读:Xcode 生成的隐私清单文件。
题5 :最严格的保护等级是 .______。
参考答案 :complete
解读 :FileProtectionType.complete。
题6 :token 访问等级是 kSecAttrAccessibleWhenUnlocked______。
参考答案 :ThisDeviceOnly
解读:仅本设备,不备份不同步。
题7 :签名使用 ______ 算法。
参考答案 :HMAC
解读:带密钥的哈希,用于签名校验。
题8 :生物识别推荐用 .deviceOwner______。
参考答案 :Authentication
解读:支持回退到设备密码。
改错题
题1:
swift
UserDefaults.standard.set(token, forKey: "authToken")
要求:找出安全问题。
参考答案:
swift
try KeychainStore.shared.saveString(token, for: "authToken")
解读 :token 是敏感信息,必须存 Keychain。UserDefaults 是明文 plist,容易被读取。
题2:
swift
let key = SymmetricKey(size: .bits256)
let encrypted = try AES.GCM.seal(data, using: key)
// key 没有保存
要求:找出问题。
参考答案:
swift
let key = SymmetricKey(size: .bits256)
let encrypted = try AES.GCM.seal(data, using: key)
try CryptoHelper.saveKey(key, name: "notes")
解读:密钥丢失后数据无法解密。必须存 Keychain。
题3:
swift
func authenticate() {
let context = LAContext()
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: "解锁") { success, error in
self.isUnlocked = success // 直接更新 UI
}
}
要求:找出线程问题。
参考答案:
swift
func authenticate() async -> Bool {
let context = LAContext()
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "解锁"
)
} catch {
return false
}
}
// 调用方
Task {
let success = await auth.authenticate(reason: "解锁")
await MainActor.run { isUnlocked = success }
}
解读 :回调不在主线程,更新 UI 必须回主线程。用 async/await 版本更安全。
题4:
swift
let key = "my_secret_key_12345"
let encrypted = try AES.GCM.seal(data, using: SymmetricKey(data: Data(key.utf8)))
要求:找出安全问题。
参考答案:
swift
let key: SymmetricKey
if let existing = try CryptoHelper.loadKey(name: "notes") {
key = existing
} else {
key = CryptoHelper.generateKey()
try CryptoHelper.saveKey(key, name: "notes")
}
let encrypted = try AES.GCM.seal(data, using: key)
解读:硬编码密钥等于没加密。攻击者反编译 App 就能拿到。
题5:
swift
// 启动时请求所有权限
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -> Bool {
CLLocationManager().requestWhenInUseAuthorization()
AVCaptureDevice.requestAccess(for: .video) { _ in }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _ in }
return true
}
要求:改为按需请求。
参考答案:
swift
// 在需要时请求
Button("拍照") {
Task {
let granted = await AVCaptureDevice.requestAccess(for: .video)
if granted { showCamera = true }
}
}
解读:按需请求能提高同意率,符合隐私最小化原则。
代码预测题
题1:
swift
let hash1 = CryptoHelper.sha256("hello")
let hash2 = CryptoHelper.sha256("hello")
let hash3 = CryptoHelper.sha256("world")
问题:hash1 == hash2 吗?hash1 == hash3 吗?
参考答案 :hash1 == hash2 为 true,hash1 == hash3 为 false。
解读:相同输入产生相同哈希,不同输入产生不同哈希。
题2:
swift
let key = CryptoHelper.generateKey()
let encrypted = try CryptoHelper.encrypt("secret", key: key)
let decrypted = try CryptoHelper.decrypt(encrypted, key: key)
问题:decrypted 是什么?
参考答案 :"secret"。
解读:AES-GCM 加密后解密得到原文。
题3:
swift
isAvailable = context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
)
问题:如果设备没有 Face ID 也没有 Touch ID,isAvailable 是什么?
参考答案 :false。
解读 :canEvaluatePolicy 返回 false。
题4:
swift
let encrypted = try AES.GCM.seal(data, using: key)
let combined = encrypted.combined!
问题:combined 包含什么?
参考答案 :nonce + 密文 + 认证标签。
解读 :combined 包含解密所需的全部信息。
题5:
swift
try data.write(to: url, options: [.completeFileProtection])
问题:设备锁定时能读这个文件吗?
参考答案:不能。
解读 :.completeFileProtection 表示设备锁定时完全不可读。
编程题
题1 :封装一个 SecureStore,支持保存和读取 token。
参考答案:
swift
@Observable
final class SecureStore {
static let shared = SecureStore()
private let store = KeychainStore.shared
var token: String? {
get { try? store.readString(for: "token") }
set {
if let newValue {
try? store.saveString(newValue, for: "token")
} else {
try? store.delete("token")
}
}
}
}
解读 :把 Keychain 细节封装起来,上层只看到 token 属性。
题2:用 CryptoKit 加密一段笔记。
参考答案:
swift
func encryptNote(_ note: String) throws -> (Data, SymmetricKey) {
let key = CryptoHelper.generateKey()
let encrypted = try CryptoHelper.encrypt(note, key: key)
return (encrypted, key)
}
func decryptNote(_ data: Data, key: SymmetricKey) throws -> String {
try CryptoHelper.decrypt(data, key: key)
}
解读:对称加密适合本地数据加密,密钥必须安全保存。
题3:给 App 加 Face ID 锁定。
参考答案:
swift
struct LibraryLockView: View {
@State private var auth = BiometricAuth()
@State private var isUnlocked = false
var body: some View {
Group {
if isUnlocked {
BookListView()
} else {
VStack(spacing: 20) {
Image(systemName: "lock.shield.fill")
.font(.system(size: 60))
.foregroundStyle(.blue)
Button("解锁") {
Task {
isUnlocked = await auth.authenticate(reason: "查看私人书库")
}
}
.buttonStyle(.borderedProminent)
}
}
}
.task {
if auth.isAvailable {
isUnlocked = await auth.authenticate(reason: "查看私人书库")
} else {
isUnlocked = true
}
}
}
}
解读 :用 @State 控制解锁状态,onAppear 自动验证。
题4:把敏感笔记加密后存到文件。
参考答案:
swift
func saveEncryptedNote(_ note: String, to url: URL) throws {
let key: SymmetricKey
if let existing = try CryptoHelper.loadKey(name: "notes") {
key = existing
} else {
key = CryptoHelper.generateKey()
try CryptoHelper.saveKey(key, name: "notes")
}
let encrypted = try CryptoHelper.encrypt(note, key: key)
try encrypted.write(to: url, options: [.completeFileProtection])
}
解读:密钥存 Keychain,密文存文件,文件设置最严格保护等级。
题5 :创建一个隐私清单,声明使用 UserDefaults。
参考答案:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
解读 :声明使用 UserDefaults 及原因代码 CA92.1。
开放思考题
题1 :为什么不能把 token 存在 UserDefaults?
参考答案 :UserDefaults 是明文 plist,容易被读取。
解读:攻击者可以用工具读取沙盒里的 plist 文件。token 必须存 Keychain。
题2:证书绑定有什么优缺点?
参考答案:优点防中间人攻击;缺点是证书更新需同步更新哈希,维护成本高。
解读:高安全场景值得,普通 App 可依赖 ATS。
题3:隐私清单的作用是什么?
参考答案:声明 App 收集的数据和使用的 API。
解读:从 2024 年起是上架必需项。缺失会被拒审。
题4:为什么加密密钥必须存 Keychain?
参考答案:密钥丢失后数据无法解密;存代码里等于没加密。
解读:Keychain 是系统级加密存储,只有 App 能访问。
题5 :为什么推荐 .deviceOwnerAuthentication 而不是 .deviceOwnerAuthenticationWithBiometrics?
参考答案:前者支持回退到设备密码,用户体验更好。
解读:戴口罩 Face ID 失败时,用户可以输入密码,不会卡住。
题6:为什么按需请求权限比启动时请求所有权限好?
参考答案:按需请求能提高同意率,符合隐私最小化原则。
解读:用户在需要某个功能时才看到权限请求,更容易理解为什么要授权。
题7:删除 App 后 Keychain 数据会怎样?
参考答案:不随 App 删除而删除。重装 App 后旧数据可能还在。
解读:需要在首次启动时清理旧数据,避免使用过期的 token。
十二、全课知识点总结
核心理解
- 安全是分层的:传输、存储、访问、合规。
- 敏感信息必须存 Keychain,没有例外。
- 加密密钥必须存 Keychain,不能硬编码。
- 隐私清单从 2024 年起是上架必需。
Keychain
- 系统级加密存储,适合 token、密码、密钥。
kSecAttrAccessibleWhenUnlockedThisDeviceOnly适合 token。SecItemAdd、SecItemCopyMatching、SecItemDelete。- 保存前先删除,避免重复。
errSecItemNotFound是正常的。- 删除 App 后 Keychain 数据不自动清除。
LocalAuthentication
LAContext.evaluatePolicy弹出生物识别。.deviceOwnerAuthentication支持回退到设备密码。- 需要
NSFaceIDUsageDescription。 - App 不接触生物数据,只拿成功/失败。
CryptoKit
SHA256:哈希,不可逆。HMAC:带密钥的签名。AES.GCM:对称加密,推荐。- 密钥必须存 Keychain,不能硬编码。
传输安全
- ATS 默认强制 HTTPS。
- 证书绑定防中间人攻击。
- 生产环境全 HTTPS。
数据保护
.completeFileProtection:最严格。.completeUntilFirstUserAuthentication:默认。
隐私合规
PrivacyInfo.xcprivacy声明数据和 API。- 权限说明完整。
- 按需请求权限。
常见错误
- token 存 UserDefaults。
- 硬编码密钥。
- 没加权限说明。
- 隐私清单缺失。
- 回调不在主线程。
- 删除 App 后 Keychain 数据残留。
本课达标标准 :能用 Keychain 保存和读取 token,能用 LocalAuthentication 做 Face ID 验证,能用 CryptoKit 做哈希、签名和 AES-GCM 加密,能配置 ATS 和证书绑定,能添加隐私清单,能设置文件保护等级,并给 App 加上安全的 token 存储和 Face ID 锁定。
下一课预告:第10课学习模块化架构,用 Swift Package Manager 把项目拆成多个可独立开发、测试、复用的模块。