commit 0678e3748194843d941ac52fda442a6c8f4ffe62
parent 62bb087a0342004de85a0e65de24bf4aabe71dcb
Author: triesap <tyson@radroots.org>
Date: Tue, 4 Aug 2026 08:04:31 +0000
accounts: enforce exclusive generated recovery
- gate unrelated actor mutations while generated recovery is active
- cancel in-flight profile work when the recovery route begins
- preserve observation and shutdown access during exclusive custody
- return generated-recovery-specific expiration diagnostics
Diffstat:
2 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs
@@ -284,7 +284,7 @@ impl StudioAppCore {
request: Arc<GeneratedRecoveryRequest>,
) -> Result<AppSnapshotDto, StudioError> {
if request.resolved.swap(true, Ordering::AcqRel) {
- return Err(confirmation_expired());
+ return Err(generated_recovery_expired());
}
self.inner
.actor
@@ -606,6 +606,17 @@ fn confirmation_expired() -> StudioError {
}
}
+fn generated_recovery_expired() -> StudioError {
+ StudioError::Failure {
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
+ safe_message: "The generated-key recovery step is no longer valid.".to_owned(),
+ }
+}
+
fn compatibility_mismatch() -> StudioError {
StudioError::Failure {
code: WireErrorCode::CompatibilityMismatch,
@@ -631,8 +642,9 @@ mod tests {
use super::{
ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME,
DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR,
- FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, SystemClock,
- compatibility_descriptor, local_first_relay_configuration, runtime, verify_compatibility,
+ FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError,
+ SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime,
+ verify_compatibility,
};
fn in_memory_core() -> Arc<StudioAppCore> {
@@ -707,11 +719,15 @@ mod tests {
.await
.expect("acknowledge");
assert_eq!(committed.accounts.len(), 1);
- assert!(
- core.acknowledge_generated_account_v2(recovery)
- .await
- .is_err()
- );
+ let repeated = core
+ .acknowledge_generated_account_v2(recovery)
+ .await
+ .expect_err("repeated acknowledgement");
+ assert!(matches!(
+ repeated,
+ StudioError::Failure { safe_message, .. }
+ if safe_message == "The generated-key recovery step is no longer valid."
+ ));
}
#[test]
diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs
@@ -669,7 +669,12 @@ impl RuntimeActor {
| RuntimeCommand::SignOut
| RuntimeCommand::ConfirmAccountRemoval(_)
);
+ let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage);
let result = self.execute_sync(context, command);
+ if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) {
+ let snapshot = self.adapter.core().snapshot();
+ self.cancel_profile_tasks(Some(&snapshot));
+ }
if changes_session && matches!(result, CommandResult::Completed(_)) {
self.advance_session_generation();
self.synchronize_foreground_session();
@@ -700,6 +705,19 @@ impl RuntimeActor {
if !lifecycle.allows(command.class()) {
return Some(CommandResult::Failed(command_unavailable()));
}
+ if self.generated_key_stage.pending().is_some()
+ && !matches!(
+ command,
+ RuntimeCommand::Snapshot
+ | RuntimeCommand::AcknowledgeGeneratedKeyStage(_)
+ | RuntimeCommand::CancelGeneratedKeyStage
+ | RuntimeCommand::SubscribeChanges(_)
+ | RuntimeCommand::UnsubscribeChanges(_)
+ | RuntimeCommand::Close
+ )
+ {
+ return Some(CommandResult::Failed(generated_recovery_route_active()));
+ }
let current_revision = self.adapter.core().snapshot().revision();
if context
.expected_revision()
@@ -1092,6 +1110,13 @@ const fn command_unavailable() -> SafeError {
)
}
+const fn generated_recovery_route_active() -> SafeError {
+ SafeError::new(
+ SafeErrorCode::InvalidApplicationState,
+ SafeMessage::new("Complete or cancel generated-key recovery before another action."),
+ )
+}
+
const fn invalid_actor_response() -> SafeError {
SafeError::new(
SafeErrorCode::InvalidApplicationState,
@@ -1305,6 +1330,8 @@ mod tests {
.contains(stage.view().account().public_key())
.expect("keyring")
);
+ assert!(actor.sign_out().await.is_err());
+ assert_eq!(actor.snapshot(), initial);
assert!(actor.cancel_generated_key_stage().await.expect("cancel"));
assert!(
!actor