コード(iOS)
使用するには型を合わせる必要があります。
バージョン1
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
import Foundation import Security final class KeychainHelper { // MARK: - 保存(Dataを暗号化して安全に保存) static func save(_ data: Data, service: String, account: String) { // 既存データがあれば削除 delete(service: service, account: account) // 登録クエリ let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data ] SecItemAdd(query as CFDictionary, nil) } // MARK: - 読み込み(Dataを返す) static func load(service: String, account: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess else { return nil } return item as? Data } // MARK: - 削除(ログアウトやトークン再発行時に使用) static func delete(service: String, account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] SecItemDelete(query as CFDictionary) } // MARK: - 存在チェック(Keychainにデータがあるか) static func exists(service: String, account: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: false, kSecMatchLimit as String: kSecMatchLimitOne ] let status = SecItemCopyMatching(query as CFDictionary, nil) return status == errSecSuccess } // MARK: - すべて削除(開発・デバッグ用) static func clearAll() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword ] SecItemDelete(query as CFDictionary) } } |
バージョン2
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
import Foundation import Security final class KeychainHelper { // 保存 static func save(service: String, account: String, value: String) throws { let data = Data(value.utf8) // 既存のデータがあれば削除 try? delete(service: service, account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } } // 読み込み static func read(service: String, account: String) throws -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess else { if status == errSecItemNotFound { return nil } throw KeychainError.unhandledError(status: status) } guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else { throw KeychainError.invalidData } return value } // 削除 static func delete(service: String, account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } } // エラー定義 enum KeychainError: Error { case invalidData case unhandledError(status: OSStatus) } } |
✅ 使い方
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
let service = "com.weatherapp" let account = "authToken" do { // 保存 try KeychainHelper.save(service: service, account: account, value: "my-secret-token-123") // 読み込み if let token = try KeychainHelper.read(service: service, account: account) { print("取得したトークン:", token) } // 削除 try KeychainHelper.delete(service: service, account: account) print("トークン削除済み") } catch { print("Keychain エラー:", error) } |
| メソッド | 用途 | 使用例 |
|---|---|---|
save(data:service:account:) | トークンを保存 | KeychainHelper.save(data, service: "com.example.app", account: "token") |
load(service:account:) | トークンを読み出す | KeychainHelper.load(service: "com.example.app", account: "token") |
delete(service:account:) | 特定のトークンを削除 | KeychainHelper.delete(service: "com.example.app", account: "token") |
exists(service:account:) | トークンがあるか確認 | if KeychainHelper.exists(service: "com.example.app", account: "token") { ... } |
clearAll() | 全削除(開発用・慎重に) | KeychainHelper.clearAll() |
✅ 実用例(ログアウト時や再取得時)
|
1 2 3 4 |
if KeychainHelper.exists(service: "com.example.tsuriba", account: "authToken") { KeychainHelper.delete(service: "com.example.tsuriba", account: "authToken") print("トークンを削除しました。次回アクセス時に再取得します。") } |
🔹Keychain の「安全な上書き(更新)」方法
Keychain には本来「上書き」というメソッドはありません。
代わりに Apple が推奨しているのは SecItemUpdate を使う方法です。
KeychainHelper にこの更新メソッドを追加する場合は、こうなります👇
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
static func update(_ data: Data, service: String, account: String) -> Bool { // 更新対象を指定するクエリ let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] // 更新内容 let attributes: [String: Any] = [ kSecValueData as String: data ] // 既存があれば更新、なければ新規作成 let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if status == errSecItemNotFound { // 無ければ保存 save(data, service: service, account: account) return true } return status == errSecSuccess } |
service と account について
Keychain の保存は「service」と「account」という2つのキーで識別されます。
- service
- account
つまり、同じ service 内に複数の account を持てる仕組みになっています。
👉 任意の文字列でOKですが、「後から見てわかりやすい」命名にすると管理が楽です。
命名のベストプラクティス
service→ アプリ固有の文字列(com.name.weatherapp)account→ 保存するデータの種類(authToken/refreshToken)
これで一目見て「どのアプリの何のデータか」がわかるようになります。
CFDictionary
CFDictionary とは?
- Core Foundation フレームワークの 辞書型(連想配列)のことです。
- Swift や Objective-C から使うときは、通常の
Dictionary<String, Any>をas CFDictionaryでキャストして渡せば使えます。 - なぜ必要かというと、Keychain の低レベル API (
SecItemAdd,SecItemCopyMatching, etc.) は Core Foundation 時代の C API をベースに作られているからです。
例で分解すると
|
1 2 3 4 5 6 7 8 |
let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data ] let status = SecItemAdd(query as CFDictionary, nil) |
ここでやっていることは:
- Swift の辞書
queryを作る SecItemAddは C の関数なので Swift のDictionaryをそのまま渡せない
→ そこでas CFDictionaryをつけて「Core Foundation 辞書」に変換している。
参考:SecItemAdd のシグネチャ
|
1 |
func SecItemAdd(_ attributes: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus |
- func SecItemAdd(_ attributes: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus
- func SecItemAdd(_ attributes: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus
- 第二引数:
UnsafeMutablePointer<CFTypeRef?>?(結果を受け取るポインタ、今回は不要なのでnil)
**CFDictionary は「Keychain API が要求する辞書の型」**で、Swift の辞書をそのままキャストして渡しているだけ。
macOS互換
macOSでも問題なく使用できますか?
基本的にはそのまま動作します。Securityフレームワークと使用しているAPI(SecItemAdd、SecItemCopyMatching、SecItemDelete)はすべてmacOSでも利用可能です。
ただし、いくつか注意すべき点があります。
✅ 問題なく動く部分
kSecClassGenericPasswordをはじめ、使用しているすべての属性キーはmacOSでサポートされていますSecItemAdd / CopyMatching / DeleteのAPIシグネチャも同一です
⚠️ macOS固有の注意点
1. kSecAttrAccessible の挙動の違い
コードに kSecAttrAccessible の指定がないため、プラットフォームのデフォルト値に依存します。macOSではiOSと異なるデフォルト動作をすることがあるため、明示的に指定することを推奨します。
|
1 2 3 4 5 6 7 |
let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked // 明示指定 ] |
2. Entitlement(権限設定)
macOSのサンドボックスアプリでは、Keychain Sharing Entitlementが必要な場合があります。Xcodeの Signing & Capabilities で確認してください。
3. clearAll() のリスク
macOSでは(特に非サンドボックス環境で)Keychainがアプリ間で共有される場合があります。kSecClass のみでの全削除は他アプリのデータにも影響する可能性があるため、開発・デバッグ専用であっても注意が必要です。
|
1 2 3 4 5 6 7 8 |
// macOSではservice名を絞って削除する方が安全 static func clearAll(service: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service ] SecItemDelete(query as CFDictionary) } |
4. 非サンドボックスアプリでのアクセス確認ダイアログ
非サンドボックスのmacOSアプリでは、Keychainアイテムへの初回アクセス時にユーザーへの許可ダイアログが表示されることがあります。
まとめ
| 観点 | 状況 |
|---|---|
| API互換性 | ✅ 問題なし |
| サンドボックスアプリ | ✅ ほぼそのまま動作 |
kSecAttrAccessible | ⚠️ 明示指定を推奨 |
clearAll() | ⚠️ macOSでは要注意 |
| 非サンドボックスアプリ | ⚠️ 許可ダイアログが出る場合あり |
Catalyst(iPad→Mac変換)や SwiftUI の macOS ターゲットであれば、特に kSecAttrAccessible の明示指定と clearAll() の修正だけ対応しておけば、安全に流用できます。
macOS対応のIntバージョン
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
import Foundation import Security final class KeychainHelper { // 保存 static func save(service: String, account: String, value: Int) throws { var mutableValue = value let data = Data(bytes: &mutableValue, count: MemoryLayout<Int>.size) try? delete(service: service, account: account) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked ] let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } } // 読み込み static func read(service: String, account: String) throws -> Int? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess else { if status == errSecItemNotFound { return nil } throw KeychainError.unhandledError(status: status) } guard let data = item as? Data, data.count == MemoryLayout<Int>.size else { throw KeychainError.invalidData } return data.withUnsafeBytes { $0.load(as: Int.self) } } // 削除(変更なし) static func delete(service: String, account: String) throws { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) } } enum KeychainError: Error { case invalidData case unhandledError(status: OSStatus) } } |
コメント