offline-protocol-sealed 0.30.0

Sealed-envelope codec, address derivation and canonical signing payloads for the Offline Protocol SDK
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! The key package payload, which is how a peer says who it is and what it
//! can parse.
//!
//! This is the body of a [`prefixes::KEY_PACKAGE`] frame, and it is the only
//! channel in this protocol by which capabilities are advertised. A device
//! with no key package to mint has no way to say it understands anything, so
//! every frame sent to it falls to the protocol floor forever. That is the
//! reason a leaf node runs MLS rather than something smaller: not the
//! sealing, the advertising.
//!
//! Both ends must agree on this shape, and they build it with different MLS
//! implementations, so it lives here rather than in the engine.
//!
//! [`prefixes::KEY_PACKAGE`]: crate::prefixes::KEY_PACKAGE

use alloc::{string::String, vec::Vec};
use serde::{Deserialize, Serialize};

/// Payload for key package exchange.
///
/// Every field except `user_id` and `key_package_data` carries
/// `#[serde(default)]`, so a payload written by an older peer that has never
/// heard of a field decodes cleanly with that field empty. Empty selects the
/// floor, by rule and never by error, which is what makes a mixed fleet safe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyPackagePayload {
    /// User ID of the key package owner.
    pub user_id: String,
    /// Raw key package data.
    ///
    /// A **bare** key package, not one wrapped in an MLS message. Both forms
    /// are legal MLS and only one of them is this protocol's wire, so an
    /// implementation whose convenience API returns the wrapped form has to
    /// unwrap it before encoding.
    pub key_package_data: Vec<u8>,
    /// Remaining valid lifetime in milliseconds (relative, not absolute).
    /// Receiver applies this to their local clock, avoiding clock skew issues.
    #[serde(default)]
    pub remaining_lifetime_ms: u64,
    /// Legacy absolute timestamp field, ignored on receive, kept for
    /// backward compatibility with old nodes that may still send it.
    #[serde(default)]
    pub timestamp_ms: u64,
    /// When `true`, the sender has reset their MLS session state and the
    /// receiver should discard any existing session for this peer before
    /// establishing a new one.
    ///
    /// Two senders set it. Post-unblock convergence: when Alice unblocks Bob,
    /// Alice deletes her MLS session and sends a fresh key package with this
    /// flag, so Bob deletes his now-orphaned session and both converge on one
    /// fresh group. And a driven session rekey, which is how post-compromise
    /// security reaches a peer that never commits: the rekey arrives as this
    /// flag on a key package, never as an unsolicited Welcome.
    ///
    /// A receiver that treats a reset as an ordinary key package refresh keeps
    /// a session the sender has already discarded, and every later frame from
    /// it decrypts to nothing.
    #[serde(default)]
    pub session_reset: bool,

    /// Wire-format versions the sender can decode (e.g. `[1]` for binary v1).
    /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning JSON
    /// only), so an old peer is never sent a binary frame it cannot parse.
    ///
    /// Trust boundary: this rides in the plaintext payload *alongside* the
    /// signed MLS `key_package_data`, not *inside* the signature, so it is not
    /// cryptographically bound to the sender. An attacker on the pre-session
    /// bootstrap could strip it (a harmless JSON downgrade) or forge `[1]`
    /// onto a legacy peer (making us emit binary that peer drops, a targeted
    /// delivery denial of service). This grants no new capability: such an
    /// attacker already controls key package delivery and could deny service
    /// outright. The negotiation is a performance optimization, never a
    /// security control.
    #[serde(default)]
    pub wire_versions: Vec<u8>,

    /// MLS envelope formats the sender can parse (e.g. `[1]` for the compact
    /// envelope, [`MLS_ENVELOPE_COMPACT_V1`]). Absent on legacy nodes
    /// (`#[serde(default)]` gives empty, meaning the legacy JSON envelope
    /// only), so an old peer is never sent an envelope it cannot parse.
    ///
    /// Distinct from `wire_versions`: that one is hop-local (which *frames*
    /// the peer decodes), this one is end-to-end (which
    /// [`prefixes::ENCRYPTED`] payload encodings the *recipient* parses after
    /// any number of relay hops).
    ///
    /// Trust boundary: identical to `wire_versions` above, a plaintext field
    /// and not signature-bound. Stripping it downgrades to the JSON envelope
    /// (harmless); forging it onto a legacy peer makes us emit envelopes that
    /// peer rejects with a `message_decryption_failed` event (a targeted
    /// delivery denial of service an attacker in that position already has). A
    /// performance optimization, never a security control.
    ///
    /// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
    #[serde(default)]
    pub env_versions: Vec<u8>,

    /// Sealed rich-payload versions the sender can parse (e.g. `[1]` for
    /// `RICH_PAYLOAD_V1`). Absent on legacy nodes (`#[serde(default)]` gives
    /// empty, meaning plain text only), so an old peer is never sent a
    /// `__RICH_V1__` body it would surface as raw JSON text.
    ///
    /// End-to-end like `env_versions` (what the *recipient* parses inside the
    /// decrypted MLS plaintext), not hop-local like `wire_versions`.
    ///
    /// Trust boundary: identical to the two fields above, a plaintext field
    /// and not signature-bound. Stripping it downgrades to plain text with the
    /// rich extras dropped (harmless); forging it onto a legacy peer makes us
    /// seal bodies that peer renders as JSON text (a nuisance an attacker in
    /// that position could match by corrupting delivery outright). A feature
    /// negotiation, never a security control.
    #[serde(default)]
    pub rich_versions: Vec<u8>,

    /// Replicated-document sync versions the sender speaks (e.g. `[1]` for
    /// `DATA_SYNC_V1`). Absent on legacy nodes and on installs with the data
    /// layer switched off (`#[serde(default)]` gives empty), and toward a peer
    /// that advertises nothing here no `__DATA_V1__` frame is ever sent.
    ///
    /// End-to-end like `env_versions` and `rich_versions`: it says what the
    /// *recipient* parses inside the decrypted MLS plaintext, not what a
    /// directly connected neighbour decodes on the wire.
    ///
    /// The version doubles as the engine's encoding generation. The CRDT
    /// engine promises that new code reads old encodings and says nothing
    /// about the reverse, so a future encoding change is a new version byte
    /// here and a mixed fleet declines what it cannot read instead of
    /// discovering it at import.
    ///
    /// Trust boundary: identical to the capability lists above, a plaintext
    /// field and not signature-bound. Stripping it stops documents replicating
    /// with that peer (they stay editable locally and converge whenever a
    /// genuine advertisement arrives); forging it onto a peer that cannot
    /// parse the frames makes us send bodies they consume and drop. Neither
    /// grants an attacker anything they could not achieve by dropping the
    /// packets outright. A feature negotiation, never a security control.
    #[serde(default)]
    pub data_versions: Vec<u8>,

    /// Control-frame signing versions the sender **verifies** (e.g. `[2]` for
    /// [`CTRL_SIGN_V2`], the payload that binds the frame's timestamp).
    /// Absent on legacy nodes (`#[serde(default)]` gives empty, meaning the v1
    /// payload only), so a peer that has never heard of the freshness-bound
    /// domain is never sent a signature it would read as invalid.
    ///
    /// Unlike every capability list above, this one says what the sender
    /// *accepts*, not what it emits. It has to: a signature is produced once
    /// and verified by the far end, so the choice of domain belongs to whoever
    /// is going to check it.
    ///
    /// Trust boundary: this is a plaintext field like the others, and an
    /// attacker who strips it makes us sign the older payload toward a peer
    /// that would have accepted the newer one. That downgrade is real and it
    /// is why stripping it is not the end of the story: a receiver that has
    /// **once** verified a v2 signature from a peer records that durably and
    /// refuses that peer's v1 control frames from then on, so the strip works
    /// only until the first genuine v2 frame arrives and never afterwards. The
    /// field is what makes the first one possible; the record is what makes it
    /// stick.
    #[serde(default)]
    pub ctrl_versions: Vec<u8>,

    /// Sealed service frame versions the sender **routes** (e.g. `[1]` for
    /// [`SVC_SEALED_V1`]): a `__SVC_REQ__` or `__SVC_RESP__` found inside a
    /// decrypted 1:1 MLS plaintext is handed to its service layer rather than
    /// surfaced as a chat message. Absent on legacy nodes
    /// (`#[serde(default)]` gives empty), and toward them service bodies stay
    /// signed plaintext, which is the floor for this frame family.
    ///
    /// End to end, like `rich_versions`: it describes the far end's parser,
    /// not any carrier.
    ///
    /// Trust boundary: unlike the feature lists above, this one decides
    /// whether a body is readable by every hop, so the receiver honours it
    /// only from a signed key package, as it does `nostr_pubkey`. Stripping it
    /// downgrades the peer's service bodies to signed plaintext; replaying an
    /// older signed package does the same until a genuine one arrives, and no
    /// freshness window bounds that replay, since a key package signed under
    /// `offline-ctrl-v1` is admitted at any age. Neither reaches a peer that
    /// has already sent a sealed service frame: the receiver keeps that proof
    /// in a ratchet no key package clears. Forging it onto a legacy peer makes
    /// us send sealed frames it surfaces as chat text, which only the
    /// sender's own key could have produced, so it grants an attacker nothing.
    #[serde(default)]
    pub svc_versions: Vec<u8>,

    /// Delivery-acknowledgement statuses the sender reads (e.g. `[1]` for
    /// [`ACK_UNDECRYPTABLE_V1`]). Absent on legacy nodes (`#[serde(default)]`
    /// gives empty, meaning plain delivery ACKs only).
    ///
    /// Like `ctrl_versions` this says what the sender *accepts*: a peer
    /// advertising entry 1 settles an ACK carrying
    /// `ack_status = "undecryptable"` as "not delivered" (resend once).
    /// One without it would read that ACK as a delivery, so a receiver answers
    /// an undecryptable frame from such a peer with nothing at all.
    ///
    /// Trust boundary: a plaintext field like the lists above. Stripping it
    /// returns the peer to unanswered undecryptable frames (the old
    /// behaviour); forging it onto a legacy peer makes us send it ACKs it
    /// reads as deliveries, which an attacker in that position could equally
    /// forge directly. A feature negotiation, never a security control.
    #[serde(default)]
    pub ack_versions: Vec<u8>,

    /// This install's Nostr public key (x-only, 64-char lowercase hex), so a
    /// peer can seal Nostr gift wraps to a key only this install holds.
    ///
    /// `None` on legacy nodes and on installs with Nostr disabled. A peer
    /// without it seals to our *publicly computable* key instead, deliverable
    /// either way, but readable by anyone who guesses our user id, so the
    /// difference is real privacy rather than a mere optimization.
    ///
    /// Trust boundary: **unlike the capability lists above, this one is only
    /// honoured from a signed key package.** All four ride in the same
    /// plaintext payload, but this field is consumed as a *destination key*,
    /// not as a feature hint, so the distinction matters: a wrong capability
    /// costs a fallback, whereas a wrong key here means envelope metadata is
    /// sealed *to whoever supplied it* and is then readable off a public
    /// relay, passively, for as long as the value stands.
    ///
    /// The canonical signing payload covers the whole
    /// [`prefixes::KEY_PACKAGE`] body under the sender's Ed25519 signature,
    /// which the gate verifies against the key their address derives from, so
    /// on this prefix an unsigned frame does not reach dispatch at all. The
    /// receiving path still consumes this field only when the gate reports the
    /// frame was actually signed, which costs nothing: a key package exists
    /// only once MLS is initialized, and the sender signs unconditionally in
    /// that state, so every genuine package carrying this field is signed.
    ///
    /// Stripping it is still possible for a network attacker and downgrades us
    /// to the bootstrap key, which is a privacy downgrade, not a disclosure to
    /// the attacker, and one they could equally achieve by dropping the packet.
    ///
    /// [`prefixes::KEY_PACKAGE`]: crate::prefixes::KEY_PACKAGE
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nostr_pubkey: Option<String>,
}

/// Compact MLS envelope version advertised in
/// [`KeyPackagePayload::env_versions`]: the [`prefixes::ENCRYPTED`] payload is
/// base64 of [`EncryptedMessage::to_bytes`] instead of the legacy JSON form
/// (whose `ciphertext` field renders as a roughly 3.6x integer array).
/// Receivers distinguish the two by the byte after the prefix: `{` opens the
/// JSON envelope and never occurs in base64.
///
/// [`prefixes::ENCRYPTED`]: crate::prefixes::ENCRYPTED
/// [`EncryptedMessage::to_bytes`]: crate::EncryptedMessage::to_bytes
pub const MLS_ENVELOPE_COMPACT_V1: u8 = 1;

/// Control-frame signing version advertised in
/// [`KeyPackagePayload::ctrl_versions`]: the sender verifies signatures over
/// [`control_signing_payload_v2`], which binds the frame's timestamp and so
/// can be refused for being stale.
///
/// There is no `CTRL_SIGN_V1` constant, and the absence is deliberate: an
/// empty list already means "v1 only", and a named constant for it would
/// invite a peer to advertise `[1]` as though declining the newer payload were
/// a capability rather than the floor.
///
/// [`control_signing_payload_v2`]: crate::canonical::control_signing_payload_v2
pub const CTRL_SIGN_V2: u8 = 2;

/// Sealed service frame version advertised in
/// [`KeyPackagePayload::svc_versions`]: the sender routes a `__SVC_REQ__` or
/// `__SVC_RESP__` it finds inside a decrypted 1:1 MLS plaintext into its
/// service layer, with the MLS-authenticated sender as the requester.
pub const SVC_SEALED_V1: u8 = 1;

/// Acknowledgement version advertised in [`KeyPackagePayload::ack_versions`]:
/// the sender reads `ack_status = "undecryptable"` on a delivery ACK as "the
/// recipient can never decrypt this frame" rather than as a delivery.
pub const ACK_UNDECRYPTABLE_V1: u8 = 1;

/// Acknowledgement version advertised in [`KeyPackagePayload::ack_versions`]:
/// the sender reads the `ack_more` metadata on a delivery ACK, so one ACK
/// frame may answer several of its frames. Over a mesh link an ACK per frame
/// was a third of the frames a phone sent, on a link already carrying less
/// than it was offered.
pub const ACK_COALESCED_V1: u8 = 2;

/// The conformance vectors for the key package payload.
///
/// The chapter these pin is `docs/spec/capability-negotiation.md`, and they pin
/// the **parse** direction only.
///
/// That is deliberate and it is the one place this suite declines to pin bytes.
/// The JSON floor is not a byte-normative encoding: `docs/spec/wire-format.md`
/// requires a receiver to accept both spellings of every optional field, so an
/// exact serialization asserted here would be a contract the specification
/// deliberately does not make, and a second implementation that omitted a null
/// would be marked non-conforming for doing exactly what the floor allows.
///
/// What a peer genuinely owes is that these bodies parse to these values, which
/// is what rule 2 of capability negotiation means in practice: absence selects
/// the floor and is never an error.
#[cfg(all(test, feature = "std"))]
mod spec_vectors {
    use super::*;

    const VECTORS: &str = include_str!("../tests/data/key-package-v1.vectors.json");

    fn vectors() -> serde_json::Value {
        serde_json::from_str(VECTORS).expect("the vector file is JSON")
    }

    fn u8s(v: &serde_json::Value) -> Vec<u8> {
        v.as_array()
            .expect("a list")
            .iter()
            .map(|n| n.as_u64().expect("a number") as u8)
            .collect()
    }

    /// Asserts the file still carries what it carried, before anything iterates
    /// it: a loop over an array a bad merge emptied passes by not running.
    #[test]
    fn the_vector_file_is_the_size_it_was() {
        assert_eq!(vectors()["parse"].as_array().expect("parse").len(), 4);
    }

    #[test]
    fn every_body_parses_to_its_vector() {
        for case in vectors()["parse"].as_array().expect("parse") {
            let name = case["name"].as_str().expect("a name");
            let json = case["json"].as_str().expect("a body");
            let want = &case["expect"];

            let got: KeyPackagePayload = serde_json::from_str(json)
                .unwrap_or_else(|e| panic!("[{name}] did not parse: {e}"));

            assert_eq!(
                got.user_id,
                want["user_id"].as_str().expect("a user id"),
                "[{name}] user_id"
            );
            assert_eq!(
                got.key_package_data,
                u8s(&want["key_package_data"]),
                "[{name}] key_package_data"
            );
            assert_eq!(
                got.remaining_lifetime_ms,
                want["remaining_lifetime_ms"].as_u64().expect("a lifetime"),
                "[{name}] remaining_lifetime_ms"
            );
            assert_eq!(
                got.timestamp_ms,
                want["timestamp_ms"].as_u64().expect("a timestamp"),
                "[{name}] timestamp_ms"
            );
            assert_eq!(
                got.session_reset,
                want["session_reset"].as_bool().expect("a reset flag"),
                "[{name}] session_reset"
            );
            for (field, got) in [
                ("wire_versions", &got.wire_versions),
                ("env_versions", &got.env_versions),
                ("rich_versions", &got.rich_versions),
                ("data_versions", &got.data_versions),
                ("ctrl_versions", &got.ctrl_versions),
                ("svc_versions", &got.svc_versions),
                ("ack_versions", &got.ack_versions),
            ] {
                assert_eq!(got, &u8s(&want[field]), "[{name}] {field}");
            }
            assert_eq!(
                got.nostr_pubkey.as_deref(),
                want["nostr_pubkey"].as_str(),
                "[{name}] nostr_pubkey"
            );
        }
    }

    /// An absent Nostr key is omitted on the way out, not written as null.
    ///
    /// Both spellings parse to the same thing, so this is not a wire
    /// requirement; it is the one emission property the vector file names, and
    /// it exists because the field is consumed as a destination key rather than
    /// as a feature hint. A `null` written where the field is simply unknown
    /// invites a reader to treat absence as a decision.
    #[test]
    fn an_absent_nostr_key_is_omitted_rather_than_null() {
        let v = vectors();
        assert_eq!(
            v["emission_is_not_pinned"]["nostr_pubkey_absent_is_omitted_not_null"],
            true
        );

        let payload = KeyPackagePayload {
            user_id: "off1abc".to_string(),
            key_package_data: Vec::new(),
            remaining_lifetime_ms: 0,
            timestamp_ms: 0,
            session_reset: false,
            wire_versions: Vec::new(),
            env_versions: Vec::new(),
            rich_versions: Vec::new(),
            data_versions: Vec::new(),
            ctrl_versions: Vec::new(),
            svc_versions: Vec::new(),
            ack_versions: Vec::new(),
            nostr_pubkey: None,
        };
        let rendered = serde_json::to_string(&payload).expect("it serializes");
        assert!(
            !rendered.contains("nostr_pubkey"),
            "an absent Nostr key was written into the body: {rendered}"
        );
    }

    /// The chapter, or `None` where the repo tree is absent: `cargo package`
    /// carries `tests/` and the vector file but cannot carry `docs/`.
    fn chapter() -> Option<String> {
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../docs/spec/capability-negotiation.md");
        std::fs::read_to_string(&path).ok().or_else(|| {
            eprintln!("spec tree not present, skipping the negotiation chapter drift checks");
            None
        })
    }

    /// The chapter names every capability field this payload carries.
    ///
    /// A field added to the struct and not to the chapter is a capability a
    /// second implementation cannot know to advertise, which is invisible: it
    /// simply never receives the form the field gates.
    #[test]
    fn the_chapter_names_every_capability_field() {
        let Some(text) = chapter() else { return };

        for field in [
            "wire_versions",
            "env_versions",
            "rich_versions",
            "data_versions",
            "ctrl_versions",
            "svc_versions",
            "ack_versions",
            "nostr_pubkey",
            "session_reset",
        ] {
            assert!(
                text.contains(field),
                "the chapter does not name {field}, so a peer built from it \
                 cannot advertise or read that capability"
            );
        }
        assert!(
            text.contains(&format!("Entry {CTRL_SIGN_V2} means")),
            "the chapter no longer states what ctrl_versions entry {CTRL_SIGN_V2} means"
        );
        assert!(
            text.contains(&format!("Entry {SVC_SEALED_V1} means the peer routes")),
            "the chapter no longer states what svc_versions entry {SVC_SEALED_V1} means"
        );
    }
}