14 KiB
14 KiB
Security
Keychain, secure storage, biometrics, and secure coding practices.
Keychain
KeychainService
import Security
class KeychainService {
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
case dataConversionError
case itemNotFound
}
private let service: String
init(service: String = Bundle.main.bundleIdentifier ?? "app") {
self.service = service
}
// MARK: - Data
func save(_ data: Data, for key: String, accessibility: CFString = kSecAttrAccessibleWhenUnlocked) throws {
// Delete existing
try? delete(key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: accessibility
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func load(_ 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)
guard status != errSecItemNotFound else {
throw KeychainError.itemNotFound
}
guard status == errSecSuccess else {
throw KeychainError.loadFailed(status)
}
guard let data = result as? Data else {
throw KeychainError.dataConversionError
}
return data
}
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: - Convenience
func saveString(_ value: String, for key: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.dataConversionError
}
try save(data, for: key)
}
func loadString(_ key: String) throws -> String {
let data = try load(key)
guard let string = String(data: data, encoding: .utf8) else {
throw KeychainError.dataConversionError
}
return string
}
func saveCodable<T: Codable>(_ value: T, for key: String) throws {
let data = try JSONEncoder().encode(value)
try save(data, for: key)
}
func loadCodable<T: Codable>(_ type: T.Type, for key: String) throws -> T {
let data = try load(key)
return try JSONDecoder().decode(type, from: data)
}
}
Accessibility Options
// Available when unlocked
kSecAttrAccessibleWhenUnlocked
// Available when unlocked, not backed up
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
// Available after first unlock (background access)
kSecAttrAccessibleAfterFirstUnlock
// Always available (not recommended)
kSecAttrAccessibleAlways
Biometric Authentication
Local Authentication
import LocalAuthentication
class BiometricService {
enum BiometricType {
case none, touchID, faceID
}
var biometricType: BiometricType {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return .none
}
switch context.biometryType {
case .touchID:
return .touchID
case .faceID:
return .faceID
default:
return .none
}
}
func authenticate(reason: String) async -> Bool {
let context = LAContext()
context.localizedCancelTitle = "Cancel"
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return false
}
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason
)
} catch {
return false
}
}
func authenticateWithFallback(reason: String) async -> Bool {
let context = LAContext()
do {
// Try biometrics first, fall back to passcode
return try await context.evaluatePolicy(
.deviceOwnerAuthentication, // Includes passcode fallback
localizedReason: reason
)
} catch {
return false
}
}
}
Biometric-Protected Keychain
extension KeychainService {
func saveBiometricProtected(_ data: Data, for key: String) throws {
try? delete(key)
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet, // Invalidate if biometrics change
&error
) else {
throw error!.takeRetainedValue()
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
func loadBiometricProtected(_ key: String, prompt: String) throws -> Data {
let context = LAContext()
context.localizedReason = prompt
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecUseAuthenticationContext as String: context
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError.loadFailed(status)
}
return data
}
}
Secure Network Communication
Certificate Pinning
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedCertificates: [SecCertificate]
init(certificates: [SecCertificate]) {
self.pinnedCertificates = certificates
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
return (.cancelAuthenticationChallenge, nil)
}
// Get server certificate
guard let serverCertificate = SecTrustCopyCertificateChain(serverTrust)?
.first else {
return (.cancelAuthenticationChallenge, nil)
}
// Compare with pinned certificates
let serverCertData = SecCertificateCopyData(serverCertificate) as Data
for pinnedCert in pinnedCertificates {
let pinnedCertData = SecCertificateCopyData(pinnedCert) as Data
if serverCertData == pinnedCertData {
let credential = URLCredential(trust: serverTrust)
return (.useCredential, credential)
}
}
return (.cancelAuthenticationChallenge, nil)
}
}
App Transport Security
In Info.plist (avoid if possible):
<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>
Data Protection
File Protection
// Protect files on disk
let fileURL = documentsDirectory.appendingPathComponent("sensitive.dat")
try data.write(to: fileURL, options: .completeFileProtection)
// Check protection class
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let protection = attributes[.protectionKey] as? FileProtectionType
In-Memory Sensitive Data
// Clear sensitive data when done
var password = "secret"
defer {
password.removeAll() // Clear from memory
}
// For arrays
var sensitiveBytes = [UInt8](repeating: 0, count: 32)
defer {
sensitiveBytes.withUnsafeMutableBytes { ptr in
memset_s(ptr.baseAddress, ptr.count, 0, ptr.count)
}
}
Secure Coding Practices
Input Validation
func processInput(_ input: String) throws -> String {
// Validate length
guard input.count <= 1000 else {
throw ValidationError.tooLong
}
// Sanitize HTML
let sanitized = input
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
// Validate format if needed
guard isValidFormat(sanitized) else {
throw ValidationError.invalidFormat
}
return sanitized
}
SQL Injection Prevention
With SwiftData/Core Data, use predicates:
// Safe - parameterized
let predicate = #Predicate<Item> { $0.name == searchTerm }
// Never do this
// let sql = "SELECT * FROM items WHERE name = '\(searchTerm)'"
Avoid Logging Sensitive Data
func authenticate(username: String, password: String) async throws {
// Bad
// print("Authenticating \(username) with password \(password)")
// Good
print("Authenticating user: \(username)")
// Use OSLog with privacy
import os
let logger = Logger(subsystem: "com.app", category: "auth")
logger.info("Authenticating user: \(username, privacy: .public)")
logger.debug("Password length: \(password.count)") // Length only, never value
}
Jailbreak Detection
class SecurityChecker {
func isDeviceCompromised() -> Bool {
// Check for common jailbreak files
let suspiciousPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in suspiciousPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
// Check if can write outside sandbox
let testPath = "/private/jailbreak_test.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
// Expected - can't write outside sandbox
}
// Check for fork
let forkResult = fork()
if forkResult >= 0 {
// Fork succeeded - jailbroken
return true
}
return false
}
}
App Store Privacy
Privacy Manifest
Create PrivacyInfo.xcprivacy:
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<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>
App Tracking Transparency
import AppTrackingTransparency
func requestTrackingPermission() async -> ATTrackingManager.AuthorizationStatus {
await ATTrackingManager.requestTrackingAuthorization()
}
// Check before tracking
if ATTrackingManager.trackingAuthorizationStatus == .authorized {
// Can use IDFA for tracking
}
Security Checklist
Data Storage
- Sensitive data in Keychain, not UserDefaults
- Appropriate Keychain accessibility
- File protection for sensitive files
- Clear sensitive data from memory
Network
- HTTPS only (ATS)
- Certificate pinning for sensitive APIs
- Secure token storage
- No hardcoded secrets
Authentication
- Biometric option available
- Secure session management
- Token refresh handling
- Logout clears all data
Code
- Input validation
- No sensitive data in logs
- Parameterized queries
- No hardcoded credentials
Privacy
- Privacy manifest complete
- ATT compliance
- Minimal data collection
- Clear privacy policy