app

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

custody.rs (8402B)


      1 use std::num::NonZeroU64;
      2 use std::sync::Mutex;
      3 use std::time::Duration;
      4 
      5 use crate::KeyMaterialProvider;
      6 use harvestcircle_domain::{
      7     IdentityCreatedAt, LocalKeyringBinding, NostrIdentity, NostrIdentityReference, Nsec, SafeError,
      8     SafeErrorCode, SafeMessage, SecretKeyInput, SignerAvailability, UnixTimestamp,
      9 };
     10 pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5);
     11 
     12 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     13 pub struct RecoveryStageId(NonZeroU64);
     14 
     15 impl RecoveryStageId {
     16     #[must_use]
     17     pub const fn new(value: NonZeroU64) -> Self {
     18         Self(value)
     19     }
     20 
     21     #[must_use]
     22     pub const fn value(self) -> u64 {
     23         self.0.get()
     24     }
     25 }
     26 
     27 #[derive(Clone, Debug, Eq, PartialEq)]
     28 pub struct GeneratedKeyStageView {
     29     identity: NostrIdentity,
     30     expires_at: UnixTimestamp,
     31 }
     32 
     33 impl GeneratedKeyStageView {
     34     #[must_use]
     35     pub const fn identity(&self) -> &NostrIdentity {
     36         &self.identity
     37     }
     38 
     39     #[must_use]
     40     pub const fn expires_at(&self) -> UnixTimestamp {
     41         self.expires_at
     42     }
     43 }
     44 
     45 pub struct StagedGeneratedKey {
     46     id: RecoveryStageId,
     47     identity: NostrIdentity,
     48     secret: SecretKeyInput,
     49     expected_revision: u64,
     50     expires_at: UnixTimestamp,
     51 }
     52 
     53 impl StagedGeneratedKey {
     54     #[must_use]
     55     pub fn view(&self) -> GeneratedKeyStageView {
     56         GeneratedKeyStageView {
     57             identity: self.identity.clone(),
     58             expires_at: self.expires_at,
     59         }
     60     }
     61 
     62     #[must_use]
     63     pub const fn expected_revision(&self) -> u64 {
     64         self.expected_revision
     65     }
     66 
     67     #[must_use]
     68     pub const fn id(&self) -> RecoveryStageId {
     69         self.id
     70     }
     71 
     72     #[must_use]
     73     pub const fn identity(&self) -> &NostrIdentity {
     74         &self.identity
     75     }
     76 
     77     #[must_use]
     78     pub fn into_commit_parts(self) -> (NostrIdentity, SecretKeyInput) {
     79         (self.identity, self.secret)
     80     }
     81 }
     82 
     83 pub struct GeneratedKeyRecoveryHandle {
     84     id: RecoveryStageId,
     85     view: GeneratedKeyStageView,
     86     recovery_nsec: Mutex<Option<Nsec>>,
     87 }
     88 
     89 impl GeneratedKeyRecoveryHandle {
     90     fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self {
     91         Self {
     92             id,
     93             view,
     94             recovery_nsec: Mutex::new(Some(recovery_nsec)),
     95         }
     96     }
     97 
     98     #[must_use]
     99     pub const fn id(&self) -> RecoveryStageId {
    100         self.id
    101     }
    102 
    103     #[must_use]
    104     pub const fn view(&self) -> &GeneratedKeyStageView {
    105         &self.view
    106     }
    107 
    108     /// Returns the generated recovery value exactly once.
    109     ///
    110     /// # Errors
    111     ///
    112     /// Returns a safe unavailable error after the value was already consumed.
    113     pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> {
    114         self.recovery_nsec
    115             .lock()
    116             .map_err(|_| recovery_not_available())?
    117             .take()
    118             .ok_or_else(recovery_not_available)
    119     }
    120 }
    121 
    122 #[derive(Default)]
    123 pub struct GeneratedKeyStage {
    124     pending: Option<StagedGeneratedKey>,
    125 }
    126 
    127 impl GeneratedKeyStage {
    128     /// Replaces an expired stage or creates the only active generated-key stage.
    129     ///
    130     /// # Errors
    131     ///
    132     /// Returns a safe conflict while an unexpired recovery stage is active.
    133     pub fn begin(
    134         &mut self,
    135         key_material: &dyn KeyMaterialProvider,
    136         id: RecoveryStageId,
    137         expected_revision: u64,
    138         now: UnixTimestamp,
    139     ) -> Result<GeneratedKeyRecoveryHandle, SafeError> {
    140         self.expire(now);
    141         if self.pending.is_some() {
    142             return Err(recovery_in_progress());
    143         }
    144         let generated = key_material.generate()?;
    145         let (public_key, npub, secret, recovery_nsec) = generated.into_parts();
    146         let identity = NostrIdentity::new(
    147             NostrIdentityReference::verify(public_key, npub.as_str().to_owned())?,
    148             LocalKeyringBinding::new(public_key, SignerAvailability::Available),
    149             None,
    150             IdentityCreatedAt::new(now),
    151             None,
    152         )?;
    153         let ttl =
    154             i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?;
    155         let expires_at = now
    156             .as_seconds()
    157             .checked_add(ttl)
    158             .and_then(UnixTimestamp::from_seconds)
    159             .ok_or_else(invalid_stage_expiry)?;
    160         let pending = StagedGeneratedKey {
    161             id,
    162             identity,
    163             secret,
    164             expected_revision,
    165             expires_at,
    166         };
    167         let view = pending.view();
    168         self.pending = Some(pending);
    169         Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec))
    170     }
    171 
    172     pub fn cancel(&mut self) -> bool {
    173         self.pending.take().is_some()
    174     }
    175 
    176     pub fn expire(&mut self, now: UnixTimestamp) -> bool {
    177         if self
    178             .pending
    179             .as_ref()
    180             .is_some_and(|pending| now >= pending.expires_at)
    181         {
    182             self.pending = None;
    183             true
    184         } else {
    185             false
    186         }
    187     }
    188 
    189     #[must_use]
    190     pub const fn pending(&self) -> Option<&StagedGeneratedKey> {
    191         self.pending.as_ref()
    192     }
    193 
    194     /// Consumes the active, unexpired stage for its commit boundary.
    195     ///
    196     /// # Errors
    197     ///
    198     /// Returns a safe unavailable error when no live stage remains.
    199     pub fn take(
    200         &mut self,
    201         id: RecoveryStageId,
    202         now: UnixTimestamp,
    203     ) -> Result<StagedGeneratedKey, SafeError> {
    204         self.expire(now);
    205         if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) {
    206             return Err(recovery_not_available());
    207         }
    208         self.pending.take().ok_or_else(recovery_not_available)
    209     }
    210 }
    211 
    212 const fn recovery_in_progress() -> SafeError {
    213     SafeError::new(
    214         SafeErrorCode::InvalidApplicationState,
    215         SafeMessage::new("A generated-key recovery step is already in progress."),
    216     )
    217 }
    218 
    219 const fn recovery_not_available() -> SafeError {
    220     SafeError::new(
    221         SafeErrorCode::InvalidApplicationState,
    222         SafeMessage::new("The generated-key recovery step is no longer available."),
    223     )
    224 }
    225 
    226 const fn invalid_stage_expiry() -> SafeError {
    227     SafeError::new(
    228         SafeErrorCode::InvalidApplicationState,
    229         SafeMessage::new("The generated-key recovery expiry is invalid."),
    230     )
    231 }
    232 
    233 #[cfg(test)]
    234 mod tests {
    235     use std::num::NonZeroU64;
    236 
    237     use harvestcircle_domain::UnixTimestamp;
    238 
    239     use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId};
    240     use crate::test_support::TestKeyMaterialProvider;
    241 
    242     fn time(seconds: i64) -> UnixTimestamp {
    243         UnixTimestamp::from_seconds(seconds).expect("time")
    244     }
    245 
    246     fn id(value: u64) -> RecoveryStageId {
    247         RecoveryStageId::new(NonZeroU64::new(value).expect("id"))
    248     }
    249 
    250     #[test]
    251     fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() {
    252         let mut stage = GeneratedKeyStage::default();
    253         let key_material = TestKeyMaterialProvider::default();
    254         let handle = stage
    255             .begin(&key_material, id(1), 4, time(10))
    256             .expect("begin");
    257         let view = handle.view();
    258         assert_eq!(view.expires_at().as_seconds(), 310);
    259         assert_eq!(stage.pending().expect("pending").expected_revision(), 4);
    260         assert!(stage.begin(&key_material, id(2), 4, time(11)).is_err());
    261         let nsec = handle.take_recovery_nsec().expect("one-use recovery");
    262         assert_eq!(nsec.with_exposed_secret(str::len), 63);
    263         assert!(handle.take_recovery_nsec().is_err());
    264         assert!(stage.cancel());
    265         assert!(!stage.cancel());
    266         assert!(format!("{view:?}").contains(view.identity().npub().as_str()));
    267         assert!(!format!("{view:?}").contains("nsec1"));
    268     }
    269 
    270     #[test]
    271     fn stage_expires_and_is_destroyed_on_owner_drop() {
    272         let mut stage = GeneratedKeyStage::default();
    273         let key_material = TestKeyMaterialProvider::default();
    274         stage
    275             .begin(&key_material, id(1), 0, time(20))
    276             .expect("begin");
    277         let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl");
    278         assert!(stage.expire(time(expiry)));
    279         assert!(stage.pending().is_none());
    280         assert!(stage.take(id(1), time(expiry)).is_err());
    281 
    282         let mut shutdown_stage = GeneratedKeyStage::default();
    283         shutdown_stage
    284             .begin(&key_material, id(2), 0, time(30))
    285             .expect("begin");
    286         drop(shutdown_stage);
    287     }
    288 }