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..f1fdf549 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. @@ -134,8 +135,107 @@ 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, 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 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 + 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. 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 + 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 + 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. + + 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 + 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. 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, 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 + 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..a66736f9 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, 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) | @@ -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..a2a10112 --- /dev/null +++ b/crates/offline-protocol-leaf/Cargo.toml @@ -0,0 +1,97 @@ +[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"] } 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..03a833f1 --- /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 + +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`). + +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..52c36d9c --- /dev/null +++ b/crates/offline-protocol-leaf/src/adapters.rs @@ -0,0 +1,542 @@ +//! 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 {} + +/// 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 +/// 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)) +} + +/// 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 { + 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> { + let Some(raw) = self + .store + .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))? + else { + return Ok(None); + }; + let raw = Zeroizing::new(raw); + let (_, state) = decode_state_entry(&raw)?; + Ok(Some(Zeroizing::new(state.to_vec()))) + } + + 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 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. + /// + /// 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, + state: GroupState, + epoch_inserts: Vec, + epoch_updates: Vec, + ) -> Result<(), Self::Error> { + 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( + KEY_TYPE_GROUP_EPOCH, + &epoch_key(&state.id, record.id), + &record.data, + )?; + marker = Some(marker.map_or(record.id, |held: u64| held.max(record.id))); + } + + self.store.store( + KEY_TYPE_GROUP_STATE, + &state_key(&state.id), + &encode_state_entry(marker, &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. + 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(()) + } + + /// 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 Some(raw) = self + .store + .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) + } +} + +/// 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. 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, +} + +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> { + 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:?}")))?; + let key = hex(&id); + + 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() + }; + + // 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) + } + + 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, + + /// 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 + /// 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); + }; + 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..66ff3320 --- /dev/null +++ b/crates/offline-protocol-leaf/src/device.rs @@ -0,0 +1,1238 @@ +//! 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, Group, 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, 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, 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)] +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. + /// + /// `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, proven rather than claimed. + 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. +/// +/// # 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 +/// +/// **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, 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 +/// 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(&mut self, peer: &str, now_unix_secs: u64) -> Result { + self.key_package_frame_inner(peer, now_unix_secs, false) + } + + fn key_package_frame_inner( + &mut self, + peer: &str, + now_unix_secs: u64, + session_reset: bool, + ) -> Result { + let client = self.client()?; + 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}")))?; + + 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. + // + // 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()))?; + + // 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) + } + + /// 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(&mut self, peer: &str, plaintext: &str, now_unix_secs: u64) -> Result { + self.seal_content(peer, plaintext.as_bytes(), now_unix_secs) + } + + 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 = self.load_group(&client, peer, &group_id)?; + + 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. + /// + /// # 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 + // 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() { + // 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(ignored( + "an acknowledgement arrived for a probe this device never sends", + )) + } else { + Ok(ignored("frame carries no prefix this device answers")) + } + } + + 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}")))?; + + 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 + ))); + } + + // 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 { + 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(); + 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(&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}")))?; + + 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:?}")))?; + + // 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. + let (mut group, _info) = client + .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:?}")))?; + + // A session exists from here, so the peer has to be in the index + // 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 + // 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(&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. + 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 = 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:?}")))?; + + 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(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"), + }], + }; + + Ok(Handled { + outbound: Vec::new(), + events, + }) + } + + /// 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(); + + // 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( + &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. + /// + /// # 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 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. + // + // 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 + .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()))?; + self.store + .delete(KEY_TYPE_GROUP_EPOCH, &format!("{key}:max")) + .map_err(|e| LeafError::Storage(e.to_string()))?; + Ok(()) + } + + /// 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 + .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())) + } + + /// 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, 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 + /// [`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 + /// 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 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 + /// 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 + /// 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: &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!( + "authored 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(), + ) + } + + /// 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) + } + + /// 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(), + 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)?; + 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( + KEY_TYPE_GROUP_STATE, + &crate::adapters::hex(group_id.as_str().as_bytes()), + ) + .map_err(|e| LeafError::Storage(e.to_string()))? + .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 { + 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() + } +} + +/// 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}"))) +} + +/// 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}"))) +} diff --git a/crates/offline-protocol-leaf/src/error.rs b/crates/offline-protocol-leaf/src/error.rs new file mode 100644 index 00000000..337d5279 --- /dev/null +++ b/crates/offline-protocol-leaf/src/error.rs @@ -0,0 +1,116 @@ +//! 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), + + /// 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. + /// + /// 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), +} + +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..e5739ce0 --- /dev/null +++ b/crates/offline-protocol-leaf/src/frames.rs @@ -0,0 +1,227 @@ +//! 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); + + // 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, 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) +} + +/// 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..7542af3e --- /dev/null +++ b/crates/offline-protocol-leaf/src/identity.rs @@ -0,0 +1,232 @@ +//! 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. + /// + /// # 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) + .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_PUBLIC, public.as_bytes()) + .map_err(|e| LeafError::Storage(e.to_string()))?; + store + .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())?; + Ok(Self { + secret, + public, + address, + }) + } + + /// 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. + /// + /// # 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) + .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 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, + 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..ee7ef51c --- /dev/null +++ b/crates/offline-protocol-leaf/src/keypkg.rs @@ -0,0 +1,129 @@ +//! 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::{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}; + +/// 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. +/// +/// 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); + + 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:?}")))?; + + 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(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:?}")))?; + + Ok(Minted { + data, + reference: crate::adapters::hex(&reference), + }) +} + +/// 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..fbe76377 --- /dev/null +++ b/crates/offline-protocol-leaf/src/lib.rs @@ -0,0 +1,210 @@ +//! 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 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 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. +//! +//! ``` +//! 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 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 +//! // 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(()) +//! # } +//! ``` +//! +//! # 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 +//! 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. +//! +//! **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 +//! 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 +//! 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..f46b4d8d --- /dev/null +++ b/crates/offline-protocol-leaf/src/store.rs @@ -0,0 +1,179 @@ +//! 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. +//! +//! # 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, +//! 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"; +/// 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)] +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..85623b6e --- /dev/null +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -0,0 +1,2180 @@ +//! 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 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_KEY_PACKAGE, + 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, GroupId, + KeyPackagePayload, WelcomeMessage, 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"); +} + +/// 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 +/// phone establishes and sends a Welcome, the device joins and confirms. +fn pair(phone: &Phone, device: &mut 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 mut device = device(store); + let device_address = pair(&phone, &mut 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 mut device = device(Arc::clone(&store) as Arc); + let device_address = pair(&phone, &mut 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 mut 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 mut 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 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. + 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 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. + 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 mut 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 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 + // 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 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 mut 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 mut device = device(Arc::new(MemoryStore::new())); + let device_address = pair(&phone, &mut 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 mut device = device(Arc::new(MemoryStore::new())); + pair(&phone, &mut device); + + 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_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 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(); + let mut 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 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. + 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 mut device = device(Arc::clone(&store) as Arc); + pair(&phone, &mut 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 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, + 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" + ); +} + +/// 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" + ); +} + +#[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, + 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 mut device = device(Arc::new(MemoryStore::new())); + let device_address = device.address().to_string(); + + // 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(&phone.address, NOW) + .expect("device advertises"); + let decoy = phone + .manager + .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 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(), + 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" + ); +} + +/// 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() + ); +} + +/// 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(), + }] + ); +} + +/// 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:?})" + ); +} + +/// 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 + ); +} + +/// 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/architecture.md b/docs/architecture.md index 6838dad0..ecb7e151 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -117,7 +117,35 @@ implementations and are not allowed to disagree about anything outside them. **Dependencies**: `offline-protocol-core`, `offline-protocol-sealed`, OpenMLS -### 7. offline-protocol-services +### 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 +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 + +**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. + +**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`. + +### 8. offline-protocol-services **Purpose**: Standalone service discovery and request/response over the mesh. @@ -138,7 +166,7 @@ implementations and are not allowed to disagree about anything outside them. **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. @@ -158,7 +186,7 @@ implementations and are not allowed to disagree about anything outside them. **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. @@ -185,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 6af61b0f..bfb32360 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 @@ -151,6 +172,87 @@ 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 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 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 +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 +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. @@ -167,6 +269,17 @@ 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. 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.