September 28, 2026
2 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
A binding can now open the built-in file stores from #468 instead of supplying its own storage providers. This is what a headless host without a platform keyring needs.
- FFI
initialize_mls_with_file_stores(mls_root, state_root, store_key). It opensSealedFileMlsStorageatmls_root, sealed under the 32-bytestore_key, andFileProtocolStateStorageatstate_root, in the account directory derived from the instance's ownapp_idandprofile. It keepsinitialize_mls's lifecycle: beforestart(), idempotent, and a repeated call opens nothing. - FFI
close_file_stores(). It releases both directory locks at once, while the instance still exists. When a host object is freed is the host runtime's decision, and until then every later instance over the same directories was refused. It is valid afterstop(). The instance cannot run again afterwards. It disables telemetry first, so the uploader's final flush reaches its queue. FileStorePair::openin the core. It opens both stores together and refuses the pairs the engine would lose data over. The FFI entry point calls it, and Rust hosts get the same refusals.- Python.
ProtocolManager(config, store_key=...)orstore_key_env=selects the file stores.close()stops and releases. Leavingasync withcloses a file-store manager and stops any other. On this pathstart()raises instead of starting without the identity. - Swift and Kotlin get the generated methods and keep their platform keystores.
What the pair refuses
No refusal changes or deletes a record. No new FFI error variant: the append-only enum (C2) is untouched.
| Refusal | FFI variant |
|---|---|
| Empty root, a key that is not 32 bytes or is all zero, roots that are one directory or one inside the other (as spelled, and again once both exist, for volumes that fold case) | InvalidArgument |
| Wrong key, unverifiable key check, a directory that cannot be created or read, state with no MLS record beside it, state that belongs with another MLS store | InvalidConfiguration |
Another store holds the directory, start() has run, or close_file_stores() has | InvalidState |
Ownership is recorded, not inferred. The first open of a pair writes one random pairing id into both stores, the state store first, so a crash between the writes never leaves the two on different ids. A later open compares them. When either store holds no readable id, the pair is judged by whether its sealed records open under the MLS store's record key. The ids say which MLS root a state root was paired with, not who wrote to it last, so a state root shared with another writer is not detected. Scanning the records alone refused the same identity over its own roots on every launch after a damaged record key was regenerated, because that launch cannot delete everything sealed under the lost key.
A damaged record key is not refused, on the launch that finds it or on any launch after while the MLS root keeps its pairing file: the engine regenerates it and settles what it lost as failed.
Also fixed
- A stopped Python manager is freed. The core kept every registered callback, which reached the manager again through Rust.
stop()swaps in inert callbacks for all five slots. An interrupted or overlappingstop()can be finished. - Freeing a Python core object can no longer hang the process. The generated handle map's plain lock is replaced by a re-entrant one at import (P10 in docs/bridges/python.md).
- Python 3.10. A caught teardown exception no longer keeps the manager alive until the collector runs.
ProtocolManager.__del__no longer raises on a manager whose constructor raised.
Validation
- Rust:
cargo fmt --check,cargo clippy --workspace -D warnings, rustdoc under-D warnings, andcargo test --workspace --liball pass locally. - Regression tests reproduce the relaunch-after-damaged-key refusal at the engine level (leftovers over the delete allowance, and a document) and over the FFI.
- Mutation-checked: nine mutants of the pairing logic each fail a test, including the write order under an injected write failure.
- Python: the full suite passes on 3.10, 3.13 and 3.14. The lifecycle files pass under repeated runs on 3.10, 3.13 and 3.14.
- CI runs the Python suite on 3.12, 3.13 and now 3.10.
Breaking changes
None. New UDL methods, new keyword-only Python parameters, and new FileStoreError variants on a #[non_exhaustive] enum.
Notes for reviewers
- Two roots, not one. The MLS root must survive an upgrade, while the state root is removed with the installation.
- The warning in
secure_storage.pyis untouched. It overlaps #469. - Not device-tested. BLE on a real adapter has not been run with the new
stop().
What
Two built-in stores for a Rust host with a filesystem and no platform keystore, in a new offline_protocol::file_store module:
FileProtocolStateStoragewrites protocol state in theOPS1format the iOS, Android and Python providers write, with the same directory and file names. A state directory opens under any binding for the same account. The golden vectors are the onestest_state_storage.pypins.SealedFileMlsStoragekeeps MLS material sealed under a key derived from an operator-supplied store key.StoreKeyProvidersupplies that key.EnvStoreKeyreadsOFFLINE_PROTOCOL_STORE_KEYas hex or base64.StaticStoreKeywraps bytes the host holds.account_storage_namespaceis the namespace derivation every binding already uses, pinned to the Python vector.
The replicated_notes example now runs on the two stores and reopens them from disk.
Decisions a reviewer should check
1. The sealed store hides record keys as well as values. The (key_type, key_id) travels inside the ciphertext, and file names are an HMAC under a second derived key. A copied directory shows how many records exist, not which peers or groups they belong to. The plan only asked for sealed values. The state store keeps the unsalted digest names because its format is shared with the bindings.
2. A wrong key is refused at open. The first open writes a sealed check file. A later open with a different key fails with WrongStoreKey, and nothing on disk changes. If the check file is lost, it is rebuilt only by a key that opens an existing record. Without this, the engine would treat a store it cannot read as empty and mint a new identity.
3. The sealed store never deletes on a read. A record that does not authenticate is reported as CorruptedData and left in place. The usual cause is a wrong key, and deleting would destroy the identity. The state store drops unparseable records, as the binding providers do, so the engine can settle the message ids.
4. Keys are derived per namespace. One store key serving two accounts cannot open one account's record in the other's directory.
5. The stale-temporary sweep only removes files older than five minutes. The bindings sweep unconditionally. That is safe with one process, but a second process over the same directory would lose an in-flight write.
6. open has its own error type, FileStoreError. After open, each store reports through its trait's error type. PR8 maps FileStoreError onto existing FFI variants.
Deviation from the plan
The plan said StateRecordCipher needed a Drop that zeroizes. That is not needed: ChaCha20Poly1305 already zeroizes its key on drop in chacha20poly1305 0.10.1. The residual risk is numbered R18 because R16 and R17 were taken after the plan was written.
Tests
- Store tests. 31 in
file_store: both conformance suites green, golden vectors, a record written by a binding opens, reopen, oversized and misframed records, case-distinct ids, owner-only modes, wrong key, lost and tampered check file, no plaintext values or key ids on disk, a moved record, a record copied across accounts, and the sweep age. - Engine tests. 3 in
protocol/tests/file_stores.rs: the address survives a restart, a sealed document survives a restart and its text is never on disk in the clear, and a wrong key cannot start an engine and changes nothing. - Mutations. 4 of 5 were caught: skipping the key check, dropping the namespace from key derivation, ignoring an unopenable probe, and sweeping fresh temporaries. The survivor is the decrypted-header check on load. A moved record cannot reach it because the file name is in the associated data, so it is a second lock and is commented as such.
Local gates: cargo fmt --all -- --check, cargo clippy --workspace -- -D warnings, cargo test --workspace --lib, and RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps.
Not in this PR
- The FFI entry and the Python wiring. That is PR8,
initialize_mls_with_file_stores. - An unrelated test build failure.
cargo test -p offline-protocol --no-default-featuresfails to compile on main becausedata_sync_groupis not feature-gated. This PR does not touch it.