Test encrypted iOS records on a cloud Mac
Encrypting offline notes when saving and decrypting them when reading is easy to test on the happy path. The real danger is a truncated file or the wrong key: the app swallows the error and saves an empty record over the original. Extracting this logic into a standalone Swift Package on a cloud Mac lets you verify failure behavior before wiring it into the UI. This article checks only the integrity of encrypted records; key persistence and account systems are out of scope.
Define the record and failure contract first
Use AES-GCM from CryptoKit and store the bytes returned by SealedBox.combined as the record. The automatically generated nonce, ciphertext, and authentication tag are stored together and must be reconstructed in full when reading. Do not trim the tag yourself or treat the nonce as a secret.
Never reuse a nonce with the same key. For routine calls, let AES.GCM.seal generate it automatically; do not derive it from a filename, the current time in seconds, or a record sequence number. Generate temporary keys within the tests, and keep them out of the repository and test reports.
Pass content to the business logic layer only after decryption succeeds. Authentication failure does not mean empty content, and it must not trigger an automatic overwrite of the original file.
If your application also needs external fields such as a record ID, you can encode them canonically and supply them as additional authenticated data. Encryption and decryption must receive identical bytes. Here, we first test a minimal record without external fields.
Create a runnable test package
On a dedicated physical node with the Xcode toolchain installed, run the following commands as a regular development user. Use a new workspace directory; do not overwrite an existing project. Confirm that swift --version returns successfully before initializing the package:
mkdir SecureRecord
cd SecureRecord
swift --version
swift package init --type library
Replace the generated Tests/SecureRecordTests/SecureRecordTests.swift with the contents of the next section. You can leave the source file's default implementation unchanged: these tests call the system CryptoKit framework directly, so no third-party dependencies need to be downloaded.
This example uses XCTest to avoid relying on syntax specific to a newer testing framework. In a real project, move the helper functions into the production module and import that module in the tests. This prevents the tests from validating a correct implementation while the app uses a different one.
Cover valid records and four edge cases
Below is a complete test file. The happy path first establishes that the bytes survive a round trip unchanged. The remaining cases check an altered tag, a wrong key, truncated input, and valid empty content. Each test uses its own key and shares no mutable state.
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())
}
}
Run swift test and require all five tests to pass. Empty plaintext still produces a nonempty record because the sealed representation also includes the nonce and tag. Checking that a file has a nonzero length is no substitute for authenticated decryption.
The assertion that two ciphertexts differ is intended to catch a regression that accidentally fixes the nonce. It does not prove the quality of the random number generator. The tests also avoid comparing complete ciphertext snapshots, which would incorrectly treat normal randomness as a failure.
Connect cryptographic failures to application behavior
Passing the authentication-failure tests only shows that the underlying code rejects invalid bytes. Add acceptance checks for the caller next: after a read failure, display an error that allows recovery, preserve the original file, and prevent background autosaving of an empty model. If a previous copy exists, select it through an explicit recovery flow rather than silently switching to it.
Validate the format and business data separately
Even after a complete record decrypts successfully, decoding the business data can still fail. Separate format-version validation, authenticated decryption, and content parsing into three steps, each returning a distinguishable internal error. A short message is enough for the user. Logs should record only the stage and error category, never plaintext or keys.
Also limit input size: check the file length before loading and decrypting it so that an abnormally large file cannot exhaust memory. Base the limit on the record capacity your application allows, rather than copying an arbitrary number. When adding a format version, also retain samples of valid older versions and explicitly unsupported versions.
Keep reproducible acceptance evidence
Commit the test file, format specification, and execution commands. For each run, record the commit identifier, Swift version, test count, and exit status. Do not upload actual user records. During development, you can add cases that alter individual bytes, but if a random mutation test fails, retain the mutation position and input length.
Finish with an iOS integration check: pass a record actually written by the app to the same decoding function, then verify that reading a damaged file does not overwrite the original content. A cloud Mac is well suited to running quick tests of the shared encryption core; file access and UI recovery require separate acceptance checks in the app target. The completion criteria should be “invalid data is rejected, the original data remains intact, and the user can recover,” not merely a single successful encryption and decryption.
Frequently asked questions
Do these tests require an iOS simulator?
Pure CryptoKit logic can run with swift test on a cloud Mac. File access, error presentation and app integration still need checks on the iOS target.
Should authentication failures return empty data?
Not as a successful result. Preserve the original record and return an explicit failure so valid empty content cannot be confused with corrupted data.
Reserve a cloud Mac for your next build
Choose a model, location, and rental period, then run your own development tools on a dedicated physical node. Availability is confirmed in real time by the console.