openmls 3.1.0
openmls: ^3.1.0 copied to clipboard
Dart wrapper for OpenMLS — a Rust implementation of the Messaging Layer Security (MLS) protocol (RFC 9420)
3.1.0 - 2026-09-08 #
For Users #
✨ Highlights
- Three bindings taken from openmls 0.9.0 — a secret can now be exported from a Welcome before deciding whether to join, a signature-key rotation can be sent as a proposal instead of a commit, and a key package's validity window can be read out and judged against a clock other than the device's. All three are additive: no existing signature moves.
- openmls v0.9.0 — unchanged this release
- openmls_frb v2.2.0 — Rust FFI bindings
Added
-
exportWelcomeSecret(rust/src/api/engine.rs) —exportSecretone step earlier in the join. It takes the samelabel,contextandkeyLengthand derives from the exporter secret of the epoch the Welcome invites you into, so an invitee can agree a key with the inviter, or prove it can read the epoch, before it accepts. The value is identical to whatexportSecretreturns after joining that epoch, which the tests assert against both the joiner and a member already in the group.Like
inspectWelcome, it commits nothing, and that is load-bearing rather than incidental: processing a Welcome makes OpenMLS delete the key package it was addressed to unless that package is last-resort. Here the delete lands in the call's snapshot and is discarded with it, so a laterjoinGroupFromWelcomeon the same Welcome still finds its key package. A test now pins that behaviour end to end — inspect, export, then join — because a commit added to either function would silently burn the invitation instead of failing loudly.It reads the unverified group info, the same as
inspectWelcome: the confirmation tag is only checked when the Welcome is staged into a group. -
proposeSelfUpdateWithNewSigner(rust/src/api/engine.rs) — the proposal form ofselfUpdateWithNewSigner, filling the one gap in that pair. An Update proposal has to be carried by somebody else's commit, so this is what a member sends when it wants its signature key rotated by the next committer rather than committing itself.Two signers are required because the message and its payload are authenticated against different keys: the envelope by
oldSignerBytes, since the sender's leaf in the tree still carries the old key, and the new leaf inside the proposal bynewSignerBytes, so it verifies against the key it announces. Upstream additionally requires that a credential set in the leaf-node parameters equal the new signer's; this wrapper builds leaf-node parameters fromleafNodeCapabilitiesandleafNodeExtensionsonly and never sets a credential there, so the new signer's credential is always the one folded in and the precondition cannot be violated from the Dart surface.Availability rests on this crate not enabling openmls's
virtual-clients-draftfeature. Upstream gates the function onnot(virtual-clients-draft), its owntest-utils, ortest— and thattest-utilswas deliberately dropped from the shipped binary in 3.0.0, sonot(virtual-clients-draft)is the only arm holding it open. -
keyPackageLifetimeandcheckLifetimeAt(rust/src/api/engine.rs) — two synchronous helpers that expose a key package's validity window and let it be judged against a supplied instant, such as a timestamp from a server, rather than the device's clock.checkLifetimeAtcalls OpenMLS's ownLifetime::validate_with_timerather than re-implementing the comparison, so the boundaries match what a peer will decide about the same package:notAfteris exclusive — an instant equal to it is already expired — whilenotBeforeis inclusive. Neither bound is adjusted here: OpenMLS's hour of clock-skew margin is added byLifetime::newwhen a key package is created (lifetime.rs,not_before = now - 1h), so it is already inside thenotBeforethatkeyPackageLifetimereads back, andcheckLifetimeAtcompares whatever bounds it is handed unmodified — it reaches OpenMLS throughLifetime::init, which upstream documents as "raw lifetime without skew".The device's clock still gates the reading half, and the split of these into two functions is what says so. Getting at the window means validating the key package, and OpenMLS's validation checks signatures, protocol version, extensions and the lifetime, that last one against this device's clock; there is no way to skip it from outside the crate. So
keyPackageLifetimefails on a package this device believes is expired and never yields its bounds — measured here, not inferred, by a test that builds an already-expired key package and asserts on the error OpenMLS reports. The pair can therefore apply an authority stricter than the local clock, which is the useful direction when choosing among a user's key packages, but cannot rescue one the local clock has already rejected.checkLifetimeAttakes the two bounds rather than key package bytes precisely so that half of the check has no local clock in it at all, for a caller that holds bounds from elsewhere.An instant past the year 9999 is an error rather than a verdict, and the cap is explicit rather than left to the platform. The platforms do not agree about what a
SystemTimecan hold:web_time's on wasm32 is a bareDurationsince the epoch and accepts everyu64of seconds, while nativestd::time::SystemTimedoes not — so the same call withu64::MAXwas a verdict on one target and an error on the other, measured on both. The cap is set where no calendar can mean a larger value rather than at any platform's limit, which is what keeps it correct without a survey of the platforms: 9999-12-31T23:59:59Z costs no caller anything, and below it every target this package ships for answers a given argument the same way. It also catches the likeliest way to call this wrongly — Dart has nosecondsSinceEpoch, so a caller reaching forDateTimemeetsmillisecondsSinceEpochfirst, and a present-day millisecond count sits far past the cap, failing loudly instead of returning a confident verdict about a date tens of thousands of years out.
Security
-
The snapshot's read path no longer leaves plaintext copies in freed memory (
rust/src/snapshot_storage.rs) —kv_readhanded out a clone of the stored value and every caller dropped it unwiped, so each read of MLS key material —EpochSecretsandMessageSecretsamong them — left a plaintext copy on the heap that nothing ever overwrote. Its two siblings did not have this: bothkv_writeandkv_deletealready zeroized the value they displaced, and both snapshot maps are zeroized onDrop. The read was the one way out that was not covered.Pre-existing rather than a regression: the diff of this file from
v2.0.1was additions only. The exposure was bounded by the same window as the rest of the snapshot — single-digit milliseconds per operation — but unlike the maps, the copies were never wiped at all, so they persisted until the allocator reused the pages.The fix is a type rather than a convention:
kv_readreturnsZeroizing<Vec<u8>>, so a caller cannot bind the value unwrapped without saying so, and the wipe runs on every path out,?included. The three decoded item lists behind it (append_to_list,read_list,remove_from_list) are wrapped the same way, because deserializing allocates fresh buffers that the wrapper around the value does not reach, andremove_from_listadditionally wipes both the elementVec::removehands back and the needle it was given.Zeroization cannot be asserted from a test — the wipe lands on memory the allocator may already have reused, and reading it back would be undefined behaviour — so a source-level test pins the shape instead, in the same way the release-profile test in
utils.rspins its invariant. It reads this file and fails ifkv_read's return type is widened back, if any decoded item list is bound outside the wrapper, or if the three lists it counts are no longer there. All three of those checks were confirmed to fail on a deliberately broken tree that still compiles.
Documentation
- The README names a Flutter behaviour that leaves
web/pkg/empty — the build hook provisions the WASM module into an app'sweb/pkg/, andflutter build webalways reaches it, butflutter run -d chromereaches it only while Flutter considers itsdart_buildtarget out of date. That target's cache key does not include the platform, so a debug run for another platform leaves a stamp the Chrome run accepts, logsSkipping target: dart_build, and never invokes the hook —RustLib.init()then fails on a 404 forpkg/openmls_frb.js. No hook can defend against it: the skip happens abovehooks_runner, where nothing the hook declares is read. Known Limitations now names the three escapes (flutter build web,rm -f build/*/dart_build.stamp,flutter clean). Behaviour is unchanged — this was always true and was undocumented.
For Contributors #
Changed
-
web-timeis now a direct wasm32 dependency of the native crate (rust/Cargo.toml) —Lifetime::validate_with_timetakes aSystemTime, and openmls'slifetime.rschooses which one bycfg(target_arch):web_time's on wasm32 andstd's everywhere else. They are unrelated types, socheckLifetimeAtcannot name the epoch it adds to without this. Same version openmls already resolves (1.1.0), so no crate enters the graph andTHIRD_PARTY_NOTICES.txtdoes not move — confirmed by the gate, not assumed.Worth knowing for anyone touching wasm32 code here:
make rust-docdoes not catch this class of error. Its--target wasm32-unknown-unknownpass went green on a deliberately wrongSystemTime, because rustdoc does not type-check function bodies.make build-webrejected the same tree withexpected web_time::time::system_time::SystemTime, found SystemTime, so it is the only local gate that compiles wasm32 bodies at all. -
copier template adopted: v4.8.0 → v4.9.0 (23 files, plus this entry) — most of it was written from this project's own findings and has been waiting on the template release; it lands here now. One conflict, in
README.md, where the template inserts the Known Limitations subsection above a heading this project had renamed; resolved by keeping the local headings. Every workflow, composite action and ruleset file is byte-identical to a v4.9.0 render afterwards, and theMakefile's three local hunks (themls_messagefuzz-target examples and theclassical_ops_do_not_init_libcruxreference) survived the merge.Android is cross-compiled on every pull request, all three ABIs, in
test-reusable.yml. Until now nothing outside a release tag cross-compiled Android at all, which is exactly how theopenssl-src3.6 / NDK r26 assembler break sat on main under every green gate and first surfaced as a twice-failed stage 1. Measured before it was written: about four minutes per ABI against this workflow's six-and-a-half-minute critical path, so the wall clock does not move.make verify-android-alignmentmeasures the 16 KB alignment Google Play requires rather than trusting the tool that supplies it. The alignment comes fromcargo-ndk's linker flags, not from the NDK —openmls_frb-2.0.1(r26) and2.1.1(r28) both measurep_align=0x4000— socargo-ndkis now pinned to 4.1.2 in the release job and the pull-request job together, and both jobs verify the bytes that come out. A misaligned.sobreaks no test here; it makes a consumer's app unpublishable.codegen-guardregenerates the bindings instead of only reading a label. A pull request that changes an existing signature was already caught, becausefrb_generated.rsstops compiling — but one that merely ADDS apub fncompiled fine and simply lacked the function on the Dart side. The job now runsmake codegenand refuses drift underlib/src/rust/orrust/src/frb_generated.rs, keeping its name (FRB bindings were regenerated) becauseprotect-main.jsonmatches it as a string.make actionlintand aWorkflow Lint (actionlint)job, pinned by version and by checksum, with the suppressions in.github/actionlint.yamlso a local run reports what CI reports. It found 93 things across this repository's workflows and none of them was a bug: 81 shellcheck findings — 79 fixed, and twoSC2129s (style only, on append-to-$GITHUB_OUTPUTblocks) suppressed inline with a reason — plus 12 false positives from actionlint's stale copy ofactions/create-github-app-token's inputs.anthropics/claude-code-actionmoves from v1.0.213 to v1.0.216 inai-review.ymlandrepair-build.yml, by commit SHA as before. Neither workflow touches the published package; both are pinned by hand, because Dependabot'sgithub-actionsecosystem does not reach the template's copy.make run-example-webclears thedart_buildstamp itself before handing over toflutter run— the working half of the Flutter behaviour described under Documentation above, which otherwise makes a Chrome run after a desktop run serve no WASM at all. A stamp that does not exist cannot be stale, and an unmatched glob is a no-op underrm -f.make verify-frb-pinsreads a sixth source whenrust/fuzz/Cargo.tomlnamesflutter_rust_bridge— a fuzz crate that drifts from the main crate does not merely disagree, it stops resolving, and nothing else notices.⚠
protect-main.jsonnow carries a required status check, and arriving is not the same as being applied. The file is in the tree; GitHub does not read it. Applying it takesmake setup-repo-protections ARGS="--update --yes"as a separate step — and--updateis the load-bearing half: the script is idempotent by ruleset name, so without it an existingProtect main branchis skipped with a message and exit 0, which looks exactly like success while changing nothing. Verified before overwriting that the live rulesets carry no hand-edits the file would clobber:delete-branchesandsigning-commitmatch byte for byte,protect-release-tagsdiffers only in the order of two bypass actors, andprotect-maindiffers only by the new rule plus arequired_reviewers: []default GitHub echoes back. Until it runs,codegen-guardreports nothing and blocks nothing — and it has since been run: the liveProtect main branchruleset now carries theFRB bindings were regeneratedcheck, matching the file. -
The scope file and the README name the new surface (
.github/agent-prompts/changelog-scope.md,README.md) — the scope file is what every future openmls release is classified against, so a binding missing from it is a binding whose upstream changes get reported as invisible to this package's users. It now namesexport_welcome_secret,propose_self_update_with_new_signer, andLifetimetogether withKeyPackageIn::validate— the latter because the whole of that validation (signature, protocol version, extensions, lifetime) is on the pathkeyPackageLifetimetakes and is therefore user-visible. The README's feature table and full API reference list the four new names.
3.0.0 - 2026-09-06 #
For Users #
✨ Highlights
- The published package initialises again —
2.0.1shippedflutter_rust_bridge: ^2.12.0alongside generated bindings that record2.12.0and are compared against the runtime with==. flutter_rust_bridge 2.13.0 was published on 2026-08-23 and landed inside that caret, so from that day on every fresh resolution — there is no committed lockfile to hold it still — threw fromRustLib.init(). The constraint now admits exactly one version. flutter testfinds the native library —flutter_toolsinstalls the hooked library underbuild/native_assets/<os>/, a directory neither of the two paths searched before covered, so a Flutter package depending on this one failed ininit()in its own unit tests while the app itself ran fine.- The post-quantum dependency tree moves off every advisory it was pinned to — the X-Wing path's libcrux crates were held at exact versions by openmls 0.8.1, and 0.9.0 moves all of them. The ignore lists shrink to one entry.
- Nine more ciphersuites, all of them post-quantum (breaking) —
MlsCiphersuitegrows from four values to thirteen, matching what the crypto provider actually runs. The nine were already going out on the wire in every leaf node built from the openmls 0.9.0 bump; they were simply not nameable in the Dart API, which made a group using one impossible to inspect and therefore impossible to decide about joining. - X-Wing is again the only suite delegated to libcrux — three ML-KEM suites had begun routing to a backend validated only for X-Wing, and one of them could not run there at all. Both this and the item above arrived with the 0.9.0 bump and are fixed in the same unreleased version, so no published release was ever affected.
- Rust backtraces stop reaching Dart error strings — the shipped binary
enabled openmls's
test-utilsfeature, and with it openmls formatted a symbolized backtrace into the internal errors that travel the ordinary error channel out to the caller. Dropping the feature also takes a test harness out of the release dependency graph. - openmls v0.9.0 — first upstream release since 0.8.1 (2026-02-13), and it closes an advisory this package had been working around locally.
- openmls_frb v2.1.1 — Rust FFI bindings
Changed (Breaking)
-
MlsCiphersuitegains nine values (rust/src/api/types.rs,lib/src/rust/api/types.dart) — the enum described four ciphersuites while the shipped crypto provider supported thirteen, and it was the provider's list that peers saw.supportedCiphersuites()now returns all thirteen, and every one of them is exercised by a full group lifecycle in both the Rust and the Dart test suites — created, added to, joined via Welcome, and messaged in both directions — rather than merely advertised.The gap was not cosmetic. Enabling
draft-ietf-mls-pq-ciphersuitesfor X-Wing also put nine ML-KEM suites into OpenMLS'sdefault_ciphersuites(), which is whatCapabilities::newfills in when a caller does not pin capabilities — so from the 0.9.0 bump onward, key packages and leaf nodes advertised all thirteen. A peer picking one of the nine produced a group thatinspectWelcomerefused withUnsupported ciphersuite, before the application could decide whether to join, even though the join itself would have worked. 0.8.1's default list held exactly the four the enum named, so 2.0.1 was consistent and the drift never reached a published release — it is introduced and fixed within this one.Ten of the thirteen are experimental suites on provisional code points that are not registered with IANA and may be renumbered or withdrawn; several are pure ML-KEM, with no classical component to fall back on. The README now lists every suite with its code point, its KEM and its signature, and says which are hybrid and which are not.
Action required: an exhaustive
switchoverMlsCiphersuiteno longer compiles. Add the nine new cases, or adefault:. Stored state is untouched — the original four keep their names, their meanings and their positions in the enum, so existing values continue to serialize identically and no stored group or key package has to be migrated.Action required, and this one is on the wire: what a client advertises changes even if you touch no code.
MlsCapabilities.ciphersuitesreads an empty list as "use OpenMLS's defaults", and that default list grew with the same feature — from four entries to thirteen — so a leaf node or key package built without explicit capabilities now advertises all thirteen, nine of them provisional code points not registered with IANA. Nothing breaks: RFC 9420 lets a client advertise suites it is never asked to run, and the group's suite stays the creator's choice. But a peer may now pick an experimental suite for a group you would join. To keep 2.0.1's advertised set, passMlsCapabilitieswith an explicitciphersuiteslist of raw code points —[0x0001, 0x0002, 0x0003]— tocreateGroupWithBuilder,proposeSelfUpdateandcreateKeyPackageWithOptions.
Changed
-
The Android libraries are built with NDK r28 instead of r26 (
.github/workflows/build-openmls.yml,.copier-answers.yml) — the shipped.sofor all three ABIs is now produced by Clang 19 rather than Clang 17. Not a chosen upgrade.rusqlite'sbundled-sqlcipher-vendored-opensslvendors OpenSSL, and the openmls 0.9.0 bump re-resolved that from 3.5.5 to 3.6.3, which ships an SM3 x86-64 assembly implementation using the Intel SM3 instructionsvsm3msg1,vsm3msg2andvsm3rnds2. NDK r26's Clang 17 does not know them, sox86_64-linux-androidfailed to build at all (invalid instruction mnemonic); Clang 18 is the first that assembles them, and r28 is the current stable line.Rolling OpenSSL back was rejected rather than untried:
openssl-src's newest 3.5 packaging is300.5.5+3.5.5, and OpenSSL 3.5.5 carries seven CVEs fixed only in 3.5.6 and later, which that crate does not package. The rollback would have traded a build failure for known vulnerabilities.The gap that let this reach a release tag is that Android is built only on an
openmls_frb-*tag — theTestsworkflow never cross-compiles it — so the breakage sat onmainfrom the 0.9.0 bump until the first tag after it. -
flutter_rust_bridge 2.12.0 → 2.13.0 (
pubspec.yaml,rust/Cargo.toml,Makefile,lib/src/rust/,.copier-answers.yml) — moved in all five places that have to agree, whichmake verify-frb-pinschecks, and the published constraint is>=2.13.0 <2.13.1.The reason is compatibility with sibling packages rather than anything in 2.13.0 itself. Because the constraint admits exactly one version — it has to, see the initialisation fix under Fixed — two flutter_rust_bridge wrappers pinned to different versions cannot resolve together in one application at all.
libsignal_darthas moved to 2.13.0, so staying on 2.12.0 would have published a release known in advance to be unresolvable alongside its next one. Both published packages still agree on 2.12.0 today, which made this the last moment the divergence could be avoided rather than repaired.The move itself is mechanical.
rustContentHashdoes not change (585923240before and after), so the wire signature between Dart and the native binary is unchanged; the whole Dart diff is the@generated bystamp in nine files plus onecodegenVersionstring; the generated Rust gainsstd::result::Result::Okqualification throughout; andCargo.lockmoves the two flutter_rust_bridge crates and nothing else, so the third-party notices change by two version numbers.It does require the native binary this release builds anyway: the WebAssembly module comes out 8.9 KiB smaller (2,549,220 → 2,540,136 bytes), which is change enough to re-run the manual browser check that the crypto has no CI gate for — all four post-quantum lifecycles pass on the rebuilt module, with key package, commit and welcome sizes identical to the previous run.
-
openmls 0.8.1 → 0.9.0 (
rust/Cargo.toml) — the X-Wing ciphersuite is unaffected despite an upstream feature gate. 0.9.0 putsHpkeKemType::XWingKemDraft6andMLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519behinddraft-ietf-mls-pq-ciphersuites, but nothing was renamed and the code point is still 0x004D, soMlsCiphersuite.mls256Xwing…keeps working exactly as before. Existing databases are unaffected too: the storage format does not move, which was verified by reopening a database written by the published 2.0.1 binary and decrypting a message it had encrypted and never opened. -
Two upstream message states are now named instead of reported as unknown (
rust/src/api/engine.rs) — 0.9.0 splitsOwnPendingCommitandOwnPrivateMessageout of what used to be errors, andprocessMessagewould otherwise have returned "Unknown processed message content type" for both.OwnPrivateMessagereplaces 0.8.1'sCannotDecryptOwnMessage: the same input, the same rejection, reached by a different path, and it now says why — the own sender ratchet is encryption-only, so a message this client authored cannot be read back.OwnPendingCommitcannot occur through this engine at all, because every commit-producing call merges before it returns and no pending commit ever reaches storage; it is handled anyway. No type in the Dart API gains a value. -
Key package lifetime validation errors read differently (upstream) — no code changed here, but the strings these errors produce did, and an application matching on them will stop matching.
LifetimeErrorreplacedRangeTooBig/NotCurrentwithExpired { not_after, now },NotValidYet { not_before, now }andSystemTimeBeforeUnixEpoch, so the reason is now stated with the timestamps instead of being a single opaque case. At the same timeLeafNodeValidationError::LifetimeandKeyPackageVerifyError::InvalidLifetimebecame#[error(transparent)], which drops the wrapper prefix: where 0.8.1 produced "Lifetime is not acceptable.", 0.9.0 produces the inner error's own text. These reach Dart through the ordinary error string, so treat the text as diagnostic and branch on the operation instead. -
rusqlite 0.34 → 0.37 (
rust/Cargo.toml) — not a chosen upgrade. openmls 0.9.0 pullsopenmls_sqlite_storage, which pinsrusqlite = "0.37"; cargo resolves optional dependencies into the lockfile even when the feature is off, andlinks = "sqlite3"admits exactly one. Nothing about the database changes: the SQLCipher amalgamation libsqlite3-sys 0.35 bundles is byte-identical to the one 0.32 bundled (same SHA-256, SQLite 3.46.1), so the on-disk format is untouched.
Security
-
Three ciphersuites had begun using a crypto backend validated only for X-Wing (
rust/src/hybrid_crypto.rs) — this provider delegates one ciphersuite, X-Wing (0x004D), to OpenMLS's libcrux provider, because RustCrypto does not accept it; everything else runs on RustCrypto. The predicate that made that decision matched on the KEM, which identified exactly one suite in openmls 0.8.1. 0.9.0 givesXWingKemDraft6to four suites — X-Wing plus threeMLKEM768X25519variants, which is arithmetically reasonable, since X-Wing is ML-KEM-768 combined with X25519 — so three ciphersuites silently began having their HPKE operations answered by a backend that had never been validated for them. This arrived with the 0.9.0 bump in this same unreleased version, so no published release shipped it.One of the three could not be answered at all:
MLS_128_MLKEM768X25519_CHACHA20POLY1305_SHA384_MLDSA44was advertised in every key package while libcrux rejected it withUnsupportedCiphersuite, having no ML-DSA signatures. RustCrypto implements it, so the routing fix makes that suite work rather than merely stop misrouting.The predicate now matches the whole
HpkeConfigtriple, which is unique per suite and is also the only information the HPKE methods receive — they are handed a config, never a ciphersuite. Two tests pin it, and they are the ones a libcrux advisory's reachability argument should now cite: one enumerates every supported ciphersuite and requires that exactly one reaches libcrux — by the predicate and by running HPKE keygen on a fresh provider per suite — and one keeps the advertised list and the provider in bijection so the set cannot grow unnoticed..cargo/audit.tomland the security-review checklist now name them. The existing guard was not wrong, it was unbounded: it proved that a single hard-coded classical suite stays off libcrux, which said nothing about how many other suites had arrived there. -
openmls no longer formats a Rust backtrace into errors the Dart caller receives (
rust/Cargo.toml,rust/src/api/engine.rs) — the shipped binary enabled openmls'stest-utils, which implies itsbacktracefeature. With that on,LibraryError::custom()builds its message as"Error description: {s}\n Backtrace:\n{…}"— a symbolized Rust backtrace carrying build-machine paths, symbol names and crate layout.LibraryErroris what openmls raises instead of unwrapping when an internal invariant is violated, and it is returned like any other error, so this package formatted it into the error string the caller gets. No panic was required to reach it. The feature is now off on every platform: the" Backtrace:"literal is gone from both shipped artifacts — the native library, 309 KiB smaller, and the WebAssembly one, 84 KiB smaller. Web consumers were affected too, because cargo unifies features across the platform-specific dependency tables, so the wasm32 build had been carryingtest-utilsas well.It could not simply be switched off before. Cargo features are additive, and
MlsGroup::export_group_context()was gated behindtest-utilsalong with thetree_hashandconfirmed_transcript_hashaccessors thatexportGroupContext()reports, so dropping the feature meant dropping fields fromMlsGroupContextInfo— a breaking change for the sake of a hygiene fix. openmls 0.9.0 madeMlsGroup::public_group()public, andexport_group_context()is a one-line wrapper overself.public_group.group_context(), so the replacement is the same call chain rather than an equivalent one: the same six fields, the same values, and regenerating the bindings produces no diff at all. Nothing in the Dart API changes.SECURITY.mddescribed this feature as enabling "accessor methods" with "no test-only code paths activated in production". That was true ofopenmls_basic_credential's feature of the same name — still enabled, still what makesprivateKey()possible — but never of openmls's, and the entry now says which is which. -
Signature private keys are wiped from memory when dropped (upstream,
openmls_basic_credential0.5.0 → 0.6.0) —SignatureKeyPair.privatewas a plainVec<u8>, so a signing key's bytes were left in freed heap memory when the pair went out of scope, and noDropimpl could be added downstream because the type is upstream's. It is now aSecretVLBytes, which isZeroizeOnDrop. This package constructs one of these per signing operation, so it is on a hot path. The fix had been merged upstream since March and unreleased; this package argued for its release on openmls#2116, which 0.9.0 closes. It needs no change here. -
The out-of-bounds parsing bug is now fixed upstream, not only worked around here (
rust/Cargo.toml) — GHSA-rrmv-c79f-cf5r was published on 2026-08-25 withpatched_versions: 0.9.0. It is the bug behind theRead-based decoder this package has carried since 1.4.2: openmls' manualDeserializeBytesimpls sliced the input at the re-serialized length and indexed out of bounds when that exceeded the bytes actually consumed, on bytes that come straight off the network. Upstream now returns the authoritative unconsumed tail. The disclosure window is why 1.4.2's and 2.0.0's entries describe this as hardening in general terms; this is the first release able to name it.The local decoder is gone with it (
rust/src/wire_decode.rs,rust/src/api/engine.rs): all nineteen call sites decode through openmls' owntls_deserialize_exact_bytesagain. That was held back until the fix covered both halves of the problem, because the workaround was never only about the panic —Extension::tls_deserializedid not require a payload to be fully consumed, so on input with trailing bytes the two decoders resumed from different offsets and disagreed about what the message said. 0.9.0 fixes the remainder arithmetic (the advisory) and makes known structured extension payloads reject trailing bytes (upstream #2134), so the two paths now agree and the removal is behaviour-neutral rather than a rollback to the old behaviour. The fuzz target over those decoders is deliberately kept: the types are still what the API parses straight off the network, and they are worth fuzzing whichever decoder is behind them. -
The X-Wing dependency tree moves off every pinned advisory version (
rust/Cargo.lock,.cargo/audit.toml,rust/deny.toml) — 0.8.1 pinned the libcrux crates at versions whose advisories could only be accepted or argued unreachable. 0.9.0 moves the whole tree:libcrux-secrets0.0.5 → 0.0.6, which is the fix version for the one entry that was an accepted availability risk rather than an unreachable one (RUSTSEC-2026-0212, incorrect constant-time swap/select on aarch64);libcrux-ed255190.0.6 → 0.0.9, past the 0.0.7 that fixes RUSTSEC-2026-0075;libcrux-aead0.0.7 → 0.0.9;hpke-rs0.6.1 → 0.7.0. The ignore lists shrink accordingly —.cargo/audit.tomlto nothing,rust/deny.tomlto a single unmaintained build-time proc-macro — verified by deleting the entries and re-running the gates rather than by reading version numbers. -
A panic on the web skips the zeroize that a native panic still performs (
SECURITY.md) —wasm32-unknown-unknowncompiles withpanic = "abort"by target default, so a Rust panic there traps the WebAssembly instance instead of unwinding and no destructor runs: the snapshot's plaintextHashMaps and the database key material are left in the module's linear memory until the page drops it. Native builds unwind and do zeroize —[profile.release]deliberately carries nopanickey — so the hole is web-only and opens only on the panic path; ordinary operation zeroizes on both platforms. Nothing in the shipped package changed: the property was always this way and is now written down, as Known Limitation 12, because a web deployment's threat model depends on it. -
The security policy now covers the post-quantum suites it ships (
SECURITY.md) — the file said nothing about them: no mention of libcrux, X-Wing, or a provisional code point anywhere. That was tolerable while the enum named four suites and one of them was experimental; it is not now that it names thirteen and ten are. Known Limitation 13 states what a reader has to decide about — the code points are unregistered and may be renumbered or withdrawn, several suites have no classical component to fall back on, the implementations are pre-1.0, and an emptyMlsCapabilities.ciphersuitesadvertises all thirteen — and where the mitigation is. It also records the invariant the.cargo/audit.tomlreachability arguments are stated over: exactly one suite reaches libcrux, enforced by three tests. Documentation only; nothing in the shipped code changed.
Fixed
-
The published key package lifetime default was wrong, and four other documents disagreed with the code (
rust/src/api/types.rs,rust/src/api/keys.rs,rust/src/api/engine.rs,rust/src/api/credential.rs,README.md,SECURITY.md,lib/openmls.dart) —KeyPackageOptions.lifetimeSecondsdocumentedNoneas "default (90 days)". openmls 0.9.0 sets that default to3 * 28days, i.e. 84, and it is the default that applies:createKeyPackagebuilds without a lifetime andcreateKeyPackageWithOptionssets one only when the caller passes it. A rotation window sized off the docstring was sized against a number this package never used.Alongside it, six references in published doc comments named Rust spellings that do not exist on the Dart surface —
from_raw()forfromRaw, and the parametersdb_path,encryption_key,process_message,certificate_chain,private_key.make doccannot catch these: the references are in plain backticks, which resolve nothing and so never warn. The README's hardening list namedsigner.serialize()as key material when it carries only the public key and scheme, whereSECURITY.mdnames the two that do carry secrets;SECURITY.mddescribed RustSec ignore justifications in.cargo/audit.toml, whose list is empty and whose header says so; the "Full API reference" omitted eight methods, two of which the security policy tells the reader to use; the architecture table gave iOS one architecture while the release ships and the build hook resolves anx86_64simulator slice (the cell now says so explicitly); andlib/openmls.dart's install snippet still said^1.0.0. -
The way to narrow what a key package advertises was undocumented, and the README said it did not exist (
rust/src/api/engine.rs,lib/src/rust/api/engine.dart,README.md) —createKeyPackageandcreateKeyPackageWithOptionscarried no doc comment at all, and the README stated outright that "createKeyPackagetakes no capabilities argument, so key packages always advertise the full list". The second half is false:KeyPackageOptions.capabilitiesexists and reachesbuilder.leaf_node_capabilities. Since this release is the one that grows the advertised list from four suites to thirteen, that sentence sat exactly where a reader would go looking for the mitigation and told them there was none. Both functions now document what the defaults advertise and which one to reach for; the README points at the option instead of ruling it out. -
Six dead references in the published API documentation (
rust/src/api/types.rs,rust/src/api/engine.rs,lib/src/rust/) — flutter_rust_bridge copies a Rust doc comment into the generated Dart verbatim, and Rust's intra-doc syntax is not Dart's:[`MlsCiphersuite`]puts a code span inside the brackets, which dartdoc reads as a reference named`MlsCiphersuite`and cannot resolve. Each one reached pub.flutter-io.cn as a dead link — the type-level note onMlsCiphersuite, the cross-reference between the two ML-DSA-87 suites, and the mentions ofsupportedCiphersuites,MlsCapabilities.ciphersuitesandMlsEngine.close. They are now written the way this package's Rust API doc comments have to be written: plain backticks around the Dart camelCase name, which resolves on neither side and rots on neither either.Nothing here was noticed by a human: dartdoc reports an unresolved reference as a warning and exits zero, so the links had been dead for as long as they had existed.
make docis a blocking gate now (see the template adoption below), which is what surfaced them. -
RustLib.init()threw for anyone who resolved this package after 2026-08-23 (pubspec.yaml) —flutter_rust_bridgewas declared as^2.12.0, while the committedlib/src/rust/frb_generated.dartrecordscodegenVersion => '2.12.0'and the runtime compares that string to its own with==. flutter_rust_bridge 2.13.0 was published on 2026-08-23 and landed inside the caret, so every fresh resolution from that day on — this repository's CI and every consumer of the published package alike — failed initialisation withcodegen version (2.12.0) should be the same as runtime version (2.13.0).pubspec.lockis deliberately not committed for a library, so nothing held the version still, and the shipped archive carries both halves of the contradiction: the caret in its pubspec and the generated file that fixes the other side. The two pins that were already exact,="2.12.0"inrust/Cargo.tomlandFRB_CODEGEN_VERSIONin theMakefile, were never the ones at risk.The constraint now admits exactly one version, written
>=X.Y.Z <X.Y.Z+1— this release ships>=2.13.0 <2.13.1(see the version move below; the shape is the fix, the number is a separate decision). Nothing wider is safe: the check is string equality, so every version a range admits except the one that generated the bindings fails, and a<next minorbound would only narrow the window — flutter_rust_bridge ships patch releases, and a 2.13.1 would break it identically. One version is also what upstream documents — "all flutter_rust_bridge-related packages will need to have exactly the same version" — and what its ownintegratestep writes withdart pub add.The range form, rather than the bare version, is forced by the release path and not by taste.
dart pub publishwarns that a single-version constraint "should allow more than one version", and it exits 65 on any warning, somake publish-dry-run— which bothmake releaseandpublish.ymlgate on — fails, and the package cannot be published at all. The>=X.Y.Z <X.Y.Z+1form resolves to the same single version and does not trip that check. Measured rather than assumed: four constraint shapes were run throughdart pub publish --dry-run, and only the bare version produced the warning.One consequence for consumers, and it is the intended one. Anyone who also depends on another flutter_rust_bridge wrapper built against a different version now gets a version-solving failure out of
pub get, instead of a successful resolve followed by a throw atinit(). The incompatibility was always there — two sets of generated bindings cannot both equal one runtime version — so what changes is only that it surfaces where it can be acted on.Nothing else moves for this fix:
rustContentHashis unchanged, so it needs no regeneration of its own. Worth stating what that hash does not cover, since this release leans on it nowhere: it is computed over the FFI function signatures, not over enum variants, so the ciphersuite expansion above leaves it unchanged too — bindings naming thirteen suites would load against a native binary that knows four without the runtime check firing. What prevents that is the release order rather than the hash. The build hook resolves its download from the crate version inrust/Cargo.toml, which ships inside the archive, and the stage-2 release refuses to run until the stage-1 native release for that exact version exists. -
The native library was not found under
flutter test(lib/src/platform/platform_io.dart,lib/src/openmls.dart,test/platform/native_asset_search_paths_test.dart) — the build hook registers the library as aCodeAsset, but apackage:asset id is not a path:DynamicLibrary.open()hands it todlopenverbatim, only@Native(assetId:)externals go through the asset mapping, and flutter_rust_bridge needs a library handle — so the file has to be located on disk. The search covered.dart_tool/lib/(dart run/dart test) and../lib/next to the executable (AOT bundles).flutter testuses neither: flutter_tools installs the hooked library underbuild/native_assets/<os>/and never creates.dart_tool/lib/, and on macOS and Linux nothing onflutter_tester's dlopen search path covers that directory — so a Flutter package depending on this one failed ininit()in its own unit tests on a clean tree, while the app itself built and ran fine. A leftover.dart_tool/lib/from an earlierdart testis what made it look intermittent; Windows resolved it by accident, because flutter_tools prepends that same directory to the tester'sPATH. The directory is now probed last: it is relative to the working directory, so ahead of the executable-relative entry it would let a shippeddart build clibinary load whatever happens to sit underbuild/native_assets/<os>/in the directory it was launched from, in preference to the library it shipped with.
For Contributors #
Added
- A test now guards the release profile's panic strategy
(
rust/src/snapshot_storage.rs,rust/Cargo.toml) — the absence of apanickey in[profile.release]is load-bearing:panic = "abort"would skip unwinding, soDropwould never run and the zeroize of both snapshotHashMaps and of the database key material would be silently bypassed, leaving plaintext key material in memory after any panic. Nothing in the tree recorded that, let alone enforced it. It cannot be checked at runtime — Cargo forces unwind for thetestandbenchprofiles and rejects the key on per-package overrides, so the setting that actually ships is invisible from inside a test binary — so the test reads the manifest instead. It goes red onpanic = "abort"in either TOML string form, on the section being renamed away, and — because an exact[profile.release]header match is a thing TOML gives several ways around — on any non-comment line of the manifest that names bothpanicandabort, whatever shape it is written in. Verified by puttingrelease.panic = "abort"somewhere the header match cannot see and watching it go red.
Changed
-
The libcrux routing guard exercises all five HPKE methods, not one (
rust/src/hybrid_crypto.rs) —libcrux_routing_is_limited_to_xwinghas two halves, and the operational half randerive_hpke_keypairalone. Each ofhpke_seal,hpke_open,hpke_setup_sender_and_export,hpke_setup_receiver_and_exportandderive_hpke_keypairmakes its ownroutes_to_libcruxcall, so a wrong backend introduced in one of the other four left both halves green: the predicate is unchanged, and nothing called the method. All five now run per ciphersuite on a fresh provider, with a full seal/open round-trip and an exporter-secret agreement check, and libcrux must still be uninitialised after each — the failure names the method. Confirmed against the defect rather than the patch: routinghpke_openunconditionally to libcrux turns the test red on the first ciphersuite, where before it stayed green. -
The two
unsafe_codeopt-outs are item-level, and the comments describing them are true (rust/src/encrypted_db.rs,rust/src/lib.rs,rust/Cargo.toml) —encrypted_db.rsopened with a module-wide#![allow(unsafe_code)]for a single wasm32-gatedunsafe impl Send + Syncpair, which exempted all 1,500 lines of it, native half included. The allow moves onto the two impls. Both comments said the bridge and this module opt out "each with its own attribute", naming a module-level one here; both now say item-level, which is what they both are. -
CLAUDE.mddescribed a version key that does not exist, and an unsigned release tag — the native library version was documented asopenmls: native_version:inpubspec.yaml; there is no such key, andhook/build.dartreads the crate version out ofrust/Cargo.toml, which is what decides the binary a consumer downloads. The publishing checklist still ended ingit tag -a, which theProtect release tagsruleset rejects for want of a signature and which the two-stagemake release-frb/make releaseflow replaced; it now points at that flow and lists the gates to have green before it, including that stage 1 only warns when local main is ahead of origin. -
copier template adopted: v4.7.0 → v4.8.0 (12 files) — half of it had already been contributed upstream from here, so what actually arrives is one CI gate and three script fixes.
analysis_options.yamljoins the test workflow's path filters, onpushandpull_requestboth. That file decides whatmake analyzereports, so a commit changing only the lint configuration was precisely the one that did not re-run the gate it changes —dartdoc_options.yamlsat one entry above for the same reason and was already listed. This was the one hole the update closed rather than confirmed.make releasenow checks that the stage-1 binary was built from this tree, not merely that a release carrying the same version string exists. The old check was keyed on the crate version alone, and the crate version does not move until stage 1 runs — so running stage 2 on its own passed it and would publish bindings against whatever binary the previous stage 1 left behind. Neither runtime net catches that:rustContentHashcompares the FFI surface, which an FRB upgrade need not move, and the codegen assert compares the bindings against the flutter_rust_bridge runtime package.Two version readers are anchored.
getUpstreamVersion— now split into a testableparseUpstreamTag— andfrbVersionFromGeneratedBindingsboth used an unanchoredfirstMatch, which takes whichever match comes first in the file rather than the live declaration. A commented-out pin, the shape an upgrade leaves behind, therefore outranked the real one below it. Latent here, since this manifest carries no commented-out pin, and the direction that matters is a comment naming a newer tag:check_updates.dartwould then report the dependency as already current and an upstream release, security fixes included, would silently never land, with nothing failing.The manifest test that enforces the panic strategy moves to its template home. It was contributed upstream from this project and comes back in
rust/src/utils.rs, so the local copy inrust/src/snapshot_storage.rsis removed andrust/Cargo.toml's comment now names the file that holds it. The suite still reports 27 tests; the assertion is the same one, and it still goes red both onpanic = "abort"inside[profile.release]and on arelease.panicwritten where an exact section match cannot see it. -
copier template adopted: v4.6.0 → v4.7.0 (34 files) — the release is mostly gates, and two of them close holes this project knew it had.
wasm32 is executed in CI, not merely compiled.
test-reusable.ymlgainsBuild WASMandRust unit tests (browser), andmake test-webruns the crate's#[cfg(target_arch = "wasm32")]tests in headless Chrome through wasm-pack. Until now every wasm32 branch in the crate was covered by nothing:make testis the Dart VM andmake build-webonly compiles, while a wasm32 body is a different implementation of the same function rather than the same code on another host. The manifest gains[target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test, which is invisible tomake third-party-notices— it runscargo tree --edges normal,build, which excludes every dev-dependency on every target — andmake verify-third-party-noticesconfirms the inventory is unchanged despite eight new crates inCargo.lock.Two documentation gates now block.
make docpromotesunresolved-doc-referenceto an error through a newdartdoc_options.yaml(.pubignored, so pub.flutter-io.cn's own dartdoc run is not held to it and a future dartdoc release cannot break documentation generation for an already-published version), andmake rust-docruns rustdoc under-D warningson the host and on wasm32. The first was red on adoption — see the six dead references above.Bookkeeping no longer outranks tests.
verify-third-party-noticesandverify-frb-pinsmoved after the test steps, so a stale inventory no longer fails the Linux leg before a single test has run.Answers, not just files.
rust_versionis raised to1.91to match the manifest, soREADME.mdandCONTRIBUTING.mdstop advertising a toolchain that cannot build the crate and the next update cannot render the stale number back over it; the newenable_freezedquestion is answeredfalse, because flutter_rust_bridge needs freezed only for data-carrying enums and structs this API does not have.Also arriving:
codegen-guard.yml(a pull request labelledcodegen-failedfails a required check rather than relying on a reviewer noticing),.github/agent-prompts/repair-build.md,--lockedin the release builds,make rust-geigerandmake run-example-web,lib/src/rust/**excluded from the coverage report (370 lines over 10 files becomes 51 over 4 — the hand-written half — both at 100%, so the badge does not move), four items in the upstream-bump checklist (a feature gate reads asE0599; features are additive and reach the shipped artifacts; advisory ignores are re-earned on every bump; MSRV lives in two files), and a CONTRIBUTING that is a document rather than a stub. -
The
unsafe_code = "deny"comment named an opt-out that does not exist (rust/Cargo.toml,rust/src/lib.rs) — both copies listedsnapshot_storage's "interior-mutability shim" as one of three modules opting out of the deny, but that module contains nounsafeat all. There are two opt-outs: the FRB-generated bridge andencrypted_db's WASMunsafe impl Send + Sync. The parenthetical claiming both go through a module-level#![allow(unsafe_code)]was wrong too — the bridge carries an item-level#[allow].SECURITY.md's own count was already right, which is how the drift stayed invisible. -
MSRV 1.89 → 1.91 (
rust/Cargo.toml) — required by openmls 0.9.0, whose workspace declares it. The 1.89 floor was ours, chosen forstd::fs::File::try_lockso the single-writer lock would not need anunsafelibc::flock; that requirement still holds, it is simply no longer the binding one. No CI change was needed — the MSRV job readsrust-versionout of the manifest and installs that toolchain, so the two cannot drift. -
Dependabot may not raise rusqlite past 0.37 (
.github/dependabot.yml) — for two independent reasons, both written out at the entry. It is pinned rather than chosen, becauseopenmls_sqlite_storagerequiresrusqlite = "0.37"andlinks = "sqlite3"admits exactly one package, so 0.38+ does not resolve at all. And 0.40.x is separately unshippable: it bundles SQLCipher 4.14.0, whosesqlcipher_fprintfallocates on Windows, so a failingVirtualLockundercipher_memory_security = ONlogs a warning, which allocates, which recurses until the stack is gone. Pull requests #19 and #21 both died that way, which is what makes it a ceiling rather than a postponement. -
Dependabot may not propose the
hooks/code_assets2.x migration (.github/dependabot.yml) — it is blocked by the pinned Flutter SDK, not by this repository's code:hook/build.dartneeds a zero-line diff, since hooks 2.0.0's one breaking change (ProtocolExtensionfrom interface to base class) touches none of the three things it uses. What fails is version solving — hooks ≥ 2.1.0 pullsrecord_use ^1.0.0, which needsmeta ^1.19.0, and Flutter 3.38.4 pinsmeta: 1.17.0exactly. The reason to ignore rather than leave a red pull request open is where the damage lands: the root package resolves fine because it has noflutter: sdkdependency, so this repository's own CI understates it, while merging would make the published package unresolvable for every Flutter consumer on an SDK that pins meta below 1.19.0 — anyone on 3.44.x or earlier. Both names are ignored together becausecode_assets 2.0.0requireshooks ^2.2.0. Lift both when the pinned Flutter reaches 3.47.0, the first stable that relaxes the pin tometa: ^1.18.3. -
Two guards now bound what the crypto routing may do (
rust/src/hybrid_crypto.rs,.cargo/audit.toml,.github/workflows/test-reusable.yml,.claude/skills/security-review/SKILL.md) — both were written before the routing fix and confirmed red against it, so they cover the defect rather than the patch.libcrux_routing_is_limited_to_xwingenumerates every supported ciphersuite and requires exactly one to reach libcrux;openmls_defaults_are_executable_and_nameablerequires every suite OpenMLS advertises by default to be one this provider can run and the public enum can name, which is the guard at the source of the drift — that list is what goes on the wire whenever a caller does not pin capabilities.api_list_matches_provider_supportwas also tightened: its second loop usedif let Ok(..), so suites the enum could not name were skipped in silence, which is precisely how nine of them went unnoticed. The workflow comment that named a single test by hand is back to the template's wording, which refers to whichever tests a justification cites. -
openmls_basic_credentialnames the post-quantum feature explicitly (rust/Cargo.toml) — it was reaching the crate only throughopenmls/draft-ietf-mls-pq-ciphersuites, which propagates with?and so applies only whileopenmls/test-utilskeeps the optional dependency alive. Without itSignatureKeyPair::newhas no ML-DSA arms and the four ML-DSA ciphersuites cannot build an identity at all — a coupling that had to be broken beforetest-utilscould be dropped from the shipped binary, which this same release then does (see Security). Feature-only change on its own:Cargo.lockand the third-party notices are untouched by this bullet. -
A test harness no longer resolves into the release dependency graph (
rust/Cargo.toml,rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) — droppingopenmls/test-utils(see Security) takes fifteen crates out of the notices, 279 → 264:wasm-bindgen-testwith its macro and shared crates,minicov,openmls_test,async-trait,cast,itertools0.14,libm,nu-ansi-term,oorandom,same-file,walkdir,winapi-utilandwindows-sys. Nine of them leaveCargo.lockoutright; the rest stay resolved but unreachable.openmls_memory_storageis not among them — it is a plain dependency ofopenmls_rust_cryptoandopenmls_libcrux_crypto, not a test-only one. Neither isbacktrace, which flutter_rust_bridge'sallo-isolatedeclares independently of anything openmls does; what changed is that openmls's own feature of that name is off. -
The example app's post-quantum tab covers three backend paths instead of one (
example/lib/demos/post_quantum_demo.dart) — it now runs the full lifecycle on X-Wing (libcrux), on the ML-KEM-768 + X25519 suite with an ML-DSA-44 signature (RustCrypto, and the suite a routing regression breaks outright), and on a pure ML-KEM-768 suite, after the classical regression check. CI now builds the WebAssembly module and runs the crate's browser tests (see the template adoption above), but that suite is a single test overcurrent_time: no gate exercises a ciphersuite, the IndexedDB store or Web Crypto on wasm32. This tab remains the only runtime check of those, so it has to be re-run by hand after any change to ciphersuites or crypto routing, and it is now worth more when it is. -
The upstream-bump checklist records the feature-gate trap (
.claude/skills/update-openmls/SKILL.md) — a gated ciphersuite variant reports asE0599 ... no variant named XWingKemDraft6, which reads exactly like a removal and cost this bump a wrong diagnosis. The checklist now says to look for a new cargo feature first, and that it has to go on all five openmls crates rather than only onopenmls. A second block covers the opposite direction — which features must stay off in a shipped binary, and how to check the built artifacts for it — so a later bump cannot quietly restore the backtrace described under Security. -
make codegennow uses the pinned generator (Makefile) —FRB_CODEGEN_VERSIONpins the binary thatmake setup-frb-codegeninstalls, butcodegendid not depend on that target and ran whateverflutter_rust_bridge_codegenhappened to be onPATH. Regenerating with a different version rewrites the bindings and thecodegenVersionthey carry — the same drift the three pins exist to prevent, arriving through the one door they did not cover. Where CI already ran the two in sequence nothing changes: the prerequisite only reads--versionwhen the pinned binary is already installed. -
Adopted copier template v4.3.0 → v4.4.0 (
.copier-answers.yml,Makefile,hook/build.dart,scripts/src/check_template_updates.dart,scripts/src/update_template.dart,.github/workflows/check-template-updates.yml,.claude/skills/update-template/SKILL.md) —make update-templatenow runs copier with--skip-tasks. A singlecopier updaterenders the template three times and ran the generation tasks (flutter create,dart pub get,dart format .,rm -rf _templates) in every one of them, including the render into this project, where a task dying between the render and copier's replay of the project's own diff could leave a half-updated tree that still reported_commitas bumped. The update skill's conflict check moves fromfind -name '*.rej'— copier converts those to inline merges and unlinks them, so it always came back empty and read as "no conflicts" — togit status --porcelain | grep '^UU', and the claim that copier's conflicts only ever land in Markdown is corrected in the skill, in this workflow's comments and in the warning it writes into every conflicted pull request — a single real update has since left conflicts inMakefile,pubspec.yaml,rust/Cargo.toml,rust/src/frb_generated.rsand two Dart scripts.get-versionis now declared in.PHONY. -
Adopted copier template v4.4.0 → v4.5.0 (
.copier-answers.yml,.githooks/pre-commit,scripts/src/common.dart,scripts/src/check_template_updates.dart,scripts/src/check_updates.dart,test/scripts/common_test.dart,.github/workflows/check-template-updates.yml,.github/workflows/check-openmls-updates.yml,CONTRIBUTING.md,CLAUDE.md,README.md,.claude/skills/update-template/SKILL.md) — four changes, all of them to how this repository is worked on rather than to what it publishes.The pre-commit hook runs
make rust-checkonly when the commit touchesrust/. Measured here: the three gates together take about five seconds with warm caches, andcargo checkis the one that stops being five seconds — 1s warm against 76s from an emptyrust/target, whichmake cleanproduces outright and which a toolchain update or a dependency bump produce in effect. Most commits touch no Rust at all, including both commits that carried the previous template adoption. The predicate compares the index against HEAD underrust/, falls back to the empty tree on a first commit, fails open when git cannot answer, and announces the skip rather than performing it silently. A staged Rust file still blocks the commit when the crate does not compile, and CI runsmake rust-checkandmake rust-clippyon every push regardless.The update checkers authenticate to the GitHub API, and say what happened when it refuses. Both asked for public releases without a token, which is quota rather than access: anonymous requests are counted at 60/hour per source IP, and hosted runners share theirs — one scheduled run died on a bare
403for that reason. Both now sendGITHUB_TOKEN(orGH_TOKENlocally), the workflows pass the job's own read-only token, and a failure keeps GitHub's own message and thex-ratelimit-*headers instead of reporting the status code alone, which could not tell a spent quota from a missing repository.githubApiGetanddescribeGithubFailureare shared incommon.dartand covered by ten new tests.The template update workflow explains a pull request it could not open. When the App behind
APP_IDlacksWorkflows: Read & write, GitHub refuses the commit withResource not accessible by integrationonPOST /git/trees— naming neither the file nor the permission, and only after every blob has been created. A failure-only step now says so, including thatActionsis a different permission and that a granted permission does nothing until the installation accepts it.Documentation stopped promising a changelog generator that no longer ran. GitHub Models was in its retirement brownout, so the AI step failed on every run and labelled the pull request
changelog-needed; the setup instructions said otherwise in three places, and the update skill told whoever ran it to expect an entry.README.mdalso still described template updates as notification pull requests, which is what they were before the workflow started applying them. The v4.6.0 adoption below replaces the provider outright, so these notices are gone again in the same release. -
Adopted copier template v4.5.0 → v4.6.0 (
.copier-answers.yml, and the files listed below) — five changes arrive with it.The AI changelog works again, against a provider this repository names. GitHub Models was retired on 2026-07-30 and the step had failed on every run since.
AI_MODELSnow holds an orderedprovider/modellist and the first entry that has a key and answers wins, so the next provider change is a repository-variable edit rather than a template release. Anthropic, Google and OpenRouter are each called through their own API overdart:iorather than acurlsubprocess, because the HTTP status decides whether the next entry is tried and a subprocess would put the key in process arguments. The next entry is tried only when a model produced no answer — network failure, an auth/rate-limit/server status, a refusal, or a response cut off at the token limit — never on the content of an answer, and nothing is salvaged from a partial one: a missing field leaves the entry unwritten and the pull request labelledchangelog-needed, which is the path that already existed.This repository is not configured by the adoption itself. There is no default list, deliberately: with
AI_MODELSunset nothing is called, which is also how a project says "no AI here". Until the variable and a provider key are set, update pull requests keep arriving withchangelog-neededexactly as they do today.AI_MODELS_TOKENis now read nowhere and can be deleted.What the entry is judged against is now written down (
.github/agent-prompts/changelog-scope.md) — which crates this package binds, whatMlsEngineactually exposes, and which upstream areas it never touches, including that this package implements its own storage rather than usingopenmls_memory_storage. The prompt classifies every upstream change against that list, and an upstream change that cannot be tied to something named there is invisible to this package's users. The file is written once and never overwritten by a template update, so it is this repository's to keep current.The test suite no longer skips
update-openmls-*pull requests (.github/workflows/test.yml) — the bump whose entire payload is new native code was the only pull request merged without the suite, clippy,rust-test,cargo-deny, the MSRV check orverify-third-party-noticesrunning against it on any platform.make buildruns beforemake testin the reusable workflow and the build hook then findsrust/target/releasewithout downloading, which is what justified the skip and now removes the need for it. This had an immediate consequence: PR #15 (openmls 0.9.0) started running the suite and failed. That failure was real and had been invisible — but the cause recorded here when this entry was written was wrong, and is corrected rather than left standing:HpkeKemType::XWingKemDraft6and theMLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519ciphersuite were read as removed upstream, and neither was. Both still exist in 0.9.0 with unchanged spelling and the same 0x004D code point, behind the newdraft-ietf-mls-pq-ciphersuitesfeature. A feature-gated variant reports asE0599 ... no variant named X, which is indistinguishable from a removal without checking upstream — the misreading the bump checklist now warns about.make verify-frb-pins, and Dependabot onpubandcargo(Makefile,scripts/verify_frb_pins.dart,.github/dependabot.yml) — five files record the flutter_rust_bridge version and two of them are compared with==at runtime, so the new gate reads all five and rejects both a caret and the unpublishable bare form, with the reason. It reads every occurrence rather than the first, because the first is not always the one that counts: adependency_overridesentry replaces the dependency outright, a second pin in a[target.'cfg(…)']section resolves per target, and make takes a later=over an earlier?=. It runs besideverify-third-party-noticeson the Linux leg; five file reads, no build. Dependabot now watches the published constraints and the native crate's dependencies, ignoringflutter_rust_bridgein both because its version has to move in four places at once and a one-file pull request is wrong by construction. The upstream crates are ignored undercargofor a different reason: Dependabot's cargo updater does follow git refs, so without that it would open its own pull request for the same bump the update workflow exists to make — without codegen, the bindings tripwire, the CHANGELOG entry or the version badge.Also in this adoption:
lintscapped to one minor line becausemake analyze ARGS="--fatal-infos"turns any new info-level lint into a build failure andpubspec.lockis not committed (precautionary — 6.1.0 was measured against this repository with nothing to report);ffigen's floor raised to^20.1.1; protoc added to the template-update workflow's gates upstream, which this repository does not render since it needs no protobuf compiler; and the repair and review agents, both inert untilAGENT_ENGINEnames one. -
Dependabot no longer rewrites constraints it was told to leave alone (
.github/dependabot.yml) —pub's default versioning strategy iswiden, "extend only the upper bound to include the new version", and it applies that across the whole manifest rather than only to what it is updating. The first run here opened a pull request whose four updates wereffigen,lints,code_assetsandhooks— and which also rewroteflutter_rust_bridgefrom">=2.12.0 <2.12.1"to^2.12.0. That is the one constraint in this file that must not float: it is the exact regression that broke every consumer of the published package when flutter_rust_bridge 2.13.0 landed inside that caret.lints,ffigenandhookswere widened past bounds set on purpose as well.ignoreis no defence, and it is worth being precise about why: it stops Dependabot opening a pull request for a dependency, not editing that dependency's constraint while it edits the file for other reasons.flutter_rust_bridgewas ignored and rewritten anyway.versioning-strategy: increase-if-necessaryfixes it — a constraint that already admits the new version is left alone, so a dependency nothing is updating stays untouched.cargoneeds none of this: on the same run it changed exactly the one crate it was bumping and left every pin alone.make verify-frb-pinscaught the rewrite on all four platforms before it could merge — its first real encounter, and what it exists for. -
The
pubandcargogroups take minor and patch only (.github/dependabot.yml) — grouping a major with everything else blocks the rest: one unmergeable entry takes the whole pull request down.It separates more than its name suggests, and the first run after the change is the evidence. Dependabot's commit trailers report a 0.x bump as
version-update:semver-minor, but its grouping applies Cargo's own reading, where a 0.x minor is the breaking bump, and keeps them out of aminor+patchgroup anyway. The six-crate cargo group split into a group of two (logpatch,uuid1.x minor) plus one pull request each forrand,sha2,hkdfandaes-gcm-siv— the four that need a migration. Exactly the intended shape. -
dart-lang/setup-dart's problem matcher is switched off at the source (.github/actions/setup-fvm/action.yml,.github/dependabot.yml) — 1.8.0 added a problem matcher fordart analyzeand registers it with::add-matcher::dart-analyzer.json. The path resolves against the calling action's directory, and this repository calls setup-dart from inside its ownsetup-fvmcomposite action, so the runner looked under.github/actions/setup-fvm/, did not find it, and failed the job fourteen seconds in, before anything was built. It failed that way on all four platforms.The first response, within this same unreleased range, was a Dependabot
ignoreon the 1.8 line. That was the wrong tool twice over. The action is now pinned to 1.8.1 withproblem-matcher: 'false', which is the input the upstream issue points at, so the version is free to move again — and the ignore has been deleted, becauseversions: ["1.8.x"]never matched anything in the first place: Dependabot parses that string throughGem::Requirement, which has no wildcard expansion. It sat in the file looking like protection while the bot went on proposing the bump. The same reading applies to thehooksandcode_assetsmajors, whose ignores are rewritten fromversions: ["2.x"]toupdate-types: [version-update:semver-major]in the same pass.The bump was worth little here in any case: setup-dart exists in that action solely to provide a
dartbinary fordart pub global activate fvmon the next line, and everything that builds and tests this package comes from the FVM-pinned SDK.
2.0.1 - 2026-08-03 #
For Users #
✨ Highlights
- openmls_frb v2.0.1 — Rust FFI bindings
Security
- Ratchet trees and key packages now decode on the panic-free path —
joining from a Welcome or an external commit decodes the sender's
ratchetTreeBytes, and every add-member entry point decodes peer key packages. Both went through openmls'tls_deserialize_exact_bytes, whose hand-writtenDeserializeBytesimpls slice the input at the re-serialized length and index out of bounds when that exceeds the bytes actually consumed.RatchetTreeInandKeyPackageInreach those impls through theExtensionandUnmergedLeavesfields nested in their leaf and parent nodes — the same shape behind theMlsMessageInparsing fix in 2.0.0, which covered MLS messages but not these two types. They now use the sameRead-based decoder. Defense in depth: no input is known that reaches the panic through these types. Both decoders run the same validation and differ only in the cursor arithmetic that follows, so on anything a conforming implementation emits they agree exactly. They can diverge only where that arithmetic was already wrong: openmls does not require an extension's payload to be fully consumed, so a payload carrying trailing bytes left the old path resuming from the wrong offset. Such input now parses correctly instead of being misread, and still has to pass the usual key-package and leaf-node validation afterwards.
For Contributors #
Fixed
-
Template update notifications had stopped working, silently and invisibly (
.github/workflows/check-template-updates.yml) — the workflow opened a pull request whose payload was its description: the version table, the template's changelog for the range, and manual update instructions. Its diff was meant to be empty. Butcreate-pull-requestopens nothing when there is no diff and exits silently — stated in its README and guarded byif (result.hasDiffWithBase)in the SHA this project pins — so those pull requests only ever existed because something made the tree dirty. That was.fvmrc, rewritten byfvm installon every run: every notification ever opened here (#1, #7, #8, #10) carried exactly one file,.fvmrc, at +3/-3, and nothing else. Fixing that drift in template v4.2.0 removed the accidental payload, so the very next template release would have produced a green job, a step summary reading "a notification PR has been created", and no pull request — detectable only as the absence of something nobody was watching for, which is the same shape that hid the FVM cache bug for months. The workflow now has a real payload, and the absence is checked rather than assumed: a run that found an update and did not open a pull request fails and says not to read it as "nothing to do". -
A mistyped signing passphrase no longer aborts a release (
scripts/src/release_common.dart,scripts/src/release.dart,scripts/src/release_frb.dart) — git signs a commit or a tag by shelling out tossh-keygen -Y sign(orgpg), and both give up after a single wrong passphrase rather than re-prompting. One typo therefore aborted the release wherever it happened, and the position that hurts is between the commit and the tag: the version bump is committed, no tag exists, and re-running the command fails its own "must be greater than the current version" precondition — leaving reverting the commit or tagging and pushing by hand as the only ways out. Both stages now route every signing and push step throughrunInheritRetry, which prints the failure and runs the step again, so the passphrase prompt simply comes back the waysshandsudobehave — no question to answer. Ctrl-C is the way out, and it works: withinheritStdiothe interrupt reaches the whole foreground group, verified at the passphrase prompt itself. The loop is uncapped, because a cap would reinstate the very failure it exists to prevent, so the two things bounding it carry the weight. A non-interactive stdin throws on the first failure, CI behaviour unchanged: nobody is there to retype anything or to interrupt, so a structurally broken step would otherwise spin forever. That test isstdin.echoModeand notstdin.hasTerminal, which reportsterminalfor any character device and so calls a run redirected from/dev/nullinteractive. From the third consecutive failure the loop paces itself at 2s and says so; the first retries stay immediate, so a typo is never slowed, while a step failing in milliseconds cannot scroll past faster than it can be read. -
An interrupted release is resumed by re-running the same command (
scripts/src/release_common.dart,scripts/src/release.dart,scripts/src/release_frb.dart) — the retry above covers a typo, but not a Ctrl-C or a closed terminal, both of which strand the release in the same half-finished state. Both stages now recognise it and continue from the tag (or push) step, skipping the bump and the CHANGELOG edit so nothing is applied twice. Because a false positive would tag and push a commit that is not the release commit, detection requires all of: a clean working tree, the version file already reading exactly the requested version, andHEAD's subject equal to the exact subject the release writes — built from the same expression that builds the commit message, so the two cannot drift. A leftover tag is accepted only when it is this release's tag and points atHEAD; the same name on any other commit is refused, as is a tag already on origin. Declining the confirmation prompt on a fresh run still reverts the edits; on a resumed run it leaves the commit in place and says so. Interrupting before the commit is the one case nothing can report at the time — Ctrl-C kills the script mid-step — so the next run's "working tree is not clean" recognises when the only modified paths are the release's own files and names the singlegit restorethat discards them.
Added
-
Template updates are applied automatically, not just announced (
scripts/update_template.dart,scripts/src/update_template.dart,make update-template,.github/workflows/check-template-updates.yml) — the scheduled check now runscopier updateitself and opens a pull request carrying the result, the way the dependency update workflow already does. Everything it needs comes from.copier-answers.yml, so the new scripts name neither this project nor its upstream library and can move into the template unchanged. Copier is pinned (9.11.1) for the reason the actions are pinned by SHA: this runs unattended, and a release that changed how copier merges would arrive as a conflict-shaped diff rather than a clean failure. Two outcomes are reported separately, because they are independent and both are quiet. A conflict leaves both sides in the file; the pull request becomes a draft, lists the files and says why nothing else caught it — the format, Rust and analysis gates read only Dart and Rust, and every conflict copier has produced in this project so far has been in Markdown, which passes all three intact._commitfailing to land is the other: copier can apply every file and still leave.copier-answers.ymlon the old version, which merges as an un-updated project and re-opens the same pull request forever. That one fails the job — after the pull request exists, so the work is kept. They do not imply each other: this release's own update landed_commitwhileCONTRIBUTING.mdwas still conflicted. The three checks the pre-commit hook runs are executed and reported in the body, never enforced — a template update that breaks a gate is precisely the one a human most needs to see. The CHANGELOG entry is written by AI from the template's changelog and the diff that actually landed, since a template release describes changes for every project generated from it and most of it can arrive here as a no-op. It is filed under### For Contributors→#### Changed, where every prior adoption lives, and the pull request asks a reviewer to move it if the release changes shipped behaviour. One dependency is worth naming: copier refuses to update a dirty destination, untracked files included, so the.fvmrcdrift fix is not merely related to this automation — without it,fvm installwould leave the tree dirty and every automated update would refuse to run. -
test/scripts/update_template_test.dart— covers the two pure decisions the automation makes.parseUnmergedPathscollapses the three conflict stagesgit ls-files -uprints into one path and keeps paths containing spaces intact;hasConflictMarkersis anchored at line start so this repository's own documentation of how to grep for conflicts does not register as one.insertContributorChangelogEntryis covered over every shape the file can be in: an existing subsection, a missing one,#### Changed (Breaking)which must never receive the entry, a missing### For Contributors, a missing## [Unreleased]— and, in every case, that nothing is written into the released section above. -
test/scripts/release_common_test.dart— coversisResumableRelease, the one predicate in the release scripts whose false positive is unrecoverable, over each condition that must individually block a resume; andonlyTheseFilesDirty, which decides whether the not-clean message may name agit restore— it declines on an untracked path and on a rename rather than suggest a command that would not work, or would discard something else. -
wire_decodefuzz target — fuzzes the decoder the group-join and add-member APIs use, overMlsMessageIn,RatchetTreeInandKeyPackageIn. The ratchet-tree and key-package paths were not covered by any target before.
Changed
-
copier template adopted: v4.2.0 → v4.3.0 — a single change, and it repairs something 4.2.0 shipped broken. Interrupting a release before its commit leaves only the release's own files modified, and 4.2.0 added a message that recognises that state and names the one
git restorewhich discards them. It never fired. The status was read throughgit(), which trims its output;git status --porcelainhas two positional status columns, so an unstaged modification is' M path', and trimming ate the leading space of the first line and shifted that path by one character.onlyTheseFilesDirtythen matched nothing and rejected the whole status, so every interrupted release got the generic "working tree is not clean" instead — in exactly the case the hint was written for, since a release edits its files without staging them. Both stages now read the status through agitStatus()that strips only trailing newlines, and a test pins the raw and trimmed shapes against each other so a future trim cannot pass unnoticed. Found while writing a release script for the template repository itself, which had inherited the same shape. This is also the first update applied by the automation added above rather than by hand. -
The panic-free decoder moved out of
api/engine.rs—from_exact_bytesnow lives inrust/src/wire_decode.rsand is generic over the decoded type, so one helper covers all four call-site types and the fuzz crate can drive the real decoder. The alternative, givingrust/fuzzits ownopenmlsdependency, would have left a second upstream tag thatmake check-new-openmls-versiondoes not bump. -
copier template adopted: v4.1.0 → v4.2.0 — the release-script half of this version was written here and upstreamed, so it arrives byte-identical and lands as a no-op; it is the retry and resume work documented above. What the adoption actually changes is the development environment. The pre-commit hook is executable at last — it was committed mode 644, and git skips a non-executable hook without saying anything, so nothing it checks ever ran. It is 755 now, and copier carries the bit through on an update, not only on a fresh render. Its content carried a second failure that could only surface once it started running: it announced every step-1 failure as a formatting problem, so a hook invoked from an IDE or GUI git client — which inherits a minimal PATH where
make,fvmandcargoare all missing — told you to runmake formatwhen the real problem was PATH. It now appends the usual install locations and names the missing tool instead of blaming whichever check ran first..fvmrcand.vscode/settings.jsonno longer drift on everymake codegen—flutter_rust_bridge_codegenshells out tofvm installtwice per run, andfvm installrewrites both files unless they already match its own output byte for byte, so every codegen left two modified files unrelated to the generated bindings, and in CI they rode along into the automated update PRs..fvmrcis now committed in fvm's own serialization — its key order and no trailing newline — with"updateVscodeSettings": false, which is what stops the second file being touched;.gitattributesmarks it-text, because a Windows checkout under the defaultcore.autocrlf=truewould otherwise land CRLF and break the byte-match invariant while git still reported the tree clean; and.vscode/settings.jsonis now committed rather than generated, since with fvm no longer writing it a machine that lacks the privileges for fvm's own symlink would leavedart.flutterSdkPathunset. It points at.fvm/flutter_sdk, the version-agnostic symlink, so a Flutter bump does not need to edit it. Verified here:fvm installnow leaves both files byte-identical.setup_repo_protections.dartalso setsdelete_branch_on_merge— the script applied rulesets and thenative-buildenvironment but never touched repo settings, so every merged branch stayed forever, and the dependency and template update workflows open one per upstream version several times a week. Adopting the script does not change the setting; it takes effect the next timemake setup-repo-protectionsruns against GitHub.
2.0.0 - 2026-07-30 #
For Users #
✨ Highlights
- Concurrent work on one group no longer loses writes — every engine operation runs under an engine-wide lock, and a database file admits a single engine at a time (breaking). Overlapping calls used to load the same snapshot and the later write-back dropped the other's changes: a merged commit, an epoch advance or a ratchet step could disappear, desynchronizing the group and leaving messages undecryptable.
- The storage layer stops leaving MLS plaintext behind — undefined behavior
removed from the snapshot provider, SQLCipher wipes its own working buffers,
and the copies the wrapper itself kept (the write-back diff, the hex
encryption key, overwritten and deleted entries) are zeroized. Database files
are created owner-only and
deleteGroupis atomic. - The package ships
THIRD_PARTY_NOTICES.txt— the licence texts the statically linked native library must carry with it, generated from the resolved dependency graph across all released targets and verified byte-for-byte in CI. - openmls — unchanged this release (openmls-v0.8.1)
- openmls_frb v2.0.0 — Rust FFI bindings
Changed (Breaking)
- Only one engine may hold a database file — the SQLCipher connection now
takes an exclusive lock at open. A second
MlsEngineon the same path — another instance, isolate, or process — fails with "Database is already open by another connection or process" instead of quietly running its own load → operate → save cycle over the same rows and overwriting the first engine's group state.close()releases the lock, and an overlapping opener waits out a five-second timeout first, so handing the file over during teardown still works. On Unix the lock is held partly by a new file next to the database,<db_path>.lock, because SQLite's own locks are POSIX advisory locks and POSIX drops every one a process holds on a file as soon as that process closes any descriptor for it — so one unrelated read of the database from elsewhere in an app (a backup copy, an integrity check, a crash reporter) silently released the exclusive lock while the engine was still running, with no error, letting another process in. The lock file is created empty and0600and is never deleted; it holds no data, so it needs no special handling in backups, but deleting it while an engine is running lets a second engine open the same database.":memory:"databases and Windows do not get one. Action required — only if your app opens the MLS database from more than one place (a background isolate, a share extension, a second engine instance): route them through a single engine, or give each its own file. One engine kept open for the lifetime of the app needs no changes and no lifecycle handling.close()is what hands the file from one engine to the next; a hot restart during development or a killed process releases the lock on its own, so the engine that follows opens without waiting out that timeout. - A
file:URI asdbPathis now rejected —create()fails instead of opening a database that silently gets neither owner-only permissions nor the single-writer lock file above, because the path a URI resolves to cannot be recovered without parsing its query parameters. Action required — only if you pass afile:URI: pass the plain path instead. Plain paths and":memory:"are unaffected.
Changed
-
The package ships
THIRD_PARTY_NOTICES.txt— the prebuilt native library is statically linked against its Rust dependency tree, and MIT, BSD and Apache-2.0 all require those notices to travel with a binary distribution, including an application that embeds the library. Flutter'sLicenseRegistrydoes not cover them: it aggregatesLICENSEfiles of pub packages, and Rust crates are not pub packages. The file sits at the package root and inside every native release archive, and is generated from the resolved dependency graph across all released targets — build edges included, because that is how vendored native code reaches the binary: the bundled OpenSSL arrives as a build-dependency ofopenssl-sysand would otherwise go unattributed (264 crates, 152 shipped licence texts). Licences a crate keeps beside vendored code are collected too — SQLCipher's, the Dart SDK headers', the Unicode tables' — as are those a git dependency keeps at its repository root rather than in the member directory, which is where every upstream MLS crate keeps its own. Where a crate ships no licence file at all, the canonical text of the licence it declares is supplied in its place, so the file delivers the licences rather than merely naming them. It is deliberately not declared underflutter: assets:— a package-declared asset is bundled into every consuming application whether or not it is used. README documents the two lines needed to surface the notices at runtime for apps that want them. -
Durability settings are explicit — connections are opened with
journal_mode = DELETE, verified, so a database left in WAL mode is converted instead of silently keeping a side file that a crash or a file-level backup can drop, and withsynchronous = FULL.fullfsyncis deliberately left off: on Apple platforms it also flushes the drive's own write cache, measured at 16 ms per MLS operation against 318 µs without it — paid on every message sent and received. SECURITY.md records the trade-off.
Security
- Serialized concurrent operations on an engine — every engine method loads
a snapshot of the stored group state, lets OpenMLS mutate it, then writes the
diff back, but nothing held that span together: two overlapping calls loaded
the same base snapshot and the later write-back dropped the other's changes.
A merged commit, an epoch advance, a stored proposal or a ratchet step could
silently disappear, desynchronizing the group and leaving messages
undecryptable. Each operation now runs under an engine-wide async lock —
async because the span contains
.awaitpoints, and because the same interleaving happens on WASM's single thread with no threads involved at all. This was a lost update, not key reuse: MLS gives every message a fresh randomreuse_guard, so two sends off one snapshot still got different nonces. - SQLCipher now wipes its own memory — connections set
cipher_memory_security = ON, verified on open, so SQLCipher zeroes its working buffers when freeing them and asks the OS to keep them out of swap. Those buffers hold MLS plaintext while it is being encrypted — the residue the wrapper's own zeroization cannot reach. Measured cost: +20% per operation (~62 µs). - Wiped the storage layer's remaining plaintext copies — the diff handed to
the database cloned every changed value out of the snapshot and dropped those
clones unwiped; they are zeroized once written. The encryption key's hex form
was built with a
format!per byte, leaving 32 small allocations of key material behind; it is now built into a singleZeroizingbuffer and wiped as soon as the key pragma has run. deleteGroupis atomic — it wrote the group's final state and then purged the group's rows in two transactions; a crash in between left rows of a deleted group behind. Both happen in one transaction now, on native and on WASM.- Database files are created owner-only — on Unix the file is pre-created
with mode
0600rather than inheriting the process umask (typically world-readable0644on desktops), and an existing file is tightened on open. SQLite gives the journal file the same mode. - Documented the anti-rollback requirement — encryption at rest does not
protect freshness: restoring an older copy of the database replays MLS state
that was already spent, and the 32-bit
reuse_guardthat makes a single rollback merely a rejected message erodes across a large or repeated one. SECURITY.md now asks deployments to place the database on rollback-protected storage (hardware monotonic counter, TPM 2.0 NV, Android StrongBox), notes that sealing the key in hardware protects confidentiality rather than freshness, and records that iOS exposes no such counter to apps. - Removed undefined behavior from the snapshot storage provider — the
StorageProviderimplementation reached its snapshot through 35&self→&mut selfpointer casts guarded by#[allow(invalid_reference_casting)]. That cast is undefined behavior regardless of threading:&selfcarries LLVM'snoalias, so a release build (lto = true,opt-level = "z") is entitled to cache or reorder reads across those writes. Replaced with proper interior mutability (parking_lot::Mutex), which lets the module drop its#![allow(unsafe_code)]escape hatch — the crate now deniesunsafe_codeeverywhere except the generated FRB bridge. - Zeroize storage values on overwrite and delete — replacing or removing a snapshot entry previously dropped the old value without wiping it, leaving plaintext MLS secrets in freed heap memory for the rest of the operation.
Fixed
- Stopped leaking snapshot allocations on every MLS operation —
into_updates()ended withstd::mem::forget(self), so bothHashMapbacking tables were never freed. Theforgetwas unnecessary: after both maps are drained, theDropimpl is a no-op. - Build hook no longer re-runs on every build — the hook declared the
.skip_openmls_hookmarker as a build dependency unconditionally, including when the marker does not exist (the normal case for every consumer).hooks_runnertreats a declared-but-missing file as modified during the build, forcing a redundant second hook pass on each build. The marker is now declared only while it exists, which still invalidates the skipped result once the marker is removed.
For Contributors #
Fixed
- Repaired the scheduled openmls update check — the upstream tag guard
adopted with copier template v3.0.0 hardcoded
^v?\d+\.\d+\.\d+$, which rejects this repo's ownopenmls-vtag prefix, so every run failed withRefusing unexpected upstream tag_name format. The workflow's blanket|| truehid that behind a green run. The pattern is now derived from the configured tag prefix. No upstream release was actually missed —openmls-v0.8.1is still the latest — but the next one would have been. - The CI Flutter cache never saved anything —
setup-fvmcached~/.fvm, but fvm keeps installed SDKs in~/fvm/versions;~/.fvmis the per-project directory it symlinks inside a checkout, not the global cache. That path exists on no runner, and a missing path is not an error toactions/cache— it warns in the post step and reports success — so every job on all four platforms reinstalled the SDK from scratch (fvm installmeasured at 71 s on Linux, inside a 163 s setup step on Windows) while the step stayed green and the repository held nofvm-*cache entry at all. The key now also carriesrunner.arch, because Linux x86_64 and Linux ARM64 both reportrunner.os == 'Linux'and were producing one byte-identical key: with saving repaired but the key unchanged, one leg would have restored the other architecture'sbin/cache/dart-sdk, which Flutter keeps rather than redownloads — its revision stamp matches — and then fails to execute.restore-keysis gone, since a near-miss restored the previous SDK and then installed the new one beside it, growing the entry by a full SDK on every Flutter bump. Three changes keep it from drifting again. fvm itself is pinned (dart pub global activate fvm 4.1.2instead of whatever is latest that day), since this action hardcodes where fvm stores SDKs and an unannounced major that relocated them would break every job at once.FVM_CACHE_PATHis set explicitly rather than inherited, so the cached path is a contract instead of a guess. And a step afterfvm installasserts the directory is populated — a failing job pointing at the action, rather than another silent warning; annotate-and-continue is precisely the mode that hid this for months, and nothing irreversible sits behind the check, which runs before the release archives, the tag and the pub.flutter-io.cn publish. It also prints the SDK size (2.5 GB per version uncompressed), because the 10 GB repository cache limit is shared with the Rust caches and evicted LRU across all of them. - CI was blind to changes in its own workflows and actions — the path
filters named
test.ymlandtest-reusable.ymlbut not.github/actions/**, so a PR touching a composite action ran no tests at all, and the job additionally skipped every PR opened by a bot. Dependabot's grouped action bumps were therefore merged unverified — run30289222353completed asskippedin one second — which is precisely the class of PR that changes what CI executes. The filters now carry.github/**on both push and pull_request, and the skip is narrowed toupdate-openmls-*branches, the update PRs that movenative_versionahead of the released binaries. Apull_requestrun resolves reusable workflows and composite actions from the merge ref, so the PR's own versions are what execute. - A native-update entry lands at the top of
[Unreleased], not below### For Contributors—insertChangelogEntrycreated its### For Usersblock at the point where the[Unreleased]section ends, so whenever the accumulated changes were CI or tooling only — the section then holds### For Contributorsand nothing else, which is its normal shape between feature work — the user-facing highlight was filed underneath them, the reverse of the order every released section uses. It also emitted a second### For Usersheading when the section already had one that ran to the end of[Unreleased]with no#### ✨ Highlights/#### Changedunder it. The insertion point is now the top of the section, and an existing### For Usersis extended rather than duplicated. - The notice inventory no longer depends on the machine that generated it —
cargo tree --target <triple>filters normal dependencies by that triple but resolves build-dependencies for the host, so the inventory recorded the build graph of whoever ran the generator. It surfaced in libsignal_dart, whereprost-build→tempfile→rustixpickserrnoon a macOS host andlinux-raw-syson a Linux one: one crate swapped for the other, the crate count unchanged, and CI rejected a file that was correct on the machine that wrote it. This package was not affected between macOS and Linux, but it is the same latent bug — a host-independent sweep records crates no macOS or Linux run ever saw, among themwinapi, which reaches the graph throughansi_terminside a proc-macro crate. Proc-macro subtrees are host-compiled just like build scripts, so the host dependence is not confined to build edges and no per-target query escapes it. The crate set is therefore taken fromcargo tree --target all, the only query cargo offers that applies no platform filtering at all; the per-target sweep is kept because it is the one thing that fails when a declared release target stops resolving. The result over-attributes deliberately: the extra entries are build tooling and platform-gated crates a given build never links —winapihere arrives only through a host-compiled proc-macro — but a file that lists them on every machine is worth more than a narrower one that changes with the machine, since the byte-exact CI check is only viable if the output is reproducible. The inventory grows from 237 to 264 crates.--checkalso prints the first differing line and the lines unique to each side now: the failure it reports is normally read from a CI log, and "the contents differ" left the reader to bisect a 400 KB file by hand make check-new-openmls-version ARGS="--update"now moves theopenmls_libcrux_cryptopin — the tag-rewrite list in the generated checker is built from theupstream_cratestemplate answer, and that answer namedopenmls_memory_storage, a crate this package has never depended on (upstream dropped it along with the blob-based storage API), while omittingopenmls_libcrux_crypto, which the experimental X-Wing suite does depend on. Its rewrite pattern therefore matched nothing and libcrux kept its old tag: the next upstream bump would have pinned four MLS crates to the new tag and one to the previous one. That resolves rather than fails — cargo will build two revisions of the same git repository side by side — so it would have surfaced as an X-Wing-only breakage or a silently doubled dependency tree rather than as a build error. Corrected in.copier-answers.ymlrather than in the generated file, so the nextcopier updatekeeps it.
Added
- CI verifies the declared MSRV —
rust-version = "1.89"inrust/Cargo.tomlis a promise to anyone building the native library from source, and nothing checked it: the first dependency or language feature to raise the real floor would have broken that build silently, with the failure landing on a contributor instead of here. A newmsrvjob reads the version out of the manifest — rather than repeating it, so the job cannot drift from the claim it checks — installs exactly that toolchain and runsmake rust-check. Verified locally against 1.89.0 before the job was added; the reusablesetup-rustaction gained atoolchaininput (defaultstable) to make it possible. make rust-testand a CI step that runs it — the crate's unit tests were never executed in CI, includingclassical_ops_do_not_init_libcrux, which several advisory ignores in.cargo/audit.tomlandrust/deny.tomlcite as their justification.- Third-party notice generator (
make third-party-notices,make verify-third-party-notices) — unionscargo tree --locked --edges normal,buildacross all twelve released targets, resolves each crate's SPDX expression and licence texts viacargo metadata, and pools identical texts by reference (the Apache-2.0 text alone appears in over a hundred crates; pooling takes the file from 1.9 MB to under 500 KB).--lockedis what keeps the output machine-independent: without it a staleCargo.locklets cargo silently re-resolve the graph, so the same commit could generate different inventories. Output is otherwise deterministic — crates sorted, texts sorted, no timestamps — so CI can diff it byte-for-byte and fail when a dependency change leaves the committed file stale. The check also runs inbuild-openmls.ymlbefore the build matrix starts, since a hand-pushed tag skips the release script's own gate and the archives it produces embed the file. - Regression tests for upstream tag validation —
test/scripts/check_updates_test.dartcovers the configured prefix, shell metacharacters, newline injection (including a bare trailing newline), path traversal and non-canonical version segments. - Storage hardening tests —
test/concurrency_test.dartdrives overlapping calls on one engine (they fail against the pre-fix build with a consumed ratchet secret and a dropped proposal),test/security/encrypted_db_test.dartcovers encryption at rest, wrong-key fail-closed and the single-writer refusal, andencrypted_db.rsgained unit tests pinning the raw-key pragma shape, the connection pragmas that must be in force, and the0600file mode.
Changed
- Minimum supported Rust version is now 1.89 (was 1.88) —
std::fs::File::try_lock, which stabilised there, holds the single-writer lock file. The alternative,libc::flock, is anunsafe fn, and the crate denies unsafe code outside the two modules that cannot avoid it. This affects only building from source: the published package downloads a prebuilt native library, so nothing changes for an app that consumes it. - Update workflows detect a crashed checker by what it wrote, not by its exit
code — the checkers exit 0 when up to date, 1 when an update is available
and 2 on failure, but the workflows ran them under
|| true, making a crashed checker indistinguishable from "no updates available". Discriminating on the exit code cannot work here, and an earlier revision of this change assumed it could: the checkers are invoked throughmake, and GNU make collapses any non-zero recipe status into its own exit 2 (verified: a recipe exiting 1 makesmakeexit 2), so anexit_code > 1guard fires on the ordinary "update available" path and would have failed both workflows on exactly the event they exist to serve — no update PR and no template notification would ever be opened again. It stayed green only because no update came up while it was in place. The gate is the artefact instead: the checker writesneeds_update=to its outputs file before signalling, and writes nothing at all when it throws, so a missingneeds_update=line means it failed, and the step fails with an::error::that tells the reader not to interpret it as "up to date". Manualtarget_versioninput is also validated in the workflow shell, before it is interpolated intoARGS. - Test workflow now triggers on
scripts/,hook/andMakefilechanges — edits to the build hook, tooling scripts and the Makefile previously ran no tests at all. Workflow-file paths are now also watched on pull requests, not only on pushes.THIRD_PARTY_NOTICES.txtand.gitattributesare watched too:make verify-third-party-noticesruns in this workflow, so the notices file is the artefact being checked and.gitattributesdecides which bytes a checkout materialises for it. Without them the one commit that can break — or fix — that check was also the one commit that did not run it, letting a stale inventory reachmainunverified and surface only in the release preflight. - GitHub Actions moved to their current majors —
actions/checkoutv4→v7,actions/upload-artifactv4→v7,actions/download-artifactv4→v8,actions/cachev4→v6,actions/create-github-app-tokenv2→v3,android-actions/setup-androidv3.2.2→v4.0.1 andschneegans/dynamic-badges-actionv1.7.0→v1.9.0. Mostly the Node 20→24 runtime migration, which needs no change on GitHub-hosted runners. Two are worth knowing about:download-artifactv8 now fails a run on an artifact digest mismatch instead of logging a warning, which is a welcome hardening of the job that packages the native archives consumers download; andcheckoutv7 refuses to check out a fork PR underpull_request_target/workflow_run, which does not affect this repo because no checkout passes an explicitref. - Dependabot branches are exempt from the branch rulesets —
Signing commitappliesnon_fast_forwardto~ALLbranches with no bypass actors, so Dependabot, which refreshes an open PR by force-pushing a rewritten commit, could never rebase one onto a movedmain; its first scheduled run gave up with "because the branch … is protected it was unable to do so", leaving the PR frozen at the day it was opened.refs/heads/dependabot/**/*is now excluded from that ruleset and fromDelete branches(which blocked@dependabot recreateand branch cleanup for the same reason). Nothing is weakened: Dependabot signs its commits regardless of the rule, andmainkeeps both its pull-request gate andrequired_signatures. The trailing/*is load-bearing — these arefnmatchpatterns in pathname mode, so a bare**stops at the first/and would miss the two- and three-segment names Dependabot actually generates. - copier template adopted: v3.0.3 → v4.0.0 — the major carries a single
contract change, that every project generate and commit
THIRD_PARTY_NOTICES.txtbefore its next CI run, and this package already satisfies it: the notice tooling was written here and upstreamed into the template, so it arrives byte-identical and most of the release lands as a no-op. What does change:validateUpstreamTagnames the input it rejected, because an APItag_name, a--versionargument and the pin recorded inrust/Cargo.tomlfail for different reasons and want different fixes;insertChangelogEntrymatches#### Changedexactly, where a prefix match previously filed the native-library bump under#### Changed (Breaking)as well, and it creates a missing subsection in the documented order rather than at the end of the block; the fuzz workflow reads its targets from the[[bin]]entries ofrust/fuzz/Cargo.tomland fans them out one job per target, so a crash in one target no longer skips the rest; the build hook declares a local native build as a dependency, somake cleanno longer leavesdart testpointed at a cached asset that is gone; and the LICENSE copyright year is a stored answer (copyright_year: 2026) instead of the year the file happens to be rendered in, so the notice keeps naming the year of first publication. Two parts of v4.0.0 are deliberately not adopted. Thefreezed_annotation/freezed/build_runnerdependencies exist so that a freshly generated project's first codegen succeeds against an unknown API surface; this package's FRB surface has no data-carrying enums — no generatedsealed class, no@freezed— andfreezed_annotationsits independencies, so every consumer would download a package that nothing here imports. And thefrb-patternsskill's new sections on write durability and non-failableDartFncallbacks describe the callback-storage architecture this package left behind: it has no Dart callbacks at all, storage is Rust-owned inSnapshotStorageProvideroverEncryptedDb, so adopting them would document a pattern that does not exist here.
1.4.2 - 2026-07-21 #
For Users #
✨ Highlights
- openmls — unchanged this release (openmls-v0.8.1)
- openmls_frb v1.5.2 — Rust FFI bindings
Security
- Hardened MLS message parsing against malformed input — incoming MLS
messages (
mlsMessageExtractGroupId/mlsMessageExtractEpoch/mlsMessageContentType, plus Welcome / GroupInfo / process-message decoding) are now decoded via theRead-based path and reject trailing bytes explicitly, so a malformed message returns an error instead of aborting the process. Reported upstream; this local guard will be removed once we depend on a fixed openmls release. - Triaged new libcrux advisories in the X-Wing PQ dependency tree
(RUSTSEC-2026-0207/-0208/-0209/-0210/-0211/-0212) — these advisories were
published against libcrux crates that reach our tree only transitively via the
experimental X-Wing ciphersuite (pinned by openmls-v0.8.1, so not fixable via
cargo update). Five are structurally unreachable (the SHA3 ones explicitly exclude ML-KEM; the AES-GCM ones are dead code — the only X-Wing suite is ChaCha20Poly1305); the sixth (-0212, libcrux-secrets constant-time swap on aarch64) is an accepted availability-only risk (CVSSVC:N/VI:N/VA:H— a wrong ML-KEM result makes an X-Wing operation fail, never a key leak). Per-advisory reachability analysis is documented inline in.cargo/audit.toml/rust/deny.toml; all clear on the next upstream OpenMLS bump. Classical (non-PQ) ciphersuites are unaffected.
Fixed
- Web build hook now refreshes stale WASM on upgrade — the web build hook
records the provisioned crate version in
web/pkg/.wasm-versionand re-downloads when it changes, instead of skipping whenever the two WASM files merely exist. Previously, upgrading the package kept the prior version's WASM in the app'sweb/pkg/(it survivesflutter clean), so on web any FRB entry calling Dart store callbacks could panic with an argument-count mismatch (called Option::unwrap() on a None value) once the wire signature changed between versions. The download cache is now version-keyed (web/<version>/), WASM files are copied unconditionally (the old mtime guard skipped a fresh-but-older source on downgrade), andrust/Cargo.tomlis a declared web-build dependency so a version bump re-runs the hook. Native platforms were unaffected.
For Contributors #
Changed
- Adopt copier template v2.5.1 → v2.5.2 — source of the web build hook fix above.
- Adopt copier template v2.5.2 → v3.0.3 — release-process and dev-tooling
changes only; no change to the published package's runtime behavior.
- Two-stage release flow (v3.0.0) — the native
openmls_frbcrate and the Dart package now release independently:make release-frbbumps, tags (openmls_frb-X.Y.Z) and builds the native binary, thenmake releaseverifies that binary exists and publishes the Dart package (vX.Y.Z). Addsscripts/release.dart/scripts/release_frb.dartand therelease-frb-crateskill; the flow is documented in CLAUDE.md. - Repository protections (v3.0.0) — GitHub rulesets (
.github/rulesets/) restricting who may pushmainand create release tags, a signed-commit rule, asetup_repo_protections.darthelper, and a Dependabot config. - Removed vestigial Windows Flutter-plugin scaffolding (v3.0.0) —
windows/CMakeLists.txtand the generated plugin registrant; this is a pure-Dart FRB package (native libraries load via the build hook), so the scaffolding was unused. - Release-tooling fixes (v3.0.1 → v3.0.3) — the pub.flutter-io.cn dry-run now runs on
the clean pre-bump tree (a bumped-but-uncommitted tree tripped
pub publish --dry-run's exit-65-on-warning), andmake releaseno longer leaves an empty## [Unreleased]heading behind.
- Two-stage release flow (v3.0.0) — the native
1.4.1 - 2026-07-14 #
For Users #
Highlights
- Hardened release binary & fail-closed supply chain — the shipped native
library is now compiled with
overflow-checksandunsafe_code = "deny", and the download hook refuses to load a binary whose SHA256 checksum cannot be verified. - openmls — unchanged this release (openmls-v0.8.1)
- openmls_frb v1.5.0 → v1.5.1 — Rust FFI bindings (release binary rebuilt
with
overflow-checks; no API or behavior change in normal use)
Security
- Hardened release binary — the wrapper crate is now compiled with
overflow-checks(integer overflow panics instead of wrapping silently) andunsafe_code = "deny"on all hand-written Rust. The few modules that legitimately needunsafe(the interior-mutability storage shim and the WASMWasmCryptoKeySend + Syncimpl) opt in explicitly; the FRB-generated bridge is exempt. - Fail-closed download verification — the native-library build hook now
aborts if the SHA256 checksums cannot be fetched or lack an entry for the
archive, instead of loading an unverified binary. An
OPENMLS_ALLOW_UNVERIFIED_DOWNLOAD=1escape hatch is provided for older releases published without a checksums file.
For Contributors #
Added
- cargo-deny (
rust/deny.toml,make rust-deny, CIdenyjob) — enforces RustSec advisories, an allowed-license list, and a source allow-list. Remediated RUSTSEC-2026-0204 (crossbeam-epoch0.9.18 → 0.9.20); six unremediable/inapplicable advisories are ignored with inline justifications (the three libcrux crypto advisories mirror.cargo/audit.toml). - cargo-fuzz harness (
rust/fuzz/,Fuzzworkflow,make fuzz*) with two targets over untrusted wire bytes —mls_message(MLS protocol-message parsers) andcredential(MlsCredential::deserialize) — plus a seed-corpus generator (make fuzz-seed). - Rust clippy in CI (
make rust-clippy,-D warnings) and a pinned FRB codegen installer (make setup-frb-codegen) so CI and local codegen produce identical bindings. - Download-cache tests (
test/hook/build_hook_test.dart).
Changed
- Adopt copier template v2.4.0 → v2.5.1
- Fixed the download cache key (crate version + full platform variant) so iOS device and simulator builds no longer poison each other's cache on Apple-silicon hosts
- Update scripts:
check_updates.dart --updatenow bumps the wrapper crate version,update_changelog.dartclassifies update severity and accepts--from, and the update workflow skips regeneration when an open PR for the same version already exists - Fixed pre-existing clippy findings (
CryptoErrorCopy deref;too_many_argumentson external-commit APIs)
1.4.0 - 2026-06-06 #
For Users #
Highlights
- openmls_frb v1.4.0 → v1.5.0 — experimental X-Wing post-quantum ciphersuite (hybrid ML-KEM-768 + X25519)
Added
- Experimental post-quantum ciphersuite:
MlsCiphersuite.mls256XwingChacha20Poly1305Sha256Ed25519— hybrid X-Wing KEM (ML-KEM-768 + X25519, draft-connolly-cfrg-xwing-kem-06) for harvest-now-decrypt-later protection. HPKE operations for this suite are delegated to the formally verified libcrux ML-KEM implementation (openmls_libcrux_crypto, same upstreamopenmls-v0.8.1pin); all classical ciphersuites continue to run unchanged on RustCrypto. The libcrux provider is initialized lazily — classical suites never depend on it. See the README "Post-Quantum Support (Experimental)" section for important limitations (no IANA codepoint, limited interoperability, future migration to the official IETF suite).
Security
cargo auditreports three RustSec advisories introduced into the dependency tree byopenmls_libcrux_crypto(RUSTSEC-2026-0124, RUSTSEC-2026-0075, RUSTSEC-2026-0073). Analysis: all are DoS-class (panic) or structurally unreachable through this library's call paths — signatures always run on RustCrypto (0075 path never invoked; libcrux's KEM/HPKE code does not link ed25519), HPKE buffers are exact-size library-allocated (0124 trigger impossible), and the standalonemac()(0073) is never called. Fixes are blocked on upstream semver pins; tracked until the next upstream OpenMLS release. Each advisory is ignored in.cargo/audit.tomlwith its reachability justification inline — remove those entries when bumping the upstream pin. The non-libcrux routing these justifications depend on is enforced by theclassical_ops_do_not_init_libcruxRust test.
Documentation
- Document
flutter build web --wasm(dart2wasm) limitation in README — Rust returns fail withType 'JSValue' is not a subtype of type 'List<dynamic>'under dart2wasm. Upstream limitation influtter_rust_bridge(#2575), affects every FRB-based Dart package. Standardflutter build web(dart2js) target continues to work. (#5)
1.3.0 - 2026-04-01 #
1.2.0 - 2026-02-18 #
For Users #
Highlights
- openmls_frb v1.2.0 → v1.3.0 — database migration system with schema versioning
Added
MlsEngine.schemaVersion()— returns the current database schema version (useful for diagnostics and debugging)
For Contributors #
Added
- Database migration system with automatic schema versioning and downgrade detection
- Native (SQLCipher): each migration runs in its own SQL transaction with version written atomically
- WASM (IndexedDB): two-phase approach — structural changes via IDB versioning, data migrations via encrypted metadata key
- Downgrade detection: clear error if DB was created by a newer library version
- Separate version counters:
LATEST_SCHEMA_VERSION(data format, both platforms) andIDB_STRUCTURAL_VERSION(IDB object stores, WASM only)
/add-db-migrationClaude skill — step-by-step guide for adding new migrations- Storage Architecture section in CLAUDE.md — snapshot pattern, scalability, security properties, Wire comparison
- DB migration reminder in openmls update workflow PR checklist
Fixed
- Fix WASM build failure caused by
idb0.6.5 API changes inencrypted_db.rs(VersionChangeEvent::old_version()now returnsResult<u32>,Uint8Array::into()requires explicit type)
Changed
- Adopt copier template v2.3.1 → v2.4.0
- Added coverage badge support in README (shields.io endpoint via GitHub Gist)
- Added Rust dependency caching (
Swatinem/rust-cache@v2) in CI setup-rust action — dramatically speeds up Windows builds (~10 min OpenSSL compile cached) - Added Strawberry Perl configuration for Windows CI to fix OpenSSL build (MSYS2 Perl from Git Bash is incompatible)
- Added
IPHONEOS_DEPLOYMENT_TARGETenv var for iOS CI builds — fixes linker errors when vendored C code is compiled with newer Xcode - Added
make check-targetscommand andscripts/check_deployment_targets.dartfor checking deployment target consistency (iOS/macOS/Android) across all project files - Added "Setting up Coverage Badge" and "Setting up pub.flutter-io.cn Publishing" sections to CONTRIBUTING.md
- Replaced
dart run scripts/withdart scripts/in Makefile commands, removing.skip_openmls_hookworkaround (scripts only usedart:imports, sodart runbuild hooks are unnecessary) - Fixed WASM build hook: local builds now take priority over cached/downloaded files, avoiding stale content hash mismatches
- Removed
flutter:version constraint frompubspec.yamlenvironment (pure Dart packages don't need it) - README: compact horizontal platform table, added "Developing Rust API", "Building Native Libraries", and "CI / Version Management" sections
1.1.0 - 2026-02-15 #
For Users #
Highlights
- openmls_frb v1.0.0 → v1.2.0 — Rust FFI bindings with engine close/reopen support and openmls v0.8.1
Added
MlsEngine.close()andMlsEngine.isClosed()— allow closing the engine (wiping the encryption key from RAM and closing the DB connection) when the app goes to background or the screen is locked. After close, all operations fail with "MlsEngine is closed". Close is idempotent
Changed
- Update openmls native library to v0.8.1 (release notes)
- Relaxed WASM size limit to improve compatibility
- Exposed
full_leavesandparentsin TreeSync for tree traversal - Updated libcrux and hpke-rs dependencies
Fixed
- README: Correct iOS minimum version from 12.0 to 13.0 and macOS from 10.14 to 10.15 in platform support table
For Contributors #
Added
make check-targets: Unified deployment target consistency checker for iOS, macOS, and Android — verifies all project files (podspec, CI workflow, Xcode project, plist, build.gradle, README) match.copier-answers.yml. Supports--updateto fix mismatches and--set <version>to change a platform target everywhere in one command
Changed
- CI: Add Rust dependency caching (
Swatinem/rust-cache) to speed up builds, especially Windows where vendored OpenSSL compilation took ~10 minutes
1.0.0 - 2026-02-11 #
Added #
- MLS Protocol (RFC 9420): Full group key agreement with forward secrecy and post-compromise security
- MlsEngine: Rust-owned encrypted database with 61 API functions (58 async + 3 sync):
- Group creation, join (Welcome, external commit), leave
- Member management (add, remove, swap)
- Encrypted messaging with additional authenticated data (AAD)
- Proposals (add, remove, self-update with custom leaf node parameters, PSK, custom, group context extensions)
- Commit handling (pending, flexible, merge/clear)
- State queries (members, epoch, extensions, configuration, epoch authenticator, ratchet tree, group info, secrets)
- Key package creation with options (lifetime, last-resort)
- Storage cleanup (delete group, delete key package, remove pending proposal)
- Basic and X.509 credential support (optional credential bytes on all creation functions)
- 3 sync message utilities (extract group ID, epoch, content type)
- Encrypted storage: All MLS state encrypted at rest
- Native: SQLCipher (AES-256 transparent full-database encryption)
- Web: IndexedDB + AES-256-GCM per-value encryption via Web Crypto API
- SecureBytes: Wrapper for sensitive byte data with automatic zeroing on disposal
- SecureUint8List: Extension with
zeroize()method for manual zeroing ofUint8List - Cross-platform support: Android, iOS, macOS, Linux, Windows, Web (WASM)
- Automatic native library download via Dart Build Hooks
- SHA256 checksum verification for supply chain security
- Based on OpenMLS v0.8.0
Security #
- All cryptographic operations run in Rust (OpenMLS with RustCrypto backend)
- Memory safety via Rust's ownership model
- No
unsafecode in the wrapper layer - Web Crypto API on WASM: Encryption key imported as non-extractable
CryptoKeyviacrypto.subtle.importKey()— raw key bytes zeroized from WASM memory immediately after import. Defensive error handling (nounwrap()) in encrypt/decrypt paths SerializableSignerderivesZeroizeOnDrop— private key bytes zeroed on drop- Eliminated clone-then-zeroize pattern in
from_raw()andserialize_signer()— private keys moved, not copied signer_from_bytes()zeroizes input bytes on all code paths, including deserialization errors- X.509
x509()documents that application layer must validate certificate chains - SECURITY.md: sensitive API table, known limitations, web deployment recommendations, vulnerability reporting via GitHub Security Advisories