クラウド Mac で検証する iOS の PBKDF2 とパラメータ更新

クラウド Mac で検証する iOS の PBKDF2 とパラメータ更新

Windows では同じパスワードでファイルを復号できるのに、iOS に移すとエラーになる。そんなときも、すぐにデータをリセットする必要はありません。原因は暗号アルゴリズムではなく、パスワードのエンコーディング、反復回数、導出する鍵の長さの不一致かもしれません。まずクラウド Mac で既知解テストを実行し、続いて iOS のテストターゲットから実際のラッパーを呼び出せば、問題のある層を絞り込めます。

入力仕様を固定する

この記事は、すでに PBKDF2 を使うプロトコルがあるクロスプラットフォームのプロジェクトを対象としています。あらゆる新規システムで PBKDF2 を標準の選択肢にすべきだという趣旨ではありません。最初に、アルゴリズム、PRF、パスワードのバイトエンコーディング、ソルトのバイト列、反復回数、出力長の六つを明文化します。ここでは PBKDF2-HMAC-SHA256 を使い、出力長を 32 バイトに固定します。

パスワードは UTF-8 でエンコードし、空白の除去、大文字・小文字の変換、Unicode 正規化は自動で行いません。見た目が同じ文字列でも、バイト列が異なる場合があります。既存のプロトコルに正規化のルールがあるなら、そのルールを引き継ぎ、バージョン管理の対象に含める必要があります。

既知解テストは「計算が正しいか」を確かめるもの、実機での測定は「ワークファクターが適切か」を確かめるものです。テストベクトルの反復回数である 1 回や 2 回を、そのまま本番設定に使ってはいけません。

ソルトとして渡す値が、16 進数のテキストではなく生のバイト列であることも確認します。文字列 73616c74 をソルトにする場合と、単語 salt のバイト列を渡す場合とでは、入力が異なります。

標準テストベクトルを実行する

開発用ツールチェーン一式が選択済みの Mac で、次の内容を pbkdf2-check.swift として保存してください。システムの CommonCrypto を直接呼び出すため、依存パッケージのダウンロードは不要です。検証に失敗すると実行を停止し、パスワードや導出結果は出力しません。

import Foundation
import CommonCrypto

enum DerivationError: Error {
    case invalidInput
    case failed(Int32)
}

func derive(
    password: String,
    salt: [UInt8],
    rounds: UInt32
) throws -> [UInt8] {
    guard rounds > 0, !salt.isEmpty else {
        throw DerivationError.invalidInput
    }

    let passwordBytes = password.utf8CString
    var output = [UInt8](repeating: 0, count: 32)

    let status = passwordBytes.withUnsafeBufferPointer { p in
        salt.withUnsafeBufferPointer { s in
            output.withUnsafeMutableBufferPointer { o in
                CCKeyDerivationPBKDF(
                    CCPBKDFAlgorithm(kCCPBKDF2),
                    p.baseAddress,
                    p.count - 1,
                    s.baseAddress,
                    s.count,
                    CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
                    rounds,
                    o.baseAddress,
                    o.count
                )
            }
        }
    }

    guard status == kCCSuccess else {
        throw DerivationError.failed(status)
    }
    return output
}

func hex(_ bytes: [UInt8]) -> String {
    bytes.map { String(format: "%02x", $0) }.joined()
}

let salt = Array("salt".utf8)
let vectors: [(UInt32, String)] = [
    (1, "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b"),
    (2, "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43")
]

for (rounds, expected) in vectors {
    let actual = try derive(
        password: "password",
        salt: salt,
        rounds: rounds
    )
    precondition(hex(actual) == expected)
}

let full = try derive(password: "ab\u{0000}cd", salt: salt, rounds: 2)
let prefix = try derive(password: "ab", salt: salt, rounds: 2)
precondition(full != prefix)

let composed = try derive(password: "\u{00e9}", salt: salt, rounds: 2)
let decomposed = try derive(password: "e\u{0301}", salt: salt, rounds: 2)
precondition(composed != decomposed)

xcrun swift pbkdf2-check.swift を実行し、終了コードがゼロなら、これらのアサーションはすべて通過しています。ここではインタープリタ実行を使います。アサーションが無効になる最適化ビルドに変更しないでください。

このコードではパスワードのバイト長を明示的に渡し、末尾の終端文字は除外しつつ、途中に含まれるヌル文字は保持しています。ヌル終端を基準に長さを求めるインターフェースに置き換えると、パスワードの後半が切り捨てられてしまいます。

アプリで使うラッパーをテストする

スクリプトが通ったら、同じサンプルを iOS の XCTest ターゲットに追加します。ただし、呼び出すのはアプリで実際に使っている鍵導出のエントリーポイントです。テスト専用の実装をコピーして済ませてはいけません。それでは下位層の計算が正しくても、上位層でソルトを誤ってデコードしている可能性が残ります。

少なくとも、次の四つの観点を追加で確認します。

  • 空のパスワード:プロトコルに従って許可または拒否を明確にし、黙って別の値に置き換えない。
  • 中国語、絵文字、埋め込まれたヌル文字:両プラットフォームで入力バイト列と最終結果を照合する。
  • 反復回数ゼロ、壊れたソルトのエンコーディング、未知のアルゴリズム識別子:鍵導出関数を呼び出す前に拒否する。
  • 同じパスワードと異なるソルト:固定サンプルで結果が異なることを確認し、固定ソルトの誤用を防ぐ。

サンプル関数が空のソルトを拒否するのは、このプロジェクトの入力ポリシーによるものです。アルゴリズムが空のソルトを扱えないという意味ではありません。本番用のソルトは、システムが提供する暗号学的に安全な乱数源で生成します。乱数源が失敗した場合は必ずエラーを返し、タイムスタンプで代用してはいけません。

テストの層を分ける

Mac のスクリプトではアルゴリズムの呼び出しが正しいかを検証し、シミュレータのテストではアプリのラッパーを検証し、実機のテストでは処理時間と UI への影響を測定します。三つの層で同じ入力仕様を記録しますが、Mac の処理速度をスマートフォンの処理速度と同一視してはいけません。

更新に必要なパラメータを保存する

鍵導出のレコードには、少なくともフォーマットのバージョン、アルゴリズム識別子、反復回数、ソルト、出力長を保存します。テキストエンコーディングを使う場合は、ソルトにどの Base64 規則を使うかなど、形式も明記します。ソルトはパスワードではなく、隠すことで安全性を確保するものではありません。

レコードを読み込む際は、フィールドの型、長さ、計算量の上限を検証してから鍵導出を実行します。壊れたファイルが極端に大きい反復回数を指定し、アプリが長時間 CPU を占有する事態を防ぐ必要があります。ワークファクターはセキュリティ要件とサポート対象の実機での測定結果に基づいて決めるもので、テストベクトルからそのまま転用してはいけません。

パラメータを更新するときに、グローバル定数だけを上書きして旧レコードを読み込んではいけません。旧レコードの検証には旧パラメータを使います。必要な平文を取得できてから、新しいソルトと新しいポリシーで新版のレコードを生成します。

導出結果を復号に使っている場合、古い暗号文だけを使って新しいパラメータへ直接切り替えることはできません。まず復号に成功してから再暗号化し、新レコードの書き込みと検証がすべて完了するまでは旧レコードを残します。保存しているものがパスワード検証値なら、比較にはレビュー済みの定数時間比較の実装を使い、サンプルの通常の配列比較をそのまま流用しないでください。

マージ時のチェックを整える

CI には独立した二つのチェックを用意します。標準テストベクトルはバイト単位で一致すること、アプリのレコード処理は旧バージョンを読み込め、不正なパラメータを拒否できることです。さらにテスト専用の旧レコードを一つ用意し、更新後も開けることと、新規に書き込んだレコードが実際に新バージョンを使っていることを確認します。

失敗レポートに残すのは、ケース名、アルゴリズム、機密性のないパラメータだけにします。パスワード、導出鍵、実ユーザーのレコードはアップロードしません。性能測定は条件を管理した実機ジョブで行い、共有実行環境で一度測った処理時間を理由にマージを止めないようにします。

最後に、移行に失敗する経路も確認します。キャンセル、ディスクへの書き込み失敗、アプリ終了の後でも、旧レコードを読み込めなければなりません。ここまで確認して初めて、単なるパスワード関数ではなく、パラメータ更新でデータを失わない一連の処理を検証したことになります。

よくある質問

例にある反復回数の 1 回や 2 回を本番でも使えますか?

使えません。既知の出力との照合に使うテスト専用の値です。本番の反復回数はセキュリティ方針と対象端末の測定で決め、外部から読み込む値には計算量の上限も設けます。

Mac で成功すれば iOS の検証も完了ですか?

完了ではありません。Mac のスクリプトはアルゴリズムとバイト列の確認用です。iOS のテストターゲットで実際の業務用ラッパーを呼び、対象の実機で処理時間と操作への影響も測定します。

クラウド Mac をレンタル

専有物理マシンを日単位、週単位、月単位、四半期単位でレンタルできます。プロジェクトに合わせてビルドを進め、契約終了後は更新しなければ自動で停止します。

プランを選ぶ

ビルドの準備を始めましょう

マシンとレンタル期間を選び、契約終了日をご確認ください。更新しなければ、期間終了後に自動で停止します。