Skip to main content

offline_protocol_core/
username.rs

1//! Normalized username claims.
2//!
3//! A username is not an identity. It is a *label* a device claims in a
4//! non-authoritative directory, and the identity it points at is the
5//! [`Address`](crate::Address) inside the signed record. Nothing in the
6//! protocol authenticates a username: anyone may claim any name, and the
7//! resolver's job is to hand back every claimant rather than to pick one. See
8//! `docs/spec/username-discovery.md`.
9//!
10//! # Why this is a type and not a `String`
11//!
12//! A discovery tag is a hash of the normalized name. Two implementations that
13//! normalize differently derive different tags and **silently fail to find each
14//! other** — there is no error, no mismatch to observe, just an empty result
15//! for a name that exists. That is the whole failure mode this type exists to
16//! remove: normalization happens once, at parse, and the derivations downstream
17//! take [`Username`] rather than `&str`, so "derive a tag for whatever the app
18//! typed" does not compile.
19//!
20//! It is the same move [`Address`](crate::Address) makes for routing tags, and
21//! for the same reason.
22
23use alloc::string::String;
24use core::fmt;
25use core::str::FromStr;
26
27use serde::{Deserialize, Serialize};
28use unicode_normalization::UnicodeNormalization;
29
30/// Why a username string was rejected by [`Username::from_str`].
31// Adding a variant to a public error enum is a breaking change without this
32// attribute; downstream crates must carry a wildcard arm.
33#[non_exhaustive]
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum UsernameError {
36    /// The username is empty, or is nothing but whitespace.
37    ///
38    /// Whitespace-only is refused here rather than treated as a name: a claim
39    /// nobody can type back, and that renders as nothing at all in every UI,
40    /// is not a name. Note that normalization does not remove it — NFC
41    /// discards nothing — so this is a screen, not a consequence of one.
42    #[error("username is empty or whitespace after normalization")]
43    Empty,
44    /// The normalized username exceeds [`Username::MAX_BYTES`].
45    ///
46    /// Measured after normalization, since that is the form that gets
47    /// published, hashed and signed.
48    #[error("username is {len} bytes after normalization, maximum is {max}")]
49    TooLong {
50        /// Normalized length in bytes.
51        len: usize,
52        /// The ceiling.
53        max: usize,
54    },
55    /// The username has the shape of an [`Address`](crate::Address).
56    ///
57    /// Refused at the type rather than at publish time so no caller can skip
58    /// the screen. Two namespaces that never share a spelling cannot be
59    /// confused by a UI that renders both, and a claim on an address-shaped
60    /// name has no legitimate use.
61    #[error("username has the shape of an address, which is not claimable")]
62    AddressShaped,
63    /// The username contains a control or format character.
64    ///
65    /// A name that can contain a newline or a bidi override is a name that
66    /// renders as something other than itself in every UI that displays it.
67    ///
68    /// Both Unicode `Cc` (control) and `Cf` (format) are refused, and the
69    /// second is the one that matters: `char::is_control` covers only `Cc`, so
70    /// a screen built on it alone lets through U+202E RIGHT-TO-LEFT OVERRIDE
71    /// and the zero-width joiners — exactly the characters that make a name
72    /// display as something other than its own bytes. Confusables between
73    /// *scripts* remain out of scope (see the module docs); characters whose
74    /// entire function is to alter rendering are not.
75    #[error("username contains a control or format character")]
76    ControlCharacter,
77}
78
79/// Whether `c` is a Unicode format (`Cf`) character.
80///
81/// [`char::is_control`] tests `Cc` only, which misses every character whose
82/// entire function is to change how the text around it renders: the bidi
83/// overrides, the zero-width joiners, the word joiner, the byte-order mark. A
84/// name containing one displays as something other than its own bytes, which is
85/// the failure [`UsernameError::ControlCharacter`] exists to prevent, so `Cf`
86/// has to be screened alongside `Cc`.
87///
88/// Hand-rolled rather than taken from a general-category crate on purpose. The
89/// alternative is a second Unicode table in the dependency graph, and this
90/// crate is already carrying `unicode-normalization` into a workspace with a
91/// binary-size profile (`minisize`) that cares. `Cf` is 21 ranges and it grows
92/// by a handful per Unicode release.
93///
94/// Snapshot of `Cf` as of **Unicode 16.0**, generated from the character
95/// database rather than transcribed. Regenerate it the same way; a range
96/// missing here is a name that renders as something else, not a crash.
97/// `username_rejects_every_format_character_range` pins one member of every
98/// range so a bad edit fails rather than silently narrowing the screen.
99fn is_format_character(c: char) -> bool {
100    matches!(c,
101        '\u{00AD}'                      // SOFT HYPHEN
102        | '\u{0600}'..='\u{0605}'
103        | '\u{061C}'                    // ARABIC LETTER MARK
104        | '\u{06DD}'                    // ARABIC END OF AYAH
105        | '\u{070F}'                    // SYRIAC ABBREVIATION MARK
106        | '\u{0890}'..='\u{0891}'
107        | '\u{08E2}'                    // ARABIC DISPUTED END OF AYAH
108        | '\u{180E}'                    // MONGOLIAN VOWEL SEPARATOR
109        | '\u{200B}'..='\u{200F}'       // ZWSP, ZWNJ, ZWJ, LRM, RLM
110        | '\u{202A}'..='\u{202E}'       // bidi embedding and overrides
111        | '\u{2060}'..='\u{2064}'
112        | '\u{2066}'..='\u{206F}'       // bidi isolates and deprecated formats
113        | '\u{FEFF}'                    // ZERO WIDTH NO-BREAK SPACE
114        | '\u{FFF9}'..='\u{FFFB}'
115        | '\u{110BD}'
116        | '\u{110CD}'
117        | '\u{13430}'..='\u{1343F}'
118        | '\u{1BCA0}'..='\u{1BCA3}'
119        | '\u{1D173}'..='\u{1D17A}'
120        | '\u{E0001}'                   // LANGUAGE TAG
121        | '\u{E0020}'..='\u{E007F}'     // tag characters
122    )
123}
124
125/// Whether `s` contains a character that renders as something other than
126/// itself: Unicode `Cc` (control) or `Cf` (format).
127///
128/// Public because a username is not the only display string this protocol
129/// signs. An invite's petname is the other one, and it is the *more* exposed of
130/// the two: it is what an app renders in the confirmation dialog after a scan,
131/// and when the invite is signed the deceptive rendering arrives carrying a
132/// valid signature. One screen serves both, so the two cannot drift into
133/// disagreeing about what a displayable name is.
134///
135/// `Cf` is the half that matters and the half [`char::is_control`] misses; see
136/// the `is_format_character` table below it.
137pub fn contains_control_or_format(s: &str) -> bool {
138    s.chars().any(|c| c.is_control() || is_format_character(c))
139}
140
141/// A normalized username claim: NFC, lowercase, bounded, never address-shaped.
142///
143/// Construct with [`Username::from_str`]. The stored form **is** the normalized
144/// form, so [`Username::as_str`] is what gets hashed into a discovery tag,
145/// signed into a record, and compared against a queried name.
146///
147/// # Ordering
148///
149/// [`Ord`] compares the normalized bytes. Nothing in the protocol breaks a tie
150/// on usernames — a resolution returns an unordered set of claims and the
151/// *user* arbitrates — so this exists for deterministic test fixtures and
152/// `BTreeMap` keys, not for consensus.
153#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
154pub struct Username {
155    normalized: String,
156}
157
158impl Username {
159    /// Maximum length of the normalized form, in bytes.
160    ///
161    /// Bytes rather than characters because the bound exists to cap what goes
162    /// on the wire and through a hash, and a character count says nothing
163    /// about either. Matches the design record's `≤ 64 bytes`, which is far
164    /// above every username policy this SDK's apps enforce (user-service caps
165    /// at 20 characters) — the ceiling is a wire bound, not a policy.
166    pub const MAX_BYTES: usize = 64;
167
168    /// Length of the canonical string form of an address.
169    ///
170    /// Duplicated from `Address::ENCODED_LEN` rather than imported so this
171    /// module stays a leaf: the screen below is a *shape* test that must reject
172    /// address-looking strings whose checksum is wrong, so it deliberately does
173    /// not parse. `username_address_shape_matches_real_addresses` pins the two
174    /// against each other.
175    const ADDRESS_LEN: usize = 44;
176
177    /// Human-readable prefix of an address, including the bech32 separator.
178    const ADDRESS_PREFIX: &'static str = "off1";
179
180    /// Normalizes and validates a username.
181    ///
182    /// Normalization is **lowercase then NFC**, in that order. The order is
183    /// load-bearing: Unicode lowercasing can emit a decomposed sequence, so
184    /// normalizing first would leave a form that is not NFC. Running NFC last
185    /// makes the operation idempotent, which
186    /// [`username_normalization_is_idempotent`] pins — a non-idempotent
187    /// normalizer would derive one tag on publish and another on resolve.
188    ///
189    /// The lowercase step is [`str::to_lowercase`], which is the **full**,
190    /// language-insensitive Unicode mapping. That choice is part of the wire
191    /// format, not an implementation detail: the full and simple mappings
192    /// disagree wherever one character lowercases to several (`İ` becomes
193    /// `i` + U+0307 under full, a bare `i` under simple), so a second
194    /// implementation that picks the other one derives a different tag and the
195    /// two silently never find each other. Language-insensitive matters for
196    /// the same reason — the Turkish tailoring maps `I` to `ı`, which would
197    /// make a name's tag depend on its publisher's locale.
198    /// [`username_lowercases_with_the_full_language_insensitive_mapping`] pins
199    /// both halves.
200    fn normalize(raw: &str) -> String {
201        raw.to_lowercase().nfc().collect()
202    }
203
204    /// Whether `candidate` has the shape of an address.
205    ///
206    /// A shape test, not a parse: an address with a corrupted checksum is not
207    /// a valid address but is still an address-*shaped* string, and claiming it
208    /// as a username is exactly as confusing in a UI as claiming a valid one.
209    fn looks_like_address(candidate: &str) -> bool {
210        candidate.len() == Self::ADDRESS_LEN
211            && candidate.starts_with(Self::ADDRESS_PREFIX)
212            && candidate
213                .bytes()
214                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
215    }
216
217    /// Returns the normalized username.
218    ///
219    /// This is the form that is hashed, signed and published. A caller that
220    /// wants the string the user typed must keep it themselves; the protocol
221    /// only ever sees this one.
222    pub fn as_str(&self) -> &str {
223        &self.normalized
224    }
225
226    /// Consumes the username, returning the normalized string.
227    pub fn into_string(self) -> String {
228        self.normalized
229    }
230}
231
232impl FromStr for Username {
233    type Err = UsernameError;
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        let normalized = Self::normalize(s);
237
238        // Whitespace-only is refused with the empty case rather than accepted:
239        // NFC discards nothing, so a name of three spaces survives
240        // normalization intact and would otherwise become a claim that renders
241        // as nothing and cannot be typed back.
242        if normalized.is_empty() || normalized.chars().all(char::is_whitespace) {
243            return Err(UsernameError::Empty);
244        }
245        if normalized.len() > Self::MAX_BYTES {
246            return Err(UsernameError::TooLong {
247                len: normalized.len(),
248                max: Self::MAX_BYTES,
249            });
250        }
251        // Checked on the normalized form: a name that only becomes a control
252        // character after normalization would otherwise slip the screen.
253        if contains_control_or_format(&normalized) {
254            return Err(UsernameError::ControlCharacter);
255        }
256        if Self::looks_like_address(&normalized) {
257            return Err(UsernameError::AddressShaped);
258        }
259
260        Ok(Self { normalized })
261    }
262}
263
264impl fmt::Display for Username {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.write_str(&self.normalized)
267    }
268}
269
270impl Serialize for Username {
271    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272        serializer.serialize_str(&self.normalized)
273    }
274}
275
276impl<'de> Deserialize<'de> for Username {
277    /// Re-validates on the way in.
278    ///
279    /// A username arriving in a deserialized record is wire input: it has been
280    /// normalized by *somebody*, and that somebody may be an attacker who
281    /// normalized it differently on purpose. Parsing rather than accepting
282    /// means a record whose username is not in canonical form fails to
283    /// deserialize, instead of being compared against a queried name it can
284    /// never equal.
285    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
286        let s = String::deserialize(deserializer)?;
287        let parsed: Self = s.parse().map_err(serde::de::Error::custom)?;
288        // Reject a name that *normalizes* to something else rather than
289        // silently repairing it. Accepting `Alice` here would make the record
290        // verify against a tag it was never published at.
291        if parsed.as_str() != s {
292            return Err(serde::de::Error::custom(
293                "username is not in normalized form",
294            ));
295        }
296        Ok(parsed)
297    }
298}
299
300#[cfg(all(test, feature = "std"))]
301mod tests {
302    use super::*;
303
304    fn parse(s: &str) -> Username {
305        s.parse().expect("username should parse")
306    }
307
308    #[test]
309    fn username_lowercases_ascii() {
310        assert_eq!(parse("Alice").as_str(), "alice");
311        assert_eq!(parse("ALICE").as_str(), "alice");
312    }
313
314    /// The case mapping is part of the wire format, so it is pinned here.
315    ///
316    /// `İ` is the character the full and simple mappings disagree on: full
317    /// gives `i` + U+0307, simple gives a bare `i`. An implementation that
318    /// chose simple would derive a different discovery tag for the same name
319    /// and silently never find the other's records — no error, just an empty
320    /// result. The ASCII golden vectors elsewhere cannot catch that, because
321    /// the two mappings agree on ASCII.
322    #[test]
323    fn username_lowercases_with_the_full_language_insensitive_mapping() {
324        let parsed = parse("\u{0130}");
325        assert_eq!(
326            parsed.as_str(),
327            "i\u{0307}",
328            "the full mapping expands İ to i + COMBINING DOT ABOVE; a bare 'i' \
329             means the simple mapping was used and every tag for such a name \
330             will disagree with a conforming implementation"
331        );
332        assert_eq!(parsed.as_str().len(), 3, "i + U+0307 is 3 UTF-8 bytes");
333
334        // Language-insensitive: the Turkish tailoring would lowercase 'I' to
335        // 'ı' (U+0131), which would make a tag depend on the publisher's
336        // locale.
337        assert_eq!(parse("I").as_str(), "i");
338    }
339
340    #[test]
341    fn username_applies_nfc() {
342        // "é" as e + U+0301 COMBINING ACUTE ACCENT normalizes to U+00E9.
343        let decomposed = "cafe\u{0301}";
344        let composed = "caf\u{00e9}";
345        assert_ne!(decomposed, composed, "fixture must start decomposed");
346        assert_eq!(parse(decomposed).as_str(), composed);
347        assert_eq!(parse(decomposed), parse(composed));
348    }
349
350    /// The property the whole type exists for: publish and resolve must derive
351    /// the same tag. A normalizer that is not idempotent derives one on the way
352    /// out and another on the way back, and the failure is a silent empty
353    /// result rather than an error.
354    #[test]
355    fn username_normalization_is_idempotent() {
356        for raw in [
357            "Alice",
358            "cafe\u{0301}",
359            "\u{1e9e}", // LATIN CAPITAL LETTER SHARP S, lowercases to "ß"
360            "İ",        // LATIN CAPITAL LETTER I WITH DOT ABOVE, expands
361            "Džungla",   // titlecase digraph
362        ] {
363            let once = parse(raw);
364            let twice = parse(once.as_str());
365            assert_eq!(
366                once, twice,
367                "normalizing {:?} twice must equal normalizing it once",
368                raw
369            );
370        }
371    }
372
373    #[test]
374    fn username_rejects_empty_and_whitespace_only() {
375        assert_eq!("".parse::<Username>(), Err(UsernameError::Empty));
376        // Not a consequence of normalization: NFC discards none of these, so
377        // without the explicit screen each one is a claimable name that renders
378        // as nothing.
379        for blank in [" ", "   ", "\u{00A0}", "\u{3000}", " \u{2009}"] {
380            assert_eq!(
381                blank.parse::<Username>(),
382                Err(UsernameError::Empty),
383                "whitespace-only {:?} must not be a claimable name",
384                blank
385            );
386        }
387    }
388
389    #[test]
390    fn username_rejects_over_length() {
391        let long = "a".repeat(Username::MAX_BYTES + 1);
392        assert_eq!(
393            long.parse::<Username>(),
394            Err(UsernameError::TooLong {
395                len: Username::MAX_BYTES + 1,
396                max: Username::MAX_BYTES,
397            })
398        );
399        let at_limit = "a".repeat(Username::MAX_BYTES);
400        assert!(at_limit.parse::<Username>().is_ok());
401    }
402
403    /// The bound is on the *normalized* form, so a name that grows past the
404    /// ceiling only once normalized must still be refused.
405    #[test]
406    fn username_length_is_measured_after_normalization() {
407        // Each "ẞ" lowercases to "ß" (2 bytes in UTF-8).
408        let raw = "\u{1e9e}".repeat(Username::MAX_BYTES / 2 + 1);
409        assert!(matches!(
410            raw.parse::<Username>(),
411            Err(UsernameError::TooLong { .. })
412        ));
413    }
414
415    #[test]
416    fn username_rejects_control_characters() {
417        assert_eq!(
418            "ali\nce".parse::<Username>(),
419            Err(UsernameError::ControlCharacter)
420        );
421    }
422
423    /// The characters that make a name render as something other than itself.
424    ///
425    /// These are `Cf`, not `Cc`, so [`char::is_control`] does not see any of
426    /// them: a screen built on it alone accepts a claim carrying a
427    /// right-to-left override, which a UI renders as a different name than the
428    /// bytes that were signed. That is the exact failure
429    /// [`UsernameError::ControlCharacter`] documents, and it went unscreened
430    /// until this test existed.
431    #[test]
432    fn username_rejects_format_characters_that_control_check_alone_misses() {
433        for (label, raw) in [
434            ("right-to-left override", "ali\u{202E}ce"),
435            ("zero-width joiner", "ali\u{200D}ce"),
436            ("zero-width non-joiner", "ali\u{200C}ce"),
437            ("zero-width space", "ali\u{200B}ce"),
438            ("left-to-right mark", "ali\u{200E}ce"),
439            ("soft hyphen", "ali\u{00AD}ce"),
440            ("word joiner", "ali\u{2060}ce"),
441            ("byte-order mark", "ali\u{FEFF}ce"),
442            ("tag character", "alice\u{E0041}"),
443        ] {
444            let parsed = raw.parse::<Username>();
445            assert!(
446                !raw.chars().any(char::is_control),
447                "{label} must not be Cc, or this test proves nothing new"
448            );
449            assert_eq!(
450                parsed,
451                Err(UsernameError::ControlCharacter),
452                "a username carrying a {label} must be refused"
453            );
454        }
455    }
456
457    /// Every range of the hand-rolled `Cf` table is live.
458    ///
459    /// The table is a snapshot maintained by hand, so the failure to guard
460    /// against is an edit that narrows a range and silently reopens the hole.
461    /// One member per range is enough to catch that; completeness against the
462    /// character database is a regeneration concern, not a runtime one.
463    #[test]
464    fn username_rejects_every_format_character_range() {
465        for c in [
466            '\u{00AD}',
467            '\u{0600}',
468            '\u{061C}',
469            '\u{06DD}',
470            '\u{070F}',
471            '\u{0890}',
472            '\u{08E2}',
473            '\u{180E}',
474            '\u{200B}',
475            '\u{202A}',
476            '\u{2060}',
477            '\u{2066}',
478            '\u{FEFF}',
479            '\u{FFF9}',
480            '\u{110BD}',
481            '\u{110CD}',
482            '\u{13430}',
483            '\u{1BCA0}',
484            '\u{1D173}',
485            '\u{E0001}',
486            '\u{E0020}',
487        ] {
488            assert!(
489                is_format_character(c),
490                "U+{:04X} must be screened as a format character",
491                c as u32
492            );
493            assert_eq!(
494                format!("ali{c}ce").parse::<Username>(),
495                Err(UsernameError::ControlCharacter),
496                "U+{:04X} must be refused in a username",
497                c as u32
498            );
499        }
500    }
501
502    /// The screen must not swallow ordinary international names. A rule that
503    /// rejects everything passes every negative test above and is useless.
504    #[test]
505    fn username_allows_ordinary_international_names() {
506        for raw in [
507            "alice",
508            "josé",
509            "мария",
510            "上田",
511            "أحمد",
512            "ali ce",
513            "a_b-c.d",
514        ] {
515            assert!(
516                raw.parse::<Username>().is_ok(),
517                "{raw:?} is a legitimate name and must parse"
518            );
519        }
520    }
521
522    /// D3's publish-time refusal, enforced at the type so no call site can
523    /// forget it.
524    #[test]
525    fn username_rejects_address_shaped_names() {
526        let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
527        assert_eq!(address.len(), Username::ADDRESS_LEN);
528        assert_eq!(
529            address.parse::<Username>(),
530            Err(UsernameError::AddressShaped)
531        );
532        // Uppercase input normalizes into the refused shape rather than
533        // slipping past a case-sensitive screen.
534        assert_eq!(
535            address.to_uppercase().parse::<Username>(),
536            Err(UsernameError::AddressShaped)
537        );
538    }
539
540    /// The screen is a shape test, so a *broken* address is refused too — an
541    /// address with a mangled checksum reads exactly as confusingly in a UI.
542    #[test]
543    fn username_rejects_address_shaped_names_with_bad_checksums() {
544        let mangled = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgqq";
545        assert_eq!(mangled.len(), Username::ADDRESS_LEN);
546        assert_eq!(
547            mangled.parse::<Username>(),
548            Err(UsernameError::AddressShaped)
549        );
550    }
551
552    /// Pins the duplicated constant against the real address format. If
553    /// `Address::ENCODED_LEN` ever changes, this fails rather than leaving the
554    /// screen quietly matching nothing.
555    #[test]
556    fn username_address_shape_matches_real_addresses() {
557        use crate::Address;
558        assert_eq!(Username::ADDRESS_LEN, Address::ENCODED_LEN);
559        let address = Address::from_hash_bytes([0u8; Address::HASH_LEN]).to_string();
560        assert!(
561            Username::looks_like_address(&address),
562            "a real address must be refused as a username: {}",
563            address
564        );
565    }
566
567    /// A name that merely starts with `off1` is fine — only the full address
568    /// shape is refused, so this does not quietly ban a namespace.
569    #[test]
570    fn username_allows_names_that_merely_start_with_the_address_prefix() {
571        assert_eq!(parse("off1ce").as_str(), "off1ce");
572    }
573
574    #[test]
575    fn username_round_trips_through_serde() {
576        let username = parse("alice");
577        let json = serde_json::to_string(&username).expect("serialize");
578        assert_eq!(json, "\"alice\"");
579        let back: Username = serde_json::from_str(&json).expect("deserialize");
580        assert_eq!(back, username);
581    }
582
583    /// Wire input is parsed, not accepted. A record naming `Alice` must fail to
584    /// deserialize rather than be repaired into `alice` — the repaired form
585    /// would verify against a tag the record was never published at.
586    #[test]
587    fn username_deserialization_refuses_unnormalized_input() {
588        for raw in ["\"Alice\"", "\"cafe\\u0301\""] {
589            assert!(
590                serde_json::from_str::<Username>(raw).is_err(),
591                "unnormalized {} must not deserialize",
592                raw
593            );
594        }
595    }
596}