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