identity.rs (20527B)
1 //! Bounded public references for availability publishers, versions and listings. 2 //! 3 //! These references identify public data. Their construction proves no event 4 //! signature, installed account, signing custody, ownership or physical stock. 5 6 use radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING; 7 use radroots_event::id::{Nip01Coordinate, RADROOTS_NIP01_COORDINATE_MAX_BYTES}; 8 9 use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; 10 11 const EVENT_ID_HEX_BYTES: usize = 64; 12 const LISTING_COORDINATE_PREFIX: &str = "30402:"; 13 14 /// A validated public publisher identity, independent of installed accounts. 15 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 16 pub struct PublicPublisher(PublicKey); 17 18 impl PublicPublisher { 19 #[must_use] 20 pub const fn from_public_key(value: PublicKey) -> Self { 21 Self(value) 22 } 23 24 /// Parses a canonical lowercase hexadecimal public author. 25 /// 26 /// # Errors 27 /// 28 /// Returns a safe invalid-public-key error for malformed or invalid keys. 29 pub fn from_hex(value: &str) -> Result<Self, SafeError> { 30 PublicKey::from_hex(value).map(Self) 31 } 32 33 /// Validates an x-only secp256k1 public author using shared identity policy. 34 /// 35 /// # Errors 36 /// 37 /// Returns a safe invalid-public-key error for an invalid curve point. 38 pub fn from_bytes(value: [u8; 32]) -> Result<Self, SafeError> { 39 PublicKey::from_bytes(value).map(Self) 40 } 41 42 #[must_use] 43 pub const fn public_key(self) -> PublicKey { 44 self.0 45 } 46 } 47 48 /// An exact event-ID reference, separate from a logical listing coordinate. 49 /// 50 /// Possessing an ID does not establish the referenced event's signature or its 51 /// relationship to any listing. Those facts require shared verified evidence. 52 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 53 pub struct AvailabilityEventVersion(radroots_event::EventId); 54 55 impl AvailabilityEventVersion { 56 #[must_use] 57 pub const fn from_canonical(value: radroots_event::EventId) -> Self { 58 Self(value) 59 } 60 61 /// Parses exactly 64 canonical lowercase hexadecimal event-ID bytes. 62 /// 63 /// # Errors 64 /// 65 /// Returns a safe public-reference error for malformed or ambiguous input. 66 pub fn from_hex(value: &str) -> Result<Self, SafeError> { 67 if value.len() != EVENT_ID_HEX_BYTES 68 || !value 69 .bytes() 70 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 71 { 72 return Err(invalid_reference()); 73 } 74 radroots_event::EventId::parse(value) 75 .map(Self) 76 .map_err(|_| invalid_reference()) 77 } 78 79 #[must_use] 80 pub const fn event_id(self) -> radroots_event::EventId { 81 self.0 82 } 83 } 84 85 /// A nonempty kind-30402 logical listing reference with shared coordinate bounds. 86 /// 87 /// The identifier remains opaque, including colons, whitespace, controls and 88 /// Unicode. This reference does not apply strict authored FoodIdentifier policy 89 /// and must not replace broader raw inbound or event-head admission. Empty or 90 /// longer raw head identifiers remain evidence at their shared owner boundary. 91 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] 92 pub struct AvailabilityListingCoordinate(Nip01Coordinate); 93 94 impl AvailabilityListingCoordinate { 95 /// Parses a canonical listing reference without normalizing its identifier. 96 /// 97 /// App bounds, framing and author validation precede shared owned copies. 98 /// Shared parsing owns coordinate interpretation and canonical construction. 99 /// 100 /// # Errors 101 /// 102 /// Returns a safe public-reference error for invalid, noncanonical, empty or 103 /// oversized listing references. 104 pub fn parse(value: &str) -> Result<Self, SafeError> { 105 if value.len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES { 106 return Err(invalid_reference()); 107 } 108 let remainder = value 109 .strip_prefix(LISTING_COORDINATE_PREFIX) 110 .ok_or_else(invalid_reference)?; 111 let (author, identifier) = remainder.split_once(':').ok_or_else(invalid_reference)?; 112 if identifier.is_empty() { 113 return Err(invalid_reference()); 114 } 115 PublicKey::from_hex(author).map_err(|_| invalid_reference())?; 116 let coordinate = Nip01Coordinate::parse(value).map_err(|_| invalid_reference())?; 117 Self::from_canonical(coordinate) 118 } 119 120 /// Moves a shared coordinate after checking the app's listing invariants. 121 /// 122 /// # Errors 123 /// 124 /// Returns a safe public-reference error for another kind, an empty 125 /// identifier or a coordinate exceeding the selected shared byte bound. 126 pub fn from_canonical(value: Nip01Coordinate) -> Result<Self, SafeError> { 127 if value.kind() != KIND_CLASSIFIED_LISTING 128 || value.identifier().is_empty() 129 || value.as_str().len() > RADROOTS_NIP01_COORDINATE_MAX_BYTES 130 { 131 return Err(invalid_reference()); 132 } 133 Ok(Self(value)) 134 } 135 136 #[must_use] 137 pub const fn canonical(&self) -> &Nip01Coordinate { 138 &self.0 139 } 140 141 #[must_use] 142 pub fn as_str(&self) -> &str { 143 self.0.as_str() 144 } 145 146 #[must_use] 147 pub fn publisher(&self) -> PublicPublisher { 148 PublicPublisher::from_public_key(PublicKey::from_canonical(*self.0.pubkey())) 149 } 150 151 #[must_use] 152 pub const fn kind(&self) -> u32 { 153 self.0.kind() 154 } 155 156 #[must_use] 157 pub fn identifier(&self) -> &str { 158 self.0.identifier() 159 } 160 } 161 162 const fn invalid_reference() -> SafeError { 163 SafeError::new( 164 SafeErrorCode::InvalidProfileMetadata, 165 SafeMessage::new("The public availability reference is invalid."), 166 ) 167 } 168 169 #[cfg(test)] 170 mod tests { 171 use std::collections::HashSet; 172 use std::error::Error; 173 174 use radroots_event::food::availability::FoodIdentifier; 175 use radroots_event::id::{Nip01Coordinate, RADROOTS_NIP01_COORDINATE_MAX_BYTES}; 176 177 use super::{AvailabilityEventVersion, AvailabilityListingCoordinate, PublicPublisher}; 178 use crate::{PublicKey, SafeError, SafeErrorCode}; 179 180 const AUTHOR_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; 181 182 fn author() -> PublicKey { 183 PublicKey::from_hex(AUTHOR_HEX).expect("valid public author") 184 } 185 186 fn coordinate_text(identifier: &str) -> String { 187 format!("30402:{AUTHOR_HEX}:{identifier}") 188 } 189 190 fn coordinate(identifier: &str) -> AvailabilityListingCoordinate { 191 AvailabilityListingCoordinate::parse(&coordinate_text(identifier)) 192 .expect("valid listing reference") 193 } 194 195 fn identifier_at_byte_limit(unit: &str, maximum: usize) -> String { 196 unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len()) 197 } 198 199 fn assert_invalid_reference(error: SafeError) { 200 assert_eq!(error.code(), SafeErrorCode::InvalidProfileMetadata); 201 } 202 203 #[test] 204 fn public_publisher_uses_shared_identity_without_installed_account() { 205 let shared = 206 radroots_identity::PublicKey::from_hex(AUTHOR_HEX).expect("valid shared public author"); 207 let publisher = PublicPublisher::from_public_key(PublicKey::from_canonical(shared)); 208 209 assert_eq!(publisher.public_key().canonical(), shared); 210 assert_eq!( 211 PublicPublisher::from_hex(AUTHOR_HEX).expect("public author"), 212 publisher 213 ); 214 assert_eq!( 215 PublicPublisher::from_bytes(*shared.as_bytes()).expect("public author bytes"), 216 publisher 217 ); 218 assert_eq!(HashSet::from([publisher, publisher]).len(), 1); 219 } 220 221 #[test] 222 fn public_publisher_rejects_invalid_curve_and_noncanonical_author() { 223 let invalid_curve = "ff".repeat(32); 224 let uppercase = AUTHOR_HEX.to_ascii_uppercase(); 225 let short = &AUTHOR_HEX[..63]; 226 let padded = format!(" {AUTHOR_HEX}"); 227 let invalid_character = "g".repeat(64); 228 229 for invalid in [ 230 "", 231 invalid_curve.as_str(), 232 uppercase.as_str(), 233 short, 234 padded.as_str(), 235 invalid_character.as_str(), 236 "npub1invalid", 237 ] { 238 assert_eq!( 239 PublicPublisher::from_hex(invalid) 240 .expect_err("invalid public author") 241 .code(), 242 SafeErrorCode::InvalidPublicKey 243 ); 244 } 245 assert_eq!( 246 PublicPublisher::from_bytes([u8::MAX; 32]) 247 .expect_err("invalid x-only curve point") 248 .code(), 249 SafeErrorCode::InvalidPublicKey 250 ); 251 } 252 253 #[test] 254 fn event_version_retains_exact_shared_event_id() { 255 let shared = radroots_event::EventId::from_bytes([0xab; 32]); 256 let version = AvailabilityEventVersion::from_canonical(shared); 257 258 assert_eq!(version.event_id(), shared); 259 assert_eq!(version.event_id().as_bytes(), &[0xab; 32]); 260 assert_eq!( 261 AvailabilityEventVersion::from_hex(&"ab".repeat(32)).expect("exact event ID"), 262 version 263 ); 264 assert_eq!(HashSet::from([version, version]).len(), 1); 265 assert_ne!( 266 version, 267 AvailabilityEventVersion::from_canonical(radroots_event::EventId::from_bytes( 268 [0xcd; 32] 269 )) 270 ); 271 } 272 273 #[test] 274 fn event_version_rejects_malformed_and_ambiguous_hex() { 275 let uppercase = "AB".repeat(32); 276 let short = "a".repeat(63); 277 let long = "a".repeat(65); 278 let invalid_character = "g".repeat(64); 279 let leading_space = format!(" {}", "ab".repeat(32)); 280 let prefixed = format!("0x{}", "ab".repeat(32)); 281 let multibyte = "é".repeat(32); 282 283 for invalid in [ 284 "", 285 uppercase.as_str(), 286 short.as_str(), 287 long.as_str(), 288 invalid_character.as_str(), 289 leading_space.as_str(), 290 prefixed.as_str(), 291 multibyte.as_str(), 292 ] { 293 assert_invalid_reference( 294 AvailabilityEventVersion::from_hex(invalid).expect_err("invalid exact event ID"), 295 ); 296 } 297 } 298 299 #[test] 300 fn listing_coordinate_delegates_to_shared_canonical_coordinate() { 301 let text = coordinate_text("farm:lot-1"); 302 let shared = Nip01Coordinate::parse(&text).expect("shared coordinate"); 303 let reference = AvailabilityListingCoordinate::parse(&text).expect("listing coordinate"); 304 305 assert_eq!(reference.canonical(), &shared); 306 assert_eq!(reference.as_str(), shared.as_str()); 307 assert_eq!(reference.kind(), 30_402); 308 assert_eq!(reference.publisher().public_key(), author()); 309 assert_eq!(reference.identifier(), "farm:lot-1"); 310 assert_eq!( 311 AvailabilityListingCoordinate::from_canonical(shared).expect("typed coordinate"), 312 reference 313 ); 314 } 315 316 #[test] 317 fn listing_coordinate_rejects_wrong_kind_and_empty_identifier() { 318 for kind in [0, 3, 5, 10_000, 30_000, 30_401, 30_403, 39_999, 40_000] { 319 assert_invalid_reference( 320 AvailabilityListingCoordinate::parse(&format!("{kind}:{AUTHOR_HEX}:lot")) 321 .expect_err("wrong listing kind"), 322 ); 323 } 324 for invalid in [ 325 String::new(), 326 coordinate_text(""), 327 format!("30402:{AUTHOR_HEX}"), 328 "30402:bad:lot".to_owned(), 329 format!("30402:{}:lot", "ff".repeat(32)), 330 ] { 331 assert_invalid_reference( 332 AvailabilityListingCoordinate::parse(&invalid) 333 .expect_err("invalid listing coordinate"), 334 ); 335 } 336 assert!( 337 Nip01Coordinate::parse(coordinate_text("")).is_ok(), 338 "raw shared coordinate admission remains broader than this listing reference" 339 ); 340 } 341 342 #[test] 343 fn listing_coordinate_rejects_noncanonical_kind_and_author_aliases() { 344 for kind in ["+30402", "030402", "+030402", " 30402", "30402 "] { 345 assert_invalid_reference( 346 AvailabilityListingCoordinate::parse(&format!("{kind}:{AUTHOR_HEX}:lot")) 347 .expect_err("noncanonical kind encoding"), 348 ); 349 } 350 for invalid in [ 351 format!("30402:{}:lot", AUTHOR_HEX.to_ascii_uppercase()), 352 format!("30402: {AUTHOR_HEX}:lot"), 353 format!("30402:{AUTHOR_HEX} :lot"), 354 format!("\u{2003}30402:{AUTHOR_HEX}:lot"), 355 ] { 356 assert_invalid_reference( 357 AvailabilityListingCoordinate::parse(&invalid) 358 .expect_err("noncanonical author or coordinate framing"), 359 ); 360 } 361 } 362 363 #[test] 364 fn listing_coordinate_preserves_opaque_identifier_after_second_colon() { 365 for identifier in [ 366 ":", 367 "::", 368 "farm:lot:2026", 369 " victoria:\0seed:\u{2603}\n", 370 "a\u{200d}b", 371 "e\u{301}", 372 "a\r\tb", 373 " trailing ", 374 ] { 375 let reference = coordinate(identifier); 376 let shared = Nip01Coordinate::parse(coordinate_text(identifier)) 377 .expect("shared opaque coordinate"); 378 379 assert_eq!(reference.identifier().as_bytes(), identifier.as_bytes()); 380 assert_eq!( 381 reference.as_str().as_bytes(), 382 coordinate_text(identifier).as_bytes() 383 ); 384 assert_eq!(reference.canonical(), &shared); 385 } 386 } 387 388 #[test] 389 fn listing_reference_identifier_policy_is_distinct_from_authored_food_identifier() { 390 let oversized_authored = "x".repeat(513); 391 for identifier in [oversized_authored.as_str(), " farm ", "a\0b", "a\u{200d}b"] { 392 assert_eq!(coordinate(identifier).identifier(), identifier); 393 assert!( 394 FoodIdentifier::parse(identifier).is_err(), 395 "opaque public references cannot inherit strict authored identifier policy" 396 ); 397 } 398 let strict = FoodIdentifier::parse("farm:lot-1").expect("strict colon identifier"); 399 assert_eq!(coordinate(strict.as_str()).identifier(), strict.as_str()); 400 } 401 402 #[test] 403 fn listing_coordinate_accepts_exact_ascii_byte_limit_and_rejects_overflow() { 404 assert_eq!(RADROOTS_NIP01_COORDINATE_MAX_BYTES, 4_096); 405 let prefix = coordinate_text(""); 406 assert_eq!(prefix.len(), 71); 407 let identifier = "x".repeat(RADROOTS_NIP01_COORDINATE_MAX_BYTES - prefix.len()); 408 let exact = coordinate_text(&identifier); 409 410 assert_eq!(identifier.len(), 4_025); 411 assert_eq!(exact.len(), RADROOTS_NIP01_COORDINATE_MAX_BYTES); 412 assert_eq!( 413 AvailabilityListingCoordinate::parse(&exact) 414 .expect("exact full coordinate limit") 415 .identifier(), 416 identifier 417 ); 418 assert_invalid_reference( 419 AvailabilityListingCoordinate::parse(&(exact + "x")) 420 .expect_err("one byte above full coordinate limit"), 421 ); 422 } 423 424 #[test] 425 fn listing_coordinate_enforces_multibyte_and_combining_byte_limits() { 426 let maximum = RADROOTS_NIP01_COORDINATE_MAX_BYTES - coordinate_text("").len(); 427 for unit in ["é", "🥕", "e\u{301}"] { 428 let identifier = identifier_at_byte_limit(unit, maximum); 429 let exact = coordinate_text(&identifier); 430 431 assert_eq!(identifier.len(), maximum); 432 assert_eq!(exact.len(), RADROOTS_NIP01_COORDINATE_MAX_BYTES); 433 assert_eq!( 434 AvailabilityListingCoordinate::parse(&exact) 435 .expect("exact UTF-8 coordinate limit") 436 .identifier() 437 .as_bytes(), 438 identifier.as_bytes() 439 ); 440 assert_invalid_reference( 441 AvailabilityListingCoordinate::parse(&(exact + "x")) 442 .expect_err("one UTF-8 byte above coordinate limit"), 443 ); 444 } 445 assert_ne!(coordinate("é"), coordinate("e\u{301}")); 446 } 447 448 #[test] 449 fn typed_coordinate_constructor_cannot_bypass_listing_invariants() { 450 for text in [ 451 format!("30000:{AUTHOR_HEX}:lot"), 452 format!("0:{AUTHOR_HEX}:"), 453 coordinate_text(""), 454 ] { 455 let shared = Nip01Coordinate::parse(text).expect("valid broader shared coordinate"); 456 assert_invalid_reference( 457 AvailabilityListingCoordinate::from_canonical(shared) 458 .expect_err("shared coordinate is not a nonempty listing reference"), 459 ); 460 } 461 let canonicalized = Nip01Coordinate::parse(format!( 462 "030402:{}:farm:lot", 463 AUTHOR_HEX.to_ascii_uppercase() 464 )) 465 .expect("shared canonicalization"); 466 let reference = AvailabilityListingCoordinate::from_canonical(canonicalized) 467 .expect("already canonical shared listing reference"); 468 assert_eq!(reference.as_str(), coordinate_text("farm:lot")); 469 } 470 471 #[test] 472 fn event_versions_and_listing_coordinates_preserve_distinct_identity() { 473 let first_id = radroots_event::EventId::from_bytes([0xab; 32]); 474 let first = AvailabilityEventVersion::from_canonical(first_id); 475 let repeated = AvailabilityEventVersion::from_hex(&first_id.to_hex()) 476 .expect("repeated exact event version"); 477 let revision = AvailabilityEventVersion::from_canonical( 478 radroots_event::EventId::from_bytes([0xcd; 32]), 479 ); 480 let listing = coordinate("lot-1"); 481 let same_listing = AvailabilityListingCoordinate::from_canonical( 482 Nip01Coordinate::parse(coordinate_text("lot-1")).expect("same shared coordinate"), 483 ) 484 .expect("same listing coordinate"); 485 let different_identifier = coordinate("lot-2"); 486 let other_author = PublicKey::from_bytes([7; 32]).expect("another public author"); 487 let different_publisher = 488 AvailabilityListingCoordinate::parse(&format!("30402:{}:lot-1", other_author.to_hex())) 489 .expect("distinct publisher coordinate"); 490 491 assert_eq!(first, repeated); 492 assert_ne!(first, revision); 493 assert_eq!(listing, same_listing); 494 assert_ne!(listing, different_identifier); 495 assert_ne!(listing, different_publisher); 496 assert_eq!(HashSet::from([first, repeated, revision]).len(), 2); 497 assert_eq!( 498 HashSet::from([ 499 listing, 500 same_listing, 501 different_identifier, 502 different_publisher 503 ]) 504 .len(), 505 3 506 ); 507 } 508 509 #[test] 510 fn availability_reference_errors_contain_only_static_safe_messages() { 511 const INPUT_MARKER: &str = "raw_availability_input_diagnostic_marker"; 512 let publisher_error = 513 PublicPublisher::from_hex(INPUT_MARKER).expect_err("malformed public author"); 514 let version_error = 515 AvailabilityEventVersion::from_hex(INPUT_MARKER).expect_err("malformed exact event ID"); 516 let coordinate_error = 517 AvailabilityListingCoordinate::parse(&format!("30403:{AUTHOR_HEX}:{INPUT_MARKER}")) 518 .expect_err("wrong kind with opaque input marker"); 519 520 assert_eq!(publisher_error.code(), SafeErrorCode::InvalidPublicKey); 521 assert_invalid_reference(version_error); 522 assert_invalid_reference(coordinate_error); 523 assert_eq!( 524 publisher_error.message(), 525 PublicPublisher::from_hex("") 526 .expect_err("empty public author") 527 .message() 528 ); 529 assert_eq!( 530 version_error.message(), 531 AvailabilityEventVersion::from_hex("") 532 .expect_err("empty event ID") 533 .message() 534 ); 535 assert_eq!( 536 coordinate_error.message(), 537 AvailabilityListingCoordinate::parse("") 538 .expect_err("empty coordinate") 539 .message() 540 ); 541 for error in [publisher_error, version_error, coordinate_error] { 542 assert!(!error.message().as_str().contains(INPUT_MARKER)); 543 assert!(!error.to_string().contains(INPUT_MARKER)); 544 assert!(!format!("{error:?}").contains(INPUT_MARKER)); 545 assert!(error.source().is_none()); 546 } 547 } 548 }