> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/device-integrity-app-shielding-flutter.md).

# Device Integrity and App Shielding in Flutter

How I separate root and jailbreak signals, app shielding, Play Integrity, App Attest, and backend policy into testable Flutter contracts

## Result

In the Flutter source I reviewed, “device security” appears in three very different forms:

* The root lifecycle still calls a helper whose name suggests an unsafe-device check, but the entire detector inside it has been commented out.
* Android and iOS link several security libraries shipped with provider SDKs and bundle environment-specific native resources.
* Fastlane can send an APK, AAB, or IPA through a vendor shielding tool and re-sign it before upload.

None of these parts proves that a device is safe. Warning UI does not prove that a detector is running. A linked framework does not prove that it protects the whole app. A successful shielding tool exit does not prove that the uploader received the intended artifact.

I split the problem into two independent chains.

The first chain protects the provenance of the release binary:

```
source revision + locked dependencies + policy version
                         │
                         ▼
                   app build
                         │
                         ▼
          shielding / obfuscation if policy requires it
                         │
                         ▼
                      re-sign
                         │
                         ▼
       verify signature + identity + entitlements + hash
                         │
                         ▼
            install/smoke that exact artifact
                         │
                         ▼
             upload exact verified artifact
```

The second chain protects a sensitive action at runtime:

```
sensitive action ──► backend one-time challenge
       │                         │
       │                         ▼
       ├── local heuristic ─► advisory signals
       │
       └── OS attestation(request binding/challenge)
                                 │
                                 ▼
                   opaque proof sent to backend
                                 │
                                 ▼
      verify freshness + app identity + signature + replay
                                 │
                                 ▼
              allow / limit / step-up / retry / deny
```

The two chains meet at backend and release policy boundaries, not at an `isRooted` Boolean in Flutter.

The resulting architecture has these properties:

* A local root/jailbreak detector emits only a `DeviceRiskSignal` with a kind, quality, and lifetime.
* Play Integrity/App Attest sit behind a platform adapter; Flutter does not decode and trust proof produced inside its own process.
* The backend issues a challenge, validates request binding, app identity, freshness/replay, and returns a tiered outcome.
* Shielding is an explicit `disabled`, `optional`, or `required` build policy, not an ambiguous environment flag.
* When the policy is `required`, the pipeline fails closed if the expected shielded artifact is unavailable.
* The uploader accepts only an artifact whose signature, identity, entitlements, hash, and smoke test have been verified.
* Challenges, proofs, request hashes, raw detector output, and signing material never enter logs.

This is a design derived from source review, not a claim that the current app is production-ready. In this research pass, I did not run the vendor shielding tool/server, a Gradle bundle, an Xcode archive, signing, a store upload, rooted/jailbroken devices, hook/instrumentation tests, Play Integrity, App Attest, or backend replay tests.

## Problem

### A Called Helper Does Not Mean the Detector Is Running

The root lifecycle in the case study still calls a device-check helper during startup. Its body, however, contains only historical comments:

* The old intent was to read two signals similar to “real device” and “jailbroken.”
* The detector import is no longer active.
* The corresponding dependency does not exist in `pubspec.yaml`, the dependency lock, or package config.
* The warning dialog and “already warned” Redux state remain active, but a scoped search found no active callsite that opens the dialog.

The source therefore proves only a dormant shell: the call remains, but the detector does not run. It proves neither that the current device is safe nor that it is unsafe.

I also do not take the latest source of a similarly named package from pub.dev and assign it to the app. Without a version in the lockfile, there is no exact package source to audit.

Simply uncommenting the block might not compile because the old dependency/import no longer exists. Even if it did compile, an `async void` function called fire-and-forget during startup would still lack a timeout, cancellation generation, and lifecycle guard after `await`.

This is the first lesson: a security control needs evidence for its implementation, wiring, configuration, runtime behavior, and tests. A function name, warning copy, or state field cannot replace that evidence.

### Root and Jailbreak Detection Is Inherently Bypassable

A local detector commonly observes several kinds of indicators:

* Files or packages associated with root/jailbreak tools.
* System properties, bootloader state, or mount permissions.
* Processes, debuggers, hook frameworks, or injected libraries.
* Emulators/simulators, virtual environments, or custom ROMs.

These signals are useful for defense in depth, but they run inside a process the attacker can patch. A hook can force a method to return `false`; a repack can remove the blocking branch; a root-hiding tool can conceal the artifact the detector expects.

OWASP describes root detection as inherently bypassable and recommends combining signals, applying proportional responses, and letting server policy decide from risk and user context. Detectors can also produce false positives on custom ROMs, enterprise test devices, or security research environments.

This code is therefore not authorization:

```dart
if (!isRooted) {
  await transferMoney();
}
```

An attacker only has to patch the local branch. Meanwhile, a legitimate app instance on an unusual device could be blocked completely.

A local signal should answer a narrower question: “What indicator did this process just observe, with what quality and freshness?” The backend then combines it with app attestation, account/session context, action value, authentication strength, and abuse history.

### A Provider Security Library Is Not Whole-App Integrity

The Android case study adds a vendored provider AAR directory to the app dependency graph. Release and debug select different security-library variants. Some archives contain native libraries and ProGuard metadata.

The iOS provider wrapper also links a security XCFramework and copies an environment-specific resource into the app bundle. The Pod lock proves that the wrapper is linked. It does not prove that the framework protects the entire Runner process or emits a verdict to the backend.

There is no clear app-level Dart/Kotlin/Swift callsite to that security API. This is still not enough to conclude that the framework is inactive: a provider SDK may call it internally or it may use a native initializer. The accurate answer is **runtime behavior is unverified**.

A bundled resource must be treated as extractable configuration. Even if a vendor names it like protected key data, a binary resource inside an APK/IPA is not a secret credential. Real secrets belong on the server or in the release system's secret store.

The source also contains a portability gap: the build script constructs an environment name with different letter case from the tracked directory. A default macOS filesystem can hide the mismatch, while a case-sensitive runner can fail. Correct syntax does not prove every filesystem contract is correct.

The public architecture therefore calls this only a `ProviderSecurityBoundary`. It does not use a real framework, archive, resource, configuration, or provider name.

### R8 and Shielding Protect Different Boundaries

The Android release configuration in the source sets both resource shrinking and minification to `false`. The scoped tree has no app ProGuard/R8 rules or `proguardFiles` callsite.

That does not mean the binary definitely receives no transformations: Fastlane may still send the artifact through vendor shielding. But I cannot write “R8 protects the app” when R8 is not enabled, and shielding is not another name for R8.

| Mechanism                      | Primary boundary                                                   | Does not provide by itself                                        |
| ------------------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------- |
| R8 shrinking/minification      | Android bytecode/resources in the build                            | Device verdict, backend authority, runtime anti-tamper guarantee  |
| Symbol stripping/obfuscation   | Raises the cost of static analysis according to platform/tool      | Request freshness or user legitimacy                              |
| Vendor shielding/RASP          | Artifact post-processing and runtime hardening according to policy | Correct upload artifact, correct signature/entitlements, clean OS |
| Local root/jailbreak heuristic | Compromise indicators observed in the client                       | Cryptographic app identity or authorization                       |
| Remote attestation             | App/platform proof bound to a request/challenge                    | Elimination of all fraud or account abuse by itself               |

OWASP MASVS-RESILIENCE treats obfuscation, anti-debugging, anti-tamper, and RASP as defense-in-depth measures that raise attack cost. They do not replace strong cryptography, server validation, or sound security architecture.

### Optional Shielding Is Not a Release Invariant

Fastlane in the case study chooses whether to run shielding through an environment flag. Several lanes have this shape:

```
build artifact
     │
     ├─ flag on  ─► vendor shield ─► re-sign ─► publish
     │
     └─ flag off ─────────────────────────────► publish
```

CI calls those lanes but does not define the flag in YAML. Its value may come from a project variable or an external runner; it may also be absent. Reading the Fastfile alone cannot prove that a real release was shielded.

Android APK distribution reconstructs an expected output name. If the file is missing while the flag is on, it can warn and fall back to the original APK. The store AAB flow uses the returned path directly and stops when the helper fails. Two different behaviors sit under the same “shield enabled” label.

iOS usually stops when the signed shielded output is missing, even though some messages mention fallback. One test-distribution lane also builds one environment but passes another environment into the shielding helper. A beta-distribution lane does not call shielding.

These are source-verified configuration/path gaps, not confirmed runtime incidents. I did not inspect real jobs or artifacts, so I cannot conclude that the wrong binary was released. The current pipeline nevertheless does not make the “required shielding” invariant auditable.

The policy must be explicit:

```
disabled  = artifact is not shielded; provenance records that fact
optional  = approved rollout may skip it; provenance records the outcome
required  = missing tool/output/verification/smoke test stops the lane
```

No state should be inferred merely because an environment variable is absent.

### Tool Exit and File Existence Are Not Enough Assurance

The Android shielding flow in the source performs sensible mechanical steps:

```
APK -> vendor post-process -> find wrapped APK
    -> zipalign -> apksigner sign -> upload path

AAB -> vendor post-process -> find wrapped AAB
    -> jarsigner sign -> upload path
```

The iOS flow similarly unsigns/post-processes an IPA, finds the output, and uses a signing helper to sign it again.

The scoped pipeline search, however, found none of these gates:

* `apksigner verify` after signing an APK.
* `jarsigner -verify` and bundle validation for an AAB.
* `codesign --verify` after signing an IPA.
* Comparison of app identity/entitlements against an expected allowlist.
* A hash/provenance record connecting source input to the output actually uploaded.
* Installation and smoke testing of the exact post-shielding artifact.

A tool exit code of `0` only says the process reported no error under its own contract. File existence only says that bytes exist at a path. Neither proves that the app installs, the signature chain is correct, all capabilities remain intact, or the uploader holds the bytes that were tested.

Artifact discovery by filename pattern or “latest file by mtime” can also select an old or wrong-matrix output when cleanup and naming are not airtight. A safer design makes the helper return a typed `VerifiedArtifact`; the uploader accepts that exact object and does not scan the filesystem again.

The iOS signing, archive, and TestFlight pipeline is a separate boundary covered in detail below. SEC-06 adds shielding and post-shield verification rather than repeating the entire certificate/profile setup.

{% content-ref url="/pages/AfcP6VFNqQRoYmvOMT1Z" %}
[Fastlane iOS, Code Signing, and TestFlight](/flutter/my-flutter/quality-delivery/fastlane-ios-code-signing-testflight.md)
{% endcontent-ref %}

### Signing Material Must Not Enter Source or Verbose Logs

The source review found tracked signing/service material and literal values in one development signing path in Fastlane. Some helpers interpolate password/alias values into shell commands with logging enabled.

I do not include any value, path, alias, certificate identity, or configuration name in this article. The public finding points only to remediation:

1. Rotate or revoke potentially exposed material according to the incident playbook.
2. Move secrets to a protected CI secret store or signing service.
3. Materialize temporary files with narrow permissions on an ephemeral runner.
4. Disable command echo and avoid command-line passwords when the tool supports stdin or a file descriptor.
5. Clean up in `ensure/finally`, including shield/sign/upload failures.
6. Log only an artifact ID/hash prefix and typed stage outcome, never a secret or machine path.

Deleting a file in a new commit does not remove it from Git history. History cleanup is a separately approved workstream; documentation must not become another place that reproduces a secret.

### Screenshot Privacy Is Not Device Integrity

An app can hide app-switcher snapshots or request a secure window while still running in an instrumented process. Conversely, a device that reaches an attestation tier does not mean every sensitive surface is protected from screenshots.

These are different policies. SEC-05 separates screenshot detection, recording state, capture prevention, and background privacy; SEC-06 uses them only as runtime risk/surface controls and does not call them attestation.

{% content-ref url="/pages/qjE6UMIKIbqVTsDVJlzD" %}
[Screenshot Detection and Background Privacy in Flutter](/flutter/my-flutter/security-observability/screenshot-background-privacy-flutter.md)
{% endcontent-ref %}

### Attestation Is Not a Plugin That Returns `isSafe`

The scoped source contains no Play Integrity, SafetyNet, DeviceCheck, App Attest, attestation adapter, or server verdict contract. An eKYC flow has a field named `nonce`, but a provider liveness nonce is not device attestation.

This absence supports only the conclusion that the app repository has no client integration/evidence. The backend is out of scope, so I do not conclude that the whole system lacks integrity controls.

A Play Integrity standard request follows a server-centric flow:

1. The app prepares the token provider before the sensitive moment.
2. When an action starts, the app hashes the relevant request values into `requestHash`.
3. The app receives an opaque integrity token and sends the token with the request to its backend.
4. The backend sends the token to Google Play for decoding and verification.
5. The backend checks request details/hash, app recognition, and licensing before using the device verdict.
6. The backend combines the verdict with risk context and returns an outcome.

Google recommends against caching a verdict for reuse and recommends tiered enforcement. Classic requests use a nonce with server-side replay logic; standard requests are the primary example in this article.

Apple App Attest likewise does not let a client trust itself:

1. The app creates a platform-backed key and asks the server for a one-time challenge.
2. The app attests the key and sends the attestation object and key ID to the server.
3. The server validates the certificate chain, challenge-derived hash, public key, App ID, and environment.
4. The server stores the public key/receipt under a user-device binding.
5. For a later sensitive request, the app signs a hash containing the request and challenge.
6. The server validates the assertion signature, App ID, challenge, and monotonic counter; the counter/replay update must be atomic.

Apple explicitly says App Attest cannot definitively identify a compromised OS. It is one risk input. DeviceCheck is a different device-scoped service; it is not a jailbreak detector and does not replace App Attest assertions.

## Solution

### Start with a Threat Model and Ownership

I do not choose a detector or tool before answering four questions:

1. Which asset needs protection: credentials, transactions, premium data, or anti-abuse rules?
2. Can the attacker patch the client, hook runtime behavior, proxy proofs, or take over a legitimate account?
3. Which actions are sensitive enough to accept extra latency and false-positive risk?
4. What outcome is appropriate for unsupported capability, outages, or low confidence?

I then divide ownership as follows:

| Layer                  | Owns                                                                                | Must not own                                  |
| ---------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------- |
| Flutter app            | Action context, capability, local advisory signals, platform calls, outcome UX      | Deciding that its own process is trustworthy  |
| Local detector         | Typed heuristics, quality, observation time, expiry, errors                         | Authorization or a clean-OS guarantee         |
| Shielding/RASP         | Artifact post-processing/hardening according to policy                              | App/device attestation or correct upload path |
| Android platform       | Opaque Play Integrity proof and documented verdict capability                       | Business decisions inside the client          |
| Apple platform         | App Attest key/attestation/assertion and DeviceCheck service                        | Guaranteed detection of a compromised OS      |
| Backend                | Challenges, proof verification, freshness/replay, account/action risk, outcomes     | Trusting a raw Boolean from the client        |
| Release pipeline       | Provenance, shielding, re-signing, verification, hashing, smoke tests, exact upload | Treating tool success as final assurance      |
| Security/product owner | Threat model, rollout, false-positive budget, exceptions                            | One deny-all policy for every action          |

### Give Local Signals Quality and Expiry

```dart
enum IntegritySignalKind {
  rootHeuristic,
  jailbreakHeuristic,
  debugger,
  hookIndicator,
  emulator,
}

enum SignalQuality { low, medium, high, unknown }

final class DeviceRiskSignal {
  const DeviceRiskSignal({
    required this.kind,
    required this.quality,
    required this.observedAt,
    required this.expiresAt,
  });

  final IntegritySignalKind kind;
  final SignalQuality quality;
  final DateTime observedAt;
  final DateTime expiresAt;

  bool isFresh(DateTime now) => now.isBefore(expiresAt);
}
```

This model intentionally contains no raw filesystem path, process name, package list, vendor status code, or device identifier. Detector details needed for security debugging belong in a restricted local diagnostic channel with separate consent and retention, not in default analytics.

The detector reports capability separately:

```dart
enum IntegrityCapability {
  supported,
  unsupported,
  temporarilyUnavailable,
}

abstract interface class LocalRiskAdapter {
  Future<IntegrityCapability> capability();

  Future<List<DeviceRiskSignal>> collect();
}
```

`unsupported`, timeout, and failure must not be normalized to an empty list meaning “safe.” An empty list only means that a successful collection observed no indicator during that collection.

### Separate Platform Proof from the Backend Decision

```dart
final class IntegrityChallenge {
  const IntegrityChallenge({
    required this.id,
    required this.value,
    required this.expiresAt,
  });

  final String id;
  final String value;
  final DateTime expiresAt;
}

sealed class PlatformAttestation {
  const PlatformAttestation();
}

final class AndroidIntegrityToken extends PlatformAttestation {
  const AndroidIntegrityToken(this.value);
  final String value;
}

final class AppleIntegrityAssertion extends PlatformAttestation {
  const AppleIntegrityAssertion({
    required this.keyId,
    required this.value,
  });

  final String keyId;
  final String value;
}

abstract interface class PlatformIntegrityAdapter {
  Future<IntegrityCapability> capability();

  Future<PlatformAttestation> attest({
    required IntegrityChallenge challenge,
    required List<int> canonicalRequestHash,
  });
}
```

`PlatformAttestation` is opaque proof. The app does not parse it into `isSafe`. The adapter only calls the platform API under the correct lifecycle and returns raw proof through a protected channel to the backend.

An Android adapter can include the challenge in canonical request data before producing `requestHash`. The backend must recompute the expected binding from the request it actually receives; it cannot trust a client-supplied hash in isolation.

An Apple adapter keeps the key ID as a non-secret handle. The private key belongs to the platform. The key ID, challenge, and assertion still must not be logged because they are protocol material that can assist correlation or replay analysis.

Unavailable capability gets a separate evidence type instead of pretending to be an attestation with a sentinel challenge:

```dart
sealed class IntegrityEvidence {
  const IntegrityEvidence();
}

final class BoundAttestation extends IntegrityEvidence {
  const BoundAttestation({
    required this.challengeId,
    required this.proof,
  });

  final String challengeId;
  final PlatformAttestation proof;
}

final class UnavailableAttestation extends IntegrityEvidence {
  const UnavailableAttestation(this.capability);
  final IntegrityCapability capability;
}
```

### Return Tiered Outcomes from the Backend

```dart
enum IntegrityOutcome { allow, limit, stepUp, retry, deny }

enum IntegrityReason {
  accepted,
  unsupported,
  serviceUnavailable,
  staleChallenge,
  replayDetected,
  appNotRecognized,
  elevatedRisk,
}

final class IntegrityDecision {
  const IntegrityDecision({
    required this.outcome,
    required this.reason,
    required this.expiresAt,
  });

  final IntegrityOutcome outcome;
  final IntegrityReason reason;
  final DateTime expiresAt;
}

abstract interface class IntegrityBackend {
  Future<IntegrityChallenge> issueChallenge({required String actionClass});

  Future<IntegrityDecision> verify({
    required String actionClass,
    required IntegrityEvidence evidence,
    required IntegrityCapability localCapability,
    required List<DeviceRiskSignal> advisorySignals,
  });
}
```

`actionClass` is an allowlisted category such as `signIn`, `viewPublicData`, or `highValueAction`; it contains no business payload. The backend still receives the real request through a separate authenticated API contract and binds proof to the account/session/action/body digest.

`IntegrityReason` is also an allowlist. The client needs enough information to render retry, step-up, or support UX, but it does not need raw verdict details that help an attacker tune a bypass.

The backend must:

* Consume a challenge/counter atomically.
* Check expiry before policy evaluation.
* Validate app identity/request binding before evaluating device tier.
* Never cache proof for a different action.
* Separate development, TestFlight/internal, and production attestation environments.
* Return a short-lived decision bound to account/session/action.
* Treat local signals as advisory input; a “clean” local signal cannot override a cryptographic or app-identity failure.

### Add Typed Timeouts and Stale-Generation Guards

Every async boundary has three risks: a hung operation, a callback after disposal, and an old result applied to a newer action. I use per-stage timeouts and a generation check after every `await`.

```dart
import 'dart:async';

enum IntegrityStage {
  lifecycle,
  platformCapability,
  localCapability,
  localSignals,
  challenge,
  platformAttestation,
  backendVerification,
}

enum IntegrityFailureKind {
  timeout,
  cancelled,
  staleChallenge,
  staleDecision,
  operationFailed,
}

final class IntegrityGateException implements Exception {
  const IntegrityGateException(this.stage, this.kind);

  final IntegrityStage stage;
  final IntegrityFailureKind kind;
}

final class IntegrityTimeouts {
  const IntegrityTimeouts({
    required this.capability,
    required this.localSignals,
    required this.challenge,
    required this.attestation,
    required this.backend,
  });

  final Duration capability;
  final Duration localSignals;
  final Duration challenge;
  final Duration attestation;
  final Duration backend;

  Duration forStage(IntegrityStage stage) => switch (stage) {
        IntegrityStage.platformCapability => capability,
        IntegrityStage.localCapability => capability,
        IntegrityStage.localSignals => localSignals,
        IntegrityStage.challenge => challenge,
        IntegrityStage.platformAttestation => attestation,
        IntegrityStage.backendVerification => backend,
        IntegrityStage.lifecycle => Duration.zero,
      };
}
```

The gate does not log exception objects or protocol material:

```dart
final class IntegrityGate {
  IntegrityGate({
    required PlatformIntegrityAdapter platform,
    required LocalRiskAdapter localRisk,
    required IntegrityBackend backend,
    required IntegrityTimeouts timeouts,
  })  : _platform = platform,
        _localRisk = localRisk,
        _backend = backend,
        _timeouts = timeouts;

  final PlatformIntegrityAdapter _platform;
  final LocalRiskAdapter _localRisk;
  final IntegrityBackend _backend;
  final IntegrityTimeouts _timeouts;

  bool _running = false;
  bool _disposed = false;
  int _generation = 0;

  Future<IntegrityDecision> assess({
    required String actionClass,
    required List<int> canonicalRequestHash,
  }) async {
    if (_disposed) {
      throw const IntegrityGateException(
        IntegrityStage.lifecycle,
        IntegrityFailureKind.cancelled,
      );
    }
    if (_running) {
      throw StateError('Integrity assessment already running');
    }

    _running = true;
    final generation = ++_generation;

    try {
      final capability = await _atStage(
        stage: IntegrityStage.platformCapability,
        generation: generation,
        run: _platform.capability,
      );
      _ensureCurrent(generation);

      final localCapability = await _atStage(
        stage: IntegrityStage.localCapability,
        generation: generation,
        run: _localRisk.capability,
      );
      _ensureCurrent(generation);

      final signals = localCapability == IntegrityCapability.supported
          ? await _atStage(
              stage: IntegrityStage.localSignals,
              generation: generation,
              run: _localRisk.collect,
            )
          : const <DeviceRiskSignal>[];
      _ensureCurrent(generation);
      final now = DateTime.now();
      final freshSignals = signals
          .where((signal) => signal.isFresh(now))
          .toList(growable: false);

      if (capability != IntegrityCapability.supported) {
        final decision = await _atStage(
          stage: IntegrityStage.backendVerification,
          generation: generation,
          run: () => _backend.verify(
            actionClass: actionClass,
            evidence: UnavailableAttestation(capability),
            localCapability: localCapability,
            advisorySignals: freshSignals,
          ),
        );
        _ensureCurrent(generation);
        _ensureFreshDecision(decision);
        return decision;
      }

      final challenge = await _atStage(
        stage: IntegrityStage.challenge,
        generation: generation,
        run: () => _backend.issueChallenge(actionClass: actionClass),
      );
      _ensureCurrent(generation);

      if (!DateTime.now().isBefore(challenge.expiresAt)) {
        throw const IntegrityGateException(
          IntegrityStage.challenge,
          IntegrityFailureKind.staleChallenge,
        );
      }

      final proof = await _atStage(
        stage: IntegrityStage.platformAttestation,
        generation: generation,
        run: () => _platform.attest(
          challenge: challenge,
          canonicalRequestHash: canonicalRequestHash,
        ),
      );
      _ensureCurrent(generation);

      if (!DateTime.now().isBefore(challenge.expiresAt)) {
        throw const IntegrityGateException(
          IntegrityStage.challenge,
          IntegrityFailureKind.staleChallenge,
        );
      }

      final decision = await _atStage(
        stage: IntegrityStage.backendVerification,
        generation: generation,
        run: () => _backend.verify(
          actionClass: actionClass,
          evidence: BoundAttestation(
            challengeId: challenge.id,
            proof: proof,
          ),
          localCapability: localCapability,
          advisorySignals: freshSignals,
        ),
      );
      _ensureCurrent(generation);
      _ensureFreshDecision(decision);
      return decision;
    } finally {
      _running = false;
    }
  }

  Future<T> _atStage<T>({
    required IntegrityStage stage,
    required int generation,
    required Future<T> Function() run,
  }) async {
    try {
      final value = await run().timeout(_timeouts.forStage(stage));
      _ensureCurrent(generation);
      return value;
    } on TimeoutException {
      _ensureCurrent(generation);
      throw IntegrityGateException(stage, IntegrityFailureKind.timeout);
    } on IntegrityGateException {
      rethrow;
    } catch (_) {
      _ensureCurrent(generation);
      throw IntegrityGateException(
        stage,
        IntegrityFailureKind.operationFailed,
      );
    }
  }

  void _ensureCurrent(int generation) {
    if (_disposed || generation != _generation) {
      throw const IntegrityGateException(
        IntegrityStage.lifecycle,
        IntegrityFailureKind.cancelled,
      );
    }
  }

  void _ensureFreshDecision(IntegrityDecision decision) {
    if (!DateTime.now().isBefore(decision.expiresAt)) {
      throw const IntegrityGateException(
        IntegrityStage.backendVerification,
        IntegrityFailureKind.staleDecision,
      );
    }
  }

  void dispose() {
    if (_disposed) return;
    _disposed = true;
    _generation++;
  }
}
```

Important properties of the sample:

* `_atStage` applies timeouts to platform capability, local capability/detector calls, the challenge, platform attestation, and backend verification.
* `_ensureCurrent` runs immediately after every `await`, both inside the helper and in orchestration continuations.
* `dispose()` increments the generation; an underlying native call may continue, but its late result is ignored.
* Single-flight prevents two sensitive actions from sharing or mixing a challenge/proof.
* Unavailable platform capability still reaches the backend as `UnavailableAttestation`; local capability is sent separately so an empty signal list does not mean “safe.” The client does not allow the action by itself.
* A backend timeout or operation failure surfaces as a typed exception. The caller may only render retry/unavailable UX or enter a server-designed step-up flow; it cannot open the action locally.
* Raw errors, challenge values, proofs, key IDs, request hashes, and raw signals never appear in logs.

A production implementation should add an injectable clock, a request canonicalizer shared contractually with the backend, a cancellation primitive when the SDK supports one, and metrics limited to `stage`, `failureKind`, platform/version bucket, action class, and latency bucket.

### Bind Android Attestation to the Request and Do Not Cache Verdicts

For a Play Integrity standard request, prepare the token provider before the critical path and refresh it according to platform guidance. When a user starts a sensitive action:

```
backend challenge + canonical action body
                  │
                  ▼
           deterministic encoding
                  │
                  ▼
             SHA-256 requestHash
                  │
                  ▼
        Play Integrity standard token
                  │
                  ▼
       backend decodes/verifies via Google
                  │
       ┌──────────┼───────────┐
       ▼          ▼           ▼
 requestDetails  appIntegrity  device/account signals
       │          │           │
       └──────────┼───────────┘
                  ▼
          tiered backend outcome
```

The backend recomputes the hash from the request/challenge it receives instead of comparing only with a client-declared hash. It checks request details, app recognition, and licensing before device tier. Proof is not cached for the next action; standard requests are a better fit than caching classic verdicts when frequent checks are needed.

Classic requests need only a short explanation here: the nonce needs randomness, a TTL, and server-side replay storage. Do not mix a classic nonce and standard `requestHash` in one implementation.

### Separate App Attest Enrollment from Assertions

The Apple flow has two phases.

Enrollment:

```
app checks isSupported
        │
        ▼
generate platform-backed key -> key ID
        │
        ▼
backend one-time challenge
        │
        ▼
attest key with challenge hash
        │
        ▼
backend verifies Apple chain + App ID + public key + environment
        │
        ▼
store public key/receipt bound to user-device context
```

Sensitive assertion:

```
backend challenge + request body
        │
        ▼
clientDataHash -> platform assertion
        │
        ▼
backend verifies signature + App ID + challenge + increasing counter
        │
        ▼
atomic counter update + policy outcome
```

The key ID is not the private key, but it is still a protocol identifier that needs redaction. Do not reuse an ambiguous key binding across users. Development and production records must remain separate; an unsupported device follows compatibility policy and must not be treated as attested.

App Attest increases the server's confidence that a request comes from a legitimate app instance. Apple still says no single policy eliminates all fraud and App Attest cannot definitively identify a compromised OS.

### Return a Typed Artifact Instead of a Guessed Filename

The minimum build contract is:

```
ShieldRequest
  sourceArtifactPath
  sourceSha256
  platform
  environmentClass
  policyVersion
  signingProfileRef       # handle, not a secret

VerifiedArtifact
  outputPath
  outputSha256
  platform
  policyVersion
  sourceSha256
  signatureSummary
  entitlementSummary
  smokeStatus
  verificationStatus
```

`outputPath` flows from the shielding helper to verification and then the uploader through one pipeline data flow. Do not glob by mtime. Do not reconstruct the name. Do not fall back when the policy is `required`.

The state machine is:

```
built
  ├─ policy disabled ─► verifiedUnshielded
  └─ policy optional/required
          │
          ▼
       shielding
          ├─ failed + optional ─► verifiedUnshielded + recorded exception
          ├─ failed + required ─► releaseFailed
          └─ output
               │
               ▼
             resign
               │
               ▼
   verify signature/identity/entitlements/hash
               ├─ mismatch ─► releaseFailed
               └─ pass
                    │
                    ▼
           install + critical smoke
                    ├─ fail ─► releaseFailed
                    └─ pass ─► upload exact path
```

If `optional` allows an unshielded artifact, that exception must be an approved policy outcome recorded in provenance. It must not be a silent fallback.

### Gate the Android Artifact

An APK can begin with these static checks:

```bash
set -euo pipefail

input_apk="$1"
shielded_apk="$2"

test -f "$input_apk"
test -f "$shielded_apk"
test "$input_apk" != "$shielded_apk"

zipalign -c -P 16 -v 4 "$shielded_apk"
apksigner verify --verbose --print-certs "$shielded_apk"
shasum -a 256 "$input_apk" "$shielded_apk"
```

This is only a static artifact gate. The pipeline must still:

* Compare the certificate digest with an allowlist from a protected manifest without printing the full identity in public logs.
* Install that exact `shielded_apk` across the supported API/ABI matrix.
* Launch the app and smoke-test login/attestation bootstrap and critical flows.
* Record the output hash in provenance and pass the exact path/hash to the uploader.

An AAB needs `jarsigner -verify` and appropriate bundle validation, followed by checks for the Play App Signing/upload-key boundary. An AAB is not installed directly like an APK; internally distributed or Play-generated APKs belong in the test plan.

### Gate the iOS Artifact

After extracting an IPA into an ephemeral directory, the static gate can begin as follows:

```bash
set -euo pipefail

app_bundle="$1"
entitlements_output="$2"

test -d "$app_bundle"
codesign --verify --deep --strict --verbose=2 "$app_bundle"
codesign --display --entitlements :- "$app_bundle" > "$entitlements_output"
plutil -lint "$entitlements_output"
```

`--deep` does not replace a verification policy for every nested framework and extension. The pipeline must enumerate nested code, compare allowlisted application identity/team/profile/entitlements, hash the exact IPA bytes, and then install/smoke-test them on an appropriate device.

In particular, shielding/re-signing must not remove associated domains, push, keychain groups, the App Attest environment, or extension entitlements. The uploader accepts the IPA whose hash was just tested.

### Keep Only Typed Outcomes in Telemetry

One possible allowlist is:

| Field           | Public example        | Never record                               |
| --------------- | --------------------- | ------------------------------------------ |
| `stage`         | `platformAttestation` | Method arguments/raw exception             |
| `failureKind`   | `timeout`             | Vendor status/body                         |
| `capability`    | `unsupported`         | Device ID/key ID                           |
| `actionClass`   | `highValueAction`     | Specific transaction payload/value         |
| `signalKind`    | `hookIndicator`       | File/process/package name                  |
| `quality`       | `medium`              | Raw score/model output                     |
| `decision`      | `stepUp`              | Token/assertion/challenge                  |
| `latencyBucket` | `1s-3s`               | Exact timestamps that can correlate a user |
| `artifactStage` | `signatureVerified`   | Machine path/signing alias                 |

Telemetry does not prove enforcement. Backend audit records and release provenance are separate stores from an analytics dashboard; access, retention, and redaction policies require separate approval.

### Compare Android and iOS Capabilities Explicitly

| Contract                | Android                                                                           | iOS                                                                      |
| ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Local signal            | Root/emulator/debug/hook heuristics; bypass and false positives are both possible | Jailbreak/debug/hook heuristics with the same client-trust limitation    |
| Primary app attestation | Play Integrity standard requests                                                  | App Attest attestation + assertion                                       |
| Device-scoped service   | Play verdict/risk labels under the Play contract                                  | DeviceCheck is a separate service with device-scoped state               |
| Request/replay binding  | `requestHash`; classic uses a nonce/server replay logic                           | One-time challenge + assertion signature + monotonic counter             |
| App identity            | App recognition/signing certificate/licensing checks                              | App ID/RP ID, Apple chain, key/environment checks                        |
| Obfuscation             | R8/minify/resource shrink is a separate build boundary                            | Symbol stripping/obfuscation depends on the tool; there is no R8         |
| Shielded artifact       | APK zipalign/re-sign/verify; AAB sign/verify/store boundary                       | IPA unsign/post-process/re-sign; nested code/entitlement verification    |
| Unsupported/outage      | Tiered backend outcome; do not cache proof for long periods                       | Compatibility check, retry/limit/step-up; separate dev/prod environments |
| Assurance               | Play-installed build + rooted/emulator/hook/device matrix                         | Development/TestFlight/App Store + jailbreak/hook/device matrix          |

### Complete the Test Matrix Before Production Assurance

| Group             | Case                                          | Required assertion                                                   |
| ----------------- | --------------------------------------------- | -------------------------------------------------------------------- |
| Source audit      | Detector body commented/dependency absent     | Status is `disabled`, not `safe`                                     |
| Local adapter     | supported/unsupported/error/timeout           | Typed state; failure never maps to trusted                           |
| Local adapter     | Hook forces detector to return false          | Backend does not allow from a clean signal alone                     |
| Local adapter     | Custom ROM/enterprise/test device             | Proportional outcome and a support/appeal path                       |
| Gate              | Double tap/concurrent action                  | Single-flight; no challenge/proof mixing                             |
| Gate              | Dispose after each `await`                    | Old generation cannot change UI/action                               |
| Gate              | Timeout at each stage                         | Correct `stage` + `failureKind`, no raw-error leak                   |
| Android           | Request body changes after hashing            | Backend rejects binding mismatch                                     |
| Android           | Token stale/replayed/app unrecognized         | Reject before evaluating device verdict                              |
| Android           | Play outage/quota/network failure             | Retry/limit by policy; no long-lived cache                           |
| Apple enrollment  | Challenge reused                              | Backend rejects                                                      |
| Apple enrollment  | Key bound to wrong user/environment           | Backend rejects or segregates record                                 |
| Apple assertion   | Signature/App ID/challenge wrong              | Backend rejects                                                      |
| Apple assertion   | Counter does not increase                     | Atomic replay rejection                                              |
| Build policy      | `required` but tool/output missing            | Release stops; no fallback                                           |
| Build matrix      | Environment/config/signing reference mismatch | Static matrix validation stops the lane                              |
| Android APK       | Shielded output                               | Align, signature, install, launch, and critical smoke pass           |
| Android AAB       | Shielded output                               | Signature/bundle validation and Play/internal install pass           |
| iOS IPA           | Re-signed output                              | Nested signature, identity, and entitlement allowlist pass           |
| iOS resource      | Environment selection                         | Passes on a case-sensitive runner                                    |
| Provenance        | Old output has a newer mtime                  | Uploader still uses the typed exact artifact                         |
| Secret handling   | Sign/tool failure in verbose mode             | Logs contain no secret/path/alias; temp files are cleaned            |
| Runtime hardening | Root/jailbreak/hook/debug/emulator            | Behavior matches policy; false-positive rate is measured             |
| Regression        | Unshielded compared with shielded             | Startup, auth, deep links, push, provider flow, and performance pass |

Unit tests with fake adapters prove only the coordinator/state machine. Production assurance also needs:

* Backend cryptographic verification test vectors.
* Concurrent replay/atomic counter tests.
* Store/internal distribution artifact tests.
* Rooted/jailbroken/hook/instrumentation device-lab coverage.
* Vendor tool upgrade and rollback drills.
* Outage, quota, clock skew, reinstall, account switch, and key reset cases.

### Roll Out from Telemetry Instead of Enabling Deny-All Immediately

A reasonable rollout can move through four phases:

```
observe
  collect redacted capability/outcome metrics, no enforcement
        │
        ▼
warn
  explain remediation, measure false positives/support load
        │
        ▼
step-up / limit
  protect selected high-risk actions
        │
        ▼
deny narrowly
  only high-confidence conditions with an approved exception path
```

Google also recommends collecting telemetry before changing enforcement and using multiple tiers. The rollout needs a backend kill switch, but the switch must not make the client authoritative or silently disable artifact verification.

### Verified Versions and Scope

* Flutter source baseline: 3.41.2.
* Dart SDK constraint: 3.11.0 up to but excluding 4.0.0.
* Android app: min SDK 24, compile/target SDK 36.
* iOS app: deployment target 15.0.
* Static checks run: Ruby syntax for the Fastfile, Bash syntax for the iOS resource script, JSON parsing for environment configurations, and plist/entitlement/XCFramework plist linting.

All static checks passed in the researched source snapshot. They did not run shielding, signing, archiving, or runtime security behavior.

The scoped repository has no focused test for the dormant detector, warning decision, shielding helper, artifact provenance, Play Integrity, or App Attest. When Flutter, AGP/Gradle, Xcode, target OS versions, vendor tools, or attestation SDKs change, rerun the complete build/artifact/device/backend matrix.

### References

* [Android — Overview of the Play Integrity API](https://developer.android.com/google/play/integrity/overview)
* [Android — Make a standard Play Integrity API request](https://developer.android.com/google/play/integrity/standard)
* [Apple — DeviceCheck](https://developer.apple.com/documentation/devicecheck)
* [Apple — Establishing your app’s integrity](https://developer.apple.com/documentation/DeviceCheck/establishing-your-app-s-integrity)
* [Apple — Validating apps that connect to your server](https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server)
* [OWASP MASVS — Resilience Against Reverse Engineering and Tampering](https://mas.owasp.org/MASVS/11-MASVS-RESILIENCE/)
* [OWASP MASTG — Implementing Root Detection](https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0030/)

## Conclusion

Device integrity does not begin with an `isRooted` Boolean, and app shielding does not end with a “tool succeeded” message. A local detector is an advisory signal running in a patchable client. Shielding and obfuscation raise attack cost according to a threat model but do not create backend authority. Play Integrity and App Attest add value only when proof is bound to a request/challenge, verified on the server, and protected against replay.

Similarly, an artifact is ready for release only after post-processing, re-signing, signature/identity/entitlement verification, hashing, and smoke testing of those exact bytes. When shielding is `required`, the pipeline must stop at any failed gate; silently falling back to the original artifact destroys the invariant.

The source review proves only a dormant local detector, native provider boundaries, and an optional shielding pipeline. It does not prove runtime tamper policy, app/device attestation, backend verification, or production security assurance. Going further requires secret-handling remediation, artifact provenance, backend replay tests, and an Android/iOS adversarial device matrix.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
