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}