クラウド Mac で iOS 暗号化レコードの改ざんと欠落を検証する

クラウド Mac で iOS 暗号化レコードの改ざんと欠落を検証する

クラウド Mac で iOS 暗号化レコードの改ざんと欠落を検証する

オフラインのメモを保存時に暗号化し、読み込み時に復号するだけなら、正常系のテストは簡単に通ります。本当に危険なのは、ファイルの一部が欠けていたり、誤った鍵を使っていたりするのに、アプリがエラーを握りつぶして空のレコードを保存してしまうことです。クラウド Mac 上でこの処理を独立した Swift Package に切り出せば、画面に組み込む前に失敗時の動作を検証できます。本記事で扱うのは暗号化レコードの完全性の確認だけで、鍵の永続化やアカウントの仕組みは対象外です。

まずレコードと失敗時の契約を定義する

CryptoKit の AES-GCM を使い、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 を使います。実際のプロジェクトでは、補助関数を本番用モジュールへ移し、テストからそのモジュールをインポートしてください。「テスト内の実装は正しいのに、アプリ内の実装は異なる」という状態を防ぐためです。

正常なレコードと4種類の例外ケースを検証する

以下はテストファイルの全文です。まず正常系で、暗号化と復号を経てもバイト列が一致することを確認します。そのうえで、タグの改変、誤った鍵、入力の切り詰め、そして正当な空の内容をそれぞれ検証します。各テストケースは独自の鍵を使い、可変状態を共有しません。

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 を実行し、5つのテストがすべて通ることを確認します。平文が空でも、レコードには nonce とタグが含まれるため、保存されるレコードは空になりません。「ファイルサイズがゼロより大きい」という確認を、認証付き復号の代わりにしてはいけません。

2回の暗号化で異なる暗号文が得られるというアサーションは、実装の変更によって nonce が誤って固定されるような退行を検出するためのものです。乱数の品質を証明するものではありません。また、暗号文全体のスナップショット比較も行いません。それを行うと、正常なランダム性まで失敗と判定してしまいます。

暗号処理の失敗をアプリの動作につなげる

認証失敗のテストが通っても、それは低レベルの処理が異常なバイト列を拒否したことを示すだけです。続いて、呼び出し側の受け入れテストを追加します。読み込みに失敗したら、復旧可能なエラーを表示し、元のファイルを保持し、バックグラウンドで空のモデルを自動保存しないことを確認してください。前の版のコピーがある場合も、黙って切り替えるのではなく、明示的な復旧フローで選択する必要があります。

形式と業務データを分けて検証する

レコード全体の復号に成功しても、業務データのデコードは失敗する可能性があります。形式バージョンの確認、認証付き復号、内容の解析を3つの手順に分け、それぞれを区別できる内部エラーを返すようにします。ユーザーへの表示は短いメッセージで十分です。ログには処理段階とエラーの種類だけを記録し、平文や鍵を出力しないでください。

入力サイズにも上限が必要です。異常に大きなファイルがメモリを使い切らないよう、先にファイルサイズを確認し、その後に読み込みと復号を行います。上限はアプリで許容するレコード容量に基づいて決め、根拠のない数値をそのまま流用しないでください。新しい形式バージョンを追加するときは、有効な旧バージョンのサンプルと、明確に非対応であるバージョンのサンプルも保存します。

再実行できる検証の証跡を残す

テストファイル、形式の仕様、実行コマンドをコミットします。実行のたびに、コミット識別子、Swift のバージョン、テスト数、終了ステータスを記録し、実際のユーザーレコードはアップロードしないでください。開発中は1バイトずつ変更するテストケースを追加しても構いませんが、ランダムな変更を加えたテストが失敗した場合は、変更位置と入力長を必ず残します。

最後に、iOS での統合チェックも行います。アプリが実際に書き出したレコードを同じ復号関数に渡し、破損したファイルを読み込んでも元の内容が上書きされないことを確認してください。クラウド Mac は、共有する暗号化コアのテストを素早く実行するのに適しています。一方、ファイルアクセスと画面上の復旧動作は、アプリのターゲットで別途受け入れテストを行う必要があります。完了の基準は、暗号化と復号が一度成功したことではなく、「異常が拒否され、元のデータが残り、ユーザーが復旧できること」です。

よくある質問

このテストには iOS シミュレータが必要ですか?

純粋な CryptoKit の処理はクラウド Mac の swift test で検証できます。ファイル読み込み、エラー表示、アプリとの統合は iOS ターゲットでも確認します。

認証に失敗したら空データを返してもよいですか?

成功結果として返すべきではありません。元のバイト列を保存して失敗を通知し、正常な空データと破損レコードを区別してください。

次のビルドに向けてクラウド Mac を予約

機種、ロケーション、利用期間を選び、専有物理サーバーでお使いの開発ツールを実行できます。利用可能状況はコントロールパネルに表示される最新情報をご確認ください。

次のビルドにつなげる

機種と利用期間を確認して、プロジェクトの準備を始めましょう。