libsignal 9.0.0
libsignal: ^9.0.0 copied to clipboard
Dart wrapper for libsignal. Signal Protocol implementation for end-to-end encryption, sealed sender, group messaging, and secure cryptographic operations.
9.0.0 - 2026-10-26 #
For Users #
✨ Highlights
- Encrypting saves the recipient's identity, as libsignal does —
(breaking) for code calling
messageEncryptWithCallbacksorsealedSenderEncryptWithCallbacksdirectly, which now take asaveIdentitycallback;SessionCipherandSealedSenderCipherkeep their signatures and call yourIdentityKeyStore.saveIdentityon every message they encrypt SessionRecord.hasUsableSenderChainasks what Signal's clients ask — (breaking) a session nobody answered is no longer usable once it is 30 days oldPreKeyRecord.privateKey()andSignedPreKeyRecord.privateKey()are gone — (breaking) a record travels whole, private half included, throughserialize()- A replayed pre-key message is refused before anything is written —
(breaking)
KyberPreKeyStore.markKyberPreKeyUsedreturnsFuture<bool>, andfalsefor an agreement the store has seen fails the decryption withinvalidMessage, as libsignal's own check does - No storage migration — none of the breaking changes needs one: records
are still persisted whole through
serialize(), and a Kyber pre-key store that records the agreements it marks, as this package already asked, only has to answer from that record. Session records gain fields under Tahini, which libsignal writes on the next save and earlier versions still read. - libsignal v0.106.0 — session management moves from Sesame to Tahini: an archived session is never made current again, and a record keeps at most 3 of them instead of 40
- libsignal_frb v8.0.0 — Rust FFI bindings
Changed (Breaking)
messageEncryptWithCallbacksandsealedSenderEncryptWithCallbackstake asaveIdentitycallback (rust/src/api/session_cipher.rs,rust/src/api/sealed_sender.rs,rust/src/recording_stores.rs,lib/src/rust/api/,lib/src/protocol/session_cipher.dart,lib/src/sealed_sender/sealed_sender_cipher.dart,lib/src/stores/identity_key_store.dart) — libsignal'smessage_encrypt, andsealed_sender_encryptthrough it, save the recipient's identity on every message, right after checking it against the stored one. The bridge had no callback to pass that write on, so the per-call store libsignal wrote to dropped it. While the identity is stored that changes nothing; for an address whose stored identity the application deleted, libsignal stores it again on the next message, and this package did not until the next decryption. Both functions now takesaveIdentity, called with the key libsignal saved and beforestoreSession, in libsignal's order.SessionCipher.encryptandSealedSenderCipher.encryptkeep their signatures and pass yourIdentityKeyStore.saveIdentity, which is therefore called on every message you send. Action required: code that calls either function directly passessaveIdentity; and anIdentityKeyStore.saveIdentitythat writes even when the key is unchanged now writes on every send, so make that case cheap.SessionRecord.hasUsableSenderChainrequires what Signal's clients require (rust/src/api/session.rs) — it handed libsignal no requirements, so it answered whether the current session has a sender chain at all. It now asks forNotStale,EstablishedWithPqxdhandSpqr, the set libsignal's own Java, Swift and Node bindings use: a session nobody answered is unusable once it is 30 days old, which is also when libsignal stops encrypting on it, and a session established without PQXDH or without SPQR is unusable. The sessions this package builds from a bundle are PQXDH and SPQR, so in practice the difference is the 30 days. libsignal warns thatSpqralso accepts an unanswered session whose pre-key message carries SPQR, which an answer without SPQR turns unusable where the client accepts that downgrade; the sessions this libsignal creates require SPQR, so such an answer fails to decrypt instead, and the docstring says so. Action required: wherehasUsableSenderChaindecides whether to send,falsenow comes where a Signal client archives the session and builds a new one withSessionBuilder.processPreKeyBundle.PreKeyRecord.privateKey()andSignedPreKeyRecord.privateKey()are removed (rust/src/api/prekey.rs,rust/src/api/signed_prekey.rs,lib/src/rust/api/) — nothing in the package used them: libsignal reads a record's private half inside its own calls, and a store persists the record whole throughserialize(), private half included. They handed raw private-key bytes to Dart, and only their own tests called them, so they go with this major. Action required: keep a record and pass it whole (serialize()/deserialize()); the private key itself is aPrivateKeywhere you generated it.IdentityKeyPair.privateKey,PrivateKey.serialize()andKyberSecretKey.serialize()stay, and so doKyberPreKeyRecord.getSecretKey()andgetKeyPair(), as in Signal's own bindings: they take a stored Kyber key pair apart, the way back fromKyberKeyPair.fromKeys()(#103).KyberPreKeyStore.markKyberPreKeyUsedreturnsFuture<bool>, andfalserefuses the message (rust/src/api/session_cipher.rs,rust/src/api/sealed_sender.rs,rust/src/recording_stores.rs,lib/src/rust/api/,lib/src/stores/kyber_pre_key_store.dart,lib/src/stores/in_memory/in_memory_kyber_pre_key_store.dart,lib/src/protocol/session_cipher.dart,lib/src/sealed_sender/sealed_sender_cipher.dart,SECURITY.md) — libsignal makes this call a check: a store that has seen the(kyberPreKeyId, signedPreKeyId, baseKey)agreement fails it, and the decryption with it, before anything is written. The bridge called it aftersaveIdentityand could not be refused, so a replayed pre-key message decrypted again and its session was stored;SECURITY.mdlisted that as a known limitation and left the application to drop the message. The callback now comes first on both decryption paths, andfalsefailsSessionCipher.decryptandSealedSenderCipher.decryptwithLibSignalErrorCode.invalidMessage, with nothing written.InMemoryKyberPreKeyStoreanswers from the agreements it already recorded. Action required: aKyberPreKeyStorereturnsfalsefor a triple it has recorded before, and otherwise records it durably and returnstrue; code that callsmessageDecryptPrekeyWithCallbacksorsealedSenderDecryptWithCallbacksdirectly passes amarkKyberPreKeyUsedthat answers the same way.
Changed
-
The update to libsignal v0.105.1 leaves the exposed protocol surface unchanged — of the range's twenty commits (compare), the ones its release notes name — simpler zkcredential presentations, the
DonationsandAuthAccountsService.getAccountEntitlementsAPIs, and a flattenedFavoriteStickerbackup message — live in crates outside this package's dependency graph. In the four libsignal crates that are in it,libsignal-protocol'sTimestampgains aTryFrom<SystemTime>conversion and anUnrepresentableTimestamperror, added for the account-entitlements API, which this package neither calls nor exposes; the rest is the version constant inrust/core/src/version.rs, andlibsignal-debugmoves to 0.105.1 by its version number alone. The upstream workspace keepsrust-versionat 1.93.1, this package's floor,spqrstays at 1.6.0 (06959b4), and regenerating the bindings changed nothing inlib/src/rust/.Third-party crates move with it, none added or removed. Each of these reaches every binary, the web module included:
libc0.2.191,sha20.11.1,tokio1.53.3,uuid1.28.0,zerocopy0.8.63,smallvec1.16.3,inout0.2.3 (yanked since; see theinoutentry below) andequivalent1.0.3;unicode-ident1.0.27 reaches only the web module.sha20.11.1 fixes how its AArch64 backends load the round constants, to satisfy the Stacked Borrows model.libc0.2.191 ends the hold 8.0.0 put on 0.2.189: 0.2.190 had put the dyld functionsbacktracecalls behindtarget_os = "macos", which broke every iOS build, and 0.2.191 makes them available to iOS again — the three iOS targets type-check against it, and a fullcargo updateno longer brings the break back.cc,syn,serde_json,toml_edit,toml_parser,toml_datetimeandzerocopy-derivemove too and only run at compile time. -
inout0.2.2 → 0.2.5 (rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) — Dependabot's bump (#122). It reaches every binary, the web module included, throughcipherandaead(aes,cbc,ctr,aes-gcm-siv). Crate 7.0.0 shipped 0.2.2; the v0.105.1 update above moved it to 0.2.3, which was yanked two days after its release: refactoring a Stacked Borrows fix had dropped the check that the output buffer ofInOutBufReserved::into_padded_blockshas room for the padding block, and the unsafe code behind it relies on that check (RustCrypto/utils#1560). 0.2.4 restores it, and 0.2.5 also returnsPadErrorwhen aPaddingimplementation breaks the block count the unsafe code assumes (RustCrypto/utils#1563). 0.2.3 could not overrun a buffer here either: the only padded encryption in this package is libsignal's AES-256-CBC with PKCS #7, the body of every 1:1 and group message, and it goes throughencrypt_padded_vec, which allocates one block more than the input's whole blocks — the room the restored check asks for. No released version of this package carried 0.2.3, and no RustSec advisory covers it. Its floor stays at Rust 1.85, below this crate's 1.93.1. -
tokio1.53.3 → 1.54.1 (rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) — Dependabot's bump (#129). flutter_rust_bridge builds on it in every binary: the lock behind each opaque Rust object is atokio::sync::RwLock, the web module included, and on native platforms its async functions run on tokio's multi-threaded runtime. In the features compiled here (rt,rt-multi-thread,sync,time), 1.54.0 reworks the runtime's injection queue, makesNotifyuse acquire/release orderings, keeps locks in FIFO order when a task's cooperative budget runs out, fixes aspawn_blockinghang when only scheduler workers exist, and handles two timer edge cases; 1.54.1 reverts one timer change of 1.54.0 (tokio's notes). tokio's minimum Rust version rises to 1.85, below this crate's 1.93.1.cc1.8.0 → 1.10.0 andfind-msvc-tools0.1.14 → 0.1.15 move with it and run only at build time. -
uuid1.28.0 → 1.29.0 (rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) — in the same bump. It reaches every binary throughlibsignal-core,libsignal-protocoland this crate. 1.29.0 adds versioned constructors toNonNilUuid, which nothing here calls; the rest of the range changes tests and version strings in the documentation (release). -
The Linux libraries are built on Ubuntu 26.04 and still need only glibc 2.34 — the release build moves from Ubuntu 24.04 along with every other Ubuntu job (see For Contributors). A Linux library records the glibc symbol versions of the image that built it, so a newer image can raise the oldest glibc it loads on; this one does not. Built on 26.04 (glibc 2.43), the library needs GLIBC_2.34 at most, measured on x86_64 and arm64, as crate 7.0.0's libraries do — Ubuntu 22.04, Debian 12, RHEL 9 or newer.
-
The v0.105.2 update leaves this package's protocol surface unchanged — the range (compare) adds an accounts-capabilities API in
rust/net/chatand changes its language bindings; this package does not build the networking crate or expose those bindings. SVR2/B changes are inrust/attestand the SVR2 bridge, which are not among the crates this package builds or the protocol primitives it exposes. Node and Java build and documentation changes are in language-binding and build tooling outside this package's exposed surface.The range changes exactly one file in the crates we bind,
rust/core/src/version.rs, updating only theVERSIONstring from0.105.1to0.105.2.make codegenproduced no change inlib/src/rust/, so these changes do not affect this library's public API.No third-party crate moves with it. None is added or removed.
spqrstays at 1.6.0 (06959b4). -
The certificate-verification update leaves this package's protocol surface unchanged — the range (compare) includes changes to certificate verification in
rust/net/infra/src/certs.rs, outside this package's dependency graph, and to Java'sCertificateVerifier.kt, a language binding this package does not expose.Within the crates we bind, the range changes exactly one file,
rust/core/src/version.rs, updating only theVERSIONstring from0.105.2to0.105.4.make codegenproduced no change inlib/src/rust/, so these changes do not affect this library's public API.No third-party crate moves with it. None is added or removed.
spqrstays at 1.6.0 (06959b4). -
libsignal v0.106.0 replaces Sesame session management with Tahini — as upstream's notes for the release put it (compare), an archived session is never made current again, a tiebreaker picks the current session when both sides start one at the same time, and a conversation that keeps switching back and forth between sessions is detected and settled. A session record keeps at most 3 archived sessions instead of 40, and one holding more is trimmed the next time a session is archived. Messages on the wire do not change; stored session records gain fields, which earlier libsignal versions still read. What an application sees: a message on an archived session still decrypts, but answering it takes a new session — in 8.0.0 decrypting it made that session current again. A record can also be left with no current session: after
SessionRecord.archiveCurrentState, or once libsignal archives the current session itself to settle a conversation.SessionCipher.encryptandSealedSenderCipher.encryptthen throwLibSignalErrorCode.sessionNotFound, as dolocalRegistrationIdandremoteRegistrationId, untilSessionBuilder.processPreKeyBundleor the peer's next pre-key message establishes a new session.In the four libsignal crates in this package's graph, the range rewrites session management in
libsignal-protocol(session_management.rs, the newtahini.rs,state/session.rs,proto/storage.proto), and the other three change no source beyondlibsignal-core's version constant. The Rust API changes are the two the notes name:process_prekeyis no longer public, which this package never called, andmessage_decrypt_prekeyreturns aPreKeyDecryptResultinstead of the plaintext, which the bridge now takes from it, as upstream's ownmessage_decryptdoes. The newinitiate_sessionwrapsprocess_prekey_bundle, which this package keeps calling. Regenerating the bindings changed only documentation inlib/src/rust/.No third-party crate moves with it. None is added or removed.
spqrstays at 1.6.0 (06959b4). -
saveIdentityruns only when libsignal saves the identity, on every decryption path — the bridge now forwards the save libsignal made instead of choosing a key itself. libsignal v0.106.0 saves nothing for a message that decrypts on an archived session, so that a delayed message cannot roll the stored identity back. 8.0.0 calledsaveIdentityon every decryption:SessionCipher.decryptwith the identity of the record's current session — a read that fails withsessionNotFoundonce a decryption can leave the record without one, after libsignal has already decrypted — andSealedSenderCipher.decryptwith the key its sender certificate names. Sealed sender now saves the key the inner message's session is bound to, which a genuine sender's certificate repeats;SealedSenderDecryptionResult.senderIdentityKeyis still the certificate's. Encrypting follows the same rule, and libsignal saves there on every message: see Changed (Breaking). -
saveIdentitycomes beforestoreSessionwhen a Whisper message decrypts and whenprocessPreKeyBundlebuilds a session (rust/src/api/session_cipher.rs,rust/src/api/session_builder.rs,SECURITY.md,test/protocol/write_order_test.dart) — libsignal saves the identity first wherever it does both, and the bridge did too everywhere else; these two paths stored the session first. On decrypt that was the worse order to be interrupted in: a crash between the two writes left a session already past a message whose plaintext was never returned, so the redelivered message no longer decrypted. The session now stays where it was until the identity is saved. A test pins the order of the writes on every entry point, which nothing did before; it failed on exactly these two paths against the previous library. -
hooksandcode_assetsadmit 2.x as well as 1.x (pubspec.yaml,.github/dependabot.yml) —^1.0.0held every app on 1.x, on a Flutter that ships 2.x itself as well. The range is now>=1.0.0 <3.0.0, so each Flutter resolves the major it supports: on 3.38 to 3.44, which pinmetabelow the 1.19.0 thathooks2.1 needs, an app resolveshooks2.0.x withcode_assets1.x, and from 3.47hooks2.2 withcode_assets2.1. The build hook needs no change: the one breaking change ofcode_assets2.0 —OS,ArchitectureandSanitizerdefine==and no longer key a const map — touches nothing it uses. Measured on Flutter 3.38.4 and 3.47.4 in a project generated from the same template: the example app builds and the package's tests pass on both. Here, the suite passes onhooks2.2.0 withcode_assets2.1.0, andexample/resolves 2.0.2 with 1.2.1 on Flutter 3.38.4 and analyzes clean. Dependabot still holds back 3.x. From template v4.18.0.
Security
- A replayed pre-key message that used no one-time pre-key is refused — a pre-key message carries a one-time EC pre-key only when the server's bundle had one, and the Kyber pre-key it used may be a last-resort one, reused by design. Once the session it built had left the record — since libsignal v0.106.0 after three newer archived sessions, not forty — the same message built that session again: its plaintext was returned a second time and the rebuilt session written back to the record. A store that records the agreements now refuses it before any of that, as libsignal does (see Changed (Breaking)). The differential tests against libsignal cover the replay, plain and sealed, and fail at the replayed message when the bridge ignores the store's answer.
- Sealed sender clears its key material before writing the results back
(
rust/src/api/sealed_sender.rs) —SealedSenderCipher.encryptheld the session record and identity key pair it had loaded, anddecryptthe identity key pair, until the call returned: across the store callbacks that write the results back, which wait on the application.SessionCipherandSessionBuilderclear theirs as soon as libsignal is done, and sealed sender now does too.
Fixed
-
Every isolate that initializes libsignal is counted by it, whichever flutter_rust_bridge library the isolate initialized first (
lib/src/libsignal.dart,lib/src/platform/,test/platform/isolate_count_test.dart) — 8.0.0 named this hang and left it: flutter_rust_bridge keeps one shutdown watcher per isolate for all of its libraries, so an isolate was counted only by the first library it initialized, and a worker that initialized libsignal alone and exited could switch libsignal's Rust-to-Dart channel off for the whole process; every async call from the main isolate then waited forever.LibSignal.init()now registers the isolate with libsignal's own native library, through a finalizer of its own, beforeRustLib.init()runs, so the rule 8.0.0 gave — initialize the flutter_rust_bridge libraries in the same order in every isolate — is no longer needed for libsignal. Counting the isolate beforeRustLib.init()installs its pointer also closes the initialization race 8.0.0 fixed, soinit()no longer installs the pointer a second time. Measured on a bench of two libraries and with the race hammer: the hang and the race are both gone, and moving the count afterRustLib.init()brings the race back in 6 rounds of 6. The tests run both scenarios in a process of their own. The count calls an internal flutter_rust_bridge symbol, so a release that changes it makesinit()fail rather than hang. From template v4.18.0. -
On the web,
LibSignal.init()throws instead of waiting forever when the WASM module is not served (lib/src/libsignal.dart,lib/src/platform/platform_web.dart,README.md) — flutter_rust_bridge loadspkg/libsignal_frb.jswith a<script>tag and waits for itsloadevent with no error path, so a missing module — a 404, or the app's HTML page that a dev server returns in its place with a 200 — leftinit()pending.init()now fetches the script first and throws aStateErrornaming it, with the HTTP status, the content type and the remedy, when it is not served as a script. A fetch that fails outright, under a strict content security policy for instance, throws nothing, and loading goes on as before. The usual cause isflutter run -d chromeon Flutter 3.38 to 3.41 after a run for another platform, which skips the build hook; README's Known Limitations now says Flutter 3.44 and later are not affected. From template v4.18.0. -
The Apple libraries leave room for the install name a consumer's tools write (
rust/build.rs) —dart run,dart testandflutter testcopy the dylib into the consuming project and rewrite its install name to the copy's absolute path withinstall_name_tool -id. Crate 7.0.1 and earlier were linked with only the room the build machine's own path needed — measured on 7.0.1's macOS and iOS simulator libraries, 167 to 215 characters — so that rewrite failed, with "larger updated load commands do not fit", once the copy's path was longer. They are now linked with-headerpad_max_install_names, which leaves room for any path up toMAXPATHLEN, and the release check refuses an Apple library with less than 1,024. Flutter app builds were never affected: they install the library as a framework under a short@rpath/name. From template v4.19.0.
Documentation
SECURITY.md: only repository Admins can create a release tag (SECURITY.md) — 8.0.0's text said the tag ruleset did not stop awritecollaborator, because its bypass list held thewriterole as well asadmin. The list now holdsadminonly (see For Contributors), and the section says so.SECURITY.md: the dependency check refuses yanked crate versions — its Dependency Auditing section now says thatrust/deny.tomlfails on a crate version its publisher has yanked, beside the source and license rules it already described.IdentityKeyStore.isTrustedIdentitysays the ciphers never call it (lib/src/stores/identity_key_store.dart,lib/src/stores/in_memory/in_memory_identity_key_store.dart) — trust is enforced throughgetIdentity: libsignal is handed the identity it returns and refuses a key that differs from it, and an address it returnsnullfor is trusted on first use. A policy written intoisTrustedIdentitywas applied to no message, and nothing said so;InMemoryIdentityKeyStoreeven described its trust-on-first-use rule as living there. The interface now says where trust is decided and how to apply a stricter policy, andsaveIdentitylists when it is called.SECURITY.md: accepting a new identity means saving it and archiving the old sessions, under every device's lock — its advice to save the new identity "or remove the old one" stopped holding once encryption saves the identity (see Changed (Breaking)): a removed identity is stored again by the next message sent on the old session. The section now says to save the new key and archive or delete the sessions bound to the old one in the same step, and the concurrency section that identity changes take the locks of all the contact's devices, since an encryption in flight writes back the identity it read when it started.SealedSenderDecryptionResult.senderIdentityKeysays whose key it is (lib/src/sealed_sender/sealed_sender_cipher.dart,rust/src/api/sealed_sender.rs,lib/src/rust/api/sealed_sender.dart) — documented as the sender's key "for verification", it is the key the sender certificate names, validated against the trust root; the keysaveIdentitystores is the one the inner message's session is bound to, which a genuine sender's certificate repeats.SealedSenderCipher's class documentation also said the identity is saved on decrypt; it is saved on encrypt too.SECURITY.mdshowssaveIdentityon encrypt in its table of writes — the table of what each entry point writes, and in what order, still hadSessionCipher.encryptandSealedSenderCipher.encryptwriting onlystoreSession, although both callsaveIdentityfirst on every message (see Changed (Breaking)), and its footnote named only decryption. It now shows every entry point as it writes today, with a note on why the identity comes first.- Releases 7.3.0 to 7.4.1 moved to
CHANGELOG-ARCHIVE.md(CHANGELOG.md,CHANGELOG-ARCHIVE.md) — this file was 169 KB with this release's 56 KB still open, and the releases before it added 20 to 45 KB each, small patches aside, so the next one would likely have crossed the 200 KiB at whichmake verify-pub-limitswarns of pub.dev's 256 KiB limit. It keeps 8.0.0 onward now, at 86 KB, and the archive, which pub.dev does not receive, holds everything up to 7.4.1, the moved sections and their compare links unchanged.
For Contributors #
Added
- A template update reports text it merged in twice
(
scripts/src/update_template.dart,scripts/update_template.dart,test/scripts/update_template_test.dart,.github/workflows/check-template-updates.yml,.claude/skills/update-template/SKILL.md) — adopting template v4.17.0 put thedescribeFrbTreeDrift,parseLsTreeandparseContentsListinggroups intotest/scripts/release_test.darta second time, with every gate green: the template had taken them from here and filed them elsewhere, and copier kept both copies without a conflict. It was found by hand. After merging,make update-template— which the template bot runs — now renders the new version afresh with this project's answers and flags each line of 25 characters or more that the merge left more often than both the file atHEADand the render have it, both having it at least once; in a conflicted file, only when it stays repeated whichever side of its conflicts is kept. The bot's pull request then opens as a draft, lists the files with the line numbers of the first repeated line and carries the labelmerged-twice; when the render cannot be made, its table says the check did not run. Replayed on this repository's twelve resolved adoptions and three raw updates, it found that case and the two under Changed below, and nothing else; a version without the render, counting blocks that occur more often than atHEAD, raised fourteen false alarms on the same adoptions. Written here and in the template together, because the bot runs the script it finds onmain: the next adoption here is already checked.scripts/src/update_changelog.darttakes the template's wrapping of one doc comment, so the file is the template's render byte for byte. - A differential test holds the session bridge to libsignal's own API
(
rust/src/session_equivalence_tests.rs) — the bridge does not hand libsignal the application's stores: it loads per-call in-memory copies, calls libsignal, reads the record back and replays the writes to Dart, and each of those steps rests on what libsignal does inside the call. v0.106.0 changed that without changing a signature the bridge depended on, and the whole suite stayed green over a bridge that failed every message on an archived session. The test runs each operation twice from one snapshot — once through the FRB entry point, against stores standing in for the Dart ones, and once through libsignal's own function against its reference stores — and requires the same outcome, the same consuming and trusting writes, and the same session, identity and pre-key state, across the six session entry points: fetching a bundle, sending plain or sealed, and delivering in order, out of order, twice or never, through sealed sender as well, which runs against libsignal'ssealed_sender_encryptandsealed_sender_decryptwith a trust root and certificates of its own. Between the steps an application archives a session, forgets the identity it stored for a peer, trusts a peer's new identity after the peer reinstalled, or runs out of one-time pre-keys. Those steps change the state both runs start from, so they need no libsignal counterpart. Each step's reference store also knows the Kyber agreements the party's Dart store has seen, so libsignal's last-resort replay check is compared too. Eight scripted conversations and forty seeded random ones reach all of it: across the seeds about 508 decryptions, 100 of them sealed, 48 that leave no current session, 17 identities stored again on encrypt and 20 refused identities. libsignal's behaviour is the expectation, so an upstream change moves it; only a bridge that stops following fails. On the repair commit alone (84b3691) three of the first five tests fail, naming the step:sessionNotFoundwhere libsignal decrypted, and an identity saved where libsignal saved none. The identity encryption writes (see Changed (Breaking)) is compared too: dropping it again fails eight of the nine tests on the plain path and five on the sealed one. The random scripts assert that they still reach the states they exist for. Runs inmake rust-test, in about seven seconds. - The update pull request says whether it could be merged unread
(
scripts/src/update_changelog.dart,scripts/update_changelog.dart,test/scripts/update_changelog_test.dart,.github/workflows/check-libsignal-updates.yml,README.md,CLAUDE.md) — a first step toward handing routine updates to the bot, and a report only: nothing merges on it. The CHANGELOG script, which already holds every input, decides it before the model is asked and publishesmerge_eligibleandmerge_blockerson both outcomes; the pull request shows it under Merge without review?. Eligible means the bindings were regenerated and the FFI surface held, the upstream range was read in full, every file the range touched in the four libsignal crates in the graph changed in quoted version strings only — read off the diff, so a protocol version, an integer, never passes, nor does a raisedrust-version— no registry crate moved, was added or removed, and no git dependency from another repository,spqramong them, moved. A pre-release is never eligible. Each blocker names what it found. Replayed on the last nine updates (v0.102.3 to v0.106.0): v0.105.2 and v0.105.4 pass, and the other seven are blocked for what a reviewer had to read there — session code,spqr, or third-party crates a fullcargo updatemoved along, thelibcthat broke iOS and the since-yankedinout0.2.3 among them. No label yet: a label is what a merge job would key on, and the rule should be measured on real updates first. The template carries the same change. make check-msrvkeeps the minimum Rust version at the graph's floor (scripts/check_msrv.dart,scripts/src/msrv.dart,test/scripts/msrv_test.dart,Makefile,scripts/src/update_changelog.dart,test/scripts/update_changelog_test.dart,.github/workflows/check-libsignal-updates.yml,.github/workflows/test-reusable.yml,README.md,CONTRIBUTING.md,CLAUDE.md,.claude/skills/update-libsignal/SKILL.md) — it comparesrust-versioninrust/Cargo.toml, therust_versionanswer and CONTRIBUTING's toolchain line with the highestrust-versiona crate of the resolved graph declares, and--updateraises all three. They agree today at 1.93.1, and the crate that sets the graph's floor islibsignal-debug0.106.0 — libsignal's own workspace floor. So the next upstream MSRV raise would have reached the update pull request as a red MSRV job, which the build repair leaves alone, and a hand edit in three files. The update workflow now raises it aftermake rust-updateand says so in the pull request, the CHANGELOG entry gains a sentence naming the crate that set it, a moved floor keeps the update from being eligible to merge unread, and CI checks it on the Linux x86_64 leg. It reads what crates declare: one that uses a feature newer than its own floor is still found only by the MSRV job. The template carries the same change.make update-evidencewrites down what an upstream update changed (scripts/update_evidence.dart,scripts/src/update_changelog.dart,test/scripts/update_changelog_test.dart,Makefile,.github/workflows/check-libsignal-updates.yml,.github/workflows/repair-build.yml,.github/agent-prompts/repair-build.md,README.md,CLAUDE.md) — the release notes, the libsignal range with its patches to the crates we build, the lockfile diff, and the range of every git dependency from another repository that moved,spqramong them: until now CLAUDE.md told whoever finished the update pull request to fetch that range by hand withgh api. The update workflow attaches it to its run as theupdate-evidenceartifact and links it from the pull request; the build repair writes the same into.agent-scratch/evidence/, and its prompt starts "ask what moved underneath" from those release notes and patches — #125's agent had the new source but not the diff that said what moved. The CHANGELOG script gathers its facts through the same code (gatherUpdateEvidence), unchanged. Measured: v0.105.4 → v0.106.0 gives the 26 KB of patches to the crates we build and no other range, which is right, sincespqrdid not move there; on the v0.102.3 → v0.103.0 locks the selector names exactlyspqr1.5.3 → 1.6.0, and its range carries theserialize.rspatch the old instruction said to diff by hand. The repair step's shell, run under a stubbedgh, read the range from the copier answers at both ends. A real repair run first exercises it at the next one. The template carries the same change.
Changed
-
The store skills describe the refusing
markKyberPreKeyUsed(.claude/skills/stores-implementation/SKILL.md,.claude/skills/frb-patterns/SKILL.md) — the store skill's interface sketch returnsFuture<bool>and says when to answerfalse, andfrb-patterns, which says a write-back callback cannot stop work already done, adds that one asked before every other write-back and before the result is returned can still stop the operation's effects. -
copier template adopted: v4.18.0 → v4.19.0 (
.copier-answers.ymland the files named here and in the For Users entry above) — beyond the Apple install-name room above, it brings:scripts/verify_release_artifacts.pyrefuses a release whose Apple libraries leave less than 1,024 characters for the install name, so a lost link flag fails the release instead of a consumer's build;CLAUDE.mddescribes it.scripts/src/update_changelog.dartchecks the HTTP status when it reads the upstream's notes: a file counts only on 200, 404 means there is none, and any other status is reported as unread instead of as a release without notes (test/scripts/update_changelog_test.dart).scripts/src/release.dartrewrites only the footer's[Unreleased]:compare link and refuses a CHANGELOG that holds another one at the start of a line, or one above a release heading; it used to take the first from the top (test/scripts/release_test.dart). This CHANGELOG has the footer's alone..github/workflows/publish.ymlhands the release notes to the next step behind a random delimiter, so a notes line readingEOFno longer cuts them short..github/workflows/repair-build.ymlrefuses a named run whose commit comes from another repository, a fork's pull request among them.scripts/src/ai_client.dartrefuses an AI key with a control character, a non-breaking space or a typographic quote inside it, and reports the header error without the header's value (test/scripts/ai_client_test.dart).CONTRIBUTING.mdnames the branches the signing ruleset leaves out.
-
copier template adopted: v4.17.0 → v4.18.0 (
.copier-answers.ymland the files named here and in the For Users entries above) — beyond the isolate count, the web check and thehooksrange above, it brings:make check-upstream-advisories, which holds signalapp/libsignal's own published security advisories against the cratesrust/Cargo.lockresolves: neithermake rust-audit(RustSec) nor Dependabot (GitHub's global database) is guaranteed to see them.check-libsignal-updates.ymlruns it daily in a job of its own, so a finding never stops the update pull request, which is often the fix, and the job fails as well when the advisories cannot be fetched (scripts/check_upstream_advisories.dart,scripts/src/upstream_advisories.dart,test/scripts/upstream_advisories_test.dart,Makefile; the new answerenable_upstream_advisories: true).compareVersionsinscripts/src/common.dartnow orders prereleases as SemVer 2.0 does and ignores build metadata, since the check decides by it whether a locked version is affected. Run on this lock file before merging, it passes: signalapp/libsignal has published no advisory yet.--data key=valueformake update-template, to answer a question a new template version adds; the run then names any answer.copier-answers.ymldid not record (scripts/src/update_template.dart,scripts/update_template.dart,test/scripts/update_template_test.dart,.claude/skills/update-template/SKILL.md).
The rest of the range was here already — the merge-eligibility report, the minimum Rust version check, the evidence artifact, the Highlights fix and the scan for text merged in twice, each with its own entry — and the adoption leaves those files as they were.
-
A local build made from another
rust/Cargo.lockis refused, andmake builddrops the build hook's cache (hook/build.dart,Makefile,CLAUDE.md,test/hook/build_hook_test.dart) — the hook took a local library inrust/target/release/orrust/target/wasm32/whenever its stamp named the current crate version, and the crate version does not move with the dependencies, so after a dependency update the previous build passed.make buildandmake build-webnow copyrust/Cargo.lockbeside the stamp as.cargo-lock, and the hook refuses a local build whose copy is missing or differs from the tree's, with aHookExceptionthat names the remedy.make buildalso removes.dart_tool/hooks_runner/libsignal/: a hook run that took the download path declares onlyrust/Cargo.toml, so a local build made after it went unnoticed until that cache was dropped. ⚠ A local build made before this has no copy and is refused: runmake build(andmake build-web) once. From template v4.18.0. -
Only an Admin can create, move or delete a tag (
.github/rulesets/protect-release-tags.json,.github/rulesets/README.md,CONTRIBUTING.md,CLAUDE.md,.claude/skills/release-package/SKILL.md,.claude/skills/release-frb-crate/SKILL.md) — theProtect release tagsruleset's bypass list also heldactor_id4, which GitHub nameswrite(read back through GraphQL on 2026-10-07), so any collaborator withwritecould create, move and delete tags, the release tags included, unsigned. Onlyadmin(5) is on it now, which is what the rulesets README always described. No automation creates tags: the workflows react to them and create GitHub Releases for tags that already exist, and GitHub Apps, Dependabot andGITHUB_TOKENwere never on the list. The README also shows how to update one ruleset withoutmake setup-repo-protections ARGS="--update", which sends every file. -
Repair Build no longer picks a draft pull request (
.github/workflows/repair-build.yml) — from 2026-10-09 every scheduled run failed on one unchanged commit: the draft template update (#113) was the oldest red bot pull request, copier's conflict markers in itsMakefilestoppedmake getbefore the agent started, and with no verdict nothing marked the head as answered, so the next run picked it again. Being the oldest, it also kept the libsignal update (#115) from ever being picked. A draft is a pull request a human has to finish, so the scheduled search now skips drafts; a run named throughworkflow_dispatchcan still target one. -
Every job names its image:
ubuntu-26.04,macos-26andwindows-2025-vs2026(every workflow with an Ubuntu, macOS or Windows job,.github/actions/setup-rust/action.yml,.github/actionlint.yaml,CLAUDE.md) — GitHub movesubuntu-latestfrom 24.04 to 26.04 job by job between 2026-10-19 and 2026-11-19, so a job could change image, and go red, with no commit to blame, andmacos-latestandwindows-latestmove the same way. Every job onubuntu-latestnow namesubuntu-26.04, and the Linux ARM64 test leg and both Linux release builds move fromubuntu-24.04andubuntu-24.04-armtoubuntu-26.04andubuntu-26.04-arm. The macOS and Windows jobs, the release builds among them, name the images-latestresolved to on 2026-10-21:macos-26(macOS 26 arm64) andwindows-2025-vs2026(Windows Server 2025 with Visual Studio 2026, the label that names the compiler as well as the system). Each is one of the labels of the very image it replaces, so only the label changes, and so does nothing in the cache keys, which carryImageOS. Measured on a throwaway branch first, from a cold cache: Tests (the whole matrix), Fuzz and a Repair Build dry run pass on 26.04 (GCC 15, glibc 2.43, Python 3.14), with wasm-pack, cargo-ndk, cargo-audit, cargo-deny, the FRB codegen and cargo-fuzz compiled there, andverify_release_artifacts.py, whose tar extraction changes default on Python 3.14, passes on 3.14 against crate 7.0.0's archives. The release workflows, the scheduled update checks and the pull-request-only workflows were not run; each first runs on 26.04 at its next trigger, the release ones at the next tag. The Rust cache now keys on the image too (ImageOS, in setup-rust andfuzz.yml): rust-cache keys on the OS family alone, and the first 26.04 runs restored what 24.04 had compiled, wasm-pack and cargo-ndk included. actionlint 1.7.12 does not know the two 26.04 labels yet, so.github/actionlint.yamlignores exactly those until it does; it knowsmacos-26andwindows-2025-vs2026, and still reports an unknownmacos-99. -
The update bot's CHANGELOG entry is written from what the update changed (
scripts/src/update_changelog.dart,test/scripts/update_changelog_test.dart,.github/agent-prompts/changelog-scope.md) — the entries for v0.104.0, v0.105.0 and v0.105.1 were each rewritten by hand, for reasons the prompt could not have avoided. It told the model, twice, to say that "the material does not carry it", while rule 7 forbade narrating the inputs, and the v0.105.1 entry did as told ("the material does not identify…"); both now say to make no claim, as does thespqrparagraph of the scope file. A 12 000-character cap marked v0.105.1's file list TRUNCATED although the API had returned all 228 files, and the entry said so; the cap is 60 000 now, above anything the API's 300 files reach. The files of the upstream crates this package builds are listed apart and first, with the compare API's own patch for each — the one that showsTimestampgainingTryFrom<SystemTime>— and those crates now come fromcargo metadata, which nameslibsignal-debugas well; the scope file lists it too. The third-party paragraph every rewrite added is now written by the script from therust/Cargo.lockdiff againstHEADand appended to the entry — every registry crate that moved, was added or was removed, andspqrwith its revision, moved or not — so no model copies the list, and none can again invent aspqrmove. The libsignal Highlights line goes above thelibsignal_frbline, or last, rather than above the release's headline bullets, and the#### Changedentry goes last in its block. Measured on v0.105.1's real inputs without a model: four crates, a COMPLETE list with the two bound files first, the patch, and a paragraph naming the sixteen crates the human's rewrite named, withspqrstaying at 1.6.0 (06959b4). Then replayed through gpt-5.6-luna and gpt-6-luna on all three bumps' real inputs: no entry narrated its inputs or repeated the crate list, and on v0.105.1 both named thetimestamp.rschange and left out the no-impact sentence, as rule 4 requires. What the replay still caught is settled in code: the model's typographic quotes and apostrophes are made straight, as everywhere else in this file (gpt-6-luna wrote’beside the rule-4 phrase's'in two entries of three), and an entry whose last sentence the model left open is closed before the paragraph is appended (one of six ran "…public API" into "Third-party crates move with it"). -
The AI reviewer is told where each file starts in the diff, and that a bot's CHANGELOG entry is a model's (
.github/workflows/ai-review.yml,.github/agent-prompts/pr-review.md) — on the v0.105.1 bump it called the bot's entry "consistent with a refresh" while the entry narrated its own inputs and named none of the crates the lockfile moved, crates the same finding listed. Its read tool returns 50 KB a call, and the lockfile hunk began at line 2 656 of the diff, past where the first read stopped. The context file now gives the line each file's hunks start on and each commit's author, so a person's rewrite of a bot's entry shows, and on a bot's pull request that touchesCHANGELOG.mdit says the entry was written by a model and what to hold it to. The prompt's rule 6 now covers an entry that names a version or crate no hunk shows, or narrates its inputs. A file the reviewer lists as read but saw only as hunks of the diff is now reported as seen in the diff rather than as "claimed but never opened": still not a file it read, but no longer worded as though it had not looked. Only the lines each read of the diff returned count, cut as OpenCode's read tool cuts them (2 000 lines or 51 200 bytes), so aCHANGELOG.mdhunk near the top counts and a lockfile hunk past the first read does not. -
Repair Build passes over what it has answered, re-runs a first failure, and counts a repair that never finished (
.github/workflows/repair-build.yml,CONTRIBUTING.md) — three ways the scheduled repair could stall or loop. It asked whether a pull request had been answered only after picking the oldest red one, so one answered pull request blocked every newer one; each candidate is asked now, and therepair-build-*branches the workflow opens itself are never candidates. Amainit had reported as unfixable went back to the agent on every run — onf68354ddc64b, four nights ofcannot-fixand then a fifth answer that hand-edited generated code (#67) — and now counts as answered by that report. Only a verdict marked a commit answered, so a job that failed before reaching one (#113'smake get, 19 runs) repeated twice a day: a newrecordjob counts each such attempt as an artifact keyed by the commit, and after two the commit is left to a person, with a comment on the pull request or an issue formainsaying so. And a red run on its first attempt has its failed jobs re-run once, by a newrerunjob, before any agent sees it — the commonest red here has been a flaky runner, and the agent's correct answer to one spent the only answer that commit gets.rerunholds the file's oneactions: write, at job level, runs nothing else, and keeps a marker before it asks, so a re-run GitHub refuses is asked for once and the run then goes to the agent. Amainred at its tip still holds the pull-request search, answered or not, because a branch cut from it inherits the failure; a named run behaves as before, and a dry run with no run named now picks what the schedule would. Replayed over fourteen fixture cases through the old and new selector: the old one stalls behind the answered pull request, re-asks the reportedmain, and repairs its ownrepair-build-*branch; the new one does none of these. -
The update bot moves only what the new upstream tag requires (
Makefile,README.md,.github/dependabot.yml,CONTRIBUTING.md,scripts/README.md) —make rust-update, which the bot runs, was a barecargo updatethat moved every crate in the lockfile: 41 registry crates over the last four bumps, none of them required by the new tag, and twice that broke a platform the bump never touched — wasm-bindgen 0.2.113 under FRB 2.11.1, andlibc0.2.190 on iOS in v0.105.1's, which undid the hold put there for it. It iscargo update --workspacenow; replayed on those four bumps, it moved exactly the four upstream crates. The rest of the graph moves in Dependabot's weekly cargo pull request, which now takes transitive crates too (allow: dependency-type: all) — until now it offered only whatrust/Cargo.tomlnames, so the comment there saying it is how a vulnerable transitive crate's fix arrives was not true. The manual-update steps inCONTRIBUTING.mdandscripts/README.mdnow saymake rust-updatetoo: they saidcd rust && cargo update, which also skipped the notices regenerationmake verify-third-party-noticeschecks. -
The repair job can run
make rust-doc(.github/workflows/repair-build.yml) — its toolchain had nowasm32-unknown-unknown, so the wasm32 half of a gate the prompt has the agent run stopped before documenting anything; a dry run's agent reported it as a gate it could not complete. -
OpenCode is pinned at 1.18.35 in both agent workflows (
.github/workflows/repair-build.yml,.github/workflows/ai-review.yml) — 1.18.15 looks a model up in a catalogue built into the binary that predates gpt-6-luna, which the agents now run, and a run on it died withProviderModelNotFoundError, shown in the transcript only asUnknownError. On 1.18.35 both agents' permission assertions pass unchanged, locally and in a dry run of each workflow. The repository variableAGENT_OPENCODE_VERSIONalready said 1.18.35; now the default does as well. -
The agents call no model nobody named (
.github/agent-config/opencode.json,.github/workflows/repair-build.yml,.github/workflows/ai-review.yml,CONTRIBUTING.md) — OpenCode titles each session through a second, "small" model that it picks from the provider's catalogue when none is set: on OpenRouter,google/gemini-3.6-flashon 1.18.15,google/gemini-3.8-flashon 1.18.35, and an image model on the catalogue it downloads today — through the same key, with the run's opening message in the request. A title is thrown away with the runner, so the call is off; the repository variableAGENT_OPENCODE_TITLE_MODELturns it back on with a named model. Both workflows assert what OpenCode resolved — off, or exactly that model — as they assert each agent's permissions, and on 1.18.15 and 1.18.35 alike those permission assertions still pass unchanged. A default-branch config with no title entry only warns:ai-review.ymlruns as a pull request's own copy, so the pull request that brings the check meets the old config. -
copier template adopted: v4.15.2 → v4.17.0 (
.copier-answers.yml,Makefile,CLAUDE.md,scripts/src/pub_limits.dart,scripts/verify_pub_limits.dart,scripts/src/release.dart,scripts/src/upstream_source.dart,scripts/src/check_updates.dart,scripts/src/common.dart,scripts/src/update_changelog.dart,scripts/check_exists_frb_release.dartand their tests,.github/workflows/test-reusable.yml,.github/workflows/check-libsignal-updates.yml,.github/workflows/check-template-updates.yml,.github/workflows/refresh-notices.yml,.github/rulesets/signing-commit.json,.github/rulesets/README.md,.claude/skills/release-package/SKILL.md,.claude/skills/update-libsignal/SKILL.md) —make verify-pub-limitsarrives. pub.dev refuses a README, CHANGELOG, LICENSE or example over 256 KiB, and a pubspec over 128 KiB, only at upload — after the tag, which is how 7.4.0 was spent — and the dry-run measures none of them. The check now runs beforemake publish-dry-run, insidemake releaseonce the CHANGELOG is finalized, and in CI on every push, warning from 200 KiB;CHANGELOG.mdis at about 131 KB. The two update checkers no longer check out with the App token and keep no credential in.git/configwhile project code and copier run. Signing commit now excludesupdate-libsignal-*andupdate-template-v*, soforce_updatecan refresh an open update pull request — a force-push that ruleset refused, measured in a generated project; the ruleset was re-applied on GitHub on 2026-10-21 with the single-rulesetPUTin.github/rulesets/README.md. That README now also says whatrefresh-notices.ymldoes: it commits throughcreateCommitOnBranch, which signs. The update scripts are reorganised aroundupstream_source(git_taghere;crates_iois the template's new alternative): a manualtarget_versionis held to shell-safe characters and validated by the checker, the release reads the crate pin strictly, andupstream_versionis validated. The agent, repair, lockfile and Dependabot changes of 2026-10-19 were written here first and arrive as no diff. Kept ours:hook/build.dartand its test (this project's 6.3.0 → 6.3.1 example),SECURITY.md(it already lists the tag protection), the rulesets README's dates and its notes on the required checks, andCLAUDE.md's two-stage release docs, which gain the size check by hand. -
test / Test (Linux ARM64)is a required check again (.github/rulesets/protect-main.json,.github/rulesets/README.md) — it was left out when the test matrix became required on 2026-09-07, over a 30-second timeout that hit a different test each run. The cause was a flutter_rust_bridge race that could drop Rust's replies to Dart while a test file's isolate was starting;LibSignal.inithas worked around it since 2026-09-30, released in 8.0.0. Of the 31Testsruns from 2026-10-01 to 2026-10-21 the leg failed three, each time with every other test leg at the same step (Analyzetwice,Get dependenciesonce).mainnow requires twelve checks, the template's list. The live ruleset was updated the same day by a single-rulesetPUTof the live ruleset plus this one context, so the parameters GitHub added to it stay as they were. The runbook's advice on dropping a flaky leg is now the template's, followed by this project's case. -
Two repeats a template merge left on 2026-08-31 are gone (
CLAUDE.md,.githooks/pre-commit) — adopting v4.4.0 → v4.6.0 put theAI_MODELStable and the paragraphs after it intoCLAUDE.mda second time, inside Stage 2 — release the Dart package, and took What the entry is judged against there with them; that section is back under AI-Powered Changelog, as in the template. The same merge kept this repository's first fix for the pre-commit hook'sGIT_DIRproblem, anunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILEat the top of the file, beside the template's, which unsets them for the checks alone. The one at the top also hid the temporary indexgit commit -ahands the hook, so the hook's owngit diff --cachedsaw nothing staged andmake rust-checkwas skipped on everygit commit -athat changedrust/(measured with a hook that prints both). The hook is the template's now. The check above found both. -
The update bot's CHANGELOG entry no longer leaves two blank lines under Highlights (
scripts/src/update_changelog.dart,test/scripts/update_changelog_test.dart) — when the previous bump's default Highlights line was the only line in its block, dropping it left the blank line from either side of it, and the new line went in above both. #119 (v0.105.2) arrived with two blank lines before#### Changed, removed by hand whenmainwas merged into it, and every bump after the first in a release window would have needed the same edit. The emptied block is now written with one blank line on each side of the new line. Replayed on #119's real input, the generator produces theCHANGELOG.mdthat was merged, byte for byte; the same replay before the fix gives the extra line. -
The AI reviewer holds a bot entry's upstream claims against the upstream range, not against this diff (
.github/workflows/ai-review.yml,.github/agent-prompts/pr-review.md) — the rule added on 2026-10-19 said that every file a bot's CHANGELOG entry names must appear in a hunk. An entry that describes the upstream release names upstream files, which no hunk here can show, so on #119 the reviewer reportedrust/net/chat,rust/attestandrust/core/src/version.rsas named by no hunk although every claim was true, and it would have on every bump. The workflow now reads the range the entry links from GitHub and lists the files it changed inpr-context.md. It takes the link only when it points atnative_repofrom.copier-answers.ymland both refs are plain, and nothing in it can fail the step: without a link nothing is added, and a range GitHub will not return is reported as unreadable. Only that section, and rule 6 of the prompt, exempt claims about that one repository's files from the hunks and hold them against the list. Every other claim is held against the diff as before, so a template adoption's entry, whose link points at the template, is still checked against the diff in full. Run on #119's real diff, the section lists the range's 49 files, the three paths above among them. Nine more variants each finish the step and write what they should: no link, a link to another repository or to the template, an unreadable range, a ref with.., a pull request opened by a person, a missing or malformednative_repo, and a range past GitHub's 300-file cap, which the section then states. No model was run: what the reviewer makes of the list shows on the next libsignal bump. -
signal-cryptois no longer a direct dependency (rust/Cargo.toml,rust/Cargo.lock,.copier-answers.yml,.github/dependabot.yml,.github/agent-prompts/changelog-scope.md,scripts/src/upstream_source.dart,scripts/src/update_changelog.dart,.claude/skills/update-libsignal/SKILL.md) — nothing inrust/srcor the fuzz targets calls it, as withthiserrorbefore it. It was a direct dependency only becauseupstream_cratesnamed it, which also gave it a Dependabot ignore and a place in each of the update scripts' crate lists. The answer now nameslibsignal-protocolandlibsignal-core, and the files that depend on it were re-rendered from the same template version (copier update --vcs-ref=v4.17.0), so nothing else moved: a three-way merge of every rendered file gives the same result outside.github/dependabot.yml, which it conflicts on as copier did, and the scope file, which this project owns, and nothing was merged in twice.signal-cryptoitself stays in the graph, throughlibsignal-protocol, soCargo.lockloses one line, the dependency edge, andTHIRD_PARTY_NOTICES.txtis unchanged. The changelog bot still names it: it lists the crates fromcargo metadata, and the answer's list is only its fallback. The scope file now says how it arrives, as it already did forlibsignal-debug. -
A
coverageminor no longer drags majors into the pub group (.github/dependabot.yml) — #117 and #118 were each the pub group's pull request, red on every OS:coverage1.16.0, a minor the group may take, with theffigen,hooksandcode_assetsmajors, although thehooksandcode_assetsmajors are ignored and the group admits only minors and patches. The minor resolves one way only: it needscli_util ^0.6.0, everyffigenbelow 23.0.0 needs^0.4.2, andffigen23 needshooksandcode_assets2.x. pub's resolver, given the four unlocked, lands on exactly #118's set, so Dependabot moved what the update had to move, past both rules; the raisedhooks ^2.2.0is what brokeexample/, where Flutter 3.38.4 pinsmeta1.17.0 andhooksfrom 2.1.0 needs^1.19.0.coverageis now ignored from 1.16.0, andffigenmajors on their own account:ffigengenerates part oflib/src/rust/frb_generated.io.dart,make codegenruns the version this package depends on, and flutter_rust_bridge's ownfrb_dartpins^20.1.1, so a major moves generated code and belongs with the FRB version, not with a bump. Both lift onceffigenhas moved. The same block goes into the template. -
protect-main.jsonnames the unattributed-changes approval (.github/rulesets/protect-main.json,.github/rulesets/README.md) — the liveProtect main branchcarriesrequire_extra_approval_for_unattributed_changes: true, which the file did not name, and the README warned thatmake setup-repo-protections ARGS="--update"might reset it. It does not: GitHub storestruefor a payload that leaves the parameter out, and the ruleset's own history shows ittrueafter the--updateof 2026-09-07, sent from a file without it. GitHub documents the parameter nowhere, neither in its REST description nor in its GraphQL schema. Others report, and nothing here has measured, that withtruea pull request carrying commits not linked to a GitHub account waits for one human approval even when none is required. The file now saystrue, so it says what GitHub enforces: compared field by field, the four live rulesets now differ from their files only in the two defaults GitHub adds itself, which the README names. The template's file and README change the same way. -
Only their release tags may enter the two release environments (
scripts/setup_repo_protections.dart,scripts/src/repo_protections.dart,test/scripts/repo_protections_test.dart,.github/workflows/publish.yml,.github/rulesets/README.md,Makefile,CLAUDE.md) — a run ofbuild-libsignal.ymlorpublish.ymlstarted from any branch reached the approval prompt, so one mistaken approval could publish a native binary or the package, andmake setup-repo-protectionsset up onlynative-build, sending nothing but its reviewer. It now configuresnative-buildandpub.devin full: you as the one required reviewer, no admin bypass, approving one's own run allowed, and a deployment policy that lets in onlylibsignal_frb-*tags andv*tags respectively. A re-run converges on the same settings, each environment is read back afterwards, andARGS="--dry-run"only reads and prints what would change.publish.yml'spublishjob now runs on a tag only, since from a branch the environment would refuse it andvalidate-packagealready makes the dry run;create-releaseis left to its policy. The dry run against this repository foundnative-buildletting admins bypass and both environments letting every ref in, the reviewer and self-review settings already right. The template's script, module and tests are the same files. -
Every test that expects a call to fail names the code it fails with (13 files under
test/groups/,test/protocol/andtest/sealed_sender/) — 37 expectations accepted any throw at all (throwsA(anything)), so a call failing for another reason, or with a plain Dart error, passed them as readily as the failure they were written for. What each one catches was measured first, with a probe in place of the matcher: all 37 throw aLibSignalException, and each now asserts its code withfailsWith—protobufError20 times,invalidMessage9,verificationFailure3,sessionNotFound2, andinvalidArgument,legacyCiphertextVersionandunrecognizedMessageVersiononce each, every one the failure its test is named for. With a wrong code put into one of them, that test fails and names the code it got. A comment that said the error type "varies by implementation" now says which it is. -
The build repair's agent sees the update it is repairing (
.github/workflows/repair-build.yml) —Describe the update under repairgives the agent the pin that moved and the files the bot changed, from the merge base ofmainand the commit under repair. It computed that base locally, on a checkout one commit deep, and git treats a shallow commit as having no parents: on every repair of a pull request the base came back empty, and the file the agent reads said only that the bot's diff could not be shown. It now asks the API (compare/main...<sha>, as ai-review takes its diff), which needs no history and no anonymous fetch. Run on real heads, the step shows #119's v0.105.1 → v0.105.2 pin move, and the API's merge base equals local git's for #119, #122 and #123. Found by a rehearsal: a draft pull request (#123, closed) broke one upstream call inrust/src/api/keys.rs, and Repair Build was dispatched on its red Tests run withdry_run: true. The agent (gpt-6-luna) repaired it with the one-word change back, rejecting a wider public API because the value was in scope, and the surface classifier ran for the first time and measuredunchanged, agreeing with the agent. The template carries the same change. -
make rust-denyfails on a yanked crate version (rust/deny.toml) —[advisories]gainsyanked = "deny"; the default only warns.inout0.2.3, yanked for losing the output-buffer check its unsafe padding code relies on, sat inrust/Cargo.lockfrom #115 (18 October) to #122 (24 October) with this job green andwarning[yanked]in every log. Measured with cargo-deny 0.19.4 (CI installs 0.20.2): with the field,make rust-denyfails on the tree that carried 0.2.3 (exit 2,advisories FAILED) and passes onmainwith 0.2.5. The comment beside the field says how to clear one: Dependabot's weekly cargo pull request, which moves transitive crates here, orcargo update -p;make rust-updaterunscargo update --workspace, which leaves a transitive crate where it is, measured on the same tree. The template carries the same change. -
The build repair asks what moved behind an upstream signature, not only how to make it fit (
.github/agent-prompts/repair-build.md,.github/workflows/repair-build.yml) — its first live repair of an update branch, on #125 (libsignal v0.106.0), adapted tomessage_decrypt_prekeyreturning aPreKeyDecryptResultby taking itsplaintext, as upstream's ownmessage_decryptdoes. It compiled, the matrix went green, and the repair was incomplete: v0.106.0 no longer makes an archived session current again when a message decrypts on it, andSessionCipher.decryptread the identity to save off the current session afterwards, so every such message failed withsessionNotFound— after the application archived a session, and after libsignal archived one itself to settle a conversation. The release notes said so in their first line, and the update bot's CHANGELOG entry, in the agent's own tree, repeated it; no test produced that state, and the prompt sent the agent to the upstream source "to learn what shape a function now has, and for nothing else". A new section, "When it compiles again, ask what moved underneath", has the agent read the changed function's documented behaviour and the bot's entry, find everything here that rests on the old behaviour, drive each change through the public API with a test run against its first compiling adaptation, write down what a consumer can observe, and report it innotes. The path allowlist the workflow enforces gainsCHANGELOG.md,README.mdandSECURITY.md: the prompt already asked an agent that widens the API for a README update and a CHANGELOG entry, and the allowlist refused both. The template carries the same change. -
The update bot replaces the libsignal Highlights line, whoever last wrote it (
scripts/src/update_changelog.dart,test/scripts/update_changelog_test.dart,.github/workflows/check-libsignal-updates.yml) — the line names the version a release section ships, one per section, and the bot replaced only its own default wording. The v0.106.0 line rewritten by hand in #125 would have been kept by the next bump, which would have added its own beside it and left the section naming two versions for someone to collapse. The first upstream bullet is now replaced whole, continuation lines included, and the prompt hands the model the line it replaces: keep what still holds for the whole release, add what the new update changes, name its version, and use the default wording only when the replaced line was the default too. Replayed on this[Unreleased]with a v0.106.1 line, the three-line v0.106.0 bullet goes and thectutilsheadline then above it stays byte for byte. Only a section that already carries two upstream lines keeps the rest and raiseshighlights_stacked. What the model writes when it merges two lines is first seen on the next bump. The template carries the same change.
8.0.1 - 2026-10-26 #
For Users #
✨ Highlights
ctutils0.4.5 — fixes RUSTSEC-2026-0385 in the native library: constant-time masks andu128comparisons (see Security). Nothing else moves: this is 8.0.0 with that one crate replaced- libsignal v0.105.0 — unchanged this release
- libsignal_frb v7.0.1 — Rust FFI bindings
Security
ctutils0.4.3 → 0.4.5, for RUSTSEC-2026-0385 (rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) —ctutilsis RustCrypto's constant-time toolkit, and it reaches every binary, the web module included, throughdigest(and so HMAC, HKDF, SHA-2 andcurve25519-dalek),aes-gcm-sivanduniversal-hash. Before 0.4.4 the mask functions of itsChoicetype (Choice::to_*_mask) could compile to a branch on a secret value on common platforms, and itsu128comparisons branched on every target whose pointers are 32 bits wide, this package'sarmeabi-v7aandwasm32builds among them (RUSTSEC-2026-0385). 0.4.4 routes the masks through an optimization barrier and checks the generated code with BINSEC in its CI; 0.4.5 also hardens Thumb-1 comparisons andChoicecomposition. Crate 7.0.0, the native library of 8.0.0, was built with 0.4.3; whether its machine code contains such a branch was not measured.cargo update -p ctutils --precise 0.4.5on 8.0.0's lock file moves no other crate, so the native library differs from 7.0.0's by this one dependency. Floor: Rust 1.87, below this crate's 1.93.1.- RUSTSEC-2026-0386 (
tokio1.53.2, unsound) does not reach the library, sotokiostays — the advisory concerns the safe constructors oftokio::io::unix::AsyncFd(RUSTSEC-2026-0386), which tokio compiles only with itsnetfeature. What pulls tokio in here enablesrt,rt-multi-thread,syncandtime, on every target, so no binary containsAsyncFd.cargo auditstill lists it, as an allowed warning.mainis on 1.53.3 already.
For Contributors #
Changed
- The release scripts also cut a release from a
release/<line>branch (scripts/src/release_common.dart,scripts/src/release_frb.dart,scripts/src/release.dart,scripts/release_frb.dart,scripts/release.dart,test/scripts/release_common_test.dart) — this release is cut fromrelease/8.0.x, a branch from thev8.0.0tag, becausemainalready carries libsignal v0.106.0 and changes meant for the next major. Both scripts refused every branch butmain, and fetched, compared with and pushedmainby name. They now acceptmainorrelease/<line>(isReleaseBranch), use the branch they run on for all three, and refuse a branch origin does not have yet, so "push first, CI green, then tag" holds on a maintenance branch as onmain. The change exists on this branch only.
8.0.0 - 2026-10-08 #
For Users #
✨ Highlights
- A failed call throws a
LibSignalExceptionwith aLibSignalErrorCode— (breaking) an application can now decide bycode, whose values change only in a major release, instead of matching the text of a thrownString. Code that catches aString, compares the thrown value with a string, or relies onon Exceptionnot matching needs the changes listed under Changed (Breaking) (#106) - Async calls no longer wait forever when another isolate shuts down during
LibSignal.init()— see Fixed - libsignal v0.105.0 (from v0.103.1, through v0.104.0) — internal/dependency update, no public-API impact
- libsignal_frb v7.0.0 — Rust FFI bindings
Changed (Breaking)
-
A failed call throws a
LibSignalExceptioncarrying aLibSignalErrorCode, where it used to throw the error's text as aString(#106) (rust/src/api/error.rs, every file inrust/src/api/,lib/libsignal.dart,README.md) — the native layer passed libsignal's errors to Dart as theirDisplaytext and nothing else, so code that needed to tell, say, a duplicate from a missing session had to search text that libsignal does not treat as an API. Each failed call now throws aLibSignalException: itscodeclassifies the failure, and itsmessageis, with one correction listed under Fixed, the text the call threw before.The codes, and the way libsignal's error variants are grouped into them, come from libsignal's own app bindings: one code per decision a caller makes, not one per failing line. Codes change only in a major release, so a
switchover all of them keeps compiling through minor and patch releases; a condition libsignal introduces in between gets the nearest existing code. Every libsignal variant is mapped by hand with no wildcard, so a new one is a compile error until it has a code. Nothing converts text into an exception: an error this package raises names its code, and a libsignal error given a prefix keeps its own.Three choices differ from libsignal's bindings. A sender certificate that fails validation (expired, not signed by the trust root or by its server certificate, or revoked) is
verificationFailurewherever this package checks one, including inside the sealed-sender decrypt functions, where libsignal reports it as an invalid message; the code says nothing about who sent the envelope, since anyone who knows your public identity key can seal one that fails this check. (A certificate naming an unknown server certificate isverificationFailurein libsignal's bindings too.) libsignal'sInvalidProtocolAddresshas no code of its own: libsignal-protocol never raises it (libsignal's bindings use it for a device id out of range), and this package reports a device id out of range asinvalidArgumenteverywhere. And libsignal'sApplicationCallbackErrorisinternalErrorrather than a callback code, since nothing here can produce it. Separately, a check this package adds — a sender-key distribution message whose distribution id differs from the one passed in — isinvalidMessage, because the id inside the message comes from the peer.messageis the text these calls threw before (except theAes256GcmSivkey-length message, see Fixed), andtoString()returnsLibSignalException(<code>): <message>. A store callback that throws still cannot be reported this way, because the store interfaces cannot return an error across the bridge: on native platforms the call fails withflutter_rust_bridge'sPanicException, and on the web, where every call that takes store callbacks is asynchronous, the WebAssembly module traps and the call never completes.Action required:
- Code that catches with
on String, tests the thrown value withis Stringoras String, or compares it with a string breaks; catchLibSignalExceptionand branch oncode. on Exceptionandon FrbExceptionclauses now catch these errors too — a bareStringnever matched them — so check what such a clause does with a decryption failure.e.toString()now starts withLibSignalException(<code>):. Checks with==orstartsWithno longer match. Checks withcontains(...)still find the message part, but now see the code name too:contains('duplicate')matches everyduplicatedMessage.- The native library changes with it, to
libsignal_frb7.0.0, which the build hook downloads by itself. If you passlibraryPathtoLibSignal.init()or ship the native library yourself, replace that library with a 7.0.0 build. 6.4.0, the one 7.4.1 uses, still passesinit(), and every error then fails to decode as aTypeError, whichon LibSignalExceptiondoes not catch; an older 6.x stops atinit().
- Code that catches with
Changed
-
The native dependency update leaves this package's exposed protocol surface unchanged — the range (compare) changes backup, chat, key-transparency, zkgroup, and language-binding files outside this package's bound crates or exposed surface; those locations are not built or called by this wrapper. In the four libsignal crates that reach the binaries —
libsignal-protocol,libsignal-core,signal-cryptoandlibsignal-debug— the complete file list changes one file,rust/core/src/version.rs, whose only change is the version constant, andlibsignal-debugmoves to 0.104.0 by its version number alone, so these changes do not affect this library's public API. Regenerating the bindings for this update on its own changed nothing inlib/src/rust/; the bindings this release does change come from the coded errors above.spqr, the post-quantum ratchet that runs inside the Double Ratchet, stays at 1.6.0 (06959b4).Third-party crates move with it, and each of these reaches every binary, the web module included:
uuid1.27.0,tokio1.53.2,zerocopy0.8.59,smallvec1.16.2 andlazy_static1.5.1. The web module also getswasm-bindgen0.2.129 withjs-sys/web-sys0.3.106 andwasm-bindgen-futures0.4.79.uuid1.27.0 also changes what the sender-key group calls accept as a distribution id: it reads theurn:uuid:prefix in any letter case, so an id written asURN:UUID:<id>, which used to fail withinvalidArgument, is now accepted as<id>, as the lowercase form already was.cc1.6.0 is not build-only either: it compiles C that is linked into the native libraries —dart-sys'sdart_api_dl.con every native platform andoslog'swrapper.con iOS and macOS.find-msvc-tools, whichccuses to find the MSVC tools on Windows, andwasm-bindgen-testmove too, and only build or test the crate. -
The update to libsignal v0.105.0 leaves the exposed protocol surface unchanged as well — the range (compare) is four commits: the changes its release notes name — chat connection info that says whether a connection is direct, now also in Java, and an
expectedLevelargument tocreateLoginReceiptCredential()— which live inrust/net/chat/,rust/bridge/shared/and the Java, Node and Swift clients, plus formatting and a version reset. None of those crates is in this package's dependency graph. In the four libsignal crates that are, the only change is again the version constant inrust/core/src/version.rs, which this package does not expose, andlibsignal-debugmoves to 0.105.0 by its version number alone. The upstream workspace keepsrust-versionat 1.93.1, this package's floor,spqrstays at 1.6.0 (06959b4), and regenerating the bindings on top of it changed nothing inlib/src/rust/.Third-party crates move with it, and each of these reaches every binary, the web module included:
zeroize1.9.1,zerocopy0.8.61,ctutils0.4.3 andeither1.19.0;zerocopy-derive0.8.61 moves too and only runs at compile time.zeroize1.9.1 is the one that changes behaviour: its zeroing no longer passes throughoptimization_barrier, so onwasm32, which has no assembly barrier in 1.9.0, the web module no longer runs that function's fallback, which upstream fixed in the same release for values whose first byte may be uninitialized; andZeroizing'sDebugoutput is nowZeroizing { .. }instead of the secret it holds.libcstays at 0.2.189 (see For Contributors).
Fixed
-
Aes256GcmSivreports the key length it was actually given (rust/src/api/crypto.rs) — a key of the wrong length threw "Key must be 32 bytes, got 0 bytes" whatever its length, because the key was zeroized before its length was read, and zeroizing aVecalso truncates it. The length is read first now. -
An async call no longer waits forever when another isolate shuts down during
LibSignal.init()(lib/src/libsignal.dart,lib/src/platform/) — flutter_rust_bridge sends every Rust-to-Dart message (the result of an async call, the invocation of a store callback) through one process-wideDart_PostCObjectpointer, and replaces it with a no-op once its count of initialized isolates drops to zero.RustLib.init()installs the real pointer before it counts the new isolate, so when the last other counted isolate group shut down inside that window, the new isolate was left alive with the no-op: its async calls waited for answers that had been dropped, while sync calls kept working.init()now installs the real pointer again once the isolate is counted.Who could reach it:
dart testruns every test file as an isolate group of one process, so a consumer's own suite could hang on the first async call of a file.flutter teststarts a process per file and cannot. An application that initializes libsignal only in short-lived worker isolates could hit it; one that initializes it in its main isolate keeps that isolate counted for its whole life — provided libsignal is the first flutter_rust_bridge library that isolate initializes, see below.⚠ Not fixed by this change: several flutter_rust_bridge libraries in one isolate. flutter_rust_bridge keeps one shutdown watcher per isolate for all of its libraries, while every native library counts isolates on its own — so an isolate is counted only by the first library it initializes. Measured with two libraries: when the main isolate initializes libsignal second, and any other isolate — a
compute()/Isolate.runworker included — initializes libsignal alone and exits, libsignal's channel is switched off for the whole process and every async call from the main isolate waits forever, while sync calls keep working. No timing is involved, and installing the pointer again ininit()cannot help: nothing is initializing when it happens. With libsignal initialized first in the main isolate the same worker is harmless, and so is a worker that initializes the libraries in the same order as the main isolate before it uses libsignal — which is the rule to follow until this is fixed: initialize the flutter_rust_bridge libraries in the same order in every isolate. flutter_rust_bridge 2.13.0 behaves this way and the fix belongs there.Here it was the CI flake that picked a different test every time — a
TimeoutExceptionon 7.5% of Windows runs and 4.5% of Linux ARM64 runs, always on the first test of a file to make an async call. Reproduced outside the test runner by shutting one isolate group down while another initializes, timed across the window: 85 hung calls in 600 before this change, none after, and in every hung isolate a new call was answered once the pointer was installed again. flutter_rust_bridge 2.13.0 and 2.14.0-beta.2 share the ordering. What remains is a finalizer that has already counted to zero and not yet written the no-op, which only a lock inside flutter_rust_bridge can close. -
A local native build left over from an older crate version is no longer loaded silently (
hook/build.dart,Makefile,README.md,CONTRIBUTING.md,CLAUDE.md) — the build hook prefers a host library inrust/target/release/(ordebug/) over the released one, and took it on the sole condition that the file existed, while the local WASM build has had to carry a crate stamp since 7.4.0 (published as 7.4.1).make buildnow stamps a host build the same way, inrust/target/release/.crate-version(a--targetbuild lands elsewhere, is never read, and gets none), and the hook refuses a library whose stamp is missing or names another version, saying how to fix it: runmake build, or deleterust/target/release/to use the released library.rust/target/debug/is no longer read: nothing here builds there, and nothing would stamp it. Measured on this tree: a library built before the change is refused as "an unknown crate version", one stamped 6.3.9 as "crate 6.3.9" against 6.4.0, a freshly built one passes, and a--targetbuild leaves the stamp untouched.It matters most in this release: the way errors cross the bridge changed, a library built before it still loads, and every error it raises then reaches Dart as a
TypeError. Who this reaches:rust/target/is.pubignored, so a consumer installing from pub.dev never takes this path; it affects work in this repository and anyone depending on it by path or git who has built the crate. Arust/target/release/built before this release carries no stamp and is refused, which is the intended answer:make buildfixes it.
Documentation
-
SECURITY.mdmatches the coded errors (SECURITY.md) — the certificate-validation example called asenderCert.validate(...)returning abool, which this package does not have; it now usesvalidateSenderCertificate, which never returnsfalsebut throws, and the section says whatverificationFailuredoes and does not tell you, and that an application must never let it change its configuration or make it fall back to unsealed sending. The identity-trust section now says to branch onLibSignalErrorCode.untrustedIdentityinstead of looking for "untrusted identity" in the message, and that on a pre-key message the code reports a key someone presented, not one shown to be the contact's. The load-time section no longer says FRB's content-hash check pins the bridge's API signature: the check covers the bridged functions' names only, solibsignal_frb6.4.0 passesinit()under this release. The section now also says to replace a library pinned withlibraryPathon every upgrade, with the crate version that release's build hook downloads. -
SECURITY.mdno longer says the tag ruleset stops awritecollaborator (SECURITY.md) — it said theProtect release tagsruleset restricts creating, moving and deleting tags to Admins and Maintainers, so that a plainwritecollaborator cannot mint a release tag. The ruleset leaves all three, unsigned, to the roles on its bypass list, and on 2026-10-07 GitHub's GraphQL API named those rolesadminandwriteon the live ruleset. What gates a release whoever pushed its tag is the approval of thenative-buildandpub.devenvironments' required reviewers, which the section now names as the control that holds on every path. The repository has no collaborator but its admin, and the ruleset itself is unchanged. -
The README says what a failure tells you, and the API docs name the codes (
README.md,rust/src/api/sealed_sender.rs,lib/src/rust/api/sealed_sender.dart,lib/src/stores/identity_key_store.dart) — a new Error Handling section shows how to branch oncode, lists the codes a decrypt call can return before anything in the message is authenticated, says thatuntrustedIdentityis a claim rather than a proof and that codes change only in a major release, and names the failures that are not aLibSignalException: a store callback that throws, any other panic, a call afterdispose(), and the checks made in Dart. The documentation ofvalidateSenderCertificatenow says that it never returnsfalseand which failures areverificationFailure, asSECURITY.mdsection F does, and that ofIdentityKeyStorenamesLibSignalErrorCode.untrustedIdentityinstead of the error's old name. -
The README says what a missing
web/pkg/actually does (README.md,CLAUDE.md,Makefile,example/lib/main.dart) — Known Limitations said thatRustLib.init()then fails on a 404 forpkg/libsignal_frb.js. It never completes: flutter_rust_bridge 2.13.0 loads the module through a<script>tag and waits for itsloadevent with no error path or timeout, so a missing module — a 404, or, underflutter run, the app's own page served in its place with a 200 — leaves the call pending with nothing reported to Dart. Measured withmake run-example-web, the build hook disabled by its skip marker so thatweb/pkg/stayed empty, in headless Chrome:init()neither returned nor threw for 75 seconds, and the dev server answered the missing file with the app's own HTML page and a 200, which is why no 404 shows in the network tab. The example's error screen never appears in this case; its spinner runs on. The README and the comments now say that the call never completes, and the example's hint no longer names a missingweb/pkg/as the usual cause of an error screen that case never reaches.
For Contributors #
Added
- Tests pin the error type, the codes' wire numbers and every code
(
rust/src/api/error.rs,test/errors/lib_signal_exception_test.dart,test/test_helpers/error_matchers.dart,test/sealed_sender/usmc_and_multi_recipient_test.dart) — a bridged function declared with another error type, such as-> Result<T, String>, still compiles, and Dart then gets that type.generated_bindings_use_no_other_error_typereadsrust/src/frb_generated.rsand fails when any bridged function's error type, under any codec, is notLibSignalException.codes_keep_their_wire_positionschecks the number each generated encoder and decoder writes for every code, so a code inserted, moved or appended fails it, as it fails the Dart test that listsLibSignalErrorCode.values. Every code exceptinternalErroris asserted with an exact matcher somewhere undertest/, through the public API.every_protocol_error_variant_has_its_codeholds one row perSignalProtocolErrorvariant, checked against the list that also builds an exhaustivematch: a variant libsignal adds does not compile until it is listed, and then fails the test until it has a row. The foreign-trust-root test inusmc_and_multi_recipient_test.dartissues its forged certificate for the key that seals it, so the trust-root check is what it reaches andverificationFailurewhat it expects; libsignal's own check that a certificate names the key the envelope was sealed with has a test of its own, a genuine certificate sealed with another key failing asinvalidMessage. The test that a throwing store surfaces asPanicExceptionruns on the Dart VM only. - A check between the release stages runs the suite against the released
binary (
CLAUDE.md,.claude/skills/release-package/SKILL.md) — no workflow ever does:publish.ymlandtest.yml, itsworkflow_runafter the native build included, build the library from source withmake build, so a mismatch between the stage-1 binary and the Dart about to be published would first reach consumers. Both now describe checkingHEADout in a fresh sibling worktree, which has norust/target/, and runningmake getandmake testthere, so that the build hook downloads that release's library for this host, checks its checksum and every test runs against it; the other platforms' libraries are not loaded. Measured both ways, against the released 6.4.0: from the tree just before this change (c653698, 927 tests), 82 fail, every one with theTypeErroran old binary gives the new error type; fromv7.4.1, whose Dart matches that binary, all 895 pass. - CI type-checks the three iOS targets on every push and pull request
(
.github/workflows/test-reusable.yml,Makefile,CLAUDE.md,.github/rulesets/README.md) — nothing that ran before a release tag compiled iOS, so thelibc0.2.190 break below was green on its pull request and onmain, and only the tag'sbuild-ioslegs would have found it, with the crate version already spent. AType-check (iOS)job now runsmake rust-check-ioson macOS:cargo check --lockedforaarch64-apple-ios,aarch64-apple-ios-simandx86_64-apple-ios, the three the release builds. Measured both ways: with the lock from before thelibchold it fails on the first target with the same fourE0425, and with the current one all three pass. It catches compile errors, not link errors, and it is not a required check, which the rulesets README now says; becausepublish.ymlruns its tests through the same reusable workflow, a failure there also stops stage 2, as the Android jobs already do.
Changed
thiserroris no longer a direct dependency of the crate (rust/Cargo.toml,rust/Cargo.lock) — nothing inrust/src/derives or names it. It still reaches the binary at the same 2.0.21 throughlibsignal-core,libsignal-protocol,signal-cryptoandspqr, so the set of compiled crates is identical on the host and on wasm32, andTHIRD_PARTY_NOTICES.txtdoes not move. The template keeps the line: projects generated from it do usethiserror.libcis held at 0.2.189, the version 6.4.0 shipped (rust/Cargo.lock,THIRD_PARTY_NOTICES.txt) — the libsignal v0.104.0 update took it to 0.2.190, which puts_dyld_image_countand the_dyld_get_image_*functions behindcfg(target_os = "macos").backtrace0.3.76, which every native build compiles throughflutter_rust_bridgeandallo-isolate, calls them on every Apple target, so all three iOS builds failed witherror[E0425]and a crate release tag would have produced no GitHub Release. No workflow that runs before a release tag compiles iOS, which is why the update pull request andmainwere green. Upstream tracks it as rust-lang/libc#5601, and 0.2.190 was still its latest release on 2026-10-07. The binaries keep thelibcthat 6.4.0 shipped. ⚠ A fullcargo update—make rust-update, which the libsignal update bot also runs — takeslibcback to 0.2.190 until upstream releases a fix; theType-check (iOS)job added above now fails on the pull request that does it, andmake rust-check-iosruns the same check locally.make releaserefuses a stage-1 release whose native sources moved since its tag (scripts/src/release.dart,scripts/release.dart,test/scripts/release_test.dart,CLAUDE.md,.claude/skills/release-package/SKILL.md) — "built from this tree" was checked on two values, the codegen version and the libsignal pin, so a commit torust/srcbetween the two stages passed, as would a stage 2 run with stage 1 forgotten whenever the pin had not moved — and the package then went out against a binary built without that code.rust/(all butrust/fuzzandrust/deny.toml) andlib/src/rust/at the stage-1 tag are now compared withHEADby git object, read through GitHub's contents API on one side andgit ls-treeon the other: equal shas mean equal content, no tag has to exist locally, andHEADneed not be pushed. It fails closed when either side cannot be read. Any commit touching those paths after stage 1 now needs a new crate, a test-only change underrust/srcand a docstring-only regeneration included, and--skip-frb-checkskips both checks. Measured on the real tags:libsignal_frb-6.4.0againstv7.4.1reports nothing, againstHEADit nameslib/src/rust,rust/Cargo.lock,rust/Cargo.tomlandrust/src, and both readers return the same objects for the same tag.- The Linux x86_64 library is built on a pinned
ubuntu-24.04(.github/workflows/build-libsignal.yml) — it was the one release build onubuntu-latest, which moves to Ubuntu 26 from 2026-10-19 (runner-images#14748), and the.solinks against the runner's glibc: a newer one can raise the oldest glibc a consumer's Linux needs to load the library. arm64 was already pinned toubuntu-24.04-arm, and 6.4.0's libraries needGLIBC_2.34on both architectures (measured withllvm-objdump -T), so the release keeps the environment 6.4.0 was built in and consumers see no change. The CI test leg for Linux x86_64 still runs onubuntu-latest.
Fixed
- The release recovery advice works with the release scripts
(
.claude/skills/release-frb-crate/SKILL.md,.claude/skills/release-package/SKILL.md,.github/workflows/build-libsignal.yml) — both skills said to delete a failed release's tag, fixmainand run the same version again, which the scripts refuse: past the fix the version bump is no longerHEAD, and the version is not greater than itself. They now tell a transient failure, which is retried on the same tag (re-run the failed jobs, or dispatch the native build on the tag, never onmain, whose newer code would be published under the tag's version), from a code failure, which spends the version and needs the next patch. The build workflow's comments said to delete a release and its tag to rebuild it, which leaves nothing to dispatch on; they now say to keep the tag. - No instruction tags a release by hand without the checks
make releasemakes, or misstates who may create a tag (Makefile,.github/workflows/publish.yml,.github/workflows/check-libsignal-updates.yml,.claude/skills/release-package/SKILL.md,.claude/skills/release-frb-crate/SKILL.md,CLAUDE.md,CONTRIBUTING.md,.github/rulesets/README.md,.claude/skills/security-review/SKILL.md) —make publish's help andpublish.yml's header told you togit tag vX.Y.Z && git push, an unsigned tag that skips every checkmake releasemakes, and the update bot's pull request listed stage 2 as steps to do by hand; all three now namemake release. The release skill's manual fallback, for a release that cannot go throughmake release, still tags by hand, and now starts with whatmake releasechecks first: that the stage-1 release exists, and thatgit diff --quietfinds nothing between its tag andHEADin the native sources. On the real tags that command agrees with the scripted check: nothing forlibsignal_frb-6.4.0againstv7.4.1, the same four paths againstHEAD. The skill said tag creation is not gated; theProtect release tagsruleset gates it, to the roles on its bypass list, and every one of them bypasses itsrequired_signaturesrule (always), whichCLAUDE.mdsaid rejects an unsigned tag. The rulesets README calledactor_id4 on that list Maintain, which GitHub nameswrite(theSECURITY.mdentry above); the README now maps it as GitHub does and says what that allows, and both release skills,CLAUDE.mdandCONTRIBUTING.mdspeak of the roles on the bypass list instead of naming them. The JSON is unchanged: which roles belong there is a policy decision. And the security-review skill's certificate example still called asenderCert.validate(...)this package does not have; it is nowSECURITY.mdsection F's. rustContentHashis described as what it is (CLAUDE.md,Makefile,hook/build.dart,test/hook/build_hook_test.dart,test/scripts/release_test.dart,scripts/src/release.dart) — seven places in six files said it compares the FFI surface. flutter_rust_bridge 2.13.0 hashes the sorted names of the bridged functions and nothing else (generate_content_hash), so a change to argument, return or error types — this release's errors included — leaves it where it was.- The contributor docs teach the coded error type (
CONTRIBUTING.md,.claude/skills/frb-patterns/SKILL.md) — both still had a new bridged function returnResult<T, String>, and the skill converted libsignal's errors withe.to_string(): the shape this release replaces, and onegenerated_bindings_use_no_other_error_typenow fails. Their examples returnResult<T, LibSignalException>, and the skill's Error Handling says how a libsignal error keeps its code (From,.context()), how one raised here names its own, and why there is noFrom<String>. The template keeps its wording, since its copy serves every generated project and each one picks its own error type, so the skill is now a standing divergence. make getpassesARGSon (Makefile,CLAUDE.md) — it randart pub geton the root package whatever it was given, somake get ARGS="--directory=example_cli"exited 0 having resolved the wrong package, and on a fresh checkoutmake analyze ARGS="example_cli"failed withuri_does_not_existwith no make target to fix it. Measured both ways: withexample_cliunresolved, the analysis fails, and after the fixedmake get ARGS="--directory=example_cli"it is clean; a plainmake getbehaves as before.
Older releases #
7.4.1 and everything before it are in CHANGELOG-ARCHIVE.md.