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 /// The sender holds no session with the receiver.
62 ///
63 /// A receiver that holds one can then never be read by the sender: the
64 /// sender lost it to a reset whose frame never arrived, or to its store.
65 /// It answers by resending its Welcome when that Welcome was built on
66 /// this very package, which the sender can still join, and otherwise by
67 /// dropping its session and building a new one from the package. Under
68 /// the same freshness and spent-reset rules as `session_reset`.
69 ///
70 /// Without it a lost reset left the pair split for good: the sender's
71 /// re-pushes could not carry the reset flag, because a reset that
72 /// overtook the replacement session's Welcome would tear that down too.
73 ///
74 /// Omitted when false, so every payload that does not set it is byte for
75 /// byte what it was, and an older receiver reads it as absent.
76 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
77 pub no_session: bool,
78
79 /// Wire-format versions the sender can decode (e.g. `[1]` for binary v1).
80 /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning JSON
81 /// only), so an old peer is never sent a binary frame it cannot parse.
82 ///
83 /// Trust boundary: this rides in the plaintext payload *alongside* the
84 /// signed MLS `key_package_data`, not *inside* the signature, so it is not
85 /// cryptographically bound to the sender. An attacker on the pre-session
86 /// bootstrap could strip it (a harmless JSON downgrade) or forge `[1]`
87 /// onto a legacy peer (making us emit binary that peer drops, a targeted
88 /// delivery denial of service). This grants no new capability: such an
89 /// attacker already controls key package delivery and could deny service
90 /// outright. The negotiation is a performance optimization, never a
91 /// security control.
92 #[serde(default)]
93 pub wire_versions: Vec<u8>,
94
95 /// MLS envelope formats the sender can parse (e.g. `[1]` for the compact
96 /// envelope, [`MLS_ENVELOPE_COMPACT_V1`]). Absent on legacy nodes
97 /// (`#[serde(default)]` gives empty, meaning the legacy JSON envelope
98 /// only), so an old peer is never sent an envelope it cannot parse.
99 ///
100 /// Distinct from `wire_versions`: that one is hop-local (which *frames*
101 /// the peer decodes), this one is end-to-end (which
102 /// [`prefixes::ENCRYPTED`] payload encodings the *recipient* parses after
103 /// any number of relay hops).
104 ///
105 /// Trust boundary: identical to `wire_versions` above, a plaintext field
106 /// and not signature-bound. Stripping it downgrades to the JSON envelope
107 /// (harmless); forging it onto a legacy peer makes us emit envelopes that
108 /// peer rejects with a `message_decryption_failed` event (a targeted
109 /// delivery denial of service an attacker in that position already has). A
110 /// performance optimization, never a security control.
111 ///
112 /// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
113 #[serde(default)]
114 pub env_versions: Vec<u8>,
115
116 /// Sealed rich-payload versions the sender can parse (e.g. `[1]` for
117 /// `RICH_PAYLOAD_V1`). Absent on legacy nodes (`#[serde(default)]` gives
118 /// empty, meaning plain text only), so an old peer is never sent a
119 /// `__RICH_V1__` body it would surface as raw JSON text.
120 ///
121 /// End-to-end like `env_versions` (what the *recipient* parses inside the
122 /// decrypted MLS plaintext), not hop-local like `wire_versions`.
123 ///
124 /// Trust boundary: identical to the two fields above, a plaintext field
125 /// and not signature-bound. Stripping it downgrades to plain text with the
126 /// rich extras dropped (harmless); forging it onto a legacy peer makes us
127 /// seal bodies that peer renders as JSON text (a nuisance an attacker in
128 /// that position could match by corrupting delivery outright). A feature
129 /// negotiation, never a security control.
130 #[serde(default)]
131 pub rich_versions: Vec<u8>,
132
133 /// Replicated-document sync versions the sender speaks (e.g. `[1]` for
134 /// `DATA_SYNC_V1`). Absent on legacy nodes and on installs with the data
135 /// layer switched off (`#[serde(default)]` gives empty), and toward a peer
136 /// that advertises nothing here no `__DATA_V1__` frame is ever sent.
137 ///
138 /// End-to-end like `env_versions` and `rich_versions`: it says what the
139 /// *recipient* parses inside the decrypted MLS plaintext, not what a
140 /// directly connected neighbour decodes on the wire.
141 ///
142 /// The version doubles as the engine's encoding generation. The CRDT
143 /// engine promises that new code reads old encodings and says nothing
144 /// about the reverse, so a future encoding change is a new version byte
145 /// here and a mixed fleet declines what it cannot read instead of
146 /// discovering it at import.
147 ///
148 /// Trust boundary: identical to the capability lists above, a plaintext
149 /// field and not signature-bound. Stripping it stops documents replicating
150 /// with that peer (they stay editable locally and converge whenever a
151 /// genuine advertisement arrives); forging it onto a peer that cannot
152 /// parse the frames makes us send bodies they consume and drop. Neither
153 /// grants an attacker anything they could not achieve by dropping the
154 /// packets outright. A feature negotiation, never a security control.
155 #[serde(default)]
156 pub data_versions: Vec<u8>,
157
158 /// Control-frame signing versions the sender **verifies** (e.g. `[2]` for
159 /// [`CTRL_SIGN_V2`], the payload that binds the frame's timestamp).
160 /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning the v1
161 /// payload only), so a peer that has never heard of the freshness-bound
162 /// domain is never sent a signature it would read as invalid.
163 ///
164 /// Unlike every capability list above, this one says what the sender
165 /// *accepts*, not what it emits. It has to: a signature is produced once
166 /// and verified by the far end, so the choice of domain belongs to whoever
167 /// is going to check it.
168 ///
169 /// Trust boundary: this is a plaintext field like the others, and an
170 /// attacker who strips it makes us sign the older payload toward a peer
171 /// that would have accepted the newer one. That downgrade is real and it
172 /// is why stripping it is not the end of the story: a receiver that has
173 /// **once** verified a v2 signature from a peer records that durably and
174 /// refuses that peer's v1 control frames from then on, so the strip works
175 /// only until the first genuine v2 frame arrives and never afterwards. The
176 /// field is what makes the first one possible; the record is what makes it
177 /// stick.
178 #[serde(default)]
179 pub ctrl_versions: Vec<u8>,
180
181 /// Sealed service frame versions the sender **routes** (e.g. `[1]` for
182 /// [`SVC_SEALED_V1`]): a `__SVC_REQ__` or `__SVC_RESP__` found inside a
183 /// decrypted 1:1 MLS plaintext is handed to its service layer rather than
184 /// surfaced as a chat message. Absent on legacy nodes
185 /// (`#[serde(default)]` gives empty), and toward them service bodies stay
186 /// signed plaintext, which is the floor for this frame family.
187 ///
188 /// End to end, like `rich_versions`: it describes the far end's parser,
189 /// not any carrier.
190 ///
191 /// Trust boundary: unlike the feature lists above, this one decides
192 /// whether a body is readable by every hop, so the receiver honours it
193 /// only from a signed key package, as it does `nostr_pubkey`. Stripping it
194 /// downgrades the peer's service bodies to signed plaintext; replaying an
195 /// older signed package does the same until a genuine one arrives, and no
196 /// freshness window bounds that replay, since a key package signed under
197 /// `offline-ctrl-v1` is admitted at any age. Neither reaches a peer that
198 /// has already sent a sealed service frame: the receiver keeps that proof
199 /// in a ratchet no key package clears. Forging it onto a legacy peer makes
200 /// us send sealed frames it surfaces as chat text, which only the
201 /// sender's own key could have produced, so it grants an attacker nothing.
202 #[serde(default)]
203 pub svc_versions: Vec<u8>,
204
205 /// Delivery-acknowledgement statuses the sender reads (e.g. `[1]` for
206 /// [`ACK_UNDECRYPTABLE_V1`]). Absent on legacy nodes (`#[serde(default)]`
207 /// gives empty, meaning plain delivery ACKs only).
208 ///
209 /// Like `ctrl_versions` this says what the sender *accepts*: a peer
210 /// advertising entry 1 settles an ACK carrying
211 /// `ack_status = "undecryptable"` as "not delivered" (resend once).
212 /// One without it would read that ACK as a delivery, so a receiver answers
213 /// an undecryptable frame from such a peer with nothing at all.
214 ///
215 /// Trust boundary: a plaintext field like the lists above. Stripping it
216 /// returns the peer to unanswered undecryptable frames (the old
217 /// behaviour); forging it onto a legacy peer makes us send it ACKs it
218 /// reads as deliveries, which an attacker in that position could equally
219 /// forge directly. A feature negotiation, never a security control.
220 #[serde(default)]
221 pub ack_versions: Vec<u8>,
222
223 /// This install's Nostr public key (x-only, 64-char lowercase hex), so a
224 /// peer can seal Nostr gift wraps to a key only this install holds.
225 ///
226 /// `None` on legacy nodes and on installs with Nostr disabled. A peer
227 /// without it seals to our *publicly computable* key instead, deliverable
228 /// either way, but readable by anyone who guesses our user id, so the
229 /// difference is real privacy rather than a mere optimization.
230 ///
231 /// Trust boundary: **unlike the capability lists above, this one is only
232 /// honoured from a signed key package.** All four ride in the same
233 /// plaintext payload, but this field is consumed as a *destination key*,
234 /// not as a feature hint, so the distinction matters: a wrong capability
235 /// costs a fallback, whereas a wrong key here means envelope metadata is
236 /// sealed *to whoever supplied it* and is then readable off a public
237 /// relay, passively, for as long as the value stands.
238 ///
239 /// The canonical signing payload covers the whole
240 /// [`prefixes::KEY_PACKAGE`] body under the sender's Ed25519 signature,
241 /// which the gate verifies against the key their address derives from, so
242 /// on this prefix an unsigned frame does not reach dispatch at all. The
243 /// receiving path still consumes this field only when the gate reports the
244 /// frame was actually signed, which costs nothing: a key package exists
245 /// only once MLS is initialized, and the sender signs unconditionally in
246 /// that state, so every genuine package carrying this field is signed.
247 ///
248 /// Stripping it is still possible for a network attacker and downgrades us
249 /// to the bootstrap key, which is a privacy downgrade, not a disclosure to
250 /// the attacker, and one they could equally achieve by dropping the packet.
251 ///
252 /// [`prefixes::KEY_PACKAGE`]: crate::prefixes::KEY_PACKAGE
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub nostr_pubkey: Option<String>,
255}
256
257/// Compact MLS envelope version advertised in
258/// [`KeyPackagePayload::env_versions`]: the [`prefixes::ENCRYPTED`] payload is
259/// base64 of [`EncryptedMessage::to_bytes`] instead of the legacy JSON form
260/// (whose `ciphertext` field renders as a roughly 3.6x integer array).
261/// Receivers distinguish the two by the byte after the prefix: `{` opens the
262/// JSON envelope and never occurs in base64.
263///
264/// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
265/// [`EncryptedMessage::to_bytes`]: crate::EncryptedMessage::to_bytes
266pub const MLS_ENVELOPE_COMPACT_V1: u8 = 1;
267
268/// Control-frame signing version advertised in
269/// [`KeyPackagePayload::ctrl_versions`]: the sender verifies signatures over
270/// [`control_signing_payload_v2`], which binds the frame's timestamp and so
271/// can be refused for being stale.
272///
273/// There is no `CTRL_SIGN_V1` constant, and the absence is deliberate: an
274/// empty list already means "v1 only", and a named constant for it would
275/// invite a peer to advertise `[1]` as though declining the newer payload were
276/// a capability rather than the floor.
277///
278/// [`control_signing_payload_v2`]: crate::canonical::control_signing_payload_v2
279pub const CTRL_SIGN_V2: u8 = 2;
280
281/// Sealed service frame version advertised in
282/// [`KeyPackagePayload::svc_versions`]: the sender routes a `__SVC_REQ__` or
283/// `__SVC_RESP__` it finds inside a decrypted 1:1 MLS plaintext into its
284/// service layer, with the MLS-authenticated sender as the requester.
285pub const SVC_SEALED_V1: u8 = 1;
286
287/// Acknowledgement version advertised in [`KeyPackagePayload::ack_versions`]:
288/// the sender reads `ack_status = "undecryptable"` on a delivery ACK as "the
289/// recipient can never decrypt this frame" rather than as a delivery.
290pub const ACK_UNDECRYPTABLE_V1: u8 = 1;
291
292/// Acknowledgement version advertised in [`KeyPackagePayload::ack_versions`]:
293/// the sender reads the `ack_more` metadata on a delivery ACK, so one ACK
294/// frame may answer several of its frames. Over a mesh link an ACK per frame
295/// was a third of the frames a phone sent, on a link already carrying less
296/// than it was offered.
297pub const ACK_COALESCED_V1: u8 = 2;
298
299/// The conformance vectors for the key package payload.
300///
301/// The chapter these pin is `docs/spec/capability-negotiation.md`, and they pin
302/// the **parse** direction only.
303///
304/// That is deliberate and it is the one place this suite declines to pin bytes.
305/// The JSON floor is not a byte-normative encoding: `docs/spec/wire-format.md`
306/// requires a receiver to accept both spellings of every optional field, so an
307/// exact serialization asserted here would be a contract the specification
308/// deliberately does not make, and a second implementation that omitted a null
309/// would be marked non-conforming for doing exactly what the floor allows.
310///
311/// What a peer genuinely owes is that these bodies parse to these values, which
312/// is what rule 2 of capability negotiation means in practice: absence selects
313/// the floor and is never an error.
314#[cfg(all(test, feature = "std"))]
315mod spec_vectors {
316 use super::*;
317
318 const VECTORS: &str = include_str!("../tests/data/key-package-v1.vectors.json");
319
320 fn vectors() -> serde_json::Value {
321 serde_json::from_str(VECTORS).expect("the vector file is JSON")
322 }
323
324 fn u8s(v: &serde_json::Value) -> Vec<u8> {
325 v.as_array()
326 .expect("a list")
327 .iter()
328 .map(|n| n.as_u64().expect("a number") as u8)
329 .collect()
330 }
331
332 /// Asserts the file still carries what it carried, before anything iterates
333 /// it: a loop over an array a bad merge emptied passes by not running.
334 #[test]
335 fn the_vector_file_is_the_size_it_was() {
336 assert_eq!(vectors()["parse"].as_array().expect("parse").len(), 4);
337 }
338
339 #[test]
340 fn every_body_parses_to_its_vector() {
341 for case in vectors()["parse"].as_array().expect("parse") {
342 let name = case["name"].as_str().expect("a name");
343 let json = case["json"].as_str().expect("a body");
344 let want = &case["expect"];
345
346 let got: KeyPackagePayload = serde_json::from_str(json)
347 .unwrap_or_else(|e| panic!("[{name}] did not parse: {e}"));
348
349 assert_eq!(
350 got.user_id,
351 want["user_id"].as_str().expect("a user id"),
352 "[{name}] user_id"
353 );
354 assert_eq!(
355 got.key_package_data,
356 u8s(&want["key_package_data"]),
357 "[{name}] key_package_data"
358 );
359 assert_eq!(
360 got.remaining_lifetime_ms,
361 want["remaining_lifetime_ms"].as_u64().expect("a lifetime"),
362 "[{name}] remaining_lifetime_ms"
363 );
364 assert_eq!(
365 got.timestamp_ms,
366 want["timestamp_ms"].as_u64().expect("a timestamp"),
367 "[{name}] timestamp_ms"
368 );
369 assert_eq!(
370 got.session_reset,
371 want["session_reset"].as_bool().expect("a reset flag"),
372 "[{name}] session_reset"
373 );
374 for (field, got) in [
375 ("wire_versions", &got.wire_versions),
376 ("env_versions", &got.env_versions),
377 ("rich_versions", &got.rich_versions),
378 ("data_versions", &got.data_versions),
379 ("ctrl_versions", &got.ctrl_versions),
380 ("svc_versions", &got.svc_versions),
381 ("ack_versions", &got.ack_versions),
382 ] {
383 assert_eq!(got, &u8s(&want[field]), "[{name}] {field}");
384 }
385 assert_eq!(
386 got.nostr_pubkey.as_deref(),
387 want["nostr_pubkey"].as_str(),
388 "[{name}] nostr_pubkey"
389 );
390 }
391 }
392
393 /// An absent Nostr key is omitted on the way out, not written as null.
394 ///
395 /// Both spellings parse to the same thing, so this is not a wire
396 /// requirement; it is the one emission property the vector file names, and
397 /// it exists because the field is consumed as a destination key rather than
398 /// as a feature hint. A `null` written where the field is simply unknown
399 /// invites a reader to treat absence as a decision.
400 #[test]
401 fn an_absent_nostr_key_is_omitted_rather_than_null() {
402 let v = vectors();
403 assert_eq!(
404 v["emission_is_not_pinned"]["nostr_pubkey_absent_is_omitted_not_null"],
405 true
406 );
407
408 let payload = KeyPackagePayload {
409 user_id: "off1abc".to_string(),
410 key_package_data: Vec::new(),
411 remaining_lifetime_ms: 0,
412 timestamp_ms: 0,
413 session_reset: false,
414 no_session: false,
415 wire_versions: Vec::new(),
416 env_versions: Vec::new(),
417 rich_versions: Vec::new(),
418 data_versions: Vec::new(),
419 ctrl_versions: Vec::new(),
420 svc_versions: Vec::new(),
421 ack_versions: Vec::new(),
422 nostr_pubkey: None,
423 };
424 let rendered = serde_json::to_string(&payload).expect("it serializes");
425 assert!(
426 !rendered.contains("nostr_pubkey"),
427 "an absent Nostr key was written into the body: {rendered}"
428 );
429 }
430
431 /// The chapter, or `None` where the repo tree is absent: `cargo package`
432 /// carries `tests/` and the vector file but cannot carry `docs/`.
433 fn chapter() -> Option<String> {
434 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
435 .join("../../docs/spec/capability-negotiation.md");
436 std::fs::read_to_string(&path).ok().or_else(|| {
437 eprintln!("spec tree not present, skipping the negotiation chapter drift checks");
438 None
439 })
440 }
441
442 /// The chapter names every capability field this payload carries.
443 ///
444 /// A field added to the struct and not to the chapter is a capability a
445 /// second implementation cannot know to advertise, which is invisible: it
446 /// simply never receives the form the field gates.
447 #[test]
448 fn the_chapter_names_every_capability_field() {
449 let Some(text) = chapter() else { return };
450
451 for field in [
452 "wire_versions",
453 "env_versions",
454 "rich_versions",
455 "data_versions",
456 "ctrl_versions",
457 "svc_versions",
458 "ack_versions",
459 "nostr_pubkey",
460 "session_reset",
461 ] {
462 assert!(
463 text.contains(field),
464 "the chapter does not name {field}, so a peer built from it \
465 cannot advertise or read that capability"
466 );
467 }
468 assert!(
469 text.contains(&format!("Entry {CTRL_SIGN_V2} means")),
470 "the chapter no longer states what ctrl_versions entry {CTRL_SIGN_V2} means"
471 );
472 assert!(
473 text.contains(&format!("Entry {SVC_SEALED_V1} means the peer routes")),
474 "the chapter no longer states what svc_versions entry {SVC_SEALED_V1} means"
475 );
476 }
477}