compliance_integrity 0.1.0-beta.1
compliance_integrity: ^0.1.0-beta.1 copied to clipboard
App and device integrity checks for Flutter - Google Play Integrity on Android and Apple App Attest on iOS, without Firebase.
compliance_integrity #
Beta: 0.1.0-beta.1. APIs may change before 1.0.0. Feedback welcome via GitHub issues.
Note: Not yet verified on physical devices. Play Integrity and App Attest flows are covered by unit tests with mocked platform interfaces only. Please report device results in GitHub issues.
App and device integrity checks for Flutter, without Firebase:
- Android: Google Play Integrity API, standard requests.
- iOS: App Attest (
DCAppAttestService).
One Dart API, typed results, no exceptions. A reference verification server lives in server/ in the repository.
A token proves nothing on the device. It must be sent to your server and verified there. Never make security decisions in the app based on whether a token was obtained.
Install #
flutter pub add compliance_integrity:^0.1.0-beta.1
or in pubspec.yaml:
dependencies:
compliance_integrity: ^0.1.0-beta.1
Quick start #
final integrity = Integrity(
cloudProjectNumber: 123456789012, // Android only, from Google Cloud
auditTrail: auditTrail, // optional, from compliance_core
);
// Optional warm-up at app start or before a sensitive screen.
await integrity.prepare();
// 1. Get a fresh challenge from your server for this request.
final challenge = await api.getChallenge();
// 2. Request a token bound to it.
switch (await integrity.requestToken(challenge)) {
case IntegritySuccess(:final token, :final kind, :final keyId):
// 3. Send it to your server, which decides.
await api.verifyIntegrity(token: token, kind: kind, keyId: keyId, challenge: challenge);
case IntegrityUnsupported() || IntegrityPlayServicesMissing():
// Device cannot attest (simulator, Huawei without Google services, ...).
// Tell the server; it applies your policy (fallback checks, step-up auth, ...).
case IntegrityNetworkError() || IntegrityRateLimited():
// Retry with backoff: 5 s, 10 s, 20 s.
case IntegrityConfigurationError(:final message):
case IntegrityPlatformError(:final message):
// Log and investigate.
}
Why the request hash must come from your server #
The request hash (Android requestHash, iOS clientDataHash) binds a token to one specific request. If the app made it up, an attacker could obtain a valid token once, on a genuine device, and replay it for any later request. A server-issued, single-use challenge with a short lifetime lets the server check that the token was produced for this request, recently, by this app.
- Android: the value is passed unchanged as
requestHash(at most 500 characters). The server comparesrequestDetails.requestHash. With the reference server, pass the challenge itself. Google recommends binding the token to the request content as well, for examplebase64url(SHA-256(challenge || action || payload)); if you do, your server must compute the same value from the request it receives. - iOS: the plugin uses
SHA-256(UTF-8(requestHash))asclientDataHash. The server computes the same value. - Never put personal data in the request hash: Play returns it verbatim in the token.
iOS: attestation and assertions #
App Attest attests a key once and then signs requests with it:
- The first successful
requestTokenreturnsIntegrityTokenKind.appAttestAttestationand akeyId. Your server verifies the attestation and stores the key's public key, bound to the user account. - Later calls return
IntegrityTokenKind.appAttestAssertion. The server verifies the signature with the stored key and checks that the counter increased. - If the server does not know the key (it rejected or lost the attestation; the reference server answers
409 {"ok": false, "action": "reattest"}), callintegrity.reset()and request again; the next request creates and attests a new key. The plugin marks a key as attested as soon as Apple returns the attestation, so this server signal is the only way to learn that the server never stored it. - If Apple rejects a stored key (for example after switching between the development and production environments), the plugin creates and attests a new key automatically. Your server must therefore accept an attestation at any time.
API overview #
| Member | Description |
|---|---|
Integrity({cloudProjectNumber, auditTrail}) |
Entry point. |
isSupported() |
Android: Play Store and Play services installed and enabled. iOS: DCAppAttestService.isSupported. Never throws. A true result does not guarantee a token. |
prepare() |
Android: prepares and caches a standard token provider. iOS: creates a key if none exists. Returns null or an IntegrityFailure. |
requestToken(requestHash) |
Returns an IntegrityResult. |
reset() |
Discards the cached provider (Android) or the stored key (iOS). |
IntegrityResult |
Sealed: IntegritySuccess, IntegrityUnsupported, IntegrityPlayServicesMissing, IntegrityNetworkError, IntegrityRateLimited, IntegrityConfigurationError, IntegrityPlatformError. Failures carry platformCode, message and isRetryable. |
Error mapping #
| Platform error | Result |
|---|---|
Play API_NOT_AVAILABLE (-1), PLAY_STORE_NOT_FOUND (-2), PLAY_SERVICES_NOT_FOUND (-6), CANNOT_BIND_TO_SERVICE (-9), PLAY_STORE_VERSION_OUTDATED (-14), PLAY_SERVICES_VERSION_OUTDATED (-15) |
IntegrityPlayServicesMissing |
Play NETWORK_ERROR (-3), timeout after 60 s |
IntegrityNetworkError |
Play TOO_MANY_REQUESTS (-8) |
IntegrityRateLimited |
Play CLOUD_PROJECT_NUMBER_IS_INVALID (-16), REQUEST_HASH_TOO_LONG (-17), missing project number, empty or too long request hash |
IntegrityConfigurationError |
Play GOOGLE_SERVER_UNAVAILABLE (-12), CLIENT_TRANSIENT_ERROR (-18), INTEGRITY_TOKEN_PROVIDER_INVALID (-19, after one automatic re-prepare), INTERNAL_ERROR (-100) |
IntegrityPlatformError with isRetryable: true |
Play APP_NOT_INSTALLED (-5), APP_UID_MISMATCH (-7), others |
IntegrityPlatformError |
App Attest unsupported device, DCError.featureUnsupported |
IntegrityUnsupported |
DCError.serverUnavailable |
IntegrityNetworkError (the same key is kept, as Apple recommends) |
DCError.invalidInput |
IntegrityConfigurationError |
DCError.invalidKey, DCError.unknownSystemFailure, Keychain errors |
IntegrityPlatformError |
| No native implementation (web, desktop) | IntegrityUnsupported |
Audit events #
integrity_check_passed when a token was obtained, integrity_check_failed otherwise. Metadata: result, plus kind or platform_code. Never the token, key id or request hash. "Passed" only means a token was produced; the server's verdict is what counts.
Setup #
Google Play Console and Google Cloud (Android) #
- In Google Cloud Console, create or pick a project and enable the Play Integrity API.
- Note the project number (not the project id). Pass it as
cloudProjectNumber. It is not a secret, but keep it in build configuration (for example--dart-define), not hard-coded across environments. - In Play Console > your app > Protected with Play (App integrity) > Play Integrity API, click Link Cloud project and select that project.
- For server verification, create a service account in the same Cloud project and give the server its credentials through Application Default Credentials (
GOOGLE_APPLICATION_CREDENTIALS). Never ship the key in the app or commit it. - Optional, in Play Console: opt in to extra verdicts (
MEETS_STRONG_INTEGRITY, device activity, app access risk, Play Protect) and configure responses. - Quota: 10,000 requests per day across all installs by default. Request an increase in Play Console before launch if needed.
- Test on a device with Google Play, with the app installed from Play (internal testing track) for
PLAY_RECOGNIZEDandLICENSEDverdicts. Sideloaded debug builds are typicallyUNRECOGNIZED_VERSION.
Android build facts: com.google.android.play:integrity:1.6.0 (requires API 23; this plugin's minSdk is 24), kotlinx-coroutines-android (required by the Pigeon-generated code). The plugin manifest adds <queries> for com.android.vending and com.google.android.gms so isSupported() can see them on Android 11+. No permissions are added.
Apple Developer account and Xcode (iOS) #
- In Certificates, Identifiers & Profiles > Identifiers, enable App Attest for your App ID. Regenerate provisioning profiles.
- In Xcode, add the App Attest capability to the Runner target. This writes the entitlement
com.apple.developer.devicecheck.appattest-environment=developmenttoRunner.entitlements:<key>com.apple.developer.devicecheck.appattest-environment</key> <string>development</string> developmentuses Apple's sandbox. TestFlight, App Store and Enterprise builds always use production, whatever the entitlement says. Keys and receipts from the two environments are not interchangeable; configure your server per environment.- Your server needs your Team ID and bundle identifier to check the App ID hash.
- Minimum iOS version: 15.0 (App Attest itself needs iOS 14). On the simulator and on Macs, App Attest reports unsupported.
- Roll out gradually: Apple asks to keep
attestKeybelow roughly 100 requests per second across all installs. - No Info.plist changes are needed. The plugin ships a privacy manifest declaring no tracking, no collected data and no required-reason APIs. Your app's own privacy manifest and App Store privacy answers must reflect what your server does with the tokens.
Manual real-device test checklist #
Run the example app (example/) and your server.
| Device | Expected |
|---|---|
| Android phone with Google Play, app from an internal testing track | IntegritySuccess(playIntegrityStandard); server verdict passes (PLAY_RECOGNIZED, MEETS_DEVICE_INTEGRITY, LICENSED). |
| Same, sideloaded debug build | Token obtained; server reports app_recognition and possibly licensing failures. |
| Same, airplane mode | IntegrityNetworkError. |
Same, wrong cloudProjectNumber |
IntegrityConfigurationError (platformCode -16). |
| Android without Google Play services (for example a Huawei device or an AOSP emulator image) | isSupported() == false; IntegrityPlayServicesMissing (-1, -2 or -6). |
| Android emulator with Google Play image | Token obtained; device verdict usually fails on the server. |
| iPhone, development build with App Attest capability | First request: appAttestAttestation with keyId; server /v1/ios/attest passes in development mode. Second request: appAttestAssertion; /v1/ios/assert passes. |
| iPhone, after deleting and reinstalling the app | A new key is attested (the old Keychain item is not usable). |
| iPhone, TestFlight build | Attestation passes only with the server in production mode. |
| iOS simulator | isSupported() == false; IntegrityUnsupported. |
| iPhone, airplane mode during first attestation | IntegrityNetworkError; the next request reuses the same key. |
Limitations #
- Classic Play Integrity requests and Play remediation dialogs (
showDialog) are not wrapped yet. - Apple's fraud risk metric (receipt refresh) is not implemented; the server stores the receipt for you to use.
- On Android,
isSupported()is a heuristic (installed and enabled packages). The request can still fail. - The plugin stores one App Attest key per app install. If several users share a device, bind keys to accounts on your server and call
reset()on account switch. - Token lifetime is not documented by Google. The reference server accepts tokens up to 2 minutes old.
Disclaimer #
Compliance Kit helps developers implement privacy and security controls. It does not by itself make an application compliant with GDPR, the CRA, EN 18031, OWASP MASVS or any other regulation or standard. Compliance depends on the whole system and must be assessed separately.
License #
MIT. See LICENSE.