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:
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(