axiom-keychain について
iOSおよびmacOSの開発者向けに、Apple Keychain Servicesを学び、問題を調査するスキルです。SecItem操作、一意性、データ保護、生体認証によるアクセス制御、共有、一般的なエラーを解説します。
主な機能
- 014種類のSecItem操作の解説
- 02一意性と重複項目エラーの調査
- 03実行状況に応じたアクセシビリティレベルの選択
- 04生体認証とパスコードによるアクセス制御
- 05共有とmacOSキーチェーンの違いの説明
活用シーン
- 01トークンやAPIキーの安全な保存
- 02キーチェーンの読み書き失敗の調査
- 03バックグラウンドアクセス向け保護レベルの選択
- 04アプリ拡張機能との認証情報共有
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-keychainインストール後、Claude Code で/axiom-keychainを入力して読み込まれたか確認します。
詳細
Keychain Services
iOS/macOS アプリ向けの安全な認証情報ストレージ、SecItem API のメンタルモデル、一意性制約、データ保護クラス、生体認証によるアクセス制御、キーチェーン共有、Mac のキーチェーンとの違いです。
このスキルを使う場面
次のような場合に使用します。
- ☑ トークン、パスワード、API キー、暗号鍵を安全に保存する
- ☑ errSecDuplicateItem、errSecItemNotFound、errSecInteractionNotAllowed の問題をデバッグする
- ☑ 用途に適した kSecAttrAccessible レベルを選ぶ
- ☑ キーチェーン項目に生体認証またはパスコードによる保護を追加する
- ☑ アクセスグループを使ってアプリ間またはアプリ拡張機能間で認証情報を共有する
- ☑ 機密データを UserDefaults/@AppStorage からキーチェーンに移行する
- ☑ SecItemCopyMatching が予想と異なる結果を返す理由を理解する
- ☑ バックグラウンドコンテキストからキーチェーン項目を保存または取得する
プロンプトの例
「認証トークンをキーチェーンに保存するにはどうすればよいですか?」 「キーチェーンに保存する際に errSecDuplicateItem が発生しますが、項目が存在しません」 「バックグラウンド更新中にキーチェーンの読み取りに失敗します」 「キーチェーン項目に Face ID による保護を追加するにはどうすればよいですか?」 「アプリとウィジェット拡張機能の間でキーチェーン項目を共有する」 「kSecAttrAccessibleAfterFirstUnlock と WhenUnlocked の違いは何ですか?」 「保存したばかりなのに SecItemCopyMatching が errSecItemNotFound を返します」 「Catalyst で macOS のキーチェーンを使うにはどうすればよいですか?」 「キーチェーンのラッパーが nil を返します。どうデバッグすればよいですか?」 「バックグラウンドタスクで errSecInteractionNotAllowed が発生します」
注意すべき兆候
必要以上に問題を複雑にしている兆候です。
- ❌ SecItem を理解せずにキーチェーンのラッパーを使う — ラッパーは 4 関数のモデルを隠し、独自のバグを持ち込みます。Quinn「The Eskimo!」は、ラッパーに関する問題の大半はキーチェーンではなくラッパーに起因すると明確に警告しています。まずモデルを理解しましょう。
- ❌ トークンを UserDefaults または @AppStorage に保存する — これらはデバイスにアクセスできる人(またはバックアップにアクセスできる人)が読み取れる plist ファイルです。認証情報はキーチェーンに保存してください。例外はありません。
- ❌ SecItemCopyMatching の結果を強制アンラップする — 戻り値の型は、渡した kSecReturn* フラグによって異なります。不一致があると、データではなく予想外の型が返されます。
- ❌ kSecAttrAccessible を指定しない — デフォルトは kSecAttrAccessibleWhenUnlocked で、バックグラウンド実行時に失敗します。アプリがバックグラウンド更新、プッシュ処理、VoIP を行う場合、問題になります。
- ❌ errSecDuplicateItem を削除してから再追加することで処理する — 競合状態が発生し、メタデータ(作成日、アクセス制御)が失われます。代わりに SecItemUpdate を使いましょう。
- ❌ 削除時の動作を理解せずに kSecMatchLimitAll を使う — SecItemDelete には kSecMatchLimit がなく、一致する項目をすべて削除します。不用意なクエリで認証情報がすべて消去されるおそれがあります。
- ❌ errSecDuplicateItem を無視する — 「前回は動作した」という場合、クエリと一意性の不一致が起きています。これはキーチェーンに関するバグの第 1 位です。
4 関数モデル
Quinn のメンタルモデルでは、キーチェーンはクラスごとにテーブルが分かれたデータベースです。4 つの SecItem 関数は SQL に直接対応します。
| SecItem 関数 | SQL 相当 | 目的 |
|---|---|---|
| SecItemAdd | INSERT | 新しい項目を作成する |
| SecItemCopyMatching | SELECT | 1 つ以上の項目を読み取る |
| SecItemUpdate | UPDATE | 既存の項目を変更する |
| SecItemDelete | DELETE | 項目を削除する |
クラスごとのテーブル
各項目クラスは、列(属性)が異なる個別のテーブルです。
| クラス | kSecClass の値 | 一般的な用途 |
|---|---|---|
| 汎用パスワード | kSecClassGenericPassword | アプリの認証情報、トークン、シークレット |
| インターネットパスワード | kSecClassInternetPassword | URL に関連付けられた認証情報 |
| 証明書 | kSecClassCertificate | X.509 証明書 |
| 鍵 | kSecClassKey | 暗号鍵 |
| ID | kSecClassIdentity | 証明書と秘密鍵のペア |
ほとんどの iOS アプリでは、必要なのは kSecClassGenericPassword だけです。インターネットパスワードは、特定のサーバー/プロトコル/ポートに関連付けられた認証情報向けです。証明書と鍵は、独自の暗号処理に使用します。
一意性制約
キーチェーンの不具合の多くはここに起因します。各クラスには主キーがあり、一意でなければならない属性の組み合わせを指します。
汎用パスワードの主キー
kSecAttrService + kSecAttrAccount + kSecAttrAccessGroup
インターネットパスワードの主キー
kSecAttrServer + kSecAttrAccount + kSecAttrPort
+ kSecAttrProtocol + kSecAttrAuthenticationType + kSecAttrSecurityDomain
+ kSecAttrAccessGroup
各クラスの属性の詳細については、axiom-keychain-ref を参照してください。
errSecDuplicateItem の落とし穴
クエリを指定して SecItemCopyMatching を呼び出すと、errSecItemNotFound が返されます。同じアイテムだと思うものを指定して SecItemAdd を呼び出すと、errSecDuplicateItem が返されます。なぜでしょうか?
クエリ属性が一意性属性と一致していません。 コピークエリでは kSecAttrService だけを検索しているかもしれませんが、一意性は service + account + accessGroup です。同じサービスでアカウントが異なるアイテムが存在する場合、クエリでは見つかりませんが、追加時には組み合わせに対する一意性制約に抵触します。
// This query finds nothing (searching by service + label, but label isn't a primary key)
let copyQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrLabel as String: "auth-token",
kSecReturnData as String: true
]
// Result: errSecItemNotFound (no item has this label)
// This add fails — an item with service "com.app.auth" + account "user-token"
// already exists. The add hits the primary key (service + account + accessGroup).
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "user-token",
kSecValueData as String: tokenData
]
// Result: errSecDuplicateItem
対処法:すべてのクエリで、主キー属性を必ずすべて指定してください。汎用パスワードでは、必ず kSecAttrService と kSecAttrAccount の両方を含めてください。
安全な追加または更新のパターン
func saveToKeychain(service: String, account: String, data: Data) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let attributes: [String: Any] = [
kSecValueData as String: data
]
// Try update first
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
// Item doesn't exist — add it
var addQuery = query
addQuery[kSecValueData as String] = data
status = SecItemAdd(addQuery as CFDictionary, nil)
}
return status
}
この方法は、アイテムのメタデータを保持し、競合状態を避けられるため、削除してから追加する方法より安全です。
パラメータブロックの構造
すべての SecItem 関数は辞書を受け取ります。この辞書には 5 つのグループのプロパティが含まれますが、使用できるグループは呼び出す関数によって異なります。
5 つのプロパティグループ
| グループ | 接頭辞/パターン | 用途 |
|---|---|---|
| クラス | kSecClass | 対象のテーブル(汎用パスワード、キーなど) |
| 属性 | kSecAttr* | 列の値(サービス、アカウント、ラベル) |
| 検索 | kSecMatch* | クエリ修飾子(上限、大文字と小文字の区別) |
| 戻り値の型 | kSecReturn* | 結果の形式 |
| 値の型 | kSecValue* | 実際のデータ/参照 |
関数ごとに適用されるグループ
digraph param_blocks {
rankdir=LR;
"SecItemAdd" [shape=box];
"SecItemCopyMatching" [shape=box];
"SecItemUpdate\n(query dict)" [shape=box];
"SecItemUpdate\n(attributes dict)" [shape=box];
"SecItemDelete" [shape=box];
"Class" [shape=ellipse];
"Attributes" [shape=ellipse];
"Search" [shape=ellipse];
"Return type" [shape=ellipse];
"Value type" [shape=ellipse];
"SecItemAdd" -> "Class";
"SecItemAdd" -> "Attributes";
"SecItemAdd" -> "Return type";
"SecItemAdd" -> "Value type";
"SecItemCopyMatching" -> "Class";
"SecItemCopyMatching" -> "Attributes";
"SecItemCopyMatching" -> "Search";
"SecItemCopyMatching" -> "Return type";
"SecItemUpdate\n(query dict)" -> "Class";
"SecItemUpdate\n(query dict)" -> "Attributes";
"SecItemUpdate\n(query dict)" -> "Search";
"SecItemUpdate\n(attributes dict)" -> "Attributes";
"SecItemUpdate\n(attributes dict)" -> "Value type";
"SecItemDelete" -> "Class";
"SecItemDelete" -> "Attributes";
"SecItemDelete" -> "Search";
}
kSecMatchLimit のデフォルト値
これは Quinn のスレッドで説明されている、ドキュメント化された落とし穴です。kSecMatchLimit のデフォルト値はコンテキストによって異なります。
| コンテキスト | kSecMatchLimit のデフォルト値 | 動作 |
|---|---|---|
| kSecReturnData を指定した SecItemCopyMatching | kSecMatchLimitOne | 1 件のアイテムを返す |
| kSecReturnAttributes を指定した SecItemCopyMatching | kSecMatchLimitOne | 1 件のアイテムを返す |
| kSecReturnRef を指定した SecItemCopyMatching | kSecMatchLimitOne | 1 件のアイテムを返す |
| SecItemDelete | 上限の概念なし | 一致するすべてのアイテムを削除する |
SecItemDelete には kSecMatchLimit パラメータがありません。クエリに一致するすべてのアイテムを削除します。クエリの範囲が広い場合(kSecClass と kSecAttrService のみなど)、そのサービスの全アカウントに属するすべてのアイテムが削除されます。
戻り値の型によって出力型が決まる
| kSecReturn* フラグ | 出力型 |
|---|---|
| kSecReturnData のみ | CFData(生のシークレット) |
| kSecReturnAttributes のみ | CFDictionary(アイテムのメタデータ) |
| kSecReturnRef のみ | SecKey / SecCertificate / SecIdentity |
| kSecReturnData + kSecReturnAttributes | CFDictionary(メタデータ + kSecValueData キー) |
| kSecReturnPersistentRef | CFData(キーチェーンのリセット後も有効な永続参照) |
結果を誤った型に強制キャストすると、クラッシュの原因になることがあります。キャスト先は必ず戻り値フラグに合わせてください。
アクセシビリティとデータ保護
kSecAttrAccessible は、キーチェーンアイテムの復号キーを利用できる条件を制御します。これはデバイスのデータ保護クラスに直接対応します。
| レベル | 利用可能になる条件 | 使用例 |
|---|---|---|
| kSecAttrAccessibleWhenUnlocked | デバイスのロック解除中 | デフォルト。UI から操作する認証情報 |
| kSecAttrAccessibleWhenUnlockedThisDeviceOnly | デバイスのロック解除中、バックアップなし | 高セキュリティのトークン |
| kSecAttrAccessibleAfterFirstUnlock | 最初のロック解除後から再起動まで | バックグラウンド更新、プッシュ処理 |
| kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly | 最初のロック解除後、バックアップなし | バックグラウンド処理と高セキュリティ |
| kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly | パスコード設定済みかつロック解除中 | 生体認証で保護されるアイテム |
バックグラウンド実行で陥りやすい罠
本番環境で最もよく発生するキーチェーンの障害です。通常の使用中は問題なく動作するアプリが、バックグラウンド更新中に errSecInteractionNotAllowed (-25308) で失敗します。
危険なパターン:
// Background task tries to read a token
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "refresh-token",
kSecReturnData as String: true
// Item was stored with default WhenUnlocked accessibility (set at add time)
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
// status == errSecInteractionNotAllowed when device is locked
// Developer's instinct: "the token is corrupted, delete and re-auth"
if status != errSecSuccess {
SecItemDelete(query as CFDictionary) // DELETES THE CREDENTIAL
}
Quinn の言葉:「一度学んだら、決して忘れない教訓です!」
アイテムに問題はありません。デバイスがロックされているため、復号キーを利用できないのです。開発者がエラーハンドラーでエラーを誤解し、完全に有効な認証情報を削除してしまいます。
修正方法:バックグラウンドで必要なトークンは kSecAttrAccessibleAfterFirstUnlock を使って保存します。
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "refresh-token",
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
kSecValueData as String: tokenData
]
また、errSecInteractionNotAllowed が発生しても決して削除しないでください。これは「デバイスのロック解除後に再試行してください」という意味であり、「このアイテムは壊れています」という意味ではありません。
アクセス制御と生体認証
SecAccessControl を使うと、個々のキーチェーンアイテムに生体認証またはデバイスのパスコードによる制限を設定できます。
生体認証で保護されたアイテムの作成
var error: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.biometryCurrentSet,
&error
) else {
// Handle error — usually means device has no passcode
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "biometric-secret",
kSecAttrAccessControl as String: accessControl,
kSecValueData as String: secretData
]
let status = SecItemAdd(query as CFDictionary, nil)
アクセス制御フラグの選択
digraph acl_decision {
"What gates access?" [shape=diamond];
"Enrollment change\nmatters?" [shape=diamond];
"Biometric or\npasscode fallback?" [shape=diamond];
".biometryCurrentSet" [shape=box, label=".biometryCurrentSet\nInvalidates if fingerprints/face change"];
".biometryAny" [shape=box, label=".biometryAny\nSurvives enrollment changes"];
".userPresence" [shape=box, label=".userPresence\nBiometry with passcode fallback"];
".devicePasscode" [shape=box, label=".devicePasscode\nPasscode only, no biometry"];
"What gates access?" -> "Enrollment change\nmatters?" [label="biometry"];
"What gates access?" -> ".devicePasscode" [label="passcode only"];
"What gates access?" -> "Biometric or\npasscode fallback?" [label="either"];
"Enrollment change\nmatters?" -> ".biometryCurrentSet" [label="yes, re-auth\non change"];
"Enrollment change\nmatters?" -> ".biometryAny" [label="no, any enrolled\nbiometry"];
"Biometric or\npasscode fallback?" -> ".userPresence" [label="user convenience"];
}
| フラグ | 動作 | 使用場面 |
|---|---|---|
| .biometryCurrentSet | 生体認証の登録内容が変更されると無効になります | 銀行、医療 — 新しい指紋や顔で再認証します |
| .biometryAny | 登録内容が変更されても有効です | 利便性を重視した認証、アプリロック |
| .userPresence | 生体認証に加え、パスコードでの認証にも対応します | 最も一般的な選択肢 — 生体認証に失敗しても機能します |
| .devicePasscode | 生体認証のプロンプトを表示せず、パスコードのみを使います | アクセシビリティを優先する場合や、生体認証を避けたいユーザー |
再利用時間を設定する LAContext
デフォルトでは、キーチェーンを読み取るたびに生体認証が求められます。一定の時間内で再利用できるようにするには、次のようにします。
let context = LAContext()
context.touchIDAuthenticationAllowableReuseDuration = 10 // seconds
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "biometric-secret",
kSecUseAuthenticationContext as String: context,
kSecReturnData as String: true
]
生体認証に一度成功すると、その後10秒以内の読み取りではプロンプトが省略されます。再利用時間の上限は LATouchIDAuthenticationMaximumAllowableReuseDuration です(現行のハードウェアでは5分)。
共有とアクセスグループ
キーチェーンアクセスグループと App Groups の違い
| 機能 | キーチェーンアクセスグループ | App Groups |
|---|---|---|
| エンタイトルメント | keychain-access-groups | com.apple.security.application-groups |
| プレフィックス | Team ID(自動で追加) | なし(文字列全体を自分で指定) |
| 対象範囲 | キーチェーンアイテムのみ | ファイル、UserDefaults、キーチェーン |
| 設定 | Signing & Capabilities → Keychain Sharing | Signing & Capabilities → App Groups |
アクセスグループの仕組み
すべてのキーチェーンアイテムは、必ず1つのアクセスグループに属します。kSecAttrAccessGroup を指定しない場合、アイテムはアプリのデフォルトのアクセスグループ(TEAMID.your.bundle.id)に格納されます。
アプリ間または拡張機能との間で共有するには、次の手順に従います。
- 両方のターゲットに同じキーチェーンアクセスグループを追加します(Signing & Capabilities → Keychain Sharing)
- 書き込み時にグループを指定します。
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.shared-auth",
kSecAttrAccount as String: "user-token",
kSecAttrAccessGroup as String: "TEAMID.com.app.shared",
kSecValueData as String: tokenData
]
Team ID プレフィックスの罠
キーチェーンアクセスグループには、システムによって Team ID が自動的に先頭に追加されます。エンタイトルメントに com.app.shared と記述すると、システムは ABCD1234.com.app.shared として保存します。
ただし、コード内の kSecAttrAccessGroup には、プレフィックスを含める必要があります:"ABCD1234.com.app.shared"。
App Groups には、このプレフィックスは追加されません。キーチェーン共有に App Group を使う場合(group. プレフィックスを付けて kSecAttrAccessGroup を指定する場合)は、文字列全体をそのまま使います。
App ID プレフィックス変更の罠
Quinn が挙げた落とし穴のとおり、アプリの App ID プレフィックス(Team ID)が変更されると、既存のキーチェーンアイテムすべてにアクセスできなくなります。これは、次の場合に発生します。
- 開発者アカウント間でアプリを移管する
- 特定のレガシーな App ID 構成を使用する
復旧方法はありません。アイテムは孤立します。移行ロジックでこの状況に備え、該当する状態を検出してユーザーに再認証を促してください。
Mac のキーチェーンの違い
macOS には2つのキーチェーンシステムがあります。その違いを理解しておくと、「iOS では動くのに Mac では失敗する」というバグを防げます。
ファイルベースのキーチェーン(レガシー)
従来の macOS キーチェーン(~/Library/Keychains/login.keychain-db)の特徴:
- Keychain Access.app からユーザーが確認できます
- アプリごとのアクセス制御リスト(ACL)をサポートします
- デフォルトでは、すべてのアプリでアイテムを共有できます
- データ保護クラスはありません
データ保護キーチェーン(最新)
iOS のキーチェーンの動作に準拠しています(TN3137 より):
- macOS 15 より前では Keychain Access.app に表示されません
- データ保護クラス(kSecAttrAccessible)を使用します
- 項目はアクセスグループのスコープに限定されます(iOS と同じです)
- Catalyst および SwiftUI マルチプラットフォームアプリで必須です
常にデータ保護キーチェーンを使用する
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.auth",
kSecAttrAccount as String: "token",
kSecUseDataProtectionKeychain as String: true, // Critical for Mac
kSecValueData as String: tokenData
]
macOS では、すべてのキーチェーン操作で kSecUseDataProtectionKeychain: true を設定してください。これにより、iOS、macOS、Catalyst、Mac Catalyst 間で一貫した動作が得られます。
macOS でこのフラグを指定しないと、項目はファイルベースのキーチェーンに保存されます。その場合:
- アクセス制御の動作が異なります(データ保護ではなく ACL を使用します)
- 共有の動作が変わります(デフォルトでは、どのアプリでも読み取れます)
- 項目が Keychain Access.app に表示されます(ユーザーが混乱します)
- ファイルベースのキーチェーンとデータ保護キーチェーン間の移行は、一方向で手動です
非合理化
| 非合理化 | それが失敗する理由 | 所要時間 |
|---|---|---|
| 「トークンなら UserDefaults で問題ありません。プライベートアプリですから」 | バックアップの抽出、デバイスアクセスツール、MDM プロファイルはいずれも UserDefaults を読み取れます。キーチェーンはハードウェアレベルで暗号化されています。 | セキュリティインシデントへの対応に 4~8 時間 |
| 「KeychainHelper クラスでラップすれば簡単になります」 | ラッパーは 4 関数モデルを抽象化し、一意性やパラメータブロックのセマンティクスを隠します。問題が起きると、ラッパーとキーチェーンの両方をデバッグすることになります。 | SecItem を学ぶ場合の 30 分に対し、ラッパーのバグのデバッグに 2~4 時間 |
| 「削除してから追加するほうが更新より簡単です」 | 作成日、アクセス制御設定、永続参照が失われます。削除と追加の間に別のプロセスが読み取ると、TOCTOU 競合が発生します。 | 間欠的な失敗のデバッグに 1~2 時間 |
| 「WhenUnlocked なら十分安全です」 | UI による読み取りには適しています。バックグラウンド更新、プッシュ処理、サイレント通知では致命的です。ユーザーがロック解除できない午前 3 時に errSecInteractionNotAllowed が発生します。 | ロック中のデバイスでしか再現しないバックグラウンドの失敗の診断に 2~6 時間 |
| 「Simulator でテストしたら動きました」 | Simulator には Secure Enclave がなく、データ保護の強制もなく、キーチェーンの動作も異なります。生体認証項目は常に成功します。アクセシビリティ制約も適用されません。 | 実機でしか起きない失敗のデバッグに 4~8 時間 |
| 「errSecInteractionNotAllowed はトークンが破損しているという意味です」 | デバイスがロックされているという意味です。トークンに問題はありません。削除すると有効な認証情報が失われ、再認証が必要になります。 | インシデントごとに、ユーザーからの苦情対応に 15~30 分 |
| 「アクセスグループは複雑すぎるので、すべて App Groups を使います」 | App Groups は副次的にキーチェーン共有を追加しますが、そのセマンティクスはキーチェーンアクセスグループとは異なります。Team ID プレフィックスの動作も異なります。両方を混在させると、見えにくいアクセススコープのバグが発生します。 | ターゲット間のアクセス失敗のデバッグに 2~4 時間 |
プレッシャーのかかる状況
シナリオ 1:「とりあえず UserDefaults に保存して」
状況:スプリントの期限が迫る中、新機能で API トークンの保存が必要です。キーチェーンのコードは複雑そうに見えます。
プレッシャー:「UserDefaults のほうが実装が速いです。セキュリティスプリントでキーチェーンに移行しましょう。」
実情:後から UserDefaults からキーチェーンに移行するには、移行コード(UserDefaults から読み取り、キーチェーンに書き込み、UserDefaults から削除し、部分的な移行に対処する)が必要です。この移行自体がセキュリティ上のリスクになります。移行中はトークンが両方の場所に存在するためです。「セキュリティスプリント」は常に優先度の高い機能があるため、実施されることはありません。その間、トークンはアプリの plist に平文で保存され、すべての iCloud バックアップに含まれます。
正しい対応:このスキルにある安全な追加または更新のパターンを使用します。コードは 15 行です。SecItem の呼び出し自体は、UserDefaults より実質的に難しくありません。難しいと感じるのは、モデルを理解していないためです。
反論用テンプレート:「キーチェーンの呼び出しは 15 行で、UserDefaults と同じです。今 UserDefaults を使うと、後で移行コードを書く必要があり、実際にはそのほうが大変です。最初から正しく実装させてください。」
シナリオ 2:「エラーが発生したらキーチェーン項目を削除する」
コンテキスト:バックグラウンド更新が errSecInteractionNotAllowed で断続的に失敗します。手っ取り早い修正は、「破損した」トークンを削除して、再ログインを強制することです。
圧力:「ユーザーから古いデータについて苦情が来ています。トークンを消去して、次回の起動時に再認証させてください。」
実態:トークンは破損していません。デバイスがロックされており、kSecAttrAccessibleWhenUnlocked によってアクセスが拒否されています。これを削除すると、バックグラウンド処理に失敗するたびに、すべてのユーザーに再認証を強制することになります。つまり、スマートフォンがロックされる毎晩のことです。本当の修正方法は、アクセシビリティレベルを kSecAttrAccessibleAfterFirstUnlock に変更することです。変更は 1 行で済みます。
適切な対応:項目のアクセシビリティを kSecAttrAccessibleAfterFirstUnlock に変更します。すでに誤ったレベルで保存されている既存の項目については、アプリ起動時に一度だけマイグレーションを追加します(読み取り、削除、正しいアクセシビリティを指定して再追加)。
反論のテンプレート:「トークンは破損していません。デバイスがロックされているだけです。トークンを削除すると、毎朝すべてのユーザーに再ログインを強制することになります。修正は 1 行です。アクセシビリティクラスを AfterFirstUnlock に変更します。既存ユーザー向けのマイグレーションも追加できます。」
シナリオ 3:「このキーチェーンラッパーライブラリを使いましょう。みんな使っています」
コンテキスト:チームは、キーチェーンへのアクセスを「簡素化」するため、人気のあるオープンソースのキーチェーンラッパーを導入しようとしています。
圧力:「GitHub で 5,000 個のスターが付いていて、あらゆるエッジケースにも対応しています。」
実態:Quinn は、このパターンを明確に記録しています。Apple Developer Forums に報告されているキーチェーン関連の問題の多くは、キーチェーンそのものではなく、ラッパーライブラリに起因しています。ラッパーは kSecMatchLimit をハードコードしたり、戻り値の型を決めつけたり、アクセシビリティとアクセス制御を混同したり、更新ではなく削除してから追加したりすることがよくあります。ラッパーが壊れた場合、ラッパーの抽象化と、その下で呼び出される SecItem の両方をデバッグすることになります。また、アクセスグループ、アクセシビリティ、エラー処理に関するラッパーの方針も引き継ぐことになりますが、それが要件に合うとは限りません。
適切な対応:アプリ固有のニーズに合わせて、薄い拡張機能または関数を作成します。安全な追加または更新のパターン、読み取り関数、削除関数があれば、ユースケースの 95%を 50 行未満でカバーできます。
反論のテンプレート:「Apple のフォーラムで報告されているキーチェーンのバグの多くは、キーチェーンそのものではなく、ラッパーライブラリに起因しています。私たちに必要なのは保存、読み取り、削除の 3 つの関数です。アクセスグループやエラー処理を独自に決める依存関係を追加するより、動作を完全に把握できる 50 行のコードを書くほうがよいです。」
チェックリスト
キーチェーンのコードをリリースする前に:
モデル:
- SecItem 関数を直接使用している(理解できていないラッパーは使用しない)
- すべてのクエリにすべての主キー属性が含まれている(一般的なパスワードの場合は service + account)
- 削除してから追加するのではなく、更新してから追加するパターンを使用している
- 必要な箇所で kSecMatchLimit を明示的に設定している(デフォルト値に依存しない)
データ保護:
- すべての追加処理で kSecAttrAccessible を明示的に設定している
- バックグラウンドでアクセスするアイテムには AfterFirstUnlock を使用している(WhenUnlocked は使用しない)
- アイテムを削除せずに errSecInteractionNotAllowed を処理している
- ロック中の実機でテストしている(シミュレーターだけではない)
アクセス制御:
- 生体認証フラグがセキュリティ要件に合っている(currentSet と any のどちらか)
- LAContext の再利用期間が適切である(長すぎない)
- 生体認証が利用できない、または失敗した場合の適切なフォールバックがある
共有:
- キーチェーンのアクセスグループのエンタイトルメントがコードと一致している(Team ID のプレフィックスを含む)
- 共有対象すべてのエンタイトルメントに同じアクセスグループが設定されている
- クエリでアクセスグループを明示的に指定している(デフォルト値に依存しない)
Mac との互換性(マルチプラットフォームの場合):
- すべての macOS 操作で kSecUseDataProtectionKeychain を true に設定している
- macOS でテストしている(iOS だけではない)
エラー処理:
- すべての SecItem 呼び出しで OSStatus の戻り値を確認している
- errSecDuplicateItem を処理している(握りつぶしたり、ログに記録して無視したりしない)
- errSecItemNotFound を通常のケースとして処理している(読み取り処理ではエラーとして扱わない)
- エラーのコンテキストに、試行した service、account、操作が含まれている
リソース
WWDC:2019-709
ドキュメント:/security/keychain_services、/security/certificate_key_and_trust_services、/technotes/tn3137-on-mac-keychains
Apple フォーラム:thread/724023(SecItem の基本)、thread/724013(SecItem の落とし穴)
スキル:axiom-keychain-diag、axiom-keychain-ref、axiom-cryptokit、axiom-code-signing、axiom-app-attest