Skip to main content

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//!
29//! # Why this is not part of `offline-protocol-core`
30//!
31//! Core links no cryptography at all, deliberately, and address derivation is
32//! a SHA-256. Core also knows nothing about MLS, and these types name it.
33//! Putting them in core would push a hash implementation into every consumer
34//! of the protocol's base types, including a relay-only leaf image that never
35//! derives an address. See ADR 0022.
36//!
37//! # Building without `std`
38//!
39//! Like [`offline_protocol_core`], this crate compiles for bare-metal targets
40//! with `--no-default-features`, and `alloc` is required in both
41//! configurations. Nothing here reads a clock, draws entropy or takes a lock,
42//! so unlike core the no_std configuration loses no functionality: the `std`
43//! feature only forwards to dependencies.
44
45#![cfg_attr(not(feature = "std"), no_std)]
46#![deny(unsafe_code)]
47#![warn(missing_docs)]
48
49extern crate alloc;
50
51pub mod canonical;
52pub mod constants;
53pub mod derive;
54pub mod envelope;
55pub mod error;
56pub mod freshness;
57pub mod keypackage;
58pub mod prefixes;
59
60pub use canonical::{
61    canonical_payload, control_signing_payload, control_signing_payload_v2,
62    gateway_address_proof_payload, CTRL_PK_META_KEY, CTRL_SIGN_DOMAIN, CTRL_SIGN_DOMAIN_V2,
63    CTRL_SIG_META_KEY, GATEWAY_ADDR_SIGN_DOMAIN,
64};
65pub use constants::{
66    ACK_FOR_KEY, LEAF_KEY_PACKAGE_LIFETIME, LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS,
67    MAX_ACCEPTED_KEY_PACKAGE_LIFETIME, SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE,
68    SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
69};
70pub use derive::{derive_address, ED25519_PUBLIC_KEY_LEN};
71pub use envelope::{EncryptedMessage, GroupId, MlsMessageType, WelcomeMessage};
72pub use error::{Result, SealedError};
73pub use freshness::{
74    control_frame_freshness, Freshness, CTRL_FRESHNESS_FUTURE_MS, CTRL_FRESHNESS_PAST_MS,
75    LEAF_CTRL_FRESHNESS_PAST_MS,
76};
77pub use keypackage::{KeyPackagePayload, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1};
78
79#[cfg(all(test, feature = "std"))]
80mod manifest_guard_tests {
81    /// Dependencies this crate declares locally instead of inheriting from the
82    /// workspace table, and which must therefore be kept in lockstep by hand.
83    /// See the comment above `[dependencies]` in this crate's `Cargo.toml` for
84    /// why inheritance is not an option here.
85    const LOCAL_DEPS: &[&str] = &[
86        "serde",
87        "serde_json",
88        "base64",
89        "thiserror",
90        "sha2",
91        "offline-protocol-core",
92    ];
93
94    /// Extracts the version requirement a manifest states for `name`.
95    ///
96    /// Handles both spellings cargo accepts: `name = "1.0"` and
97    /// `name = { version = "1.0", ... }`.
98    fn version_req(manifest: &str, name: &str) -> Option<String> {
99        let line = manifest
100            .lines()
101            .find(|l| l.starts_with(&format!("{name} = ")))?;
102        let rest = match line.find("version = \"") {
103            Some(i) => &line[i + "version = \"".len()..],
104            None => {
105                let i = line.find('"')?;
106                &line[i + 1..]
107            }
108        };
109        rest.find('"').map(|end| rest[..end].to_string())
110    }
111
112    /// The local dependency entries must not drift from the workspace table.
113    ///
114    /// Dropping inheritance is what makes `default-features = false` take
115    /// effect (cargo ignores it on an inherited dependency), but it also means
116    /// a workspace-wide version bump silently stops applying to this crate.
117    /// This test is what turns that silence into a failure.
118    #[test]
119    fn local_dep_versions_match_the_workspace_table() {
120        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
121        let root = dir.join("../../Cargo.toml");
122        // A published .crate archive contains no workspace root. Skip rather
123        // than panic: this guard protects the repo, and a packaged build that
124        // panics here fails the release for a condition it cannot observe.
125        let Ok(root_manifest) = std::fs::read_to_string(&root) else {
126            return;
127        };
128        let own_manifest =
129            std::fs::read_to_string(dir.join("Cargo.toml")).expect("own manifest is readable");
130
131        for dep in LOCAL_DEPS {
132            let workspace = version_req(&root_manifest, dep)
133                .unwrap_or_else(|| panic!("`{dep}` not found in the workspace dependency table"));
134            let own = version_req(&own_manifest, dep)
135                .unwrap_or_else(|| panic!("`{dep}` not found in this crate's manifest"));
136            assert_eq!(
137                workspace, own,
138                "`{dep}` version drifted: workspace says {workspace:?}, \
139                 offline-protocol-sealed says {own:?}. Update both together."
140            );
141        }
142    }
143}
144
145#[cfg(all(test, feature = "std"))]
146mod interop_harness_guard_tests {
147    /// `tools/mls-interop` is its own cargo workspace, so nothing in
148    /// `cargo test --workspace` compiles it and no type check connects it to
149    /// this crate. It is also the one harness whose whole purpose is to run
150    /// the SDK's configuration against a second MLS implementation, which it
151    /// stops doing the moment it declares its own copy of that configuration.
152    ///
153    /// It used to: a local `derive_address` and local ratchet constants, with
154    /// a manifest comment saying nothing pinned them. This test is what
155    /// replaced that comment, and it is the only thing in the workspace test
156    /// run that can notice them coming back.
157    #[test]
158    fn the_interop_harness_uses_this_crate_rather_than_its_own_copies() {
159        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
160        let harness = dir.join("../../tools/mls-interop");
161        // Absent in a published .crate archive; see the manifest guard above.
162        let (Ok(main_rs), Ok(manifest)) = (
163            std::fs::read_to_string(harness.join("src/main.rs")),
164            std::fs::read_to_string(harness.join("Cargo.toml")),
165        ) else {
166            eprintln!("tools/mls-interop not present, skipping harness copy check");
167            return;
168        };
169
170        assert!(
171            manifest.contains("offline-protocol-sealed"),
172            "the harness must depend on offline-protocol-sealed, not restate what it declares"
173        );
174
175        for symbol in [
176            "derive_address",
177            "SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE",
178            "SENDER_RATCHET_MAXIMUM_FORWARD_DISTANCE",
179            "LEAF_KEY_PACKAGE_LIFETIME",
180            "LEAF_KEY_PACKAGE_NOT_BEFORE_BACKDATE_SECONDS",
181            "MAX_ACCEPTED_KEY_PACKAGE_LIFETIME",
182        ] {
183            assert!(
184                main_rs.contains(symbol),
185                "the harness no longer references `{symbol}`; if it stopped needing it, \
186                 drop it from this list, but if it declared its own copy instead, do not"
187            );
188        }
189
190        // The copies themselves. A `fn derive_address` or a bare numeric
191        // constant here is the harness testing itself instead of the SDK.
192        for copy in [
193            "fn derive_address",
194            "const OUT_OF_ORDER_TOLERANCE",
195            "const MAXIMUM_FORWARD_DISTANCE",
196            "const KEY_PACKAGE_LIFETIME",
197            "const NOT_BEFORE_BACKDATE",
198            "const MAX_ACCEPTED",
199        ] {
200            assert!(
201                !main_rs.contains(copy),
202                "`{copy}` is back in tools/mls-interop/src/main.rs: the harness has to use \
203                 offline-protocol-sealed's, or it stops testing the SDK's configuration"
204            );
205        }
206    }
207}