Skip to main content

trustpin_attest 7.1.0 copy "trustpin_attest: ^7.1.0" to clipboard
trustpin_attest: ^7.1.0 copied to clipboard

TrustPin app attestation for Flutter: proves to your API that requests come from your genuine app on a genuine device, checked by your own TrustPin verifier.

TrustPin App Attestation for Flutter #

pub package

Prove to your API that a request comes from your genuine app on a genuine device that meets your policy. The device attests once per device key to your own TrustPin verifier, then renews a short-lived token cheaply. The token travels in the TrustPin-Attestation header, and your API or gateway verifies it.

Platform Minimum Evidence
iOS 15 Apple App Attest
macOS 13 Apple App Attest
Android API 25 Android Key Attestation; Play Integrity where Google Play services exist

This package wraps the native TrustPin attestation modules (TrustPinAttestKit on iOS and macOS, cloud.trustpin:kotlin-attest on Android) and works with the trustpin_sdk pinning package, which it requires: the SDK pins its calls to your verifier.

Attestation is not runtime protection: it doesn't detect hooking, debugging, tampering, root or jailbreak. Setup, the policy, deploying the verifier and verifying tokens in your API are covered at docs.trustpin.cloud.

Installation #

dependencies:
  trustpin_sdk: ^7.1.0
  trustpin_attest: ^7.1.0

Both packages must be on the same version. They wrap the native 7.1.x SDKs (TrustPinAttestKit, cloud.trustpin:kotlin-attest).

Flutter 3.44.0 or later; iOS and macOS through Swift Package Manager only. From 7.1.0 the plugin ships no podspec. Flutter 3.44 is the first stable release with Swift Package Manager on by default; if you turned it off, turn it back on:

flutter config --enable-swift-package-manager

Before you write code #

  • The policy: in the TrustPin console, list your app (Team ID and bundle ID, or package name and signing-certificate digests) and publish the policy.
  • Your verifier: deployed and reachable from the app, and its host registered as a domain of your TrustPin project, because verifier calls are pinned. In strict mode an unregistered verifier host fails every call (VERIFIER_UNAVAILABLE) and no header is sent. It must speak contract version 2 or newer (verifier 0.2.2 or later); an older one is refused (VERIFIER_INCOMPATIBLE).
  • iOS and macOS: add the App Attest capability to the Runner target. It adds the entitlement com.apple.developer.devicecheck.appattest-environment: development for builds run from Xcode, production for TestFlight and the App Store.
  • Android with Play Integrity: the Google Cloud project number of the project linked in Play Console (see Configure).

Configure #

Add the attestation keys to the configuration files the pinning setup reads:

iOS, macOS: TrustPin-Info.plist

<key>AttestVerifierURL</key>
<string>https://attest.example.com</string>
<key>AttestedHosts</key>
<array>
    <string>api.example.com</string>
</array>

Android: android/app/src/main/assets/trustpin.json

{
  "attest_verifier_url": "https://attest.example.com",
  "attested_hosts": ["api.example.com"]
}

Add "google_cloud_project_number" only when your policy uses Play Integrity: with a number, the app contacts Google Play services from setup on.

  • The verifier URL must be https:// on a host name, without credentials, query or fragment. IP addresses are refused.
  • Attested hosts: one to fifty host names, each exact or *.example.com for exactly one label. Only these hosts ever receive the header.

Or configure in code with TrustPinAttestConfiguration and setup.

Set up #

Once per launch, after pinning:

import 'package:trustpin_attest/trustpin_attest.dart';
import 'package:trustpin_sdk/trustpin_sdk.dart';

await TrustPin.shared.setupWithNativeBundle();
await TrustPinAttest.shared.setupWithNativeBundle();
await TrustPinAttest.shared.bindToHeader('Authorization');
await TrustPinAttest.shared.prefetch();

setup makes no verifier call: it validates the configuration and loads a device key from an earlier launch. A second call throws ALREADY_INITIALIZED. prefetch() never throws.

Named instances pair with pinning instances of the same id: TrustPinAttest.instance('id') with TrustPin.instance('id'), which must be set up first.

Bind tokens to the user's session #

  • bindToHeader('Authorization') binds each token to the value of that request header.
  • setTokenBinding(value) binds to a fixed value, such as a session id.

Each clears the other. Only a SHA-256 of the value leaves the device. A binding change renews the token with the existing device key; it's not a new attestation.

If your policy has Require token binding on, no token exists until a binding is known: the state is failed with BINDING_REQUIRED and no retry time. That's expected before sign-in, and attestation resumes as soon as a binding is set.

Send the header #

trustpin_sdk's HTTP integrations add the header automatically to HTTPS requests for the attested hosts:

// package:http
final client = TrustPinHttpClient.create();

// Dio: pass the instance so redirects of attested requests are followed
final dio = Dio();
dio.interceptors.add(TrustPinDioInterceptor(dio: dio));
  • Requests are never failed by the SDK: when no token is ready in a short, bounded time, the request goes out without the header, and your API decides.
  • A header the request already carries is never replaced.
  • A redirect never carries the header to another host: the integrations follow redirects of attested GET and HEAD requests themselves, pinning each hop and asking for its own header.

For other HTTP clients:

final headers = await TrustPin.shared.attestationHeaders(
  uri,
  headers: {'Authorization': 'Bearer $session'},
);

and TrustPin.shared.redirectedRequestHeaders(...) for each redirect hop. For non-HTTP transports (gRPC, WebSockets), await TrustPinAttest.shared.getToken() and send it in the TrustPin-Attestation header.

The interceptors check the server's certificate before each request; the request itself goes out on Dart's own connection. Binding the token to the session keeps a token useless outside it.

Sensitive operations: assertions #

For an operation that must come from this device, such as a payment, send an assertion next to the token. It binds the device's attested key to a SHA-256 digest of your choice, typically the request body:

import 'package:crypto/crypto.dart';

final body = utf8.encode(jsonEncode({'amount': '1.00', 'to': 'demo'}));
final headers = {'Authorization': 'Bearer $session'};
headers.addAll(await TrustPinAttest.shared
    .assertionHeaders(Uint8List.fromList(sha256.convert(body).bytes)));
await client.post(url, headers: headers, body: body);
  • assertionHeaders returns both headers, TrustPinAttest.headerName with the token and TrustPinAttest.assertionHeaderName (TrustPin-Assertion) with the assertion. Each call produces a new assertion: one per operation.
  • Your backend passes both headers and its own digest of what it received to your verifier.
  • It throws INVALID_DIGEST when the digest isn't 32 bytes, and the usual codes when no token is available.

Verify the token in your API #

Your API verifies the token against your verifier's JWKS: signature (ES256), type trustpin-attest+jwt, issuer, audience and expiry, the project (tp.prj), the binding, and that it isn't a debug token in production. In monitor mode, a fail result is expected: log tp.reasons and allow. See docs.trustpin.cloud.

Simulators, emulators and debug tokens #

Simulators and emulators can't produce real evidence. Pass a debug token from the build, never from a file:

const debugToken = String.fromEnvironment('TRUSTPIN_DEBUG_TOKEN');
await TrustPinAttest.shared.setupWithNativeBundle(
  debugToken: debugToken.isEmpty ? null : debugToken,
);
flutter run --dart-define=TRUSTPIN_DEBUG_TOKEN=<token>
  • It's used on the iOS simulator and in debuggable Android builds (emulators and devices), never on iOS devices or macOS, and never sent in release builds.
  • Your policy (Allow debug tokens) and your verifier must both allow debug tokens. Their tokens carry tp.env debug, which production APIs refuse.

State, errors and logging #

TrustPinAttest.shared.stateChanges.listen((state) {
  final label = switch (state) {
    TrustPinAttestUnavailable() => 'No token yet',
    TrustPinAttestAttesting() => 'Attesting…',
    TrustPinAttestReady(:final expiresAt) => 'Token ready until $expiresAt',
    TrustPinAttestFailed(:final error, :final retryAt) =>
      'Failed: ${error.code}, next attempt after ${retryAt ?? 'restart'}',
  };
  print(label);
});
  • stateChanges emits the current state first, then every change.
  • events reports what the SDK did, for your monitoring: keys generated or replaced, attestations and renewals with their durations, refusals and waits with their end, unsupported devices, an unreachable verifier, and assertions. Events never contain a token, a key or a device identifier. New event types can appear in a minor release: keep a default case.
TrustPinAttest.shared.events.listen((event) {
  if (event case TrustPinAttestAttestationSucceeded(:final duration)) {
    metrics.record('attestation', duration);
  }
});
  • After a failure no timer runs: nothing is tried before retryAt, and the next request, getToken() or prefetch() after it starts a new attempt. A null retryAt means no new attempt in this launch, until reset() or a restart, except BINDING_REQUIRED, which resumes once a binding is set.
  • Errors are TrustPinAttestException with a stable code:
Code Meaning
INVALID_CONFIGURATION A configuration value was rejected, or the file is missing
ALREADY_INITIALIZED setup was called again
NOT_SET_UP Called before setup
UNSUPPORTED_DEVICE App Attest is unsupported on this device (iOS, macOS)
EVIDENCE_UNAVAILABLE The device can't produce the evidence the policy requires
BINDING_REQUIRED The policy requires a binding and none is set yet
ATTESTATION_FAILED The verifier refused; see reasons
VERIFIER_UNAVAILABLE The verifier couldn't be reached, including when strict pinning refuses its host
VERIFIER_INCOMPATIBLE The verifier is too old for this SDK; update it
INVALID_DIGEST assertionHeaders got a digest that isn't 32 bytes

New codes can appear in a minor release: handle unknown ones.

  • Logging is off by default: setLogLevel(TrustPinAttestLogLevel.debug) in development, and TrustPinAttest.logs to route messages. Logs never contain tokens, key certificates, bound values or debug tokens.
  • reset() discards the device key, so the token's sub changes. It's for support flows, not routine use, and does nothing before setup.

Backups and privacy #

  • Device keys and key certificates are never backed up or synced: a restored app attests again.
  • Evidence and tokens go only to your verifier, nothing to TrustPin.
  • iOS and macOS: the Swift package ships a privacy manifest (no tracking, no collected data).
  • Android: with a Google Cloud project number configured, the app uses the Play Integrity API; declare it in Play Console's Data safety form.

Example #

The sample app has an Attestation tab: setup, a protected call with a token bound to the Authorization header, a sensitive call with an assertion, sign-out and reset, with the SDK's events in the tab's log.

0
likes
140
points
--
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

TrustPin app attestation for Flutter: proves to your API that requests come from your genuine app on a genuine device, checked by your own TrustPin verifier.

Homepage
Repository (GitHub)
View/report issues

Topics

#security #attestation #networking

License

unknown (license)

Dependencies

flutter, plugin_platform_interface, trustpin_sdk

More

Packages that depend on trustpin_attest

Packages that implement trustpin_attest