nts 10.0.0
nts: ^10.0.0 copied to clipboard
Authenticated network time for Flutter apps, secured by Network Time Security (NTS).
nts #
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 ntsfrom a pure-Dart project will not resolve. -
rustupon yourPATH. The protocol core ships as Rust source and is compiled on your machine by the Flutter Native Assets build hook the first time you runflutter run/flutter build. The hook readsrust/rust-toolchain.tomlvia rustup, which automatically installs the pinned toolchain (currently Rust 1.98.0) and the cross-compile target for the platform being built. No manualcargoinvocation, 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 … | shone-liner from rustup.rs, orbrew install rustupon Homebrew. - Windows: download and run
rustup-init.exefrom 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
PATHis picked up, and verify withrustup --version.The same mechanism covers toolchain upgrades: when a release of this package bumps the pin in
rust/rust-toolchain.toml, the nextflutter run/flutter buildauto-downloads the new version — norustup updateor other manual step is needed. The superseded toolchain stays on disk; reclaim the space withrustup toolchain uninstall <old-version>if you like. - macOS / Linux: run the
-
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.sothat 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 thendkVersionyour app's Gradle script pins — the Native Assets build resolves its toolchain from the SDK independently, so raising or loweringndkVersiondoes 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.
-
Native platform bootstrap (Android only, automatic). On Android the bundled
NtsPlugincaptures theJavaVM+ applicationContextthatrustls-platform-verifierneeds to reach the systemX509TrustManager. It runs fromGeneratedPluginRegistrantbefore Dartmain()executes, so addingntsto yourpubspec.yamlis enough — there is noMainActivityshim, JNI symbol, orapp/build.gradle.ktsMaven entry to maintain on the default Flutter/Gradle setup. (ThePREFER_SETTINGSexception 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 callcom.nllewellyn.nts.PlatformInit.init(context)from Kotlin directly; see the KDoc on that class. -
Dart/FRB initialization (
await NtsBridge.ensureInitialized(), every platform, manual). This loads the bundled Rust dylib through the Native Assets pipeline and wires theflutter_rust_bridgev2 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. CallingntsGetTime,ntsQuery, orntsWarmCookiesbefore it resolves raises an error. In a Flutter app, do it right afterWidgetsFlutterBinding.ensureInitialized()inmain(); 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 ownapi:, for instance). It rejects a call made while the entrypoint holds state, throwing aStateError; eitherNtsBridge.dispose()or the rawNtsRustLib.dispose()clears that state, so outside a deliberate teardown the first call is the only one that succeeds. PreferNtsBridge.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 thetimeout,dnsConcurrencyCap, andbridgeConcurrencyCapknobs thatntsGetTimefixes 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 returnedNtsWarmCookiesOutcome.freshCookiesreports how many single-use cookies the server delivered (RFC 8915 §4 leaves the pool size to server policy), which bounds how manyntsQuerycalls 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-defaultTrustModes. Carries per-clientquery/warmCookies/getTimetwins plusinvalidate/clearsession management. See Security considerations below.
A hand-rolled synchronized clock adds two layers on top of raw
samples — the same two ntsGetTime automates:
-
Burst sampling. A single NTPv4 reply carries whatever jitter the network and the server's queueing happened to introduce on that one packet. Calling
ntsWarmCookiesonce and thenntsQueryseveral 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 (peerDelayMicroswhen it falls inside(0, roundTripMicros], elseroundTripMicros); 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 withroundTripMicros(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 intervalpaddspto δ andp/2tooffsetMicros, so taking the fallback keepspout of the delay halved below; it does not undo thep/2in the offset. See thepeerDelayMicrosdartdoc for the allowance to measure when applying a bound of your own. More sophisticated callers can median-filter, score byserverStratum, or run Marzullo's algorithm across multiple servers. -
Symmetric-path delay compensation.
utcUnixMicrosis 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 asutcUnixMicrosplus 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_BOOTTIMEon Android/Linux,mach_continuous_timeon iOS/macOS,QueryInterruptTimePreciseon Windows), exposed publicly asMonotonicClock. Unlike Dart'sStopwatch, readings keep advancing while the device is in deep sleep, so theNtsSyncedTime.utcNowprojection stays correct across suspend/resume and an in-flightgetTimebudget 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.
roundTripMicrosis measured monotonically around the UDP round-trip, which matters because it is both the selection ceiling for the peer delay and the fallback delay drivingntsGetTime's burst selection anddelay / 2compensation 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()orelapsedSince()throws that error and leaves the context permanently invalid. - Lifecycle events invalidate. After
NtsBridge.dispose(), a rawNtsRustLib.dispose()/init(), an explicitinvalidate(), or an advance of the native generation, the nextnow()/elapsedSince()throwsStrictClockInvalidatedwith the matchingreason. 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 throwsStrictClockSourceIncompatible,StrictClockDescriptorIncompatibleorStrictClockGenerationIncompatiblewithout reading the clock, and the context stays valid. isValidis not a liveness probe.isValidandinvalidationReasonreport only failures this context has already observed. A reset or generation advance elsewhere shows up on the next read, so callnow()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
microsor compare them across processes or reboots. ClockSourceDescriptorvalues can be persisted and compared withisCompatibleWith. 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 withStrictClockBootScopeUnavailableand reasonBootScopeUnavailableReason.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 rawNtsRustLib.dispose()followed byinitMock(api:)with the same object is not detected; useNtsBridge.dispose()when a reset must be observed. - Raw readings cannot be upgraded. A stored
microsinteger, aMonotonicClockreading or anNtsSyncedTimeanchor has no export provenance, and wrapping it in the publicSameBootReferenceconstructor does not give it any. That constructor exists to rebuild a reference the consumer exported and kept in storage it authenticates.exportReferenceaccepts only aStrictSyncedTimebound to the exporting context. - Reference time is not export time. A
SameBootReferencecarries the instant itsStrictSyncedTimewas 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.bootsessionuuidis 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..65535asNtsError.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.
timeoutis 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 asNtsError.timeoutcarrying theTimeoutPhasethat ran out; use aswitchexpression onNtsErrorfor exhaustive failure handling. The FFI boundary is millisecond-resolution: a sub-millisecondtimeoutcomponent is rounded up to the next whole millisecond, and sub-millisecondverificationTimeprecision is truncated to whole milliseconds since the epoch — microseconds do not round-trip through either parameter.dnsConcurrencyCap(default 4) bounds in-flightgetaddrinfoworker threads process-wide.getaddrinfois 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 in1..4294967295(0is rejected asNtsError.invalidSpec); saturation surfaces asNtsError.timeout.bridgeConcurrencyCap(default 4) bounds how many of this package's calls occupyflutter_rust_bridgeworker threads at once. Excess calls queue on the Dart side holding no worker; queue wait is charged againsttimeout, and a budget that expires while queued fails withTimeoutPhase.bridgeSaturationwithout 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 exhaustiveNtsErrorswitch. 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.dartThe 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=mockto 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-errorflags.
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 theverbose_logsNative Assets user-define for enablingrustlstrace 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.
