关于 ios-security
面向 iOS 开发者的安全实践指南,涵盖敏感数据存储、身份验证、加密与 Apple 平台安全要求,并提供代码示例和审查清单。
主要功能
- 01Keychain 敏感数据存储指导
- 02文件数据保护级别说明
- 03CryptoKit 加密与哈希实践
- 04Secure Enclave 和生物识别保护
- 05ATS 与证书固定建议
- 06安全编码及隐私清单检查
适用场景
- 01为密码、令牌和 API 密钥选择安全存储方式
- 02为敏感文件设置适当的数据保护
- 03审查 iOS 应用的加密和生物识别实现
- 04检查网络连接、日志及隐私清单配置
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill ios-security安装后在 Claude Code 中输入/ios-security确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/app-review-guidelines.mdcryptokit-advanced.mdfile-storage-patterns.mdprivacy-manifest.md详细介绍
iOS 安全
介绍如何处理敏感数据、验证用户身份、正确加密,以及遵循 Apple 在 iOS 上的安全最佳实践。
目录
钥匙串服务
钥匙串是存储敏感数据的唯一正确位置。切勿将密码、令牌、API 密钥或机密信息存储在 UserDefaults、文件或 Core Data 中。
存储凭据
func saveToKeychain(account: String, data: Data, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let updates: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updates as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.updateFailed(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.saveFailed(status)
}
}
读取凭据
func readFromKeychain(account: String, service: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.readFailed(status)
}
return data
}
删除凭据
func deleteFromKeychain(account: String, service: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
kSecAttrAccessible 值
| 值 | 可用时机 | 仅限本设备 | 用途 |
|---|---|---|---|
kSecAttrAccessibleWhenUnlocked | 设备已解锁 | 否 | 常规凭据 |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly | 设备已解锁 | 是 | 敏感凭据 |
kSecAttrAccessibleAfterFirstUnlock | 首次解锁后 | 否 | 可在后台访问的令牌 |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | 首次解锁后 | 是 | 后台令牌,不备份 |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | 已设置密码且设备已解锁 | 是 | 最高安全级别 |
规则:
- 对敏感数据使用
ThisDeviceOnly变体,以防止备份和恢复到其他设备。 - 对后台操作所需的令牌使用
AfterFirstUnlock。 - 对最敏感的数据使用
WhenPasscodeSetThisDeviceOnly。移除密码后,项目会被删除。 - 切勿使用
kSecAttrAccessibleAlways(已弃用且不安全)。
钥匙串访问组
在同一团队的多个应用之间共享钥匙串项目:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "shared-token",
kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]
@AppStorage 与钥匙串
| 存储方式 | 用途 | 安全性 |
|---|---|---|
@AppStorage / UserDefaults | 非敏感偏好设置(主题、引导状态、功能标记) | 静态存储时未加密 |
| 钥匙串 | 密码、令牌、API 密钥、机密信息 | 硬件加密,并受访问控制 |
规则: 如果数据泄露会令人尴尬或造成危险,就应存入钥匙串。其他数据都可以使用 @AppStorage。
// Non-sensitive preference -- @AppStorage is fine
@AppStorage("hasCompletedOnboarding") private var hasOnboarded = false
// Sensitive credential -- MUST use Keychain
// WRONG: @AppStorage("authToken") private var token = ""
// CORRECT: Use saveToKeychain(account:data:service:)
数据保护
iOS 会根据保护级别对文件进行加密:
| 级别 | 可用时机 | 用途 |
|---|---|---|
.complete | 仅在设备解锁时 | 敏感用户数据 |
.completeUnlessOpen | 锁定后,已打开的句柄仍可使用 | 正在进行的下载、录音 |
.completeUntilFirstUserAuthentication | 首次解锁后(默认) | 大多数应用数据 |
.none | 始终可用 | 非敏感且系统所需的数据 |
// Set file protection
try data.write(to: url, options: .completeFileProtection)
// Check protection level
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let protection = attributes[.protectionKey] as? FileProtectionType
任何包含用户敏感数据的文件都应使用 .complete。默认的 .completeUntilFirstUserAuthentication 适用于一般应用数据。
CryptoKit
所有加密操作都应使用 CryptoKit。新代码不要使用 CommonCrypto 或原始 Security 框架。
对称加密(AES-GCM)
import CryptoKit
let key = SymmetricKey(size: .bits256)
func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(data, using: key)
guard let combined = sealed.combined else {
throw CryptoError.sealFailed
}
return combined
}
func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: data)
return try AES.GCM.open(box, using: key)
}
哈希
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
// Also available: SHA384, SHA512
HMAC(消息认证)
let key = SymmetricKey(size: .bits256)
// Sign
let signature = HMAC<SHA256>.authenticationCode(for: data, using: key)
// Verify
let isValid = HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: data, using: key)
有关数字签名(P256/ECDSA)、密钥协商(Curve25519)、ChaChaPoly 和 HKDF 密钥派生,请参阅 references/cryptokit-advanced.md。
安全隔区
为了获得最高安全性,请将密钥存储在安全隔区中。密钥永远不会离开硬件。仅支持 P256。
guard SecureEnclave.isAvailable else { return }
let accessControl = SecAccessControlCreateWithFlags(
nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], nil
)!
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(accessControl: accessControl)
let signature = try privateKey.signature(for: data) // May trigger biometric prompt
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
// Persist: store dataRepresentation in Keychain, restore with:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
生物识别身份验证
本节介绍钥匙串项目和数据访问的生物识别保护。有关面向用户的生物识别登录流程(将 LAContext.evaluatePolicy 用作登录机制),请参阅 authentication 技能。
LocalAuthentication(Face ID / Touch ID)
import LocalAuthentication
func authenticateWithBiometrics() async throws -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, error: &error
) else {
// Biometrics not available -- fall back to passcode
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Authenticate to access your account"
)
}
throw AuthError.biometricsUnavailable
}
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
)
}
Info.plist 要求
必须在 Info.plist 中包含 NSFaceIDUsageDescription:
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your secure data</string>
缺少此键会导致 Face ID 设备发生崩溃。
LAContext 配置
let context = LAContext()
context.localizedFallbackTitle = "Use Passcode"
context.touchIDAuthenticationAllowableReuseDuration = 30
let currentState = context.evaluatedPolicyDomainState // Compare to detect enrollment changes
生物识别 + Keychain
使用生物识别访问权限保护 Keychain 项目:
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "auth-token",
kSecValueData as String: tokenData,
kSecAttrAccessControl as String: access,
kSecUseAuthenticationContext as String: LAContext()
]
SecAccessControl 标志:
.biometryCurrentSet-- 需要生物识别;如果注册信息发生变化,则会失效。安全性最高。.biometryAny-- 需要生物识别;注册信息发生变化后仍然有效。.userPresence-- 生物识别或密码。灵活性最高。
App Transport Security(ATS)
ATS 默认强制使用 HTTPS。切勿禁用。
ATS 要求
- TLS 1.2 或更高版本
- 前向保密密码套件
- SHA-256 或更高等级的证书
- 2048 位或更长的 RSA 密钥(或 256 位 ECC 密钥)
例外域(最后手段)
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy-api.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.2</string>
</dict>
</dict>
</dict>
规则:
- 切勿将
NSAllowsArbitraryLoads设为 true。Apple 会拒绝该应用。 - 例外域需要在 App Review 备注中说明理由。
- 仅对你无法控制的第三方服务使用例外域。
证书固定
为敏感 API 连接固定证书,以防止 MITM 攻击。
URLSession 委托固定
import CryptoKit
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
// SHA-256 hash of the certificate's Subject Public Key Info
private let pinnedHashes: Set<String> = [
"base64EncodedSHA256HashOfSPKI=="
]
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard let trust = challenge.protectionSpace.serverTrust,
let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let certificate = chain.first else {
return (.cancelAuthenticationChallenge, nil)
}
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(
publicKey, nil
) as Data? else {
return (.cancelAuthenticationChallenge, nil)
}
let hash = SHA256.hash(data: publicKeyData)
let hashString = Data(hash).base64EncodedString()
if pinnedHashes.contains(hashString) {
return (.useCredential, URLCredential(trust: trust))
}
return (.cancelAuthenticationChallenge, nil)
}
}
规则:
- 固定公钥哈希,而非证书。证书会轮换,公钥则更稳定。
- 始终至少包含一个备用固定项。
- 制定轮换计划。如果所有固定密钥都过期,应用将无法连接。
- 考虑设置紧急停止开关(通过远程配置禁用证书固定)。
安全编码模式
切勿记录敏感数据
// WRONG
logger.debug("User logged in with token: \(token)")
// CORRECT
logger.debug("User logged in successfully")
清除内存中的敏感数据
var sensitiveData = Data(/* ... */)
defer {
sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}
验证所有输入
guard let url = URL(string: input),
["https"].contains(url.scheme?.lowercased()) else {
throw SecurityError.invalidURL
}
let resolved = url.standardized.path
guard resolved.hasPrefix(allowedDirectory.path) else {
throw SecurityError.pathTraversal
}
API 密钥占位符模式
使用 #error 防止意外提交占位符 API 密钥:
// Forces a build error until the real key is configured
#error("Add your API key to Secrets.plist -- see README for setup")
private let apiKey = Secrets.value(for: "API_KEY")
越狱检测
检查已知越狱文件路径(/Applications/Cydia.app、/usr/sbin/sshd 等)以及沙盒逃逸情况。越狱检测并非万无一失,应将其作为一层防护,而非唯一防护。完整实现请参阅 references/cryptokit-advanced.md。
隐私清单
应用和 SDK 必须在 PrivacyInfo.xcprivacy 中声明数据访问情况。必需理由 API 声明及安全相关的数据收集详情,请参阅
references/privacy-manifest.md。提交要求和
合规检查清单请参阅 references/app-review-guidelines.md。
常见错误
- 将机密信息存储在 UserDefaults 中。 令牌、密码和 API 密钥必须存放在 Keychain 中。
- 在源代码中硬编码机密信息。 Swift 文件中不得包含 API 密钥或凭据。
- 全局禁用 ATS。
NSAllowsArbitraryLoads = true有被拒风险。 - 记录敏感数据。 切勿记录令牌、密码或 API 密钥。
- 缺少 PrivacyInfo.xcprivacy。 所有使用必需理由 API 的应用都必须包含此文件。
- 使用 CommonCrypto 而非 CryptoKit。 CryptoKit 更安全,也更现代。
- 缺少 NSFaceIDUsageDescription。 会导致 Face ID 设备发生崩溃。
- 需要
.biometryCurrentSet时却使用.biometryAny。 前者会在注册信息变化后继续有效,对于高安全级别项目来说可能不合适。 - 路径遍历漏洞。 始终解析并验证路径。
- 缺少并发注解。 确保 Keychain 封装类型符合 Sendable;将面向 UI 的安全提示隔离到
@MainActor。
审查清单
- 密钥存储在 Keychain 中,而非 UserDefaults 或文件中;不硬编码凭据
- 使用正确的
kSecAttrAccessible值;非备份数据使用ThisDeviceOnly - 为敏感文件设置文件保护级别(
.complete) - 使用 CryptoKit 加密(而非 CommonCrypto);使用 256 位对称密钥
- 密钥存储在 Keychain 或 Secure Enclave 中
- 生物识别认证支持回退;在 Info.plist 中添加
NSFaceIDUsageDescription - 使用正确的
SecAccessControl标志;配置LAContext - 强制使用 HTTPS;不设置
NSAllowsArbitraryLoads;为敏感 API 配置证书固定 - 存在 PrivacyInfo.xcprivacy;声明所有必需理由的 API
- 日志中不包含敏感数据;使用后清除 Data;验证 URL/路径
参考资料
- CryptoKit 高级模式:references/cryptokit-advanced.md
- 隐私清单指南:references/privacy-manifest.md
- App Review 安全指南:references/app-review-guidelines.md
- 文件存储目录指南与保护:references/file-storage-patterns.md