mod.rs (14929B)
1 //! Exact authored event bodies, plans, and semantic digests. 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{borrow::ToOwned, string::String, vec::Vec}; 5 #[cfg(feature = "std")] 6 use std::{borrow::ToOwned, string::String, vec::Vec}; 7 8 use core::fmt; 9 use radroots_blossom::authorization::AuthoredUploadClaim; 10 use radroots_event::{ 11 GenericEventDraft, 12 contract::ContractKey, 13 draft::DraftError, 14 id::EventId, 15 wire::{CanonicalEventIdError, compute_canonical_nip01_event_id}, 16 }; 17 use radroots_identity::PublicKey; 18 use sha2::{Digest, Sha256}; 19 20 pub const PLAN_WIRE_VERSION_V1: u32 = 1; 21 const PLAN_DIGEST_DOMAIN: &[u8] = b"radroots.authored_event_plan.v1"; 22 const BLOSSOM_AUTHORIZATION_PLAN_DIGEST_DOMAIN: &[u8] = 23 b"radroots.blossom_upload_authorization_plan.v1"; 24 25 mod typed; 26 mod wire; 27 28 pub use typed::{AuthoredPlanError, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS}; 29 pub use wire::{ 30 HistoricalPlanIntegrity, PLAN_WIRE_MAX_BYTES, PlanDecodeError, PlanRegistryRelation, PlanWireV1, 31 }; 32 33 /// Exact contract-owned NIP-01 payload fields before author/time binding. 34 /// 35 /// Fields are private and there is deliberately no arbitrary-parts 36 /// constructor. Bodies enter through generic validation, strict typed 37 /// conversion, or bounded historical reconstruction. 38 /// 39 /// ```compile_fail 40 /// use radroots_event_codec::authoring::AuthoredEventBody; 41 /// 42 /// let _ = AuthoredEventBody::new("radroots.social.geochat.v1", 20_000, vec![], "raw"); 43 /// ``` 44 #[derive(Clone, Debug, PartialEq, Eq)] 45 pub struct AuthoredEventBody { 46 contract: ContractKey, 47 kind: u32, 48 tags: Vec<Vec<String>>, 49 content: String, 50 } 51 52 impl AuthoredEventBody { 53 fn from_generic(draft: &GenericEventDraft) -> Self { 54 Self { 55 contract: draft.contract().clone(), 56 kind: draft.kind_u32(), 57 tags: draft.tags_as_vec(), 58 content: draft.content().to_owned(), 59 } 60 } 61 62 #[must_use] 63 pub const fn contract(&self) -> &ContractKey { 64 &self.contract 65 } 66 67 #[must_use] 68 pub const fn kind(&self) -> u32 { 69 self.kind 70 } 71 72 #[must_use] 73 pub fn tags(&self) -> &[Vec<String>] { 74 &self.tags 75 } 76 77 #[must_use] 78 pub fn content(&self) -> &str { 79 &self.content 80 } 81 } 82 83 /// Stable SHA-256 commitment to an exact authored event plan. 84 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] 85 pub struct PlanDigest([u8; Self::BYTE_LENGTH]); 86 87 impl PlanDigest { 88 pub const BYTE_LENGTH: usize = 32; 89 90 #[must_use] 91 pub const fn from_bytes(bytes: [u8; Self::BYTE_LENGTH]) -> Self { 92 Self(bytes) 93 } 94 95 #[must_use] 96 pub const fn as_bytes(&self) -> &[u8; Self::BYTE_LENGTH] { 97 &self.0 98 } 99 100 #[must_use] 101 pub fn to_hex(self) -> String { 102 hex::encode(self.0) 103 } 104 105 pub fn parse_hex(value: &str) -> Result<Self, PlanDigestError> { 106 if value.len() != Self::BYTE_LENGTH * 2 107 || !value 108 .bytes() 109 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 110 { 111 return Err(PlanDigestError); 112 } 113 let mut bytes = [0_u8; Self::BYTE_LENGTH]; 114 hex::decode_to_slice(value, &mut bytes).map_err(|_| PlanDigestError)?; 115 Ok(Self(bytes)) 116 } 117 } 118 119 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 120 pub struct PlanDigestError; 121 122 impl fmt::Display for PlanDigestError { 123 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 124 formatter.write_str("plan digest must be exactly 64 lowercase hexadecimal characters") 125 } 126 } 127 128 #[cfg(feature = "std")] 129 impl std::error::Error for PlanDigestError {} 130 131 /// One immutable exact NIP-01 event plan, ready for authorization and signing. 132 /// 133 /// ```compile_fail 134 /// use radroots_event_codec::authoring::AuthoredEventPlan; 135 /// 136 /// let _ = AuthoredEventPlan::new(); 137 /// ``` 138 #[derive(Clone, Debug, PartialEq, Eq)] 139 pub struct AuthoredEventPlan { 140 body: AuthoredEventBody, 141 author: PublicKey, 142 created_at: u64, 143 expected_event_id: EventId, 144 digest: PlanDigest, 145 } 146 147 /// Exact BUD-11 upload-authorization event plan for HTTP use only. 148 /// 149 /// This type is deliberately distinct from [`AuthoredEventPlan`]. It cannot be 150 /// accepted by relay push APIs and therefore cannot accidentally publish a 151 /// short-lived Blossom authorization token as a Nostr event. 152 #[derive(Clone, Debug, PartialEq, Eq)] 153 pub struct BlossomAuthorizationPlan { 154 author: PublicKey, 155 created_at: u64, 156 kind: u32, 157 tags: Vec<Vec<String>>, 158 content: String, 159 expected_event_id: EventId, 160 digest: PlanDigest, 161 } 162 163 impl BlossomAuthorizationPlan { 164 /// Binds one validated upload claim to the exact expected signer identity. 165 pub fn for_upload( 166 claim: &AuthoredUploadClaim, 167 author: PublicKey, 168 ) -> Result<Self, CanonicalEventIdError> { 169 let wire = claim.wire_parts(); 170 let kind = u32::from(wire.kind()); 171 let tags = wire.tags().to_vec(); 172 let content = wire.content().to_owned(); 173 let expected_event_id = compute_canonical_nip01_event_id( 174 author.to_hex().as_str(), 175 wire.created_at(), 176 kind, 177 &tags, 178 content.as_str(), 179 )?; 180 let digest = compute_blossom_authorization_plan_digest( 181 &author, 182 wire.created_at(), 183 kind, 184 &tags, 185 content.as_str(), 186 &expected_event_id, 187 ); 188 Ok(Self { 189 author, 190 created_at: wire.created_at(), 191 kind, 192 tags, 193 content, 194 expected_event_id, 195 digest, 196 }) 197 } 198 199 #[must_use] 200 pub const fn author(&self) -> &PublicKey { 201 &self.author 202 } 203 204 #[must_use] 205 pub const fn created_at(&self) -> u64 { 206 self.created_at 207 } 208 209 #[must_use] 210 pub const fn kind(&self) -> u32 { 211 self.kind 212 } 213 214 #[must_use] 215 pub fn tags(&self) -> &[Vec<String>] { 216 &self.tags 217 } 218 219 #[must_use] 220 pub fn content(&self) -> &str { 221 self.content.as_str() 222 } 223 224 #[must_use] 225 pub const fn expected_event_id(&self) -> &EventId { 226 &self.expected_event_id 227 } 228 229 #[must_use] 230 pub const fn digest(&self) -> PlanDigest { 231 self.digest 232 } 233 } 234 235 impl AuthoredEventPlan { 236 /// Converts the generic-only event input into its immutable authored plan. 237 pub fn from_generic(draft: GenericEventDraft) -> Result<Self, DraftError> { 238 draft.validate_for_authoring()?; 239 let author = *draft.expected_pubkey(); 240 let created_at = draft.created_at_u64(); 241 let expected_event_id = *draft.expected_event_id(); 242 let body = AuthoredEventBody::from_generic(&draft); 243 Ok(Self::from_validated_parts( 244 body, 245 author, 246 created_at, 247 expected_event_id, 248 )) 249 } 250 251 pub(super) fn from_validated_parts( 252 body: AuthoredEventBody, 253 author: PublicKey, 254 created_at: u64, 255 expected_event_id: EventId, 256 ) -> Self { 257 let digest = compute_plan_digest(&body, &author, created_at, &expected_event_id); 258 Self { 259 body, 260 author, 261 created_at, 262 expected_event_id, 263 digest, 264 } 265 } 266 267 #[must_use] 268 pub const fn body(&self) -> &AuthoredEventBody { 269 &self.body 270 } 271 272 #[must_use] 273 pub const fn author(&self) -> &PublicKey { 274 &self.author 275 } 276 277 #[must_use] 278 pub const fn created_at(&self) -> u64 { 279 self.created_at 280 } 281 282 #[must_use] 283 pub const fn expected_event_id(&self) -> &EventId { 284 &self.expected_event_id 285 } 286 287 #[must_use] 288 pub const fn digest(&self) -> PlanDigest { 289 self.digest 290 } 291 } 292 293 fn compute_plan_digest( 294 body: &AuthoredEventBody, 295 author: &PublicKey, 296 created_at: u64, 297 expected_event_id: &EventId, 298 ) -> PlanDigest { 299 let mut encoder = DigestEncoder::new(); 300 encoder.bytes(PLAN_DIGEST_DOMAIN); 301 encoder.u32(PLAN_WIRE_VERSION_V1); 302 encoder.u32(body.contract.registry_version().get()); 303 encoder.bytes(body.contract.contract_id().as_str().as_bytes()); 304 encoder.bytes(author.as_bytes()); 305 encoder.u64(created_at); 306 encoder.u32(body.kind); 307 encoder.u32(body.tags.len() as u32); 308 for tag in &body.tags { 309 encoder.u32(tag.len() as u32); 310 for element in tag { 311 encoder.bytes(element.as_bytes()); 312 } 313 } 314 encoder.bytes(body.content.as_bytes()); 315 encoder.bytes(expected_event_id.as_bytes()); 316 encoder.finish() 317 } 318 319 fn compute_blossom_authorization_plan_digest( 320 author: &PublicKey, 321 created_at: u64, 322 kind: u32, 323 tags: &[Vec<String>], 324 content: &str, 325 expected_event_id: &EventId, 326 ) -> PlanDigest { 327 let mut encoder = DigestEncoder::new(); 328 encoder.bytes(BLOSSOM_AUTHORIZATION_PLAN_DIGEST_DOMAIN); 329 encoder.bytes(author.as_bytes()); 330 encoder.u64(created_at); 331 encoder.u32(kind); 332 encoder.u32(tags.len() as u32); 333 for tag in tags { 334 encoder.u32(tag.len() as u32); 335 for element in tag { 336 encoder.bytes(element.as_bytes()); 337 } 338 } 339 encoder.bytes(content.as_bytes()); 340 encoder.bytes(expected_event_id.as_bytes()); 341 encoder.finish() 342 } 343 344 struct DigestEncoder(Sha256); 345 346 impl DigestEncoder { 347 fn new() -> Self { 348 Self(Sha256::new()) 349 } 350 351 fn u32(&mut self, value: u32) { 352 self.0.update(value.to_be_bytes()); 353 } 354 355 fn u64(&mut self, value: u64) { 356 self.0.update(value.to_be_bytes()); 357 } 358 359 fn bytes(&mut self, value: &[u8]) { 360 self.u64(value.len() as u64); 361 self.0.update(value); 362 } 363 364 fn finish(self) -> PlanDigest { 365 PlanDigest::from_bytes(self.0.finalize().into()) 366 } 367 } 368 369 #[cfg(test)] 370 mod tests { 371 use super::*; 372 use radroots_blossom::{ 373 Sha256 as BlossomSha256, 374 authorization::{AuthorizationContent, ServerDomain}, 375 }; 376 use radroots_event::envelope::kind::KIND_GEOCHAT; 377 378 const ALICE: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 379 const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; 380 381 fn plan( 382 author: &str, 383 created_at: u64, 384 tags: Vec<Vec<String>>, 385 content: &str, 386 ) -> AuthoredEventPlan { 387 AuthoredEventPlan::from_generic( 388 GenericEventDraft::new( 389 "radroots.social.geochat.v1", 390 KIND_GEOCHAT, 391 created_at, 392 tags, 393 content, 394 author, 395 ) 396 .expect("generic draft"), 397 ) 398 .expect("authored plan") 399 } 400 401 #[test] 402 fn generic_input_becomes_an_exact_immutable_plan() { 403 let plan = plan(ALICE, 1_700_000_000, Vec::new(), "hello"); 404 assert_eq!( 405 plan.body().contract().contract_id().as_str(), 406 "radroots.social.geochat.v1" 407 ); 408 assert_eq!(plan.body().kind(), KIND_GEOCHAT); 409 assert!(plan.body().tags().is_empty()); 410 assert_eq!(plan.body().content(), "hello"); 411 assert_eq!(plan.author().to_hex(), ALICE); 412 assert_eq!(plan.created_at(), 1_700_000_000); 413 assert_eq!(plan.digest().to_hex().len(), 64); 414 } 415 416 #[test] 417 fn semantic_digest_has_a_stable_golden_vector() { 418 let plan = plan( 419 ALICE, 420 1_700_000_000, 421 vec![vec!["g".to_owned(), "u4pru".to_owned()]], 422 "hello 🍓", 423 ); 424 assert_eq!( 425 plan.digest().to_hex(), 426 "eb719fe792ef467b35810967c28a7fdc1c2fb8cebaefdf76efa878fe6dd4f1ec" 427 ); 428 } 429 430 #[test] 431 fn plan_digest_parser_rejects_a_wrong_length_before_hex_decoding() { 432 assert_eq!(PlanDigest::parse_hex("a"), Err(PlanDigestError)); 433 } 434 435 #[test] 436 fn every_bound_authoring_field_changes_the_plan_digest() { 437 let base = plan(ALICE, 1_700_000_000, Vec::new(), "hello"); 438 let variants = [ 439 plan(BOB, 1_700_000_000, Vec::new(), "hello"), 440 plan(ALICE, 1_700_000_001, Vec::new(), "hello"), 441 plan( 442 ALICE, 443 1_700_000_000, 444 vec![vec!["g".to_owned(), "u4pru".to_owned()]], 445 "hello", 446 ), 447 plan(ALICE, 1_700_000_000, Vec::new(), "hello!"), 448 ]; 449 for variant in variants { 450 assert_ne!(variant.expected_event_id(), base.expected_event_id()); 451 assert_ne!(variant.digest(), base.digest()); 452 } 453 } 454 455 fn blossom_plan( 456 author: &str, 457 created_at: u64, 458 content: &str, 459 server: &str, 460 payload: &[u8], 461 ) -> BlossomAuthorizationPlan { 462 let claim = AuthoredUploadClaim::new( 463 AuthorizationContent::parse(content).expect("content"), 464 ServerDomain::parse(server).expect("server"), 465 BlossomSha256::digest(payload), 466 created_at, 467 60, 468 ) 469 .expect("upload claim"); 470 BlossomAuthorizationPlan::for_upload(&claim, PublicKey::from_hex(author).expect("author")) 471 .expect("authorization plan") 472 } 473 474 #[test] 475 fn blossom_upload_plan_binds_every_http_authorization_field() { 476 let base = blossom_plan( 477 ALICE, 478 1_700_000_000, 479 "Upload exact image", 480 "media.example", 481 b"exact-image", 482 ); 483 assert_eq!(base.kind(), 24_242); 484 assert_eq!(base.author().to_hex(), ALICE); 485 assert_eq!(base.created_at(), 1_700_000_000); 486 assert_eq!(base.content(), "Upload exact image"); 487 assert_eq!(base.tags().len(), 4); 488 assert_eq!(base.digest().to_hex().len(), 64); 489 490 let variants = [ 491 blossom_plan( 492 BOB, 493 1_700_000_000, 494 "Upload exact image", 495 "media.example", 496 b"exact-image", 497 ), 498 blossom_plan( 499 ALICE, 500 1_700_000_001, 501 "Upload exact image", 502 "media.example", 503 b"exact-image", 504 ), 505 blossom_plan( 506 ALICE, 507 1_700_000_000, 508 "Upload another image", 509 "media.example", 510 b"exact-image", 511 ), 512 blossom_plan( 513 ALICE, 514 1_700_000_000, 515 "Upload exact image", 516 "uploads.example", 517 b"exact-image", 518 ), 519 blossom_plan( 520 ALICE, 521 1_700_000_000, 522 "Upload exact image", 523 "media.example", 524 b"different-image", 525 ), 526 ]; 527 for variant in variants { 528 assert_ne!(variant.expected_event_id(), base.expected_event_id()); 529 assert_ne!(variant.digest(), base.digest()); 530 } 531 } 532 }