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::error::{MlsError, Result};
58use crate::manager::MlsManager;
59use offline_protocol_sealed::canonical_payload;
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/// `offline_protocol_sealed::canonical`.
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    .map_err(MlsError::from)
247}
248
249/// Decodes and verifies an invite blob.
250///
251/// Verification is mandatory and total: an invite that fails any check is an
252/// error, never a partially-trusted value. In order, cheap before expensive:
253///
254/// 1. base64url decodes, and the blob is structurally complete;
255/// 2. `v == 1` and no unknown flag bits;
256/// 3. the address parses in canonical form;
257/// 4. the petname, when present, is displayable (no `Cc`, no `Cf`);
258/// 5. `derive_address(public_key) == address`;
259/// 6. the signature, when present, verifies under `public_key`.
260///
261/// # Errors
262///
263/// Returns [`MlsError::InvalidMessage`] for a malformed or truncated blob,
264/// [`MlsError::VerificationFailed`] when the address does not derive from the
265/// key or a present signature does not verify.
266pub fn parse_invite(blob: &str) -> Result<Invite> {
267    let bytes = URL_SAFE_NO_PAD
268        .decode(blob.trim())
269        .map_err(|e| MlsError::InvalidMessage(format!("Invite is not base64url: {}", e)))?;
270
271    let mut cursor = Reader::new(&bytes);
272
273    let version = cursor.take_u8("version")?;
274    if version != INVITE_VERSION {
275        return Err(MlsError::InvalidMessage(format!(
276            "Unsupported invite version {}, expected {}",
277            version, INVITE_VERSION
278        )));
279    }
280
281    let flags = cursor.take_u8("flags")?;
282    if flags & !KNOWN_FLAGS != 0 {
283        return Err(MlsError::InvalidMessage(format!(
284            "Invite carries unknown flag bits: {:#010b}",
285            flags
286        )));
287    }
288
289    let public_key = cursor.take("public key", PUBLIC_KEY_LEN)?.to_vec();
290
291    let address_len = cursor.take_u8("address length")? as usize;
292    let address_bytes = cursor.take("address", address_len)?;
293    let address_str = std::str::from_utf8(address_bytes)
294        .map_err(|_| MlsError::InvalidMessage("Invite address is not UTF-8".to_string()))?;
295    let address = Address::from_str(address_str)
296        .map_err(|e| MlsError::InvalidMessage(format!("Invite address is invalid: {}", e)))?;
297
298    let petname = if flags & FLAG_PETNAME != 0 {
299        let len = cursor.take_u8("petname length")? as usize;
300        if len == 0 {
301            // The flag says a petname follows; a zero-length one is a second
302            // spelling of "absent", which `encode_invite` never emits.
303            return Err(MlsError::InvalidMessage(
304                "Invite sets the petname flag but carries an empty petname".to_string(),
305            ));
306        }
307        if len > MAX_PETNAME_BYTES {
308            return Err(MlsError::InvalidMessage(format!(
309                "Invite petname is {} bytes, maximum is {}",
310                len, MAX_PETNAME_BYTES
311            )));
312        }
313        let bytes = cursor.take("petname", len)?;
314        let name = std::str::from_utf8(bytes)
315            .map_err(|_| MlsError::InvalidMessage("Invite petname is not UTF-8".to_string()))?;
316        // A petname carrying a bidi override or a zero-width joiner renders as
317        // something other than its own bytes, and this is the string an app
318        // shows in the confirmation dialog after a scan. Worse than for a
319        // username: when the invite is signed, the deceptive rendering arrives
320        // bound to a *valid* signature, so an app that trusts `signed` is
321        // trusting the wrong half. Refused here rather than left to every
322        // caller to sanitize.
323        if contains_control_or_format(name) {
324            return Err(MlsError::InvalidMessage(
325                "Invite petname contains a control or format character".to_string(),
326            ));
327        }
328        Some(name.to_string())
329    } else {
330        None
331    };
332
333    let signature = if flags & FLAG_SIGNATURE != 0 {
334        Some(cursor.take("signature", SIGNATURE_LEN)?.to_vec())
335    } else {
336        None
337    };
338
339    // Trailing bytes mean the blob is not what it claims to be. Ignoring them
340    // would let one address travel under several distinct blobs, and would hide
341    // a section this version does not know how to read.
342    cursor.finish()?;
343
344    // The check the whole format exists for. Runs before the signature so a
345    // substituted key fails on the cheap comparison rather than on a verify.
346    let derived = MlsManager::derive_address(&public_key)?;
347    if derived != address {
348        return Err(MlsError::VerificationFailed(
349            "Invite address is not the address its public key derives to".to_string(),
350        ));
351    }
352
353    let signed = match signature {
354        Some(sig) => {
355            let payload = invite_signing_payload(&address, &public_key, petname.as_deref())?;
356            if !MlsManager::verify_signature(&public_key, &payload, &sig)? {
357                return Err(MlsError::VerificationFailed(
358                    "Invite signature does not verify under its public key".to_string(),
359                ));
360            }
361            true
362        }
363        None => false,
364    };
365
366    Ok(Invite {
367        address,
368        public_key,
369        petname,
370        signed,
371    })
372}
373
374/// A bounds-checked forward reader over the invite blob.
375///
376/// Every field is taken through this so a truncated blob produces a named
377/// error rather than a panic on a slice index.
378struct Reader<'a> {
379    bytes: &'a [u8],
380    offset: usize,
381}
382
383impl<'a> Reader<'a> {
384    fn new(bytes: &'a [u8]) -> Self {
385        Self { bytes, offset: 0 }
386    }
387
388    fn take(&mut self, field: &str, len: usize) -> Result<&'a [u8]> {
389        let end = self.offset.checked_add(len).ok_or_else(|| {
390            MlsError::InvalidMessage(format!("Invite {} length overflows", field))
391        })?;
392        if end > self.bytes.len() {
393            return Err(MlsError::InvalidMessage(format!(
394                "Invite is truncated: {} needs {} bytes, {} remain",
395                field,
396                len,
397                self.bytes.len().saturating_sub(self.offset)
398            )));
399        }
400        let slice = &self.bytes[self.offset..end];
401        self.offset = end;
402        Ok(slice)
403    }
404
405    fn take_u8(&mut self, field: &str) -> Result<u8> {
406        Ok(self.take(field, 1)?[0])
407    }
408
409    fn finish(&self) -> Result<()> {
410        if self.offset != self.bytes.len() {
411            return Err(MlsError::InvalidMessage(format!(
412                "Invite has {} trailing bytes",
413                self.bytes.len() - self.offset
414            )));
415        }
416        Ok(())
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use ed25519_dalek::{Signer, SigningKey};
424
425    /// A deterministic identity, so the golden vectors below are reproducible.
426    fn identity(seed: u8) -> (SigningKey, Vec<u8>, Address) {
427        let signing = SigningKey::from_bytes(&[seed; 32]);
428        let public = signing.verifying_key().to_bytes().to_vec();
429        let address = MlsManager::derive_address(&public).expect("derive");
430        (signing, public, address)
431    }
432
433    fn sign(signing: &SigningKey, payload: &[u8]) -> Vec<u8> {
434        signing.sign(payload).to_bytes().to_vec()
435    }
436
437    #[test]
438    fn invite_round_trips_unsigned_without_a_petname() {
439        let (_, public, address) = identity(1);
440        let blob = encode_invite(&address, &public, None, None).expect("encode");
441        let invite = parse_invite(&blob).expect("parse");
442        assert_eq!(invite.address, address);
443        assert_eq!(invite.public_key, public);
444        assert_eq!(invite.petname, None);
445        assert!(!invite.signed);
446    }
447
448    #[test]
449    fn invite_round_trips_with_a_petname() {
450        let (_, public, address) = identity(2);
451        let blob = encode_invite(&address, &public, Some("Alice"), None).expect("encode");
452        let invite = parse_invite(&blob).expect("parse");
453        assert_eq!(invite.petname.as_deref(), Some("Alice"));
454        assert!(!invite.signed);
455    }
456
457    #[test]
458    fn invite_round_trips_signed() {
459        let (signing, public, address) = identity(3);
460        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
461        let signature = sign(&signing, &payload);
462        let blob =
463            encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
464        let invite = parse_invite(&blob).expect("parse");
465        assert_eq!(invite.petname.as_deref(), Some("Alice"));
466        assert!(invite.signed);
467    }
468
469    /// An empty petname is the same state as no petname, and must not produce a
470    /// second encoding of it.
471    #[test]
472    fn invite_treats_an_empty_petname_as_absent() {
473        let (_, public, address) = identity(4);
474        let with_empty = encode_invite(&address, &public, Some(""), None).expect("encode");
475        let without = encode_invite(&address, &public, None, None).expect("encode");
476        assert_eq!(with_empty, without);
477        assert_eq!(parse_invite(&with_empty).expect("parse").petname, None);
478    }
479
480    /// The check the format exists for: a blob pairing one key with another's
481    /// address must be refused.
482    #[test]
483    fn invite_refuses_an_address_that_is_not_the_keys() {
484        let (_, public_a, _) = identity(5);
485        let (_, _, address_b) = identity(6);
486
487        // `encode_invite` refuses to mint it...
488        assert!(encode_invite(&address_b, &public_a, None, None).is_err());
489
490        // ...and a hand-built blob is refused at parse, which is the check that
491        // actually matters since an attacker does not use our encoder.
492        let address_bytes = address_b.to_string();
493        let mut blob = vec![INVITE_VERSION, 0u8];
494        blob.extend_from_slice(&public_a);
495        blob.push(address_bytes.len() as u8);
496        blob.extend_from_slice(address_bytes.as_bytes());
497        let encoded = URL_SAFE_NO_PAD.encode(&blob);
498
499        assert!(matches!(
500            parse_invite(&encoded),
501            Err(MlsError::VerificationFailed(_))
502        ));
503    }
504
505    /// The relabeling attack the optional signature exists to stop.
506    #[test]
507    fn invite_refuses_a_petname_swapped_under_a_signature() {
508        let (signing, public, address) = identity(7);
509        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
510        let signature = sign(&signing, &payload);
511
512        // The attacker keeps the victim's real, public {address, pubkey} and
513        // the genuine signature, and swaps only the name.
514        let relabeled =
515            encode_invite(&address, &public, Some("Bob"), Some(&signature)).expect("encode");
516
517        assert!(matches!(
518            parse_invite(&relabeled),
519            Err(MlsError::VerificationFailed(_))
520        ));
521    }
522
523    /// An invite signed by a *different* key than the one it names must fail:
524    /// the signature is verified under the invite's own public key, so this is
525    /// the case where an attacker signs someone else's identity.
526    #[test]
527    fn invite_refuses_a_signature_by_a_foreign_key() {
528        let (_, public, address) = identity(8);
529        let (foreign_signing, _, _) = identity(9);
530        let payload = invite_signing_payload(&address, &public, None).expect("payload");
531        let signature = sign(&foreign_signing, &payload);
532        let blob = encode_invite(&address, &public, None, Some(&signature)).expect("encode");
533        assert!(matches!(
534            parse_invite(&blob),
535            Err(MlsError::VerificationFailed(_))
536        ));
537    }
538
539    #[test]
540    fn invite_refuses_an_unknown_version() {
541        let (_, public, address) = identity(10);
542        let blob = encode_invite(&address, &public, None, None).expect("encode");
543        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
544        bytes[0] = 2;
545        assert!(matches!(
546            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
547            Err(MlsError::InvalidMessage(_))
548        ));
549    }
550
551    /// An unknown flag bit selects a section this build cannot read, so it is
552    /// refused rather than ignored — ignoring it desynchronizes the parse.
553    #[test]
554    fn invite_refuses_unknown_flag_bits() {
555        let (_, public, address) = identity(11);
556        let blob = encode_invite(&address, &public, None, None).expect("encode");
557        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
558        bytes[1] |= 0b1000_0000;
559        assert!(matches!(
560            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
561            Err(MlsError::InvalidMessage(_))
562        ));
563    }
564
565    #[test]
566    fn invite_refuses_truncation_at_every_length() {
567        let (signing, public, address) = identity(12);
568        let payload = invite_signing_payload(&address, &public, Some("Alice")).expect("payload");
569        let signature = sign(&signing, &payload);
570        let blob =
571            encode_invite(&address, &public, Some("Alice"), Some(&signature)).expect("encode");
572        let bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
573
574        for cut in 0..bytes.len() {
575            let truncated = URL_SAFE_NO_PAD.encode(&bytes[..cut]);
576            assert!(
577                parse_invite(&truncated).is_err(),
578                "a blob truncated to {} bytes must not parse",
579                cut
580            );
581        }
582    }
583
584    #[test]
585    fn invite_refuses_trailing_bytes() {
586        let (_, public, address) = identity(13);
587        let blob = encode_invite(&address, &public, None, None).expect("encode");
588        let mut bytes = URL_SAFE_NO_PAD.decode(&blob).expect("decode");
589        bytes.push(0);
590        assert!(matches!(
591            parse_invite(&URL_SAFE_NO_PAD.encode(&bytes)),
592            Err(MlsError::InvalidMessage(_))
593        ));
594    }
595
596    #[test]
597    fn invite_refuses_an_over_long_petname() {
598        let (_, public, address) = identity(14);
599        let long = "a".repeat(MAX_PETNAME_BYTES + 1);
600        assert!(encode_invite(&address, &public, Some(&long), None).is_err());
601    }
602
603    /// A petname that renders as something other than its own bytes is refused
604    /// at both ends, and the parse side is the one that matters: an attacker
605    /// does not use our encoder.
606    ///
607    /// The characters here are `Cf`, so [`char::is_control`] does not see any
608    /// of them — a screen built on it alone would pass every one. This is the
609    /// string an app shows in the dialog after a scan, so a right-to-left
610    /// override here reads as a different name than the bytes that were
611    /// signed.
612    #[test]
613    fn invite_refuses_a_petname_that_renders_as_another_name() {
614        let (signing, public, address) = identity(15);
615
616        for (label, name) in [
617            ("right-to-left override", "ali\u{202E}ce"),
618            ("zero-width joiner", "ali\u{200D}ce"),
619            ("soft hyphen", "ali\u{00AD}ce"),
620            ("byte-order mark", "ali\u{FEFF}ce"),
621            ("newline", "ali\nce"),
622        ] {
623            assert!(
624                encode_invite(&address, &public, Some(name), None).is_err(),
625                "a {label} petname must not be mintable"
626            );
627
628            // Hand-built, since the encoder now refuses to produce one — and
629            // signed, which is the case that matters: without this screen the
630            // deceptive rendering would arrive carrying a *valid* signature,
631            // so an app trusting `signed` would be trusting the wrong half.
632            let payload = invite_signing_payload(&address, &public, Some(name)).expect("payload");
633            let signature = sign(&signing, &payload);
634            let address_bytes = address.to_string();
635            let mut blob = vec![INVITE_VERSION, FLAG_PETNAME | FLAG_SIGNATURE];
636            blob.extend_from_slice(&public);
637            blob.push(address_bytes.len() as u8);
638            blob.extend_from_slice(address_bytes.as_bytes());
639            blob.push(name.len() as u8);
640            blob.extend_from_slice(name.as_bytes());
641            blob.extend_from_slice(&signature);
642
643            assert!(
644                matches!(
645                    parse_invite(&URL_SAFE_NO_PAD.encode(&blob)),
646                    Err(MlsError::InvalidMessage(_))
647                ),
648                "a {label} petname must be refused at parse even when signed"
649            );
650        }
651    }
652
653    /// The screen must not swallow ordinary names, or it is useless.
654    #[test]
655    fn invite_allows_ordinary_international_petnames() {
656        let (_, public, address) = identity(16);
657        for name in ["Alice", "José", "上田", "أحمد", "Ann Lee"] {
658            let blob = encode_invite(&address, &public, Some(name), None).expect("encode");
659            assert_eq!(
660                parse_invite(&blob).expect("parse").petname.as_deref(),
661                Some(name)
662            );
663        }
664    }
665
666    /// Golden vectors. These strings are the invite wire format: an
667    /// implementation in another language must produce them byte for byte.
668    ///
669    /// **Computed independently of this code**, by a Python script that builds
670    /// bech32m from the BIP-350 reference implementation, takes Ed25519 from
671    /// `cryptography`, and assembles the blob from the written format rather
672    /// than from this source. Agreement is therefore a two-implementation
673    /// cross-check and not a restatement of whatever the encoder happened to
674    /// emit. Regenerate them the same way, by changing the format on purpose —
675    /// never by pasting new code output.
676    ///
677    /// The identity is the all-`0x01` Ed25519 seed, so a second implementation
678    /// can reproduce every line without this repository.
679    #[test]
680    fn invite_golden_vectors() {
681        let (signing, public, address) = identity(1);
682        assert_eq!(
683            address.to_string(),
684            "off1qy682ruch4vlely5dkj94247jva7z49yk5xpqee0",
685            "the golden identity's address changed"
686        );
687
688        let unsigned = encode_invite(&address, &public, None, None).expect("encode");
689        assert_eq!(
690            unsigned,
691            "AQCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMA"
692        );
693
694        let named = encode_invite(&address, &public, Some("alice"), None).expect("encode");
695        assert_eq!(
696            named,
697            "AQGKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXCxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAVhbGljZQ"
698        );
699
700        let payload = invite_signing_payload(&address, &public, Some("alice")).expect("payload");
701        assert_eq!(
702            base64::engine::general_purpose::STANDARD.encode(&payload),
703            "b2ZmbGluZS1pbnZpdGUtdjEAAAABAQAAACxvZmYxcXk2ODJydWNoNHZsZWx5NWRrajk0MjQ3anZhN3o0OXlrNXhwcWVlMAAAACCKiOPddAnxlf1S2y08ul1yymcJvx2UEhvzdIgBtA9vXAAAAAVhbGljZQ=="
704        );
705
706        let signature = sign(&signing, &payload);
707        let signed =
708            encode_invite(&address, &public, Some("alice"), Some(&signature)).expect("encode");
709        assert!(parse_invite(&signed).expect("parse").signed);
710        assert_eq!(signed.len(), 199, "signed invite length changed");
711    }
712}