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::{LeafDevice, LeafStore, MemoryStore};
35//! use std::sync::Arc;
36//!
37//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
38//! // A real device implements `LeafStore` over its secure key storage.
39//! let store: Arc<dyn LeafStore> = Arc::new(MemoryStore::new());
40//! let mut device = LeafDevice::open(store, "com.example.lock")?;
41//!
42//! // `now` comes from the radio stack, the commissioner, or the pairing
43//! // exchange. It is a parameter because a device has no clock, and an MLS
44//! // implementation that reads one it does not have stamps 1970.
45//! let now = 1_787_314_332;
46//! let peer = device.address().to_string();
47//! let advertisement = device.key_package_frame(&peer, now)?;
48//! assert!(advertisement.content.starts_with("__MLS_KEY_PKG__"));
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! # Four obligations this crate cannot discharge for you
54//!
55//! **A time source at pairing.** Every entry point that needs a clock takes
56//! `now_unix_secs`. A device that supplies something wrong emits a key package
57//! the peer refuses as expired, and it never pairs at all. Validity is a
58//! freshness bound rather than an authentication mechanism, so a wrong clock
59//! costs availability, not confidentiality.
60//!
61//! **Real entropy.** This crate registers no `getrandom` backend, on purpose:
62//! doing so would let firmware link and run with randomness this crate
63//! invented, and MLS key generation is exactly as strong as what that symbol
64//! returns. Wire it to the part's hardware entropy source.
65//!
66//! **Durable storage.** [`LeafStore`] must be durable and atomic per entry.
67//! This crate orders every persist before the emit it belongs to, so a store
68//! that lies is the one remaining way to reuse an AEAD nonce after a power
69//! cut.
70//!
71//! **Authorization.** A session proves *who* a peer is and never that they may
72//! do anything. Every gate in this crate answers the first question, and any
73//! address in radio range can complete a pairing, because producing a key that
74//! derives to its own address costs nothing. So firmware decides when the radio
75//! accepts a new pairing, and firmware decides what a message from a given peer
76//! may actuate, by the address on the event. A lock that opens for whatever
77//! arrives on an established session opens for anyone patient enough to pair
78//! with it. [`LeafDevice::peers`] is how firmware audits what a device
79//! accumulated and [`LeafDevice::unpair`] is how it removes one.
80//!
81//! # One writer
82//!
83//! Every operation that advances state takes `&mut self`, so a device is one
84//! value exclusively held rather than a handle to share between tasks. Two
85//! seals at once would reuse an AEAD nonce without any power cut being
86//! involved; see [`LeafDevice`] for that argument and for what a replayed
87//! control frame can still do.
88//!
89//! # Bare metal
90//!
91//! Builds with `--no-default-features` for a target with no `std`, which is
92//! the configuration the CI job gates. The `std` build is the same code with
93//! its dependencies' `std` features on, and is what the unit tests run under.
94
95#![cfg_attr(not(feature = "std"), no_std)]
96#![deny(unsafe_code)]
97#![warn(missing_docs)]
98
99extern crate alloc;
100
101#[cfg(feature = "std")]
102extern crate std;
103
104mod adapters;
105mod device;
106mod error;
107mod frames;
108mod identity;
109mod keypkg;
110pub mod store;
111
112pub use device::{Handled, LeafDevice, LeafEvent};
113pub use error::{LeafError, Result};
114pub use identity::CIPHERSUITE;
115pub use store::{LeafStore, StoreError};
116
117#[cfg(any(test, feature = "std"))]
118pub use store::MemoryStore;
119
120#[cfg(all(test, feature = "std"))]
121mod manifest_guard_tests {
122 use std::string::{String, ToString};
123 use std::vec::Vec;
124 use std::{eprintln, format, fs, path::PathBuf};
125
126 /// Dependencies this crate declares locally rather than inheriting.
127 ///
128 /// Inheriting them would silently drop `default-features = false` (the
129 /// trap [ADR 0020](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0020-core-compiles-without-std.md)
130 /// records), so they are spelled out here. This test is the counterweight:
131 /// dropping inheritance means a workspace-wide version bump would
132 /// otherwise stop applying to this crate with nothing to notice.
133 const LOCAL_DEPS: &[&str] = &[
134 "serde",
135 "serde_json",
136 "base64",
137 "thiserror",
138 "zeroize",
139 "getrandom",
140 "mls-rs",
141 "mls-rs-crypto-rustcrypto",
142 "mls-rs-core",
143 "offline-protocol-core",
144 "offline-protocol-sealed",
145 ];
146
147 fn version_req(manifest: &str, name: &str) -> Option<String> {
148 for line in manifest.lines() {
149 let line = line.trim();
150 let Some(rest) = line.strip_prefix(name) else {
151 continue;
152 };
153 let Some(rest) = rest.trim_start().strip_prefix('=') else {
154 continue;
155 };
156 let rest = rest.trim();
157 if let Some(inner) = rest.strip_prefix('{') {
158 let idx = inner.find("version")?;
159 let after = &inner[idx..];
160 let start = after.find('"')? + 1;
161 let end = after[start..].find('"')? + start;
162 return Some(after[start..end].to_string());
163 }
164 if let Some(inner) = rest.strip_prefix('"') {
165 let end = inner.find('"')?;
166 return Some(inner[..end].to_string());
167 }
168 }
169 None
170 }
171
172 #[test]
173 fn local_dep_versions_match_the_workspace_table() {
174 let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
175 let root = here.join("../../Cargo.toml");
176 // A packaged `.crate` archive has no workspace root beside it, and a
177 // release must not fail for its absence.
178 let (Ok(root), Ok(local)) = (
179 fs::read_to_string(&root),
180 fs::read_to_string(here.join("Cargo.toml")),
181 ) else {
182 eprintln!("skipping: workspace root not readable from here");
183 return;
184 };
185
186 let mismatched: Vec<String> = LOCAL_DEPS
187 .iter()
188 .filter_map(|name| {
189 let ours = version_req(&local, name)?;
190 let theirs = version_req(&root, name)?;
191 (ours != theirs).then(|| format!("{name}: local {ours}, workspace {theirs}"))
192 })
193 .collect();
194
195 assert!(
196 mismatched.is_empty(),
197 "local dependency versions have drifted from the workspace table: {mismatched:?}"
198 );
199
200 for name in LOCAL_DEPS {
201 assert!(
202 version_req(&local, name).is_some(),
203 "{name} is in LOCAL_DEPS but not declared in this crate's manifest"
204 );
205 assert!(
206 version_req(&root, name).is_some(),
207 "{name} is in LOCAL_DEPS but not in the workspace dependency table"
208 );
209 }
210 }
211}