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}