nts 10.0.1
nts: ^10.0.1 copied to clipboard
Authenticated network time for Flutter apps, secured by Network Time Security (NTS).
nts — minimal usage snippet #
The smallest end-to-end use of package:nts: a warm-then-burst flow
against time.cloudflare.com over RFC 8915 NTS-KE that picks the
lowest-RTT sample and applies symmetric-path delay compensation, with
an exhaustive switch on every NtsError variant.
ntsQuery deliberately returns the raw protocol primitives (server
transmit timestamp + measured RTT) rather than a finished synchronized
clock; this snippet shows the minimum filtering and offset correction
a production caller needs when composing the primitives by hand. Most
callers can skip all of it and use the one-call ntsGetTime
convenience instead — see the README's "Quick start" and "Manual
control (advanced primitives)" sections for the rationale.
The same code ships as a runnable Flutter target at
example/main.dart.
// Minimal `package:nts` usage example — a warm-then-burst flow against
// `time.cloudflare.com` over RFC 8915. Phase 1 fills the per-host cookie
// jar with a fresh NTS-KE handshake; phase 2 spends every cookie the
// server delivered on authenticated NTPv4 exchanges, picks the
// lowest-RTT reply, and applies the standard symmetric-path delay
// correction to recover the server's clock at the moment the chosen
// reply arrived.
//
// `ntsQuery` returns the raw protocol primitives — server transmit
// timestamp plus measured round-trip time — not a finished synchronized
// clock. The burst-and-pick pattern below is the minimum a production
// caller needs on top to get a stable offset; the one-call `ntsGetTime`
// convenience automates this recipe (selecting on the RFC 5905 peer
// delay each sample also carries, which additionally excludes server
// processing time). See `README.md`'s "Manual control (advanced
// primitives)" section for the full rationale.
//
// Run from a Flutter target (`flutter run -t example/main.dart`)
// so the Native Assets pipeline bundles the Rust dylib into the app.
// `dart run` also runs the build hook (Dart 3.10+), but the bridge's
// default loader does not load the library it builds; see
// `example/bin/nts_cli.dart` for the explicit `ExternalLibrary.open`
// loader pattern needed there.
// ignore_for_file: avoid_print
import 'package:nts/nts.dart';
Future<void> main() async {
// Bridge bootstrap. Resolves the bundled `libnts_rust.{dylib|so|dll}`
// through the stable Native Assets API and wires the FRB dispatch
// table. Must be awaited before any nts* entry point; repeated and
// concurrent calls converge on the first rather than re-initializing.
await NtsBridge.ensureInitialized();
// RFC 8915 NTS-KE endpoint. Port 4460 is the IANA-assigned default;
// any host listed at <https://github.com/jauderho/nts-servers> works.
const spec = NtsServerSpec(host: 'time.cloudflare.com', port: 4460);
try {
// Phase 1 — warm the cookie jar. Forces a fresh TLS 1.3 NTS-KE
// handshake against `spec`, ingests the delivered cookie pool, and
// returns an `NtsWarmCookiesOutcome` whose `freshCookies` field
// reports how many cookies the server handed out alongside a
// `phaseTimings` breakdown of the handshake's wall-clock cost.
// RFC 8915 §4 leaves that count to server policy, so the burst
// below is sized off this return value rather than any fixed
// constant. Replaces any cached session for that `spec`, so
// subsequent `ntsQuery` calls skip the KE leg until the jar
// drains. Useful at startup or whenever the NTS-KE cost should be
// amortized away from a time-critical path. `timeout` and
// `dnsConcurrencyCap` are omitted here so the package's tuned
// defaults (`kDefaultTimeout`, `kDefaultDnsConcurrencyCap`)
// apply; `bridgeConcurrencyCap` is passed explicitly (at its
// default value) to surface the Dart-side admission gate that
// bounds how many of the package's calls occupy
// `flutter_rust_bridge` worker threads at once.
final warmed = await ntsWarmCookies(
spec: spec,
bridgeConcurrencyCap: kDefaultBridgeConcurrencyCap,
);
print('warmed = ${warmed.freshCookies} cookies');
// Phase 2 — spend the warmed cookies on authenticated NTPv4
// exchanges. Each `ntsQuery` reuses the warmed AEAD keys, so the
// steady-state cost is one UDP round-trip per call. Collect every
// sample so we can score them against each other below.
final samples = <NtsTimeSample>[];
for (var i = 0; i < warmed.freshCookies; i++) {
samples.add(
await ntsQuery(
spec: spec,
bridgeConcurrencyCap: kDefaultBridgeConcurrencyCap,
),
);
}
// Pick the sample with the smallest measured round-trip. NTP's
// symmetric-path assumption is most accurate when the path is
// shortest, so the lowest-RTT reply yields the smallest residual
// offset error after compensation. This is the basic statistical
// filter every production NTP/NTS client implements; more
// sophisticated callers can also weight by stratum or run Marzullo's
// algorithm across multiple servers.
final best = samples.reduce(
(a, b) => a.roundTripMicros <= b.roundTripMicros ? a : b,
);
// Apply the standard symmetric-path correction: assume the one-way
// delay is half the round-trip, so the server's clock at the moment
// its reply landed locally is `utc_unix_micros + rtt / 2`. The
// package returns the raw server transmit timestamp on purpose;
// applying this offset is the caller's responsibility because the
// "right" filter (median, lowest-RTT, Marzullo, …) is workload
// specific.
final adjustedMicros = best.utcUnixMicros + (best.roundTripMicros ~/ 2);
final adjustedUtc = DateTime.fromMicrosecondsSinceEpoch(
adjustedMicros,
isUtc: true,
);
final rttMs = best.roundTripMicros / 1000.0;
print('samples = ${samples.length}');
print('best-rtt = ${rttMs.toStringAsFixed(2)} ms');
print('utc = ${adjustedUtc.toIso8601String()} (RTT/2-compensated)');
print('stratum = ${best.serverStratum}');
print('aead-id = ${best.aeadId}');
print('cookies = ${best.freshCookies}');
} on NtsError catch (err) {
// `NtsError` is a Dart 3 sealed class — exhaustive switch
// expressions catch new variants at compile time if the package
// ever grows them. Both `ntsWarmCookies` and `ntsQuery` surface
// failures through this same hierarchy.
final detail = switch (err) {
NtsErrorInvalidSpec(:final message) => 'invalid spec: $message',
NtsErrorNetwork(:final message) => 'network: $message',
NtsErrorKeProtocol(:final message) => 'NTS-KE: $message',
NtsErrorNtpProtocol(:final message) => 'NTP: $message',
NtsErrorAuthentication(:final message) => 'AEAD auth: $message',
NtsErrorTimeout(:final phase) => 'timeout in phase ${phase.name}',
NtsErrorNoCookies() => 'no cookies returned',
NtsErrorTrustBackendUnavailable(:final message) =>
'trust backend unavailable: $message',
NtsErrorInternal(:final message) => 'internal: $message',
NtsErrorAbiMismatch(:final message) => 'ABI mismatch: $message',
NtsErrorClockFault(:final stage, :final fault) =>
'clock fault in stage ${stage.name}: ${fault.message}',
};
print('nts call failed: $detail');
}
}
Strict clock #
New in 10.0. The strict path fails closed instead of falling back to a clock it cannot attribute. This snippet uses it locally, with no boot identity, and shows that same-boot transfer is refused: the package approves no boot-scope provider in this release, so an app-written one is rejected before it is consulted. It does not persist anything; authenticating and storing a reference is the consumer's job.
// ignore_for_file: avoid_print
import 'package:nts/nts.dart';
/// A provider the app wrote itself. Providers are approved by
/// instance through `kApprovedBootScopeProviders`, which ships empty.
final class AppBootScopeProvider implements BootScopeProvider {
const AppBootScopeProvider();
@override
String get providerId => 'com.example.boot-scope';
@override
Future<BootScope?> current() async => null;
}
Future<void> main() async {
await NtsBridge.ensureInitialized();
final StrictClockContext clock;
try {
clock = StrictClockContext.resolve();
} on StrictClockError catch (e) {
// Fail closed: no MonotonicClock or DateTime.now() substitute.
print('strict clock unavailable: $e');
return;
}
const spec = NtsServerSpec(host: 'time.cloudflare.com', port: 4460);
final StrictSyncedTime synced;
try {
synced = await ntsGetTimeStrict(spec: spec, context: clock);
} on NtsError catch (e) {
// Includes NtsErrorClockFault when the clock could not vouch.
print('no strict time: $e');
return;
}
try {
// Local strict use: synchronous reads, no I/O, no boot identity.
print('authenticated utc = ${synced.utcNow()}');
// Transfer needs an approved provider; none is approved.
try {
await clock.exportReference(
synced,
provider: const AppBootScopeProvider(),
);
} on StrictClockBootScopeUnavailable catch (e) {
// A scope refusal leaves the context valid.
print('transfer refused: ${e.reason.name}');
}
print('sync age = ${synced.elapsedSinceSync()}');
} on StrictClockError catch (e) {
// `clock` is invalid; resolve a new context and sync again.
print('strict clock failed: $e');
}
}
Want the GUI? #
The full Flutter showcase lives at
example/lib/main.dart and is invoked with an
explicit target:
# from the example directory
flutter run -t lib/main.dart
# from the repo root
flutter run -t example/lib/main.dart
A clock-mode selector beside the trust-mode dropdown picks what
Get Time runs: Legacy clock (NtsClient.getTime, anchored on
the best-effort MonotonicClock), Strict clock
(NtsClient.getTimeStrict on a resolved StrictClockContext, as in
the snippet above) or Compare clocks (both against the same
server, logged side by side). In the strict modes a panel shows the
context's provenance, source and generation, with Re-resolve,
Invalidate and Export reference actions. Under the mock bridge
the strict modes report the clock as unavailable, because
StrictClockContext.resolve refuses a mock. See the
GUI User Manual for details.
See example/README.md for the three entry points
(snippet, GUI, CLI) and the bridge-mode toggles.
