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::{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}