From bb3ad352276ef7570ee6ba0c54d071c308273e2c Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 21 Aug 2026 22:25:39 +0530 Subject: [PATCH 1/9] feat(leaf,core): a door lock speaks this protocol, not a smaller one ADR 0021 decided that a leaf node runs real MLS through a second implementation, and measured that it fits: 390 KiB of flash for the candidate profile, about a quarter of an xG24. This is the crate. `offline-protocol-leaf` is dual std/no_std and sits on core and sealed only, never on the engine or the MLS crate, because nothing above sealed builds without std. It runs the never-committing member profile: the phone creates the group, adds the device and issues every commit, while the device joins, opens what arrives, answers and persists. `LeafDevice` is a frame-level state machine rather than a bag of primitives. That was a choice, and the reason is that the choreography is the security-critical part. Exposing mint/join/seal/open and leaving the sequence to firmware means every integrator re-derives the reset teardown, the confirmation that has to be a group-aware decrypt, and the gates, and gets to discover on a bench which of them they got wrong. What it refuses is the substance: - A control frame must carry a signature whose key derives to the address the frame claims. An identifier that is not an address is the same refusal rather than a skip: a claim with no derivation to check is not one to wave through, it is the bypass. - A key package body must name the peer that signed the frame carrying it. - A Welcome must name that peer AND be for the group this pair would build, or a relayed Welcome puts the device in a room it never chose. - A sealed frame's MLS sender must be the peer the frame came from, checked by re-deriving from the group member's own signature key. That is ADR 0010's binding, applied on the device so both ends are the same. Persist-before-emit is structural rather than documented. Every operation that advances ratchet state writes through `LeafStore` and only then returns the frame, so a store that fails produces an error and no frame at all. A device that emitted first would come back from a power cut and reuse an AEAD nonce, which is a confidentiality failure and not a lost message. No MLS state is cached in the device value either: every operation loads the group from storage, which costs a load per frame and buys a device with nothing in RAM for a power cut to desynchronize. The mls-rs storage traits are adapted internally rather than exposed. Their write ordering carries what an atomic transaction would: epoch records first, group state last, so a cut mid-write leaves the old state beside records it does not reference, rather than a new state whose prior epochs were never written, which is exactly the out-of-order tolerance a lossy radio needs. Three obligations stay with the integrator, and the API is shaped so none can be forgotten quietly. Every entry point needing a clock takes `now_unix_secs`, because mls-rs stamps 1970 when it cannot read one and the peer then refuses the package as expired: a device that ships that way never pairs at all. The crate registers no getrandom backend, because doing so would let firmware link and run with entropy this crate invented. `LeafStore` must be atomic per entry. Sixteen tests run a real OpenMLS phone against this mls-rs device in one process: pair, talk both ways, driven rekey through the session_reset sequence, replay refusal, power-cycle resume, every gate above, and a negative control that arms a failing store and asserts both that no frame is produced and that the write was actually attempted, so it cannot pass by short-circuiting somewhere earlier. Also adds `Message::from_parts` to core: `Message::new` with its clock and its entropy made explicit. ADR 0020 made core build without std on the reading that a constrained node receives frames rather than minting them, which is true of one that only forwards and false the moment one answers. Without it a bare-metal node cannot produce a Message at all, since the struct has a private field and no other constructor is reachable. `new` delegates, so there is one struct literal rather than two. The bare-metal CI job gains the same three steps core and sealed have, and earned its keep immediately: mls-rs implements Display on its error only under std, so four `{e}` formats compiled on the host and failed for the device. --- .github/workflows/ci.yml | 24 + CHANGELOG.md | 50 +- CLAUDE.md | 21 +- CONTRIBUTING.md | 2 + Cargo.lock | 351 +++++++- Cargo.toml | 6 + README.md | 1 + crates/offline-protocol-core/src/message.rs | 51 +- crates/offline-protocol-leaf/Cargo.toml | 99 +++ crates/offline-protocol-leaf/LICENSE | 661 +++++++++++++++ crates/offline-protocol-leaf/README.md | 22 + crates/offline-protocol-leaf/src/adapters.rs | 251 ++++++ crates/offline-protocol-leaf/src/device.rs | 658 +++++++++++++++ crates/offline-protocol-leaf/src/error.rs | 89 ++ crates/offline-protocol-leaf/src/frames.rs | 210 +++++ crates/offline-protocol-leaf/src/identity.rs | 188 +++++ crates/offline-protocol-leaf/src/keypkg.rs | 99 +++ crates/offline-protocol-leaf/src/lib.rs | 189 +++++ crates/offline-protocol-leaf/src/store.rs | 163 ++++ .../tests/phone_interop.rs | 759 ++++++++++++++++++ docs/architecture.md | 26 + 21 files changed, 3903 insertions(+), 17 deletions(-) create mode 100644 crates/offline-protocol-leaf/Cargo.toml create mode 100644 crates/offline-protocol-leaf/LICENSE create mode 100644 crates/offline-protocol-leaf/README.md create mode 100644 crates/offline-protocol-leaf/src/adapters.rs create mode 100644 crates/offline-protocol-leaf/src/device.rs create mode 100644 crates/offline-protocol-leaf/src/error.rs create mode 100644 crates/offline-protocol-leaf/src/frames.rs create mode 100644 crates/offline-protocol-leaf/src/identity.rs create mode 100644 crates/offline-protocol-leaf/src/keypkg.rs create mode 100644 crates/offline-protocol-leaf/src/lib.rs create mode 100644 crates/offline-protocol-leaf/src/store.rs create mode 100644 crates/offline-protocol-leaf/tests/phone_interop.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16e58ef3..427ce203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,6 +237,30 @@ jobs: - name: Build the sealed layer standalone with default features run: cargo build --package offline-protocol-sealed --locked + # The leaf node itself. `bare-metal-rng` selects getrandom's custom + # backend, which the target needs because getrandom has none for it and + # refuses to compile otherwise; the crate still registers no + # implementation, because that is the firmware's TRNG to wire up. + # + # This step is the only thing that catches an mls-rs error formatted with + # `{}`: mls-rs implements `Display` on its error only under `std`, so the + # host build accepts what the device build rejects. + - name: Build the leaf node for bare metal + run: > + cargo build --package offline-protocol-leaf + --no-default-features --features bare-metal-rng + --locked --target thumbv8m.main-none-eabihf + + - name: Clippy the leaf node for bare metal + run: > + cargo clippy --package offline-protocol-leaf + --no-default-features --features bare-metal-rng + --locked --target thumbv8m.main-none-eabihf + -- -D warnings + + - name: Build the leaf node standalone with default features + run: cargo build --package offline-protocol-leaf --locked + # `embedded-footprint` has its own workspace, so the repo's Clippy job # does not reach it. The leaf configurations are linted individually # because each is a separate link with a different feature set. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c026a7c..49064728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,8 +134,56 @@ archived by series under [docs/changelog/](docs/changelog/); see the One device, one key: a fleet sharing an identity key turns one extraction in a laboratory into every unit's identity. -### Changed +- **`offline-protocol-leaf`: a constrained device that speaks this protocol as + a real peer.** A door lock or a sensor with a few hundred kilobytes of flash + and no operating system now runs RFC 9420 MLS through mls-rs and holds an + end-to-end encrypted conversation with a phone under the same guarantees a + phone gets. Same frames, same envelope, same trust gates, no second sealing + path and no reduced properties, which is the decision + [ADR 0021](./docs/adr/0021-a-leaf-node-speaks-mls.md) took and this crate + implements. + + `LeafDevice` is a frame-level state machine rather than a bag of primitives: + an inbound message goes in, the frames to send and what happened come out. It + runs the **never-committing member** profile, so the phone creates the group + and issues every commit while the device joins, opens, answers and persists. + Per-commit cost on the device is two elliptic-curve operations and + per-message cost is symmetric only. + + What it refuses is the point. Every control frame must carry a signature + whose key **derives to the address the frame claims**, and an identifier that + is not an address is the same refusal rather than a skip, because a claim + with no derivation to check is the bypass. A Welcome must name the peer that + signed it and the group this pair would build. A sealed frame's MLS sender + must be the peer the frame came from. Sixteen tests cover this against a real + OpenMLS phone in the same process, which is the only kind of test that + catches a default in one library the other refuses. + + **Persist-before-emit is structural, not documented.** Every operation that + advances ratchet state writes through `LeafStore` and only then returns the + frame, so a store that fails produces an error and no frame at all. A device + that emitted first would come back from a power cut and reuse an AEAD nonce, + which is a confidentiality failure rather than a lost message. A test arms a + failing store and asserts both that nothing is emitted and that the write was + actually attempted, so it cannot pass by short-circuiting earlier. + + Three obligations stay with the integrator, and the API is shaped so none can + be forgotten silently: every entry point needing a clock takes + `now_unix_secs` (a device that lets an MLS library read a clock it does not + have stamps 1970 and is refused as expired, so it never pairs at all), the + crate registers no `getrandom` backend (firmware wires the part's hardware + entropy source, and key generation is exactly as strong as what it returns), + and `LeafStore` must be atomic per entry. + +- **`Message::from_parts` in `offline-protocol-core`**, which is + `Message::new` with its two ambient inputs, the clock and the entropy, made + explicit. ADR 0020 made core build without `std` on the reading that a + constrained node "receives frames rather than minting them". That is true of + a node which only forwards and false the moment one answers, so without this + a bare-metal node could not produce a `Message` at all. `Message::new` + delegates to it, so there is one struct literal rather than two that drift. +### Changed - **The envelope codec and `GroupId::new` now return `SealedError`** rather than `MlsError`, having moved into `offline-protocol-sealed`. Nothing else changes: `From` exists for both `MlsError` and the engine's diff --git a/CLAUDE.md b/CLAUDE.md index 35717fe5..c0717f58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ that are versioned, reviewable, and readable by people who are not an agent. | A decision that looks odd or over-engineered | [docs/adr/](docs/adr/README.md) | | `offline-protocol-core`: adding an import, a dependency, or a constructor | ADR [0020](docs/adr/0020-core-compiles-without-std.md) (it is dual std/no_std) | | `offline-protocol-sealed`: the envelope codec, `derive_address`, canonical signing payloads, ratchet constants, the 1:1 control-frame prefixes, `KeyPackagePayload` | ADR [0022](docs/adr/0022-one-sealed-layer-shared-with-the-leaf.md) (also dual std/no_std, and the one home for each) | +| `offline-protocol-leaf`: anything a device does at pairing, on a frame, or with its store | ADR [0021](docs/adr/0021-a-leaf-node-speaks-mls.md) and [docs/spec/leaf-provisioning.md](docs/spec/leaf-provisioning.md) (a time source, real entropy and durable-before-emit are obligations, not suggestions) | | Replicated documents: the store, sync frames, attachments | [docs/spec/data-sync.md](docs/spec/data-sync.md), [the replication state machine](docs/state-machines/data-replication.md), ADR [0018](docs/adr/0018-data-layer-engine-and-storage-seams.md) and [0019](docs/adr/0019-remote-document-imports-are-contained-not-trusted.md) | | Any binding: Swift, Kotlin, Python, TypeScript | [docs/bridges/](docs/bridges/README.md) | @@ -60,15 +61,19 @@ RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps # Benchmarks (Criterion) cargo bench --package offline-protocol-bench -# Bare metal. `offline-protocol-core` and `offline-protocol-sealed` are dual -# std/no_std and CI gates both no_std halves; nothing else in the workspace -# compiles either without `std`, so a stray `use std::` in one of them only -# fails here. +# Bare metal. `offline-protocol-core`, `offline-protocol-sealed` and +# `offline-protocol-leaf` are dual std/no_std and CI gates all three no_std +# halves; nothing else in the workspace compiles without `std`, so a stray +# `use std::` in one of them only fails here. So does an mls-rs error +# formatted with `{}`: mls-rs implements Display only under std. rustup target add thumbv8m.main-none-eabihf for crate in offline-protocol-core offline-protocol-sealed; do cargo clippy -p "$crate" --no-default-features \ --target thumbv8m.main-none-eabihf -- -D warnings done +# The leaf crate needs a getrandom backend selected; the firmware registers one. +cargo clippy -p offline-protocol-leaf --no-default-features \ + --features bare-metal-rng --target thumbv8m.main-none-eabihf -- -D warnings ./tools/embedded-footprint/measure.sh # flash/RAM cost of the protocol layer ``` @@ -117,6 +122,10 @@ offline-protocol Engine: OfflineProtocol, ProtocolConfig, Transpor | offline-protocol-uniffi UniFFI bindings (cdylib + staticlib) offline-protocol-bench Criterion benchmarks + +offline-protocol-leaf A constrained device as a never-committing MLS member. + Sits on core + sealed only, never on the engine + (dual std/no_std) ``` ### Key extension points @@ -205,8 +214,8 @@ Conventional Commits: `(): ` Types: `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `chore` -Scopes: `core`, `sealed`, `transport`, `router`, `reliability`, `services`, -`protocol`, `uniffi`, `bindings` +Scopes: `core`, `sealed`, `leaf`, `transport`, `router`, `reliability`, +`services`, `protocol`, `uniffi`, `bindings` ## Code style (Rust) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bec6b2ae..a98c6c58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,7 @@ We use [Conventional Commits](https://www.conventionalcommits.org/): **Scopes**: - `core`: offline-protocol-core - `sealed`: offline-protocol-sealed (envelope codec, address derivation, signing payloads) +- `leaf`: offline-protocol-leaf (a constrained device as a never-committing MLS member) - `transport`: offline-protocol-transport - `router`: offline-protocol-router (DORS) - `reliability`: offline-protocol-reliability @@ -117,6 +118,7 @@ offline-protocol-sdk/ ├── crates/ # Rust crates (core logic) │ ├── offline-protocol-core/ │ ├── offline-protocol-sealed/ +│ ├── offline-protocol-leaf/ │ ├── offline-protocol-transport/ │ ├── offline-protocol-router/ │ ├── offline-protocol-reliability/ diff --git a/Cargo.lock b/Cargo.lock index a095ea79..8a2f674a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -164,6 +165,17 @@ dependencies = [ "winnow", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -433,6 +445,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -659,17 +677,36 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "debug_tree" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1ec383f2d844902d3c34e4253ba11ae48513cdaddc565cf1a6518db09a8e57" +dependencies = [ + "once_cell", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -694,7 +731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -715,7 +752,7 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", @@ -793,7 +830,7 @@ dependencies = [ "pem-rfc7468", "pkcs8", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -923,6 +960,70 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generator" version = "0.8.9" @@ -1131,7 +1232,7 @@ dependencies = [ "hash32 0.2.1", "rustc_version", "serde", - "spin", + "spin 0.9.9", "stable_deref_trait", ] @@ -1172,6 +1273,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hkdf" @@ -1368,6 +1472,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1830,6 +1943,17 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "md5" version = "0.7.0" @@ -1848,6 +1972,143 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mls-rs" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4392c3b3ed7d835ca8f318f85ca65d3f0d3e879538d6e70679827a2f3af72029" +dependencies = [ + "async-trait", + "cfg-if", + "debug_tree", + "futures", + "getrandom 0.2.16", + "hex", + "itertools 0.14.0", + "maybe-async", + "mls-rs-codec", + "mls-rs-core", + "mls-rs-identity-x509", + "portable-atomic", + "portable-atomic-util", + "rand_core 0.6.4", + "serde", + "spin 0.10.1", + "subtle", + "thiserror 2.0.17", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "mls-rs-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bd834f164dc06c1fed805540ae307a460b7ed7c2769a35a376f1de577a0dc1" +dependencies = [ + "itertools 0.14.0", + "mls-rs-codec-derive", + "thiserror 2.0.17", + "wasm-bindgen", +] + +[[package]] +name = "mls-rs-codec-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b31fb579767147e96686889f1e7459d6bd41a131b11d7cd130776cffadb1c3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "mls-rs-core" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e282079e5bd2fe95a009ac8af6a8e510924d876234ee494cd97f15f52de53cb0" +dependencies = [ + "async-trait", + "hex", + "maybe-async", + "mls-rs-codec", + "serde", + "thiserror 2.0.17", + "wasm-bindgen", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-hpke" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53db9a20568dec53e4f280ec8152c862b98efe105894e378d996be3305f32f2" +dependencies = [ + "async-trait", + "cfg-if", + "maybe-async", + "mls-rs-core", + "mls-rs-crypto-traits", + "thiserror 2.0.17", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-rustcrypto" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda8f08b88f84c46924dc0d1cf50c9d3ea2a6e2ac5192c38d8de465724032a2c" +dependencies = [ + "aead", + "aes-gcm", + "async-trait", + "chacha20poly1305", + "ed25519-dalek 2.2.0", + "generic-array", + "getrandom 0.2.16", + "hkdf", + "hmac", + "maybe-async", + "mls-rs-core", + "mls-rs-crypto-hpke", + "mls-rs-crypto-traits", + "p256", + "p384", + "rand_core 0.6.4", + "sec1 0.8.1", + "sha2 0.10.9", + "thiserror 2.0.17", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "mls-rs-crypto-traits" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49171fd5c7c77cd29ec452dcc6f537b8c568084b97023dcc5c0d41140da8ceb4" +dependencies = [ + "async-trait", + "maybe-async", + "mls-rs-core", + "zeroize", +] + +[[package]] +name = "mls-rs-identity-x509" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1ecb6a61a296b8240cea19171477293663dcc6540353dc8cbcda2d9f61039b" +dependencies = [ + "async-trait", + "maybe-async", + "mls-rs-core", + "thiserror 2.0.17", + "wasm-bindgen", +] + [[package]] name = "nom" version = "7.1.3" @@ -2013,6 +2274,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "offline-protocol-leaf" +version = "0.23.0" +dependencies = [ + "base64", + "getrandom 0.2.16", + "mls-rs", + "mls-rs-core", + "mls-rs-crypto-rustcrypto", + "offline-protocol-core", + "offline-protocol-mls", + "offline-protocol-sealed", + "serde", + "serde_json", + "thiserror 2.0.17", + "zeroize", +] + [[package]] name = "offline-protocol-mls" version = "0.23.0" @@ -2234,8 +2513,10 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2 0.10.9", ] [[package]] @@ -2346,7 +2627,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.10", "spki", ] @@ -2407,6 +2688,24 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +dependencies = [ + "critical-section", +] + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -2772,13 +3071,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", "pkcs8", "subtle", "zeroize", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "der 0.8.1", + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -2948,6 +3257,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -2972,6 +3287,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "portable-atomic", +] + [[package]] name = "spki" version = "0.7.3" @@ -2979,7 +3303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -3028,6 +3352,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.23.0" diff --git a/Cargo.toml b/Cargo.toml index 16ad6e52..45840baf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/offline-protocol-core", "crates/offline-protocol-sealed", + "crates/offline-protocol-leaf", "crates/offline-protocol-transport", "crates/offline-protocol-router", "crates/offline-protocol-reliability", @@ -116,6 +117,10 @@ hmac = "0.12" subtle = "2" rand_core = { version = "0.6", features = ["getrandom"] } zeroize = "1" +mls-rs = { version = "=0.56.0", default-features = false } +mls-rs-crypto-rustcrypto = { version = "=0.22.1", default-features = false } +mls-rs-core = { version = "=0.27.0", default-features = false } +getrandom = { version = "0.2", default-features = false } # At-rest sealing for install-scoped protocol-state records. chacha20poly1305 = "0.10" @@ -131,6 +136,7 @@ chacha20poly1305 = "0.10" # version, so all nine move together. offline-protocol-core = { path = "crates/offline-protocol-core", version = "0.23.0" } offline-protocol-sealed = { path = "crates/offline-protocol-sealed", version = "0.23.0" } +offline-protocol-leaf = { path = "crates/offline-protocol-leaf", version = "0.23.0" } offline-protocol-transport = { path = "crates/offline-protocol-transport", version = "0.23.0" } offline-protocol-router = { path = "crates/offline-protocol-router", version = "0.23.0" } offline-protocol-reliability = { path = "crates/offline-protocol-reliability", version = "0.23.0" } diff --git a/README.md b/README.md index 439d87a2..e5105db5 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ The SDK consists of modular Rust crates: - **offline-protocol-mls** - End-to-end encryption using MLS (RFC 9420) - **offline-protocol-services** - Service discovery and request/response over mesh - **offline-protocol-data** - Replicated documents (CRDT) that merge after offline edits +- **offline-protocol-leaf** - A constrained device (lock, sensor) as a never-committing MLS member, for bare metal - **offline-protocol** - Main protocol engine with auto-encryption - **offline-protocol-uniffi** - UniFFI bindings for Swift/Kotlin diff --git a/crates/offline-protocol-core/src/message.rs b/crates/offline-protocol-core/src/message.rs index bfc4b374..dab239d1 100644 --- a/crates/offline-protocol-core/src/message.rs +++ b/crates/offline-protocol-core/src/message.rs @@ -618,16 +618,63 @@ impl Message { recipient: UserId, app_id: AppId, content: impl Into, + ) -> Self { + Self::from_parts( + MessageId::new(), + sender, + recipient, + app_id, + content, + Timestamp::now(), + ) + } + + /// Creates a message from parts the caller supplies, reading no clock and + /// drawing no entropy. + /// + /// This is [`Message::new`] with its two ambient inputs made explicit, and + /// it is the constructor a bare-metal target uses, because that target has + /// neither. Every other field takes the same default `new` gives it. + /// + /// [`Message::new`] delegates here, so there is one struct literal for an + /// outbound message rather than two that drift. + /// + /// # Why this exists + /// + /// [ADR 0020](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0020-core-compiles-without-std.md) + /// made this crate build without `std` on the reading that a constrained + /// node "receives frames rather than minting them". That is true of a node + /// which only forwards, and false the moment one answers: a leaf node + /// running MLS mints a key package, a confirmation and every sealed reply. + /// Without this, such a node cannot produce a `Message` at all, because + /// the struct has a private field and no other public constructor is + /// available without `std`. + /// + /// # Supplying the two inputs + /// + /// A device with no entropy still needs a unique [`MessageId`]. Deriving + /// one from a persisted counter and the device's own identity is enough: + /// ids are compared for equality by the deduplicator, never assumed + /// unpredictable. The timestamp is display metadata and is not a security + /// input, so a device whose clock is only as good as its last pairing is + /// no less safe, only less legible in a log. + pub fn from_parts( + id: MessageId, + sender: UserId, + recipient: UserId, + app_id: AppId, + content: impl Into, + timestamp: Timestamp, ) -> Self { Self { - id: MessageId::new(), + id, sender, recipient, app_id, priority: MessagePriority::default(), ttl: TTL::default(), hop_count: HopCount::new(), - timestamp: Timestamp::now(), + timestamp, lamport_clock: LamportClock::default(), content_type: ContentType::default(), content: content.into(), diff --git a/crates/offline-protocol-leaf/Cargo.toml b/crates/offline-protocol-leaf/Cargo.toml new file mode 100644 index 00000000..48d8168d --- /dev/null +++ b/crates/offline-protocol-leaf/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "offline-protocol-leaf" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "A constrained leaf node that speaks the Offline Protocol as a never-committing MLS member" + +[lints] +workspace = true + +[features] +default = ["std"] +# `std` forwards to dependencies and gates the unit tests. Nothing in this +# crate reads a clock or draws entropy on its own account in either +# configuration: the time comes in as a parameter and the randomness comes +# from a `getrandom` backend the firmware supplies, which is what makes the +# bare-metal build the same code rather than a reduced one. +std = [ + "offline-protocol-core/std", + "offline-protocol-sealed/std", + "mls-rs/std", + "mls-rs-core/std", + "mls-rs-crypto-rustcrypto/std", + "serde/std", + "serde_json/std", + "base64/std", + "thiserror/std", +] + +# Selects `getrandom`'s custom backend, which a bare-metal target needs +# because `getrandom` has no backend for one and refuses to compile without +# this. Enabling it does **not** supply randomness: the firmware must register +# an implementation (`getrandom::register_custom_getrandom!`) wired to the +# part's hardware entropy source, and MLS key generation is exactly as strong +# as what that returns. +# +# Deliberately not implied by anything. Turning it on under `std` would replace +# the operating system's entropy with a symbol that is probably not defined, +# and the failure would be a link error at best and a predictable key at worst. +bare-metal-rng = ["dep:getrandom", "getrandom/custom"] + +# Every dependency below is declared locally rather than inherited with +# `workspace = true`, for the reason ADR 0020 records: cargo silently ignores +# `default-features = false` when it sits next to `workspace = true`, so an +# inherited dependency keeps dragging its `std` feature into the bare-metal +# build and the failure appears as a linker error in someone's firmware rather +# than as a failed check here. `local_dep_versions_match_the_workspace_table` +# in this crate is the counterweight: dropping inheritance means a +# workspace-wide version bump would otherwise stop applying here silently. +[dependencies] +offline-protocol-core = { path = "../offline-protocol-core", version = "0.23.0", default-features = false } +offline-protocol-sealed = { path = "../offline-protocol-sealed", version = "0.23.0", default-features = false } + +# Pinned with `=` deliberately. An interop result is a claim about two exact +# versions (ADR 0021), `tools/mls-interop` pins the same two, and this +# dependency is monitored rather than settled: mls-rs has had no third-party +# security audit and its only bare-metal crypto provider is the one its own +# authors label experimental. +# +# The four features are the leaf profile measured in `tools/embedded-footprint`: +# `private_message` because application messages are `PrivateMessage` and +# nothing works without it, `out_of_order` and `prior_epoch` because a radio +# reorders and drops, and `by_ref_proposal` for the proposals a phone's commit +# can carry. `rfc_compliant` is deliberately absent: it pulls X.509 in for +# about 12 KiB of flash this protocol never uses, since credentials here are +# basic ones carrying an address. +mls-rs = { version = "=0.56.0", default-features = false, features = [ + "private_message", + "out_of_order", + "prior_epoch", + "by_ref_proposal", +] } +mls-rs-crypto-rustcrypto = { version = "=0.22.1", default-features = false } +# Named directly because this crate implements two of its traits +# (`GroupStateStorage`, `KeyPackageStorage`) and `mls-rs` does not re-export +# the types they carry. Pinned with the same `=` and for the same reason. +mls-rs-core = { version = "=0.27.0", default-features = false } + +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +thiserror = { version = "2.0", default-features = false } +# `Zeroizing` is in the signature of `GroupStateStorage`, so it is ours to name. +zeroize = { version = "1", default-features = false } +# Optional and off by default: see the `bare-metal-rng` feature above. +getrandom = { version = "0.2", default-features = false, optional = true } + +# The phone side, so the tests are a real OpenMLS peer talking to this +# crate's mls-rs one rather than this crate talking to itself. `cargo test` +# here is the only place in the workspace where the two implementations meet +# inside one process; `tools/mls-interop` is the other, out of process. +[dev-dependencies] +offline-protocol-mls = { path = "../offline-protocol-mls", version = "0.23.0" } +serde_json = { version = "1.0", default-features = false, features = ["std"] } + +[lib] diff --git a/crates/offline-protocol-leaf/LICENSE b/crates/offline-protocol-leaf/LICENSE new file mode 100644 index 00000000..be3f7b28 --- /dev/null +++ b/crates/offline-protocol-leaf/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/crates/offline-protocol-leaf/README.md b/crates/offline-protocol-leaf/README.md new file mode 100644 index 00000000..056fed24 --- /dev/null +++ b/crates/offline-protocol-leaf/README.md @@ -0,0 +1,22 @@ +# offline-protocol-leaf + +A constrained device that speaks the Offline Protocol as a real peer: a door lock, a sensor, a mains-powered relay. It parses frames, validates addressing, runs RFC 9420 MLS through [mls-rs](https://github.com/awslabs/mls-rs), and holds an end-to-end encrypted conversation with a phone under the same guarantees a phone gets. Same frames, same envelope, same trust gates, no second sealing path. + +Provides: + +- `LeafDevice`, a frame-level state machine: hand it an inbound message, get back the frames to send and what happened +- The never-committing member profile, where the phone creates the group and issues every commit and the device joins, opens, answers and persists +- `LeafStore`, one blob-storage seam a device implements over its secure key storage, with persist-before-emit enforced rather than documented +- Key package minting with the backdated `not_before` and supplied timestamp a device needs to pair at all + +Three obligations this crate cannot discharge for you: a **time source** at pairing (every entry point takes `now_unix_secs`, because a device that lets an MLS library read a clock it does not have stamps 1970 and is refused as expired), **real entropy** (this crate registers no `getrandom` backend on purpose; wire the symbol to the part's hardware source), and **durable storage** (`LeafStore` must be atomic per entry, because a ratchet state rolled back by a power cut reuses an AEAD nonce). + +Like [`offline-protocol-core`](https://crates.io/crates/offline-protocol-core) and [`offline-protocol-sealed`](https://crates.io/crates/offline-protocol-sealed), this crate compiles for bare-metal targets with `--no-default-features` (add `--features bare-metal-rng`). + +This crate is for firmware. Applications on a phone want the main [`offline-protocol`](https://crates.io/crates/offline-protocol) crate instead, or the [React Native](https://www.npmjs.com/package/@offline-protocol/mesh-sdk) or [Python](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/bindings/python/README.md) bindings. + +## License + +Copyright © 2025-2026 Offline Protocol, Inc. + +Dual-licensed: [AGPL-3.0-only](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/LICENSE) for open-source use, or a [commercial license](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/LICENSE-COMMERCIAL.md) for proprietary use. diff --git a/crates/offline-protocol-leaf/src/adapters.rs b/crates/offline-protocol-leaf/src/adapters.rs new file mode 100644 index 00000000..58acf2f2 --- /dev/null +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -0,0 +1,251 @@ +//! Adapters that carry mls-rs's storage traits onto [`LeafStore`]. +//! +//! mls-rs asks for two storage providers, and neither shape belongs in a +//! device integrator's lap: they carry mls-rs's own types, they are versioned +//! with a dependency [ADR 0021](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0021-a-leaf-node-speaks-mls.md) +//! calls monitored rather than settled, and getting the write ordering wrong +//! is a confidentiality bug rather than a lost message. So firmware implements +//! one blob store and these adapters do the rest. + +use alloc::{format, string::String, sync::Arc, vec::Vec}; +use mls_rs_core::{ + error::IntoAnyError, + group::{EpochRecord, GroupState, GroupStateStorage}, + key_package::{KeyPackageData, KeyPackageStorage}, +}; +use mls_rs_core::{ + identity::BasicCredential, + mls_rs_codec::{MlsDecode, MlsEncode}, +}; +use zeroize::Zeroizing; + +use crate::store::{LeafStore, StoreError, KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE}; +use crate::store::{KEY_TYPE_KEY_PACKAGE, KEY_TYPE_PEER}; + +impl IntoAnyError for StoreError {} + +/// Renders bytes as lowercase hex, so an arbitrary group id can be a key id. +/// +/// Group ids are chosen by whoever created the group and are not required to +/// be printable. Hex is the shortest thing that cannot collide with the `:` +/// this module uses as a separator. +pub(crate) fn hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + // `write!` would need `core::fmt::Write` in scope and can fail; a + // table lookup cannot. + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + out.push(DIGITS[(b >> 4) as usize] as char); + out.push(DIGITS[(b & 0x0f) as usize] as char); + } + out +} + +/// Where a group's current state is kept. +fn state_key(group_id: &[u8]) -> String { + hex(group_id) +} + +/// Where one prior epoch is kept. +fn epoch_key(group_id: &[u8], epoch_id: u64) -> String { + format!("{}:{}", hex(group_id), epoch_id) +} + +/// Where the highest stored epoch id is kept. +fn max_epoch_key(group_id: &[u8]) -> String { + format!("{}:max", hex(group_id)) +} + +/// Carries [`GroupStateStorage`] onto the device's blob store. +#[derive(Clone)] +pub(crate) struct GroupStateAdapter { + store: Arc, +} + +impl GroupStateAdapter { + pub(crate) fn new(store: Arc) -> Self { + Self { store } + } +} + +impl GroupStateStorage for GroupStateAdapter { + type Error = StoreError; + + fn state(&self, group_id: &[u8]) -> Result>>, Self::Error> { + Ok(self + .store + .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))? + .map(Zeroizing::new)) + } + + fn epoch( + &self, + group_id: &[u8], + epoch_id: u64, + ) -> Result>>, Self::Error> { + Ok(self + .store + .load(KEY_TYPE_GROUP_EPOCH, &epoch_key(group_id, epoch_id))? + .map(Zeroizing::new)) + } + + /// Writes the epoch records first and the group state last. + /// + /// mls-rs asks for one atomic transaction, and this seam offers atomicity + /// per entry rather than across a set, so the ordering has to carry what + /// the transaction would have. Prior-epoch records are additive: they let + /// a message that arrives late still decrypt. Writing them before the + /// state means a power cut mid-write leaves the **old** state alongside + /// records it does not yet reference, which costs nothing. The reverse + /// order would leave a new state whose prior epochs were never written, so + /// the device would come back having lost exactly the out-of-order + /// tolerance a lossy radio needs. + /// + /// The caller does not emit anything until this returns `Ok`, so a failure + /// here is a frame that was never sent rather than state that fell behind + /// one that was. + fn write( + &mut self, + state: GroupState, + epoch_inserts: Vec, + epoch_updates: Vec, + ) -> Result<(), Self::Error> { + let mut highest: Option = None; + + for record in epoch_inserts.iter().chain(epoch_updates.iter()) { + self.store.store( + KEY_TYPE_GROUP_EPOCH, + &epoch_key(&state.id, record.id), + &record.data, + )?; + highest = Some(highest.map_or(record.id, |h: u64| h.max(record.id))); + } + + if let Some(highest) = highest { + let previous = self.max_epoch_id(&state.id)?.unwrap_or(0); + if highest >= previous { + self.store.store( + KEY_TYPE_GROUP_EPOCH, + &max_epoch_key(&state.id), + &highest.to_be_bytes(), + )?; + } + } + + self.store + .store(KEY_TYPE_GROUP_STATE, &state_key(&state.id), &state.data) + } + + fn max_epoch_id(&self, group_id: &[u8]) -> Result, Self::Error> { + let raw = self + .store + .load(KEY_TYPE_GROUP_EPOCH, &max_epoch_key(group_id))?; + match raw { + None => Ok(None), + Some(bytes) => { + let array: [u8; 8] = bytes.as_slice().try_into().map_err(|_| { + StoreError::Corrupt(format!( + "max epoch record is {} bytes, expected 8", + bytes.len() + )) + })?; + Ok(Some(u64::from_be_bytes(array))) + } + } + } +} + +/// Carries [`KeyPackageStorage`] onto the device's blob store. +/// +/// The values here hold the init and leaf-node private keys of a key package +/// the device minted and has not yet spent. mls-rs deletes an entry when the +/// package is consumed by a join, which is why an init key is single use and +/// why a static pairing artifact must never carry one. +#[derive(Clone)] +pub(crate) struct KeyPackageAdapter { + store: Arc, +} + +impl KeyPackageAdapter { + pub(crate) fn new(store: Arc) -> Self { + Self { store } + } +} + +impl KeyPackageStorage for KeyPackageAdapter { + type Error = StoreError; + + fn delete(&mut self, id: &[u8]) -> Result<(), Self::Error> { + self.store.delete(KEY_TYPE_KEY_PACKAGE, &hex(id)) + } + + fn insert(&mut self, id: Vec, pkg: KeyPackageData) -> Result<(), Self::Error> { + let encoded = pkg + .mls_encode_to_vec() + .map_err(|e| StoreError::Store(format!("cannot encode key package data: {e:?}")))?; + self.store.store(KEY_TYPE_KEY_PACKAGE, &hex(&id), &encoded) + } + + fn get(&self, id: &[u8]) -> Result, Self::Error> { + let Some(raw) = self.store.load(KEY_TYPE_KEY_PACKAGE, &hex(id))? else { + return Ok(None); + }; + let decoded = KeyPackageData::mls_decode(&mut &raw[..]) + .map_err(|e| StoreError::Corrupt(format!("key package data does not decode: {e:?}")))?; + Ok(Some(decoded)) + } +} + +/// What a peer told us it can parse, and what we therefore may emit to it. +/// +/// Persisted because these are end-to-end capabilities: they describe what the +/// recipient parses after any number of relay hops, so a device that forgot +/// them across a power cycle would silently downgrade every established peer +/// until the next key package exchange. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub(crate) struct PeerRecord { + /// Envelope forms the peer parses. `[1]` means the compact envelope. + #[serde(default)] + pub(crate) env_versions: Vec, + /// Frame encodings the peer decodes on the next hop. + #[serde(default)] + pub(crate) wire_versions: Vec, + /// Whether this device has already given this peer a key package. + /// + /// Persisted rather than held in RAM because an init key is single use: + /// a device that forgot across a power cycle would mint a second package + /// on the next exchange and leave the peer holding two, of which only one + /// is ever spent. Cleared when a peer resets the session, which is the + /// one moment a fresh package is required rather than wasteful. + #[serde(default)] + pub(crate) key_package_sent: bool, +} + +impl PeerRecord { + pub(crate) fn load(store: &Arc, peer: &str) -> Result, StoreError> { + let Some(raw) = store.load(KEY_TYPE_PEER, peer)? else { + return Ok(None); + }; + serde_json::from_slice(&raw) + .map(Some) + .map_err(|e| StoreError::Corrupt(format!("peer record does not decode: {e}"))) + } + + pub(crate) fn save(&self, store: &Arc, peer: &str) -> Result<(), StoreError> { + let encoded = serde_json::to_vec(self) + .map_err(|e| StoreError::Store(format!("cannot encode peer record: {e}")))?; + store.store(KEY_TYPE_PEER, peer, &encoded) + } +} + +/// Reads the address out of a basic credential. +/// +/// The credential's whole content is the peer's address in its canonical text +/// form. Anything else is a credential this protocol did not mint. +pub(crate) fn credential_address(credential: &BasicCredential) -> Result<&str, crate::LeafError> { + core::str::from_utf8(&credential.identifier).map_err(|_| { + crate::LeafError::IdentityBinding(String::from( + "credential identifier is not valid UTF-8, so it names no address", + )) + }) +} diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs new file mode 100644 index 00000000..c16d5903 --- /dev/null +++ b/crates/offline-protocol-leaf/src/device.rs @@ -0,0 +1,658 @@ +//! The device: what it does with a frame, and what it hands back. + +use alloc::{ + format, + string::{String, ToString}, + sync::Arc, + vec, + vec::Vec, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use mls_rs::client_builder::MlsConfig; +use mls_rs::group::ReceivedMessage; +use mls_rs::{Client, MlsMessage}; +use offline_protocol_core::{Address, AppId, Message, MessagePriority}; +use offline_protocol_sealed::{ + prefixes, EncryptedMessage, GroupId, KeyPackagePayload, MlsMessageType, WelcomeMessage, + MLS_ENVELOPE_COMPACT_V1, +}; + +use crate::adapters::PeerRecord; +use crate::error::{LeafError, Result}; +use crate::frames; +use crate::identity::{build_client, Identity}; +use crate::keypkg; +use crate::store::{LeafStore, KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_PEER}; + +/// Something that happened, for the firmware to act on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeafEvent { + /// A peer advertised itself. The device now knows what it may emit. + PeerAdvertised { + /// The peer's address. + peer: String, + }, + /// A session came up. The device can seal to this peer from here. + SessionEstablished { + /// The peer's address. + peer: String, + }, + /// A peer discarded its session and the device followed. + SessionReset { + /// The peer's address. + peer: String, + }, + /// An application message arrived and decrypted. + MessageReceived { + /// The peer's address. + peer: String, + /// The plaintext. + text: String, + }, + /// A commit arrived and was applied. Post-compromise security advances + /// here, on the cadence the peer sets, because this device never commits. + CommitApplied { + /// The peer's address. + peer: String, + /// The epoch the session is now in. + epoch: u64, + }, + /// A frame was not for this device, or carried nothing to act on. + /// + /// Surfaced rather than swallowed so a bench can tell "refused" from + /// "silently dropped", which are the two states a pairing failure looks + /// like from the outside. + Ignored { + /// Why nothing happened. + reason: String, + }, +} + +/// What a frame produced. +#[derive(Debug, Default)] +pub struct Handled { + /// Frames to transmit, in order. Every one is already durable. + pub outbound: Vec, + /// What the firmware should know about. + pub events: Vec, +} + +/// A leaf node. +/// +/// # The never-committing profile +/// +/// The peer creates the group, adds this device, and issues every commit. The +/// device joins, opens what arrives, answers, and persists. It never emits a +/// Welcome, a commit or a proposal, and never any group, rich, document or +/// relay frame. Per-commit cost here is two elliptic-curve operations; +/// per-message cost is symmetric only. +/// +/// Post-compromise security therefore arrives on the peer's cadence rather +/// than this device's, and a driven rekey reaches it as a key package with +/// `session_reset` set rather than as an unsolicited Welcome. +/// +/// # Storage is the source of truth +/// +/// No MLS state is cached in this value. Every operation loads the group from +/// the store, does its work, writes back, and only then returns the frame to +/// send. That costs a load per frame, which is the right trade for a device +/// that handles a frame every few minutes and must survive losing power +/// between any two instructions: there is no in-RAM state for a power cut to +/// desynchronize from what is on flash. +/// +/// `Debug` renders the device's address and nothing else. Everything else it +/// holds is either secret or a handle to secrets, and a device that printed +/// its identity key into a log would undo the part of the threat model that +/// says the key never leaves storage. +pub struct LeafDevice { + store: Arc, + identity: Identity, + app_id: AppId, +} + +impl LeafDevice { + /// Generates an identity and returns a device that holds it. + /// + /// Draws from the `getrandom` backend the firmware registered. Refuses if + /// the store already holds an identity, because replacing one changes the + /// device's address and silently orphans every peer paired with it. + pub fn provision(store: Arc, app_id: &str) -> Result { + let identity = Identity::provision(&store)?; + Ok(Self { + store, + identity, + app_id: parse_app_id(app_id)?, + }) + } + + /// Loads a device that was provisioned earlier. + pub fn resume(store: Arc, app_id: &str) -> Result { + let identity = Identity::resume(&store)?; + Ok(Self { + store, + identity, + app_id: parse_app_id(app_id)?, + }) + } + + /// Loads a device, provisioning one on first boot. + pub fn open(store: Arc, app_id: &str) -> Result { + match Self::resume(Arc::clone(&store), app_id) { + Err(LeafError::NotProvisioned) => Self::provision(store, app_id), + other => other, + } + } + + /// This device's address. Self-certifying: it is the hash of the identity + /// key, so a peer checks a claim rather than trusting a directory. + pub fn address(&self) -> &Address { + &self.identity.address + } + + /// Mints a key package and wraps it in a signed frame for `peer`. + /// + /// `now_unix_secs` is the pairing time source. It is a parameter because a + /// device has no clock, and passing something wrong here is the difference + /// between pairing and being refused as expired. + pub fn key_package_frame(&self, peer: &str, now_unix_secs: u64) -> Result { + self.key_package_frame_inner(peer, now_unix_secs, false) + } + + fn key_package_frame_inner( + &self, + peer: &str, + now_unix_secs: u64, + session_reset: bool, + ) -> Result { + let client = self.client()?; + let data = keypkg::mint(&client, now_unix_secs)?; + let payload = keypkg::payload(&self.identity.address.to_string(), data, session_reset); + let body = serde_json::to_string(&payload) + .map_err(|e| LeafError::MalformedFrame(format!("cannot encode key package: {e}")))?; + + let mut message = frames::build( + &self.store, + &self.identity, + &self.app_id, + peer, + format!("{}{}", prefixes::KEY_PACKAGE, body), + now_unix_secs, + MessagePriority::High, + )?; + frames::sign_control_frame(&self.identity, &mut message)?; + + // Recorded before the frame is handed back, so a device that emits one + // and loses power does not emit a second on the next boot and leave + // the peer holding two init keys of which only one is ever spent. + let mut record = self.peer_record(peer)?; + record.key_package_sent = true; + record + .save(&self.store, peer) + .map_err(|e| LeafError::Storage(e.to_string()))?; + + Ok(message) + } + + /// Seals `plaintext` to `peer`. + /// + /// The ratchet advances, the new state is persisted, and only then does + /// the frame exist. A store that fails produces an error and no frame, + /// which is the whole point: a device that emitted first and persisted + /// second would, after a power cut, come back and reuse an AEAD nonce. + pub fn seal(&self, peer: &str, plaintext: &str, now_unix_secs: u64) -> Result { + self.seal_content(peer, plaintext.as_bytes(), now_unix_secs) + } + + fn seal_content(&self, peer: &str, plaintext: &[u8], now_unix_secs: u64) -> Result { + let client = self.client()?; + let group_id = self.group_id(peer)?; + let mut group = client + .load_group(group_id.as_str().as_bytes()) + .map_err(|_| LeafError::NoSession(peer.to_string()))?; + + let sealed = group + .encrypt_application_message(plaintext, Vec::new()) + .map_err(|e| LeafError::Mls(format!("cannot seal: {e:?}")))? + .to_bytes() + .map_err(|e| LeafError::Mls(format!("cannot encode sealed message: {e:?}")))?; + let epoch = group.current_epoch(); + + // Persist before emit. Everything below this line only shapes bytes + // that are already accounted for on flash. + group + .write_to_storage() + .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; + + let envelope = EncryptedMessage { + group_id, + message_type: MlsMessageType::Application, + epoch, + ciphertext: sealed, + sender_id: self.identity.address.to_string(), + timestamp_ms: (now_unix_secs.saturating_mul(1000)), + }; + + let body = self.encode_envelope(peer, &envelope)?; + frames::build( + &self.store, + &self.identity, + &self.app_id, + peer, + format!("{}{}", prefixes::ENCRYPTED, body), + now_unix_secs, + MessagePriority::Medium, + ) + } + + /// Chooses the envelope encoding for this peer. + /// + /// Compact only when the peer advertised it. Otherwise the JSON floor, + /// which every conforming receiver parses unconditionally and which no + /// negotiation ever removes. + fn encode_envelope(&self, peer: &str, envelope: &EncryptedMessage) -> Result { + let record = self.peer_record(peer)?; + if record.env_versions.contains(&MLS_ENVELOPE_COMPACT_V1) { + Ok(BASE64.encode(envelope.to_bytes())) + } else { + serde_json::to_string(envelope) + .map_err(|e| LeafError::MalformedFrame(format!("cannot encode envelope: {e}"))) + } + } + + /// Handles one inbound frame. + /// + /// Returns the frames to send and what happened. Everything in + /// [`Handled::outbound`] is already durable by the time it is returned. + pub fn handle(&self, message: &Message, now_unix_secs: u64) -> Result { + let content = &message.content; + + // Order matters: the encrypted-confirm prefix is not checked here at + // all, because it never travels as a frame. It is only ever found + // inside a decrypted plaintext, which is where this looks for it. + if let Some(body) = frames::strip_prefix(content, prefixes::KEY_PACKAGE) { + self.on_key_package(message, body, now_unix_secs) + } else if let Some(body) = frames::strip_prefix(content, prefixes::WELCOME) { + self.on_welcome(message, body, now_unix_secs) + } else if let Some(body) = frames::strip_prefix(content, prefixes::ENCRYPTED) { + self.on_encrypted(message, body, now_unix_secs) + } else if frames::strip_prefix(content, prefixes::SESSION_CONFIRM_PROBE).is_some() { + self.on_probe(message, now_unix_secs) + } else if frames::strip_prefix(content, prefixes::SESSION_CONFIRM_ACK).is_some() { + frames::verify_control_frame(message)?; + Ok(Handled { + outbound: Vec::new(), + events: vec![LeafEvent::SessionEstablished { + peer: message.sender.as_str().to_string(), + }], + }) + } else { + Ok(Handled { + outbound: Vec::new(), + events: vec![LeafEvent::Ignored { + reason: String::from("frame carries no prefix this device answers"), + }], + }) + } + } + + fn on_key_package(&self, message: &Message, body: &str, now_unix_secs: u64) -> Result { + frames::verify_control_frame(message)?; + let payload: KeyPackagePayload = serde_json::from_str(body) + .map_err(|e| LeafError::MalformedFrame(format!("key package body: {e}")))?; + + let sender = message.sender.as_str(); + + // The body names its own owner, and the frame names its sender. A + // package that claims to belong to someone other than the peer that + // signed the frame is one being relayed under a borrowed name, so the + // two must agree before anything is stored under either. + if payload.user_id != sender { + return Err(LeafError::IdentityBinding(format!( + "key package claims to be '{}' but the frame is signed by '{}'", + payload.user_id, sender + ))); + } + + let mut events = Vec::new(); + let mut record = self.peer_record(sender)?; + + if payload.session_reset { + self.forget_session(sender)?; + record.key_package_sent = false; + events.push(LeafEvent::SessionReset { + peer: sender.to_string(), + }); + } + + record.env_versions = payload.env_versions.clone(); + record.wire_versions = payload.wire_versions.clone(); + record + .save(&self.store, sender) + .map_err(|e| LeafError::Storage(e.to_string()))?; + events.push(LeafEvent::PeerAdvertised { + peer: sender.to_string(), + }); + + // Answer with our own package only if this peer has not already had + // one. Without that guard two peers that both answer trade key + // packages forever, each one spending an init key. + let outbound = if record.key_package_sent { + Vec::new() + } else { + vec![self.key_package_frame_inner(sender, now_unix_secs, false)?] + }; + + Ok(Handled { outbound, events }) + } + + fn on_welcome(&self, message: &Message, body: &str, now_unix_secs: u64) -> Result { + frames::verify_control_frame(message)?; + let welcome: WelcomeMessage = serde_json::from_str(body) + .map_err(|e| LeafError::MalformedFrame(format!("welcome body: {e}")))?; + + let sender = message.sender.as_str(); + + // The inviter named inside the body must be the peer that signed the + // frame, and the group must be the one this pair would build. Without + // the second check a peer could hand over a Welcome for a group it + // built with somebody else, and the device would join it and seal into + // a room whose membership it never checked. + if welcome.inviter_id != sender { + return Err(LeafError::IdentityBinding(format!( + "welcome names inviter '{}' but the frame is signed by '{}'", + welcome.inviter_id, sender + ))); + } + let expected = self.group_id(sender)?; + if welcome.group_id != expected { + return Err(LeafError::IdentityBinding(format!( + "welcome is for group '{}', not this pair's '{}'", + welcome.group_id, expected + ))); + } + + let client = self.client()?; + let welcome_message = MlsMessage::from_bytes(&welcome.welcome_data) + .map_err(|e| LeafError::Mls(format!("welcome does not decode: {e:?}")))?; + + // `tree_data: None` because the peer puts the ratchet tree in the + // Welcome. A device that needed it out of band would need a side + // channel it does not have. + let (mut group, _info) = client + .join_group(None, &welcome_message, None) + .map_err(|e| LeafError::Mls(format!("cannot join from the welcome: {e:?}")))?; + + group + .write_to_storage() + .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; + + // The confirmation is a group-aware decrypt, sealed inside an ordinary + // envelope. A peer that created a session of its own confirms only on + // a successful decrypt, so a plaintext acknowledgement would leave it + // unconfirmed however many times it was sent. + let confirm = self.seal_content( + sender, + prefixes::SESSION_CONFIRM_ENCRYPTED.as_bytes(), + now_unix_secs, + )?; + + Ok(Handled { + outbound: vec![confirm], + events: vec![LeafEvent::SessionEstablished { + peer: sender.to_string(), + }], + }) + } + + fn on_encrypted(&self, message: &Message, body: &str, _now: u64) -> Result { + // Not signature-gated: this is the data plane, and MLS authenticates + // its own sender. A second signature on the outside would state what + // the AEAD already proves on the inside. + let envelope = parse_envelope(body)?; + let sender = message.sender.as_str(); + + // The envelope names a sender too, and it is inside nothing: it rides + // in the clear beside the ciphertext. Binding it to the wire sender + // before the group is loaded keeps a relayed frame from being + // attributed to whoever forwarded it. + if envelope.sender_id != sender { + return Err(LeafError::IdentityBinding(format!( + "envelope claims sender '{}' but the frame came from '{}'", + envelope.sender_id, sender + ))); + } + + let client = self.client()?; + let group_id = self.group_id(sender)?; + if envelope.group_id != group_id { + return Err(LeafError::IdentityBinding(format!( + "envelope is for group '{}', not this pair's '{}'", + envelope.group_id, group_id + ))); + } + + let mut group = client + .load_group(group_id.as_str().as_bytes()) + .map_err(|_| LeafError::NoSession(sender.to_string()))?; + + let inbound = MlsMessage::from_bytes(&envelope.ciphertext) + .map_err(|e| LeafError::Mls(format!("sealed payload does not decode: {e:?}")))?; + + let received = group + .process_incoming_message(inbound) + .map_err(|e| LeafError::Mls(format!("cannot open: {e:?}")))?; + + let epoch = group.current_epoch(); + group + .write_to_storage() + .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; + + let events = match received { + ReceivedMessage::ApplicationMessage(app) => { + // The leaf identity binding, applied here as it is on the + // phone: the group member that actually sealed this must be + // the peer the frame claims to come from. MLS proves a member + // sealed it; only re-deriving the address from that member's + // signature key proves *which* member, and in a group this + // device did not build, membership is not something it chose. + self.bind_sender_credential(&group, app.sender_index, sender)?; + + let text = String::from_utf8_lossy(app.data()).to_string(); + // Consumed and never surfaced: it exists to be a decrypt, not + // to be read. + if text == prefixes::SESSION_CONFIRM_ENCRYPTED { + vec![LeafEvent::SessionEstablished { + peer: sender.to_string(), + }] + } else { + vec![LeafEvent::MessageReceived { + peer: sender.to_string(), + text, + }] + } + } + ReceivedMessage::Commit(_) => vec![LeafEvent::CommitApplied { + peer: sender.to_string(), + epoch, + }], + _ => vec![LeafEvent::Ignored { + reason: String::from("sealed frame carried nothing this device acts on"), + }], + }; + + Ok(Handled { + outbound: Vec::new(), + events, + }) + } + + fn on_probe(&self, message: &Message, now_unix_secs: u64) -> Result { + frames::verify_control_frame(message)?; + let sender = message.sender.as_str(); + let mut ack = frames::build( + &self.store, + &self.identity, + &self.app_id, + sender, + String::from(prefixes::SESSION_CONFIRM_ACK), + now_unix_secs, + MessagePriority::High, + )?; + frames::sign_control_frame(&self.identity, &mut ack)?; + Ok(Handled { + outbound: vec![ack], + events: Vec::new(), + }) + } + + /// Discards everything belonging to a session with `peer`. + /// + /// Called when a peer says it has discarded its own. A device that kept + /// the old session would hold one the peer has already thrown away, and + /// every later frame from it would decrypt to nothing. + fn forget_session(&self, peer: &str) -> Result<()> { + let group_id = self.group_id(peer)?; + let key = crate::adapters::hex(group_id.as_str().as_bytes()); + self.store + .delete(KEY_TYPE_GROUP_STATE, &key) + .map_err(|e| LeafError::Storage(e.to_string()))?; + self.store + .delete(KEY_TYPE_GROUP_EPOCH, &format!("{key}:max")) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(()) + } + + /// Requires the MLS member at `index` to derive to `claimed`. + /// + /// The refusal is deliberately the same for a member whose credential is + /// not an address at all: a credential with no derivation to check is not + /// one to wave through, it is the bypass. That is the rule + /// [ADR 0010](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0010-unconditional-leaf-identity-binding.md) + /// makes unconditional on the phone, and a device that skipped it would be + /// the weaker end of a protocol whose whole claim is that both ends are + /// the same. + fn bind_sender_credential( + &self, + group: &mls_rs::Group, + index: u32, + claimed: &str, + ) -> Result<()> { + let member = group.member_at_index(index).ok_or_else(|| { + LeafError::IdentityBinding(format!("no group member at index {index}")) + })?; + + let credential = member + .signing_identity + .credential + .as_basic() + .ok_or_else(|| { + LeafError::IdentityBinding(String::from( + "group member presents no basic credential, so it names no address", + )) + })?; + + let credential_address = crate::adapters::credential_address(credential)?; + if credential_address != claimed { + return Err(LeafError::IdentityBinding(format!( + "sealed by group member '{credential_address}', but the frame claims '{claimed}'" + ))); + } + + // And the credential's claim is itself checked rather than trusted: a + // basic credential is self-asserted, so the address in it means + // something only because it is the hash of the key beside it. + frames::verify_sender_derivation( + credential_address, + member.signing_identity.signature_key.as_bytes(), + ) + } + + fn client(&self) -> Result> { + build_client(&self.identity, &self.store) + } + + fn group_id(&self, peer: &str) -> Result { + Ok(GroupId::for_session( + &self.identity.address.to_string(), + peer, + )?) + } + + fn peer_record(&self, peer: &str) -> Result { + Ok(PeerRecord::load(&self.store, peer) + .map_err(|e| LeafError::Storage(e.to_string()))? + .unwrap_or_default()) + } + + /// Whether a session with `peer` exists on flash. + pub fn has_session(&self, peer: &str) -> Result { + let group_id = self.group_id(peer)?; + Ok(self + .store + .load( + KEY_TYPE_GROUP_STATE, + &crate::adapters::hex(group_id.as_str().as_bytes()), + ) + .map_err(|e| LeafError::Storage(e.to_string()))? + .is_some()) + } + + /// What this device recorded about a peer's capabilities. + pub fn peer_env_versions(&self, peer: &str) -> Result> { + Ok(self.peer_record(peer)?.env_versions) + } +} + +impl core::fmt::Debug for LeafDevice { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("LeafDevice") + .field("address", &self.identity.address.to_string()) + .finish_non_exhaustive() + } +} + +fn parse_app_id(app_id: &str) -> Result { + AppId::new(app_id).map_err(|e| LeafError::MalformedFrame(format!("app id: {e}"))) +} + +/// Parses an inbound envelope in every form a conforming sender may emit. +/// +/// Three forms, and none of them is gated on what this device advertised. +/// Parsing is unconditional in this protocol: a device that decoded only the +/// form it asked for would drop frames from a peer that legitimately believed +/// it capable, which is exactly what happens after a partial fleet upgrade. +/// +/// The sniff works because `{` opens JSON and, read as the little-endian +/// length prefix the compact codec starts with, is a number far above the +/// codec's own string cap. +fn parse_envelope(body: &str) -> Result { + if body.starts_with('{') { + return serde_json::from_str(body) + .map_err(|e| LeafError::MalformedFrame(format!("json envelope: {e}"))); + } + let bytes = BASE64 + .decode(body) + .map_err(|e| LeafError::MalformedFrame(format!("envelope is not base64: {e}")))?; + + if let Ok(envelope) = EncryptedMessage::from_bytes(&bytes) { + return Ok(envelope); + } + serde_json::from_slice(&bytes) + .map_err(|e| LeafError::MalformedFrame(format!("base64 envelope: {e}"))) +} + +/// Erases every trace of a peer. +/// +/// Exposed because a device that is factory reset or unpaired must be able to +/// forget, and because leaving MLS state behind for a peer the owner removed +/// is the kind of residue that outlives the reason it existed. +impl LeafDevice { + /// Forgets a peer: its session, its prior epochs, and what it advertised. + pub fn unpair(&self, peer: &str) -> Result<()> { + self.forget_session(peer)?; + self.store + .delete(KEY_TYPE_PEER, peer) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(()) + } +} diff --git a/crates/offline-protocol-leaf/src/error.rs b/crates/offline-protocol-leaf/src/error.rs new file mode 100644 index 00000000..8cbc986d --- /dev/null +++ b/crates/offline-protocol-leaf/src/error.rs @@ -0,0 +1,89 @@ +//! Errors a leaf node can produce. + +use alloc::string::String; +use thiserror::Error; + +/// Result type for this crate. +pub type Result = core::result::Result; + +/// What can go wrong on a leaf node. +/// +/// Deliberately not `#[non_exhaustive]`, for the reason +/// [ADR 0022](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0022-one-sealed-layer-shared-with-the-leaf.md) +/// gives for `SealedError`: firmware that maps these onto its own error space +/// should get a compile error when a variant is added, rather than a wildcard +/// arm that silently renders a new failure as an old one. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LeafError { + /// The backing store failed. + #[error("Storage failed: {0}")] + Storage(String), + + /// The device has no identity yet, and an operation needed one. + #[error("Device is not provisioned")] + NotProvisioned, + + /// The device already has an identity, and provisioning would replace it. + /// + /// Overwriting an identity is not a recoverable state: the device's + /// address changes, every peer's paired record names a device that no + /// longer exists, and nothing on the wire says why. + #[error("Device is already provisioned")] + AlreadyProvisioned, + + /// A cryptographic operation failed. + #[error("Crypto failed: {0}")] + Crypto(String), + + /// MLS refused an operation. + #[error("MLS failed: {0}")] + Mls(String), + + /// A frame did not parse. + #[error("Malformed frame: {0}")] + MalformedFrame(String), + + /// A control frame arrived unsigned, or its signature did not verify. + /// + /// Unsigned is a refusal rather than a downgrade: every control frame in + /// this protocol carries a signature, and one that does not is either an + /// implementation that skipped the step or an injection. + #[error("Control frame refused: {0}")] + ControlFrameRefused(String), + + /// A presented key did not derive to the address that claimed it, or the + /// claimed identifier is not an address at all. + /// + /// Both are the same refusal on purpose. An identifier that does not parse + /// as an address has no derivation to check, and answering "acceptable" + /// for it is the bypass rather than a lenience. + #[error("Identity binding failed: {0}")] + IdentityBinding(String), + + /// No session exists with this peer. + #[error("No session with {0}")] + NoSession(String), + + /// The sealed layer refused a value. + #[error("{0}")] + Sealed(String), +} + +impl From for LeafError { + fn from(e: offline_protocol_sealed::SealedError) -> Self { + // The inner text passes through rather than the rendered `Display` of + // a wrapper, so a failure reads the same here as it does on the phone. + use offline_protocol_sealed::SealedError as S; + match e { + S::Serialization(m) => LeafError::Sealed(alloc::format!("Serialization failed: {m}")), + S::Deserialization(m) => { + LeafError::Sealed(alloc::format!("Deserialization failed: {m}")) + } + S::InvalidGroupId(m) => LeafError::Sealed(alloc::format!("Invalid group id: {m}")), + S::InvalidPublicKey(m) => LeafError::Sealed(alloc::format!("Invalid public key: {m}")), + S::FieldTooLarge(n) => LeafError::Sealed(alloc::format!( + "Field too large for canonical payload length prefix: {n} bytes" + )), + } + } +} diff --git a/crates/offline-protocol-leaf/src/frames.rs b/crates/offline-protocol-leaf/src/frames.rs new file mode 100644 index 00000000..f382ee8e --- /dev/null +++ b/crates/offline-protocol-leaf/src/frames.rs @@ -0,0 +1,210 @@ +//! Minting outbound frames, and refusing inbound ones that cannot prove who +//! sent them. +//! +//! # The gate +//! +//! Every control frame in this protocol carries an Ed25519 signature in two +//! metadata keys, over a domain-separated canonical payload built from the +//! sender, the id, the recipient and the content. A leaf verifies three things +//! and refuses on any of them: +//! +//! 1. the signature metadata is present and complete, +//! 2. the signature verifies under the key the frame presents, +//! 3. **the presented key derives to the address the frame claims to be from**. +//! +//! The third is the one that means anything. The first two prove a key signed +//! this; only the third proves it is the peer's key. Both halves of a +//! mismatch, and an identifier that is not an address at all, are the same +//! refusal: an identifier with no derivation to check is not a claim that +//! needs waving through, it is the bypass, and answering "acceptable" for it +//! is how an attacker skips the gate by claiming a nickname. + +use alloc::{ + format, + string::{String, ToString}, + sync::Arc, + vec::Vec, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use offline_protocol_core::{ + Address, AppId, LamportClock, Message, MessageId, MessagePriority, Timestamp, UserId, +}; +use offline_protocol_sealed::{ + control_signing_payload, derive_address, CTRL_PK_META_KEY, CTRL_SIG_META_KEY, +}; + +use crate::error::{LeafError, Result}; +use crate::identity::{self, Identity}; +use crate::store::{LeafStore, KEY_TYPE_IDENTITY}; + +const KEY_ID_COUNTER: &str = "send_counter"; + +/// Reads, advances and persists the device's send counter. +/// +/// One counter serves both jobs a phone uses entropy and a clock for: it makes +/// a unique [`MessageId`] without drawing randomness, and it is the Lamport +/// value that orders this device's sends. Ids here are compared for equality +/// by a receiver's deduplicator and are never assumed unpredictable, so a +/// counter is the right primitive rather than a weaker substitute for one. +/// +/// It is persisted **before** the frame it numbers exists, so a power cut +/// costs a skipped id rather than a repeated one. A repeated id would be +/// silently swallowed by the peer's deduplicator, which is a message that +/// vanishes with nothing anywhere reporting it. +fn next_counter(store: &Arc) -> Result { + let current = store + .load(KEY_TYPE_IDENTITY, KEY_ID_COUNTER) + .map_err(|e| LeafError::Storage(e.to_string()))? + .and_then(|raw| <[u8; 8]>::try_from(raw.as_slice()).ok()) + .map(u64::from_be_bytes) + .unwrap_or(0); + + let next = current.saturating_add(1); + store + .store(KEY_TYPE_IDENTITY, KEY_ID_COUNTER, &next.to_be_bytes()) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(next) +} + +/// Builds a message id that no other device mints. +/// +/// The first eight bytes come from the device's own address, which is a hash +/// of its identity key, and the last eight are the send counter. Two devices +/// collide only if their addresses collide, which is the same margin every +/// other identity claim in this protocol rests on. +fn message_id(address: &Address, counter: u64) -> MessageId { + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&address.hash_bytes()[..8]); + bytes[8..].copy_from_slice(&counter.to_be_bytes()); + MessageId::from_bytes(bytes) +} + +/// Mints an outbound message with no clock and no entropy. +pub(crate) fn build( + store: &Arc, + identity: &Identity, + app_id: &AppId, + recipient: &str, + content: String, + now_unix_secs: u64, + priority: MessagePriority, +) -> Result { + let counter = next_counter(store)?; + let sender = UserId::new(identity.address.to_string()) + .map_err(|e| LeafError::MalformedFrame(format!("own address is not a user id: {e}")))?; + let recipient = UserId::new(recipient) + .map_err(|e| LeafError::MalformedFrame(format!("recipient is not a user id: {e}")))?; + + let mut message = Message::from_parts( + message_id(&identity.address, counter), + sender, + recipient, + app_id.clone(), + content, + Timestamp::from_millis(millis(now_unix_secs)), + ); + message.priority = priority; + message.lamport_clock = LamportClock::from_value(counter); + Ok(message) +} + +/// Seconds to milliseconds, saturating rather than wrapping. +/// +/// A device whose time source hands back something absurd gets a clamped +/// timestamp rather than a negative one. The timestamp is display metadata +/// and is not a security input, so clamping loses nothing that matters. +fn millis(now_unix_secs: u64) -> i64 { + now_unix_secs + .saturating_mul(1000) + .try_into() + .unwrap_or(i64::MAX) +} + +/// Stamps a control frame with a signature and the key that made it. +/// +/// The canonical payload comes from the sealed layer, which is the same +/// function the phone's producer and verifier both call, so there is no +/// second construction here to get subtly wrong. Metadata is deliberately +/// outside the signature because relays rewrite it, which is also why the +/// order of these two inserts does not matter. +pub(crate) fn sign_control_frame(identity: &Identity, message: &mut Message) -> Result<()> { + let payload = control_signing_payload(message)?; + let signature = identity::sign(identity, &payload)?; + message + .metadata + .insert(CTRL_SIG_META_KEY.to_string(), BASE64.encode(&signature)); + message.metadata.insert( + CTRL_PK_META_KEY.to_string(), + BASE64.encode(identity.public.as_bytes()), + ); + Ok(()) +} + +/// Verifies a control frame and returns the key that signed it. +/// +/// Unsigned is a refusal, not a downgrade. Every control frame in this +/// protocol is signed, so one that is not is either an implementation that +/// skipped the step or an injection, and there is no third reading that would +/// make accepting it safe. +pub(crate) fn verify_control_frame(message: &Message) -> Result> { + let signature = message.metadata.get(CTRL_SIG_META_KEY); + let public_key = message.metadata.get(CTRL_PK_META_KEY); + + let (signature, public_key) = + match (signature, public_key) { + (Some(s), Some(p)) => (s, p), + (None, None) => { + return Err(LeafError::ControlFrameRefused(String::from( + "control frame carries no signature", + ))) + } + // Half the pair is worse than neither: it is a frame that was shaped + // to look signed to something that checks only for presence. + _ => return Err(LeafError::ControlFrameRefused(String::from( + "control frame carries a signature without its key, or a key without its signature", + ))), + }; + + let signature = BASE64 + .decode(signature) + .map_err(|e| LeafError::ControlFrameRefused(format!("signature is not base64: {e}")))?; + let public_key = BASE64 + .decode(public_key) + .map_err(|e| LeafError::ControlFrameRefused(format!("public key is not base64: {e}")))?; + + verify_sender_derivation(message.sender.as_str(), &public_key)?; + + let payload = control_signing_payload(message)?; + identity::verify(&public_key, &signature, &payload)?; + Ok(public_key) +} + +/// Requires the presented key to derive to the address the frame claims. +/// +/// # Why an unparseable sender is an error rather than a skip +/// +/// A sender that is not an address has no derivation to check, and the +/// tempting answer, pass because there is nothing to compare, hands over the +/// whole gate: an attacker claims a nickname and the check that distinguishes +/// them from its owner never runs. Refusing outright is what makes this +/// unconditional in the sense that matters, which is that there is no input +/// for which it declines to run. +pub(crate) fn verify_sender_derivation(sender: &str, public_key: &[u8]) -> Result<()> { + let claimed = sender.parse::
().map_err(|e| { + LeafError::IdentityBinding(format!("sender '{sender}' is not an address: {e}")) + })?; + let derived = derive_address(public_key)?; + if derived != claimed { + return Err(LeafError::IdentityBinding(format!( + "sender address mismatch: '{claimed}' claimed, key derives to '{derived}'" + ))); + } + Ok(()) +} + +/// Splits a reserved prefix off a frame's content. +/// +/// Returns the body, or `None` when the content does not carry this prefix. +pub(crate) fn strip_prefix<'a>(content: &'a str, prefix: &str) -> Option<&'a str> { + content.strip_prefix(prefix) +} diff --git a/crates/offline-protocol-leaf/src/identity.rs b/crates/offline-protocol-leaf/src/identity.rs new file mode 100644 index 00000000..396a7afd --- /dev/null +++ b/crates/offline-protocol-leaf/src/identity.rs @@ -0,0 +1,188 @@ +//! The device's long-term identity, and the MLS client built on it. +//! +//! A leaf node holds one Ed25519 keypair, generated once at provisioning. It +//! signs control frames, it derives the device's address, and it is the +//! signature key inside the device's MLS credential, which is the same three +//! jobs one key does on a phone. +//! +//! # One device, one key +//! +//! A fleet provisioned with a shared identity key is one identity on many +//! devices. Extracting it from a single unit in a laboratory then yields every +//! unit's identity, and the address that names one lock names all of them. +//! This crate cannot prevent a manufacturer from doing that, so it is written +//! down here and in the +//! [threat model](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/security/threat-model.md) +//! as R12. +//! +//! # Where the randomness comes from +//! +//! [`LeafDevice::provision`](crate::LeafDevice::provision) draws from the +//! `getrandom` backend the firmware registers, and the key is exactly as +//! strong as what that returns. This crate deliberately registers no backend +//! of its own: doing so would let a device link and run with entropy this +//! crate invented, which is the one failure that leaves no trace anywhere. + +use alloc::{format, string::ToString, sync::Arc, vec, vec::Vec}; +use mls_rs::client_builder::MlsConfig; +use mls_rs::identity::basic::{BasicCredential, BasicIdentityProvider}; +use mls_rs::identity::SigningIdentity; +use mls_rs::{CipherSuite, CipherSuiteProvider, Client, CryptoProvider}; +use mls_rs_core::crypto::{SignaturePublicKey, SignatureSecretKey}; +use mls_rs_crypto_rustcrypto::RustCryptoProvider; +use offline_protocol_core::Address; +use offline_protocol_sealed::{derive_address, LEAF_KEY_PACKAGE_LIFETIME}; + +use crate::adapters::{GroupStateAdapter, KeyPackageAdapter}; +use crate::error::{LeafError, Result}; +use crate::store::{LeafStore, KEY_TYPE_IDENTITY}; + +/// The one ciphersuite this protocol uses, and never negotiates. +/// +/// `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`, ciphersuite 3. The phone +/// pins the same suite in one place under its own MLS implementation's +/// spelling, and the two names have to mean the same number. +pub const CIPHERSUITE: CipherSuite = CipherSuite::CURVE25519_AES128; + +const KEY_ID_SECRET: &str = "signature_secret"; +const KEY_ID_PUBLIC: &str = "signature_public"; + +/// A provisioned device's signing identity. +#[derive(Clone)] +pub(crate) struct Identity { + pub(crate) secret: SignatureSecretKey, + pub(crate) public: SignaturePublicKey, + pub(crate) address: Address, +} + +impl Identity { + /// Generates a fresh identity and writes it before returning it. + /// + /// The write comes first for the same reason every other write in this + /// crate does: a device that hands out an address it did not persist comes + /// back after a power cut as a different device, and the peer that paired + /// with the first one has no way to learn that. + pub(crate) fn provision(store: &Arc) -> Result { + if store + .load(KEY_TYPE_IDENTITY, KEY_ID_SECRET) + .map_err(|e| LeafError::Storage(e.to_string()))? + .is_some() + { + return Err(LeafError::AlreadyProvisioned); + } + + let suite = suite_provider()?; + let (secret, public) = suite + .signature_key_generate() + .map_err(|e| LeafError::Crypto(format!("cannot generate a signature key: {e:?}")))?; + + store + .store(KEY_TYPE_IDENTITY, KEY_ID_SECRET, secret.as_bytes()) + .map_err(|e| LeafError::Storage(e.to_string()))?; + store + .store(KEY_TYPE_IDENTITY, KEY_ID_PUBLIC, public.as_bytes()) + .map_err(|e| LeafError::Storage(e.to_string()))?; + + let address = derive_address(public.as_bytes())?; + Ok(Self { + secret, + public, + address, + }) + } + + /// Loads a previously provisioned identity. + pub(crate) fn resume(store: &Arc) -> Result { + let secret = store + .load(KEY_TYPE_IDENTITY, KEY_ID_SECRET) + .map_err(|e| LeafError::Storage(e.to_string()))? + .ok_or(LeafError::NotProvisioned)?; + let public = store + .load(KEY_TYPE_IDENTITY, KEY_ID_PUBLIC) + .map_err(|e| LeafError::Storage(e.to_string()))? + .ok_or(LeafError::NotProvisioned)?; + + let public = SignaturePublicKey::from(public); + let address = derive_address(public.as_bytes())?; + Ok(Self { + secret: SignatureSecretKey::from(secret), + public, + address, + }) + } + + /// The credential this device presents inside MLS. + /// + /// A basic credential whose whole content is the device's address in + /// canonical text. RFC 9420 calls a basic credential "a bare assertion of + /// an identity", which is exactly what it is: the assertion means + /// something only because the address is the hash of the signature key + /// beside it, so a verifier re-derives rather than trusts. + pub(crate) fn signing_identity(&self) -> SigningIdentity { + let credential = BasicCredential::new(self.address.to_string().into_bytes()); + SigningIdentity::new(credential.into_credential(), self.public.clone()) + } +} + +/// The crypto provider, restricted to the one suite. +/// +/// `with_enabled_cipher_suites` is a runtime filter rather than a compile-time +/// one: the provider keeps all four curves in one enum, so P-384 and P-256 +/// arithmetic link into the image whatever this says. Restricting it is still +/// right, because it makes a request for another suite fail here rather than +/// succeed with a suite the peer does not speak. +pub(crate) fn crypto_provider() -> RustCryptoProvider { + RustCryptoProvider::with_enabled_cipher_suites(vec![CIPHERSUITE]) +} + +/// The suite provider, for the raw sign and verify a control frame needs. +pub(crate) fn suite_provider() -> Result<::CipherSuiteProvider> +{ + crypto_provider() + .cipher_suite_provider(CIPHERSUITE) + .ok_or_else(|| { + LeafError::Crypto(alloc::string::String::from( + "the crypto provider does not support the protocol's ciphersuite", + )) + }) +} + +/// Builds the MLS client for this device. +/// +/// Storage is the device's own, through the adapters, so the client reads and +/// writes the same blobs across a power cycle. Nothing about the group lives +/// in this value: it is rebuilt per operation and the group is loaded from +/// storage, which is what makes storage the source of truth rather than a +/// cache of something held in RAM. +pub(crate) fn build_client( + identity: &Identity, + store: &Arc, +) -> Result> { + Ok(Client::builder() + .identity_provider(BasicIdentityProvider) + .crypto_provider(crypto_provider()) + .group_state_storage(GroupStateAdapter::new(Arc::clone(store))) + .key_package_repo(KeyPackageAdapter::new(Arc::clone(store))) + .signing_identity( + identity.signing_identity(), + identity.secret.clone(), + CIPHERSUITE, + ) + .key_package_lifetime(LEAF_KEY_PACKAGE_LIFETIME) + .build()) +} + +/// Signs `payload` with the device's identity key. +pub(crate) fn sign(identity: &Identity, payload: &[u8]) -> Result> { + suite_provider()? + .sign(&identity.secret, payload) + .map_err(|e| LeafError::Crypto(format!("cannot sign: {e:?}"))) +} + +/// Verifies `signature` over `payload` under `public_key`. +pub(crate) fn verify(public_key: &[u8], signature: &[u8], payload: &[u8]) -> Result<()> { + let key = SignaturePublicKey::from(public_key.to_vec()); + suite_provider()? + .verify(&key, signature, payload) + .map_err(|e| LeafError::ControlFrameRefused(format!("signature does not verify: {e:?}"))) +} diff --git a/crates/offline-protocol-leaf/src/keypkg.rs b/crates/offline-protocol-leaf/src/keypkg.rs new file mode 100644 index 00000000..cdfc6918 --- /dev/null +++ b/crates/offline-protocol-leaf/src/keypkg.rs @@ -0,0 +1,99 @@ +//! Minting the key package a device advertises itself with. +//! +//! Two properties here are load bearing, and each is a library default that +//! produces a package the peer refuses. Both were found by running the two MLS +//! implementations against each other rather than by reading either one's +//! documentation, which is why `tools/mls-interop` restores each in turn and +//! requires the refusal. +//! +//! **`not_before` is backdated.** The peer tests `not_before < now`, strictly, +//! while mls-rs writes `not_before` as exactly the timestamp it is handed. A +//! package stamped with the current second is refused for being not yet valid. +//! The backdate is also the margin that absorbs clock skew between the two +//! devices, which is the form this failure actually takes in the field. +//! +//! **The timestamp is supplied, never read.** This is the one with +//! consequences past the call site. mls-rs stamps `not_before = 0` when it +//! cannot read a clock, so a bare-metal device that lets it try emits a +//! validity window in 1970 and is refused as expired: a device shipping that +//! way never pairs at all. Hence `now_unix_secs` on every entry point here, +//! and hence a leaf node needs a time source at pairing, from its radio stack, +//! its commissioner, or the pairing exchange. +//! +//! Key package validity is a **freshness bound, not an authentication +//! mechanism**. A wrong clock costs availability rather than confidentiality. + +use alloc::{format, string::ToString, vec::Vec}; +use mls_rs::client_builder::MlsConfig; +use mls_rs::time::MlsTime; +use mls_rs::Client; +use mls_rs_core::mls_rs_codec::MlsEncode; +use offline_protocol_core::WIRE_VERSION_V1; +use offline_protocol_sealed::{ + KeyPackagePayload, LEAF_KEY_PACKAGE_LIFETIME, LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS, + MLS_ENVELOPE_COMPACT_V1, +}; + +use crate::error::{LeafError, Result}; + +/// Mints a key package and returns its **bare** encoding. +/// +/// mls-rs's convenience API returns a key package wrapped in an MLS message, +/// and this protocol puts the bare key package on the wire. Both forms are +/// legal MLS and only one of them is what the peer's parser accepts, so the +/// wrapper is removed here rather than left for a caller to notice. +pub(crate) fn mint(client: &Client, now_unix_secs: u64) -> Result> { + let not_before = now_unix_secs.saturating_sub(LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS); + + client + .generate_key_package_message( + Default::default(), + Default::default(), + Some(MlsTime::from(not_before)), + ) + .map_err(|e| LeafError::Mls(format!("cannot generate a key package: {e:?}")))? + .into_key_package() + .ok_or_else(|| { + LeafError::Mls(alloc::string::String::from( + "generated message is not a key package", + )) + })? + .mls_encode_to_vec() + .map_err(|e| LeafError::Mls(format!("cannot encode the key package: {e:?}"))) +} + +/// Builds the advertisement body that carries the key package. +/// +/// # What a leaf advertises +/// +/// The compact envelope and the binary hop encoding, because both are pure +/// parsing work that saves radio time on a link that has very little. Nothing +/// else: `rich_versions` and `data_versions` stay empty, so a peer sends plain +/// text and no document sync frames, which by the protocol's own rule is a +/// downgrade to the floor rather than an error. A device that advertised a +/// capability it does not implement would be sent frames it renders as +/// literal text. +pub(crate) fn payload( + user_id: &str, + key_package_data: Vec, + session_reset: bool, +) -> KeyPackagePayload { + KeyPackagePayload { + user_id: user_id.to_string(), + key_package_data, + // Relative rather than absolute, so the receiver applies it to their + // own clock and skew between the two devices cannot expire a package + // that is perfectly valid. + remaining_lifetime_ms: LEAF_KEY_PACKAGE_LIFETIME + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + timestamp_ms: 0, + session_reset, + wire_versions: alloc::vec![WIRE_VERSION_V1], + env_versions: alloc::vec![MLS_ENVELOPE_COMPACT_V1], + rich_versions: Vec::new(), + data_versions: Vec::new(), + nostr_pubkey: None, + } +} diff --git a/crates/offline-protocol-leaf/src/lib.rs b/crates/offline-protocol-leaf/src/lib.rs new file mode 100644 index 00000000..ff1a6516 --- /dev/null +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -0,0 +1,189 @@ +//! A constrained device that speaks the Offline Protocol as a real peer. +//! +//! A leaf node is a door lock, a sensor, or a mains-powered relay: something +//! with a radio, a few hundred kilobytes of flash and no operating system. It +//! parses frames, validates addressing, runs RFC 9420 MLS, and holds an +//! end-to-end encrypted conversation with a phone **under the same guarantees +//! a phone gets**. Same frames, same envelope, same trust gates, no second +//! sealing path and no reduced properties. +//! +//! That is the decision in +//! [ADR 0021](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0021-a-leaf-node-speaks-mls.md), +//! and it is affordable because a phone paired with one device is a +//! **two-member group**: the ratchet tree is three nodes, per-commit cost is +//! two elliptic-curve operations, and per-message cost is symmetric only. What +//! both ends must agree on lives in +//! [`offline_protocol_sealed`], so the two MLS implementations never disagree +//! about a byte outside themselves. +//! +//! # What this crate is +//! +//! [`LeafDevice`] is a frame-level state machine, not a bag of primitives. It +//! takes an inbound [`Message`](offline_protocol_core::Message) and hands back +//! the frames to send and what happened. The choreography it implements is +//! security-critical and easy to get subtly wrong: the derive-and-compare gate +//! at every site that accepts an identity claim, the confirmation that has to +//! be a group-aware decrypt, and the reset sequence that a driven rekey +//! arrives as. +//! +//! ``` +//! use offline_protocol_leaf::{LeafDevice, LeafStore, MemoryStore}; +//! use std::sync::Arc; +//! +//! # fn main() -> Result<(), Box> { +//! // A real device implements `LeafStore` over its secure key storage. +//! let store: Arc = Arc::new(MemoryStore::new()); +//! let device = LeafDevice::open(store, "com.example.lock")?; +//! +//! // `now` comes from the radio stack, the commissioner, or the pairing +//! // exchange. It is a parameter because a device has no clock, and an MLS +//! // implementation that reads one it does not have stamps 1970. +//! let now = 1_787_314_332; +//! let peer = device.address().to_string(); +//! let advertisement = device.key_package_frame(&peer, now)?; +//! assert!(advertisement.content.starts_with("__MLS_KEY_PKG__")); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Three obligations this crate cannot discharge for you +//! +//! **A time source at pairing.** Every entry point that needs a clock takes +//! `now_unix_secs`. A device that supplies something wrong emits a key package +//! the peer refuses as expired, and it never pairs at all. Validity is a +//! freshness bound rather than an authentication mechanism, so a wrong clock +//! costs availability, not confidentiality. +//! +//! **Real entropy.** This crate registers no `getrandom` backend, on purpose: +//! doing so would let firmware link and run with randomness this crate +//! invented, and MLS key generation is exactly as strong as what that symbol +//! returns. Wire it to the part's hardware entropy source. +//! +//! **Durable storage.** [`LeafStore`] must be durable and atomic per entry. +//! This crate orders every persist before the emit it belongs to, so a store +//! that lies is the one remaining way to reuse an AEAD nonce after a power +//! cut. +//! +//! # Bare metal +//! +//! Builds with `--no-default-features` for a target with no `std`, which is +//! the configuration the CI job gates. The `std` build is the same code with +//! its dependencies' `std` features on, and is what the unit tests run under. + +#![cfg_attr(not(feature = "std"), no_std)] +#![deny(unsafe_code)] +#![warn(missing_docs)] + +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +mod adapters; +mod device; +mod error; +mod frames; +mod identity; +mod keypkg; +pub mod store; + +pub use device::{Handled, LeafDevice, LeafEvent}; +pub use error::{LeafError, Result}; +pub use identity::CIPHERSUITE; +pub use store::{LeafStore, StoreError}; + +#[cfg(any(test, feature = "std"))] +pub use store::MemoryStore; + +#[cfg(all(test, feature = "std"))] +mod manifest_guard_tests { + use std::string::{String, ToString}; + use std::vec::Vec; + use std::{eprintln, format, fs, path::PathBuf}; + + /// Dependencies this crate declares locally rather than inheriting. + /// + /// Inheriting them would silently drop `default-features = false` (the + /// trap [ADR 0020](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0020-core-compiles-without-std.md) + /// records), so they are spelled out here. This test is the counterweight: + /// dropping inheritance means a workspace-wide version bump would + /// otherwise stop applying to this crate with nothing to notice. + const LOCAL_DEPS: &[&str] = &[ + "serde", + "serde_json", + "base64", + "thiserror", + "zeroize", + "getrandom", + "mls-rs", + "mls-rs-crypto-rustcrypto", + "mls-rs-core", + "offline-protocol-core", + "offline-protocol-sealed", + ]; + + fn version_req(manifest: &str, name: &str) -> Option { + for line in manifest.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix(name) else { + continue; + }; + let Some(rest) = rest.trim_start().strip_prefix('=') else { + continue; + }; + let rest = rest.trim(); + if let Some(inner) = rest.strip_prefix('{') { + let idx = inner.find("version")?; + let after = &inner[idx..]; + let start = after.find('"')? + 1; + let end = after[start..].find('"')? + start; + return Some(after[start..end].to_string()); + } + if let Some(inner) = rest.strip_prefix('"') { + let end = inner.find('"')?; + return Some(inner[..end].to_string()); + } + } + None + } + + #[test] + fn local_dep_versions_match_the_workspace_table() { + let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = here.join("../../Cargo.toml"); + // A packaged `.crate` archive has no workspace root beside it, and a + // release must not fail for its absence. + let (Ok(root), Ok(local)) = ( + fs::read_to_string(&root), + fs::read_to_string(here.join("Cargo.toml")), + ) else { + eprintln!("skipping: workspace root not readable from here"); + return; + }; + + let mismatched: Vec = LOCAL_DEPS + .iter() + .filter_map(|name| { + let ours = version_req(&local, name)?; + let theirs = version_req(&root, name)?; + (ours != theirs).then(|| format!("{name}: local {ours}, workspace {theirs}")) + }) + .collect(); + + assert!( + mismatched.is_empty(), + "local dependency versions have drifted from the workspace table: {mismatched:?}" + ); + + for name in LOCAL_DEPS { + assert!( + version_req(&local, name).is_some(), + "{name} is in LOCAL_DEPS but not declared in this crate's manifest" + ); + assert!( + version_req(&root, name).is_some(), + "{name} is in LOCAL_DEPS but not in the workspace dependency table" + ); + } + } +} diff --git a/crates/offline-protocol-leaf/src/store.rs b/crates/offline-protocol-leaf/src/store.rs new file mode 100644 index 00000000..ba1fac50 --- /dev/null +++ b/crates/offline-protocol-leaf/src/store.rs @@ -0,0 +1,163 @@ +//! The storage seam, and the one rule that makes it safe. +//! +//! # Persist before emit +//! +//! A leaf node MUST have its MLS state durable before it emits a frame whose +//! production advanced that state. The failure this prevents is not a delivery +//! hiccup: a device that answers and then loses power before its ratchet state +//! reaches flash comes back and **reuses an AEAD nonce**, which is a +//! confidentiality failure in a protocol whose whole claim is the AEAD +//! boundary. +//! +//! This crate does not ask firmware to remember that. Every operation that +//! advances state writes through this trait and only then returns the bytes to +//! send, so a store that returns an error produces no frame at all. The +//! ordering is not documented here and implemented elsewhere; it is the reason +//! [`LeafDevice`](crate::LeafDevice) hands back frames rather than exposing +//! the MLS group it seals with. +//! +//! # What an implementation owes +//! +//! [`LeafStore::store`] must be **durable and atomic per entry**: after it +//! returns `Ok`, a power cut must leave the new value readable, and it must +//! never leave a torn one. mls-rs asks the same of its own storage provider, +//! in the same words, for the same reason. A flash driver that buffers a write +//! and reports success satisfies the type and breaks the rule. +//! +//! # Where the key material should live +//! +//! Everything written through this trait is secret: the identity private key, +//! MLS group state, key package private keys. On a part with secure key +//! storage this trait is how that storage is reached, which is +//! [R12](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/security/threat-model.md) +//! in the threat model. A device that keeps them in general flash yields them +//! to anyone holding the device. + +use alloc::{string::String, vec::Vec}; +use thiserror::Error; + +/// Key type for the device's own identity material. +pub const KEY_TYPE_IDENTITY: &str = "identity"; +/// Key type for MLS group state. +pub const KEY_TYPE_GROUP_STATE: &str = "group_state"; +/// Key type for MLS prior-epoch records. +pub const KEY_TYPE_GROUP_EPOCH: &str = "group_epoch"; +/// Key type for key package private material. +pub const KEY_TYPE_KEY_PACKAGE: &str = "key_package"; +/// Key type for what a peer told us it can parse. +pub const KEY_TYPE_PEER: &str = "peer"; + +/// What a store can fail with. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum StoreError { + /// The write did not happen, or cannot be proven to have happened. + #[error("store failed: {0}")] + Store(String), + /// The read failed. + #[error("load failed: {0}")] + Load(String), + /// The delete failed. + #[error("delete failed: {0}")] + Delete(String), + /// The stored bytes are not what this crate wrote. + #[error("corrupt record: {0}")] + Corrupt(String), +} + +/// Durable storage for a leaf node's secret material and MLS state. +/// +/// The shape mirrors `MlsStorage` in `offline-protocol-mls`, which is the same +/// seam on the phone: a two-part `(key_type, key_id)` key, `&self` methods so +/// one store can be shared by the several places that write through it, and +/// per-entry atomicity. A device implements it over the part's secure key +/// storage, its flash filesystem, or an EEPROM. +/// +/// # Errors +/// +/// Returning an error is always safe. It aborts the operation before anything +/// is emitted, which is the whole point of the seam. +pub trait LeafStore: Send + Sync { + /// Writes `data`, replacing any previous value for this key. + /// + /// Must be durable and atomic per entry: after `Ok`, a power cut leaves + /// either the new value or the old one, never a torn record, and a + /// subsequent [`LeafStore::load`] returns the new value. + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError>; + + /// Reads a value, or `None` if this key was never written. + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError>; + + /// Removes a value. Removing a key that is not there is not an error. + /// + /// Every value this crate stores is secret, so an implementation should + /// erase rather than unlink. + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError>; +} + +#[cfg(any(test, feature = "std"))] +mod memory { + use super::*; + use alloc::collections::BTreeMap; + use alloc::string::ToString; + use std::sync::Mutex; + + /// A store that keeps everything in memory. + /// + /// For tests and for bringing a board up before its flash driver works. + /// **It is not a leaf node's storage**: it satisfies the durability + /// contract only in the sense that there is nothing to lose power. + #[derive(Debug, Default)] + pub struct MemoryStore { + entries: Mutex>>, + } + + impl MemoryStore { + /// Creates an empty store. + pub fn new() -> Self { + Self::default() + } + + /// Number of entries held, for tests that assert what was written. + pub fn len(&self) -> usize { + self.entries.lock().map(|e| e.len()).unwrap_or(0) + } + + /// Whether the store holds nothing. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + } + + impl LeafStore for MemoryStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + let mut entries = self + .entries + .lock() + .map_err(|e| StoreError::Store(e.to_string()))?; + entries.insert((key_type.to_string(), key_id.to_string()), data.to_vec()); + Ok(()) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + let entries = self + .entries + .lock() + .map_err(|e| StoreError::Load(e.to_string()))?; + Ok(entries + .get(&(key_type.to_string(), key_id.to_string())) + .cloned()) + } + + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError> { + let mut entries = self + .entries + .lock() + .map_err(|e| StoreError::Delete(e.to_string()))?; + entries.remove(&(key_type.to_string(), key_id.to_string())); + Ok(()) + } + } +} + +#[cfg(any(test, feature = "std"))] +pub use memory::MemoryStore; diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs new file mode 100644 index 00000000..215e7df2 --- /dev/null +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -0,0 +1,759 @@ +//! A real phone talking to a real device. +//! +//! The phone here is `offline-protocol-mls`, built on OpenMLS, driven through +//! its ordinary public API. The device is this crate, built on mls-rs. So +//! every test below is a genuine two-implementation exchange rather than this +//! crate agreeing with itself, which is the only kind of test that can catch +//! the class of bug that matters here: a default in one library that the other +//! refuses. +//! +//! `tools/mls-interop` covers the same pair out of process and pins the +//! library versions. This file covers the choreography that sits above them: +//! the gates, the reset sequence, and the persist-before-emit rule. + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; + +use offline_protocol_core::{Message, MessagePriority, UserId}; +use offline_protocol_leaf::{LeafDevice, LeafError, LeafEvent, LeafStore, MemoryStore, StoreError}; +use offline_protocol_mls::{storage::InMemoryStorage, MlsManager, MlsStorage}; +use offline_protocol_sealed::{ + control_signing_payload, derive_address, prefixes, EncryptedMessage, KeyPackagePayload, + MLS_ENVELOPE_COMPACT_V1, +}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + +const APP_ID: &str = "com.example.lock"; +const NOW: u64 = 1_787_314_332; + +/// A phone, constructed the way production does it: the identity is minted +/// first and the manager is then built at the address it derives to. +/// +/// The bootstrap pass exists because a manager's address is a function of a +/// key it mints on first construction, so there is nothing to name it with +/// until it exists. The second construction loads the same identity out of the +/// same storage. +struct Phone { + manager: MlsManager, + address: String, +} + +fn new_phone() -> Phone { + let storage: Arc = Arc::new(InMemoryStorage::new()); + let bootstrap = MlsManager::new("bootstrap", Arc::clone(&storage)).expect("bootstrap manager"); + let public = bootstrap + .get_identity_public_key() + .expect("identity public key"); + let address = derive_address(&public).expect("derive").to_string(); + drop(bootstrap); + + let manager = MlsManager::new(address.clone(), storage).expect("addressed manager"); + Phone { manager, address } +} + +fn device(store: Arc) -> LeafDevice { + LeafDevice::open(store, APP_ID).expect("device opens") +} + +/// Builds a control frame the way the phone's engine does, and signs it with +/// the phone's identity. +/// +/// The canonical payload comes from the sealed layer, which is the one +/// construction both ends use, so this is the phone's own signature rather +/// than a second implementation of one. +fn phone_control_frame(phone: &Phone, to: &str, content: String) -> Message { + let mut message = Message::new( + UserId::new(&phone.address).expect("phone address is a user id"), + UserId::new(to).expect("device address is a user id"), + offline_protocol_core::AppId::new(APP_ID).expect("app id"), + content, + ); + message.priority = MessagePriority::High; + sign_as(phone, &mut message); + message +} + +fn sign_as(phone: &Phone, message: &mut Message) { + let payload = control_signing_payload(message).expect("canonical payload"); + let signature = phone.manager.sign_data(&payload).expect("phone signs"); + let public = phone + .manager + .get_identity_public_key() + .expect("phone public key"); + message.metadata.insert( + offline_protocol_sealed::CTRL_SIG_META_KEY.to_string(), + BASE64.encode(&signature), + ); + message.metadata.insert( + offline_protocol_sealed::CTRL_PK_META_KEY.to_string(), + BASE64.encode(&public), + ); +} + +/// Reads a device's key package frame the way the phone's dispatch does. +fn import_device_key_package(phone: &Phone, frame: &Message) { + let body = frame + .content + .strip_prefix(prefixes::KEY_PACKAGE) + .expect("frame carries a key package"); + let payload: KeyPackagePayload = serde_json::from_str(body).expect("key package body parses"); + phone + .manager + .import_key_package(&payload.user_id, &payload.key_package_data) + .expect("the phone accepts the device's key package"); +} + +/// Pairs a phone and a device, returning the device's address. +/// +/// This is the whole choreography in one place: the device advertises, the +/// phone establishes and sends a Welcome, the device joins and confirms. +fn pair(phone: &Phone, device: &LeafDevice) -> String { + let device_address = device.address().to_string(); + + let advertisement = device + .key_package_frame(&phone.address, NOW) + .expect("device mints a key package"); + import_device_key_package(phone, &advertisement); + + let welcome = phone + .manager + .create_session(&device_address) + .expect("phone creates the session"); + let welcome_frame = phone_control_frame( + phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome serializes") + ), + ); + + let handled = device.handle(&welcome_frame, NOW).expect("device joins"); + assert!( + handled.events.contains(&LeafEvent::SessionEstablished { + peer: phone.address.clone(), + }), + "joining a welcome did not establish a session: {:?}", + handled.events + ); + + // The confirmation is a group-aware decrypt, so the phone must be able to + // open it. That is the whole reason it is sealed rather than sent as a + // plaintext acknowledgement. + assert_eq!(handled.outbound.len(), 1, "expected one confirmation frame"); + let confirm = envelope_of(&handled.outbound[0]); + let opened = phone + .manager + .decrypt_from_user(&confirm, &device_address) + .expect("phone opens the confirmation"); + assert_eq!( + opened.as_deref(), + Some(prefixes::SESSION_CONFIRM_ENCRYPTED.as_bytes()), + "the confirmation did not carry the encrypted-confirm marker" + ); + + device_address +} + +/// Pulls the envelope out of a device's `__MLS_ENC__` frame. +fn envelope_of(message: &Message) -> EncryptedMessage { + let body = message + .content + .strip_prefix(prefixes::ENCRYPTED) + .expect("frame carries an envelope"); + if body.starts_with('{') { + serde_json::from_str(body).expect("json envelope parses") + } else { + let bytes = BASE64.decode(body).expect("envelope is base64"); + EncryptedMessage::from_bytes(&bytes).expect("compact envelope parses") + } +} + +/// Wraps a device's sealed frame the way a transport would deliver it. +fn phone_sealed_frame(phone: &Phone, to: &str, envelope: &EncryptedMessage) -> Message { + let body = BASE64.encode(envelope.to_bytes()); + Message::new( + UserId::new(&phone.address).expect("phone address"), + UserId::new(to).expect("device address"), + offline_protocol_core::AppId::new(APP_ID).expect("app id"), + format!("{}{}", prefixes::ENCRYPTED, body), + ) +} + +#[test] +fn a_phone_and_a_device_pair_and_talk_both_ways() { + let phone = new_phone(); + let store = Arc::new(MemoryStore::new()); + let device = device(store); + let device_address = pair(&phone, &device); + + // Phone to device. + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); + + // Device to phone. + let answer = device + .seal(&phone.address, "unlocked", NOW) + .expect("device seals"); + let opened = phone + .manager + .decrypt_from_user(&envelope_of(&answer), &device_address) + .expect("phone opens the answer"); + assert_eq!(opened.as_deref(), Some(&b"unlocked"[..])); +} + +#[test] +fn a_driven_rekey_reaches_the_device_as_a_session_reset() { + let phone = new_phone(); + let store = Arc::new(MemoryStore::new()); + let device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &device); + + assert!( + device.has_session(&phone.address).expect("session check"), + "pairing left no session" + ); + + // A phone-driven rekey: the phone discards its own session first and then + // sends a fresh key package with the reset flag, which is the shape + // post-compromise security takes for a member that never commits. + // + // The teardown-first ordering is the engine's, and it is deliberate there: + // it makes convergence symmetric whatever the two addresses sort like, so + // the device always *joins* rather than racing a tiebreaker. + phone + .manager + .delete_session(&device_address) + .expect("phone discards its own session"); + + let phone_package = phone + .manager + .take_push_key_package(&device_address) + .expect("phone mints a package"); + let mut payload: KeyPackagePayload = serde_json::from_str(&format!( + r#"{{"user_id":{},"key_package_data":[]}}"#, + serde_json::to_string(&phone.address).expect("address") + )) + .expect("skeleton payload"); + payload.key_package_data = phone_package.bundle.key_package_data.clone(); + payload.session_reset = true; + payload.env_versions = vec![MLS_ENVELOPE_COMPACT_V1]; + + let reset_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload serializes") + ), + ); + + let handled = device.handle(&reset_frame, NOW).expect("device resets"); + assert!( + handled.events.contains(&LeafEvent::SessionReset { + peer: phone.address.clone(), + }), + "a reset flag did not reset the session: {:?}", + handled.events + ); + assert!( + !device.has_session(&phone.address).expect("session check"), + "the device kept a session the phone had already discarded" + ); + + // And it answers with a fresh package, so the exchange can begin again. + assert_eq!( + handled.outbound.len(), + 1, + "a reset did not produce a fresh key package" + ); + let fresh = &handled.outbound[0]; + assert!(fresh.content.starts_with(prefixes::KEY_PACKAGE)); + + // The phone can complete a second pairing from it, which is what makes the + // rekey a heal rather than a permanent break. + import_device_key_package(&phone, fresh); + let welcome = phone + .manager + .create_session(&device_address) + .expect("phone re-establishes"); + let welcome_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome serializes") + ), + ); + let rejoined = device.handle(&welcome_frame, NOW).expect("device rejoins"); + assert!(rejoined.events.contains(&LeafEvent::SessionEstablished { + peer: phone.address.clone(), + })); +} + +#[test] +fn an_unsigned_control_frame_is_refused() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + let mut frame = phone_control_frame( + &phone, + &device.address().to_string(), + format!("{}{{}}", prefixes::KEY_PACKAGE), + ); + frame.metadata.clear(); + + let err = device + .handle(&frame, NOW) + .expect_err("unsigned was accepted"); + assert!( + matches!(err, LeafError::ControlFrameRefused(_)), + "unsigned control frame produced {err:?}" + ); +} + +#[test] +fn half_a_signature_is_refused() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + let mut frame = phone_control_frame( + &phone, + &device.address().to_string(), + format!("{}{{}}", prefixes::KEY_PACKAGE), + ); + frame + .metadata + .remove(offline_protocol_sealed::CTRL_SIG_META_KEY); + + let err = device + .handle(&frame, NOW) + .expect_err("a key without a signature was accepted"); + assert!( + matches!(err, LeafError::ControlFrameRefused(_)), + "half a signature produced {err:?}" + ); +} + +#[test] +fn a_signing_key_that_does_not_derive_to_the_sender_is_refused() { + let phone = new_phone(); + let impostor = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + // A frame that claims to be from the phone, signed correctly, but by + // somebody else's key. The signature verifies; the derivation does not. + let mut frame = phone_control_frame( + &phone, + &device.address().to_string(), + format!("{}{{}}", prefixes::KEY_PACKAGE), + ); + sign_as(&impostor, &mut frame); + + let err = device + .handle(&frame, NOW) + .expect_err("an impostor's key was accepted"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "impersonation produced {err:?}" + ); +} + +#[test] +fn a_sender_that_is_not_an_address_is_refused() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + // The bypass this gate exists to close: claim a nickname, and a check that + // skipped unparseable identifiers would never run at all. + let mut frame = phone_control_frame( + &phone, + &device.address().to_string(), + format!("{}{{}}", prefixes::KEY_PACKAGE), + ); + frame.sender = UserId::new("alice").expect("nickname is a valid user id"); + sign_as(&phone, &mut frame); + + let err = device + .handle(&frame, NOW) + .expect_err("a nickname sender was accepted"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "a non-address sender produced {err:?}" + ); +} + +#[test] +fn a_key_package_that_claims_another_owner_is_refused() { + let phone = new_phone(); + let other = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + let package = phone + .manager + .take_push_key_package(&device.address().to_string()) + .expect("package"); + let payload = KeyPackagePayload { + // The body says one peer; the frame is signed by another. + user_id: other.address.clone(), + key_package_data: package.bundle.key_package_data, + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: false, + wire_versions: vec![], + env_versions: vec![], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + + let frame = phone_control_frame( + &phone, + &device.address().to_string(), + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ), + ); + + let err = device + .handle(&frame, NOW) + .expect_err("a borrowed-name package was accepted"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "a mismatched package owner produced {err:?}" + ); +} + +#[test] +fn a_welcome_for_another_pairs_group_is_refused() { + let phone = new_phone(); + let stranger = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + // A Welcome the stranger built for a group of their own, relayed by the + // phone. Without the group check the device would join a room whose + // membership it never chose. + let advertisement = device + .key_package_frame(&stranger.address, NOW) + .expect("device advertises"); + import_device_key_package(&stranger, &advertisement); + let welcome = stranger + .manager + .create_session(&device_address) + .expect("stranger creates a session"); + + let frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome") + ), + ); + + let err = device + .handle(&frame, NOW) + .expect_err("a relayed welcome was accepted"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "a foreign welcome produced {err:?}" + ); +} + +#[test] +fn state_survives_a_power_cycle() { + let phone = new_phone(); + let store: Arc = Arc::new(MemoryStore::new()); + let device_address = { + let device = device(Arc::clone(&store)); + pair(&phone, &device) + }; + + // The device value is gone; only the store remains. This is what a reboot + // looks like from the outside, and it is the whole reason no MLS state is + // cached in the device value. + let revived = LeafDevice::resume(Arc::clone(&store), APP_ID).expect("device resumes"); + assert_eq!( + revived.address().to_string(), + device_address, + "the device came back as a different device" + ); + + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"still there?") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = revived.handle(&frame, NOW).expect("revived device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "still there?".to_string(), + }], + "a session did not survive the power cycle" + ); +} + +#[test] +fn a_replayed_sealed_frame_is_refused() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &device); + + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + + device.handle(&frame, NOW).expect("first delivery opens"); + let err = device + .handle(&frame, NOW) + .expect_err("a replayed frame was opened a second time"); + assert!( + matches!(err, LeafError::Mls(_)), + "a replay produced {err:?}" + ); +} + +#[test] +fn the_device_answers_a_probe_with_an_ack() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + + let probe = phone_control_frame( + &phone, + &device.address().to_string(), + prefixes::SESSION_CONFIRM_PROBE.to_string(), + ); + let handled = device.handle(&probe, NOW).expect("device answers"); + + assert_eq!(handled.outbound.len(), 1); + assert_eq!( + handled.outbound[0].content, + prefixes::SESSION_CONFIRM_ACK, + "a probe was not answered with an acknowledgement" + ); + assert!( + handled.outbound[0] + .metadata + .contains_key(offline_protocol_sealed::CTRL_SIG_META_KEY), + "the acknowledgement went out unsigned" + ); +} + +#[test] +fn a_peer_that_already_has_a_key_package_is_not_sent_another() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + // The device advertises first, which is how pairing starts. + let _ = device + .key_package_frame(&phone.address, NOW) + .expect("device advertises"); + + // The phone answers with its own package, as the engine does. If the + // device answered that in turn, the two would trade packages forever, and + // each exchange spends an init key. + let package = phone + .manager + .take_push_key_package(&device_address) + .expect("package"); + let payload = KeyPackagePayload { + user_id: phone.address.clone(), + key_package_data: package.bundle.key_package_data, + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: false, + wire_versions: vec![], + env_versions: vec![MLS_ENVELOPE_COMPACT_V1], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + let frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ), + ); + + let handled = device.handle(&frame, NOW).expect("device records the peer"); + assert!( + handled.outbound.is_empty(), + "the device answered a key package with another one, which loops" + ); + assert!(handled.events.contains(&LeafEvent::PeerAdvertised { + peer: phone.address.clone(), + })); +} + +#[test] +fn the_envelope_encoding_follows_what_the_peer_advertised() { + let phone = new_phone(); + let device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &device); + + // Pairing recorded nothing about the phone's capabilities, because the + // phone never sent a key package in this flow. Absent means the floor. + assert!(device + .peer_env_versions(&phone.address) + .expect("record") + .is_empty()); + let floor = device + .seal(&phone.address, "floor", NOW) + .expect("device seals"); + let body = floor + .content + .strip_prefix(prefixes::ENCRYPTED) + .expect("envelope"); + assert!( + body.starts_with('{'), + "a peer that advertised nothing was sent a compact envelope" + ); + + // And the phone opens it, because the JSON envelope is the permanent floor + // rather than a legacy path anything may stop parsing. + let opened = phone + .manager + .decrypt_from_user(&envelope_of(&floor), &device_address) + .expect("phone opens the floor envelope"); + assert_eq!(opened.as_deref(), Some(&b"floor"[..])); +} + +/// A store that fails every write after it is armed. +/// +/// Used as a negative control for the rule this crate is built around: if a +/// persist fails, no frame may exist. A device that emitted first and +/// persisted second would come back from a power cut and reuse an AEAD nonce. +#[derive(Default)] +struct FailingStore { + inner: MemoryStore, + failing: AtomicBool, + writes_after_arming: Mutex, +} + +impl FailingStore { + fn arm(&self) { + self.failing.store(true, Ordering::SeqCst); + } +} + +impl LeafStore for FailingStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + if self.failing.load(Ordering::SeqCst) { + if let Ok(mut count) = self.writes_after_arming.lock() { + *count += 1; + } + return Err(StoreError::Store("flash is on fire".to_string())); + } + self.inner.store(key_type, key_id, data) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + self.inner.load(key_type, key_id) + } + + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError> { + self.inner.delete(key_type, key_id) + } +} + +#[test] +fn a_failing_store_produces_no_frame() { + let phone = new_phone(); + let store = Arc::new(FailingStore::default()); + let device = device(Arc::clone(&store) as Arc); + pair(&phone, &device); + + store.arm(); + + let err = device + .seal(&phone.address, "unlock", NOW) + .expect_err("a frame was produced despite the store failing"); + assert!( + matches!(err, LeafError::Storage(_)), + "a failing store produced {err:?}" + ); + + // The control: the write really was attempted, so this test is failing at + // the persist rather than short-circuiting somewhere earlier and passing + // for the wrong reason. + assert!( + *store.writes_after_arming.lock().expect("count") > 0, + "no write was even attempted, so this proves nothing about ordering" + ); +} + +#[test] +fn a_device_refuses_to_replace_its_own_identity() { + let store: Arc = Arc::new(MemoryStore::new()); + let first = LeafDevice::provision(Arc::clone(&store), APP_ID).expect("first provisioning"); + + let err = LeafDevice::provision(Arc::clone(&store), APP_ID) + .expect_err("a second provisioning replaced the identity"); + assert!(matches!(err, LeafError::AlreadyProvisioned)); + + // And `open` is the safe door: it resumes rather than replacing. + let reopened = LeafDevice::open(store, APP_ID).expect("open resumes"); + assert_eq!(reopened.address().to_string(), first.address().to_string()); +} + +#[test] +fn a_device_address_derives_from_its_own_key() { + // The property every trust gate in this protocol rests on, checked from + // the outside: what the device calls itself is a function of the key it + // signs with, so a peer can refute a claim without a directory. + let device = device(Arc::new(MemoryStore::new())); + let probe_target = device.address().to_string(); + + let phone = new_phone(); + let mut frame = phone_control_frame( + &phone, + &probe_target, + prefixes::SESSION_CONFIRM_PROBE.to_string(), + ); + sign_as(&phone, &mut frame); + + let handled = device.handle(&frame, NOW).expect("probe answered"); + let ack = &handled.outbound[0]; + let key = BASE64 + .decode( + ack.metadata + .get(offline_protocol_sealed::CTRL_PK_META_KEY) + .expect("ack carries a key"), + ) + .expect("key is base64"); + + assert_eq!( + derive_address(&key).expect("derive").to_string(), + device.address().to_string(), + "the device signed with a key that does not derive to its own address" + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 6838dad0..649ccdb0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,6 +117,32 @@ implementations and are not allowed to disagree about anything outside them. **Dependencies**: `offline-protocol-core`, `offline-protocol-sealed`, OpenMLS +### offline-protocol-leaf + +**Purpose**: A constrained device (a door lock, a sensor, a mains-powered +relay) speaking the protocol as a real peer rather than a reduced one. It runs +RFC 9420 MLS through mls-rs as a **never-committing member**: the phone creates +the group, adds the device and issues every commit, while the device joins, +opens what arrives, answers and persists. See +[ADR 0021](adr/0021-a-leaf-node-speaks-mls.md). + +**Key Components**: +- `LeafDevice` - a frame-level state machine: an inbound message in, the frames to send and what happened out +- `LeafStore` - one blob-storage seam a device implements over its secure key storage +- Key package minting with the backdated `not_before` and supplied timestamp a device needs in order to pair at all + +**Three obligations it cannot discharge for the integrator**: a time source at +pairing, real hardware entropy behind `getrandom`, and durable atomic storage. +Persist-before-emit is enforced structurally rather than documented: every +operation that advances the ratchet writes before it returns a frame, because a +state rolled back by a power cut reuses an AEAD nonce. + +**Safety**: `#![deny(unsafe_code)]` + +**Dependencies**: `offline-protocol-core`, `offline-protocol-sealed`, mls-rs. +Deliberately **not** the engine or the MLS crate: nothing above `sealed` builds +without `std`. + ### 7. offline-protocol-services **Purpose**: Standalone service discovery and request/response over the mesh. From c10655214b86eab1f24e6596ed96f4c72abc9807 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 21 Aug 2026 23:40:46 +0530 Subject: [PATCH 2/9] fix(leaf): what a device gets wrong when nobody can reflash it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the new leaf crate turned up a handful of things that are all the same shape: fine on a bench, and a problem on a door lock that has to run for years without anyone touching it. The probe answer was the worst. A leaf acknowledged a confirmation probe whether or not it still had a session, and a peer treats that acknowledgement as proof the session is usable: it confirms and flushes everything it had queued into it. A device that lost its store therefore confirmed a session it could not decrypt one frame of, and then went quiet, which from the peer's side is indistinguishable from a quiet link. The phone has always gated its own answer on holding a session. Now the device does too, and the test that asserted the old behaviour was asserting the bug. Prior-epoch records were kept forever. It turns out mls-rs leaves retention to the storage provider — its own in-memory one trims to three — and this provider trimmed to nothing at all. That is flash filling up on a part that has a few hundred kilobytes of it, and it is every epoch's secrets sitting in there while it happens, so "how far out of order a message may arrive" had quietly become "how far back a stolen device reads". Trim to a window on write, and sweep the lot on unpair, which until now deleted the group state and the marker and left the actual secrets behind. Under a name the next session answers to, no less, since a pair's group id is derived from the two addresses and does not change on a re-pair. Provisioning wrote the secret first and the public key second. A cut between them left `resume` refusing for the missing public key and `provision` refusing for the present secret, and `open` has no third door. One power cut on a device's very first boot and it answers every call with an error, forever. Writing the secret last makes it the completion marker, and makes the torn state one the next boot simply overwrites. Everything that advances state now takes `&mut self`. Two seals racing loaded the same generation and emitted both frames under one AEAD nonce, which is exactly the failure this crate's whole persist-before-emit rule exists to prevent, reached without anyone losing power at all. A compile error is cheaper than a paragraph asking people not to do that. While at it: a Welcome is checked against the group it actually joined rather than the one its body claimed, a reset frame is acted on once so a captured one is not a repeatable teardown, and peer records and unspent key packages are bounded, because producing a frame that derives to its own address costs an attacker nothing. A full peer table refuses a stranger rather than evicting somebody the owner actually paired with. That reset dedup bounds a repeat and does not close replay. Nothing in the signed payload says *when*, so a frame older than the ring can still be spent once. Closing it is a freshness field on the wire and a change to both ends, so it goes in the spec as an open gap rather than getting quietly papered over here. --- CHANGELOG.md | 18 +- crates/offline-protocol-leaf/src/adapters.rs | 172 +++++- crates/offline-protocol-leaf/src/device.rs | 278 ++++++++- crates/offline-protocol-leaf/src/error.rs | 10 + crates/offline-protocol-leaf/src/frames.rs | 13 + crates/offline-protocol-leaf/src/identity.rs | 24 +- crates/offline-protocol-leaf/src/lib.rs | 10 +- crates/offline-protocol-leaf/src/store.rs | 6 + .../tests/phone_interop.rs | 554 +++++++++++++++++- docs/spec/leaf-provisioning.md | 18 + 10 files changed, 1046 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49064728..063bcb35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,11 +154,25 @@ archived by series under [docs/changelog/](docs/changelog/); see the whose key **derives to the address the frame claims**, and an identifier that is not an address is the same refusal rather than a skip, because a claim with no derivation to check is the bypass. A Welcome must name the peer that - signed it and the group this pair would build. A sealed frame's MLS sender - must be the peer the frame came from. Sixteen tests cover this against a real + signed it, name the group this pair would build, and then actually join that + group rather than the one its body claimed. A sealed frame's MLS sender must + be the peer the frame came from. A confirmation probe is answered only by a + device that still holds a session, because a peer confirms on that answer and + flushes into it. A reset frame is acted on once, so a captured one is not a + repeatable session teardown. Twenty-three tests cover this against a real OpenMLS phone in the same process, which is the only kind of test that catches a default in one library the other refuses. + **What it keeps is bounded.** Prior-epoch records are trimmed to a window + rather than kept forever, which bounds both the flash they occupy and how far + back a stolen device reads; unpairing erases them along with the session, so + epoch secrets do not outlive the erasure an owner asked for. Peer records and + unspent key packages are bounded too, and a full peer table refuses a + stranger rather than evicting somebody the owner paired with, because + producing a frame that derives to its own address costs an attacker nothing. + Every operation that advances state takes `&mut self`, so two seals racing + into one AEAD nonce is a compile error rather than a rare one. + **Persist-before-emit is structural, not documented.** Every operation that advances ratchet state writes through `LeafStore` and only then returns the frame, so a store that fails produces an error and no frame at all. A device diff --git a/crates/offline-protocol-leaf/src/adapters.rs b/crates/offline-protocol-leaf/src/adapters.rs index 58acf2f2..00a03465 100644 --- a/crates/offline-protocol-leaf/src/adapters.rs +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -24,6 +24,24 @@ use crate::store::{KEY_TYPE_KEY_PACKAGE, KEY_TYPE_PEER}; impl IntoAnyError for StoreError {} +/// How many prior-epoch records a group keeps. +/// +/// mls-rs delegates this to the storage provider rather than applying it +/// itself: its own in-memory provider trims to three on every write, and a +/// provider that never trims keeps every epoch a group has ever left. That is +/// two failures rather than one. The records accumulate on a part whose flash +/// is measured in hundreds of kilobytes, and each one holds that epoch's +/// secrets, so retaining them all turns "how far out of order a message may +/// arrive" into "how far back a stolen device decrypts". Three is the window +/// a phone-driven commit cadence needs on a lossy radio, and it is the bound +/// on both. +/// +/// This is leaf-side storage policy, not a number the two ends must match: +/// the phone's provider keeps its own history and neither reads the other's. +/// It is therefore declared here rather than in `offline-protocol-sealed`, +/// which is for values a peer would disagree with us about. +pub(crate) const PRIOR_EPOCH_RETENTION: u64 = 3; + /// Renders bytes as lowercase hex, so an arbitrary group id can be a key id. /// /// Group ids are chosen by whoever created the group and are not required to @@ -104,6 +122,16 @@ impl GroupStateStorage for GroupStateAdapter { /// The caller does not emit anything until this returns `Ok`, so a failure /// here is a frame that was never sent rather than state that fell behind /// one that was. + /// + /// Expired records are dropped **after** the state write, and their + /// failures are not propagated. Both follow from the same rule. Deleting + /// first would let a cut leave a state beside fewer prior epochs than it + /// references, which is the direction this ordering exists to avoid; and + /// once the state write has returned, the write the caller is waiting on + /// is durable, so reporting a failed housekeeping delete as an error would + /// suppress a frame whose state is already on flash. A record that + /// survives a failed delete is swept by + /// [`LeafDevice::unpair`](crate::LeafDevice::unpair). fn write( &mut self, state: GroupState, @@ -133,7 +161,25 @@ impl GroupStateStorage for GroupStateAdapter { } self.store - .store(KEY_TYPE_GROUP_STATE, &state_key(&state.id), &state.data) + .store(KEY_TYPE_GROUP_STATE, &state_key(&state.id), &state.data)?; + + // One delete per record that entered, which is all the window can + // lose: mls-rs requires each inserted epoch id to be exactly one above + // the highest stored, so the window advances by the number of inserts + // and never skips. Updates rewrite records already inside it. + // + // The marker is deliberately left alone. `max_epoch_id` is what + // mls-rs checks that next id against, so it has to stay the highest + // epoch ever written rather than the highest still held. + for record in epoch_inserts.iter() { + if let Some(expired) = record.id.checked_sub(PRIOR_EPOCH_RETENTION) { + let _ = self + .store + .delete(KEY_TYPE_GROUP_EPOCH, &epoch_key(&state.id, expired)); + } + } + + Ok(()) } fn max_epoch_id(&self, group_id: &[u8]) -> Result, Self::Error> { @@ -155,12 +201,36 @@ impl GroupStateStorage for GroupStateAdapter { } } +/// How many minted-but-unspent key packages a device keeps. +/// +/// mls-rs deletes an entry when a join consumes it, so the only entries that +/// accumulate are packages nobody ever spent: a pairing that was abandoned, or +/// one a stranger provoked. Without a bound each of those is private key +/// material written to flash and never reclaimed, and provoking a mint costs +/// an attacker one signed frame. +/// +/// Evicting the oldest is the trade this makes, and it is not free: a peer +/// holding an evicted package can no longer complete a join with it, so a +/// flood turns into a pairing failure rather than a full flash. Four is enough +/// for a household pairing its phones in sequence, and small enough that the +/// residue is bounded. +const MAX_UNSPENT_KEY_PACKAGES: usize = 4; + +/// Where the list of unspent key package ids is kept. +/// +/// Held under the same key type as the packages themselves. It cannot collide +/// with one: every other id there is [`hex`] output, and `_` is not a hex +/// digit. +const KEY_PACKAGE_INDEX: &str = "__index__"; + /// Carries [`KeyPackageStorage`] onto the device's blob store. /// /// The values here hold the init and leaf-node private keys of a key package /// the device minted and has not yet spent. mls-rs deletes an entry when the /// package is consumed by a join, which is why an init key is single use and -/// why a static pairing artifact must never carry one. +/// why a static pairing artifact must never carry one. What it does not do is +/// bound the ones never consumed, so this adapter does: see +/// [`MAX_UNSPENT_KEY_PACKAGES`]. #[derive(Clone)] pub(crate) struct KeyPackageAdapter { store: Arc, @@ -170,20 +240,80 @@ impl KeyPackageAdapter { pub(crate) fn new(store: Arc) -> Self { Self { store } } + + /// The ids of unspent packages, oldest first. + /// + /// A corrupt index is treated as an empty one rather than an error. It is + /// housekeeping state, and refusing to mint a key package because a list + /// of previous ones does not parse would turn a recoverable annoyance into + /// a device that cannot pair. + fn index(&self) -> Result, StoreError> { + let Some(raw) = self.store.load(KEY_TYPE_KEY_PACKAGE, KEY_PACKAGE_INDEX)? else { + return Ok(Vec::new()); + }; + Ok(serde_json::from_slice(&raw).unwrap_or_default()) + } + + fn save_index(&self, index: &[String]) -> Result<(), StoreError> { + let encoded = serde_json::to_vec(index) + .map_err(|e| StoreError::Store(format!("cannot encode key package index: {e}")))?; + self.store + .store(KEY_TYPE_KEY_PACKAGE, KEY_PACKAGE_INDEX, &encoded) + } } impl KeyPackageStorage for KeyPackageAdapter { type Error = StoreError; fn delete(&mut self, id: &[u8]) -> Result<(), Self::Error> { - self.store.delete(KEY_TYPE_KEY_PACKAGE, &hex(id)) + let key = hex(id); + self.store.delete(KEY_TYPE_KEY_PACKAGE, &key)?; + + // Dropped from the index too, so a package a join consumed does not + // hold a slot against the ones still outstanding. + // + // Best effort, because the package itself is already gone, which is + // what was asked for. mls-rs calls this after it has persisted the + // group state that consumed the package, so an error here would report + // a deletion that did happen as one that did not, and the caller would + // withhold a frame whose state is on flash. A stale entry costs one + // slot and is evicted in its turn. + if let Ok(mut index) = self.index() { + if let Some(at) = index.iter().position(|held| held == &key) { + index.remove(at); + let _ = self.save_index(&index); + } + } + Ok(()) } fn insert(&mut self, id: Vec, pkg: KeyPackageData) -> Result<(), Self::Error> { let encoded = pkg .mls_encode_to_vec() .map_err(|e| StoreError::Store(format!("cannot encode key package data: {e:?}")))?; - self.store.store(KEY_TYPE_KEY_PACKAGE, &hex(&id), &encoded) + let key = hex(&id); + + // The index is written before the package it names. A cut between the + // two leaves an index entry for a package that is not there, which + // costs one wasted slot; the reverse would leave private key material + // no index knows about, which is the thing that never gets reclaimed. + let mut index = self.index()?; + if !index.iter().any(|held| held == &key) { + index.push(key.clone()); + } + let evicted: Vec = if index.len() > MAX_UNSPENT_KEY_PACKAGES { + index + .drain(..index.len() - MAX_UNSPENT_KEY_PACKAGES) + .collect() + } else { + Vec::new() + }; + self.save_index(&index)?; + for stale in evicted { + self.store.delete(KEY_TYPE_KEY_PACKAGE, &stale)?; + } + + self.store.store(KEY_TYPE_KEY_PACKAGE, &key, &encoded) } fn get(&self, id: &[u8]) -> Result, Self::Error> { @@ -219,9 +349,43 @@ pub(crate) struct PeerRecord { /// one moment a fresh package is required rather than wasteful. #[serde(default)] pub(crate) key_package_sent: bool, + + /// Ids of the reset-flagged key package frames already acted on. + /// + /// A reset tears down a live session, so a frame carrying one is worth + /// capturing and sending again. Remembering the last few ids means the + /// same captured frame cannot tear down a session twice. + /// + /// This bounds a repeat, and does not close replay. Nothing in the signed + /// payload states freshness, so an attacker holding a reset frame older + /// than this ring can still spend it once. Closing that is a wire change + /// rather than a device one, and is recorded on + /// [`LeafDevice`](crate::LeafDevice). + #[serde(default)] + pub(crate) recent_reset_ids: Vec, } +/// How many reset frame ids a peer record remembers. +pub(crate) const RECENT_RESET_IDS: usize = 4; + impl PeerRecord { + /// Records `id` as acted on, dropping the oldest beyond the ring. + pub(crate) fn remember_reset(&mut self, id: &str) { + if self.recent_reset_ids.iter().any(|seen| seen == id) { + return; + } + self.recent_reset_ids.push(String::from(id)); + if self.recent_reset_ids.len() > RECENT_RESET_IDS { + let excess = self.recent_reset_ids.len() - RECENT_RESET_IDS; + self.recent_reset_ids.drain(..excess); + } + } + + /// Whether this reset frame has already been acted on. + pub(crate) fn has_seen_reset(&self, id: &str) -> bool { + self.recent_reset_ids.iter().any(|seen| seen == id) + } + pub(crate) fn load(store: &Arc, peer: &str) -> Result, StoreError> { let Some(raw) = store.load(KEY_TYPE_PEER, peer)? else { return Ok(None); diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index c16d5903..fdce59f4 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -17,12 +17,40 @@ use offline_protocol_sealed::{ MLS_ENVELOPE_COMPACT_V1, }; -use crate::adapters::PeerRecord; +use crate::adapters::{PeerRecord, PRIOR_EPOCH_RETENTION}; use crate::error::{LeafError, Result}; use crate::frames; use crate::identity::{build_client, Identity}; use crate::keypkg; -use crate::store::{LeafStore, KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_PEER}; +use crate::store::{ + LeafStore, KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_PEER, KEY_TYPE_PEER_INDEX, +}; + +/// How many peers an **inbound** frame may add records for. +/// +/// Every peer that sends a well-formed key package gets a record and a minted +/// package in return, both of which are writes to flash. The signature on that +/// frame proves the sender holds the key its address derives from, which is +/// exactly as hard as generating a key, so without a bound a stranger fills a +/// device's storage for the cost of some signing. The phone bounds the same +/// exchange for the same reason. +/// +/// It bounds what arrives, not what firmware chooses: +/// [`LeafDevice::key_package_frame`] is the integrator deciding to pair and is +/// not held to this. The failure being prevented is a stranger spending a +/// device's flash, not an owner spending their own. +/// +/// Sixteen covers a household and its guests. What it costs when full is +/// stated on [`LeafError::TooManyPeers`]. +const MAX_PEERS: usize = 16; + +/// How far below the retained window [`LeafDevice::unpair`] sweeps. +/// +/// [`PRIOR_EPOCH_RETENTION`] says what a healthy device holds. This is the +/// margin for one that is not: a delete that failed during trimming leaves a +/// record holding an epoch's secrets, and forgetting a peer is the moment to +/// clear those rather than the moment to assume they are not there. +const FORGET_EPOCH_SLACK: u64 = 16; /// Something that happened, for the firmware to act on. #[derive(Debug, Clone, PartialEq, Eq)] @@ -100,6 +128,30 @@ pub struct Handled { /// between any two instructions: there is no in-RAM state for a power cut to /// desynchronize from what is on flash. /// +/// # One writer at a time +/// +/// Every operation that advances state takes `&mut self`, so the compiler +/// enforces what the ratchet requires. Two seals running at once would load +/// the same generation, encrypt under the same key and nonce, and emit both +/// frames: the AEAD nonce reuse this crate's whole persist-before-emit rule +/// exists to prevent, arrived at without ever losing power. The same argument +/// covers the send counter, where a race mints one id twice and the peer's +/// deduplicator swallows the second message. +/// +/// A device is therefore one value, exclusively held. Two `LeafDevice`s over +/// one [`LeafStore`] would each satisfy the borrow checker and race anyway; +/// construct one per store. +/// +/// # What it does not defend against +/// +/// A replayed control frame. The signed payload states who, to whom, and what, +/// with nothing that says *when*, so a captured frame verifies forever. The +/// destructive case is a reset-flagged key package, which tears down a live +/// session, and the peer record remembers the last few of those so the same +/// frame cannot spend twice. An attacker holding an older one can still spend +/// it once. Closing that needs freshness inside the signed payload, which is a +/// change to the wire and to both ends rather than to this crate. +/// /// `Debug` renders the device's address and nothing else. Everything else it /// holds is either secret or a handle to secrets, and a device that printed /// its identity key into a log would undo the part of the threat model that @@ -154,12 +206,12 @@ impl LeafDevice { /// `now_unix_secs` is the pairing time source. It is a parameter because a /// device has no clock, and passing something wrong here is the difference /// between pairing and being refused as expired. - pub fn key_package_frame(&self, peer: &str, now_unix_secs: u64) -> Result { + pub fn key_package_frame(&mut self, peer: &str, now_unix_secs: u64) -> Result { self.key_package_frame_inner(peer, now_unix_secs, false) } fn key_package_frame_inner( - &self, + &mut self, peer: &str, now_unix_secs: u64, session_reset: bool, @@ -199,11 +251,16 @@ impl LeafDevice { /// the frame exist. A store that fails produces an error and no frame, /// which is the whole point: a device that emitted first and persisted /// second would, after a power cut, come back and reuse an AEAD nonce. - pub fn seal(&self, peer: &str, plaintext: &str, now_unix_secs: u64) -> Result { + pub fn seal(&mut self, peer: &str, plaintext: &str, now_unix_secs: u64) -> Result { self.seal_content(peer, plaintext.as_bytes(), now_unix_secs) } - fn seal_content(&self, peer: &str, plaintext: &[u8], now_unix_secs: u64) -> Result { + fn seal_content( + &mut self, + peer: &str, + plaintext: &[u8], + now_unix_secs: u64, + ) -> Result { let client = self.client()?; let group_id = self.group_id(peer)?; let mut group = client @@ -263,7 +320,7 @@ impl LeafDevice { /// /// Returns the frames to send and what happened. Everything in /// [`Handled::outbound`] is already durable by the time it is returned. - pub fn handle(&self, message: &Message, now_unix_secs: u64) -> Result { + pub fn handle(&mut self, message: &Message, now_unix_secs: u64) -> Result { let content = &message.content; // Order matters: the encrypted-confirm prefix is not checked here at @@ -295,7 +352,12 @@ impl LeafDevice { } } - fn on_key_package(&self, message: &Message, body: &str, now_unix_secs: u64) -> Result { + fn on_key_package( + &mut self, + message: &Message, + body: &str, + now_unix_secs: u64, + ) -> Result { frames::verify_control_frame(message)?; let payload: KeyPackagePayload = serde_json::from_str(body) .map_err(|e| LeafError::MalformedFrame(format!("key package body: {e}")))?; @@ -313,15 +375,36 @@ impl LeafDevice { ))); } + // Bounded before the first byte is written under this peer's name. + // Everything below here stores something, and a signature that only + // proves a key derives to its own address is not a scarce thing to + // produce. + self.admit_peer(sender)?; + let mut events = Vec::new(); let mut record = self.peer_record(sender)?; if payload.session_reset { - self.forget_session(sender)?; - record.key_package_sent = false; - events.push(LeafEvent::SessionReset { - peer: sender.to_string(), - }); + let frame_id = message.id.as_str(); + if record.has_seen_reset(&frame_id) { + // A reset already acted on. Tearing down again on the same + // frame is how a captured one becomes a repeatable way to + // break a session that has since been rebuilt, so the + // teardown is what a repeat loses; the record below is still + // refreshed, because a peer restating its capabilities is + // harmless and a retransmission is the ordinary reason to see + // this twice. + events.push(LeafEvent::Ignored { + reason: String::from("a session reset arrived twice on the same frame"), + }); + } else { + self.forget_session(sender)?; + record.remember_reset(&frame_id); + record.key_package_sent = false; + events.push(LeafEvent::SessionReset { + peer: sender.to_string(), + }); + } } record.env_versions = payload.env_versions.clone(); @@ -345,7 +428,7 @@ impl LeafDevice { Ok(Handled { outbound, events }) } - fn on_welcome(&self, message: &Message, body: &str, now_unix_secs: u64) -> Result { + fn on_welcome(&mut self, message: &Message, body: &str, now_unix_secs: u64) -> Result { frames::verify_control_frame(message)?; let welcome: WelcomeMessage = serde_json::from_str(body) .map_err(|e| LeafError::MalformedFrame(format!("welcome body: {e}")))?; @@ -382,6 +465,22 @@ impl LeafDevice { .join_group(None, &welcome_message, None) .map_err(|e| LeafError::Mls(format!("cannot join from the welcome: {e:?}")))?; + // The group checked above was the one the body *claimed*. This is the + // one that was actually joined, and they are separate values: the + // claim is a JSON field beside the Welcome, and the Welcome carries + // its own group id inside. A frame that puts an honest claim in front + // of somebody else's Welcome passes the first check and fails here. + // + // Refused before the state is written, because joining has already + // spent this device's init key, and persisting the group as well would + // leave a room it never chose sitting on flash for every later gate to + // keep refusing. + if group.group_id() != expected.as_str().as_bytes() { + return Err(LeafError::IdentityBinding(format!( + "welcome claimed group '{expected}' but joined a different one" + ))); + } + group .write_to_storage() .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; @@ -404,7 +503,7 @@ impl LeafDevice { }) } - fn on_encrypted(&self, message: &Message, body: &str, _now: u64) -> Result { + fn on_encrypted(&mut self, message: &Message, body: &str, _now: u64) -> Result { // Not signature-gated: this is the data plane, and MLS authenticates // its own sender. A second signature on the outside would state what // the AEAD already proves on the inside. @@ -486,9 +585,34 @@ impl LeafDevice { }) } - fn on_probe(&self, message: &Message, now_unix_secs: u64) -> Result { + /// Answers a liveness probe, but only for a peer this device can still + /// talk to. + /// + /// The acknowledgement is not a pleasantry: a peer treats it as proof the + /// session is usable and confirms on it, then flushes everything it had + /// queued into that session. A device that answered after losing its store + /// would confirm a session it cannot decrypt a single frame of, and the + /// peer would have no way to find that out, because the device's silence + /// afterwards is indistinguishable from a quiet link. Staying quiet here + /// leaves the peer unconfirmed, which is a state it knows how to repair. + /// + /// This is the peer's own rule, applied on the device: it answers a probe + /// only while it holds a session of its own. + fn on_probe(&mut self, message: &Message, now_unix_secs: u64) -> Result { frames::verify_control_frame(message)?; let sender = message.sender.as_str(); + + if !self.has_session(sender)? { + return Ok(Handled { + outbound: Vec::new(), + events: vec![LeafEvent::Ignored { + reason: String::from( + "a confirmation probe arrived for a peer this device has no session with", + ), + }], + }); + } + let mut ack = frames::build( &self.store, &self.identity, @@ -510,9 +634,35 @@ impl LeafDevice { /// Called when a peer says it has discarded its own. A device that kept /// the old session would hold one the peer has already thrown away, and /// every later frame from it would decrypt to nothing. - fn forget_session(&self, peer: &str) -> Result<()> { + /// + /// # The prior epochs go too + /// + /// A session is the group state, the marker, **and** the prior-epoch + /// records, and the last of those is the part that is easy to leave + /// behind. Each holds an epoch's secrets, so records that outlive the + /// session they belong to are key material surviving an erasure the owner + /// asked for. They also outlive it under a name the next session answers + /// to: a pair's group id is derived from the two addresses, so a device + /// that re-pairs with the same peer rebuilds the same id and starts + /// writing epochs beside the last session's. + fn forget_session(&mut self, peer: &str) -> Result<()> { let group_id = self.group_id(peer)?; let key = crate::adapters::hex(group_id.as_str().as_bytes()); + + // The marker is the highest epoch ever written, so it is the top of + // the sweep. Trimming keeps only the newest few below it; the slack + // covers a delete that failed while it was doing so. A delete of a key + // that is not there is not an error, which is what makes a fixed + // window the right shape rather than an enumeration this seam cannot + // offer. + let highest = self.max_epoch(&key)?.unwrap_or(0); + let floor = highest.saturating_sub(PRIOR_EPOCH_RETENTION + FORGET_EPOCH_SLACK); + for epoch in floor..=highest { + self.store + .delete(KEY_TYPE_GROUP_EPOCH, &format!("{key}:{epoch}")) + .map_err(|e| LeafError::Storage(e.to_string()))?; + } + self.store .delete(KEY_TYPE_GROUP_STATE, &key) .map_err(|e| LeafError::Storage(e.to_string()))?; @@ -522,6 +672,84 @@ impl LeafDevice { Ok(()) } + /// The highest epoch id ever written for a group, by storage key. + fn max_epoch(&self, group_key: &str) -> Result> { + let raw = self + .store + .load(KEY_TYPE_GROUP_EPOCH, &format!("{group_key}:max")) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(raw + .and_then(|bytes| <[u8; 8]>::try_from(bytes.as_slice()).ok()) + .map(u64::from_be_bytes)) + } + + /// Forgets a peer's session and what it advertised, leaving the index. + fn forget_peer(&mut self, peer: &str) -> Result<()> { + self.forget_session(peer)?; + self.store + .delete(KEY_TYPE_PEER, peer) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(()) + } + + /// The peers this device holds records for. + /// + /// An index that does not decode is read as empty rather than as an error. + /// It is a bound on storage, not a security claim, and a device that + /// refused to pair because a list of previous peers is unreadable would + /// have turned a recoverable annoyance into a brick. + fn peer_index(&self) -> Result> { + let Some(raw) = self + .store + .load(KEY_TYPE_PEER_INDEX, "peers") + .map_err(|e| LeafError::Storage(e.to_string()))? + else { + return Ok(Vec::new()); + }; + Ok(serde_json::from_slice(&raw).unwrap_or_default()) + } + + fn save_peer_index(&self, index: &[String]) -> Result<()> { + let encoded = serde_json::to_vec(index) + .map_err(|e| LeafError::MalformedFrame(format!("cannot encode peer index: {e}")))?; + self.store + .store(KEY_TYPE_PEER_INDEX, "peers", &encoded) + .map_err(|e| LeafError::Storage(e.to_string())) + } + + /// Makes room for `peer` in the bounded set, or refuses. + /// + /// A peer already held is admitted for free. A new one at capacity takes + /// the place of a pairing that never completed, which is what a record + /// with no session is; if every slot holds a real session, the answer is + /// [`LeafError::TooManyPeers`] rather than the eviction of somebody the + /// owner actually paired with. That direction is the whole point of having + /// the rule: a stranger who can provoke a record must not be able to + /// provoke the removal of one. + fn admit_peer(&mut self, peer: &str) -> Result<()> { + let mut index = self.peer_index()?; + if index.iter().any(|held| held == peer) { + return Ok(()); + } + + if index.len() >= MAX_PEERS { + // A store that fails the session check counts as holding one, so a + // read error protects the peer rather than evicting it. + let victim = index + .iter() + .find(|held| !self.has_session(held).unwrap_or(true)) + .cloned(); + let Some(victim) = victim else { + return Err(LeafError::TooManyPeers); + }; + self.forget_peer(&victim)?; + index.retain(|held| held != &victim); + } + + index.push(peer.to_string()); + self.save_peer_index(&index) + } + /// Requires the MLS member at `index` to derive to `claimed`. /// /// The refusal is deliberately the same for a member whose credential is @@ -648,11 +876,17 @@ fn parse_envelope(body: &str) -> Result { /// is the kind of residue that outlives the reason it existed. impl LeafDevice { /// Forgets a peer: its session, its prior epochs, and what it advertised. - pub fn unpair(&self, peer: &str) -> Result<()> { - self.forget_session(peer)?; - self.store - .delete(KEY_TYPE_PEER, peer) - .map_err(|e| LeafError::Storage(e.to_string()))?; + /// + /// Also the slot it held, so unpairing is how an owner makes room on a + /// device whose peer table is full. + pub fn unpair(&mut self, peer: &str) -> Result<()> { + self.forget_peer(peer)?; + + let mut index = self.peer_index()?; + if index.iter().any(|held| held == peer) { + index.retain(|held| held != peer); + self.save_peer_index(&index)?; + } Ok(()) } } diff --git a/crates/offline-protocol-leaf/src/error.rs b/crates/offline-protocol-leaf/src/error.rs index 8cbc986d..92bb5505 100644 --- a/crates/offline-protocol-leaf/src/error.rs +++ b/crates/offline-protocol-leaf/src/error.rs @@ -64,6 +64,16 @@ pub enum LeafError { #[error("No session with {0}")] NoSession(String), + /// The device already holds as many peers as it keeps room for, and none + /// of them is an incomplete pairing that could be recycled. + /// + /// Refusing rather than evicting an established peer is deliberate. A + /// device with a full table is one a stranger cannot displace the owner + /// from; the owner clears a slot with + /// [`LeafDevice::unpair`](crate::LeafDevice::unpair). + #[error("Peer table is full")] + TooManyPeers, + /// The sealed layer refused a value. #[error("{0}")] Sealed(String), diff --git a/crates/offline-protocol-leaf/src/frames.rs b/crates/offline-protocol-leaf/src/frames.rs index f382ee8e..a3de5a8b 100644 --- a/crates/offline-protocol-leaf/src/frames.rs +++ b/crates/offline-protocol-leaf/src/frames.rs @@ -105,6 +105,19 @@ pub(crate) fn build( ); message.priority = priority; message.lamport_clock = LamportClock::from_value(counter); + + // A leaf asks for no delivery acknowledgement, because it has nothing to + // do with one. The default is `true`, which is right for a sender that + // holds a retry queue and settles a message against the answer; this + // device has neither, so every acknowledgement it provoked would be a + // frame it parses as carrying no prefix it answers and drops. On a link + // with very little airtime that is one wasted transmission per frame sent. + // + // The other direction is not this crate's to decide. A phone marks its own + // frames as needing one and a leaf emits none, so it retries until it + // gives up. Whether a leaf peer is exempt from that machinery or owes an + // acknowledgement is a question for the spec, which today lists neither. + message.requires_ack = false; Ok(message) } diff --git a/crates/offline-protocol-leaf/src/identity.rs b/crates/offline-protocol-leaf/src/identity.rs index 396a7afd..72010689 100644 --- a/crates/offline-protocol-leaf/src/identity.rs +++ b/crates/offline-protocol-leaf/src/identity.rs @@ -62,6 +62,20 @@ impl Identity { /// crate does: a device that hands out an address it did not persist comes /// back after a power cut as a different device, and the peer that paired /// with the first one has no way to learn that. + /// + /// # Why the secret is written last + /// + /// An identity is two entries and the store is atomic per entry, not + /// across a pair, so a cut lands between them. The secret is therefore + /// both the last write and the marker this function refuses on, which + /// makes a half-written identity a state the next boot **overwrites** + /// rather than one it is stuck in. + /// + /// Written the other way round the two checks disagree: `resume` refuses + /// for the missing public key, `provision` refuses for the present secret, + /// and `open` has no third door. A device that lost power once during its + /// very first boot would then answer every call with an error, in the + /// field, with nothing short of an out-of-band wipe to recover it. pub(crate) fn provision(store: &Arc) -> Result { if store .load(KEY_TYPE_IDENTITY, KEY_ID_SECRET) @@ -77,10 +91,10 @@ impl Identity { .map_err(|e| LeafError::Crypto(format!("cannot generate a signature key: {e:?}")))?; store - .store(KEY_TYPE_IDENTITY, KEY_ID_SECRET, secret.as_bytes()) + .store(KEY_TYPE_IDENTITY, KEY_ID_PUBLIC, public.as_bytes()) .map_err(|e| LeafError::Storage(e.to_string()))?; store - .store(KEY_TYPE_IDENTITY, KEY_ID_PUBLIC, public.as_bytes()) + .store(KEY_TYPE_IDENTITY, KEY_ID_SECRET, secret.as_bytes()) .map_err(|e| LeafError::Storage(e.to_string()))?; let address = derive_address(public.as_bytes())?; @@ -92,6 +106,12 @@ impl Identity { } /// Loads a previously provisioned identity. + /// + /// Both entries are required, which is safe only because + /// [`Identity::provision`] writes the public key first: the pair is either + /// complete or missing the secret, and the second of those is what `open` + /// recovers from. Swapping those two writes makes this function the half + /// of a deadlock. pub(crate) fn resume(store: &Arc) -> Result { let secret = store .load(KEY_TYPE_IDENTITY, KEY_ID_SECRET) diff --git a/crates/offline-protocol-leaf/src/lib.rs b/crates/offline-protocol-leaf/src/lib.rs index ff1a6516..7ad67dff 100644 --- a/crates/offline-protocol-leaf/src/lib.rs +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -33,7 +33,7 @@ //! # fn main() -> Result<(), Box> { //! // A real device implements `LeafStore` over its secure key storage. //! let store: Arc = Arc::new(MemoryStore::new()); -//! let device = LeafDevice::open(store, "com.example.lock")?; +//! let mut device = LeafDevice::open(store, "com.example.lock")?; //! //! // `now` comes from the radio stack, the commissioner, or the pairing //! // exchange. It is a parameter because a device has no clock, and an MLS @@ -64,6 +64,14 @@ //! that lies is the one remaining way to reuse an AEAD nonce after a power //! cut. //! +//! # One writer +//! +//! Every operation that advances state takes `&mut self`, so a device is one +//! value exclusively held rather than a handle to share between tasks. Two +//! seals at once would reuse an AEAD nonce without any power cut being +//! involved; see [`LeafDevice`] for that argument and for what a replayed +//! control frame can still do. +//! //! # Bare metal //! //! Builds with `--no-default-features` for a target with no `std`, which is diff --git a/crates/offline-protocol-leaf/src/store.rs b/crates/offline-protocol-leaf/src/store.rs index ba1fac50..4f81056e 100644 --- a/crates/offline-protocol-leaf/src/store.rs +++ b/crates/offline-protocol-leaf/src/store.rs @@ -46,6 +46,12 @@ pub const KEY_TYPE_GROUP_EPOCH: &str = "group_epoch"; pub const KEY_TYPE_KEY_PACKAGE: &str = "key_package"; /// Key type for what a peer told us it can parse. pub const KEY_TYPE_PEER: &str = "peer"; +/// Key type for the list of peers this device holds records for. +/// +/// One entry, rewritten as peers are admitted and forgotten. It exists because +/// this seam offers no way to enumerate what is in it, and a bound nobody can +/// count is not a bound. +pub const KEY_TYPE_PEER_INDEX: &str = "peer_index"; /// What a store can fail with. #[derive(Debug, Clone, PartialEq, Eq, Error)] diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index 215e7df2..3a7d8212 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -16,12 +16,17 @@ use std::sync::{ Arc, Mutex, }; +use std::collections::BTreeMap; + use offline_protocol_core::{Message, MessagePriority, UserId}; -use offline_protocol_leaf::{LeafDevice, LeafError, LeafEvent, LeafStore, MemoryStore, StoreError}; +use offline_protocol_leaf::{ + store::{KEY_TYPE_GROUP_EPOCH, KEY_TYPE_IDENTITY, KEY_TYPE_PEER}, + LeafDevice, LeafError, LeafEvent, LeafStore, MemoryStore, StoreError, +}; use offline_protocol_mls::{storage::InMemoryStorage, MlsManager, MlsStorage}; use offline_protocol_sealed::{ - control_signing_payload, derive_address, prefixes, EncryptedMessage, KeyPackagePayload, - MLS_ENVELOPE_COMPACT_V1, + control_signing_payload, derive_address, prefixes, EncryptedMessage, GroupId, + KeyPackagePayload, WelcomeMessage, MLS_ENVELOPE_COMPACT_V1, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -110,7 +115,7 @@ fn import_device_key_package(phone: &Phone, frame: &Message) { /// /// This is the whole choreography in one place: the device advertises, the /// phone establishes and sends a Welcome, the device joins and confirms. -fn pair(phone: &Phone, device: &LeafDevice) -> String { +fn pair(phone: &Phone, device: &mut LeafDevice) -> String { let device_address = device.address().to_string(); let advertisement = device @@ -188,8 +193,8 @@ fn phone_sealed_frame(phone: &Phone, to: &str, envelope: &EncryptedMessage) -> M fn a_phone_and_a_device_pair_and_talk_both_ways() { let phone = new_phone(); let store = Arc::new(MemoryStore::new()); - let device = device(store); - let device_address = pair(&phone, &device); + let mut device = device(store); + let device_address = pair(&phone, &mut device); // Phone to device. let sealed = phone @@ -221,8 +226,8 @@ fn a_phone_and_a_device_pair_and_talk_both_ways() { fn a_driven_rekey_reaches_the_device_as_a_session_reset() { let phone = new_phone(); let store = Arc::new(MemoryStore::new()); - let device = device(Arc::clone(&store) as Arc); - let device_address = pair(&phone, &device); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); assert!( device.has_session(&phone.address).expect("session check"), @@ -311,7 +316,7 @@ fn a_driven_rekey_reaches_the_device_as_a_session_reset() { #[test] fn an_unsigned_control_frame_is_refused() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let mut frame = phone_control_frame( &phone, @@ -332,7 +337,7 @@ fn an_unsigned_control_frame_is_refused() { #[test] fn half_a_signature_is_refused() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let mut frame = phone_control_frame( &phone, @@ -356,7 +361,7 @@ fn half_a_signature_is_refused() { fn a_signing_key_that_does_not_derive_to_the_sender_is_refused() { let phone = new_phone(); let impostor = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); // A frame that claims to be from the phone, signed correctly, but by // somebody else's key. The signature verifies; the derivation does not. @@ -379,7 +384,7 @@ fn a_signing_key_that_does_not_derive_to_the_sender_is_refused() { #[test] fn a_sender_that_is_not_an_address_is_refused() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); // The bypass this gate exists to close: claim a nickname, and a check that // skipped unparseable identifiers would never run at all. @@ -404,7 +409,7 @@ fn a_sender_that_is_not_an_address_is_refused() { fn a_key_package_that_claims_another_owner_is_refused() { let phone = new_phone(); let other = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let package = phone .manager @@ -447,7 +452,7 @@ fn a_key_package_that_claims_another_owner_is_refused() { fn a_welcome_for_another_pairs_group_is_refused() { let phone = new_phone(); let stranger = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let device_address = device.address().to_string(); // A Welcome the stranger built for a group of their own, relayed by the @@ -486,14 +491,14 @@ fn state_survives_a_power_cycle() { let phone = new_phone(); let store: Arc = Arc::new(MemoryStore::new()); let device_address = { - let device = device(Arc::clone(&store)); - pair(&phone, &device) + let mut device = device(Arc::clone(&store)); + pair(&phone, &mut device) }; // The device value is gone; only the store remains. This is what a reboot // looks like from the outside, and it is the whole reason no MLS state is // cached in the device value. - let revived = LeafDevice::resume(Arc::clone(&store), APP_ID).expect("device resumes"); + let mut revived = LeafDevice::resume(Arc::clone(&store), APP_ID).expect("device resumes"); assert_eq!( revived.address().to_string(), device_address, @@ -519,8 +524,8 @@ fn state_survives_a_power_cycle() { #[test] fn a_replayed_sealed_frame_is_refused() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); - let device_address = pair(&phone, &device); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &mut device); let sealed = phone .manager @@ -541,7 +546,8 @@ fn a_replayed_sealed_frame_is_refused() { #[test] fn the_device_answers_a_probe_with_an_ack() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); + pair(&phone, &mut device); let probe = phone_control_frame( &phone, @@ -564,10 +570,38 @@ fn the_device_answers_a_probe_with_an_ack() { ); } +#[test] +fn a_probe_without_a_session_is_not_answered() { + let phone = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + + // No pairing: this is a device that was wiped, or one this peer never + // paired with. The phone confirms its session on the acknowledgement and + // then flushes everything it had queued into it, so answering here would + // leave it holding a session confirmed against a device that cannot + // decrypt a single frame of it, with nothing afterwards to tell it so. + let probe = phone_control_frame( + &phone, + &device.address().to_string(), + prefixes::SESSION_CONFIRM_PROBE.to_string(), + ); + let handled = device.handle(&probe, NOW).expect("probe is handled"); + + assert!( + handled.outbound.is_empty(), + "a device with no session confirmed one anyway" + ); + assert!( + matches!(handled.events.as_slice(), [LeafEvent::Ignored { .. }]), + "a session-less probe produced {:?}", + handled.events + ); +} + #[test] fn a_peer_that_already_has_a_key_package_is_not_sent_another() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let device_address = device.address().to_string(); // The device advertises first, which is how pairing starts. @@ -617,8 +651,8 @@ fn a_peer_that_already_has_a_key_package_is_not_sent_another() { #[test] fn the_envelope_encoding_follows_what_the_peer_advertised() { let phone = new_phone(); - let device = device(Arc::new(MemoryStore::new())); - let device_address = pair(&phone, &device); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &mut device); // Pairing recorded nothing about the phone's capabilities, because the // phone never sent a key package in this flow. Absent means the floor. @@ -689,8 +723,8 @@ impl LeafStore for FailingStore { fn a_failing_store_produces_no_frame() { let phone = new_phone(); let store = Arc::new(FailingStore::default()); - let device = device(Arc::clone(&store) as Arc); - pair(&phone, &device); + let mut device = device(Arc::clone(&store) as Arc); + pair(&phone, &mut device); store.arm(); @@ -730,10 +764,14 @@ fn a_device_address_derives_from_its_own_key() { // The property every trust gate in this protocol rests on, checked from // the outside: what the device calls itself is a function of the key it // signs with, so a peer can refute a claim without a directory. - let device = device(Arc::new(MemoryStore::new())); + let mut device = device(Arc::new(MemoryStore::new())); let probe_target = device.address().to_string(); let phone = new_phone(); + // Paired first, because a probe is only answered by a device that holds a + // session; the acknowledgement is just the most convenient signed frame to + // read the key out of. + pair(&phone, &mut device); let mut frame = phone_control_frame( &phone, &probe_target, @@ -757,3 +795,467 @@ fn a_device_address_derives_from_its_own_key() { "the device signed with a key that does not derive to its own address" ); } + +/// A store that can be asked what is in it. +/// +/// `LeafStore` deliberately offers no enumeration, which is right for the +/// seam and useless for a test that has to prove something was *removed*. +#[derive(Debug, Default)] +struct CountingStore { + entries: Mutex>>, +} + +impl CountingStore { + /// The key ids held under one key type. + fn keys_of(&self, key_type: &str) -> Vec { + self.entries + .lock() + .expect("lock") + .keys() + .filter(|(held, _)| held == key_type) + .map(|(_, id)| id.clone()) + .collect() + } + + /// Prior-epoch records, which are the `:max` marker's siblings. + fn epoch_records(&self) -> Vec { + self.keys_of(KEY_TYPE_GROUP_EPOCH) + .into_iter() + .filter(|id| !id.ends_with(":max")) + .collect() + } +} + +impl LeafStore for CountingStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + self.entries + .lock() + .expect("lock") + .insert((key_type.to_string(), key_id.to_string()), data.to_vec()); + Ok(()) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + Ok(self + .entries + .lock() + .expect("lock") + .get(&(key_type.to_string(), key_id.to_string())) + .cloned()) + } + + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError> { + self.entries + .lock() + .expect("lock") + .remove(&(key_type.to_string(), key_id.to_string())); + Ok(()) + } +} + +/// Drives one phone-side commit and delivers it to the device. +fn commit_to(phone: &Phone, device: &mut LeafDevice, device_address: &str) { + let group_id = GroupId::for_session(&phone.address, device_address).expect("pair group id"); + let commit = phone.manager.update_keys(&group_id).expect("phone commits"); + let frame = phone_sealed_frame(phone, device_address, &commit); + let handled = device + .handle(&frame, NOW) + .expect("device applies the commit"); + assert!( + handled + .events + .iter() + .any(|event| matches!(event, LeafEvent::CommitApplied { .. })), + "a commit did not apply: {:?}", + handled.events + ); +} + +#[test] +fn a_commit_trims_the_prior_epochs_it_leaves_behind() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + // Post-compromise security arrives on the phone's cadence, so on a device + // that lives for years this is the loop that runs forever. Every commit + // leaves the epoch it departed behind as a record, and mls-rs leaves it to + // the storage provider to decide how many of those to keep: a provider + // that keeps all of them fills a part with a few hundred kilobytes of + // flash, and keeps every one of those epochs' secrets while it does, so a + // device taken apart later reads back everything it ever received. + for _ in 0..12 { + commit_to(&phone, &mut device, &device_address); + } + + let records = store.epoch_records(); + assert!( + records.len() <= 3, + "twelve commits left {} prior-epoch records: {records:?}", + records.len() + ); + + // And the window that is kept is still a working one: the session did not + // survive by being trimmed into uselessness. + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"still talking") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "still talking".to_string(), + }] + ); +} + +#[test] +fn unpairing_erases_the_prior_epoch_records_too() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + commit_to(&phone, &mut device, &device_address); + commit_to(&phone, &mut device, &device_address); + assert!( + !store.epoch_records().is_empty(), + "the setup wrote no epoch records, so this test would pass vacuously" + ); + + device.unpair(&phone.address).expect("device unpairs"); + + // Each of those records holds an epoch's secrets. Leaving them behind is + // key material outliving the erasure the owner asked for, under a name the + // next session with the same peer answers to, because a pair's group id is + // derived from the two addresses and does not change on a re-pair. + assert!( + store.epoch_records().is_empty(), + "unpairing left epoch records behind: {:?}", + store.epoch_records() + ); + assert!( + store.keys_of(KEY_TYPE_PEER).is_empty(), + "unpairing left the peer record behind" + ); + assert!( + !device.has_session(&phone.address).expect("session check"), + "unpairing left a session" + ); +} + +/// A store that refuses to write one key, to cut power at a chosen moment. +struct CutStore { + inner: MemoryStore, + refuse: Mutex>, +} + +impl CutStore { + fn cutting(key_id: &str) -> Self { + Self { + inner: MemoryStore::new(), + refuse: Mutex::new(Some(key_id.to_string())), + } + } + + fn restore_power(&self) { + *self.refuse.lock().expect("lock") = None; + } +} + +impl LeafStore for CutStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + if self.refuse.lock().expect("lock").as_deref() == Some(key_id) { + return Err(StoreError::Store("power cut".to_string())); + } + self.inner.store(key_type, key_id, data) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + self.inner.load(key_type, key_id) + } + + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError> { + self.inner.delete(key_type, key_id) + } +} + +#[test] +fn a_provisioning_cut_between_the_two_identity_writes_is_recoverable() { + // An identity is two entries and the store is atomic per entry, not across + // a pair, so a cut lands between them. The secret is written last and is + // the marker `provision` refuses on, which is what makes the torn state one + // the next boot overwrites. Written the other way round the two checks + // disagree, `open` has no third door, and a device that lost power once on + // its very first boot answers every call with an error forever. + let store = Arc::new(CutStore::cutting("signature_secret")); + + let err = LeafDevice::provision(Arc::clone(&store) as Arc, APP_ID) + .expect_err("the cut write reported success"); + assert!(matches!(err, LeafError::Storage(_)), "cut produced {err:?}"); + + // The control: the first write really did land, so this is the torn state + // and not simply an empty store. + assert!( + store + .load(KEY_TYPE_IDENTITY, "signature_public") + .expect("load") + .is_some(), + "nothing was written at all, so this proves nothing about ordering" + ); + + store.restore_power(); + let device = LeafDevice::open(Arc::clone(&store) as Arc, APP_ID) + .expect("a device that was cut mid-provisioning could not be opened"); + + // And it is a working identity, not a half of one. + let resumed = LeafDevice::resume(store as Arc, APP_ID).expect("device resumes"); + assert_eq!(resumed.address().to_string(), device.address().to_string()); +} + +#[test] +fn a_replayed_session_reset_does_not_tear_down_the_new_session() { + let phone = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &mut device); + + // A reset frame, captured off the air the first time it was sent. + phone + .manager + .delete_session(&device_address) + .expect("phone discards its own session"); + let package = phone + .manager + .take_push_key_package(&device_address) + .expect("phone mints a package"); + let payload = KeyPackagePayload { + user_id: phone.address.clone(), + key_package_data: package.bundle.key_package_data, + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: true, + wire_versions: vec![], + env_versions: vec![MLS_ENVELOPE_COMPACT_V1], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + let reset_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ), + ); + + let handled = device.handle(&reset_frame, NOW).expect("device resets"); + assert!(handled.events.contains(&LeafEvent::SessionReset { + peer: phone.address.clone(), + })); + + // The pair rebuilds, as a driven rekey is meant to. + import_device_key_package(&phone, &handled.outbound[0]); + let welcome = phone + .manager + .create_session(&device_address) + .expect("phone re-establishes"); + let welcome_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome") + ), + ); + device.handle(&welcome_frame, NOW).expect("device rejoins"); + assert!(device.has_session(&phone.address).expect("session check")); + + // Now the captured frame is sent again. Nothing in the signed payload says + // when it was made, so it verifies exactly as well as it did the first + // time; what stops it is the device remembering that it already acted on + // it. Without that, one captured frame is a session teardown that can be + // replayed at will. + let replayed = device + .handle(&reset_frame, NOW) + .expect("the replay is handled"); + assert!( + !replayed.events.contains(&LeafEvent::SessionReset { + peer: phone.address.clone(), + }), + "a replayed reset tore down the session again: {:?}", + replayed.events + ); + assert!( + device.has_session(&phone.address).expect("session check"), + "a replayed reset discarded a session the peer still holds" + ); + + // And the session it kept is the working one. + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); +} + +#[test] +fn a_flood_of_strangers_cannot_displace_an_established_peer() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + // The phone advertises, so it holds a record like any other peer and is a + // candidate for eviction on the same terms. + let package = phone + .manager + .take_push_key_package(&device_address) + .expect("package"); + let payload = KeyPackagePayload { + user_id: phone.address.clone(), + key_package_data: package.bundle.key_package_data, + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: false, + wire_versions: vec![], + env_versions: vec![MLS_ENVELOPE_COMPACT_V1], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + let frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ), + ); + device + .handle(&frame, NOW) + .expect("device records the phone"); + + // Producing a signature that derives to its own address is as hard as + // generating a key, so a stranger's frame costs nothing to make. Each one + // that lands writes a peer record and mints a package, both to flash. + for index in 0..24 { + let stranger = new_phone(); + let payload = KeyPackagePayload { + user_id: stranger.address.clone(), + key_package_data: vec![index], + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: false, + wire_versions: vec![], + env_versions: vec![], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + let frame = phone_control_frame( + &stranger, + &device_address, + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ), + ); + // Admitted or refused, both are fine. What is not fine is unbounded. + let _ = device.handle(&frame, NOW); + } + + assert!( + store.keys_of(KEY_TYPE_PEER).len() <= 16, + "a flood grew the peer table to {}", + store.keys_of(KEY_TYPE_PEER).len() + ); + + // The property that matters: whoever the flood displaced, it was not the + // peer the owner actually paired with. + assert!( + device.has_session(&phone.address).expect("session check"), + "a flood of strangers evicted the established peer" + ); + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); +} + +#[test] +fn a_welcome_whose_body_lies_about_its_group_is_refused() { + let phone = new_phone(); + let stranger = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + // The stranger builds a real group with this device, using a package the + // device minted for it. + let advertisement = device + .key_package_frame(&stranger.address, NOW) + .expect("device advertises"); + import_device_key_package(&stranger, &advertisement); + let foreign = stranger + .manager + .create_session(&device_address) + .expect("stranger creates a session"); + + // The phone relays that Welcome under an honest-looking body: it names + // itself as the inviter and this pair's own group id, so both of the + // checks that read the body pass. Only the Welcome inside disagrees, and + // it is the one that decides which group is actually joined. + let forged = WelcomeMessage { + group_id: GroupId::for_session(&phone.address, &device_address).expect("pair group id"), + welcome_data: foreign.welcome_data.clone(), + inviter_id: phone.address.clone(), + group_name: None, + timestamp_ms: 0, + }; + let frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&forged).expect("welcome") + ), + ); + + let err = device + .handle(&frame, NOW) + .expect_err("a welcome whose body lied was accepted"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "a mismatched welcome produced {err:?}" + ); + assert!( + !device.has_session(&phone.address).expect("session check"), + "the refused welcome still left a session on flash" + ); +} diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index 6af61b0f..a82bd08b 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -151,6 +151,15 @@ A conforming leaf: - **never emits** a Welcome, a commit, a proposal, or any group, rich, document or relay frame. +A leaf MUST answer a probe only while it holds a session with that peer, which +is the rule a phone already applies to the same frame. The acknowledgement is +not a liveness signal: a peer confirms its session on receiving one and then +flushes everything it had queued into that session. A device that answered +after losing its store would confirm a session it cannot decrypt one frame of, +and the silence afterwards is indistinguishable from a quiet link, so the peer +never learns. Staying quiet leaves it unconfirmed, which is a state it has a +path out of. + Per-commit cost on the device is two elliptic-curve operations. Per-message cost is symmetric only. @@ -167,6 +176,15 @@ exchange begins again from step 2 above. A leaf that treats `session_reset` as an ordinary key package refresh keeps a session the phone has already discarded, and every later frame from it decrypts to nothing. +A leaf MUST NOT act on the same reset frame twice. Nothing in the signed payload +states freshness, so a captured reset verifies as well on its tenth delivery as +on its first, and each teardown it earns is a session the pair has to rebuild. +Remembering the frames already acted on costs one bounded list per peer and +denies the repeat. It does not close replay: a frame older than that list can +still be spent once. **Closing it needs a freshness field inside the signed +payload**, which is a change to the wire and to both ends rather than to a +device, and is an open gap rather than a decision this chapter has taken. + Letting a device originate Update proposals would make it self-healing on its own schedule. That is deliberately outside this version. From 61c96068d0f8e31f205b3cc849833aa0d604053c Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 00:22:15 +0530 Subject: [PATCH 3/9] fix(leaf): an acknowledgement nobody asked for is not a session Review of this branch found one gate standing open and three smaller things that make a device harder to diagnose than it needs to be. The gate. An inbound __MLS_CONFIRM_ACK__ produced SessionEstablished for whoever sent it, with no check that a session existed. A leaf emits acknowledgements and never probes, so it never has one outstanding and every inbound one is unsolicited. The phone has always gated the same frame on holding a session of its own, and the profile in the spec lists that prefix under what a leaf emits rather than under what it accepts. Since producing a frame that derives to its own address costs an attacker nothing, this let anyone in range tell firmware a session exists that the device would then refuse to seal into. Same shape as an unsolicited connection_accepted, and it had no test at all, which is how it survived being written. Authorization is now the fourth obligation rather than an unstated one. Every gate in this crate answers "is this peer the address it claims to be", and none of them answers "did the owner mean this peer". Any address in radio range can complete a pairing, so a lock that opens for whatever arrives on an established session opens for anyone patient enough to pair with it, and every frame in that exchange verifies. That is firmware's call, and firmware can only make it if the crate says so. peers() is the accessor that goes with it: a reboot loses whatever the events said, and a bound nobody can read afterwards is not one an owner can act on. A group that would not load was reported as a missing session however it failed. Absent state is a device that never paired, and re-pairing repairs it. Present-but-unloadable state is a store handing back bytes this device did not write, and reporting that as a missing session sends a bench after the one repair that cannot work. The unpair sweep anchored at zero when its marker did not decode, which deleted one record, returned Ok, and left the rest of the epochs' secrets on flash under a name the next session with that peer answers to. The group state names the same epochs and is about to be deleted anyway, so it is the fallback anchor. The two gaps that are not device-side are issues now rather than comments: 402 for the acknowledgement asymmetry that has a phone retrying every frame until it gives up, each retry landing here as a replay refusal firmware cannot tell from an attack, and 403 for the missing freshness field that leaves a captured control frame verifying forever. --- CHANGELOG.md | 17 +- CLAUDE.md | 2 +- crates/offline-protocol-leaf/README.md | 2 +- crates/offline-protocol-leaf/src/device.rs | 164 ++++++++++++++---- crates/offline-protocol-leaf/src/frames.rs | 8 +- crates/offline-protocol-leaf/src/lib.rs | 12 +- crates/offline-protocol-leaf/src/store.rs | 10 ++ .../tests/phone_interop.rs | 125 ++++++++++++- docs/architecture.md | 6 +- docs/spec/leaf-provisioning.md | 37 +++- 10 files changed, 332 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 063bcb35..7f0b2e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,8 +158,11 @@ archived by series under [docs/changelog/](docs/changelog/); see the group rather than the one its body claimed. A sealed frame's MLS sender must be the peer the frame came from. A confirmation probe is answered only by a device that still holds a session, because a peer confirms on that answer and - flushes into it. A reset frame is acted on once, so a captured one is not a - repeatable session teardown. Twenty-three tests cover this against a real + flushes into it, and an inbound acknowledgement is never acted on at all, + because a leaf emits those and never probes, so every one that arrives is + unsolicited and treating it as proof of a session would let any keypair + holder assert one. A reset frame is acted on once, so a captured one is not a + repeatable session teardown. Twenty-six tests cover this against a real OpenMLS phone in the same process, which is the only kind of test that catches a default in one library the other refuses. @@ -181,13 +184,19 @@ archived by series under [docs/changelog/](docs/changelog/); see the failing store and asserts both that nothing is emitted and that the write was actually attempted, so it cannot pass by short-circuiting earlier. - Three obligations stay with the integrator, and the API is shaped so none can + Four obligations stay with the integrator, and the API is shaped so none can be forgotten silently: every entry point needing a clock takes `now_unix_secs` (a device that lets an MLS library read a clock it does not have stamps 1970 and is refused as expired, so it never pairs at all), the crate registers no `getrandom` backend (firmware wires the part's hardware entropy source, and key generation is exactly as strong as what it returns), - and `LeafStore` must be atomic per entry. + and `LeafStore` must be atomic per entry. The fourth is **authorization**: a + session proves who a peer is and never that the owner meant them, since any + address in radio range can complete a pairing, so firmware decides when the + radio accepts one and what a given peer's messages may actuate. A lock that + opens for whatever arrives on an established session opens for anyone patient + enough to pair with it. `LeafDevice::peers` is how firmware audits what a + device accumulated and `unpair` is how it removes one. - **`Message::from_parts` in `offline-protocol-core`**, which is `Message::new` with its two ambient inputs, the clock and the entropy, made diff --git a/CLAUDE.md b/CLAUDE.md index c0717f58..a66736f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ that are versioned, reviewable, and readable by people who are not an agent. | A decision that looks odd or over-engineered | [docs/adr/](docs/adr/README.md) | | `offline-protocol-core`: adding an import, a dependency, or a constructor | ADR [0020](docs/adr/0020-core-compiles-without-std.md) (it is dual std/no_std) | | `offline-protocol-sealed`: the envelope codec, `derive_address`, canonical signing payloads, ratchet constants, the 1:1 control-frame prefixes, `KeyPackagePayload` | ADR [0022](docs/adr/0022-one-sealed-layer-shared-with-the-leaf.md) (also dual std/no_std, and the one home for each) | -| `offline-protocol-leaf`: anything a device does at pairing, on a frame, or with its store | ADR [0021](docs/adr/0021-a-leaf-node-speaks-mls.md) and [docs/spec/leaf-provisioning.md](docs/spec/leaf-provisioning.md) (a time source, real entropy and durable-before-emit are obligations, not suggestions) | +| `offline-protocol-leaf`: anything a device does at pairing, on a frame, or with its store | ADR [0021](docs/adr/0021-a-leaf-node-speaks-mls.md) and [docs/spec/leaf-provisioning.md](docs/spec/leaf-provisioning.md) (a time source, real entropy, durable-before-emit and authorization are obligations, not suggestions) | | Replicated documents: the store, sync frames, attachments | [docs/spec/data-sync.md](docs/spec/data-sync.md), [the replication state machine](docs/state-machines/data-replication.md), ADR [0018](docs/adr/0018-data-layer-engine-and-storage-seams.md) and [0019](docs/adr/0019-remote-document-imports-are-contained-not-trusted.md) | | Any binding: Swift, Kotlin, Python, TypeScript | [docs/bridges/](docs/bridges/README.md) | diff --git a/crates/offline-protocol-leaf/README.md b/crates/offline-protocol-leaf/README.md index 056fed24..03a833f1 100644 --- a/crates/offline-protocol-leaf/README.md +++ b/crates/offline-protocol-leaf/README.md @@ -9,7 +9,7 @@ Provides: - `LeafStore`, one blob-storage seam a device implements over its secure key storage, with persist-before-emit enforced rather than documented - Key package minting with the backdated `not_before` and supplied timestamp a device needs to pair at all -Three obligations this crate cannot discharge for you: a **time source** at pairing (every entry point takes `now_unix_secs`, because a device that lets an MLS library read a clock it does not have stamps 1970 and is refused as expired), **real entropy** (this crate registers no `getrandom` backend on purpose; wire the symbol to the part's hardware source), and **durable storage** (`LeafStore` must be atomic per entry, because a ratchet state rolled back by a power cut reuses an AEAD nonce). +Four obligations this crate cannot discharge for you: a **time source** at pairing (every entry point takes `now_unix_secs`, because a device that lets an MLS library read a clock it does not have stamps 1970 and is refused as expired), **real entropy** (this crate registers no `getrandom` backend on purpose; wire the symbol to the part's hardware source), **durable storage** (`LeafStore` must be atomic per entry, because a ratchet state rolled back by a power cut reuses an AEAD nonce), and **authorization** (a session proves who a peer is and never that the owner meant them, since any address in radio range can complete a pairing, so firmware decides when the radio accepts one and what a given peer may actuate). Like [`offline-protocol-core`](https://crates.io/crates/offline-protocol-core) and [`offline-protocol-sealed`](https://crates.io/crates/offline-protocol-sealed), this crate compiles for bare-metal targets with `--no-default-features` (add `--features bare-metal-rng`). diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index fdce59f4..b60ddc0f 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -10,7 +10,7 @@ use alloc::{ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use mls_rs::client_builder::MlsConfig; use mls_rs::group::ReceivedMessage; -use mls_rs::{Client, MlsMessage}; +use mls_rs::{Client, Group, MlsMessage}; use offline_protocol_core::{Address, AppId, Message, MessagePriority}; use offline_protocol_sealed::{ prefixes, EncryptedMessage, GroupId, KeyPackagePayload, MlsMessageType, WelcomeMessage, @@ -71,8 +71,15 @@ pub enum LeafEvent { peer: String, }, /// An application message arrived and decrypted. + /// + /// `peer` is proven: it is the address the sealing group member's own + /// signature key derives to. What it is not is permission. Any address in + /// radio range can complete a pairing, so firmware decides what a message + /// from this particular peer is allowed to actuate. A lock that opens for + /// whatever arrives on an established session opens for anyone patient + /// enough to pair with it. MessageReceived { - /// The peer's address. + /// The peer's address, proven rather than claimed. peer: String, /// The plaintext. text: String, @@ -144,13 +151,23 @@ pub struct Handled { /// /// # What it does not defend against /// +/// **Anyone pairing.** Every gate here answers "is this peer the address it +/// claims to be", and none of them answers "did the owner mean this peer". +/// Producing a key that derives to its own address costs nothing, so an +/// unattended device admits whoever asks, up to the bound above. That is the +/// same position two phones are in, where the out-of-band artifact carries +/// first-contact trust; on a device it is firmware that decides when the radio +/// accepts a pairing at all, and firmware that decides what an opened message +/// may actuate. [`LeafDevice::peers`] is how it audits what accumulated. +/// /// A replayed control frame. The signed payload states who, to whom, and what, /// with nothing that says *when*, so a captured frame verifies forever. The /// destructive case is a reset-flagged key package, which tears down a live /// session, and the peer record remembers the last few of those so the same /// frame cannot spend twice. An attacker holding an older one can still spend /// it once. Closing that needs freshness inside the signed payload, which is a -/// change to the wire and to both ends rather than to this crate. +/// change to the wire and to both ends rather than to this crate, and is +/// tracked as [issue 403](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/403). /// /// `Debug` renders the device's address and nothing else. Everything else it /// holds is either secret or a handle to secrets, and a device that printed @@ -263,9 +280,7 @@ impl LeafDevice { ) -> Result { let client = self.client()?; let group_id = self.group_id(peer)?; - let mut group = client - .load_group(group_id.as_str().as_bytes()) - .map_err(|_| LeafError::NoSession(peer.to_string()))?; + let mut group = self.load_group(&client, peer, &group_id)?; let sealed = group .encrypt_application_message(plaintext, Vec::new()) @@ -335,11 +350,23 @@ impl LeafDevice { } else if frames::strip_prefix(content, prefixes::SESSION_CONFIRM_PROBE).is_some() { self.on_probe(message, now_unix_secs) } else if frames::strip_prefix(content, prefixes::SESSION_CONFIRM_ACK).is_some() { - frames::verify_control_frame(message)?; + // Never acted on, and not a frame this device accepts at all. A + // leaf emits an acknowledgement and never a probe, so it has none + // outstanding and every inbound one is unsolicited. + // + // Reading one as proof of a session is the bypass: producing a + // frame that derives to its own address costs an attacker nothing, + // so acting on it would let anyone holding a keypair tell firmware + // a session exists that this device would refuse to seal into. The + // phone gates the same frame on holding a session of its own; the + // profile in the spec lists this prefix under what a leaf emits and + // not under what it accepts. Ok(Handled { outbound: Vec::new(), - events: vec![LeafEvent::SessionEstablished { - peer: message.sender.as_str().to_string(), + events: vec![LeafEvent::Ignored { + reason: String::from( + "an acknowledgement arrived for a probe this device never sends", + ), }], }) } else { @@ -530,9 +557,7 @@ impl LeafDevice { ))); } - let mut group = client - .load_group(group_id.as_str().as_bytes()) - .map_err(|_| LeafError::NoSession(sender.to_string()))?; + let mut group = self.load_group(&client, sender, &group_id)?; let inbound = MlsMessage::from_bytes(&envelope.ciphertext) .map_err(|e| LeafError::Mls(format!("sealed payload does not decode: {e:?}")))?; @@ -655,7 +680,17 @@ impl LeafDevice { // that is not there is not an error, which is what makes a fixed // window the right shape rather than an enumeration this seam cannot // offer. - let highest = self.max_epoch(&key)?.unwrap_or(0); + // + // A marker that is missing or unreadable cannot bound anything, and + // anchoring at zero would delete one record, return `Ok`, and leave + // every other epoch's secrets on flash: an erasure the owner asked for + // that reports success and did not happen. The group state names the + // same neighbourhood of epochs and is about to be deleted anyway, so it + // is the fallback anchor. + let highest = match self.max_epoch(&key)? { + Some(highest) => highest, + None => self.current_epoch(&group_id).unwrap_or(0), + }; let floor = highest.saturating_sub(PRIOR_EPOCH_RETENTION + FORGET_EPOCH_SLACK); for epoch in floor..=highest { self.store @@ -673,6 +708,11 @@ impl LeafDevice { } /// The highest epoch id ever written for a group, by storage key. + /// + /// `None` covers both a marker that was never written and one that does + /// not decode. The two are the same answer here, which is safe only + /// because the caller treats `None` as "no bound available" and finds + /// another anchor, rather than as "no epochs". fn max_epoch(&self, group_key: &str) -> Result> { let raw = self .store @@ -761,7 +801,7 @@ impl LeafDevice { /// the same. fn bind_sender_credential( &self, - group: &mls_rs::Group, + group: &Group, index: u32, claimed: &str, ) -> Result<()> { @@ -799,6 +839,44 @@ impl LeafDevice { build_client(&self.identity, &self.store) } + /// Loads the group for a session with `peer`, and says which failure it is. + /// + /// A group that will not load is two different things wearing one error. + /// State that is **absent** is a device that never paired with this peer, + /// or one that unpaired, and re-pairing is the repair. State that is + /// **present and unloadable** is a store handing back bytes this device did + /// not write, which no amount of re-pairing fixes and which a bench needs + /// to be told about rather than sent chasing a pairing problem. + fn load_group( + &self, + client: &Client, + peer: &str, + group_id: &GroupId, + ) -> Result> { + match client.load_group(group_id.as_str().as_bytes()) { + Ok(group) => Ok(group), + Err(e) if self.state_present(group_id)? => Err(LeafError::Storage(format!( + "group state for {peer} is on flash but does not load: {e:?}" + ))), + Err(_) => Err(LeafError::NoSession(peer.to_string())), + } + } + + /// The epoch a group is in, read from its stored state. + /// + /// Best effort by construction: the one caller is the sweep in + /// [`LeafDevice::forget_session`], which needs an anchor when the marker + /// cannot give it one, and a state that will not load leaves nothing to + /// read. A device with neither has nothing above the window to have + /// written. + fn current_epoch(&self, group_id: &GroupId) -> Option { + let client = self.client().ok()?; + client + .load_group(group_id.as_str().as_bytes()) + .ok() + .map(|group| group.current_epoch()) + } + fn group_id(&self, peer: &str) -> Result { Ok(GroupId::for_session( &self.identity.address.to_string(), @@ -815,6 +893,11 @@ impl LeafDevice { /// Whether a session with `peer` exists on flash. pub fn has_session(&self, peer: &str) -> Result { let group_id = self.group_id(peer)?; + self.state_present(&group_id) + } + + /// Whether group state for this id is on flash, whatever its condition. + fn state_present(&self, group_id: &GroupId) -> Result { Ok(self .store .load( @@ -825,10 +908,41 @@ impl LeafDevice { .is_some()) } + /// The peers this device holds records for. + /// + /// Firmware's way to audit what a device accumulated. A session proves who + /// a peer is and never that the owner meant to have them, so a device that + /// has been in a hallway for a year may hold peers nobody chose; this is + /// how they are found, and [`LeafDevice::unpair`] is how they are removed. + /// Not every entry holds a session: a pairing that stopped after the first + /// frame leaves a record too, and those are the slots a new peer recycles. + pub fn peers(&self) -> Result> { + self.peer_index() + } + /// What this device recorded about a peer's capabilities. pub fn peer_env_versions(&self, peer: &str) -> Result> { Ok(self.peer_record(peer)?.env_versions) } + + /// Forgets a peer: its session, its prior epochs, and what it advertised. + /// + /// Exposed because a device that is factory reset or unpaired must be able + /// to forget, and because leaving MLS state behind for a peer the owner + /// removed is the kind of residue that outlives the reason it existed. + /// + /// Also releases the slot it held, so unpairing is how an owner makes room + /// on a device whose peer table is full. + pub fn unpair(&mut self, peer: &str) -> Result<()> { + self.forget_peer(peer)?; + + let mut index = self.peer_index()?; + if index.iter().any(|held| held == peer) { + index.retain(|held| held != peer); + self.save_peer_index(&index)?; + } + Ok(()) + } } impl core::fmt::Debug for LeafDevice { @@ -868,25 +982,3 @@ fn parse_envelope(body: &str) -> Result { serde_json::from_slice(&bytes) .map_err(|e| LeafError::MalformedFrame(format!("base64 envelope: {e}"))) } - -/// Erases every trace of a peer. -/// -/// Exposed because a device that is factory reset or unpaired must be able to -/// forget, and because leaving MLS state behind for a peer the owner removed -/// is the kind of residue that outlives the reason it existed. -impl LeafDevice { - /// Forgets a peer: its session, its prior epochs, and what it advertised. - /// - /// Also the slot it held, so unpairing is how an owner makes room on a - /// device whose peer table is full. - pub fn unpair(&mut self, peer: &str) -> Result<()> { - self.forget_peer(peer)?; - - let mut index = self.peer_index()?; - if index.iter().any(|held| held == peer) { - index.retain(|held| held != peer); - self.save_peer_index(&index)?; - } - Ok(()) - } -} diff --git a/crates/offline-protocol-leaf/src/frames.rs b/crates/offline-protocol-leaf/src/frames.rs index a3de5a8b..e5739ce0 100644 --- a/crates/offline-protocol-leaf/src/frames.rs +++ b/crates/offline-protocol-leaf/src/frames.rs @@ -115,8 +115,12 @@ pub(crate) fn build( // // The other direction is not this crate's to decide. A phone marks its own // frames as needing one and a leaf emits none, so it retries until it - // gives up. Whether a leaf peer is exempt from that machinery or owes an - // acknowledgement is a question for the spec, which today lists neither. + // gives up, and every retry of a sealed frame lands here as a replay the + // device refuses: airtime spent, and an error stream firmware cannot tell + // from an attack. Whether a leaf peer is exempt from that machinery or owes + // an acknowledgement is a question for the spec, which today lists neither. + // Tracked as issue 402: + // https://github.com/Offline-Protocol/offline-protocol-sdk/issues/402 message.requires_ack = false; Ok(message) } diff --git a/crates/offline-protocol-leaf/src/lib.rs b/crates/offline-protocol-leaf/src/lib.rs index 7ad67dff..46ac1196 100644 --- a/crates/offline-protocol-leaf/src/lib.rs +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -46,7 +46,7 @@ //! # } //! ``` //! -//! # Three obligations this crate cannot discharge for you +//! # Four obligations this crate cannot discharge for you //! //! **A time source at pairing.** Every entry point that needs a clock takes //! `now_unix_secs`. A device that supplies something wrong emits a key package @@ -64,6 +64,16 @@ //! that lies is the one remaining way to reuse an AEAD nonce after a power //! cut. //! +//! **Authorization.** A session proves *who* a peer is and never that they may +//! do anything. Every gate in this crate answers the first question, and any +//! address in radio range can complete a pairing, because producing a key that +//! derives to its own address costs nothing. So firmware decides when the radio +//! accepts a new pairing, and firmware decides what a message from a given peer +//! may actuate, by the address on the event. A lock that opens for whatever +//! arrives on an established session opens for anyone patient enough to pair +//! with it. [`LeafDevice::peers`] is how firmware audits what a device +//! accumulated and [`LeafDevice::unpair`] is how it removes one. +//! //! # One writer //! //! Every operation that advances state takes `&mut self`, so a device is one diff --git a/crates/offline-protocol-leaf/src/store.rs b/crates/offline-protocol-leaf/src/store.rs index 4f81056e..f46b4d8d 100644 --- a/crates/offline-protocol-leaf/src/store.rs +++ b/crates/offline-protocol-leaf/src/store.rs @@ -24,6 +24,16 @@ //! in the same words, for the same reason. A flash driver that buffers a write //! and reports success satisfies the type and breaks the rule. //! +//! # What it bounds, and what it does not +//! +//! This crate bounds how much a store *holds*: peers, unspent key packages and +//! prior-epoch records each have a ceiling. It does not bound how often a store +//! is *written*, and an inbound key package costs a handful of writes to a part +//! whose flash has a finite number of them. A device exposed to strangers wants +//! its pairing window controlled by firmware rather than left open, which is +//! the same conclusion the authorization obligation reaches from the other +//! direction. +//! //! # Where the key material should live //! //! Everything written through this trait is secret: the identity private key, diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index 3a7d8212..886b50e9 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use offline_protocol_core::{Message, MessagePriority, UserId}; use offline_protocol_leaf::{ - store::{KEY_TYPE_GROUP_EPOCH, KEY_TYPE_IDENTITY, KEY_TYPE_PEER}, + store::{KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_IDENTITY, KEY_TYPE_PEER}, LeafDevice, LeafError, LeafEvent, LeafStore, MemoryStore, StoreError, }; use offline_protocol_mls::{storage::InMemoryStorage, MlsManager, MlsStorage}; @@ -598,6 +598,54 @@ fn a_probe_without_a_session_is_not_answered() { ); } +#[test] +fn an_unsolicited_acknowledgement_does_not_establish_a_session() { + let stranger = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + + // A leaf emits acknowledgements and never probes, so it never has one + // outstanding and every inbound acknowledgement is unsolicited. Acting on + // one would hand a session to anyone holding a keypair: the frame is + // signed, and a signature that derives to its own address costs nothing to + // produce. The phone gates the same frame on holding a session of its own, + // and the leaf profile lists this prefix under what a device emits rather + // than under what it accepts. + let frame = phone_control_frame( + &stranger, + &device.address().to_string(), + prefixes::SESSION_CONFIRM_ACK.to_string(), + ); + + let handled = device + .handle(&frame, NOW) + .expect("the acknowledgement is handled"); + assert!( + !handled + .events + .iter() + .any(|event| matches!(event, LeafEvent::SessionEstablished { .. })), + "an unsolicited acknowledgement established a session: {:?}", + handled.events + ); + assert!( + handled.outbound.is_empty(), + "an unsolicited acknowledgement produced a frame" + ); + assert!( + !device + .has_session(&stranger.address) + .expect("session check"), + "an unsolicited acknowledgement left a session on flash" + ); + + // And what firmware would have acted on is refused where it counts: there + // is no session to seal into, whatever the event said. + let err = device + .seal(&stranger.address, "unlock", NOW) + .expect_err("sealed to a peer that only sent an acknowledgement"); + assert!(matches!(err, LeafError::NoSession(_)), "produced {err:?}"); +} + #[test] fn a_peer_that_already_has_a_key_package_is_not_sent_another() { let phone = new_phone(); @@ -948,6 +996,81 @@ fn unpairing_erases_the_prior_epoch_records_too() { ); } +#[test] +fn a_corrupt_group_state_is_not_reported_as_a_missing_session() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + pair(&phone, &mut device); + + // A store handing back bytes this device did not write. Reported as a + // missing session it sends a bench chasing a re-pair, which is the one + // repair that cannot work: the pairing is fine and the flash is not. + let key = store + .keys_of(KEY_TYPE_GROUP_STATE) + .into_iter() + .next() + .expect("pairing wrote group state"); + store + .store(KEY_TYPE_GROUP_STATE, &key, b"not a group") + .expect("store"); + + let err = device + .seal(&phone.address, "unlock", NOW) + .expect_err("a corrupt group state still produced a frame"); + assert!( + matches!(err, LeafError::Storage(_)), + "a corrupt group state produced {err:?}" + ); + + // The control: an absent session is still the other error, so this test is + // not simply asserting that everything is a storage failure. + let stranger = new_phone(); + let err = device + .seal(&stranger.address, "unlock", NOW) + .expect_err("sealed to a peer with no session"); + assert!( + matches!(err, LeafError::NoSession(_)), + "an absent session produced {err:?}" + ); +} + +#[test] +fn unpairing_sweeps_epochs_a_corrupt_marker_cannot_bound() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + commit_to(&phone, &mut device, &device_address); + commit_to(&phone, &mut device, &device_address); + assert!( + !store.epoch_records().is_empty(), + "the setup wrote no epoch records, so this test would pass vacuously" + ); + + // The marker is what bounds the sweep, and it is one more record on a part + // whose flash can hand back something else. Anchoring at zero when it does + // deletes one record, returns `Ok`, and leaves the rest of the epochs' + // secrets on flash under a name the next session with this peer answers to. + let marker = store + .keys_of(KEY_TYPE_GROUP_EPOCH) + .into_iter() + .find(|id| id.ends_with(":max")) + .expect("a marker was written"); + store + .store(KEY_TYPE_GROUP_EPOCH, &marker, b"not eight bytes") + .expect("store"); + + device.unpair(&phone.address).expect("device unpairs"); + + assert!( + store.epoch_records().is_empty(), + "a corrupt marker left epoch records behind: {:?}", + store.epoch_records() + ); +} + /// A store that refuses to write one key, to cut power at a chosen moment. struct CutStore { inner: MemoryStore, diff --git a/docs/architecture.md b/docs/architecture.md index 649ccdb0..12b86f2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,8 +131,10 @@ opens what arrives, answers and persists. See - `LeafStore` - one blob-storage seam a device implements over its secure key storage - Key package minting with the backdated `not_before` and supplied timestamp a device needs in order to pair at all -**Three obligations it cannot discharge for the integrator**: a time source at -pairing, real hardware entropy behind `getrandom`, and durable atomic storage. +**Four obligations it cannot discharge for the integrator**: a time source at +pairing, real hardware entropy behind `getrandom`, durable atomic storage, and +authorization, because a session proves who a peer is and never that the owner +meant them. Persist-before-emit is enforced structurally rather than documented: every operation that advances the ratchet writes before it returns a frame, because a state rolled back by a power cut reuses an AEAD nonce. diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index a82bd08b..72072ffd 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -21,10 +21,12 @@ The decision behind this, and the measurements that justified it, are in rather than implement twice are in [ADR 0022](../adr/0022-one-sealed-layer-shared-with-the-leaf.md). -## Four obligations +## Five obligations These hold before any mechanism below makes sense. Three of them are invisible -in a passing build and expensive to discover on a bench. +in a passing build and expensive to discover on a bench. The fifth is invisible +in a working deployment as well, because a device that grants too much works +perfectly until somebody asks it to. ### 1. A static artifact carries an address and a key, never a key package @@ -81,6 +83,25 @@ firmware supplies. Measurement harnesses in this repository register a counter in that slot so an image that never executes can be linked and sized. That stub must never reach firmware, and it is documented as such where it appears. +### 5. A session is authentication, not authorization + +A leaf MUST NOT treat an established session as permission to act. It MUST +decide what a peer may do from that peer's address, and the integrator MUST +control when the device accepts a new pairing at all. + +Every gate in this protocol answers "is this peer the address it claims to be". +None of them answers "did the owner mean this peer". Producing a key that +derives to its own address costs nothing, so a device left open to pairing ends +up holding sessions with whoever was in range, every one of them +cryptographically impeccable. A lock that opens for any message on an +established session opens for anyone patient enough to pair with it, and every +frame in that exchange verifies. + +This is the obligation a test cannot fail for you: the device works, the peer +is authenticated, the ciphertext is sound. The bound is an implementation +choice, whether that is a pairing button, a commissioning window, or an owner +list written at first pairing, and this chapter requires only that one exists. + ## The pairing exchange Pairing is the ordinary session establishment described in @@ -160,6 +181,14 @@ and the silence afterwards is indistinguishable from a quiet link, so the peer never learns. Staying quiet leaves it unconfirmed, which is a state it has a path out of. +A leaf MUST NOT treat an inbound `__MLS_CONFIRM_ACK__` as evidence of a +session, which is why the frame appears above under what a leaf emits and not +under what it accepts. A leaf emits acknowledgements and never probes, so it +never has one outstanding and every inbound one is unsolicited. Acting on one +would let any holder of a keypair assert that a session exists: the frame is +signed, and a signature that derives to its own address costs nothing to +produce. The frame is answered by a peer that probed, and a leaf is not one. + Per-commit cost on the device is two elliptic-curve operations. Per-message cost is symmetric only. @@ -183,7 +212,9 @@ Remembering the frames already acted on costs one bounded list per peer and denies the repeat. It does not close replay: a frame older than that list can still be spent once. **Closing it needs a freshness field inside the signed payload**, which is a change to the wire and to both ends rather than to a -device, and is an open gap rather than a decision this chapter has taken. +device, and is an open gap rather than a decision this chapter has taken. It is +tracked as +[issue 403](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/403). Letting a device originate Update proposals would make it self-healing on its own schedule. That is deliberately outside this version. From 96ddcce7f607806f1e924f884e8f7acbdbc074ce Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 09:36:14 +0530 Subject: [PATCH 4/9] fix(leaf): a marker that outran its state locks the door for good The storage adapter said a power cut mid-write "costs nothing". That is true of the epoch records. It is not true of the marker sitting beside them, and the marker is the one that matters. mls-rs sequences every epoch insert against max_epoch_id: the id has to be exactly one above what storage reports, or the insert is refused. Nothing in this crate caches either value, deliberately, so both come off flash on every operation. Land the marker, lose power before the state follows, and the two disagree forever. The retry offers the epoch id the marker has already counted, so every commit from that point on is refused. Reversing the order does not help, it moves the same wedge into the other window. On a door lock that is not a dropped frame. The device stops opening anything its peer sends until the peer's own recovery gives up and drives a full reset, and while that plays out firmware sees an error stream it cannot tell from an attack. So the marker goes inside the state entry, where the seam's per-entry atomicity covers both or neither. The separate high-water record stays, because it has a different job: it outlives the state and bounds the erasure sweep on unpair, which is the one thing the in-state marker cannot do. Three smaller ones from the same pass. `peers()` is documented as the authorization audit surface and did not list a peer that paired through a Welcome, which is the ordinary route; a session nobody can enumerate is one nobody can revoke. A confirmation probe was answered on bytes being present rather than loadable, which confirms a session the device cannot open a single frame of, and that is precisely what the gate was written to prevent. And `resume` trusted the stored public key instead of deriving it from the secret beside it, so a device could come back at an address no peer knows it by and say nothing about it. Every new test was checked against the pre-fix code and fails there. The sweep-anchor one earns its keep by mutation: delete the fallback it guards and it goes red. While at it, one helper for "this frame produced nothing" instead of three copies of it, and an empty [lib] section nobody ever filled in. --- CHANGELOG.md | 16 +- crates/offline-protocol-leaf/Cargo.toml | 2 - crates/offline-protocol-leaf/src/adapters.rs | 183 ++++++++--- crates/offline-protocol-leaf/src/device.rs | 130 +++++--- crates/offline-protocol-leaf/src/identity.rs | 26 +- .../tests/phone_interop.rs | 285 ++++++++++++++++++ docs/spec/leaf-provisioning.md | 19 +- 7 files changed, 569 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b2e57..13ea2a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,18 @@ archived by series under [docs/changelog/](docs/changelog/); see the failing store and asserts both that nothing is emitted and that the write was actually attempted, so it cannot pass by short-circuiting earlier. + The seam is atomic per entry rather than across a set, so what a cut lands + between is chosen rather than left to chance. Prior-epoch records go down + before the state, because a state with records it does not yet reference + costs nothing while the reverse loses the out-of-order tolerance a lossy + radio needs. The **epoch marker mls-rs sequences against travels inside the + state entry**, because ordering cannot make those two safe in either + direction: a marker that reached flash without its state refuses every commit + that follows, permanently, and the device goes deaf to its peer until that + peer drives a full reset. A separate high-water record survives the state and + bounds the erasure sweep on unpair, which is the one job the in-state marker + cannot do. + Four obligations stay with the integrator, and the API is shaped so none can be forgotten silently: every entry point needing a clock takes `now_unix_secs` (a device that lets an MLS library read a clock it does not @@ -196,7 +208,9 @@ archived by series under [docs/changelog/](docs/changelog/); see the radio accepts one and what a given peer's messages may actuate. A lock that opens for whatever arrives on an established session opens for anyone patient enough to pair with it. `LeafDevice::peers` is how firmware audits what a - device accumulated and `unpair` is how it removes one. + device accumulated and `unpair` is how it removes one, and every route to a + session puts the peer on that list, because a session firmware cannot see is + one it can neither review nor revoke. - **`Message::from_parts` in `offline-protocol-core`**, which is `Message::new` with its two ambient inputs, the clock and the entropy, made diff --git a/crates/offline-protocol-leaf/Cargo.toml b/crates/offline-protocol-leaf/Cargo.toml index 48d8168d..a2a10112 100644 --- a/crates/offline-protocol-leaf/Cargo.toml +++ b/crates/offline-protocol-leaf/Cargo.toml @@ -95,5 +95,3 @@ getrandom = { version = "0.2", default-features = false, optional = true } [dev-dependencies] offline-protocol-mls = { path = "../offline-protocol-mls", version = "0.23.0" } serde_json = { version = "1.0", default-features = false, features = ["std"] } - -[lib] diff --git a/crates/offline-protocol-leaf/src/adapters.rs b/crates/offline-protocol-leaf/src/adapters.rs index 00a03465..e48b1e46 100644 --- a/crates/offline-protocol-leaf/src/adapters.rs +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -74,6 +74,85 @@ fn max_epoch_key(group_id: &[u8]) -> String { format!("{}:max", hex(group_id)) } +/// How many bytes of a state entry carry the sequencing marker: a presence +/// flag and a big-endian epoch id. +const STATE_MARKER_LEN: usize = 9; + +/// Puts the sequencing marker in front of the state mls-rs handed us. +/// +/// # Why the marker rides inside the state entry +/// +/// mls-rs sequences every epoch insert against +/// [`GroupStateStorage::max_epoch_id`]: an inserted record's id must be +/// exactly one above what that returns, or the operation is refused. Nothing +/// in this crate caches either value, so both are read from flash on every +/// operation, and the two therefore have to move together or not at all. +/// +/// Held as separate entries they cannot. This seam is atomic per entry rather +/// than across a set, so a cut or a failed write between a marker record and +/// the state leaves the marker one ahead of the state it describes, and from +/// there **every later commit is refused, permanently**: the retry inserts the +/// epoch id the marker has already counted. Reversing the order does not fix +/// it, it moves the same wedge into the other window. What that costs is not a +/// lost frame: the device stops opening anything its peer sends until the +/// peer's own recovery drives a full reset, which on a door lock is an owner +/// standing outside it. +/// +/// One entry closes the window. The marker a validation reads is a slice of +/// the same bytes as the state it validates against, so there is no moment in +/// which the two disagree. +fn encode_state_entry(marker: Option, state: &[u8]) -> Vec { + let mut out = Vec::with_capacity(STATE_MARKER_LEN + state.len()); + match marker { + Some(epoch) => { + out.push(1); + out.extend_from_slice(&epoch.to_be_bytes()); + } + // A group that has left no epoch behind yet. mls-rs skips the + // sequencing check entirely for `None`, which is what a group that was + // just joined needs. + None => out.extend_from_slice(&[0u8; STATE_MARKER_LEN]), + } + out.extend_from_slice(state); + out +} + +/// Splits a state entry into its marker and the state mls-rs stored. +fn decode_state_entry(raw: &[u8]) -> Result<(Option, &[u8]), StoreError> { + if raw.len() < STATE_MARKER_LEN { + return Err(StoreError::Corrupt(format!( + "group state entry is {} bytes, too short to carry its epoch marker", + raw.len() + ))); + } + let (header, state) = raw.split_at(STATE_MARKER_LEN); + let marker = match header[0] { + 0 => None, + 1 => { + let mut epoch = [0u8; 8]; + epoch.copy_from_slice(&header[1..STATE_MARKER_LEN]); + Some(u64::from_be_bytes(epoch)) + } + other => { + return Err(StoreError::Corrupt(format!( + "group state entry carries epoch marker flag {other}, expected 0 or 1" + ))) + } + }; + Ok((marker, state)) +} + +/// The sequencing marker in a group's state entry, if it can be read. +/// +/// For the erasure sweep in [`LeafDevice::unpair`](crate::LeafDevice::unpair), +/// which needs an anchor rather than a guarantee: every failure here is the +/// same answer, "this record cannot bound anything", and the caller looks +/// elsewhere. +pub(crate) fn state_marker(store: &Arc, group_key: &str) -> Option { + let raw = store.load(KEY_TYPE_GROUP_STATE, group_key).ok()??; + decode_state_entry(&raw).ok().and_then(|(marker, _)| marker) +} + /// Carries [`GroupStateStorage`] onto the device's blob store. #[derive(Clone)] pub(crate) struct GroupStateAdapter { @@ -90,10 +169,15 @@ impl GroupStateStorage for GroupStateAdapter { type Error = StoreError; fn state(&self, group_id: &[u8]) -> Result>>, Self::Error> { - Ok(self + let Some(raw) = self .store .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))? - .map(Zeroizing::new)) + else { + return Ok(None); + }; + let raw = Zeroizing::new(raw); + let (_, state) = decode_state_entry(&raw)?; + Ok(Some(Zeroizing::new(state.to_vec()))) } fn epoch( @@ -119,18 +203,20 @@ impl GroupStateStorage for GroupStateAdapter { /// the device would come back having lost exactly the out-of-order /// tolerance a lossy radio needs. /// + /// The state and the marker that sequences it go down as **one entry**, + /// because ordering cannot make those two safe in either direction. See + /// [`encode_state_entry`] for what a torn write between them costs. + /// /// The caller does not emit anything until this returns `Ok`, so a failure /// here is a frame that was never sent rather than state that fell behind /// one that was. /// - /// Expired records are dropped **after** the state write, and their - /// failures are not propagated. Both follow from the same rule. Deleting - /// first would let a cut leave a state beside fewer prior epochs than it - /// references, which is the direction this ordering exists to avoid; and - /// once the state write has returned, the write the caller is waiting on - /// is durable, so reporting a failed housekeeping delete as an error would - /// suppress a frame whose state is already on flash. A record that - /// survives a failed delete is swept by + /// The high-water record and the expired-record deletes both happen + /// **after** the state write, and neither propagates a failure. Both + /// follow from the same rule: once the state write has returned, the write + /// the caller is waiting on is durable, so reporting a failure in + /// housekeeping would suppress a frame whose state is already on flash. + /// A record that survives a failed delete is swept by /// [`LeafDevice::unpair`](crate::LeafDevice::unpair). fn write( &mut self, @@ -138,7 +224,8 @@ impl GroupStateStorage for GroupStateAdapter { epoch_inserts: Vec, epoch_updates: Vec, ) -> Result<(), Self::Error> { - let mut highest: Option = None; + let previous = self.max_epoch_id(&state.id)?; + let mut marker = previous; for record in epoch_inserts.iter().chain(epoch_updates.iter()) { self.store.store( @@ -146,31 +233,40 @@ impl GroupStateStorage for GroupStateAdapter { &epoch_key(&state.id, record.id), &record.data, )?; - highest = Some(highest.map_or(record.id, |h: u64| h.max(record.id))); + marker = Some(marker.map_or(record.id, |held: u64| held.max(record.id))); } - if let Some(highest) = highest { - let previous = self.max_epoch_id(&state.id)?.unwrap_or(0); - if highest >= previous { - self.store.store( - KEY_TYPE_GROUP_EPOCH, - &max_epoch_key(&state.id), - &highest.to_be_bytes(), - )?; - } - } + self.store.store( + KEY_TYPE_GROUP_STATE, + &state_key(&state.id), + &encode_state_entry(marker, &state.data), + )?; - self.store - .store(KEY_TYPE_GROUP_STATE, &state_key(&state.id), &state.data)?; + // The erasure high-water mark, which is a different job from the + // marker above and is why it is a different record rather than the + // same one read twice. + // + // The marker inside the state entry answers "what may be inserted + // next", and it goes away with the state it lives in. This record + // answers "how far up did this group ever write", which + // [`LeafDevice::unpair`](crate::LeafDevice::unpair) needs in order to + // bound a sweep over records it cannot enumerate, and it has to be + // readable in the one case the other is not: a state entry the part + // hands back as something else. It is deliberately never lowered by + // trimming, so it stays above every record that could still be there. + let advanced_to = if marker == previous { None } else { marker }; + if let Some(high) = advanced_to { + let _ = self.store.store( + KEY_TYPE_GROUP_EPOCH, + &max_epoch_key(&state.id), + &high.to_be_bytes(), + ); + } // One delete per record that entered, which is all the window can // lose: mls-rs requires each inserted epoch id to be exactly one above // the highest stored, so the window advances by the number of inserts // and never skips. Updates rewrite records already inside it. - // - // The marker is deliberately left alone. `max_epoch_id` is what - // mls-rs checks that next id against, so it has to stay the highest - // epoch ever written rather than the highest still held. for record in epoch_inserts.iter() { if let Some(expired) = record.id.checked_sub(PRIOR_EPOCH_RETENTION) { let _ = self @@ -182,22 +278,25 @@ impl GroupStateStorage for GroupStateAdapter { Ok(()) } + /// The highest epoch id this group has left behind. + /// + /// Read from the same entry as the state it belongs to, because mls-rs + /// sequences every epoch insert against this value and a marker that got + /// ahead of its state wedges the group permanently. See + /// [`encode_state_entry`]. + /// + /// A group with no state has no marker, and that `None` is what makes + /// mls-rs skip the sequencing check for a group that was just joined. fn max_epoch_id(&self, group_id: &[u8]) -> Result, Self::Error> { - let raw = self + let Some(raw) = self .store - .load(KEY_TYPE_GROUP_EPOCH, &max_epoch_key(group_id))?; - match raw { - None => Ok(None), - Some(bytes) => { - let array: [u8; 8] = bytes.as_slice().try_into().map_err(|_| { - StoreError::Corrupt(format!( - "max epoch record is {} bytes, expected 8", - bytes.len() - )) - })?; - Ok(Some(u64::from_be_bytes(array))) - } - } + .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))? + else { + return Ok(None); + }; + let raw = Zeroizing::new(raw); + let (marker, _) = decode_state_entry(&raw)?; + Ok(marker) } } diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index b60ddc0f..a6bfbae2 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -259,6 +259,12 @@ impl LeafDevice { .save(&self.store, peer) .map_err(|e| LeafError::Storage(e.to_string()))?; + // Handing out a package is the moment a pairing with this peer becomes + // possible, so it is the moment the peer becomes something firmware + // should be able to see in [`LeafDevice::peers`]. Inbound exchanges + // reach here having already been admitted, where this is a no-op. + self.index_peer(peer)?; + Ok(message) } @@ -361,21 +367,11 @@ impl LeafDevice { // phone gates the same frame on holding a session of its own; the // profile in the spec lists this prefix under what a leaf emits and // not under what it accepts. - Ok(Handled { - outbound: Vec::new(), - events: vec![LeafEvent::Ignored { - reason: String::from( - "an acknowledgement arrived for a probe this device never sends", - ), - }], - }) + Ok(ignored( + "an acknowledgement arrived for a probe this device never sends", + )) } else { - Ok(Handled { - outbound: Vec::new(), - events: vec![LeafEvent::Ignored { - reason: String::from("frame carries no prefix this device answers"), - }], - }) + Ok(ignored("frame carries no prefix this device answers")) } } @@ -512,6 +508,15 @@ impl LeafDevice { .write_to_storage() .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; + // A session exists from here, so the peer has to be in the index + // whatever route it took to get one. Ordinarily it is already there, + // put there by the exchange that handed out the key package this + // Welcome spent; what this covers is that entry having been recycled + // in between, which is a slot a peer with no session is eligible to + // lose. A session firmware cannot see in [`LeafDevice::peers`] is one + // it cannot audit and cannot [`LeafDevice::unpair`]. + self.index_peer(sender)?; + // The confirmation is a group-aware decrypt, sealed inside an ordinary // envelope. A peer that created a session of its own confirms only on // a successful decrypt, so a plaintext acknowledgement would leave it @@ -627,15 +632,24 @@ impl LeafDevice { frames::verify_control_frame(message)?; let sender = message.sender.as_str(); - if !self.has_session(sender)? { - return Ok(Handled { - outbound: Vec::new(), - events: vec![LeafEvent::Ignored { - reason: String::from( - "a confirmation probe arrived for a peer this device has no session with", - ), - }], - }); + // Loaded rather than counted. "State is on flash" and "this device can + // decrypt" are different claims, and an acknowledgement asserts the + // second: a device that answered on the strength of bytes it cannot + // load would confirm a session it cannot open one frame of, which is + // the failure staying quiet exists to avoid. A state that is present + // and unloadable is also not silence, it is a store handing back what + // this device did not write, and it propagates for the reason + // [`LeafDevice::load_group`] separates the two. + let client = self.client()?; + let group_id = self.group_id(sender)?; + match self.load_group(&client, sender, &group_id) { + Ok(_) => {} + Err(LeafError::NoSession(_)) => { + return Ok(ignored( + "a confirmation probe arrived for a peer this device has no session with", + )) + } + Err(e) => return Err(e), } let mut ack = frames::build( @@ -674,23 +688,28 @@ impl LeafDevice { let group_id = self.group_id(peer)?; let key = crate::adapters::hex(group_id.as_str().as_bytes()); - // The marker is the highest epoch ever written, so it is the top of - // the sweep. Trimming keeps only the newest few below it; the slack - // covers a delete that failed while it was doing so. A delete of a key - // that is not there is not an error, which is what makes a fixed - // window the right shape rather than an enumeration this seam cannot - // offer. + // The anchor is the top of the sweep. Trimming keeps only the newest + // few below it; the slack covers a delete that failed while it was + // doing so. A delete of a key that is not there is not an error, which + // is what makes a fixed window the right shape rather than an + // enumeration this seam cannot offer. // - // A marker that is missing or unreadable cannot bound anything, and - // anchoring at zero would delete one record, return `Ok`, and leave - // every other epoch's secrets on flash: an erasure the owner asked for - // that reports success and did not happen. The group state names the - // same neighbourhood of epochs and is about to be deleted anyway, so it - // is the fallback anchor. - let highest = match self.max_epoch(&key)? { - Some(highest) => highest, - None => self.current_epoch(&group_id).unwrap_or(0), + // Three sources, tried in order, because an anchor that is missing or + // unreadable cannot bound anything and anchoring at zero would delete + // one record, return `Ok`, and leave every other epoch's secrets on + // flash: an erasure the owner asked for that reports success and did + // not happen. The marker inside the state entry is first because it is + // the one written atomically with the state. The separate high-water + // record is second and covers the case that one cannot: a state entry + // the part hands back as something else. The group's own epoch is + // last, and names the same neighbourhood. + let anchor = match crate::adapters::state_marker(&self.store, &key) { + Some(highest) => Some(highest), + None => self.max_epoch(&key)?, }; + let highest = anchor + .or_else(|| self.current_epoch(&group_id)) + .unwrap_or(0); let floor = highest.saturating_sub(PRIOR_EPOCH_RETENTION + FORGET_EPOCH_SLACK); for epoch in floor..=highest { self.store @@ -757,6 +776,27 @@ impl LeafDevice { .map_err(|e| LeafError::Storage(e.to_string())) } + /// Records `peer` in the index without holding it to [`MAX_PEERS`]. + /// + /// The bound exists to stop a stranger spending a device's flash, and + /// neither caller here is a stranger: one is firmware choosing to pair, + /// the other is a Welcome, which only lands on a key package this device + /// minted for that peer in the first place. + /// + /// It is the index rather than the bound that has to be complete. A peer + /// missing from it holds a session nothing can audit and + /// [`LeafDevice::unpair`] cannot be pointed at, and a session is exactly + /// what the authorization obligation asks firmware to review, since none + /// of the gates in this crate answers whether the owner meant this peer. + fn index_peer(&mut self, peer: &str) -> Result<()> { + let mut index = self.peer_index()?; + if index.iter().any(|held| held == peer) { + return Ok(()); + } + index.push(peer.to_string()); + self.save_peer_index(&index) + } + /// Makes room for `peer` in the bounded set, or refuses. /// /// A peer already held is admitted for free. A new one at capacity takes @@ -953,6 +993,20 @@ impl core::fmt::Debug for LeafDevice { } } +/// A frame that produced nothing, and why. +/// +/// Surfaced rather than swallowed so a bench can tell "refused" from "silently +/// dropped", which are the two states a pairing failure looks like from +/// outside a device with no console. +fn ignored(reason: &str) -> Handled { + Handled { + outbound: Vec::new(), + events: vec![LeafEvent::Ignored { + reason: String::from(reason), + }], + } +} + fn parse_app_id(app_id: &str) -> Result { AppId::new(app_id).map_err(|e| LeafError::MalformedFrame(format!("app id: {e}"))) } diff --git a/crates/offline-protocol-leaf/src/identity.rs b/crates/offline-protocol-leaf/src/identity.rs index 72010689..7542af3e 100644 --- a/crates/offline-protocol-leaf/src/identity.rs +++ b/crates/offline-protocol-leaf/src/identity.rs @@ -112,6 +112,17 @@ impl Identity { /// complete or missing the secret, and the second of those is what `open` /// recovers from. Swapping those two writes makes this function the half /// of a deadlock. + /// + /// # Why the pair is checked rather than trusted + /// + /// The two entries are stored separately, and the whole reason this crate + /// has a durability contract is that a part can hand back something other + /// than what was written. A device that resumed on a public key its secret + /// does not derive to would take an address no peer knows it by and sign + /// frames that verify nowhere: every gate in this protocol refuses it, and + /// every one of them names a different failure than the one that happened, + /// which is a bench chasing a pairing problem that is really a flash + /// problem. One scalar multiplication per boot buys the right error. pub(crate) fn resume(store: &Arc) -> Result { let secret = store .load(KEY_TYPE_IDENTITY, KEY_ID_SECRET) @@ -122,10 +133,23 @@ impl Identity { .map_err(|e| LeafError::Storage(e.to_string()))? .ok_or(LeafError::NotProvisioned)?; + let secret = SignatureSecretKey::from(secret); let public = SignaturePublicKey::from(public); + + let derived = suite_provider()? + .signature_key_derive_public(&secret) + .map_err(|e| { + LeafError::Crypto(format!("cannot derive a public key from the secret: {e:?}")) + })?; + if derived.as_bytes() != public.as_bytes() { + return Err(LeafError::Storage(alloc::string::String::from( + "the stored public key is not the one this device's secret derives to", + ))); + } + let address = derive_address(public.as_bytes())?; Ok(Self { - secret: SignatureSecretKey::from(secret), + secret, public, address, }) diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index 886b50e9..a973883a 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -1382,3 +1382,288 @@ fn a_welcome_whose_body_lies_about_its_group_is_refused() { "the refused welcome still left a session on flash" ); } + +/// A store that refuses writes of one key type while armed. +/// +/// A power cut lands between two entries rather than inside one, and this is +/// how that looks to a caller: everything before the cut is durable, the entry +/// at the cut is not, and the device is asked to carry on afterwards. +struct TornStore { + inner: MemoryStore, + refuse_type: Mutex>, + refusals: Mutex, +} + +impl TornStore { + fn new() -> Self { + Self { + inner: MemoryStore::new(), + refuse_type: Mutex::new(None), + refusals: Mutex::new(0), + } + } + + fn cut(&self, key_type: &str) { + *self.refuse_type.lock().expect("lock") = Some(key_type.to_string()); + } + + fn restore_power(&self) { + *self.refuse_type.lock().expect("lock") = None; + } + + fn refusals(&self) -> usize { + *self.refusals.lock().expect("lock") + } +} + +impl LeafStore for TornStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + if self.refuse_type.lock().expect("lock").as_deref() == Some(key_type) { + *self.refusals.lock().expect("lock") += 1; + return Err(StoreError::Store("power cut".to_string())); + } + self.inner.store(key_type, key_id, data) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + self.inner.load(key_type, key_id) + } + + fn delete(&self, key_type: &str, key_id: &str) -> Result<(), StoreError> { + self.inner.delete(key_type, key_id) + } +} + +#[test] +fn a_cut_between_the_epoch_records_and_the_state_does_not_wedge_the_session() { + let phone = new_phone(); + let store = Arc::new(TornStore::new()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + // A healthy commit first, so the group has a marker to get ahead of. The + // wedge this guards needs a marker that already exists. + commit_to(&phone, &mut device, &device_address); + + // The cut: the commit's epoch records reach flash, the state does not. + // mls-rs sequences every later insert against the marker, so a marker that + // landed here without its state refuses every commit that follows, for + // good: the retry offers the epoch id the marker has already counted. + store.cut(KEY_TYPE_GROUP_STATE); + let group_id = GroupId::for_session(&phone.address, &device_address).expect("pair group id"); + let commit = phone.manager.update_keys(&group_id).expect("phone commits"); + let frame = phone_sealed_frame(&phone, &device_address, &commit); + let err = device + .handle(&frame, NOW) + .expect_err("a state write that failed still reported success"); + assert!( + matches!(err, LeafError::Storage(_)), + "a cut state write produced {err:?}" + ); + + // The control: the write really was refused, so this test is exercising + // the torn window rather than passing because nothing was attempted. + assert!( + store.refusals() > 0, + "no state write was attempted, so this proves nothing about the window" + ); + + // Power comes back and the phone retries the frame it never saw answered, + // which is what a radio does with anything unacknowledged. + store.restore_power(); + let retried = device + .handle(&frame, NOW) + .expect("the retried commit was refused, so the session is wedged"); + assert!( + retried + .events + .iter() + .any(|event| matches!(event, LeafEvent::CommitApplied { .. })), + "the retried commit did not apply: {:?}", + retried.events + ); + + // And the session is a working one rather than one that merely stopped + // reporting errors. + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let app_frame = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&app_frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); + + // The device answers too, so its own ratchet advanced rather than merely + // its reader. + let answer = device + .seal(&phone.address, "unlocked", NOW) + .expect("device seals"); + let opened = phone + .manager + .decrypt_from_user(&envelope_of(&answer), &device_address) + .expect("phone opens the answer"); + assert_eq!(opened.as_deref(), Some(&b"unlocked"[..])); +} + +#[test] +fn a_peer_that_paired_through_a_welcome_is_in_the_audit_list() { + let phone = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + pair(&phone, &mut device); + + // Authorization is the obligation this crate cannot discharge, and + // `peers` is what it offers firmware instead: the list of who a device + // ended up holding. A session missing from it is one nobody can audit and + // nobody can point `unpair` at, and advertise-then-Welcome is the ordinary + // way a session comes to exist, not an unusual one. + assert!( + device.has_session(&phone.address).expect("session check"), + "the pairing left no session, so this test would pass vacuously" + ); + let peers = device.peers().expect("peers"); + assert!( + peers.contains(&phone.address), + "a peer this device holds a session with is missing from the audit list: {peers:?}" + ); + + // And the list is actionable: what it names can be removed. + device.unpair(&phone.address).expect("device unpairs"); + assert!( + !device.has_session(&phone.address).expect("session check"), + "unpairing a peer from the audit list left its session" + ); + assert!( + device.peers().expect("peers").is_empty(), + "unpairing left the peer in the audit list" + ); +} + +#[test] +fn a_probe_against_a_group_state_that_will_not_load_is_not_answered() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + pair(&phone, &mut device); + + // Bytes on flash are not a session. A peer confirms its session on the + // acknowledgement and then flushes everything it queued into it, so a + // device that answered on the strength of a state it cannot load would + // confirm a session it cannot open one frame of, and its silence + // afterwards is indistinguishable from a quiet link. + let key = store + .keys_of(KEY_TYPE_GROUP_STATE) + .into_iter() + .next() + .expect("pairing wrote group state"); + store + .store(KEY_TYPE_GROUP_STATE, &key, b"not a group") + .expect("store"); + + let probe = phone_control_frame( + &phone, + &device.address().to_string(), + prefixes::SESSION_CONFIRM_PROBE.to_string(), + ); + let err = device + .handle(&probe, NOW) + .expect_err("a device with an unloadable session confirmed one anyway"); + + // And it is reported as what it is. A store handing back bytes this device + // did not write is not a missing session, and sending a bench to re-pair + // is sending it after the one repair that cannot work. + assert!( + matches!(err, LeafError::Storage(_)), + "an unloadable state produced {err:?}" + ); +} + +#[test] +fn a_resumed_device_refuses_a_public_key_its_secret_did_not_make() { + let store: Arc = Arc::new(MemoryStore::new()); + let original = LeafDevice::provision(Arc::clone(&store), APP_ID).expect("provisioning"); + let address = original.address().to_string(); + let own = store + .load(KEY_TYPE_IDENTITY, "signature_public") + .expect("load") + .expect("provisioning wrote a public key"); + + // The identity is two entries, and the reason this crate has a durability + // contract at all is that a part can hand back something other than what + // was written. Somebody else's public key is the shape that does the most + // damage quietly: the device comes back at an address no peer knows it by + // and signs frames that verify nowhere, and every gate in the protocol + // refuses it while naming a different failure than the one that happened. + let other_store: Arc = Arc::new(MemoryStore::new()); + LeafDevice::provision(Arc::clone(&other_store), APP_ID).expect("second device"); + let foreign = other_store + .load(KEY_TYPE_IDENTITY, "signature_public") + .expect("load") + .expect("the second device wrote a public key"); + assert_ne!( + foreign, own, + "the two devices minted the same key, so this test would prove nothing" + ); + + store + .store(KEY_TYPE_IDENTITY, "signature_public", &foreign) + .expect("store"); + let err = LeafDevice::resume(Arc::clone(&store), APP_ID) + .expect_err("a device resumed on a key its secret does not derive to"); + assert!( + matches!(err, LeafError::Storage(_)), + "a mismatched identity pair produced {err:?}" + ); + + // The control: with its own key back, the same device resumes as itself. + // This refuses a mismatch rather than refusing everything. + store + .store(KEY_TYPE_IDENTITY, "signature_public", &own) + .expect("store"); + let resumed = LeafDevice::resume(store, APP_ID).expect("the intact pair still resumes"); + assert_eq!(resumed.address().to_string(), address); +} + +#[test] +fn unpairing_sweeps_epochs_when_the_state_entry_cannot_be_read() { + let phone = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut device); + + commit_to(&phone, &mut device, &device_address); + commit_to(&phone, &mut device, &device_address); + assert!( + !store.epoch_records().is_empty(), + "the setup wrote no epoch records, so this test would pass vacuously" + ); + + // The sweep's first anchor is the marker inside the state entry, and this + // is the case that anchor cannot cover: the entry itself is unreadable, so + // neither the marker nor the group's own epoch can bound anything. The + // separate high-water record exists for exactly this, and without it the + // sweep would anchor at zero, delete one record, return `Ok`, and leave + // the rest of the epochs' secrets on flash under a name the next session + // with this peer answers to. + let key = store + .keys_of(KEY_TYPE_GROUP_STATE) + .into_iter() + .next() + .expect("pairing wrote group state"); + store + .store(KEY_TYPE_GROUP_STATE, &key, b"nope") + .expect("store"); + + device.unpair(&phone.address).expect("device unpairs"); + + assert!( + store.epoch_records().is_empty(), + "an unreadable state entry left epoch records behind: {:?}", + store.epoch_records() + ); +} diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index 72072ffd..1f9cd9e8 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -172,14 +172,17 @@ A conforming leaf: - **never emits** a Welcome, a commit, a proposal, or any group, rich, document or relay frame. -A leaf MUST answer a probe only while it holds a session with that peer, which -is the rule a phone already applies to the same frame. The acknowledgement is -not a liveness signal: a peer confirms its session on receiving one and then -flushes everything it had queued into that session. A device that answered -after losing its store would confirm a session it cannot decrypt one frame of, -and the silence afterwards is indistinguishable from a quiet link, so the peer -never learns. Staying quiet leaves it unconfirmed, which is a state it has a -path out of. +A leaf MUST answer a probe only while it holds a session with that peer **that +it can still load**, which is the rule a phone already applies to the same +frame. The acknowledgement is not a liveness signal: a peer confirms its +session on receiving one and then flushes everything it had queued into that +session. A device that answered after losing its store would confirm a session +it cannot decrypt one frame of, and the silence afterwards is +indistinguishable from a quiet link, so the peer never learns. Staying quiet +leaves it unconfirmed, which is a state it has a path out of. Stored bytes are +not the test: state that is present and unloadable decrypts exactly as much as +state that is absent, so a leaf MUST NOT answer on the strength of a record it +has not opened. A leaf MUST NOT treat an inbound `__MLS_CONFIRM_ACK__` as evidence of a session, which is why the frame appears above under what a leaf emits and not From 558777f79fc2ab055b8ea015b5d70d3d14f5dea5 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 10:08:04 +0530 Subject: [PATCH 5/9] fix(leaf): a frame addressed to somebody else is not this device's A radio hears everything in range, and `handle()` never once asked whether the frame in its hand was addressed to this device. It went straight to the prefix and started work. It turns out neither kind of frame answers that question on its own. A control frame's signature *covers* the recipient rather than checking it, so one honestly signed for somebody else verifies perfectly here. A sealed frame carries no signature at all, so its recipient is whatever the last hand to touch it wrote there. So an overheard key package admitted a peer, spent flash on a record, minted a private init key nobody asked this device for, and answered a phone that never addressed it. A sealed frame this device really can open was acted on after anyone who captured it rewrote the recipient, because that field is not inside the AEAD. And every other prefix came back as an identity binding failure, so two neighbours talking reached firmware wearing the shape of an attack, on a device whose only account of itself is that error stream. Ask the question first, before a signature is verified or a prefix is read. Ignored rather than refused, because overhearing is what a shared radio does, and firmware that carries frames for its neighbours needs "not mine" to be a fact it can act on rather than a failure it has to interpret. None of this ever let anyone read anyone else's ciphertext, to be clear: the group and credential gates held either way. It was a device spending flash and attention on other people's mail. While at it, the leaf section in the architecture doc was the one crate heading carrying no number, wedged between 6 and 7. --- CHANGELOG.md | 11 +- crates/offline-protocol-leaf/src/device.rs | 49 +++++ .../tests/phone_interop.rs | 193 +++++++++++++++++- docs/architecture.md | 10 +- docs/spec/leaf-provisioning.md | 22 ++ 5 files changed, 276 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ea2a11..574b76af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,9 +162,14 @@ archived by series under [docs/changelog/](docs/changelog/); see the because a leaf emits those and never probes, so every one that arrives is unsolicited and treating it as proof of a session would let any keypair holder assert one. A reset frame is acted on once, so a captured one is not a - repeatable session teardown. Twenty-six tests cover this against a real - OpenMLS phone in the same process, which is the only kind of test that - catches a default in one library the other refuses. + repeatable session teardown. Underneath all of them, a frame addressed to + another node is ignored before a prefix is read: a signature covers the + recipient rather than checking it, and a sealed frame carries none at all, so + without that gate an overheard key package mints a private init key nobody + asked for and a captured frame with its recipient rewritten is still acted + on. Every one of these is covered against a real OpenMLS phone in the same + process, which is the only kind of test that catches a default in one library + the other refuses. **What it keeps is bounded.** Prior-epoch records are trimmed to a window rather than kept forever, which bounds both the flash they occupy and how far diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index a6bfbae2..65bf254f 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -341,7 +341,41 @@ impl LeafDevice { /// /// Returns the frames to send and what happened. Everything in /// [`Handled::outbound`] is already durable by the time it is returned. + /// + /// # A frame addressed elsewhere is answered by nothing + /// + /// A radio hears what it is not the recipient of, so this is the first + /// question asked, before a signature is verified or a prefix is read. + /// Nothing further down asks it again, and neither kind of frame answers + /// it on its own: a control frame's signature **covers** the recipient + /// rather than checking it, so one honestly signed for somebody else + /// verifies perfectly here, and a sealed frame carries no signature at + /// all, so its recipient is whatever the last hand to touch it wrote + /// there. Being able to open a frame is a different claim from having been + /// sent it. + /// + /// Three things follow from not asking. An overheard key package admits a + /// peer, spends flash on a record, mints a private init key nobody asked + /// this device for, and answers a phone that never addressed it. A sealed + /// frame this device really can open is acted on after anyone who captured + /// it rewrote the recipient, because that field is not inside the AEAD. + /// And every other prefix arrives as an identity-binding failure, so + /// ordinary traffic between two neighbours reaches firmware wearing the + /// shape of an attack, on a device whose only account of itself is that + /// error stream. + /// + /// What the check does not do is keep anyone else's ciphertext readable + /// here: the group and credential gates below hold either way. + /// + /// It is [`LeafEvent::Ignored`] rather than an error because overhearing is + /// what a shared radio does, and because firmware that carries frames for + /// its neighbours needs "not mine" to be a fact it can act on rather than a + /// failure it has to interpret. pub fn handle(&mut self, message: &Message, now_unix_secs: u64) -> Result { + if !self.is_addressed_to_me(message) { + return Ok(ignored("frame is addressed to another node")); + } + let content = &message.content; // Order matters: the encrypted-confirm prefix is not checked here at @@ -875,6 +909,21 @@ impl LeafDevice { ) } + /// Whether this frame names this device as its recipient. + /// + /// The comparison is between parsed addresses rather than between strings, + /// which costs nothing and is the same test every other identity claim in + /// this protocol gets. A recipient that is not an address at all is not + /// this device: this device is named by one, and by exactly one spelling + /// of it, since [`Address`] refuses anything but the canonical rendering. + fn is_addressed_to_me(&self, message: &Message) -> bool { + message + .recipient + .as_str() + .parse::
() + .is_ok_and(|recipient| recipient == self.identity.address) + } + fn client(&self) -> Result> { build_client(&self.identity, &self.store) } diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index a973883a..a523dcc7 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -20,7 +20,10 @@ use std::collections::BTreeMap; use offline_protocol_core::{Message, MessagePriority, UserId}; use offline_protocol_leaf::{ - store::{KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_IDENTITY, KEY_TYPE_PEER}, + store::{ + KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE, KEY_TYPE_IDENTITY, KEY_TYPE_KEY_PACKAGE, + KEY_TYPE_PEER, + }, LeafDevice, LeafError, LeafEvent, LeafStore, MemoryStore, StoreError, }; use offline_protocol_mls::{storage::InMemoryStorage, MlsManager, MlsStorage}; @@ -1667,3 +1670,191 @@ fn unpairing_sweeps_epochs_when_the_state_entry_cannot_be_read() { store.epoch_records() ); } + +/// Builds a key package advertisement body, with the package data left as +/// junk because a device records what a peer advertises and never parses the +/// package itself: the phone builds the group, not the device. +fn advertisement_body(user_id: &str) -> String { + let payload = KeyPackagePayload { + user_id: user_id.to_string(), + key_package_data: vec![7], + remaining_lifetime_ms: 0, + timestamp_ms: 0, + session_reset: false, + wire_versions: vec![], + env_versions: vec![MLS_ENVELOPE_COMPACT_V1], + rich_versions: vec![], + data_versions: vec![], + nostr_pubkey: None, + }; + format!( + "{}{}", + prefixes::KEY_PACKAGE, + serde_json::to_string(&payload).expect("payload") + ) +} + +#[test] +fn a_frame_addressed_to_another_node_is_ignored() { + let phone = new_phone(); + let bystander = new_phone(); + let store = Arc::new(CountingStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + let device_address = device.address().to_string(); + + // One body, sent twice, to two different recipients. That is the whole + // test: the frames are otherwise identical, so whatever the device does + // differently it did because of the addressing and nothing else. + let body = advertisement_body(&phone.address); + + // A radio hears what it is not the recipient of. This frame is honestly + // signed and its signature covers the recipient, so every gate below the + // dispatch verifies it happily; only the addressing says it is not ours. + let overheard = phone_control_frame(&phone, &bystander.address, body.clone()); + let handled = device + .handle(&overheard, NOW) + .expect("overhearing a neighbour is not a failure"); + + assert!( + handled.outbound.is_empty(), + "the device answered a frame addressed to somebody else: {:?}", + handled.outbound.len() + ); + assert!( + matches!(handled.events.as_slice(), [LeafEvent::Ignored { .. }]), + "an overheard frame produced {:?}", + handled.events + ); + + // And it spent nothing. Each of these is a write to a part with a finite + // number of them, and the key package is a private init key minted for a + // pairing nobody asked this device for. + assert!( + store.keys_of(KEY_TYPE_PEER).is_empty(), + "an overheard frame wrote a peer record: {:?}", + store.keys_of(KEY_TYPE_PEER) + ); + assert!( + store.keys_of(KEY_TYPE_KEY_PACKAGE).is_empty(), + "an overheard frame minted a key package: {:?}", + store.keys_of(KEY_TYPE_KEY_PACKAGE) + ); + assert!( + device.peers().expect("peers").is_empty(), + "an overheard frame put a peer in the audit list" + ); + + // The control: the same body addressed to this device is acted on. Without + // this the test above would pass on a device that answers nothing at all. + let addressed = phone_control_frame(&phone, &device_address, body); + let handled = device + .handle(&addressed, NOW) + .expect("device handles a frame addressed to it"); + assert_eq!( + handled.outbound.len(), + 1, + "a frame addressed to this device was not answered: {:?}", + handled.events + ); + assert!( + device.peers().expect("peers").contains(&phone.address), + "a frame addressed to this device did not record its sender" + ); +} + +#[test] +fn a_sealed_frame_addressed_elsewhere_is_not_opened() { + let phone = new_phone(); + let bystander = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &mut device); + + // A frame this device really can open, addressed to somebody else. The + // data plane carries no signature, by design: MLS authenticates its own + // sender, so the AEAD covers the ciphertext and nothing covers the + // addressing beside it. Anyone who captured this frame can therefore + // rewrite its recipient and hand it back. + // + // Openable is not the same question as addressed here, and without the + // dispatch gate the device never asks the second one: it opens the + // ciphertext and reports an ordinary message. Firmware that also carries + // frames for its neighbours would then act on the same frame it forwards. + let sealed = phone + .manager + .encrypt_for_user(&device_address, b"unlock") + .expect("phone seals"); + let elsewhere = phone_sealed_frame(&phone, &bystander.address, &sealed); + + let handled = device + .handle(&elsewhere, NOW) + .expect("a frame addressed elsewhere is not a failure"); + assert!( + matches!(handled.events.as_slice(), [LeafEvent::Ignored { .. }]), + "a sealed frame addressed elsewhere produced {:?}", + handled.events + ); + + // The control: the same ciphertext addressed here opens. What the device + // refused was the addressing rather than the frame. + let addressed = phone_sealed_frame(&phone, &device_address, &sealed); + let handled = device.handle(&addressed, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); +} + +#[test] +fn traffic_between_two_neighbours_is_ignored_rather_than_reported_as_a_failure() { + let phone = new_phone(); + let bystander = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + pair(&phone, &mut device); + + // Two other nodes talking, overheard. This device is in neither end of it + // and cannot open a byte, so nothing here is a confidentiality question. + // What it is is a reporting one: every gate downstream refuses this as an + // identity binding failure, and on a device whose only account of itself + // is its error stream that makes ordinary neighbour traffic arrive wearing + // the shape of an attack on it. + let stranger = new_phone(); + let envelope = EncryptedMessage { + group_id: GroupId::for_session(&stranger.address, &bystander.address) + .expect("their pair's group id"), + message_type: offline_protocol_sealed::MlsMessageType::Application, + epoch: 1, + ciphertext: vec![9, 9, 9], + sender_id: stranger.address.clone(), + timestamp_ms: 0, + }; + let overheard = phone_sealed_frame(&stranger, &bystander.address, &envelope); + + let handled = device + .handle(&overheard, NOW) + .expect("two neighbours talking is not this device's failure"); + assert!( + matches!(handled.events.as_slice(), [LeafEvent::Ignored { .. }]), + "overheard neighbour traffic produced {:?}", + handled.events + ); + + // The control: the device still has its own working session, so this is a + // device that ignores what is not its business rather than one that has + // stopped listening. + let sealed = phone + .manager + .encrypt_for_user(&device.address().to_string(), b"unlock") + .expect("phone seals"); + let frame = phone_sealed_frame(&phone, &device.address().to_string(), &sealed); + let handled = device.handle(&frame, NOW).expect("device opens"); + assert_eq!( + handled.events, + vec![LeafEvent::MessageReceived { + peer: phone.address.clone(), + text: "unlock".to_string(), + }] + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 12b86f2f..ecb7e151 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,7 @@ implementations and are not allowed to disagree about anything outside them. **Dependencies**: `offline-protocol-core`, `offline-protocol-sealed`, OpenMLS -### offline-protocol-leaf +### 7. offline-protocol-leaf **Purpose**: A constrained device (a door lock, a sensor, a mains-powered relay) speaking the protocol as a real peer rather than a reduced one. It runs @@ -145,7 +145,7 @@ state rolled back by a power cut reuses an AEAD nonce. Deliberately **not** the engine or the MLS crate: nothing above `sealed` builds without `std`. -### 7. offline-protocol-services +### 8. offline-protocol-services **Purpose**: Standalone service discovery and request/response over the mesh. @@ -166,7 +166,7 @@ without `std`. **Dependencies**: `offline-protocol-core` -### 8. offline-protocol-data +### 9. offline-protocol-data **Purpose**: Replicated documents — offline-first state that any member of a space can edit while disconnected, merging deterministically when replicas meet again. Messaging is synced events; this crate is synced state. @@ -186,7 +186,7 @@ without `std`. **Dependencies**: `loro` (pinned exactly; it publishes no MSRV metadata, so every bump re-runs the MSRV check and the mobile size measurement) -### 9. offline-protocol +### 10. offline-protocol **Purpose**: Main SDK API integrating all components. @@ -213,7 +213,7 @@ AEAD key held in secure storage before they reach the state provider. See **Dependencies**: All other crates. `offline-protocol-data` is behind a default-on `data` feature, so a native consumer that only wants messaging can drop the CRDT engine with `default-features = false`. -### 10. offline-protocol-uniffi +### 11. offline-protocol-uniffi **Purpose:** UniFFI bindings for cross-platform interoperability. diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index 1f9cd9e8..29baf83d 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -172,6 +172,28 @@ A conforming leaf: - **never emits** a Welcome, a commit, a proposal, or any group, rich, document or relay frame. +Every frame it accepts is one **addressed to it**. A leaf MUST establish that +before it acts on anything, whatever prefix the frame carries and however well +the frame verifies, and nothing further down asks the question again: a control +frame's signature covers the recipient, so one honestly signed for somebody +else verifies perfectly, and a sealed frame carries no signature at all, so its +recipient is whatever the last hand to touch it wrote there. Being able to open +a frame is a different claim from having been sent it. + +What the check saves is not a message opened by the wrong node, since the group +and credential gates hold either way. It is an overheard key package admitting +a peer, spending flash, minting a private init key nobody asked this device +for, and answering a node that never addressed it; a sealed frame the device +can open being acted on after anyone who captured it rewrote the recipient; and +every other prefix arriving as an identity-binding failure, so ordinary traffic +between two neighbours reaches firmware wearing the shape of an attack on a +device whose only account of itself is that error stream. + +A frame addressed elsewhere is **ignored rather than refused**. Overhearing is +what a shared radio does, and firmware that carries frames for its neighbours +needs "not mine" to be a fact it can act on rather than a failure it has to +interpret. + A leaf MUST answer a probe only while it holds a session with that peer **that it can still load**, which is the rule a phone already applies to the same frame. The acknowledgement is not a liveness signal: a peer confirms its From 17d5f328418042518863e2e64fadcabb9ea9dcd6 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 10:08:25 +0530 Subject: [PATCH 6/9] fix(leaf): don't orphan key material the index has stopped naming The key package adapter trimmed its index first and erased the evicted packages afterwards. Both of those are separate writes that fail independently, because this seam is atomic per entry and nothing more. Land the failure on that side and you leave private init key material on flash the index no longer names, and nothing ever reclaims it: unpair sweeps epoch records, this key type has no sweep of its own, and an eviction the index has already forgotten is never attempted again. The comment sitting directly above the code named that exact residue as the one to avoid. The code then did it anyway. Erase first, then trim. Either write can fail now and what survives is the harmless residue instead: an index entry naming a package that is not there, which costs one slot and is evicted in its turn. A delete that fails takes the whole mint down with it, which is a key package this device does not hand out rather than one it cannot account for. --- crates/offline-protocol-leaf/src/adapters.rs | 19 +++- .../tests/phone_interop.rs | 101 ++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/crates/offline-protocol-leaf/src/adapters.rs b/crates/offline-protocol-leaf/src/adapters.rs index e48b1e46..a0ffe5f1 100644 --- a/crates/offline-protocol-leaf/src/adapters.rs +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -392,10 +392,6 @@ impl KeyPackageStorage for KeyPackageAdapter { .map_err(|e| StoreError::Store(format!("cannot encode key package data: {e:?}")))?; let key = hex(&id); - // The index is written before the package it names. A cut between the - // two leaves an index entry for a package that is not there, which - // costs one wasted slot; the reverse would leave private key material - // no index knows about, which is the thing that never gets reclaimed. let mut index = self.index()?; if !index.iter().any(|held| held == &key) { index.push(key.clone()); @@ -407,10 +403,23 @@ impl KeyPackageStorage for KeyPackageAdapter { } else { Vec::new() }; - self.save_index(&index)?; + + // Two writes, ordered so the same thing survives either failure: an + // index entry naming a package that is not there. That costs one slot + // and is evicted in its turn. The opposite residue is private key + // material the index has stopped naming, and **nothing reclaims that**: + // the sweep in [`LeafDevice::unpair`](crate::LeafDevice::unpair) is + // over epoch records, this key type has no sweep of its own, and an + // eviction the index has already forgotten is never attempted again. + // + // So the evicted packages are erased before the index stops naming + // them, and the index is written before the package it names. A delete + // that fails takes the whole mint down with it, which is a key package + // this device does not hand out rather than one it cannot account for. for stale in evicted { self.store.delete(KEY_TYPE_KEY_PACKAGE, &stale)?; } + self.save_index(&index)?; self.store.store(KEY_TYPE_KEY_PACKAGE, &key, &encoded) } diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index a523dcc7..ea8788ca 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -1858,3 +1858,104 @@ fn traffic_between_two_neighbours_is_ignored_rather_than_reported_as_a_failure() }] ); } + +/// A store that writes and reads normally and refuses every delete. +/// +/// The shape a flash part takes when erasing a sector fails: the write path is +/// fine and the reclaim path is not. +#[derive(Default)] +struct UndeletableStore { + inner: MemoryStore, + keys: Mutex>, +} + +impl UndeletableStore { + /// The key ids held under one key type, in insertion order. + fn keys_of(&self, key_type: &str) -> Vec { + self.keys + .lock() + .expect("lock") + .iter() + .filter(|(held, _)| held == key_type) + .map(|(_, id)| id.clone()) + .collect() + } +} + +impl LeafStore for UndeletableStore { + fn store(&self, key_type: &str, key_id: &str, data: &[u8]) -> Result<(), StoreError> { + self.inner.store(key_type, key_id, data)?; + let mut keys = self.keys.lock().expect("lock"); + if !keys.iter().any(|(t, i)| t == key_type && i == key_id) { + keys.push((key_type.to_string(), key_id.to_string())); + } + Ok(()) + } + + fn load(&self, key_type: &str, key_id: &str) -> Result>, StoreError> { + self.inner.load(key_type, key_id) + } + + fn delete(&self, _key_type: &str, _key_id: &str) -> Result<(), StoreError> { + Err(StoreError::Delete("the sector will not erase".to_string())) + } +} + +#[test] +fn a_failed_eviction_leaves_no_key_package_the_index_has_forgotten() { + // Where the list of unspent packages lives. Mirrored from the adapter, + // which keeps it private; it cannot collide with a package id because + // every one of those is hex and `_` is not a hex digit. + const KEY_PACKAGE_INDEX: &str = "__index__"; + /// What the adapter keeps, past which a mint evicts the oldest. + const MAX_UNSPENT: usize = 4; + + let store = Arc::new(UndeletableStore::default()); + let mut device = device(Arc::clone(&store) as Arc); + + // Fill the window. Nothing is evicted yet, so no delete is attempted and + // the failing erase is not in play. + let peers: Vec = (0..MAX_UNSPENT).map(|_| new_phone().address).collect(); + for peer in &peers { + device + .key_package_frame(peer, NOW) + .expect("device mints a key package"); + } + let held: Vec = store + .keys_of(KEY_TYPE_KEY_PACKAGE) + .into_iter() + .filter(|id| id != KEY_PACKAGE_INDEX) + .collect(); + assert_eq!( + held.len(), + MAX_UNSPENT, + "the window did not fill, so the eviction below would never run: {held:?}" + ); + + // One more. This one evicts, the erase fails, and the mint fails with it. + let err = device + .key_package_frame(&new_phone().address, NOW) + .expect_err("a package was minted although its eviction could not be erased"); + + // The invariant: nothing is on flash that the index has stopped naming. + // A package the index still names but which is gone costs one slot and is + // evicted in its turn; the reverse is private key material nothing + // reclaims, because no sweep covers this key type and an eviction the + // index has forgotten is never attempted again. + let raw = store + .load(KEY_TYPE_KEY_PACKAGE, KEY_PACKAGE_INDEX) + .expect("load") + .expect("minting wrote an index"); + let index: Vec = serde_json::from_slice(&raw).expect("the index parses"); + let orphaned: Vec = store + .keys_of(KEY_TYPE_KEY_PACKAGE) + .into_iter() + .filter(|id| id != KEY_PACKAGE_INDEX) + .filter(|id| !index.contains(id)) + .collect(); + assert!( + orphaned.is_empty(), + "a failed eviction left key package private material no index names: \ + {orphaned:?} (error was {err:?})" + ); +} From 083ca9995bbd49239345d4b321938ca749db7c59 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 10:54:13 +0530 Subject: [PATCH 7/9] docs(changelog): core's no_std entry contradicts the one below it ADR 0020 made core build without std on the reading that a constrained node "receives frames rather than minting them", and the changelog entry for it says exactly that. Two entries further down, in the same unreleased section, `Message::from_parts` shows up precisely because that reading is false the moment a node answers rather than only forwards. Both cannot ship in the same release notes. Reword the older one to say which half it meant, and point it at the constructor that covers the other half. --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 574b76af..bf587083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,10 @@ archived by series under [docs/changelog/](docs/changelog/); see the a monotonic clock (`LocalInstant`), entropy (`MessageId::new`, and `Message::new` and `MessageBuilder` with it), and threads (the `sync` module). Everything that parses, validates, re-encodes or compares is present - in both configurations, which is the half a leaf node uses, because it - receives frames rather than minting them. Build messages from wire-supplied - parts via `MessageId::from_bytes` and `Timestamp::from_millis` on that path. + in both configurations, which is the half a leaf node that only forwards + uses. A node that answers also mints, and `Message::from_parts` in this same + release is what lets it: build messages from wire-supplied parts, via + `MessageId::from_bytes` and `Timestamp::from_millis` on that path. Two field types changed spelling to `MetadataMap`, which under `std` **is** `HashMap` exactly as before, and is a `BTreeMap` without it. From fd39e2cf6071dddbc44685598870a06e8577e3c2 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 10:56:01 +0530 Subject: [PATCH 8/9] fix(leaf): a key package is a bearer token, so bind it to its peer The device mints a key package, wraps it in a frame addressed to the peer it is pairing with, signs it, and puts it on the air. Anyone in range gets a copy, and a copy of a key package is exactly as spendable as the original. So a listener builds a group with it. Every gate on the Welcome it sends then passes, and passes *honestly*: it really does hold the key its own address derives from, it really is the inviter it names, and the group it built really is the one this pair's id would name, because that id is a function of the two addresses and it is one of them. The device joins and confirms to a node it was never introduced to. That is not a confidentiality failure. The group and credential gates hold, and the listener learns nothing it could not have got by pairing honestly, which nothing stops it doing anyway. What it costs is the *init key*, which is single use. Spent by the listener, the peer the package was minted for is left holding a Welcome that no longer opens, and `key_package_sent` means the device will not mint another until a driven reset. A listener that keeps doing it keeps the pairing broken. It also drove a hole through the peer bound. `index_peer` skips MAX_PEERS on the Welcome path, and the comment above it justified that by claiming a Welcome "only lands on a key package this device minted for that peer in the first place". Which was false. It landed on a key package this device minted, for whoever picked it up. Record the reference of the package at the moment it is minted, and require a Welcome to spend that one. Before the join, not after, because after is too late: the whole point is that the package is still there for the peer it belongs to. A peer with no recorded reference is refused for the same reason an unparseable identifier is refused elsewhere in this crate, which is that having nothing to compare is the bypass rather than a lenience. Note that "has this peer ever been given a package" is *not* the test, and it is worth being clear about why: a listener that has also paired holds a package of its own, and would sail through such a check while spending somebody else's. Both cases have a test, and both fail against the code before this commit. While at it, the comment above `index_peer` now says what is actually true, and says which single ordering still lets a peer reach a Welcome without a slot. --- CHANGELOG.md | 12 +- crates/offline-protocol-leaf/src/adapters.rs | 19 ++ crates/offline-protocol-leaf/src/device.rs | 96 +++++++- crates/offline-protocol-leaf/src/error.rs | 17 ++ crates/offline-protocol-leaf/src/keypkg.rs | 52 ++++- crates/offline-protocol-leaf/src/lib.rs | 8 +- .../tests/phone_interop.rs | 208 +++++++++++++++++- docs/spec/leaf-provisioning.md | 24 ++ 8 files changed, 397 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf587083..5f3f3a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -155,9 +155,15 @@ archived by series under [docs/changelog/](docs/changelog/); see the whose key **derives to the address the frame claims**, and an identifier that is not an address is the same refusal rather than a skip, because a claim with no derivation to check is the bypass. A Welcome must name the peer that - signed it, name the group this pair would build, and then actually join that - group rather than the one its body claimed. A sealed frame's MLS sender must - be the peer the frame came from. A confirmation probe is answered only by a + signed it, name the group this pair would build, spend **the key package this + device minted for that peer**, and then actually join the group its body + claimed. That third one is what separates a peer from anyone who overheard + it: a key package rides in a frame that is signed but not encrypted, so a + copy taken off the air is as spendable as the original and satisfies every + other gate honestly. Checked before the join, because joining spends the init + key, and a package burned by a listener leaves the peer it was minted for + holding a Welcome that no longer opens. A sealed frame's MLS sender must be + the peer the frame came from. A confirmation probe is answered only by a device that still holds a session, because a peer confirms on that answer and flushes into it, and an inbound acknowledgement is never acted on at all, because a leaf emits those and never probes, so every one that arrives is diff --git a/crates/offline-protocol-leaf/src/adapters.rs b/crates/offline-protocol-leaf/src/adapters.rs index a0ffe5f1..52c36d9c 100644 --- a/crates/offline-protocol-leaf/src/adapters.rs +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -458,6 +458,25 @@ pub(crate) struct PeerRecord { #[serde(default)] pub(crate) key_package_sent: bool, + /// The reference of the key package this device last minted for this peer. + /// + /// A key package is a **bearer token**: it travels in a frame that is + /// signed but not encrypted, so a copy taken off the air is as spendable + /// as the original, and a Welcome built around one passes every other gate + /// honestly. Recording which package went to which peer is what makes the + /// difference checkable, and + /// [`LeafDevice::handle`](crate::LeafDevice::handle) checks it before the + /// join rather than after, so a refused Welcome leaves the package + /// **unspent** and the peer it was minted for can still use it. + /// + /// One reference rather than a history: this device mints one package per + /// peer and re-mints only on a reset, so the newest is the one a peer was + /// told to use. A peer holding an older package that a re-mint replaced is + /// refused and re-pairs, which is the same path every other stale-pairing + /// failure takes. + #[serde(default)] + pub(crate) key_package_ref: Option, + /// Ids of the reset-flagged key package frames already acted on. /// /// A reset tears down a live session, so a frame carrying one is worth diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index 65bf254f..59cfd59b 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -234,8 +234,12 @@ impl LeafDevice { session_reset: bool, ) -> Result { let client = self.client()?; - let data = keypkg::mint(&client, now_unix_secs)?; - let payload = keypkg::payload(&self.identity.address.to_string(), data, session_reset); + let minted = keypkg::mint(&client, now_unix_secs)?; + let payload = keypkg::payload( + &self.identity.address.to_string(), + minted.data, + session_reset, + ); let body = serde_json::to_string(&payload) .map_err(|e| LeafError::MalformedFrame(format!("cannot encode key package: {e}")))?; @@ -253,8 +257,16 @@ impl LeafDevice { // Recorded before the frame is handed back, so a device that emits one // and loses power does not emit a second on the next boot and leave // the peer holding two init keys of which only one is ever spent. + // + // The reference is recorded here for a second reason: it is the only + // thing that later distinguishes this peer's Welcome from one built by + // whoever else heard this frame. Minting has already written the + // package itself, so a cut between the two leaves a package no Welcome + // can name, which the adapter evicts in its turn. The opposite order + // would leave a name pointing at nothing. let mut record = self.peer_record(peer)?; record.key_package_sent = true; + record.key_package_ref = Some(minted.reference); record .save(&self.store, peer) .map_err(|e| LeafError::Storage(e.to_string()))?; @@ -515,6 +527,21 @@ impl LeafDevice { let welcome_message = MlsMessage::from_bytes(&welcome.welcome_data) .map_err(|e| LeafError::Mls(format!("welcome does not decode: {e:?}")))?; + // And the Welcome must spend the key package this device minted **for + // this peer**. The two checks above are about the peer and the group; + // this one is about the material, and nothing else in the exchange + // covers it, because a key package rides in a frame that is signed but + // not encrypted and is therefore spendable by whoever copied it off the + // air. + // + // Checked before the join, which is the whole point: joining spends the + // init key, and the failure being prevented is not a stranger reading + // anything (the group and credential gates hold either way) but this + // device's package being **burned by somebody it was not meant for**, + // which leaves the intended peer holding a Welcome that can no longer + // be joined and a pairing that only a driven reset recovers. + self.require_own_key_package(sender, &welcome_message)?; + // `tree_data: None` because the peer puts the ratchet tree in the // Welcome. A device that needed it out of band would need a side // channel it does not have. @@ -543,12 +570,17 @@ impl LeafDevice { .map_err(|e| LeafError::Storage(format!("cannot persist group state: {e:?}")))?; // A session exists from here, so the peer has to be in the index - // whatever route it took to get one. Ordinarily it is already there, - // put there by the exchange that handed out the key package this - // Welcome spent; what this covers is that entry having been recycled - // in between, which is a slot a peer with no session is eligible to - // lose. A session firmware cannot see in [`LeafDevice::peers`] is one - // it cannot audit and cannot [`LeafDevice::unpair`]. + // whatever route it took to get one: a session firmware cannot see in + // [`LeafDevice::peers`] is one it can neither audit nor + // [`LeafDevice::unpair`]. + // + // Ordinarily it is already there, put there by the same mint that + // recorded the reference this Welcome just spent, and an eviction + // since then would have taken that reference with it and been refused + // above. What is left is the one order that separates them: the mint + // saves the peer record and indexes second, so an index write that + // failed leaves a peer holding a live reference and no slot. That peer + // arrives here. self.index_peer(sender)?; // The confirmation is a group-aware decrypt, sealed inside an ordinary @@ -814,8 +846,16 @@ impl LeafDevice { /// /// The bound exists to stop a stranger spending a device's flash, and /// neither caller here is a stranger: one is firmware choosing to pair, - /// the other is a Welcome, which only lands on a key package this device - /// minted for that peer in the first place. + /// the other is a Welcome, and a Welcome is only reached by a peer whose + /// recorded key package reference it spends. That reference is written + /// only by a mint, and a mint is reached only through firmware or through + /// [`LeafDevice::admit_peer`], which is where the bound is applied. So + /// every peer arriving here has already been counted or chosen. + /// + /// That is a claim about [`LeafDevice::require_own_key_package`] and would + /// be false without it: a key package travels unencrypted, so before that + /// gate any listener could copy one, build this pair's group around it and + /// arrive here having been counted by nothing. /// /// It is the index rather than the bound that has to be complete. A peer /// missing from it holds a session nothing can audit and @@ -864,6 +904,42 @@ impl LeafDevice { self.save_peer_index(&index) } + /// Requires a Welcome to spend the key package minted for its sender. + /// + /// A key package is a bearer token: this device hands one to a peer in a + /// frame addressed to that peer, and a shared radio carries it to everyone + /// else as well. A listener that copies it can build a group whose id is + /// the one this pair would build, sign the Welcome with its own key, and + /// name itself as inviter, so the inviter check and the group check both + /// pass honestly. What it cannot do is present the reference of a package + /// this device minted for **it**. + /// + /// A peer with no recorded reference is refused for the same reason an + /// unparseable identifier is elsewhere in this crate: there is nothing to + /// compare, and "nothing to compare" is the bypass rather than a lenience. + /// It also restores the bound: every peer reaching a session has been + /// through [`LeafDevice::admit_peer`] or was chosen by firmware, which is + /// what lets [`LeafDevice::index_peer`] skip [`MAX_PEERS`]. + fn require_own_key_package(&self, peer: &str, welcome: &MlsMessage) -> Result<()> { + let Some(minted) = self.peer_record(peer)?.key_package_ref else { + return Err(LeafError::UnsolicitedWelcome(format!( + "no key package was ever minted for '{peer}', so nothing it sends can spend one" + ))); + }; + + let spends_ours = welcome + .welcome_key_package_references() + .into_iter() + .any(|reference| crate::adapters::hex(reference) == minted); + + if !spends_ours { + return Err(LeafError::UnsolicitedWelcome(format!( + "welcome from '{peer}' spends a key package this device did not mint for it" + ))); + } + Ok(()) + } + /// Requires the MLS member at `index` to derive to `claimed`. /// /// The refusal is deliberately the same for a member whose credential is diff --git a/crates/offline-protocol-leaf/src/error.rs b/crates/offline-protocol-leaf/src/error.rs index 92bb5505..337d5279 100644 --- a/crates/offline-protocol-leaf/src/error.rs +++ b/crates/offline-protocol-leaf/src/error.rs @@ -64,6 +64,23 @@ pub enum LeafError { #[error("No session with {0}")] NoSession(String), + /// A Welcome asked this device to join on a key package it did not mint + /// for the peer that sent it. + /// + /// A key package is a **bearer token**. It rides in a frame that is signed + /// but not encrypted, so anyone in radio range copies one off the air, and + /// every other gate on a Welcome then passes for them honestly: they do + /// hold the key their own address derives from, and they did build the + /// group this pair's id names. Only this refusal separates the peer the + /// package was minted for from whoever else heard it. + /// + /// Its own variant rather than an identity binding, because the two send + /// firmware to different places. An identity binding failure says a peer + /// is not who it claims; this says the peer is exactly who it claims and + /// is spending something that was never given to it. + #[error("Unsolicited welcome: {0}")] + UnsolicitedWelcome(String), + /// The device already holds as many peers as it keeps room for, and none /// of them is an incomplete pairing that could be recycled. /// diff --git a/crates/offline-protocol-leaf/src/keypkg.rs b/crates/offline-protocol-leaf/src/keypkg.rs index cdfc6918..ee7ef51c 100644 --- a/crates/offline-protocol-leaf/src/keypkg.rs +++ b/crates/offline-protocol-leaf/src/keypkg.rs @@ -23,7 +23,11 @@ //! Key package validity is a **freshness bound, not an authentication //! mechanism**. A wrong clock costs availability rather than confidentiality. -use alloc::{format, string::ToString, vec::Vec}; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; use mls_rs::client_builder::MlsConfig; use mls_rs::time::MlsTime; use mls_rs::Client; @@ -36,30 +40,56 @@ use offline_protocol_sealed::{ use crate::error::{LeafError, Result}; -/// Mints a key package and returns its **bare** encoding. +/// A freshly minted key package, and the name storage keys it by. +pub(crate) struct Minted { + /// The bare key package, as it goes on the wire. + pub(crate) data: Vec, + /// The package's reference, hex, which is both the key + /// [`KeyPackageStorage`](mls_rs_core::key_package::KeyPackageStorage) + /// files it under and the name a Welcome spends it by. + pub(crate) reference: String, +} + +/// Mints a key package and returns its **bare** encoding and its reference. /// /// mls-rs's convenience API returns a key package wrapped in an MLS message, /// and this protocol puts the bare key package on the wire. Both forms are /// legal MLS and only one of them is what the peer's parser accepts, so the /// wrapper is removed here rather than left for a caller to notice. -pub(crate) fn mint(client: &Client, now_unix_secs: u64) -> Result> { +/// +/// The reference comes back with it because a package is a **bearer token**, +/// and the only defence against one being spent by whoever copied it off the +/// air is knowing which peer this one went to. See +/// [`PeerRecord::key_package_ref`](crate::adapters::PeerRecord::key_package_ref). +/// It is read from the wrapper before that wrapper is consumed, so it is the +/// reference of exactly the bytes returned beside it rather than a second +/// derivation that could disagree. +pub(crate) fn mint(client: &Client, now_unix_secs: u64) -> Result { let not_before = now_unix_secs.saturating_sub(LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS); - client + let message = client .generate_key_package_message( Default::default(), Default::default(), Some(MlsTime::from(not_before)), ) - .map_err(|e| LeafError::Mls(format!("cannot generate a key package: {e:?}")))? + .map_err(|e| LeafError::Mls(format!("cannot generate a key package: {e:?}")))?; + + let reference = message + .key_package_reference(&crate::identity::suite_provider()?) + .map_err(|e| LeafError::Mls(format!("cannot reference the key package: {e:?}")))? + .ok_or_else(|| LeafError::Mls(String::from("generated message is not a key package")))?; + + let data = message .into_key_package() - .ok_or_else(|| { - LeafError::Mls(alloc::string::String::from( - "generated message is not a key package", - )) - })? + .ok_or_else(|| LeafError::Mls(String::from("generated message is not a key package")))? .mls_encode_to_vec() - .map_err(|e| LeafError::Mls(format!("cannot encode the key package: {e:?}"))) + .map_err(|e| LeafError::Mls(format!("cannot encode the key package: {e:?}")))?; + + Ok(Minted { + data, + reference: crate::adapters::hex(&reference), + }) } /// Builds the advertisement body that carries the key package. diff --git a/crates/offline-protocol-leaf/src/lib.rs b/crates/offline-protocol-leaf/src/lib.rs index 46ac1196..5ece1363 100644 --- a/crates/offline-protocol-leaf/src/lib.rs +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -22,9 +22,11 @@ //! takes an inbound [`Message`](offline_protocol_core::Message) and hands back //! the frames to send and what happened. The choreography it implements is //! security-critical and easy to get subtly wrong: the derive-and-compare gate -//! at every site that accepts an identity claim, the confirmation that has to -//! be a group-aware decrypt, and the reset sequence that a driven rekey -//! arrives as. +//! at every site that accepts an identity claim, the binding of a Welcome to +//! the key package this device minted for the peer that sent it (a package +//! travels unencrypted, so whoever copies one off the air satisfies every +//! other gate honestly), the confirmation that has to be a group-aware +//! decrypt, and the reset sequence that a driven rekey arrives as. //! //! ``` //! use offline_protocol_leaf::{LeafDevice, LeafStore, MemoryStore}; diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index ea8788ca..17bcce81 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -114,6 +114,16 @@ fn import_device_key_package(phone: &Phone, frame: &Message) { .expect("the phone accepts the device's key package"); } +/// The bare key package out of a device's advertisement frame. +fn raw_key_package(frame: &Message) -> Vec { + let body = frame + .content + .strip_prefix(prefixes::KEY_PACKAGE) + .expect("frame carries a key package"); + let payload: KeyPackagePayload = serde_json::from_str(body).expect("key package body parses"); + payload.key_package_data +} + /// Pairs a phone and a device, returning the device's address. /// /// This is the whole choreography in one place: the device advertises, the @@ -1337,25 +1347,33 @@ fn a_flood_of_strangers_cannot_displace_an_established_peer() { #[test] fn a_welcome_whose_body_lies_about_its_group_is_refused() { let phone = new_phone(); - let stranger = new_phone(); let mut device = device(Arc::new(MemoryStore::new())); let device_address = device.address().to_string(); - // The stranger builds a real group with this device, using a package the - // device minted for it. + // The phone builds a group that is not this pair's, and does it with the + // package the device minted for the phone, so the reference check passes + // on material that really was handed to this peer. That is what keeps this + // test pointed at the gate it names rather than at an earlier one. let advertisement = device - .key_package_frame(&stranger.address, NOW) + .key_package_frame(&phone.address, NOW) .expect("device advertises"); - import_device_key_package(&stranger, &advertisement); - let foreign = stranger + let decoy = phone .manager - .create_session(&device_address) - .expect("stranger creates a session"); + .create_group("decoy") + .expect("phone creates a group that is not this pair's"); + let (foreign, _commit) = phone + .manager + .add_group_member( + &decoy.group_id, + &device_address, + &raw_key_package(&advertisement), + ) + .expect("phone adds the device to the decoy group"); - // The phone relays that Welcome under an honest-looking body: it names - // itself as the inviter and this pair's own group id, so both of the - // checks that read the body pass. Only the Welcome inside disagrees, and - // it is the one that decides which group is actually joined. + // The body then lies: it names the phone as inviter and this pair's own + // group id, so both of the checks that read the body pass. Only the + // Welcome inside disagrees, and it is the one that decides which group is + // actually joined. let forged = WelcomeMessage { group_id: GroupId::for_session(&phone.address, &device_address).expect("pair group id"), welcome_data: foreign.welcome_data.clone(), @@ -1959,3 +1977,169 @@ fn a_failed_eviction_leaves_no_key_package_the_index_has_forgotten() { {orphaned:?} (error was {err:?})" ); } + +/// A key package is a bearer token, and a shared radio delivers it to everyone. +/// +/// The frame carrying it is signed and addressed, and neither of those keeps a +/// copy out of a listener's hands. Every other gate on the Welcome that +/// listener builds passes honestly: it holds the key its own address derives +/// from, and the group it built really is the one its pair with this device +/// would build. Only the reference of the package it spends gives it away. +#[test] +fn an_overheard_key_package_cannot_be_spent_by_the_node_that_overheard_it() { + let phone = new_phone(); + let eavesdropper = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + let advertisement = device + .key_package_frame(&phone.address, NOW) + .expect("device advertises to the phone"); + + // Copied off the air and spent under the eavesdropper's own name. + import_device_key_package(&eavesdropper, &advertisement); + let hijack = eavesdropper + .manager + .create_session(&device_address) + .expect("the eavesdropper builds a group on the copied package"); + let frame = phone_control_frame( + &eavesdropper, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&hijack).expect("welcome") + ), + ); + + let err = device + .handle(&frame, NOW) + .expect_err("an overheard key package was spent by whoever copied it"); + assert!( + matches!(err, LeafError::UnsolicitedWelcome(_)), + "spending an overheard key package produced {err:?}" + ); + assert!( + !device + .has_session(&eavesdropper.address) + .expect("session check"), + "the refused welcome still left a session on flash" + ); + // The bound is applied on the way in, and a Welcome skips it. That is only + // safe while a Welcome cannot be reached without a package of one's own. + assert!( + !device + .peers() + .expect("peer list") + .contains(&eavesdropper.address), + "a refused welcome still spent a slot in the peer table" + ); + + // The whole point of refusing before the join: the init key was never + // spent, so the peer it was minted for can still use that very package. A + // device that noticed after joining would have burned it, and the pairing + // it was for would need a driven reset to recover. + import_device_key_package(&phone, &advertisement); + let welcome = phone + .manager + .create_session(&device_address) + .expect("phone creates the session on the package it was given"); + let welcome_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome serializes") + ), + ); + let handled = device + .handle(&welcome_frame, NOW) + .expect("the intended peer can still join"); + assert!( + handled.events.contains(&LeafEvent::SessionEstablished { + peer: phone.address.clone(), + }), + "the overheard attempt burned the package it was refused for: {:?}", + handled.events + ); +} + +/// Having a package of one's own is not permission to spend somebody else's. +/// +/// This is the case a "has this peer ever been given a package" flag would +/// wave through, and it is the one that costs the most: the neighbour is a +/// peer this device really did mint for, and the package it spends is the one +/// the phone is waiting to use. +#[test] +fn a_welcome_cannot_spend_a_key_package_minted_for_a_different_peer() { + let phone = new_phone(); + let neighbour = new_phone(); + let mut device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + let for_phone = device + .key_package_frame(&phone.address, NOW) + .expect("device advertises to the phone"); + // The neighbour is paired with too, so it holds a record and a package of + // its own on this device. + device + .key_package_frame(&neighbour.address, NOW) + .expect("device advertises to the neighbour"); + + // But it builds its Welcome on the phone's package rather than its own. + import_device_key_package(&neighbour, &for_phone); + let hijack = neighbour + .manager + .create_session(&device_address) + .expect("the neighbour builds a group on the phone's package"); + let frame = phone_control_frame( + &neighbour, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&hijack).expect("welcome") + ), + ); + + let err = device + .handle(&frame, NOW) + .expect_err("a peer spent a package minted for somebody else"); + assert!( + matches!(err, LeafError::UnsolicitedWelcome(_)), + "spending another peer's key package produced {err:?}" + ); + assert!( + !device + .has_session(&neighbour.address) + .expect("session check"), + "the refused welcome still left a session on flash" + ); + + // And the phone's package survived it. + import_device_key_package(&phone, &for_phone); + let welcome = phone + .manager + .create_session(&device_address) + .expect("phone creates the session"); + let welcome_frame = phone_control_frame( + &phone, + &device_address, + format!( + "{}{}", + prefixes::WELCOME, + serde_json::to_string(&welcome).expect("welcome serializes") + ), + ); + let handled = device + .handle(&welcome_frame, NOW) + .expect("the intended peer can still join"); + assert!( + handled.events.contains(&LeafEvent::SessionEstablished { + peer: phone.address.clone(), + }), + "the neighbour's attempt burned the phone's package: {:?}", + handled.events + ); +} diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index 29baf83d..20d9dd6b 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -194,6 +194,30 @@ what a shared radio does, and firmware that carries frames for its neighbours needs "not mine" to be a fact it can act on rather than a failure it has to interpret. +A leaf MUST join only on a Welcome that spends **the key package it minted for +the peer that sent it**, and MUST establish that before joining, because +joining spends the init key. + +A key package is a bearer token. It travels in a frame that is signed and +addressed but not encrypted, so a shared radio hands a copy to everyone in +range, and a copy is exactly as spendable as the original. Every other gate on +the Welcome that a listener builds around a copied package passes honestly: +the listener does hold the key its own address derives from, it does name +itself as inviter, and the group it built really is the one this pair's id +names. A leaf that cannot tell the two apart therefore has no gate at all here, +only checks that a copier satisfies for free. + +What the check saves is not confidentiality, since the joined group is one this +device holds keys for either way. It is the **init key**, which is single use: +spent by a listener, it leaves the peer it was minted for holding a Welcome +that can no longer be joined, and a pairing that only a driven reset recovers. +Refusing before the join leaves that package unspent. Refusing after would not. + +Recording which package went to which peer is what makes the difference +checkable, and it is why a leaf MUST NOT treat "this peer was once given a +package" as the test: a listener that has also paired holds a package of its +own, and a Welcome spending somebody else's is the case that costs the most. + A leaf MUST answer a probe only while it holds a session with that peer **that it can still load**, which is the rule a phone already applies to the same frame. The acknowledgement is not a liveness signal: a peer confirms its From 8aed2a85b45a9f71268b04e1455953b070be2ac1 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Sat, 22 Aug 2026 10:56:55 +0530 Subject: [PATCH 9/9] fix(leaf): a pair that stops being a pair is still a pair to this device The Welcome gate refuses a group that is not the one this pair would build, which is what keeps a device out of a room it never chose. It runs exactly once, at the join. A commit changes the roster. It does not change the group id. And in the never-committing profile every commit belongs to the peer, so the peer can add whoever it likes to the pair's own group and the device will apply it, persist it, and report `CommitApplied` as if nothing happened. There is no later gate: the sealed-frame path binds the *sender* of the frame, and the sender is still the peer, honestly, while it relays a third member's commits under its own name. So the device follows its peer into a room one member at a time and never sees it happen. Which is a strange property for the one check in this crate that exists to stop exactly that. Re-read the roster on every commit, which is the only moment it can change, and require two members that derive to this device and the peer. Derived rather than read off the credential, because a basic credential is a bare assertion and this is the moment the shape of the group is in question. Two scalar multiplications on a cadence the peer sets is affordable. While at it, bind the committer the way an application message's sender is already bound. This reports rather than rolls back, and that is not laziness: a member cannot skip one commit and keep decrypting the next, so by the time there is a roster to read the commit is applied and durable. The choice is not whether to follow the peer. It is whether firmware gets told. A conforming phone will not trigger any of this. It refuses to join a `session:*` group as a third member (SEC-M6), so it cannot supply one. That is the phone protecting itself, in the phone's own code, which is worth precisely nothing to a device that cannot be reflashed and does not get to choose what it is talking to. --- CHANGELOG.md | 7 +- crates/offline-protocol-leaf/src/device.rs | 85 +++++++++++++++++-- crates/offline-protocol-leaf/src/lib.rs | 3 +- .../tests/phone_interop.rs | 35 ++++++++ docs/spec/leaf-provisioning.md | 15 ++++ 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f3f3a76..f1fdf549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,8 +162,11 @@ archived by series under [docs/changelog/](docs/changelog/); see the copy taken off the air is as spendable as the original and satisfies every other gate honestly. Checked before the join, because joining spends the init key, and a package burned by a listener leaves the peer it was minted for - holding a Welcome that no longer opens. A sealed frame's MLS sender must be - the peer the frame came from. A confirmation probe is answered only by a + holding a Welcome that no longer opens. A commit must leave the group a pair, + re-read from the roster and derived rather than read, because a commit + changes the membership without changing the group id and every commit here is + the peer's to make. A sealed frame's MLS sender must be the peer the frame + came from, commits included. A confirmation probe is answered only by a device that still holds a session, because a peer confirms on that answer and flushes into it, and an inbound acknowledgement is never acted on at all, because a leaf emits those and never probes, so every one that arrives is diff --git a/crates/offline-protocol-leaf/src/device.rs b/crates/offline-protocol-leaf/src/device.rs index 59cfd59b..66ff3320 100644 --- a/crates/offline-protocol-leaf/src/device.rs +++ b/crates/offline-protocol-leaf/src/device.rs @@ -666,10 +666,29 @@ impl LeafDevice { }] } } - ReceivedMessage::Commit(_) => vec![LeafEvent::CommitApplied { - peer: sender.to_string(), - epoch, - }], + ReceivedMessage::Commit(commit) => { + // Two questions a commit has to answer, ordered so the + // first failure is the informative one: is this still the + // pair this device joined, and did the peer whose frame + // carried this actually author it. The roster answers the + // first. Only the committer's own credential answers the + // second, because the frame states who sent it and a peer is + // free to relay a third member's work under its own name, + // which without this reaches firmware wearing the peer's + // address. + // + // Reported rather than rolled back. A member cannot skip one + // commit and keep decrypting the next, so by the time there + // is anything to read the commit is applied and durable. What + // these two buy is that firmware hears a group stopped being + // the pair it agreed to, rather than nothing at all. + self.require_still_a_pair(&group, sender)?; + self.bind_sender_credential(&group, commit.committer, sender)?; + vec![LeafEvent::CommitApplied { + peer: sender.to_string(), + epoch, + }] + } _ => vec![LeafEvent::Ignored { reason: String::from("sealed frame carried nothing this device acts on"), }], @@ -904,6 +923,62 @@ impl LeafDevice { self.save_peer_index(&index) } + /// Requires the group to still be the pair this device agreed to. + /// + /// The Welcome gate is what keeps this device out of a room it never + /// chose, and it runs once. Nothing repeats it afterwards, and a commit is + /// free to add a member without the group id changing, so a device that + /// checked only at the join would follow its peer into a room one member + /// at a time and never see it happen. The never-committing profile makes + /// that entirely the peer's decision, which is exactly why it is worth + /// checking rather than trusting. + /// + /// So the roster is re-read on every commit, which is the only moment it + /// can change: two members, and both of them **derived** rather than read + /// off a credential, since a basic credential is a bare assertion. That is + /// two scalar multiplications on a cadence the peer sets, spent at the one + /// instant the shape of the group is in question. + /// + /// Reported rather than rolled back, for the reason the caller gives: by + /// the time there is a roster to read, the commit is applied and durable. + /// A member cannot skip one commit and keep decrypting the next, so the + /// choice here is not whether to follow the peer but whether firmware gets + /// to hear that it happened. + fn require_still_a_pair(&self, group: &Group, peer: &str) -> Result<()> { + let roster = group.roster(); + let members = roster.members(); + if members.len() != 2 { + return Err(LeafError::IdentityBinding(format!( + "a commit left this group holding {} members, and a leaf node's group is a pair", + members.len() + ))); + } + + let mine = self.identity.address.to_string(); + for member in &members { + let credential = member + .signing_identity + .credential + .as_basic() + .ok_or_else(|| { + LeafError::IdentityBinding(String::from( + "a group member presents no basic credential, so it names no address", + )) + })?; + let address = crate::adapters::credential_address(credential)?; + if address != mine && address != peer { + return Err(LeafError::IdentityBinding(format!( + "a commit left '{address}' in this group, which is neither this device nor '{peer}'" + ))); + } + frames::verify_sender_derivation( + address, + member.signing_identity.signature_key.as_bytes(), + )?; + } + Ok(()) + } + /// Requires a Welcome to spend the key package minted for its sender. /// /// A key package is a bearer token: this device hands one to a peer in a @@ -972,7 +1047,7 @@ impl LeafDevice { let credential_address = crate::adapters::credential_address(credential)?; if credential_address != claimed { return Err(LeafError::IdentityBinding(format!( - "sealed by group member '{credential_address}', but the frame claims '{claimed}'" + "authored by group member '{credential_address}', but the frame claims '{claimed}'" ))); } diff --git a/crates/offline-protocol-leaf/src/lib.rs b/crates/offline-protocol-leaf/src/lib.rs index 5ece1363..fbe76377 100644 --- a/crates/offline-protocol-leaf/src/lib.rs +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -25,7 +25,8 @@ //! at every site that accepts an identity claim, the binding of a Welcome to //! the key package this device minted for the peer that sent it (a package //! travels unencrypted, so whoever copies one off the air satisfies every -//! other gate honestly), the confirmation that has to be a group-aware +//! other gate honestly), the roster re-read that keeps a pair a pair once +//! commits start arriving, the confirmation that has to be a group-aware //! decrypt, and the reset sequence that a driven rekey arrives as. //! //! ``` diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index 17bcce81..85623b6e 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -2143,3 +2143,38 @@ fn a_welcome_cannot_spend_a_key_package_minted_for_a_different_peer() { handled.events ); } + +/// A pair that stops being a pair is a room this device never chose. +/// +/// The Welcome gate refuses a group that is not this pair's, and it runs once. +/// A commit changes the roster without changing the group id, and in the +/// never-committing profile every commit is the peer's to make, so the only +/// thing standing between a device and a room full of strangers is re-reading +/// the roster on the way through. +#[test] +fn a_commit_that_adds_a_third_member_is_refused() { + let phone = new_phone(); + let outsider = new_phone(); + let store = Arc::new(MemoryStore::new()); + let mut device = device(store); + let device_address = pair(&phone, &mut device); + let group_id = GroupId::for_session(&phone.address, &device_address).expect("pair group id"); + + let bundle = outsider + .manager + .get_or_create_key_package() + .expect("the outsider has a key package"); + let (_welcome, commit) = phone + .manager + .add_group_member(&group_id, &outsider.address, &bundle.key_package_data) + .expect("the peer adds a third member to the pair's own group"); + + let frame = phone_sealed_frame(&phone, &device_address, &commit); + let err = device + .handle(&frame, NOW) + .expect_err("the device followed its peer into a room it never chose"); + assert!( + matches!(err, LeafError::IdentityBinding(_)), + "a commit that grew the pair produced {err:?}" + ); +} diff --git a/docs/spec/leaf-provisioning.md b/docs/spec/leaf-provisioning.md index 20d9dd6b..bfb32360 100644 --- a/docs/spec/leaf-provisioning.md +++ b/docs/spec/leaf-provisioning.md @@ -218,6 +218,21 @@ checkable, and it is why a leaf MUST NOT treat "this peer was once given a package" as the test: a listener that has also paired holds a package of its own, and a Welcome spending somebody else's is the case that costs the most. +A leaf MUST re-check that its group is still a pair on every commit, and MUST +refuse one that is not. The Welcome gate keeps a device out of a room it never +chose, and it runs exactly once. A commit changes the roster without changing +the group id, and in this profile every commit is the peer's to make, so a leaf +that checked only at the join follows its peer into a room one member at a time +and never sees it happen. The roster is the only thing that says otherwise, and +the member addresses in it MUST be derived rather than read, a basic credential +being a bare assertion. + +Such a commit is **reported rather than rolled back**. A member cannot skip one +commit and keep decrypting the next, so by the time there is a roster to read +the commit is applied and durable. What the refusal buys is that firmware hears +the pair stopped being a pair, on a device where the alternative is not +noticing. + A leaf MUST answer a probe only while it holds a session with that peer **that it can still load**, which is the rule a phone already applies to the same frame. The acknowledgement is not a liveness signal: a peer confirms its