app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

profile.rs (11287B)


      1 use harvestcircle_domain::{
      2     EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode,
      3     SafeMessage, UnixTimestamp,
      4 };
      5 use nostr::{Event, JsonUtil, Kind, Metadata};
      6 
      7 const MAX_EVENT_JSON_BYTES: usize = 64 * 1_024;
      8 const MAX_PROFILE_CONTENT_BYTES: usize = 16 * 1_024;
      9 
     10 /// Verifies and converts one serialized Nostr kind-0 event.
     11 ///
     12 /// # Errors
     13 ///
     14 /// Returns a safe profile-refresh error when the event is oversized,
     15 /// malformed, invalidly signed, authored by another key, or not kind 0.
     16 pub fn parse_verified_kind0(
     17     event_json: &str,
     18     expected_author: PublicKey,
     19 ) -> Result<Kind0ProfileCandidate, SafeError> {
     20     if event_json.len() > MAX_EVENT_JSON_BYTES {
     21         return Err(invalid_event());
     22     }
     23 
     24     let event = Event::from_json(event_json).map_err(|_| invalid_event())?;
     25     event.verify().map_err(|_| invalid_event())?;
     26     if event.kind != Kind::Metadata
     27         || event.pubkey.to_bytes() != *expected_author.as_bytes()
     28         || event.content.len() > MAX_PROFILE_CONTENT_BYTES
     29     {
     30         return Err(invalid_event());
     31     }
     32 
     33     let metadata = Metadata::from_json(&event.content).map_err(|_| invalid_metadata())?;
     34     let profile = ProfileMetadata::new(
     35         metadata.name,
     36         metadata.display_name,
     37         metadata.nip05,
     38         metadata.about,
     39         metadata.picture,
     40     )?;
     41     let created_at = i64::try_from(event.created_at.as_secs())
     42         .ok()
     43         .and_then(UnixTimestamp::from_seconds)
     44         .ok_or_else(invalid_event)?;
     45 
     46     Ok(Kind0ProfileCandidate::new(
     47         EventId::from_bytes(event.id.to_bytes()),
     48         expected_author,
     49         created_at,
     50         profile,
     51     ))
     52 }
     53 
     54 const fn invalid_event() -> SafeError {
     55     SafeError::new(
     56         SafeErrorCode::ProfileRefreshFailed,
     57         SafeMessage::new("The Nostr profile event is invalid."),
     58     )
     59 }
     60 
     61 const fn invalid_metadata() -> SafeError {
     62     SafeError::new(
     63         SafeErrorCode::InvalidProfileMetadata,
     64         SafeMessage::new("The Nostr profile metadata is invalid."),
     65     )
     66 }
     67 
     68 #[cfg(test)]
     69 mod tests {
     70     use harvestcircle_domain::{PublicKey, SafeErrorCode};
     71     use nostr::{EventBuilder, JsonUtil, Keys, Metadata, Url};
     72 
     73     use super::parse_verified_kind0;
     74 
     75     fn signed_profile() -> (Keys, String) {
     76         let keys = Keys::generate();
     77         let event = EventBuilder::metadata(
     78             &Metadata::new()
     79                 .name(" farmer ")
     80                 .display_name(" Farm Identity ")
     81                 .nip05("farmer@example.test")
     82                 .about("Local grower")
     83                 .picture(
     84                     Url::parse("https://images.example.test/farmer.png")
     85                         .expect("valid picture URL"),
     86                 ),
     87         )
     88         .sign_with_keys(&keys)
     89         .expect("signed metadata event");
     90         (keys, event.as_json())
     91     }
     92 
     93     fn text_at_utf8_limit(unit: &str, maximum: usize) -> String {
     94         unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len())
     95     }
     96 
     97     fn signed_metadata(fields: [Option<String>; 5]) -> (PublicKey, String) {
     98         let [name, display_name, nip05, about, picture] = fields;
     99         let mut metadata = Metadata::new();
    100         metadata.name = name;
    101         metadata.display_name = display_name;
    102         metadata.nip05 = nip05;
    103         metadata.about = about;
    104         metadata.picture = picture;
    105         let keys = Keys::generate();
    106         let event = EventBuilder::new(nostr::Kind::Metadata, metadata.as_json())
    107             .sign_with_keys(&keys)
    108             .expect("signed metadata");
    109         event
    110             .verify()
    111             .expect("fixture has a valid signature and event ID");
    112         let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("public author");
    113         (author, event.as_json())
    114     }
    115 
    116     fn assert_signed_one_byte_over_limit_is_rejected(index: usize, maximum: usize) {
    117         for unit in ["x", "é", "🥕", "e\u{301}"] {
    118             let value = text_at_utf8_limit(unit, maximum) + "x";
    119             assert_eq!(value.len(), maximum + 1);
    120             let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None);
    121             fields[index] = Some(format!(" {value} "));
    122             let (author, json) = signed_metadata(fields);
    123             assert_eq!(
    124                 parse_verified_kind0(&json, author)
    125                     .expect_err("correctly signed overlimit metadata")
    126                     .code(),
    127                 SafeErrorCode::InvalidProfileMetadata
    128             );
    129         }
    130     }
    131 
    132     #[test]
    133     fn signed_kind0_accepts_exact_utf8_profile_boundaries() {
    134         for unit in ["x", "é", "🥕", "e\u{301}"] {
    135             let fields = [128, 128, 320, 4_096, 2_048]
    136                 .map(|maximum| Some(text_at_utf8_limit(unit, maximum)));
    137             let (author, json) = signed_metadata(fields.clone());
    138             let candidate = parse_verified_kind0(&json, author).expect("signed UTF-8 boundaries");
    139             assert_eq!(candidate.author(), author);
    140             assert_eq!(candidate.metadata().name(), fields[0].as_deref());
    141             assert_eq!(candidate.metadata().display_name(), fields[1].as_deref());
    142             assert_eq!(candidate.metadata().nip05(), fields[2].as_deref());
    143             assert_eq!(candidate.metadata().about(), fields[3].as_deref());
    144             assert_eq!(candidate.metadata().picture(), fields[4].as_deref());
    145         }
    146     }
    147 
    148     #[test]
    149     fn signed_kind0_rejects_name_one_byte_over_utf8_limit() {
    150         assert_signed_one_byte_over_limit_is_rejected(0, 128);
    151     }
    152 
    153     #[test]
    154     fn signed_kind0_rejects_display_name_one_byte_over_utf8_limit() {
    155         assert_signed_one_byte_over_limit_is_rejected(1, 128);
    156     }
    157 
    158     #[test]
    159     fn signed_kind0_rejects_nip05_one_byte_over_utf8_limit() {
    160         assert_signed_one_byte_over_limit_is_rejected(2, 320);
    161     }
    162 
    163     #[test]
    164     fn signed_kind0_rejects_about_one_byte_over_utf8_limit() {
    165         assert_signed_one_byte_over_limit_is_rejected(3, 4_096);
    166     }
    167 
    168     #[test]
    169     fn signed_kind0_rejects_picture_one_byte_over_utf8_limit() {
    170         assert_signed_one_byte_over_limit_is_rejected(4, 2_048);
    171     }
    172 
    173     #[test]
    174     fn signed_kind0_preserves_trim_blank_and_about_layout_policy() {
    175         let (author, json) = signed_metadata([
    176             Some(" \u{2003}e\u{301}\u{2003} ".to_owned()),
    177             Some(" \t ".to_owned()),
    178             None,
    179             Some(" \nFirst\nSecond\rThird\tFourth\t ".to_owned()),
    180             Some(" ".to_owned()),
    181         ]);
    182         let candidate = parse_verified_kind0(&json, author).expect("normalized signed metadata");
    183         assert_eq!(candidate.metadata().name(), Some("e\u{301}"));
    184         assert_eq!(candidate.metadata().display_name(), None);
    185         assert_eq!(candidate.metadata().nip05(), None);
    186         assert_eq!(
    187             candidate.metadata().about(),
    188             Some("First\nSecond\rThird\tFourth")
    189         );
    190         assert_eq!(candidate.metadata().picture(), None);
    191 
    192         for index in 0..5 {
    193             for control in ['\0', '\u{1b}', '\u{7f}', '\u{85}', '\n', '\r', '\t'] {
    194                 if index == 3 && matches!(control, '\n' | '\r' | '\t') {
    195                     continue;
    196                 }
    197                 let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None);
    198                 fields[index] = Some(format!("a{control}b"));
    199                 let (author, json) = signed_metadata(fields);
    200                 assert_eq!(
    201                     parse_verified_kind0(&json, author)
    202                         .expect_err("signed metadata with forbidden embedded control")
    203                         .code(),
    204                     SafeErrorCode::InvalidProfileMetadata
    205                 );
    206             }
    207         }
    208     }
    209 
    210     #[test]
    211     fn profile_event_verifies_signature_author_kind_and_metadata() {
    212         let (keys, json) = signed_profile();
    213         let expected_author =
    214             PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
    215 
    216         let candidate = parse_verified_kind0(&json, expected_author).expect("verified profile");
    217 
    218         assert_eq!(candidate.author(), expected_author);
    219         assert_eq!(candidate.metadata().name(), Some("farmer"));
    220         assert_eq!(candidate.metadata().display_name(), Some("Farm Identity"));
    221         assert_eq!(candidate.metadata().nip05(), Some("farmer@example.test"));
    222         assert_eq!(candidate.metadata().about(), Some("Local grower"));
    223         assert_eq!(
    224             candidate.metadata().picture(),
    225             Some("https://images.example.test/farmer.png")
    226         );
    227     }
    228 
    229     #[test]
    230     fn profile_event_rejects_tampering_wrong_author_kind_and_oversize_content() {
    231         let (keys, json) = signed_profile();
    232         let expected_author =
    233             PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
    234         let wrong_author = PublicKey::from_bytes(Keys::generate().public_key().to_bytes())
    235             .expect("valid public key");
    236         let tampered = json.replace("Local grower", "Remote grower");
    237         let note = EventBuilder::text_note("not metadata")
    238             .sign_with_keys(&keys)
    239             .expect("signed note")
    240             .as_json();
    241         let oversized = EventBuilder::metadata(&Metadata::new().about("x".repeat(16 * 1_024 + 1)))
    242             .sign_with_keys(&keys)
    243             .expect("signed oversized profile")
    244             .as_json();
    245 
    246         for rejected in [
    247             parse_verified_kind0(&tampered, expected_author),
    248             parse_verified_kind0(&json, wrong_author),
    249             parse_verified_kind0(&note, expected_author),
    250             parse_verified_kind0(&oversized, expected_author),
    251         ] {
    252             assert_eq!(
    253                 rejected.expect_err("invalid event").code(),
    254                 SafeErrorCode::ProfileRefreshFailed
    255             );
    256         }
    257     }
    258 
    259     #[test]
    260     fn profile_event_rejects_malformed_and_bounded_invalid_metadata() {
    261         let keys = Keys::generate();
    262         let malformed = EventBuilder::new(nostr::Kind::Metadata, "not json")
    263             .sign_with_keys(&keys)
    264             .expect("signed malformed metadata")
    265             .as_json();
    266         let invalid = EventBuilder::metadata(&Metadata::new().name("x".repeat(129)))
    267             .sign_with_keys(&keys)
    268             .expect("signed invalid metadata")
    269             .as_json();
    270         let author = PublicKey::from_bytes(keys.public_key().to_bytes()).expect("valid public key");
    271 
    272         assert_eq!(
    273             parse_verified_kind0(&malformed, author)
    274                 .expect_err("malformed metadata")
    275                 .code(),
    276             SafeErrorCode::InvalidProfileMetadata
    277         );
    278         assert_eq!(
    279             parse_verified_kind0(&invalid, author)
    280                 .expect_err("bounded metadata")
    281                 .code(),
    282             SafeErrorCode::InvalidProfileMetadata
    283         );
    284         assert_eq!(
    285             parse_verified_kind0(&"x".repeat(64 * 1_024 + 1), author)
    286                 .expect_err("oversized event")
    287                 .code(),
    288             SafeErrorCode::ProfileRefreshFailed
    289         );
    290         assert_eq!(
    291             super::invalid_event().code(),
    292             SafeErrorCode::ProfileRefreshFailed
    293         );
    294         assert_eq!(
    295             super::invalid_metadata().code(),
    296             SafeErrorCode::InvalidProfileMetadata
    297         );
    298     }
    299 }