【SwiftUI提高练中学】第9课 安全与隐私

本课目标:把 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 把项目拆成多个可独立开发、测试、复用的模块。

相关推荐
传奇开心果编程11 小时前
【声明式UI开发实用技术学与练】第10课 用状态机建模复杂交互
移动开发·编程语言理论·开发范式·编程框架·声明式ui
传奇开心果编程12 小时前
【声明式UI实用开发技术学与练】第5课 表单处理:受控与非受控
ui·移动开发·开发范式·编程框架·声明式ui
茶底世界之下12 小时前
动态切换 nearest / linear 时,为什么不能只替换 sampler
ios·swift
传奇开心果编程13 小时前
【声明式UI开发实用技术学与练】第9课 派生状态与计算
ui·移动开发·ux·编程框架·声明式ui
应用市场15 小时前
把旧 Pixel 变成相册备份中转站(上):Mac 到安卓的照片传输工具设计——流式上传、sha256 校验、adb forward 与 Bonjour
android·macos·adb·kotlin·swift
传奇开心果编程17 小时前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
东坡肘子17 小时前
Swift Server,又多了一个 Google -- 肘子的 Swift 周报 #156
人工智能·swiftui·swift
打工仔折腾 AI1 天前
从零写一个CAD 05:以鼠标为中心的滚轮缩放,招法能复用但顺序不能反
开发语言·后端·python·性能优化·计算机外设·swift·ai agent 实战
传奇开心果编程2 天前
【现代声明式UI学与练】第4课 列表渲染与 key——如何高效渲染列表、key 的作用、列表重排时的状态保持
学习·flutter·react native·ui·swiftui·android jetpack