device-integrity について
Apple アプリ開発者向けに、DeviceCheck によるデバイスとアプリの整合性確認を解説するガイドです。デバイストークンと、ハードウェア保護キーを使う App Attest のサーバー検証を扱います。
主な機能
- 01使い捨て DeviceCheck トークンの生成
- 02デバイス単位のビット値の管理
- 03Secure Enclave 保護の App Attest キー作成
- 04キーのアテステーションとリクエスト署名
- 05サーバーでのアテステーションとアサーション検証
活用シーン
- 01デバイス単位の特典利用や不正フラグの記録
- 02機密性の高いリクエスト前のアプリインスタンス確認
- 03署名付きリクエストと一回限りのチャレンジの紐付け
- 04非対応デバイス向けのフォールバック設計
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill device-integrityインストール後、Claude Code で/device-integrityを入力して読み込まれたか確認します。
同梱ファイルevals/evals.jsonreferences/device-integrity-patterns.md
詳細
デバイスの整合性
サーバーへのリクエストが、正規のアプリを実行している本物の Apple デバイスから送信されていることを確認します。DeviceCheck は、シンプルなフラグ(例:「プロモーション特典を利用済み」)に使えるデバイスごとのビットを提供します。App Attest は Secure Enclave のキーと Apple のアテステーションを使い、機密性の高いリクエストについて、アプリが正規のものであることを暗号技術によって証明します。
目次
- DCDevice(DeviceCheck トークン)
- DCAppAttestService(App Attest)
- App Attest のキー生成
- App Attest のアテステーションフロー
- App Attest のアサーションフロー
- サーバーでの検証に関するガイダンス
- エラー処理
- 一般的なパターン
- よくある間違い
- レビュー用チェックリスト
- 参考資料
DCDevice(DeviceCheck トークン)
DCDevice は、デバイスを識別する一意で一時的なトークンを生成します。各トークンは使い捨てとして扱い、キャッシュしたり再利用したりせず、サーバーでの操作ごとに新しいトークンを生成してください。このトークンをサーバーに送信すると、サーバーは Apple のサーバーと通信して、デバイスごとの 2 つのビットを読み取るか、設定します。iOS 11 以降で利用できます。
トークンの生成
import DeviceCheck
func generateDeviceToken() async throws -> Data {
guard DCDevice.current.isSupported else {
throw DeviceIntegrityError.deviceCheckUnsupported
}
return try await DCDevice.current.generateToken()
}
サーバーへのトークンの送信
func sendTokenToServer(_ token: Data) async throws {
let tokenString = token.base64EncodedString()
var request = URLRequest(url: serverURL.appending(path: "verify-device"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["device_token": tokenString])
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw DeviceIntegrityError.serverVerificationFailed
}
}
サーバーサイドの概要
サーバーは、新しいトークンを Apple の認証済み DeviceCheck API と交換します。エンドポイントと環境の詳細については、DeviceCheck サーバーエンドポイントを参照してください。
2 つのビットの用途
Apple は、開発者チームごとに、デバイスあたり 2 つのブール値を保存します。その意味は自由に決められます。一般的な用途は次のとおりです。
- ビット 0: デバイスがプロモーション特典を利用済みかどうか。
- ビット 1: デバイスに不正のフラグが付けられているかどうか。
ビットはアプリを再インストールしても保持されます。リセットするタイミングは、サーバー API を使って制御できます。
DCAppAttestService(App Attest)
DCAppAttestService は、特定のデバイス上にある特定のアプリのインスタンスが正規のものであることを検証します。Secure Enclave 内のハードウェアで保護されたキーを使い、暗号技術によるアテステーションとアサーションを生成します。iOS 14 以降で利用できます。
フローは 3 つのフェーズで構成されます。
- キー生成 -- Secure Enclave でキーペアを作成します。
- アテステーション -- Apple が、そのキーが正規の Apple デバイス上で実行されているアプリに属することを証明します。
- アサーション -- アテステーション済みのキーでサーバーリクエストに署名し、継続的に正規性を証明します。
サポート状況の確認
import DeviceCheck
let attestService = DCAppAttestService.shared
guard attestService.isSupported else {
// Fall back to DCDevice token or other risk assessment.
// App Attest is not available on simulators or all device models.
return
}
アプリ拡張では、App Attest がサポートされるのは Action、拡張可能な SSO、および watchOS の拡張のみです。isSupported が true を返す場合でも、それ以外の拡張タイプはサポート対象外として扱ってください。
App Attest のキー生成
各デバイス上で、ユーザーアカウントごとに暗号鍵ペアを 1 つ生成します。秘密鍵は Secure Enclave に保持されます。返された keyId は、アプリが後でキーにアクセスするために使える唯一の識別子です。そのため、アカウントとデバイスに紐づく keyId を記録して再利用し、1 つのキーを複数のユーザー間で共有しないでください。新しいキーを生成するたびに App Attest のキー数リスク指標に影響するため、不要な再生成は避けてください。サーバーがアテステーションを検証した後に限り、keyId を使用可能として扱ってください。サーバーでの検証に失敗した場合は、keyId を破棄し、再試行する前に新しいキーを生成してください。
import DeviceCheck
actor AppAttestManager {
private let service = DCAppAttestService.shared
private var keyId: String?
/// Generate and record a key pair for App Attest.
func generateKeyIfNeeded() async throws -> String {
if let existingKeyId = loadKeyIdFromKeychain() {
self.keyId = existingKeyId
return existingKeyId
}
let newKeyId = try await service.generateKey()
saveKeyIdToKeychain(newKeyId)
self.keyId = newKeyId
return newKeyId
}
// MARK: - Keychain helpers (simplified)
private func saveKeyIdToKeychain(_ keyId: String) {
let data = Data(keyId.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "app-attest-key-id-\(currentAccountID)",
kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
SecItemDelete(query as CFDictionary) // Remove old if exists
SecItemAdd(query as CFDictionary, nil)
}
private func loadKeyIdFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "app-attest-key-id-\(currentAccountID)",
kSecAttrService as String: Bundle.main.bundleIdentifier ?? "",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
App Attest のアテステーションフロー
アテステーションは、正規のアプリのインスタンスを実行している本物の Apple デバイス上でキーが生成されたことを証明します。キーごとに一度だけアテステーションを実行し、検証済みの公開鍵とレシートをサーバーに保存します。サーバーがアテステーションを受け入れた後、アプリは後続のアサーションに備えて keyId を保存します。
クライアントサイドのアテステーション
import DeviceCheck
import CryptoKit
extension AppAttestManager {
/// Attest the key with Apple. Send the attestation object to your server.
func attestKey() async throws -> Data {
guard let keyId else {
throw DeviceIntegrityError.keyNotGenerated
}
// 1. Request a one-time challenge from your server
let challenge = try await fetchServerChallenge()
// 2. Hash the challenge (Apple requires a SHA-256 hash)
let challengeHash = Data(SHA256.hash(data: challenge))
// 3. Ask Apple to attest the key
let attestation = try await service.attestKey(keyId, clientDataHash: challengeHash)
// 4. Send the attestation object to your server for verification
try await sendAttestationToServer(
keyId: keyId,
attestation: attestation,
challenge: challenge
)
return attestation
}
private func fetchServerChallenge() async throws -> Data {
let url = serverURL.appending(path: "attest/challenge")
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
private func sendAttestationToServer(
keyId: String,
attestation: Data,
challenge: Data
) async throws {
var request = URLRequest(url: serverURL.appending(path: "attest/verify"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: String] = [
"key_id": keyId,
"attestation": attestation.base64EncodedString(),
"challenge": challenge.base64EncodedString()
]
request.httpBody = try JSONEncoder().encode(payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw DeviceIntegrityError.attestationVerificationFailed
}
}
}
サーバー側のアテステーション検証
クライアントが keyId を使用可能なものとして扱う前に、サーバーでアテステーションを検証し、検証済みの公開鍵とレシートを保存する必要があります。証明書、App ID、環境、カウンター、クレデンシャル、ノンスの確認については、サーバー側のアテステーション検証を参照してください。
App Attest アサーションフロー
アテステーション後は、アサーションを使用して機密性の高いリクエストに署名します。各アサーションは、リクエストがアテステーション済みのアプリインスタンスから送信されたことを証明し、リプレイを防ぐためのサーバー発行の使い捨てチャレンジを含みます。
クライアント側のアサーション
import DeviceCheck
import CryptoKit
extension AppAttestManager {
/// Generate an assertion for encoded client data.
/// Client data should include a one-time server challenge and request context.
func generateAssertion(for clientData: Data) async throws -> Data {
guard let keyId else {
throw DeviceIntegrityError.keyNotGenerated
}
let clientDataHash = Data(SHA256.hash(data: clientData))
return try await service.generateAssertion(keyId, clientDataHash: clientDataHash)
}
}
ネットワークリクエストでのアサーションの使用
struct AppAttestClientData: Encodable {
let challenge: String
let method: String
let path: String
let bodySHA256: String
}
extension AppAttestManager {
/// Perform an attested API request.
func makeAttestedRequest(
to url: URL,
method: String = "POST",
body: Data
) async throws -> (Data, URLResponse) {
let challenge = try await fetchAssertionChallenge()
let bodyHash = Data(SHA256.hash(data: body)).base64EncodedString()
let clientData = try JSONEncoder().encode(
AppAttestClientData(
challenge: challenge,
method: method,
path: url.path,
bodySHA256: bodyHash
)
)
let assertion = try await generateAssertion(for: clientData)
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(assertion.base64EncodedString(), forHTTPHeaderField: "X-App-Attest-Assertion")
request.setValue(clientData.base64EncodedString(), forHTTPHeaderField: "X-App-Attest-Client-Data")
request.httpBody = body
return try await URLSession.shared.data(for: request)
}
private func fetchAssertionChallenge() async throws -> String {
let url = serverURL.appending(path: "assert/challenge")
let (data, _) = try await URLSession.shared.data(from: url)
return String(decoding: data, as: UTF8.self)
}
}
サーバー側のアサーション検証
リクエストを許可する前に、サーバーで各アサーションの署名、RP ID、カウンター、使い捨てチャレンジ、リクエストとの紐付けを検証する必要があります。完全なアルゴリズムについては、サーバー側のアサーション検証を参照してください。
サーバー検証のガイダンス
アテステーションとアサーションの比較、推奨されるエンドポイント設計、リスク評価など、サーバーアーキテクチャに関する詳しいガイダンスについては、references/device-integrity-patterns.mdを参照してください。
セキュリティ境界
App Attest は、選択したリクエストについてアプリインスタンスの完全性を証明します。ユーザー認証、OAuth/JWT/セッション管理、API トークンの設計、エンタイトルメントやサブスクリプションの認可、TLS、証明書のピン留め、一般的なネットワークセキュリティに代わるものではありません。これらは認証、ネットワーク、またはより広範なセキュリティガイダンスに委ねるものとして扱い、App Attest の検証に成功した後も通常の認証と認可を適用してください。
エラー処理
DeviceCheck の操作から返される DCError コードを処理してください。主なケースは次のとおりです。
.serverUnavailable— 指数バックオフで再試行します.invalidKey— キーがすでにアテステーション済みである、未アテステーションのキーでアサーションが使用された、またはサービスがキーを拒否したことを示します.featureUnsupported—DCDeviceトークンにフォールバックします.invalidInput—clientDataHashまたはkeyIdの形式が正しくありません
attestKey で .serverUnavailable が発生した場合は、同じ keyId と同じ clientDataHash を使用して、後で再試行してください。それ以外のアテステーションエラーの場合は、キー識別子を破棄し、新しいキーを作成してから再試行してください。エラー処理のコード、再試行戦略、拒否されたキーの復旧について詳しくは、references/device-integrity-patterns.mdを参照してください。
一般的なパターン
環境エンタイトルメント
エンタイトルメントファイルで App Attest の環境を設定します。テスト中は development を、App Store ビルドでは production を使用してください。XML、デフォルトのサンドボックス動作、配布時の動作、拡張機能の制限については、環境エンタイトルメントを参照してください。
統合マネージャーのパターン全体、段階的なロールアウトのガイダンス、エラー型の定義については、references/device-integrity-patterns.mdを参照してください。
よくある間違い
- 起動のたびに新しいキーを生成する。 デバイス上のユーザーアカウントごとに一度だけ生成し、
keyIdを永続化して、キーの数を少なく保ちます。 DCDeviceトークンを再利用する。 生成したトークンは一度だけ使用するものとして扱います。サーバーで処理を行うたびに、新しいトークンを生成します。- 未対応のデバイスや拡張機能に対するフォールバックを省略する。 すべてのデバイスや拡張機能の種類がApp Attestに対応しているわけではありません。フォールバックとして、
DCDeviceトークンまたはほかのリスク評価を使用します。 - クライアント側でアテステーションを信頼する。 すべての検証はサーバー上で行う必要があります。
- リクエスト本文だけに署名する。 アサーションのクライアントデータには、一度だけ使用するサーバーチャレンジと、サーバーがアサーションをリクエストに紐付けるのに十分なリクエストコンテキストを含める必要があります。
- 誤ったアテステーションノンスを検証する。 証明書拡張を
SHA256(challenge)単独ではなく、SHA256(authData || SHA256(challenge))と比較します。 - リプレイ攻撃対策を実装しない。 サーバーは、一度だけ使用するチャレンジを検証し、アサーションカウンターを追跡する必要があります。
- 開発環境と本番環境を混同する。 サンドボックスのキーとレシートは本番環境では動作せず、本番環境のキーとレシートはサンドボックスでは動作しません。
DCError.invalidKeyを処理しない。 アテステーションの繰り返し、アテステーションされていないアサーションキー、またはサービスによる拒否がないか確認します。状態が不正だと判明した場合にのみ再生成します。
レビューチェックリスト
-
DCDeviceトークンはサーバー処理ごとに生成し、再利用のためにキャッシュしない - 使用前に
DCAppAttestService.isSupportedを確認し、未対応のデバイスや拡張機能の種類にはフォールバックを用意する - キーは各デバイス上のユーザーアカウントごとに一度だけ生成し、
keyIdはそのアプリのアカウント/デバイスに限って永続化する - 各キーのアテステーションは一度だけ実行し、サーバーに検証済みの公開鍵とレシートを保存する
- サーバーは、アテステーション証明書チェーン、App IDハッシュ、環境の
aaguid、クレデンシャルID、ノンスSHA256(authData || SHA256(challenge))を検証する - アサーションには一度だけ使用するチャレンジとリクエストコンテキストを含め、サーバーは署名、RP ID、カウンター、チャレンジ、リクエストとの紐付けを検証する
- App Attestの検証後も、保護されたエンドポイントでは通常のユーザー認証と権限の認可を引き続き実施する
-
DCErrorのケースを処理する:.serverUnavailableの場合は同じキー/ハッシュでアテステーションを再試行し、不正なキーは破棄して再生成する - App Attestの環境エンタイトルメントと、サンドボックス/本番サーバーのルーティングに一貫性を持たせる
- 段階的なロールアウトを検討し、有効化/無効化のための機能フラグを設ける