nts 9.2.0
nts: ^9.2.0 copied to clipboard
Authenticated network time for Flutter apps, secured by Network Time Security (NTS).
Changelog #
Entries for 1.4.0 and earlier live in
CHANGELOG_ARCHIVE.md,
which is kept in the repository but excluded from the published
tarball.
9.2.0 #
Added #
NtsBridge(lib/src/api/bridge.dart, exported fromlib/nts.dart) — a safe, idempotent lifecycle wrapper over the FRB-generated entrypoint, and the recommended bootstrap from now on:NtsBridge.ensureInitialized({externalLibrary, handler, forceSameCodegenVersion})initialises the bridge if it is not already initialised and completes once it is. Safe to call repeatedly and from more than one code path, whichNtsRustLib.init()is not — that throws aStateErroron every call after the first, and there is no de-init. It is also safe concurrently: FRB'sinitImplassigns its state only after awaiting the external library load, so the guard a consumer would otherwise write by hand (if (!initialized) await init()) races — two callers both observefalse, both enter, and the second throws.NtsBridgelatches on the in-flight future instead. An initialisation performed directly (includingNtsRustLib.initMock()) is recognised rather than fought. Arguments configure the attempt a call actually starts; a call that joins a latched attempt, or finds the bridge already initialised, ignores them.NtsBridge.statereportsNtsBridgeState.uninitialized,.mock, or.native. This is the discrimination consumers previously had to reach into FRB's@internalinstance/apimembers to obtain —MonotonicClockand the example CLI loader both did, each with aninvalid_use_of_internal_memberignore, and both now switch on the enum instead. The mock/native split is structural, onapi is BaseApiImpl, not on the initialisation route: a hand-written double reads as.mockhowever it was installed, and the generated implementation reads as.nativeeven when supplied toinitMock().- Public member dartdocs that stated the initialisation requirement
as
NtsRustLib.init()now nameNtsBridge.ensureInitialized()(NtsClientand its synchronous members,ntsDnsPoolStats,ntsTrustStats, theTrustModeexample).NtsRustLib.init()is still exported and still described where the underlying step is the point. NtsBridge.dispose()releases the bridge's Dart-side resources, or does nothing when it was never initialised.NtsRustLib.dispose()throws in that case. Disposal is not de-initialisation:stateis unchanged afterwards.- A failed
ensureInitializedattempt retains its latch — replaying the error to every later caller — only when the attempt itself took ownership of the entrypoint, which it can only ever do by installing the generated API. A.mockobserved after the failure can only have come from an independentNtsRustLib.initMock(), which is an initialisation that succeeded; the latch is dropped in that case so a later caller completes over the usable mock instead of failing on a stale native-load error. ensureInitialized's dartdoc states that driving the raw entrypoint concurrently with it is unsupported in either direction, and why neither case is detectable. A concurrentNtsRustLib.init(): FRB installs the entrypoint state before awaiting its Rust initializers, so the wrapper can see.nativeand complete while the direct call is still running, and a failure it then suffers is invisible; FRB exposes no way to await someone else's attempt. A concurrentNtsRustLib.initMock()supplying the generated implementation: that also reads as.native, hence is indistinguishable from state the wrapper installed itself, so a failure of the wrapper's own attempt would be replayed over a bridge that is in fact usable. (A hand-written double is not affected — it reads.mock, which is attributable to someone else.) The dartdoc also records the one case a completed direct call leaves behind: aninit()that threw from its Rust initializers leaves the entrypoint installed and permanently unusable, andensureInitializedreports success over it, because FRB records nothing about the failure and the attempt was never latched. That error is the direct caller's to keep.- The live suite (
test/live/nts_live_test.dart) bootstraps throughensureInitialized()rather thanNtsRustLib.init(), and asserts a repeat call completes withstate == .native. That covers the fresh-success branch, which no mock-only test can reach: it needs a real library whose Rust initializers actually run. The mock-only suite covers the already-installed and failed-load branches.
CONTRIBUTING.md— the GitHub-surfaced entry point for third-party contributors. Covers prerequisites, the one-timegit config core.hooksPath tool/hooksopt-in, the branch and pull request loop, the quality gates, the code conventions, and the changelog and release-only versioning policy. It also states explicitly that the maintainer's issue-tracking services (Beads, Dolt, Linear) are not contributor prerequisites:.beads/is inert withoutbdinstalled, and no hook or CI job invokes any of the three. SonarCloud does run as a CI step, but it skips itself whenSONAR_TOKENis absent, as it always is on fork pull requests, and it is not a required check.DEVELOPMENT.mdremains authoritative for the toolchain and CI detail.
Changed #
- Behavioural:
offsetMicrosandpeerDelayMicrosread lower than on 9.1 for the same exchange. T1, the RFC 5905 client transmit timestamp, is now stamped immediately before the C2S AEAD seal that builds the request packet, after the UDP socket is bound and connected. It previously preceded both the packet build and the bind, whileroundTripMicroswas anchored at thesendthat follows the bind, so the two intervals had different start points: the peer delay δ = (T4−T1)−(T3−T2) carried the bind and the NTPv4-host DNS lookup that the round trip excluded, and θ carried half of that interval as apparent offset. Measured against the bundled server catalog, δ ran 1–9% aboveroundTripMicroson every healthy sample — enough that the(0, roundTripMicros]selection windowntsGetTimeapplies rejected δ in practice and theroundTripMicrosfallback was the branch always taken. δ and the round trip now share an anchor, separated only by the request build and seal and the socket write-timeout re-arm that bounds the send against the call's remaining budget — the build and seal memcpy-scale over a packet of a few hundred bytes, the re-arm a single non-blocking syscall, neither waiting on I/O; the window admits δ on healthy samples under ordinary scheduling. It is not reserved solely for implausible exchanges: the worker can still be preempted between T1 and the send, so a loaded host can select the fallback on a healthy sample. That selection excludes a real bias rather than discarding a clean measurement — any intervalpbetween T1 and the send addspto δ andp/2tooffsetMicros, the same arithmetic that made the pre-9.2 ordering a bias, and the fallback keeps thepout of the delay the compensation halves. It does not undo thep/2in θ. Callers that recorded absoluteoffsetMicrosorpeerDelayMicrosvalues from 9.1 or earlier should expect a small downward shift, and any consumer that widened its own δ upper bound to accommodate the setup interval — as the example health prober did — can tighten it to an allowance measured on its own targets.ntsGetTime's synchronized UTC is unaffected in direction: it compensates by half the selected delay either way, but now selects the tighter of the two estimates. - The example health prober's per-sample θ gate
(
example/lib/src/health/probe.dart) tightened accordingly: the allowance overroundTripMicrosis now a flat 5 ms ceiling covering the whole pre-send interval — building and sealing the request packet, and the socket write-timeout re-arm, neither of which blocks on I/O. It previously added the sample's ownphaseTimings.dnsMicros, gated on an inference about whether the query had re-handshaked (since that field sums both lookups a query can make and only the NTPv4-host one fell inside the pre-send interval). No lookup falls between T1 and the send now, so both the DNS term and the inference are gone.
Fixed #
- Android hosts on AGP 9 / Kotlin 2.2+ toolchains failed to compile
android/build.gradle.kts(the plugin's Android module) with a script-compilation error rather than a warning (#313). Three deprecations landed as hard errors: the classicandroid { ... }extension-function accessor is deprecated onceandroid.newDsl=true, the AGP 9 default;kotlinOptions { jvmTarget = ... }had its deprecation level raised toERRORin Kotlin 2.2.0; andAndroidSourceDirectorySet.srcDirs(...)is deprecated in favor of thedirectoriesmutable set. The module now appliesorg.jetbrains.kotlin.androidonly when built-in Kotlin is not in effect. Gating on the resolved AGP major version alone is not sufficient, since AGP 9 still supportsandroid.builtInKotlin=false(and this package's Flutter 3.38 floor has no fallback that applies KGP on that compatibility path), so the effectiveandroid.builtInKotlinGradle property is checked too. The module also configures theandroidextension viaconfigure<LibraryExtension> { ... }, movesjvmTargettokotlin.compilerOptionsbehind aplugins.withId("org.jetbrains.kotlin.android")guard, and adds tojava.directoriesinstead of callingsrcDirs(...). Verified against both AGP 8.11.1 (the example app's previous pin, unaffected) and AGP 9.2.1 withandroid.newDsl=true/android.builtInKotlin=trueforced on, as well as AGP 9.2.1 withandroid.builtInKotlin=false(Flutter 3.44's current forced default). The example app's ownandroid/app/build.gradle.ktsfollows the same pattern, and its AGP pin moved to 9.2.1 with the Gradle wrapper on 9.7.1. - The UDP socket's write timeout is now re-armed against the call-wide
deadline immediately before the
send, matching the re-arm therecvalready had. The bind-time value is anchored at bind completion, and the T1 stamp and the C2S seal now sit between that anchor and thesend; the seal does no I/O, but the worker can be preempted across it, so the bind-time value was stale by an unbounded amount. A blockingsendcould therefore run for the full bind-time budget on top of the time already spent, overshooting the single wall-clock budgettimeoutdocuments. An already-lapsed budget now fails withNtsError.timeout(TimeoutPhase.ntp)instead of putting a packet on the wire the caller has stopped waiting for. - The AES-SIV-CMAC paths (AEAD IDs 15 and 17) migrated to the
aes-siv0.8 API, completing the move of both SIV families onto the RustCryptohybrid-arraytraits line. The crate dropped itsaead::generic_arrayre-export, soSivKey::cipherandSivKey512::cipherconstruct the key array via the infallible&[u8; N]conversion instead ofGenericArray::from_slice, and theaes_gcm_siv::KeyInitimport is no longer needed now thataes_siv::KeyInitis the same trait. The dependency also gains an explicitzeroizefeature: 0.7 wipedSiv::encryption_keyinDropunconditionally, 0.8 gates that wipe behind an optional dependency absent fromdefault, so adefault-features = falsebuild that carried the old feature list forward would have dropped the wipe of the key copy eachcipher()call makes — silently, with nothing failing to flag it. A new compile-time assertion pinsAes128SivandAes256SivasZeroizeOnDropso removing the feature again fails to build. With that in place there is no behavioural change: key handling, the zeroization derives, and the wire format are untouched. Withaes-sivoff the old line, themultiple-versionsgate incargo denysheds six version-pinned skips (aead,aes,cipher,cpufeatures,ctr,inout); the remaining duplicates —block-buffer,crypto-common, and nowdigest— are held byflutter_rust_bridge_macros -> md-5 -> digest 0.10and expire when that chain moves. - The documentation no longer claims that bridge initialisation is a
no-op after the first call.
NtsRustLib.init()throws aStateErrorinstead, so the claim was wrong everywhere it appeared, and the README went further and drew an operational conclusion from it ("safe to call from a shared bootstrap path") that described precisely the usage that throws. The library dartdoc inlib/nts.dart, the README's two-layer initialisation section, quick start, platform support, non-Flutter loader guidance and API summary,example/main.dart,example/example.md, andARCHITECTURE.mdnow documentNtsBridge.ensureInitialized()as the bootstrap and describeNtsRustLib.init()accurately as the single-shot raw entrypoint. TheNtsSyncedTimeclass and constructor dartdoc inlib/src/api/models.dartstate their initialisation prerequisite the same way, keepingNtsRustLib.initMock()as the test alternative. (Theno-opwording inandroid/.../PlatformInit.ktis correct and unchanged — that bootstrap really is idempotent.) - The README's non-Flutter loader guidance no longer describes a later
ensureInitialized()call passing a different library as a "no-op", and both it and theensureInitializeddartdoc now warn that ignored is not unloaded.ExternalLibrary.openmaps the library synchronously inside its own constructor, so the argument's load-time initializers have already run by the timeensureInitializedis entered and can decide to discard it. The library-hijack surface the section exists to describe is closed by nominating one call site as the initialization owner and constructing the library only there, not by a later call being ignored, and the guidance says so. It also says whatNtsBridge.stateis not: a way to tell whether a call will be the one that initializes. The getter rules out a completed initialization only — a latched attempt still awaiting its library load has installed no entrypoint state, sostatereadsuninitializedfor that whole window. - The CI matrix's old-SDK leg is no longer documented as exercising the
declared SDK floor. It runs Flutter 3.38.10, which is ten patches
above the
flutter: '>=3.38.0'constraint and is not the oldest release satisfying it — earlier 3.38.x patches do not build native dependencies through the Native Assets build hook reliably, so a leg pinned to the literal floor would fail for reasons unrelated to this package's sources. The pin is deliberate and unchanged; what moved is the claim attached to it inci.yml,pubspec.yaml,DEVELOPMENT.md(both the bullet and the workflow table), and the pull request template, all of which described 3.38.10 as the declared floor. The declared floor is a dependency-resolution bound, not a build-verified one, and the comments now say which is which. - The pull request template no longer asks every contributor to bump
pubspec.yamlversion:following semver. That instruction contradicted the release-only bumping policy, under which version fields move only in a dedicated release commit. The checklist item now asks for the field to be left untouched, with an explicit carve-out so a release PR can tick the same box truthfully. - The pull request template illustrates the issue-reference
convention with a placeholder (
NTS-<num>) rather than a real, long-closed issue identifier, so the example cannot be pasted through into a pull request that has nothing to do with it. - The example CLI loader rejects a bridge of the wrong kind in both
directions rather than only one.
mockBridgeDispositionbecomesbridgeDisposition(state, useMock:), and a native run that finds an installed mock now exits with the same diagnostic shape as a--mockrun that finds an installed native bridge. Previously that direction fell through toNtsBridge.ensureInitialized(), which completes for any installed state, so the run reported success and then dispatched every call toMockNtsApi. Example app only; no package API change. - The example app's GUI bootstrap installs its fallback mock only when
the failed
ensureInitialized()left the bridge uninitialized. A Rust-initializer failure leaves itnativebut half-built, and the unconditionalinitMockthrew a secondStateErrorover the top of the real error, so the banner never rendered. Example app only; no package API change. - That other arm now aborts to a
Bridge unavailablescreen rather than proceeding into the normal UI. No mock can stand in for a half-built entrypoint, so bootstrap carries abridgeUsableflag andmain()short-circuits on it: noAppState, noNtsController, and so noNtsClientminted over a bridge every call would throw through. Previously the arm fell through, and the UI then misreported itself — the corner banner readsmock fallbackoff any non-null load error, andAppState.bridgeLoadErrorwas documented as implying one had been installed.AppStategains amockFallbackflag that says whether it actually was, the corner banner is driven off that rather than off the error, andbridgeLoadErroris described as what it is: a bootstrap diagnostic that a catalog failure also populates. Ashell diagnosticsgroup covers the resulting three-way split through the publicNtsExampleApp: no diagnostic renders neither banner, a diagnostic without a fallback renders the error banner alone, and a fallback renders both. The middle case is the one the old condition got wrong, and it fails against it. The dead-end screen is public asBridgeUnavailableAppso a fourth case can pump it directly;main()'s branch into it cannot be driven from a test, since reaching it needs a real half-built entrypoint. Example app only; no package API change. - That arm now returns from bootstrap immediately, via a
_Boot.bridgeUnavailablevariant, instead of setting a flag and falling through the remaining steps. Loading the server catalog and hydratingSharedPreferencesfor a UI that will never be built was wasted at best, and at worst lost the bridge error: the catalog arm prefixes rather than replaces, but an uncaughtFavoritesStore.load()failure propagated out of bootstrap andBridgeUnavailableAppnever rendered._Boot.favoritesis now nullable, non-null exactly whenbridgeUsableis true. Example app only; no package API change. - The example CLI loader awaits
NtsBridge.ensureInitialized()on the native reuse arm rather than returning immediately. A retained initialisation failure lives on the wrapper's latch, so returning there converted an installed-but-half-built bridge into apparent success; awaiting it surfaces the original error and exits 70 like every other load failure. Mock reuse still returns directly — a mock is usable the momentinitMock()returns, and routing it through the wrapper would latch a completed future over state the wrapper never installed. Example app only; no package API change. - The example app moves from
file_picker^12.0.0-beta.7to the^12.0.0stable release.FilePicker.pickFiles()now returnsList<PlatformFile>rather than a nullable result object, soCustomRootsPanel._pickFilecalls the single-fileFilePicker.pickFile()instead, which returnsPlatformFile?and matches what the panel wants. The macOS generated plugin registrant follows the plugin's split into federated packages (file_picker→file_picker_darwin). Example app only; no package API change. AGENTS.mdandCLAUDE.mdnow say which of their sections are maintainer workflow. Both open with a short note on who the file is for, and every section covering the maintainer's issue tracking (Beads, DoltHub, Linear, the assignee convention) carries aMaintainer-onlymarker naming the tooling it presumes. The contributor-relevant material — pull request workflow, branch protection, shell conventions, the doc-snippet validator, versioning, and the zeroization policy — is deliberately unmarked.
9.1.0 #
Added #
-
Copilot code review now follows a review protocol tracked in the repository. A
code-reviewagent skill (.github/skills/code-review/) carries the protocol, the architecture-specific checks, and a mandatory summary-comment format;.github/copilot-instructions.mdholds the repository-wide guidance, and.github/instructions/adds path-specific guidance for**/*.dartandrust/**/*.rs. MCP server availability is a repository-settings concern rather than a tracked file, and is documented inDEVELOPMENT.md.The checks are grounded in the surfaces where this repository's defects actually appear: generated-binding drift across the FRB boundary, hand-maintained lists mirroring the sealed
NtsErrorhierarchy that the analyzer cannot see, the two distinctTrustModefallback paths inke.rsandhybrid_verifier.rs, the zeroization rules, and release-only version bumping. The protocol also instructs the reviewer to report every finding rather than suppressing low-confidence ones — on PR #292 four suppressed comments were all valid and two were substantive. Repository tooling only; no package or example change. (NTS-149) -
The example CLI (
example/bin/nts_cli.dart) can now select a trust-anchor policy and assert the backend the call resolved.--trust-modetakesplatform-with-fallback(default),platform-only,bundled-only, orcustom;--custom-roots <path>supplies the PEM bundle or DER certificate thecustommode requires;--require-trust-backendasserts that every call resolved a namedTrustBackend, reportingTrustBackendMismatchinstead of success when it did not and counting that as a failure for--exit-on-error. The assertion reads the attribution off failures too, so a mismatch replaces either a success or an attributed failure: a call that resolved the wrong anchor set and only then lost the NTP leg reports the mismatch rather than the timeout it surfaced as.This is a backend-resolution assertion, not evidence that a chain was verified. Rust attaches the initial backend once
build_tls_configreturns, which is before any DNS, connect, or TLS I/O, so a DNS or connect failure on an attributed variant carries one too and an unreachable host can be reported as mismatching. That is the intended scope — the policy under test is which anchor set the call was configured to trust. The one value not fixed at config-build time is Android'splatformWithHybridFallback, which replaces the initialplatformonly after the webpki-roots fallback verifier accepted a chain during TLS verification, so that attribution does evidence a verified chain. Four variants (invalidSpec,trustBackendUnavailable,internal,abiMismatch) have no attribution field, so they keep their own error type even when raised downstream of config-build.Previously every run went through the top-level
ntsQuery/ntsWarmCookiesand therefore the process-wide default client, whose mode is fixed atTrustMode.platformWithFallback— the most permissive policy the package offers — so the tool could neither probe under a stricter policy nor detect a silentwebpki-rootsfallback. A non-default mode now mints one call-scopedNtsClientfor the batch and disposes it after the fan-out; the default path is unchanged and constructs no client. Under--json,trust_modeandrequired_trust_backendare emitted only when their flag was passed, so a flagless run's records are unchanged. Example app only; no package API change. (NTS-146) -
The two catalog tools (
example/bin/nts_health.dart,example/bin/nts_manifest.dart) accept the same--trust-mode,--custom-roots, and--require-trust-backendflags, so a whole server list can be vetted under a stricter trust policy rather than only the hostnames passed tonts_cli. The flags live on the sharedaddCommonProbeOptionsblock, so both tools gain them together.A non-default policy mints one call-scoped
NtsClientfor the whole catalog and disposes it after the probe wave; the default path constructs no client and keeps routing through the top-level functions, so a flagless run is unchanged.--require-trust-backendis asserted on the NTS-KE warm and on every sample's own attribution, since a query re-handshakes once the warmed cookie pool is spent or its session was evicted, and on the attribution carried by a failed call, which is what a wrong-backend re-handshake that then fails looks like; the first mismatch abandons the rest of the host's run and is classified as a severeTrustBackendMismatchKE-stage failure, which makes the hostnonConformingand therefore a drop candidate for--fail-on-dropsand an exclusion from the generated manifest.All three CLIs validate the
--trust-mode/--custom-rootspairing during argument parsing rather than leaving it to theNtsClientconstructor. The constructor runs afterinitBridge, so on a machine with no loadable dylib an invalid pairing previously exited with the bridge-load code instead of the usage code both README exit-code tables document.The
--custom-rootsbuffer is wiped in place once the client has copied it. The package zeroises only the copy it makes at the FFI boundary and documents the caller's list as read-but-never-retained, so wiping the caller-side bytes is the caller's job — and the buffer was otherwise reachable for the rest of the run. Because the roots are read early (so an unreadable path is an argument error rather than a trust-policy one), several terminations sit between that read and the wipe: the remaining flag validation,nts_manifest's--per-regioncheck, the catalog load, and everyinitBridgefailure.exitterminates the VM without unwinding, so afinallycannot cover them; the buffer is instead registered on read and cleared at each of those sites, with the client construction keeping afinallyfor the success and non-NtsErrorpaths.loadAndProbeCatalogre-registers on entry, since its argument object is publicly constructible and can therefore carry roots the parser never saw. Example app only; no package API change. (NTS-147) -
RFC 8452 known-answer vectors for AEAD ID 30 (the §8 worked example and a §C.1 case with a multi-block AAD), driven through
seal_packet/open_packet, plus an open-path counterpart to the existing GCM-SIV nonce-length rejection test. -
The example package's hand-built list of
NtsErrorsamples (example/test/nts_format_test.dart) is now guarded by a_NtsErrorKindenum and an exhaustive_variantKindswitch. Dart has no reflection over sealed subtypes, so the list is written out by hand and had no way to notice a new variant; every property asserted over "everyNtsErrorshape" was therefore only as complete as that list. Adding a variant to the sealed type is now adart analyzeerror in the switch, and omitting its sample from the list fails a test. The tag, severity,timeoutPhaseName, anderrorTrustBackendassertions are driven off the same pivot rather than hand-enumerated — two of those lists had already drifted, missingabiMismatchandtrustBackendUnavailable. Example tests only; no package or example behaviour change. (NTS-148)
Security #
aes-gcm-sivis now pinned to 0.12 with itszeroizefeature requested explicitly. 0.11 depended onzeroizeunconditionally; 0.12 made it an optional feature that is absent fromdefault, so this crate'sdefault-features = falsebuild would otherwise have silently stopped wiping the derived POLYVAL and AES subkeys and the per-message counter block thatAesGcmSivallocates on the stack per operation. The resolvedzeroizeremains 1.9, above the ≥ 1.8 floor the project's zeroization policy requires.
Changed #
-
The example health prober (
example/lib/src/health/probe.dart) now reports each sample's clock offset fromNtsTimeSample.offsetMicros— the RFC 5905 §8 offset θ computed natively from the four NTP exchange timestamps — instead of deriving one asutcUnixMicros + roundTripMicros / 2 − DateTime.now(). That derivation carried two error terms θ does not, pulling in opposite directions: half the round trip includes the server's own processing time between recv and send, which overstates the offset, while the local reading was taken on the Dart event loop — after the FFI return and worker-thread handoff — and is subtracted, so scheduling lag understates it. Neither cancels the other, and their sum is a function of load rather than of the remote clock. Against a machine measured at +83 ms bysntp, the catalog tools were reporting +90–100 ms. θ has been onNtsTimeSamplesince 7.1; the prober predated it.θ is only meaningful if the local clock was not stepped between the T1 and T4, and unlike
ntsGetTime— which reports θ as a statistic — this module feeds it into a verdict that decides whether a host stays in the catalog. Samples are therefore screened on the peer delay: a value that is not a positive duration cannot be a real delay, so θ is suppressed rather than trusted.ProbeOk.offsetMicrosis now nullable to carry that, suppressed samples are excluded from the median instead of counting as a zero offset that would mask a real skew, and a host with no usable sample is not flagged on an offset it never observed — it reports aclock offset unavailable (no corroborated sample)note. The CSV report gains a trailingnotecolumn, and the text report renders the note in the non-standard section as well as the healthy one, so that explanation reaches every output format and every bucket a host with a suppressed offset can land in.The upper bound is tolerant rather than strict, since a forward step adds to the peer delay instead of subtracting and would otherwise slip past a lower bound alone. The bound
ntsGetTimeapplies (peerDelayMicros <= roundTripMicros) does not hold on this client: T1 is captured before the request packet is built and the UDP socket bound, whileroundTripMicrosstarts at the send, so the peer delay legitimately includes a pre-send interval the round trip excludes. Measured across the bundled catalog it exceeds the round trip by 1–9% on every healthy server, so asserting that bound would suppress every real sample; the prober admits up toroundTripMicros + phaseTimings.dnsMicros + 5 msinstead. That allowance is additive rather than a multiple of the round trip because the pre-send interval includes the NTPv4-host DNS lookup, whose latency is unrelated to the round trip — a ratio-derived ceiling would reject a healthy sample from a nearby server behind a slow resolver. It is measured from the sample rather than derived from the verdict threshold: on a sample that ran no handshakednsMicrosis that lookup alone, and the remaining 5 ms covers the packet build and the bind, which do no I/O. A threshold-derived allowance would have bounded the undetected step at the threshold and so the undetected corruption of θ at half of it, which is enough to move a host whose true offset is non-zero across the verdict line. The lookup term is claimed only where it is attributable: the burst runs against a pre-warmed pool, but an exhausted pool or an evicted session makes a sample re-handshake, anddnsMicrosthen also carries a KE-host lookup that completed before T1. Such a sample is allowed the flat 5 ms only.A second screen corroborates θ across the burst: a surviving sample's θ is kept only if some other surviving sample agrees with it to within half the sum of the two samples' round trips. Asymmetry is the honest source of disagreement and displaces a sample's θ by at most half its own round trip, so that sum bounds what an honest pair can differ by, while a step of S displaces one sample's θ by S/2 and so escapes the window once S exceeds the sum. The window is per pair rather than one figure for the burst: a retransmit or a queued reply makes one sample far slower than its neighbours, and a burst-wide minimum would suppress such a pair over jitter neither is at fault for — which is not the safe direction to err, since a host left with no surviving θ is judged without the clock check at all. The scale is drawn from
roundTripMicrosrather than the peer delay because the round trip is measured on a monotonic clock, so no step can widen the window in either direction, and because it excludes the pre-send interval, which is not a property of the path. This is what rejects a step small enough to pass the per-sample bound but large enough to move the median across the threshold. Neither screen proves the clock was steady: a step that disturbs neither the peer delay beyond the setup cost nor the burst's agreement is not detected. Example app only; no package change. (NTS-152) -
Docs:
NtsTimeSample.offsetMicrosdescribed θ's vulnerable window as spanning the UDP send and recv. It actually opens at T1, which precedes the packet build and the socket bind, so a step during that setup corrupts θ too — and the same early T1 biases θ upward by half the setup interval even on a steady clock (sub-millisecond to a few milliseconds, from the 1–9% measurement above). The rustdoc, generated bindings, and wrapper dartdoc now say so and point at NTS-153. Documentation only; no behaviour change. -
Docs:
NtsTimeSample.peerDelayMicrosdocumented δ as always<= roundTripMicroson a steadily-running clock, and a value outside(0, roundTripMicros]as a clock-step signal. The upper half of that is false on this client for the capture-point reason above — δ measured above the round trip on every healthy sample across the bundled catalog — so the(0, roundTripMicros]windowntsGetTimeapplies is a selection policy rather than a verdict on the sample: it takes theroundTripMicrosbranch in practice rather than distinguishing stepped samples, and only its lower bound is diagnostic. That the round trip wins is an empirical result, not an identity: δ = setup + roundTrip − serverProcessing, so δ clears the ceiling only while the pre-send setup cost outweighs the server's T3−T2, which held across every catalog server measured. A non-positive δ is also no longer attributed to a local step specifically: it witnesses an implausible timestamp exchange, which a server clock stepped between T2 and T3, or inconsistent server stamps, produce just as well. The tolerant upper bound the field recommends to consumers now points atphaseTimings.dnsMicrosas the measurable part of the pre-send interval. The field's rustdoc, the generated bindings, and the wrapper dartdoc now say so, and point at NTS-153 for aligning the capture points.README.md,ARCHITECTURE.md, andexample/GUI_GUIDE.mdcarried the same superseded claim — each describedntsGetTimeas selecting the peer delay — and now all three lead with the round-trip branch taken in practice, scoped to the catalog measurement, citing the window as the condition rather than as a plausibility judgement. The remaining API-doc sites that still called an in-window δ "plausible" (nts_queryandNtsTimeSample::utc_unix_microsin the rustdoc and their generated and wrapper counterparts, plusNtsSyncedTime) now use the same selection-window framing, so the public API surface no longer contradicts the prose. Documentation only; no behaviour change. -
The AES-128-GCM-SIV path (AEAD ID 30) migrated to the
aes-gcm-siv0.12 API. The crate moved to the RustCryptohybrid-arraytraits line, soKeyInitnow comes fromaes_gcm_sivrather thanaes_siv, and the deprecatedArray::from_sliceconstructors are replaced by the infallible&[u8; 16]conversion for the key and aTryFromconversion for the nonce. The nonce conversion subsumes the hand-written length check inseal_packet/open_packet, which now maps its failure to the sameInvalidNonceLengtherror — no behavioural change on either path.aes-siv(AEAD IDs 15 and- is still on the older
generic-arrayline because its 0.8 release is release-candidate only, socargo deny'smultiple-versionsgate carries version-pinned skips for the eight duplicated RustCrypto crates. Six of them expire whenaes-siv0.8 ships;block-bufferandcrypto-commonare additionally held byflutter_rust_bridge_macros -> md-5 -> digest 0.10and will remain needed until that chain also moves.
- is still on the older
9.0.0 #
Breaking #
-
The cumulative counters on
NtsDnsPoolStatsandNtsTrustStatuschanged fromBigInttoint:recovered,refused,spawnFailed,defaultBackendPlatformCount,defaultBackendHybridCount,defaultBackendWebpkiCount,defaultBackendCustomCount, andandroidHybridFallbackCount.These were the last
BigIntfields on the public surface. They wereBigIntonly because the Rust structs behind the bridge declared themu64, which FRB binds toBigInt; the stated rationale on the fields — that a 32-bit wraparound would be visible on long-running builds — justifies the 64-bit backing store, not theBigIntbinding. The backing counters remainAtomicU64; only the bridge-facing struct fields are redeclared asi64, which FRB binds asPlatformInt64and the conversion layer narrows to a plainint. This matches whatPhaseTimingsandntsBoottimeMicrosalready did, and removes the split insideNtsDnsPoolStats, whoseinFlight/highWaterMarkwere already plainint.u64→i64is range-narrowing, so the overflow policy is explicit: the projection saturates ati64::MAXrather than wrapping, keeping the published sequence non-decreasing. The clamp is unreachable in practice — a counter bumped once per DNS lookup or per handshake would need 2^63 events to reach it. Web remains unsupported (NTS-KE needs a raw TCP socket), so the 53-bit JavaScript integer limit is not a consideration.Migration: drop
BigInt.from(...)at construction sites and compare against plain integer literals.stats.refused > BigInt.zerobecomesstats.refused > 0. Both DTOs are nowconst-constructible with literal counters. The wire layout is unchanged — the counters still cross the boundary as 8 bytes each — so no native rebuild is required beyond the regenerated bindings.
Security #
-
NTS cookies are now capped at 512 octets and client NTP requests at 1200 octets. RFC 8915 deliberately leaves the cookie opaque and unbounded (§4.1.6, §5.4) because only the issuing server needs to interpret it; deployed servers issue roughly 100 octets. The client previously accepted whatever a KE server sent, bounded only by the overall KE message budget. That mattered beyond the one allocation because
build_client_requestsizes each cookie placeholder to the cookie it is standing in for, so a single oversized cookie inflated every subsequent NTP request by roughly twice its length — a KE-side input silently driving UDP datagram growth on the query path, past the point of IP fragmentation and into MTU black-holing.Oversized cookies are rejected at two points, with deliberately different policies. During KE record decoding the whole message fails with the new
CodecError::CookieTooLarge, checked before the body is copied, so the handshake stays atomic — a partial harvest would silently degrade the pool. In an AEAD-authenticated NTP response the oversized entries are filtered instead: the time sample is sound, and discarding it would trade a real synchronisation for cookies the client is free to ignore. Conforming cookies in the same packet are still deposited, the drop count is reported onServerResponse::oversized_cookies_dropped, and ants::ntpwarning is logged so the slower-than-expected pool refill is observable rather than silent.build_client_requestalso projects the full on-wire packet size — header, unique identifier, cookie, placeholders, and authenticator, each padded to the 4-octet extension alignment — and refuses with the newNtpError::PacketTooLargebefore allocating. The cookie cap alone does not bound the packet, becauseplaceholder_countis caller-supplied and each placeholder is sized to the cookie. The 1200-octet limit is the RFC 8200 §5 minimum MTU less headers, with margin for tunnel encapsulation. The projection doubles as the allocation hint, replacing a fixed guess. (NTS-125) -
The per-client session table is now bounded, so cached AEAD keys and cookie jars are no longer retained for the life of the process.
SessionTablepreviously held everyhost:portit had ever handshaken with until an explicitinvalidate/clearor a rekey signal for that exact key — a caller that rotated through many servers, or that derived host strings from untrusted input, accumulated key material without limit and had only manualclear()as a remedy.Two bounds now apply. A hard ceiling of 64 entries evicts the least-recently-used session to make room for a new host, ranked by a per-session stamp that each successful cookie draw refreshes so an actively-used session is never the victim; re-handshaking a host already cached replaces it in place and evicts nothing. Independently, any session idle for 24 hours is dropped. That stamp is a
BootInstantrather than anInstant, so idle time keeps accruing across device suspend — underInstanta table populated before a long sleep would hold its keys for the sleep duration on top of the TTL, which is exactly the backgrounded-app case the TTL exists to cover.Both bounds are swept whenever a session is installed, and the TTL is additionally checked when a cached session is drawn from. The second check is what makes the TTL bind for a process that goes quiet and then queries the same host again: that path installs nothing, so without it the stale session would be served and its stamp refreshed, and the entry would never age out.
Eviction drops the
Session, releasing itsZeroizeOnDropAEAD keys and its cookie jar, so the bound is on secret retention and not merely on memory.invalidate(spec)andclear()are unchanged and remain the eager controls for callers that need a session gone at a specific moment. No public API changes; the bounds are internal policy. (NTS-124)
Added #
-
NtsClient.dispose()releases the client's native handle — and with it the session table, its cached AEAD keys, and its cookie jars — at a moment the caller chooses, instead of leaving it to the GC finalizer. The method existed internally (onlyntsGetTime's call-scoped client used it) and is now public.Optional, not required: the finalizer remains the backstop, so a client that is simply dropped is still reclaimed. What was missing was any way to make the timing deterministic — a client scoped to a work batch, a screen, or a test could pin native state well past the point the app considered it dead, and an app minting many short-lived clients had no lever at all short of GC pressure.
Distinct from
clear(), which empties the session table and leaves the client usable;dispose()ends the client. Idempotent, and safe to call with aquery/warmCookies/getTimealready executing on the native side: such a call took its own reference to the native object when its arguments were encoded, and runs to completion. A call still queued at the bridge admission gate has not encoded its arguments yet, so it is refused once admitted, as is any method called afterdispose(); the refusal is an FRBFrbExceptionrather than anNtsError, since the failure is in the handle rather than in the protocol. (NTS-114) -
TimeoutPhase.dnsSpawnFaileddistinguishes "the OS refused to create a DNS worker thread" from the pool-cap refusal already reported asTimeoutPhase.dnsSaturation. Additive enum growth:switchstatements overTimeoutPhasethat were previously exhaustive will now need a case for it (or adefault). -
NtsDnsPoolStats.spawnFailedcounts those refusals, disjoint from bothrefused(admission blocked by the cap) andrecovered(a detached worker that actually ran). The pairrefusedvsspawnFailedis what makes the cap-vs-ceiling distinction observable without parsing error strings, since both refusals surface asWouldBlockinternally. Callers constructingNtsDnsPoolStatsdirectly — test fixtures, chiefly — must pass the new required field. -
NtsTimeSample.keWarningsandNtsWarmCookiesOutcome.keWarningsexpose the non-fatal NTS-KE warning codes a server sent with the handshake (RFC 8915 §4.1.4 record type 3) asList<int>raw code values, in the order received. Previously the KE layer parsed these records but discarded them, so a server signalling a warning was indistinguishable from one that sent none. Empty for every server observed in practice — the IANA NTS-KE warning registry has no assignments as of RFC 8915 — so a non-empty list means the peer sent a code this client version cannot interpret. Codes are surfaced, not acted on: nothing here fails a query, since by definition a warning did not stop the handshake. A non-empty list is also logged once per handshake atwarnon targetnts::ke.A warning describes the handshake, so on
NtsTimeSamplethe value follows the session across cached-session queries rather than resetting to empty likephaseTimings— matching howtrustBackendalready behaves. A caller polling in steady state therefore cannot miss codes by having started after the cookie pool went warm. OnNtsWarmCookiesOutcomethere is no cached-path nuance, since that call always runs a fresh handshake; a singleflight waiter that collapsed onto a concurrent leader reports the leader's codes, asfreshCookiesandtrustBackendalready do.Additive and source-compatible: both fields default to
const [], so existing constructor calls andNtsTimeSamplefixtures compile unchanged. Callers that destructure exhaustively or compare DTOs against hand-built expected values will observe the new field in==,hashCode, andtoString. (NTS-127) -
New advisory CI workflow
.github/workflows/cross-platform.ymlruns the Rust live probes and thetest/live/Dart suite on bothubuntu-latestandwindows-latest, weekly (Mondays 07:00 UTC) and on manual dispatch. It adds the first CI coverage of the Windows-conditionalwindows-sysarm behindnts::boottime, and the first CI run of the Dart live suite on any platform. The workflow is not a required status check: its steps depend on public NTS server reachability, so a red run is a signal to triage rather than a merge blocker. Repository infrastructure only — no packaged code changed. (NTS-12) -
The
dependency-reviewjob now carries anallow-dependencies-licensescarve-out for build-time GitHub Actions, separating them from the NTS-72 SPDXallow-licenseslist. That list is a distribution policy governing what may be linked into the published package or thents_rustcdylib, which is why it is kept in lockstep with[licenses].allowinrust/deny.toml; actions are a different population, executed on an ephemeral runner and never conveyed to a user. Exemptions are named per-action rather than per-licence so a future copyleft action must be added deliberately. One entry today:Swatinem/rust-cache(LGPL-3.0), already used byci.ymlandfuzz.ymland surfaced only because the new workflow above introduced it "newly" from the diff's perspective. Repository infrastructure only — no packaged code changed, and the distribution policy is unchanged. (NTS-12)
Fixed #
-
NTS-KE handshakes against servers that clear the Critical bit on the AEAD Algorithm record now succeed instead of failing with
NtsError.keProtocol. RFC 8915 §4.1.5 states that the Critical bit on this record MAY be set — it is the deliberate exception to the MUST imposed on EndOfMessage (§4.1.1), Next Protocol (§4.1.2), Error (§4.1.3), and Warning (§4.1.4). The parser enforced the bit by false symmetry with the Next Protocol check, making every conforming server that clears it permanently unreachable; members of the publicntp.brpool do exactly this, and becausegps.ntp.brresolves to two addresses that disagree, the failure presented as intermittent.A cleared bit is now recorded at
debuglevel under thents::kelog target and the handshake continues, matching the treatment already given to unknown non-critical records (§4.1.4). No downgrade surface is introduced: the record is carried inside the TLS channel, so an on-path attacker can alter neither the bit nor the algorithm identifier, and the returned identifier is still validated against the client's offered list. The Critical-bit requirement on the Next Protocol record is unchanged — §4.1.2 genuinely says MUST. (NTS-138) -
The Dart-side copy of
customRootsis now wiped after the FFI handoff instead of being left readable until the GC runs. TheNtsClientconstructor copies the caller'sList<int>into theUint8Listthe FFI encoder requires; the Rust side holds its equivalent in aZeroizing<Vec<u8>>(CustomRootsBytes), so the intermediate Dart copy was the weaker end of that story for deployments where the anchor set itself is confidential. The copy is overwritten with zeros in afinally, so it is cleared on the throwing path too — the case where the bytes would otherwise be both unreachable and unwipeable.Bounded, not total, and the constructor dartdoc now says so. Two copies stay outside the package's reach: the caller's own list, which is theirs to manage and is never mutated, and the FRB serializer buffer the encoder writes into, which is upstream-owned — the same class of residue the Rust-side
CustomRootsBytesdocs already record for the PEM parse path. -
The ABI-mismatch conversion no longer rewrites a bare
ArgumentErrorasNtsError.abiMismatch. The wrapper widens its catch around the FFI call to convert codec decode failures — bytes the generated codec cannot read against the layout it was built for — into an error naming the rebuild. The predicate matchedArgumentErroralongsideRangeErrorandUnimplementedError, which is the widest of the three: it swept in throws that have nothing to do with the wire layout, answering an unrelated diagnostic with "rebuild the native library from the Rust sources" and sending the reader somewhere the fault is not.Driving the real generated
sse_decode_*functions over malformed buffers shows every drift shape they produce is aRangeError(a short buffer, or a fieldless-enum index past the end ofvalues) or anUnimplementedError(an unrecognised variant tag). No shape yields a bareArgumentError, so matching it bought no coverage. The predicate is now those two shapes;RangeErrorremains matched in its own right rather than via itsArgumentErrorsupertype. Anything else thrown from inside the call — a bareArgumentError, aFormatException, theStateErrorFRB raises for a missedNtsRustLib.init()— reaches the caller unchanged. The codec-driven tests now assert membership in exactly those two shapes, so a decoder that started throwing something else fails the suite rather than quietly relying on a broader catch. -
A system clock reading before the Unix epoch no longer produces an all-zero NTP transmit timestamp. On a device whose RTC has reset to 1970-or-earlier,
SystemTime::now().duration_since(UNIX_EPOCH)fails and the conversion returned0, so every query on that device sent an identical T1. The server echoes T1 back asorigin_timestamp, and the client checks the echo — a constant makes that check pass for any captured response, not just the one it was sent for, weakening it as an anti-spoof signal precisely on the devices whose clocks are least trustworthy.The pre-epoch branch now derives a non-zero, microsecond-resolution value from the sleep-aware boot clock, so successive queries differ. The boot clock is packed into the NTP64 wire format rather than being offset onto any epoch, so a reader interpreting it as a timestamp lands in the 1900s. That is deliberate: it is a uniqueness and echo token rather than a time, and an implausible year keeps it from being read as a genuine clock value in a packet capture. The offset computed from such an exchange remains meaningless, exactly as it was when the value was zero: T1 and T4 sit in the 1900s while T2 and T3 carry real server time. Peer-delay, by contrast, becomes sound — T1 and T4 come from the same fallback source, so T4−T1 is a true elapsed duration where previously it was zero. The emitted sample time still comes from the server's T3, and round-trip time is still measured locally.
-
DNS worker-thread spawn failure is no longer misreported as a network error. When the bounded resolver pool granted a slot but the OS then refused to create the
nts-dnsworker thread, theio::Errorfromthread::Builder::spawnescaped through the same path as a genuine lookup failure. Because the two mapping sites keyed only offErrorKind, anENOMEMrefusal (ErrorKind::OutOfMemory) surfaced asNtsError.networkwith the messageDNS lookup failed for host:port: …, pointing operators at the network or the server when the actual cause was a process-local thread or memory ceiling. AnEAGAINrefusal (ErrorKind::WouldBlock) was silently conflated with cap saturation instead.Spawn refusal now reports as the new
TimeoutPhase.dnsSpawnFailed(see Added). It is kept distinct fromdnsSaturationbecause the remediations are opposed: saturation means the cap is the binding constraint and raisingdnsConcurrencyCaphelps, whereas a spawn refusal means admission already succeeded, so raising the cap would admit more work the process cannot service. -
The DNS pool's
recoveredcounter no longer credits workers that never started.thread::Builder::spawntakes ownership of the closure and drops it when the spawn fails, so theSlotGuardmoved into the closure ran itsDropon that path — incrementing the counter thatARCHITECTURE.mddesignates as the "libc is wedged" signal for a thread that never ran, and blunting exactly the signal operators are told to alert on. The slot now travels to the worker as aDrop-freePendingSlotand is re-armed there, leaving the spawn-failure branch to release the slot explicitly. -
A call queued behind the bridge admission gate now surfaces its timeout after a device suspend instead of parking past it. Queue wait was already charged on the sleep-aware monotonic clock, so the budget crossing the FFI boundary stayed honest, but cancellation of a still-queued waiter was a
Timerarmed for the full timeout.Timerruns on the event loop's suspend-frozen clock, so a device that slept through the budget resumed with the timer still owing its whole remaining slice — the waiter kept parking for an outcome already decided, and only unparked once a slot happened to free or the frozen timer eventually caught up.Deadlines are now absolute readings on the same sleep-aware clock, swept by one queue-wide timer rather than one full-length timer per waiter. Each arming is capped at 250 ms, so a resume re-evaluates every deadline against the boot clock within one slice; the cap never delays a nearer deadline, which is every deadline while awake, and the sweeper is only armed while the queue is non-empty. Expiry now happens in the same single-pass compaction that performs admission, so the existing O(n) cost under a mass-timeout burst is unchanged and a freed slot goes to a waiter that can still use it rather than to one the dispatch-side residual check would reject again. No public API changes. (NTS-111)
-
KE responses that redirect the NTP phase are now validated before any post-handshake I/O.
validate_responseinnts::ketook the NTPv4 Server and Port records raw: an empty Server body reached the resolver as an empty host, andPort(0)reached the UDP socket as an unroutable destination. Both surfaced as an opaqueNetworkfailure or timeout well after the handshake had succeeded, even though the same host/port shape is rejected up front when it arrives from the caller viaNtsServerSpec. A KE peer that completes TLS — a buggy server, or one whose certificate an attacker holds — could therefore steer the client into failing I/O rather than being refused as a protocol violation. Both now fail the handshake with newKeErrorvariantsEmptyServer/ZeroPort, surfacing to callers asNtsError::KeProtocolwith a stable RFC-citing message. Only the redirected values are checked: an absent Server record still falls back to the already-validated request host, and an absent Port record toDEFAULT_NTPV4_PORT(123). (NTS-123) -
Duplicate NTPv4 Server and Port records in a KE response are now rejected.
validate_responsealready refused duplicate NextProtocol and AEAD Algorithm records, but Server and Port still resolved via a first-matchfind_map— so an ambiguous response silently pinned one endpoint with no signal that the response was malformed, the same pre-hardening pattern deliberately removed for the other two records. NewKeError::DuplicateServer/DuplicatePortvariants are raised from the existing duplicate-detection loop, ahead of the walks that would otherwise mask the violation. (NTS-128) -
Per-call timeout budgets now keep elapsing while the device is asleep. The KE handshake deadline (
nts::ke::Deadline), the UDP setup deadline (api::nts::UdpDeadline), the singleflight leader/waiter budgets incheckout_withandwarm_cookies_with, theHandshakeSlotcondvar waiter, and the call-wide anchor innts_querywere all anchored onstd::time::Instant, which is suspend-frozen on every platform this package targets (CLOCK_MONOTONIC/mach_absolute_time/ QPC). A mobile call withtimeoutMs: 5000that suspended mid-handshake resumed after wake with most of its original budget still nominally unspent, while wall clock had already blown past the caller's limit — and the Dart layer, which charges residual against a sleep-aware clock, disagreed with the native side about how much budget was left. All six now anchor on the newnts::boottime::BootInstant, anInstant-shaped wrapper around the existing suspend-inclusiveboottime_microsreading (CLOCK_BOOTTIME/mach_continuous_time/QueryInterruptTimePrecise). The condvar waiter additionally re-reads the boot clock on every wake, becausewait_timeoutis itself suspend-frozen and would otherwise under-count a suspend that spanned a park. Short in-call measurements — the per-phase durations reported inNtsDiagnosticsand the RTT bracket around a singlesend/recv— deliberately stay onInstant. (NTS-122) -
SeenUidCacheentries now age across device suspend. The replay guard's 5-minute TTL stampedInstantreadings, so a cache populated before a long sleep retained its Unique Identifiers for the sleep duration plus the TTL rather than the TTL alone. The behaviour was conservative for replay detection (the window stayed open longer than documented) and bounded by the existingSEEN_UID_CAPceiling, but it pinned memory across suspend and put the cache on a different clock from the budgets above. Timestamps are nowBootInstant. (NTS-129, landed with NTS-122 so the clock abstraction was reviewed against both a deadline consumer and a TTL consumer at once) -
The Dart wrapper now rejects a
verificationTimeabove the year-9999 ceiling before dispatch._validateRangeschecked only for negatives, so a far-future instant crossed the FFI boundary and came back with a Rust-authoredinvalidSpecmessage fromvalidate_verification_time_ms. The Dart side now mirrorsMAX_VERIFICATION_TIME_MS(253402300799000, 9999-12-31T23:59:59Z) and authors its own message, restoring the front-loaded single error surface the port, timeout, and concurrency caps already use. The ceiling is inclusive on both sides. (NTS-107) -
A blank
NtsServerSpec.hostis now rejected on the Dart boundary. Onlyportwas range-checked; an empty host was left to Rust'svalidate, costing an FFI hop for a Rust-authored message, andNtsClient.invalidatesoft-failed such a spec asfalserather than failing closed._validateSpecnow rejectshost.trim().isEmptywith a wrapper-authoredNtsError.invalidSpecacross the four async wrappers,getTime, andinvalidate. Whitespace-only hosts are rejected rather than normalised, since the session key ishost:portverbatim. (NTS-108) -
getTimeno longer inflates an already-spent budget to dispatch the handshake. The warm phase clamped its share of the shared 8-second budget up to 1ms when the balance had fallen below thetimeout >= 1msfloor the lower-level wrappers enforce, so a call whose budget was gone still ran a full KE handshake — extending the documented total budget, and, when that handshake succeeded, replacing the cached session forspec(the process-wide one on the default-client path) on a call that should never have reached protocol work. The balance is now checked before dispatch and a spent one fails immediately with the same syntheticNtsError.timeout(phase: TimeoutPhase.ntp)the post-handshake exhaustion path already used, dispatching nothing. The 1ms floor is now a single_kMinDispatchBudgetconstant shared with the burst loop, which already broke on the same threshold. Only reachable when a device suspend lands between the budget starting and the handshake dispatching — the budget is metered on a sleep-aware clock, which is what makes that window observable at all. (NTS-110) -
The
ntsGetTimedartdoc now describes the budget it actually enforces. It documented the 8-second total as plain wall-clock and listed only post-handshake exhaustion under its failure modes, omitting that the budget is sleep-aware (so a suspended call resumes with the suspended interval already charged), that a spent balance is refused rather than rounded up, and that an exhausted call therefore leaves the cached session untouched.NtsClient.getTime, which delegates to the same helper and defers to that dartdoc for its contract, gains a matching one-line pointer. Documentation only. (NTS-119) -
The example app's iOS deployment target is raised from
13.0to14.0across all three build configurations, meeting the floor declared byfile_picker. Example app only; no package API change.
Changed #
-
The example app's
NtsControllernow callsNtsClient.dispose()on the client it supersedes when a trust-mode flip or a custom-roots change re-mints one, and gains its owndispose()that cancels the two signal subscriptions and releases the final client.main.dartowns the controller from aStatefulWidgetso that teardown has a place to run. The controller previously dropped every superseded client for the GC finalizer to reclaim — the exact patterndispose()was added in 9.0 to replace — leaving the native session table, cached AEAD keys and cookie jars pinned well past the point the app considered the client dead. The three action methods gain anon FrbExceptionarm ahead of their catch-all: a call already executing natively is unaffected by adispose(), but one still queued at the bridge admission gate is refused, as are the later legs ofgetTime's warm-then-burst sequence. Those are logged as warnings against the superseded client; a bridge failure against the active client stays an error. Example app only; no package API change. (NTS-143) -
The example CLI (
example/bin/nts_cli.dart) now reports the DNS pool counters, snapshottingntsDnsPoolStats()either side of the query run so the cumulative fields read as a per-run delta.refused(admission blocked bydnsConcurrencyCap) andspawnFailed(the OS refused the worker thread) are the pair 9.0 added to make that distinction observable, and the CLI was the reference consumer with no way to show it. Human mode gets a two-line trailing block;--jsongets a{"event":"dns_pool_stats"}NDJSON record. Example app only; no package API change. (NTS-144) -
The example app's log renderings now surface
keWarnings. The text form appends a trailingke-warnings=[1,4097]token to the continuation row only when the list is non-empty — the IANA registry has no assignments as of RFC 8915, so every server observed in practice sends none and an always-presentke-warnings=[]would be pure noise. The JSON payloads carryke_warningsunconditionally, empty list included, so a machine consumer can index the key without a presence branch. Both surfaces route throughnts_format, so the GUI log view and the CLI pick it up without a per-caller change. Example app only; no package API change. (NTS-142) -
The internal cookie store is now a single FIFO queue rather than a map keyed by host. Its only owner, a cached session, is 1:1 with a negotiated
host:port, so the key duplicated a value the session already held and every call site passedsession.ntpv4_hostto get it back. What the key did add was a way to get it wrong: the KE endpoint and the NTPv4 host diverge whenever a KE response carries a Server record (RFC 8915 §4.1.7), so a deposit filed under one and a draw made under the other would strand the cookies behind a second key and present an empty jar — no type error, no panic, just a session that re-handshakes on every query. Removing the key makes that mismatch unrepresentable. Internal only; no public API or observable behaviour changes. (NTS-130) -
The internal cookie store's capacity is now a
NonZeroUsizerather than ausizevalidated by a runtime assertion. A zero capacity is degenerate rather than merely invalid — every insertion would evict what it had just stored, so the jar would read as permanently empty and each query would report having no cookies. The old constructor caught that with anassert!, which turns a caller's mistake into a process abort at the moment the jar is built. Encoding the bound in the parameter type rejects it at the call site instead, and removes the only panic on the path. Internal only; no public API or observable behaviour changes. (NTS-132) -
The host attribution in the NTS-KE warning log moved out of
establish_sessioninto a small named helper. The warnings come from the KE peer, but a KE response carrying a Server record (RFC 8915 §4.1.7) redirects the NTP phase to a different machine that emitted nothing — so labelling the warning with the redirect target names the wrong host. That misattribution was previously caught only by review; the helper makes it directly testable without a log-capture harness. Record-level coverage was also added forvalidate_response, pinning that Warning records reach the caller in wire order and that the redirect host stays distinct from the KE host. Internal only; the emitted log line, the public API, and all observable behaviour are unchanged. (NTS-133) -
The bounded DNS resolver's worker-spawn step is now injectable via an internal
resolve_with_spawner, so the spawn-failure branch has direct test coverage. That branch normalises both libc shapes (EAGAIN,ENOMEM) toWouldBlockand tags the message with a stable prefix, which is the only thing distinguishing a refused spawn from a saturated pool at the two mapping sites that classify the error. The prefix contract was previously pinned only against a hand-built error, so a refactor that dropped or reformatted it would have silently regressed the reported phase back to DNS saturation with no test failure. The new test drives the real branch and follows the resulting error through to its phase tag, exercising producer and consumer together. The production path still resolves to a single monomorphised call to the real thread builder. Internal only; no public API or observable behaviour changes. (NTS-134) -
Breaking (error type): the
trustMode/customRootspair validation now throwsNtsError.invalidSpecinstead ofArgumentError. Both violations — a non-nullcustomRootswithoutTrustMode.custom, andTrustMode.customwithout non-empty roots — previously escaped the documented "single structured failure type" contract, so a caller with only anon NtsError catcharm missed them on the asyncntsGetTimepath. The checks move to_validateTrustPolicyinnts_validation.dartso theNtsClientfactory and every entry point routing through it share one implementation. Messages are unchanged; only the thrown type differs. Callers catchingArgumentErrorfor these two cases must switch toNtsError(orNtsErrorInvalidSpec). (NTS-109) -
ntsGetTimeandNtsClient.getTimenow share one preamble and one closure binding. Both previously repeated the same three-step verification-instant conversion, validation, and re-wrap, then bound a structurally identicalwarm/queryclosure pair forwarding five arguments apiece — the only difference between the two blocks being whether the closures called the top-level functions or the client methods. Both now delegate to a shared_getTimeForhelper that selects the endpoint pair by tear-off and binds the arguments once, so the forwarded arguments cannot drift between the two surfaces. The burst-orchestration engine is unchanged, as is thentsGetTimebranch that runs a non-default trust policy against a private, call-scoped client. Internal only — both public signatures and all observable behaviour, including the promise that validation failures arrive as a rejected future rather than a synchronous throw, are unchanged. (NTS-77) -
Rust intra-doc links in the generated Dart bindings are now rewritten into Dart form. FRB copies
rust/src/api/nts.rsdoc comments verbatim intolib/src/ffi/api/nts.dart, so the Dart mirror documented Dart APIs using Rust paths: 59 links across the file used::, Rust casing, orSelf, none of which name anything on the Dart side, so every one rendered as a dead reference. A new post-codegen pass intool/check_bindings.dartresolves each path against a symbol table derived from the generated Dart rather than from a casing rule, because FRB treats the shapes differently — a plain enum variant becomes a lowerCamelCase value, a freezed sealed-class variant a named factory, a#[frb(sync)]newthe unnamed constructor, a free function a camelCase top-level. A uniform lowercasing rule would emit confidently wrong targets for three of those. References to items FRB excludes from the bindings are downgraded to inline code, matching what the Rust source already does by hand for crate-internal names, and anything that resolves to nothing fails the check with the originatingrust/src/api/*.rsline rather than passing through Rust-shaped. The Rust source is untouched, so Rust readers keep working intra-doc links. Documentation only. (NTS-135) -
MonotonicClockno longer names a generated class when deciding whether the installed bridge API is the real FFI dispatch implementation. The gate testedapi is NtsRustLibApiImpl, an identifier derived fromdart_entrypoint_class_nameinflutter_rust_bridge.yaml; renaming the entrypoint broke the file loudly, but a codegen template change that reshaped the class hierarchy could have left it compiling while selecting the opposite arm — silently demoting a real bridge to the suspend-frozenStopwatchfallback that v7.0.0 removed for production builds. The test is now againstBaseApiImpl, hand-written flutter_rust_bridge runtime code that every generated implementation extends, andtest/api_smoke_test.dartpins both arms of the relationship so an FRB upgrade that broke it fails a test instead. Internal only; no public API or observable behaviour changes. (NTS-115) -
Three dartdoc clarifications on the public API, no code change.
NtsSyncedTime.errorBoundMicrosnow states outright that it is a snapshot bounding the anchor instant and stays fixed whileutcNowkeeps projecting, so it is not the current maximum error; it sketches how to age it with a caller-supplied drift rate, andutcNowcross-links back to it.PhaseTimingsnow says its fields are monotonic elapsed durations measured inside the native call rather than calendar timestamps or sleep-inclusive spans, and points suspend-inclusive budgeting atMonotonicClock/ thegetTimebudget — summing phases stays sound for in-call accounting, but no addend counts suspend, so the sum cannot yield one.NtsClient.invalidatenow distinguishes "no entry was cached" from "the spec is invalid": thefalsereturn reports only the former, an invalid spec throws, and nothing is checked against the network in either direction. (NTS-116, NTS-118, NTS-120)
Documentation #
-
dart run tool/check_bindings.dartis now documented as the canonical way to regenerate the FRB bindings. The docs previously gaveflutter_rust_bridge_codegen generateas the regeneration step, which emits the unpatched form and so reverts the five post-codegen patch passes the script applies — lint suppression, the three diagnostic-message rewrites on the SSE and DCO codec catch-all arms, and the Rust-to-Dart intra-doc link rewriting. The result fails the drift gate, and the gate's own error message pointed back at the command that caused it, so a contributor following it verbatim stayed red. That message now names the script and says why bare codegen is not a substitute,DEVELOPMENT.mdtabulates all five passes rather than only the lint-suppression one, and the remaining references across the PR template, the FRB config,.gitignore,pubspec.yaml,rust/src/lib.rs, and the ABI-mismatch error text were updated to match. Tooling and docs only — no behavioural change. (NTS-136) -
Release notes for
1.4.0and earlier moved to a newCHANGELOG_ARCHIVE.md, which is tracked in git but excluded from the published tarball.CHANGELOG.mdhad reached 221 KB — the largest file in the package and 39% of its uncompressed payload — and pub.dev renders the whole of it on the package page.2.0.0onwards stays inCHANGELOG.md(157 KB); the archive carries the rest, is linked from the top ofCHANGELOG.mdand from the README's "Upgrading" section, and is covered by the doc-snippet validator on the same terms asCHANGELOG.md. No entries were edited or dropped. (NTS-137)
8.0.0 #
Breaking #
-
NtsErrorgains anabiMismatchvariant. The class issealed, so any exhaustiveswitchover it must add an arm; aswitchwith adefaultor wildcard is unaffected. Nothing else about the existing nine variants changed. -
Removed the deprecated millisecond-valued parameters and the constant aliasing them, deprecated since 5.2 in favour of the
Duration/DateTimespellings. Gone: thekDefaultTimeoutMsconstant; thetimeoutMsparameter onntsQuery,ntsWarmCookies,NtsClient.query, andNtsClient.warmCookies; and theverificationTimeMsparameter on those four plusntsGetTimeandNtsClient.getTime. Migration is mechanical:timeoutMs: nbecomestimeout: Duration(milliseconds: n),verificationTimeMs: nbecomesverificationTime: DateTime.fromMillisecondsSinceEpoch(n, isUtc: true), andkDefaultTimeoutMsbecomeskDefaultTimeout.inMilliseconds. TheNtsError.invalidSpecfailures raised when a caller supplied both spellings of a parameter are gone with them — the conflict is no longer representable. (NTS-99)
Added #
-
Failures that originate in the FFI decode path are now converted to
NtsError.abiMismatchinstead of escaping as raw Dart errors. A native library built from Rust sources that disagree with these bindings dispatches successfully and only fails on the way back, inside the generated codec — and because those failures are bareErrors rather thanNtsErrors, they bypassed the wrapper's conversion arm entirely. The result was aRangeError (byteOffset)naming neither the cause nor the fix. All four asynchronous entry points and all five synchronous ones (ntsDnsPoolStats,ntsTrustStatus,NtsClient.trustMode,NtsClient.invalidate,NtsClient.clear) now surface a typed error whose message names the rebuild (cargo build --releaseinrust/, plusflutter_rust_bridge_codegen generateif the Rust API changed). Three decode-failure shapes are attributed to a layout disagreement:RangeError,UnimplementedError(an enum discriminant the generatedswitchhas no arm for), andArgumentError.StateErroris deliberately excluded — it signals a missedNtsRustLib.init(), a bootstrap ordering mistake with its own remediation, and continues to reach callers unconverted as the entry points' dartdoc promises. This complements the CLI loader warning below, which catches the common case ahead of the call but cannot fire for a library loaded from outside a crate tree, a prebuilt binary shipped without sources, or one built for another architecture. (NTS-98)The three attributed shapes are no longer taken on faith. Alongside the mock-driven tests that prove each entry point is wrapped, a second set drives the real generated
sse_decode_*functions over hand-built buffers that disagree with the layout they were generated for — a short struct, an unknown enum tag, an out-of-range fieldless enum index, a nonsense length prefix — and feeds whatever they throw back through the wrapper. Building a genuinely mismatched native library in CI is not practical, so the buffers stand in for one. One case is pinned as deliberately not converted: aStringwhose length prefix is honest but whose bytes are not valid UTF-8 throwsFormatException, which reaches callers unchanged. (NTS-101)
Fixed #
- The example package's CLI tools (
nts_cli,nts_health,nts_manifest) now warn when the native library they load predates the Rust sources it was built from. These tools run under plaindart run, outside the Native Assets pipeline, so nothing kept the dylib in step withrust/src/**:autoLocateDylibresolved the build path by existence alone and opened whatever file was there. A library built before a subsequent Rust change was loaded silently against newer bindings, and the resulting ABI mismatch surfaced as an untypedRangeError (byteOffset)on every host — including known-good ones — with nothing pointing at the real cause. The loader now compares the library's mtime againstrust/src/**andrust/Cargo.toml, and prints a stderr warning namingcargo build --releaseand the crate directory the library came from (derived from its path, so a--library <path>pointing at another crate is named correctly) when it is older. The check stays silent unless bothCargo.tomlandsrc/sit at the derived crate root, so a library outside a crate tree is not reported. The run proceeds, since the mismatch is not certain. Maintainer/contributor-facing only:rust/target/is gitignored and pubignored, and package consumers build throughhook/build.dart, whose cargo invocation tracks freshness itself. Note the check is one-directional — checking out an older Rust revision leaves a newer library that is equally wrong but indistinguishable by mtime. (NTS-97)
Changed #
-
Refreshed both Rust lockfiles ahead of the major, moving 36 packages to their latest compatible versions —
tokio1.52.3 to 1.53.1,regex1.12.3 to 1.13.1,cc1.2.63 to 1.4.0,memchr2.8.1 to 2.8.3,webpki-root-certs1.0.7 to 1.0.9, plusanyhow,bytes, and thefuturesandwasm-bindgenfamilies. Two packages (wasip2,wit-bindgen) drop out of the fuzz lockfile, no longer reachable oncejobservermoved fromgetrandom0.3 to 0.4. No manifest constraint moved;rust/Cargo.tomlis untouched. Three crates are deliberately held back, each pinning rather than loosening the gate that rejected the update, per the guidance in thedependency-reviewjob's own comment block:thiserrorstays at 2.0.18 in both lockfiles. 2.0.19 switchesthiserror-impltosyn 3.0.3while the rest of the graph is onsyn 2.0.119, trippingmultiple-versions = "deny"inrust/deny.toml. (NTS-102)tokiostays at 1.52.3 inrust/fuzz/Cargo.lockonly; the production lockfile carries 1.53.1.rustc 1.99.0-nightlyICEs inrustc_codegen_ssacompiling 1.53.1 under the sanitizer flag setcargo-fuzzpasses. Stable compiles the same version cleanly, so only the fuzz jobs are affected, and the fuzz harness never ships. (NTS-103)rustc-demanglestays at 0.1.27 in both lockfiles. 0.1.28 declares the legacy slash formMIT/Apache-2.0rather than the SPDX expressionMIT OR Apache-2.0;dependency-reviewcannot parse it and synthesizes aLicenseRef-bad-*placeholder that can never match the allow-list. The license terms are unchanged and acceptable — this is a metadata-format defect upstream.cargo denynormalizes the slash form and stays green either way. (NTS-104)
generic-arrayalso stays at 0.14.7, constrained transitively by the RustCrypto AEAD stack rather than by anything this crate declares.
7.1.0 #
Fixed #
- Fixed the example package's
nts_format_test.dartfailing withBad state: MonotonicClock requires the nts bridge: theformatGetTimeSuccessfixture constructsNtsSyncedTime, whose constructor captures a monotonic anchor fromMonotonicClock.instance, but the test never initialized the mock bridge. The test now callsNtsRustLib.initMockinsetUpAll. CI additionally runsflutter testfor the example package (it was previously only analyzed), so example-test regressions fail the build. (NTS-95)
Added #
- New RFC 5905 §8 clock-filter fields on
NtsTimeSample, computed in the native worker from the four on-wire timestamps (T1 client transmit, T2 server receive, T3 server transmit, T4 client receive — T4 is now captured immediately after the UDP recv):offsetMicros(true clock offset θ = ((T2−T1)+(T3−T4))/2, which cancels symmetric network delay and excludes server processing time, unlike theroundTrip / 2approximation),peerDelayMicros(peer delay δ = (T4−T1)−(T3−T2), the round trip minus server processing time),rootDelayMicros/rootDispersionMicros(the reply header's 16.16 fixed-point root metrics converted to microseconds — root delay is decoded as signed per RFC 5905, with negative on-wire values clamped to0since a negative delay is not physically meaningful), andserverPrecision(log₂-seconds clock precision from the reply header). All five Dart constructor parameters are optional and default to0, so existing hand-built fixtures and mocks keep compiling unchanged; a zeropeerDelayMicrosis treated as "not available" by the plausibility check below. (NTS-78) - New RFC 5905 statistics on
NtsSyncedTime:offsetMicros(the winning sample's θ),jitterMicros(sample jitter ψ — the RMS of the offset differences between the winning sample and every other burst sample, RFC 5905 §10;0for a single-sample burst), anderrorBoundMicros(worst-case error at the anchor instant, following the root-distance recipe: half the winning sample's network delay + half the server's root delay + the server's root dispersion + the sample jitter). The constructor parameters are optional: the statistics default to0and the error bound falls back to the pre-7.1roundTripMicros ~/ 2worst case. (NTS-78) - New
NtsTimeSample.recvBoottimeMicrosfield: a sleep-aware monotonic reading (same clock source and epoch asntsBoottimeMicros/MonotonicClock) taken inside the native worker immediately after the AEAD-NTPv4 UDP recv returned — the wire-level receipt instant of the sample, before any FFI-return, worker-thread handoff, or Dart event-loop latency. Subtracting it from a laterMonotonicClockreading in the same process yields the scheduling lag since receipt. The epoch is arbitrary (per-boot): never persist the value or compare it across boots, devices, or processes. The public Dart constructor parameter is optional and defaults to0(an epoch-implausible sentinel that triggers the anchor-lag fallback below), so existing hand-built fixtures and mocks keep compiling unchanged. (NTS-94)
Changed #
ntsGetTime/NtsClient.getTimenow select the winning burst sample by lowest network delay — the RFC 5905 peer delay δ when it is plausible (within(0, roundTripMicros]), falling back to the locally measured round trip when it is not (pre-7.1-shaped fixtures, or a local clock step mid-exchange) — and compensate the one-way delay with that same value (utc + delay / 2instead ofutc + roundTrip / 2). On real servers δ excludes server processing time, so the compensated instant no longer counts the server's receive-to-transmit gap as network transit. (NTS-78)ntsGetTime/NtsClient.getTimenow anchor the constructedNtsSyncedTimeon the winning sample's wire-level receipt stamp instead of a post-awaitDart-side observation, removing the FFI-return / event-loop scheduling latency that previously made the compensated UTC lag true time by that (unmeasured) delta. Samples whose stamp fails an epoch-plausibility window (hand-built fixtures, mock-mode fallback clocks) fall back to the previous post-awaitapproximation. (NTS-94)
7.0.0 #
Added #
- New public
MonotonicClockclass (exported frompackage:nts/nts.dart): a sleep-aware monotonic time source whose readings keep advancing while the device is in deep sleep, unlikeStopwatch. ReadsCLOCK_BOOTTIMEon Android/Linux,mach_continuous_timeon iOS/macOS, andQueryInterruptTimePreciseon Windows through a new synchronous bridge call (ntsBoottimeMicros). Each instance resolves its source once at construction, so readings from one instance never mix epochs; construction before bridge init throws (see the breakingNtsSyncedTimeentry below for the exact contract). The sharedMonotonicClock.instanceis the same timeline the package now uses internally. (NTS-90)
Changed #
-
Breaking: constructing an
NtsSyncedTimebefore the bridge is initialized now throws aStateError(namingNtsRustLib.init()as the fix). In 6.0.0 the constructor anchored on a plainStopwatchand worked without the bridge; it now captures its anchor fromMonotonicClock.instance, which — like directMonotonicClockconstruction — fails fast when neitherNtsRustLib.init()norNtsRustLib.initMock()has run. A production build can therefore never silently degrade to a clock that freezes during device sleep. TheStopwatchfallback exists only for mock mode (NtsRustLib.initMock(), or a hand-supplied API passed toNtsRustLib.init(api: ...), when the API does not stubcrateApiNtsNtsBoottimeMicros) and is gated structurally: a real bridge (the generated FFI implementation installed byNtsRustLib.init()) dispatches the clock read directly with no probe and no catch, so any failure propagates instead of silently switching the instance to a suspend-frozen source. Migration:await NtsRustLib.init()(orNtsRustLib.initMock(...)in tests) before touchingMonotonicClock,NtsSyncedTime, orntsGetTime; downstream mocks should stubcrateApiNtsNtsBoottimeMicrosto keep the sleep-aware source in tests. The throwing lazy static is not poisoned: the firstMonotonicClock.instanceaccess after init resolves normally. (NTS-93) -
NtsSyncedTime.utcNow/elapsedSinceSync, thegetTimetotal timeout budget, and the bridge admission gate's queue-wait metering now run on the sleep-awareMonotonicClock.instancetimeline instead of per-callStopwatches. A device that sleeps mid-session no longer silently freezes the projected clock or stalls an in-flight budget:utcNowstays correct across suspend/resume, and a budget that elapses during sleep surfaces astimeout(ntp)on resume. Pure-Dart tests keep working throughNtsRustLib.initMock(), which retains theStopwatchfallback for mocks that do not stub the boottime call (see the breaking entry above). (NTS-90) -
The top-level
ntsGetTimenow accepts optionaltrustModeandcustomRootsparameters, so a one-call synchronized clock can run under a non-default trust-anchor policy without hand-constructing anNtsClient. The default (TrustMode.platformWithFallback, no custom roots) keeps the existing process-wide singleton path byte-for-byte unchanged; any other policy routes the whole warm+burst flow through a private call-scoped client whose native handle is disposed before the call returns. This is sound on this path specifically becausentsGetTimealways forces a fresh handshake and spends only the cookies that handshake minted — no cache-reuse window exists in which a session established under a different policy could be served. Pair validation matches theNtsClientconstructor (customRootsrequiresTrustMode.customand vice versa, rejected withArgumentErrorbefore any FFI dispatch).ntsQuery/ntsWarmCookiesare deliberately unchanged: their value is the warm session cache, and a per-call policy there requires session-table re-keying tracked separately. (NTS-89)
6.0.0 #
Breaking changes #
- Removed the eight deprecated underscore-prefixed typedef aliases
for the pre-3.0
NtsErrorvariant names (NtsError_InvalidSpec,NtsError_Network,NtsError_KeProtocol,NtsError_NtpProtocol,NtsError_Authentication,NtsError_Timeout,NtsError_NoCookies,NtsError_Internal) and the@Deprecatedfield0getter aliases on the variant subclasses. Both surfaces had been deprecated since 3.0.0; removal was scheduled for 4.0.0, deferred, and missed again at 5.0.0. Migration is mechanical: drop the underscore (NtsError_X→NtsErrorX) and switchfield0reads /:final field0pattern matches to the named field (messageon every string-payload variant,phaseonNtsErrorTimeout). (NTS-87)
5.2.4 #
Added #
- Added a one-call high-level convenience API: top-level
ntsGetTimeand per-clientNtsClient.getTime. Both compose the existing wrappers — a freshwarmCookieshandshake followed by a serial burst of up tomin(8, freshCookies)querycalls — pick the lowest-RTT sample, apply the standard symmetric-path compensation (utc + roundTrip / 2), and return the result as a newNtsSyncedTimeanchored to a process-local monotonicStopwatch(utcNowprojects the authenticated instant forward, immune to system clock steps;roundTripMicros,samplesUsed,trustBackend, andelapsedSinceSyncexpose the diagnostics). Tuning is fixed and internal — one configuration sized to serve phones and desktops alike: an 8-sample burst, one total 8-second wall-clock budget shared across the handshake and every burst query as a single shrinking deadline, and the package-default concurrency caps forwarded to every underlying call. Deployments needing different numbers composentsWarmCookies+ntsQuerydirectly. Error posture is best-effort across the burst: individual query failures are tolerated when at least one sample lands; an all-fail burst rethrows the last query error, a zero-cookie handshake surfacesNtsError.noCookies, and a budget exhausted before the first query surfacesNtsError.timeout(phase: ntp). Validation front-loads the same range checks asntsQuerybefore any FFI dispatch. Dart-only wrapper layer; zero FFI/bridge changes. The example app's GUI gains a matching Get Time action button alongside NTS Query / Warm Cookies, with aformatGetTimeSuccesslog rendering that reports the burst size, projected UTC, and± RTT/2error bound. (NTS-76, NTS-80) - Added
.github/workflows/advisory.ymlwith two scheduled, non-blocking documentation-hygiene jobs (weekly, Wednesday 05:00 UTC): atyposspell check over the whole tree (configured via the new_typos.toml, whose suppressions are all verified false positives — theallo-isolatecrate name, hyphenatedmis-*prose prefixes, bead IDs, base64/PEM test fixtures,PNGs, and Xcode-generated*.pbxproj/*.xib/*.storyboardfiles) and alycheelink check over all Markdown (configured via the newlychee.toml, which excludes build-artifact paths and the auth-gated Dependabot dashboard URL, retries transient failures, and accepts 429s). Both jobs stay off the required-checks list; the workflow also runs on PRs that touch itself or its config files so changes to the checks are exercised before merge. Both runs verified clean locally against typos v1.48.0 and lychee v0.24.2. (NTS-74) - Defined explicit Codecov status checks in
.codecov.yml, replacing the default "auto" targets: project statuses for the merged report (88%) and per-flagdart(94%) /rust(86%) baselines, each with a 1% threshold, plus a patch status (75%, 5% threshold) for PR-diff coverage. Targets are calibrated ~1pt under the observed baselines (dart 95.43%, rust 87.30%, overall 88.92%). All statuses startinformational: true— they report to GitHub without blocking — and will be promoted to blocking once stable, per the same advisory-first convention as new CI jobs. Flag statuses setflag_coverage_not_uploaded_behavior: includeso carried-forward sessions are evaluated when a PR skips one coverage leg. Config-only; no workflow changes. (NTS-73) - Added
rust/deny.tomland acargo-denyCI job (bans, licenses, sources) to.github/workflows/ci.yml, plus the shared narrow SPDX license allow-list wired into thedependency-reviewjob'sallow-licensesinput. The allow-list is exactly the set of licenses the current dependency tree needs (MIT, Apache-2.0, Apache-2.0 WITH LLVM-exception, ISC, BSD-3-Clause, Zlib, 0BSD, Unlicense, Unicode-3.0, CDLA-Permissive-2.0); any new license reaching the tree fails CI and becomes a deliberate PR decision made by extending[licenses].allowinrust/deny.tomland theallow-licensesinput together. Thebanscheck denies duplicate crate versions (two known duplicates —getrandom0.2.x andwindows-sys0.52 — are version-pinned skips that expire naturally), andsourcesrestricts all crates to crates.io. Theadvisoriescheck is deliberately not run: RustSec coverage already comes from the daily cargo-audit job inaudit.yml. CI-only; the new job stays off the required-checks list initially. (NTS-72)
Changed #
- Migrated the public time-handling API to idiomatic Dart types: the
six async entry points (
ntsQuery,ntsWarmCookies,ntsGetTime, and theirNtsClienttwins) now takeDuration timeout(defaultkDefaultTimeout, a new exported constant equal toDuration(milliseconds: 5000)) andDateTime? verificationTime(interpreted as UTC viatoUtc(); must not be before the Unix epoch). The formerintparameters —timeoutMs,verificationTimeMs— and thekDefaultTimeoutMsconstant remain fully functional but are@Deprecated, slated for removal in a future major release. Passing both spellings of the same knob is rejected asNtsError.invalidSpecwhen the conflict is detectable (verificationTime+verificationTimeMsalways;timeout+timeoutMswhentimeoutdiffers from the default). Behaviour is unchanged for un-migrated callers. Internally the wall-clock budget now flows asDurationend-to-end and converts to the FFI's millisecond integer only at the dispatch boundary, using a ceiling so a live sub-millisecond remainder is never rounded down to a dead budget (the forwarded value may exceed the true remainder by <1 ms). Validation messages name both the new and deprecated parameters, and the timeout message states the 1 ms floor. The example app (GUI controller,nts_cli, health probes) migrated to theDurationAPI; the CLI--timeoutflag stays milliseconds with a single conversion point. Dart-only wrapper change; zero FFI/bridge changes. (NTS-81)
Documentation #
- Restructured the README around the high-level convenience API: the
"Use" section became a "Quick start" leading with
ntsGetTimeand theNtsSyncedTime.utcNowmonotonic projection, and the "Production Considerations" section became "Manual control (advanced primitives)", presentingntsQuery/ntsWarmCookies/NtsClientas the composition surface for callers who need non-default burst sizes, budgets, or handshake timing. The API summary table now listsntsGetTimefirst as the recommended entry point. Cross-references inexample/example.mdandexample/main.dartupdated to the renamed section. Docs-only; no behavioural change. (NTS-85) - Documented the millisecond resolution of the FFI boundary on the
typed time parameters (
ntsQuerydartdoc and the README tuning notes): 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. Docs-only; no behavioural change. (NTS-84)
Security #
- Bumped
anyhowfrom1.0.102to1.0.103to clear RUSTSEC-2026-0190 (Scorecard code-scanning alert #79): an unsoundness inanyhow::Error::downcast_mut()where, after context is added viaError::context, the returned&mut Tis derived from a borrow chain that includes a shared reference, so writing through it is a Stacked Borrows violation (undefined behaviour).anyhowis a purely transitive dependency here (viaflutter_rust_bridge→allo-isolate); noCargo.tomlin this repo declares it.allo-isolate's own caret constraint already permits the patched release, so the fix is aCargo.lock-only bump — applied to bothrust/Cargo.lockandrust/fuzz/Cargo.lock— with no manifest or source change. (NTS-71)
5.2.3 #
Added #
-
Added two cargo-fuzz targets covering the unauthenticated UDP parse surface (
rust/fuzz/fuzz_targets/):parse_authenticator_bodydrives the Authenticator body's length arithmetic (nonce_len/ct_lenprefixes,div_ceilpadding, slice bounds) directly, andparse_server_responsedrives the full receive entry end-to-end under a fixed real AES-SIV-CMAC-256 key — modelling the off-path attacker, who cannot forge AEAD tags, so every pre-AEAD arm (header checks, extension sweep, unauthenticated-NTSN, duplicate-UID, Authenticator parse, AAD-offset arithmetic) is fuzzed exactly as exposed.IdentityAeadwas deliberately not plumbed in: it is#[cfg(test)]-only and would require extending theAeadKeydispatch enum, which its docs pin as intentionally not extended. Both targets ship committed seed corpora (including a fully authenticated canonical reply that parsesOkunder the harness key) and are wired into the nightly.github/workflows/fuzz.ymlmatrix, which now runs five targets. Enabled by re-exportingparse_authenticator_body,parse_server_response, andAeadKeythrough the__internal-fuzz-gated__internal_fuzzmodule; no production API change. (NTS-60) -
Added a
matrix-parityjob to the nightly fuzz workflow (.github/workflows/fuzz.yml) that diffscargo fuzz listagainst the workflow'smatrix.targetlist and fails on any mismatch in either direction. The requirement that every[[bin]]inrust/fuzz/Cargo.tomlis mirrored in the matrix was previously comment-enforced only — a drifted entry manifested as a silently un-fuzzed target with no red signal. The job shares the workflow's triggers, so the PR paths filter covers both drift sources (a new fuzz target underrust/fuzz/**, or a matrix edit to the workflow file). CI-only; no runtime change. (NTS-68) -
Added a Dart-side bridge admission gate bounding how many of the package's blocking bridge calls occupy
flutter_rust_bridgeworker threads at once. Each in-flightntsQuery/ntsWarmCookies/NtsClient.query/NtsClient.warmCookiescall pins one FRB worker (a fixed pool of one thread per logical CPU) for up totimeoutMs, so an unbounded distinct-host fan-out could previously exhaust the pool and stall unrelated bridge calls — a hazard 5.2.2 documented but did not enforce. The four wrappers now accept abridgeConcurrencyCapparameter (defaultkDefaultBridgeConcurrencyCap = 4, validated1..4294967295for symmetry withdnsConcurrencyCapeven though the value never crosses the FFI boundary) enforced by one FIFO gate per isolate (gate state is Dart-side and isolate-local; the FRB worker pool it bounds is shared process-wide): calls beyond the cap queue on the Dart side holding no worker thread, queue wait is charged againsttimeoutMs(only the remainder crosses the FFI boundary; uncontended calls forward the budget verbatim), and a budget that expires while queued fails withNtsError.timeoutcarrying the newTimeoutPhase.bridgeSaturationvalue — Dart-authored, fired before any FFI dispatch, so itstrustBackendis alwaysnull. Mixed-cap bursts get the same asymmetric admission semantics as the Rust-side DNS resolver pool, with one FIFO refinement: a queued call is only overtaken by a later call whose larger cap admits it while the queued call's own cap does not. Behavioural change for existing callers: a more-than-4-distinct-host burst now runs 4-at-a-time instead of pool-width-at-a-time, and the tail of a burst against slow servers can surfacebridgeSaturationwhere it previously competed for pool threads. Source-compat note: exhaustiveswitches overTimeoutPhasegain a new case. The example catalog tools raise the cap to their-cfan-out (mirroring the existing DNS-cap sizing) so probe measurements stay self-saturation-free. (NTS-69) -
Added two catalog CLIs to the example app alongside
nts_cli:nts_healthprobes every server in the bundled catalog with a bounded fan-out and renders a per-server health report (text or JSON), andnts_manifestdistils those probe results into a reliable-server manifest, with a committed snapshot atexample/assets/reliable-servers.json. Both tools share one argument parser (example/lib/src/cli/catalog_tool_args.dart) and one probe engine (example/lib/src/health/probe.dart). Probes that fast-fail withTimeoutPhase.dnsSaturationare bucketed as a local-saturation verdict rather than a server failure, so an over-aggressive local fan-out cannot masquerade as server unreliability; the renderers and aggregation logic are covered by dedicated tests. Example-only; no package API change. (NTS-58, NTS-59) -
Exposed the Rust-side DNS resolver pool cap across the example surfaces. The three catalog CLIs (
nts_cli,nts_health,nts_manifest) gain a--dns-capflag: by default both concurrency caps are auto-sized to the host fan-out (-c/--concurrency) so probe runs stay self-saturation-free, and an explicit--dns-capoverrides the auto-sizing — a value below the fan-out deliberately re-exposes theTimeoutPhase.dnsSaturationfast-fail for testing. The GUI controller (example/lib/src/state/nts_controller.dart) now passesdnsConcurrencyCap(package defaultkDefaultDnsConcurrencyCap = 4) explicitly at itsntsQuery/ntsWarmCookiescall sites, mirroring the existingbridgeConcurrencyCapthreading. Example-only; no package API change — the parameter itself has been public since 1.3.0.
Changed #
-
Bumped the pinned Rust toolchain (
rust/rust-toolchain.toml) from 1.96.1 to 1.97.1. The point release carries the fix for an LLVM miscompilation (rust-lang/rust#159035) present since at least Rust 1.87 — relevant to the cryptographic core, so tracked promptly. No binding regeneration was required:dart run tool/check_bindings.dartunder the new pin reports the FRB bindings in sync (theEq-derive internal names echoed in the generated ignore-list header are unchanged between 1.96 and 1.97), andcargo fmt --check/cargo clippy -D warnings/cargo testall pass with no new lints. MSRV declared inrust/Cargo.tomland mirrored inrust/clippy.tomlstays at 1.87 (1.97 stabilizes nothing the crate adopts). The pin references inREADME.md,DEVELOPMENT.md, andexample/README.mdare aligned with the new version, and the docs now spell out the upgrade path for consumers and contributors: none — rustup resolvesrust-toolchain.tomlon the nextflutter run/flutter build(or anycargoinvocation insiderust/) and auto-installs a bumped pin, so norustup updateor other manual step is required. (NTS-79) -
Bumped the pinned Rust toolchain (
rust/rust-toolchain.toml) from 1.92.0 to 1.96.1 and landed the clippy fixes deferred to this bump in the same change: theempty_enumlint key inrust/Cargo.tomlis renamed to its 1.95+ spellingempty_enums, and twoclippy::map_unwrap_orsites are rewritten (is_ok_andinnts/ke.rs,map_orinapi/nts/tests.rs). The FRB bindings were regenerated under the new pin to absorb a comment-only drift in the generated ignore-list header (lib/src/ffi/api/nts.dart): rustc renamed theEq-derive internal methodassert_receiver_is_total_eqtoassert_fields_are_eqbetween 1.92 and 1.96, andflutter_rust_bridge_codegenechoes those names — required to keep therust-bridge-syncCI gate green. No functional change; MSRV declared inrust/Cargo.tomlis unaffected. (NTS-51) -
The
parse_server_responsefuzz harness now consumes the canned fixture constants (UID,CLIENT_TX,S2C) as re-exports through the__internal-fuzz-gated__internal_fuzzmodule instead of hardcoding mirrors ofnts::test_helpers. Previously, a change to the helper constants would silently de-authenticate the committedcanonical-authenticated-responseseed — pre-AEAD arms would still fuzz but post-AEAD coverage would vanish with no red signal. With the re-exports, a helper change either propagates to the harness or fails to compile.test_helpersis now additionally compiled under the__internal-fuzzfeature (still compiled out of release builds); no production API change. (NTS-67) -
Tightened the PR-time
dependency-reviewCI gate fromfail-on-severity: hightomoderate, so moderate-severity advisories on newly-introduced dependencies now block the merge instead of passing silently. Thehighsetting was always framed as a starting floor; with the dailyaudit.ymlcargo-audit job in place as a second net, the tighter PR-time floor costs nothing extra. Per the established policy, if the gate fires on a transitive bump the fix is to pin the offending dependency, not loosen the gate. CI-only; no runtime change. (NTS-62)
Documentation #
-
Documented FRB worker-pool occupancy of the blocking bridge calls.
ntsQuery/ntsWarmCookies(and theNtsClientequivalents) areasyncon the Dart side, but each in-flight call pins oneflutter_rust_bridgeworker thread for its full blocking duration — up totimeoutMs— and the default pool holds one thread per logical CPU, so an unbounded burst of cold queries against many distinct hosts can occupy every worker and stall unrelated bridge calls. Same-host storms are already collapsed onto one handshake by the Rust-side per-key singleflight. Added a worker-pool-occupancy note with a bounded fan-out recommendation to thentsQuerydartdoc, cross-referenced fromntsWarmCookiesandNtsClient.query/NtsClient.warmCookies, plus a matching module-doc note inrust/src/api/nts.rs. Comment-only; no behaviour change. (NTS-64) -
Documented the
nts_healthcatalog CLI inexample/README.md— prerequisites, usage, flag reference, and how its verdicts relate to the probe outcomes. (NTS-58) -
Realigned the root documentation set (
README.md,ARCHITECTURE.md,DEVELOPMENT.md) and the example docs (example/README.md,example/GUI_GUIDE.md,example/CLI_GUIDE.md) with the Native Assets build flow and the 1.96.1 toolchain pin. The build-hook path is documented once —hook/build.dartresolves the toolchain through rustup from therust/rust-toolchain.tomlpin, auto-installing it plus the platform's cross-compile target on first use — and the example docs cross-reference the root anchors (#prerequisites,#timeout-budget-and-bounded-dns,#rust-log-verbosity) instead of restating them. The root README's "Use" snippet now passesdnsConcurrencyCapalongsidebridgeConcurrencyCapso both resource bounds are demonstrated, theNTS_BRIDGE=mockfallback andverbose_logsuser-define descriptions match the technical detail inDEVELOPMENT.md, and the CLI usage blocks inexample/README.md/example/CLI_GUIDE.mdare verified against the live--helpoutput of the tools. Documentation-only; no behaviour change.
Fixed #
- The FRB drift gate (
tool/check_bindings.dart, run locally and by CI'sVerify FRB bindings are in syncjob) now fails when codegen creates a generated file the repo does not yet track. The gate previously relied ongit diff --exit-code, which reports only tracked-file changes, so a brand-new FRB-emitted module was caught only indirectly (via the tracked dispatcher's import-list change). Agit status --porcelain --untracked-files=allcheck scoped to the watched paths (lib/src/ffi/,rust/src/frb_generated.rs) now fails the gate outright with a dedicated diagnostic naming each untracked file. Complements the existing orphaned-module check, which covers the removal direction. Tooling-only; no runtime change. (NTS-63)
Security #
- Closed the last plain-bytes cookie transit: fresh NTS cookies recovered
from the encrypted NTPv4 reply are now wrapped in
Zeroizing<Vec<u8>>at the parse site (ServerResponse::fresh_cookies), carried throughSessionTable::deposit_cookies, and moved into theCookieJarwithout unwrapping. Previously the transit collection held nakedVec<u8>values until the jar boundary, so the deposit-side discard paths (stale session generation, evicted session) freed cookie bytes without wiping them. The AEAD-decrypted extension body insideparse_server_responseis alsoZeroizing-wrapped now, as is every encrypted-extension body copied out of it (cookie or not), so the decrypted plaintext and its non-cookie discards are wiped on drop as well.ServerResponsealso gains a manual redactedDebug(<redacted; N cookies>) matching the existingClientRequest/CookieJardiscipline, plus a compile-time type pin and a Debug-redaction regression test. Internal type change only — no public API or FRB binding change. (NTS-61)
5.2.2 #
Documentation #
-
Fixed three broken intra-doc links in the
From<std::io::Error> for NtsErrordoc block inrust/src/api/nts.rs. The block linked to crate-private items (KeTimeoutPhase,KeError::PhaseTimeout,bind_connected_udp_using) from a public-API doc context, whichrustdoc -D warningsrejects asprivate_intra_doc_links. Demoted the three references to plain code spans (matching how the same items are already cited elsewhere in the file); the two resolvable links on the block (nts_query,TimeoutPhase::Ntp) are unchanged. Comment-only; no behaviour change. (NTS-49) -
Documented
CookieJar's concurrency contract on the struct's rustdoc. The type auto-derivesSend + Sync(all its fields areSend + Sync), so the marker traits alone do not warn callers off concurrent use; the real constraint is the absence of interior mutability — every mutator takes&mut self, so a jar shared across threads must be externally synchronised.SessionTablealready owns every jar inside itsMutex<HashMap<…>>; the new note closes the gap for any future caller that reaches forCookieJardirectly. Comment-only; no behaviour change. (NTS-42) -
Documented the multi-client trust-routing pattern for apps that must reach servers in more than one trust domain (e.g. a private-CA internal server alongside public servers). Added a "Reaching multiple trust domains" section to
README.mdwith a worked two-client example, and a matching cross-reference in theTrustModeAPI documentation. Reinforces thatTrustModeis fixed per client and that minting one client per trust domain keeps each CA scoped to the hosts it should authenticate rather than widening every server's trusted-issuer set to the union. (NTS-48) -
Documented the asymmetric starvation behaviour of
dnsConcurrencyCapon the Dart-side public API. The cap is a per-call ceiling, but admission is gated against a single process-wide in-flight counter, so a low-cap caller can be refused immediately (NtsError.timeout/TimeoutPhase.dnsSaturation) when the pool is already filled by a higher-cap caller's workers, even though it has started no lookups of its own; the reverse cannot happen. Added a concrete mixed-cap example to thentsQuerydartdoc, inherited byntsWarmCookiesandNtsClient.query/NtsClient.warmCookiesthrough their existing cross-references. Comment-only; no behaviour change. (NTS-44)
Fixed #
- Singleflight waiters now attribute a timeout to the phase the leader
was actually in (DNS, Connect, TLS, or KE record I/O) instead of a
blanket
KeRecordIo. The leader publishes its live phase to its singleflight slot via an advisoryRelaxedatomic (PhaseReporter) at each handshake boundary; a waiter whose per-call deadline expires reads it and emits the matchingTimeoutPhase, so a leader and its parked waiters now report the same phase for the same slow operation rather than telling two different stories. Reuses the existingTimeoutPhasevariants — no public API or FRB binding change. (NTS-43)
Security #
-
Investigated a code-level mitigation for the relative-
ioDirectorylibrary-hijack surface that the README's "Non-Flutter Dart callers must passexternalLibraryexplicitly" subsection documents. Theflutter_rust_bridge-generatedkDefaultExternalLibraryLoaderConfigpinsioDirectory: 'rust/target/release/', which FRB's loader resolves against the process working directory, so a bareNtsRustLib.init()outside a Flutter host loads whatever native library has been planted there. Findings against the pinned FRB2.12.0: (1)flutter_rust_bridge_codegen generateexposes only--default-external-library-loader-web-prefixand--wasm-bindgen-name— there is no codegen knob to suppress the relative fallback, require an absolute path, or detect a non-Flutter context; (2) the generated file is marked do-not-edit and is overwritten on every regen, so editingioDirectoryby hand is not durable; (3) the closest upstream thread,fzyzcjy/flutter_rust_bridge#2168, tracks adding a YAMLioDirectoryoverride but is path-correctness-motivated (it proposescargo metadataauto-detection), not a refuse-relative security mode. A runtime mitigation does exist — build anExternalLibraryLoaderConfigwithioDirectory: null, load it via the publicloadExternalLibrary, and pass the resulting library toNtsRustLib.init(externalLibrary: lib)— but that is a package-owned behaviour change beyond this investigation's scope and is filed as a follow-up. Outcome: the documentation mitigation remains the supported guidance and NTS-11 converts to an upstream-watch tracker againstfzyzcjy/flutter_rust_bridge#2168. No code or behaviour change. (NTS-11) -
Hardened the per-request nonce contract at the NTPv4 codec boundary.
build_client_requestand theClientRequest::noncefield now document that the nonce MUST be CSPRNG-sourced and unique per request under a given C2S key — the non-empty check is a floor, not the full contract, because the codec is RNG-free and stateless by design. Added a regression test that drives the production randomness funnel and asserts the on-wire Authenticator nonce is distinct across 100 consecutive requests, mirroring the existing Unique Identifier test. No behaviour change. (NTS-41) -
Added a short-lived in-memory replay guard over accepted-response Unique Identifiers as a defense-in-depth layer above the AEAD. The post-AEAD replay protection previously rested entirely on two stateless echo checks — the response must echo the request's Unique Identifier (RFC 8915 §5.3) and its
origin_timestampmust echo the request'stransmit_timestamp(RFC 5905 §8) — whose replay resistance assumes a unique UID per request without enforcing it. EachNtsClient's session table now remembers the UIDs of responses it has accepted for a bounded window (5 minutes, capped at 4096 entries with FIFO eviction) and rejects a response whose UID was already accepted withNtsError.ntpProtocol, before its now-stale cookies are deposited. The AEAD remains the primary guarantee; the cache only closes the residual UID-reuse gap (e.g. a CSPRNG failure or caller bug reusing a UID together with a transmit timestamp). Behaviour change on the replayed-UID path only — the happy path mints a fresh CSPRNG UID per request and never trips the guard. (NTS-40) -
Hardened the
verificationTimeMsclock-skew override with a defensive upper bound. Values above9999-12-31T23:59:59Z(253_402_300_799_000epoch ms) are now rejected withNtsError.invalidSpecrather than being fed into theDuration::from_millisconversion that pins the TLS certificate validity-window check. The override was already validated as non-negative; this closes the matching open-ended upper bound on a security-relevant time path. (NTS-39)
5.2.1 #
Fixed #
- Completed the API-summary table in
README.md, adding the missingNtsClientrow,TrustMode/TrustBackendenum variants, and the four missingdefaultBackend*Counttelemetry counters. - Corrected the
ntsTrustStatus()dartdoc observable count (six -> seven) and added the missing description fordefaultBackendCustomCount. - Fixed stale references to "three" atomic loads and counters in the
ntsTrustStatus()documentation to match the current implementation.
5.2.0 #
Added #
- Added
verificationTimeMstontsQuery,ntsWarmCookies, and the correspondingNtsClientmethods. This optional clock-skew override substitutes a caller-supplied timestamp for the TLS verifier's "current time" when checking certificate validity windows, which can rescue cold-start scenarios where a badly-skewed device clock would otherwise deadlock on the initial handshake.
Changed #
- Upgraded
hooksfrom^1.0.3to^2.0.2(no API changes tohook/build.dart; the 2.0.0 breaking change affects packages that implementProtocolExtension, which this hook does not). - Upgraded
build_runnerfrom^2.14.1to^2.15.0. - Dependency resolution updates:
native_toolchain_rust1.0.4+0 (direct dependency) plus transitivecode_assets1.2.1,build4.0.6,built_value8.12.6,json_annotation4.12.0,source_gen4.2.3,vm_service15.2.0.
5.1.0 #
Added #
TrustMode.bundledOnlyvalidates exclusively against the bundledwebpki-rootsset. No platform-store consultation, no silent fallback. Allows consumers to enforce strict validation against the library's static bundle, preventing platform-level CA compromises or middlebox/decryption proxies from intercepting the exchange.TrustMode.customalongsidecustomRootslist of bytes (PEM or DER format) to trust only caller-supplied root certificates. Allows consumers to authenticate TLS connections in private environments or using custom/enterprise CAs without relying on the global platform store or other clients.- Plumbed a fourth trust telemetry counter (
custom) to trace custom-roots handshakes. - Validates constructor parameters of
NtsClientsynchronously.
Fixed #
- Adapted the Android JNI bootstrap (
rust/src/android_init.rs) to thejni0.22Env/EnvUnownedsplit and thejboolean→boolchange.rustls-platform-verifier0.7'sinit_with_envrequires&mut Env, so the unowned native-method handle is upgraded to an ownedEnvviaEnvUnowned::with_envandinit_with_envis called inside the closure returningbool. Init failure maps toOk(false)inside the closure so a failed bootstrap stays non-fatal (no Java exception) and downgrades to thewebpki-rootsfallback, preserving the prior contract. The shim is#[cfg(target_os = "android")]and host CI runners never compiled it, so this break shipped in v5.0.0 undetected; a newaarch64-linux-androidcargo checkstep in the rust CI job now guards it. (#145, closes #143, NTS-30) - Removed misleading
(PlatformOnly mode)prefix from theKeError::TrustBackendUnavailableDisplayimplementation. The variant is shared between platform-verifier failures and custom-roots failures, so the prefix was inaccurate for the latter.PlatformOnly-specific context is now embedded inside the message string at the two call sites that produce it (nts-o88).
Security #
- Gated verbose snippet-body output in the doc-snippet validator
(
tool/check_doc_snippets.dart) behind--print-snippets/SNIPPET_VALIDATOR_VERBOSE=1. On analysis failure the tool no longer echoes the verbatim wrapped snippet bodies into the retained CI log by default — only the doc file, snippet index, and analyzer diagnostics are printed. The opt-in path additionally runs a best-effort redaction pass over obvious secret-shaped tokens (key/value assignments,Bearertokens, AWS access-key IDs, PEM private-key blocks). (nts-mf7) - Hardened
TrustMode::Customroots handling: caller-supplied root certificate bytes are now stored asArc<Zeroizing<Vec<u8>>>. The bytes are wiped from RAM when the finalArcclone is dropped (the clone chain is internal to the KE / query pipeline; see theCustomRootsBytesrustdoc). Thezeroize≥ 1.8Vecimpl wipes both the initialised length and the spare capacity at drop, so the wrapper is capacity-leak free without a manualshrink_to_fit. SeeAGENTS.md→ "Security: Zeroization" for the project-wide convention. - Removed unmaintained
rustls-pemfilecrate (RustSec RUSTSEC-2025-0134). PEM certificate parsing inbuild_with_custom_rootsnow usesCertificateDer::pem_slice_iterfromrustls-pki-types(already a transitive dependency), which is the migration path recommended by the advisory. No new dependencies introduced;rustls-pemfileis no longer present inCargo.lock. - Documented and tightened the custom-roots parsing pipeline scope
(
build_with_custom_roots,rust/src/nts/ke.rs). TheCustomRootsByteswrapper guarantees the input buffer is wiped on final-clone drop; the rustdoc andAGENTS.md→ "Security: Zeroization" → "Custom roots parsing pipeline" now state the exact scope (input buffer wiped; DER path no longer allocates an intermediate copy becauseCertificateDer::from_sliceborrows out of theZeroizingbacking buffer; PEM path's upstream-owned per-certVec<u8>is dropped per loop iteration but not zeroised — full closure requires an upstream rustls/rustls-pki-types API change tracked asnts-xdo). The refactor also eliminates the previousbytes.to_vec()copy on the DER path and bounds the residual liveness window of PEM per-cert buffers to a single iteration. (nts-r3s) - Implemented manual
DebugforTrustModeand internalCustomRootsBytesto redact sensitive certificate bytes from logs, rendering as<REDACTED: N bytes>. (nts-8wp) - Escaped upstream RustSec advisory fields before interpolating them
into the
cargo auditsticky PR comment table. A stray|in an advisory title would have broken the table layout; in the worst case a crafted advisory record could inject formatting that confused reviewers. The jq script now escapes|to\|and collapses CR/LF/Tab to a single space for every field that originates fromcargo audit --json(package name, version, advisory id, URL, title). URL validation is out of scope; the RustSec database is treated as trusted upstream. (nts-mat)
Documentation #
- Expanded
TrustModeAPI documentation to detail the security trade-offs of each variant — in particular the exposure ofplatformWithFallbackto TLS-inspection appliances that inject a corporate CA into the platform store, which can undermine the AEAD-integrity guarantee NTS derives from TLS keying material. High-security callers are now guided towardNtsClient(trustMode: TrustMode.bundledOnly)in the API doc,README.mdSecurity Considerations section, and theARCHITECTURE.mdtrust-anchor reference.
Packaging #
.pubignorenow also excludessonar-project.properties, the maintainer-only SonarCloud/SonarQube project configuration. It joins the maintainer configs already excluded (analysis_options.yaml,dart_test.yaml,flutter_rust_bridge.yaml); package consumers never run SonarCloud against the published tarball, so the file is pure noise on the published surface. Sub-1 KB, so no archive-size impact.
Internal #
- Custom-roots bundle is now held behind
Arc<[u8]>inside the internalKeTrustModeand stored onNtsClientin that internal form, so the per-query/ per-warmCookiesand per-handshake.clone()calls that thread the trust-mode through the cookie-cache and KE-handshake layers are O(1) atomic refcount bumps rather than full-bundle copies. The publicTrustMode.custom+customRoots: List<int>?consumer API is unchanged; the internal FRB-generated Dart bindings were updated to decode theCustomvariant's payload (Uint8List field0) via the SSE codec. tool/check_bindings.dartnow post-processes the FRB-generatedrust/src/frb_generated.rsandlib/src/ffi/frb_generated.dartto replace the empty diagnostic arms FRB 2.12 emits as the defensive#[non_exhaustive]catch-all in its generated codec impls (unimplemented!("")in the Rust SSE codec,UnimplementedError('')in the Dart SSE codec,Exception("unreachable")in the Dart DCO codec) with diagnostic-bearing forms that include the unexpected wire-format tag value. Runtime semantics are unchanged (the arms remain unreachable for exhaustive enums in practice), but any unexpected panic in generated codec code is now greppable back to its FRB origin and identifies which tag triggered it.build_with_custom_rootsnow accepts PEM bundles whose first-----BEGIN CERTIFICATE-----marker is preceded by an attribute preamble (Bag Attributes/subject=/issuer=lines thatopenssl pkcs7 -print_certsand PKCS12 exports routinely emit) rather than misclassifying those buffers as DER. Detection now fires when the UTF-8 view of the input contains the BEGIN marker anywhere, not only at the first non-whitespace byte; raw DER input continues to take the DER branch since it is not valid UTF-8.build_tls_config_inner(Android and non-Android) nowmatchesKeTrustModeexhaustively in the fallback branch instead of anif trust_mode == KeTrustMode::PlatformOnly { … } else { … }shape. Adding a futureKeTrustModevariant will now force a compile-time decision at this site rather than silently inheriting thePlatformWithFallbackarm.- Added
example/**to thedartpath filter in.github/workflows/ci.ymlso example-only diffs run theAnalyze example appstep and a broken example turns theDart tests gatered. Closes the gating gap exposed by #142 / #145, where an example-only change could merge without the gate reflectingflutter analyzebreakage. (NTS-32, #147)
5.0.0 #
Breaking changes #
- The FRB bridge entrypoint class is renamed from
RustLibtoNtsRustLib, withRustLibApi/RustLibApiImpl/RustLibWirebecomingNtsRustLibApi/NtsRustLibApiImpl/NtsRustLibWire. Replaceawait RustLib.init()withawait NtsRustLib.init()(and the same forRustLib.initMock). The rename lets consumers depend on multipleflutter_rust_bridge-backed packages withoutimport ... as prefixaliasing.
Packaging #
.pubignorenow excludes the test-only Rust modules (rust/src/**/tests.rsandrust/src/**/test_helpers.rs) that surfaced in the 4.0.0 published archive after PRs #61, #63, and #64 extracted them from inline#[cfg(test)] mod tests { … }blocks into sibling files. The sibling files are referenced via#[cfg(test)] mod tests;/#[cfg(test)] pub(crate) mod test_helpers;in their parent modules, so the#[cfg(test)]attribute removes the module reference before file lookup and consumer-sidecargo build --releasedriven by Native Assets never compiles or even parses them. Inline#[cfg(test)]blocks inside files likerust/src/nts/cookies.rs/dns.rs/aead.rsstay in place because those parent files are required by release builds; only the innertestsmod is cfg-gated. These optimizations shave ~243 KB uncompressed (~60 KB compressed) from the Rust tree, partially offsetting the addition of high-quality screenshots for pub.dev; the final published tarball is approximately 783 KB. No consumer-visible behaviour change; surfaces a post-4.0.0 archive-sanity-check observation.
Security #
- Added GitHub CodeQL advanced workflow for static security
analysis of the Rust core. The workflow is synchronized with the
pinned toolchain in
rust/rust-toolchain.tomland includes mirrored exclusions for fuzzing targets in both the workflow filters and the CodeQL configuration. Findings are surfaced to the Security tab. (PR #87, beadnts-wat)
Maintenance #
- Added GitHub Dependabot configuration to track updates for Dart
(
pub), Rust (cargo), and GitHub Actions. Excludedflutter_rust_bridgefrom automated updates to maintain coordinated pinning across the Dart/Rust boundary. (beadnts-tqp)
4.0.0 #
This major release consolidates the post-3.0 work that landed on
main between the 3.0 cut and this tag. It is a major version
bump because several of the items below break the public Dart or
Rust API surface, and one (the strict per-chain PlatformOnly
semantics on Android) is a deliberate behaviour change for a
caller-opted-in mode.
The headline shape changes:
-
NtsErrorsurface uniformity — the three remaining single-payloadNtsErrorvariants (invalidSpec,trustBackendUnavailable,internal) move from positional to named-parameter constructors so everyString-payloaded variant binds to the same name (message) and every variant with a non-trustBackendpayload is constructed with named arguments. The fivenetwork/keProtocol/ntpProtocol/authentication/timeoutvariants already moved in 3.0.0; this completes the sweep. -
Wrapper-side integer-range validation — the four async wrapper entry points and
NtsClient.invalidatenow reject out-of-rangeport/timeoutMs/dnsConcurrencyCaparguments asNtsError.invalidSpecbefore any FFI dispatch, closing the gap where aRangeErrorthrown by the FRB encoder used to escape the wrapper's "single error surface" contract.kDefaultDnsConcurrencyCapis bumped from the0sentinel to the actual numeric default (4) so consumers reading the constant see what the package actually applies. -
Strict per-chain
TrustMode::PlatformOnlyon Android — the Android-sideHybridVerifierno longer silently retries against thewebpki-rootsstatic bundle for the two curated fallback-eligible failure shapes (Revokedfrom missing-OCSP-AIA chains;General("failed to call native verifier: …")from R8-stripped JNI glue) when the caller is running underTrustMode::PlatformOnly. The platform verifier's error propagates verbatim.PlatformWithFallback(the historic default) is unchanged. -
NTS-KE streaming-read budget hardened to 16 KiB — the streaming layer in
rust/src/nts/ke.rs::read_to_end_cappednow refuses to accumulate more than 16 KiB per handshake (down from the 64 KiB codec ceiling), closing a memory-pressure vector where a malicious or buggy server could force ~64 KiB of heap allocation per failed handshake. The codec-layer ceiling at 64 KiB stays in place as the RFC 8915 §4.1.4 upper bound for valid messages. -
MSRV pinned at Rust 1.87 — the actual functional floor (transitive
security-framework 3.7.0requires edition2024 plususize::is_multiple_offrom 1.87) is now declared inrust/Cargo.tomland matched inrust/clippy.tomlso downstream consumers see an accuraterust-versionwithout over-constraining their toolchain pin.
The nts_rust crate is bumped from 0.4.0 to 0.5.0 to reflect
items 3, 4, and 5 (the on-the-wire NTS-KE / NTPv4 framing is
unchanged; the crate bump tracks the Rust-side API shape change
in KeError and the new streaming-read budget). The Dart-facing
FRB surface gains no new public types; the surface changes are
the constructor reshape in item 1 and the new rejected-input
paths in item 2.
Internal-only improvements that ride along: nts_warm_cookies
now collapses concurrent forced refreshes through the same
singleflight inflight registry that nts_query already used,
the example app is reorganised across two tabs ("Client" / "Log")
with a compacted ActionPanel and a new single-entry
LatestResultPanel summary card to eliminate RenderFlex
overflows on landscape phones / tablets, the
formatTrustBackend helper renames the
platformWithHybridFallback rendering to webpki-fallback to
match the authentication mechanism, and the Trust-status panel
drops the singleton-snapshot row that was structurally destined
to remain at sentinel values during every demo run.
Seven hygiene fixes from two rounds of external code review of
the release branch land on top — six code-level fixes documented
in the ### Security subsection below, and one docs-level fix
(README "Security considerations") in the ### Documentation
subsection. The six code-level fixes:
- cookie bytes zeroize on every
CookieJarin-jar eviction path — capacity-overflow eviction input, authentication- failure clears inclear_host, and a newimpl Drop for CookieJar(matching the discipline already applied to AEAD key material). Together with item 6 below this closes both in-jar and post-take residual surfaces; CookieJar'sDebugimpl renders per-host counts only (matching the redactedDebugonKeOutcome);perform_handshakeverifies that the post-handshake negotiated ALPN matchesntske/1(the valuebuild_tls_configalready advertised; RFC 8915 §4 requires it), via a newKeError::AlpnMismatchvariant;- every
.lock().expect(…)site inapi::ntsnow routes through a privatelock_recoverhelper that recovers from poisoning instead of panicking, so a single panic on any thread holding one of the module's mutexes cannot turn into a permanent crash-on-use mode for the client across the FRB boundary; KeOutcomePartial'sDebugimpl renders cookies as a count only, mirroring the discipline already applied toKeOutcome;- spent cookies zeroize end-to-end through the
CookieJar::take→QueryContext.cookie→ClientRequest.cookie→ outbound packet pipeline viaZeroizing<Vec<u8>>wrapping at every intermediate holder — the popped cookie is not wiped at jar-pop time (build_client_requesthas not yet serialised it onto the wire) but does wipe on drop of theZeroizingwrapper once the in-flight NTPv4 exchange completes.ClientRequestalso gains a manual redactedDebugthat prints the cookie field as<redacted; N bytes>.
Plus the docs-level fix (### Documentation subsection below):
README "Security considerations" calls out the SSRF / internal-
network-reachability surface inherent in a caller-supplied-host
network library.
All seven are internal-only — no public Dart-facing surface
change; see the ### Security subsection below for the full
per-finding writeup.
Changed — example app #
-
The home page is now split across two tabs ("Client" / "Log") driven by a
DefaultTabController. The Client tab carries the server list, action panel, trust-status row, and a new single-entry "Latest result" summary card; the Log tab gives the live-log card a full viewport height. The previous single-Column layout squeezed_LogHeaderpast its intrinsic minimum on landscape phones / tablets and triggeredRenderFlexoverflow warnings; the tabbed layout removes the squeeze without changing any underlying widget contracts. (nts-a3o) -
The action panel's
TrustModeselector is now a compactDropdownButton<TrustMode>inlined alongside the "NTS Query" and "Warm Cookies" buttons inside a singleWrap. On landscape viewports everything fits on one row (~64dp tall vs. the previous ~132dp two-row layout); on narrow phone widths theWraprolls the dropdown onto a second line. The set of selectable trust modes (platformWithFallback,platformOnly) and the controller-side cookie-pool-drop semantics on flip are unchanged. (nts-a3o) -
The "Favourites only" filter chip is now labelled "Favourites". Same behaviour, shorter text — widens the available space in the filter row's
Regiondropdown on narrow viewports. (nts-a3o) -
New
LatestResultPanelwidget on the Client tab surfaces the most recentNtsLogEntryin a single-entry summary card, rendered byte-for-byte identically to its sibling row on the Log tab via the hoistedbuildLogEntrySpanshelper. Bounded to four visible lines via themaxLinesparameter onSelectableText.rich. (nts-a3o) -
The
formatTrustBackendhelper now rendersTrustBackend.platformWithHybridFallbackaswebpki-fallback(wasplatform+hybrid-fallback). This is the variant where the platform verifier rejected the chain and thewebpki-rootsbundle overrode that verdict for one of the curated fallback-eligible shapes (missing-OCSP-AIA chains such as Let's Encrypt R12, R8-stripped AAR classes). The prior label read like "platform plus a possible hybrid fallback" without saying which actually authenticated. The new single-token form pairs naturally with the existingwebpki-rootslabel for the end-to-end-webpki variant (per-chain override vs. end-to-end use) and stays safe forawk/greppipelines against thebin/nts_cli.dartstdout, which threads the same helper. The underlyingTrustBackendenum values are unchanged; only the human-readable label insideexample/lib/src/state/nts_format.dartchanged. (nts-t3p) -
The "Trust status" panel now surfaces only the last-handshake row. The "Singleton snapshot" row that read the process-wide
ntsTrustStatus()and its threedefaultBackend*Countcumulative counters has been removed. Those counters are gated on theis_defaultflag of the underlyingNtsClient(only the top-levelntsQuery/ntsWarmCookiesroute through the default singleton); the example app always dispatches through a caller-minted client, so the row was structurally destined to remain at its sentinelnull/ 0 values during every demo run, which read as a bug to users investigating the panel. The package's publicntsTrustStatus()API is unchanged. (nts-otu) -
Removed (example app, internal):
NtsController.refreshTrustStatus,AppState.trustStatus,formatTrustStatus()inlib/src/state/nts_format.dart, and the coveringgroup('formatTrustStatus', …)block innts_format_test.dart. All were dead after the singleton-snapshot row was removed.
Changed — NtsError variant constructors #
-
BREAKING — the three previously single-positional
NtsErrorvariants now use named-parameter constructors:NtsError.invalidSpec(String x)→NtsError.invalidSpec(message: x)NtsError.trustBackendUnavailable(String x)→NtsError.trustBackendUnavailable(message: x)NtsError.internal(String x)→NtsError.internal(message: x)
Same shape change
3.0.0made for the other five variants; applied here for surface uniformity. The pre-4.0 single-positional shape survives as a@Deprecatedfield0getter on each variant subclass so 2.x and 3.0.x callers that read the payload (in pattern-match destructurings or direct field reads) keep compiling under a deprecation warning, but all construction sites must move to the named form.toString()output is unchanged:NtsError.invalidSpec(message)/NtsError.trustBackendUnavailable(message)/NtsError.internal(message)render exactly as in 3.0.x. -
The five 3.0.0 named-parameter variants (
network,keProtocol,ntpProtocol,authentication,timeout) are unchanged in 4.0.0; theirfield0getters retain their existing deprecation.
Changed — wrapper now validates integer ranges before FFI dispatch #
-
BREAKING (additive) — the four wrapper entry points (
ntsQuery,ntsWarmCookies,NtsClient.query,NtsClient.warmCookies) now validatespec.port,timeoutMs, anddnsConcurrencyCapagainst the FFI encoding range before dispatching into the FRB layer:port: rejected unless in1..65535. Mirrors the existing Rust-sideport must be non-zerospec validator with a wrapper-authored message produced before any FFI dispatch rather than a Rust-authored one returned after a futile FFI hop.timeoutMs: rejected unless in1..4294967295(i.e. theu32encoding range, with0no longer treated as a sentinel for "inherit the Rust-side default").dnsConcurrencyCap: rejected unless in1..4294967295on the same terms.
Out-of-range values cause the returned
Futureto complete withNtsError.invalidSpec(the four wrapper entry points areasync, so the error materialises onawaitrather than as a synchronous throw at the call site) instead of escaping asRangeErrorfrom the FRB encoder. This closes the contract gap where the wrapper'stry { … } on ffi.NtsError catch { … }previously could not catch encoder-side range errors, and is the change the wrapper's "throws anNtsErroron every failure path" dartdoc has always claimed.Strictly additive for callers who already passed in-range values: no behavioural change. Callers who passed literal
0fortimeoutMsordnsConcurrencyCapto ride the pre-4.0 sentinel now seeNtsError.invalidSpeconawaitand must switch to the named constants — see the migration section below. -
BREAKING (additive) —
NtsClient.invalidatenow applies the sameport ∈ 1..65535validation as the four async wrappers above. The pre-4.0 sync sister bypassed_validateRangesand forwardedspec.portdirectly into the FRBu16encoder, so out-of-range ports (negative, or>65535) escaped the documentedNtsError-only contract asRangeErrorfrom the FFI bridge. Out-of-range ports now throwNtsError.invalidSpecsynchronously (the call returnsbool, so the throw site is the call expression itself, not anawait).clear()and thetrustModegetter take no spec and are unchanged. Callers who passed literalport: 0toinvalidateto "trivially return false" now seeNtsError.invalidSpecsynchronously and should pass a real port instead — the previous behaviour was a quirk of the unvalidated path, not a documented contract.
Changed — kDefaultDnsConcurrencyCap exposes the actual numeric default #
- BREAKING (constant-value change) —
kDefaultDnsConcurrencyCapchanges from0(the pre-4.0 sentinel that delegated to the Rust-sideDEFAULT_MAX_INFLIGHT_DNS_LOOKUPS) to4(the actual numeric value the Rust side substituted). Callers who omit the parameter or who reference the constant by name see no behavioural change — they get the same4they got in 3.0.x. Callers who embedded the literal0in their code (typically because they followed older docs that described0as the package default) now trip the new range validator above.
Changed — TrustMode::PlatformOnly is now strict at the per-chain level on Android #
-
BREAKING (Android-only) —
TrustMode::PlatformOnly/TrustMode.platformOnlynow refuses every silent fallback to thewebpki-rootsstatic bundle, including the per-chain hybrid fallback that the AndroidHybridVerifierperformed in 3.0.x for two curated failure shapes:CertificateError::Revoked(typical when a chain like Let's Encrypt R12 omits the OCSP responder URL in the AIA extension — the platformPKIXRevocationCheckerhard-fails such chains asRevoked).Error::General("failed to call native verifier: …")(typical when R8 / ProGuard dead-code-eliminates the AAR'sorg.rustls.platformverifier.*glue in a release build that forgot the keep rules).
In 3.0.x both arms silently retried against
webpki-rootsregardless ofTrustMode, and the only signal aPlatformOnlycaller had that the static bundle had been consulted was a post-hocKeOutcome::trust_backend == PlatformWithHybridFallbackon the resulting sample. As of 4.0.0 theHybridVerifieris constructed with theKeTrustModeand gates both arms onPlatformWithFallback; inPlatformOnlymode the platform verifier's error propagates verbatim andwebpki-rootsis never consulted.- Migration: callers who want the safety net should switch
to (or stay on)
TrustMode::PlatformWithFallback(the historic default for bothNtsClient::new()and the top-level convenience functions), where both arms continue to fire as in 3.0.x. - Migration: callers who already used
PlatformOnlyto enforce a corporate-CA / MDM-pin posture see their stated intent honoured in full and can drop any post-hoctrust_backend != PlatformWithHybridFallbackdefensive checks they had layered on top of the per-sample outcome. - Default
NtsClientis unaffected.NtsClient::new()isPlatformWithFallback, so the default behaviour matches 3.0.x and there is no opt-out behaviour change for callers who never constructed aPlatformOnlyclient.
The pre-4.0 dartdoc on
TrustMode::PlatformOnlyframed the per-chain limitation as inherent ("PlatformOnlytherefore means 'no silent build-time downgrade', not 'the public-CA bundle is unreachable'"). The strict semantics this release ships replace that disclaimer with the contract Android callers actually want.Resolves the bd-tracked finding
nts-2lh.
Changed — NTS-KE streaming read budget capped at 16 KiB #
-
BREAKING (Rust-side error variant) —
KeError::MessageTooLargeis replaced byKeError::ResponseTooLarge { received, cap }. The new variant surfaces the would-be post-append accumulator length so an operator inspecting a handshake failure can see how far over the streaming budget the offending read pushed the accumulator. The variant is internal toKeError; theFrom<KeError> for NtsErrormapping already routes unmatched variants throughNtsError::KeProtocol { message, .. }, so the new shape surfaces to Dart callers with the diagnostic preserved verbatim and no change to the public Dart-facing surface. -
Behaviour change — the streaming layer in
rust/src/nts/ke.rs::read_to_end_cappednow caps the read accumulator at the newNTS_KE_READ_BUDGET = 16_384(16 KiB) rather than at the 64 KiB codec ceiling. A malicious or buggy NTS-KE server can no longer force ~64 KiB of heap allocation per failed handshake; 64 KiB × N concurrent handshakes was a memory-pressure vector on memory-constrained mobile processes. Comparable Rust NTS implementations cap at 4 KiB (ntpd-rs::ntp-proto::nts::messages::MAX_MESSAGE_SIZE); the 16 KiB pick leaves ample slack for an NTS-KE server that ships an unusually large but otherwise valid response (multiple cookies, server-name overrides) without re-exposing the original 64 KiB vector. -
The cap decision is factored out of the streaming read loop into a pure helper
next_chunk_within_budget(buf_len, n, cap)so the streaming-budget guard can be exercised by unit tests without standing up a TLS stream. Three regression tests pin the change: the strict inequality between streaming budget and codec ceiling, the exact-fit / overshoot boundary, and a chunk-stride simulation that drives a 100 KB body through the same 4 KiB chunks the live read loop uses. -
The 64 KiB codec ceiling (
MAX_MESSAGE_BYTESinrust/src/nts/records.rs) is unchanged — it stays in place as the RFC 8915 §4.1.4 upper bound for valid messages, reachable from non-streaming entry points like tests and file-based inputs.Resolves the bd-tracked finding
nts-dsi.
Changed — MSRV pinned at Rust 1.87 #
- BREAKING (toolchain) —
rust/Cargo.tomlnow declaresrust-version = "1.87". The actual functional floor is set by the transitivesecurity-framework 3.7.0(pulled in byrustls-platform-verifier, which requires edition2024) plususize::is_multiple_of(stable in 1.87, used innts::ntpandnts::recordsfor the extension-field length validators). The active toolchain pin inrust-toolchain.tomlis higher (currently 1.92.0); the matchingmsrventry inrust/clippy.tomlkeeps clippy's msrv-aware suggestions accurate. - Consumers building the crate as a Rust dependency need at minimum a 1.87 toolchain. Flutter consumers using the package via the standard build flow are unaffected because the bundled toolchain pin already exceeds 1.87.
Changed — nts_warm_cookies collapses concurrent forced refreshes via singleflight #
-
No behaviour change for the dartdoc'd contract —
nts_warm_cookies(Dart:ntsWarmCookies) andNtsClient::warm_cookies(Dart:NtsClient.warmCookies) still "force a fresh handshake," still returnNtsWarmCookiesOutcome { freshCookies, phaseTimings, trustBackend }, and still install the freshly-handshaken session under the spec'shost:portkey. The public Rust and Dart signatures are unchanged. -
Internal behaviour change — the implementation now routes through
SessionTable::warm_cookies, which shares the singleflightinflightregistry with the cache-awareSessionTable::checkoutmachinery used bynts_query. Pre-4.0nts_warm_cookiescalledestablish_sessiondirectly, so N concurrentnts_warm_cookiescalls against the samehost:portproduced N parallel KE handshakes. As of 4.0.0:- N concurrent
nts_warm_cookiesagainst the samehost:portcollapse onto exactly one KE handshake. The first arrival becomes the singleflight leader, runs the handshake without holding any lock, installs its session, and publishes its harvested cookie count + resolvedtrustBackendon the singleflight slot; concurrent callers park on the same slot bounded by their own per-calltimeout_msbudget and, on success, return those values verbatim from the slot payload (no cache re-read). - Waiters report
phaseTimingswith every field at0(same conventionnts_queryalready uses for cache-hit and waiter-wake paths) because they did not perform KE work themselves. Only the leader observes its own handshake's phase timings. nts_warm_cookiesandnts_queryshare the singleflight key space, so a concurrent warm + query against the samehost:portalso collapses onto one handshake; whichever caller arrives first becomes the leader and the other observes its result.freshCookiescontract pinned: the singleflight slot now publishes the leader's harvested cookie count alongside theOksignal, so ants_warm_cookieswaiter surfaces the value the server delivered with the KE response even when the leader happens to be ants_querycaller that pops one cookie out of the freshly installed jar before the warm waiter wakes. Previously the waiter snapshot-readcookies_remaining()from the cache and could reportdelivered - 1, contradicting the documentedNtsWarmCookiesOutcome.fresh_cookies/NtsTimeSample.freshCookiesdartdoc ("Number of fresh cookies the server delivered with the KE response").- Operationally relevant for UI bindings that hook
ntsWarmCookiesto a button: rapid taps no longer fan out to parallel KE handshakes, which avoids both wasted bandwidth and server-side per-IP rate-limit triggers (e.g. NTSN-style KoD on the NTPv4 leg, or per-IP throttling on the KE port). - Failure-fan-out semantic preserved: when the leader's handshake
fails, every waiter receives a cloned
NtsErrorwith the same variant and payload, so waiters do not silently retry against a server that just rejected the leader.
- N concurrent
Security #
Six code-level hygiene fixes raised by two rounds of external
code review of the release branch land here; the seventh review
finding (README "Security considerations" / SSRF surface
call-out) is docs-only and lives in the ### Documentation
subsection below. None changes the public Dart-facing surface
(no NtsError variant added at the Dart layer; the new internal
KeError::AlpnMismatch flows through the existing catch-all
mapping to NtsError.keProtocol). All six are belt-and-braces
in the same direction the package already takes — AEAD keys
already zeroize on drop and KeOutcome already has a redacted
Debug impl; these extend the same discipline end-to-end
across cookies, add a spec-correctness guard on the TLS
handshake, and turn the Rust API layer's .lock().expect(…)
sites into recoverable operations so a single panic can no
longer permanently crash an NtsClient across the FRB boundary.
-
Cookie bytes are now zeroized on every in-jar eviction path. The per-host FIFO store in
rust/src/nts/cookies.rspreviously held cookies as plainVec<u8>and dropped them withpop_front/VecDeque::clearon overflow eviction,clear_host, andDrop. None of those paths wiped the backing allocation, so a process-memory scrape after eviction could in principle recover the cookie bytes. Cookies are NTS authentication material (RFC 8915 §6: "use at most once" / "keep at most 8 unused per server"), so the discipline already applied to AEAD key material inrust/src/nts/aead.rs(viaZeroizeOnDrop) now extends to the cookie store: capacity-overflow eviction inCookieJar::put, authentication-failure clears inCookieJar::clear_host, and a newimpl Drop for CookieJarall callVec::zeroizebefore the backing allocation is released. Thetakepath is not wiped at jar-pop time — that path hands the cookie to the in-flight NTPv4 exchange that has yet to spend it, so wiping at the pop site would defeat the consumer. The complementary fix below in the end-to-end-cookie-zeroize entry extends the discipline across the take path itself: the popped cookie now rides inside aZeroizing<Vec<u8>>wrapper from the jar boundary to the wire and wipes on drop oncebuild_client_requesthas serialised the bytes into the outbound packet, so both the in-jar and post-take paths are covered. -
CookieJar'sDebugimpl no longer prints cookie bytes. The struct's previous#[derive(Debug, Clone)]rendered the full per-hostVec<Vec<u8>>on any{:?}formatting site. Cookies are authentication material; an accidental panic backtrace, log macro, or diagnostic format could leak them.Debugis now hand-rolled to print per-host counts only, mirroring the redactedDebugalready applied toKeOutcome. Internal change; no public-API impact. -
NTS-KE now verifies the negotiated TLS ALPN matches
ntske/1.build_tls_configalready advertisedalpn_protocols = [b"ntske/1"]per RFC 8915 §4, butperform_handshakedid not callClientConnection::alpn_protocol()after the handshake completed. A TLS 1.3 server that completed the handshake without honouring our ALPN selection (either omitting the ALPN extension entirely or selecting a different protocol) would have its payload flow intoread_to_end_cappedand surface as a less-specific NTS-KE record-parse error. After this release, the post-handshake guard explicitly checksalpn_protocol() == Some(b"ntske/1")and returns a newKeError::AlpnMismatch { negotiated: Option<Vec<u8>> }otherwise (distinct fromrustls::Error::NoApplicationProtocol, which fires during the handshake when ALPN is mutually required by the server). The new variant surfaces to Dart via the catch-allFrom<KeError> for NtsErrormapping asNtsError.keProtocol; no Dart-side surface change. Three regression tests pin the helper at the variant level (acceptSome(b"ntske/1"), rejectNone, rejectSome(b"h2"), preserveSome(empty)as distinct fromNone). -
api::ntsmutex sites now recover from poisoning instead of panicking. EveryMutex::lockcall inrust/src/api/nts.rs(theSessionTable.mapandSessionTable.inflightcaches, and the per-keyHandshakeSlot.resultsingleflight slot) used to call.expect("…")on the returnedLockResult. If any thread panicked while holding one of those locks the mutex became poisoned and every subsequent FRB-boundary call from any thread would deterministically panic too — turning one recoverable failure into a permanent "thisNtsClientis dead forever" mode across the Dart bridge. A new privatelock_recover(&mutex)helper returns the inner guard viaPoisonError::into_innerregardless of the poison flag, and every.lock().expect(…)site has been swept to use it. The caches and singleflight registry are tolerant of mid-update panics by construction (caches: at worst a stale entry that the next eviction reaps; singleflight:LeaderGuard::dropalready publishes anInternalerror to waiters on the leader-aborted path), so unpoisoned access is safe. Two regression tests pin the recovery semantics: one asserts a poisoned-then-recovered mutex returns the inner value, and one asserts mutations throughlock_recoversurvive across recovery while plainMutex::lockstill reports the poison flag (recovery is opt-in per call site, not a global unpoison). -
KeOutcomePartial'sDebugimpl no longer prints cookie bytes. The internal partial-outcome struct returned byvalidate_responsepreviously had#[derive(Debug)]over acookies: Vec<Vec<u8>>field. Althoughpub(crate)so the type does not surface beyond this crate, any{:?}site reached during a refactor (panic backtrace,dbg!, internal error-formatting chain that ever touches the partial outcome) would leak the cookies the post-handshakeKeOutcomealready redacts.Debugis now hand-rolled to rendercookiesas<redacted; N cookies>— same shape as theKeOutcomemanual impl. A regression test mirrors the existingke_outcome_debug_redacts_exporter_keys_and_cookiesshape, pinning the marker count and the absence of cookie byte tokens in the rendered output. -
Spent cookies are now zeroized end-to-end through the
CookieJar→ outbound packet pipeline. The 4.0.0 first security pass added zeroization to theCookieJareviction paths (putoverflow,clear_host,Drop), but the "happy path"takereturned a plainVec<u8>that then moved throughQueryContext.cookie: Vec<u8>→ClientRequest.cookie: Vec<u8>→build_client_request→ outbound packet, with no intermediate allocation wiped after the packet was built and sent.CookieJar::takenow returnsOption<Zeroizing<Vec<u8>>>so the spent bytes ride inside the sameZeroizingwrapper from the jar boundary all the way to the wire;QueryContext.cookieandClientRequest.cookiewere both retyped toZeroizing<Vec<u8>>(same shape asKeOutcome.c2s_key/s2c_keyalready use), so each intermediate holder wipes the cookie bytes onDrop.ClientRequestadditionally drops its#[derive(Debug, Clone)]for a manualDebugimpl that redacts the cookie field as<redacted; N bytes>— closing the cookie-Debug-leak path one step further along the pipeline. Two regression tests pin the change: a compile-timeassert_zeroizing_vechelper accepts only&Zeroizing<Vec<u8>>onQueryContext.cookieandClientRequest.cookie, and a runtime test assertsformat!("{req:?}")does not surface cookie byte tokens for a sentinel-payloadedClientRequest.
Documentation #
- README's "API summary" table now includes:
- The
trustBackendfield onNtsTimeSampleandNtsWarmCookiesOutcome(added in 3.0.0 but missing from the table). - The
trustBackendUnavailablevariant onNtsError(likewise). - A row for
ntsTrustStatus()and a row for theNtsTrustStatusDTO it returns (the entire trust-diagnostic surface was absent from the table).
- The
- The dartdoc on
kDefaultTimeoutMsandkDefaultDnsConcurrencyCapno longer points at0as a way to inherit the Rust-side default. The two constants now state their actual numeric values (5000 and- and the operational rationale for each.
- The dartdoc on the synchronous diagnostics
ntsDnsPoolStats()andntsTrustStatus()now states theRustLib.init()precondition explicitly. Both calls dispatch through the FRB v2 dispatch table even though they return synchronously, so a missed initialization fails with a low-level FRB error rather than a structuredNtsError. The note is crosslinked to README's "Initialization has two layers" section so the Android JNI bootstrap context is one click away. - The same
RustLib.init()precondition note now also lives on the threeNtsClientsynchronous methods that share the same FRB dispatch path (NtsClient.invalidate,NtsClient.clear, and theNtsClient.trustModegetter). Closes the residual scope of the earlier sweep, which had only touched the two top-level diagnostics functions. - README's "API summary" table gains rows for the two trust-related
enums (
TrustModeandTrustBackend) that the prior table sweep scoped out. Consumers reading the table can now resolve thetrustBackendfield onNtsTimeSample/NtsWarmCookiesOutcomeand thedefaultClientBackendfield onNtsTrustStatusto a concrete enum without leaving the README. - New
## Security considerationssection inREADME.mdbetweenProduction Considerationsand theAPI summary. Documents the inherent SSRF surface a "take a caller-supplied hostname, do DNS / TCP / UDP against it" library carries — the package cannot constrain which hosts a caller is allowed to reach, so call sites that accept hostnames from untrusted input must apply allowlists / private-range rejection / port gating themselves. Cross-links the bounded DNS pool to make the "amplification is bounded, destination is not" distinction explicit. Surfaces a recommendation raised by an external code review of the release branch. - Android
PlatformInit.ktlog messages and KDoc no longer claim unconditional fallback towebpki-rootswhenSystem.loadLibraryornativeInitfails. With the 4.0.0 strict per-chainTrustMode.platformOnlysemantics in place, that fallback only applies toTrustMode.platformWithFallbackcallers;platformOnlycallers see the same failure surface asNtsError.trustBackendUnavailableat handshake time. TheUnsatisfiedLinkErrorlog, thenativeInit-returned-false log, and theinitKDoc all now name both branches. Surfaces a platform-glue review observation against the release branch. - iOS
os_logsubsystem renamed fromcom.nts.exampletocom.nllewellyn.nts. The previous string read as a placeholder that escaped from an early draft and its docstring falsely claimed it tracked the host application's reverse-DNS bundle convention. The new identifier is library-owned (a stable handle consumers can pin Console.app filters against acrossntsversions) and matches the Android plugin package (com.nllewellyn.nts.PlatformInit) so the same filter string works on both platforms. Updated sites:rust/src/ios_init.rs(SUBSYSTEMconstant + module-level docstring),rust/src/api/simple.rs(init_appdocstring),rust/Cargo.toml(Console.app filter comment),example/pubspec.yaml(verbose-logs guidance comment), andDEVELOPMENT.md(verbose-logs section). Hosts that had pinned a Console.app filter against the previous string need to update it tocom.nllewellyn.nts; this is the only externally visible consequence and is documented here so users investigating a silent filter break after the 4.0.0 upgrade find it. - README's
## Security considerationssection gains a### Non-Flutter Dart callers must pass externalLibrary explicitlysubsection. Documents the relative-ioDirectorylibrary-hijack surface inRustLib.kDefaultExternalLibraryLoaderConfig(ioDirectory: 'rust/target/release/'): inside a Flutter host the Native Assets pipeline supplies a controlled absolute load path before that default ever runs, but a non-Flutter Dart caller (dart runCLI, Dart server runtime, integration-test harness) that callsRustLib.init()without anexternalLibraryargument while running from an attacker-influenced working directory will load whateverrust/target/release/libnts_rust.*has been planted there. The bundledexample/bin/nts_cli.dartalready follows the recommended pattern (auto-locate to an absolute path, thenExternalLibrary.open(resolved)) and the new subsection cross-references it. The hijack is independent of NTS itself —RustLib.init()resolves before any TLS / NTS code runs — but the package is the vehicle, so the documentation surface is the appropriate mitigation layer. Surfaces a platform-glue review observation against the release branch.
Migration from 3.0.x #
Move positional construction calls to the named form
Three constructors changed shape; the migration is one named parameter per call site:
// 3.0.x
const NtsError.invalidSpec('host is empty')
const NtsError.trustBackendUnavailable('platform CA bundle missing')
const NtsError.internal('unreachable')
// 4.0.0
const NtsError.invalidSpec(message: 'host is empty')
const NtsError.trustBackendUnavailable(message: 'platform CA bundle missing')
const NtsError.internal(message: 'unreachable')
The analyzer reports a "missing required argument" plus an "extra positional argument" diagnostic pair at every old-shape call site, so the diff is mechanical and each affected line is flagged exactly.
Rename payload binders in pattern destructurings
If your code pattern-matches with :final field0, switch to
:final message to follow the descriptive name. The old binder
keeps working because field0 survives as a @Deprecated getter
alias, so this is optional, not required:
// Both compile in 4.0.0; the new form drops the deprecation
// warning and matches the binder name used by every other
// `String`-payloaded variant in the same switch.
final detail = switch (err) {
// ... existing arms unchanged ...
NtsErrorInvalidSpec(:final message) => 'invalid spec: $message',
NtsErrorTrustBackendUnavailable(:final message) =>
'trust backend unavailable: $message',
NtsErrorInternal(:final message) => 'internal: $message',
};
Replace literal 0 for timeoutMs / dnsConcurrencyCap
The wrapper now rejects literal 0 for either u32 argument with
NtsError.invalidSpec. The migration is one of two equivalent
moves per call site, depending on whether you care about explicit
documentation of intent:
// 3.0.x
await ntsQuery(
spec: spec,
timeoutMs: 0, // deprecated sentinel: "use the package default"
dnsConcurrencyCap: 0, // same
);
// 4.0.0 — option A: omit, inherit the constant default
await ntsQuery(spec: spec);
// 4.0.0 — option B: name the constant explicitly
await ntsQuery(
spec: spec,
timeoutMs: kDefaultTimeoutMs,
dnsConcurrencyCap: kDefaultDnsConcurrencyCap,
);
The two new constants resolve to 5000 and 4 respectively; both
match the values the Rust side previously substituted when it saw
0, so neither option changes runtime behaviour — only the visible
failure mode for code that meant something else by 0.
Out of scope #
- The deprecated
NtsError_*underscore-prefixed typedefs (e.g.NtsError_InvalidSpec) and the@Deprecatedfield0getter aliases on every variant survive into 4.0.0. They remain the read-side back-compat for 2.x / 3.0.x callers and were originally slated for removal in this same 4.0.0 sweep, but the named-constructor migration (item 1 in the framing above), the strict-PlatformOnlybehaviour change (item 3), and the 16 KiB streaming budget (item 4) are already the load-bearing breaking changes for this release. Folding the typedef + getter removal in would not change the migration surface for any caller who hadn't already updated for those items, so the cleanup defers to a follow-up release. The existing deprecation warnings stay in place.
3.0.0 #
The first release after 2.0.0 consolidates four chunks of work
that landed on main between the 2.x line and the 3.0 cut:
- Trust-anchor backend diagnostics + strict
platformOnlymode — everyntsQuery/ntsWarmCookiesresult now reports which trust-anchor backend authenticated its TLS chain, and callers can opt into refusing the silent downgrade from the platform store to the staticwebpki-rootsbundle. - Per-host singleflight on the cache-layer checkout path —
concurrent cold queries against the same
host:portcollapse onto a single in-flight NTS-KE handshake instead of each running their own duplicate one. Internal toSessionTable; no API change. - Owned
NtsClientsession handle — an explicit, owned client whose per-host session table can be scoped to a caller, cleared on demand, and isolated from other callers. The top-levelntsQuery/ntsWarmCookiescontinue to delegate to a process-wide defaultNtsClient, so existing single-cache callers see no change. - Hand-written public DTOs and sealed
NtsError— the public surface is no longer a re-export of the FRB-generated bindings. A Rust-side struct rename or reorder is no longer a SemVer event for any of the public DTO types.
This is a major version bump because chunks 1 and 4 each
break the public Dart API: chunk 4 renames the NtsError_*
variant subclasses from the underscore-prefixed freezed convention
to idiomatic PascalCase (with deprecated typedef aliases for the
old names) and re-types the microsecond fields from PlatformInt64
to plain Dart int; chunk 1 adds an NtsErrorTrustBackendUnavailable
variant to the sealed NtsError class which breaks exhaustiveness
for Dart 3 switch consumers. Chunks 2 and 3 are purely additive
on their own.
The Rust crate (nts_rust) version is at 0.4.0, unchanged
across these chunks; the on-the-wire NTS-KE / NTPv4 framing was
not modified by any of them. The Dart-facing FRB surface did
grow new types and fields (TrustMode, TrustBackend,
NtsTrustStatus, ntsTrustStatus(), and a trustBackend field
on NtsTimeSample / NtsWarmCookiesOutcome) — those additions
are the source of the major bump, not a network-protocol change.
Migration from 2.0.0 #
Rename pre-3.0 freezed-style variant subclasses
Drop the underscore from NtsError_* variant subclasses in
switch arms and is checks: NtsError_InvalidSpec →
NtsErrorInvalidSpec, etc. The factory-constructor syntax
(const NtsError.invalidSpec('x'), const NtsError.timeout(TimeoutPhase.ntp),
…) is unchanged. Deprecated typedef aliases let the old names
keep compiling with a deprecation warning until the next major
bump removes them, so the migration can be done at the
consumer's pace anywhere across the 3.x line.
Drop .toInt() and PlatformInt64Util.from(...) in DTO sites
Microsecond fields on NtsTimeSample (utcUnixMicros,
roundTripMicros) and PhaseTimings (dnsMicros, …,
keRecordIoMicros) are now plain int rather than FRB's
PlatformInt64. Drop .toInt() calls on field reads and replace
PlatformInt64Util.from(N) with N in test fixtures and mocks
that build these types directly.
Add an arm for the new sealed-class variant
Any exhaustive switch (err) { … } over an NtsError value must
add an arm for the new NtsErrorTrustBackendUnavailable variant:
// As written for 3.0.x; the `field0` getters were removed in 6.0.0
// in favour of the named `message` / `phase` fields.
final detail = switch (err) {
// ... existing arms unchanged ...
NtsErrorNoCookies() => 'no cookies returned',
NtsErrorTrustBackendUnavailable(:final field0) =>
'trust backend unavailable: $field0',
NtsErrorInternal(:final field0) => 'internal: $field0',
};
Callers that only catch NtsError (or Exception) and do not
destructure variants need no changes. Default-singleton callers
of ntsQuery / ntsWarmCookies continue to get the pre-3.0
hybrid trust-anchor behaviour (platform verifier first,
webpki-roots fallback on construction failure) and will never
see the new variant; it is reachable only when a custom
NtsClient is constructed with trustMode: TrustMode.platformOnly.
Switch any on FrbException clauses to on NtsError
NtsError now implements Dart's marker Exception interface
instead of FRB's internal FrbException. Catching with
try { ... } on NtsError catch (err) is unchanged; catching with
try { ... } on FrbException catch (err) no longer binds an
NtsError and will need to switch to the NtsError clause.
Drop FFI re-exports from package:nts/nts.dart
The FFI DTOs, functions, and NtsError family are no longer
re-exported from package:nts/nts.dart. The bridge bootstrap
(RustLib) remains re-exported because callers still need it
to call await RustLib.init() (and RustLib.initMock in tests);
that one symbol is the intentional exception, scoped to the
bootstrap. Code that imported other FFI types or functions
through the public barrel must either move to the public surface
(package:nts/nts.dart) or, for internal-mock use cases that
build RustLibApi instances, import from package:nts/src/ffi/...
directly with the existing // ignore_for_file: implementation_imports
pattern. The example's MockNtsApi (example/lib/src/mock_api.dart)
shows the intended shape.
Added — public DTOs and sealed NtsError #
- All public DTOs (
NtsServerSpec,NtsTimeSample,NtsWarmCookiesOutcome,NtsDnsPoolStats,PhaseTimings) are now hand-written inlib/src/api/models.dart. Microsecond fields are typed as plainintrather thanPlatformInt64. NtsErroris a Dart 3sealed classhand-written inlib/src/api/errors.dartinstead of the FRB-generated freezed sealed class. Variant subclasses use idiomatic Dart PascalCase (NtsErrorInvalidSpecetc.). Pre-3.0NtsError_*names survive as@Deprecatedtypedef aliases and will be removed at the next major bump.lib/src/api/nts.dartwraps every FFI call in a try/catch that converts the FFINtsErrorto the public variant. Conversions are exhaustiveswitchexpressions; a future Rust-side variant addition surfaces as a compile error in the conversion layer rather than as a silently-dropped variant at the consumer.
Added — NtsClient handle #
NtsClientinlib/src/api/nts.dart. Construct withNtsClient()to mint a fresh client whose session table starts empty and never shares state with anotherNtsClientor with the process-wide default. The handle exposes:Future<NtsTimeSample> query({...})— per-client equivalent of the top-levelntsQuery.Future<NtsWarmCookiesOutcome> warmCookies({...})— per-client equivalent of the top-levelntsWarmCookies.bool invalidate(NtsServerSpec spec)— drops the cached session forspec'shost:port, returnstrueif an entry was removed. Synchronous; backed by one mutex acquisition +HashMap::removeon the Rust side.void clear()— drops every cached session in this client's table. Synchronous.
- Rust:
pub struct NtsClientinrust/src/api/nts.rswith the same five operations (new,query,warm_cookies,invalidate,clear). Rust callers can construct an explicitNtsClientfor the same reasons; the existing top-levelnts_queryandnts_warm_cookiesfree functions delegate to a process-wide defaultNtsClientviadefault_nts_client(). - The Rust per-host cache layer is now an instance of a private
SessionTablestruct (was a freesessions()accessor over aOnceLock<Mutex<HashMap<…>>>).nts_queryandnts_warm_cookiesshare their bodies withNtsClient::queryandNtsClient::warm_cookiesthrough internal*_innerhelpers parameterised on&SessionTable, so the per-instance and process-wide-default code paths are bit-identical except for which table the cookies and keys live in. - When to construct an explicit
NtsClient: test isolation (so one test's cached sessions cannot bleed into another's); diagnostics tools that want to force a fresh NTS-KE handshake on demand without restarting the process; apps that want a clear scope-bounded lifetime for cached sessions, e.g. discarding the cache between work batches. If your app already uses one steady set of NTS servers and you have no need for the lifecycle methods, keep calling the top-levelntsQuery/ntsWarmCookies— the singleton convenience is the recommended default.
Added — per-host singleflight #
- Per-key singleflight in
SessionTable::checkout(Rust internal):- The first concurrent checkout against a given
host:portbecomes the leader and runsestablish_sessionwithout holding any lock. - Concurrent checkouts against the same key become waiters: they
park on a per-key slot until the leader publishes a result,
bounded by their own per-call
timeoutMsbudget so a slow leader cannot stretch a follower's wall-clock past its caller's budget. - On leader success the waiters re-take the cookie jar of the
freshly installed session; if more waiters wake than the new
pool has cookies, the extras simply re-enter the role-election
loop and elect a new leader for the next handshake. Each
successful handshake delivers ~8 cookies (RFC 8915 default), so
the loop converges in
ceil(waiters / pool_size)handshake rounds in the worst case, never spinning indefinitely. - On leader failure each waiter receives a cloned
NtsErrormatching the leader's variant and payload — waiters do not silently retry (which would amplify load against a server that just rejected the leader's handshake) and do not seeNtsError::Internal(which would mask the real failure shape). - Leader-path RAII cleanup (
LeaderGuard) ensures the inflight slot is removed even when the leader panics or returns early without explicit completion; in that case waiters unpark on a sentinelNtsError::Internalrather than blocking against the stale slot until their per-call deadline elapses.
- The first concurrent checkout against a given
- The visible-from-Dart effect is faster cold-start and lower rate-limit pressure on the upstream server when a UI fires several queries against the same time source in parallel.
- Per-call timing semantics are unchanged: the leader reports its own KE phase timings; waiters report zero phase timings (same as cache hits — "no handshake ran in this thread"), matching the existing convention.
- The singleflight is keyed by
session_key(spec)(i.e.host:port), so concurrent queries against different hosts continue to run their handshakes fully in parallel. - The singleflight registry lives on
SessionTable, so twoNtsClientinstances never collide with each other's leader-election state, and the process-wide default client's singleflight is independent of any bespokeNtsClienta caller mints. nts_warm_cookiesdoes not participate in the singleflight. It always runs its ownestablish_session, matching its documented "force a fresh handshake" contract — a manual refresh gesture should not be silently coalesced with an unrelatedntsQuery's handshake.
Added — trust-anchor diagnostics + strict mode #
TrustModeenum on the public DTO surface (inlib/src/api/models.dart):TrustMode.platformWithFallback— the pre-3.0 default behaviour: platform verifier first,webpki-rootsstatic-bundle fallback ifbuild_with_native_verifierfails at TLS-config construction time.TrustMode.platformOnly— strict mode: refuse the fallback and surfaceNtsError.trustBackendUnavailable(diagnostic)if the platform verifier cannot be constructed. Use when a pinned corporate CA or MDM-installed root is the load-bearing trust anchor and a silent downgrade to the static bundle would defeat the deployment's TLS-inspection posture.
TrustBackendenum on the public DTO surface:TrustBackend.platform—rustls-platform-verifiervalidated the chain against the OS trust store (system + user/MDM roots).TrustBackend.platformWithHybridFallback— Android-only: the hybrid verifier overrode a platform-side failure with thewebpki-rootsbundle for one of the curated fallback-eligible failure shapes (e.g. missing-OCSP-AIA chains such as Let's Encrypt R12, R8-stripped AAR classes).TrustBackend.webpkiRoots—build_with_native_verifierfailed at TLS-config construction time and the staticwebpki-rootsbundle authenticated the chain end-to-end.
NtsTimeSample.trustBackendandNtsWarmCookiesOutcome.trustBackendfields. Per-handshake attribution carried on every successful result. On the steady-state cached-sessionntsQuerypath (no fresh KE handshake) the value reflects the original handshake's resolution, cached on the underlying session, so callers always see a concrete attribution rather than a placeholder for cached queries.NtsClientconstructor now accepts an optionaltrustMode: TrustModenamed parameter; defaults toTrustMode.platformWithFallbackso existing call sites are source-compatible. The choice is immutable for the life of the client. Read it back via the newNtsClient.trustModegetter (synchronous; backed by a one-byte read on the Rust side).- Top-level
ntsTrustStatus()function returning anNtsTrustStatussnapshot. Synchronous (no future / isolate hop): backed by three atomic-relaxed loads, cheap enough to call from a UI poll loop or a pre-flight "can I even validate against the platform store?" check. The snapshot exposes:defaultClientBackend: TrustBackend?— backend the default singletonNtsClient(used byntsQuery/ntsWarmCookies) most recently resolved to.nullwhen no handshake has yet run against the singleton in this process. Custom-client callers should readNtsTimeSample.trustBackend/NtsWarmCookiesOutcome.trustBackendfor accurate per-client attribution.androidPlatformInitSucceeded: bool—trueiff the Android JNI bootstrap (PlatformInit.nativeInit) reported success at least once.falseon every other platform (no JNI bootstrap step exists). Afalsevalue on Android implies subsequent handshakes will be running againstwebpki-rootsregardless of the caller'sTrustMode.androidHybridFallbackCount: BigInt— cumulative count of TLS chains the Android hybrid verifier has accepted via thewebpki-rootsfallback path since process start. Always zero on non-Android platforms.
NtsError.trustBackendUnavailable(String diagnostic)variant (sealed class member:NtsErrorTrustBackendUnavailable). Surfaces only on the strict-modeTrustMode.platformOnlypath; the payload carries the underlyingbuild_with_native_verifierconstruction-failure diagnostic.- Per-handshake
trustBackend: TrustBackend?attribution is now carried on every error variant whose precondition is "the TLS handshake reachedbuild_tls_configtime":NtsError.network,NtsError.keProtocol,NtsError.ntpProtocol,NtsError.authentication,NtsError.timeout, andNtsError.noCookies. Populated whenever the failure fired after the backend was resolved — which, given thatperform_handshakecallsbuild_tls_configbefore any DNS, connect, or TLS I/O begins, covers every current failure site: KE-legdnsSaturation/dnsTimeout/ pre-bindconnect/tls/keRecordIofailures (all attributed via the per-callattributeclosure inperform_handshake), every post-checkout UDP leg's bind / send / recv / recv-arm failure, the cache-hitNoCookiesshort-circuits, and Android's per-instanceHybridVerifierupgrade toTrustBackend.platformWithHybridFallbackwhen the fallback counter incremented during the TLS write/flush window. The field is typed as nullable because the RustKeFailurewrapper attachesNonefor failures that fire beforebuild_tls_configreturnsOk, but no currentperform_handshakepath produces such a failure on the variants listed above. Variants whose precondition rules out a backend (invalidSpec,trustBackendUnavailable,internal) do not carry the field at all. Closes the diagnostic gap where a server-side post-handshake failure (e.g. an NTS-KE record parse error against an Android hybrid-fallback chain) lost the fallback attribution and exported as[backend=null].
Changed — trust-anchor diagnostics + strict mode #
- The
webpki-rootsstatic-bundle fallback insidebuild_tls_configis now gated by the caller'sTrustMode. Pre-3.0 it always ran on platform-verifier construction failure; in 3.0+ it runs only when the client was constructed withTrustMode.platformWithFallback(the default), and is replaced by anNtsError.trustBackendUnavailablereturn when the client was constructed withTrustMode.platformOnly. - The Android
HybridVerifiernow reports back to the per-handshake trust-state tracker on everywebpki-rootsfallback decision so the per-querytrustBackendfield can distinguishTrustBackend.platformfromTrustBackend.platformWithHybridFallback. No behavioural change to the verification logic itself. - The Android JNI bootstrap (
Java_com_nllewellyn_nts_PlatformInit_nativeInit) now latches a process-global "platform init succeeded" flag on every successfulrustls_platform_verifier::android::init_with_envcall. Used byntsTrustStatus()to reportandroidPlatformInitSucceeded; idempotent (the flag only ever flips false → true). - BREAKING — sealed
NtsErrorvariants whose payload grew thetrustBackendfield (network,keProtocol,ntpProtocol,authentication,timeout,noCookies) now use named-parameter constructors (NtsError.network(message: ..., trustBackend: ...)rather thanNtsError.network(...)). The pre-3.0 single positional payload survives as a@Deprecatedfield0getter on each variant subclass so 2.x consumers keep compiling under a deprecation warning, but all construction sites must move to the named form.toString()preserves the pre-3.0 format (NtsError.network(message)) whentrustBackendisnulland appends, backend: <name>otherwise, so existing equality / string assertions for backend-less variants do not need to change.invalidSpec,trustBackendUnavailable, andinternalretain their pre-3.0 single-positional shape (no behavioural change there).
Added — wrapper observability instrumentation #
Three operator-facing log::info! emit sites at NTS protocol
milestones, wired through the existing log → tracing →
tracing-oslog (iOS) / android_logger (Android) pipeline so
they reach Console.app (iOS) and logcat (Android) without
further consumer wiring:
nts::ketarget — fires once per successful NTS-KE handshake withhost,aead_id,cookies,ntp_host,ntp_port, andtrust_backend.ntp_host/ntp_portare emitted as separatekey=valuepairs rather thanhost:portso an IPv6 literal in the NTPv4 server address does not mangle the address-vs-port boundary for log scrapers.nts::querytarget — fires once per successfulntsQuerycall withhost,stratum,aead_id,fresh_cookies,rtt_us, andtrust_backend.nts::warmtarget — fires once per successfulntsWarmCookiescall withhost,cookies_in_jar, andtrust_backend.
All three are stripped at compile time in release builds via the
default-on log-strip Cargo feature
(log/release_max_level_warn), so they cost zero string-table
bytes and zero runtime overhead in production. To enable them
during local on-device verification, flip
hooks.user_defines.nts.verbose_logs to true in
example/pubspec.yaml and rebuild after a flutter clean (see
the pubspec.yaml comment block for the exact procedure).
Changed — Authentication / KeProtocol routing documentation #
Documents the cross-variant routing that was previously only
captured on the example app's describeError helper:
AEAD-algorithm negotiation failures during NTS-KE — a server
picking an AEAD identifier this client does not implement —
surface as NtsError.keProtocol, not NtsError.authentication.
The Authentication variant is reserved for
cryptographic-verification failures of the AEAD primitive itself
on a fully negotiated algorithm (tag mismatch, malformed AEAD
input). A monitoring rule wired to "tag mismatch" alarms must
therefore key on Authentication only.
The routing note now lives on three sources of truth:
NtsError.authenticationfactory dartdoc inlib/src/api/errors.dart.NtsError::Authenticationrustdoc inrust/src/api/nts.rs(mirrors into the FFI bindinglib/src/ffi/api/nts.dartvia codegen).- The pre-existing
describeErrordartdoc inexample/lib/src/state/nts_format.dartis corrected to name the actual primary route (KeError::UnsupportedAead→From<KeError> for NtsErrorcatch-all) plus the defence-in-depth path (AeadError::UnsupportedAlgorithm→ explicit arm ofFrom<AeadError> for NtsError); the previous prose cited a non-existentFrom<AeadError> for KeErrorimpl.
No code-path or behaviour change; Authentication and
KeProtocol continue to route exactly as they did in 2.0.0. The
fix is purely documentary, scoped to the three doc surfaces
above.
Out of scope #
nts_warm_cookiesdoes not participate in the singleflight in this release. A concurrentnts_warm_cookies+ntsQueryagainst the same host therefore still races the install (same race as pre-3.0; the singleflight does not make it worse). If real call patterns surface a need to coalesce warm-cookies traffic, a follow-up can extend the singleflight to span both flows.- Cache-eviction policy (LRU / max-size / TTL) and per-host singleflight metrics remain follow-ups under their own tickets.
- The strict trust mode does not implement certificate or public-key
pinning; it only refuses the
webpki-rootsdowngrade. Callers who want to pin a specific root or leaf should layer that check on top of the platform-verifier path themselves (no public hook for it exists in 3.0). - The per-handshake
trustBackendfield is reported on the public DTOs but not yet on the JSON output of the example CLI's--jsonmode. A follow-up can add it once the JSON contract is reviewed. NtsError.trustBackendUnavailableis reachable only viaTrustMode.platformOnly; default-singleton callers continue to see the pre-3.0 fallback behaviour and will never observe this variant.
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.
Breaking changes #
NtsError::Timeoutnow carries aTimeoutPhasepayload identifying which phase of the call hit the budget. Existing pattern matches onNtsError::Timeout(Rust) orNtsError_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_cookiesnow returnsNtsWarmCookiesOutcome { fresh_cookies, phase_timings }instead of a bareu32(Rust) /int(Dart). The cookie count is still available viaoutcome.fresh_cookies; the newphase_timingsfield exposes the same per-phase wall-clock breakdown asNtsTimeSample.phase_timings.NtsTimeSamplegains a requiredphase_timings: PhaseTimingsfield. 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 anNtsTimeSampleby hand.
Phase attribution and timings #
- New
TimeoutPhaseenum tagsNtsError::Timeout. VariantsDnsSaturation(resolver pool full, raisedns_concurrency_cap),DnsTimeout(resolver slow, lengthentimeout_msor replace the recursive resolver),Connect,Tls,KeRecordIo, andNtpcover every blocking phase ofnts_query/nts_warm_cookies. - New
PhaseTimingsstruct exposes microsecond-resolution wall-clock costs for the four pre-NTP phases (dns_micros,connect_micros,tls_handshake_micros,ke_record_io_micros); the existingNtsTimeSample::round_trip_microsis the UDP-phase equivalent and is intentionally not duplicated.dns_microsis summed across the KE-host and NTPv4-host lookups; phases that did not run in this call are reported as0rather than absent. See the new "Phase attribution and timings" section inARCHITECTURE.mdfor the full diagnostic shape. nts_queryinstruments the KE pipeline (DNS, connect, TLS, KE record I/O) insideperform_handshakeand threads the timings out through a refactoredKeOutcome.phase_timings; the UDP-path DNS cost is captured inbind_connected_udp_usingand folded into the samedns_microsfield on the returned sample.nts_warm_cookiesexposes the same KE-phase breakdown viaNtsWarmCookiesOutcome.phase_timings. The UDP NTP exchange does not run on this path, so theNtpphase is implicitly zero.nts_querynow 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 ontimeout_ms; previously a cold query whose KE phases consumed most oftimeout_mswould re-anchor a freshtimeout_ms-long window for the UDP leg, letting the total wall-clock reach roughly 2x the caller's budget before surfacing asTimeout(Ntp). A budget that was already exhausted by the KE phases now short-circuits withTimeout(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.dartnow runs_checkForOrphanedApiModulesafter codegen + lint patches + format and before the trailinggit diffdrift check. The check walkslib/src/ffi/api/*.dart(skipping*.freezed.dartand*.g.dartcompanions, which are emitted frompartdirectives in the primary file rather than referenced from the dispatcher) and flags any primary module file the regeneratedlib/src/ffi/frb_generated.dartdoes not import. Closes the FRB stale-module footgun: when the lastpubitem is removed from arust/src/api/<module>.rs, FRB drops the wire impls fromfrb_generated.{rs,dart}but leaves the previously emittedlib/src/ffi/api/<module>.darton disk. The stale module then references symbols that no longer exist in the dispatcher and surfaces as an opaque "symbol not found inRustLibApi" build break underflutter analyze/flutter testrather than at codegen time. The dispatcher'simport 'api/<basename>.dart';line set is the authoritative "still contributing" stand-in: FRB writes one such import for every Rust source underrust/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.dartcompanions) explicitly. The orphan list is sorted before printing so the diagnostic renders deterministically across filesystems with differentDirectory.listSynciteration orders (APFS, ext4, etc. differ). Local invocation produceserror:prefixed lines; CI invocation underGITHUB_ACTIONS=trueemits the same body with::error::so therust-bridge-syncjob surfaces it as a workflow annotation. Exit code is1on the orphan path, failing the job explicitly on the orphan diagnostic rather than implicitly via trailing drift. Header comment intool/check_bindings.dartis rewritten to document the orphan check and its rationale.
Coverage artefact ignore at any depth #
.gitignoregains an unanchoredcoverage/entry.flutter test --coveragewritescoverage/lcov.infoat the package root, andcargo tarpaulin --output-dir coverage(configured inrust/tarpaulin.toml) writesrust/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 byexample/.gitignore:34, so no duplication.
