app

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

identity.rs (16851B)


      1 //! Public identity metadata and lifecycle values.
      2 
      3 use crate::time::UnixTimestamp;
      4 use crate::{Npub, PublicKey, SafeError, SafeErrorCode, SafeMessage};
      5 
      6 const MAX_IDENTITY_LABEL_UTF8_BYTES: usize = 80;
      7 
      8 #[derive(Clone, Debug, Eq, PartialEq)]
      9 pub struct NostrIdentityReference {
     10     public_key: PublicKey,
     11     npub: Npub,
     12 }
     13 
     14 impl NostrIdentityReference {
     15     /// Constructs one canonical Nostr identity reference and derives its npub.
     16     ///
     17     /// # Errors
     18     ///
     19     /// Returns a safe public-key error if canonical NIP-19 encoding fails.
     20     pub fn derive(public_key: PublicKey) -> Result<Self, SafeError> {
     21         Ok(Self {
     22             public_key,
     23             npub: Npub::derive(public_key)?,
     24         })
     25     }
     26 
     27     /// Reconstitutes persisted identity only when its public forms agree.
     28     ///
     29     /// # Errors
     30     ///
     31     /// Returns a safe public-key error for a mismatched or malformed npub.
     32     pub fn verify(public_key: PublicKey, npub: String) -> Result<Self, SafeError> {
     33         Ok(Self {
     34             public_key,
     35             npub: Npub::verify(public_key, npub)?,
     36         })
     37     }
     38 
     39     #[must_use]
     40     pub const fn public_key(&self) -> PublicKey {
     41         self.public_key
     42     }
     43 
     44     #[must_use]
     45     pub const fn npub(&self) -> &Npub {
     46         &self.npub
     47     }
     48 }
     49 
     50 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     51 pub struct LocalKeyringBinding {
     52     identity: PublicKey,
     53     availability: SignerAvailability,
     54 }
     55 
     56 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
     57 #[non_exhaustive]
     58 pub enum SignerBinding {
     59     LocalKeyring(LocalKeyringBinding),
     60 }
     61 
     62 impl SignerBinding {
     63     #[must_use]
     64     pub const fn identity(self) -> PublicKey {
     65         match self {
     66             Self::LocalKeyring(binding) => binding.identity(),
     67         }
     68     }
     69 
     70     #[must_use]
     71     pub const fn availability(self) -> SignerAvailability {
     72         match self {
     73             Self::LocalKeyring(binding) => binding.availability(),
     74         }
     75     }
     76 
     77     #[must_use]
     78     pub const fn as_local_keyring(self) -> Option<LocalKeyringBinding> {
     79         match self {
     80             Self::LocalKeyring(binding) => Some(binding),
     81         }
     82     }
     83 }
     84 
     85 impl From<LocalKeyringBinding> for SignerBinding {
     86     fn from(binding: LocalKeyringBinding) -> Self {
     87         Self::LocalKeyring(binding)
     88     }
     89 }
     90 
     91 impl LocalKeyringBinding {
     92     #[must_use]
     93     pub const fn new(identity: PublicKey, availability: SignerAvailability) -> Self {
     94         Self {
     95             identity,
     96             availability,
     97         }
     98     }
     99 
    100     #[must_use]
    101     pub const fn identity(self) -> PublicKey {
    102         self.identity
    103     }
    104 
    105     #[must_use]
    106     pub const fn availability(self) -> SignerAvailability {
    107         self.availability
    108     }
    109 
    110     #[must_use]
    111     pub const fn repair_action(self) -> Option<SignerRepairAction> {
    112         match self.availability {
    113             SignerAvailability::Available => None,
    114             SignerAvailability::CredentialMissing => Some(SignerRepairAction::ImportCredential),
    115             SignerAvailability::StoreUnavailable => Some(SignerRepairAction::RetryCredentialStore),
    116         }
    117     }
    118 
    119     /// Records a missing credential after a successful store lookup.
    120     ///
    121     /// # Errors
    122     ///
    123     /// Returns a safe state error unless the binding was previously available.
    124     pub fn mark_credential_missing(&mut self) -> Result<(), SafeError> {
    125         self.transition(
    126             SignerAvailability::Available,
    127             SignerAvailability::CredentialMissing,
    128         )
    129     }
    130 
    131     pub fn mark_store_unavailable(&mut self) {
    132         self.availability = SignerAvailability::StoreUnavailable;
    133     }
    134 
    135     /// Completes an explicit credential repair.
    136     ///
    137     /// # Errors
    138     ///
    139     /// Returns a safe state error unless a credential was missing.
    140     pub fn repair_credential(&mut self) -> Result<(), SafeError> {
    141         self.transition(
    142             SignerAvailability::CredentialMissing,
    143             SignerAvailability::Available,
    144         )
    145     }
    146 
    147     /// Resolves a recovered store lookup to its observed credential state.
    148     ///
    149     /// # Errors
    150     ///
    151     /// Returns a safe state error unless the credential store was unavailable.
    152     pub fn resolve_store_recovery(&mut self, credential_present: bool) -> Result<(), SafeError> {
    153         if self.availability != SignerAvailability::StoreUnavailable {
    154             return Err(invalid_identity_metadata());
    155         }
    156         self.availability = if credential_present {
    157             SignerAvailability::Available
    158         } else {
    159             SignerAvailability::CredentialMissing
    160         };
    161         Ok(())
    162     }
    163 
    164     fn transition(
    165         &mut self,
    166         expected: SignerAvailability,
    167         next: SignerAvailability,
    168     ) -> Result<(), SafeError> {
    169         if self.availability != expected {
    170             return Err(invalid_identity_metadata());
    171         }
    172         self.availability = next;
    173         Ok(())
    174     }
    175 }
    176 
    177 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    178 pub enum SignerAvailability {
    179     Available,
    180     CredentialMissing,
    181     StoreUnavailable,
    182 }
    183 
    184 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    185 pub enum SignerRepairAction {
    186     ImportCredential,
    187     RetryCredentialStore,
    188 }
    189 
    190 #[derive(Clone, Debug, Eq, PartialEq)]
    191 pub struct IdentityLabel(String);
    192 
    193 impl IdentityLabel {
    194     /// Trims and validates an optional human-assigned identity label value.
    195     ///
    196     /// # Errors
    197     ///
    198     /// Returns a safe metadata error when the resulting label is empty, too
    199     /// long, or contains a control character.
    200     pub fn parse(value: &str) -> Result<Self, SafeError> {
    201         let normalized = value.trim();
    202         if normalized.is_empty()
    203             || normalized.len() > MAX_IDENTITY_LABEL_UTF8_BYTES
    204             || normalized.chars().any(char::is_control)
    205         {
    206             return Err(invalid_identity_metadata());
    207         }
    208         Ok(Self(normalized.to_owned()))
    209     }
    210 
    211     #[must_use]
    212     pub fn as_str(&self) -> &str {
    213         &self.0
    214     }
    215 }
    216 
    217 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
    218 pub struct IdentityCreatedAt(UnixTimestamp);
    219 
    220 impl IdentityCreatedAt {
    221     #[must_use]
    222     pub const fn new(timestamp: UnixTimestamp) -> Self {
    223         Self(timestamp)
    224     }
    225 
    226     #[must_use]
    227     pub const fn timestamp(self) -> UnixTimestamp {
    228         self.0
    229     }
    230 }
    231 
    232 #[derive(Clone, Debug, Eq, PartialEq)]
    233 pub struct NostrIdentity {
    234     identity: NostrIdentityReference,
    235     signer_binding: SignerBinding,
    236     label: Option<IdentityLabel>,
    237     created_at: IdentityCreatedAt,
    238     last_used_at: Option<UnixTimestamp>,
    239 }
    240 
    241 impl NostrIdentity {
    242     /// Creates an identity summary whose identity and signer binding refer to the same identity.
    243     ///
    244     /// # Errors
    245     ///
    246     /// Returns an invalid-identity-metadata error when the signer binding belongs to a different
    247     /// public key.
    248     pub fn new(
    249         identity: NostrIdentityReference,
    250         signer_binding: impl Into<SignerBinding>,
    251         label: Option<IdentityLabel>,
    252         created_at: IdentityCreatedAt,
    253         last_used_at: Option<UnixTimestamp>,
    254     ) -> Result<Self, SafeError> {
    255         let signer_binding = signer_binding.into();
    256         if identity.public_key() != signer_binding.identity() {
    257             return Err(invalid_identity_metadata());
    258         }
    259         Ok(Self {
    260             identity,
    261             signer_binding,
    262             label,
    263             created_at,
    264             last_used_at,
    265         })
    266     }
    267 
    268     #[must_use]
    269     pub const fn public_key(&self) -> PublicKey {
    270         self.identity.public_key()
    271     }
    272 
    273     #[must_use]
    274     pub fn npub(&self) -> &Npub {
    275         self.identity.npub()
    276     }
    277 
    278     #[must_use]
    279     pub const fn signer_binding(&self) -> SignerBinding {
    280         self.signer_binding
    281     }
    282 
    283     #[must_use]
    284     pub fn label(&self) -> Option<&IdentityLabel> {
    285         self.label.as_ref()
    286     }
    287 
    288     #[must_use]
    289     pub const fn created_at(&self) -> IdentityCreatedAt {
    290         self.created_at
    291     }
    292 
    293     #[must_use]
    294     pub const fn last_used_at(&self) -> Option<UnixTimestamp> {
    295         self.last_used_at
    296     }
    297 
    298     #[must_use]
    299     pub fn with_local_keyring_availability(
    300         &self,
    301         availability: SignerAvailability,
    302     ) -> Option<Self> {
    303         self.signer_binding.as_local_keyring()?;
    304         Some(Self {
    305             identity: self.identity.clone(),
    306             signer_binding: SignerBinding::LocalKeyring(LocalKeyringBinding::new(
    307                 self.public_key(),
    308                 availability,
    309             )),
    310             label: self.label.clone(),
    311             created_at: self.created_at,
    312             last_used_at: self.last_used_at,
    313         })
    314     }
    315 
    316     #[must_use]
    317     pub fn with_last_used_at(&self, last_used_at: UnixTimestamp) -> Self {
    318         Self {
    319             identity: self.identity.clone(),
    320             signer_binding: self.signer_binding,
    321             label: self.label.clone(),
    322             created_at: self.created_at,
    323             last_used_at: Some(last_used_at),
    324         }
    325     }
    326 
    327     #[must_use]
    328     pub fn display_label(&self) -> String {
    329         self.label
    330             .as_ref()
    331             .map_or_else(|| self.npub().short(), |label| label.as_str().to_owned())
    332     }
    333 }
    334 
    335 const fn invalid_identity_metadata() -> SafeError {
    336     SafeError::new(
    337         SafeErrorCode::InvalidIdentityMetadata,
    338         SafeMessage::new("The identity metadata is invalid."),
    339     )
    340 }
    341 
    342 #[cfg(test)]
    343 mod tests {
    344     use crate::PublicKey;
    345     use crate::time::UnixTimestamp;
    346 
    347     use super::{
    348         IdentityCreatedAt, IdentityLabel, LocalKeyringBinding, NostrIdentity,
    349         NostrIdentityReference, SignerAvailability, SignerBinding, SignerRepairAction,
    350     };
    351 
    352     const DERIVED_NPUB: &str = "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7";
    353     const MISMATCHED_NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg";
    354 
    355     fn public_key() -> PublicKey {
    356         PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
    357     }
    358 
    359     fn identity(label: Option<IdentityLabel>) -> NostrIdentity {
    360         let public_key = public_key();
    361         NostrIdentity::new(
    362             NostrIdentityReference::derive(public_key).expect("identity"),
    363             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    364             label,
    365             IdentityCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
    366             None,
    367         )
    368         .expect("identity")
    369     }
    370 
    371     fn label_at_utf8_limit(unit: &str) -> String {
    372         unit.repeat(80 / unit.len()) + &"x".repeat(80 % unit.len())
    373     }
    374 
    375     #[test]
    376     fn identity_label_accepts_exact_utf8_boundaries_after_trimming() {
    377         for unit in ["x", "é", "🥕", "e\u{301}"] {
    378             let value = label_at_utf8_limit(unit);
    379             assert_eq!(value.len(), 80);
    380             let padded = format!(" \u{2003}{value}\u{2003} ");
    381             let label = IdentityLabel::parse(&padded).expect("80-byte normalized label");
    382             assert_eq!(label.as_str(), value);
    383             assert_eq!(identity(Some(label)).display_label(), value);
    384         }
    385     }
    386 
    387     #[test]
    388     fn identity_label_rejects_one_byte_over_utf8_limit() {
    389         for unit in ["x", "é", "🥕", "e\u{301}"] {
    390             let value = label_at_utf8_limit(unit) + "x";
    391             assert_eq!(value.len(), 81);
    392             let error = IdentityLabel::parse(&format!(" {value} "))
    393                 .expect_err("81-byte normalized label must fail before storage");
    394             assert_eq!(error.code(), crate::SafeErrorCode::InvalidIdentityMetadata);
    395         }
    396     }
    397 
    398     #[test]
    399     fn identity_label_preserves_blank_and_embedded_control_policy() {
    400         for value in [
    401             "",
    402             " \u{2003}\t\r\n ",
    403             "a\nb",
    404             "a\rb",
    405             "a\tb",
    406             "a\0b",
    407             "a\u{1b}b",
    408             "a\u{7f}b",
    409             "a\u{85}b",
    410         ] {
    411             assert_eq!(
    412                 IdentityLabel::parse(value)
    413                     .expect_err("invalid label")
    414                     .code(),
    415                 crate::SafeErrorCode::InvalidIdentityMetadata
    416             );
    417         }
    418     }
    419 
    420     #[test]
    421     fn identity_label_is_trimmed_bounded_and_control_free() {
    422         let label = IdentityLabel::parse("  Farm identity  ").expect("valid label");
    423         assert_eq!(label.as_str(), "Farm identity");
    424 
    425         for invalid in ["", "   ", "line\nbreak", &"x".repeat(81)] {
    426             assert!(IdentityLabel::parse(invalid).is_err());
    427         }
    428     }
    429 
    430     #[test]
    431     fn identity_display_prefers_label_then_shortened_npub() {
    432         let labelled = identity(Some(IdentityLabel::parse("Farm").expect("valid label")));
    433         let unlabelled = identity(None);
    434 
    435         assert_eq!(labelled.display_label(), "Farm");
    436         assert_eq!(unlabelled.display_label(), "npub1qurswpc…rsnvjvl7");
    437     }
    438 
    439     #[test]
    440     fn local_identity_summary_contains_public_metadata_only() {
    441         let identity = identity(None);
    442         let debug = format!("{identity:?}");
    443 
    444         assert_eq!(
    445             identity.signer_binding().availability(),
    446             SignerAvailability::Available
    447         );
    448         assert!(identity.label().is_none());
    449         assert!(identity.last_used_at().is_none());
    450         assert_eq!(identity.created_at().timestamp().as_seconds(), 10);
    451         assert_eq!(identity.public_key(), public_key());
    452         assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
    453         assert!(!debug.contains("nsec1"));
    454         assert!(!debug.contains(&"11".repeat(32)));
    455     }
    456 
    457     #[test]
    458     fn nostr_identity_reference_derives_npub_and_rejects_mismatched_persisted_forms() {
    459         let public_key = public_key();
    460         let identity = NostrIdentityReference::derive(public_key).expect("identity");
    461         assert_eq!(identity.public_key(), public_key);
    462         assert_eq!(identity.npub().as_str(), DERIVED_NPUB);
    463         assert_eq!(
    464             NostrIdentityReference::verify(public_key, DERIVED_NPUB.to_owned()).expect("verified"),
    465             identity
    466         );
    467         assert!(NostrIdentityReference::verify(public_key, MISMATCHED_NPUB.to_owned()).is_err());
    468         assert!(
    469             NostrIdentityReference::verify(
    470                 PublicKey::from_hex(
    471                     "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7",
    472                 )
    473                 .expect("second public key"),
    474                 MISMATCHED_NPUB.to_owned()
    475             )
    476             .is_err()
    477         );
    478     }
    479 
    480     #[test]
    481     fn local_keyring_binding_carries_only_canonical_identity_reference() {
    482         let public_key = public_key();
    483         let identity = NostrIdentityReference::derive(public_key).expect("identity");
    484         let binding = LocalKeyringBinding::new(public_key, SignerAvailability::Available);
    485 
    486         assert_eq!(binding.identity(), identity.public_key());
    487         assert!(!format!("{binding:?}").contains("nsec1"));
    488     }
    489 
    490     #[test]
    491     fn local_keyring_capability_is_explicit_and_preserves_the_binding() {
    492         let binding = LocalKeyringBinding::new(public_key(), SignerAvailability::Available);
    493         let signer_binding = SignerBinding::LocalKeyring(binding);
    494 
    495         assert_eq!(signer_binding.as_local_keyring(), Some(binding));
    496     }
    497 
    498     #[test]
    499     fn signer_binding_rejects_an_identity_mismatch() {
    500         let identity = NostrIdentityReference::derive(public_key()).expect("identity");
    501         let other = PublicKey::from_bytes([8_u8; 32]).expect("other public key");
    502         let binding = SignerBinding::LocalKeyring(LocalKeyringBinding::new(
    503             other,
    504             SignerAvailability::Available,
    505         ));
    506 
    507         let error = NostrIdentity::new(
    508             identity,
    509             binding,
    510             None,
    511             IdentityCreatedAt::new(UnixTimestamp::from_seconds(10).expect("valid time")),
    512             None,
    513         )
    514         .expect_err("mismatched identity and binding");
    515 
    516         assert_eq!(error.code(), crate::SafeErrorCode::InvalidIdentityMetadata);
    517     }
    518 
    519     #[test]
    520     fn local_keyring_binding_repair_transitions_are_typed_and_fail_closed() {
    521         let public_key = public_key();
    522         let mut binding = LocalKeyringBinding::new(public_key, SignerAvailability::Available);
    523         assert_eq!(binding.repair_action(), None);
    524         assert!(binding.repair_credential().is_err());
    525 
    526         binding
    527             .mark_credential_missing()
    528             .expect("missing credential");
    529         assert_eq!(
    530             binding.repair_action(),
    531             Some(SignerRepairAction::ImportCredential)
    532         );
    533         binding.repair_credential().expect("repair");
    534 
    535         binding.mark_store_unavailable();
    536         assert_eq!(
    537             binding.repair_action(),
    538             Some(SignerRepairAction::RetryCredentialStore)
    539         );
    540         binding
    541             .resolve_store_recovery(false)
    542             .expect("store recovery");
    543         assert_eq!(
    544             binding.availability(),
    545             SignerAvailability::CredentialMissing
    546         );
    547         assert!(binding.resolve_store_recovery(true).is_err());
    548     }
    549 }