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 }