クラウド Mac で iOS のセキュアアーカイブ復号処理を検証する

クラウド Mac で iOS のセキュアアーカイブ復号処理を検証する

クラウド Mac で iOS のセキュアアーカイブ復号処理を検証する

旧バージョンのアプリが保存したアーカイブを新バージョンで読み込むとき、最も危険なのはエラーになることではありません。空のデータを返したまま保存処理が続き、元のファイルを上書きしてしまうことです。この読み取り経路をクラウド Mac 上で検証します。まず Foundation だけを使うデコードテストを実行し、次に iOS のテストターゲットで同じサンプルを読み込みます。目的は、単に一度の往復処理が成功することではなく、不正な型、未対応のバージョン、破損した入力を確実に拒否できることを確認することです。

まず読み取りの境界を定義する

この記事で扱うのは、NSKeyedArchiver が生成するオブジェクトアーカイブです。暗号化は扱いません。サンプルでは、バージョン番号とテキストフィールドを持つ単一のルートオブジェクトだけを許可します。検証に入る前に、次の3つの契約を定めます。

supportsSecureCoding は型による宣言であり、業務上のフィールド条件を自動的に検証するものではありません。読み取り側では requiresSecureCoding を有効にし、各オブジェクトフィールドも型を制限できるメソッドでデコードする必要があります。

セキュアデコードが制限するのは、インスタンス化できるオブジェクトの型です。出所の認証、機密性、ファイルサイズの制限は提供しません。デコーダーに渡す前に、入力サイズを制限してください。

不正な入力を拒否できるデコーダーを作る

次の内容を archive-check.swift として保存します。依存するのは Foundation だけです。まず xcrun swift --version が実行できることを確認してください。サンプルのバージョン番号はアーカイブ形式のバージョンであり、OS のバージョンではありません。

import Foundation

final class Memo: NSObject, NSSecureCoding {
    static var supportsSecureCoding: Bool { true }
    let schema: Int
    let text: String

    init(schema: Int = 1, text: String) {
        self.schema = schema
        self.text = text
        super.init()
    }

    required init?(coder: NSCoder) {
        guard coder.containsValue(forKey: "schema"),
              coder.decodeInteger(forKey: "schema") == 1,
              let value = coder.decodeObject(
                  of: NSString.self, forKey: "text"
              ),
              value.length <= 4096 else {
            return nil
        }
        schema = 1
        text = value as String
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(schema, forKey: "schema")
        coder.encode(text as NSString, forKey: "text")
    }
}

enum ArchiveFailure: Error {
    case invalidSize
    case invalidObject
}

func readMemo(_ data: Data) throws -> Memo {
    guard !data.isEmpty, data.count <= 1_048_576 else {
        throw ArchiveFailure.invalidSize
    }
    let reader = try NSKeyedUnarchiver(forReadingFrom: data)
    reader.requiresSecureCoding = true
    reader.decodingFailurePolicy = .setErrorAndReturn
    let memo = reader.decodeObject(
        of: Memo.self, forKey: NSKeyedArchiveRootObjectKey
    )
    reader.finishDecoding()
    if let error = reader.error {
        throw error
    }
    guard let memo else {
        throw ArchiveFailure.invalidObject
    }
    return memo
}

func pack(_ value: Any) throws -> Data {
    try NSKeyedArchiver.archivedData(
        withRootObject: value, requiringSecureCoding: true
    )
}

ここでは最初にバージョンフィールドの有無を確認しています。整数のデコード時に返されるデフォルト値によって、キーの欠落が見えなくなるのを防ぐためです。テキストの上限は NSString.length に合わせ、UTF-16 コード単位で数えます。実装時には製品の仕様に応じて上限を決め、サンプルのしきい値をそのまま汎用的な安全基準として使わないでください。

setErrorAndReturn は、オブジェクトを読み取る前に設定する必要があります。外側の do/catch ですべてのデコード障害を捕捉できるとは限りません。不正なオブジェクトの処理には、通常の Swift のエラースロー経路ではなく、デコード失敗ポリシーが関わる場合があります。

4種類の入力で最小限の回帰テストを組む

続けて、次のコードを追記します。正常なサンプルでは元のテキストを読み戻せること、残りの3つのサンプルでは必ず失敗することを確認します。期待と異なる結果が1つでもあれば、検証プログラムは非ゼロの終了ステータスで終了します。

func expectRejection(_ data: Data) {
    do {
        _ = try readMemo(data)
    } catch {
        return
    }
    fatalError("Unexpected archive acceptance")
}

let valid = try pack(Memo(text: "draft"))
let restored = try readMemo(valid)
guard restored.text == "draft" else {
    fatalError("Round-trip mismatch")
}

expectRejection(try pack("wrong root" as NSString))
expectRejection(try pack(Memo(schema: 2, text: "future")))
expectRejection(Data(valid.prefix(12)))

実行時は、まずコンパイルしてから起動します。インタープリター実行の環境が、以降のサンプルで使う型名に影響するのを避けるためです。

set -eu
xcrun swiftc archive-check.swift -o archive-check
./archive-check

不正なサンプルを処理すると、Foundation が標準エラー出力に診断メッセージを出すことがあります。それだけで検証プログラムが失敗したとはいえません。判断基準はプロセスの終了ステータスとアサーションであり、ログが完全に空かどうかではありません。不正なルートオブジェクトが受け入れられた場合は、まず許可リストを修正してください。異常系のテストを削除して成功扱いにしてはいけません。

入れ子のオブジェクトと過去のサンプルへ拡張する

フィールドごとに許可する型を絞る

実際のアーカイブに配列が含まれる場合、NSArray を許可しただけで要素も安全だと判断してはいけません。decodeObject(of:forKey:) のクラス集合を受け取るオーバーロードを使い、コンテナーと想定する要素のクラスを許可集合に含めたうえで、要素数、フィールド長、相互の関係を検証します。失敗を回避するために、適用範囲の広い NSObject を追加してはいけません。

アーカイブの型制約は、オブジェクトグラフの複雑さの制御を代替しません。入力サイズを制限しても、デコード後のメモリ使用量が保証されるわけではありません。信頼できない入力には、制約を設けたプロトコルや、分離した処理境界を追加で用意する必要があります。

旧バージョンの実ファイルをテストに固定する

機密情報を除去した旧バージョンのアーカイブをテストリソースとしてリポジトリに追加し、生成元のバージョン、期待するフィールド、許容する移行結果を記録します。動的に生成するサンプルは拒否ロジックの検証には適していますが、旧バージョンが実際に書き出したバイト列を今も読み取れることの証明にはなりません。

Swift クラスのランタイム名は、モジュールによって変わることがあります。型の配置やモジュール名を変更する際は、まず過去のサンプルで検証し、その後、明確に特定した旧名称に対してクラスマッピングを設定してください。入力から任意のクラス名を読み取り、自動で許可してはいけません。コマンドラインのテストが通った後も、同じファイル群を iOS のテストターゲットで実行し、対象ランタイムの違いを確認する必要があります。

失敗を読み取り層にとどめ、保存層へ波及させない

デコード結果は、成功または明示的なエラーとして表現し、try? の後に空のオブジェクトで補う実装は避けてください。読み取りに失敗したら元のファイルを保持し、画面には復旧方法を示します。移行が完了し、新しいデータの再読み取り検証にも成功した場合に限り、旧ファイルの置き換えを許可します。

マージ前に確認するのは5点です。正常サンプルのフィールドが一致すること、不正なルート型を拒否すること、未対応のバージョンを拒否すること、途中で切れた入力を成功扱いして書き戻さないこと、過去のサンプルが iOS ターゲットで通ることです。異常系はそれぞれ独立したテストに分け、失敗時にどの契約が破られたのかをすぐ特定できるようにします。

クラウド Mac 上のコマンドラインプログラムは素早いフィードバックを担い、iOS のテストは対象プラットフォームでの確認を担います。両方が通って初めて、アーカイブの読み取り経路について再現可能な検証結果が得られたといえます。開発マシンで一度成功しただけでは十分ではありません。

よくある質問

supportsSecureCoding だけで十分ですか?

十分ではありません。読み取り時に requiresSecureCoding を有効にし、クラスを指定する decodeObject を使ったうえで、バージョンと各値も検証します。

読み取りに失敗したら空の配列を返してよいですか?

失敗を正常な空データに置き換えないでください。元ファイルを残して明示的なエラーを返し、次の保存で元データが上書きされるのを防ぎます。

次のビルドに、クラウド Mac を

専有の物理 Mac mini をご利用いただけます。仮想マシンではありません。日・週・月・四半期から利用期間を選べます。構成と実際の空き状況は、コンソールの表示をご確認ください。