nts

CI Fuzzing Status CodeQL Cargo Audit Dependabot codecov Quality Gate Status OpenSSF Scorecard Pub Version MIT License


Tamper-proof time synchronization for Dart and Flutter.

Why NTS?

Most apps trust whatever time their device reports, and that time ultimately comes from plain NTP — an unauthenticated protocol that any attacker on the network path can forge or replay. Shifting a client's clock breaks anything anchored to it: TLS certificate validity, JWT expiry, TOTP codes, OAuth refresh windows, license checks, audit logs.

Network Time Security (NTS) fixes this by authenticating the time server with TLS and cryptographically signing every time response. A forged or modified reply is rejected; a hijacked NTP server is detected. The result is a clock you can trust as much as you trust the operator's TLS certificate, rather than as much as you trust the network between you and an anonymous UDP listener.

This package gives Dart and Flutter apps a single async call (ntsGetTime) that returns an authenticated, sleep-aware monotonic-anchored UTC clock, with the protocol details delegated to a bundled native implementation. Lower-level primitives remain available for callers who need manual control. See ARCHITECTURE.md for the underlying RFC 8915 layering and cryptographic specifics.

Getting Started

Prerequisites

  • Flutter ≥ 3.38.0 (Dart ≥ 3.10). The package depends on the Flutter SDK and ships a Flutter plugin module on Android, so it must be consumed from a Flutter app — dart pub add nts from a pure-Dart project will not resolve.

  • rustup on your PATH. The protocol core ships as Rust source and is compiled on your machine by the Flutter Native Assets build hook the first time you run flutter run / flutter build. The hook reads rust/rust-toolchain.toml via rustup, which automatically installs the pinned toolchain (currently Rust 1.98.0) and the cross-compile target for the platform being built. No manual cargo invocation, dylib copying, or build configuration is needed — but without rustup installed the build fails at the hook step.

    New to Rust? Installing rustup is a one-time step; see the official install page (or the longer-form rust-lang.org guide):

    • macOS / Linux: run the curl … | sh one-liner from rustup.rs, or brew install rustup on Homebrew.
    • Windows: download and run rustup-init.exe from rustup.rs. It will prompt you to install the Visual Studio C++ Build Tools if they are missing — accept, as the MSVC linker is required to build the crate.

    Restart your terminal (or IDE) afterwards so the updated PATH is picked up, and verify with rustup --version.

    The same mechanism covers toolchain upgrades: when a release of this package bumps the pin in rust/rust-toolchain.toml, the next flutter run / flutter build auto-downloads the new version — no rustup update or other manual step is needed. The superseded toolchain stays on disk; reclaim the space with rustup toolchain uninstall <old-version> if you like.

  • Android NDK r28 or newer in your Android SDK, on Android only. Android 15+ requires 64-bit native libraries to be 16 KB page aligned, and the NDK clang driver only defaults to that from r28 — r27 and earlier produce a 4 KB-aligned libnts_rust.so that Google Play rejects. Nothing to do on a current setup, since every Flutter this package supports installs an r28 NDK; if the newest NDK in your SDK is older, the build hook fails with a message naming the revision it found. Note that this is the NDK installed in the SDK, not the ndkVersion your app's Gradle script pins — the Native Assets build resolves its toolchain from the SDK independently, so raising or lowering ndkVersion does not change which NDK compiles the Rust library.

Install

flutter pub add nts

Platform support

Supported on Android, iOS, macOS, Linux, and Windows. On every platform, integration is flutter pub add nts plus one await NtsBridge.ensureInitialized() during application startup before the first ntsGetTime / ntsQuery / ntsWarmCookies call — no per-platform bootstrap code. See "Initialization has two layers" below for the rationale.

On Android the native bootstrap is automatic via the bundled NtsPlugin on the default Flutter/Gradle setup. The plugin adds upstream's GitHub-hosted Maven repository for the rustls-platform-verifier companion AAR (group org.rustls) to the host build, so the first Android build needs network access to github.com, or a mirror of that repository. The one exception: hosts that declare their repositories in settings.gradle.kts under RepositoriesMode.PREFER_SETTINGS (not the flutter create default) must add that repository there, because Gradle then ignores the plugin's project-level one; the rationale comment in this package's android/build.gradle.kts documents the full recipe. RepositoriesMode.FAIL_ON_PROJECT_REPOS is not supported: Gradle rejects the project-level repositories that both this plugin and the Flutter Gradle Plugin declare.

The AAR's manifest is merged into your app's. It adds the INTERNET permission and sets android:networkSecurityConfig to a config that permits cleartext HTTP only to the certificate revocation list (CRL) hosts upstream lists, so revocation checks can download CRLs. If your app sets its own android:networkSecurityConfig, the manifest merge fails until you add tools:replace="android:networkSecurityConfig" to your <application> element. Your config then applies instead: unless it permits cleartext HTTP to those hosts, CRL downloads are blocked and the revocation check soft-fails. A config of your own named network_security_config.xml replaces upstream's without a merge conflict.

Web and WebAssembly are unsupported: NTS-KE needs a raw TCP socket on :4460 and NTPv4 needs a raw UDP socket on :123, neither of which is reachable from a browser tab.

The strict clock selects a native backend on all five platforms, but how much of its behaviour has been observed differs by platform; see Platform evidence.

Quick start

For most applications, ntsGetTime is the whole integration: one call that performs the NTS-KE handshake, takes a burst of up to 8 authenticated samples under a single 8-second total budget, picks the sample with the lowest network delay — the RFC 5905 peer delay when it falls inside the selection window (0, roundTripMicros], which healthy samples normally do, else the measured round trip — applies the standard delay / 2 compensation, and returns a synchronized clock with offset, jitter, and worst-case error-bound statistics.

import 'package:nts/nts.dart';

Future<void> main() async {
  // 1. Initialize the FRB bridge before anything else in this
  //    package. This loads the bundled Rust binary that does the
  //    actual NTS-KE handshake and AEAD-NTP exchange and wires the
  //    Dart-side dispatch table. Required on every platform, and safe
  //    to call from more than one bootstrap path.
  await NtsBridge.ensureInitialized();

  // 2. Pick an RFC 8915 NTS-KE endpoint. Port 4460 is the IANA default.
  const spec = NtsServerSpec(host: 'time.cloudflare.com', port: 4460);

  // 3. Synchronize. Handshake + 8-sample burst + lowest-delay
  //    selection + delay/2 compensation, all automatic under one
  //    8-second total budget.
  final synced = await ntsGetTime(spec: spec);

  // 4. Read the clock. `utcNow` projects the authenticated instant
  //    forward on a sleep-aware monotonic anchor (keeps counting
  //    through device deep sleep), so it stays correct even if the
  //    user or OS steps the system clock after the sync.
  print('authenticated utc = ${synced.utcNow}');
  print('winning sample rtt = ${synced.roundTripMicros}µs '
      '(${synced.samplesUsed} samples used)');
  print('offset = ${synced.offsetMicros}µs, '
      'jitter = ${synced.jitterMicros}µs, '
      'error ≤ ${synced.errorBoundMicros}µs at sync');
}

Keep the returned NtsSyncedTime and read utcNow on demand rather than re-querying per read; elapsedSinceSync reports the age of the sync so you can decide when a refresh is worth it. Local oscillator drift accumulates roughly a millisecond per minute-to-hour of age depending on hardware.

ntsGetTime's tuning is deliberately fixed (8-sample burst, one 8-second total budget, package-default concurrency caps): a zero-decision call sized to serve phones and desktops alike. If you need different numbers, compose the primitives yourself — see Manual control below.

Initialization has two layers. Get them straight before deciding what your host code needs to do.

  1. Native platform bootstrap (Android only, automatic). On Android the bundled NtsPlugin captures the JavaVM + application Context that rustls-platform-verifier needs to reach the system X509TrustManager. It runs from GeneratedPluginRegistrant before Dart main() executes, so adding nts to your pubspec.yaml is enough — there is no MainActivity shim, JNI symbol, or app/build.gradle.kts Maven entry to maintain on the default Flutter/Gradle setup. (The PREFER_SETTINGS exception described under "Platform support" above is the one deviation from this.) iOS, macOS, Linux, and Windows have no equivalent step. Hosts that bypass the standard Flutter activity lifecycle (custom embeddings, isolates spawned ahead of plugin registration, integration tests driving the dylib directly) can call com.nllewellyn.nts.PlatformInit.init(context) from Kotlin directly; see the KDoc on that class.

  2. Dart/FRB initialization (await NtsBridge.ensureInitialized(), every platform, manual). This loads the bundled Rust dylib through the Native Assets pipeline and wires the flutter_rust_bridge v2 dispatch table on the calling isolate. The Android plugin does not subsume this step: it mutates Dart isolate state, and the plugin runs on the Android platform thread before the Dart isolate exists. Calling ntsGetTime, ntsQuery, or ntsWarmCookies before it resolves raises an error. In a Flutter app, do it right after WidgetsFlutterBinding.ensureInitialized() in main(); while the bridge stays initialized, repeated and concurrent calls converge on the first one rather than re-initializing, so it is safe to call from a shared bootstrap path. A call after a disposal starts a fresh attempt instead.

    The underlying NtsRustLib.init() is exported too, for callers that need to drive the generated entrypoint directly (supplying their own api:, for instance). It rejects a call made while the entrypoint holds state, throwing a StateError; either NtsBridge.dispose() or the raw NtsRustLib.dispose() clears that state, so outside a deliberate teardown the first call is the only one that succeeds. Prefer NtsBridge.ensureInitialized() unless you specifically need the raw entrypoint.

A complete, runnable version with exhaustive NtsError handling lives in example/main.dart. For valid hostnames to plug into NtsServerSpec, see the community-maintained NTS server list.

Upgrading

Migration notes for breaking releases live in CHANGELOG.md under the relevant version header. Releases up to and including 5.2.4 — among them the 5.0.0 RustLib → NtsRustLib rename and the 1.4.0 Android bootstrap rework (JNI symbol rename plus auto-init plugin) — are in CHANGELOG_ARCHIVE.md, which is kept in the repository but not shipped in the package.

Manual control (advanced primitives)

ntsGetTime is the packaged implementation of the standard NTP-hygiene recipe. When its fixed tuning does not fit — a different burst size, a tighter or looser budget, handshake timing decoupled from sampling, custom filtering — the same protocol primitives it is built from are public:

  • ntsQuery — one authenticated NTPv4 round-trip. Returns the raw protocol output (NtsTimeSample): the server's transmit timestamp plus the locally measured RTT. The first call handshakes implicitly; later calls reuse the cached session. Exposes the timeout, dnsConcurrencyCap, and bridgeConcurrencyCap knobs that ntsGetTime fixes internally.
  • ntsWarmCookies — force a fresh NTS-KE handshake without sampling, e.g. to front-load the TLS cost at app startup or on a known-good network window. The returned NtsWarmCookiesOutcome.freshCookies reports how many single-use cookies the server delivered (RFC 8915 §4 leaves the pool size to server policy), which bounds how many ntsQuery calls can follow before the next implicit handshake.
  • NtsClient — an owned client with its own session table and trust policy, for callers who need isolation from the default singleton or non-default TrustModes. Carries per-client query / warmCookies / getTime twins plus invalidate / clear session management. See Security considerations below.

A hand-rolled synchronized clock adds two layers on top of raw samples — the same two ntsGetTime automates:

  1. Burst sampling. A single NTPv4 reply carries whatever jitter the network and the server's queueing happened to introduce on that one packet. Calling ntsWarmCookies once and then ntsQuery several times in quick succession — one query per delivered cookie — produces a small distribution you can reason about statistically. Pick the sample with the smallest network delay (peerDelayMicros when it falls inside (0, roundTripMicros], else roundTripMicros); on a low-delay path the symmetric-path assumption below holds tightest, so that sample carries the smallest residual offset error. T1 shares an anchor with roundTripMicros (stamped immediately before the request is built and sealed, which the send follows), so the window normally admits δ rather than rejecting it on the ordinary setup cost it carried before 9.2. What δ still carries that the round trip does not is that request build and seal plus the socket write-timeout re-arm before the send — neither blocking on I/O, but enough to put δ outside the window when the server's own T3−T2 is smaller still. Beyond that overhead, a δ outside the window is a clock step, a slew, or — on a loaded host — the worker being preempted between T1 and the send. Any such interval p adds p to δ and p/2 to offsetMicros, so taking the fallback keeps p out of the delay halved below; it does not undo the p/2 in the offset. See the peerDelayMicros dartdoc for the allowance to measure when applying a bound of your own. More sophisticated callers can median-filter, score by serverStratum, or run Marzullo's algorithm across multiple servers.

  2. Symmetric-path delay compensation. utcUnixMicros is the moment the server stamped the reply, not the moment it landed locally. The reply then spent roughly half the network delay travelling back to the client, so the server's clock at the moment of arrival is best approximated as utcUnixMicros plus half the delay selected above. This is the standard NTP correction (RFC 5905 §8); it assumes the outbound and return paths are symmetric, which is why filtering on the lowest-delay sample matters — short paths are more likely to be symmetric.

Each sample also carries the fully computed RFC 5905 §8 statistics — offsetMicros (the true clock offset θ, from all four on-wire timestamps) and peerDelayMicros (the round trip minus the server's processing time) — plus the server-reported rootDelayMicros, rootDispersionMicros, and serverPrecision, so a custom filter can implement the full RFC 5905 clock-filter and root-distance recipes without re-deriving anything. Persist the winning offset and apply it on top of the device's monotonic clock rather than calling ntsQuery on every read; a few-second jitter floor on cellular networks makes per-call queries strictly worse than one well-filtered offset reused across many reads. (ntsGetTime does exactly this, returning the projection as NtsSyncedTime.utcNow alongside the winning offset, the burst's RMS jitter, and a worst-case errorBoundMicros.)

Below ntsGetTime, the package stops at protocol primitives by design: the right filter (lowest-delay, median, Marzullo across multiple servers, weighted by stratum), the right resampling cadence, and the right way to project the offset onto DateTime.now() are all workload specific. The example/main.dart snippet shows the minimum burst-filter-compensate flow described above built from the primitives.

Monotonic Time & Measurement Accuracy

A time-synchronization package cannot trust the clock it exists to correct, so package:nts never meters a timeout or measures a network delay against the system clock. All internal timing runs on monotonic sources:

  • Sleep-aware on the Dart side. Budgets and projections read the suspend-inclusive "boottime" clock family through the Rust core (CLOCK_BOOTTIME on Android/Linux, mach_continuous_time on iOS/macOS, QueryInterruptTimePrecise on Windows), exposed publicly as MonotonicClock. Unlike Dart's Stopwatch, readings keep advancing while the device is in deep sleep, so the NtsSyncedTime.utcNow projection stays correct across suspend/resume and an in-flight getTime budget expires on schedule instead of stalling.
  • Monotonic deadlines in the Rust core. The NTS-KE handshake (DNS, TCP connect, TLS, record exchange) and the UDP exchange run under single shrinking Instant-anchored deadlines, so timeouts fire neither prematurely nor late regardless of NTP slews, clock steps, or manual adjustments mid-call.
  • Trustworthy RTT. roundTripMicros is measured monotonically around the UDP round-trip, which matters because it is both the selection ceiling for the peer delay and the fallback delay driving ntsGetTime's burst selection and delay / 2 compensation whenever the peer delay falls outside that ceiling. A contaminated RTT would corrupt the synchronized time itself.

For the platform syscall mappings, epoch semantics, the bridge-initialization requirement, and how to synchronize your own protocol-level timeouts with the package's clock, see doc/MONOTONIC_TIME.md.

MonotonicClock is best effort. Callers that need every reading to be attributable to the native clock, or to fail, should use the strict clock instead.

Strict clock

New in 10.0. MonotonicClock and the NtsSyncedTime it anchors are best effort: if the platform clock cannot be read, the native core can latch a process-local counter that may not count device sleep, and nothing on the reading says so. The strict path removes that fallback. A StrictClockContext names the clock it reads, and every read either returns a reading attributed to that clock or throws on that call.

Legacy versus strict

Legacy: MonotonicClock, ntsGetTime, NtsSyncedTime Strict: StrictClockContext, ntsGetTimeStrict, StrictSyncedTime
Native read fails May latch a fallback counter that may not count sleep, with no signal Fails that call: context methods and StrictSyncedTime projections throw a StrictClockError, and ntsGetTimeStrict throws NtsError.clockFault carrying it as fault; the context is invalid from then on
Provenance None Every StrictReading carries descriptor, generation and provenance
Sample attribution Not attributed Each sample is matched to the context by its native receipt stamp; an unattributable sample fails the call
Synced clock reads utcNow / elapsedSinceSync getters utcNow() / elapsedSinceSync() methods that re-read the context and throw once it is invalid
Mock bridge Falls back to Stopwatch resolve() throws StrictClockMockOnly; tests use resolveForTesting()

The legacy entry points are not untouched in 10.0: the native core now runs its own deadline, session and receipt clock reads strictly for every caller, so ntsGetTime, ntsQuery and ntsWarmCookies can also fail with NtsError.clockFault. See the ## 10.0.0 entry in CHANGELOG.md.

Resolve, read and sync

StrictClockContext.resolve() needs an initialized native bridge. It is synchronous, never cached, and fails distinctly: StrictClockUninitialized before NtsBridge.ensureInitialized(), StrictClockMockOnly on a hand-written mock bridge, StrictClockUnsupported when the build has no supported backend, a source fault, a regression or StrictClockUnknownSource when the binding read cannot be trusted, and StrictClockInvalidated with reason nativeGeneration when a process-wide invalidation races that read. now() performs the bridge-state checks and one synchronous native read; it does no I/O.

import 'package:nts/nts.dart';

Future<void> main() async {
  await NtsBridge.ensureInitialized();

  final StrictClockContext clock;
  try {
    clock = StrictClockContext.resolve();
  } on StrictClockError catch (e) {
    // Fail closed: do not substitute MonotonicClock or DateTime.now().
    print('strict clock unavailable: $e');
    return;
  }

  const spec = NtsServerSpec(host: 'time.cloudflare.com', port: 4460);
  try {
    final start = clock.now();
    print('${start.descriptor}, generation ${start.generation}');

    final synced = await ntsGetTimeStrict(spec: spec, context: clock);
    print('authenticated utc = ${synced.utcNow()}');
    print('sync age = ${synced.elapsedSinceSync()}');
    print('since start = ${clock.elapsedSince(start)}');
  } on NtsErrorClockFault catch (e) {
    print('clock could not vouch at ${e.stage.name}: ${e.fault}');
  } on NtsError catch (e) {
    print('sync failed: $e');
  } on StrictClockError catch (e) {
    // Raised by now(), utcNow(), elapsedSinceSync() and elapsedSince().
    // `clock` is now invalid; resolve a new context to continue.
    print('strict clock failed: $e');
  }
}

Network calls report a clock failure as NtsError.clockFault, whose stage names where the read served and whose fault is the underlying StrictClockError. Context methods and the StrictSyncedTime projections throw a failed clock read's StrictClockError directly. The transfer methods can also fail in other ways: exportReference throws ArgumentError for a StrictSyncedTime bound to another context, and once a provider is consulted, exportReference and bindReference propagate whatever its current() throws.

Errors and invalidation

  • A failing read invalidates. A source fault or a regression on now() or elapsedSince() throws that error and leaves the context permanently invalid.
  • Lifecycle events invalidate. After NtsBridge.dispose(), a raw NtsRustLib.dispose() / init(), an explicit invalidate(), or an advance of the native generation, the next now() / elapsedSince() throws StrictClockInvalidated with the matching reason. The generation is process-wide: a native fault, or the disposal of a native bridge, on any isolate or engine in the process advances it, and every context in the process fails on its next read.
  • Foreign readings are rejected without invalidating. elapsedSince() given a reading from another bridge incarnation, coordinate or generation throws StrictClockSourceIncompatible, StrictClockDescriptorIncompatible or StrictClockGenerationIncompatible without reading the clock, and the context stays valid.
  • isValid is not a liveness probe. isValid and invalidationReason report only failures this context has already observed. A reset or generation advance elsewhere shows up on the next read, so call now() to learn the current state.

Recovery

An invalid context is never repaired. Resolve a new one with StrictClockContext.resolve(), which itself fails if the clock is still unusable, and re-sync with ntsGetTimeStrict on the new context. Discard the old context's readings and StrictSyncedTime rather than carrying them over: the old StrictSyncedTime keeps throwing, and a new context rejects readings from an earlier bridge incarnation or generation.

Ownership and scope

  • A reading means nothing outside the process that took it. The generation is a per-process lifecycle token, so equal generations in two processes prove nothing. Do not persist raw micros or compare them across processes or reboots.
  • ClockSourceDescriptor values can be persisted and compared with isCompatibleWith. Compatibility says two coordinates use the same source and conversion rules; it does not say two readings share a counter epoch.
  • Same-boot transfer between processes (exportReference / bindReference) is unavailable as shipped. No boot-scope provider is approved, so a native context refuses with StrictClockBootScopeUnavailable and reason BootScopeUnavailableReason.providerNotApproved. Local strict reads do not depend on it.

Portability limits

  • One device, one counter. A strict reading is a position on one device's boot-time counter. Compare readings only within one boot of one device. A restored backup or a migrated install is a different counter, and nothing in the package maps between them.
  • Engines own their contexts; disposal is process-wide. Every engine calls NtsBridge.ensureInitialized() and resolves its own contexts; contexts are not shared between isolates. Because the generation is process-wide, NtsBridge.dispose() on a native bridge in one engine invalidates the contexts of every engine in the process. A raw NtsRustLib.dispose() followed by initMock(api:) with the same object is not detected; use NtsBridge.dispose() when a reset must be observed.
  • Raw readings cannot be upgraded. A stored micros integer, a MonotonicClock reading or an NtsSyncedTime anchor has no export provenance, and wrapping it in the public SameBootReference constructor does not give it any. That constructor exists to rebuild a reference the consumer exported and kept in storage it authenticates. exportReference accepts only a StrictSyncedTime bound to the exporting context.
  • Reference time is not export time. A SameBootReference carries the instant its StrictSyncedTime was referenced to (the winning sample's wire receipt), not the instant it was exported, stored or bound, and binding maps it without conversion. A receipt at 1,234,567 µs exported at 4,000,111 µs and bound when the receiver reads 5,000,123 µs is 3,765,556 µs old, not 1,000,012 µs.
  • Comparable is not trusted. Same-boot comparability, once a provider exists, says only that two readings are on one counter. It does not authenticate a stored reference, prove that it is the latest one (rollback freshness), make its UTC trustworthy after the fact, or bound drift or real-time error. Those stay with the consumer.
  • iOS boot continuity is unsupported. iOS exposes no cold-restore boot identity to apps (kern.bootsessionuuid is no longer available to apps as of iOS 18). The package does not substitute boot time, uptime comparisons, stored identifiers or hashes of them, so transfer on iOS stays unavailable with no provider planned. Local strict reads are unaffected.
  • Android scope is conditional. Settings.Global.BOOT_COUNT (API 24 and later) is a candidate boot-scope provider only. Its reset behaviour has not been validated and it is not approved, so Android refuses transfer like every other platform until a reviewed release approves it. Linux, macOS and Windows have no evaluated provider.

Platform evidence

Every platform selects a suspend-inclusive source, but what has been observed differs. This table is what the strict clock evidence matrix records today; anything not listed is unverified. Floors follow the package's: Android API 24 (the plugin's minSdk), Windows 10 (QueryInterruptTimePrecise), and the Flutter SDK's deployment targets on iOS and macOS.

Platform Backend Recorded Not yet recorded
Android linuxBoottime (CLOCK_BOOTTIME) Source review; compilation; Rust unit tests (CI and on a device); hermetic Dart tests; on a Pixel Tablet: native read, multiple engines, bridge teardown, relaunch and descriptor stability, suspend, locked reads, clock change, process death, reboot, uptime after boot Locked reads from a foreground service (observed from the foreground app only)
iOS appleContinuous (mach_continuous_time) Source review; compilation; Rust unit tests on a device; hermetic Dart tests; on an iPad mini: native read, multiple engines, bridge teardown, relaunch and descriptor stability, suspend, locked reads, clock change, OS memory kill (jetsam), reboot, uptime after boot A launch before first unlock (the device is unreachable until then)
macOS appleContinuous (mach_continuous_time) Source review; compilation; Rust unit tests; hermetic Dart tests; native read on a Mac Multiple engines, bridge teardown across engines, relaunch, descriptor stability across relaunch, suspend, clock change, process death, reboot, uptime after boot
Linux linuxBoottime (CLOCK_BOOTTIME) Source review; compilation; Rust unit tests; hermetic Dart tests Native read on a host; all runtime behaviour
Windows windowsInterruptTime (QueryInterruptTimePrecise) Source review; hermetic Dart tests Compilation and Rust unit tests; native read on a host; all runtime behaviour

Only Android and iOS have recorded evidence that strict readings advance correctly across a real suspend, stay put across a wall-clock change, and behave as specified across process death, reboot and multiple engines, each on one physical device. Hermetic tests and simulators do not count as that evidence.

Security considerations

The package handles authentication and replay protection on its own side of the wire (RFC 8915 NTS over TLS 1.3, AEAD on every NTPv4 exchange, single-use cookies, UID and origin-timestamp echo checks, strict TrustMode.platformOnly available for callers who want the platform CA store with no static-bundle downgrade). What it does not do — and structurally cannot do, because the whole point of the API is "take a caller-supplied host and connect to it" — is constrain which hosts a caller is allowed to reach.

If your app accepts hostnames from untrusted input (e.g. a user- entered NTS server URL, a remotely-fetched server list, a deep-link parameter) and passes them through to ntsQuery / ntsWarmCookies / NtsClient, treat those call sites as a server-side-request- forgery (SSRF) surface and apply the validation your threat model requires before dispatching into this library. Reasonable controls include:

  • Allowlist — pin the set of acceptable hosts at the application layer (e.g. against a curated catalog like the one the Flutter showcase ships). Most consumer-facing apps that need authenticated time can ship with a fixed allowlist and never resolve attacker-controlled hostnames at all.
  • Reject private-range resolution — if you do accept arbitrary hostnames, resolve them yourself first, refuse the call if the resolved address falls in an RFC 1918 / RFC 4193 / loopback / link-local range, and only then pass the (resolved, validated) hostname through. This is a textbook SSRF mitigation; this library cannot apply it on your behalf because legitimate on-premise deployments routinely point at RFC 1918 hostnames (a stratum-1 GPS receiver on a corporate VLAN) and the library has no way to tell those apart from a callsite that's being exploited.
  • Constrain the port — the wrapper rejects ports outside 1..65535 as NtsError.invalidSpec, but every value inside that range is reachable. If your threat model requires it, gate the port at the application layer before the call.

The bounded DNS worker pool (kDefaultDnsConcurrencyCap = 4, see the API summary section) bounds the amplification exposure of a saturated hostname-resolution path, but does not gate destinations — that gate is the caller's responsibility.

Trust-anchor selection

The default TrustMode.platformWithFallback consults the platform trust store, which is the right choice for the broadest connectivity: it honours corporate CAs, MDM-installed roots, and user-added certificates, so the client works out of the box on managed devices and private networks.

The security trade-off is that platform-managed stores on corporate or MDM-managed devices often include an inspection CA installed by policy. An appliance holding a certificate signed by that CA can complete a man-in-the-middle NTS-KE handshake and derive the same AEAD keying material the client derives. Because NTS authenticates time responses using those keys, a middlebox with a platform-trusted cert can forge NTPv4 replies that the client accepts as authentic.

If your threat model requires end-to-end integrity against TLS inspection, construct the client explicitly with TrustMode.bundledOnly:

import 'package:nts/nts.dart';

Future<void> main() async {
  await NtsBridge.ensureInitialized(); // before using NtsClient
  final client = NtsClient(trustMode: TrustMode.bundledOnly);
  final sample = await client.query(
    spec: const NtsServerSpec(host: 'time.cloudflare.com', port: 4460),
    bridgeConcurrencyCap: 4, // built-in default, shown for visibility
  );
}

bundledOnly limits trust anchors to the library's static webpki-roots bundle. An inspection appliance cannot present a certificate this client will accept, because the bundle contains only public CAs and no CA injected via MDM or policy. The trade-off is that bundledOnly will reject certificates from private or enterprise CAs, so it is unsuitable for NTS servers that present private-CA-issued certificates. For those deployments, use TrustMode.custom with the relevant root bundle supplied via customRoots. See the TrustMode API documentation and ARCHITECTURE.md for the full decision matrix.

Reaching multiple trust domains

A single client applies one trust policy to every host it queries — the TrustMode is fixed at construction and immutable per client. You cannot route different per-host trust policies through one client. A TrustMode.custom client built around a private CA accepts only certificates that chain to that CA, so it rejects time.cloudflare.com; a bundledOnly client trusts only the public webpki-roots bundle, so it rejects a server presenting a private-CA certificate. (A platform-mode client is the awkward middle case: whether it accepts a private-CA server depends on whether that CA is installed in the OS trust store, so it does not give a clean, predictable boundary either way.)

When an app must enforce distinct boundaries for distinct hosts — say an internal NTS server behind a private CA and public servers — mint one client per trust domain and route each query to the matching client:

import 'dart:io';
import 'package:nts/nts.dart';

Future<void> main() async {
  await NtsBridge.ensureInitialized(); // before using NtsClient

  // Trusts only the private CA, so it authenticates only the
  // internal server; public hosts are rejected.
  final privateRoots = File('/etc/nts/internal-ca.pem').readAsBytesSync();
  final internalClient = NtsClient(
    trustMode: TrustMode.custom,
    customRoots: privateRoots,
  );

  // Trusts only the public webpki-roots bundle, so an MDM-injected
  // root or the private CA above cannot authenticate these hosts.
  final publicClient = NtsClient(trustMode: TrustMode.bundledOnly);

  // Both clients share one isolate-wide bridge admission gate, so the
  // explicit `bridgeConcurrencyCap` (the built-in default of 4) bounds
  // their combined worker occupancy, not each client's separately.
  final internal = await internalClient.query(
    spec: const NtsServerSpec(host: 'ntp.internal.example', port: 4460),
    bridgeConcurrencyCap: 4,
  );
  final external = await publicClient.query(
    spec: const NtsServerSpec(host: 'time.cloudflare.com', port: 4460),
    bridgeConcurrencyCap: 4,
  );
}

Per-client scoping is the security point, not just an ergonomic convenience: it keeps the private CA trusted only for the internal server. Merging both anchor sets into one client would let the private CA authenticate a public hostname (and vice-versa), widening every server's trusted-issuer set to the union — the exact exposure the per-client boundary exists to prevent. Each client also owns its own session table, so cookies and AEAD keys never cross the domain boundary.

Non-Flutter Dart callers must pass externalLibrary explicitly

The automatic library resolution described under Prerequisites is a Flutter-specific feature: the Native Assets build hook only runs inside flutter run / flutter build, where it compiles the Rust crate and hands bridge initialization a controlled absolute path to the resulting dynamic library.

Pure Dart environments — a dart run CLI such as the bundled example/bin/nts_cli.dart, a server-side script, an integration-test harness — never trigger that hook. Nothing compiles the crate for you (build it with cargo build --release in rust/), and nothing supplies a load path: without one, the FRB-generated default loader falls back to resolving the relative directory rust/target/release/ against the current working directory.

That fallback is a security problem, not just a convenience gap. Initializing with no externalLibrary: argument from a working directory an attacker can influence is a library-hijack surface: a malicious rust/target/release/libnts_rust.dylib (or .so / .dll) dropped there yields arbitrary code execution under the calling process's privileges — before any of this package's TLS / NTS code is even reached.

Outside Flutter, therefore, always resolve an absolute path to the compiled library (.so on Linux, .dylib on macOS, .dll on Windows) from a trusted source — a packaged install location, an environment variable owned by the deploying operator, a command-line argument — and pass it through the externalLibrary parameter:

import 'package:nts/nts.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
    show ExternalLibrary;

await NtsBridge.ensureInitialized(
  externalLibrary: ExternalLibrary.open('/absolute/path/to/libnts_rust.dylib'),
);

Flutter callers can keep using the bare await NtsBridge.ensureInitialized() form: the Native Assets pipeline supplies the load path before the relative fallback can fire.

Note that externalLibrary configures the first initialization only. A later ensureInitialized() call passing a different library ignores it, because the bridge is already initialized — so the call site that owns the trusted path must be the one that runs first. A disposal — NtsBridge.dispose(), or the raw NtsRustLib.dispose() — does clear that state, and a call after it is a fresh first initialization, but disposal is a teardown rather than a reconfiguration hook: it invalidates everything the disposed bridge was serving.

Ignored is not the same as harmless. ExternalLibrary.open maps the library synchronously, in its own constructor, so writing

import 'package:nts/nts.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'
    show ExternalLibrary;

final String untrustedPath = '/some/path/libnts_rust.dylib';

await NtsBridge.ensureInitialized(
  externalLibrary: ExternalLibrary.open(untrustedPath),
);

executes that library's load-time initializers even when the call turns out to be a no-op — the argument is evaluated before ensureInitialized runs at all. The hijack surface described above is therefore closed by controlling where the path comes from, not by relying on a later call being ignored: nominate one call site as the initialization owner, and construct an ExternalLibrary only there.

NtsBridge.state is not a substitute for that. It rules out a completed initialization, not an in-flight one — an attempt that is still awaiting its library load has not installed entrypoint state yet, so state reads uninitialized for the whole of that window and a second call site guarding on it maps its library anyway.

API summary

Symbol Purpose
NtsBridge.ensureInitialized({externalLibrary, handler, forceSameCodegenVersion}) Recommended bootstrap. Load the native dylib and wire the FRB v2 dispatch table on the calling isolate, or complete immediately when that is already done. Await before any other call, on every platform; safe to call repeatedly and concurrently, so it can live in a shared bootstrap path. Arguments configure the first initialization only. (Android-side rustls-platform-verifier JNI bootstrap is handled separately by the bundled NtsPlugin before main(); see "Initialization has two layers" above.)
NtsBridge.state Which of NtsBridgeState.uninitialized / .mock / .native the bridge holds on this isolate. The split is structural, on the installed API's type, not by initialization route or by who supplied it: .native means a generated FFI dispatch implementation (a BaseApiImpl) is installed, so calls cross into the Rust core — including when a caller passed that generated implementation to NtsRustLib.initMock(). .mock means the installed API is not one, in practice a hand-written double.
NtsBridge.dispose() Release the bridge's Dart-side resources, or do nothing when it was never initialized. Optional — the bridge is torn down with the process. It is a de-init: NtsBridge.state reads .uninitialized afterwards, and a later NtsBridge.ensureInitialized() runs a fresh attempt.
NtsRustLib.init() Raw generated entrypoint, for callers that must supply their own api: or otherwise drive it directly. Rejects a call made while the entrypoint holds state, throwing a StateError; either NtsBridge.dispose() or the raw NtsRustLib.dispose() clears that state, so a call after disposal succeeds. Prefer NtsBridge.ensureInitialized().
ntsGetTime({required spec, verificationTime}) Recommended entry point. One-call convenience: fresh handshake + serial burst of up to min(8, freshCookies) queries, lowest-delay selection (the RFC 5905 peer delay when it falls inside the selection window (0, roundTripMicros], which healthy samples normally do, else the measured round trip), delay / 2 compensation. Returns NtsSyncedTime. Succeeds when at least one burst sample lands. Tuning is fixed and internal: an 8-sample burst and one 8-second total budget shared across the handshake and every query; deployments needing different numbers compose ntsWarmCookies + ntsQuery directly.
ntsGetTimeStrict({required spec, required context, trustMode, customRoots, verificationTime}) Strict counterpart of ntsGetTime: the same warm + burst and budget, with every clock decision made on the supplied StrictClockContext and no fallback at any of them — the budget is metered on it, each sample is attributed by its native receipt stamp rather than by plausibility, and the result is anchored on it. Returns StrictSyncedTime. A clock decision that cannot be made fails the call with NtsError.clockFault. Requires a StrictClockProvenance.native context in production.
ntsQuery({required spec, timeout = kDefaultTimeout, dnsConcurrencyCap = kDefaultDnsConcurrencyCap, bridgeConcurrencyCap = kDefaultBridgeConcurrencyCap, verificationTime, context}) Advanced primitive: one authenticated NTPv4 exchange. Returns NtsTimeSample. verificationTime (optional DateTime, interpreted as UTC, not before the epoch) pins TLS certificate validity-window checks to a fixed instant instead of the system clock — useful for cold-start clock-skew rescue. context (optional StrictClockContext) meters the bridge queue wait on that context instead of MonotonicClock, so a fault there fails the call as NtsError.clockFault rather than falling back.
ntsWarmCookies({required spec, timeout = kDefaultTimeout, dnsConcurrencyCap = kDefaultDnsConcurrencyCap, bridgeConcurrencyCap = kDefaultBridgeConcurrencyCap, verificationTime, context}) Advanced primitive: force a fresh NTS-KE handshake. Returns NtsWarmCookiesOutcome. verificationTime and context carry the same semantics as on ntsQuery.
ntsDnsPoolStats() Synchronous snapshot of the bounded DNS resolver pool counters (inFlight, highWaterMark, recovered, refused). See ARCHITECTURE.md for the saturation signature.
ntsTrustStatus() Synchronous snapshot (NtsTrustStatus) of the process-global trust-anchor diagnostic state. Seven observables: defaultClientBackend (most-recently resolved backend for the default singleton; null until the first singleton handshake runs), four cumulative counters partitioning the singleton's resolution history by backend (defaultBackendPlatformCount, defaultBackendHybridCount, defaultBackendWebpkiCount, defaultBackendCustomCount), androidPlatformInitSucceeded (static JNI bootstrap flag), and androidHybridFallbackCount (Android-only). Platform-irrelevant fields report sentinel values (null / false / 0). Cheap enough for a UI poll loop.
NtsClient({trustMode = TrustMode.platformWithFallback, customRoots}) Owned client with its own per-host session table — cookies, AEAD keys, and KE sessions are isolated from the default singleton and from other clients. Methods: query / warmCookies / getTime / getTimeStrict (per-client equivalents of the top-level functions, same parameters including verificationTime and context), invalidate(spec) (drop one cached session) / clear() (drop all), dispose() (release the native handle and the whole session table eagerly instead of waiting for the GC finalizer — optional, idempotent, and distinct from clear() in that it ends the client rather than emptying it), and the trustMode getter. customRoots is required (and only valid) when trustMode is TrustMode.custom.
kDefaultTimeout Package default for timeout (Duration(milliseconds: 5000)).
kDefaultDnsConcurrencyCap Package default for dnsConcurrencyCap (4, sized for mobile pthread-stack budgets — see the constant's dartdoc).
kDefaultBridgeConcurrencyCap Package default for bridgeConcurrencyCap (4, sized to the smallest common mobile FRB worker pool — see the constant's dartdoc).
NtsServerSpec(host, port) NTS-KE endpoint (port 4460 by default).
NtsSyncedTime Synchronized clock returned by getTime: utcUnixMicros (compensated best sample at the anchor), roundTripMicros (winning sample), samplesUsed, trustBackend, offsetMicros (winning sample's RFC 5905 θ), jitterMicros (burst RMS jitter ψ), errorBoundMicros (worst-case error at the anchor, root-distance recipe), utcNow (sleep-aware monotonic projection immune to system clock changes and device suspend), elapsedSinceSync. Identity semantics — a live clock, not a value-type DTO.
StrictSyncedTime Synchronized clock returned by getTimeStrict, bound to the StrictClockContext the call ran on. Same sample statistics as NtsSyncedTime (utcUnixMicros, roundTripMicros, samplesUsed, trustBackend, offsetMicros, jitterMicros, errorBoundMicros) plus the anchor's coordinate: descriptor, generation, anchorMicros. utcNow() / elapsedSinceSync() re-read the bound context and throw the StrictClockError that read raises — StrictClockInvalidated once the context has been retired — instead of falling back. isValid reports only invalidations already observed, as on StrictClockContext.
MonotonicClock General-purpose sleep-aware monotonic time source: readings keep advancing across device deep sleep, unlike Stopwatch (CLOCK_BOOTTIME on Android/Linux, mach_continuous_time on iOS/macOS, QueryInterruptTimePrecise on Windows). The shared MonotonicClock.instance singleton is the same timeline the package uses internally; constructing an instance (or first accessing MonotonicClock.instance) while NtsBridge.state is NtsBridgeState.uninitialized throws a StateError. nowMicros(), elapsedSince(startMicros).
StrictClockContext Preferred strict sleep-aware clock. Where MonotonicClock is best-effort and carries no provenance, StrictClockContext.resolve() binds one explicit ClockSourceDescriptor and one live generation on the native bridge, with provenance StrictClockProvenance.native. now() returns a StrictReading on that coordinate; elapsedSince(reading) returns a Duration from any reading taken through the same bridge incarnation on the same coordinate and generation, rejecting a foreign one (StrictClockSourceIncompatible / StrictClockDescriptorIncompatible / StrictClockGenerationIncompatible) without reading the clock. A source fault or a regression throws a StrictClockError on that call and leaves the context permanently invalid; a lifecycle event (bridge reset, native generation advance, explicit invalidate()) makes the next now() / elapsedSince() throw StrictClockInvalidated — there is no Stopwatch fallback and no repair short of resolving a new context. isValid and invalidationReason report only invalidations the context has already observed — an explicit invalidate(), or a call that threw — not liveness: a bridge reset or generation advance not yet seen by a read leaves them unchanged until the next call fails. resolve() fails distinctly on an uninitialized bridge (StrictClockUninitialized), a hand-written mock (StrictClockMockOnly; tests use resolveForTesting(), whose contexts carry testInjected provenance) and an unsupported platform (StrictClockUnsupported). exportReference(time, provider:) / bindReference(reference, provider:) are the same-boot transfer surface: they carry a StrictSyncedTime's reference reading — the winning sample's wire receipt, on an instance from getTimeStrict — between engines or processes of one boot as a SameBootReference. exportReference checks that the instance is bound to the exporting context; the public StrictSyncedTime constructor means wire-receipt provenance is the claim of whoever built the instance, so what is exported is its reference reading, which is always an instant that context attributed and that orders at or before a read taken during the export. bindReference first refuses a reference whose coordinate is not exactly compatible with StrictClockDescriptorIncompatible — an inexact mapping is never converted — and an unprovable boot scope is then refused with StrictClockBootScopeUnavailable; both leave the context valid, while a lifecycle or read failure invalidates it as any failed read does. Both are explicitly unavailable as shipped — kApprovedBootScopeProviders is empty, so a native context refuses with BootScopeUnavailableReason.providerNotApproved every call that reaches the provider step; the checks that precede it still fire first, so a time bound to another context is an ArgumentError and an incompatible reference a StrictClockDescriptorIncompatible. Local strict reads are unaffected.
StrictClockError Sealed hierarchy every strict clock failure belongs to, for exhaustive switching. Thrown by StrictClockContext.resolve(), now(), elapsedSince(), exportReference() and bindReference() and by the StrictSyncedTime projections (resolveForTesting() throws the same errors once past its mock-only precondition; violating that precondition is a test-setup StateError outside the hierarchy): StrictClockUninitialized, StrictClockMockOnly, StrictClockUnsupported, StrictClockSourceFault(kind: SourceFaultKind, detail: String, errno?) (errno present exactly for SourceFaultKind.syscallFailed; the constructor throws ArgumentError on any other pairing, at runtime in every build mode), StrictClockRegression(previous, observed), StrictClockInvalidated(generation, reason: StrictClockInvalidationReason), StrictClockUnknownSource, StrictClockSourceIncompatible, StrictClockDescriptorIncompatible, StrictClockGenerationIncompatible, StrictClockBootScopeUnavailable(reason: BootScopeUnavailableReason, providerId) (raised only by exportReference / bindReference; it reports that same-boot scope could not be proven, not that the clock failed, so — like the three Incompatible subtypes before it, which are likewise raised without reading the clock — it leaves the context valid). Three further subtypes arise only while a call acquires a sample and reach the caller as the fault of an NtsError.clockFault, never from a context method: StrictClockSuspendedInFlight (the native core's per-sample verdict at ClockFaultStage.receipt that the device slept between send and receive — checked for every caller, so ntsQuery surfaces it with or without a context), and StrictClockMissingReceipt / StrictClockForeignReceipt (a sample with no native receipt stamp, or one from another generation or backend, at ClockFaultStage.attribution — raised only by the strict getTimeStrict surfaces, which are the only ones that attribute). A failure is only ever thrown, never returned in place of a reading. Two failures on the transfer surface sit outside the hierarchy, so an exhaustive StrictClockError catch does not cover them: the ArgumentError exportReference throws for a StrictSyncedTime bound to another context, and whatever BootScopeProvider.current() throws, which propagates unchanged — the transfer is abandoned and nothing is exported, adopted or invalidated.
ClockSourceDescriptor The coordinate a StrictClockContext is bound to: backend (ClockBackend.linuxBoottime / appleContinuous / windowsInterruptTime), semanticsVersion, conversionVersion. Two descriptors are compatible when all three match, independent of package version.
NtsTimeSample utcUnixMicros, roundTripMicros, serverStratum, aeadId, freshCookies, phaseTimings, trustBackend, recvBoottimeMicros (sleep-aware wire-level receipt stamp, taken as a strict read immediately after the UDP recv in the native worker; per-boot epoch, never persist), recvClockGeneration and recvClockBackend (the strict-clock generation and backend that read took — a stamp is attributed by comparing them with a StrictClockContext's own, not by its numeric plausibility and not by assuming it shares MonotonicClock's coordinate, since the best-effort clock can latch its Instant fallback while strict receipts stay on the native clock; 0 / null mark a fixture with no strict receipt), plus the RFC 5905 statistics offsetMicros (θ), peerDelayMicros (δ), rootDelayMicros, rootDispersionMicros, serverPrecision, plus keWarnings (non-fatal NTS-KE warning codes from the establishing handshake, RFC 8915 §4.1.4 record type 3, as raw int values — empty for every server observed in practice, since the IANA registry has no assignments as of RFC 8915). roundTripMicros is the UDP-phase wall-clock cost; the four pre-NTP phases live on phaseTimings; trustBackend records which trust-anchor backend the post-handshake TLS verification chose. keWarnings describes the handshake rather than the call, so like trustBackend — and unlike phaseTimings — it follows the session across cached-session queries instead of resetting to empty.
NtsWarmCookiesOutcome freshCookies, phaseTimings, trustBackend, keWarnings. The UDP phase does not run on this path, so only KE-pipeline timings are populated; trustBackend carries the same per-handshake attribution as on NtsTimeSample. keWarnings carries the same warning codes, with no cached-path nuance: this call always runs a fresh handshake, so the codes are always that handshake's own.
PhaseTimings dnsMicros, connectMicros, tlsHandshakeMicros, keRecordIoMicros. Microsecond-resolution wall-clock breakdown of the four pre-NTP phases of an ntsQuery / ntsWarmCookies call. Phases that did not run report 0. See ARCHITECTURE.md's "Phase attribution and timings" section.
TimeoutPhase bridgeSaturation, dnsSaturation, dnsTimeout, connect, tls, keRecordIo, ntp. Carried as the payload of NtsError.timeout so callers can attribute a budget exhaustion to a specific phase without parsing diagnostic strings. bridgeSaturation is Dart-authored (budget elapsed while queued at the bridge admission gate, before any FFI dispatch) and always carries a null trustBackend.
NtsDnsPoolStats inFlight, highWaterMark, recovered, refused. Process-wide pool counters; relaxed-atomic snapshot.
NtsTrustStatus defaultClientBackend, defaultBackendPlatformCount, defaultBackendHybridCount, defaultBackendWebpkiCount, defaultBackendCustomCount, androidPlatformInitSucceeded, androidHybridFallbackCount. Returned by ntsTrustStatus(); the four defaultBackend*Count fields partition the default singleton's resolution history by backend. Per-counter monotonic across consecutive snapshots.
TrustMode platformWithFallback (default; build-time fallback to the webpki-roots static bundle on build_with_native_verifier failure), platformOnly (refuses the build-time silent fallback; build_with_native_verifier failure surfaces as NtsError.trustBackendUnavailable), bundledOnly (validates only against the bundled webpki-roots static set — no platform-store consultation, eliminating TLS-inspection exposure at the cost of rejecting private/enterprise-CA certificates), custom (validates only against caller-supplied roots passed as customRoots on the NtsClient constructor, PEM or DER). Caller-selected at NtsClient construction; immutable for the life of the client. See the per-variant dartdoc for the Android HybridVerifier's separate per-chain interaction.
TrustBackend platform (OS trust store via rustls-platform-verifier), platformWithHybridFallback (Android-only — platform verifier's view was overridden by the webpki-roots fallback for a curated platform-failure shape such as Let's Encrypt R12 missing-OCSP-AIA chains), webpkiRoots (build-time fallback to the static bundle; loses visibility into MDM / user-installed roots), custom (caller-supplied custom root certificates authenticated the chain). Carried per-handshake on NtsTimeSample.trustBackend / NtsWarmCookiesOutcome.trustBackend, also exposed process-globally via NtsTrustStatus.defaultClientBackend.
NtsError Sealed class: invalidSpec, network, keProtocol, ntpProtocol, authentication, timeout(TimeoutPhase), noCookies, trustBackendUnavailable, internal, abiMismatch, clockFault(stage: ClockFaultStage, fault: StrictClockError, generation, trustBackend?). clockFault means the clock the call was bound to could not vouch for the result; every entry point can raise it, with or without a context, because the native core runs its own clock reads strictly for every caller.
ClockFaultStage admission, handshake, session, udp, receipt, awaitResult, attribution, projection. Carried on NtsError.clockFault to name where in the call the read that faulted served. The first five are native stages any call can report; the last three are Dart-authored and fire only on the strict surfaces (ntsGetTimeStrict, NtsClient.getTimeStrict, or ntsQuery / ntsWarmCookies given context).

ntsGetTime, ntsQuery, and ntsWarmCookies ship as a hand-written wrapper around the bundled FFI surface; consumers can omit timeout, dnsConcurrencyCap, bridgeConcurrencyCap, and verificationTime to inherit the package defaults, and future internal-only Rust signature changes do not propagate as breaking call-site edits. See ARCHITECTURE.md's "Public API stability layer" section for the contract.

Three tuning parameters deserve a short orientation here; the full mechanics live in ARCHITECTURE.md ("Timeout budget and bounded DNS", "Bridge admission gate") and in each constant's dartdoc.

  • timeout is a single wall-clock budget anchored at the start of the call: DNS, TCP connect, TLS handshake, KE record I/O, and the UDP exchange all draw from one shrinking deadline, so no phase can stretch the total cost past the caller's budget. Exhaustion at any phase surfaces as NtsError.timeout carrying the TimeoutPhase that ran out; use a switch expression on NtsError for exhaustive failure handling. The FFI boundary is millisecond-resolution: a sub-millisecond timeout component is rounded up to the next whole millisecond, and sub-millisecond verificationTime precision is truncated to whole milliseconds since the epoch — microseconds do not round-trip through either parameter.
  • dnsConcurrencyCap (default 4) bounds in-flight getaddrinfo worker threads process-wide. getaddrinfo is non-cancellable, so a stalled lookup is detached rather than killed; the cap bounds thread-stack accumulation when a resolver blackholes traffic. Values must lie in 1..4294967295 (0 is rejected as NtsError.invalidSpec); saturation surfaces as NtsError.timeout.
  • bridgeConcurrencyCap (default 4) bounds how many of this package's calls occupy flutter_rust_bridge worker threads at once. Excess calls queue on the Dart side holding no worker; queue wait is charged against timeout, and a budget that expires while queued fails with TimeoutPhase.bridgeSaturation without ever dispatching. Same validation range as the DNS cap; the gate is isolate-local, while the DNS counter is process-wide.

The two caps compose rather than conflict: with the bridge cap at or below the DNS cap (the defaults are both 4), live calls alone can never saturate the DNS pool. For high distinct-host fan-out, raise both caps together; ARCHITECTURE.md covers the mixed-cap admission semantics and the skewed-cap trade-offs.

Running the examples

Everything above covers integrating nts as a library dependency in your own app. The repository additionally ships runnable reference surfaces under example/, in increasing order of complexity:

  • example/main.dart — the minimal single-file usage snippet: one authenticated NTPv4 query plus an exhaustive NtsError switch. Start here.

  • Flutter GUI (example/lib/) — visual showcase with a server catalog, favourites, region filtering, and a unified live log:

    cd example
    flutter run -d macos -t lib/main.dart
    

    The GUI drives the real Rust bridge by default and falls back to an in-memory mock (with an explanatory banner) if the dylib cannot be loaded; pass --dart-define=NTS_BRIDGE=mock to opt into the mock explicitly. See the GUI User Manual for navigation, the NTS Query / Warm Cookies / Get Time actions, the legacy / strict / compare clock modes, and how to read the status banners.

  • Dart CLI (example/bin/nts_cli.dart) — scriptable companion for batched probing, cron jobs, and CI smoke checks. See the CLI User Manual for the positional host arguments and the --port / --timeout / --warm / --mock / --json / --exit-on-error flags.

All surfaces share the same Rust-backed bridge and the same formatting helpers; see the example README for the internal wiring.

Version compatibility

nts depends on flutter_rust_bridge with an exact pin rather than a caret range. The Dart code generated into lib/src/ffi/ and the Rust runtime crate linked into libnts_rust share a wire format that upstream does not guarantee to be stable across minor versions. A mismatched pair does not fail to compile or throw — it misreads each other's memory layout, so the failure mode is silent memory corruption. The pin makes the mismatch a resolution error instead.

The consequence is that your app's flutter_rust_bridge constraint must admit exactly the version nts pins:

nts version Required flutter_rust_bridge
9.3.x – 10.0.x 2.13.0
9.2.x and earlier 2.12.0

If your app does not depend on flutter_rust_bridge directly there is nothing to do — pub resolves the transitive pin for you. If it does, and resolution fails, match the constraint to the row above rather than adding a dependency_overrides entry: an override silences the resolver but reintroduces the corruption the pin exists to prevent.

Technical reference

For internals, contribution workflow, and operational tuning:

  • CONTRIBUTING.md — prerequisites, the branch and pull request loop, the quality gates a change must clear, and the code conventions this repository enforces.
  • ARCHITECTURE.md — Dart ↔ FRB ↔ Rust layering, module-by-module breakdown of the Rust crate, and the repository layout.
  • DEVELOPMENT.md — Rust toolchain, regenerating FRB bindings via check_bindings.dart (which also serves as the drift gate), running Rust / Dart tests, and the verbose_logs Native Assets user-define for enabling rustls trace output.
  • RFC 8915 — Official IETF specification for Network Time Security.

Contact

Maintainer: Nicholas Llewellyn — nllewelln@gmail.com. For bugs and feature requests, prefer GitHub issues; for private security reports, see SECURITY.md.

License

MIT. See LICENSE.

Libraries

nts
Authenticated Network Time Security (RFC 8915) for Dart and Flutter.