Проверка шифрованных записей iOS на облачном Mac
В офлайн-заметках содержимое шифруется при сохранении и расшифровывается при чтении. Проверить штатный сценарий обычно несложно. Настоящая опасность возникает, когда файл усечён или выбран неверный ключ, а приложение подавляет ошибку и затем сохраняет пустую запись. Вынеся эту логику в отдельный Swift Package на облачном Mac, можно проверить поведение при сбоях ещё до подключения интерфейса. В этой статье проверяется только целостность шифрованных записей; хранение ключей и система учётных записей остаются за её рамками.
Сначала определим формат записи и поведение при ошибках
Используем AES-GCM из CryptoKit и сохраняем запись в виде байтов, возвращаемых SealedBox.combined. Автоматически созданный nonce, шифротекст и тег аутентификации хранятся вместе; при чтении необходимо восстановить всю структуру целиком. Не обрезайте тег самостоятельно и не считайте nonce секретом.
С одним и тем же ключом нельзя повторно использовать nonce. В обычных вызовах достаточно поручить его создание AES.GCM.seal; не выводите его из имени файла, текущего времени в секундах или порядкового номера записи. Ключи временно создаются внутри тестов и не попадают ни в репозиторий, ни в отчёты о тестировании.
Передавать содержимое в бизнес-логику можно только после успешной расшифровки. Ошибка аутентификации не означает пустое содержимое и не должна запускать автоматическую перезапись исходного файла.
Если бизнес-логике нужны внешние поля, например идентификатор записи, их можно закодировать в каноническом виде и передать как дополнительные аутентифицируемые данные. При шифровании и расшифровке нужно использовать одинаковые байты. Здесь сначала проверим минимальную запись без внешних полей.
Создадим пакет, в котором можно запустить тесты
На выделенном физическом узле с установленным набором инструментов Xcode выполните следующие команды от имени обычного пользователя-разработчика. Используйте новый рабочий каталог, чтобы не перезаписать существующий проект. Перед инициализацией убедитесь, что swift --version выполняется без ошибок:
mkdir SecureRecord
cd SecureRecord
swift --version
swift package init --type library
Замените содержимое созданного файла Tests/SecureRecordTests/SecureRecordTests.swift кодом из следующего раздела. В исходном файле можно оставить реализацию по умолчанию: эти тесты напрямую вызывают системный CryptoKit и не требуют загрузки сторонних зависимостей.
Здесь используется XCTest, чтобы пример не зависел от специфического синтаксиса нового фреймворка тестирования. В реальном проекте вспомогательные функции следует перенести в производственный модуль и импортировать его в тесты. Иначе можно получить ситуацию, в которой тестовая реализация верна, а приложение использует другую.
Проверим корректную запись и четыре нештатных сценария
Ниже приведён полный тестовый файл. Сначала штатный сценарий подтверждает, что после шифрования и расшифровки получаются те же байты. Затем отдельно проверяются изменение тега, неверный ключ, усечённые входные данные и допустимое пустое содержимое. Каждый тест использует собственный ключ; общего изменяемого состояния нет.
import Foundation
import CryptoKit
import XCTest
final class SecureRecordTests: XCTestCase {
private func encode(
_ data: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.seal(data, using: key)
return try XCTUnwrap(box.combined)
}
private func decode(
_ record: Data,
key: SymmetricKey
) throws -> Data {
let box = try AES.GCM.SealedBox(combined: record)
return try AES.GCM.open(box, using: key)
}
func testRoundTripAndFreshNonce() throws {
let key = SymmetricKey(size: .bits256)
let input = Data("draft-v1".utf8)
let first = try encode(input, key: key)
let second = try encode(input, key: key)
XCTAssertEqual(try decode(first, key: key), input)
XCTAssertEqual(try decode(second, key: key), input)
XCTAssertNotEqual(first, second)
}
func testChangedTagIsRejected() throws {
let key = SymmetricKey(size: .bits256)
var record = try encode(Data("draft-v1".utf8), key: key)
let last = record.index(before: record.endIndex)
record[last] ^= 0x01
XCTAssertThrowsError(try decode(record, key: key))
}
func testWrongKeyIsRejected() throws {
let key = SymmetricKey(size: .bits256)
let otherKey = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
XCTAssertThrowsError(try decode(record, key: otherKey))
}
func testTruncatedRecordsAreRejected() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data("draft-v1".utf8), key: key)
for length in [0, 1, record.count - 1] {
let truncated = Data(record.prefix(length))
XCTAssertThrowsError(try decode(truncated, key: key))
}
}
func testEmptyPlaintextIsValid() throws {
let key = SymmetricKey(size: .bits256)
let record = try encode(Data(), key: key)
XCTAssertFalse(record.isEmpty)
XCTAssertEqual(try decode(record, key: key), Data())
}
}
Запустите swift test: все пять тестов должны пройти. Даже пустой открытый текст создаёт непустую запись, поскольку в неё также входят nonce и тег. Проверка «длина файла больше нуля» не заменяет расшифровку с проверкой аутентичности.
Утверждение о различии двух шифротекстов помогает обнаружить регрессию, при которой реализация случайно начинает использовать фиксированный nonce. Оно не доказывает качество случайных чисел. Тесты также не сравнивают полный шифротекст с эталонным снимком: иначе нормальная случайность ошибочно считалась бы сбоем.
Свяжем ошибки алгоритма с поведением приложения
Успешное прохождение тестов на ошибки аутентификации означает лишь то, что нижний уровень отвергает некорректные байты. Добавьте приёмочные проверки для вызывающего кода: после ошибки чтения приложение должно показать сообщение, позволяющее перейти к восстановлению, сохранить исходный файл и не допустить автоматического фонового сохранения пустой модели. Если есть копия предыдущей версии, выбирать её следует через явный процесс восстановления, а не незаметно переключаться на неё.
Проверяйте формат и бизнес-данные отдельно
Даже если полная запись успешно расшифрована, декодирование бизнес-данных всё ещё может завершиться ошибкой. Разделите проверку версии формата, расшифровку с проверкой аутентичности и разбор содержимого на три шага, каждый из которых возвращает различимую внутреннюю ошибку. Пользователю достаточно краткого сообщения. В журнал записывайте только этап и категорию ошибки, без открытого текста и ключей.
Также ограничьте размер входных данных: сначала проверяйте длину файла, затем загружайте и расшифровывайте его, чтобы аномально большой файл не занял всю память. Верхнюю границу определяйте по допустимому для приложения объёму записи, а не копируйте произвольное число. При добавлении новой версии формата сохраняйте образцы как корректной старой версии, так и версии, которая явно не поддерживается.
Сохраним воспроизводимые результаты приёмки
Включите в коммит тестовый файл, описание формата и команды запуска. Для каждого запуска фиксируйте идентификатор коммита, версию Swift, количество тестов и статус завершения. Не загружайте реальные пользовательские записи. На этапе разработки можно добавить тесты с побайтовыми изменениями, но при сбое теста со случайной мутацией обязательно сохраняйте позицию изменения и длину входных данных.
В завершение проведите интеграционную проверку на iOS: передайте запись, действительно созданную приложением, той же функции декодирования, а затем убедитесь, что чтение повреждённого файла не перезаписывает исходное содержимое. Облачный Mac подходит для быстрых тестов общего криптографического ядра; доступ к файлам и восстановление через интерфейс нужно отдельно проверять в целевой сборке приложения. Итоговый критерий — «некорректные данные отвергнуты, исходные данные сохранены, пользователь может восстановить запись», а не просто один успешный цикл шифрования и расшифровки.
Часто задаваемые вопросы
Нужен ли для этих тестов симулятор iOS?
Чистую логику CryptoKit можно проверить командой swift test на облачном Mac. Чтение файлов, сообщения об ошибках и интеграцию приложения проверяют отдельно на iOS.
Можно ли возвращать пустые данные при ошибке аутентификации?
Нет: это смешивает корректную пустую запись с повреждённой. Сохраните исходные байты и передайте вызывающему коду явную ошибку.
Арендуйте облачный Mac для следующей сборки
Выберите модель, город и срок аренды, чтобы работать со своими инструментами разработки на выделенном физическом узле. Доступность проверяйте по актуальным данным в консоли.