nts 2.0.0 copy "nts: ^2.0.0" to clipboard
nts: ^2.0.0 copied to clipboard

Authenticated network time for Flutter apps, secured by Network Time Security (NTS).

Changelog #

2.0.0 #

Adds first-class phase attribution to the public NTS surface so callers diagnosing a slow or refused query can distinguish DNS saturation, a slow getaddrinfo, a stalled TCP connect, a slow TLS handshake, a trickled NTS-KE record exchange, and a slow UDP NTP round-trip without inspecting free-form diagnostic strings or bolting a Dart-side Stopwatch around ntsQuery. The Rust crate nts_rust is bumped to 0.4.0 to reflect a breaking change in the public NTS API surface; the Dart package is bumped to 2.0.0 for the matching breaking change in the FFI signatures and the NtsError::Timeout payload (closes nts-r2l).

Breaking changes #

  • NtsError::Timeout now carries a TimeoutPhase payload identifying which phase of the call hit the budget. Existing pattern matches on NtsError::Timeout (Rust) or NtsError_Timeout() (Dart) need to bind the new field; pre-2.0 consumers that ignored the variant data with () will not compile against this release.
  • nts_warm_cookies now returns NtsWarmCookiesOutcome { fresh_cookies, phase_timings } instead of a bare u32 (Rust) / int (Dart). The cookie count is still available via outcome.fresh_cookies; the new phase_timings field exposes the same per-phase wall-clock breakdown as NtsTimeSample.phase_timings.
  • NtsTimeSample gains a required phase_timings: PhaseTimings field. Constructors that named every existing field will need to supply the new field; the Dart-side equivalent applies to any test fixture or mock that builds an NtsTimeSample by hand.

Phase attribution and timings #

  • New TimeoutPhase enum tags NtsError::Timeout. Variants DnsSaturation (resolver pool full, raise dns_concurrency_cap), DnsTimeout (resolver slow, lengthen timeout_ms or replace the recursive resolver), Connect, Tls, KeRecordIo, and Ntp cover every blocking phase of nts_query / nts_warm_cookies.
  • New PhaseTimings struct exposes microsecond-resolution wall-clock costs for the four pre-NTP phases (dns_micros, connect_micros, tls_handshake_micros, ke_record_io_micros); the existing NtsTimeSample::round_trip_micros is the UDP-phase equivalent and is intentionally not duplicated. dns_micros is summed across the KE-host and NTPv4-host lookups; phases that did not run in this call are reported as 0 rather than absent. See the new "Phase attribution and timings" section in ARCHITECTURE.md for the full diagnostic shape.
  • nts_query instruments the KE pipeline (DNS, connect, TLS, KE record I/O) inside perform_handshake and threads the timings out through a refactored KeOutcome.phase_timings; the UDP-path DNS cost is captured in bind_connected_udp_using and folded into the same dns_micros field on the returned sample.
  • nts_warm_cookies exposes the same KE-phase breakdown via NtsWarmCookiesOutcome.phase_timings. The UDP NTP exchange does not run on this path, so the Ntp phase is implicitly zero.
  • nts_query now anchors a single call-wide wall-clock at the top of the call and subtracts the time consumed by the KE phases before arming the UDP-setup deadline. Restores the documented "single global wall-clock budget" contract on timeout_ms; previously a cold query whose KE phases consumed most of timeout_ms would re-anchor a fresh timeout_ms-long window for the UDP leg, letting the total wall-clock reach roughly 2x the caller's budget before surfacing as Timeout(Ntp). A budget that was already exhausted by the KE phases now short-circuits with Timeout(Ntp) immediately rather than entering the UDP-setup leg at all.

Tooling: orphan detection in the FRB drift check (no runtime impact) #

  • tool/check_bindings.dart now runs _checkForOrphanedApiModules after codegen + lint patches + format and before the trailing git diff drift check. The check walks lib/src/ffi/api/*.dart (skipping *.freezed.dart and *.g.dart companions, which are emitted from part directives in the primary file rather than referenced from the dispatcher) and flags any primary module file the regenerated lib/src/ffi/frb_generated.dart does not import. Closes the FRB stale-module footgun: when the last pub item is removed from a rust/src/api/<module>.rs, FRB drops the wire impls from frb_generated.{rs,dart} but leaves the previously emitted lib/src/ffi/api/<module>.dart on disk. The stale module then references symbols that no longer exist in the dispatcher and surfaces as an opaque "symbol not found in RustLibApi" build break under flutter analyze / flutter test rather than at codegen time. The dispatcher's import 'api/<basename>.dart'; line set is the authoritative "still contributing" stand-in: FRB writes one such import for every Rust source under rust/src/api/ that contributed at least one FRB-visible item on the most recent codegen run, so running the check after codegen guarantees the import set is current regardless of what is committed.
  • Detection is read-only on purpose. Auto-deleting risks papering over a removal that wasn't intended; the diagnostic instructs the developer to remove the orphan (and any *.freezed.dart / *.g.dart companions) explicitly. The orphan list is sorted before printing so the diagnostic renders deterministically across filesystems with different Directory.listSync iteration orders (APFS, ext4, etc. differ). Local invocation produces error: prefixed lines; CI invocation under GITHUB_ACTIONS=true emits the same body with ::error:: so the rust-bridge-sync job surfaces it as a workflow annotation. Exit code is 1 on the orphan path, failing the job explicitly on the orphan diagnostic rather than implicitly via trailing drift. Header comment in tool/check_bindings.dart is rewritten to document the orphan check and its rationale (closes nts-lmf).

Coverage artefact ignore at any depth #

  • .gitignore gains an unanchored coverage/ entry. flutter test --coverage writes coverage/lcov.info at the package root, and cargo tarpaulin --output-dir coverage (configured in rust/tarpaulin.toml) writes rust/coverage/lcov.info. Both are local artefacts: each CI run regenerates them and uploads to Codecov directly from .github/workflows/ci.yml, so the in-tree copies are never consumed by anything downstream. The unanchored pattern catches both paths above; example/coverage/ was already covered by example/.gitignore:34, so no duplication.

1.4.0 #

Converts nts from a pure Dart package using the Native Assets pipeline into a full Flutter plugin so that downstream consumers can use the package on Android without having to replicate the Rust ↔ Kotlin JNI bootstrap, the rustls-platform-verifier-android Maven repository discovery, or the R8 keep-rule contract by hand. No Dart API surface change (public exports unchanged; only dartdoc was updated to document the two-layer initialization model) and no FRB pin movement; the Rust crate nts_rust is bumped to 0.3.0 to reflect a breaking JNI ABI change. Dart package version bumped to 1.4.0 (minor).

Auto-initialised Android rustls-platform-verifier bootstrap #

  • New plugin module under the package root at android/. It ships:

    • com.nllewellyn.nts.NtsPlugin — a FlutterPlugin that calls PlatformInit.init(applicationContext) from onAttachedToEngine. GeneratedPluginRegistrant.registerWith runs that hook before Dart main() in any host using FlutterActivity, FlutterFragmentActivity, or the Flutter add-to-app FlutterEngine lifecycle, so the rustls-platform-verifier panic (Expect rustls-platform-verifier to be initialized…) is no longer reachable through the standard integration path.
    • com.nllewellyn.nts.PlatformInit — the matched JNI Kotlin counterpart for the Rust symbol exported from rust/src/android_init.rs. Also exposes a public static init(Context) for hosts that bypass GeneratedPluginRegistrant (rare; mainly bespoke add-to-app embeddings or tests that drive the dylib directly).
    • consumer-rules.pro — ProGuard / R8 keep rules covering both the rustls-platform-verifier companion AAR (org.rustls.platformverifier.**) and our own JNI shim (com.nllewellyn.nts.PlatformInit). Auto-merged into the host app's shrinker config; consumers do not have to copy keep rules.
    • build.gradle.kts — discovers the on-disk Maven repository bundled inside the rustls-platform-verifier-android cargo crate via cargo metadata, so the AAR resolves regardless of whether nts is installed from a path dependency, the pub cache, or a monorepo, on hosts that use the default Flutter/Gradle repository setup. Replaces the brittle ../../rust/Cargo.toml traversal that previously lived in example/android/app/build.gradle.kts and only worked from the example tree. Hosts that enable dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS in settings.gradle.kts are the documented exception: that mode rejects the project-level Maven injection the plugin performs through rootProject.allprojects { ... }, so those hosts must declare the on-disk repository themselves under dependencyResolutionManagement.repositories in settings.gradle.kts. The cargo-metadata path is stable and can be reused verbatim; the rationale comment in android/build.gradle.kts carries the full constraint.

    Native code (libnts_rust.so) continues to be delivered by the Native Assets pipeline (hook/build.dart); the plugin module ships no jniLibs/ and does no Cargo wiring of its own. Platforms other than Android are untouched: iOS / macOS / Linux / Windows remain pure Native-Assets packages with no accompanying plugin module.

Stable JNI symbol under reverse-DNS namespace (BREAKING ABI) #

  • The JNI entry point exported from rust/src/android_init.rs is renamed from Java_com_nts_example_RustlsBootstrap_nativeInit to Java_com_nllewellyn_nts_PlatformInit_nativeInit. The previous symbol was mangled for the example app's package name, which is not a contract any downstream consumer can reasonably satisfy (renaming the symbol locally would diverge from upstream releases on every pull). The new FQDN is under the maintainer's reverse-DNS namespace and is documented as the stable public ABI.
    • Impact: any host application that previously hand-rolled a matching com.nts.example.RustlsBootstrap Kotlin class plus keep rules (i.e. only the example app shipped in this repository, given the 1.3.x contract was effectively unconsumable) must drop that class. The plugin's auto-init replaces the manual wiring; flutter pub upgrade plus flutter clean is sufficient.

Migrating from 1.3.x #

  • Out-of-tree consumers that hand-rolled the 1.3.x Android contract must drop the manual scaffolding when bumping to 1.4.0. The JNI symbol moved to the maintainer's reverse-DNS namespace (Java_com_nllewellyn_nts_PlatformInit_nativeInit), so the legacy external fun nativeInit declaration on a host-app shim no longer resolves against the dylib's exports. System.loadLibrary("nts_rust") still succeeds (the library itself loads), but the first invocation of the unbound declaration throws UnsatisfiedLinkError: No implementation found for void com.<host>.RustlsBootstrap.nativeInit(android.content.Context). In the documented 1.3.x integration shape that fires from MainActivity.onCreate before super.onCreate(...), so the host app crashes at process start, well before any TLS handshake is attempted. The plugin contributes equivalent functionality; one round of flutter pub upgrade + flutter clean is sufficient once the items below are removed.
  • Host-app RustlsBootstrap.kt (or any equivalent class whose FQDN was used to mangle the Java_*_nativeInit symbol exported from rust/src/android_init.rs). Delete the file. The plugin ships com.nllewellyn.nts.PlatformInit as the new stable counterpart; nothing in the host app needs to know its name.
  • MainActivity.onCreate shim that called RustlsBootstrap.init(this) ahead of super.onCreate(...). Revert MainActivity to a no-body FlutterActivity. The plugin's onAttachedToEngine runs from GeneratedPluginRegistrant before Dart main() executes, so any code path that reached the bootstrap before reaches it now without the manual call.
  • app/build.gradle.kts entries that wired up the rustls-platform-verifier-android Maven repository: the findRustlsPlatformVerifierMaven() helper (or whichever shape it took locally), the repositories { maven { url = uri(...) } metadataSources { artifact() } } block, and the implementation("rustls:rustls-platform-verifier:0.1.1@aar") dependency. All three are contributed by the plugin's own android/build.gradle.kts. Leaving them in place resolves the AAR twice; harmless at runtime but it wastes Gradle resolution time and pins the consumer to a version the plugin will move out from under them.
  • proguard-rules.pro keep rules covering org.rustls.platformverifier.** and the host-app's own RustlsBootstrap JNI class. Both are now in the plugin's consumer-rules.pro and auto-merged into the host's R8 config. The RustlsBootstrap-flavoured rule is doubly stale because the symbol name has changed; an unmodified rule keeps a class that no longer exists and produces no shrinker warning either way.
  • settings.gradle.kts does not change. The dev.flutter.flutter-plugin-loader block is the standard Flutter wiring that picks up the new plugin module automatically; no manual include, pluginManagement, or Maven entry is required.
  • Custom embeddings. Hosts that legitimately bypass GeneratedPluginRegistrant — bespoke add-to-app integrations, integration tests driving the dylib directly, isolates spawned ahead of plugin registration — should call com.nllewellyn.nts.PlatformInit.init(context) from Kotlin in place of the deleted RustlsBootstrap.init(...). The signature and idempotency contract are identical; only the FQDN moves.
  • Hosts that did not hand-roll the 1.3.x Android contract have nothing to do beyond flutter pub upgrade + flutter clean. The previous JNI symbol was mangled for the example app's package name and could not be satisfied without forking the Rust crate, so the realistic pre-1.4.0 integration path for any out-of-tree consumer was a vendored copy of this repository with the symbol renamed locally — that fork should be retired in favour of the published 1.4.0 plugin and the stable com.nllewellyn.nts.PlatformInit symbol.

Non-Android upgrade path #

iOS, macOS, Linux, and Windows consumers are unaffected by the migration steps above. The android/ plugin module and the flutter: plugin: platforms: android: key in pubspec.yaml are scoped exclusively to Android — the Flutter tool generates no plugin registration on other targets, no ios/ / macos/ / linux/ / windows/ plugin module exists to compile or link, and the Native Assets pipeline that delivers libnts_rust.{so,dylib,dll} is unchanged. The public Dart API exported from lib/nts.dart is unchanged, and await RustLib.init() remains the only initialization step. The upgrade is a single-line pubspec.yaml bump.

The on-platform TLS validators are also unchanged: every non-Android target continues to use rustls-platform-verifier 0.5 directly, which talks to the Security framework on iOS/macOS, the system trust store on Linux, and the Win32 Crypt* APIs on Windows without any host-side initialization step. None of these paths require JVM-style bootstrap, which is why the "Native platform bootstrap" layer documented in the README is Android-only.

Example app simplified to a vanilla FlutterActivity #

  • example/android/app/src/main/kotlin/com/nts/example/RustlsBootstrap.kt removed. Its responsibilities are now split between the plugin's NtsPlugin (registration-time auto-init) and PlatformInit (manual fallback).
  • example/android/app/src/main/kotlin/com/nts/example/MainActivity.kt reverted to a no-body FlutterActivity. The Android trust-store bootstrap happens before super.onCreate() runs.
  • example/android/app/build.gradle.kts no longer carries the findRustlsPlatformVerifierMaven() helper, the rustls:rustls-platform-verifier:0.1.1@aar implementation dep, or the local Maven repository declaration. All three now live in the plugin's own android/build.gradle.kts.
  • example/android/app/proguard-rules.pro reduced to a stub comment. The keep rules previously declared here are merged in from the plugin's consumer-rules.pro.

Internal documentation refresh #

  • rust/src/lib.rs, rust/src/android_init.rs, and rust/src/nts/hybrid_verifier.rs updated to reference the new Kotlin FQDN and the plugin's consumer-rules.pro rather than the decommissioned RustlsBootstrap.kt in the example app. The hybrid_verifier warn-level fallback message that fires when R8 has stripped org.rustls.platformverifier.* now points operators at the plugin's keep-rule file.

1.3.2 #

Repo-policy and CI-hygiene cleanup, plus a single Rust-side runtime fix that closes a multi-hour recovery stall observed in downstream consumers when an NTS server rotates its master key out from under our cookie pool. No public Dart API change (lib/nts.dart is byte-identical) and no FRB pin movement; the Rust crate nts_rust is bumped to 0.2.3 to reflect the behavioural change in nts_query. Dart package version bumped to 1.3.2 (patch).

Fail-fast eviction of stale NTS sessions on rekey signals #

  • nts_query (rust/src/api/nts.rs) now evicts the cached Session for the spec on either of the two on-wire signals that indicate the server has rotated keys out from under our cookie pool. The next checkout for that host finds no entry and performs a fresh NTS-KE handshake instead of draining the remaining cookies through identical failures plus the caller's per-source exponential backoff. Closes nts-0jl.
    • AEAD authentication failure — local C2S seal in build_client_request or remote S2C verify in parse_server_response returns NtpError::Aead. The cached keys are out of step with the server's current master key.
    • RFC 8915 §5.7 NTSN Kiss-of-Death with matching UID — a standards-compliant server that cannot validate the cookie SHOULD respond with stratum 0 + reference_id=NTSN, and that response MUST NOT carry an Authenticator (the server has no usable session keys to AEAD-sign with). The AEAD-only eviction path missed this shape entirely: parse_server_response rejected it as MissingAuthenticator so the cached session survived and the same dead-pool draining symptom recurred. The new NtpError::StaleCookie arm classifies the matching-UID NTSN distinctly and routes it through the same generation-guarded eviction.
  • The eviction is gated on a generation snapshot captured at checkout time, symmetric to the guard already present in deposit_cookies. If a concurrent nts_warm_cookies (or another checkout that triggered its own re-handshake) installed a fresh session under the same key while this query was on the wire, the in-flight failure belongs to the old keys and the new session survives untouched. Without the guard a single transient signal would force every concurrent caller for the same host through a redundant re-handshake.
  • Off-path-attacker scope of the NTSN path: the matching-UID check is the only authenticity signal available (no AEAD), so an attacker who can observe one wire packet and forge a UID-matching NTSN can at worst force one extra KE handshake before the next legitimate response heals the session. A non-matching (or absent) UID falls through to MissingAuthenticator and leaves the cached session intact; unauthenticated non-NTSN kiss codes (RATE, DENY, …) do the same so a server that AEAD-signs them (the standards path) still surfaces with its kiss code, while a stripped forgery cannot trigger an eviction.
  • AEAD-error mapping verified end-to-end: From<AeadError> routes OpenFailed (tag mismatch — the dominant master-key-rotation signal), SealFailed, InvalidKeyLength, and InvalidNonceLength to NtsError::Authentication (eviction); UnsupportedAlgorithm is only reachable from the KE path and routes to KeProtocol (no eviction). From<NtpError>::Aead(_) routes the same way; wire-format, Kiss-of-Death, and Unsynchronized arms route to NtpProtocol (no eviction — those are transient or server-attested signals, not key-state failures); the new StaleCookie arm routes to NtpProtocol for the Dart-facing taxonomy, with eviction applied pre-conversion inside the evict_on_rekey_signal closure so the public NtsError enum stays byte-identical.
  • Healthy-path cost is unchanged: the trigger is a map_err closure that only acquires the sessions() mutex inside the Aead/StaleCookie arms, so success returns are byte-identical to the pre-fix behaviour.
  • Coverage: seven new tests pin the behaviour. In rust/src/api/nts.rs::tests: (i) matching-generation eviction drops the entry, jar and keys with it; (ii) stale-generation eviction is a no-op when a concurrent re-handshake has advanced the cached session; (iii) eviction is a quiet no-op when the entry is already absent; (iv) end-to-end via a loopback faux server, an AEAD tag mismatch evicts the session; (v) end-to-end, a non-AEAD protocol failure preserves it; (vi) end-to-end, a matching-UID NTSN KoD evicts; (vii) end-to-end, a wrong-UID NTSN preserves. In rust/src/nts/ntp.rs::tests: four new parser-level tests cover matching-UID NTSN → StaleCookie, wrong-UID NTSN → MissingAuthenticator, UID-less NTSN → MissingAuthenticator, and unauthenticated non-NTSN kiss codes → MissingAuthenticator.
  • The FRB-generated lib/src/ffi/api/nts.dart regains evict_session in the alphabetised "ignored because not pub" comment line; no bindings code changes (the helper is intentionally crate-private).

Branch-protection enforcement (repo policy, no runtime impact) #

  • Toggle enforce_admins: true on the main branch protection rule so the six required status checks (the four pre-existing CI gates plus the two new Hooks * checks added in this PR), linear-history requirement, and PR-only merge policy are binding for the maintainer account. The previous configuration (enforce_admins: false) made the rule advisory for repo admins, which left a direct git push origin main unblocked for the most likely violator.
  • Add repo-tracked git hooks under tool/hooks/ (POSIX shell, no runtime dependency beyond git) that refuse direct work on main/master: pre-commit blocks plain commits, pre-merge-commit blocks merge commits (which bypass pre-commit), and pre-push blocks any push whose destination ref is refs/heads/main/refs/heads/master regardless of the source branch. Activated per clone with git config core.hooksPath tool/hooks; without that activation layer 1 contributes nothing. Two commit-time bypasses remain, both caught at push time by pre-push and the GitHub-side rule: (a) rebases that rewrite local main (each replayed commit runs in detached HEAD, so pre-commit falls through), and (b) fast-forward merges (git merge feature/foo while main has no diverging commits advances the ref without creating a commit, so pre-merge-commit does not fire).
  • Document the enforcement model in AGENTS.md's new "Branch Protection" section and DEVELOPMENT.md's "Local hook setup" subsection. The model has two enforcement layers (local hooks for fast-fail, GitHub branch protection at the remote) plus CI as the upstream of the status checks the protection rule consumes. The branch protection rule itself does the merge gating: the rule refuses direct pushes from non-admin contributors, and enforce_admins: true extends that refusal to admin/owner accounts (closing the maintainer-bypass path); required_status_checks refuses the PR merge until the listed contexts pass. CI is not a separate enforcement layer, just the source of the signals the rule reads. Public Dart/Rust API unchanged.
  • CI gains two narrowly-scoped sibling jobs in .github/workflows/ci.yml plus a tool/hooks/** path classification on the existing dorny/paths-filter step, so a PR that touches only the enforcement scripts still gets validated rather than skipping every heavy job and merging unverified. Both jobs are added to required_status_checks on main, raising the required-context count from four to six:
    • Hooks shell-syntax check runs sh -n plus presence and exec-bit verification on each tracked hook (the explicit list fails closed if a hook is deleted, renamed, or chmod-stripped, where a glob would silently pass).
    • Hooks behaviour check runs tool/hooks/test_hooks.sh, a new POSIX-shell test that provisions a throwaway repo via mktemp -d, points core.hooksPath at tool/hooks/, stages real commits and real merges, and invokes pre-push directly with synthetic refs/SHAs on stdin (git's documented pre-push contract: read updates from stdin, exit non-zero to abort). Asserts on exit codes plus stderr content. Catches the regression shape sh -n cannot — a script that parses but no longer enforces policy at runtime — and carries an explicit assertion sentinel for the unquoted-heredoc class of bug where set -u aborts the hook before the recovery recipe can print. No other CI behaviour changes: the build, rust, and rust-bridge-sync filters and gates are byte-identical.

Coverage exclusion alignment #

  • Reconcile the four loci that determine the coverage denominator so local artifacts, CI flags, and the Codecov dashboard agree. .codecov.yml now ignores lib/src/ffi/api/nts.dart (FRB-generated single-expression forwarders of the form ntsQuery(...) => RustLib.instance.api.crateApiNtsNtsQuery(...) — reachable from the smoke tests but low-signal for authored-code coverage; the FFI dispatch they delegate into lives in frb_generated*.dart and is what RustLib.initMock() substitutes) and rust/src/api/simple.rs (holds only the #[frb(init)] lifecycle hook init_app, fired on dylib load and unreachable from cargo test --lib). rust/tarpaulin.toml (new) carries the same Rust exclusion set so a local cargo tarpaulin reproduces CI numbers without per-invocation --exclude-files flags. .github/workflows/ci.yml adds the matching --exclude-files 'src/api/simple.rs' and the comment block above the step now enumerates all four filtered Rust files (previously named only two). DEVELOPMENT.md's "Coverage exclusion policy" subsection is refreshed to match.

greet smoke-test stub removal #

  • Delete the greet function from rust/src/api/simple.rs (left over from the FRB scaffold; never re-exported through lib/nts.dart, so internal-only by the package's own public-API stability statement) and refresh the file header to document its remaining role as the lifecycle-hook host. lib/src/ffi/api/simple.dart is removed; FRB does not auto-clean stale module files when a Rust api/ module loses its last pub item (followup tracked as nts-lmf to extend tool/check_bindings.dart to flag this footgun). The crateApiSimpleGreet overrides in example/lib/src/mock_api.dart, test/api_smoke_test.dart, and test/ffi_smoke_test.dart are removed in the same commit; the FRB-generated layer (lib/src/ffi/frb_generated.{dart,io.dart,web.dart}, rust/src/frb_generated.rs) is regenerated via flutter_rust_bridge_codegen 2.12.0 and committed clean against the drift gate.

CI: Flutter stable channel migration #

  • Switch the Flutter SDK reference from the pinned 3.41.7 release to the stable channel across the five loci that named it: .fvmrc ("flutter": "stable"), .github/workflows/ci.yml (matrix entry renamed 3.41.7stable), pubspec.yaml, DEVELOPMENT.md, and .github/pull_request_template.md. The pinned-semver references are rewritten to describe the channel rather than a specific version. The compatibility-floor matrix leg (3.38.10, the lowest Flutter satisfying flutter: ^3.38.0 in pubspec.yaml) is unchanged so the floor remains pinned.
  • subosito/flutter-action receives flutter-version: any for the stable leg (the action's documented channel-latest sentinel, since the action does not accept channel names as flutter-version values); the format / coverage / Codecov upload gates are retargeted from matrix.flutter == '3.41.7' to matrix.flutter == 'stable'. rust-bridge-sync drops its flutter-version pin and points the FVM symlink at $HOME/fvm/versions/stable to match .fvmrc.
  • Branch-protection continuity is preserved: the matrix-leg job names (Format / analyze / Dart tests (Flutter ${{ matrix.flutter }})) are not required status checks. The Dart tests gate aggregator job (needs: [changes, build], if: always()) is the entry on main's required_status_checks list and rolls up the matrix outcome under a name that does not move with the channel rename, so the rule continues to gate merges without any branch-protection edit. The five other required contexts (Detect changed paths, Verify FRB bindings are in sync, Rust build + tests + coverage, Hooks shell-syntax check, Hooks behaviour check) are also untouched by this rename.
  • The historical 3.41.7 mention inside the ## 1.0.0 release entry below is intentionally left in place — it is a published-release entry and pub.dev archives the changelog at publish time.

1.3.1 #

Documentation-only patch on the 1.3.0 observability surface. No code, FFI, or runtime behaviour changes; the Rust crate nts_rust is unchanged at 0.2.2.

NtsDnsPoolStats — acknowledge inFlight > highWaterMark transient #

  • Tighten the dartdoc on ntsDnsPoolStats (lib/src/api/nts.dart) and the mirrored Rust docstring on NtsDnsPoolStats plus its high_water_mark field (rust/src/api/nts.rs). The 1.3.0 wording ("Monotonically non-decreasing for the lifetime of the process", "racy by construction… never logically impossible") invited the strict reading that highWaterMark >= inFlight holds at every observation point. It does not: try_acquire_slot performs the fetch_add on in_flight and the fetch_max on high_water_mark as two independent atomic operations, so a concurrent pool_snapshot() can observe inFlight = prev + 1 and highWaterMark = prev for the few-nanosecond window between them. The replacement wording calls this transient out by name and restates the actual guarantee — per-counter monotonicity across consecutive snapshots, not a cross-counter invariant within a single snapshot.
  • Rationale for documenting rather than patching snapshot_of to return max(in_flight, high_water_mark): the two Relaxed loads in the snapshot path are not atomic together, so a derived max() suppresses one common observation but does not produce a coherent point-in-time view; closing the race in the increment path requires a CAS loop on a packed (in_flight, hwm) tuple, which is not justified by an observable-only-via-snapshot diagnostic counter; and the three operator-facing failure-mode signatures (healthy / cap-bound / libc wedge — see the rest of the dartdoc) reason about per-counter trajectories across consecutive snapshots, not single-snapshot cross-counter invariants. The transient does not degrade their diagnostic value.
  • The generated FFI dartdoc in lib/src/ffi/api/nts.dart is regenerated from the Rust source and tracks the new wording. No other diff in the FRB-generated layer.

Documentation #

  • Clarify shared-pool semantics for mixed-cap callers in the rust/src/nts/dns.rs module header and the "Timeout budget and bounded DNS" section of ARCHITECTURE.md. The 1.2.0 wording — "the effective ceiling at any moment is set by whichever caller is currently being admitted" — invited a stateful reading in which the most recently admitted caller's cap somehow governs subsequent admissions. The actual mechanic is purely local: every admitted worker counts toward every caller's threshold, and each admission decision compares the live pool size against that call's own cap. The replacement wording names the asymmetric starvation behaviour explicitly (a small-cap caller can be refused when the pool is filled by a large-cap caller; the reverse cannot happen) so it is discoverable by a future ctrl-F search for "starvation" or "fairness". The published 1.2.0 changelog entry is intentionally not retroactively edited (pub.dev archives the changelog at publish time).

1.3.0 #

Public-API stability layer, bounded DNS resolver pool observability, and a documentation correction in the Rust core. Strictly additive on the Dart surface: existing call sites (including test/ffi_smoke_test.dart and the example app, GUI, and CLI) keep their current arguments and continue to compile. The Rust crate nts_rust is unchanged at 0.2.2.

Public API stability layer (lib/src/api/nts.dart, new) #

  • Introduce a hand-written wrapper in lib/src/api/nts.dart that becomes the package's stable public surface. The wrapper exposes ntsQuery and ntsWarmCookies with idiomatic Dart optional named parameters (timeoutMs, dnsConcurrencyCap) and package defaults (kDefaultTimeoutMs = 5000, kDefaultDnsConcurrencyCap = 0), forwarding to the FRB-generated bindings for the actual FFI call. await ntsQuery(spec: spec) (no other arguments) now compiles and produces the same behaviour as 1.2.0's ntsQuery(spec: spec, timeoutMs: 5000, dnsConcurrencyCap: 0).
  • Rewrite lib/nts.dart as an explicit re-export of the wrapper plus the bridge bootstrap (RustLib). The blanket re-export of lib/src/ffi/api/nts.dart (and the greet toolchain helper from lib/src/ffi/api/simple.dart) is removed; the FFI surface is now an internal implementation detail. Consumers' call sites are unchanged because the wrapper exposes the same names with compatible signatures.
  • Motivation: flutter_rust_bridge v2 codegen emits every Rust pub fn argument as a required named parameter on the Dart side, with no FRB attribute today that maps it to an optional Dart parameter with a default. Absorbing that asymmetry in a hand-written layer decouples the public contract from the FFI contract — internal Rust signature evolution (extra knobs, struct field churn, lint-pin regen) no longer propagates as breaking call-site edits for every downstream consumer. The 1.2.0 release was the concrete episode that motivated this: adding dnsConcurrencyCap was a strict superset of the previous behaviour but broke source compatibility for every caller because the new parameter landed as required.
  • The deprecation policy for future Rust-side removals is symmetric: parameters dropped from the Rust core survive in the wrapper as deprecated no-ops for at least one minor release before being removed at the next major. Documented in ARCHITECTURE.md's new "Public API stability layer" section.

Bounded DNS resolver pool observability #

  • Add ntsDnsPoolStats() (synchronous; no future / isolate hop) returning a process-wide snapshot of the bounded resolver pool with four counters: inFlight (live workers currently pinned in the system resolver), highWaterMark (peak inFlight since process start, monotonic), recovered (cumulative completed workers that released their slot), and refused (cumulative admission attempts rejected because the cap was reached). The function is marked #[frb(sync)] on the Rust side so reading four atomics does not pay the FRB future-marshalling overhead.
  • The new struct NtsDnsPoolStats lands as part of the wrapper layer's public surface alongside NtsServerSpec / NtsTimeSample.
  • Saturation surfaces unchanged on the hot path as NtsError.timeout (the error contract stays collapsed); the new counters are the side-channel that lets operators distinguish a healthy oscillating-below-the-cap resolver from a true libc-level wedge. The diagnostic shape is documented in dartdoc on ntsDnsPoolStats() and in ARCHITECTURE.md's "Timeout budget and bounded DNS" section.
  • Internal refactor in rust/src/nts/dns.rs: the previous lone IN_FLIGHT_DNS_LOOKUPS: AtomicUsize is replaced by a PoolStats bundle (in-flight + high-water + recovered + refused atomics), so try_acquire_slot / SlotGuard::drop keep the four counters in lockstep and the test seam parameterises a per-test bundle the same way the previous lone counter was parameterised. The existing resolve_with_global / resolve_with_timeout signatures are unchanged; only the internal resolve_with seam picks up the new type. Memory-ordering rationale for each counter (Relaxed for cumulative tallies, AcqRel for in-flight, AcqRel for the HWM fetch_max) is documented inline.
  • Three new Rust unit tests in nts::dns::tests:
    • recovered_increments_on_worker_completion — the cumulative counter bumps exactly once per slot release, after the worker returns from the resolver, alongside the in-flight drain.
    • refused_increments_on_cap_exhaustion — companion to cap_reached_returns_would_block; pins the counter delta on rejected admissions.
    • high_water_mark_tracks_concurrent_admissions — admits N workers behind a Barrier, asserts the mark catches up to N while the slots overlap, then releases and asserts the mark stays at N (monotonic, not pinned to the live in-flight count).
  • New wrapper-level smoke test (test/api_smoke_test.dart) verifies ntsDnsPoolStats() is a synchronous getter returning an NtsDnsPoolStats and that the FFI struct's fields are forwarded through the wrapper verbatim.

Documentation #

  • rust/src/nts/cookies.rs: rewrite the DEFAULT_CAPACITY doc comment. The previous wording claimed the "initial NTS-KE response always delivers exactly 8" cookies, which is not mandated by the protocol — RFC 8915 §4 leaves the count returned by any given server to server policy. The replacement cites RFC 8915 §6 (the client-side cap of 8 unused cookies) and notes that the value matches what several public deployments (Cloudflare) are observed to deliver, with a §4 reference for the server-policy framing. No code change; this aligns the internal docs with the example/-side framing already shipped in 1.1.2 / 1.2.0.
  • README.md: rewrite the "API summary" table to show the wrapper signatures with = defaults (timeoutMs = kDefaultTimeoutMs, dnsConcurrencyCap = kDefaultDnsConcurrencyCap), add rows for the two kDefault* constants, and add a paragraph linking to the new ARCHITECTURE.md section. The dnsConcurrencyCap prose is updated to mention that omitting the parameter (or passing 0) inherits the built-in default.
  • ARCHITECTURE.md: add a new "Public API stability layer" section describing the wrapper, the FRB asymmetry it absorbs, the deprecation policy, and the contract split between lib/src/api/ (hand-written, stable) and lib/src/ffi/ (generated, regenerable). Update the repository layout table to list the new wrapper directory.

Examples #

  • example/main.dart: simplify the warm-then-burst flow to use the new wrapper defaults (await ntsWarmCookies(spec: spec) and await ntsQuery(spec: spec) instead of threading explicit timeoutMs: 5000, dnsConcurrencyCap: 0 through every call). Comment in Phase 1 documents that the defaults are sourced from kDefaultTimeoutMs / kDefaultDnsConcurrencyCap. example/example.md's fenced block stays byte-for-byte identical to example/main.dart (5310 bytes).
  • The Flutter GUI controller (example/lib/src/state/nts_controller.dart) and the CLI (example/bin/nts_cli.dart) continue to thread their own configured values explicitly. They are not migrated to the defaults pattern in this release; the wrapper accepts both call styles.

Tests #

  • test/api_smoke_test.dart (new): wrapper-level smoke test that pins the package defaults (kDefaultTimeoutMs == 5000, kDefaultDnsConcurrencyCap == 0), asserts the wrapper applies them when the optional parameters are omitted, verifies that explicit overrides (including the 0 sentinel) are forwarded verbatim to the FRB layer, and exercises the synchronous ntsDnsPoolStats() plumbing. Seven test cases.
  • test/ffi_smoke_test.dart: rewrite the import block. greet and the FRB-layer ntsQuery / ntsWarmCookies are now imported directly from package:nts/src/ffi/... rather than the public barrel, so the test continues to exercise the FFI contract unchanged while the public barrel stops re-exporting them. The five existing test cases are unmodified and still pass.

Generated bindings #

  • lib/src/ffi/api/nts.dart, lib/src/ffi/frb_generated.dart, lib/src/ffi/frb_generated.io.dart, lib/src/ffi/frb_generated.web.dart, and rust/src/frb_generated.rs regenerated via flutter_rust_bridge_codegen generate (pinned at 2.12.0) to pick up the new NtsDnsPoolStats struct and the nts_dns_pool_stats entry point. No drift detected by tool/check_bindings.dart after the regen + lint-suppression patches.

Verification #

  • fvm flutter analyze: clean (no issues).
  • fvm flutter test test/api_smoke_test.dart test/ffi_smoke_test.dart: 12 / 12 pass.
  • fvm flutter test (example/): 31 / 31 pass.
  • cargo fmt --check (in rust/): clean.
  • cargo clippy --tests --all-targets -- -D warnings (in rust/): clean.
  • cargo test (in rust/): 112 / 112 pass, 3 ignored (live-network).
  • example/main.dartexample/example.md fenced-block byte-for-byte parity: 5310 bytes.

1.2.0 #

Reliability and timeout-budget hardening across the Rust core. The public Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec, NtsTimeSample, NtsError) gains one new optional knob — dnsConcurrencyCap — for tuning the bounded DNS resolver per call; existing call sites that omit it continue to compile because the codegen marks the parameter required (pass 0 to inherit the default). Consumer-visible behaviour also improves on the timeout-fidelity and DNS-stall paths. Rust crate nts_rust is bumped from 0.2.1 to 0.2.2; the bindings (lib/src/ffi/) are regenerated to reflect the new parameter.

Bounded DNS resolution (rust/src/nts/dns.rs, new module) #

  • Replace the unbounded ToSocketAddrs lookup that previously fronted both NTS-KE TCP connect and the NTPv4 UDP bind with a thread-pool resolver that offloads getaddrinfo to a detached worker and bounds the wait via a mpsc::Receiver::recv_timeout. A stalled name server no longer holds the calling thread past the caller's timeoutMs budget; the resolver returns io::ErrorKind::TimedOut once the remaining budget is exhausted, which the api::nts and nts::ke call sites collapse to NtsError::Timeout.
  • Add a global atomic concurrency cap on in-flight resolver workers to protect the host environment from a runaway burst of ntsQuery calls against a blackholed DNS server. The cap is configurable per call via the dnsConcurrencyCap parameter on ntsQuery / ntsWarmCookies; passing 0 selects the built-in default of 4, sized for mobile (worst-case ~512 KB-1 MB of pthread stack per leaked worker on iOS/Android, capping the steady-state leak from a blackholed resolver to ~4 MB instead of unbounded growth). Server-side callers that legitimately need higher fan-out can pass a larger cap per invocation. Cap exhaustion surfaces as io::ErrorKind::WouldBlock from the resolver entry point and is mapped to NtsError::Timeout at both KE and UDP call sites so the Dart-side switch arm is reached without introducing a new variant.
  • Because the threshold compares against a single process-wide counter, two concurrent callers passing different caps share the same in-flight pool: the effective ceiling at any moment is set by whichever caller is currently being admitted, not a private quota.
  • The detached-worker pattern intentionally leaks the OS thread on timeout rather than aborting it: getaddrinfo is not cancellable on any major libc, so attempting to interrupt the worker would corrupt the resolver state. The slot cap bounds the steady-state cost of this leak under pathological conditions.

NTS-KE handshake (rust/src/nts/ke.rs) #

  • Introduce a private Deadline newtype that anchors a single Instant at the top of perform_handshake and exposes remaining() (saturating) plus apply_to(&TcpStream) (refreshes socket read/write timeouts; returns io::ErrorKind::TimedOut if the budget is exhausted). Replaces the previous pattern where every blocking phase — DNS lookup, TCP connect, TLS handshake, NTS-KE record I/O — was independently armed with the caller's full timeoutMs, allowing the total wall-clock cost to overshoot the budget by up to ~3x.
  • connect_with_deadline_using<F> becomes the new core path; connect_with_timeout_using is retained as a thin Option<Duration> → Option<Deadline> wrapper that preserves the slow-DNS test seam. perform_handshake threads one Deadline through DNS resolution, TCP connect, post-connect socket-timeout setup, pre-write/pre-flush refreshes, and the read loop.
  • read_to_end_capped now takes Stream<'_, ClientConnection, TcpStream> plus Option<&Deadline> and refreshes the underlying socket's read/write timeouts on every loop iteration, so a server that drip-feeds the NTS-KE response cannot stretch the read phase past the global deadline.
  • New regression tests:
    • deadline_remaining_saturates_at_zero_after_expiry,
    • deadline_apply_to_returns_timed_out_when_expired,
    • deadline_apply_to_sets_socket_timeouts_within_remaining_budget,
    • connect_with_deadline_respects_external_deadline_for_unroutable_ip,
    • connect_with_timeout_surfaces_slow_dns_as_timed_out.

UDP query path (rust/src/api/nts.rs) #

  • Mirror the KE-side helper with a private UdpDeadline newtype for UdpSocket. Surface: new(Duration), remaining() (saturating), and remaining_or_timeout() -> Result<Duration, NtsError> which short-circuits to NtsError::Timeout once the budget is exhausted rather than feeding Duration::ZERO into set_read_timeout (which is EINVAL on some platforms).
  • bind_connected_udp_using rewritten to anchor one UdpDeadline, invoke remaining_or_timeout()? before resolve_with_global so the resolver receives the live remaining budget rather than the original timeoutMs, and again before set_read_timeout/set_write_timeout so the UDP socket inherits the remaining budget. The downstream socket.send / socket.recv in nts_query therefore trip no later than the global deadline, even when the KE phase has consumed most of it.
  • UdpDeadline is intentionally a separate type from the KE-side Deadline because apply_to would otherwise need to be socket-type-generic; the duplicated surface is ~20 lines.
  • New regression tests:
    • udp_deadline_remaining_or_timeout_after_expiry,
    • bind_connected_udp_socket_timeouts_reflect_remaining_budget,
    • bind_connected_udp_surfaces_slow_dns_as_timeout.

Documentation #

  • The dartdoc on ntsQuery (regenerated into lib/src/ffi/api/nts.dart from the Rust docstring on crate::api::nts::nts_query) now states that timeout_ms "bounds the DNS lookup that precedes each phase so a stalled getaddrinfo cannot stretch the wall-clock cost past the caller's budget" rather than the previous wording which described the timeout as per-phase.

Housekeeping #

  • Apply cargo fmt (pinned toolchain 1.92.0) across api/mod.rs, ios_init.rs, lib.rs, nts/aead.rs, nts/cookies.rs, nts/ntp.rs, and nts/records.rs to reconcile drift accumulated since the 1.1.0 cycle. Behaviour is unchanged.
  • .gitignore: add .DS_Store so macOS Finder metadata stops appearing in git status.
  • rust/src/nts/mod.rs: declare the new dns module.

Verification #

  • cargo test --manifest-path rust/Cargo.toml: 108 passed, 0 failed, 3 ignored (live-network).
  • cargo clippy --manifest-path rust/Cargo.toml --tests --all-targets -- -D warnings: clean.
  • cargo fmt --manifest-path rust/Cargo.toml --check: clean.
  • dart analyze: clean.
  • flutter test test/ffi_smoke_test.dart: 5 / 5 pass.

1.1.2 #

Example-app polish and RFC 8915 §4 compliance in the consumer demo. No changes to the published Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec, NtsTimeSample, NtsError), the Rust crate (nts_rust stays at 0.2.1), the FFI bindings, or the Native Assets build hook. The diff is confined to example/, README.md, and example/GUI_GUIDE.md.

Example app (example/) #

  • example/lib/src/widgets/log_view.dart: fix an auto-scroll "stickiness" race condition. The scroll-to-bottom side-effect ran in a WidgetsBinding.instance.addPostFrameCallback, so by the time the callback evaluated whether the user had been near the bottom the layout had already been extended by the freshly-appended entry and the threshold check fired against maxScrollExtent measured after the append. The decision is now taken synchronously in the signal effect against the pre-append layout, while the animated jump still runs post-frame against the resolved target. The 32 px stickiness threshold and 120 ms animation duration are unchanged.
  • example/main.dart, example/example.md, README.md, example/GUI_GUIDE.md: drop the hardcoded const _burstSize = 8 assumption from the warm-then-burst sample. RFC 8915 §4 leaves the cookie-pool size to server policy — the NTS-KE handshake does not let a client request a specific count — so the burst loop now runs for (var i = 0; i < warmed; i++) against the actual count returned by ntsWarmCookies. Prose in README.md and example/GUI_GUIDE.md is rewritten to cite the RFC and the live-log recovered N fresh cookie(s) report rather than the previous "(typically 8)" / "Eight matches" framing. example/main.dart and the fenced block in example/example.md remain byte-for-byte identical at 5172 bytes.
  • example/lib/src/widgets/log_view.dart: trim ~20 px of trailing whitespace below the newest log entry. After the stickiness fix made the layout settle visibly, two compounding sources of dead space at the bottom of the log card became apparent: _spansFor appended \n to every entry (including the last), leaving a phantom blank line; and SingleChildScrollView used symmetric EdgeInsets.all(12), stacking 12 px of bottom inset on top of that phantom line. The fix drops the trailing newline from the message span, inserts a TextSpan(text: '\n') separator between entries at the build site (so adjacent entries still render on their own lines, and selection-copy still yields one entry per line), and tightens the bottom padding to EdgeInsets.fromLTRB(12, 12, 12, 8). Total trailing gutter below the newest entry: ~28 px → ~8 px.

Packaging #

  • screenshots/gui_showcase.png (820,984 bytes) → gui_showcase.webp (183,230 bytes, −78%) via cwebp -lossless -z 9 -m 6. Output is pixel-identical to the source PNG (lossless ARGB, dimensions preserved at 1766×2062, alpha intact). pubspec.yaml's screenshots: entry now points at the .webp path. pub.dev's screenshot pipeline is WebP-native via pana's webpinfo validator, so this also skips the server-side cwebp round-trip. Tarball footprint drops from 835 KB to ~213 KB.

Verification #

  • fvm flutter analyze (root + example/): no issues.
  • fvm dart analyze (root): no issues.
  • fvm flutter test (example/): 31 / 31 pass.
  • example/main.dartexample/example.md fenced-block byte-for-byte parity holds at 5172 bytes.
  • webpinfo screenshots/gui_showcase.webp: VP8L, 1766×2062, alpha=1.

1.1.1 #

Maintenance release. The public Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec, NtsTimeSample, NtsError) is unchanged.

  • Bump the native_toolchain_rust build-hook dependency floor from ^1.0.3 to ^1.0.4 to pick up upstream fixes shipped in the native_toolchain_rust 1.0.4 release (pub.dev, 2026-04-27). The package has no runtime impact; it runs only inside hook/build.dart during the Native Assets compile of the bundled Rust crate.
  • Refresh pubspec.lock and rust/Cargo.lock to keep the resolved dependency graph aligned with the new floor.
  • Patch-bump the internal Rust crate nts_rust from 0.2.0 to 0.2.1 so the crate version moves in lockstep with the Dart package release. The bindings (lib/src/ffi/) and Native Assets bridge are unaffected; no behavioural changes ship in the Rust core.
  • README, example, and dartdoc updates from the previous release stay in place; this release adds no new user-facing documentation.

1.1.0 #

Protocol-compliance and reliability hardening across the Rust core. The public Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec, NtsTimeSample, NtsError) is unchanged; consumer-visible behaviour improves on the timeout, cookie-cache, and error-classification paths. Rust crate nts_rust is bumped from 0.1.0 to 0.2.0 to mark the internal protocol-validation tightening; the bindings (lib/src/ffi/) and Native Assets bridge are unaffected.

NTS-KE handshake (rust/src/nts/ke.rs) #

  • Replace the OS-default TCP connect with a deadline-aware connection loop that honours the caller's timeoutMs. Earlier releases passed the budget only to the read/write side of the socket and let TcpStream::connect block on the platform default (typically 75 s on macOS / 21 s on Linux), which made ntsQuery(..., timeoutMs: 5000) hang for the full kernel default when the KE endpoint blackholed SYNs. The new loop iterates the resolved address list, computes the per-attempt deadline from the remaining budget, and surfaces a KeError::Io(ErrorKind::TimedOut) on the first exhausted attempt rather than the last. Mapped through From<KeError> for NtsError to NtsError.timeout so the Dart-side switch arm is reached.
  • Regression test connect_with_timeout_respects_budget_for_unroutable_ip exercises the deadline against 192.0.2.1 (RFC 5737 TEST-NET-1) and asserts the call returns within 1.5× the configured budget.
  • Introduce a monotonically-increasing generation: u64 on Session and propagate it into QueryContext::session_generation so each in-flight NTPv4 query carries the identity of the handshake that produced its cookies. Session::deposit_cookies now gates the cookie-jar update on a matching generation: cookies extracted from a response signed under generation N are silently dropped if the session has been re-handshaked to generation N+1 between dispatch and receipt. This closes a cross-session poisoning window where a late response from a stale session could install cookies bound to retired keys, causing the next ntsQuery to dispatch unauthenticatable cookies and fail the AEAD seal.
  • The generation counter is also incremented on every successful Session::rehandshake, so the stale-cookie filter applies symmetrically to both concurrent-query races and explicit ntsWarmCookies invocations during an in-flight query.

NTPv4 header validation (rust/src/nts/ntp.rs) #

  • Add STRATUM_UNSYNCHRONIZED_FLOOR = 16 and reject any post-AEAD reply with stratum >= 16 as NtpError::Unsynchronized. RFC 5905 reserves stratum 16 as the "unsynchronized" sentinel and 17–255 as reserved; previous versions only filtered LI=3, so a server in the alarm condition could surface a wall-clock offset to the discipline loop if it left LI=0.
  • Reorder the validation so the Stratum-0 short-circuit (Kiss-o'-Death) runs before the LI=3 / stratum-ceiling check. Real-world KoD packets routinely arrive with LI=3 because the server has no synchronised time to advertise; the previous ordering swallowed the 4-octet kiss code (RATE, DENY, RSTR, NTSN, …) into a generic Unsynchronized error and stripped the diagnostic the caller needs to choose a back-off strategy.
  • Validation remains positioned after AEAD open() and the origin-timestamp check. stratum and the leap indicator are part of the NTP AAD, so by this point the server has signed the value; off-path attackers cannot forge KoD or stratum-16 to disrupt the client. The post-AEAD ordering is pinned by the *_after_seal_*_tamper_as_aead_failure test family.
  • New regression tests:
    • parse_response_prefers_kod_over_unsynchronized_when_both_set pins the new precedence (Stratum 0 + LI=3 ⇒ KissOfDeath).
    • parse_response_rejects_invalid_high_stratum pins the new stratum-ceiling check (stratum 16 + LI=0 ⇒ Unsynchronized).
  • Broaden the Display arm and rustdoc on NtpError::Unsynchronized to "server reports unsynchronized clock (LI=3 or stratum >= 16)" so the diagnostic accurately reflects both triggers; the message passes through NtsError::NtpProtocol(..) to the Dart side unchanged.

Housekeeping #

  • rust/src/nts/records.rs: replace body.len() % 2 != 0 with !body.len().is_multiple_of(2) in decode_u16_array to satisfy the clippy::manual_is_multiple_of lint (warn-by-default in clippy 1.92, surfaced once cargo clippy --all-targets -- -D warnings was added to the release gate). Behaviour is unchanged.

Verification #

  • cargo test --lib: 95 passed, 0 failed, 3 ignored (live-network).
  • cargo clippy --tests --all-targets -- -D warnings: clean across the workspace.

1.0.7 #

Documentation and published-tarball hygiene. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.

  • example/lib/src/state/nts_controller.dart: prepend a 46-line dartdoc block to runQuery that documents the NTS-KE cold-start cost (TCP + TLS 1.3 + KE handshake + first NTPv4 exchange ≈ 4 RTTs end to end, no session-ticket resumption), the steady-state path (cached session keys, in-band cookie pool replenishment, ~1 RTT), and the attribution boundary (the latency is RFC 8915 protocol overhead, not RustLib.init(), the Native Assets pipeline, or per-call FFI cost). Includes a production note pointing at example/main.dart's ntsWarmCookies() warm-then-query pattern as the canonical way to amortize the cold-start cost; the GUI deliberately does not follow it so that the protocol observation tool surfaces the unmasked latency.

  • Repository-wide documentation refactor (7 files: pubspec.yaml, analysis_options.yaml, DEVELOPMENT.md, README.md, example/.pubignore, example/README.md, tool/check_bindings.dart) to replace meta-commentary about pub.dev scorecards, pana rubrics, and tag-drop heuristics with objective technical justifications. The platform allow-list now reads as RFC 8915's raw TCP/UDP requirement plus rustls+ring's lack of a wasm32 target; the FRB pin is justified by the silent-memory-corruption risk of a wire-format mismatch; the analyzer-exclude removal is justified by lockstep with the consumer's analyzer view; the // ignore_for_file: directives in lib/src/ffi/** are justified by public_member_api_docs being enabled and the FFI surface not being excluded. The IANA AEAD-registry reference in example/GUI_GUIDE.md is preserved as a legitimate protocol citation.

  • .pubignore (new, root): introduce a root .pubignore that mirrors the root .gitignore patterns (per dart.dev/go/pubignore, a directory's .pubignore replaces its .gitignore for publish purposes) and additionally excludes consumer-irrelevant files: AGENTS.md, CLAUDE.md (AI-agent guidance), ARCHITECTURE.md, DEVELOPMENT.md (self-identified contributor-only documentation), analysis_options.yaml (consumer analyzers read the consumer's own config), flutter_rust_bridge.yaml (FRB codegen config; bindings ship pre-generated), tool/ (CI drift check for FRB regeneration), and test/ (internal FFI smoke test, not a public-API verifier).

  • example/.pubignore: add analysis_options.yaml and test/ to the example's exclusion list for the same reasons as the root. The canonical consumer entry point remains example/main.dart.

  • Net effect verified via dart pub publish --dry-run: the published tarball drops from 840 KB (1.0.6) to 824 KB, twelve maintainer-only files are stripped, and the warning/hint output is unchanged. No source files in lib/, rust/, or hook/ are touched, so the binding drift gate and Native Assets build hook are unaffected.

1.0.6 #

Binding regen consequent on the 1.0.5 analyzer-exclude removal. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.

  • lib/src/ffi/frb_generated.dart: regenerate against the current analysis_options.yaml. Removing the analyzer.exclude: [lib/src/ffi/**] block in 1.0.5 (nts-2cq) had a side effect that the bindings CI job did not surface until the next commit that re-triggered the job: flutter_rust_bridge_codegen runs an analyzer-aware fix-up over the Dart it emits before exiting, that pass was a no-op while the FFI files were excluded, and with the exclude gone the pass applies prefer_final_locals and prefer_const_constructors to the synthesized dispatcher boilerplate. The committed file (last regenerated in 1.0.2, 0349077) was therefore stale relative to the codegen's deterministic output. The regen is purely cosmetic — var locals inside dco_decode_nts_error / sse_decode_* become final, and the two nullary NtsError variants gain const prefixes — and produces no wire-format or public-API change. The file-level // ignore_for_file: directives managed by tool/check_bindings.dart still suppress both rules so future codegen output that emits a non-final local or non-const constructor remains acceptable to pana without re-failing the drift gate.

1.0.5 #

Example clarity and pub.dev metadata fidelity. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.

  • example/main.dart: switch the minimal sample from a single ntsQuery() call to a warm-then-query flow that calls ntsWarmCookies() first and then ntsQuery(). The original one-call form lumped the NTS-KE handshake into the same latency budget as the NTPv4 exchange and never made the cookie pool visible; the new form mirrors the production access pattern, surfaces the cookies_remaining counter on NtsTimeSample, and gives readers a self-contained reference for both stages of the protocol. example/example.md is regenerated as a byte-for-byte fenced mirror so the pub.dev Example tab tracks the runnable sample. The exhaustive NtsError switch and the RustLib.init() bootstrap order are unchanged.

  • example/example.md: drop the developer-facing meta-commentary about the rendering quirk that motivated the file's existence (pana priority list, the example/main.dart shadowing dance from 1.0.3 / 1.0.4). The fenced sample is the consumer-visible artefact; the rendering history is recorded in this changelog and in the nts-9td commit message, not in the file pub.dev publishes.

  • analysis_options.yaml: remove the analyzer.exclude: [lib/src/ffi/**] block so local dart analyze / flutter analyze runs see the same surface pana sees on pub.dev. The FRB-generated files in lib/src/ffi/ carry file-level // ignore_for_file: directives (managed by tool/check_bindings.dart and landed in 1.0.2) for the rules they cannot satisfy, which pana respects but analyzer.exclude does not — keeping both meant local CI was strictly more permissive than the pub.dev scorecard. With the exclude removed, lint drift between the two environments is impossible.

  • pubspec.yaml: add a top-level platforms: allow-list with android, ios, macos, linux, windows. Earlier releases shipped without this block, which let pana award the web and wasm platform tags on the strength of the Dart surface compiling cleanly under dart2js / dart2wasm — but actual runtime use of any nts API on Web cannot work, because RFC 8915 needs raw TCP for NTS-KE on :4460 and raw UDP for NTPv4 on :123 (neither of which browsers expose to web pages), and the rustls + ring + rustls-platform-verifier stack does not target wasm32-unknown-unknown. Declaring the supported platforms explicitly drops both incorrect tags from the next pana rescore so the pub.dev scorecard reflects the package's true platform surface.

1.0.4 #

pub.dev Example tab fix (take two). No runtime changes.

  • Add example/example.md containing the minimal NTS-KE sample as a fenced ```dart block plus a pointer to the Flutter GUI showcase at example/lib/main.dart. The 1.0.3 rename of the minimal sample to example/main.dart did not unblock the Example tab: empirical check on the published version-pinned URL still rendered example/lib/main.dart. The bracket notation example[/lib]/main.dart in dart.dev's package-layout doc is shorthand for two separate slots in pana's selection list, with the lib/ form ranked higher than the bare form. The actual list lives in pana/lib/src/maintenance.dart:

    1. example/README.md
    2. example/example.md ← new in 1.0.4, secures the slot
    3. example/lib/main.dart (GUI showcase, no longer rendered)
    4. example/bin/main.dart
    5. example/main.dart (1.0.3 rename target, also no longer rendered)

    Slot 2 beats slot 3, so the new example/example.md finally wins over example/lib/main.dart. The minimal sample at example/main.dart stays in the archive as the runnable Flutter target; the .md is just a syntactic mirror so pub.dev picks it.

  • No changes to the published Dart surface, the Rust crate, or the Native Assets bridge. The two new lines in pubspec.yaml and CHANGELOG.md are the only metadata edits.

1.0.3 #

pub.dev Example tab fix. No runtime changes.

  • Rename example/example.dart to example/main.dart so pub.dev's Example tab renders the intended minimal single-call sample. pub.dev picks the rendered file from a hardcoded priority list documented at https://dart.dev/tools/pub/package-layout#examples; the previous layout placed the minimal sample at priority 5 (example[/lib]/example.dart) where it was shadowed by the Flutter GUI showcase at priority 2 (example/lib/main.dart). The bare example/main.dart slot also sits at priority 2 but wins over the lib/ variant, so the rename promotes the minimal sample without removing the GUI showcase from the published tarball.
  • Update example/README.md to spell the GUI entry point explicitly as flutter run -t lib/main.dart (or -t example/lib/main.dart from the repo root) so contributors don't accidentally launch the new top-level example/main.dart as the Flutter target.
  • Update root README.md and ARCHITECTURE.md to reference the new path. The 1.0.1 changelog entry that introduced example/example.dart is left unchanged for historical accuracy.

1.0.2 #

Static-analysis score recovery. No runtime changes.

  • Suppress pana-only lints across the FRB-generated bindings via the // ignore_for_file: directive of each file, applied as a post-codegen patch step in tool/check_bindings.dart. pana's static-analysis run uses a stricter ruleset than flutter_lints and surfaced 117+ INFO lints against the synthesized freezed wrappers (NtsError), auto-generated default constructors (NtsServerSpec, NtsTimeSample), and dispatcher boilerplate that FRB cannot back with Rust docstrings, costing 10 pub points. Patched files and rules:
    • lib/src/ffi/api/nts.dart: public_member_api_docs.
    • lib/src/ffi/frb_generated.dart: public_member_api_docs, prefer_final_locals, prefer_const_constructors.
    • lib/src/ffi/frb_generated.io.dart: public_member_api_docs.
    • lib/src/ffi/frb_generated.web.dart: public_member_api_docs. Local pana 0.23.12 now reports 160 / 160 against the working tree.

1.0.1 #

Documentation and pub.dev metadata polish. No runtime changes.

  • Restructure README around a What → Why → How flow and offload the Rust toolchain, build hooks, and crate breakdown into new ARCHITECTURE.md and DEVELOPMENT.md reference documents.
  • Add a self-contained example/example.dart for pub.dev's Example tab.
  • Resolve two dartdoc unresolved-reference warnings in lib/src/ffi/api/nts.dart by replacing Rust intra-doc link syntax with literal values in the upstream Rust docstrings and regenerating the bindings.
  • Trim the package description to fit pana's 180-char ceiling, add five pub.dev topics (ntp, time, networking, security, cryptography), and register screenshots/gui_showcase.png as the package listing screenshot.
  • Expand the inline comment on the flutter_rust_bridge: 2.12.0 pin to document the wire-format rationale and the accepted pana warning.

1.0.0 #

Initial stable release.

Protocol #

  • Network Time Security (RFC 8915) client implementing the full NTS-KE handshake (TLS 1.3, ALPN ntske/1, port 4460) followed by AEAD-protected NTPv4 (RFC 5905) over UDP/123.
  • AEAD algorithms: AES-SIV-CMAC-256 (IANA ID 15, default) and AES-128-GCM-SIV (IANA ID 16), negotiated during NTS-KE.
  • Cookie management: in-memory cookie jar with automatic refresh via ntsWarmCookies() when the pool is exhausted.

API #

  • ntsQuery({required NtsServerSpec spec, required int timeoutMs}) returns Future<NtsTimeSample> with server transmit time, round-trip duration, stratum, negotiated AEAD ID, and fresh cookie count.
  • ntsWarmCookies({required NtsServerSpec spec, required int timeoutMs}) forces a fresh handshake and reports the number of cookies received.
  • NtsError sealed class with eight typed variants (invalidSpec, network, keProtocol, ntpProtocol, authentication, timeout, noCookies, internal) for exhaustive pattern matching.

Implementation #

  • Cryptographic core implemented in Rust (rustls for TLS 1.3, aes-siv / aes-gcm for AEAD, ring for primitives).
  • Bridged to Dart via flutter_rust_bridge 2.12.0 (pinned exactly to match the Rust crate's wire format).
  • Bundled through the stable Native Assets API (hook/build.dart + native_toolchain_rust); no manual cargo invocation required from consumers.

Platform support #

Android, iOS, macOS, Linux, Windows. Web is not supported (no UDP socket primitive in the browser).

Build #

  • Default release builds use the log-strip Cargo feature, eliding info! / debug! / trace! format strings at compile time; warn! and error! survive for diagnostics.
  • The verbose_logs user-define in pubspec.yaml opts into a debug build with full logging (including rustls protocol traces) for development.

Tooling #

  • tool/check_bindings.dart regenerates FRB bindings and fails CI if the committed Dart bindings or rust/src/frb_generated.rs drift from the generator output.
  • CI matrix exercises both the declared SDK floor (Flutter 3.38.10 / Dart 3.10.9) and the pinned development version (Flutter 3.41.7 / Dart 3.11.5).

Requirements #

  • Dart ^3.10.0, Flutter >=3.38.0. The lower bound matches the hooks package (>=1.0.3) requirement.
  • Native Assets API (stable since Flutter 3.24).
3
likes
160
points
572
downloads
screenshot

Documentation

API reference

Publisher

verified publishernllewellyn.com

Weekly Downloads

Authenticated network time for Flutter apps, secured by Network Time Security (NTS).

Repository (GitHub)
View/report issues

Topics

#ntp #time #networking #security #cryptography

License

MIT (license)

Dependencies

flutter, flutter_rust_bridge, freezed_annotation, hooks, native_toolchain_rust

More

Packages that depend on nts

Packages that implement nts