lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 11f1fc28540bdc1977fff99aa5fe679630f65eff
parent ed60ad52cf4bacdaa8766a5cc5fa894238304afd
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:20:38 +0000

custody: commit generated keys after acknowledgement

- return generated recovery material through a one-use handle
- bind acknowledgement to the exclusive actor-owned stage
- defer account and credential persistence until acknowledgement
- reject repeated recovery reads and repeated stage commits

Diffstat:
Mcrates/studio_application/src/accounts.rs | 37++++++++++++++++++++++++++++++++++++-
Mcrates/studio_application/src/custody.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/studio_application/src/lib.rs | 3++-
Mcrates/studio_storage/src/application_adapter.rs | 25++++++++++++++++++++++++-
Mcrates/studio_storage/src/runtime_actor.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
5 files changed, 236 insertions(+), 27 deletions(-)

diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -11,7 +11,7 @@ use crate::{ Clock, DurableOperationKind, DurableOperationPhase, DurableOperationRepository, DurableOperationStart, DurableRequestId, DurableTerminalOutcome, OperationDiagnostic, OperationId, OperationJournal, OperationPriorState, PendingAccountOperation, - RemovalConfirmationToken, SecretStore, StateTransition, + RemovalConfirmationToken, SecretStore, StagedGeneratedKey, StateTransition, }; pub struct GenerateAccountReceipt { @@ -44,6 +44,41 @@ impl GenerateAccountReceipt { } impl AppCore { + /// Commits a staged generated key only after its recovery acknowledgement. + /// + /// # Errors + /// + /// Returns a safe conflict, keyring, persistence, or recovery error. + #[allow(clippy::too_many_arguments)] + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + operations: &(impl DurableOperationRepository + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + let expected_revision = staged.expected_revision(); + self.require_revision(expected_revision)?; + let (account, secret) = staged.into_commit_parts(); + self.persist_account_durable( + request_id, + DurableOperationKind::Create, + expected_revision, + &account, + secret, + None, + accounts, + app_state, + secrets, + operations, + clock, + )?; + Ok(ImportAccountReceipt { account }) + } + /// Generates and commits one account under a durable caller request. /// /// # Errors diff --git a/crates/studio_application/src/custody.rs b/crates/studio_application/src/custody.rs @@ -1,3 +1,5 @@ +use std::num::NonZeroU64; +use std::sync::Mutex; use std::time::Duration; use radroots_studio_domain::{ @@ -8,6 +10,21 @@ use radroots_studio_nostr::generate_local_keypair; pub const GENERATED_KEY_STAGE_TTL: Duration = Duration::from_mins(5); +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct RecoveryStageId(NonZeroU64); + +impl RecoveryStageId { + #[must_use] + pub const fn new(value: NonZeroU64) -> Self { + Self(value) + } + + #[must_use] + pub const fn value(self) -> u64 { + self.0.get() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct GeneratedKeyStageView { account: AccountSummary, @@ -27,9 +44,9 @@ impl GeneratedKeyStageView { } pub struct StagedGeneratedKey { + id: RecoveryStageId, account: AccountSummary, secret: SecretKeyInput, - recovery_nsec: Nsec, expected_revision: u64, expires_at: UnixTimestamp, } @@ -49,12 +66,13 @@ impl StagedGeneratedKey { } #[must_use] - pub const fn account(&self) -> &AccountSummary { - &self.account + pub const fn id(&self) -> RecoveryStageId { + self.id } - pub fn with_recovery_nsec<T>(&self, operation: impl FnOnce(&str) -> T) -> T { - self.recovery_nsec.with_exposed_secret(operation) + #[must_use] + pub const fn account(&self) -> &AccountSummary { + &self.account } #[must_use] @@ -63,6 +81,45 @@ impl StagedGeneratedKey { } } +pub struct GeneratedKeyRecoveryHandle { + id: RecoveryStageId, + view: GeneratedKeyStageView, + recovery_nsec: Mutex<Option<Nsec>>, +} + +impl GeneratedKeyRecoveryHandle { + fn new(id: RecoveryStageId, view: GeneratedKeyStageView, recovery_nsec: Nsec) -> Self { + Self { + id, + view, + recovery_nsec: Mutex::new(Some(recovery_nsec)), + } + } + + #[must_use] + pub const fn id(&self) -> RecoveryStageId { + self.id + } + + #[must_use] + pub const fn view(&self) -> &GeneratedKeyStageView { + &self.view + } + + /// Returns the generated recovery value exactly once. + /// + /// # Errors + /// + /// Returns a safe unavailable error after the value was already consumed. + pub fn take_recovery_nsec(&self) -> Result<Nsec, SafeError> { + self.recovery_nsec + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .ok_or_else(recovery_not_available) + } +} + #[derive(Default)] pub struct GeneratedKeyStage { pending: Option<StagedGeneratedKey>, @@ -76,9 +133,10 @@ impl GeneratedKeyStage { /// Returns a safe conflict while an unexpired recovery stage is active. pub fn begin( &mut self, + id: RecoveryStageId, expected_revision: u64, now: UnixTimestamp, - ) -> Result<GeneratedKeyStageView, SafeError> { + ) -> Result<GeneratedKeyRecoveryHandle, SafeError> { self.expire(now); if self.pending.is_some() { return Err(recovery_in_progress()); @@ -100,15 +158,15 @@ impl GeneratedKeyStage { .and_then(UnixTimestamp::from_seconds) .ok_or_else(invalid_stage_expiry)?; let pending = StagedGeneratedKey { + id, account, secret, - recovery_nsec, expected_revision, expires_at, }; let view = pending.view(); self.pending = Some(pending); - Ok(view) + Ok(GeneratedKeyRecoveryHandle::new(id, view, recovery_nsec)) } pub fn cancel(&mut self) -> bool { @@ -138,8 +196,15 @@ impl GeneratedKeyStage { /// # Errors /// /// Returns a safe unavailable error when no live stage remains. - pub fn take(&mut self, now: UnixTimestamp) -> Result<StagedGeneratedKey, SafeError> { + pub fn take( + &mut self, + id: RecoveryStageId, + now: UnixTimestamp, + ) -> Result<StagedGeneratedKey, SafeError> { self.expire(now); + if self.pending.as_ref().map(StagedGeneratedKey::id) != Some(id) { + return Err(recovery_not_available()); + } self.pending.take().ok_or_else(recovery_not_available) } } @@ -167,21 +232,31 @@ const fn invalid_stage_expiry() -> SafeError { #[cfg(test)] mod tests { + use std::num::NonZeroU64; + use radroots_studio_domain::UnixTimestamp; - use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage}; + use super::{GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, RecoveryStageId}; fn time(seconds: i64) -> UnixTimestamp { UnixTimestamp::from_seconds(seconds).expect("time") } + fn id(value: u64) -> RecoveryStageId { + RecoveryStageId::new(NonZeroU64::new(value).expect("id")) + } + #[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"); + let handle = stage.begin(id(1), 4, time(10)).expect("begin"); + let view = handle.view(); 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.begin(id(2), 4, time(11)).is_err()); + let nsec = handle.take_recovery_nsec().expect("one-use recovery"); + assert_eq!(nsec.with_exposed_secret(str::len), 63); + assert!(handle.take_recovery_nsec().is_err()); assert!(stage.cancel()); assert!(!stage.cancel()); assert!(format!("{view:?}").contains(view.account().npub().as_str())); @@ -191,14 +266,14 @@ mod tests { #[test] fn stage_expires_and_is_destroyed_on_owner_drop() { let mut stage = GeneratedKeyStage::default(); - stage.begin(0, time(20)).expect("begin"); + stage.begin(id(1), 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()); + assert!(stage.take(id(1), time(expiry)).is_err()); let mut shutdown_stage = GeneratedKeyStage::default(); - shutdown_stage.begin(0, time(30)).expect("begin"); + shutdown_stage.begin(id(2), 0, time(30)).expect("begin"); drop(shutdown_stage); } } diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -32,7 +32,8 @@ pub use config::{ RelayRuntimeMode, relay_configuration_from_environment, relay_configuration_from_value, }; pub use custody::{ - GENERATED_KEY_STAGE_TTL, GeneratedKeyStage, GeneratedKeyStageView, StagedGeneratedKey, + GENERATED_KEY_STAGE_TTL, GeneratedKeyRecoveryHandle, GeneratedKeyStage, GeneratedKeyStageView, + RecoveryStageId, StagedGeneratedKey, }; pub use nostr_client::SdkNostrClient; pub use ports::{ diff --git a/crates/studio_storage/src/application_adapter.rs b/crates/studio_storage/src/application_adapter.rs @@ -2,7 +2,7 @@ use std::path::Path; use radroots_studio_application::{ AppCore, AppSnapshot, Clock, DurableRequestId, GenerateAccountReceipt, ImportAccountReceipt, - RelayConfiguration, RemovalConfirmationToken, SecretStore, + RelayConfiguration, RemovalConfirmationToken, SecretStore, StagedGeneratedKey, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput}; @@ -14,6 +14,29 @@ pub struct PersistentAppCore { } impl PersistentAppCore { + /// Commits an acknowledged generated-key stage through the durable coordinator. + /// + /// # Errors + /// + /// Returns a safe conflict, credential, storage, or recovery error. + pub fn commit_staged_generated_key( + &self, + request_id: &DurableRequestId, + staged: StagedGeneratedKey, + secrets: &(impl SecretStore + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<ImportAccountReceipt, SafeError> { + self.core.commit_staged_generated_key( + request_id, + staged, + &self.database, + &self.database, + secrets, + &self.database, + clock, + ) + } + /// Opens the application database without accessing credentials or relays. /// /// # Errors diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -1,5 +1,5 @@ use std::collections::BTreeMap; -use std::num::NonZeroUsize; +use std::num::{NonZeroU64, NonZeroUsize}; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -8,10 +8,11 @@ use std::time::{Duration, Instant}; use radroots_studio_application::{ ActorMailbox, AppSnapshot, ChangeSubscriptionId, Clock, CommandContext, CommandEnvelope, CommandReceipt, CommandResult, CommandSubmission, ForegroundSessionBinding, - GenerateAccountReceipt, GeneratedKeyStage, GeneratedKeyStageView, ImportAccountReceipt, - LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RelayConfiguration, - RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, SecretStore, - SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, TaskCorrelation, + GenerateAccountReceipt, GeneratedKeyRecoveryHandle, GeneratedKeyStage, ImportAccountReceipt, + LifecycleGate, NostrClient, OrderedSnapshotChanges, ProfileRefreshPlan, RecoveryStageId, + RelayConfiguration, RemovalConfirmationToken, RequestId, RuntimeCommandClass, RuntimeLifecycle, + SecretStore, SessionGeneration, SnapshotChange, SnapshotChangeReceiver, SnapshotRevision, + TaskCorrelation, }; use radroots_studio_domain::{ AccountIdentity, BindingAvailability, Kind0ProfileCandidate, LocalSignerBinding, PublicKey, @@ -29,6 +30,7 @@ enum RuntimeCommand { Snapshot, GenerateAccount, BeginGeneratedKeyStage, + AcknowledgeGeneratedKeyStage(RecoveryStageId), CancelGeneratedKeyStage, ImportSecretKey(SecretKeyInput), SelectAccount(PublicKey), @@ -45,7 +47,7 @@ enum RuntimeCommand { enum RuntimeCommandValue { Snapshot(Box<AppSnapshot>), Generated(GenerateAccountReceipt), - GeneratedKeyStage(GeneratedKeyStageView), + GeneratedKeyStage(GeneratedKeyRecoveryHandle), GeneratedKeyStageCancelled(bool), Imported(ImportAccountReceipt), RemovalRequest(RemovalConfirmationToken), @@ -62,6 +64,7 @@ impl RuntimeCommand { } Self::GenerateAccount | Self::BeginGeneratedKeyStage + | Self::AcknowledgeGeneratedKeyStage(_) | Self::ImportSecretKey(_) | Self::ActivateAccount(_) | Self::ConfirmAccountRemoval(_) => RuntimeCommandClass::UseCredential, @@ -285,7 +288,7 @@ impl RuntimeActorHandle { /// # Errors /// /// Returns a safe conflict, timeout, key-generation, or actor error. - pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyStageView, SafeError> { + pub async fn begin_generated_key_stage(&self) -> Result<GeneratedKeyRecoveryHandle, SafeError> { match self .dispatch(RuntimeCommand::BeginGeneratedKeyStage, None) .await? @@ -295,6 +298,21 @@ impl RuntimeActorHandle { } } + /// Acknowledges recovery and commits the staged account and credential once. + /// + /// # Errors + /// + /// Returns a safe unavailable, conflict, keyring, storage, timeout, or actor error. + pub async fn acknowledge_generated_key_stage( + &self, + id: RecoveryStageId, + ) -> Result<AppSnapshot, SafeError> { + let value = self + .dispatch(RuntimeCommand::AcknowledgeGeneratedKeyStage(id), None) + .await?; + Self::expect_snapshot(value) + } + /// Cancels and zeroizes the active generated-key stage, if present. /// /// # Errors @@ -669,8 +687,18 @@ impl RuntimeActor { }), RuntimeCommand::BeginGeneratedKeyStage => self .generated_key_stage - .begin(expected_revision, self.clock.now()) + .begin( + RecoveryStageId::new( + NonZeroU64::new(context.request_id().get()) + .expect("request IDs are always non-zero"), + ), + expected_revision, + self.clock.now(), + ) .map(RuntimeCommandValue::GeneratedKeyStage), + RuntimeCommand::AcknowledgeGeneratedKeyStage(id) => { + durable_request.and_then(|request| self.commit_generated_key_stage(&request, id)) + } RuntimeCommand::CancelGeneratedKeyStage => Ok( RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), ), @@ -732,6 +760,23 @@ impl RuntimeActor { result.map_or_else(CommandResult::Failed, CommandResult::Completed) } + fn commit_generated_key_stage( + &mut self, + request: &radroots_studio_application::DurableRequestId, + id: RecoveryStageId, + ) -> Result<RuntimeCommandValue, SafeError> { + let staged = self.generated_key_stage.take(id, self.clock.now())?; + self.adapter.commit_staged_generated_key( + request, + staged, + self.secrets.as_ref(), + self.clock.as_ref(), + )?; + Ok(RuntimeCommandValue::Snapshot(Box::new( + self.adapter.core().snapshot(), + ))) + } + fn start_profile_task( &mut self, context: CommandContext, @@ -1154,7 +1199,7 @@ mod tests { assert_eq!(actor.snapshot(), initial); assert!( !secrets - .contains(stage.account().public_key()) + .contains(stage.view().account().public_key()) .expect("keyring") ); assert!(actor.cancel_generated_key_stage().await.expect("cancel")); @@ -1175,6 +1220,36 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] + async fn recovery_handle_is_one_use_and_acknowledgement_commits_once() { + let (actor, secrets) = actor(); + let initial = actor.snapshot(); + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + let public_key = handle.view().account().public_key(); + let recovery = handle.take_recovery_nsec().expect("recovery material"); + assert_eq!(recovery.with_exposed_secret(str::len), 63); + assert!(handle.take_recovery_nsec().is_err()); + assert_eq!(actor.snapshot(), initial); + assert!(!secrets.contains(public_key).expect("not committed")); + + let committed = actor + .acknowledge_generated_key_stage(handle.id()) + .await + .expect("acknowledge"); + assert_eq!(committed.accounts().len(), 1); + assert_eq!(committed.selected_account(), Some(public_key)); + assert!(secrets.contains(public_key).expect("credential committed")); + assert!( + actor + .acknowledge_generated_key_stage(handle.id()) + .await + .is_err() + ); + } + + #[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(