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}