nts 5.2.0
nts: ^5.2.0 copied to clipboard
Authenticated network time for Flutter apps, secured by Network Time Security (NTS).
Changelog #
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:
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.
1.4.0 #
Converts nts from a pure Dart package using the Native Assets pipeline
into a full Flutter plugin so that downstream consumers can use the
package on Android without having to replicate the Rust ↔ Kotlin JNI
bootstrap, the rustls-platform-verifier-android Maven repository
discovery, or the R8 keep-rule contract by hand. No Dart API surface
change (public exports unchanged; only dartdoc was updated to document
the two-layer initialization model) and no FRB pin movement; the Rust
crate nts_rust is bumped to 0.3.0 to reflect a breaking JNI ABI
change. Dart package version bumped to 1.4.0 (minor).
Auto-initialised Android rustls-platform-verifier bootstrap #
-
New plugin module under the package root at
android/. It ships:com.nllewellyn.nts.NtsPlugin— aFlutterPluginthat callsPlatformInit.init(applicationContext)fromonAttachedToEngine.GeneratedPluginRegistrant.registerWithruns that hook before Dartmain()in any host usingFlutterActivity,FlutterFragmentActivity, or the Flutter add-to-appFlutterEnginelifecycle, so therustls-platform-verifierpanic (Expect rustls-platform-verifier to be initialized…) is no longer reachable through the standard integration path.com.nllewellyn.nts.PlatformInit— the matched JNI Kotlin counterpart for the Rust symbol exported fromrust/src/android_init.rs. Also exposes a publicstatic init(Context)for hosts that bypassGeneratedPluginRegistrant(rare; mainly bespoke add-to-app embeddings or tests that drive the dylib directly).consumer-rules.pro— ProGuard / R8 keep rules covering both therustls-platform-verifiercompanion AAR (org.rustls.platformverifier.**) and our own JNI shim (com.nllewellyn.nts.PlatformInit). Auto-merged into the host app's shrinker config; consumers do not have to copy keep rules.build.gradle.kts— discovers the on-disk Maven repository bundled inside therustls-platform-verifier-androidcargo crate viacargo metadata, so the AAR resolves regardless of whetherntsis installed from a path dependency, the pub cache, or a monorepo, on hosts that use the default Flutter/Gradle repository setup. Replaces the brittle../../rust/Cargo.tomltraversal that previously lived inexample/android/app/build.gradle.ktsand only worked from the example tree. Hosts that enabledependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOSinsettings.gradle.ktsare the documented exception: that mode rejects the project-level Maven injection the plugin performs throughrootProject.allprojects { ... }, so those hosts must declare the on-disk repository themselves underdependencyResolutionManagement.repositoriesinsettings.gradle.kts. The cargo-metadata path is stable and can be reused verbatim; the rationale comment inandroid/build.gradle.ktscarries the full constraint.
Native code (
libnts_rust.so) continues to be delivered by the Native Assets pipeline (hook/build.dart); the plugin module ships nojniLibs/and does no Cargo wiring of its own. Platforms other than Android are untouched: iOS / macOS / Linux / Windows remain pure Native-Assets packages with no accompanying plugin module.
Stable JNI symbol under reverse-DNS namespace (BREAKING ABI) #
- The JNI entry point exported from
rust/src/android_init.rsis renamed fromJava_com_nts_example_RustlsBootstrap_nativeInittoJava_com_nllewellyn_nts_PlatformInit_nativeInit. The previous symbol was mangled for the example app's package name, which is not a contract any downstream consumer can reasonably satisfy (renaming the symbol locally would diverge from upstream releases on every pull). The new FQDN is under the maintainer's reverse-DNS namespace and is documented as the stable public ABI.- Impact: any host application that previously hand-rolled a
matching
com.nts.example.RustlsBootstrapKotlin class plus keep rules (i.e. only the example app shipped in this repository, given the1.3.xcontract was effectively unconsumable) must drop that class. The plugin's auto-init replaces the manual wiring;flutter pub upgradeplusflutter cleanis sufficient.
- Impact: any host application that previously hand-rolled a
matching
Migrating from 1.3.x #
- Out-of-tree consumers that hand-rolled the
1.3.xAndroid contract must drop the manual scaffolding when bumping to1.4.0. The JNI symbol moved to the maintainer's reverse-DNS namespace (Java_com_nllewellyn_nts_PlatformInit_nativeInit), so the legacyexternal fun nativeInitdeclaration on a host-app shim no longer resolves against the dylib's exports.System.loadLibrary("nts_rust")still succeeds (the library itself loads), but the first invocation of the unbound declaration throwsUnsatisfiedLinkError: No implementation found for void com.<host>.RustlsBootstrap.nativeInit(android.content.Context). In the documented1.3.xintegration shape that fires fromMainActivity.onCreatebeforesuper.onCreate(...), so the host app crashes at process start, well before any TLS handshake is attempted. The plugin contributes equivalent functionality; one round offlutter pub upgrade+flutter cleanis sufficient once the items below are removed. - Host-app
RustlsBootstrap.kt(or any equivalent class whose FQDN was used to mangle theJava_*_nativeInitsymbol exported fromrust/src/android_init.rs). Delete the file. The plugin shipscom.nllewellyn.nts.PlatformInitas the new stable counterpart; nothing in the host app needs to know its name. MainActivity.onCreateshim that calledRustlsBootstrap.init(this)ahead ofsuper.onCreate(...). RevertMainActivityto a no-bodyFlutterActivity. The plugin'sonAttachedToEngineruns fromGeneratedPluginRegistrantbefore Dartmain()executes, so any code path that reached the bootstrap before reaches it now without the manual call.app/build.gradle.ktsentries that wired up therustls-platform-verifier-androidMaven repository: thefindRustlsPlatformVerifierMaven()helper (or whichever shape it took locally), therepositories { maven { url = uri(...) } metadataSources { artifact() } }block, and theimplementation("rustls:rustls-platform-verifier:0.1.1@aar")dependency. All three are contributed by the plugin's ownandroid/build.gradle.kts. Leaving them in place resolves the AAR twice; harmless at runtime but it wastes Gradle resolution time and pins the consumer to a version the plugin will move out from under them.proguard-rules.prokeep rules coveringorg.rustls.platformverifier.**and the host-app's ownRustlsBootstrapJNI class. Both are now in the plugin'sconsumer-rules.proand auto-merged into the host's R8 config. TheRustlsBootstrap-flavoured rule is doubly stale because the symbol name has changed; an unmodified rule keeps a class that no longer exists and produces no shrinker warning either way.settings.gradle.ktsdoes not change. Thedev.flutter.flutter-plugin-loaderblock is the standard Flutter wiring that picks up the new plugin module automatically; no manualinclude,pluginManagement, or Maven entry is required.- Custom embeddings. Hosts that legitimately bypass
GeneratedPluginRegistrant— bespoke add-to-app integrations, integration tests driving the dylib directly, isolates spawned ahead of plugin registration — should callcom.nllewellyn.nts.PlatformInit.init(context)from Kotlin in place of the deletedRustlsBootstrap.init(...). The signature and idempotency contract are identical; only the FQDN moves. - Hosts that did not hand-roll the
1.3.xAndroid contract have nothing to do beyondflutter pub upgrade+flutter clean. The previous JNI symbol was mangled for the example app's package name and could not be satisfied without forking the Rust crate, so the realistic pre-1.4.0integration path for any out-of-tree consumer was a vendored copy of this repository with the symbol renamed locally — that fork should be retired in favour of the published1.4.0plugin and the stablecom.nllewellyn.nts.PlatformInitsymbol.
Non-Android upgrade path #
iOS, macOS, Linux, and Windows consumers are unaffected by the
migration steps above. The android/ plugin module and the
flutter: plugin: platforms: android: key in pubspec.yaml are
scoped exclusively to Android — the Flutter tool generates no
plugin registration on other targets, no ios/ / macos/ /
linux/ / windows/ plugin module exists to compile or link,
and the Native Assets pipeline that delivers
libnts_rust.{so,dylib,dll} is unchanged. The public Dart API
exported from lib/nts.dart is unchanged, and
await RustLib.init() remains the only initialization step.
The upgrade is a single-line pubspec.yaml bump.
The on-platform TLS validators are also unchanged: every
non-Android target continues to use rustls-platform-verifier
0.5 directly, which talks to the Security framework on
iOS/macOS, the system trust store on Linux, and the Win32
Crypt* APIs on Windows without any host-side initialization
step. None of these paths require JVM-style bootstrap, which is
why the "Native platform bootstrap" layer documented in the
README is Android-only.
Example app simplified to a vanilla FlutterActivity #
example/android/app/src/main/kotlin/com/nts/example/RustlsBootstrap.ktremoved. Its responsibilities are now split between the plugin'sNtsPlugin(registration-time auto-init) andPlatformInit(manual fallback).example/android/app/src/main/kotlin/com/nts/example/MainActivity.ktreverted to a no-bodyFlutterActivity. The Android trust-store bootstrap happens beforesuper.onCreate()runs.example/android/app/build.gradle.ktsno longer carries thefindRustlsPlatformVerifierMaven()helper, therustls:rustls-platform-verifier:0.1.1@aarimplementationdep, or the local Maven repository declaration. All three now live in the plugin's ownandroid/build.gradle.kts.example/android/app/proguard-rules.proreduced to a stub comment. The keep rules previously declared here are merged in from the plugin'sconsumer-rules.pro.
Internal documentation refresh #
rust/src/lib.rs,rust/src/android_init.rs, andrust/src/nts/hybrid_verifier.rsupdated to reference the new Kotlin FQDN and the plugin'sconsumer-rules.prorather than the decommissionedRustlsBootstrap.ktin the example app. Thehybrid_verifierwarn-level fallback message that fires when R8 has strippedorg.rustls.platformverifier.*now points operators at the plugin's keep-rule file.
1.3.2 #
Repo-policy and CI-hygiene cleanup, plus a single Rust-side
runtime fix that closes a multi-hour recovery stall observed in
downstream consumers when an NTS server rotates its master key
out from under our cookie pool. No public Dart API change
(lib/nts.dart is byte-identical) and no FRB pin movement; the
Rust crate nts_rust is bumped to 0.2.3 to reflect the
behavioural change in nts_query. Dart package version bumped
to 1.3.2 (patch).
Fail-fast eviction of stale NTS sessions on rekey signals #
nts_query(rust/src/api/nts.rs) now evicts the cachedSessionfor the spec on either of the two on-wire signals that indicate the server has rotated keys out from under our cookie pool. The nextcheckoutfor that host finds no entry and performs a fresh NTS-KE handshake instead of draining the remaining cookies through identical failures plus the caller's per-source exponential backoff.- AEAD authentication failure — local C2S seal in
build_client_requestor remote S2C verify inparse_server_responsereturnsNtpError::Aead. The cached keys are out of step with the server's current master key. - RFC 8915 §5.7 NTSN Kiss-of-Death with matching UID — a
standards-compliant server that cannot validate the cookie
SHOULD respond with stratum 0 +
reference_id=NTSN, and that response MUST NOT carry an Authenticator (the server has no usable session keys to AEAD-sign with). The AEAD-only eviction path missed this shape entirely:parse_server_responserejected it asMissingAuthenticatorso the cached session survived and the same dead-pool draining symptom recurred. The newNtpError::StaleCookiearm classifies the matching-UID NTSN distinctly and routes it through the same generation-guarded eviction.
- AEAD authentication failure — local C2S seal in
- The eviction is gated on a generation snapshot captured at
checkouttime, symmetric to the guard already present indeposit_cookies. If a concurrentnts_warm_cookies(or anothercheckoutthat triggered its own re-handshake) installed a fresh session under the same key while this query was on the wire, the in-flight failure belongs to the old keys and the new session survives untouched. Without the guard a single transient signal would force every concurrent caller for the same host through a redundant re-handshake. - Off-path-attacker scope of the NTSN path: the matching-UID
check is the only authenticity signal available (no AEAD), so
an attacker who can observe one wire packet and forge a
UID-matching NTSN can at worst force one extra KE handshake
before the next legitimate response heals the session. A
non-matching (or absent) UID falls through to
MissingAuthenticatorand leaves the cached session intact; unauthenticated non-NTSN kiss codes (RATE,DENY, …) do the same so a server that AEAD-signs them (the standards path) still surfaces with its kiss code, while a stripped forgery cannot trigger an eviction. - AEAD-error mapping verified end-to-end:
From<AeadError>routesOpenFailed(tag mismatch — the dominant master-key-rotation signal),SealFailed,InvalidKeyLength, andInvalidNonceLengthtoNtsError::Authentication(eviction);UnsupportedAlgorithmis only reachable from the KE path and routes toKeProtocol(no eviction).From<NtpError>::Aead(_)routes the same way; wire-format, Kiss-of-Death, andUnsynchronizedarms route toNtpProtocol(no eviction — those are transient or server-attested signals, not key-state failures); the newStaleCookiearm routes toNtpProtocolfor the Dart-facing taxonomy, with eviction applied pre-conversion inside theevict_on_rekey_signalclosure so the publicNtsErrorenum stays byte-identical. - Healthy-path cost is unchanged: the trigger is a
map_errclosure that only acquires thesessions()mutex inside theAead/StaleCookiearms, so success returns are byte-identical to the pre-fix behaviour. - Coverage: seven new tests pin the behaviour. In
rust/src/api/nts.rs::tests: (i) matching-generation eviction drops the entry, jar and keys with it; (ii) stale-generation eviction is a no-op when a concurrent re-handshake has advanced the cached session; (iii) eviction is a quiet no-op when the entry is already absent; (iv) end-to-end via a loopback faux server, an AEAD tag mismatch evicts the session; (v) end-to-end, a non-AEAD protocol failure preserves it; (vi) end-to-end, a matching-UID NTSN KoD evicts; (vii) end-to-end, a wrong-UID NTSN preserves. Inrust/src/nts/ntp.rs::tests: four new parser-level tests cover matching-UID NTSN →StaleCookie, wrong-UID NTSN →MissingAuthenticator, UID-less NTSN →MissingAuthenticator, and unauthenticated non-NTSN kiss codes →MissingAuthenticator. - The FRB-generated
lib/src/ffi/api/nts.dartregainsevict_sessionin the alphabetised "ignored because notpub" comment line; no bindings code changes (the helper is intentionally crate-private).
Branch-protection enforcement (repo policy, no runtime impact) #
- Toggle
enforce_admins: trueon themainbranch protection rule so the six required status checks (the four pre-existing CI gates plus the two newHooks *checks added in this PR), linear-history requirement, and PR-only merge policy are binding for the maintainer account. The previous configuration (enforce_admins: false) made the rule advisory for repo admins, which left a directgit push origin mainunblocked for the most likely violator. - Add repo-tracked git hooks under
tool/hooks/(POSIX shell, no runtime dependency beyondgit) that refuse direct work onmain/master:pre-commitblocks plain commits,pre-merge-commitblocks merge commits (which bypasspre-commit), andpre-pushblocks any push whose destination ref isrefs/heads/main/refs/heads/masterregardless of the source branch. Activated per clone withgit config core.hooksPath tool/hooks; without that activation layer 1 contributes nothing. Two commit-time bypasses remain, both caught at push time bypre-pushand the GitHub-side rule: (a) rebases that rewrite localmain(each replayed commit runs in detached HEAD, sopre-commitfalls through), and (b) fast-forward merges (git merge feature/foowhilemainhas no diverging commits advances the ref without creating a commit, sopre-merge-commitdoes not fire). - Document the enforcement model in
AGENTS.md's new "Branch Protection" section andDEVELOPMENT.md's "Local hook setup" subsection. The model has two enforcement layers (local hooks for fast-fail, GitHub branch protection at the remote) plus CI as the upstream of the status checks the protection rule consumes. The branch protection rule itself does the merge gating: the rule refuses direct pushes from non-admin contributors, andenforce_admins: trueextends that refusal to admin/owner accounts (closing the maintainer-bypass path);required_status_checksrefuses the PR merge until the listed contexts pass. CI is not a separate enforcement layer, just the source of the signals the rule reads. Public Dart/Rust API unchanged. - CI gains two narrowly-scoped sibling jobs in
.github/workflows/ci.ymlplus atool/hooks/**path classification on the existingdorny/paths-filterstep, so a PR that touches only the enforcement scripts still gets validated rather than skipping every heavy job and merging unverified. Both jobs are added torequired_status_checksonmain, raising the required-context count from four to six:Hooks shell-syntax checkrunssh -nplus presence and exec-bit verification on each tracked hook (the explicit list fails closed if a hook is deleted, renamed, or chmod-stripped, where a glob would silently pass).Hooks behaviour checkrunstool/hooks/test_hooks.sh, a new POSIX-shell test that provisions a throwaway repo viamktemp -d, pointscore.hooksPathattool/hooks/, stages real commits and real merges, and invokespre-pushdirectly with synthetic refs/SHAs on stdin (git's documented pre-push contract: read updates from stdin, exit non-zero to abort). Asserts on exit codes plus stderr content. Catches the regression shapesh -ncannot — a script that parses but no longer enforces policy at runtime — and carries an explicit assertion sentinel for the unquoted-heredoc class of bug whereset -uaborts the hook before the recovery recipe can print. No other CI behaviour changes: thebuild,rust, andrust-bridge-syncfilters and gates are byte-identical.
Coverage exclusion alignment #
- Reconcile the four loci that determine the coverage denominator so
local artifacts, CI flags, and the Codecov dashboard agree.
.codecov.ymlnow ignoreslib/src/ffi/api/nts.dart(FRB-generated single-expression forwarders of the formntsQuery(...) => RustLib.instance.api.crateApiNtsNtsQuery(...)— reachable from the smoke tests but low-signal for authored-code coverage; the FFI dispatch they delegate into lives infrb_generated*.dartand is whatRustLib.initMock()substitutes) andrust/src/api/simple.rs(holds only the#[frb(init)]lifecycle hookinit_app, fired on dylib load and unreachable fromcargo test --lib).rust/tarpaulin.toml(new) carries the same Rust exclusion set so a localcargo tarpaulinreproduces CI numbers without per-invocation--exclude-filesflags..github/workflows/ci.ymladds the matching--exclude-files 'src/api/simple.rs'and the comment block above the step now enumerates all four filtered Rust files (previously named only two).DEVELOPMENT.md's "Coverage exclusion policy" subsection is refreshed to match.
greet smoke-test stub removal #
- Delete the
greetfunction fromrust/src/api/simple.rs(left over from the FRB scaffold; never re-exported throughlib/nts.dart, so internal-only by the package's own public-API stability statement) and refresh the file header to document its remaining role as the lifecycle-hook host.lib/src/ffi/api/simple.dartis removed; FRB does not auto-clean stale module files when a Rustapi/module loses its lastpubitem (a follow-up extendstool/check_bindings.dartto flag this footgun). ThecrateApiSimpleGreetoverrides inexample/lib/src/mock_api.dart,test/api_smoke_test.dart, andtest/ffi_smoke_test.dartare removed in the same commit; the FRB-generated layer (lib/src/ffi/frb_generated.{dart,io.dart,web.dart},rust/src/frb_generated.rs) is regenerated viaflutter_rust_bridge_codegen 2.12.0and committed clean against the drift gate.
CI: Flutter stable channel migration #
- Switch the Flutter SDK reference from the pinned
3.41.7release to thestablechannel across the five loci that named it:.fvmrc("flutter": "stable"),.github/workflows/ci.yml(matrix entry renamed3.41.7→stable),pubspec.yaml,DEVELOPMENT.md, and.github/pull_request_template.md. The pinned-semver references are rewritten to describe the channel rather than a specific version. The compatibility-floor matrix leg (3.38.10, the lowest Flutter satisfyingflutter: ^3.38.0inpubspec.yaml) is unchanged so the floor remains pinned. subosito/flutter-actionreceivesflutter-version: anyfor thestableleg (the action's documented channel-latest sentinel, since the action does not accept channel names asflutter-versionvalues); the format / coverage / Codecov upload gates are retargeted frommatrix.flutter == '3.41.7'tomatrix.flutter == 'stable'.rust-bridge-syncdrops itsflutter-versionpin and points the FVM symlink at$HOME/fvm/versions/stableto match.fvmrc.- Branch-protection continuity is preserved: the matrix-leg job
names (
Format / analyze / Dart tests (Flutter ${{ matrix.flutter }})) are not required status checks. TheDart tests gateaggregator job (needs: [changes, build],if: always()) is the entry onmain'srequired_status_checkslist and rolls up the matrix outcome under a name that does not move with the channel rename, so the rule continues to gate merges without any branch-protection edit. The five other required contexts (Detect changed paths,Verify FRB bindings are in sync,Rust build + tests + coverage,Hooks shell-syntax check,Hooks behaviour check) are also untouched by this rename. - The historical
3.41.7mention inside the## 1.0.0release entry below is intentionally left in place — it is a published-release entry and pub.dev archives the changelog at publish time.
1.3.1 #
Documentation-only patch on the 1.3.0 observability surface. No code,
FFI, or runtime behaviour changes; the Rust crate nts_rust is
unchanged at 0.2.2.
NtsDnsPoolStats — acknowledge inFlight > highWaterMark transient #
- Tighten the dartdoc on
ntsDnsPoolStats(lib/src/api/nts.dart) and the mirrored Rust docstring onNtsDnsPoolStatsplus itshigh_water_markfield (rust/src/api/nts.rs). The 1.3.0 wording ("Monotonically non-decreasing for the lifetime of the process", "racy by construction… never logically impossible") invited the strict reading thathighWaterMark >= inFlightholds at every observation point. It does not:try_acquire_slotperforms thefetch_addonin_flightand thefetch_maxonhigh_water_markas two independent atomic operations, so a concurrentpool_snapshot()can observeinFlight = prev + 1andhighWaterMark = prevfor the few-nanosecond window between them. The replacement wording calls this transient out by name and restates the actual guarantee — per-counter monotonicity across consecutive snapshots, not a cross-counter invariant within a single snapshot. - Rationale for documenting rather than patching
snapshot_ofto returnmax(in_flight, high_water_mark): the twoRelaxedloads in the snapshot path are not atomic together, so a derivedmax()suppresses one common observation but does not produce a coherent point-in-time view; closing the race in the increment path requires a CAS loop on a packed(in_flight, hwm)tuple, which is not justified by an observable-only-via-snapshot diagnostic counter; and the three operator-facing failure-mode signatures (healthy / cap-bound / libc wedge — see the rest of the dartdoc) reason about per-counter trajectories across consecutive snapshots, not single-snapshot cross-counter invariants. The transient does not degrade their diagnostic value. - The generated FFI dartdoc in
lib/src/ffi/api/nts.dartis regenerated from the Rust source and tracks the new wording. No other diff in the FRB-generated layer.
Documentation #
- Clarify shared-pool semantics for mixed-cap callers in the
rust/src/nts/dns.rsmodule header and the "Timeout budget and bounded DNS" section ofARCHITECTURE.md. The 1.2.0 wording — "the effective ceiling at any moment is set by whichever caller is currently being admitted" — invited a stateful reading in which the most recently admitted caller's cap somehow governs subsequent admissions. The actual mechanic is purely local: every admitted worker counts toward every caller's threshold, and each admission decision compares the live pool size against that call's own cap. The replacement wording names the asymmetric starvation behaviour explicitly (a small-cap caller can be refused when the pool is filled by a large-cap caller; the reverse cannot happen) so it is discoverable by a future ctrl-F search for "starvation" or "fairness". The published 1.2.0 changelog entry is intentionally not retroactively edited (pub.dev archives the changelog at publish time).
1.3.0 #
Public-API stability layer, bounded DNS resolver pool observability,
and a documentation correction in the Rust core. Strictly additive on
the Dart surface: existing call sites (including
test/ffi_smoke_test.dart and the example app, GUI, and CLI) keep
their current arguments and continue to compile. The Rust crate
nts_rust is unchanged at 0.2.2.
Public API stability layer (lib/src/api/nts.dart, new) #
- Introduce a hand-written wrapper in
lib/src/api/nts.dartthat becomes the package's stable public surface. The wrapper exposesntsQueryandntsWarmCookieswith idiomatic Dart optional named parameters (timeoutMs,dnsConcurrencyCap) and package defaults (kDefaultTimeoutMs = 5000,kDefaultDnsConcurrencyCap = 0), forwarding to the FRB-generated bindings for the actual FFI call.await ntsQuery(spec: spec)(no other arguments) now compiles and produces the same behaviour as 1.2.0'sntsQuery(spec: spec, timeoutMs: 5000, dnsConcurrencyCap: 0). - Rewrite
lib/nts.dartas an explicit re-export of the wrapper plus the bridge bootstrap (RustLib). The blanket re-export oflib/src/ffi/api/nts.dart(and thegreettoolchain helper fromlib/src/ffi/api/simple.dart) is removed; the FFI surface is now an internal implementation detail. Consumers' call sites are unchanged because the wrapper exposes the same names with compatible signatures. - Motivation:
flutter_rust_bridgev2 codegen emits every Rustpub fnargument as arequirednamed parameter on the Dart side, with no FRB attribute today that maps it to an optional Dart parameter with a default. Absorbing that asymmetry in a hand-written layer decouples the public contract from the FFI contract — internal Rust signature evolution (extra knobs, struct field churn, lint-pin regen) no longer propagates as breaking call-site edits for every downstream consumer. The 1.2.0 release was the concrete episode that motivated this: addingdnsConcurrencyCapwas a strict superset of the previous behaviour but broke source compatibility for every caller because the new parameter landed asrequired. - The deprecation policy for future Rust-side removals is symmetric:
parameters dropped from the Rust core survive in the wrapper as
deprecated no-ops for at least one minor release before being
removed at the next major. Documented in
ARCHITECTURE.md's new "Public API stability layer" section.
Bounded DNS resolver pool observability #
- Add
ntsDnsPoolStats()(synchronous; no future / isolate hop) returning a process-wide snapshot of the bounded resolver pool with four counters:inFlight(live workers currently pinned in the system resolver),highWaterMark(peakinFlightsince process start, monotonic),recovered(cumulative completed workers that released their slot), andrefused(cumulative admission attempts rejected because the cap was reached). The function is marked#[frb(sync)]on the Rust side so reading four atomics does not pay the FRB future-marshalling overhead. - The new struct
NtsDnsPoolStatslands as part of the wrapper layer's public surface alongsideNtsServerSpec/NtsTimeSample. - Saturation surfaces unchanged on the hot path as
NtsError.timeout(the error contract stays collapsed); the new counters are the side-channel that lets operators distinguish a healthy oscillating-below-the-cap resolver from a true libc-level wedge. The diagnostic shape is documented in dartdoc onntsDnsPoolStats()and inARCHITECTURE.md's "Timeout budget and bounded DNS" section. - Internal refactor in
rust/src/nts/dns.rs: the previous loneIN_FLIGHT_DNS_LOOKUPS: AtomicUsizeis replaced by aPoolStatsbundle (in-flight + high-water + recovered + refused atomics), sotry_acquire_slot/SlotGuard::dropkeep the four counters in lockstep and the test seam parameterises a per-test bundle the same way the previous lone counter was parameterised. The existingresolve_with_global/resolve_with_timeoutsignatures are unchanged; only the internalresolve_withseam picks up the new type. Memory-ordering rationale for each counter (Relaxedfor cumulative tallies,AcqRelfor in-flight,AcqRelfor the HWMfetch_max) is documented inline. - Three new Rust unit tests in
nts::dns::tests:recovered_increments_on_worker_completion— the cumulative counter bumps exactly once per slot release, after the worker returns from the resolver, alongside the in-flight drain.refused_increments_on_cap_exhaustion— companion tocap_reached_returns_would_block; pins the counter delta on rejected admissions.high_water_mark_tracks_concurrent_admissions— admits N workers behind aBarrier, asserts the mark catches up to N while the slots overlap, then releases and asserts the mark stays at N (monotonic, not pinned to the live in-flight count).
- New wrapper-level smoke test (
test/api_smoke_test.dart) verifiesntsDnsPoolStats()is a synchronous getter returning anNtsDnsPoolStatsand that the FFI struct's fields are forwarded through the wrapper verbatim.
Documentation #
rust/src/nts/cookies.rs: rewrite theDEFAULT_CAPACITYdoc comment. The previous wording claimed the "initial NTS-KE response always delivers exactly 8" cookies, which is not mandated by the protocol — RFC 8915 §4 leaves the count returned by any given server to server policy. The replacement cites RFC 8915 §6 (the client-side cap of 8 unused cookies) and notes that the value matches what several public deployments (Cloudflare) are observed to deliver, with a §4 reference for the server-policy framing. No code change; this aligns the internal docs with theexample/-side framing already shipped in 1.1.2 / 1.2.0.README.md: rewrite the "API summary" table to show the wrapper signatures with=defaults (timeoutMs = kDefaultTimeoutMs,dnsConcurrencyCap = kDefaultDnsConcurrencyCap), add rows for the twokDefault*constants, and add a paragraph linking to the new ARCHITECTURE.md section. ThednsConcurrencyCapprose is updated to mention that omitting the parameter (or passing0) inherits the built-in default.ARCHITECTURE.md: add a new "Public API stability layer" section describing the wrapper, the FRB asymmetry it absorbs, the deprecation policy, and the contract split betweenlib/src/api/(hand-written, stable) andlib/src/ffi/(generated, regenerable). Update the repository layout table to list the new wrapper directory.
Examples #
example/main.dart: simplify the warm-then-burst flow to use the new wrapper defaults (await ntsWarmCookies(spec: spec)andawait ntsQuery(spec: spec)instead of threading explicittimeoutMs: 5000, dnsConcurrencyCap: 0through every call). Comment in Phase 1 documents that the defaults are sourced fromkDefaultTimeoutMs/kDefaultDnsConcurrencyCap.example/example.md's fenced block stays byte-for-byte identical toexample/main.dart(5310 bytes).- The Flutter GUI controller (
example/lib/src/state/nts_controller.dart) and the CLI (example/bin/nts_cli.dart) continue to thread their own configured values explicitly. They are not migrated to the defaults pattern in this release; the wrapper accepts both call styles.
Tests #
test/api_smoke_test.dart(new): wrapper-level smoke test that pins the package defaults (kDefaultTimeoutMs == 5000,kDefaultDnsConcurrencyCap == 0), asserts the wrapper applies them when the optional parameters are omitted, verifies that explicit overrides (including the0sentinel) are forwarded verbatim to the FRB layer, and exercises the synchronousntsDnsPoolStats()plumbing. Seven test cases.test/ffi_smoke_test.dart: rewrite the import block.greetand the FRB-layerntsQuery/ntsWarmCookiesare now imported directly frompackage:nts/src/ffi/...rather than the public barrel, so the test continues to exercise the FFI contract unchanged while the public barrel stops re-exporting them. The five existing test cases are unmodified and still pass.
Generated bindings #
lib/src/ffi/api/nts.dart,lib/src/ffi/frb_generated.dart,lib/src/ffi/frb_generated.io.dart,lib/src/ffi/frb_generated.web.dart, andrust/src/frb_generated.rsregenerated viaflutter_rust_bridge_codegen generate(pinned at 2.12.0) to pick up the newNtsDnsPoolStatsstruct and thents_dns_pool_statsentry point. No drift detected bytool/check_bindings.dartafter the regen + lint-suppression patches.
Verification #
fvm flutter analyze: clean (no issues).fvm flutter test test/api_smoke_test.dart test/ffi_smoke_test.dart: 12 / 12 pass.fvm flutter test(example/): 31 / 31 pass.cargo fmt --check(inrust/): clean.cargo clippy --tests --all-targets -- -D warnings(inrust/): clean.cargo test(inrust/): 112 / 112 pass, 3 ignored (live-network).example/main.dart↔example/example.mdfenced-block byte-for-byte parity: 5310 bytes.
1.2.0 #
Reliability and timeout-budget hardening across the Rust core. The public
Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec,
NtsTimeSample, NtsError) gains one new optional knob —
dnsConcurrencyCap — for tuning the bounded DNS resolver per call;
existing call sites that omit it continue to compile because the
codegen marks the parameter required (pass 0 to inherit the default).
Consumer-visible behaviour also improves on the timeout-fidelity and
DNS-stall paths. Rust crate nts_rust is bumped from 0.2.1 to
0.2.2; the bindings (lib/src/ffi/) are regenerated to reflect the
new parameter.
Bounded DNS resolution (rust/src/nts/dns.rs, new module) #
- Replace the unbounded
ToSocketAddrslookup that previously fronted both NTS-KE TCP connect and the NTPv4 UDP bind with a thread-pool resolver that offloadsgetaddrinfoto a detached worker and bounds the wait via ampsc::Receiver::recv_timeout. A stalled name server no longer holds the calling thread past the caller'stimeoutMsbudget; the resolver returnsio::ErrorKind::TimedOutonce the remaining budget is exhausted, which theapi::ntsandnts::kecall sites collapse toNtsError::Timeout. - Add a global atomic concurrency cap on in-flight resolver workers to
protect the host environment from a runaway burst of
ntsQuerycalls against a blackholed DNS server. The cap is configurable per call via thednsConcurrencyCapparameter onntsQuery/ntsWarmCookies; passing0selects the built-in default of 4, sized for mobile (worst-case ~512 KB-1 MB of pthread stack per leaked worker on iOS/Android, capping the steady-state leak from a blackholed resolver to ~4 MB instead of unbounded growth). Server-side callers that legitimately need higher fan-out can pass a larger cap per invocation. Cap exhaustion surfaces asio::ErrorKind::WouldBlockfrom the resolver entry point and is mapped toNtsError::Timeoutat both KE and UDP call sites so the Dart-side switch arm is reached without introducing a new variant. - Because the threshold compares against a single process-wide counter, two concurrent callers passing different caps share the same in-flight pool: the effective ceiling at any moment is set by whichever caller is currently being admitted, not a private quota.
- The detached-worker pattern intentionally leaks the OS thread on
timeout rather than aborting it:
getaddrinfois not cancellable on any major libc, so attempting to interrupt the worker would corrupt the resolver state. The slot cap bounds the steady-state cost of this leak under pathological conditions.
NTS-KE handshake (rust/src/nts/ke.rs) #
- Introduce a private
Deadlinenewtype that anchors a singleInstantat the top ofperform_handshakeand exposesremaining()(saturating) plusapply_to(&TcpStream)(refreshes socket read/write timeouts; returnsio::ErrorKind::TimedOutif the budget is exhausted). Replaces the previous pattern where every blocking phase — DNS lookup, TCP connect, TLS handshake, NTS-KE record I/O — was independently armed with the caller's fulltimeoutMs, allowing the total wall-clock cost to overshoot the budget by up to ~3x. connect_with_deadline_using<F>becomes the new core path;connect_with_timeout_usingis retained as a thinOption<Duration> → Option<Deadline>wrapper that preserves the slow-DNS test seam.perform_handshakethreads oneDeadlinethrough DNS resolution, TCP connect, post-connect socket-timeout setup, pre-write/pre-flush refreshes, and the read loop.read_to_end_cappednow takesStream<'_, ClientConnection, TcpStream>plusOption<&Deadline>and refreshes the underlying socket's read/write timeouts on every loop iteration, so a server that drip-feeds the NTS-KE response cannot stretch the read phase past the global deadline.- New regression tests:
deadline_remaining_saturates_at_zero_after_expiry,deadline_apply_to_returns_timed_out_when_expired,deadline_apply_to_sets_socket_timeouts_within_remaining_budget,connect_with_deadline_respects_external_deadline_for_unroutable_ip,connect_with_timeout_surfaces_slow_dns_as_timed_out.
UDP query path (rust/src/api/nts.rs) #
- Mirror the KE-side helper with a private
UdpDeadlinenewtype forUdpSocket. Surface:new(Duration),remaining()(saturating), andremaining_or_timeout() -> Result<Duration, NtsError>which short-circuits toNtsError::Timeoutonce the budget is exhausted rather than feedingDuration::ZEROintoset_read_timeout(which isEINVALon some platforms). bind_connected_udp_usingrewritten to anchor oneUdpDeadline, invokeremaining_or_timeout()?beforeresolve_with_globalso the resolver receives the live remaining budget rather than the originaltimeoutMs, and again beforeset_read_timeout/set_write_timeoutso the UDP socket inherits the remaining budget. The downstreamsocket.send/socket.recvinnts_querytherefore trip no later than the global deadline, even when the KE phase has consumed most of it.UdpDeadlineis intentionally a separate type from the KE-sideDeadlinebecauseapply_towould otherwise need to be socket-type-generic; the duplicated surface is ~20 lines.- New regression tests:
udp_deadline_remaining_or_timeout_after_expiry,bind_connected_udp_socket_timeouts_reflect_remaining_budget,bind_connected_udp_surfaces_slow_dns_as_timeout.
Documentation #
- The dartdoc on
ntsQuery(regenerated intolib/src/ffi/api/nts.dartfrom the Rust docstring oncrate::api::nts::nts_query) now states thattimeout_ms"bounds the DNS lookup that precedes each phase so a stalledgetaddrinfocannot stretch the wall-clock cost past the caller's budget" rather than the previous wording which described the timeout as per-phase.
Housekeeping #
- Apply
cargo fmt(pinned toolchain1.92.0) acrossapi/mod.rs,ios_init.rs,lib.rs,nts/aead.rs,nts/cookies.rs,nts/ntp.rs, andnts/records.rsto reconcile drift accumulated since the 1.1.0 cycle. Behaviour is unchanged. .gitignore: add.DS_Storeso macOS Finder metadata stops appearing ingit status.rust/src/nts/mod.rs: declare the newdnsmodule.
Verification #
cargo test --manifest-path rust/Cargo.toml: 108 passed, 0 failed, 3 ignored (live-network).cargo clippy --manifest-path rust/Cargo.toml --tests --all-targets -- -D warnings: clean.cargo fmt --manifest-path rust/Cargo.toml --check: clean.dart analyze: clean.flutter test test/ffi_smoke_test.dart: 5 / 5 pass.
1.1.2 #
Example-app polish and RFC 8915 §4 compliance in the consumer demo. No
changes to the published Dart surface (ntsQuery, ntsWarmCookies,
NtsServerSpec, NtsTimeSample, NtsError), the Rust crate
(nts_rust stays at 0.2.1), the FFI bindings, or the Native Assets
build hook. The diff is confined to example/, README.md, and
example/GUI_GUIDE.md.
Example app (example/) #
example/lib/src/widgets/log_view.dart: fix an auto-scroll "stickiness" race condition. The scroll-to-bottom side-effect ran in aWidgetsBinding.instance.addPostFrameCallback, so by the time the callback evaluated whether the user had been near the bottom the layout had already been extended by the freshly-appended entry and the threshold check fired againstmaxScrollExtentmeasured after the append. The decision is now taken synchronously in the signal effect against the pre-append layout, while the animated jump still runs post-frame against the resolved target. The 32 px stickiness threshold and 120 ms animation duration are unchanged.example/main.dart,example/example.md,README.md,example/GUI_GUIDE.md: drop the hardcodedconst _burstSize = 8assumption from the warm-then-burst sample. RFC 8915 §4 leaves the cookie-pool size to server policy — the NTS-KE handshake does not let a client request a specific count — so the burst loop now runsfor (var i = 0; i < warmed; i++)against the actual count returned byntsWarmCookies. Prose inREADME.mdandexample/GUI_GUIDE.mdis rewritten to cite the RFC and the live-logrecovered N fresh cookie(s)report rather than the previous "(typically 8)" / "Eight matches" framing.example/main.dartand the fenced block inexample/example.mdremain byte-for-byte identical at 5172 bytes.example/lib/src/widgets/log_view.dart: trim ~20 px of trailing whitespace below the newest log entry. After the stickiness fix made the layout settle visibly, two compounding sources of dead space at the bottom of the log card became apparent:_spansForappended\nto every entry (including the last), leaving a phantom blank line; andSingleChildScrollViewused symmetricEdgeInsets.all(12), stacking 12 px of bottom inset on top of that phantom line. The fix drops the trailing newline from the message span, inserts aTextSpan(text: '\n')separator between entries at the build site (so adjacent entries still render on their own lines, and selection-copy still yields one entry per line), and tightens the bottom padding toEdgeInsets.fromLTRB(12, 12, 12, 8). Total trailing gutter below the newest entry: ~28 px → ~8 px.
Packaging #
screenshots/gui_showcase.png(820,984 bytes) →gui_showcase.webp(183,230 bytes, −78%) viacwebp -lossless -z 9 -m 6. Output is pixel-identical to the source PNG (lossless ARGB, dimensions preserved at 1766×2062, alpha intact).pubspec.yaml'sscreenshots:entry now points at the.webppath. pub.dev's screenshot pipeline is WebP-native via pana'swebpinfovalidator, so this also skips the server-sidecwebpround-trip. Tarball footprint drops from 835 KB to ~213 KB.
Verification #
fvm flutter analyze(root +example/): no issues.fvm dart analyze(root): no issues.fvm flutter test(example/): 31 / 31 pass.example/main.dart↔example/example.mdfenced-block byte-for-byte parity holds at 5172 bytes.webpinfo screenshots/gui_showcase.webp: VP8L, 1766×2062, alpha=1.
1.1.1 #
Maintenance release. The public Dart surface (ntsQuery, ntsWarmCookies,
NtsServerSpec, NtsTimeSample, NtsError) is unchanged.
- Bump the
native_toolchain_rustbuild-hook dependency floor from^1.0.3to^1.0.4to pick up upstream fixes shipped in thenative_toolchain_rust1.0.4 release (pub.dev, 2026-04-27). The package has no runtime impact; it runs only insidehook/build.dartduring the Native Assets compile of the bundled Rust crate. - Refresh
pubspec.lockandrust/Cargo.lockto keep the resolved dependency graph aligned with the new floor. - Patch-bump the internal Rust crate
nts_rustfrom0.2.0to0.2.1so the crate version moves in lockstep with the Dart package release. The bindings (lib/src/ffi/) and Native Assets bridge are unaffected; no behavioural changes ship in the Rust core. - README, example, and dartdoc updates from the previous release stay in place; this release adds no new user-facing documentation.
1.1.0 #
Protocol-compliance and reliability hardening across the Rust core. The
public Dart surface (ntsQuery, ntsWarmCookies, NtsServerSpec,
NtsTimeSample, NtsError) is unchanged; consumer-visible behaviour
improves on the timeout, cookie-cache, and error-classification paths.
Rust crate nts_rust is bumped from 0.1.0 to 0.2.0 to mark the
internal protocol-validation tightening; the bindings (lib/src/ffi/)
and Native Assets bridge are unaffected.
NTS-KE handshake (rust/src/nts/ke.rs) #
- Replace the OS-default TCP connect with a deadline-aware connection
loop that honours the caller's
timeoutMs. Earlier releases passed the budget only to the read/write side of the socket and letTcpStream::connectblock on the platform default (typically 75 s on macOS / 21 s on Linux), which madentsQuery(..., timeoutMs: 5000)hang for the full kernel default when the KE endpoint blackholed SYNs. The new loop iterates the resolved address list, computes the per-attempt deadline from the remaining budget, and surfaces aKeError::Io(ErrorKind::TimedOut)on the first exhausted attempt rather than the last. Mapped throughFrom<KeError> for NtsErrortoNtsError.timeoutso the Dart-side switch arm is reached. - Regression test
connect_with_timeout_respects_budget_for_unroutable_ipexercises the deadline against192.0.2.1(RFC 5737 TEST-NET-1) and asserts the call returns within 1.5× the configured budget.
Cookie management (rust/src/api/nts.rs) #
- Introduce a monotonically-increasing
generation: u64onSessionand propagate it intoQueryContext::session_generationso each in-flight NTPv4 query carries the identity of the handshake that produced its cookies.Session::deposit_cookiesnow gates the cookie-jar update on a matching generation: cookies extracted from a response signed under generation N are silently dropped if the session has been re-handshaked to generation N+1 between dispatch and receipt. This closes a cross-session poisoning window where a late response from a stale session could install cookies bound to retired keys, causing the nextntsQueryto dispatch unauthenticatable cookies and fail the AEAD seal. - The generation counter is also incremented on every successful
Session::rehandshake, so the stale-cookie filter applies symmetrically to both concurrent-query races and explicitntsWarmCookiesinvocations during an in-flight query.
NTPv4 header validation (rust/src/nts/ntp.rs) #
- Add
STRATUM_UNSYNCHRONIZED_FLOOR = 16and reject any post-AEAD reply withstratum >= 16asNtpError::Unsynchronized. RFC 5905 reserves stratum 16 as the "unsynchronized" sentinel and 17–255 as reserved; previous versions only filtered LI=3, so a server in the alarm condition could surface a wall-clock offset to the discipline loop if it left LI=0. - Reorder the validation so the Stratum-0 short-circuit (Kiss-o'-Death)
runs before the LI=3 / stratum-ceiling check. Real-world KoD
packets routinely arrive with LI=3 because the server has no
synchronised time to advertise; the previous ordering swallowed the
4-octet kiss code (
RATE,DENY,RSTR,NTSN, …) into a genericUnsynchronizederror and stripped the diagnostic the caller needs to choose a back-off strategy. - Validation remains positioned after AEAD
open()and the origin-timestamp check.stratumand the leap indicator are part of the NTP AAD, so by this point the server has signed the value; off-path attackers cannot forge KoD or stratum-16 to disrupt the client. The post-AEAD ordering is pinned by the*_after_seal_*_tamper_as_aead_failuretest family. - New regression tests:
parse_response_prefers_kod_over_unsynchronized_when_both_setpins the new precedence (Stratum 0 + LI=3 ⇒KissOfDeath).parse_response_rejects_invalid_high_stratumpins the new stratum-ceiling check (stratum 16 + LI=0 ⇒Unsynchronized).
- Broaden the
Displayarm and rustdoc onNtpError::Unsynchronizedto"server reports unsynchronized clock (LI=3 or stratum >= 16)"so the diagnostic accurately reflects both triggers; the message passes throughNtsError::NtpProtocol(..)to the Dart side unchanged.
Housekeeping #
rust/src/nts/records.rs: replacebody.len() % 2 != 0with!body.len().is_multiple_of(2)indecode_u16_arrayto satisfy theclippy::manual_is_multiple_oflint (warn-by-default in clippy 1.92, surfaced oncecargo clippy --all-targets -- -D warningswas added to the release gate). Behaviour is unchanged.
Verification #
cargo test --lib: 95 passed, 0 failed, 3 ignored (live-network).cargo clippy --tests --all-targets -- -D warnings: clean across the workspace.
1.0.7 #
Documentation and published-tarball hygiene. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.
-
example/lib/src/state/nts_controller.dart: prepend a 46-line dartdoc block torunQuerythat documents the NTS-KE cold-start cost (TCP + TLS 1.3 + KE handshake + first NTPv4 exchange ≈ 4 RTTs end to end, no session-ticket resumption), the steady-state path (cached session keys, in-band cookie pool replenishment, ~1 RTT), and the attribution boundary (the latency is RFC 8915 protocol overhead, notRustLib.init(), the Native Assets pipeline, or per-call FFI cost). Includes a production note pointing atexample/main.dart'sntsWarmCookies()warm-then-query pattern as the canonical way to amortize the cold-start cost; the GUI deliberately does not follow it so that the protocol observation tool surfaces the unmasked latency. -
Repository-wide documentation refactor (7 files:
pubspec.yaml,analysis_options.yaml,DEVELOPMENT.md,README.md,example/.pubignore,example/README.md,tool/check_bindings.dart) to replace meta-commentary about pub.dev scorecards,panarubrics, and tag-drop heuristics with objective technical justifications. The platform allow-list now reads as RFC 8915's raw TCP/UDP requirement plus rustls+ring's lack of a wasm32 target; the FRB pin is justified by the silent-memory-corruption risk of a wire-format mismatch; the analyzer-exclude removal is justified by lockstep with the consumer's analyzer view; the// ignore_for_file:directives inlib/src/ffi/**are justified bypublic_member_api_docsbeing enabled and the FFI surface not being excluded. The IANA AEAD-registry reference inexample/GUI_GUIDE.mdis preserved as a legitimate protocol citation. -
.pubignore(new, root): introduce a root.pubignorethat mirrors the root.gitignorepatterns (per dart.dev/go/pubignore, a directory's.pubignorereplaces its.gitignorefor publish purposes) and additionally excludes consumer-irrelevant files:AGENTS.md,CLAUDE.md(AI-agent guidance),ARCHITECTURE.md,DEVELOPMENT.md(self-identified contributor-only documentation),analysis_options.yaml(consumer analyzers read the consumer's own config),flutter_rust_bridge.yaml(FRB codegen config; bindings ship pre-generated),tool/(CI drift check for FRB regeneration), andtest/(internal FFI smoke test, not a public-API verifier). -
example/.pubignore: addanalysis_options.yamlandtest/to the example's exclusion list for the same reasons as the root. The canonical consumer entry point remainsexample/main.dart. -
Net effect verified via
dart pub publish --dry-run: the published tarball drops from 840 KB (1.0.6) to 824 KB, twelve maintainer-only files are stripped, and the warning/hint output is unchanged. No source files inlib/,rust/, orhook/are touched, so the binding drift gate and Native Assets build hook are unaffected.
1.0.6 #
Binding regen consequent on the 1.0.5 analyzer-exclude removal. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.
lib/src/ffi/frb_generated.dart: regenerate against the currentanalysis_options.yaml. Removing theanalyzer.exclude: [lib/src/ffi/**]block in 1.0.5 had a side effect that the bindings CI job did not surface until the next commit that re-triggered the job:flutter_rust_bridge_codegenruns an analyzer-aware fix-up over the Dart it emits before exiting, that pass was a no-op while the FFI files were excluded, and with the exclude gone the pass appliesprefer_final_localsandprefer_const_constructorsto the synthesized dispatcher boilerplate. The committed file (last regenerated in 1.0.2,0349077) was therefore stale relative to the codegen's deterministic output. The regen is purely cosmetic —varlocals insidedco_decode_nts_error/sse_decode_*becomefinal, and the two nullaryNtsErrorvariants gainconstprefixes — and produces no wire-format or public-API change. The file-level// ignore_for_file:directives managed bytool/check_bindings.dartstill suppress both rules so future codegen output that emits a non-final local or non-const constructor remains acceptable to pana without re-failing the drift gate.
1.0.5 #
Example clarity and pub.dev metadata fidelity. No changes to the published Dart surface, the Rust crate, or the Native Assets bridge.
-
example/main.dart: switch the minimal sample from a singlentsQuery()call to a warm-then-query flow that callsntsWarmCookies()first and thenntsQuery(). The original one-call form lumped the NTS-KE handshake into the same latency budget as the NTPv4 exchange and never made the cookie pool visible; the new form mirrors the production access pattern, surfaces thecookies_remainingcounter onNtsTimeSample, and gives readers a self-contained reference for both stages of the protocol.example/example.mdis regenerated as a byte-for-byte fenced mirror so the pub.dev Example tab tracks the runnable sample. The exhaustiveNtsErrorswitch and theRustLib.init()bootstrap order are unchanged. -
example/example.md: drop the developer-facing meta-commentary about the rendering quirk that motivated the file's existence (panapriority list, theexample/main.dartshadowing dance from 1.0.3 / 1.0.4). The fenced sample is the consumer-visible artefact; the rendering history is recorded in this changelog, not in the file pub.dev publishes. -
analysis_options.yaml: remove theanalyzer.exclude: [lib/src/ffi/**]block so localdart analyze/flutter analyzeruns see the same surface pana sees on pub.dev. The FRB-generated files inlib/src/ffi/carry file-level// ignore_for_file:directives (managed bytool/check_bindings.dartand landed in 1.0.2) for the rules they cannot satisfy, which pana respects butanalyzer.excludedoes not — keeping both meant local CI was strictly more permissive than the pub.dev scorecard. With the exclude removed, lint drift between the two environments is impossible. -
pubspec.yaml: add a top-levelplatforms:allow-list withandroid,ios,macos,linux,windows. Earlier releases shipped without this block, which let pana award thewebandwasmplatform tags on the strength of the Dart surface compiling cleanly underdart2js/dart2wasm— but actual runtime use of any nts API on Web cannot work, because RFC 8915 needs raw TCP for NTS-KE on:4460and raw UDP for NTPv4 on:123(neither of which browsers expose to web pages), and therustls+ring+rustls-platform-verifierstack does not targetwasm32-unknown-unknown. Declaring the supported platforms explicitly drops both incorrect tags from the next pana rescore so the pub.dev scorecard reflects the package's true platform surface.
1.0.4 #
pub.dev Example tab fix (take two). No runtime changes.
-
Add
example/example.mdcontaining the minimal NTS-KE sample as a fenced ```dart block plus a pointer to the Flutter GUI showcase atexample/lib/main.dart. The 1.0.3 rename of the minimal sample toexample/main.dartdid not unblock the Example tab: empirical check on the published version-pinned URL still renderedexample/lib/main.dart. The bracket notationexample[/lib]/main.dartin dart.dev's package-layout doc is shorthand for two separate slots in pana's selection list, with thelib/form ranked higher than the bare form. The actual list lives inpana/lib/src/maintenance.dart:example/README.mdexample/example.md← new in 1.0.4, secures the slotexample/lib/main.dart(GUI showcase, no longer rendered)example/bin/main.dartexample/main.dart(1.0.3 rename target, also no longer rendered)
Slot 2 beats slot 3, so the new
example/example.mdfinally wins overexample/lib/main.dart. The minimal sample atexample/main.dartstays in the archive as the runnable Flutter target; the.mdis just a syntactic mirror so pub.dev picks it. -
No changes to the published Dart surface, the Rust crate, or the Native Assets bridge. The two new lines in
pubspec.yamlandCHANGELOG.mdare the only metadata edits.
1.0.3 #
pub.dev Example tab fix. No runtime changes.
- Rename
example/example.darttoexample/main.dartso pub.dev's Example tab renders the intended minimal single-call sample. pub.dev picks the rendered file from a hardcoded priority list documented at https://dart.dev/tools/pub/package-layout#examples; the previous layout placed the minimal sample at priority 5 (example[/lib]/example.dart) where it was shadowed by the Flutter GUI showcase at priority 2 (example/lib/main.dart). The bareexample/main.dartslot also sits at priority 2 but wins over thelib/variant, so the rename promotes the minimal sample without removing the GUI showcase from the published tarball. - Update
example/README.mdto spell the GUI entry point explicitly asflutter run -t lib/main.dart(or-t example/lib/main.dartfrom the repo root) so contributors don't accidentally launch the new top-levelexample/main.dartas the Flutter target. - Update root
README.mdandARCHITECTURE.mdto reference the new path. The 1.0.1 changelog entry that introducedexample/example.dartis left unchanged for historical accuracy.
1.0.2 #
Static-analysis score recovery. No runtime changes.
- Suppress pana-only lints across the FRB-generated bindings via the
// ignore_for_file:directive of each file, applied as a post-codegen patch step intool/check_bindings.dart. pana's static-analysis run uses a stricter ruleset thanflutter_lintsand surfaced 117+ INFO lints against the synthesized freezed wrappers (NtsError), auto-generated default constructors (NtsServerSpec,NtsTimeSample), and dispatcher boilerplate that FRB cannot back with Rust docstrings, costing 10 pub points. Patched files and rules:lib/src/ffi/api/nts.dart:public_member_api_docs.lib/src/ffi/frb_generated.dart:public_member_api_docs,prefer_final_locals,prefer_const_constructors.lib/src/ffi/frb_generated.io.dart:public_member_api_docs.lib/src/ffi/frb_generated.web.dart:public_member_api_docs. Localpana 0.23.12now reports 160 / 160 against the working tree.
1.0.1 #
Documentation and pub.dev metadata polish. No runtime changes.
- Restructure README around a What → Why → How flow and offload the
Rust toolchain, build hooks, and crate breakdown into new
ARCHITECTURE.mdandDEVELOPMENT.mdreference documents. - Add a self-contained
example/example.dartfor pub.dev's Example tab. - Resolve two
dartdocunresolved-reference warnings inlib/src/ffi/api/nts.dartby replacing Rust intra-doc link syntax with literal values in the upstream Rust docstrings and regenerating the bindings. - Trim the package description to fit pana's 180-char ceiling, add
five pub.dev topics (
ntp,time,networking,security,cryptography), and registerscreenshots/gui_showcase.pngas the package listing screenshot. - Expand the inline comment on the
flutter_rust_bridge: 2.12.0pin to document the wire-format rationale and the accepted pana warning.
1.0.0 #
Initial stable release.
Protocol #
- Network Time Security (RFC 8915) client implementing the full NTS-KE
handshake (TLS 1.3, ALPN
ntske/1, port 4460) followed by AEAD-protected NTPv4 (RFC 5905) over UDP/123. - AEAD algorithms: AES-SIV-CMAC-256 (IANA ID 15, default) and AES-128-GCM-SIV (IANA ID 16), negotiated during NTS-KE.
- Cookie management: in-memory cookie jar with automatic refresh via
ntsWarmCookies()when the pool is exhausted.
API #
ntsQuery({required NtsServerSpec spec, required int timeoutMs})returnsFuture<NtsTimeSample>with server transmit time, round-trip duration, stratum, negotiated AEAD ID, and fresh cookie count.ntsWarmCookies({required NtsServerSpec spec, required int timeoutMs})forces a fresh handshake and reports the number of cookies received.NtsErrorsealed class with eight typed variants (invalidSpec,network,keProtocol,ntpProtocol,authentication,timeout,noCookies,internal) for exhaustive pattern matching.
Implementation #
- Cryptographic core implemented in Rust (
rustlsfor TLS 1.3,aes-siv/aes-gcmfor AEAD,ringfor primitives). - Bridged to Dart via
flutter_rust_bridge2.12.0 (pinned exactly to match the Rust crate's wire format). - Bundled through the stable Native Assets API (
hook/build.dart+native_toolchain_rust); no manualcargoinvocation required from consumers.
Platform support #
Android, iOS, macOS, Linux, Windows. Web is not supported (no UDP socket primitive in the browser).
Build #
- Default release builds use the
log-stripCargo feature, elidinginfo!/debug!/trace!format strings at compile time;warn!anderror!survive for diagnostics. - The
verbose_logsuser-define inpubspec.yamlopts into a debug build with full logging (includingrustlsprotocol traces) for development.
Tooling #
tool/check_bindings.dartregenerates FRB bindings and fails CI if the committed Dart bindings orrust/src/frb_generated.rsdrift from the generator output.- CI matrix exercises both the declared SDK floor (Flutter 3.38.10 / Dart 3.10.9) and the pinned development version (Flutter 3.41.7 / Dart 3.11.5).
Requirements #
- Dart
^3.10.0, Flutter>=3.38.0. The lower bound matches thehookspackage (>=1.0.3) requirement. - Native Assets API (stable since Flutter 3.24).
