Skip to main content

offline_protocol_sealed/
keypackage.rs

1//! The key package payload, which is how a peer says who it is and what it
2//! can parse.
3//!
4//! This is the body of a [`prefixes::KEY_PACKAGE`] frame, and it is the only
5//! channel in this protocol by which capabilities are advertised. A device
6//! with no key package to mint has no way to say it understands anything, so
7//! every frame sent to it falls to the protocol floor forever. That is the
8//! reason a leaf node runs MLS rather than something smaller: not the
9//! sealing, the advertising.
10//!
11//! Both ends must agree on this shape, and they build it with different MLS
12//! implementations, so it lives here rather than in the engine.
13//!
14//! [`prefixes::KEY_PACKAGE`]: crate::prefixes::KEY_PACKAGE
15
16use alloc::{string::String, vec::Vec};
17use serde::{Deserialize, Serialize};
18
19/// Payload for key package exchange.
20///
21/// Every field except `user_id` and `key_package_data` carries
22/// `#[serde(default)]`, so a payload written by an older peer that has never
23/// heard of a field decodes cleanly with that field empty. Empty selects the
24/// floor, by rule and never by error, which is what makes a mixed fleet safe.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct KeyPackagePayload {
27    /// User ID of the key package owner.
28    pub user_id: String,
29    /// Raw key package data.
30    ///
31    /// A **bare** key package, not one wrapped in an MLS message. Both forms
32    /// are legal MLS and only one of them is this protocol's wire, so an
33    /// implementation whose convenience API returns the wrapped form has to
34    /// unwrap it before encoding.
35    pub key_package_data: Vec<u8>,
36    /// Remaining valid lifetime in milliseconds (relative, not absolute).
37    /// Receiver applies this to their local clock, avoiding clock skew issues.
38    #[serde(default)]
39    pub remaining_lifetime_ms: u64,
40    /// Legacy absolute timestamp field, ignored on receive, kept for
41    /// backward compatibility with old nodes that may still send it.
42    #[serde(default)]
43    pub timestamp_ms: u64,
44    /// When `true`, the sender has reset their MLS session state and the
45    /// receiver should discard any existing session for this peer before
46    /// establishing a new one.
47    ///
48    /// Two senders set it. Post-unblock convergence: when Alice unblocks Bob,
49    /// Alice deletes her MLS session and sends a fresh key package with this
50    /// flag, so Bob deletes his now-orphaned session and both converge on one
51    /// fresh group. And a driven session rekey, which is how post-compromise
52    /// security reaches a peer that never commits: the rekey arrives as this
53    /// flag on a key package, never as an unsolicited Welcome.
54    ///
55    /// A receiver that treats a reset as an ordinary key package refresh keeps
56    /// a session the sender has already discarded, and every later frame from
57    /// it decrypts to nothing.
58    #[serde(default)]
59    pub session_reset: bool,
60
61    /// Wire-format versions the sender can decode (e.g. `[1]` for binary v1).
62    /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning JSON
63    /// only), so an old peer is never sent a binary frame it cannot parse.
64    ///
65    /// Trust boundary: this rides in the plaintext payload *alongside* the
66    /// signed MLS `key_package_data`, not *inside* the signature, so it is not
67    /// cryptographically bound to the sender. An attacker on the pre-session
68    /// bootstrap could strip it (a harmless JSON downgrade) or forge `[1]`
69    /// onto a legacy peer (making us emit binary that peer drops, a targeted
70    /// delivery denial of service). This grants no new capability: such an
71    /// attacker already controls key package delivery and could deny service
72    /// outright. The negotiation is a performance optimization, never a
73    /// security control.
74    #[serde(default)]
75    pub wire_versions: Vec<u8>,
76
77    /// MLS envelope formats the sender can parse (e.g. `[1]` for the compact
78    /// envelope, [`MLS_ENVELOPE_COMPACT_V1`]). Absent on legacy nodes
79    /// (`#[serde(default)]` gives empty, meaning the legacy JSON envelope
80    /// only), so an old peer is never sent an envelope it cannot parse.
81    ///
82    /// Distinct from `wire_versions`: that one is hop-local (which *frames*
83    /// the peer decodes), this one is end-to-end (which
84    /// [`prefixes::ENCRYPTED`] payload encodings the *recipient* parses after
85    /// any number of relay hops).
86    ///
87    /// Trust boundary: identical to `wire_versions` above, a plaintext field
88    /// and not signature-bound. Stripping it downgrades to the JSON envelope
89    /// (harmless); forging it onto a legacy peer makes us emit envelopes that
90    /// peer rejects with a `message_decryption_failed` event (a targeted
91    /// delivery denial of service an attacker in that position already has). A
92    /// performance optimization, never a security control.
93    ///
94    /// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
95    #[serde(default)]
96    pub env_versions: Vec<u8>,
97
98    /// Sealed rich-payload versions the sender can parse (e.g. `[1]` for
99    /// `RICH_PAYLOAD_V1`). Absent on legacy nodes (`#[serde(default)]` gives
100    /// empty, meaning plain text only), so an old peer is never sent a
101    /// `__RICH_V1__` body it would surface as raw JSON text.
102    ///
103    /// End-to-end like `env_versions` (what the *recipient* parses inside the
104    /// decrypted MLS plaintext), not hop-local like `wire_versions`.
105    ///
106    /// Trust boundary: identical to the two fields above, a plaintext field
107    /// and not signature-bound. Stripping it downgrades to plain text with the
108    /// rich extras dropped (harmless); forging it onto a legacy peer makes us
109    /// seal bodies that peer renders as JSON text (a nuisance an attacker in
110    /// that position could match by corrupting delivery outright). A feature
111    /// negotiation, never a security control.
112    #[serde(default)]
113    pub rich_versions: Vec<u8>,
114
115    /// Replicated-document sync versions the sender speaks (e.g. `[1]` for
116    /// `DATA_SYNC_V1`). Absent on legacy nodes and on installs with the data
117    /// layer switched off (`#[serde(default)]` gives empty), and toward a peer
118    /// that advertises nothing here no `__DATA_V1__` frame is ever sent.
119    ///
120    /// End-to-end like `env_versions` and `rich_versions`: it says what the
121    /// *recipient* parses inside the decrypted MLS plaintext, not what a
122    /// directly connected neighbour decodes on the wire.
123    ///
124    /// The version doubles as the engine's encoding generation. The CRDT
125    /// engine promises that new code reads old encodings and says nothing
126    /// about the reverse, so a future encoding change is a new version byte
127    /// here and a mixed fleet declines what it cannot read instead of
128    /// discovering it at import.
129    ///
130    /// Trust boundary: identical to the capability lists above, a plaintext
131    /// field and not signature-bound. Stripping it stops documents replicating
132    /// with that peer (they stay editable locally and converge whenever a
133    /// genuine advertisement arrives); forging it onto a peer that cannot
134    /// parse the frames makes us send bodies they consume and drop. Neither
135    /// grants an attacker anything they could not achieve by dropping the
136    /// packets outright. A feature negotiation, never a security control.
137    #[serde(default)]
138    pub data_versions: Vec<u8>,
139
140    /// Control-frame signing versions the sender **verifies** (e.g. `[2]` for
141    /// [`CTRL_SIGN_V2`], the payload that binds the frame's timestamp).
142    /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning the v1
143    /// payload only), so a peer that has never heard of the freshness-bound
144    /// domain is never sent a signature it would read as invalid.
145    ///
146    /// Unlike every capability list above, this one says what the sender
147    /// *accepts*, not what it emits. It has to: a signature is produced once
148    /// and verified by the far end, so the choice of domain belongs to whoever
149    /// is going to check it.
150    ///
151    /// Trust boundary: this is a plaintext field like the others, and an
152    /// attacker who strips it makes us sign the older payload toward a peer
153    /// that would have accepted the newer one. That downgrade is real and it
154    /// is why stripping it is not the end of the story: a receiver that has
155    /// **once** verified a v2 signature from a peer records that durably and
156    /// refuses that peer's v1 control frames from then on, so the strip works
157    /// only until the first genuine v2 frame arrives and never afterwards. The
158    /// field is what makes the first one possible; the record is what makes it
159    /// stick.
160    #[serde(default)]
161    pub ctrl_versions: Vec<u8>,
162
163    /// This install's Nostr public key (x-only, 64-char lowercase hex), so a
164    /// peer can seal Nostr gift wraps to a key only this install holds.
165    ///
166    /// `None` on legacy nodes and on installs with Nostr disabled. A peer
167    /// without it seals to our *publicly computable* key instead, deliverable
168    /// either way, but readable by anyone who guesses our user id, so the
169    /// difference is real privacy rather than a mere optimization.
170    ///
171    /// Trust boundary: **unlike the capability lists above, this one is only
172    /// honoured from a signed key package.** All four ride in the same
173    /// plaintext payload, but this field is consumed as a *destination key*,
174    /// not as a feature hint, so the distinction matters: a wrong capability
175    /// costs a fallback, whereas a wrong key here means envelope metadata is
176    /// sealed *to whoever supplied it* and is then readable off a public
177    /// relay, passively, for as long as the value stands.
178    ///
179    /// The canonical signing payload covers the whole
180    /// [`prefixes::KEY_PACKAGE`] body under the sender's Ed25519 signature,
181    /// which the gate verifies against the key their address derives from, so
182    /// on this prefix an unsigned frame does not reach dispatch at all. The
183    /// receiving path still consumes this field only when the gate reports the
184    /// frame was actually signed, which costs nothing: a key package exists
185    /// only once MLS is initialized, and the sender signs unconditionally in
186    /// that state, so every genuine package carrying this field is signed.
187    ///
188    /// Stripping it is still possible for a network attacker and downgrades us
189    /// to the bootstrap key, which is a privacy downgrade, not a disclosure to
190    /// the attacker, and one they could equally achieve by dropping the packet.
191    ///
192    /// [`prefixes::KEY_PACKAGE`]: crate::prefixes::KEY_PACKAGE
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub nostr_pubkey: Option<String>,
195}
196
197/// Compact MLS envelope version advertised in
198/// [`KeyPackagePayload::env_versions`]: the [`prefixes::ENCRYPTED`] payload is
199/// base64 of [`EncryptedMessage::to_bytes`] instead of the legacy JSON form
200/// (whose `ciphertext` field renders as a roughly 3.6x integer array).
201/// Receivers distinguish the two by the byte after the prefix: `{` opens the
202/// JSON envelope and never occurs in base64.
203///
204/// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
205/// [`EncryptedMessage::to_bytes`]: crate::EncryptedMessage::to_bytes
206pub const MLS_ENVELOPE_COMPACT_V1: u8 = 1;
207
208/// Control-frame signing version advertised in
209/// [`KeyPackagePayload::ctrl_versions`]: the sender verifies signatures over
210/// [`control_signing_payload_v2`], which binds the frame's timestamp and so
211/// can be refused for being stale.
212///
213/// There is no `CTRL_SIGN_V1` constant, and the absence is deliberate: an
214/// empty list already means "v1 only", and a named constant for it would
215/// invite a peer to advertise `[1]` as though declining the newer payload were
216/// a capability rather than the floor.
217///
218/// [`control_signing_payload_v2`]: crate::canonical::control_signing_payload_v2
219pub const CTRL_SIGN_V2: u8 = 2;
220
221/// The conformance vectors for the key package payload.
222///
223/// The chapter these pin is `docs/spec/capability-negotiation.md`, and they pin
224/// the **parse** direction only.
225///
226/// That is deliberate and it is the one place this suite declines to pin bytes.
227/// The JSON floor is not a byte-normative encoding: `docs/spec/wire-format.md`
228/// requires a receiver to accept both spellings of every optional field, so an
229/// exact serialization asserted here would be a contract the specification
230/// deliberately does not make, and a second implementation that omitted a null
231/// would be marked non-conforming for doing exactly what the floor allows.
232///
233/// What a peer genuinely owes is that these bodies parse to these values, which
234/// is what rule 2 of capability negotiation means in practice: absence selects
235/// the floor and is never an error.
236#[cfg(all(test, feature = "std"))]
237mod spec_vectors {
238    use super::*;
239
240    const VECTORS: &str = include_str!("../tests/data/key-package-v1.vectors.json");
241
242    fn vectors() -> serde_json::Value {
243        serde_json::from_str(VECTORS).expect("the vector file is JSON")
244    }
245
246    fn u8s(v: &serde_json::Value) -> Vec<u8> {
247        v.as_array()
248            .expect("a list")
249            .iter()
250            .map(|n| n.as_u64().expect("a number") as u8)
251            .collect()
252    }
253
254    /// Asserts the file still carries what it carried, before anything iterates
255    /// it: a loop over an array a bad merge emptied passes by not running.
256    #[test]
257    fn the_vector_file_is_the_size_it_was() {
258        assert_eq!(vectors()["parse"].as_array().expect("parse").len(), 4);
259    }
260
261    #[test]
262    fn every_body_parses_to_its_vector() {
263        for case in vectors()["parse"].as_array().expect("parse") {
264            let name = case["name"].as_str().expect("a name");
265            let json = case["json"].as_str().expect("a body");
266            let want = &case["expect"];
267
268            let got: KeyPackagePayload = serde_json::from_str(json)
269                .unwrap_or_else(|e| panic!("[{name}] did not parse: {e}"));
270
271            assert_eq!(
272                got.user_id,
273                want["user_id"].as_str().expect("a user id"),
274                "[{name}] user_id"
275            );
276            assert_eq!(
277                got.key_package_data,
278                u8s(&want["key_package_data"]),
279                "[{name}] key_package_data"
280            );
281            assert_eq!(
282                got.remaining_lifetime_ms,
283                want["remaining_lifetime_ms"].as_u64().expect("a lifetime"),
284                "[{name}] remaining_lifetime_ms"
285            );
286            assert_eq!(
287                got.timestamp_ms,
288                want["timestamp_ms"].as_u64().expect("a timestamp"),
289                "[{name}] timestamp_ms"
290            );
291            assert_eq!(
292                got.session_reset,
293                want["session_reset"].as_bool().expect("a reset flag"),
294                "[{name}] session_reset"
295            );
296            for (field, got) in [
297                ("wire_versions", &got.wire_versions),
298                ("env_versions", &got.env_versions),
299                ("rich_versions", &got.rich_versions),
300                ("data_versions", &got.data_versions),
301                ("ctrl_versions", &got.ctrl_versions),
302            ] {
303                assert_eq!(got, &u8s(&want[field]), "[{name}] {field}");
304            }
305            assert_eq!(
306                got.nostr_pubkey.as_deref(),
307                want["nostr_pubkey"].as_str(),
308                "[{name}] nostr_pubkey"
309            );
310        }
311    }
312
313    /// An absent Nostr key is omitted on the way out, not written as null.
314    ///
315    /// Both spellings parse to the same thing, so this is not a wire
316    /// requirement; it is the one emission property the vector file names, and
317    /// it exists because the field is consumed as a destination key rather than
318    /// as a feature hint. A `null` written where the field is simply unknown
319    /// invites a reader to treat absence as a decision.
320    #[test]
321    fn an_absent_nostr_key_is_omitted_rather_than_null() {
322        let v = vectors();
323        assert_eq!(
324            v["emission_is_not_pinned"]["nostr_pubkey_absent_is_omitted_not_null"],
325            true
326        );
327
328        let payload = KeyPackagePayload {
329            user_id: "off1abc".to_string(),
330            key_package_data: Vec::new(),
331            remaining_lifetime_ms: 0,
332            timestamp_ms: 0,
333            session_reset: false,
334            wire_versions: Vec::new(),
335            env_versions: Vec::new(),
336            rich_versions: Vec::new(),
337            data_versions: Vec::new(),
338            ctrl_versions: Vec::new(),
339            nostr_pubkey: None,
340        };
341        let rendered = serde_json::to_string(&payload).expect("it serializes");
342        assert!(
343            !rendered.contains("nostr_pubkey"),
344            "an absent Nostr key was written into the body: {rendered}"
345        );
346    }
347
348    /// The chapter, or `None` where the repo tree is absent: `cargo package`
349    /// carries `tests/` and the vector file but cannot carry `docs/`.
350    fn chapter() -> Option<String> {
351        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
352            .join("../../docs/spec/capability-negotiation.md");
353        std::fs::read_to_string(&path).ok().or_else(|| {
354            eprintln!("spec tree not present, skipping the negotiation chapter drift checks");
355            None
356        })
357    }
358
359    /// The chapter names every capability field this payload carries.
360    ///
361    /// A field added to the struct and not to the chapter is a capability a
362    /// second implementation cannot know to advertise, which is invisible: it
363    /// simply never receives the form the field gates.
364    #[test]
365    fn the_chapter_names_every_capability_field() {
366        let Some(text) = chapter() else { return };
367
368        for field in [
369            "wire_versions",
370            "env_versions",
371            "rich_versions",
372            "data_versions",
373            "ctrl_versions",
374            "nostr_pubkey",
375            "session_reset",
376        ] {
377            assert!(
378                text.contains(field),
379                "the chapter does not name {field}, so a peer built from it \
380                 cannot advertise or read that capability"
381            );
382        }
383        assert!(
384            text.contains(&format!("Entry {CTRL_SIGN_V2} means")),
385            "the chapter no longer states what ctrl_versions entry {CTRL_SIGN_V2} means"
386        );
387    }
388}