lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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 }