Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/security.md
2025-11-29 18:28:37 +08:00

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: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")

    // 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