appattest_flutter 0.4.0
appattest_flutter: ^0.4.0 copied to clipboard
Flutter bridge for AppAttest — App-Attest-gated secret delivery for iOS.
appattest_flutter #
Flutter bridge for AppAttest — App-Attest-gated secret delivery for iOS.
Ships in lockstep with the Swift SDK (current:
v0.4.0).
Platform support #
- iOS 17+ — full support. Uses Apple's
DCAppAttestServicevia the native AppAttest Swift SDK. - Android / other — not supported. App Attest is iOS-only; the plugin registers no implementation on other platforms.
Install #
# pubspec.yaml
dependencies:
appattest_flutter: ^0.4.0
Then:
flutter pub get
cd ios && pod install && cd ..
The iOS side depends on the AppAttest pod (the core Swift SDK), wired
automatically through the plugin's podspec.
Quick start #
import 'package:appattest_flutter/appattest_flutter.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// `release` is required — see "Buckets and columns" below.
AppAttest.start(release: ReleaseBucket.production);
runApp(const MyApp());
}
// Anywhere:
await AppAttest.waitForReady();
final key = await AppAttest.secret('OPENAI_API_KEY'); // String?
final all = await AppAttest.allSecrets(); // Map<String, String>
start(release:) is fire-and-forget: the first launch attests the device once
(persists across launches), then syncs secrets; later launches hydrate
from the Keychain and re-sync in the background. Foreground re-entry
re-syncs automatically — your app does no lifecycle wiring.
State #
final s = await AppAttest.getState(); // AppAttestState(name, error?)
final sub = AppAttest.stateStream.listen((s) {
debugPrint('appattest: ${s.name}');
});
// later: sub.cancel();
AppAttestStateName: initializing, attesting, syncing, ready,
subscriptionRequired, creditsRequired, unavailable. The non-ready
terminal states carry state.error.
End-user-facing apps: show a generic "temporarily unavailable" notice
for the non-ready terminal states. Developer / staff builds: log the
full error (including actionUrl) so the developer knows whether to
subscribe, top up, or investigate.
Refresh & recovery #
await AppAttest.retry(); // re-run the sync (no re-attestation)
await AppAttest.invalidateBundle(); // drop the cached bundle, force a fresh sync
await AppAttest.reset(); // full wipe; next start(release:) re-attests
retry() recovers from transient failures. invalidateBundle() forces
fresh secret bytes when you don't want to wait for the next rotation
pickup. reset() is the nuclear option, for sign-out / data-clearing flows.
Debug mode (simulator, tests, CI) #
The simulator can't produce a real App Attest attestation. Use local stubs:
import 'package:flutter/foundation.dart';
if (kDebugMode) {
await AppAttest.setDebugMode(DebugMode.local, {
'OPENAI_API_KEY': 'sk-test-stub',
});
}
AppAttest.start(release: ReleaseBucket.staging);
DebugMode has a single case, local; pass null to return to real
attestation. The native debug surface is #if DEBUG-gated — physically
absent from Release builds, which always run real attestation; calling it
there throws debug_mode_release_blocked.
Dev builds on real devices don't need debug mode — they attest for real and read the sandbox column (below).
Buckets and columns #
Two independent axes. Keeping them apart is the whole model.
1. Which server bucket you declare — you choose it, explicitly.
release is a required argument on AppAttest.start(release:). There
is no default, and no inference from kDebugMode or build flavor. The
bucket is exactly what you pass:
AppAttest.start(release: ReleaseBucket.production); // shipping build
AppAttest.start(release: ReleaseBucket.staging); // pre-ship verification
ReleaseBucket.staging and ReleaseBucket.production are two
functionally-identical, separately-keyed, metered buckets. Neither is
free.
2. Which secrets column Apple puts you in — the AAGUID decides, not you.
Apple's App Attest AAGUID is a build-time property stamped into every attestation, and you cannot forge it:
- Development-signed builds (Xcode → device) → sandbox column.
- Distribution builds carrying the production App Attest entitlement (TestFlight, App Store) → production column.
The two axes are orthogonal: both servers have both columns. Edge
resolves your declared bucket against the AAGUID. A development-signed
build declaring ReleaseBucket.production is rejected with a loud
403 bucket_not_permitted — never silently re-routed.
For last-mile verification of production secrets before submitting to the App Store, use TestFlight: it carries the real production AAGUID, so it reads the production column. There is no debug-build path to the production column.
Error handling #
Failures throw AppAttestError (code and message, plus subscribeUrl /
topupUrl / actionUrl on the billing cases):
try {
await AppAttest.waitForReady();
} on AppAttestError catch (e) {
if (e.code == ErrorCode.subscriptionRequired) {
debugPrint('project needs a subscription: ${e.actionUrl}');
}
}
| Code | Meaning |
|---|---|
subscription_required |
Project subscription not active (subscribeUrl). |
credits_required |
Allowance exhausted and balance empty (topupUrl). |
attestation_rejected |
Apple or AppAttest rejected this install — terminal until reinstall. |
service_unavailable |
Temporary service condition; retryable (the SDK backs off automatically). |
network |
Device-side transport failure; retryable. |
debug_mode_release_blocked |
setDebugMode called in a Release build. |
invalid_argument |
Malformed call input. |
(Compare via the ErrorCode constants — ErrorCode.subscriptionRequired
etc.; the values are the snake_case strings above.)
License #
MIT © 2026 Bault LLC. See LICENSE.