Skip to main content

offline_protocol_leaf/
lib.rs

1//! A constrained device that speaks the Offline Protocol as a real peer.
2//!
3//! A leaf node is a door lock, a sensor, or a mains-powered relay: something
4//! with a radio, a few hundred kilobytes of flash and no operating system. It
5//! parses frames, validates addressing, runs RFC 9420 MLS, and holds an
6//! end-to-end encrypted conversation with a phone **under the same guarantees
7//! a phone gets**. Same frames, same envelope, same trust gates, no second
8//! sealing path and no reduced properties.
9//!
10//! That is the decision in
11//! [ADR 0021](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0021-a-leaf-node-speaks-mls.md),
12//! and it is affordable because a phone paired with one device is a
13//! **two-member group**: the ratchet tree is three nodes, per-commit cost is
14//! two elliptic-curve operations, and per-message cost is symmetric only. What
15//! both ends must agree on lives in
16//! [`offline_protocol_sealed`], so the two MLS implementations never disagree
17//! about a byte outside themselves.
18//!
19//! # What this crate is
20//!
21//! [`LeafDevice`] is a frame-level state machine, not a bag of primitives. It
22//! takes an inbound [`Message`](offline_protocol_core::Message) and hands back
23//! the frames to send and what happened. The choreography it implements is
24//! security-critical and easy to get subtly wrong: the derive-and-compare gate
25//! at every site that accepts an identity claim, the binding of a Welcome to
26//! the key package this device minted for the peer that sent it (a package
27//! travels unencrypted, so whoever copies one off the air satisfies every
28//! other gate honestly), the roster re-read at every use of the group that
29//! keeps a pair a pair once commits start arriving, the confirmation that has
30//! to be a group-aware decrypt, and the reset sequence that a driven rekey
31//! arrives as.
32//!
33//! ```
34//! use offline_protocol_leaf::{shared_store, LeafDevice, MemoryStore};
35//!
36//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! // A real device implements `LeafStore` over its secure key storage.
38//! let store = shared_store(MemoryStore::new());
39//! let mut device = LeafDevice::open(store, "com.example.lock")?;
40//!
41//! // `now` comes from the radio stack, the commissioner, or the pairing
42//! // exchange. It is a parameter because a device has no clock, and an MLS
43//! // implementation that reads one it does not have stamps 1970.
44//! let now = 1_787_314_332;
45//! let peer = device.address().to_string();
46//! let advertisement = device.key_package_frame(&peer, now)?;
47//! assert!(advertisement.content.starts_with("__MLS_KEY_PKG__"));
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! # Four obligations this crate cannot discharge for you
53//!
54//! **A time source at pairing.** Every entry point that needs a clock takes
55//! `now_unix_secs`. A device that supplies something wrong emits a key package
56//! the peer refuses as expired, and it never pairs at all. Validity is a
57//! freshness bound rather than an authentication mechanism, so a wrong clock
58//! costs availability, not confidentiality.
59//!
60//! **Real entropy.** This crate registers no `getrandom` backend, on purpose:
61//! doing so would let firmware link and run with randomness this crate
62//! invented, and MLS key generation is exactly as strong as what that symbol
63//! returns. Wire it to the part's hardware entropy source.
64//!
65//! **Durable storage.** [`LeafStore`] must be durable and atomic per entry.
66//! This crate orders every persist before the emit it belongs to, so a store
67//! that lies is the one remaining way to reuse an AEAD nonce after a power
68//! cut.
69//!
70//! **Authorization.** A session proves *who* a peer is and never that they may
71//! do anything. Every gate in this crate answers the first question, and any
72//! address in radio range can complete a pairing, because producing a key that
73//! derives to its own address costs nothing. So firmware decides when the radio
74//! accepts a new pairing, and firmware decides what a message from a given peer
75//! may actuate, by the address on the event. A lock that opens for whatever
76//! arrives on an established session opens for anyone patient enough to pair
77//! with it. [`LeafDevice::peers`] is how firmware audits what a device
78//! accumulated and [`LeafDevice::unpair`] is how it removes one.
79//!
80//! # One writer
81//!
82//! Every operation that advances state takes `&mut self`, so a device is one
83//! value exclusively held rather than a handle to share between tasks. Two
84//! seals at once would reuse an AEAD nonce without any power cut being
85//! involved; see [`LeafDevice`] for that argument and for what a replayed
86//! control frame can still do.
87//!
88//! # Bare metal
89//!
90//! Builds with `--no-default-features` for a target with no `std`, which is
91//! the configuration the CI job gates. The `std` build is the same code with
92//! its dependencies' `std` features on, and is what the unit tests run under.
93//!
94//! CI builds and lints it for a Cortex-M33 (`thumbv8m.main-none-eabihf`), for
95//! RISC-V with the atomic extension (`riscv32imac-unknown-none-elf`: ESP32-C6
96//! and ESP32-H2), and for the two classes with no compare-and-swap at all
97//! (`riscv32imc-unknown-none-elf`: ESP32-C3 and ESP32-C2; `thumbv6m-none-eabi`:
98//! every Cortex-M0). On those last two the firmware must link a
99//! critical-section implementation, because the store handle's reference count
100//! and mls-rs's own locks are built on one; see [`SharedStore`].
101//!
102//! Compiling is all that is claimed. Nothing here is linked into an image or
103//! run on a board by CI. ESP32-C3 and ESP32-C2 cannot yet be built on esp-hal
104//! at all: mls-rs selects portable-atomic's `critical-section` backend on them,
105//! esp-hal selects `unsafe-assume-single-core`, and portable-atomic refuses the
106//! pair.
107
108#![cfg_attr(not(feature = "std"), no_std)]
109#![deny(unsafe_code)]
110#![warn(missing_docs)]
111
112extern crate alloc;
113
114#[cfg(feature = "std")]
115extern crate std;
116
117mod adapters;
118mod device;
119mod error;
120mod frames;
121mod identity;
122mod keypkg;
123mod shared;
124pub mod store;
125
126pub use device::{Handled, LeafDevice, LeafEvent};
127pub use error::{LeafError, Result};
128pub use identity::CIPHERSUITE;
129pub use shared::{shared_store, SharedStore};
130pub use store::{LeafStore, StoreError};
131
132#[cfg(any(test, feature = "std"))]
133pub use store::MemoryStore;
134
135#[cfg(all(test, feature = "std"))]
136mod manifest_guard_tests {
137    use std::string::{String, ToString};
138    use std::vec::Vec;
139    use std::{eprintln, format, fs, path::PathBuf};
140
141    /// Dependencies this crate declares locally rather than inheriting.
142    ///
143    /// Inheriting them would silently drop `default-features = false` (the
144    /// trap [ADR 0020](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0020-core-compiles-without-std.md)
145    /// records), so they are spelled out here. This test is the counterweight:
146    /// dropping inheritance means a workspace-wide version bump would
147    /// otherwise stop applying to this crate with nothing to notice.
148    const LOCAL_DEPS: &[&str] = &[
149        "serde",
150        "serde_json",
151        "base64",
152        "thiserror",
153        "zeroize",
154        "getrandom",
155        "portable-atomic-util",
156        "mls-rs",
157        "mls-rs-crypto-rustcrypto",
158        "mls-rs-core",
159        "offline-protocol-core",
160        "offline-protocol-sealed",
161    ];
162
163    fn version_req(manifest: &str, name: &str) -> Option<String> {
164        for line in manifest.lines() {
165            let line = line.trim();
166            let Some(rest) = line.strip_prefix(name) else {
167                continue;
168            };
169            let Some(rest) = rest.trim_start().strip_prefix('=') else {
170                continue;
171            };
172            let rest = rest.trim();
173            if let Some(inner) = rest.strip_prefix('{') {
174                let idx = inner.find("version")?;
175                let after = &inner[idx..];
176                let start = after.find('"')? + 1;
177                let end = after[start..].find('"')? + start;
178                return Some(after[start..end].to_string());
179            }
180            if let Some(inner) = rest.strip_prefix('"') {
181                let end = inner.find('"')?;
182                return Some(inner[..end].to_string());
183            }
184        }
185        None
186    }
187
188    #[test]
189    fn local_dep_versions_match_the_workspace_table() {
190        let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
191        let root = here.join("../../Cargo.toml");
192        // A packaged `.crate` archive has no workspace root beside it, and a
193        // release must not fail for its absence.
194        let (Ok(root), Ok(local)) = (
195            fs::read_to_string(&root),
196            fs::read_to_string(here.join("Cargo.toml")),
197        ) else {
198            eprintln!("skipping: workspace root not readable from here");
199            return;
200        };
201
202        let mismatched: Vec<String> = LOCAL_DEPS
203            .iter()
204            .filter_map(|name| {
205                let ours = version_req(&local, name)?;
206                let theirs = version_req(&root, name)?;
207                (ours != theirs).then(|| format!("{name}: local {ours}, workspace {theirs}"))
208            })
209            .collect();
210
211        assert!(
212            mismatched.is_empty(),
213            "local dependency versions have drifted from the workspace table: {mismatched:?}"
214        );
215
216        for name in LOCAL_DEPS {
217            assert!(
218                version_req(&local, name).is_some(),
219                "{name} is in LOCAL_DEPS but not declared in this crate's manifest"
220            );
221            assert!(
222                version_req(&root, name).is_some(),
223                "{name} is in LOCAL_DEPS but not in the workspace dependency table"
224            );
225        }
226    }
227}