offline_protocol_sealed/lib.rs
1//! The pieces both ends of a sealed conversation must agree on.
2//!
3//! A phone and a leaf node run different MLS implementations (ADR 0021: the
4//! phone runs OpenMLS, a Cortex-M leaf runs mls-rs), but they are not allowed
5//! to disagree about anything outside them. The envelope a ciphertext travels
6//! in, the derivation that turns an identity key into an address, the byte
7//! string a signature is taken over, and the ratchet numbers a session is
8//! configured with are all *shared* rather than *implemented twice*. This
9//! crate is where they live, and it exists because those pieces previously sat
10//! in crates a leaf node cannot link.
11//!
12//! Each piece is here for the same reason, stated as the failure a second copy
13//! causes:
14//!
15//! - [`envelope`]: two copies of the codec are two wire formats the moment one
16//! is edited alone, and the symptom is a peer that cannot read a message.
17//! - [`mod@derive`]: two derivations are two identities for one key, and every
18//! trust gate in the protocol is a derive-and-compare, so a drifted copy
19//! rejects legitimate peers or, worse, accepts a mismatched one.
20//! - [`canonical`]: a verifier that rebuilds a signing payload differently
21//! from the signer either rejects every signature or, if the difference is
22//! an ambiguity rather than a mismatch, accepts a forged one.
23//! - [`constants`]: a number configured differently on the two ends produces
24//! a session that silently stops decrypting under load.
25//! - [`freshness`]: two ends that disagree about how old a signed control
26//! frame may be produce a pair that refuses its own traffic in one direction
27//! and accepts a replayed frame in the other.
28//! - [`identity_assertion`]: a second copy of the byte layout a peer proves
29//! its address with is a peer one platform can verify and another cannot,
30//! and the symptom is a link that carries frames the receiving core then
31//! refuses as unattributed.
32//!
33//! # Why this is not part of `offline-protocol-core`
34//!
35//! Core links no cryptography at all, deliberately, and address derivation is
36//! a SHA-256. Core also knows nothing about MLS, and these types name it.
37//! Putting them in core would push a hash implementation into every consumer
38//! of the protocol's base types, including a relay-only leaf image that never
39//! derives an address. See ADR 0022.
40//!
41//! # Building without `std`
42//!
43//! Like [`offline_protocol_core`], this crate compiles for bare-metal targets
44//! with `--no-default-features`, and `alloc` is required in both
45//! configurations. Nothing here reads a clock, draws entropy or takes a lock,
46//! so unlike core the no_std configuration loses no functionality: the `std`
47//! feature only forwards to dependencies.
48
49#![cfg_attr(not(feature = "std"), no_std)]
50#![deny(unsafe_code)]
51#![warn(missing_docs)]
52
53extern crate alloc;
54
55pub mod canonical;
56pub mod constants;
57pub mod derive;
58pub mod envelope;
59pub mod error;
60pub mod freshness;
61pub mod identity_assertion;
62pub mod keypackage;
63pub mod prefixes;
64
65pub use canonical::{
66 canonical_payload, control_signing_payload, control_signing_payload_v2,
67 gateway_address_proof_payload, CTRL_PK_META_KEY, CTRL_SIGN_DOMAIN, CTRL_SIGN_DOMAIN_V2,
68 CTRL_SIG_META_KEY, GATEWAY_ADDR_SIGN_DOMAIN,
69};
70pub use constants::{
71 ACK_FOR_KEY, LEAF_KEY_PACKAGE_LIFETIME, LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS,
72 MAX_ACCEPTED_KEY_PACKAGE_LIFETIME, SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE,
73 SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
74};
75pub use derive::{derive_address, ED25519_PUBLIC_KEY_LEN};
76pub use envelope::{EncryptedMessage, GroupId, MlsMessageType, WelcomeMessage};
77pub use error::{Result, SealedError};
78pub use freshness::{
79 control_frame_freshness, Freshness, CTRL_FRESHNESS_FUTURE_MS, CTRL_FRESHNESS_PAST_MS,
80 LEAF_CTRL_FRESHNESS_PAST_MS,
81};
82pub use identity_assertion::{
83 encode_identity_assertion, parse_identity_assertion, IdentityAssertion, ED25519_SIGNATURE_LEN,
84 IDENTITY_ASSERTION_MIN_LEN,
85};
86pub use keypackage::{KeyPackagePayload, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1};
87
88#[cfg(all(test, feature = "std"))]
89mod manifest_guard_tests {
90 /// Dependencies this crate declares locally instead of inheriting from the
91 /// workspace table, and which must therefore be kept in lockstep by hand.
92 /// See the comment above `[dependencies]` in this crate's `Cargo.toml` for
93 /// why inheritance is not an option here.
94 const LOCAL_DEPS: &[&str] = &[
95 "serde",
96 "serde_json",
97 "base64",
98 "thiserror",
99 "sha2",
100 "offline-protocol-core",
101 ];
102
103 /// Extracts the version requirement a manifest states for `name`.
104 ///
105 /// Handles both spellings cargo accepts: `name = "1.0"` and
106 /// `name = { version = "1.0", ... }`.
107 fn version_req(manifest: &str, name: &str) -> Option<String> {
108 let line = manifest
109 .lines()
110 .find(|l| l.starts_with(&format!("{name} = ")))?;
111 let rest = match line.find("version = \"") {
112 Some(i) => &line[i + "version = \"".len()..],
113 None => {
114 let i = line.find('"')?;
115 &line[i + 1..]
116 }
117 };
118 rest.find('"').map(|end| rest[..end].to_string())
119 }
120
121 /// The local dependency entries must not drift from the workspace table.
122 ///
123 /// Dropping inheritance is what makes `default-features = false` take
124 /// effect (cargo ignores it on an inherited dependency), but it also means
125 /// a workspace-wide version bump silently stops applying to this crate.
126 /// This test is what turns that silence into a failure.
127 #[test]
128 fn local_dep_versions_match_the_workspace_table() {
129 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
130 let root = dir.join("../../Cargo.toml");
131 // A published .crate archive contains no workspace root. Skip rather
132 // than panic: this guard protects the repo, and a packaged build that
133 // panics here fails the release for a condition it cannot observe.
134 let Ok(root_manifest) = std::fs::read_to_string(&root) else {
135 return;
136 };
137 let own_manifest =
138 std::fs::read_to_string(dir.join("Cargo.toml")).expect("own manifest is readable");
139
140 for dep in LOCAL_DEPS {
141 let workspace = version_req(&root_manifest, dep)
142 .unwrap_or_else(|| panic!("`{dep}` not found in the workspace dependency table"));
143 let own = version_req(&own_manifest, dep)
144 .unwrap_or_else(|| panic!("`{dep}` not found in this crate's manifest"));
145 assert_eq!(
146 workspace, own,
147 "`{dep}` version drifted: workspace says {workspace:?}, \
148 offline-protocol-sealed says {own:?}. Update both together."
149 );
150 }
151 }
152}
153
154#[cfg(all(test, feature = "std"))]
155mod interop_harness_guard_tests {
156 /// `tools/mls-interop` is its own cargo workspace, so nothing in
157 /// `cargo test --workspace` compiles it and no type check connects it to
158 /// this crate. It is also the one harness whose whole purpose is to run
159 /// the SDK's configuration against a second MLS implementation, which it
160 /// stops doing the moment it declares its own copy of that configuration.
161 ///
162 /// It used to: a local `derive_address` and local ratchet constants, with
163 /// a manifest comment saying nothing pinned them. This test is what
164 /// replaced that comment, and it is the only thing in the workspace test
165 /// run that can notice them coming back.
166 #[test]
167 fn the_interop_harness_uses_this_crate_rather_than_its_own_copies() {
168 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
169 let harness = dir.join("../../tools/mls-interop");
170 // Absent in a published .crate archive; see the manifest guard above.
171 let (Ok(main_rs), Ok(manifest)) = (
172 std::fs::read_to_string(harness.join("src/main.rs")),
173 std::fs::read_to_string(harness.join("Cargo.toml")),
174 ) else {
175 eprintln!("tools/mls-interop not present, skipping harness copy check");
176 return;
177 };
178
179 assert!(
180 manifest.contains("offline-protocol-sealed"),
181 "the harness must depend on offline-protocol-sealed, not restate what it declares"
182 );
183
184 for symbol in [
185 "derive_address",
186 "SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE",
187 "SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE",
188 "LEAF_KEY_PACKAGE_LIFETIME",
189 "LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS",
190 "MAX_ACCEPTED_KEY_PACKAGE_LIFETIME",
191 ] {
192 assert!(
193 main_rs.contains(symbol),
194 "the harness no longer references `{symbol}`; if it stopped needing it, \
195 drop it from this list, but if it declared its own copy instead, do not"
196 );
197 }
198
199 // The copies themselves. A `fn derive_address` or a bare numeric
200 // constant here is the harness testing itself instead of the SDK.
201 for copy in [
202 "fn derive_address",
203 "const OUT_OF_ORDER_TOLERANCE",
204 "const MAXIMUM_FORWARD_DISTANCE",
205 "const KEY_PACKAGE_LIFETIME",
206 "const NOT_BEFORE_BACKDATE",
207 "const MAX_ACCEPTED",
208 ] {
209 assert!(
210 !main_rs.contains(copy),
211 "`{copy}` is back in tools/mls-interop/src/main.rs: the harness has to use \
212 offline-protocol-sealed's, or it stops testing the SDK's configuration"
213 );
214 }
215 }
216}
217
218#[cfg(all(test, feature = "std"))]
219mod second_codec_guard_tests {
220 /// Every `src/` tree in the workspace and the tools, except this crate's.
221 fn other_rust_sources() -> Vec<std::path::PathBuf> {
222 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
223 let mut out = Vec::new();
224 for group in ["crates", "tools"] {
225 let Ok(entries) = std::fs::read_dir(root.join(group)) else {
226 continue;
227 };
228 for entry in entries.flatten() {
229 let dir = entry.path();
230 if dir.file_name().and_then(|n| n.to_str()) == Some("offline-protocol-sealed") {
231 continue;
232 }
233 collect_rs(&dir.join("src"), &mut out);
234 }
235 }
236 out
237 }
238
239 fn collect_rs(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
240 let Ok(entries) = std::fs::read_dir(dir) else {
241 return;
242 };
243 for entry in entries.flatten() {
244 let path = entry.path();
245 if path.is_dir() {
246 collect_rs(&path, out);
247 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
248 out.push(path);
249 }
250 }
251 }
252
253 /// The identity assertion was split three times before this crate held
254 /// it, once per bridge, and the Python copy was not even the same
255 /// format. The convenient route back is a harness, a transport or a
256 /// fixture writing `bytes[..32]`, `bytes[32..96]` and a second `fn
257 /// parse_identity_assertion` rather than taking the dependency. This is
258 /// the only thing in the workspace test run that can notice.
259 #[test]
260 fn no_other_crate_declares_an_identity_assertion_codec() {
261 let sources = other_rust_sources();
262 // A published .crate archive has no sibling crates; see the manifest
263 // guard above for why that is a skip and not a failure.
264 if sources.is_empty() {
265 eprintln!("no sibling crates present, skipping the second-codec check");
266 return;
267 }
268 for path in sources {
269 let text = std::fs::read_to_string(&path).expect("source is readable");
270 for copy in [
271 "fn parse_identity_assertion",
272 "fn encode_identity_assertion",
273 ] {
274 assert!(
275 !text.contains(copy),
276 "`{copy}` is declared in {}: the assertion layout has one home, \
277 offline-protocol-sealed, and everything else calls it",
278 path.display()
279 );
280 }
281 }
282 }
283}