libsignal 7.3.0
libsignal: ^7.3.0 copied to clipboard
Dart wrapper for libsignal. Signal Protocol implementation for end-to-end encryption, sealed sender, group messaging, and secure cryptographic operations.
7.3.0 - 2026-09-08 #
For Users #
✨ Highlights
IdentityKeyPair.sign()signs with the identity key without copying it into the Dart heap — the one route that existed read theprivateKeygetter and rebuilt aPrivateKeyfrom those bytes, putting the long-term identity secret somewhere nothing can zeroize it. The getter still works, so nothing breaks; every call site in this package moved to the new method- libsignal v0.102.0 — unchanged this release
- libsignal_frb v6.3.0 — Rust FFI bindings
Changed
-
PrivateKey.agree()documents what it does not do (rust/src/api/keys.rs) — it is the raw X25519 primitive, and its docstring said only that the output is sensitive and should be zeroed. That is true and insufficient: the three ways this method is misused are not memory-hygiene mistakes.The result is not a key. X25519 returns the x-coordinate of a curve point, a field element rather than a uniformly distributed 32-byte string, so encrypting with it directly is wrong even though the bytes look random — it belongs in a KDF first, and
hkdfDeriveon this same surface takes it asinputKeyMaterial. A single agreement between two long-lived keys returns the same secret forever, so on its own it provides no forward secrecy; that comes from ratcheting over ephemeral keys, which is whatSessionBuilderandSessionCipheralready do and what a caller of this method has to build. And the method authenticates nothing: libsignal rejects the all-zero shared secret a low-order public key produces, in constant time and as a thrown error rather than 32 zero bytes, but checking that the peer's public key is the expected one stays the caller's job.The docstring now says so, and says plainly that ordinary Signal Protocol use never needs this method. No behaviour changed —
rustContentHashis unmoved, which is the mechanical confirmation that the FFI surface did not. -
IdentityKeyPair.sign()signs without copying the identity secret into the Dart heap (rust/src/api/keys.rs,README.md) — signing a signed pre-key or a Kyber pre-key with the long-term identity key had exactly one route:PrivateKey.deserialize(bytes: identity.privateKey.toList()), then.sign()on the result. That is what the README documented and what every call site in this repository did. It materialises the long-term identity private key as aVec<u8>handed across FFI, and from there nothing can reach it: nozeroizein Rust, nodispose()on the Dart side, only the garbage collector at a time of its choosing.identityKeyPair.sign(message: ...)does the same work with the secret never leaving Rust.It grants no capability that was not already reachable: the
privateKeygetter it replaces can sign the same arbitrary bytes today, so this narrows the surface a secret is exposed on rather than widening what the key can do. Signatures from it are not interchangeable withsignAlternateIdentity, which signs a domain-separated message — a fixed 32-byte prefix and a label ahead of the other identity key — and a serialized public key cannot begin with that prefix, so the two uses overlap only if a caller deliberately builds it.Upstream libsignal's
IdentityKeyPairhas no general-purposesign; this is a deliberate addition on our side, and theprivateKeygetter still works, so nothing that relied on the old route breaks. The getter's own documentation now points at this method. -
The README names the Flutter build-system skip that leaves
web/pkg/unprovisioned (README.md) — the build hook copies the WASM module into the consuming app'sweb/pkg/, andflutter run -d chromereaches the hook only while Flutter still considers itsdart_buildtarget out of date. That target's cache key omits the target platform: a debugflutter runkeys its build directory on the engine revision, the entrypoint, the build mode and the output path alone, so a debug run for another platform leaves a stamp naming its own dependencies, the next run for Chrome finds every one of them unchanged, logsSkipping target: dart_build, and the hook is never invoked. In an app whoseweb/pkg/is not already provisioned that surfaces asRustLib.init()failing on a 404 forpkg/libsignal_frb.js, with nothing in the output naming the hook or the platform that poisoned the stamp.Nothing in this package can close it — the skip happens above
hooks_runner, so no dependency the hook declares is ever read — so Known Limitations documents the escapes instead: oneflutter build web, which is keyed to its own build directory and always reaches the hook; deletingbuild/*/dart_build.stamp; orflutter clean. Onceweb/pkg/holds the right files,flutter run -d chromeserves them. -
The Android libraries are built by a pinned cargo-ndk, and their 16 KB alignment is measured on the bytes that get uploaded (
.github/workflows/build-libsignal.yml,scripts/verify_android_alignment.py,Makefile) — Google Play has required an app's bundled native libraries to be 16 KB-aligned, for apps targeting Android 15 or later, since 1 November 2025. Nothing here would have noticed a regression: a misaligned.sofails no test in this repository, it makes the consuming app unpublishable, which is the worst place to find out and somebody else's release that it stops.The alignment is supplied by cargo-ndk's linker flags —
-Wl,-z,max-page-size=16384and itscommon-page-sizetwin — and not by the NDK: r26-built and r28-built artefacts measurep_align=0x4000alike, and 32-bitarmeabi-v7ameasures0x1000on both, correctly, the requirement being a 64-bit one. So the property belonged to a tool the release job installed withcargo install cargo-ndk --lockedand no version, taking whatever was newest that day. It is pinned to4.1.2now, the version whose flags were read out of the binary, and the job verifies the result rather than trusting the pin:make verify-android-alignmentreads ELF program headers directly — noreadelf, no NDK — and fails closed on a non-ELF file, on a file with noPT_LOADsegments, and on finding nothing to check at all.
For Contributors #
Added
-
mainrequires the whole CI matrix, not just a codegen guard (.github/rulesets/protect-main.json,.github/rulesets/README.md) — none of the four rulesets carried arequired_status_checksrule, so a red CI run never blocked a merge. Eleven contexts are required now:FRB bindings were regeneratedplus ten legs of the test matrix.Requiring the guard alone came first, and it closed the loop that guard was written for: it exists because replaying the AI reviewer over 47 merged pull requests put its recall on that one condition at 21%, and the two-line shell check that replaced it had reported ever since with nothing depending on it. Requiring the matrix as well was blocked on a mechanism rather than on a preference — see the
test.ymlentry under Changed for what had to move and why the two are inseparable.Two legs stay out of the ruleset while still running in the workflow.
test / Update Coverage Badgeis skipped on pull requests, so requiring it would assert nothing.test / Test (Linux ARM64)is out because of a flake — but the flake is not its property, and the runbook now says so on measured ground rather than on reputation. Across the 25 most recentTestsruns the signatureTimeoutException after 0:00:30produced three isolated leg failures:Windows x86_64twice andLinux ARM64once, every other red run in that window being a genuine multi-job breakage. Three events over four legs cannot single out a platform, and "the slowest runner flakes" is not the answer either — the slowest leg by wall clock isLinux x86_64(248 s average against ARM64's 91 s) and it has never flaked. So the exclusion is chosen by what its absence costs — Linux stays required throughtest / Test (Linux x86_64), while dropping Windows would leave that platform with no required coverage at all — and Windows stays required knowing it will occasionally fail on its own until the timeout is understood.Every context string was read off the head commit of a real pull request rather than off a push to
main: the two triggers do not produce the same set of check runs —Update Coverage Badgereportssuccesson one andskippedon the other — and it is the pull-request set that a merge gate is measured against. The runbook records that check, and records that applying an edited ruleset takesmake setup-repo-protections ARGS="--update", plainsetup-repo-protectionsskipping one that already exists.Applied to
mainon 2026-09-07 and verified against the live API: the rule carries all eleven contexts, each withintegration_id15368 andstrict_required_status_checks_policystillfalse; the Admin bypass and the other three rulesets are untouched;rules/branches/mainreports the rule as effective, and a Dependabot branch still reports none. All four live rulesets match their committed JSON, and the--updatePUT preservedrequire_extra_approval_for_unattributed_changes, a field GitHub stores as a default and the committed file does not carry — worth confirming rather than assuming, since a PUT sends the file and not the difference. -
CI cross-compiles the three Android ABIs on every pull request (
.github/workflows/test-reusable.yml) — Android was cross-compiled in exactly one place,build-libsignal.yml, which runs onworkflow_dispatchand on alibsignal_frb-*release tag. Every leg of the test matrix runsmake build, which builds for the host. So an Android-only build failure was invisible onmainunder every green gate, and the workflow that would discover it is the one publishing binaries — at a moment when the tag has already been pushed and a crate version is already spent. All three ABIs rather than one, because a vendored-assembly failure can be architecture-specific while the same dependency carries assembly the other legs never reach. It builds and does not test: nothing in CI can execute an Android artefact without an emulator, and a compile-and-assemble failure is what this catches. The job readsandroid_ndk_versionand the API level from the same answersbuild-libsignal.ymldoes, so the gate cannot run on a different toolchain than the release. -
make actionlint, and aWorkflow Lint (actionlint)job that runs it (.github/actionlint.yaml,Makefile,.github/workflows/test-reusable.yml) — the workflows are the one part of this repository that nothing rehearses before merge: a job is only ever executed by pushing it, so a typo in an expression, a context that does not exist, or aneeds:naming a renamed job all reachmainand then fail on the very run that was supposed to gate them. actionlint reads them statically and hands everyrun:block to shellcheck, which is where most of what it finds lives — so the job asserts shellcheck is present rather than trusting the runner image, an absent one being a green gate that quietly stopped checking its most productive half. It is pinned by version and by checksum, because the step fetches an executable from a third-party release and runs it over the repository, and nothing bumps that pin automatically. Suppressions live in.github/actionlint.yamlrather than in flags, so a local run reports exactly what CI reports. Clean on this repository's workflows, divergent ones included, at the first run.
Changed
-
test.ymlno longer filters pull requests by path, so every pull request runs the matrix (.github/workflows/test.yml) — this is what let the ruleset above grow past one context, and it is not a preference. A required check is satisfied by a check run reporting on the pull request's head commit, and the two ways a job can fail to run are not equivalent: a job excluded by a job-levelif:still reports, asskipped, and counts as satisfied, while a workflow excluded by a workflow-levelpaths:reports nothing at all — and no setting reads a missing check as passed. Whiletest.ymlfilteredpull_requestby path, requiring anytest / …context would have left every documentation-only pull request waiting forever with nothing to fix.The filter stays on
push, where it guards the cache scope rather than a gate: a pull-request run can read the base branch's cache scope but never the reverse. The cost of dropping it was measured before it was paid — of the fifteen most recently merged pull requests, fifteen already matched it, so what changes in practice is that the rare documentation-only pull request now runs the matrix too.test.yml's header states that asymmetry forward rather than only recording the decision, because the tempting repair if the cost ever bites is the filter coming back, and the correct one is a job-levelif:on the expensive legs. -
make verify-frb-pinstells a fuzz crate with nothing to say from one it cannot read (scripts/src/frb_pins.dart,scripts/verify_frb_pins.dart,test/scripts/frb_pins_test.dart) — the sixth source2761886added was required outright. That is right here, whererust/fuzz/Cargo.tomlpinsflutter_rust_bridgeand cargo refuses to resolve the fuzz crate against the main one when the two drift, and wrong everywhere else: the copier template generates a fuzz crate that takes the main crate by path and names flutter_rust_bridge nowhere, so the same file carried over unchanged turns the gate red on the first run of every generated project. Measured in a render rather than reasoned about: dropped into one, the old file fails the manifest for holding no readable pin, and the new one reports it as declaring no such dependency.A manifest that does not declare the crate is absent now, the way bindings are absent until
make codegenhas run. One that declares it but writes the version in a form the reader does not accept is still a failure: collapsing those two is how a gate ends up reporting agreement it never checked. The predicate that separates them is anchored to the start of a line, so a commented-out dependency does not count as one andflutter_rust_bridge_codegenis not mistaken for it, and both directions are covered by tests that go red on the substring version somebody would otherwise simplify it to.Nothing about this repository's own check changes: all six sources still say 2.13.0 and a fuzz crate left behind on an older pin still fails by name. The success line measures its own column instead of assuming eighteen characters, because two of the reasons a source can be absent are wider than that.
-
The pin's prose no longer counts files a particular checkout happens to have (
CONTRIBUTING.md,Makefile,.github/dependabot.yml) — six files can record the version and two of them are conditional, so "five files" and "the version has to move in four places at once" were each true of one project. The Dependabot comment now namesmake verify-frb-pinsas the thing that enumerates them, which is the part that cannot go stale, instead of repeating a list beside it. -
make run-example-webclears the staledart_buildstamp before it runs (Makefile,CLAUDE.md) — the target wipedexample/web/pkg/and trusted the build hook to refresh it, which the hook cannot do when Flutter never invokes it.flutter runshares one build directory, and so onedart_buildstamp, between a debug run for macOS and a debug run for Chrome, because the target platform is not part of that directory's key; whichever ran first satisfies the other. The wipe then turned a staleweb/pkg/into a missing one, so the example failed to start with a 404 forpkg/libsignal_frb.jsrather than with the wrong WASM — the more confusing of the two failures, and the one that reads as a Rust or FRB bug. Deletingexample/build/*/dart_build.stampis correct by construction: a stamp that does not exist cannot be stale, and an unmatched glob underrm -fis a no-op, so a fresh tree is unaffected. Measured both ways before and after — with the stamp in place the hook does not run andexample/web/pkg/stays missing; with it deleted the hook runs, and the dev server servespkg/libsignal_frb.jsandpkg/libsignal_frb_bg.wasmin full. The comment above the target no longer claims the hook "should refresh on its own", andCLAUDE.mdcarries the same warning. -
The codegen guard regenerates the bindings instead of only reading a label (
.github/workflows/codegen-guard.yml) — the job's name promised more than it checked. It refused a pull request carryingcodegen-failedand nothing else, which is right for the case it was written for and blind to the neighbouring one: a pull request that changes an existing signature is already caught, becausefrb_generated.rsstops compiling andmake buildgoes red on four platforms — but one that adds apub fn, or edits a docstring, compiles perfectly and simply lacks the function on the Dart side. That is the gapbc0fdc9fell through here, where a corrected Rust security note never reached the generated Dart.make codegenappeared in CI in exactly one place, the bot's own update workflow, and never as a gate on a human's pull request.The two rules are a disjunction — label present or regeneration moves something — which is worth stating because the file argues at length against the conjunction, and that argument still holds. Drift is read from
git status --porcelain, notgit diff --exit-code, because codegen can add a file andgit diffis blind to an untracked path. The job name is untouched on purpose:FRB bindings were regeneratedis a required status check inprotect-main.json, matched as a string, so a rename or a second job would make the ruleset stop matching silently and leave every pull request waiting on a report nobody files. For the same reason the trigger still carries nopaths:filter; the cost is avoided per step instead, with the regeneration half running only when the pull request touches something that can move the bindings. -
Adopted copier template v4.8.0 → v4.9.0 (
.copier-answers.yml) — much of the range is this repository's own work returning: the sixth pin source, the required status check and thedart_buildstamp fix are the entries above, and they came back byte-identical, soprotect-main.json,frb_pins.dart,verify_frb_pins.dartandfrb_pins_test.dartwere not touched at all. What actually arrived is the four items already listed plus two sweeps: every$GITHUB_OUTPUT,$GITHUB_ENV,$GITHUB_PATHand$GITHUB_STEP_SUMMARYredirection is quoted, in the composite actions as well as the workflows — the reason the new lint gate is green at full strength rather than green because its noisiest check was off — and.github/rulesets/README.mdgains theactionlintand threeCross-compile (Android …)contexts, which the runbook had been due to grow by hand.Two hand-merges.
README.mdandCONTRIBUTING.mdconflicted and resolved to ours in full, both misalignments rather than disagreements: copier paired the template's new Known Limitations text against an unrelated heading, and its rewritten pin section against the security checklist. Both additions are already here, and this repository's wording of the fuzz-crate paragraph is the accurate one — our fuzz crate does nameflutter_rust_bridge, where the template describes a generated one that does not..github/rulesets/README.mdmerged with no conflict marker and a duplicated section: the template's rewritten runbook was appended beside the existing one, leaving two### Required status checksand two### Why Dependabot branches are excludedwith contradictory context lists. The template's copy is kept — it is the one carrying the new contexts — withtest / Test (Linux ARM64)pruned from it and named as pruned, which is what its own caution about flaky legs asks each project to do.android_ndk_versiondeliberately stays at r26. The template moved its default to r28 because OpenSSL 3.6 emits Intel SM3 assembly that r26's Clang 17 cannot assemble, reached throughopenssl-srcby projects that vendor SQLCipher;rust/Cargo.lockhere contains noopenssl-src,openssl-sys,rusqliteorlibsqlite3-sys, so the reason does not apply and an update keeps a recorded answer regardless. The new Android job reads that same answer, so gate and release stay on one toolchain either way.
Fixed
- The rulesets runbook described a bypass actor that is not there
(
.github/rulesets/README.md) — it said Signing commit "is bypassed only by the update GitHub App" while its own table two paragraphs above said "none by default", the committed JSON has an emptybypass_actors, and so does the live ruleset. The conclusion the sentence draws — that not even an admin may force-push — is a consequence of the empty array, not of an actor, and the file says as much aboutdelete-branches.jsonin the next section.
7.2.0 - 2026-09-06 #
For Users #
✨ Highlights
- flutter_rust_bridge 2.13.0 — bindings, the native crate's runtime and the
published constraint all move together. Action required for anyone whose
own
pubspec.yamlconstrainsflutter_rust_bridgeso as to exclude 2.13.0; an ordinary caret constraint needs no change - Rust 1.93.1 is the new floor for building the native library from source — libsignal v0.102.0 raises its own; consumers who install the published binary through the build hook are unaffected
- libsignal v0.102.0 — upstream bump. Nothing reaches the surface this package exposes: of the three files that changed in the crates we bind, two are comments and one is the version string
- libsignal_frb v6.2.0 — Rust FFI bindings
Changed
-
libsignal moves to v0.102.0, and nothing it changed is visible here (
rust/Cargo.toml) — the release is large upstream (26 commits, 169 files) and its own notes lead with new typed chat APIs — account deletion, SVR credential checks, currency conversions, pre-key counts, device capabilities, sticker upload forms and TOTP/MFA key management — plus registration without an E.164 and a switch to gRPC by default. Every one of those lands inlibsignal-net-chat,libsignal-account-keysor the Java, Node and Swift bridges, and not one of those crates is in this package's dependency graph at all.Four crates from that repository do reach the graph:
libsignal-protocol,libsignal-coreandsignal-crypto, which this package names, andlibsignal-debug, which arrives transitively.signal-cryptoandlibsignal-debughave no changed file in the range;libsignal-protocolandlibsignal-corehave three between them, and two of those are comments —rust/protocol/src/sealed_sender.rs(+2/-3, a doc comment swapping a dormant RFC link for a TODO aboutslice::element_offset),rust/core/src/lib.rs(+2/-3, an updated issue number in a comment about try-blocks) andrust/core/src/version.rs(the version string).Asked at the lockfile rather than the file tree, the answer is the same:
rust/Cargo.lockholds 226 packages before and after, with none added and none removed, and four version moves —libsignal-debug0.101.2 → 0.102.0, which inherits the workspace version, pluscc1.4.4 → 1.4.5,find-msvc-tools0.1.11 → 0.1.12 andsmallvec1.15.2 → 1.16.0.THIRD_PARTY_NOTICES.txtrecords those four and nothing else. Upstream's two new workspace dependency bounds both miss this package:displaydoc's floor rises to 0.2.6 where the graph already resolves 0.2.7, and thetinyvec < 1.13.0cap guards a crate the graph does not contain. Regenerating the bindings produced no change underlib/src/rust/; the FFI surface did not move -
Building the native library from source now needs Rust 1.93.1 (
rust/Cargo.toml) — libsignal v0.102.0 raises its workspacerust-versionfrom 1.88 to 1.93.1, so the floor this package declares had to rise with it or the promise would be false: the manifest said 1.88 while the dependency could no longer be compiled by it. This is the from-source path only — consumers who install the precompiled binary through the build hook never invoke a Rust toolchain and are unaffected. Action required for anyone building from source on a toolchain older than 1.93.1:rustup update -
flutter_rust_bridgemoves to 2.13.0, and a consumer who pins it narrowly has to move with it (pubspec.yaml) — the constraint is now">=2.13.0 <2.13.1". It admits exactly one version for the reason 7.1.1 documents: the runtime compares its own version against thecodegenVersionrecorded inlib/src/rust/frb_generated.dartwith string equality, so every version a wider range admits except the one that generated the bindings failsRustLib.init(). Upstream says the same thing — "all flutter_rust_bridge-related packages will need to have exactly the same version".The move is mechanical, and it was measured rather than assumed. The whole Dart diff under
lib/src/rust/is 36 lines: the@generated bystamp in 17 files and onecodegenVersionstring.rustContentHashdoes not move (450650216 before and after), so the wire signature between Dart and the native binary is unchanged. The generated Rust is hygiene only, and the 377-line diff accounts for itself line by line: 173 lines spellOk(...)asstd::result::Result::Ok(...), two spell it the other way round at the infallible wrappers (Result::<_, ()>::Ok(x)becomesOk::<_, ()>(x)), eight drop one space aftermove || {, three carry the@generated bystamp, one carriesFLUTTER_RUST_BRIDGE_CODEGEN_VERSION, and one addsmismatched_lifetime_syntaxesto the allow list. That is 188 lines removed and 189 added with nothing left unclassified. Inrust/Cargo.lockthe bump movesflutter_rust_bridge_macrosin lockstep and pulls in no new transitive crate: the runtime's own 21 dependency edges are byte-identical.Action required only if you constrain
flutter_rust_bridgeyourself, and what happens when you don't act is worth stating exactly, because it is quieter than a failure. An ordinary^2.12.0is>=2.12.0 <3.0.0, admits 2.13.0, and needs no change at all. A narrow pin that excludes 2.13.0 —">=2.12.0 <2.12.1", the form this package itself ships — does not fail: pub backtracks and resolves the previouslibsignalinstead, and the only sign is the generic "packages have newer versions incompatible with dependency constraints" advisory, which names neither package. The upgrade is withheld rather than refused, so move your own constraint to">=2.13.0 <2.13.1"in the same commit that upgrades this package. Resolution fails outright only where no version satisfies both constraints — in practice the case 7.1.1 already described, a project that also depends on another flutter_rust_bridge wrapper built against a different version. -
The dead
getrandom0.2 declaration is gone from the wasm32 block (rust/Cargo.toml) — it had become its own only reason to exist. Inrust/Cargo.lockthe sole consumer ofgetrandom 0.2.17waslibsignal_frbitself, which is to say this declaration; for contrast 0.3.4 had two consumers and 0.4.3 had four, of which one and three respectively were crates other than this one. So the declaration was not holding a backend on for some crate that needed it — it was the only thing holding that version in the graph at all, and removing it removed the version: 0.2.17 is no longer in the lockfile, and a wasm32 build now compiles exactly two getrandoms, 0.3.4 and 0.4.3.wasi 0.11.1+wasi-snapshot-preview1went with it, having been reachable only through that version, soTHIRD_PARTY_NOTICES.txt— which ships inside the published archive — now lists 227 crates and 131 licence texts rather than 229 and 132.
Security
- The HKDF doc no longer promises a zeroization that does not happen
(
rust/src/api/crypto.rs) — its# Securityblock said the internal copy of the key material was "cleared when theHkdfinstance goes out of scope". It is not.hkdf0.13 publishes no[features]at all,hmac0.13 does havezeroize = ["digest/zeroize"], and nothing in this crate's graph turns it on, so dropping anHkdfruns no zeroizingDrop— it releases the memory for reuse. The arguments this function owns are still zeroized on every path, including the error one; what changed is that the note says which copy is cleared and which is not, and names the change that would close the gap (depend onhmacdirectly with itszeroizefeature, gated onmake build-webbecause it moves the dependency graph). Nothing about the derived output changes. Found by replaying the AI reviewer over merged pull requests. The correction reaches the published documentation with this release and not before: it was written into the Rust source without amake codegenrun, solib/src/rust/api/crypto.dart— which ships, being outside.pubignore— went on carrying the retracted claim. How that happened, and why no gate caught it, is under For Contributors.
Fixed
- A dead reference in the published API documentation (
lib/libsignal.dart) — the library-level doc listed sender certificates as[SenderCertificate], a type this package does not expose: they reach Dart as a function family (createSenderCertificate,validateSenderCertificate). dartdoc reports an unresolved reference as a warning and exits zero, so it shipped to pub.flutter-io.cn on every release that carried it and nothing ever said so. It was the only instance, and the gate below now makes that class a build failure.
For Contributors #
Added
-
Two documentation gates, and both block (
dartdoc_options.yaml,make doc,make rust-doc) — dartdoc'sunresolved-doc-referenceis promoted from a warning to an error, and rustdoc runs under-D warningson the host and on wasm32. Both were red on adoption: one dead reference on the Dart side, three on the Rust side, all dead for as long as they had existed. They are the same mistake in both directions — flutter_rust_bridge copies a Rust doc comment into the generated Dart verbatim, and Rust's intra-doc syntax is not Dart's — sorust/src/api/now names the Dart surface in plain backticks, which links on neither side and rots on neither either.dartdoc_options.yamlis.pubignored on purpose: pub.flutter-io.cn runs dartdoc itself and would honour the same promotion, which could break documentation generation for an already-published version. -
wasm32 is executed, not merely compiled (
make test-web,test-reusable.yml) — CI gainsBuild WASMandRust unit tests (browser), andmake test-webruns the crate'scfg(target_arch = "wasm32")tests in headless Chrome. Until now nothing covered those branches: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.rust/Cargo.tomlgains the harness that needs, as a wasm32dev-dependenciesentry (wasm-bindgen-test); it is the only non-comment change to that manifest, so the shipped binary is untouched, andmake verify-third-party-noticesstill passes with eleven new crates inCargo.lockbecausecargo tree --edges normal,buildexcludes every dev-dependency on every target. First run here: 1 test, green. -
A pull request whose FRB bindings were never regenerated is refused (
.github/workflows/codegen-guard.yml) — two lines of shell that fail any pull request carrying thecodegen-failedlabel. It exists because the alternative was measured and lost: replaying the AI reviewer over all 47 merged pull requests put its recall on this exact condition at 6 of 28, 21%, even though the label sits in plain text in the context file it reads and its reasoning on the ones it did catch was sound. A model that understands a rule and applies it one time in five is not a gate; the same rule stated directly is 100%. The condition is the label alone, deliberately — when codegen fails an unchangedlib/src/rust/is the expected state, so an extra "and the bindings did not change" clause would add nothing and would let the check pass on a pull request where somebody hand-edited generated output.labeledandunlabeledare in the trigger list so that removing the label after a manual fix re-runs the check rather than leaving the pull request red with nothing left to fix, and the labels are read back from the API rather than from the event payload, because the label is attached in the same breath as the pull request is opened. 28 pull requests merged carrying this label before the guard existed. -
The SSv2 offsets guard is covered, by the only route that reaches it (
test/sealed_sender/usmc_and_multi_recipient_test.dart) — a message that parses always carries offsets inside its own buffer, so no amount ofsealedSenderV2ParseSentMessagereaches the second guard inreceivedMessageFor. It is reachable all the same, becauseSealedSenderV2SentMessageandSealedSenderV2Recipientare public value classes with public constructors: anything that persists a parse result and rebuilds it later hands that method numbers the parser never produced, and without the guard they go straight intosetRange. Five tests — a control that fits, then a key range ending past the buffer, an inverted range, a shared offset past the end, and the empty-devices early return that answers before the offsets are read at all. -
HKDF-SHA256 is pinned to RFC 5869, not just to itself (
test/crypto/hkdf_kat_test.dart) —hkdf_test.dartnext door is round-trips and shape checks: it proveshkdfDeriveis deterministic and that its output moves when its inputs do, and it would keep passing if every derived byte changed. The AES-GCM-SIV vectors added in 7.1.1 closed exactly that gap for the AEAD and left it open here. RFC 5869 publishes seven vectors; A.4-A.7 are HMAC-SHA1 and this package exposes no SHA-1 derivation, so the three SHA-256 cases are the whole of what applies — including A.2, whose 82-octet output is the only one that runs the expansion past two rounds and so pins the block counter. A.3 earns its place twice over: its zero-length salt is the pathhkdfDerivetakes whenever a caller passes[], which maps to HKDF's "salt not provided" and so to 32 zero bytes — which is the PRK the RFC computed it against. -
The release profile's panic strategy has a test behind it (
rust/src/utils.rs,release_profile_must_not_abort_on_panic) — the manifest has said for several releases that the absence of apanickey in[profile.release]is load-bearing:panic = "abort"skips unwinding, soDropnever runs and every zeroize-on-Drop in this crate is silently bypassed, leaving key material in memory after any panic. Nothing checked it, and the surface is wide — ten files underrust/src/api/reach forzeroize, mostly throughZeroizing, whose wipe is aDropimpl and so exactly what unwinding runs. It reads the manifest rather than watching a panic because it has to: Cargo forcespanic = "unwind"on thetestandbenchprofiles and rejects the key on per-package overrides, so the setting that actually ships is unobservable from inside a test binary. Beyond the exact[profile.release]+panic = "abort"shape it rejects any non-comment line naming bothpanicandabort— TOML gives several ways around an exact header match, and enumerating them is the part that would rot. Verified against this manifest rather than in the abstract: green as it stands, red on the key planted inside[profile.release], red on a non-comment line naming both elsewhere, and red again when the section is renamed away. The comment-stripping is proved by the pass rather than asserted — five comment lines in this manifest name both words. Adopted from the copier template, where it originated in a sibling project.
Changed
-
Adopted copier template v4.6.0 → v4.7.0 (
.copier-answers.yml) — the two gates above are most of it. The rest: release builds are locked, so everycargo buildandcargo installinbuild-libsignal.yml— the workflow that produces the shipped binaries — now passes--lockedand builds from the committed lockfile rather than re-resolving; Dependabot stops proposinghooksandcode_assetsmajors, which are blocked by the pinned Flutter SDK rather than by anything here and so cannot be merged or fixed in this repository; and bookkeeping stops outranking tests, withverify-third-party-noticesandverify-frb-pinsmoving after the test steps so a stale inventory no longer fails the Linux leg before a single test has run. Eleven files came back conflicted. Four were merged rather than taken from either side:rust/Cargo.tomlkeepsjs-sys— which the template render does not have andrust/src/utils.rscalls — and attaches the new unwinding note to the existing[profile.release]instead of the second one the template side would have added, which TOML rejects;rust/deny.tomlkeeps its liveRUSTSEC-2026-0173entry under the template's new preamble, and keeps its own licence note, which states the AGPL-compatibility criterion the generic one replaces with an invitation to extend the list;CONTRIBUTING.mdkeeps the headings its own table of contents links to and takes the template's prose;SECURITY.mdkeeps this project's reporting section and takes the note that private vulnerability reporting has to be enabled per repository before the link works for outside reporters. The newenable_freezedanswer is false: flutter_rust_bridge needsfreezedonly for data-carrying enums and structs this API does not have, and answering it once replaces stripping the three dependencies by hand after every update.README.mdwas taken whole from this side — all four of its conflicts were misalignments against a locally rewritten file, and one template side was a code fence that never closed -
The Dependabot ignore that was holding setup-dart back is deleted, because it never held anything back — Dependabot parses a
github-actionsignore throughGem::Requirement, which has no wildcard expansion."1.8.x"becomes= 1.8.x, a version string nothing equals: 1.7.2, 1.8.0, 1.8.1 and 1.9.0 all fail it. The bot went on rebuilding its branch over the very commit that added the entry, with 1.8.1 still in the diff. An ignore that reads as protection and enforces nothing is worse than no entry at all, and the real fix now sits in the action itself. -
randis ignored at>= 0.10.0until libsignal moves — anOsRngis handed by&mutstraight into libsignal's ownRng + CryptoRngbounds at 19 call sites inrust/src/api/, and those bounds are rand 0.9's traits;libsignal-protocol,libsignal-coreandspqrall resolve rand 0.9.5. A 0.10UnwrapErr<OsRng>implements a differentRngCore, so the bump cannot compile. It is written as aversionsrange rather thanupdate-typesbecause Dependabot's trailers call 0.9 → 0.10semver-minor, so an ignore aimed at majors would never fire — the same trap the group filter above it already documents. -
getrandomis ignored at>= 0.4.0, because the manifest declares it twice — two majors are live in the wasm32 graph at once and each needs its browser backend enabled by name, sorust/Cargo.tomlcarriesgetrandom0.3 (reached throughlibsignal-coreandrand_core0.9) alongsidegetrandom_04, a renamed declaration of the same crate at 0.4 (reached throughaes-gcm-siv→aead→crypto-common). Dependabot sees one dependency and raised the 0.3 one to 0.4, pointing both names at a single version; cargo refuses that outright — "depends on crategetrandom v0.4.3multiple times with different names" — which is why three jobs went red. The name clash was not even the whole defect:libsignal-corekeeps 0.3.4 in the lockfile regardless, so it would have been left with nowasm_jsbackend, because the retargeted declaration was the only thing enabling it — acompile_error!insidegetrandomon the nextmake build-web. Aversionsrange rather than a blanket ignore, so 0.3.x patches still arrive. Not measured, and assumed against us: whether an ignore keyed on the dependency name also withholds 0.4.x patches fromgetrandom_04. -
anthropics/claude-code-actionmoves to v1.0.213 (ai-review.yml,repair-build.yml) — the pin sat at 1.0.210 because Dependabot's metadata cache was behind the real releases when it last ran, not because 1.0.210 was chosen on merit. Both SHAs were dereferenced against the upstream annotated tags before merging, since comparing arefs/tags/*object id against a pinned commit id compares two different things and would have reported a mismatch that is not one. -
lintsmoves to>=6.1.0 <6.2.0— the window slides onto the minor the matrix has already run green and stays one minor wide, so a new lint release still arrives as a pull request the four platforms evaluate rather than as a silent change of whatmake analyzeenforces. -
make coveragemeasures the code somebody wrote — everything underlib/src/rust/is now excluded, not just thefrb_generated*files: the rest of that directory is the same generator's output one layer up, and 41 of the 45 lines it left uncovered werehashCodeandoperator ==on value classes. Including them meant amake codegenrun could move the badge with nobody having written a line, which is what took the figure from 100% to 93.8% when the sealed-sender surface grew. The denominator drops from 720 lines to the 468 hand-written ones, and with the guard test above it reads 100.0% again. The Makefile comment records why the second glob is not written**/lib/src/rust/**: a glob starting with**can never match an absolute path, so that form is tested only against the relative path, matches nothing, and turns the ignore off without saying so. -
The repair agent is told what it is explaining, and to leave generated files alone — its second pass on PR #67 lost the diagnosis: a new flutter_rust_bridge landed between the two runs, the repair job's own checkout began failing for an unrelated reason, and that local reproduction displaced the recorded log as the thing being explained. The agent was not what broke; the invariant was simply never written down. The prompt now states it — the subject is the failure in the log of the run named in
failure.env, anything met locally that does not match it is a second finding fornotesrather thancause, and a failure that does not reproduce is acannot-fixnaming the recorded failure rather than a licence to adopt some other one. It also forbids editing generated output by hand: the fix that pass produced was a singlecodegenVersionline underlib/src/rust/, which the nextmake codegenreverts, so the red was silenced rather than repaired. Those paths may still change — by changing what generates them and runningmake codegen, which the agent is already permitted to run. -
The
getrandomremoval waited for a release branch — nothing local proved a wasm32 dependency change at the time, becausemake buildis host-only and CI then compiled wasm only on alibsignal_frb-*tag push. A graph change merged without runningmake build-webfirst is one whose Web target is compiled for the first time inside a release — which is how two crate versions were already burned. This one was gated onmake build-webbefore it was committed. The constraint itself is gone as of this release:Build WASMandRust unit tests (browser)run on every push and pull request now, so the tag is no longer the first time wasm is compiled. -
analysis_options.yamljoins the test workflow's path filters (.github/workflows/test.yml, 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. The same reasoning already carriesdartdoc_options.yaml, one entry above it. Adopted from the copier template. -
Adopted copier template v4.7.0 → v4.8.0 (
.copier-answers.yml) — a near no-op, and deliberately so: almost everything in v4.8.0 started here. The panic guard and theanalysis_options.yamlfilter were taken by hand from the template's unreleased work before this release, and the two anchored version readers and the stage-2 staleness check were written here and backported, so the update mostly delivered this repository's own commits back to it.0rejected hunks;scripts/src/common.dart,scripts/src/release.dartand both their test files were not touched at all, because the template renders them byte-identically to what is committed here — which was the point of matching the render rather than merely fixing the same bug twice.Two files conflicted, both standing keep-ours divergences resolved to ours:
CLAUDE.md, whose two-stage release section is richer than the template's, andREADME.md, where copier paired our### Key Typesheading against the platform table — a misalignment rather than a disagreement, since our table already carries the iOSx64 (sim)correction that went upstream.Neither of the two things that actually needed attention was a conflict. Copier inserted its own "push first" paragraph into
CLAUDE.mdoutside the conflict brackets, where a blind keep-ours would have kept it alongside ours and said the same thing twice; andtest/scripts/frb_pins_test.dartmerged clean and wrong, with the anchoring regression test present twice — copier reconstructs the pre-update render, which predates the hand-added test, so the resulting patch applies it again. Duplicate test names are legal Dart, so nothing failed; the suite simply ran it twice. Both were removed. Net effect once resolved: the_commitbump, plus copier rewritingrust_version: '1.93.1'without quotes, which YAML reads identically.
Fixed
-
The fuzz crate stopped building the moment the main crate moved to flutter_rust_bridge 2.13.0, and no gate could see it (
rust/fuzz/Cargo.toml,scripts/src/frb_pins.dart) —rust/fuzzpinsflutter_rust_bridgedirectly and takes the main crate by path, so the=2.12.0it kept when4f47a2eraised the main crate to 2.13.0 was not drift: cargo cannot resolve the two together at all.cargo metadatathere exits 101 with "failed to select a version forflutter_rust_bridge", which makes every fuzz target unbuildable, locally and in CI. Three separate things had to miss it, and each did.rust/fuzzis its own workspace root, so no resolution underrust/ever passes through it. TheFuzzworkflow triggers onrust/**pull requests and a weekly cron but not on a push, and4f47a2ereachedmainas a push — so the first red run was somebody else's pull request, days later. Andmake verify-frb-pins, the gate whose whole job is that these versions move together, read five files and not this one, even though the pin carries a comment saying it must match the main crate. The pin is now 2.13.0, and the gate reads six files: a bump that forgets the fuzz crate now fails on the constraint instead of in a fuzz run nobody is watching.FrbPinalso records absence rather than inferring it from the wording of its own message, which a second optional source would otherwise have made load-bearing. -
The corrected HKDF note reached Rust but never reached Dart (
lib/src/rust/api/crypto.dart) — the# Securitycorrection recorded above was written intorust/src/api/crypto.rswithout amake codegenrun, so the generated Dart still carried the claim the correction exists to retract: that the internal copy of the key material is cleared when theHkdfvalue is dropped. flutter_rust_bridge copies a Rust doc comment into the Dart output verbatim, which makes a docstring edit a bindings edit, and nothing in CI compares committed bindings against what codegen produces on a hand-made commit — thebindings=changedtripwire lives incheck-libsignal-updates.yml, on the automated upstream path only, andcodegen-guard.ymlrefuses a labelled pull request rather than comparing anything. Regenerating moved nothing else:rustContentHashis unchanged at 450650216, so the wire signature did not shift. -
A rate-limited GitHub reply no longer becomes "no release notes were published" (
scripts/src/update_changelog.dart) —curl -scarries no-f, so it exits 0 on 403, 429 and every 5xx, and the only other guard matched the single exact stringNot Found. A rate-limited reply reads "API rate limit exceeded for -
Four dropped tokens in the AES-GCM-SIV vector file, one of them load-bearing —
test/crypto/aes_gcm_siv_kat_test.dartshipped in 7.1.1 with${i + 1}missing from both loop-generated test names, so its 48 tests carried two names between them: a failing vector named no vector, anddart test -ncould select none. The header lostaes-gcm-sivfrom two sentences in the same edit — "a future bump of — or a change" and "transcribed from the crate's own" — leaving the file warning about a bump of nothing and citing no source for its table. The vectors themselves, and everything they assert, were never affected. -
dart-lang/setup-dartmoves to 1.8.1, and the reason it was held at 1.7.2 is switched off in the same commit — 1.8.0 added a problem matcher fordart analyzeand registers it with::add-matcher::dart-analyzer.json, resolving that path againstGITHUB_ACTION_PATH. For an action invoked from inside another composite action that variable holds the caller's directory, so the runner looked for.github/actions/setup-fvm/dart-analyzer.json, did not find it, and failed the job seconds in — before anything was built. It took down all four test legs, andpublish.ymlandbuild-libsignal.ymlcall the same action, so a release would have hit it too. Upstream (dart-lang/setup-dart#198) is open and 1.8.1 does not fix it, so the SHA pin advances together withproblem-matcher: 'false'rather than ahead of it: that input only exists from 1.8.0 on, so neither half is safe on its own. Nothing in this action runsdart analyze— that job uses the FVM-pinned SDK — so the matcher buys nothing here even when it works. -
Two version parsers could be answered by a comment (
scripts/src/common.dart,scripts/src/frb_pins.dart) — both read a file as text with an unanchored pattern andfirstMatch, which takes whichever match comes first rather than the declaration.getUpstreamVersionis the one that mattered: a commented-outlibsignal-protocol = { … tag = "…" }— the shape an upgrade leaves behind — outranked the real pin below it, and in the dangerous direction a comment naming a newer tag makesmake check-new-libsignal-versionreport the dependency as already current, so an upstream release, security fixes included, silently never lands. Nothing fails; the update just never happens. Measured rather than supposed: against the old pattern a stale comment above the real pin readv0.101.2, and a comment namingv0.999.0readv0.999.0. Both patterns are now anchored to the start of a line, the inline table may no longer span lines, and the parsing rule is split out of the disk read asparseUpstreamTagso it is testable without a fixture tree — the wayfrb_pins.dartalready splits every reader from the disk. Seven tests cover the two, and four of them fail against the previous patterns. The same fix went to the copier template, which renders this file byte-identically, so no divergence is introduced. -
Stage 2 would have accepted a stale native binary (
scripts/src/release.dart) — the preflight checked only that a GitHub Release namedlibsignal_frb-<crate version>existed. The crate version does not move until stage 1 runs, somake releaseon its own found the release left by the previous cut, passed, and would have published these bindings against that binary. Not hypothetical here:libsignal_frb-6.1.3exists and was built from libsignal v0.101.2 with flutter_rust_bridge 2.12.0 codegen, while this tree generates 2.13.0 against v0.102.0. Neither runtime net would have caught it —rustContentHashcompares the FFI surface, which is byte-identical across exactly this mismatch (450650216 both sides), and the codegen assert compares the bindings against the flutter_rust_bridge runtime package, never against the native library. What differs is the generator that decided the argument marshalling, so the failure would be a wire mismatch inside a consumer's app — the same shape as theOption::unwrap()arg-count panic behind 6.0.1. The preflight now also readsfrb_generated.dartandrust/Cargo.tomlat the tag and refuses when either the codegen version or the upstream pin differs from the working tree, naming both. It reads them over the GitHub API rather than fetching the tag, because over-fetching tags is what aborted a release once already; it fails closed when it cannot read them; and--skip-frb-checkremains the escape hatch. The comparison is a pure function (describeFrbReleaseDrift) so it is covered without a fixture tree, and it was run against the real 6.1.3 tag: two refusals, both correct, and silent when the tag matches the tree. -
make verify-frb-pinsdocumented five sources and reads six (scripts/src/frb_pins.dart,Makefile) — the file header and the target comment were left behind whenrust/fuzz/Cargo.tomljoined the gate;CONTRIBUTING.mdhad already been updated. The count is the whole of what those comments claim, so a wrong one is the same prose-versus-code drift the gate itself exists to catch.
7.1.1 - 2026-09-01 #
For Users #
✨ Highlights
- libsignal v0.101.2 — upstream bump. One change does land in a surface this package exposes, and it is an internal API migration with no behavioural effect; sealed sender's cipher state is now cleared on drop
- libsignal_frb v6.1.3 — Rust FFI bindings
Changed
-
libsignal updated to v0.101.2, across two upstream releases (compare) — 139 files over 26 commits. Neither v0.101.1 nor v0.101.2 published any release notes, so the diff is the whole account of what arrived. Exactly three files land in the crates this package binds, and both substantive ones are from v0.101.1; for these crates v0.101.2 is the version constant alone.
rust/core/src/version.rsis that constant.rust/protocol/Cargo.tomlturns on thezeroizefeature (below).rust/protocol/src/sealed_sender.rsis anaead0.5 → 0.6 migration —encrypt_in_place_detachedbecomesencrypt_inout_detached,AeadInPlacebecomesAeadInOut— passing the same key, nonce and associated data, so the wire format does not move. That is checked rather than assumed: the six differential tests inrust/src/ssv2_equivalence_tests.rs, which reassemble a real multi-recipient message and compare it byte-for-byte against upstream's own output, all pass against v0.101.2. Everything else upstream is in the bridge and the Swift/Java/Node bindings, message-backup, net, attest, zkgroup and media, none of which this package exposes.make codegenproduced no diff inlib/src/rust/, so the FFI surface is unchanged. -
This crate's RustCrypto dependencies now match the ones libsignal resolves —
sha20.10 → 0.11,hkdf0.12 → 0.13 andaes-gcm-siv0.11 → 0.12. The first two had been a version behindlibsignal-protocolfor some time and the third fell behind with the bump above, so the graph was resolving two copies of each: twosha2, twohkdf, twoaes-gcm-sivand twoaead. It now resolves one of each, and the binary carries one implementation of each primitive rather than two.The three could not be bumped separately, which is how they were offered — three separate pull requests, each of which fails to build.
sha20.11 moves todigest0.11 whilehkdf0.12 still expectsdigest0.10, soHkdf::<Sha256>::newstops type-checking under either bump alone; applying both at once resolves it, and that part needed no code change.aes-gcm-siv0.12 bringsaead0.6, whereArray::from_sliceis deprecated in favour ofTryFrom. The two nonce conversions inrust/src/api/crypto.rswere rewritten accordingly. The length is validated a few lines above either of them, so the conversion cannot fail; it is handled rather than unwrapped so that a later change to that check cannot become a panic crossing the FFI boundary.Ciphertext is unchanged, which matters because anything encrypted by an earlier release has to stay readable. That is now pinned by a test rather than argued — see the RFC 8452 vectors below.
One thing had to be named for the Web build to keep working.
aes-gcm-siv0.12 enablesaead/getrandomby default,aead0.6 forwards that tocrypto-common, andcrypto-commondepends ongetrandom0.4 with no target cfg — so that crate is now compiled forwasm32-unknown-unknown, where it refuses to build unless its browser backend is selected by name. Two getrandom majors were already declared inrust/Cargo.tomlfor exactly this reason; 0.4 is now declared alongside them withwasm_js. No other platform was affected — the twelve native targets built before this was added.
Security
- Sealed sender's AES-GCM-SIV key material is cleared on drop — upstream
enabled the
zeroizefeature ofaes-gcm-sivforlibsignal-protocol. This package exposes sealed sender, so the hardening reaches consumers through the native binary rather than staying upstream.
Fixed
-
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
>=2.12.0 <2.12.1. Nothing wider is safe: the check is string equality, so every version a range admits except the one that generated the bindings fails, and>=2.12.0 <2.13.0would only narrow the window — flutter_rust_bridge ships patch releases, and a 2.12.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
2.12.0, 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.>=2.12.0 <2.12.1resolves 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.
rustContentHashis unchanged, so the published native binary still matches and no rebuild or regeneration is needed. Verified by resolving a clean checkout both ways: 2.13.0 with 54 failures before, 2.12.0 with all 714 tests passing after.
For Contributors #
Added
-
AES-256-GCM-SIV is pinned to RFC 8452, not just to itself (
test/crypto/aes_gcm_siv_kat_test.dart) — the existing cipher tests are round-trips. They prove encrypt and decrypt agree with each other, and they would keep passing if the ciphertext bytes moved, which is exactly what swapping an implementation can do — and this release swaps one. The 24 published vectors from Appendix C.2 now run through the public Dart API in both directions, so a future bump ofaes-gcm-siv, or a change to how the nonce is converted, cannot silently make data written by an earlier release unreadable. -
An agent proposes a fix when
maingoes red —repair-build.ymlruns daily; when the latest completedTestsrun onmainfailed, it hands that run's log to an agent and opens a pull request with the proposed fix. This task was picked to be first precisely because its oracle is real: the pull request is checked by the same four-platform matrix as any other, so a wrong fix costs a review rather than a merge. The agent holds no credential that can write to this repository. The work is split across three jobs — detect, repair, propose — and the GitHub App token is minted in the third, which the agent does not run in. The repair job checks out withpersist-credentials: false, so no token is left in.git/configfor itsBashtool to find, and its work leaves that job as a patch file rather than as a branch. What it may change is enforced by a path allowlist after the fact, not entrusted to the prompt..github/**is outside it deliberately: the cheapest way to turn a build green is to weaken whatever reported it, and that has to be a human's decision..githooks/**is outside it too — those files carry an executable bit that the signed-commit API cannot represent, so the push would be rejected anyway. The commands it may run are enumerated rather than wildcarded, which is not the same caution repeated.Bash(make:*)would have admittedmake release,make publish,make update-changelogandmake setup-repo-protections; the push at the end ofmake releasefails for want of a credential, but only after it has bumpedpubspec.yamlin the working tree — andpubspec.yamlis inside the path allowlist, so a version bump would have ridden along in the pull request looking like part of the fix.cargois enumerated for the same reason:cargo publishshares a prefix withcargo check.lsandrgwere dropped in favour ofGlobandGrep, which do the same work but are scoped to the workspace —rgreads any path the process can,/proc/self/environincluded, which is where this job's secrets live. Nothing carrying a secret is published. This repository is public, so its Actions logs and pull-request bodies are too, and the verdict is free text an agent wrote after reading a log that third parties contribute to — copied into a body created through the API, which never passes the log masking that would otherwise catch a key. Before anything leaves the agent's job, the patch and the verdict are scanned for the values ofANTHROPIC_API_KEYand the job token, and a hit fails the run rather than redacting: a secret reaching that point means something went wrong earlier, and publishing a censored copy would hide it. The scan names only the variable, never the value. Verdict fields are capped at 4000 characters each, because a rehearsal produced 3500 and an unbounded public write should not be one field away. Three states that would otherwise pass for success are made loud. The agent must leave averdict.json; its absence means the run exhausted its turns or crashed, which is otherwise indistinguishable from "nothing needed fixing". A verdict offixedwith an empty diff fails instead of opening an empty pull request. And the failing run must be judgingmain's current tip — cutting a branch from an older commit would open a pull request that reverts whatever landed in between. What the agent is given is prepared for it, and that is where the cost is. A rehearsal against a real failure measured 2.19M cached input tokens over 24 turns against a ~100K context, of which the 218 KB log was more than half — the log, not the model tier, is what a run costs, and it is paid for on every turn rather than once. The error lines and their surroundings came to 13 KB for the same failure, so a distilledbuild-failure.summaryis now the entry point and the full log stays beside it for when the summary leaves a question open. Alongside it,run-context.mdcarries how every other job in the failing run concluded and howmainhas fared lately: both rehearsals reached for exactly that and it was decisive — a leg green on the same commit, and a symptom striking a different test each time — and neither fact is anywhere in the failing job's log. Gathering it in a script rather than granting the agentghavoids putting a token in its environment and avoids trusting a prefix match to keepghread-only. The log is third-party text — compiler, package-manager and dependency output — so the prompt frames it explicitly as data rather than instructions. Those instructions live in.github/agent-prompts/repair-build.mdrather than in the workflow or in.claude/skills/: a prompt in a plain file is reviewable on its own, diffable, and portable to another engine. Configuration:ANTHROPIC_API_KEY, the same secret the CHANGELOG entry uses, checked for presence before the agent is called so a missing one names itself instead of surfacing as an authentication error from inside a third-party action; andAGENT_CLAUDECODE_MODEL, which names the model and has no default.workflow_dispatchtakes arun_idand adry_runflag so the whole path can be rehearsed against a past failure instead of waiting formainto break — and a rehearsal against a commit that is no longer the tip is forced into a dry run, so it cannot open a reverting pull request. Every run says what it decided, and the commonest decision is not a pull request. A step summary in each of the first two jobs records whether an agent is configured at all, what the latestTestsrun onmainconcluded, which engine and model ran, and the verdict with its reasoning — so "the automation is switched off" and "the automation looked andmainis fine" stop producing identical-looking green runs. When the agent returnscannot-fixa fourth job files an issue carrying the diagnosis, keyed to the broken commit so one failure gets one issue. That outcome is the one this workflow reaches most often —mainwent red four times in five weeks here and every one was a flaky runner — and it used to leave nothing behind but an annotation, which is to say the agent's usual and correct answer was the one nobody was told about. An issue rather than a red run, because a daily failure for a condition that resolves itself is how people learn to ignore a workflow, and declining to act is the behaviour the prompt asks for rather than an error. The job that files it holds the App token and runs no agent, the same split the pull-request path uses, and the secret scan now covers both publication paths rather than only the pull request. A fifth job retires those issues oncemainis green again, so a report cannot outlive its subject and turn a useful signal into a list nobody reads — but it leaves alone anything somebody has commented on or assigned to themselves, because at that point closing it would be a bot overruling a person on a judgement it is not making. It reads the issue list on the read-only token and mints the App token only when there is something to close, so an ordinary green day creates no write credential at all. -
A second engine for the repair agent: OpenCode, on any provider —
vars.AGENT_ENGINEselectsclaude-codeoropencode, and neither is a default: an unset engine attempts no repair and says so, because something that writes into this repository should be named by a person rather than inherited from whatever a template shipped. The claim that the prompt was "portable to another engine" is now load-bearing rather than aspirational: both engines read the same.github/agent-prompts/repair-build.md, work on the same prepared evidence, and are judged by the same path allowlist, secret scan andverdict.jsoncontract afterwards. Only the agent call itself differs, which is what keeping it to a single step was for. The motivation is per-repository cost. The bill is dominated by cached input replayed on every turn, so it scales with the number of repositories rather than with how oftenmainbreaks; across three that is the difference between dollars and cents per month. Engines are compared on the whole workload rather than on sticker rates, because a cheap model without prompt caching can cost more than an expensive one with it. Neither engine's own GitHub Action is used. OpenCode ships one, but it is driven by/opencodecomments on issues and pull requests and opens the pull request itself. Both are things this workflow deliberately does not do — comment triggers carry someone else's text straight into an agent's context, and opening the pull request from the agent's job would put a write credential in exactly the job that is kept free of one. Installing the CLI keeps the three-job split intact. The permission model is translated rather than approximated, and lands tighter than the Claude Code side. It lives in.github/agent-config/opencode.json, outside the path allowlist, so the agent cannot widen its own permissions and have that ride along in the pull request. Commands are enumerated as exact strings with no wildcards at all: OpenCode matches glob patterns, so a trailing*would makemake test *also matchmake test && make release— andmake releasebumpspubspec.yaml, which is inside the path allowlist. Exact patterns cannot absorb a&&clause, so that closes structurally rather than by trying to blacklist shell operators.cargois absent entirely, since CLAUDE.md tells the agent never to call it directly.askis never used: a question in a headless run has nobody to answer it, and--auto, which approves everything not explicitly denied, is deliberately not passed. Two things surfaced from testing that config rather than reading about it.writeis a real tool but is not among the permission keys in OpenCode's published schema, so it must be granted explicitly or the agent cannot create the one file the workflow treats as proof it finished. And a"*": "deny"catch-all — which looked like obvious hardening — resolved before the command rules on one machine and after them on another, where last-match-wins would have let it silently override the entire allowlist; pinning the OpenCode version achieves what it was meant to without depending on rule order. The config now resolves identically whether or not a global~/.config/opencodeconfig exists, which is the property that makes a local rehearsal mean anything. The turn limit isstepsin that config; OpenCode has no--max-turns. On exhaustion it forces a text-only response, so the agent cannot write a verdict, so the run fails loudly — the same outcome as Claude Code running out of turns, reached by a different route. Which provider is used is configuration, not code. OpenCode reaches every provider it knows about through that provider's own environment variable, so the name of the variable isAGENT_OPENCODE_PROVIDER_ENV(defaultOPENROUTER_API_KEY) and the value is a singleAGENT_OPENCODE_API_KEYsecret. Pointing a repository at a different provider, a self-hosted gateway or a proxy is then two repository settings rather than a YAML edit repeated in every generated project and shipped through a template release — the same reasoning theAI_MODELSlist was built on, applied to the engine. A custom or self-hosted endpoint additionally takes aproviderblock with abaseURLin the OpenCode config; no workflow change is needed for that either. The name is validated before use, because a typo would otherwise surface as an OpenCode failure rather than as the settings mistake it is. Configuration:AGENT_ENGINE, theAGENT_OPENCODE_API_KEYsecret, and optionallyAGENT_OPENCODE_PROVIDER_ENVandAGENT_OPENCODE_VERSION;AGENT_OPENCODE_MODELnames the model and has no default. The model default is chosen for context rather than price — the prompt invites the agent to open the full log, which is capped at 400 KB and tokenises to far more than a small window holds, so a cheaper model with a 128K context would serve every ordinary run and fail on exactly the unfamiliar failure the full log exists for. The secret scan coversAGENT_OPENCODE_API_KEYalongsideANTHROPIC_API_KEYand scans both whichever engine ran, so adding an engine cannot quietly leave a key unscanned; because it matches the secret's value rather than a variable name, it keeps working whatever provider that value was handed to. -
An agent reviews pull requests, and is trusted with nothing —
ai-review.ymlreads the diff of a pull request opened from a branch in this repository and leaves one comment, updated in place on each push rather than added to. It uses the same three-job split as the repair workflow and for the same reason: the agent job holds no credential, not even one that could write a comment, and the job that publishes runs no agent. It gates nothing, deliberately. Published measurements of adversarially filtered LLM review put roughly four in five candidate findings in the false-positive bin, and one reported case had ten independent reviewers unanimously confirm a vulnerability that did not exist — killed only by running a test. So the reviewer has no way to say "approved": it emits findings or it emits none, and the absence of findings is the absence of findings rather than an endorsement. What decides whether a change is sound is the four-platform matrix and the lint gates, exactly as before. Before this is wired to anything that can block a merge, its false-positive rate should be measured by replaying merged pull requests through it — that number, not the catch rate, is what decides whether it can gate, because a blocker that cries wolf turns "automatic" into "automatic unless a model got fussy". It never sees a bot's account of the diff before forming its own. When the pull request was opened by automation, its title and description are simply not written to disk: that text is prose another model wrote to explain this same change, and reading it first produces a review of the explanation. A person's description is included, because an account of intent is context rather than a claim to audit. The prompt spends as much space on what not to report — anythingmake format-check,make analyze ARGS="--fatal-infos"ormake rust-clippyalready enforces, style, taste, ungrounded speculation — as on what to look for, since noise is what makes a reviewer stop being read. The reviewer is strictly read-only, with no tool that can write, edit or patch a file and no shell at all, and it reports through its reply rather than by leaving a file behind. That was forced by measurement: across seven runs the model this repository runs never called the write tool once, reaching instead for patch application, and denied that it produced a complete set of findings and then spent its remaining turns failing to save them. Reporting through the reply removes the need for any write permission, which makes "the reviewer does not change the repository" a property of the tool list rather than a promise — and the job asserts the working tree is untouched anyway. Both agents' resolved permissions are asserted against the engine before either runs, because a per-agent block in OpenCode is appended to the global one rather than replacing it, so a rule can read as narrower than it resolves. What it publishes is checked, not copied. The reply is cut out of the transcript by parsing rather than by pattern-matching a code fence, so a finding that quotes a fenced diff hunk no longer truncates the object and loses the whole review. Every finding is checked against the fields the prompt calls required, and one that is missing them is published with the omission printed beside it rather than as though it were whole; a severity the prompt does not define is read as blocking rather than quietly shown as a note. The list of files the reviewer claims to have read is cross-checked against the files it actually opened, and a claim the transcript does not support is removed and reported as removed — in the one clean control run, four of the six files it named had never been opened. Everything a model wrote is escaped before it reaches the comment, so a fence, a stray</details>or an@-mention in a quoted diff line cannot break out of its block or notify anybody. Configuration: none beyond the repair agent's.REVIEW_AGENT_ENGINE,REVIEW_AGENT_CLAUDECODE_MODELandREVIEW_AGENT_OPENCODE_MODELeach fall back to the repair setting, so a repository that configured one agent has both; the separate variables exist so that running the reviewer on a different model family is a settings change, which is worth having because a reviewer drawn from the same family as the writer shares its blind spots. There is no comment trigger:issue_commentruns in the base repository's context with secrets, which is the classic pwn request, so review of somebody else's pull request isworkflow_dispatch— the one manual trigger that already requires write access — and it takes adry_runflag so a rehearsal does not comment on a real pull request. Fork pull requests are refused explicitly rather than left to fail for want of a key, and so are Dependabot's: GitHub runs those without access to a repository's secrets, so the reviewer would have no key, and the refusal is a named skip rather than a red run on every dependency bump. On the Claude Code engine,REVIEW_ALLOWED_BOTSnames the bots whose pull requests may be reviewed — that engine refuses a non-human actor otherwise, and seven of the last eight pull requests here were opened by one. Every run writes what it decided to the job summary, since most runs of this workflow decide not to review and used to leave nothing but a green tick behind. The token that posts the comment is minted with one permission rather than with everything the App installation holds.
Changed
-
make release-frbno longer stamps its highlight into the middle of a sentence (scripts/src/release_frb.dart) —stampFrbHighlightinserted the**libsignal_frb vX.Y.Z**line atlastBullet + 1, one line after the first line of the last Highlights bullet. A bullet that wraps continues on indented lines that do not start with-, so the stamp landed inside it and split the sentence in half. Every previous release happened to have a single-line highlight there, which is why this only surfaced on the v6.1.2 stamp — and it surfaced in a section a release is about to freeze, where it could not have been corrected afterwards. Everything between the last bullet and the end of the block belongs to that bullet, so the insert point is now the block's last non-blank line. The regression test fails against the old insert. -
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. -
AI changelog: configurable provider list, replacing the retired GitHub Models — GitHub Models was retired on 2026-07-30, taking
make update-changelogand the CHANGELOG entry ofmake update-templatewith it. The replacement makes the model operational configuration rather than code:AI_MODELSholds an orderedprovider/modellist and the first entry that has a key and answers wins, so the next provider change is a repository-variable edit instead of a code change rolled out to every generated project through a template release — the exact cost this retirement imposed. There is no default list: withAI_MODELSunset nothing is called and the entry is simply not written, because a model that writes into this repository's CHANGELOG should be one somebody named rather than one the template picked. Keys without a list is a misconfiguration, not an opt-out, so that case warns instead of going quiet. Keys live one per provider (ANTHROPIC_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY), forwarded step-scoped in both workflows so an org-level secret is not handed to the third-party actions in the same job.AI_MODELS_TOKENis gone. It named no provider, so with one variable per provider there was nothing left for it to mean; it is removed from the scripts and from both workflows, and the secret can be deleted. Action required: setAI_MODELSand the per-provider keys before the next automated update runs — the old secret alone no longer writes anything. That case does not go quiet: keys present with no list is reported as a misconfiguration.AI_EFFORTtunes the cost/quality trade-off the same way. -
The AI answer is now a provider-enforced JSON contract, and a partial one is never salvaged — every provider is called through its own API (
scripts/src/ai_client.dart) with a JSON schema it enforces, viadart:iorather than acurlsubprocess: the HTTP status is what decides whether the next model is tried, and the key no longer sits in process arguments. A response that stops at the token limit or is refused is rejected before its content is read. Previously an unparseable answer was written into thechangedfield verbatim, which turned a malformed answer into a malformed CHANGELOG — a truncated response still contains a brace-delimited fragment that looks extractable. The next list entry is tried only when a model produced no answer at all (network, auth, rate-limit, server error, refusal, truncation); never on the content of an answer, which would make entries silently inconsistent, and never on a malformed request, so a bug in what is sent stays visible. Authentication is classified by response body as well as status — Google answers an invalid key with400 API_KEY_INVALIDwhere Anthropic uses401, and reading that as a malformed request would halt the walk at a misconfigured first entry instead of falling through to the second. -
An entry that calls a release breaking and harmless at once is refused — a run labelled the removal of a
libsignal-protocolhelper**BREAKING:**and then closed with "these changes do not affect this library's public API". Only one can hold: a change is breaking here when it touches the surface this package exposes, not merely because it lands in a crate this package binds, and the helper in question sat in the second category. The prompt now separates those two conditions and says so, and the contradiction is checked in code before the entry is written, because it is decidable from the text and telling users a release is breaking when it is not is the expensive direction to be wrong in. Failing leaves the entry unwritten and the pull request labelled for a person, which is the path a malformed answer already took. The check normalises typographic apostrophes first — a model writing prose reaches for’whatever the example shows, and a check that a curly quote walks straight through is worse than none, because it reads as a guard. -
OpenRouter is available as a third provider — one key for many models, so trying a different model costs neither a code change nor a new secret. Because it is an aggregator its model half carries its own slash, which the entry parser already handled:
openrouter/anthropic/claude-opus-5. Its API is OpenAI-shaped, which elsewhere is a deprecated side-door but here is the only interface it has, and it does support structured outputs. Two costs are worth knowing before putting it first: a third party sits on the path, and the schema guarantee is weaker — support is per model and per backing provider, andstrictis enforced exactly by some and treated as guidance by others. There being no default list, nothing selects it implicitly: it enters the priority order only through anAI_MODELSedit. A route whose model cannot do structured outputs is rejected outright rather than silently downgraded, and OpenRouter's habit of reporting upstream failures as anerrorobject inside an HTTP 200 is checked before the answer is read. -
The API key cannot reach a log — it is read from the environment and goes out in one request header per provider, never in a URL, in process arguments (the reason this moved off a
curlsubprocess), or in the prompt. Nothing logs it: the priority line prints model ids and variable names only. The one indirect path was the provider's own error body, which is quoted into logs and pull-request output — none of the three echo the key back, checked against a live rejection from each, but that is a property of their wording, so the key is now stripped from reported text regardless. -
The entry now reports the codegen result instead of being barred from mentioning it — the CHANGELOG step moved to after
Regenerate FRB bindings, which records whetherlib/src/rust/actually changed and passes it in as--codegen unchanged|changed|not-run. Given a result the model states it; given none it must stay silent on the subject. The same fact reaches the pull-request body, where it is the input to the stage-1 SemVer call: on a plain dependency bumpchangedis unexpected — codegen readsrust/src/api, which a bump does not touch — so it doubles as a tripwire for an upstream type this crate re-exports having moved. -
The changelog prompt can no longer inherit a finding along with the style — it is told to copy the house style but never a build result. Caught on the first live run: the model wrote "FRB bindings regenerate byte-for-byte identical", which it had no way to know — it ran no codegen, and the phrase came from earlier entries in the 150 lines of CHANGELOG it is given as a style reference. It was true this time because a human had checked it back then; on the first update where codegen does produce a diff, the same sentence would have been published as a falsehood. Its sibling prompt in
update_templatealready forbade inventing verification; this one now does too. -
The libsignal update PR is no longer the one PR that skips the test suite —
test.ymlexcludedupdate-libsignal-*branches, so the pull request whose entire payload is new native code was merged without the suite, clippy,rust-test,cargo-deny, the MSRV check orverify-third-party-noticesrunning against it on any of the four platforms. Confirmed on the last real one (#57): fuzzing reported seven passing jobs andtestreported skipping. The stated reason — the bump moves libsignal ahead of the publishedlibsignal_frbbinary — is already handled by the reusable workflow, which runsmake buildbeforemake test; the build hook then findsrust/target/releaseand returns without downloading (hook/build.dart), and nothing caches.dart_tool, so the first hook run happens after that build. Update pull requests now cost a full matrix run, which is the point of them. -
Pull requests now name the model that wrote the entry — reported through
--ci-output(ai_provider=<provider/model>) by both scripts. Without it a first provider that has quietly started failing shows up only as entries that drift in house style, months later. -
Empty upstream release notes are named as such — libsignal publishes every release with an empty body, so the prompt's release-notes section was blank, which reads to a model as "nothing changed" rather than "look at the commit list". Verified against the GitHub API across the last 15 releases.
-
scripts/src/ai_client.dartis covered by tests — 48 cases over the list parsing, key resolution, the two schema shapes and all three response parsers, including the four that would otherwise corrupt a CHANGELOG silently: an answer behind a leading thinking block, a truncated response, a refusal, and Gemini reasoning parts flaggedthought. -
Adopted copier template v4.4.0 → v4.6.0 (
.copier-answers.yml, and the files listed below) — two template releases at once, so most of this is arriving rather than being decided here.Check Template Updatesstops failing. It has been red on every run since 2026-08-16 — fifteen consecutive runs, one cause.create-pull-requestbegins withgit checkout -B <temp> HEAD, and git refuses that while the index holds unmerged entries:error: you need to resolve your current index first. Its owngit add -Acomes later and is never reached, so a conflicted update counted the conflicts, wrote the draft body naming them, and then died on git — producing no pull request at all, which is the one outcome the workflow exists to report. The unmerged paths are now staged before the pull request is created, which is what the draft is for. The same workflow also installs protoc now: it runsmake rust-checkas one of the gates it reports, and without protocspqr's build script panics withCould not find `protoc`, so that row read fail on every update. It never turned a run red — the gates are deliberately non-fatal — which is worse, because a row that is always red is a row nobody reads.The bind list moved out of the script (
.github/agent-prompts/changelog-scope.md,scripts/src/update_changelog.dart) — the "what this package binds and exposes" block that this repository added to its prompt is now a file the template creates once and never overwrites, so it can be edited without touching Dart and without conflicting on every template release. Moving it made room to say something the inline version left out:spqrreaches this package's users without being named anywhere inlib/orrust/src/api/, because it runs inside the Double Ratchet that is exposed. An upstream change to it satisfies neither condition in the prompt's rule 2 and is nevertheless user-visible, so the file says so explicitly.make verify-frb-pins(scripts/verify_frb_pins.dart,Makefile,.github/workflows/) — five files record the flutter_rust_bridge version and two of them are compared with==at runtime. The exact-range constraint above stops the resolver drifting; nothing stopped a person editing four of the five. The gate reads all five, rejects both a caret and the unpublishable bare form with the reason, and runs besideverify-third-party-noticeson the Linux leg — five file reads, no build. 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?=..copier-answers.ymljoins thetest.ymlpath filters, because a commit that edits onlyfrb_versionis exactly the commit that can put the pins out of step.Dependabot watches
pubandcargo(.github/dependabot.yml) — it watched onlygithub-actions, which is one reason the flutter_rust_bridge break arrived as a silent resolve rather than a reviewable pull request.flutter_rust_bridgeitself is ignored in both, because its version has to move in four places at once and a one-file pull request is wrong by construction;make verify-frb-pinscatches that instead. 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 bumpcheck-libsignal-updates.ymlexists to make — without codegen, the bindings tripwire, the CHANGELOG entry or the version badge.Two floating dev inputs pinned, one left floating on purpose (
pubspec.yaml) —pubspec.lockis deliberately not committed for a library, so CI re-resolves on every run.lintsis capped to one minor line becausemake analyze ARGS="--fatal-infos"turns any newly-added info-level lint into a build failure; this is precautionary rather than a live fix, since 6.1.0 is already published and was measured against this repository with nothing to report.ffigenkeeps its^20.1.1floor, which the template now shares.hooksandcode_assetsstay on the caret: they define the protocolhook/build.dartimplements and the SDK is the other half of it, so capping them below what the pinned Flutter expects breaks the hook at a consumer's build. Thetestjob's Rust toolchain also staysstablewhile the MSRV job pins, and the asymmetry is now written down where a reader asks about it.The App token is minted by Client ID (
.github/workflows/) —actions/create-github-app-token@v3deprecatesapp-id, and all six call sites now passclient-id: ${{ vars.APP_CLIENT_ID }}. This superseded the unmergedchore/app-token-client-idbranch entirely —pr-review.md,ai-review.ymlandrepair-build.ymlcame out byte-identical to it and the other two files gained the same change plus what the template brought — so that branch and its worktree have been deleted.Committing from a git worktree no longer breaks the pinned Flutter SDK (
.githooks/pre-commit) — arriving from the template, where the fix this repository made was ported. git exports an absoluteGIT_DIRfrom a worktree, every child process inherits it, andflutterthen reads the committing repository's HEAD to determine its own version and writes0.0.0-unknowninto the shared SDK's version cache. -
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-dart1.8.x is ignored, temporarily and narrowly (.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 looks under.github/actions/setup-fvm/, does not find it, and fails the job fourteen seconds in, before anything is built. It failed that way on all four platforms.Only the 1.8 line is ignored, so 1.9.0 arrives for evaluation rather than this freezing the action; 1.8.1, the latest at the time of writing, does not fix it. The bump is 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.
7.1.0 - 2026-08-15 #
For Users #
✨ Highlights
-
Every Signal Protocol message type is now inspectable from Dart —
PreKeySignalMessage,SenderKeyMessage,SenderKeyDistributionMessageandPlaintextContentjoinSignalMessageandDecryptionErrorMessage. Messages could be produced and consumed but never read, which is why a group ciphertext's owndistributionIdhad to be known out-of-band and a session's first post-quantum ratchet payload was unreachable (#62) -
flutter testworks again for Flutter apps that depend on this package (#63) — on macOS and LinuxLibSignal.init()could not find the native library the build hook had just provisioned for the test runner, so a dependent package's own unit tests failed on a clean checkout while the app itself built and ran fine -
Sealed sender gains content hints, group ids and multi-recipient fan-out —
UnidentifiedSenderMessageContentmakes the sealed envelope itself addressable, so a recipient can learn whether an undecryptable message is worth a resend request without decrypting it;sealedSenderMultiRecipientEncryptWithCallbacksproduces one Sealed Sender v2 message for a whole group -
libsignal v0.101.0 — upstream's work this release is in zkgroup, zkcredential and the net/chat APIs; nothing moved in the crates this package binds
-
libsignal_frb v6.1.1 — Rust FFI bindings
Changed
-
New
PreKeySignalMessagetype (#62) —PreKeySignalMessage.deserialize(data: bytes)plusserialize(),messageVersion(),registrationId(),preKeyId(),signedPreKeyId(),kyberPreKeyId(),kyberCiphertext(),baseKey(),identityKey(),message()andcloneMessage().message()returns the wrappedSignalMessage, which is what makes a session's very first post-quantum ratchet payload readable:PreKeySignalMessage.deserialize(data: ct).message().pqRatchet(). Purely additive — nothing in the existing surface changed, and decryption still goes throughSessionCipher, which owns the stores this type has no access to. As withSignalMessage.deserialize, parsing validates structure but does not authenticate: the inner MAC is only checked during decryption, so anything read off an un-decrypted message is attacker-controlled -
New
SenderKeyMessageandSenderKeyDistributionMessagetypes —distributionId(),chainId(),iteration(),messageVersion(), plusciphertext()andverifySignature()on the message andsigningKey()on the distribution message. This is what lets a recipient derive thedistributionIdthatGroupCipher.decryptandprocessDistributionMessagerequire, instead of having to carry it alongside the ciphertext. The distribution message's chain key is deliberately not exposed: it is secret key material, there is no constructor to pair it with, and an accessor would only add a way to leak it -
New
PlaintextContenttype —PlaintextContent.fromDecryptionErrorMessage(...)builds the envelope aDecryptionErrorMessagetravels in, which previously could be parsed from an incoming message but not produced, leaving the retry-receipt flow half-implemented. NoteDecryptionErrorMessage.extractFromSerializedContenttakesbody(), notserialize()— it rejects the leading identifier byte -
New
UnidentifiedSenderMessageContenttype andContentHintenum — build the inner payload of a sealed sender message yourself to set a content hint (none/resendable/implicit, with unknown values passed through) and a group id, then seal it withsealedSenderEncryptFromUsmcWithCallbacks.sealedSenderDecryptToUsmcWithCallbacksgoes the other way: it returns the envelope without decrypting the message inside, which is how a client reads the hint for a message it cannot decrypt. It requirestrustRoot,timestamp,localName,localDeviceIdandgetIdentity, and runs all three of the checks that stand betweenSealedSenderCipher.decryptand its plaintext — see Security below. An empty group id is omitted from the serialized form, so it reads back as absent after a round trip -
Sealed Sender v2 multi-recipient encryption —
sealedSenderMultiRecipientEncryptWithCallbacksencrypts oneUnidentifiedSenderMessageContentfor many destinations at once, producing the single SentMessage blob a server fans out;sealedSenderV2ParseSentMessagereads that blob so it can be split per recipient, returning each recipient's service id, devices with their registration ids, and the offsets of that recipient's message within the blob.SealedSenderV2SentMessage.receivedMessageFor(recipient:, data:)assembles one recipient's ready-to-deliver message from them — one at a time, so the shared body is never copied per recipient. Excluded recipients are listed with no payload. Sessions are read but never advanced, so nothing needs storing. Two constraints the single-recipient path does not have: destination address names must be service ids (a bare UUID orPNI:<uuid>), and destination registration ids must fit in 14 bits (0..=16383). Identities are resolved throughgetIdentityand an unknown one is refused rather than trusted on first use — per contiguous run of destinations sharing an address name, from the run's first destination, which is what Sealed Sender v2's per-account key material requires. Group each account's devices together in the list and the behaviour is uniform; SECURITY.md has the table -
libsignal v0.100.0 → v0.101.0 — nothing upstream changed in the surface this package binds. Across the whole range, the only file touched in
libsignal-protocol,libsignal-coreandsignal-cryptoisrust/core/src/version.rs, the version constant itself;make codegenagrees, producing byte-identical bindings. Upstream's work went to zkgroup (GenericServerSecretParamsandGenericServerPublicParamsdropserde::Deserializein favour ofTryFrom<&[u8]>, and call-link credentials now record which params version issued them), zkcredential, and the typed net/chat APIs (submitCallQualitySurvey(),AuthUsernamesService.confirmUsername()) — none of which this package exposes. The BoringSSL bump that comes with it (boring-rs v5.2.0) does not reach the binary either:boringis not in this crate's dependency graph at all. The remaining lockfile movement is patch-level transitive bumps
Security
-
sealedSenderDecryptToUsmcnow checks identity trust, not just the certificate chain — it validated the sender certificate against the trust root but never compared the identity key that certificate carries against the one stored for that sender, while its doc comment claimed parity withSealedSenderCipher.decrypt. A certificate that chains to the trust root but binds a different identity key was unsealed and reported as that sender, silently — where the decrypt path refuses the identical message withuntrusted identity. The everyday form of this needs no attacker at all: a peer who re-registers gets a valid certificate carrying a new identity key, which is a safety-number change, and a caller using the envelope to decide who to send a resend request to would never have seen it. The function now takes agetIdentitycallback and applies the same rule as everywhere else in the bridge — nothing stored is first use and is accepted, a stored identity must match — raising the sameuntrusted identityerror. Order is enforced too: the certificate chain is checked before the store is consulted, so an unvalidated (attacker-chosen) sender name can never drive a store lookup. Action required: passgetIdentity— the same callback you already givesealedSenderDecryptWithCallbacks. This changes the signature announced in #62; nothing on pub.flutter-io.cn shipped with the old one -
sealedSenderV2ParseSentMessageno longer amplifies its input — it built each recipient's message as its own byte array, and since every recipient's message ends with the same shared body, output grew as roughlyrecipients × message size: a 570 KB input measured at 1 GB of output, an amplification that rises with the square of the input. It now returnskeyMaterialStart/keyMaterialEndper recipient plus onesharedBytesOffset, which isO(recipients)whatever the body size, andSealedSenderV2SentMessage.receivedMessageForbuilds a single recipient's message on demand. This is also how libsignal expects a fan-out server to work —range_for_recipient_key_materialandoffset_of_shared_bytesexist for it. A message larger thanu32::MAXis now rejected rather than having its offsets truncated. Action required: replacerecipient.receivedMessagewithparsed.receivedMessageFor(recipient: recipient, data: theSameBytes) -
Sealed sender now detects a self-send, on both paths — upstream's
sealed_sender_decryptrefuses a message whose sender certificate names the receiving device, so a server that reflects your own sealed message back at you cannot have you process it as incoming. This package rebuilds that function out of its parts rather than calling it, and the check was lost on the way:sealedSenderDecryptWithCallbacksunsealed and decrypted such a message, andsealedSenderDecryptToUsmcWithCallbacksunsealed it and reported you as the sender — enough for a caller to aim a resend request at itself. Both now raiseself send of a sealed sender message, before any store is touched. The check compares the service id and the device id, so another device of your own account is unaffected. Action required:sealedSenderDecryptToUsmcWithCallbackstakeslocalNameandlocalDeviceId, the same twosealedSenderDecryptWithCallbacksalready took.sealedSenderDecryptWithCallbacksis unchanged in shape -
Secrets now survive a panicking store callback — a Dart store callback that throws unwinds the Rust worker thread (Flutter Rust Bridge declares these callbacks non-failable), and every secret in the sealed-sender path was cleared by a
zeroize()written after the work, which that unwind skips. A productionIdentityKeyStore.getIdentityover a locked database was enough to leave an identity key pair — and, for a multi-recipient send, every destination'sSessionRecord— in freed memory. Clearing is now tied toDrop(Zeroizing, and a guard around the destination list), so it happens on return, on error and on unwind alike. TheSessionRecordloaded during a sealed-sender decrypt, which was never zeroized at all, is covered too -
SealedSenderV2SentMessage.receivedMessageForrejects a buffer that is not the parsed one — it documented that it throws whendatadoes not match the parsed message, but only checked that the offsets fit. A buffer merely longer than the parsed blob was accepted and the shared run silently extended, so the delivered message grew a tail the blob never had. The parse result now carriesparsedLengthand any other length is refused. A different buffer of the same length still cannot be told apart — the doc now says that outright rather than implying otherwise.datais alsoList<int>now, matching every other byte parameter in the generated API
Fixed
-
LibSignal.init()now works underflutter test(#63) — the build hook registers the native library as aCodeAsset, but apackage:asset id is not a path: it cannot bedlopened, and Dart offers no way to ask for a registered asset's file location, so the library has to be found on disk. Only thedart run/dart testand AOT-bundle locations were probed.flutter testinstalls the very same hooked library underbuild/native_assets/<os>/and never creates.dart_tool/lib/, so on macOS and Linux the unit tests of every Flutter package depending onlibsignalfailed inLibSignal.init()on a clean tree, while the app itself built and ran fine. Windows resolved it by accident: flutter_tools prepends that directory to the test runner'sPATH, which is where Windows looks for a DLL. A leftover.dart_tool/lib/from a previousdart testwas what made it look intermittent. That directory is now probed too — last, after the AOT bundle, so a library that happens to sit in the working directory can never shadow the one a compiled application shipped with. Workaround on older versions: pass the path explicitly, e.g.LibSignal.init(libraryPath: 'build/native_assets/macos/liblibsignal_frb.dylib')(.../linux/liblibsignal_frb.soon Linux) -
A sender key distribution message processed under the wrong distribution id is now refused instead of silently dropped —
GroupCipher.processDistributionMessagetakes the distribution id from the caller, but aSenderKeyDistributionMessagealso carries one, and libsignal stores the new sender-key state under the id inside the message. When the two disagreed the state was written to a key the wrapper never reads back. On first contact that surfaced as an error, but when a record already existed under the caller's id the read-back returned that stale record, so the call succeeded while discarding the distribution message — the group's later messages then failed to decrypt with a misleading "Process a distribution message first." The ids are now compared up front and a mismatch throwsDistribution ID mismatch: message carries <x>, caller passed <y>. The matching-id path is unchanged.GroupCipher.decryptwas already fail-closed on the same mismatch and is untouched
For Contributors #
Added
-
Native-asset probe-order coverage —
test/platform/native_asset_search_paths_test.dartpins that theflutter testinstall directory is in the probe list, spelled as a per-host literal rather than by recomputing the implementation's ownPlatform.operatingSystemexpression, and that it stays behind the AOT bundle. Nothing inmake testcould have caught #63 —dart testalways resolves through.dart_tool/lib/, so the first probe wins there. The only end-to-end guard would be aflutter testleg overexample/, which does not exist yet -
PreKeySignalMessagetest coverage —test/protocol/prekey_signal_message_test.dartcovers the serialize round-trip, every accessor against the keys the session was actually built from, that inspecting a message does not consume it, and that malformed input (including a bareSignalMessage) is rejected -
Distribution-id mismatch coverage —
test/groups/distribution_id_mismatch_test.dartpins the refusal in both branches (with and without an existing record) and that two independent groups still round-trip -
Coverage for the new message and sealed-sender types —
test/groups/sender_key_message_inspection_test.dart,test/protocol/plaintext_content_test.dartandtest/sealed_sender/usmc_and_multi_recipient_test.dart, including a full multi-recipient round trip where two recipients each unseal their own message, and the refusal of an untrusted destination -
Differential tests for the Sealed Sender v2 fan-out —
rust/src/ssv2_equivalence_tests.rs(run bymake rust-test, and by CI) puts a corpus of crafted SentMessages plus a real multi-recipient message through both this package's parser and libsignal's, and asserts the message Dart reassembles from the returned offsets is byte-identical toreceived_message_parts_for_recipient. Moving that assembly out of Rust is the one place in this release where logic was rewritten rather than added, and nothing else pins it: an upstream change to the SentMessage layout would otherwise surface as multi-recipient messages quietly failing to decrypt. The same file sweeps every truncation and a few thousand byte mutations for panics, and pins that its own comparison can fail -
Identity-trust regression coverage for the sealed-sender envelope —
test/sealed_sender/decrypt_to_usmc_identity_trust_test.dartrunssealedSenderDecryptToUsmcandSealedSenderCipher.decryptagainst the same forged-certificate message and requires both to refuse it, pins trust-on-first-use for an unknown sender, and asserts thegetIdentitycallback is never reached when the certificate chain fails. Its absence is what let the gap ship.usmc_and_multi_recipient_test.dartgains the fan-out reconstruction properties and a case pinning that an unknown destination identity is refused per contiguous run rather than per device -
SPQR progress regression test —
test/protocol/spqr_ratchet_progress_test.dartruns a 200-round-trip alternating conversation and decodes the epoch and payload type out of eachSignalMessage.pqRatchet()frame instead of measuring its length. Every chunk-bearing SPQR frame is the same ~37 bytes (the encoder chunks all ML-KEM material at 32 bytes), so length says nothing about progress; the epoch does. The test asserts both sides pass epoch 1 — which requires a full ML-KEM encapsulation to have completed across ~400 store round-trips per side — and that the responder answers withCt1on exactly its third send, i.e. as soon as the third header chunk has arrived, pinning that the PreKey decrypt path applies and persists its inbound SPQR chunk — chunk 0 of that header is read straight off the PreKey message through the newPreKeySignalMessage.message(). A second case pins the responder's 4-byteNoneframes while the header is still incomplete as expected behaviour (reported as #62)
Changed
TestPartymoved totest/test_helpers/test_party.dart— it lived insidesession_cipher_test.dartand was already being imported across test files; it is now a proper helper library alongsidesession_helpers.dart
7.0.2 - 2026-08-08 #
For Users #
✨ Highlights
- libsignal v0.100.0 — dependency update only: the single change reaching the crates this package binds removes a helper this library never called, and the FFI surface regenerates byte-for-byte identical
- libsignal_frb v6.0.2 — Rust FFI bindings
Changed
- libsignal native library → v0.100.0 (compare)
- The range covers two upstream releases. v0.99.4 — upstream's own summary is "SVRB: 2026Q1 to previous", "SGX: Enforce TCB number in evidence" and "Backups: Validate the new
blockedAtTimestampfield on Contact and Group" — lands entirely inrust/net,rust/attestandrust/message-backup, alongside aLogSafeDisplayforsocks::Protocoland the Java/Kotlin binding generators. None of that is exposed by this library, and in the three crates this package binds (libsignal-protocol,signal-crypto,libsignal-core) its only diff is theVERSIONconstant - v0.100.0 is the minor bump, and the one release in range that touches a bound crate. Upstream summarises it as "SPQR: Remove requirePqRatio argument for sessions, instead requiring for all sessions". Concretely,
should_use_nonpq_session()is deleted fromlibsignal-protocolalong with its re-export and its test — the helper that decided, from a server-supplied ratio, which non-post-quantum sessions to keep and which to archive during the post-quantum ratchet rollout — and upstream's ownSessionRecord_HasUsableSenderChainbridge drops the matchingrequirePqRatioargument, so it now always demandsNotStale | EstablishedWithPqxdh | Spqr - The removal does not reach this package. It never called or exposed
should_use_nonpq_session: choosing a migration ratio is an application's policy question rather than a protocol binding's, andSessionRecord.hasUsableSenderChain()here is this package's own FRB binding, which never carried the argument upstream has now dropped. The release build is clean andmake codegenreproduceslib/src/rust/byte-for-byte, so the FFI surface is unchanged and the binding's signature is the same on both sides - Also in range but out of reach:
UnauthBackupsService.listBackupMedia, a new typed API in therust/netchat layer this package does not bind, and a zkgroup fix that stops invalid curve points being treated as candidate profile keys —zkgroupis not in this package's dependency graph at all - Upstream prepared a v0.99.5 that was never tagged, which is why two releases span three version numbers
- Both upstream GitHub releases carry an empty body; the summaries quoted above come from upstream's in-repo
RELEASE_NOTES.md, and the per-crate analysis is derived from the commit range - Transitively, the shipped binary picks up
libsignal-debug0.99.3 → 0.100.0,zerocopy0.8.55 → 0.8.56, anddata-encoding2.11.0 → 2.11.1 with itsdata-encoding-macro0.1.20 → 0.1.21 wrapper.zerocopy-derive,data-encoding-macro-internalanddelegate-attrmove as well but are proc-macros, andaho-corasick1.1.4 → 1.1.5 andregex-automata0.4.16 → 0.4.18 enter the graph only throughprost-build, a build-dependency oflibsignal-protocolandspqr— so none of those five reach the binary.THIRD_PARTY_NOTICES.txtis regenerated to match
- The range covers two upstream releases. v0.99.4 — upstream's own summary is "SVRB: 2026Q1 to previous", "SGX: Enforce TCB number in evidence" and "Backups: Validate the new
7.0.1 - 2026-08-03 #
For Users #
✨ Highlights
- libsignal v0.99.3 — dependency update only: nothing in the libsignal crates this package links changed beyond added tests and version strings, and the FFI surface regenerates byte-for-byte identical
- libsignal_frb v6.0.1 — Rust FFI bindings
Changed
-
libsignal native library → v0.99.3 (compare)
- Upstream work across v0.99.2 and v0.99.3 targets the chat/backup transport, key transparency, the SVR2 enclaves and their attestation, a PNI-less zkgroup
AuthCredentialAPI, and the Node/Java/TypeScript bindings — none of which this library exposes - Of the crates from that repository which reach the binary — the three this package binds (
libsignal-protocol,signal-crypto,libsignal-core) plus the transitivelibsignal-debug— the only source change in either release is two added#[test]functions covering HPKE invalid inputs insignal-crypto; everything else is theVERSIONconstant. The FRB bindings regenerate byte-for-byte identical, so the FFI surface is unchanged - Neither upstream release published release notes, so this entry is derived from the commit range rather than from a changelog
- Transitively, the shipped binary picks up
aes0.9.1 → 0.9.2 andhybrid-array0.4.13 → 0.4.14 (the RustCrypto array crateaesis built on).cc,clang-sys,displaydoc,eitherandtoml_parseralso move, but reach this crate only as build-dependencies or through proc-macro subtrees, so none of them ship.THIRD_PARTY_NOTICES.txtis regenerated to match
- Upstream work across v0.99.2 and v0.99.3 targets the chat/backup transport, key transparency, the SVR2 enclaves and their attestation, a PNI-less zkgroup
-
Encryption of store contents at rest is documented — every record a store persists serializes with its private key material included, and the library holds no key to encrypt it with: it is a pure Dart package with no platform-channel access, so it cannot reach Keychain, Android Keystore, DPAPI or libsecret, and on the web no key source exists that does not require a passphrase each session. A new
SECURITY.mdsection gives the sealed-store pattern on the already-publicAes256GcmSiv+hkdfDerive— KEK installed once as an opaque handle, AAD bound to the slot being read, nonce rules and why GCM-SIV rather than GCM, a format version byte — plus a per-platform table of where the KEK comes from and an explicit statement that this protects against an attacker who reads your storage, not one executing code in your process
For Contributors #
Changed
-
.fvmrcno longer drifts on everymake codegen—flutter_rust_bridge_codegenshells out tofvm install, andfvm installrewrites.fvmrcand.vscode/settings.jsonwhenever they are not already byte-identical to what it would emit. The committed files were not: fvm orders the keysflutter, flavors, runPubGetOnSdkChanges, updateVscodeSettings, updateGitIgnoreand writes no trailing newline, and it rewritesdart.flutterSdkPathto the version-pinned.fvm/versions/<v>. So every codegen run left two modified files behind, and the nightly libsignal-update workflow — which runs codegen and thencreate-pull-requestwithoutadd-paths— swept them into its PR commits..fvmrcis now committed in fvm's own serialization withupdateVscodeSettings: false, which makesfvm installa byte-level no-op on both files; verified by runningmake codegenand comparing checksums. fvm writes the file with Dart'sJsonEncoder.withIndent(' ')+writeAsStringSync, which emits LF and no trailing newline on every platform, so.fvmrcis also marked-textin.gitattributes— otherwise a Windows checkout under the defaultcore.autocrlf=truegets CRLF, never matches, and is silently rewritten on every install..vscode/settings.jsondeliberately keeps.fvm/flutter_sdkrather than fvm 4's version-pinned path: the symlink is still created by fvm 4, so it works on fvm 2, 3 and 4 alike, while.fvm/versions/3.38.4breaks for anyone on fvm 2.x and needs editing on every Flutter bump. Leaving the file to fvm was the worse option in any case — where fvm has no privileged access (Windows without Developer Mode, where it also creates neither symlink) it writes an absolute, machine-local SDK path into this committed file. The one cost is a[WARN] You are using VSCode, but fvm is not managing VSCode settingsline on each install; do not "fix" it by removing the setting -
The pre-commit hook reports a missing toolchain as a missing toolchain — any failure of step 1 was announced as
Formatting check failed. Run 'make format', so a hook run from an IDE or GUI git client — which inherits a minimal PATH and cannot findfvm,makeorcargo— sent you looking at your code instead of your PATH. The hook now appends the usual install locations before the first check — appended rather than prepended so a tool deliberately placed earlier in PATH keeps winning, and covering both the Unix (~/.pub-cache/bin) and the Windows/Git-Bash (%LOCALAPPDATA%\Pub\Cache\bin) pub-cache layouts, honouringPUB_CACHE/CARGO_HOME, and adding only directories that exist. It then checksmake,fvmandcargoare present up front, and distinguishes exit 127 from a genuine check failure so a broken environment is never reported as a code problem. Both the old and new hooks areshellcheckclean -
Discard FVM config changesinsetup-fvmis documented as a guard, not a fix — a step in a composite action can only clean up after that action, whilefvm installalso runs later in the job from insidemake codegen, so its position was never the defect. Comment only; the config change above is the actual fix -
make setup-repo-protectionsnow turns on automatic head-branch deletion — the script applied rulesets and thenative-buildenvironment but never touched repo settings, sodelete_branch_on_mergesat at GitHub's default of off and every merged branch stayed forever; 42update-libsignal-*branches had accumulated since v0.86.10 (deleted, and each is still reachable through its pull request'srefs/pull/<n>/head).delete-branch: trueonpeter-evans/create-pull-requestdoes not cover this — it only removes branches the action itself closes as obsolete. The script now also sendsPATCH repos/<slug>withdelete_branch_on_merge=true, warning rather than failing when it cannot. Note that GitHub performs the deletion as whoever merged the pull request, so theDelete branchesruleset confines it to that ruleset's bypass actors (repository admins here); for anyone else it quietly does nothing, which leaves the branch exactly where the setting being off would have left it -
A mistyped signing passphrase no longer aborts a release, and an interrupted one is resumed by re-running the same command —
gitsigns a commit or a tag by shelling out tossh-keygen -Y sign, which reads the passphrase exactly once and callsfatal()on a failed load rather than re-prompting. One typo therefore killed the release wherever it happened, and the position that hurts is between the commit and the tag, because that state blocks its own recovery: the version bump is committed, no tag exists, and re-running trips the "must be greater than the current version" precondition. Both stages now route every signing and push step throughrunInheritRetry, which prints the failure and runs the step again, so the prompt simply comes back the waysshandsudobehave — Ctrl-C is the way out, which works becauseinheritStdiodelivers the interrupt to the whole foreground process group. The loop is uncapped (an attempt limit would reinstate the failure it exists to prevent), a non-interactive stdin throws on the first failure so CI behaviour is unchanged — tested viastdin.echoMode, deliberately nothasTerminal, which calls a run redirected from/dev/nullinteractive — and from the third consecutive failure it paces itself at two seconds so a step failing in milliseconds cannot scroll past faster than it can be read.alreadyDoneis consulted after a failure so a step whose effect already landed reports success instead of being attempted twice, andbeforeRetryre-stages the release files before each commit retry, because our own pre-commit hook runsmake rust-check, whosecargo checkrewritesrust/Cargo.lockwhen the crate version moved. Separately, a Ctrl-C or a closed terminal is now recognised:isResumableReleaserequires all of a clean tree, the version file already reading exactly the requested version, andHEAD's subject equal to the exact subject the release writes (held in onecommitSubjectvariable passed both togit commit -mand to the predicate, so the two cannot drift apart), and a leftover tag is accepted only when it is this release's tag and points atHEAD. Interrupting before the commit is the one case nothing can report at the time, so the "working tree is not clean" error now usesonlyTheseFilesDirtyto name the singlegit restorethat discards the release's own edits — declining to suggest one for an untracked path or a rename, where the command would not work or would take something else with it. Covered by a newtest/scripts/release_common_test.dart(13 cases over both predicates); the retry loop's own I/O is driven by a terminal by construction and was verified against a pty upstream instead -
copier template adopted: v4.1.0 → v4.2.0 — three of the five commits in this range are the template's adoption of fixes made here first (the
.fvmrc/.vscode/settings.jsondrift, the pre-commit hook's PATH handling, anddelete_branch_on_merge), and all three came back byte-identical, socopier updateleft those files untouched. The template's fourth fix — that itspre-commithook shipped mode 644 and therefore never ran in a generated project — never applied here: this repo's hook has been 755 since it was added. What actually lands is the release-script work above, plus two documentation carriers for a decision this repo already made:.vscode/settings.jsongains the header explaining why it is committed and whyfvm install's "removeupdateVscodeSettings: false" warning must not be acted on, andCONTRIBUTING.mdgains an Editor Setup (FVM) section saying the same for contributors, including the note that Windows needs Developer Mode before the firstfvm installfor the.fvm/flutter_sdksymlinkdart.flutterSdkPathpoints at. Adopting the release-script change now is deliberate: no release is in flight, so unlike the v3.0.2 adoption it cannot alter the behaviour of a run already under way -
copier template adopted: v4.2.0 → v4.3.0 — the template now applies its own updates instead of only announcing them:
make update-template(scripts/update_template.dart,scripts/src/update_template.dart, and atest/scripts/update_template_test.dartcovering the unmerged-path parser and the CHANGELOG insertion) runscopier update, reports what it could not merge, and files the adoption entry; the scheduled workflow runs it and opens a pull request carrying the result, the way the libsignal update workflow already does. It reports two failure modes separately because both are quiet: conflicts leave both sides in the file and make the pull request a draft — nothing else catches them, sinceformat-check,rust-checkandanalyzeread only Dart and Rust while copier's conflicts land in Markdown — and.copier-answers.ymlfailing to move_commitfails the job after the pull request exists, because that state merges as an un-updated project and re-opens the same pull request forever. Copier is pinned (copier==9.11.1,jinja2-strcase==0.0.2) for the reason the actions are pinned by SHA: this runs unattended, and a copier release that changed how it merges would arrive as a conflict-shaped diff rather than a clean failure. The gates the pre-commit hook runs are executed and reported in the pull request body but never enforced — a template update that breaks a gate is precisely the one a human most needs to seeAlso fixed: the
git restorehint added in v4.2.0 never fired. The release scripts readgit status --porcelainthroughgit(), which trims its output; the two status columns are positional, 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, because a release edits its files without staging them. Both scripts now read the status through agitStatus()that strips only trailing newlines, and a test pins the two shapes against each other so a future trim cannot pass unnoticed. This is why the update was taken before the release rather than after itThe fourth commit in the range releases the template repository itself and touches nothing under
template/, so it does not reach here.copier updateproduced no conflicts and no.rejfiles, and_commitlanded on v4.3.0 unaided; none of this repository's standing divergences (fuzz.yml,SECURITY.md,CLAUDE.md's two-stage Release Flow, the rulesets' populated bypass actor,scripts/src/update_changelog.dart's project-specific prompt) were in range —CLAUDE.mdtook a single new line in its command list
7.0.0 - 2026-07-30 #
For Users #
✨ Highlights
- Kyber pre-keys are marked used on every decryption path, with libsignal's full argument list — (breaking) closes a gap where
SealedSenderCipher.decryptconsumed a Kyber pre-key without ever telling the store, and widensKyberPreKeyStore.markKyberPreKeyUsedto the three arguments libsignal's own store trait receives, so last-resort anti-replay becomes implementable - Pre-key consumption follows libsignal instead of guessing at it — a redelivered pre-key message no longer re-consumes the one-time keys libsignal deliberately left alone, and the session is now persisted after those writes, so a crash between the two cannot leave a one-time pre-key usable forever
- Store durability, write ordering and rollback are a documented contract — every store interface and cipher, plus a new
SECURITY.mdsection, now state what your implementation has to guarantee. This corrects rather than extends the previous advice: a lock inside the store leavesload → ratchet → storeunprotected, so two concurrentencryptcalls for one address derive the same message key THIRD_PARTY_NOTICES.txtships with the package — the prebuilt native library is statically linked against its Rust dependency tree, and those licences require the notices to travel with a binary, including an application that embeds it. Signal's own AGPL-3.0-only crates are named there alongside the permissive majority- libsignal v0.99.1 — unchanged this release
- libsignal_frb v6.0.0 — Rust FFI bindings
Changed (Breaking)
-
KyberPreKeyStore.markKyberPreKeyUsednow takes the signed pre-key ID and the sender's base key — the signature changes frommarkKyberPreKeyUsed(int kyberPreKeyId)tomarkKyberPreKeyUsed(int kyberPreKeyId, int signedPreKeyId, PublicKey baseKey), mirroring libsignal'sKyberPreKeyStore::mark_kyber_pre_key_used. Previously only the Kyber ID reached Dart, so the last-resort check that trait documents ("check whether the same combination of pre-keys was used with the given base key before") was impossible for a consumer to implement — the data simply never arrived. Action required: update yourKyberPreKeyStoreimplementation to the new signature. Retiring a one-time key still only needskyberPreKeyId; for a last-resort key, record the(kyberPreKeyId, signedPreKeyId, baseKey)triple and treat a repeat as a replayed pre-key message. SeeKyberPreKeyStore.markKyberPreKeyUsedand limitation 5 inSECURITY.mdfor what a detected repeat can and cannot do -
SealedSenderDecryptResult.preKeyToRemoveremoved — only affects callers of the raw generated API (sealedSenderDecryptWithCallbacks);SealedSenderCipher.decryptis unchanged for its users. Sealed-sender decryption now takesremovePreKeyandmarkKyberPreKeyUsedcallbacks, which the bridge invokes itself in libsignal's order, rather than returning an ID for the caller to act on afterwards — matching howSessionCipher.decrypthas always worked. Action required: if you call the raw function, pass the two new callbacks and delete your post-callremovePreKeyhandling
Changed
- The package ships
THIRD_PARTY_NOTICES.txt— the prebuilt native library is statically linked against its Rust dependency tree, and those licences require their 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 is generated from the resolved dependency graph with no platform filtering at all, so the same commit yields the same file on any machine — build edges are included because that is how vendored native code reaches the binary — and CI verifies it stays in sync withCargo.lock. It is not an inventory of permissive licences: Signal's own crates in that graph (libsignal-protocol,libsignal-core,signal-cryptoand their siblings) are AGPL-3.0-only, and they are named alongside the MIT / Apache-2.0 / BSD / ISC majority, with the README's new Third-party notices section pointing at LICENSE.libsignal for what that means when you redistribute a binary. Where a crate ships no licence file of its own, 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:, which would bundle it into every consuming application whether or not it is ever displayed; the README shows how to register it withLicenseRegistryfor an app that wants it at runtime
Security
-
Store durability, write ordering and rollback are now a documented contract — storage is delegated to the application, and libsignal derives message keys deterministically (the Double Ratchet has no per-message nonce guard), so a store write that is lost to a crash or rolled back by a restore makes the next send reuse a message key and IV. The contract is now stated where implementers read it: on every store interface (
SessionStore,IdentityKeyStore,PreKeyStore,SignedPreKeyStore,KyberPreKeyStore,SenderKeyStore), onSessionCipher/SessionBuilder/SealedSenderCipher/GroupCipher, and in a newSECURITY.mdsection. No behaviour change — the library already awaited every store-write callback before returning a ciphertext or plaintext (verified against the Rust bridge for every entry point); what was missing was the requirement that your callback not complete until the write is durable- Durable before release — a store write must reach stable storage before the operation's output leaves the device or is acted upon, either inside the callback (
fsync, SQLitesynchronous = FULL) or via a transaction committed before sending. Deletes and pre-key consumption (removePreKey,markKyberPreKeyUsed) count as writes - Serialize per address — corrects the previous guidance in
SECURITY.md§H, which suggested a lock inside the store: that leaves theload → ratchet → storewindow unprotected, so two concurrentencryptcalls for one address derive the same message key with no crash involved. The lock must span the whole cipher call - Rollback — at-rest encryption gives confidentiality, not rollback protection; documents the achievable mitigation (bind the store to a marker in non-backed-up storage and treat a restored copy as a session reset) plus the platform limits of
fsyncon Apple platforms and of IndexedDB durability on the web
- Durable before release — a store write must reach stable storage before the operation's output leaves the device or is acted upon, either inside the callback (
-
SealedSenderCipher.decryptnow marks the Kyber pre-key it consumed — it removed the one-time EC pre-key a pre-key message consumed but never calledmarkKyberPreKeyUsedfor the Kyber pre-key on that same path, so a store that retires marked one-time Kyber pre-keys kept serving one that sealed sender had already consumed. Sealed sender is a normal delivery path for a first message, so this was the ordinary case rather than a corner. Both decryption paths now issue the same four writes -
Pre-key consumption now reports what libsignal actually did, and
storeSessionis written last — the bridge inferredremovePreKey/markKyberPreKeyUsedfrom the fields of the incoming message, while libsignal issues them only when the pre-key message really establishes a new session. A redelivered pre-key message matching an existing session therefore re-consumed keys libsignal had deliberately left alone. The bridge now observes the calls libsignal makes against the stores it is handed. That change requires the session to be persisted after the consumption writes (it previously went first): had the order stayed, a crash between the session write andremovePreKeywould let the redelivered message match the persisted session, consume nothing, and leave a one-time pre-key usable forever. The awaited-write table inSECURITY.mddocuments the new order
For Contributors #
Added
-
CI verifies the declared MSRV —
rust-version = "1.88"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 rather than 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, installs protoc —spqr's prost-based build script shells out to it, so without it the job would fail on tooling rather than on the MSRV it exists to check — and runsmake rust-check. Verified locally against 1.88 before the job was added; the reusablesetup-rustaction gained atoolchaininput (defaultstable) to make it possible -
Reference durable store in
example_cli(repository only —example_cli/is not part of the published archive) —lib/stores/durable_file_stores.dartimplements all six stores on an append-only journal that flushes before each write's future completes and replays on open, truncating a torn tail — which, without per-frame checksums, it cannot tell apart from a corrupt header, a limitation the file documents. It ships anAddressLockshelper for call-site serialization.lib/demos/durable_store_demo.dartproves the round trip: it establishes a session, exchanges messages, closes the stores, reopens them from disk and continues the same conversation.DurableKyberPreKeyStoredemonstrates both halves of the Kyber contract: a one-time key is retired on its first mark (loadKyberPreKeystops serving it), while a last-resort key stays in service and every(kyberPreKeyId, signedPreKeyId, baseKey)agreement is journalled, with repeats surfaced throughreplayedAgreements. The demo's final step exercises that second half end to end — it rolls Bob's state back the way a restored backup would, replays the same ciphertext, and shows the identical agreement being marked twice -
test/protocol/kyber_pre_key_consumption_test.dart— pins the two behaviours that had no coverage: a second pre-key message arriving on the session an earlier one established consumes nothing further, andSealedSenderCipher.decryptmarks the Kyber pre-key with the same triple asSessionCipher.decrypt
Changed
-
stores-implementationandsecurity-reviewskills, plus theCONTRIBUTING.mdreview checklist, corrected — they recommended a lock inside the store, which does not coverload → ratchet → store, and are now aligned with the durability/serialization contract. Both transaction examples also note that the store's writes must be routed through the ambient transaction (sqflitedeadlocks if thedbhandle is used insidedb.transaction(...)) -
GitHub Actions bumped to their Node 24 majors — the first grouped Dependabot run moves
actions/checkout4 → 7,actions/upload-artifact4 → 7,actions/download-artifact4 → 8,actions/cache4 → 6,actions/create-github-app-token2 → 3,android-actions/setup-android3.2.2 → 4.0.1 andschneegans/dynamic-badges-action1.7.0 → 1.9.0, converging on the pins the copier template now carries. This is catching up to the runner rather than optional drift: CI was already warning that "actions/cache@v4, actions/checkout@v4" target the deprecated Node 20 and "are being forced to run on Node.js 24". Every input these workflows pass still exists on the new majors, and both SHA-pinned actions were verified against their upstream tag refs. The two behaviour changes that do land:download-artifactnow fails a run on a digest mismatch instead of only warning, andsetup-androiddropped its SDK cache (slower Android legs, same output). No workflow logic changed -
Dependabot branches excluded from the
Signing commitandDelete branchesrulesets — both target~ALLbranches, sonon_fast_forwardstopped Dependabot from force-pushing a rebase onto a movedmainanddeletionstopped it from cleaning up a merged branch: a grouped update PR could never refresh itself oncemainhad moved.refs/heads/dependabot/**/*is now in each ruleset'sref_name.exclude— the trailing/*is load-bearing, since a bare**does not cross a/and so would miss the multi-segment branch names Dependabot actually creates.mainis unaffected (it is not a Dependabot branch) and keepsrequired_signaturesfrom the same ruleset. Scoped withexcluderather than a bypass actor, which on a~ALLruleset would have exempted that actor onmaintoo -
copier template adopted: v3.0.3 → v4.1.0 — the major's single contract change is that every project generate and commit
THIRD_PARTY_NOTICES.txtbefore its next CI run, becausetest-reusable.ymlnow verifies it; that file and its generator arrive here for the first time (see For Users above). Also landing:make rust-testand a CI step that runs the crate's own unit tests;make third-party-notices/make verify-third-party-notices, withmake rust-updateregenerating the inventory so the lockfile and the notices cannot drift apart; the fuzz workflow reads its targets from the[[bin]]entries ofrust/fuzz/Cargo.tomland fans them out one job per target (fail-fast: false, per-target crash artefacts) instead of looping over a hardcoded list in a single job that stopped at the first crash — the discovery step was run againstrust/fuzz/Cargo.tomland yields exactly the six existing targets;validateUpstreamTagnames which input it rejected, since an APItag_name, a--versionargument and the pin recorded inrust/Cargo.tomlfail for different reasons;insertChangelogEntrymatches#### Changedexactly, where a prefix match previously also filed a native-library bump under#### Changed (Breaking); the build hook declares a local native build as a dependency, somake cleanno longer leavesdart testpointed at a cached asset that is gone; andcopyright_yearbecomes a stored answer, recorded as 2025 — the year of first publication — though for an AGPL-3.0 project it does not reach the renderedLICENSE, which the template only stamps for MIT and BSD. Three deviations are deliberate. The AI changelog prompt stays this project's own: the template now carries a generic version, while the one here enumerates the crates this wrapper binds and the upstream areas it does not expose, which is what keeps an upstream networking, keytrans or zkgroup change from being announced as a feature of this package. Thefreezed_annotation/freezed/build_runnerdependencies are not adopted — they exist so that a freshly generated project's first codegen succeeds against an unknown API surface, whereas this FRB surface has no data-carrying enums, andfreezed_annotationsits independencies, so every consumer would download a package nothing here imports. Andffigenstays at^20.1.1instead of returning to the template's^20.0.0. The follow-up minor, v4.1.0, landed net-zero: its whole content is this project's own notice-inventory and MSRV work (the two fixes below, plus the reproducibility pass) carried back upstream, socopier updatehad nothing left to apply beyond recording the version -
The
Signing commitruleset no longer bypasses the update GitHub App —bypass_actorsis now empty, matching the template. The app's commits are created through the API and are therefore signed by GitHub, sorequired_signaturesis satisfied without an exemption, and on a~ALLruleset a bypass actor is exempted everywhere,mainincluded — the same reasoning the Dependabot entry above applies.refs/heads/update-*is still not excluded from the ruleset: the template's policy is to widenexcludeonly on an observed failure, and a failure here is visible rather than silent, since the bot comments on the pull request it could not refresh
Fixed
- 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 rather than of the released targets. Here that isprost-build→tempfile→rustix, whose backend is host-gated:errnoon a macOS host,linux-raw-syson a Linux one. One crate swapped for the other with the crate count unchanged, so the file generated locally was rejected by the CI check on its first run — correct where it was written, wrong everywhere else, and the check could only report "the contents differ". Nor is the problem confined to build edges: proc-macro subtrees are host-compiled too, which is howwinapi— reached throughansi_terminside a proc-macro crate — stays invisible everywhere except a Windows host. No per-target query escapes this, so the crate set is now 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. Over-attribution is the deliberate trade: the extra entries are build tooling and platform-gated crates that a given build never links —winapihere reaches the graph only through a host-compiled proc-macro — but a notice 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. Accordingly the inventory grows from 206 to 241 crates, the additions being platform-gated crates and build tooling that were always in the graph but invisible from a macOS host (linux-raw-sys,windows-sys,winapi,bindgen,clang-sys, …). Cross-checked againstcargo-about: it now reports no crate this inventory omits.--checknow also prints the first differing line and the lines unique to each side, since its failure is normally read from a CI log where bisecting a 450 KB file by hand is the only alternative
6.1.1 - 2026-07-25 #
For Users #
✨ Highlights
- libsignal v0.99.1 — internal/dependency update, no public-API impact
- libsignal_frb v5.1.2 — Rust FFI bindings
Changed
- libsignal native library → v0.99.1 (compare)
- Upstream user-facing changes target the chat/backup/registration services and logging — none of which this library exposes
- The crates we bind (
libsignal-protocol,signal-crypto,libsignal-core) saw internal refactors to track the updated RustCrypto / curve25519-dalek / spqr dependencies, with no change to behaviour or the FFI surface (FRB bindings regenerate byte-for-byte identical)
Security
- Upstream libcrux advisories resolved — v0.99.1 pulls in
libcrux-sha30.0.10 andlibcrux-secrets0.0.6, which fix RUSTSEC-2026-0207, RUSTSEC-2026-0208 (incremental/AVX2 SHAKE) and RUSTSEC-2026-0212 (aarch64 const-time swap). The interimcargo-audit/cargo-denysuppressions for these three have been removed
6.1.0 - 2026-07-21 #
For Users #
✨ Highlights
- Build provenance attestation (Sigstore, SLSA Build L2) — every native-release archive is now cryptographically attested to this repository's tag-triggered build, closing the previously documented authenticity gap (verify with
gh attestation verify) - Web: stale-WASM-after-upgrade fixed — the web build hook now refreshes
web/pkg/on a version change instead of serving the previous version's WASM, which could crash Dart-store-callback paths (processPreKeyBundle,SessionCipher, sealed sender, group messaging) after an upgrade - Smaller package & explicit minimum OS versions — the vestigial platform-plugin scaffolding is removed (smaller published archive) and the prebuilt binaries are now built against the documented macOS 10.15 / Android API 24 minimums
- libsignal v0.97.4 — internal/dependency update, no public-API impact
- libsignal_frb v5.1.1 — Rust FFI bindings
Changed
- Platform-plugin scaffolding removed from the published package — the vestigial
ios/,macos/,android/,linux/,windows/directories (podspecs, Gradle project, CMakeLists, plugin stubs) are gone. The package has never declared aflutter: plugin:section, so flutter_tools never consumed them; native delivery is (and remains) via thehook/build.dartbuild hook. No consumer action required — the published archive just gets smaller - Explicit minimum OS versions for the prebuilt binaries — CI now builds the macOS dylibs with
MACOSX_DEPLOYMENT_TARGET: '10.15'(previously rustc's per-target default, 10.12 for x86_64) and links the Android.sos against API level 24 via cargo-ndk--platform 24(previously cargo-ndk's default, 21), matching the documented platform-support table - libsignal v0.97.4 update — bump the bound native library (compare)
- Upstream changes are limited to
AuthAccountsService(registration-lock set/clear, discoverable-by-phone-number, registration-recovery-password),UnauthBackupsService.copyMedia/copyBackupMedia, SVR2 node APIs, and language-binding / bridge tooling (node/java/swift/ts) — none of which this library exposes - The only change to the crates we bind (
libsignal-protocol,signal-crypto,libsignal-core) is thelibsignal-coreversion string (rust/core/src/version.rs);make codegenproduces no binding diff - Note: These changes do not affect this library's public API
- Upstream changes are limited to
- libsignal v0.97.3 update — bump the bound native library (compare)
- Upstream changes are limited to
AuthUsernamesService.deleteUsernameHash()/deleteUsernameLink()(username services), reclassifying an established chat connection's transport errors as retryable (.ioError, Swift binding), and increasing the key-transparency clock-skew tolerance interval — none of which this library exposes - The crates we bind (
libsignal-protocol,signal-crypto,libsignal-core) are unchanged apart from version strings;make codegenproduces no binding diff - Note: These changes do not affect this library's public API
- Upstream changes are limited to
Security
- Build provenance attestation (Sigstore, SLSA Build L2) — every native-release archive is now attested with GitHub Artifact Attestations: CI signs a provenance statement proving the archive was built by this repository's tag-triggered
build-libsignal.ymlfrom a specific commit, closing the previously documented authenticity gap (the SHA256 checksums file ships in the same release as the archives). Verify withgh attestation verify <archive> --repo djx-y-z/libsignal_dart; a Sigstore bundle (libsignal_frb-<version>.sigstore.jsonl) is attached to each release for fully offline verification. See SECURITY.md → Authenticity (the build hook itself still verifies SHA256 only — attestation verification is manual)
Fixed
- Stale web WASM after a package upgrade — the web build hook (
hook/build.dart) now records the provisioned crate version inweb/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 consuming app'sweb/pkg/(it survivesflutter clean), so on web any FRB entry that calls Dart store callbacks —SessionBuilder.processPreKeyBundle,SessionCipher,SealedSenderCipher, group messaging — panicked with an argument-count mismatch (called Option::unwrap() on a None value) once the wire signature had changed between versions. The download cache is now version-keyed andrust/Cargo.tomlis a declared web-build dependency, both mirroring the native path (which was unaffected) - Build hook download/cache resilience — the hook (
hook/build.dart) is more robust against partial/transient failures: a download-cache entry is only reused after a.download-completemarker proves the extraction finished (an interruptedtarno longer leaves a truncated library that is reused forever), a locally builtrust/target/library is used only when it matches the target OS and architecture (previously a host build could be bundled for a cross-target, e.g. a macOS dylib into an iOS app), the web path no longer fetches checksums when a warm cache can serve the files offline, and both the checksums fetch and the binary download now retry on transient HTTP 5xx/429 instead of failing the build on a single blip
For Contributors #
Added
make release-frb+release-frb-crateskill — one-command native-crate release (stage 1): bumpsrust/Cargo.toml, stamps the CHANGELOGlibsignal_frbHighlights line, and creates a signed commit +libsignal_frb-<version>tag, pushing to trigger the native build. The commit/tag/push inherit the terminal, so the signing passphrase is entered interactively during the command. Pairs withrelease-package(stage 2)make release+ updatedrelease-packageskill — one-command Dart package release (stage 2) symmetric tomake release-frb: verifies the stage-1 native binary exists on GitHub Releases, bumpspubspec.yaml, finalizes the CHANGELOG ([Unreleased]→ dated version + a fresh[Unreleased]+ the bottom compare-link refs), validates with a publish dry-run, then signs a commit +vX.Y.Ztag and pushes to trigger the pub.flutter-io.cn publish. The two release commands share git/terminal helpers inscripts/src/release_common.dart- Repository-protection tooling — the branch and release-tag rulesets now live in-repo as committed JSON (
.github/rulesets/*.json, the source of truth), andmake setup-repo-protectionsapplies them to GitHub viagh(idempotent by ruleset name) and configures thenative-buildenvironment. A new Protect release tags ruleset restricts tag creation (all tags) to Admins/Maintainers — covering the release-triggeringlibsignal_frb-*/v*and any other — and the native-crate publish (build-libsignal.yml) now runs in the required-reviewernative-buildenvironment — gating tag-push andworkflow_dispatchalike, mirroring thepub.flutter-io.cnenvironment that gates pub.flutter-io.cn publishing - Dependabot for GitHub Actions —
.github/dependabot.yml: weekly grouped update PRs (Monday 06:00 UTC,chore(deps)prefix) bump the pinned actions — both the commit SHA and its# vX.Y.Zcomment — across the workflows and the composite actions (adirectoriesglob covers/.github/actions/*, since/only scans.github/workflows/).dtolnay/rust-toolchainis ignored: it has no versioned releases (master-SHA pin, toolchain selected via input) and stays manually bumped
Changed
- Accept unremediable upstream libcrux crypto advisories in cargo-deny / cargo-audit — three RustSec advisories published 2026-07-17 (
RUSTSEC-2026-0207/-0208, incorrect / panicking SHAKE inlibcrux-sha30.0.8;RUSTSEC-2026-0212, incorrect aarch64 constant-time swap inlibcrux-secrets0.0.5) live in libsignal's git-pinned ML-KEM stack and are not fixable from this repo — the fix requires a libsignal release that bumpslibcrux-ml-kem(v0.97.4 still ships the old libcrux). Added torust/deny.toml[advisories].ignoreand therust-audit--ignoreflags as a tracked interim suppression so thecargo-deny/cargo-auditCI jobs pass — to be removed once a fixed libsignal release lands - Decoupled the
libsignal_frbnative release from libsignal dependency updates — automated update PRs no longer bump the crate version or build binaries; dependency updates accumulate onmain(tested from source in CI), and the native build is now triggered by pushing alibsignal_frb-<version>tag instead of by pushing tomain. The crate-version bump is now a deliberate release decision (make release-frb). See CLAUDE.md → Release Flow - AI changelog generator classifies upstream changes against the bound-crate surface — the prompt now states which crates/APIs this wrapper actually binds, so out-of-scope upstream changes (net / chat / keytrans / username services / zkgroup / …) are framed as "none of which this library exposes", and it links to a version
compareinstead of the (often incomplete) release notes - CI enforces deployment-target consistency —
test-reusable.ymlnow runsmake check-targets(Linux leg) so the build fails if the iOS / macOS / Android minimum deployment targets drift out of sync across the CI build env vars, the example Xcode projects and the README platform table. Previously the check existed (make check-targets) but was never run automatically - Deployment-target sources consolidated —
.copier-answers.ymlremains the single source of truth; with the platform scaffolding removed,make check-targetsandscripts/get_android_min_sdk.dartno longer read the podspecs/build.gradlebut verify the CI workflow (IPHONEOS_DEPLOYMENT_TARGET,MACOSX_DEPLOYMENT_TARGET, cargo-ndk--platform) instead - Upstream tag names validated before reaching the shell —
check_updates.dart/check_template_updates.dartreject a releasetag_namethat is not a plain semver-ish tag before it lands inGITHUB_OUTPUT, and the update workflows pass step outputs/inputs intorun:blocks viaenv:instead of inline${{ }}interpolation — closing a shell-injection path from upstream release names (backport of the liboqs audit) - Least-privilege
GITHUB_TOKENeverywhere —publish.ymlandbuild-libsignal.ymlnow default tocontents: readwith job-level opt-ups (id-token: writeon the pub.flutter-io.cn publish job,contents: writeon the release jobs); the two update-checker workflows dropcontents/pull-requests: writeentirely (all writes go through the App token) - Third-party actions pinned to commit SHAs —
dart-lang/setup-dart,peter-evans/create-pull-request,android-actions/setup-android,ilammy/msvc-dev-cmd,schneegans/dynamic-badges-action,Swatinem/rust-cache,dtolnay/rust-toolchain(toolchain now passed via thetoolchaininput since the ref no longer selects it) setup-makeverifies gnumake.exe by SHA256 — release assets are mutable, so the size check alone did not lock the Windows make binary; a hardcoded SHA256 (updated together with the version) now does- Pre-release hardening pass (audit fixes) — a review of this cycle's changes fixed, among others:
make release-frbnow syncs and stagesrust/Cargo.lockalongsiderust/Cargo.toml(the pre-commitcargo checkno longer leaves a dirty tree that blocked stage 2, and the signed tag no longer carries a stale lock);Swatinem/rust-cacheis repinned from the floatingv2tag object to the realv2.9.1commit (would have broken every Rust job when upstream re-taggedv2); the pub.flutter-io.cn release notes are written via--notes-fileinstead of an inline heredoc (a literalEOFline in the changelog can no longer break out into the shell);build-libsignal.ymlno longer delete-then-recreates a release (fail-loud, no silent clobber) and the release-existence probe fails closed on API errors; thefuzz.ymldispatchdurationinput is validated and passed viaenv:;make check-targetsfails closed when a checked file/pattern disappears; and--dateinmake releaseis validated. Docs corrected across README/CLAUDE/CONTRIBUTING/SECURITY/rulesets (build-hook fallback,AI_MODELS_TOKEN, two-stage publishing,.skip_*_hooksemantics, stale Cargokit/loading-order references) - Adopt copier template v3.0.0 — most of this template release (the two-stage release flow, repository rulesets, Dependabot, and the CI deployment-target check) was already backported into this repo, so the update reduced to a documentation and tooling sync:
CONTRIBUTING.mdgains the "Releasing (two stages)" and "Repository rulesets & tag protection" sections, and theMakefile.PHONYlist is reordered to match the template (no behavior change)
6.0.0 - 2026-07-14 #
For Users #
✨ Highlights
- Identity-trust enforcement (breaking) — a remote identity key that differs from the stored one is now rejected with
UntrustedIdentityon every session operation (MITM / safety-number-change detection), instead of being silently accepted - Hardened supply chain & binary — the native-binary download is now fail-closed (aborts if it can't be verified), and the wrapper crate is built with integer-overflow checks
- App store additional permission — the license now allows AGPL-compliant apps to ship through app stores with AGPL-incompatible terms (e.g. the Apple App Store); see
LICENSE.appstore - libsignal v0.97.2 — internal/dependency update, no public-API impact
- libsignal_frb v5.0.0 (internal Rust FFI crate) — breaking (major): adds a required
get_identitycallback
Changed (Breaking)
- Identity-trust is now enforced on every session operation, matching upstream libsignal's
is_trusted_identitysemantics.SessionBuilder.processPreKeyBundle,SessionCipher.encrypt/decrypt(both pre-key and regular Whisper messages), andSealedSenderCipher.encrypt/decryptnow consult yourIdentityKeyStore.getIdentityand reject a remote identity key that differs from the stored one with anUntrustedIdentityerror. Previously a substituted identity (e.g. from a malicious key-distribution server) was accepted without error. First contact is still trusted-on-first-use.- Action required: catch
UntrustedIdentity(its message containsuntrusted identity) and treat it as a safety-number change — verify with the user, then save the new identity (or clear the old one) in your store and archive the old session for that address before retrying. Requires yourIdentityKeyStore.getIdentityto be implemented correctly.
- Action required: catch
Changed
- Update libsignal native library to v0.97.2 (compare)
- Upstream changes between v0.96.4 and v0.97.2 are limited to net/registration, chat/backups gRPC, bridge/codegen tooling, and CI / language-binding (node/swift/java) updates — none of which this library exposes
- The only diffs in the crates we bind are cosmetic: a test-only import in
kem.rs, an internalTryFromrefactor instate/bundle.rs(and_then(…map…)→.zip(…), behavior identical), and thelibsignal-coreversion string - Note: These changes do not affect this library's public API
- App store additional permission (AGPL §7) — the package license now carries an explicit app-store exception (the Feeel/wger wording, see
LICENSE.appstore): GPL/AGPL-compliant applications may distribute this package in object-code form through app stores whose terms are incompatible with the AGPL (such as the Apple App Store), provided their source stays available under the AGPL through an unrestricted channel. The permission covers only this repository's code; the status of an equivalent permission for the bundled upstreamlibsignalis tracked in signalapp/libsignal#684. Requested in #44
Security
- Fail-closed native library verification — the build hook (
hook/build.dart) now aborts the build if the SHA256 checksums for a downloaded binary cannot be fetched or the archive has no entry, instead of silently proceeding unverified. An escape hatch (LIBSIGNAL_ALLOW_UNVERIFIED_DOWNLOAD=1) remains for releases with no checksums file - Hardened crate build — the wrapper's release profile enables
overflow-checks, so an integer overflow in the wrapper is a deterministic (catchable) panic rather than silent wraparound (the audited crypto dependencies are left untouched) - Secret-lifetime & zeroing caveats documented —
SECURITY.mdnow spells out that opaque secret handles (PrivateKey,KyberSecretKey,SessionRecord, …) stay resident in native memory until a non-deterministic GC finalizer runs, so security-critical code should calldispose()to bound that window (noting the extractable key types areCopy/plain-boxed and thus not zeroized on drop —dispose()shortens the exposure window, it does not wipe), and that Rust'szeroizecovers Rust memory only: secret bytes that cross the FFI boundary into a DartUint8Listlive on the un-zeroed GC heap whereSecureBytes/zeroize()are best-effort.PrivateKey.cloneKey()/KyberSecretKey.cloneKey()now carry a# Securitydoc note that each copy is an independent secret
Fixed
- Device ID truncation — the
ProtocolAddressandPreKeyBundleconstructors no longer truncate theu32device ID tou8before validating (e.g.257is no longer accepted as device1); out-of-range IDs are rejected as documented (1–127) - HKDF output bound —
hkdfDerivenow rejects an output length above the RFC 5869 maximum (255 × 32 = 8160bytes) before allocating, instead of attempting an oversized allocation - In-memory identity-store equality —
InMemoryIdentityKeyStorenow compares identity keys by value (equals()) rather than by object reference, so a re-presented key is correctly seen as unchanged (matters for production stores copied from this reference implementation) - Native-library download cache key — the build hook (
hook/build.dart) now keys its download cache by crate version and the full platform variant (e.g.ios-device-arm64vsios-simulator-arm64) rather than only OS + architecture. On Apple-silicon hosts iOS device and simulator builds shared a key, so whichever built first poisoned the cache for the other anddyldrejected the bundled library at runtime (incompatible platform: have 'iOS-simulator', need 'iOS'); a version bump could also serve a stale cached binary
For Contributors #
Added
- Fuzzing harness —
cargo-fuzztargets (rust/fuzz/) covering every byte-parsing entry point (keys, messages, records, sealed-sender certificates, crypto primitives, pre-key decryption), a seed-corpus generator, and aFuzzCI workflow (per-PR smoke run + weekly deep run). Seemake fuzz-list/make fuzz - Dependency policy —
cargo-deny(rust/deny.toml,make rust-deny, CIdenyjob) enforcing RustSec advisories, an AGPL-compatible license allow-list, and a source allow-list restricted to crates.io and the official Signal repositories - Rust linting (Clippy) —
cargo clippy --all-targets -- -D warningsnow runs in CI (the reusable test workflow, on the Linux x86_64 leg) and locally viamake rust-clippy; the hand-written wrapper is lint-clean, with the FRB-inherent lints (many-callback store signatures, complex tuple returns) annotated with justified site-local#[allow]s
Changed
- CI least-privilege — the reusable test workflow now declares
permissions: contents: read - Rust lint — hand-written Rust is compiled with
unsafe_code = "deny"(only the FRB-generated bridge is exempt) - Copier template adopted (v2.5.1) —
flutter_rust_bridge_codegenis now pinned viamake setup-frb-codegen(kept in sync with theflutter_rust_bridgedependency,2.12.0); the libsignal-update workflow installs the codegen binary (fixing a codegen step that failed with exit 127) and skips regenerating an update PR that already exists;check_updates.dartbumps the wrapper crate version mirroring the upstream SemVer delta,update_changelog.dartclassifies update severity via AI, and theupdate-libsignalskill now analyzes the full upstream diff
5.0.9 - 2026-06-27 #
For Users #
✨ Highlights
- libsignal v0.96.4 — internal improvements and updates
- libsignal_frb v4.0.9 — Rust FFI bindings
Changed
- Update libsignal native library to v0.96.4 (compare)
- Upstream changes are limited to net/registration and chat gRPC helpers, server-side SVR enclave rotation (2026Q2), FFI bridge tooling, and new typed
reserveUsernameHash()/ donation-permit client APIs — none of which this library exposes - The
libsignal-protocolandsignal-cryptocrates are unchanged;libsignal-coreonly bumps its internal version string - Note: These changes do not affect this library's public API
- Upstream changes are limited to net/registration and chat gRPC helpers, server-side SVR enclave rotation (2026Q2), FFI bridge tooling, and new typed
5.0.8 - 2026-06-24 #
For Users #
✨ Highlights
- libsignal v0.96.3 — internal improvements and updates
- libsignal_frb v4.0.8 — Rust FFI bindings
Changed
- Update libsignal native library to v0.96.3 (release notes)
- Upstream changes are limited to an internal ML-KEM parameter key type fix plus net/node/gRPC/server-side updates, none of which this library exposes
- Note: These changes do not affect this library's public API
5.0.7 - 2026-06-20 #
For Users #
✨ Highlights
- libsignal v0.96.2 — internal improvements and updates
- libsignal_frb v4.0.7 — Rust FFI bindings
Changed
- Update libsignal native library to v0.96.2 (release notes)
- Upstream changes are limited to zkgroup donation credentials (
DonationPermit), which this library does not expose - Note: These changes do not affect this library's public API
- Upstream changes are limited to zkgroup donation credentials (
5.0.6 - 2026-06-19 #
For Users #
✨ Highlights
- libsignal v0.96.1 — internal improvements and updates
- libsignal_frb v4.0.6 — Rust FFI bindings
Changed
- Update libsignal native library to v0.96.1 (release notes)
- Internal improvements and updates
- Note: These changes do not affect this library's public API
5.0.5 - 2026-06-12 #
For Users #
✨ Highlights
- libsignal v0.96.0 — internal improvements and updates
- libsignal_frb v4.0.5 — Rust FFI bindings
Changed
- Update libsignal native library to v0.96.0 (release notes)
- Internal improvements and updates
- Note: These changes do not affect this library's public API
5.0.4 - 2026-06-10 #
For Users #
✨ Highlights
- libsignal v0.95.0 — internal improvements and updates
- libsignal_frb v4.0.4 — Rust FFI bindings
Changed
- Update libsignal native library to v0.95.0 (release notes)
- Internal improvements and updates
- Note: These changes do not affect this library's public API
5.0.3 - 2026-06-04 #
For Users #
✨ Highlights
- libsignal v0.94.4 — internal improvements and updates
- libsignal_frb v4.0.3 — Rust FFI bindings
Changed
- Update libsignal native library to v0.94.4 (release notes)
- Internal improvements and updates
- Note: These changes do not affect this library's public API
5.0.2 - 2026-05-31 #
For Users #
✨ Highlights
- libsignal v0.94.3 — internal improvements and updates
- libsignal_frb v4.0.2 — Rust FFI bindings
Changed
- Update libsignal native library to v0.94.3 (compare)
- Binding/tooling improvements (JNI, Node, Swift type converters), backup validator and reflector routing updates
- Note: No changes to the libsignal-protocol crate — does not affect this library's public API
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.0.1 - 2026-05-19 #
For Users #
✨ Highlights
- libsignal v0.94.1 — internal improvements and updates
- libsignal_frb v4.0.1 — Rust FFI bindings
Changed
- Update libsignal native library to v0.94.1 (compare)
- Networking improvements: gRPC/H2 transport additions, reflector proxy support
- Key Transparency: added account data reset, additional logging around monitor versions
- Note: No changes to libsignal-protocol crate — does not affect this library's public API
5.0.0 - 2026-05-12 #
For Users #
✨ Highlights
- libsignal v0.94.0 — extends sender/recipient address binding to
SignalMessage.verifyMac() - libsignal_frb v4.0.0 — Rust FFI bindings updated with new sender/recipient address parameters on
verifyMac(breaking)
Changed
- Update libsignal native library to v0.94.0 (release notes)
- Breaking:
SignalMessage.verifyMac()now requiressenderAddressName,senderAddressDeviceId,recipientAddressName, andrecipientAddressDeviceIdparameters - Upstream made the previous
SignalMessage::verify_macmethod private and exposedverify_mac_with_addressesas the public replacement, extending the misdirection protection (started in v0.91.0) to message MAC verification
- Breaking:
4.0.1 - 2026-05-06 #
For Users #
✨ Highlights
- libsignal v0.93.2 — internal improvements and updates
- libsignal_frb v3.0.1 — Rust FFI bindings
Changed
- Update libsignal native library to v0.93.2 (compare)
- Networking improvements: H2 GOAWAY (graceful shutdown) handling for WebSockets
- Updated
hickory-protoDNS dependency to 0.26.1 - Updated CDSI production enclave and added new SVR enclaves (server-side)
- Note: No changes to libsignal-protocol crate — does not affect this library's public API
4.0.0 - 2026-05-01 #
For Users #
✨ Highlights
- libsignal v0.93.1 — extends sender/recipient address binding to remaining session APIs
- libsignal_frb v3.0.0 — Rust FFI bindings updated with new
localAddressparameter (breaking)
Changed
- Update libsignal native library to v0.93.1 (v0.93.0, v0.93.1)
- Breaking:
SessionBuilderconstructor now requireslocalAddressparameter - Breaking:
processPrekeyBundleWithCallbacksnow requireslocalNameandlocalDeviceIdparameters - Breaking:
messageDecryptSignalWithCallbacksnow requireslocalNameandlocalDeviceIdparameters process_prekey_bundleandmessage_decrypt_signalnow bind sender/recipient addresses, completing the misdirection protection introduced in v0.91.0
- Breaking:
3.0.3 - 2026-04-20 #
For Users #
✨ Highlights
- libsignal v0.92.2 — internal refactors and dependency updates
- libsignal_frb v2.0.2 — Rust FFI bindings (libsignal upstream bump)
Changed
- Update libsignal native library to v0.92.2 (compare)
- Internal refactor of 1:1 messaging code
- Key Transparency (keytrans) improvements: persist latest distinguished tree head, validate search responses
- Upgraded
randcrate andrustls-webpki - Note: These changes do not affect this library's public API
3.0.2 - 2026-04-12 #
For Users #
✨ Highlights
- libsignal v0.92.1 — SPQR v1 enforcement and dependency updates
- libsignal_frb v2.0.1 — updated native dependencies
Changed
3.0.1 - 2026-04-03 #
Fixed
- Fix README examples for
SessionCipherandSealedSenderCipherto match new API (addedlocalAddressand all required stores) - Fix incorrect class name
SealedSessionCipher→SealedSenderCipherin README - Fix incorrect method name
decryptPreKeySignalMessage→decryptPreKeyMessagein README
3.0.0 - 2026-04-03 #
For Users #
✨ Highlights
- libsignal v0.91.0 — message encryption now includes sender/recipient addresses in MAC for misdirection protection
- libsignal_frb v2.0.0 — Rust FFI bindings updated with new
localAddressparameter (breaking)
Changed
- Update libsignal native library to v0.91.0 (release notes)
- Breaking:
SessionCipherandSealedSenderCipherconstructors now requirelocalAddressparameter - Breaking:
messageEncryptWithCallbacksandmessageDecryptPrekeyWithCallbacksnow requirelocalNameandlocalDeviceIdparameters - Breaking:
sealedSenderDecryptWithCallbacksnow requireslocalNameandlocalDeviceIdparameters - 1:1 message encryption and decryption now includes sender/recipient addresses in the message MAC to prevent message misdirection attacks
- Backward compatible with messages from older clients that don't include addresses
- Breaking:
2.9.0 - 2026-03-29 #
For Users #
✨ Highlights
- libsignal v0.90.0 —
CiphertextMessagenow implementsClone - libsignal_frb v1.5.0 — Rust FFI bindings
Changed
- Update libsignal native library to v0.90.0 (release notes)
CiphertextMessageenum now derivesClone(previously onlyDebug)- Networking improvements: authenticated WebSocket message sending, key transparency API simplification
- Note: These changes do not affect this library's public API
- Update Flutter Rust Bridge to v2.12.0 (fix)
- Fixes web build compatibility with wasm-bindgen >=0.2.109
- Removed version pins for wasm-bindgen, js-sys, and web-sys
2.8.2 - 2026-03-25 #
For Users #
✨ Highlights
- libsignal v0.89.2 — dependency updates and networking improvements
- libsignal_frb v1.4.5 — Rust FFI bindings
Changed
- Update libsignal native library to v0.89.2 (release notes)
- Updated libcrux and SPQR (post-quantum) dependencies
- Updated rustls-webpki and tokio-util dependencies
- Networking improvements: service-level backoff, request cancellation
- Note: No changes to
libsignal-protocolcrate API — this library's public API is unaffected
2.8.1 - 2026-03-20 #
For Users #
✨ Highlights
- libsignal v0.89.1 — patch release with dependency updates
- libsignal_frb v1.4.4 — Rust FFI bindings
Changed
- Update libsignal native library to v0.89.1 (release notes)
- Patch release with internal dependency updates
- No public API changes
2.8.0 - 2026-03-18 #
For Users #
✨ Highlights
- libsignal v0.89.0 — internal improvements and updates
- libsignal_frb v1.4.3 — Rust FFI bindings
Changed
- Update libsignal native library to v0.89.0 (release notes)
- Internal improvements to the FFI bridge and callback mechanisms
- Enhanced backup/export functionalities
- Updates to keytrans handling
- Note: These changes do not affect this library's public API
2.7.2 - 2026-03-15 #
For Users #
✨ Highlights
- libsignal v0.88.3 — internal improvements and updates
- libsignal_frb v1.4.2 — Rust FFI bindings
Changed
- Update libsignal native library to v0.88.3 (release notes)
- Internal changes: FFI bridge callback improvements, backup/export refactoring, keytrans updates
- Note: These changes do not affect this library's public API
2.7.1 - 2026-03-07 #
For Users #
✨ Highlights
- libsignal v0.88.1 — internal bridge refactoring
- libsignal_frb v1.4.1 — Rust FFI bindings
Changed
- Update libsignal native library to v0.88.1 (release notes)
- Internal refactoring: further improvements to SenderKeyStore bridge implementations
- Note: These changes do not affect this library's public API
2.7.0 - 2026-03-03 #
For Users #
✨ Highlights
- libsignal v0.88.0 — internal bridge refactoring, no protocol changes
- libsignal_frb v1.4.0 — Rust FFI bindings
Changed
- Update libsignal native library to v0.88.0 (release notes)
- Internal refactoring: consolidated SenderKeyStore bridge implementations
- No changes to
libsignal-protocolcrate API — this library's public API is unaffected
2.6.0 - 2026-02-27 #
For Users #
✨ Highlights
- libsignal v0.87.5 — updated post-quantum cryptography dependencies
- libsignal_frb v1.3.0 — Rust FFI bindings
Changed
- Update libsignal native library to v0.87.5 (release notes)
- Updated SPQR (SparsePostQuantumRatchet) to v1.5.0
- Updated hpke-rs to v0.6.0 and libcrux-ml-kem to v0.0.7
- Added
zeroizesupport for HPKE Rng in signal-crypto - Note: These changes do not affect this library's public API
2.5.0 - 2026-02-21 #
For Users #
✨ Highlights
- libsignal v0.87.4 — updated BoringSSL and internal improvements
- libsignal_frb v1.2.0 — Rust FFI bindings
Changed
- Update libsignal native library to v0.87.4 (release notes)
- Updated
boringdependency to v5.0.1 (bundled BoringSSL update) - Added RemoteConfig for accountExists gRPC
- keytrans: removed search-with-version fallback from
monitor_and_search - Note: These changes do not affect this library's public API
- Updated
2.4.0 - 2026-02-18 #
For Users #
✨ Highlights
- libsignal v0.87.2 — security hardening for Diffie-Hellman key agreements
- libsignal_frb v1.1.0 — Rust FFI bindings
Security
- Update libsignal native library to v0.87.2 (release notes)
- Added validation of X25519 Diffie-Hellman shared secrets — rejects all-zero outputs per RFC 7748 §6.1, preventing potential use of predictable shared secrets from malicious low-order public keys
- Enabled overflow checks for release builds
- Updated BoringSSL to signalapp/boring v4.21.1
- Note: No changes to this library's public API
For Contributors #
Changed
- Adopt copier template v2.3.2 → v2.4.0
- 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_libsignal_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
- Added Rust dependency caching (
2.3.1 - 2026-02-11 #
For Users #
Changed
- Remove
flutterSDK constraint fromenvironment— pub.flutter-io.cn now displays both Dart and Flutter SDK badges (#14, thanks @ahnaineh)
For Contributors #
Changed
- Adopt copier template v2.2.0 → v2.3.2
- Publishing checklist now uses annotated tags (
git tag -a) instead of lightweight tags - Added
git push origin mainstep before pushing tag in publishing checklist - Replaced "Claude Commands" section with "Claude Skills" section in CLAUDE.md
- Removed redundant
prepare-releaseandupdate-templateClaude commands (functionality covered by Claude skills) - Updated platform support table in README: SDK 24+, iOS 13.0+, macOS 10.15+, WASM label
- Improved
frb-patternsClaude skill with additional patterns:- Added anti-pattern example to Constructor-Style API Pattern section
- Added Transparent Struct Pattern section
- Added Bridging Sync Traits to Async Callbacks section with
block_onexample - Added Adapter Pattern documentation for bridging DartFn callbacks to upstream traits
- Added
block_onpanics troubleshooting entry - Added "When to regenerate" checklist to Regenerating Bindings section
- Added No Threading on WASM warning
- Publishing checklist now uses annotated tags (
Fixed
- Restore 100% test coverage by adding
coverage:ignoremarkers to untestable platform-specific code inplatform_io.dart- AOT mode library loading path (unreachable during
dart testwhich runs in JIT mode) openLibraryFromPath()function (only called with customlibraryPath, already ignored at call site)
- AOT mode library loading path (unreachable during
2.3.0 - 2026-02-07 #
For Users #
✨ Highlights
- libsignal v0.87.1 — latest upstream native library
- libsignal_frb v1.0.3 — Rust FFI bindings
Changed
- Update libsignal native library to v0.87.1 (release notes)
CallLinkRootKeynow allows variable sizing; call link epochs removed from backup- Test infrastructure improvements (reusable session fuzz test support)
- Note: These changes do not affect this library's API
- Update
libsignal_frb(Rust crate) to v1.0.3
Security
- Updated
bytesdependency to v1.11.1 to address RUSTSEC-2026-0009
For Contributors #
Changed
- Adopt copier template (
copier-dart-frb-wrapper) v2.0.1 for project structure- Standardized scripts naming:
check_new_upstream_version.dart,check_exists_frb_release.dart - Unified common utilities in
scripts/src/common.dart - Renamed workflow:
build-libsignal-frb.yml→build-libsignal.yml - Configurable
version_tag_prefixfor upstream version tag handling - Improved version normalization in
check_updates.dart— supports configurable tag prefix instead of hardcodedvstripping
- Standardized scripts naming:
- Renamed
make update→make rust-updateto avoid ambiguity - Refactored build hook (
hook/build.dart)- Added SHA256 checksum verification for WASM downloads (supply chain security)
- Smarter app root detection: verifies pubspec depends on this package before copying WASM files
- WASM file caching with shared output directory (avoids redundant downloads)
- Incremental file copy: only copies if source is newer than destination
- Added
_crateNameconstant to eliminate hardcodedlibsignal_frbstrings - Added
rust/Cargo.tomlas dependency for cache invalidation on local builds - Improved error messages with actionable guidance throughout
- Replaced copier template placeholders with dynamic values from helper scripts
{{ android_min_sdk }}→ reads fromandroid/build.gradleat build time{{ crate_name }}→ uses_crateNameconstantfvm install→fvm usewith version from.fvmrc
- Updated example app platform configs to use template-standard naming
- Renamed
libsignal_example→examplein web, Windows, macOS, Linux, iOS configs
- Renamed
- Renamed Claude skill
ffi-patterns→frb-patternsto match current FRB architecture - Improved CI workflows with better step status tracking
- Each step now reports
success=true/falsefor clearer PR status - PR body shows inline status for each updated file
- Each step now reports
- Removed unused
GITHUB_TOKENfromcheck_updates.dart(not needed for public GitHub API) - Fully automated libsignal update workflow (
check-libsignal-updates.yml)- Now automatically runs
cargo updateto update Cargo.lock - Now automatically regenerates FRB bindings via
make codegen - Now automatically updates CHANGELOG.md using AI (requires
AI_MODELS_TOKENsecret withmodels:readpermission) - All steps are non-blocking: PR is created even if some steps fail
- PR description shows status of each step (success/failure)
- Labels added for failed steps (
cargo-toml-failed,cargo-lock-failed,codegen-failed,changelog-needed)
- Now automatically runs
Fixed
- Fix
workflow_runtrigger intest.yml— referenced wrong workflow name ("Build libsignal Native Libraries"→"Build libsignal FRB Libraries"), causing tests to never auto-trigger after build completion - Fix env var name in
build-libsignal.ymlcheck-release step (GH_TOKEN→GITHUB_TOKEN) — Dart script readsGITHUB_TOKEN, notGH_TOKEN - Fix outdated script filenames in
scripts/README.md(check_new_libsignal_version.dart→check_new_upstream_version.dart,check_exists_libsignal_frb_release.dart→check_exists_frb_release.dart) - Fix incorrect env var reference in
CLAUDE.mdinline comment (GITHUB_TOKEN→AI_MODELS_TOKEN) - Upgrade
flutter_lintsin example app from^5.0.0to^6.0.0 - Fix
.pubignore— include Rust source files in published package (only excluderust/target/build artifacts, not entirerust/directory); add trailing newline
Removed
- Removed legacy scripts with project-specific naming
scripts/check_new_libsignal_version.dart→scripts/check_new_upstream_version.dartscripts/check_exists_libsignal_frb_release.dart→scripts/check_exists_frb_release.dartscripts/src/check_new_libsignal_version.dart→scripts/src/check_updates.dart
- Removed unused
scripts/combine_artifacts.dart
Added
make check-template-updatescommand to check for new copier template versionscheck-template-updates.ymlworkflow — daily CI check for template updates with automated notification PRupdate-templateClaude skill — step-by-step guide for applying template updates- Documents
--defaultsflag for non-interactivecopier update(required for Claude Code) - Documents manual
_commitupdate in.copier-answers.ymlwhen copier fails to update it (conflicts or no file changes)
- Documents
make rust-updatecommand to updaterust/Cargo.lockviacargo updatemake update-changelogcommand to update CHANGELOG.md using GitHub Models AI- AI-powered changelog generation script (
scripts/update_changelog.dart)- Fetches libsignal release notes from GitHub API
- Uses GitHub Models (gpt-4o-mini) to generate appropriate changelog entry
- Includes real examples from project's CHANGELOG in AI prompt for consistent formatting
- Automatically inserts entry in correct CHANGELOG.md location
- Helper scripts for dynamic build configuration
scripts/get_android_min_sdk.dart— readsminSdkfromandroid/build.gradlescripts/get_flutter_version.dart— reads Flutter version from.fvmrc
- Analyzer exclusions for
hook/**,scripts/**,example/**,example_cli/**(separate packages, not part of main analysis)
2.2.1 - 2026-02-03 #
For Users #
Fixed
- Fix native library loading for pure Dart CLI applications
- JIT mode (
dart run): loads from.dart_tool/lib/ - AOT mode (
dart build cli): loads frombundle/lib/relative to executable - Enables standalone executables to be distributed and run from any location
- JIT mode (
Security
- Remove CWD-based library search to prevent library hijacking attacks
- Previously searched
rust/target/release/in current working directory - Attacker could place malicious library in CWD to hijack application
- Now only searches trusted paths: build hook locations and executable-relative paths
- Previously searched
2.2.0 - 2026-02-03 #
For Users #
✨ Highlights
- libsignal v0.87.0 — latest upstream Signal Protocol library
- libsignal_frb v1.0.2 — Rust FFI bindings
Changed
- Update libsignal native library to v0.87.0 (release notes)
- Breaking change in upstream:
PublicKeyordered comparison (Ord trait) has been removed - New:
accountExists()API exposed to client libraries - New: gRPC support for username hash lookup
- Note: Our
PublicKey.compare()method continues to work — now compares by serialized bytes
- Breaking change in upstream:
- Update
libsignal_frb(Rust crate) to v1.0.2- Adapted
PublicKey.compare()to use byte comparison after upstream Ord removal
- Adapted
Fixed
- Fix native library loading for pure Dart CLI applications using
dart runDynamicLibrary.open()doesn't resolve native asset IDs in JIT mode- Now reads
.dart_tool/native_assets.yamlto get the actual library path - Enables
example_cliand other CLI apps to work with published package
Security
- Updated
bytesdependency to v1.11.1 to fix integer overflow vulnerability (RUSTSEC-2026-0007)
For Contributors #
Added
make updatecommand to updaterust/Cargo.lockviacargo updatemake update-changelogcommand to update CHANGELOG.md using GitHub Models AI- AI-powered changelog generation script (
scripts/update_changelog.dart)- Fetches libsignal release notes from GitHub API
- Uses GitHub Models (gpt-4o-mini) to generate appropriate changelog entry
- Includes real examples from project's CHANGELOG in AI prompt for consistent formatting
- Automatically inserts entry in correct CHANGELOG.md location
Changed
- Fully automated libsignal update workflow (
check-libsignal-updates.yml)- Now automatically runs
cargo updateto update Cargo.lock - Now automatically regenerates FRB bindings via
make codegen - Now automatically updates CHANGELOG.md using AI (requires
AI_MODELS_TOKENsecret withmodels:readpermission) - All steps are non-blocking: PR is created even if some steps fail
- PR description shows status of each step (success/failure)
- Labels added for failed steps (
cargo-toml-failed,cargo-lock-failed,codegen-failed,changelog-needed) - Added checklist items for
rust/Cargo.tomlversion bump andmake rust-check
- Now automatically runs
- Updated
update_changelog.dartscript to generate two Highlights entries (libsignal + libsignal_frb) - Updated Claude skill
.claude/skills/update-libsignal/SKILL.mdwith "Review Automated PR" section
2.1.1 - 2026-01-30 #
For Users #
Changed
- Update libsignal native library to v0.86.16 (release notes)
- chat: Make gRPC failures directly convertible to RequestError
- Make E164Info and AciInfo constructors public
- Note: These changes do not affect this library's API
2.1.0 - 2026-01-29 #
For Users #
✨ Highlights
- libsignal v0.86.15 — latest upstream Signal Protocol library
Added
SecureBytesclass for wrapping sensitive byte data with automatic zeroing on disposalSecureUint8Listextension withzeroize()method for manual zeroing ofUint8List
Changed
- Update libsignal native library to v0.86.15 (release notes)
- SVR2: Updated production enclave
- SVRB: Added new production enclave to
currentset - New
accountExists()typed API - Backup: Support for key transparency fields
- Note: These changes are server-side infrastructure updates, no API changes affect this library
Security
- Rust-side zeroing of sensitive input bytes in all
deserialize()methods (keys, prekeys, sessions) - Added security documentation comments to methods returning sensitive data (serialize, agree, decrypt)
- Added zeroing best practices to SECURITY.md (Section J)
- Regenerated FRB bindings to include security documentation in Dart API
For Contributors #
Changed
- Remove unused
source_filesfrom iOS podspec- Native assets packages don't need CocoaPods to compile Swift code
- Libraries are loaded via
hook/build.dart, not CocoaPods - See Flutter docs
Fixed
- Fix Windows CI: download
makeandprotocfrom GitHub Releases instead of Chocolatey (CDN unreliable)
2.0.0 - 2026-01-24 #
For Users #
⚠️ Breaking Changes
-
Platform requirements: Minimum iOS raised to 13.0, macOS to 10.15
-
Architecture: Migrated from C FFI to Flutter Rust Bridge (FRB)
- No more
dispose()calls needed — memory managed automatically by Rust - Store operations now use DartFn callbacks for async Dart-to-Rust communication
- No more
-
API Changes:
ProtocolAddress('name', 1)→ProtocolAddress(name: 'name', deviceId: 1)privateKey.serialize().bytes→privateKey.serialize()(returnsUint8Listdirectly)publicKey.verify(message, signature)→publicKey.verify(message: message, signature: signature)Fingerprint.create(...)→Fingerprint(iterations: ..., version: ..., ...)Aes256GcmSiv(key)→Aes256GcmSiv(key: key)cipher.encrypt/decryptnow requiresassociatedDataparameterGroupSessionclass replaced with callback-based functions
✨ Highlights
- Web platform support (WASM) — run Signal Protocol in browsers
- Flutter Rust Bridge architecture — cleaner API, automatic memory management
- libsignal v0.86.14 — latest upstream Signal Protocol library
- Modern platform support — iOS 13.0+, macOS 10.15+ (Catalina)
Security
- Add low-order point validation for public keys in
PreKeyBundleandFingerprint- Reject non-canonical Curve25519 points that could be used in small subgroup attacks
Added
- Web platform support (WASM) — first-class browser support via wasm-pack
- Native assets build hooks (
hook/build.dart) for automatic library download - Precompiled binaries via GitHub Releases — no Rust required for end users
- SHA256 checksum verification for precompiled binaries
Changed
- Update libsignal native library to v0.86.14 (release notes)
- MSRV bumped to Rust 1.88
- Improve error message for unexpected ciphertext message types (now shows actual type)
Removed
SecureBytes,SerializationValidator,LibSignalExceptionclasses- Manual Dart wrapper classes (replaced by FRB-generated code)
For Contributors #
Added
make rust-audit— Rust dependency vulnerability scanningmake setup-rust-tools— installs cargo-audit, flutter_rust_bridge_codegenmake setup-protoc— cross-platform protoc installationmake setup-web— installs wasm-pack for web buildsmake setup-android— installs cargo-ndk for Android builds- Rust security audit job in CI (runs
cargo-auditon every test run) - Plaintext handling documentation in SECURITY.md
- CI workflow for building precompiled binaries (
build-libsignal-frb.yml)
Changed
- Update
.claude/skills/documentation for FRB architecture - Restructure
make setupto install all required tools
Removed
- Old C FFI code (
lib/src/bindings/,rust/src/ffi/) - Pre-built native libraries (
bin/,macos/Libraries/,ios/Libraries/, etc.) headers/signal_ffi.h
1.1.2 - 2026-01-19 #
Changed #
- Update libsignal native library to v0.86.12 (release notes)
- H2 support for unauthenticated chat (new remote config option)
- Updated libcrux-ml-kem and spqr dependencies
1.1.1 - 2026-01-13 #
Added #
.claude/skills/folder now included in repository and published package
Changed #
- Update libsignal native library to v0.86.11 (release notes)
- Fixes TLS proxy connectivity issue with certain TLS certificates
- Update FFI bindings to match new libsignal API:
- KyberPreKeyStore callbacks now include
destroycallback - Callback function names updated to longer namespaced format
- Parameter types updated (
SignalConstPointer*toSignalMutPointer*where applicable)
- KyberPreKeyStore callbacks now include
1.1.0 - 2026-01-08 #
Added #
- Add
make setup-buildcommand to install native build dependencies (Rust, protoc) - Add
make setup-fvmcommand (renamed from previousmake setup) - Restructure
make setupto run full setup (FVM + build dependencies) - Add "Skip Build Hook Pattern" documentation to CLAUDE.md
- Add multi-platform testing: Linux x86_64, Linux ARM64, macOS ARM64, Windows x86_64
- Add reusable test workflow (
test-reusable.yml) to eliminate code duplication betweentest.ymlandpublish.yml
Changed #
- Replace
softprops/action-gh-releasewith officialghCLI in CI workflows - Update GitHub Actions to latest versions:
actions/create-github-app-tokenv1 → v2peter-evans/create-pull-requestv7 → v8ilammy/msvc-dev-cmdv1 → v1.13.0
- Tests now run in parallel on all 4 platforms
- Extract test logic into reusable workflow for better maintainability
- Update libsignal native library to v0.86.10 (release notes)
- Simplify
check-libsignal-updates.ymlworkflow:- Remove AI analysis (GitHub Models) - now only updates
native_versionin pubspec.yaml - Remove automatic FFI bindings regeneration (now manual step after merge)
- Add clear instructions in PR body for manual steps after build completes
- Remove AI analysis (GitHub Models) - now only updates
- Simplify
check_updates.dartscript:- Remove
--ai,--no-ai,--bump,--no-changelogoptions - No longer updates package version or CHANGELOG.md automatically
- Remove
- Remove
scripts/src/ai_analysis.dart(no longer needed) - Use GitHub App token instead of
GITHUB_TOKENin workflows:check-libsignal-updates.yml: PR creationbuild-libsignal.yml: release version checks
- Skip tests for bot PRs in
test.yml(native libraries not yet built for version updates) - Discard FVM config changes in CI to prevent unwanted
.fvmrcand.vscode/settings.jsonmodifications in PRs - Extract Rust setup into reusable
.github/actions/setup-rustaction
Fixed #
- Fix duplicate "v" prefix in native library release notes (
vv0.86.10→v0.86.10) - Remove redundant "Usage" section from native library release description
- Fix ARM64 group messaging crash caused by
SignalUuid16-byte struct-by-value FFI limitation (dart-lang/sdk#36730)- Pass
SignalUuidas twoInt64values matching ARM64 AAPCS64 register layout - Affects
signal_sender_key_distribution_message_createandsignal_group_encrypt_message
- Pass
- Fix Windows native library build in CI
- Create shell wrapper for
fvminsetup-fvmaction (Git Bash cannot execute.batfiles) - Use PowerShell for build step to ensure MSVC
link.exeis used instead of Git's/usr/bin/link
- Create shell wrapper for
- Fix
make regenCI failure whencbindgenis not pre-installed - Fix
make regenCI failure due to missingprotoc(required by libsignal's spqr dependency) - Add
protocto build prerequisites documentation (README.md, CLAUDE.md)
1.0.1 - 2026-01-02 #
Added #
- Added
make doccommand for local API documentation generation - Added "Implementation Status" section to README.md with overview of wrapped native functionality
- Added pre-commit git hook for format check and static analysis (configured via
make setup) - Added
workflow_dispatchtrigger to test workflow (allows manual test runs from GitHub Actions)
Changed #
- Improved test coverage to 98.4%
- Added
// coverage:ignorecomments to genuinely untestable code (FFI callbacks, finalizers, defensive null checks) - Removed unused
extractOwnedBufferfunction fromFfiHelpers - Refactored CI update workflow: moved AI analysis from bash to Dart script
- Simplified
check-libsignal-updates.ymlworkflow (~530 → ~220 lines) - Added
--ai,--no-ai,--ciflags tocheck_updates.dartscript - Script now writes directly to
GITHUB_OUTPUTin CI mode (no jq parsing needed) build-libsignal.ymlworkflow now skips build if release already exists (prevents unnecessary rebuilds when only package version changes)
Fixed #
- Fixed
publish.ymlworkflow: use Flutter SDK (via FVM) instead of Dart SDK for publishing Flutter packages - Added
workflow_dispatchwith dry-run option to publish workflow - Added duplicate version check (validates against pub.flutter-io.cn API before publishing)
- Added
publish-dry-runvalidation step before actual publishing - Aligned publish workflow structure with liboqs_dart for consistency
- Fixed version parsing in
build-libsignal.ymlworkflow (use Dart script instead of grep for reliable parsing) - Fixed unresolved dartdoc references in
LibSignalException,GroupSession, andInMemoryIdentityKeyStore - Fixed
.pubignoreto includeCONTRIBUTING.mdin published package - Fixed
.pubignoreto exclude generateddoc/directory - Fixed LICENSE file format for proper pub.flutter-io.cn recognition (added full AGPL-3.0 text with SPDX identifier)
1.0.0 - 2025-12-31 #
Added #
- Pre-built native libraries for all platforms (iOS, Android, macOS, Linux, Windows)
- Signal Protocol: Double Ratchet algorithm for forward secrecy and break-in recovery
- X3DH: Extended Triple Diffie-Hellman for asynchronous key agreement
- Key Management: Curve25519 key pairs (
PrivateKey,PublicKey,IdentityKeyPair) - Pre-keys:
PreKeyRecord,SignedPreKeyRecord,PreKeyBundlefor session establishment - Post-quantum: Kyber key pairs (
KyberKeyPair,KyberPreKeyRecord) for quantum resistance - Sessions:
SessionRecord,ProtocolAddressfor session management - Messages:
SignalMessage,PreKeySignalMessagefor encrypted communication - Sealed Sender: Anonymous message sending (
ServerCertificate,SenderCertificate) - Group Messaging: SenderKey distribution (
GroupSession,SenderKeyRecord,SenderKeyDistributionMessage) - Cryptographic utilities: AES-256-GCM-SIV (
Aes256GcmSiv), HKDF (Hkdf), identity fingerprints (Fingerprint) - Storage interfaces:
SessionStore,IdentityKeyStore,PreKeyStore,SignedPreKeyStore,KyberPreKeyStore,SenderKeyStore - In-memory store implementations for testing and prototyping
- Automatic native library download via build hooks
- SHA256 verification for native library integrity
LibSignal.init()for optional library pre-initialization- Comprehensive exception handling with
SignalException - GitHub Actions CI/CD pipeline for automated testing and publishing
- Automated upstream version tracking with AI-powered changelog generation
- Cross-platform build scripts for native library compilation
- Example Flutter application and CLI example demonstrating all features
Security #
- Based on libsignal v0.86.11 from Signal Foundation
- Secret keys are handled securely with proper memory management
- Cryptographic operations use constant-time implementations where applicable