> 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/smart-otp-totp-flutter-architecture.md).

# Smart OTP/TOTP Architecture in Flutter

How I separate enrollment, secrets, time sources, and server verification so TOTP in Flutter has clear replay protection and failure paths

## Outcome

In my app, generating a six-digit code does not mean the Smart OTP flow is secure. The harder parts are how the secret is provisioned to the device, how the app handles clock drift, and how the server rejects a code that has already been used.

I organize the flow into two phases:

1. Enrollment creates a new authenticator, verifies its first proof, and only then stores the secret locally.
2. Each operation requires a local PIN or biometric check before the app generates a TOTP; the server still verifies the code, prevents replay, and decides whether to approve the operation.

```
Enrollment
──────────
Authenticated session
      │
      ▼
Server creates pending enrollment + unique secret
      │
      ▼
App generates TOTP proof + sends enrollment challenge
      │
      ▼
Server activates authenticator
      │
      ▼
App awaits writing the secret to Secure Storage

Runtime
───────
Local PIN/biometric ─► unlocks authenticator use
                                  │
secret + synchronized time ───────┤
                                  ▼
                                TOTP
                                  │ + pending operation
                                  ▼
                      Server verifies + consumes + approves
```

The most important outcome is that every layer has one responsibility:

* The Flutter app generates a code from the secret and time step.
* Local authentication only unlocks the authenticator on the device.
* The server checks the code, clock window, replay, rate limits, and operation state.
* Secure Storage holds the secret while the app is not using it.

The source I verified already has two-phase enrollment, states for enrollment on the current device or another device, a local PIN/biometric gate, a TOTP generator, and a server callback. This article keeps those patterns while explicitly identifying what the client source does not prove, such as server-side anti-replay and atomic verification.

## Problem

The name “Smart OTP” can hide several different concepts inside one flow. In this article, I distinguish them as follows:

| Concept              | Role                                                                                          |
| -------------------- | --------------------------------------------------------------------------------------------- |
| OTP                  | The general name for a one-time password.                                                     |
| HOTP                 | An OTP based on HMAC and an incrementing counter, defined by RFC 4226.                        |
| TOTP                 | HOTP with a time step as the moving factor, defined by RFC 6238.                              |
| Enrollment challenge | An independent verification step when binding a new authenticator; it is not the TOTP secret. |
| Smart OTP            | A product UX label, not the name of a separate cryptographic standard.                        |

An OTP sent by SMS, email, or another channel does not automatically become a TOTP. TOTP exists only when the authenticator and verifier share a secret, algorithm, and time policy.

### Generating the Correct Code Is Only the Smallest Part

TOTP can be summarized as follows:

```
timeStep = floor((unixTime - T0) / period)
TOTP     = Truncate(HMAC(secret, timeStep))
```

However, this formula does not answer the more important questions:

* Where is the secret created, and how is it provisioned to the device?
* If the server activates the authenticator but the app fails to write Secure Storage, which state is correct?
* If the device clock is wrong or NTP times out, should the app generate a code?
* Is a code submitted twice within the same time step accepted twice?
* Does the code confirm the exact operation the user just reviewed?

Without explicit contracts for these questions, the generator can return the correct code while the overall flow still contains replay or recovery gaps.

### The Client Clock Is Not the Authority

The source I verified adds an NTP offset to the device time and uses the result for both generation and countdown. When NTP fails, the offset returns to `0`, so the app continues with the device clock.

This improves availability but has two limits:

* The user or operating system can make the device clock drift.
* An NTP offset only helps the authenticator stay close to server time; it does not let the client decide whether a code is still valid.

The source also has initialization paths that start synchronization without `await`, while another runtime call site waits for synchronization to complete. I therefore do not treat calling NTP at app startup as proof that the time source is ready when the user opens the generator.

### “Time-Based” Does Not Automatically Mean One-Time

Within the same time step, the same secret and policy generate the same TOTP. If the verifier only compares the code and returns success, an attacker can replay that code before it expires.

RFC 6238 requires the verifier not to accept a second attempt after an OTP has been successfully validated. NIST also requires a verifier to accept an OTP only once during its validity period and to rate-limit short outputs.

Replay protection is therefore server-side state. A client countdown does not solve this problem.

### TOTP Is Not a Transaction Signature

The source sends a pending operation identifier together with the TOTP to the server. This identifier helps the server find the correct operation and check its owner, state, and expiry. However, the TOTP does not contain a hash of the operation contents.

In other words, `operation + TOTP` is a verification request with context. It is not a transaction signature and is not phishing-resistant. If you need cryptographic binding to an amount, recipient, or operation contents, you need a different challenge/signature protocol.

### Threat Model for This Article

The approach in this article reduces the following risks:

* A secret is unnecessarily placed in widget state, logs, or analytics.
* The app reports successful enrollment even though local persistence failed.
* The generator and countdown use different periods.
* The app reuses an old snapshot after returning from the background.
* The server accepts a TOTP that has already succeeded.
* Two concurrent requests consume the same OTP or transition the same operation.

This article assumes that the app–server channel is protected and that the server creates secrets using a cryptographically secure random source. It does not protect against root/jailbreak, runtime instrumentation, malware, or a hooked app process. TOTP is also not phishing-resistant and does not replace passkeys or cryptographic transaction signing.

## Solution

### Separate Seven Boundaries

I do not let a widget read the secret, call NTP, generate a code, and then decide by itself that an operation succeeded. The flow is split into these boundaries:

| Boundary              | Responsibility                                                               |
| --------------------- | ---------------------------------------------------------------------------- |
| Enrollment service    | Creates pending enrollment and activates an authenticator after valid proof. |
| `TotpVault`           | Writes, reads, and deletes the active secret.                                |
| `TimeSource`          | Returns the current instant and synchronization quality.                     |
| `TotpGenerator`       | Generates the code and countdown from the same secret, time, and policy.     |
| Local activation gate | Requires a PIN or biometric check before using the authenticator.            |
| Operation client      | Sends the TOTP with pending operation context.                               |
| Server verifier       | Verifies, rate-limits, consumes replay state, and transitions the operation. |

When a step fails, its boundary returns a meaningful failure. The widget only renders the outcome; it does not infer secret, clock, or server state.

### Pin the Dependency and TOTP Policy

The resolved dependencies in the source I verified are:

```yaml
dependencies:
  otp: 3.2.0
  ntp: 2.0.0
```

The `otp` package supports both HOTP and TOTP. I use the API that returns a `String` to preserve leading zeros, and I explicitly pass digits, period, algorithm, and compatibility options instead of relying on package defaults.

The current source contract uses HMAC-SHA-1, a six-digit output, compatibility mode for the Base32 secret, and a period from configuration with a 30-second default. This is the compatibility contract between the current client and verifier, not a reason for either side to change the algorithm or period independently.

First, I group the parameters into a validated policy:

```dart
enum TotpHash { sha1, sha256, sha512 }

final class TotpPolicy {
  const TotpPolicy._({
    required this.period,
    required this.digits,
    required this.hash,
    required this.googleCompatible,
  });

  factory TotpPolicy({
    required Duration period,
    required int digits,
    required TotpHash hash,
    bool googleCompatible = true,
  }) {
    if (period.inSeconds <= 0) {
      throw ArgumentError.value(period, 'period');
    }
    if (digits < 6 || digits > 8) {
      throw ArgumentError.value(digits, 'digits');
    }

    return TotpPolicy._(
      period: period,
      digits: digits,
      hash: hash,
      googleCompatible: googleCompatible,
    );
  }

  final Duration period;
  final int digits;
  final TotpHash hash;
  final bool googleCompatible;
}
```

The current source generation code falls back to a default period when configuration is invalid, but the countdown uses the raw period directly in a modulo operation. The two paths can therefore use different effective policies, and the countdown can even fail when the period is `0`.

I replace scattered fallbacks with one `TotpPolicy`. The generator, countdown, enrollment proof, tests, and verifier contract must all use this validated policy.

Do not change the hash or period on the client alone. An algorithm migration must be versioned per authenticator and rolled out together with the verifier.

### Represent Time Source Quality

Instead of returning only a timestamp, my time source also returns synchronization quality:

```dart
enum ClockQuality {
  synchronized,
  deviceFallback,
}

final class ClockSample {
  const ClockSample({
    required this.instant,
    required this.quality,
    required this.measuredAt,
  });

  final DateTime instant;
  final ClockQuality quality;
  final DateTime measuredAt;
}

abstract interface class TimeSource {
  Future<ClockSample> now();
}
```

A minimal implementation based on an NTP offset can look like this:

```dart
import 'package:ntp/ntp.dart';

final class NtpOffsetTimeSource implements TimeSource {
  NtpOffsetTimeSource({
    this.timeout = const Duration(seconds: 5),
    this.maxAge = const Duration(minutes: 15),
  });

  final Duration timeout;
  final Duration maxAge;

  int _offsetMilliseconds = 0;
  DateTime? _measuredAt;
  ClockQuality _quality = ClockQuality.deviceFallback;
  Future<void>? _refreshing;

  Future<void> refresh() {
    final running = _refreshing;
    if (running != null) return running;

    final future = _refresh();
    _refreshing = future;
    return future.whenComplete(() {
      if (identical(_refreshing, future)) _refreshing = null;
    });
  }

  Future<void> _refresh() async {
    final local = DateTime.now();
    try {
      _offsetMilliseconds = await NTP
          .getNtpOffset(localTime: local)
          .timeout(timeout);
      _measuredAt = DateTime.now();
      _quality = ClockQuality.synchronized;
    } catch (_) {
      _offsetMilliseconds = 0;
      _measuredAt = DateTime.now();
      _quality = ClockQuality.deviceFallback;
    }
  }

  @override
  Future<ClockSample> now() async {
    final measuredAt = _measuredAt;
    if (measuredAt == null || DateTime.now().difference(measuredAt) > maxAge) {
      await refresh();
    }

    final currentMeasuredAt = _measuredAt ?? DateTime.now();

    return ClockSample(
      instant: DateTime.now()
          .add(Duration(milliseconds: _offsetMilliseconds))
          .toUtc(),
      quality: _quality,
      measuredAt: currentMeasuredAt,
    );
  }
}
```

This example preserves the availability behavior of the source: when NTP fails, the app can continue with the device clock. The difference is that the fallback remains visible; the caller receives `deviceFallback` and can choose the appropriate UX.

For higher-assurance operations, you can fail closed when the clock is not synchronized. If you allow fallback, the server must not trust a client timestamp or widen its validation window just because the app reports an unsynchronized clock.

Traditional NTP is not an authorization source either. It only helps the prover stay closer to server time; the verifier still uses the server clock.

### Generate the Code and Countdown from the Same Clock Sample

I map the public policy to the `otp` package in one place:

```dart
import 'package:otp/otp.dart';

Algorithm _toAlgorithm(TotpHash hash) {
  return switch (hash) {
    TotpHash.sha1 => Algorithm.SHA1,
    TotpHash.sha256 => Algorithm.SHA256,
    TotpHash.sha512 => Algorithm.SHA512,
  };
}

final class TotpSnapshot {
  const TotpSnapshot({
    required this.code,
    required this.validFor,
    required this.clockQuality,
  });

  final String code;
  final Duration validFor;
  final ClockQuality clockQuality;
}

final class TotpGenerator {
  TotpGenerator({
    required TimeSource timeSource,
    required TotpPolicy policy,
  })  : _timeSource = timeSource,
        _policy = policy;

  final TimeSource _timeSource;
  final TotpPolicy _policy;

  Future<TotpSnapshot> current(String base32Secret) async {
    final clock = await _timeSource.now();
    final milliseconds = clock.instant.millisecondsSinceEpoch;
    final periodSeconds = _policy.period.inSeconds;

    final code = OTP.generateTOTPCodeString(
      base32Secret,
      milliseconds,
      length: _policy.digits,
      interval: periodSeconds,
      algorithm: _toAlgorithm(_policy.hash),
      isGoogle: _policy.googleCompatible,
    );

    final currentSeconds = milliseconds ~/ 1000;
    final remainingSeconds = periodSeconds - (currentSeconds % periodSeconds);

    return TotpSnapshot(
      code: code,
      validFor: Duration(seconds: remainingSeconds),
      clockQuality: clock.quality,
    );
  }
}
```

The code and `validFor` are calculated from the same `ClockSample`. If `now()` is called twice on opposite sides of a time-step boundary, the UI can show a new code with an old countdown.

When the app resumes, the clock offset changes, or the countdown reaches a boundary, I call `current()` again instead of continuing to decrement old state. The countdown is UX; the server still decides whether the code is valid when the request arrives.

### Keep the Secret Out of Widget State

The source generator currently holds the secret in screen state to generate the next code. This works, but it widens the secret's lifetime in memory and makes route/state objects another place where debugging or telemetry could expose it.

In the public example, the widget only receives a snapshot:

```dart
abstract interface class TotpVault {
  Future<String?> readActiveSecret();
  Future<void> writeActiveSecret(String secret);
  Future<void> deleteActiveSecret();
}

final class TotpService {
  TotpService({
    required TotpVault vault,
    required TotpGenerator generator,
  })  : _vault = vault,
        _generator = generator;

  final TotpVault _vault;
  final TotpGenerator _generator;

  Future<TotpSnapshot> current() async {
    final secret = await _vault.readActiveSecret();
    if (secret == null || secret.isEmpty) {
      throw StateError('TOTP authenticator is not available');
    }

    return _generator.current(secret);
  }
}
```

`TotpVault` is a boundary, not a wrapper around plain preferences. Its implementation must use appropriate storage, scope the secret to the authenticator/account, and never log keys or values.

A Dart `String` does not let me guarantee memory zeroization. The practical goal is therefore to minimize how long the secret lives and how many places can see it, not to claim that it disappears from RAM immediately after generation.

### Enrollment Is a Two-Phase State Machine

The source I verified performs four steps in order:

1. Ask the server to provision a secret.
2. Generate the first TOTP from that secret.
3. Send an independent enrollment challenge together with the TOTP proof and device context.
4. Start persisting the local secret and activation state only after server success.

I keep this order in the public flow and add a failure path for local persistence:

```
notEnrolled
    │ begin(session + step-up)
    ▼
pendingServer
    │ provision secret through protected channel
    ▼
pendingClient
    │ prove possession with TOTP + enrollment challenge
    ▼
activeServer
    │ await local Secure Storage write
    ├── success ─► activeBoth
    └── failure ─► revoke/rollback or recoveryRequired
```

Dart-like pseudocode for the orchestration:

```
Future<void> enroll({required String stepUpCode}) async {
  final pending = await enrollmentApi.begin();
  final proof = await pendingGenerator.current(pending.secret);

  final activeReference = await enrollmentApi.activate(
    enrollmentReference: pending.reference,
    stepUpCode: stepUpCode,
    totpProof: proof.code,
  );

  try {
    await vault.writeActiveSecret(pending.secret);
  } catch (_) {
    await enrollmentApi.revoke(activeReference);
    rethrow;
  }
}
```

The types in this example are neutral abstractions. `begin()` must run inside an authenticated session; pending enrollment needs a TTL and single-use challenge; the secret must be unique to each authenticator and travel through a protected channel.

If the server activates the authenticator but the local write fails, the app must not report completed enrollment. The current source call site starts the write after server success but does not `await` it before navigation. The public example waits for the write and revokes or enters recovery instead of hiding divergent state.

When changing devices, the server needs to bind the new authenticator and invalidate the old one according to policy. A server-side “enrolled” Boolean does not prove that the current device has an active secret.

### PIN and Biometrics Are Only a Local Activation Gate

The runtime source reads the local PIN, may open the biometric system prompt, and only then reads the secret and generates a code. I keep the local gate before `TotpService.current()`:

```
User requests confirmation
        │
        ▼
Local activation gate
  ├── cancel/fail ─► stop, do not read the secret
  └── success
        │
        ▼
TotpService.current()
        │
        ▼
OperationClient.submit(code)
```

Local success does not verify the TOTP with the server. It also does not automatically bind the secret to biometrics. If an implementation only receives a Boolean and then reads the secret from storage, a compromised app process can still bypass that boundary.

The biometric article explains `local_auth` policy, fallback, and limitations in detail. I do not repeat system prompt and error mapping here.

{% content-ref url="/pages/svjjp8ZWjKE9gh0H7k7o" %}
[Biometric Authentication with local\_auth](/flutter/my-flutter/security-observability/biometric-authentication-local-auth.md)
{% endcontent-ref %}

The TOTP secret needs appropriate persistence and failure handling. The Secure Storage article owns the Keychain/Keystore details and the write gate for storage that is not ready.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

### Verify and Consume on the Server in One Atomic Boundary

The Flutter source only proves that the client sends a pending operation and TOTP to the server and waits for a response. It does not show me the verifier implementation. The following is therefore a requirement for the complete system, not confirmed backend behavior.

```
verifyAndApprove(user, operation, otp):
  require authenticated session
  require operation belongs to user
  require operation is pending and not expired
  enforce rate limit(user, authenticator, operation)

  for step in boundedWindow(serverNow):
    expected = totp(secretForAuthenticator, step, policy)
    if constantTimeEqual(expected, otp):
      atomically:
        require (authenticator, step) not consumed
        mark (authenticator, step) consumed
        transition operation pending -> approved
      return approved

  record sanitized failure
  return rejected
```

This boundary needs to preserve these invariants:

* The verifier uses the server clock and does not treat a client timestamp as authoritative.
* The clock window is only large enough for expected drift, network delay, and user entry time.
* A wider window creates a larger attack window.
* An OTP that has succeeded must not be accepted again during its validity period.
* The replay check and operation transition must be atomic so two concurrent requests cannot both succeed.
* Short codes must be rate-limited on the server.
* Expected codes must be compared with an appropriate constant-time primitive.
* Logs must not contain OTPs, secrets, or enrollment payloads.
* Approve and reject are separate idempotent transitions with their own ownership and expiry checks.

If the product wants to reuse the same TOTP for multiple operations within one time step, that changes the threat model. For high-value operations, I keep single-use semantics. If strong binding to operation contents is required, I move to a challenge/signature protocol instead of weakening TOTP replay rules.

### Handle Lifecycle and Re-Enrollment

TOTP uses wall-clock time, while the UI timer is only a rendering mechanism. A timer can pause while the app is in the background. I therefore handle these events at the service boundary:

* App resume: obtain a new clock sample and rebuild the snapshot.
* NTP offset refresh: discard the old snapshot and recalculate the code/countdown.
* Missing secret while the server still reports active: open recovery or re-enrollment instead of generating from an empty string.
* Local secret remains after server revocation: delete it after confirming server state.
* User changes devices: bind a new authenticator and invalidate the old one according to policy.
* User disables the authenticator: revoke it on the server or follow the recovery protocol, then `await` deletion of the local secret.

The source has a flow that clears the PIN, secret, and registration metadata, but the current cleanup call site also does not wait for its write to complete. The public implementation needs to surface partial failure instead of reporting success as soon as deletion starts.

### Verify the Algorithm with Public Vectors

I do not use a secret or expected code taken from an internal test in this article. RFC 6238 provides public test vectors for SHA-1, SHA-256, and SHA-512.

For example, this is the public SHA-1 vector at Unix time 59 seconds:

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:otp/otp.dart';

void main() {
  test('matches the public RFC 6238 SHA-1 vector', () {
    const publicRfcSecret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';

    final code = OTP.generateTOTPCodeString(
      publicRfcSecret,
      59000,
      length: 8,
      interval: 30,
      algorithm: Algorithm.SHA1,
      isGoogle: true,
    );

    expect(code, '94287082');
  });
}
```

This is a public RFC fixture, not a production secret. I add these test groups:

* Every hash/digits/period combination in the verifier contract has a corresponding vector.
* Codes preserve leading zeros because the generator returns a `String`.
* An invalid period is rejected when `TotpPolicy` is created.
* The code and countdown use the same clock sample at a boundary.
* NTP success, timeout, device fallback, and refresh after offset expiry.
* App resume after multiple time steps does not reuse an old snapshot.
* A failed enrollment challenge does not persist an active secret.
* Server activation followed by local write failure enters rollback or recovery.
* Cancelling the local gate does not read the secret or call the operation service.
* A double tap creates only one in-flight submission.

The verifier needs its own tests:

* The current step is accepted only once.
* A duplicate for the same authenticator/time step is rejected as replay.
* Previous or next steps are accepted only within the bounded window.
* Of two concurrent requests with the same OTP, only one consumes successfully.
* An operation with the wrong owner, an expired operation, or a completed operation is rejected.
* Request races cannot bypass rate limiting.
* OTPs and secrets never appear in logs, traces, or error responses.

### Android and iOS Test Matrix

TOTP generation is shared Dart code, but storage, biometrics, and lifecycle behavior still need tests on both platforms:

| Case                                   | Android         | iOS             | Expected result                                     |
| -------------------------------------- | --------------- | --------------- | --------------------------------------------------- |
| New enrollment                         | Physical device | Physical device | Secret persists after server success                |
| Secure Storage write failure           | Fault injection | Fault injection | App does not report normal active state             |
| PIN/biometric cancel                   | Yes             | Yes             | TOTP is not read or sent                            |
| Background across a time-step boundary | Yes             | Yes             | Snapshot is recalculated on resume                  |
| Wrong device clock with NTP available  | Yes             | Yes             | Offset is applied                                   |
| NTP timeout or offline                 | Yes             | Yes             | Fallback or fail-closed behavior matches policy     |
| Slow network across a boundary         | Yes             | Yes             | Server window remains bounded and retry UX is clear |
| Re-enrollment                          | Two devices     | Two devices     | Old authenticator is revoked according to policy    |
| Concurrent submission                  | Yes             | Yes             | Only one operation consumes successfully            |

Emulators and simulators are sufficient for many widget flows, fake clocks, and fault injection. They do not replace hardware testing for Keychain/Keystore, biometric prompts, clock/lifecycle behavior, and multiple devices.

### Current Source Verification Result

The source has one deterministic TOTP test and one case for generation fallback when the period is invalid. It does not have direct tests for countdown boundaries, NTP failure or offset expiry, enrollment rollback, Secure Storage failure, background/resume, or server replay.

During verification for this article, the focused test did not reach its assertions:

* The first dependency resolution attempt was blocked by a private Git dependency over SSH.
* The `--no-pub` attempt did not compile because the local cache was missing both private and public dependencies.

I therefore do not treat either run as proof that TOTP passed or failed. I also did not run hardware QA, and I did not have backend source to verify anti-replay, rate limiting, or atomic verification.

### Common Mistakes and Trade-Offs

#### The Generator and Countdown Use Different Periods

The symptom is that the code changes at one interval while the countdown resets at another, or the modulo operation fails when the period is `0`. The cause is that each helper applies its own configuration fallback. The fix is to validate one `TotpPolicy` and pass the same instance to every path.

#### NTP Synchronization Was Called but the Code Still Uses the Wrong Time

A fire-and-forget task may not have completed when the generator runs. Check readiness, quality, and offset age instead of only checking whether synchronization was once started. Rebuild the snapshot after a refresh.

#### The Server Accepts the Same Code Again Within the Time Step

TOTP does not remember previous uses. The verifier must persist the consumed step and perform the replay check and operation transition in one atomic boundary.

#### The App Reports Successful Enrollment but Has No Secret After Restart

The server activated the authenticator, but the local write did not finish or failed. `await` the write, handle the exception, and revoke or enter recovery. Do not navigate to a success screen before the states converge.

#### Biometric Success Is Treated as Server Authentication

Biometrics only open the local gate. The server must still verify the TOTP, session, and operation. If a key must only be usable after biometrics, use platform access-control or cryptographic APIs rather than a Boolean.

#### The Clock Window Is Widened to Reduce User Errors

A wider window reduces false rejection but increases how long an attacker can use an exposed code. Keep the window bounded and improve the time source and UX instead of expanding it indefinitely.

#### The App Allows Copying the Code

Copying or displaying a code supports use on another device but increases exposure through the clipboard, screenshots, and shoulder surfing. If the feature does not need manual transfer, do not expose the code in the UI. If it does, limit display time and handle content protection in a separate topic.

### Verified Versions

* Flutter: 3.41.2.
* Dart SDK: 3.11.0 or later and earlier than 4.0.0.
* Reference app Android min SDK: 24.
* Reference app iOS deployment target: 15.0.
* `otp`: 3.2.0 from the dependency lock.
* `ntp`: 2.0.0.

When upgrading a package, recheck secret format, padding/compatibility options, algorithm defaults, and time-source APIs. Do not only run `pub upgrade` and assume the verifier contract remains compatible.

### References

* [RFC 6238 — TOTP: Time-Based One-Time Password Algorithm](https://www.rfc-editor.org/rfc/rfc6238)
* [RFC 4226 — HOTP: An HMAC-Based One-Time Password Algorithm](https://www.rfc-editor.org/rfc/rfc4226)
* [NIST SP 800-63B — Authentication and Authenticator Management](https://pages.nist.gov/800-63-4/sp800-63b.html)
* [`otp 3.2.0`](https://pub.dev/packages/otp/versions/3.2.0)
* [`ntp 2.0.0`](https://pub.dev/packages/ntp/versions/2.0.0)

## Conclusion

After separating these boundaries, I no longer treat Smart OTP as a widget that displays six digits. Enrollment has explicit pending, activation, and rollback states; the secret does not pass through the UI; the generator and countdown use the same time policy; and the server verifies and consumes the TOTP in one atomic boundary.

A local PIN or biometric check only unlocks the authenticator. It does not replace server verification, turn TOTP into a transaction signature, or make the flow phishing-resistant.

This approach fits a software authenticator that must generate codes offline while still controlling secret lifecycle, clock drift, and replay at the verifier. If an operation needs cryptographic binding to its contents or stronger phishing resistance, TOTP is not the final destination and you should use a more appropriate protocol.

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