Настройка Keychain для безопасного хранения данных в iOS
После обновления iOS приложение не находит токен авторизации — пользователь снова видит экран логина. Или хуже: токен сохранился, но доступен другому приложению того же вендора без каких-либо ограничений. Обе ситуации — следствие неправильно настроенного Keychain.
Где конкретно ломается
Самая частая ошибка — сохранять токены через UserDefaults. Данные попадают в Library/Preferences/*.plist, который входит в iCloud-бэкап и доступен через iTunes backup extraction инструментами вроде iBackup Viewer. Это не теоретическая угроза.
Вторая по частоте — использование Keychain без явного kSecAttrAccessible. По умолчанию значение kSecAttrAccessibleWhenUnlocked, что разумно, но многие проекты не задумываются, нужен ли доступ к данным при заблокированном экране (для background tasks) или только когда устройство разблокировано и только после первого unlock после перезагрузки. Это принципиально разные threat models.
Третья — отсутствие kSecAttrAccessGroup при работе в App Group. Если у вас основное приложение и виджет или extension, и токен не расшарен явно, extension не видит Keychain-запись, хотя Bundle ID из той же группы.
Правильная настройка через Security framework
Базовый паттерн записи:
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.app.auth",
kSecAttrAccount as String: "access_token",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: tokenData,
kSecAttrAccessGroup as String: "TEAMID.com.example.shared" // если нужен App Group
]
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — правильный выбор для большинства токенов: доступен после первого разблока после перезагрузки, не синхронизируется в iCloud, не переносится на другое устройство. Если вам нужна синхронизация между устройствами пользователя (например, пароль от заметок) — тогда kSecAttrAccessibleWhenUnlocked с kSecAttrSynchronizable: kCFBooleanTrue. Но для auth-токенов синхронизация через iCloud Keychain нежелательна — компрометация одного устройства компрометирует все.
Для чтения с обновлением (upsert) — сначала SecItemUpdate, при ошибке errSecItemNotFound — SecItemAdd. Не делайте SecItemDelete + SecItemAdd — это создаёт race condition в многопоточной среде.
Biometric protection через LocalAuthentication
Если нужна биометрия перед доступом к данным (ключи шифрования, приватные ключи):
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet, // .userPresence если нужен fallback на пасскод
nil
)!
Флаг .biometryCurrentSet инвалидирует запись при изменении биометрии (новый отпечаток, переход на Face ID) — это намеренное поведение для высокосекретных данных. Для менее критичных .biometryAny сохраняет доступ после добавления новых отпечатков.
Обёртка и тестируемость
Прямые вызовы SecItemAdd в продакшн-коде делают юнит-тесты невозможными без реального Keychain. Оборачиваем в протокол:
protocol KeychainService {
func save(_ data: Data, for key: String) throws
func load(for key: String) throws -> Data
func delete(for key: String) throws
}
В тестах подставляем InMemoryKeychainService. В проде — реализация через Security framework. Это стандартный подход в Clean Architecture для iOS.
Процесс работы
Аудит текущего кода — ищем UserDefaults, NSKeyedArchiver, плейн-текст в файлах. Далее проектируем KeychainService под конкретный набор хранимых данных, реализуем с правильными kSecAttrAccessible, настраиваем App Group если нужно расшаривание с extensions, покрываем unit-тестами через мок. Отдельно — проверка поведения при смене биометрии и при обновлении приложения.
Сроки — 1–3 дня. Простая замена UserDefaults на Keychain для одного типа данных — ближе к дню. Полный аудит + рефакторинг + App Group + биометрия — до трёх.







