ios-security
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseiOS Security
iOS 安全
Guidance for handling sensitive data, authenticating users, encrypting
correctly, and following Apple's security best practices on iOS.
本文档指导你在iOS上处理敏感数据、验证用户身份、正确加密以及遵循Apple的安全最佳实践。
Keychain Services
Keychain Services
The Keychain is the ONLY correct place to store sensitive data. Never store
passwords, tokens, API keys, or secrets in UserDefaults, files, or Core Data.
Keychain是存储敏感数据的唯一正确位置。切勿将密码、令牌、API密钥或机密信息存储在UserDefaults、文件或Core Data中。
Storing Credentials
存储凭据
swift
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)
}
}swift
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)
}
}Reading Credentials
读取凭据
swift
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
}swift
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
}Deleting Credentials
删除凭据
swift
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)
}
}swift
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 Values
kSecAttrAccessible 取值
| Value | When Available | Device-Only | Use For |
|---|---|---|---|
| Device unlocked | No | General credentials |
| Device unlocked | Yes | Sensitive credentials |
| After first unlock | No | Background-accessible tokens |
| After first unlock | Yes | Background tokens, no backup |
| Passcode set + unlocked | Yes | Highest security |
Rules:
- Use variants for sensitive data. Prevents backup/restore to other devices.
ThisDeviceOnly - Use for tokens needed by background operations.
AfterFirstUnlock - Use for most sensitive data. Item is deleted if passcode is removed.
WhenPasscodeSetThisDeviceOnly - NEVER use (deprecated and insecure).
kSecAttrAccessibleAlways
| 取值 | 可用时机 | 仅限本机 | 适用场景 |
|---|---|---|---|
| 设备解锁后 | 否 | 通用凭据 |
| 设备解锁后 | 是 | 敏感凭据 |
| 首次解锁后 | 否 | 后台可访问的令牌 |
| 首次解锁后 | 是 | 后台令牌,不备份 |
| 设置密码且设备解锁后 | 是 | 最高安全级别 |
规则:
- 敏感数据使用系列取值,防止备份/恢复到其他设备。
ThisDeviceOnly - 后台操作所需的令牌使用。
AfterFirstUnlock - 最高敏感数据使用,移除密码时该项目会被删除。
WhenPasscodeSetThisDeviceOnly - 绝不要使用(已弃用且不安全)。
kSecAttrAccessibleAlways
Keychain Access Groups
Keychain 访问组
Share keychain items across apps from the same team:
swift
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "shared-token",
kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]同一开发团队的应用之间共享Keychain项目:
swift
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "shared-token",
kSecAttrAccessGroup as String: "TEAMID.com.company.shared"
]Data Protection
数据保护
iOS encrypts files based on their protection class:
| Class | When Available | Use For |
|---|---|---|
| Only when unlocked | Sensitive user data |
| Open handles survive lock | Active downloads, recordings |
| After first unlock (default) | Most app data |
| Always | Non-sensitive, system-needed data |
swift
// 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? FileProtectionTypeUse for any file containing user-sensitive data. The default
is acceptable for general app data.
.complete.completeUntilFirstUserAuthenticationiOS会根据文件的保护类别对其进行加密:
| 类别 | 可用时机 | 适用场景 |
|---|---|---|
| 仅设备解锁后 | 敏感用户数据 |
| 打开的句柄在设备锁定后仍可用 | 活跃下载、录制内容 |
| 首次解锁后(默认) | 大多数应用数据 |
| 始终可用 | 非敏感、系统所需数据 |
swift
// 设置文件保护
try data.write(to: url, options: .completeFileProtection)
// 检查保护级别
let attributes = try FileManager.default.attributesOfItem(atPath: path)
let protection = attributes[.protectionKey] as? FileProtectionType包含用户敏感数据的文件请使用。默认的适用于普通应用数据。
.complete.completeUntilFirstUserAuthenticationCryptoKit
CryptoKit
Use CryptoKit for all cryptographic operations. Do not use CommonCrypto or the
raw Security framework for new code.
所有加密操作请使用CryptoKit。新代码不要使用CommonCrypto或原始Security框架。
Symmetric Encryption (AES-GCM)
对称加密(AES-GCM)
swift
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)
}swift
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)
}Hashing
哈希
swift
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
// Also available: SHA384, SHA512swift
let hash = SHA256.hash(data: data)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
// 还支持:SHA384、SHA512HMAC (Message Authentication)
HMAC(消息认证)
swift
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)For digital signatures (P256/ECDSA), key agreement (Curve25519), ChaChaPoly,
and HKDF key derivation, see .
references/cryptokit-advanced.mdswift
let key = SymmetricKey(size: .bits256)
// 签名
let signature = HMAC<SHA256>.authenticationCode(for: data, using: key)
// 验证
let isValid = HMAC<SHA256>.isValidAuthenticationCode(signature, authenticating: data, using: key)有关数字签名(P256/ECDSA)、密钥协商(Curve25519)、ChaChaPoly以及HKDF密钥派生的内容,请参阅。
references/cryptokit-advanced.mdSecure Enclave
Secure Enclave
For the highest security, store keys in the Secure Enclave. Keys never leave
the hardware. Only P256 is supported.
swift
// Check availability first
guard SecureEnclave.isAvailable else {
// Fall back to software-based keys
return
}
// Create access control
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
// Create a Secure Enclave key with access control
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl
)
// Sign data (may trigger biometric prompt)
let signature = try privateKey.signature(for: data)
// Verify with the public key (no hardware access needed)
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
// Persist the key for later use
let keyData = privateKey.dataRepresentation
// Store keyData in Keychain, then restore with:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: keyData
)最高安全级别的密钥请存储在Secure Enclave中,密钥永远不会离开硬件。仅支持P256密钥。
swift
// 先检查可用性
guard SecureEnclave.isAvailable else {
// 回退到基于软件的密钥
return
}
// 创建访问控制
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet],
nil
)!
// 创建带访问控制的Secure Enclave密钥
let privateKey = try SecureEnclave.P256.Signing.PrivateKey(
accessControl: accessControl
)
// 签名数据(可能触发生物识别验证提示)
let signature = try privateKey.signature(for: data)
// 用公钥验证(无需访问硬件)
let isValid = privateKey.publicKey.isValidSignature(signature, for: data)
// 持久化密钥以便后续使用
let keyData = privateKey.dataRepresentation
// 将keyData存储在Keychain中,之后可通过以下方式恢复:
let restored = try SecureEnclave.P256.Signing.PrivateKey(
dataRepresentation: keyData
)Biometric Authentication
生物识别认证
LocalAuthentication (Face ID / Touch ID)
LocalAuthentication(Face ID / Touch ID)
swift
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"
)
}swift
import LocalAuthentication
func authenticateWithBiometrics() async throws -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics, error: &error
) else {
// 生物识别不可用 -- 回退到密码验证
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
return try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "验证身份以访问你的账户"
)
}
throw AuthError.biometricsUnavailable
}
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "验证身份以访问你的账户"
)
}Info.plist Requirement
Info.plist 要求
You MUST include in Info.plist:
NSFaceIDUsageDescriptionxml
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your secure data</string>Missing this key causes a crash on Face ID devices.
你必须在Info.plist中添加:
NSFaceIDUsageDescriptionxml
<key>NSFaceIDUsageDescription</key>
<string>验证身份以访问你的安全数据</string>缺少该键会导致应用在支持Face ID的设备上崩溃。
LAContext Configuration
LAContext 配置
swift
let context = LAContext()
// Allow fallback to device passcode
context.localizedFallbackTitle = "Use Passcode"
// Reuse authentication for 30 seconds
context.touchIDAuthenticationAllowableReuseDuration = 30
// Detect biometry enrollment changes by comparing domain state
let currentState = context.evaluatedPolicyDomainState
// Compare currentState to a previously stored valueswift
let context = LAContext()
// 允许回退到设备密码
context.localizedFallbackTitle = "使用密码"
// 30秒内可复用验证结果
context.touchIDAuthenticationAllowableReuseDuration = 30
// 通过对比域状态检测生物识别注册变更
let currentState = context.evaluatedPolicyDomainState
// 将currentState与之前存储的值进行对比Biometric + Keychain
生物识别 + Keychain
Protect keychain items with biometric access:
swift
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 flags:
- -- Requires biometry, invalidated if enrollment changes. Most secure.
.biometryCurrentSet - -- Requires biometry, survives enrollment changes.
.biometryAny - -- Biometry or passcode. Most flexible.
.userPresence
用生物识别保护Keychain项目:
swift
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)
App Transport Security (ATS)
ATS enforces HTTPS by default. Do NOT disable it.
ATS默认强制使用HTTPS,请勿禁用它。
What ATS Requires
ATS 的要求
- TLS 1.2 or later
- Forward secrecy cipher suites
- SHA-256 or better certificates
- 2048-bit or greater RSA keys (or 256-bit ECC)
- TLS 1.2或更高版本
- 前向保密密码套件
- SHA-256或更高级别的证书
- 2048位或更长的RSA密钥(或256位ECC密钥)
Exception Domains (Last Resort)
例外域(最后手段)
xml
<!-- Only for legacy servers you cannot upgrade -->
<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>Rules:
- NEVER set to true. Apple will reject the app.
NSAllowsArbitraryLoads - Exception domains require justification in App Review notes.
- Use exception domains only for third-party servers you cannot control.
xml
<!-- 仅适用于无法升级的遗留服务器 -->
<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>规则:
- 绝不要将设为true,Apple会拒绝此类应用。
NSAllowsArbitraryLoads - 例外域需要在应用审核备注中说明理由。
- 仅对无法控制的第三方服务器使用例外域。
Certificate Pinning
证书绑定
Pin certificates for sensitive API connections to prevent MITM attacks.
对敏感API连接使用证书绑定,以防止中间人攻击。
URLSession Delegate Pinning
URLSession 代理绑定
swift
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)
}
}Rules:
- Pin the public key hash, not the certificate. Certificates rotate; public keys are more stable.
- Always include at least one backup pin.
- Have a rotation plan. If all pinned keys expire, the app cannot connect.
- Consider a kill switch (remote config to disable pinning in emergency).
swift
import CryptoKit
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
// 证书主体公钥信息的SHA-256哈希
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)
}
}规则:
- 绑定公钥哈希而非证书,证书会轮换,公钥更稳定。
- 至少添加一个备用绑定哈希。
- 制定轮换计划,若所有绑定密钥过期,应用将无法连接。
- 考虑添加应急开关(远程配置以在紧急情况下禁用绑定)。
Secure Coding Patterns
安全编码模式
Never Log Sensitive Data
切勿记录敏感数据
swift
// WRONG
logger.debug("User logged in with token: \(token)")
// CORRECT
logger.debug("User logged in successfully")swift
// 错误示例
logger.debug("用户登录,令牌:\(token)")
// 正确示例
logger.debug("用户登录成功")Clear Sensitive Data From Memory
从内存中清除敏感数据
swift
var sensitiveData = Data(/* ... */)
defer {
sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}swift
var sensitiveData = Data(/* ... */)
defer {
sensitiveData.resetBytes(in: 0..<sensitiveData.count)
}Validate All Input
验证所有输入
swift
// Validate URL schemes
guard let url = URL(string: input),
["https"].contains(url.scheme?.lowercased()) else {
throw SecurityError.invalidURL
}
// Prevent path traversal
let resolved = url.standardized.path
guard resolved.hasPrefix(allowedDirectory.path) else {
throw SecurityError.pathTraversal
}swift
// 验证URL协议
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
}Jailbreak Detection
越狱检测
swift
func isDeviceCompromised() -> Bool {
let paths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in paths {
if FileManager.default.fileExists(atPath: path) { return true }
}
// Check if app can write outside sandbox
let testPath = "/private/test_jailbreak"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
return false
}
}Jailbreak detection is not foolproof. Use it as one layer, not the only layer.
swift
func isDeviceCompromised() -> Bool {
let paths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in paths {
if FileManager.default.fileExists(atPath: path) { return true }
}
// 检查应用是否能写入沙箱外路径
let testPath = "/private/test_jailbreak"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
return false
}
}越狱检测并非万无一失,仅将其作为安全防护的一层,而非唯一手段。
Privacy Manifests
隐私清单
Apps and SDKs must declare data access in . See
for required-reason API declarations and
security-related data collection details. For submission requirements and
compliance checklists, see .
PrivacyInfo.xcprivacyreferences/privacy-manifest.mdreferences/app-review-guidelines.md应用和SDK必须在中声明数据访问情况。有关必填原因API声明和安全相关数据收集的详细信息,请参阅。提交要求和合规清单请参阅。
PrivacyInfo.xcprivacyreferences/privacy-manifest.mdreferences/app-review-guidelines.mdCommon Mistakes
常见错误
- Storing secrets in UserDefaults. Tokens, passwords, API keys must go in Keychain.
- Hardcoded secrets in source. No API keys or credentials in Swift files.
- Disabling ATS globally. is a rejection risk.
NSAllowsArbitraryLoads = true - Logging sensitive data. Never log tokens, passwords, or API keys.
- Missing PrivacyInfo.xcprivacy. Required for all apps using required-reason APIs.
- Using CommonCrypto instead of CryptoKit. CryptoKit is safer and modern.
- Missing NSFaceIDUsageDescription. Crashes on Face ID devices.
- Using when
.biometryAnyis needed. The former survives enrollment changes, which may be undesirable for high-security items..biometryCurrentSet - Path traversal vulnerabilities. Always resolve and validate paths.
- Missing concurrency annotations. Ensure Keychain wrapper types are Sendable; isolate UI-facing security prompts to .
@MainActor
- 在UserDefaults中存储机密信息:令牌、密码、API密钥必须存储在Keychain中。
- 在源码中硬编码机密信息:Swift文件中不得包含API密钥或凭据。
- 全局禁用ATS:会导致应用被拒绝。
NSAllowsArbitraryLoads = true - 记录敏感数据:切勿记录令牌、密码或API密钥。
- 缺少PrivacyInfo.xcprivacy:使用必填原因API的所有应用都必须添加该文件。
- 使用CommonCrypto而非CryptoKit:CryptoKit更安全、更现代化。
- 缺少NSFaceIDUsageDescription:会导致应用在Face ID设备上崩溃。
- 在需要时使用
.biometryCurrentSet:后者在注册变更后仍然有效,对于高安全级别的项目来说可能不符合需求。.biometryAny - 路径遍历漏洞:始终解析并验证路径。
- 缺少并发注解:确保Keychain包装类型是Sendable;将面向UI的安全提示隔离到中。
@MainActor
Review Checklist
审核清单
- Secrets in Keychain, not UserDefaults or files; no hardcoded credentials
- Correct value;
kSecAttrAccessiblefor non-backup dataThisDeviceOnly - File protection class set for sensitive files ()
.complete - CryptoKit for encryption (not CommonCrypto); 256-bit symmetric keys
- Keys stored in Keychain or Secure Enclave
- Biometric auth with fallback; in Info.plist
NSFaceIDUsageDescription - Correct flags;
SecAccessControlconfiguredLAContext - HTTPS enforced; no ; cert pinning for sensitive APIs
NSAllowsArbitraryLoads - PrivacyInfo.xcprivacy present; all required-reason APIs declared
- No sensitive data in logs; Data cleared after use; URLs/paths validated
- 机密信息存储在Keychain中,而非UserDefaults或文件;无硬编码凭据
- 使用正确的取值;非备份数据使用
kSecAttrAccessibleThisDeviceOnly - 敏感文件设置了正确的文件保护类别()
.complete - 使用CryptoKit进行加密(而非CommonCrypto);使用256位对称密钥
- 密钥存储在Keychain或Secure Enclave中
- 生物识别认证支持回退;Info.plist中包含
NSFaceIDUsageDescription - 使用正确的标志;配置了
SecAccessControlLAContext - 强制使用HTTPS;未设置;敏感API使用证书绑定
NSAllowsArbitraryLoads - 存在PrivacyInfo.xcprivacy;所有必填原因API已声明
- 日志中无敏感数据;使用后清除Data;验证URL/路径