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 のテストターゲットで実際の業務用ラッパーを呼び、対象の実機で処理時間と操作への影響も測定します。