app

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

commit 627537793abb2a72067dee89dd05dca630a91657
parent dcc6042618354018d0a268501fb31273d4c05030
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:15:50 +0000

custody: stage generated keys in Rust

- add an exclusive actor-owned generated-key recovery stage
- keep staged credentials and recovery material outside snapshots
- expire or cancel pending stages through zeroizing ownership
- clear unfinished recovery material during runtime shutdown

Diffstat:
Acore/crates/application/src/custody.rs | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/application/src/lib.rs | 4++++
Mcore/crates/storage/src/runtime_actor.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
3 files changed, 294 insertions(+), 7 deletions(-)

diff --git a/core/crates/application/src/custody.rs b/core/crates/application/src/custody.rs @@ -0,0 +1,204 @@ +use std::time::Duration; + +use radroots_studio_domain::{ + AccountCreatedAt, AccountIdentity, AccountSummary, BindingAvailability, LocalSignerBinding, + Nsec, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput, UnixTimestamp, +}; +use radroots_studio_nostr::generate_local_keypair; + +pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeneratedKeyStageView { + account: AccountSummary, + expires_at: UnixTimestamp, +} + +impl GeneratedKeyStageView { + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + #[must_use] + pub const fn expires_at(&self) -> UnixTimestamp { + self.expires_at + } +} + +pub struct StagedGeneratedKey { + account: AccountSummary, + secret: SecretKeyInput, + recovery_nsec: Nsec, + expected_revision: u64, + expires_at: UnixTimestamp, +} + +impl StagedGeneratedKey { + #[must_use] + pub fn view(&self) -> GeneratedKeyStageView { + GeneratedKeyStageView { + account: self.account.clone(), + expires_at: self.expires_at, + } + } + + #[must_use] + pub const fn expected_revision(&self) -> u64 { + self.expected_revision + } + + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account + } + + pub fn with_recovery_nsec<T>(&self, operation: impl FnOnce(&str) -> T) -> T { + self.recovery_nsec.with_exposed_secret(operation) + } + + #[must_use] + pub fn into_commit_parts(self) -> (AccountSummary, SecretKeyInput) { + (self.account, self.secret) + } +} + +#[derive(Default)] +pub struct GeneratedKeyStage { + pending: Option<StagedGeneratedKey>, +} + +impl GeneratedKeyStage { + /// Replaces an expired stage or creates the only active generated-key stage. + /// + /// # Errors + /// + /// Returns a safe conflict while an unexpired recovery stage is active. + pub fn begin( + &mut self, + expected_revision: u64, + now: UnixTimestamp, + ) -> Result<GeneratedKeyStageView, SafeError> { + self.expire(now); + if self.pending.is_some() { + return Err(recovery_in_progress()); + } + let generated = generate_local_keypair()?; + let (public_key, npub, secret, recovery_nsec) = generated.into_parts(); + let account = AccountSummary::new( + AccountIdentity::verify(public_key, npub.as_str().to_owned())?, + LocalSignerBinding::new(public_key, BindingAvailability::Available), + None, + AccountCreatedAt::new(now), + None, + )?; + let ttl = + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).map_err(|_| invalid_stage_expiry())?; + let expires_at = now + .as_seconds() + .checked_add(ttl) + .and_then(UnixTimestamp::from_seconds) + .ok_or_else(invalid_stage_expiry)?; + let pending = StagedGeneratedKey { + account, + secret, + recovery_nsec, + expected_revision, + expires_at, + }; + let view = pending.view(); + self.pending = Some(pending); + Ok(view) + } + + pub fn cancel(&mut self) -> bool { + self.pending.take().is_some() + } + + pub fn expire(&mut self, now: UnixTimestamp) -> bool { + if self + .pending + .as_ref() + .is_some_and(|pending| now >= pending.expires_at) + { + self.pending = None; + true + } else { + false + } + } + + #[must_use] + pub const fn pending(&self) -> Option<&StagedGeneratedKey> { + self.pending.as_ref() + } + + /// Consumes the active, unexpired stage for its commit boundary. + /// + /// # Errors + /// + /// Returns a safe unavailable error when no live stage remains. + pub fn take(&mut self, now: UnixTimestamp) -> Result<StagedGeneratedKey, SafeError> { + self.expire(now); + self.pending.take().ok_or_else(recovery_not_available) + } +} + +const fn recovery_in_progress() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("A generated-key recovery step is already in progress."), + ) +} + +const fn recovery_not_available() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The generated-key recovery step is no longer available."), + ) +} + +const fn invalid_stage_expiry() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("The generated-key recovery expiry is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_domain::UnixTimestamp; + + use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage}; + + fn time(seconds: i64) -> UnixTimestamp { + UnixTimestamp::from_seconds(seconds).expect("time") + } + + #[test] + fn stage_is_exclusive_cancelable_and_never_publishes_secret_debug() { + let mut stage = GeneratedKeyStage::default(); + let view = stage.begin(4, time(10)).expect("begin"); + assert_eq!(view.expires_at().as_seconds(), 310); + assert_eq!(stage.pending().expect("pending").expected_revision(), 4); + assert!(stage.begin(4, time(11)).is_err()); + assert!(stage.cancel()); + assert!(!stage.cancel()); + assert!(format!("{view:?}").contains(view.account().npub().as_str())); + assert!(!format!("{view:?}").contains("nsec1")); + } + + #[test] + fn stage_expires_and_is_destroyed_on_owner_drop() { + let mut stage = GeneratedKeyStage::default(); + stage.begin(0, time(20)).expect("begin"); + let expiry = 20 + i64::try_from(GENERATED_KEY_STAGE_TTL.as_secs()).expect("ttl"); + assert!(stage.expire(time(expiry))); + assert!(stage.pending().is_none()); + assert!(stage.take(time(expiry)).is_err()); + + let mut shutdown_stage = GeneratedKeyStage::default(); + shutdown_stage.begin(0, time(30)).expect("begin"); + drop(shutdown_stage); + } +} diff --git a/core/crates/application/src/lib.rs b/core/crates/application/src/lib.rs @@ -5,6 +5,7 @@ pub mod actor; pub mod app_core; mod change_stream; pub mod config; +pub mod custody; pub mod nostr_client; pub mod ports; mod profile_refresh; @@ -30,6 +31,9 @@ pub use change_stream::{ pub use config::{ RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, }; +pub use custody::{ + GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, GeneratedKeyStageView, StagedGeneratedKey, +}; pub use nostr_client::SdkNostrClient; pub use ports::{ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey, diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs @@ -8,10 +8,10 @@ use std::time::{Duration, Instant}; use radroots_studio_application::{ ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, CommandReceipt, CommandResult, CommandSubmission, ForegroundSessionBinding, - GenerateAccountReceipt, ImportAccountReceipt, LifecycleGate, NostrClient, - OrderedSnapshotChanges, ProfileRefreshPlan, RelayConfiguration, RemovalConfirmationToken, - RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, SessionGeneration, - SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, TaskCorrelation, + GenerateAccountReceipt, GeneratedKeyStage, GeneratedKeyStageView, ImportAccountReceipt, + LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RelayConfiguration, + RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, + SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, TaskCorrelation, }; use radroots_studio_domain::{ AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey, @@ -28,6 +28,8 @@ const DEFAULT_TASK_CAPACITY: usize = 64; enum RuntimeCommand { Snapshot, GenerateAccount, + BeginGeneratedKeyStage, + CancelGeneratedKeyStage, ImportSecretKey(SecretKeyInput), SelectAccount(PublicKey), ActivateAccount(PublicKey), @@ -43,6 +45,8 @@ enum RuntimeCommand { enum RuntimeCommandValue { Snapshot(Box<AppSnapshot>), Generated(GenerateAccountReceipt), + GeneratedKeyStage(GeneratedKeyStageView), + GeneratedKeyStageCancelled(bool), Imported(ImportAccountReceipt), RemovalRequest(RemovalConfirmationToken), Subscription(RuntimeChangeSubscription), @@ -57,12 +61,14 @@ impl RuntimeCommand { RuntimeCommandClass::Observe } Self::GenerateAccount + | Self::BeginGeneratedKeyStage | Self::ImportSecretKey(_) | Self::ActivateAccount(_) | Self::ConfirmAccountRemoval(_) => RuntimeCommandClass::UseCredential, - Self::SelectAccount(_) | Self::SignOut | Self::RequestAccountRemoval(_) => { - RuntimeCommandClass::MutateLocalState - } + Self::SelectAccount(_) + | Self::SignOut + | Self::RequestAccountRemoval(_) + | Self::CancelGeneratedKeyStage => RuntimeCommandClass::MutateLocalState, Self::RefreshActiveProfile => RuntimeCommandClass::UseRelay, Self::Close => RuntimeCommandClass::Shutdown, } @@ -82,6 +88,7 @@ struct RuntimeActor { changes: OrderedSnapshotChanges, published_foreground_session: Arc<Mutex<Option<ForegroundSessionBinding>>>, durable_request_namespace: String, + generated_key_stage: GeneratedKeyStage, } struct PendingProfileTask { @@ -213,6 +220,7 @@ impl RuntimeActorHandle { changes, published_foreground_session: Arc::clone(&foreground_session), durable_request_namespace, + generated_key_stage: GeneratedKeyStage::default(), }; drop(runtime.spawn(actor.run(receiver))); Ok(Self { @@ -272,6 +280,36 @@ impl RuntimeActorHandle { } } + /// Begins the only actor-owned generated-key recovery stage. + /// + /// # Errors + /// + /// Returns a safe conflict, timeout, key-generation, or actor error. + pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyStageView, SafeError> { + match self + .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None) + .await? + { + RuntimeCommandValue::GeneratedKeyStage(view) => Ok(view), + _ => Err(invalid_actor_response()), + } + } + + /// Cancels and zeroizes the active generated-key stage, if present. + /// + /// # Errors + /// + /// Returns a safe timeout or actor error. + pub async fn cancel_generated_key_stage(&self) -> Result<bool, SafeError> { + match self + .dispatch(RuntimeCommand::CancelGeneratedKeyStage, None) + .await? + { + RuntimeCommandValue::GeneratedKeyStageCancelled(cancelled) => Ok(cancelled), + _ => Err(invalid_actor_response()), + } + } + /// Imports one account through the serialized actor boundary. /// /// # Errors @@ -629,6 +667,13 @@ impl RuntimeActor { ) .map(RuntimeCommandValue::Generated) }), + RuntimeCommand::BeginGeneratedKeyStage => self + .generated_key_stage + .begin(expected_revision, self.clock.now()) + .map(RuntimeCommandValue::GeneratedKeyStage), + RuntimeCommand::CancelGeneratedKeyStage => Ok( + RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), + ), RuntimeCommand::ImportSecretKey(input) => durable_request.and_then(|request| { self.adapter .import_secret_key_durable( @@ -750,6 +795,7 @@ impl RuntimeActor { })(); match transition { Ok(()) => { + self.generated_key_stage.cancel(); self.cancel_profile_tasks(None); self.changes.close(); *self @@ -1096,6 +1142,39 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] + async fn generated_key_stage_is_exclusive_cancelable_and_snapshot_free() { + let (actor, secrets) = actor(); + let initial = actor.snapshot(); + let stage = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + + assert!(actor.begin_generated_key_stage().await.is_err()); + assert_eq!(actor.snapshot(), initial); + assert!( + !secrets + .contains(stage.account().public_key()) + .expect("keyring") + ); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + assert!( + !actor + .cancel_generated_key_stage() + .await + .expect("cancel empty") + ); + assert_eq!(actor.snapshot(), initial); + + actor + .begin_generated_key_stage() + .await + .expect("replacement stage"); + actor.close().await.expect("close clears stage"); + assert_eq!(actor.lifecycle(), RuntimeLifecycle::Closed); + } + + #[tokio::test(flavor = "multi_thread")] async fn session_generation_cancels_correlated_profile_work_on_sign_out() { let client = Arc::new(BlockingNostr::new()); let actor = RuntimeActorHandle::in_memory(