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