commit 5ea63f8112d28f9a272932774a919a9889a7b5ac
parent 627537793abb2a72067dee89dd05dca630a91657
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:
5 files changed, 236 insertions(+), 27 deletions(-)
diff --git a/core/crates/application/src/accounts.rs b/core/crates/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/core/crates/application/src/custody.rs b/core/crates/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/core/crates/application/src/lib.rs b/core/crates/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/core/crates/storage/src/application_adapter.rs b/core/crates/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/core/crates/storage/src/runtime_actor.rs b/core/crates/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(