Skip to main content

offline_protocol_mls/
invite.rs

1//! The invite payload: a self-verifying `{address, pubkey, petname?, sig?}`
2//! blob for QR codes and links.
3//!
4//! This is the *permanent* path to first contact, and the primary one. Username
5//! discovery (see [`crate::discovery`]) is additive: it is a non-authoritative
6//! directory whose only trust anchor is a human confirming a name out of band,
7//! which is exactly what scanning a QR code is. Deleting the invite path would
8//! therefore delete the discovery layer's security model, not merely one of two
9//! ways to reach someone.
10//!
11//! # What verification proves, and what it does not
12//!
13//! [`parse_invite`] enforces `derive_address(pubkey) == address` and, when a
14//! signature is present, that the signature verifies under `pubkey`. That makes
15//! the blob **self-certifying**: no directory, no server and no prior contact is
16//! consulted, which is why the helpers are namespace-level and callable before
17//! `create()`.
18//!
19//! It does **not** defend against substitution. An attacker who hands you their
20//! own invite, correctly signed by their own key, is indistinguishable from a
21//! legitimate stranger — no payload format can fix that, only the out-of-band
22//! context in which the code was shown to you.
23//!
24//! What the optional signature defends is **relabeling**. Without it, anyone can
25//! mint an invite pairing a victim's real, public `{address, pubkey}` with an
26//! attacker-chosen petname, so an invite forwarded through a third party can
27//! save Alice's key under the name "Bob". With it, the petname is bound to the
28//! key by the key's owner.
29//!
30//! Include a signature when the invite may travel without its issuer. A QR code
31//! shown phone-to-phone is already authenticated by the physical channel, and
32//! an app that prompts the user to confirm or edit the name has made the user
33//! the authority over it, which is what a petname properly is.
34//!
35//! # What an invite deliberately does not carry
36//!
37//! **No key package.** An MLS key package's init key is consumed by the first
38//! peer who uses it, and a QR code is static, so pairing them guarantees a
39//! collision as soon as two people scan the same code. Session establishment
40//! runs over whatever transport connects, by the ordinary exchange.
41//!
42//! **No expiry.** A printed QR code that stops working is a bug. Apps that need
43//! revocable invites have the server-mediated group-invite-link mechanism.
44//!
45//! # Container
46//!
47//! The SDK specifies the blob; apps own their URI scheme. The recommended form
48//! is `<app-scheme>://connect?c=<blob>` — one opaque parameter, so it composes
49//! with any existing scheme and route.
50
51use std::str::FromStr;
52
53use base64::engine::general_purpose::URL_SAFE_NO_PAD;
54use base64::Engine;
55use offline_protocol_core::{contains_control_or_format, Address};
56
57use crate::canonical::canonical_payload;
58use crate::error::{MlsError, Result};
59use crate::manager::MlsManager;
60
61/// Signature domain for invite payloads.
62///
63/// Must not be a prefix of, or prefixed by, any other live signing domain. See
64/// the `canonical` module.
65pub const INVITE_SIGN_DOMAIN: &[u8] = b"offline-invite-v1";
66
67/// The only invite format version this build produces or accepts.
68pub const INVITE_VERSION: u8 = 1;
69
70/// Maximum petname length in bytes.
71///
72/// A petname is a display string, so the bound is on bytes for the same reason
73/// [`Username::MAX_BYTES`](offline_protocol_core::Username::MAX_BYTES) is: it
74/// caps what goes on the wire and through a signature.
75pub const MAX_PETNAME_BYTES: usize = 64;
76
77/// Length of an Ed25519 public key.
78const PUBLIC_KEY_LEN: usize = 32;
79
80/// Length of an Ed25519 signature.
81const SIGNATURE_LEN: usize = 64;
82
83/// Set when a petname follows the address.
84const FLAG_PETNAME: u8 = 0b0000_0001;
85
86/// Set when a signature terminates the blob.
87const FLAG_SIGNATURE: u8 = 0b0000_0010;
88
89/// Every flag bit this version defines.
90///
91/// An unknown bit is refused rather than ignored: the bits select which
92/// trailing sections are present, so misreading one desynchronizes the parse
93/// and would surface as a corrupt petname rather than as a version error.
94const KNOWN_FLAGS: u8 = FLAG_PETNAME | FLAG_SIGNATURE;
95
96/// A decoded and verified invite.
97///
98/// Holding one of these means the address self-certified against the public key
99/// and, if `signed` is true, that the petname is bound to that key by its owner.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Invite {
102    /// The address the invite reaches, already checked to be
103    /// `derive_address(public_key)`.
104    pub address: Address,
105    /// The Ed25519 identity key the address derives from.
106    pub public_key: Vec<u8>,
107    /// The suggested display name, if the invite carried one.
108    ///
109    /// Suggested, not authoritative: it is a *locally assigned* name and an app
110    /// is right to let the user edit it.
111    pub petname: Option<String>,
112    /// Whether a valid signature accompanied the invite.
113    ///
114    /// False means the petname is unbound, not that the invite is invalid. An
115    /// unsigned invite is a legitimate and common shape.
116    pub signed: bool,
117}
118
119/// Encodes an invite blob, optionally signed.
120///
121/// `signature` must be a signature by the key `public_key` names, over
122/// [`invite_signing_payload`]. Pass `None` for the unsigned form.
123///
124/// # Errors
125///
126/// Returns [`MlsError::InvalidPublicKey`] if `public_key` is not 32 bytes,
127/// [`MlsError::Serialization`] if the petname is over-long or carries a control
128/// or format character, or if the signature is the wrong length.
129pub fn encode_invite(
130    address: &Address,
131    public_key: &[u8],
132    petname: Option<&str>,
133    signature: Option<&[u8]>,
134) -> Result<String> {
135    if public_key.len() != PUBLIC_KEY_LEN {
136        return Err(MlsError::InvalidPublicKey(format!(
137            "Ed25519 public key must be {} bytes, got {}",
138            PUBLIC_KEY_LEN,
139            public_key.len()
140        )));
141    }
142    // Checked here as well as at the type, because a caller can hand us an
143    // address that is not this key's and produce a blob that fails to parse on
144    // every scanner. Failing at mint is the honest place.
145    let derived = MlsManager::derive_address(public_key)?;
146    if derived != *address {
147        return Err(MlsError::Serialization(
148            "Invite address is not the address this public key derives to".to_string(),
149        ));
150    }
151
152    let petname_bytes = match petname {
153        // An empty petname and an absent one mean the same thing, so they get
154        // the same encoding rather than two spellings of one state.
155        Some("") => None,
156        Some(name) => {
157            if name.len() > MAX_PETNAME_BYTES {
158                return Err(MlsError::Serialization(format!(
159                    "Invite petname is {} bytes, maximum is {}",
160                    name.len(),
161                    MAX_PETNAME_BYTES
162                )));
163            }
164            // The same screen a username gets, for a stronger reason. See
165            // [`parse_invite`]: refusing at mint means an honest app cannot
166            // build one of these by accident from a pasted display name.
167            if contains_control_or_format(name) {
168                return Err(MlsError::Serialization(
169                    "Invite petname contains a control or format character".to_string(),
170                ));
171            }
172            Some(name.as_bytes())
173        }
174        None => None,
175    };
176
177    if let Some(sig) = signature {
178        if sig.len() != SIGNATURE_LEN {
179            return Err(MlsError::Serialization(format!(
180                "Invite signature must be {} bytes, got {}",
181                SIGNATURE_LEN,
182                sig.len()
183            )));
184        }
185    }
186
187    let address_string = address.to_string();
188    let address_bytes = address_string.as_bytes();
189
190    let mut flags = 0u8;
191    if petname_bytes.is_some() {
192        flags |= FLAG_PETNAME;
193    }
194    if signature.is_some() {
195        flags |= FLAG_SIGNATURE;
196    }
197
198    let mut blob = Vec::with_capacity(
199        2 + PUBLIC_KEY_LEN
200            + 1
201            + address_bytes.len()
202            + petname_bytes.map_or(0, |p| 1 + p.len())
203            + signature.map_or(0, |s| s.len()),
204    );
205    blob.push(INVITE_VERSION);
206    blob.push(flags);
207    blob.extend_from_slice(public_key);
208    // The address is ASCII bech32m of a fixed length, so a single-byte prefix
209    // cannot overflow.
210    blob.push(address_bytes.len() as u8);
211    blob.extend_from_slice(address_bytes);
212    if let Some(petname) = petname_bytes {
213        blob.push(petname.len() as u8);
214        blob.extend_from_slice(petname);
215    }
216    if let Some(sig) = signature {
217        blob.extend_from_slice(sig);
218    }
219
220    Ok(URL_SAFE_NO_PAD.encode(&blob))
221}
222
223/// Builds the payload an invite signature is taken over.
224///
225/// `domain ‖ u32be‖bytes` over `[v, address, public_key, petname]`, in that
226/// fixed order, with an absent petname encoded as a zero-length field.
227///
228/// Exposed because signing happens where the private key is (the engine) while
229/// verification happens here, and both must build byte-identical input.
230pub fn invite_signing_payload(
231    address: &Address,
232    public_key: &[u8],
233    petname: Option<&str>,
234) -> Result<Vec<u8>> {
235    let address_string = address.to_string();
236    let petname_bytes = petname.unwrap_or("").as_bytes();
237    canonical_payload(
238        INVITE_SIGN_DOMAIN,
239        &[
240            &[INVITE_VERSION],
241            address_string.as_bytes(),
242            public_key,
243            petname_bytes,
244        ],
245    )
246}
247
248/// Decodes and verifies an invite blob.
249///
250/// Verification is mandatory and total: an invite that fails any check is an
251/// error, never a partially-trusted value. In order, cheap before expensive:
252///
253/// 1. base64url decodes, and the blob is structurally complete;
254/// 2. `v == 1` and no unknown flag bits;
255/// 3. the address parses in canonical form;
256/// 4. the petname, when present, is displayable (no `Cc`, no `Cf`);
257/// 5. `derive_address(public_key) == address`;
258/// 6. the signature, when present, verifies under `public_key`.
259///
260/// # Errors
261///
262/// Returns [`MlsError::InvalidMessage`] for a malformed or truncated blob,
263/// [`MlsError::VerificationFailed`] when the address does not derive from the
264/// key or a present signature does not verify.
265pub fn parse_invite(blob: &str) -> Result<Invite> {
266    let bytes = URL_SAFE_NO_PAD
267        .decode(blob.trim())
268        .map_err(|e| MlsError::InvalidMessage(format!("Invite is not base64url: {}", e)))?;
269
270    let mut cursor = Reader::new(&bytes);
271
272    let version = cursor.take_u8("version")?;
273    if version != INVITE_VERSION {
274        return Err(MlsError::InvalidMessage(format!(
275            "Unsupported invite version {}, expected {}",
276            version, INVITE_VERSION
277        )));
278    }
279
280    let flags = cursor.take_u8("flags")?;
281    if flags & !KNOWN_FLAGS != 0 {
282        return Err(MlsError::InvalidMessage(format!(
283            "Invite carries unknown flag bits: {:#010b}",
284            flags
285        )));
286    }
287
288    let public_key = cursor.take("public key", PUBLIC_KEY_LEN)?.to_vec();
289
290    let address_len = cursor.take_u8("address length")? as usize;
291    let address_bytes = cursor.take("address", address_len)?;
292    let address_str = std::str::from_utf8(address_bytes)
293        .map_err(|_| MlsError::InvalidMessage("Invite address is not UTF-8".to_string()))?;
294    let address = Address::from_str(address_str)
295        .map_err(|e| MlsError::InvalidMessage(format!("Invite address is invalid: {}", e)))?;
296
297    let petname = if flags & FLAG_PETNAME != 0 {
298        let len = cursor.take_u8("petname length")? as usize;
299        if len == 0 {
300            // The flag says a petname follows; a zero-length one is a second
301            // spelling of "absent", which `encode_invite` never emits.
302            return Err(MlsError::InvalidMessage(
303                "Invite sets the petname flag but carries an empty petname".to_string(),
304            ));
305        }
306        if len > MAX_PETNAME_BYTES {
307            return Err(MlsError::InvalidMessage(format!(
308                "Invite petname is {} bytes, maximum is {}",
309                len, MAX_PETNAME_BYTES
310            )));
311        }
312        let bytes = cursor.take("petname", len)?;
313        let name = std::str::from_utf8(bytes)
314            .map_err(|_| MlsError::InvalidMessage("Invite petname is not UTF-8".to_string()))?;
315        // A petname carrying a bidi override or a zero-width joiner renders as
316        // something other than its own bytes, and this is the string an app
317        // shows in the confirmation dialog after a scan. Worse than for a
318        // username: when the invite is signed, the deceptive rendering arrives
319        // bound to a *valid* signature, so an app that trusts `signed` is
320        // trusting the wrong half. Refused here rather than left to every
321        // caller to sanitize.
322        if contains_control_or_format(name) {
323            return Err(MlsError::InvalidMessage(
324                "Invite petname contains a control or format character".to_string(),
325            ));
326        }
327        Some(name.to_string())
328    } else {
329        None
330    };
331
332    let signature = if flags & FLAG_SIGNATURE != 0 {
333        Some(cursor.take("signature", SIGNATURE_LEN)?.to_vec())
334    } else {
335        None
336    };
337
338    // Trailing bytes mean the blob is not what it claims to be. Ignoring them
339    // would let one address travel under several distinct blobs, and would hide
340    // a section this version does not know how to read.
341    cursor.finish()?;
342
343    // The check the whole format exists for. Runs before the signature so a
344    // substituted key fails on the cheap comparison rather than on a verify.
345    let derived = MlsManager::derive_address(&public_key)?;
346    if derived != address {
347        return Err(MlsError::VerificationFailed(
348            "Invite address is not the address its public key derives to".to_string(),
349        ));
350    }
351
352    let signed = match signature {
353        Some(sig) => {
354            let payload = invite_signing_payload(&address, &public_key, petname.as_deref())?;
355            if !MlsManager::verify_signature(&public_key, &payload, &sig)? {
356                return Err(MlsError::VerificationFailed(
357                    "Invite signature does not verify under its public key".to_string(),
358                ));
359            }
360            true
361        }
362        None => false,
363    };
364
365    Ok(Invite {
366        address,
367        public_key,
368        petname,
369        signed,
370    })
371}
372
373/// A bounds-checked forward reader over the invite blob.
374///
375/// Every field is taken through this so a truncated blob produces a named
376/// error rather than a panic on a slice index.
377struct Reader<'a> {
378    bytes: &'a [u8],
379    offset: usize,
380}
381
382impl<'a> Reader<'a> {
383    fn new(bytes: &'a [u8]) -> Self {
384        Self { bytes, offset: 0 }
385    }
386
387    fn take(&mut self, field: &str, len: usize) -> Result<&'a [u8]> {
388        let end = self.offset.checked_add(len).ok_or_else(|| {
389            MlsError::InvalidMessage(format!("Invite {} length overflows", field))
390        })?;
391        if end > self.bytes.len() {
392            return Err(MlsError::InvalidMessage(format!(
393                "Invite is truncated: {} needs {} bytes, {} remain",
394                field,
395                len,
396                self.bytes.len().saturating_sub(self.offset)
397            )));
398        }
399        let slice = &self.bytes[self.offset..end];
400        self.offset = end;
401        Ok(slice)
402    }
403
404    fn take_u8(&mut self, field: &str) -> Result<u8> {
405        Ok(self.take(field, 1)?[0])
406    }
407
408    fn finish(&self) -> Result<()> {
409        if self.offset != self.bytes.len() {
410            return Err(MlsError::InvalidMessage(format!(
411                "Invite has {} trailing bytes",
412                self.bytes.len() - self.offset
413            )));
414        }
415        Ok(())
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use ed25519_dalek::{Signer, SigningKey};
423
424    /// A deterministic identity, so the golden vectors below are reproducible.
425    fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
426        let signing = SigningKey::from_bytes(&[seed; 32]);
427        let public = signing.verifying_key().to_bytes().to_vec();
428        let address = MlsManager::derive_address(&public).expect("derive");
429        (signing, public, address)
430    }
431
432    fn sign(signing: &SigningKey, payload: &[u8]) -> Vec<u8> {
433        signing.sign(payload).to_bytes().to_vec()
434    }
435
436    #[test]
437    fn invite_round_trips_unsigned_without_a_petname() {
438        let (_, public, address) = identity(1);
439        let blob = encode_invite(&address, &public, None, None).expect("encode");
440        let invite = parse_invite(&blob).expect("parse");
441        assert_eq!(invite.address, address);
442        assert_eq!(invite.public_key, public);
443        assert_eq!(invite.petname, None);
444        assert!(!invite.signed);
445    }
446
447    #[test]
448    fn invite_round_trips_with_a_petname() {
449        let (_, public, address) = identity(2);
450        let blob = encode_invite(&address, &public, Some("Alice"), None).expect("encode");
451        let invite = parse_invite(&blob).expect("parse");
452        assert_eq!(invite.petname.as_deref(), Some("Alice"));
453        assert!(!invite.signed);
454    }
455
456    #[test]
457    fn invite_round_trips_signed() {
458        let (signing, public, address) = identity(3);
459        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
460        let signature = sign(&signing, &payload);
461        let blob =
462            encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
463        let invite = parse_invite(&blob).expect("parse");
464        assert_eq!(invite.petname.as_deref(), Some("Alice"));
465        assert!(invite.signed);
466    }
467
468    /// An empty petname is the same state as no petname, and must not produce a
469    /// second encoding of it.
470    #[test]
471    fn invite_treats_an_empty_petname_as_absent() {
472        let (_, public, address) = identity(4);
473        let with_empty = encode_invite(&address, &public, Some(""), None).expect("encode");
474        let without = encode_invite(&address, &public, None, None).expect("encode");
475        assert_eq!(with_empty, without);
476        assert_eq!(parse_invite(&with_empty).expect("parse").petname, None);
477    }
478
479    /// The check the format exists for: a blob pairing one key with another's
480    /// address must be refused.
481    #[test]
482    fn invite_refuses_an_address_that_is_not_the_keys() {
483        let (_, public_a, _) = identity(5);
484        let (_, _, address_b) = identity(6);
485
486        // `encode_invite` refuses to mint it...
487        assert!(encode_invite(&address_b, &public_a, None, None).is_err());
488
489        // ...and a hand-built blob is refused at parse, which is the check that
490        // actually matters since an attacker does not use our encoder.
491        let address_bytes = address_b.to_string();
492        let mut blob = vec![INVITE_VERSION, 0u8];
493        blob.extend_from_slice(&public_a);
494        blob.push(address_bytes.len() as u8);
495        blob.extend_from_slice(address_bytes.as_bytes());
496        let encoded = URL_SAFE_NO_PAD.encode(&blob);
497
498        assert!(matches!(
499            parse_invite(&encoded),
500            Err(MlsError::VerificationFailed(_))
501        ));
502    }
503
504    /// The relabeling attack the optional signature exists to stop.
505    #[test]
506    fn invite_refuses_a_petname_swapped_under_a_signature() {
507        let (signing, public, address) = identity(7);
508        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
509        let signature = sign(&signing, &payload);
510
511        // The attacker keeps the victim's real, public {address, pubkey} and
512        // the genuine signature, and swaps only the name.
513        let relabeled =
514            encode_invite(&address, &public, Some("Bob"), Some(&signature)).expect("encode");
515
516        assert!(matches!(
517            parse_invite(&relabeled),
518            Err(MlsError::VerificationFailed(_))
519        ));
520    }
521
522    /// An invite signed by a *different* key than the one it names must fail:
523    /// the signature is verified under the invite's own public key, so this is
524    /// the case where an attacker signs someone else's identity.
525    #[test]
526    fn invite_refuses_a_signature_by_a_foreign_key() {
527        let (_, public, address) = identity(8);
528        let (foreign_signing, _, _) = identity(9);
529        let payload = invite_signing_payload(&address, &public, None).expect("payload");
530        let signature = sign(&foreign_signing, &payload);
531        let blob = encode_invite(&address, &public, None, Some(&signature)).expect("encode");
532        assert!(matches!(
533            parse_invite(&blob),
534            Err(MlsError::VerificationFailed(_))
535        ));
536    }
537
538    #[test]
539    fn invite_refuses_an_unknown_version() {
540        let (_, public, address) = identity(10);
541        let blob = encode_invite(&address, &public, None, None).expect("encode");
542        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
543        bytes[0] = 2;
544        assert!(matches!(
545            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
546            Err(MlsError::InvalidMessage(_))
547        ));
548    }
549
550    /// An unknown flag bit selects a section this build cannot read, so it is
551    /// refused rather than ignored — ignoring it desynchronizes the parse.
552    #[test]
553    fn invite_refuses_unknown_flag_bits() {
554        let (_, public, address) = identity(11);
555        let blob = encode_invite(&address, &public, None, None).expect("encode");
556        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
557        bytes[1] |= 0b1000_0000;
558        assert!(matches!(
559            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
560            Err(MlsError::InvalidMessage(_))
561        ));
562    }
563
564    #[test]
565    fn invite_refuses_truncation_at_every_length() {
566        let (signing, public, address) = identity(12);
567        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
568        let signature = sign(&signing, &payload);
569        let blob =
570            encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
571        let bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
572
573        for cut in 0..bytes.len() {
574            let truncated = URL_SAFE_NO_PAD.encode(&bytes[..cut]);
575            assert!(
576                parse_invite(&truncated).is_err(),
577                "a blob truncated to {} bytes must not parse",
578                cut
579            );
580        }
581    }
582
583    #[test]
584    fn invite_refuses_trailing_bytes() {
585        let (_, public, address) = identity(13);
586        let blob = encode_invite(&address, &public, None, None).expect("encode");
587        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
588        bytes.push(0);
589        assert!(matches!(
590            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
591            Err(MlsError::InvalidMessage(_))
592        ));
593    }
594
595    #[test]
596    fn invite_refuses_an_over_long_petname() {
597        let (_, public, address) = identity(14);
598        let long = "a".repeat(MAX_PETNAME_BYTES + 1);
599        assert!(encode_invite(&address, &public, Some(&long), None).is_err());
600    }
601
602    /// A petname that renders as something other than its own bytes is refused
603    /// at both ends, and the parse side is the one that matters: an attacker
604    /// does not use our encoder.
605    ///
606    /// The characters here are `Cf`, so [`char::is_control`] does not see any
607    /// of them — a screen built on it alone would pass every one. This is the
608    /// string an app shows in the dialog after a scan, so a right-to-left
609    /// override here reads as a different name than the bytes that were
610    /// signed.
611    #[test]
612    fn invite_refuses_a_petname_that_renders_as_another_name() {
613        let (signing, public, address) = identity(15);
614
615        for (label, name) in [
616            ("right-to-left override", "ali\u{202E}ce"),
617            ("zero-width joiner", "ali\u{200D}ce"),
618            ("soft hyphen", "ali\u{00AD}ce"),
619            ("byte-order mark", "ali\u{FEFF}ce"),
620            ("newline", "ali\nce"),
621        ] {
622            assert!(
623                encode_invite(&address, &public, Some(name), None).is_err(),
624                "a {label} petname must not be mintable"
625            );
626
627            // Hand-built, since the encoder now refuses to produce one — and
628            // signed, which is the case that matters: without this screen the
629            // deceptive rendering would arrive carrying a *valid* signature,
630            // so an app trusting `signed` would be trusting the wrong half.
631            let payload = invite_signing_payload(&address, &public, Some(name)).expect("payload");
632            let signature = sign(&signing, &payload);
633            let address_bytes = address.to_string();
634            let mut blob = vec![INVITE_VERSION, FLAG_PETNAME | FLAG_SIGNATURE];
635            blob.extend_from_slice(&public);
636            blob.push(address_bytes.len() as u8);
637            blob.extend_from_slice(address_bytes.as_bytes());
638            blob.push(name.len() as u8);
639            blob.extend_from_slice(name.as_bytes());
640            blob.extend_from_slice(&signature);
641
642            assert!(
643                matches!(
644                    parse_invite(&URL_SAFE_NO_PAD.encode(&blob)),
645                    Err(MlsError::InvalidMessage(_))
646                ),
647                "a {label} petname must be refused at parse even when signed"
648            );
649        }
650    }
651
652    /// The screen must not swallow ordinary names, or it is useless.
653    #[test]
654    fn invite_allows_ordinary_international_petnames() {
655        let (_, public, address) = identity(16);
656        for name in ["Alice", "José", "上田", "أحمد", "Ann Lee"] {
657            let blob = encode_invite(&address, &public, Some(name), None).expect("encode");
658            assert_eq!(
659                parse_invite(&blob).expect("parse").petname.as_deref(),
660                Some(name)
661            );
662        }
663    }
664
665    /// Golden vectors. These strings are the invite wire format: an
666    /// implementation in another language must produce them byte for byte.
667    ///
668    /// **Computed independently of this code**, by a Python script that builds
669    /// bech32m from the BIP-350 reference implementation, takes Ed25519 from
670    /// `cryptography`, and assembles the blob from the written format rather
671    /// than from this source. Agreement is therefore a two-implementation
672    /// cross-check and not a restatement of whatever the encoder happened to
673    /// emit. Regenerate them the same way, by changing the format on purpose —
674    /// never by pasting new code output.
675    ///
676    /// The identity is the all-`0x01` Ed25519 seed, so a second implementation
677    /// can reproduce every line without this repository.
678    #[test]
679    fn invite_golden_vectors() {
680        let (signing, public, address) = identity(1);
681        assert_eq!(
682            address.to_string(),
683            "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
684            "the golden identity's address changed"
685        );
686
687        let unsigned = encode_invite(&address, &public, None, None).expect("encode");
688        assert_eq!(
689            unsigned,
690            "AQCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMA"
691        );
692
693        let named = encode_invite(&address, &public, Some("alice"), None).expect("encode");
694        assert_eq!(
695            named,
696            "AQGKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAVhbGljZQ"
697        );
698
699        let payload = invite_signing_payload(&address, &public, Some("alice")).expect("payload");
700        assert_eq!(
701            base64::engine::general_purpose::STANDARD.encode(&payload),
702            "b2ZmbGluZS1pbnZpdGUtdjEAAAABAQAAACxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAAAACCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXAAAAAVhbGljZQ=="
703        );
704
705        let signature = sign(&signing, &payload);
706        let signed =
707            encode_invite(&address, &public, Some("alice"), Some(&signature)).expect("encode");
708        assert!(parse_invite(&signed).expect("parse").signed);
709        assert_eq!(signed.len(), 199, "signed invite length changed");
710    }
711}