libsignal 7.3.0 copy "libsignal: ^7.3.0" to clipboard
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 the privateKey getter and rebuilt a PrivateKey from 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 hkdfDerive on this same surface takes it as inputKeyMaterial. 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 what SessionBuilder and SessionCipher already 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 — rustContentHash is 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 a Vec<u8> handed across FFI, and from there nothing can reach it: no zeroize in Rust, no dispose() 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 privateKey getter 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 with signAlternateIdentity, 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 IdentityKeyPair has no general-purpose sign; this is a deliberate addition on our side, and the privateKey getter 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's web/pkg/, and flutter run -d chrome reaches the hook only while Flutter still considers its dart_build target out of date. That target's cache key omits the target platform: a debug flutter run keys 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, logs Skipping target: dart_build, and the hook is never invoked. In an app whose web/pkg/ is not already provisioned that surfaces as RustLib.init() failing on a 404 for pkg/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: one flutter build web, which is keyed to its own build directory and always reaches the hook; deleting build/*/dart_build.stamp; or flutter clean. Once web/pkg/ holds the right files, flutter run -d chrome serves 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 .so fails 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=16384 and its common-page-size twin — and not by the NDK: r26-built and r28-built artefacts measure p_align=0x4000 alike, and 32-bit armeabi-v7a measures 0x1000 on both, correctly, the requirement being a 64-bit one. So the property belonged to a tool the release job installed with cargo install cargo-ndk --locked and no version, taking whatever was newest that day. It is pinned to 4.1.2 now, the version whose flags were read out of the binary, and the job verifies the result rather than trusting the pin: make verify-android-alignment reads ELF program headers directly — no readelf, no NDK — and fails closed on a non-ELF file, on a file with no PT_LOAD segments, and on finding nothing to check at all.

For Contributors #

Added

  • main requires the whole CI matrix, not just a codegen guard (.github/rulesets/protect-main.json, .github/rulesets/README.md) — none of the four rulesets carried a required_status_checks rule, so a red CI run never blocked a merge. Eleven contexts are required now: FRB bindings were regenerated plus 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.yml entry 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 Badge is 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 recent Tests runs the signature TimeoutException after 0:00:30 produced three isolated leg failures: Windows x86_64 twice and Linux ARM64 once, 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 is Linux 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 through test / 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 Badge reports success on one and skipped on 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 takes make setup-repo-protections ARGS="--update", plain setup-repo-protections skipping one that already exists.

    Applied to main on 2026-09-07 and verified against the live API: the rule carries all eleven contexts, each with integration_id 15368 and strict_required_status_checks_policy still false; the Admin bypass and the other three rulesets are untouched; rules/branches/main reports the rule as effective, and a Dependabot branch still reports none. All four live rulesets match their committed JSON, and the --update PUT preserved require_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 on workflow_dispatch and on a libsignal_frb-* release tag. Every leg of the test matrix runs make build, which builds for the host. So an Android-only build failure was invisible on main under 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 reads android_ndk_version and the API level from the same answers build-libsignal.yml does, so the gate cannot run on a different toolchain than the release.

  • make actionlint, and a Workflow 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 a needs: naming a renamed job all reach main and then fail on the very run that was supposed to gate them. actionlint reads them statically and hands every run: 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.yaml rather 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.yml no 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-level if: still reports, as skipped, and counts as satisfied, while a workflow excluded by a workflow-level paths: reports nothing at all — and no setting reads a missing check as passed. While test.yml filtered pull_request by path, requiring any test / … 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-level if: on the expensive legs.

  • make verify-frb-pins tells 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 source 2761886 added was required outright. That is right here, where rust/fuzz/Cargo.toml pins flutter_rust_bridge and 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 codegen has 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 and flutter_rust_bridge_codegen is 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 names make verify-frb-pins as the thing that enumerates them, which is the part that cannot go stale, instead of repeating a list beside it.

  • make run-example-web clears the stale dart_build stamp before it runs (Makefile, CLAUDE.md) — the target wiped example/web/pkg/ and trusted the build hook to refresh it, which the hook cannot do when Flutter never invokes it. flutter run shares one build directory, and so one dart_build stamp, 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 stale web/pkg/ into a missing one, so the example failed to start with a 404 for pkg/libsignal_frb.js rather than with the wrong WASM — the more confusing of the two failures, and the one that reads as a Rust or FRB bug. Deleting example/build/*/dart_build.stamp is correct by construction: a stamp that does not exist cannot be stale, and an unmatched glob under rm -f is 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 and example/web/pkg/ stays missing; with it deleted the hook runs, and the dev server serves pkg/libsignal_frb.js and pkg/libsignal_frb_bg.wasm in full. The comment above the target no longer claims the hook "should refresh on its own", and CLAUDE.md carries 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 carrying codegen-failed and 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, because frb_generated.rs stops compiling and make build goes red on four platforms — but one that adds a pub fn, or edits a docstring, compiles perfectly and simply lacks the function on the Dart side. That is the gap bc0fdc9 fell through here, where a corrected Rust security note never reached the generated Dart. make codegen appeared 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, not git diff --exit-code, because codegen can add a file and git diff is blind to an untracked path. The job name is untouched on purpose: FRB bindings were regenerated is a required status check in protect-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 no paths: 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 the dart_build stamp fix are the entries above, and they came back byte-identical, so protect-main.json, frb_pins.dart, verify_frb_pins.dart and frb_pins_test.dart were not touched at all. What actually arrived is the four items already listed plus two sweeps: every $GITHUB_OUTPUT, $GITHUB_ENV, $GITHUB_PATH and $GITHUB_STEP_SUMMARY redirection 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.md gains the actionlint and three Cross-compile (Android …) contexts, which the runbook had been due to grow by hand.

    Two hand-merges. README.md and CONTRIBUTING.md conflicted 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 name flutter_rust_bridge, where the template describes a generated one that does not. .github/rulesets/README.md merged with no conflict marker and a duplicated section: the template's rewritten runbook was appended beside the existing one, leaving two ### Required status checks and two ### Why Dependabot branches are excluded with contradictory context lists. The template's copy is kept — it is the one carrying the new contexts — with test / 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_version deliberately 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 through openssl-src by projects that vendor SQLCipher; rust/Cargo.lock here contains no openssl-src, openssl-sys, rusqlite or libsqlite3-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 empty bypass_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 about delete-branches.json in 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.yaml constrains flutter_rust_bridge so 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 in libsignal-net-chat, libsignal-account-keys or 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-core and signal-crypto, which this package names, and libsignal-debug, which arrives transitively. signal-crypto and libsignal-debug have no changed file in the range; libsignal-protocol and libsignal-core have 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 about slice::element_offset), rust/core/src/lib.rs (+2/-3, an updated issue number in a comment about try-blocks) and rust/core/src/version.rs (the version string).

    Asked at the lockfile rather than the file tree, the answer is the same: rust/Cargo.lock holds 226 packages before and after, with none added and none removed, and four version moves — libsignal-debug 0.101.2 → 0.102.0, which inherits the workspace version, plus cc 1.4.4 → 1.4.5, find-msvc-tools 0.1.11 → 0.1.12 and smallvec 1.15.2 → 1.16.0. THIRD_PARTY_NOTICES.txt records 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 the tinyvec < 1.13.0 cap guards a crate the graph does not contain. Regenerating the bindings produced no change under lib/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 workspace rust-version from 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_bridge moves 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 the codegenVersion recorded in lib/src/rust/frb_generated.dart with string equality, so every version a wider range admits except the one that generated the bindings fails RustLib.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 by stamp in 17 files and one codegenVersion string. rustContentHash does 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 spell Ok(...) as std::result::Result::Ok(...), two spell it the other way round at the infallible wrappers (Result::<_, ()>::Ok(x) becomes Ok::<_, ()>(x)), eight drop one space after move || {, three carry the @generated by stamp, one carries FLUTTER_RUST_BRIDGE_CODEGEN_VERSION, and one adds mismatched_lifetime_syntaxes to the allow list. That is 188 lines removed and 189 added with nothing left unclassified. In rust/Cargo.lock the bump moves flutter_rust_bridge_macros in 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_bridge yourself, and what happens when you don't act is worth stating exactly, because it is quieter than a failure. An ordinary ^2.12.0 is >=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 previous libsignal instead, 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 getrandom 0.2 declaration is gone from the wasm32 block (rust/Cargo.toml) — it had become its own only reason to exist. In rust/Cargo.lock the sole consumer of getrandom 0.2.17 was libsignal_frb itself, 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-preview1 went with it, having been reachable only through that version, so THIRD_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 # Security block said the internal copy of the key material was "cleared when the Hkdf instance goes out of scope". It is not. hkdf 0.13 publishes no [features] at all, hmac 0.13 does have zeroize = ["digest/zeroize"], and nothing in this crate's graph turns it on, so dropping an Hkdf runs no zeroizing Drop — 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 on hmac directly with its zeroize feature, gated on make build-web because 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 a make codegen run, so lib/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's unresolved-doc-reference is promoted from a warning to an error, and rustdoc runs under -D warnings on 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 — so rust/src/api/ now names the Dart surface in plain backticks, which links on neither side and rots on neither either. dartdoc_options.yaml is .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 gains Build WASM and Rust unit tests (browser), and make test-web runs the crate's cfg(target_arch = "wasm32") tests in headless Chrome. Until now nothing covered those branches: make test is the Dart VM and make build-web only compiles, while a wasm32 body is a different implementation of the same function rather than the same code on another host. rust/Cargo.toml gains the harness that needs, as a wasm32 dev-dependencies entry (wasm-bindgen-test); it is the only non-comment change to that manifest, so the shipped binary is untouched, and make verify-third-party-notices still passes with eleven new crates in Cargo.lock because cargo tree --edges normal,build excludes 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 the codegen-failed label. 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 unchanged lib/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. labeled and unlabeled are 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 of sealedSenderV2ParseSentMessage reaches the second guard in receivedMessageFor. It is reachable all the same, because SealedSenderV2SentMessage and SealedSenderV2Recipient are 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 into setRange. 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.dart next door is round-trips and shape checks: it proves hkdfDerive is 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 path hkdfDerive takes 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 a panic key in [profile.release] is load-bearing: panic = "abort" skips unwinding, so Drop never 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 under rust/src/api/ reach for zeroize, mostly through Zeroizing, whose wipe is a Drop impl and so exactly what unwinding runs. It reads the manifest rather than watching a panic because it has to: Cargo forces panic = "unwind" on the test and bench profiles 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 both panic and abort — 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 every cargo build and cargo install in build-libsignal.yml — the workflow that produces the shipped binaries — now passes --locked and builds from the committed lockfile rather than re-resolving; Dependabot stops proposing hooks and code_assets majors, 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, with verify-third-party-notices and verify-frb-pins moving 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.toml keeps js-sys — which the template render does not have and rust/src/utils.rs calls — 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.toml keeps its live RUSTSEC-2026-0173 entry 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.md keeps the headings its own table of contents links to and takes the template's prose; SECURITY.md keeps 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 new enable_freezed answer is false: flutter_rust_bridge needs freezed only 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.md was 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-actions ignore through Gem::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.

  • rand is ignored at >= 0.10.0 until libsignal moves — an OsRng is handed by &mut straight into libsignal's own Rng + CryptoRng bounds at 19 call sites in rust/src/api/, and those bounds are rand 0.9's traits; libsignal-protocol, libsignal-core and spqr all resolve rand 0.9.5. A 0.10 UnwrapErr<OsRng> implements a different RngCore, so the bump cannot compile. It is written as a versions range rather than update-types because Dependabot's trailers call 0.9 → 0.10 semver-minor, so an ignore aimed at majors would never fire — the same trap the group filter above it already documents.

  • getrandom is 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, so rust/Cargo.toml carries getrandom 0.3 (reached through libsignal-core and rand_core 0.9) alongside getrandom_04, a renamed declaration of the same crate at 0.4 (reached through aes-gcm-sivaeadcrypto-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 crate getrandom v0.4.3 multiple times with different names" — which is why three jobs went red. The name clash was not even the whole defect: libsignal-core keeps 0.3.4 in the lockfile regardless, so it would have been left with no wasm_js backend, because the retargeted declaration was the only thing enabling it — a compile_error! inside getrandom on the next make build-web. A versions range 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 from getrandom_04.

  • anthropics/claude-code-action moves 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 a refs/tags/* object id against a pinned commit id compares two different things and would have reported a mismatch that is not one.

  • lints moves 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 what make analyze enforces.

  • make coverage measures the code somebody wrote — everything under lib/src/rust/ is now excluded, not just the frb_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 were hashCode and operator == on value classes. Including them meant a make codegen run 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 for notes rather than cause, and a failure that does not reproduce is a cannot-fix naming 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 single codegenVersion line under lib/src/rust/, which the next make codegen reverts, so the red was silenced rather than repaired. Those paths may still change — by changing what generates them and running make codegen, which the agent is already permitted to run.

  • The getrandom removal waited for a release branch — nothing local proved a wasm32 dependency change at the time, because make build is host-only and CI then compiled wasm only on a libsignal_frb-* tag push. A graph change merged without running make build-web first 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 on make build-web before it was committed. The constraint itself is gone as of this release: Build WASM and Rust 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.yaml joins the test workflow's path filters (.github/workflows/test.yml, on push and pull_request both) — that file decides what make analyze reports, 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 carries dartdoc_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 the analysis_options.yaml filter 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. 0 rejected hunks; scripts/src/common.dart, scripts/src/release.dart and 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, and README.md, where copier paired our ### Key Types heading against the platform table — a misalignment rather than a disagreement, since our table already carries the iOS x64 (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.md outside the conflict brackets, where a blind keep-ours would have kept it alongside ours and said the same thing twice; and test/scripts/frb_pins_test.dart merged 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 _commit bump, plus copier rewriting rust_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/fuzz pins flutter_rust_bridge directly and takes the main crate by path, so the =2.12.0 it kept when 4f47a2e raised the main crate to 2.13.0 was not drift: cargo cannot resolve the two together at all. cargo metadata there exits 101 with "failed to select a version for flutter_rust_bridge", which makes every fuzz target unbuildable, locally and in CI. Three separate things had to miss it, and each did. rust/fuzz is its own workspace root, so no resolution under rust/ ever passes through it. The Fuzz workflow triggers on rust/** pull requests and a weekly cron but not on a push, and 4f47a2e reached main as a push — so the first red run was somebody else's pull request, days later. And make 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. FrbPin also 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 # Security correction recorded above was written into rust/src/api/crypto.rs without a make codegen run, so the generated Dart still carried the claim the correction exists to retract: that the internal copy of the key material is cleared when the Hkdf value 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 — the bindings=changed tripwire lives in check-libsignal-updates.yml, on the automated upstream path only, and codegen-guard.yml refuses a labelled pull request rather than comparing anything. Regenerating moved nothing else: rustContentHash is 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 -s carries no -f, so it exits 0 on 403, 429 and every 5xx, and the only other guard matched the single exact string Not 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-bearingtest/crypto/aes_gcm_siv_kat_test.dart shipped 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, and dart test -n could select none. The header lost aes-gcm-siv from 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-dart moves 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 for dart analyze and registers it with ::add-matcher::dart-analyzer.json, resolving that path against GITHUB_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, and publish.yml and build-libsignal.yml call 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 with problem-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 runs dart 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 and firstMatch, which takes whichever match comes first rather than the declaration. getUpstreamVersion is the one that mattered: a commented-out libsignal-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 makes make check-new-libsignal-version report 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 read v0.101.2, and a comment naming v0.999.0 read v0.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 as parseUpstreamTag so it is testable without a fixture tree — the way frb_pins.dart already 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 named libsignal_frb-<crate version> existed. The crate version does not move until stage 1 runs, so make release on 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.3 exists 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 — rustContentHash compares 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 the Option::unwrap() arg-count panic behind 6.0.1. The preflight now also reads frb_generated.dart and rust/Cargo.toml at 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-check remains 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-pins documented five sources and reads six (scripts/src/frb_pins.dart, Makefile) — the file header and the target comment were left behind when rust/fuzz/Cargo.toml joined the gate; CONTRIBUTING.md had 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.rs is that constant. rust/protocol/Cargo.toml turns on the zeroize feature (below). rust/protocol/src/sealed_sender.rs is an aead 0.5 → 0.6 migration — encrypt_in_place_detached becomes encrypt_inout_detached, AeadInPlace becomes AeadInOut — 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 in rust/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 codegen produced no diff in lib/src/rust/, so the FFI surface is unchanged.

  • This crate's RustCrypto dependencies now match the ones libsignal resolvessha2 0.10 → 0.11, hkdf 0.12 → 0.13 and aes-gcm-siv 0.11 → 0.12. The first two had been a version behind libsignal-protocol for some time and the third fell behind with the bump above, so the graph was resolving two copies of each: two sha2, two hkdf, two aes-gcm-siv and two aead. 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. sha2 0.11 moves to digest 0.11 while hkdf 0.12 still expects digest 0.10, so Hkdf::<Sha256>::new stops type-checking under either bump alone; applying both at once resolves it, and that part needed no code change.

    aes-gcm-siv 0.12 brings aead 0.6, where Array::from_slice is deprecated in favour of TryFrom. The two nonce conversions in rust/src/api/crypto.rs were 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-siv 0.12 enables aead/getrandom by default, aead 0.6 forwards that to crypto-common, and crypto-common depends on getrandom 0.4 with no target cfg — so that crate is now compiled for wasm32-unknown-unknown, where it refuses to build unless its browser backend is selected by name. Two getrandom majors were already declared in rust/Cargo.toml for exactly this reason; 0.4 is now declared alongside them with wasm_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 zeroize feature of aes-gcm-siv for libsignal-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_bridge was declared as ^2.12.0, while the committed lib/src/rust/frb_generated.dart records codegenVersion => '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 with codegen version (2.12.0) should be the same as runtime version (2.13.0). pubspec.lock is 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" in rust/Cargo.toml and FRB_CODEGEN_VERSION in the Makefile, 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.0 would 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 own integrate step writes with dart pub add.

    The range form, rather than the bare 2.12.0, is forced by the release path and not by taste. dart pub publish warns that a single-version constraint "should allow more than one version", and it exits 65 on any warning, so make publish-dry-run — which both make release and publish.yml gate on — fails, and the package cannot be published at all. >=2.12.0 <2.12.1 resolves to the same single version and does not trip that check. Measured rather than assumed: four constraint shapes were run through dart 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 at init(). 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. rustContentHash is 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 of aes-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 main goes redrepair-build.yml runs daily; when the latest completed Tests run on main failed, 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 with persist-credentials: false, so no token is left in .git/config for its Bash tool 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 admitted make release, make publish, make update-changelog and make setup-repo-protections; the push at the end of make release fails for want of a credential, but only after it has bumped pubspec.yaml in the working tree — and pubspec.yaml is inside the path allowlist, so a version bump would have ridden along in the pull request looking like part of the fix. cargo is enumerated for the same reason: cargo publish shares a prefix with cargo check. ls and rg were dropped in favour of Glob and Grep, which do the same work but are scoped to the workspace — rg reads any path the process can, /proc/self/environ included, 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 of ANTHROPIC_API_KEY and 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 a verdict.json; its absence means the run exhausted its turns or crashed, which is otherwise indistinguishable from "nothing needed fixing". A verdict of fixed with an empty diff fails instead of opening an empty pull request. And the failing run must be judging main'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 distilled build-failure.summary is now the entry point and the full log stays beside it for when the summary leaves a question open. Alongside it, run-context.md carries how every other job in the failing run concluded and how main has 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 agent gh avoids putting a token in its environment and avoids trusting a prefix match to keep gh read-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.md rather 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; and AGENT_CLAUDECODE_MODEL, which names the model and has no default. workflow_dispatch takes a run_id and a dry_run flag so the whole path can be rehearsed against a past failure instead of waiting for main to 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 latest Tests run on main concluded, which engine and model ran, and the verdict with its reasoning — so "the automation is switched off" and "the automation looked and main is fine" stop producing identical-looking green runs. When the agent returns cannot-fix a 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 — main went 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 once main is 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 providervars.AGENT_ENGINE selects claude-code or opencode, 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 and verdict.json contract 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 often main breaks; 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 /opencode comments 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 make make test * also match make test && make release — and make release bumps pubspec.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. cargo is absent entirely, since CLAUDE.md tells the agent never to call it directly. ask is 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. write is 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/opencode config exists, which is the property that makes a local rehearsal mean anything. The turn limit is steps in 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 is AGENT_OPENCODE_PROVIDER_ENV (default OPENROUTER_API_KEY) and the value is a single AGENT_OPENCODE_API_KEY secret. 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 the AI_MODELS list was built on, applied to the engine. A custom or self-hosted endpoint additionally takes a provider block with a baseURL in 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, the AGENT_OPENCODE_API_KEY secret, and optionally AGENT_OPENCODE_PROVIDER_ENV and AGENT_OPENCODE_VERSION; AGENT_OPENCODE_MODEL names 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 covers AGENT_OPENCODE_API_KEY alongside ANTHROPIC_API_KEY and 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 nothingai-review.yml reads 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 — anything make format-check, make analyze ARGS="--fatal-infos" or make rust-clippy already 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_MODEL and REVIEW_AGENT_OPENCODE_MODEL each 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_comment runs in the base repository's context with secrets, which is the classic pwn request, so review of somebody else's pull request is workflow_dispatch — the one manual trigger that already requires write access — and it takes a dry_run flag 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_BOTS names 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-frb no longer stamps its highlight into the middle of a sentence (scripts/src/release_frb.dart) — stampFrbHighlight inserted the **libsignal_frb vX.Y.Z** line at lastBullet + 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 codegen now uses the pinned generator (Makefile) — FRB_CODEGEN_VERSION pins the binary that make setup-frb-codegen installs, but codegen did not depend on that target and ran whatever flutter_rust_bridge_codegen happened to be on PATH. Regenerating with a different version rewrites the bindings and the codegenVersion they 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 --version when 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-changelog and the CHANGELOG entry of make update-template with it. The replacement makes the model operational configuration rather than code: AI_MODELS holds an ordered provider/model list 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: with AI_MODELS unset 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_TOKEN is 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: set AI_MODELS and 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_EFFORT tunes 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, via dart:io rather than a curl subprocess: 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 the changed field 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 with 400 API_KEY_INVALID where Anthropic uses 401, 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-protocol helper **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, and strict is 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 an AI_MODELS edit. A route whose model cannot do structured outputs is rejected outright rather than silently downgraded, and OpenRouter's habit of reporting upstream failures as an error object 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 curl subprocess), 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 whether lib/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 bump changed is unexpected — codegen reads rust/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_template already forbade inventing verification; this one now does too.

  • The libsignal update PR is no longer the one PR that skips the test suitetest.yml excluded update-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 or verify-third-party-notices running against it on any of the four platforms. Confirmed on the last real one (#57): fuzzing reported seven passing jobs and test reported skipping. The stated reason — the bump moves libsignal ahead of the published libsignal_frb binary — is already handled by the reusable workflow, which runs make build before make test; the build hook then finds rust/target/release and 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.dart is 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 flagged thought.

  • 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 Updates stops failing. It has been red on every run since 2026-08-16 — fifteen consecutive runs, one cause. create-pull-request begins with git checkout -B <temp> HEAD, and git refuses that while the index holds unmerged entries: error: you need to resolve your current index first. Its own git add -A comes 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 runs make rust-check as one of the gates it reports, and without protoc spqr's build script panics with Could 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: spqr reaches this package's users without being named anywhere in lib/ or rust/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 beside verify-third-party-notices on 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: a dependency_overrides entry 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.yml joins the test.yml path filters, because a commit that edits only frb_version is exactly the commit that can put the pins out of step.

    Dependabot watches pub and cargo (.github/dependabot.yml) — it watched only github-actions, which is one reason the flutter_rust_bridge break arrived as a silent resolve rather than a reviewable pull request. flutter_rust_bridge itself 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-pins catches that instead. The upstream crates are ignored under cargo for a different reason: Dependabot's cargo updater does follow git refs, so without that it would open its own pull request for the same bump check-libsignal-updates.yml exists 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.lock is deliberately not committed for a library, so CI re-resolves on every run. lints is capped to one minor line because make 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. ffigen keeps its ^20.1.1 floor, which the template now shares. hooks and code_assets stay on the caret: they define the protocol hook/build.dart implements 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. The test job's Rust toolchain also stays stable while 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@v3 deprecates app-id, and all six call sites now pass client-id: ${{ vars.APP_CLIENT_ID }}. This superseded the unmerged chore/app-token-client-id branch entirely — pr-review.md, ai-review.yml and repair-build.yml came 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 absolute GIT_DIR from a worktree, every child process inherits it, and flutter then reads the committing repository's HEAD to determine its own version and writes 0.0.0-unknown into 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 is widen, "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 were ffigen, lints, code_assets and hooks — and which also rewrote flutter_rust_bridge from ">=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, ffigen and hooks were widened past bounds set on purpose as well.

    ignore is 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_bridge was ignored and rewritten anyway. versioning-strategy: increase-if-necessary fixes it — a constraint that already admits the new version is left alone, so a dependency nothing is updating stays untouched. cargo needs none of this: on the same run it changed exactly the one crate it was bumping and left every pin alone.

    make verify-frb-pins caught the rewrite on all four platforms before it could merge — its first real encounter, and what it exists for.

  • The pub and cargo groups 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 a minor+patch group anyway. The six-crate cargo group split into a group of two (log patch, uuid 1.x minor) plus one pull request each for rand, sha2, hkdf and aes-gcm-siv — the four that need a migration. Exactly the intended shape.

  • dart-lang/setup-dart 1.8.x is ignored, temporarily and narrowly (.github/dependabot.yml) — 1.8.0 added a problem matcher for dart analyze and 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 own setup-fvm composite 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 dart binary for dart pub global activate fvm on 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 DartPreKeySignalMessage, SenderKeyMessage, SenderKeyDistributionMessage and PlaintextContent join SignalMessage and DecryptionErrorMessage. Messages could be produced and consumed but never read, which is why a group ciphertext's own distributionId had to be known out-of-band and a session's first post-quantum ratchet payload was unreachable (#62)

  • flutter test works again for Flutter apps that depend on this package (#63) — on macOS and Linux LibSignal.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-outUnidentifiedSenderMessageContent makes the sealed envelope itself addressable, so a recipient can learn whether an undecryptable message is worth a resend request without decrypting it; sealedSenderMultiRecipientEncryptWithCallbacks produces 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 PreKeySignalMessage type (#62) — PreKeySignalMessage.deserialize(data: bytes) plus serialize(), messageVersion(), registrationId(), preKeyId(), signedPreKeyId(), kyberPreKeyId(), kyberCiphertext(), baseKey(), identityKey(), message() and cloneMessage(). message() returns the wrapped SignalMessage, 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 through SessionCipher, which owns the stores this type has no access to. As with SignalMessage.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 SenderKeyMessage and SenderKeyDistributionMessage typesdistributionId(), chainId(), iteration(), messageVersion(), plus ciphertext() and verifySignature() on the message and signingKey() on the distribution message. This is what lets a recipient derive the distributionId that GroupCipher.decrypt and processDistributionMessage require, 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 PlaintextContent typePlaintextContent.fromDecryptionErrorMessage(...) builds the envelope a DecryptionErrorMessage travels in, which previously could be parsed from an incoming message but not produced, leaving the retry-receipt flow half-implemented. Note DecryptionErrorMessage.extractFromSerializedContent takes body(), not serialize() — it rejects the leading identifier byte

  • New UnidentifiedSenderMessageContent type and ContentHint enum — 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 with sealedSenderEncryptFromUsmcWithCallbacks. sealedSenderDecryptToUsmcWithCallbacks goes 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 requires trustRoot, timestamp, localName, localDeviceId and getIdentity, and runs all three of the checks that stand between SealedSenderCipher.decrypt and 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 encryptionsealedSenderMultiRecipientEncryptWithCallbacks encrypts one UnidentifiedSenderMessageContent for many destinations at once, producing the single SentMessage blob a server fans out; sealedSenderV2ParseSentMessage reads 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 or PNI:<uuid>), and destination registration ids must fit in 14 bits (0..=16383). Identities are resolved through getIdentity and 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-core and signal-crypto is rust/core/src/version.rs, the version constant itself; make codegen agrees, producing byte-identical bindings. Upstream's work went to zkgroup (GenericServerSecretParams and GenericServerPublicParams drop serde::Deserialize in favour of TryFrom<&[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: boring is not in this crate's dependency graph at all. The remaining lockfile movement is patch-level transitive bumps

Security

  • sealedSenderDecryptToUsmc now 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 with SealedSenderCipher.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 with untrusted 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 a getIdentity callback 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 same untrusted identity error. 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: pass getIdentity — the same callback you already give sealedSenderDecryptWithCallbacks. This changes the signature announced in #62; nothing on pub.flutter-io.cn shipped with the old one

  • sealedSenderV2ParseSentMessage no 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 roughly recipients × message size: a 570 KB input measured at 1 GB of output, an amplification that rises with the square of the input. It now returns keyMaterialStart/keyMaterialEnd per recipient plus one sharedBytesOffset, which is O(recipients) whatever the body size, and SealedSenderV2SentMessage.receivedMessageFor builds a single recipient's message on demand. This is also how libsignal expects a fan-out server to work — range_for_recipient_key_material and offset_of_shared_bytes exist for it. A message larger than u32::MAX is now rejected rather than having its offsets truncated. Action required: replace recipient.receivedMessage with parsed.receivedMessageFor(recipient: recipient, data: theSameBytes)

  • Sealed sender now detects a self-send, on both paths — upstream's sealed_sender_decrypt refuses 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: sealedSenderDecryptWithCallbacks unsealed and decrypted such a message, and sealedSenderDecryptToUsmcWithCallbacks unsealed it and reported you as the sender — enough for a caller to aim a resend request at itself. Both now raise self 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: sealedSenderDecryptToUsmcWithCallbacks takes localName and localDeviceId, the same two sealedSenderDecryptWithCallbacks already took. sealedSenderDecryptWithCallbacks is 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 production IdentityKeyStore.getIdentity over a locked database was enough to leave an identity key pair — and, for a multi-recipient send, every destination's SessionRecord — in freed memory. Clearing is now tied to Drop (Zeroizing, and a guard around the destination list), so it happens on return, on error and on unwind alike. The SessionRecord loaded during a sealed-sender decrypt, which was never zeroized at all, is covered too

  • SealedSenderV2SentMessage.receivedMessageFor rejects a buffer that is not the parsed one — it documented that it throws when data does 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 carries parsedLength and 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. data is also List<int> now, matching every other byte parameter in the generated API

Fixed

  • LibSignal.init() now works under flutter test (#63) — the build hook registers the native library as a CodeAsset, but a package: asset id is not a path: it cannot be dlopened, and Dart offers no way to ask for a registered asset's file location, so the library has to be found on disk. Only the dart run/dart test and AOT-bundle locations were probed. flutter test installs the very same hooked library under build/native_assets/<os>/ and never creates .dart_tool/lib/, so on macOS and Linux the unit tests of every Flutter package depending on libsignal failed in LibSignal.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's PATH, which is where Windows looks for a DLL. A leftover .dart_tool/lib/ from a previous dart test was 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.so on Linux)

  • A sender key distribution message processed under the wrong distribution id is now refused instead of silently droppedGroupCipher.processDistributionMessage takes the distribution id from the caller, but a SenderKeyDistributionMessage also 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 throws Distribution ID mismatch: message carries <x>, caller passed <y>. The matching-id path is unchanged. GroupCipher.decrypt was already fail-closed on the same mismatch and is untouched

For Contributors #

Added

  • Native-asset probe-order coveragetest/platform/native_asset_search_paths_test.dart pins that the flutter test install directory is in the probe list, spelled as a per-host literal rather than by recomputing the implementation's own Platform.operatingSystem expression, and that it stays behind the AOT bundle. Nothing in make test could have caught #63dart test always resolves through .dart_tool/lib/, so the first probe wins there. The only end-to-end guard would be a flutter test leg over example/, which does not exist yet

  • PreKeySignalMessage test coveragetest/protocol/prekey_signal_message_test.dart covers 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 bare SignalMessage) is rejected

  • Distribution-id mismatch coveragetest/groups/distribution_id_mismatch_test.dart pins 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 typestest/groups/sender_key_message_inspection_test.dart, test/protocol/plaintext_content_test.dart and test/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-outrust/src/ssv2_equivalence_tests.rs (run by make 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 to received_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 envelopetest/sealed_sender/decrypt_to_usmc_identity_trust_test.dart runs sealedSenderDecryptToUsmc and SealedSenderCipher.decrypt against the same forged-certificate message and requires both to refuse it, pins trust-on-first-use for an unknown sender, and asserts the getIdentity callback is never reached when the certificate chain fails. Its absence is what let the gap ship. usmc_and_multi_recipient_test.dart gains 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 testtest/protocol/spqr_ratchet_progress_test.dart runs a 200-round-trip alternating conversation and decodes the epoch and payload type out of each SignalMessage.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 with Ct1 on 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 new PreKeySignalMessage.message(). A second case pins the responder's 4-byte None frames while the header is still incomplete as expected behaviour (reported as #62)

Changed

  • TestParty moved to test/test_helpers/test_party.dart — it lived inside session_cipher_test.dart and was already being imported across test files; it is now a proper helper library alongside session_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 blockedAtTimestamp field on Contact and Group" — lands entirely in rust/net, rust/attest and rust/message-backup, alongside a LogSafeDisplay for socks::Protocol and 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 the VERSION constant
    • 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 from libsignal-protocol along 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 own SessionRecord_HasUsableSenderChain bridge drops the matching requirePqRatio argument, so it now always demands NotStale | 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, and SessionRecord.hasUsableSenderChain() here is this package's own FRB binding, which never carried the argument upstream has now dropped. The release build is clean and make codegen reproduces lib/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 the rust/net chat layer this package does not bind, and a zkgroup fix that stops invalid curve points being treated as candidate profile keys — zkgroup is 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-debug 0.99.3 → 0.100.0, zerocopy 0.8.55 → 0.8.56, and data-encoding 2.11.0 → 2.11.1 with its data-encoding-macro 0.1.20 → 0.1.21 wrapper. zerocopy-derive, data-encoding-macro-internal and delegate-attr move as well but are proc-macros, and aho-corasick 1.1.4 → 1.1.5 and regex-automata 0.4.16 → 0.4.18 enter the graph only through prost-build, a build-dependency of libsignal-protocol and spqr — so none of those five reach the binary. THIRD_PARTY_NOTICES.txt is regenerated to match

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 AuthCredential API, 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 transitive libsignal-debug — the only source change in either release is two added #[test] functions covering HPKE invalid inputs in signal-crypto; everything else is the VERSION constant. 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 aes 0.9.1 → 0.9.2 and hybrid-array 0.4.13 → 0.4.14 (the RustCrypto array crate aes is built on). cc, clang-sys, displaydoc, either and toml_parser also move, but reach this crate only as build-dependencies or through proc-macro subtrees, so none of them ship. THIRD_PARTY_NOTICES.txt is regenerated to match
  • 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.md section gives the sealed-store pattern on the already-public Aes256GcmSiv + 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

  • .fvmrc no longer drifts on every make codegenflutter_rust_bridge_codegen shells out to fvm install, and fvm install rewrites .fvmrc and .vscode/settings.json whenever they are not already byte-identical to what it would emit. The committed files were not: fvm orders the keys flutter, flavors, runPubGetOnSdkChanges, updateVscodeSettings, updateGitIgnore and writes no trailing newline, and it rewrites dart.flutterSdkPath to 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 then create-pull-request without add-paths — swept them into its PR commits. .fvmrc is now committed in fvm's own serialization with updateVscodeSettings: false, which makes fvm install a byte-level no-op on both files; verified by running make codegen and comparing checksums. fvm writes the file with Dart's JsonEncoder.withIndent(' ') + writeAsStringSync, which emits LF and no trailing newline on every platform, so .fvmrc is also marked -text in .gitattributes — otherwise a Windows checkout under the default core.autocrlf=true gets CRLF, never matches, and is silently rewritten on every install. .vscode/settings.json deliberately keeps .fvm/flutter_sdk rather 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.4 breaks 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 settings line 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 find fvm, make or cargo — 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, honouring PUB_CACHE / CARGO_HOME, and adding only directories that exist. It then checks make, fvm and cargo are 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 are shellcheck clean

  • Discard FVM config changes in setup-fvm is documented as a guard, not a fix — a step in a composite action can only clean up after that action, while fvm install also runs later in the job from inside make codegen, so its position was never the defect. Comment only; the config change above is the actual fix

  • make setup-repo-protections now turns on automatic head-branch deletion — the script applied rulesets and the native-build environment but never touched repo settings, so delete_branch_on_merge sat at GitHub's default of off and every merged branch stayed forever; 42 update-libsignal-* branches had accumulated since v0.86.10 (deleted, and each is still reachable through its pull request's refs/pull/<n>/head). delete-branch: true on peter-evans/create-pull-request does not cover this — it only removes branches the action itself closes as obsolete. The script now also sends PATCH repos/<slug> with delete_branch_on_merge=true, warning rather than failing when it cannot. Note that GitHub performs the deletion as whoever merged the pull request, so the Delete branches ruleset 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 commandgit signs a commit or a tag by shelling out to ssh-keygen -Y sign, which reads the passphrase exactly once and calls fatal() 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 through runInheritRetry, which prints the failure and runs the step again, so the prompt simply comes back the way ssh and sudo behave — Ctrl-C is the way out, which works because inheritStdio delivers 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 via stdin.echoMode, deliberately not hasTerminal, which calls a run redirected from /dev/null interactive — 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. alreadyDone is consulted after a failure so a step whose effect already landed reports success instead of being attempted twice, and beforeRetry re-stages the release files before each commit retry, because our own pre-commit hook runs make rust-check, whose cargo check rewrites rust/Cargo.lock when the crate version moved. Separately, a Ctrl-C or a closed terminal is now recognised: isResumableRelease requires all of a clean tree, the version file already reading exactly the requested version, and HEAD's subject equal to the exact subject the release writes (held in one commitSubject variable passed both to git commit -m and 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 at HEAD. Interrupting before the commit is the one case nothing can report at the time, so the "working tree is not clean" error now uses onlyTheseFilesDirty to name the single git restore that 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 new test/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.json drift, the pre-commit hook's PATH handling, and delete_branch_on_merge), and all three came back byte-identical, so copier update left those files untouched. The template's fourth fix — that its pre-commit hook 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.json gains the header explaining why it is committed and why fvm install's "remove updateVscodeSettings: false" warning must not be acted on, and CONTRIBUTING.md gains an Editor Setup (FVM) section saying the same for contributors, including the note that Windows needs Developer Mode before the first fvm install for the .fvm/flutter_sdk symlink dart.flutterSdkPath points 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 a test/scripts/update_template_test.dart covering the unmerged-path parser and the CHANGELOG insertion) runs copier 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, since format-check, rust-check and analyze read only Dart and Rust while copier's conflicts land in Markdown — and .copier-answers.yml failing to move _commit fails 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 see

    Also fixed: the git restore hint added in v4.2.0 never fired. The release scripts read git status --porcelain through git(), 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. onlyTheseFilesDirty then 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 a gitStatus() 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 it

    The fourth commit in the range releases the template repository itself and touches nothing under template/, so it does not reach here. copier update produced no conflicts and no .rej files, and _commit landed 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.md took 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.decrypt consumed a Kyber pre-key without ever telling the store, and widens KyberPreKeyStore.markKyberPreKeyUsed to 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.md section, now state what your implementation has to guarantee. This corrects rather than extends the previous advice: a lock inside the store leaves load → ratchet → store unprotected, so two concurrent encrypt calls for one address derive the same message key
  • THIRD_PARTY_NOTICES.txt ships 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.markKyberPreKeyUsed now takes the signed pre-key ID and the sender's base key — the signature changes from markKyberPreKeyUsed(int kyberPreKeyId) to markKyberPreKeyUsed(int kyberPreKeyId, int signedPreKeyId, PublicKey baseKey), mirroring libsignal's KyberPreKeyStore::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 your KyberPreKeyStore implementation to the new signature. Retiring a one-time key still only needs kyberPreKeyId; for a last-resort key, record the (kyberPreKeyId, signedPreKeyId, baseKey) triple and treat a repeat as a replayed pre-key message. See KyberPreKeyStore.markKyberPreKeyUsed and limitation 5 in SECURITY.md for what a detected repeat can and cannot do

  • SealedSenderDecryptResult.preKeyToRemove removed — only affects callers of the raw generated API (sealedSenderDecryptWithCallbacks); SealedSenderCipher.decrypt is unchanged for its users. Sealed-sender decryption now takes removePreKey and markKyberPreKeyUsed callbacks, which the bridge invokes itself in libsignal's order, rather than returning an ID for the caller to act on afterwards — matching how SessionCipher.decrypt has always worked. Action required: if you call the raw function, pass the two new callbacks and delete your post-call removePreKey handling

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's LicenseRegistry does not cover them: it aggregates LICENSE files 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 with Cargo.lock. It is not an inventory of permissive licences: Signal's own crates in that graph (libsignal-protocol, libsignal-core, signal-crypto and 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 under flutter: assets:, which would bundle it into every consuming application whether or not it is ever displayed; the README shows how to register it with LicenseRegistry for 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), on SessionCipher / SessionBuilder / SealedSenderCipher / GroupCipher, and in a new SECURITY.md section. 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, SQLite synchronous = 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 the load → ratchet → store window unprotected, so two concurrent encrypt calls 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 fsync on Apple platforms and of IndexedDB durability on the web
  • SealedSenderCipher.decrypt now marks the Kyber pre-key it consumed — it removed the one-time EC pre-key a pre-key message consumed but never called markKyberPreKeyUsed for 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 storeSession is written last — the bridge inferred removePreKey / markKyberPreKeyUsed from 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 and removePreKey would let the redelivered message match the persisted session, consume nothing, and leave a one-time pre-key usable forever. The awaited-write table in SECURITY.md documents the new order

For Contributors #

Added

  • CI verifies the declared MSRVrust-version = "1.88" in rust/Cargo.toml is 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 new msrv job 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 runs make rust-check. Verified locally against 1.88 before the job was added; the reusable setup-rust action gained a toolchain input (default stable) 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.dart implements 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 an AddressLocks helper for call-site serialization. lib/demos/durable_store_demo.dart proves the round trip: it establishes a session, exchanges messages, closes the stores, reopens them from disk and continues the same conversation. DurableKyberPreKeyStore demonstrates both halves of the Kyber contract: a one-time key is retired on its first mark (loadKyberPreKey stops serving it), while a last-resort key stays in service and every (kyberPreKeyId, signedPreKeyId, baseKey) agreement is journalled, with repeats surfaced through replayedAgreements. 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, and SealedSenderCipher.decrypt marks the Kyber pre-key with the same triple as SessionCipher.decrypt

Changed

  • stores-implementation and security-review skills, plus the CONTRIBUTING.md review checklist, corrected — they recommended a lock inside the store, which does not cover load → 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 (sqflite deadlocks if the db handle is used inside db.transaction(...))

  • GitHub Actions bumped to their Node 24 majors — the first grouped Dependabot run moves actions/checkout 4 → 7, actions/upload-artifact 4 → 7, actions/download-artifact 4 → 8, actions/cache 4 → 6, actions/create-github-app-token 2 → 3, android-actions/setup-android 3.2.2 → 4.0.1 and schneegans/dynamic-badges-action 1.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-artifact now fails a run on a digest mismatch instead of only warning, and setup-android dropped its SDK cache (slower Android legs, same output). No workflow logic changed

  • Dependabot branches excluded from the Signing commit and Delete branches rulesets — both target ~ALL branches, so non_fast_forward stopped Dependabot from force-pushing a rebase onto a moved main and deletion stopped it from cleaning up a merged branch: a grouped update PR could never refresh itself once main had moved. refs/heads/dependabot/**/* is now in each ruleset's ref_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. main is unaffected (it is not a Dependabot branch) and keeps required_signatures from the same ruleset. Scoped with exclude rather than a bypass actor, which on a ~ALL ruleset would have exempted that actor on main too

  • 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.txt before its next CI run, because test-reusable.yml now verifies it; that file and its generator arrive here for the first time (see For Users above). Also landing: make rust-test and a CI step that runs the crate's own unit tests; make third-party-notices / make verify-third-party-notices, with make rust-update regenerating the inventory so the lockfile and the notices cannot drift apart; the fuzz workflow reads its targets from the [[bin]] entries of rust/fuzz/Cargo.toml and 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 against rust/fuzz/Cargo.toml and yields exactly the six existing targets; validateUpstreamTag names which input it rejected, since an API tag_name, a --version argument and the pin recorded in rust/Cargo.toml fail for different reasons; insertChangelogEntry matches #### Changed exactly, 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, so make clean no longer leaves dart test pointed at a cached asset that is gone; and copyright_year becomes a stored answer, recorded as 2025 — the year of first publication — though for an AGPL-3.0 project it does not reach the rendered LICENSE, 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. The freezed_annotation / freezed / build_runner dependencies 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, and freezed_annotation sits in dependencies, so every consumer would download a package nothing here imports. And ffigen stays at ^20.1.1 instead 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, so copier update had nothing left to apply beyond recording the version

  • The Signing commit ruleset no longer bypasses the update GitHub Appbypass_actors is now empty, matching the template. The app's commits are created through the API and are therefore signed by GitHub, so required_signatures is satisfied without an exemption, and on a ~ALL ruleset a bypass actor is exempted everywhere, main included — the same reasoning the Dependabot entry above applies. refs/heads/update-* is still not excluded from the ruleset: the template's policy is to widen exclude only 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 itcargo 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 is prost-buildtempfilerustix, whose backend is host-gated: errno on a macOS host, linux-raw-sys on 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 how winapi — reached through ansi_term inside a proc-macro crate — stays invisible everywhere except a Windows host. No per-target query escapes this, so the crate set is now taken from cargo 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 — winapi here 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 against cargo-about: it now reports no crate this inventory omits. --check now 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-sha3 0.0.10 and libcrux-secrets 0.0.6, which fix RUSTSEC-2026-0207, RUSTSEC-2026-0208 (incremental/AVX2 SHAKE) and RUSTSEC-2026-0212 (aarch64 const-time swap). The interim cargo-audit / cargo-deny suppressions 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 a flutter: plugin: section, so flutter_tools never consumed them; native delivery is (and remains) via the hook/build.dart build 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 the libsignal-core version string (rust/core/src/version.rs); make codegen produces no binding diff
    • Note: These changes do not affect this library's public API
  • 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 codegen produces no binding diff
    • Note: These changes do not affect this library's public API

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.yml from a specific commit, closing the previously documented authenticity gap (the SHA256 checksums file ships in the same release as the archives). Verify with gh 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 in web/pkg/.wasm-version and 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's web/pkg/ (it survives flutter 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 and rust/Cargo.toml is 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-complete marker proves the extraction finished (an interrupted tar no longer leaves a truncated library that is reused forever), a locally built rust/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-crate skill — one-command native-crate release (stage 1): bumps rust/Cargo.toml, stamps the CHANGELOG libsignal_frb Highlights 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 with release-package (stage 2)
  • make release + updated release-package skill — one-command Dart package release (stage 2) symmetric to make release-frb: verifies the stage-1 native binary exists on GitHub Releases, bumps pubspec.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.Z tag and pushes to trigger the pub.flutter-io.cn publish. The two release commands share git/terminal helpers in scripts/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), and make setup-repo-protections applies them to GitHub via gh (idempotent by ruleset name) and configures the native-build environment. A new Protect release tags ruleset restricts tag creation (all tags) to Admins/Maintainers — covering the release-triggering libsignal_frb-* / v* and any other — and the native-crate publish (build-libsignal.yml) now runs in the required-reviewer native-build environment — gating tag-push and workflow_dispatch alike, mirroring the pub.flutter-io.cn environment 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.Z comment — across the workflows and the composite actions (a directories glob covers /.github/actions/*, since / only scans .github/workflows/). dtolnay/rust-toolchain is 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 in libcrux-sha3 0.0.8; RUSTSEC-2026-0212, incorrect aarch64 constant-time swap in libcrux-secrets 0.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 bumps libcrux-ml-kem (v0.97.4 still ships the old libcrux). Added to rust/deny.toml [advisories].ignore and the rust-audit --ignore flags as a tracked interim suppression so the cargo-deny / cargo-audit CI jobs pass — to be removed once a fixed libsignal release lands
  • Decoupled the libsignal_frb native release from libsignal dependency updates — automated update PRs no longer bump the crate version or build binaries; dependency updates accumulate on main (tested from source in CI), and the native build is now triggered by pushing a libsignal_frb-<version> tag instead of by pushing to main. 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 compare instead of the (often incomplete) release notes
  • CI enforces deployment-target consistencytest-reusable.yml now runs make 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.yml remains the single source of truth; with the platform scaffolding removed, make check-targets and scripts/get_android_min_sdk.dart no longer read the podspecs/build.gradle but verify the CI workflow (IPHONEOS_DEPLOYMENT_TARGET, MACOSX_DEPLOYMENT_TARGET, cargo-ndk --platform) instead
  • Upstream tag names validated before reaching the shellcheck_updates.dart / check_template_updates.dart reject a release tag_name that is not a plain semver-ish tag before it lands in GITHUB_OUTPUT, and the update workflows pass step outputs/inputs into run: blocks via env: instead of inline ${{ }} interpolation — closing a shell-injection path from upstream release names (backport of the liboqs audit)
  • Least-privilege GITHUB_TOKEN everywherepublish.yml and build-libsignal.yml now default to contents: read with job-level opt-ups (id-token: write on the pub.flutter-io.cn publish job, contents: write on the release jobs); the two update-checker workflows drop contents/pull-requests: write entirely (all writes go through the App token)
  • Third-party actions pinned to commit SHAsdart-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 the toolchain input since the ref no longer selects it)
  • setup-make verifies 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-frb now syncs and stages rust/Cargo.lock alongside rust/Cargo.toml (the pre-commit cargo check no longer leaves a dirty tree that blocked stage 2, and the signed tag no longer carries a stale lock); Swatinem/rust-cache is repinned from the floating v2 tag object to the real v2.9.1 commit (would have broken every Rust job when upstream re-tagged v2); the pub.flutter-io.cn release notes are written via --notes-file instead of an inline heredoc (a literal EOF line in the changelog can no longer break out into the shell); build-libsignal.yml no longer delete-then-recreates a release (fail-loud, no silent clobber) and the release-existence probe fails closed on API errors; the fuzz.yml dispatch duration input is validated and passed via env:; make check-targets fails closed when a checked file/pattern disappears; and --date in make release is validated. Docs corrected across README/CLAUDE/CONTRIBUTING/SECURITY/rulesets (build-hook fallback, AI_MODELS_TOKEN, two-stage publishing, .skip_*_hook semantics, 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.md gains the "Releasing (two stages)" and "Repository rulesets & tag protection" sections, and the Makefile .PHONY list 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 UntrustedIdentity on 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_identity callback

Changed (Breaking)

  • Identity-trust is now enforced on every session operation, matching upstream libsignal's is_trusted_identity semantics. SessionBuilder.processPreKeyBundle, SessionCipher.encrypt/decrypt (both pre-key and regular Whisper messages), and SealedSenderCipher.encrypt/decrypt now consult your IdentityKeyStore.getIdentity and reject a remote identity key that differs from the stored one with an UntrustedIdentity error. 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 contains untrusted 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 your IdentityKeyStore.getIdentity to be implemented correctly.

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 internal TryFrom refactor in state/bundle.rs (and_then(…map…).zip(…), behavior identical), and the libsignal-core version 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 upstream libsignal is 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 documentedSECURITY.md now 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 call dispose() to bound that window (noting the extractable key types are Copy/plain-boxed and thus not zeroized on drop — dispose() shortens the exposure window, it does not wipe), and that Rust's zeroize covers Rust memory only: secret bytes that cross the FFI boundary into a Dart Uint8List live on the un-zeroed GC heap where SecureBytes/zeroize() are best-effort. PrivateKey.cloneKey() / KyberSecretKey.cloneKey() now carry a # Security doc note that each copy is an independent secret

Fixed

  • Device ID truncation — the ProtocolAddress and PreKeyBundle constructors no longer truncate the u32 device ID to u8 before validating (e.g. 257 is no longer accepted as device 1); out-of-range IDs are rejected as documented (1–127)
  • HKDF output boundhkdfDerive now rejects an output length above the RFC 5869 maximum (255 × 32 = 8160 bytes) before allocating, instead of attempting an oversized allocation
  • In-memory identity-store equalityInMemoryIdentityKeyStore now 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-arm64 vs ios-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 and dyld rejected 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 harnesscargo-fuzz targets (rust/fuzz/) covering every byte-parsing entry point (keys, messages, records, sealed-sender certificates, crypto primitives, pre-key decryption), a seed-corpus generator, and a Fuzz CI workflow (per-PR smoke run + weekly deep run). See make fuzz-list / make fuzz
  • Dependency policycargo-deny (rust/deny.toml, make rust-deny, CI deny job) 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 warnings now runs in CI (the reusable test workflow, on the Linux x86_64 leg) and locally via make 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_codegen is now pinned via make setup-frb-codegen (kept in sync with the flutter_rust_bridge dependency, 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.dart bumps the wrapper crate version mirroring the upstream SemVer delta, update_changelog.dart classifies update severity via AI, and the update-libsignal skill 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-protocol and signal-crypto crates are unchanged; libsignal-core only bumps its internal version string
    • Note: These changes do not affect this library's public API

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

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 with Type 'JSValue' is not a subtype of type 'List<dynamic>' under dart2wasm. Upstream limitation in flutter_rust_bridge (#2575), affects every FRB-based Dart package. Standard flutter 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 requires senderAddressName, senderAddressDeviceId, recipientAddressName, and recipientAddressDeviceId parameters
    • Upstream made the previous SignalMessage::verify_mac method private and exposed verify_mac_with_addresses as the public replacement, extending the misdirection protection (started in v0.91.0) to message MAC verification

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-proto DNS 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 localAddress parameter (breaking)

Changed

  • Update libsignal native library to v0.93.1 (v0.93.0, v0.93.1)
    • Breaking: SessionBuilder constructor now requires localAddress parameter
    • Breaking: processPrekeyBundleWithCallbacks now requires localName and localDeviceId parameters
    • Breaking: messageDecryptSignalWithCallbacks now requires localName and localDeviceId parameters
    • process_prekey_bundle and message_decrypt_signal now bind sender/recipient addresses, completing the misdirection protection introduced in v0.91.0

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 rand crate and rustls-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

  • Update libsignal native library to v0.92.1 (v0.92.0, v0.92.1)
    • Force use of SPQR v1 for all newly initiated sessions (v0.92.0) — fallback to non-PQR sessions is no longer allowed
    • Expose getUploadForm() for backup uploads (v0.92.1)
    • Note: These changes do not affect this library's public API

3.0.1 - 2026-04-03 #

Fixed

  • Fix README examples for SessionCipher and SealedSenderCipher to match new API (added localAddress and all required stores)
  • Fix incorrect class name SealedSessionCipherSealedSenderCipher in README
  • Fix incorrect method name decryptPreKeySignalMessagedecryptPreKeyMessage in 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 localAddress parameter (breaking)

Changed

  • Update libsignal native library to v0.91.0 (release notes)
    • Breaking: SessionCipher and SealedSenderCipher constructors now require localAddress parameter
    • Breaking: messageEncryptWithCallbacks and messageDecryptPrekeyWithCallbacks now require localName and localDeviceId parameters
    • Breaking: sealedSenderDecryptWithCallbacks now requires localName and localDeviceId parameters
    • 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

2.9.0 - 2026-03-29 #

For Users #

✨ Highlights

  • libsignal v0.90.0CiphertextMessage now implements Clone
  • libsignal_frb v1.5.0 — Rust FFI bindings

Changed

  • Update libsignal native library to v0.90.0 (release notes)
    • CiphertextMessage enum now derives Clone (previously only Debug)
    • 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-protocol crate 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-protocol crate 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 zeroize support 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 boring dependency 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

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_TARGET env var for iOS CI builds — fixes linker errors when vendored C code is compiled with newer Xcode
    • Added make check-targets command and scripts/check_deployment_targets.dart for 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/ with dart scripts/ in Makefile commands, removing .skip_libsignal_hook workaround (scripts only use dart: imports, so dart run build hooks are unnecessary)
    • Fixed WASM build hook: local builds now take priority over cached/downloaded files, avoiding stale content hash mismatches

2.3.1 - 2026-02-11 #

For Users #

Changed

  • Remove flutter SDK constraint from environment — 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 main step before pushing tag in publishing checklist
    • Replaced "Claude Commands" section with "Claude Skills" section in CLAUDE.md
    • Removed redundant prepare-release and update-template Claude commands (functionality covered by Claude skills)
    • Updated platform support table in README: SDK 24+, iOS 13.0+, macOS 10.15+, WASM label
    • Improved frb-patterns Claude 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_on example
      • Added Adapter Pattern documentation for bridging DartFn callbacks to upstream traits
      • Added block_on panics troubleshooting entry
      • Added "When to regenerate" checklist to Regenerating Bindings section
      • Added No Threading on WASM warning

Fixed

  • Restore 100% test coverage by adding coverage:ignore markers to untestable platform-specific code in platform_io.dart
    • AOT mode library loading path (unreachable during dart test which runs in JIT mode)
    • openLibraryFromPath() function (only called with custom libraryPath, already ignored at call site)

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)
    • CallLinkRootKey now 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

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.ymlbuild-libsignal.yml
    • Configurable version_tag_prefix for upstream version tag handling
    • Improved version normalization in check_updates.dart — supports configurable tag prefix instead of hardcoded v stripping
  • Renamed make updatemake rust-update to 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 _crateName constant to eliminate hardcoded libsignal_frb strings
    • Added rust/Cargo.toml as 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 from android/build.gradle at build time
    • {{ crate_name }} → uses _crateName constant
    • fvm installfvm use with version from .fvmrc
  • Updated example app platform configs to use template-standard naming
    • Renamed libsignal_exampleexample in web, Windows, macOS, Linux, iOS configs
  • Renamed Claude skill ffi-patternsfrb-patterns to match current FRB architecture
  • Improved CI workflows with better step status tracking
    • Each step now reports success=true/false for clearer PR status
    • PR body shows inline status for each updated file
  • Removed unused GITHUB_TOKEN from check_updates.dart (not needed for public GitHub API)
  • Fully automated libsignal update workflow (check-libsignal-updates.yml)
    • Now automatically runs cargo update to update Cargo.lock
    • Now automatically regenerates FRB bindings via make codegen
    • Now automatically updates CHANGELOG.md using AI (requires AI_MODELS_TOKEN secret with models:read permission)
    • 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)

Fixed

  • Fix workflow_run trigger in test.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.yml check-release step (GH_TOKENGITHUB_TOKEN) — Dart script reads GITHUB_TOKEN, not GH_TOKEN
  • Fix outdated script filenames in scripts/README.md (check_new_libsignal_version.dartcheck_new_upstream_version.dart, check_exists_libsignal_frb_release.dartcheck_exists_frb_release.dart)
  • Fix incorrect env var reference in CLAUDE.md inline comment (GITHUB_TOKENAI_MODELS_TOKEN)
  • Upgrade flutter_lints in example app from ^5.0.0 to ^6.0.0
  • Fix .pubignore — include Rust source files in published package (only exclude rust/target/ build artifacts, not entire rust/ directory); add trailing newline

Removed

  • Removed legacy scripts with project-specific naming
    • scripts/check_new_libsignal_version.dartscripts/check_new_upstream_version.dart
    • scripts/check_exists_libsignal_frb_release.dartscripts/check_exists_frb_release.dart
    • scripts/src/check_new_libsignal_version.dartscripts/src/check_updates.dart
  • Removed unused scripts/combine_artifacts.dart

Added

  • make check-template-updates command to check for new copier template versions
  • check-template-updates.yml workflow — daily CI check for template updates with automated notification PR
  • update-template Claude skill — step-by-step guide for applying template updates
    • Documents --defaults flag for non-interactive copier update (required for Claude Code)
    • Documents manual _commit update in .copier-answers.yml when copier fails to update it (conflicts or no file changes)
  • make rust-update command to update rust/Cargo.lock via cargo update
  • make update-changelog command 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 — reads minSdk from android/build.gradle
    • scripts/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 from bundle/lib/ relative to executable
    • Enables standalone executables to be distributed and run from any location

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

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: PublicKey ordered 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
  • Update libsignal_frb (Rust crate) to v1.0.2
    • Adapted PublicKey.compare() to use byte comparison after upstream Ord removal

Fixed

  • Fix native library loading for pure Dart CLI applications using dart run
    • DynamicLibrary.open() doesn't resolve native asset IDs in JIT mode
    • Now reads .dart_tool/native_assets.yaml to get the actual library path
    • Enables example_cli and other CLI apps to work with published package

Security

  • Updated bytes dependency to v1.11.1 to fix integer overflow vulnerability (RUSTSEC-2026-0007)

For Contributors #

Added

  • make update command to update rust/Cargo.lock via cargo update
  • make update-changelog command 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 update to update Cargo.lock
    • Now automatically regenerates FRB bindings via make codegen
    • Now automatically updates CHANGELOG.md using AI (requires AI_MODELS_TOKEN secret with models:read permission)
    • 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.toml version bump and make rust-check
  • Updated update_changelog.dart script to generate two Highlights entries (libsignal + libsignal_frb)
  • Updated Claude skill .claude/skills/update-libsignal/SKILL.md with "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

  • SecureBytes class for wrapping sensitive byte data with automatic zeroing on disposal
  • SecureUint8List extension with zeroize() method for manual zeroing of Uint8List

Changed

  • Update libsignal native library to v0.86.15 (release notes)
    • SVR2: Updated production enclave
    • SVRB: Added new production enclave to current set
    • 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_files from 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 make and protoc from 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
  • API Changes:

    • ProtocolAddress('name', 1)ProtocolAddress(name: 'name', deviceId: 1)
    • privateKey.serialize().bytesprivateKey.serialize() (returns Uint8List directly)
    • publicKey.verify(message, signature)publicKey.verify(message: message, signature: signature)
    • Fingerprint.create(...)Fingerprint(iterations: ..., version: ..., ...)
    • Aes256GcmSiv(key)Aes256GcmSiv(key: key)
    • cipher.encrypt/decrypt now requires associatedData parameter
    • GroupSession class 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 PreKeyBundle and Fingerprint
    • 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, LibSignalException classes
  • Manual Dart wrapper classes (replaced by FRB-generated code)

For Contributors #

Added

  • make rust-audit — Rust dependency vulnerability scanning
  • make setup-rust-tools — installs cargo-audit, flutter_rust_bridge_codegen
  • make setup-protoc — cross-platform protoc installation
  • make setup-web — installs wasm-pack for web builds
  • make setup-android — installs cargo-ndk for Android builds
  • Rust security audit job in CI (runs cargo-audit on 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 setup to 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 destroy callback
    • Callback function names updated to longer namespaced format
    • Parameter types updated (SignalConstPointer* to SignalMutPointer* where applicable)

1.1.0 - 2026-01-08 #

Added #

  • Add make setup-build command to install native build dependencies (Rust, protoc)
  • Add make setup-fvm command (renamed from previous make setup)
  • Restructure make setup to 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 between test.yml and publish.yml

Changed #

  • Replace softprops/action-gh-release with official gh CLI in CI workflows
  • Update GitHub Actions to latest versions:
    • actions/create-github-app-token v1 → v2
    • peter-evans/create-pull-request v7 → v8
    • ilammy/msvc-dev-cmd v1 → 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.yml workflow:
    • Remove AI analysis (GitHub Models) - now only updates native_version in pubspec.yaml
    • Remove automatic FFI bindings regeneration (now manual step after merge)
    • Add clear instructions in PR body for manual steps after build completes
  • Simplify check_updates.dart script:
    • Remove --ai, --no-ai, --bump, --no-changelog options
    • No longer updates package version or CHANGELOG.md automatically
  • Remove scripts/src/ai_analysis.dart (no longer needed)
  • Use GitHub App token instead of GITHUB_TOKEN in workflows:
    • check-libsignal-updates.yml: PR creation
    • build-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 .fvmrc and .vscode/settings.json modifications in PRs
  • Extract Rust setup into reusable .github/actions/setup-rust action

Fixed #

  • Fix duplicate "v" prefix in native library release notes (vv0.86.10v0.86.10)
  • Remove redundant "Usage" section from native library release description
  • Fix ARM64 group messaging crash caused by SignalUuid 16-byte struct-by-value FFI limitation (dart-lang/sdk#36730)
    • Pass SignalUuid as two Int64 values matching ARM64 AAPCS64 register layout
    • Affects signal_sender_key_distribution_message_create and signal_group_encrypt_message
  • Fix Windows native library build in CI
    • Create shell wrapper for fvm in setup-fvm action (Git Bash cannot execute .bat files)
    • Use PowerShell for build step to ensure MSVC link.exe is used instead of Git's /usr/bin/link
  • Fix make regen CI failure when cbindgen is not pre-installed
  • Fix make regen CI failure due to missing protoc (required by libsignal's spqr dependency)
  • Add protoc to build prerequisites documentation (README.md, CLAUDE.md)

1.0.1 - 2026-01-02 #

Added #

  • Added make doc command 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_dispatch trigger to test workflow (allows manual test runs from GitHub Actions)

Changed #

  • Improved test coverage to 98.4%
  • Added // coverage:ignore comments to genuinely untestable code (FFI callbacks, finalizers, defensive null checks)
  • Removed unused extractOwnedBuffer function from FfiHelpers
  • Refactored CI update workflow: moved AI analysis from bash to Dart script
  • Simplified check-libsignal-updates.yml workflow (~530 → ~220 lines)
  • Added --ai, --no-ai, --ci flags to check_updates.dart script
  • Script now writes directly to GITHUB_OUTPUT in CI mode (no jq parsing needed)
  • build-libsignal.yml workflow now skips build if release already exists (prevents unnecessary rebuilds when only package version changes)

Fixed #

  • Fixed publish.yml workflow: use Flutter SDK (via FVM) instead of Dart SDK for publishing Flutter packages
  • Added workflow_dispatch with dry-run option to publish workflow
  • Added duplicate version check (validates against pub.flutter-io.cn API before publishing)
  • Added publish-dry-run validation step before actual publishing
  • Aligned publish workflow structure with liboqs_dart for consistency
  • Fixed version parsing in build-libsignal.yml workflow (use Dart script instead of grep for reliable parsing)
  • Fixed unresolved dartdoc references in LibSignalException, GroupSession, and InMemoryIdentityKeyStore
  • Fixed .pubignore to include CONTRIBUTING.md in published package
  • Fixed .pubignore to exclude generated doc/ 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, PreKeyBundle for session establishment
  • Post-quantum: Kyber key pairs (KyberKeyPair, KyberPreKeyRecord) for quantum resistance
  • Sessions: SessionRecord, ProtocolAddress for session management
  • Messages: SignalMessage, PreKeySignalMessage for 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
4
likes
150
points
1.39k
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Dart wrapper for libsignal. Signal Protocol implementation for end-to-end encryption, sealed sender, group messaging, and secure cryptographic operations.

Repository (GitHub)
View/report issues
Contributing

Topics

#rust #flutter-rust-bridge #signal #cryptography #encryption

License

AGPL-3.0 (license)

Dependencies

code_assets, crypto, flutter_rust_bridge, hooks

More

Packages that depend on libsignal