Allow enableRecovery to derive the key from a passphrase

enableRecovery now takes an optional passphrase and returns the recovery
key as the suspend result (like resetRecoveryKey), avoiding the
flow/return-value race. For the passphrase path the SDK-derived base58
key is kept out of the session-scoped progress flow so it is never
surfaced to the user; the caller receives it as the return value and is
responsible for scrubbing it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-06-02 13:52:08 -07:00
parent 62ac6a3d2f
commit 0cb9d2911d
3 changed files with 27 additions and 11 deletions
@@ -24,9 +24,15 @@ interface EncryptionService {
suspend fun enableBackups(): Result<Unit> suspend fun enableBackups(): Result<Unit>
/** /**
* Enable recovery. Observe enableProgressStateFlow to get progress and recovery key. * Enable recovery and return the SDK-generated recovery key on success.
* Observe [enableRecoveryProgressStateFlow] for in-progress UI updates.
*
* @param waitForBackupsToUpload when true, suspends until existing room keys finish uploading.
* @param passphrase optional user-supplied passphrase. When set, the SDK derives the
* secret-storage key from it instead of a random base58 key; the passphrase can later be
* passed to [recover], and the returned base58 key should not be surfaced to the user.
*/ */
suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit> suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String? = null): Result<String>
/** /**
* Change the recovery and return the new recovery key. * Change the recovery and return the new recovery key.
@@ -131,19 +131,28 @@ class RustEncryptionService(
override suspend fun enableRecovery( override suspend fun enableRecovery(
waitForBackupsToUpload: Boolean, waitForBackupsToUpload: Boolean,
): Result<Unit> = withContext(dispatchers.io) { passphrase: String?,
): Result<String> = withContext(dispatchers.io) {
runCatchingExceptions { runCatchingExceptions {
service.enableRecovery( // The key arrives as the suspend return value (like resetRecoveryKey), avoiding a
// flow/return-value race; the listener only feeds sub-progress.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Starting
val key = service.enableRecovery(
waitForBackupsToUpload = waitForBackupsToUpload, waitForBackupsToUpload = waitForBackupsToUpload,
progressListener = object : EnableRecoveryProgressListener { progressListener = object : EnableRecoveryProgressListener {
override fun onUpdate(status: RustEnableRecoveryProgress) { override fun onUpdate(status: RustEnableRecoveryProgress) {
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status) enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
} }
}, },
passphrase = null, passphrase = passphrase,
) )
// enableRecovery returns the encryption key, but we read it from the state flow // Pin Done explicitly so observers get a coherent terminal value. For the passphrase
.let { } // path the user never sees the SDK base58 key, so keep it out of this session-scoped
// (in-memory) flow entirely; the caller still receives it as the return value and is
// responsible for scrubbing it. The auto-generated path must retain the key here since
// that is how it is surfaced to the user.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Done(if (passphrase != null) "" else key)
key
}.mapFailure { }.mapFailure {
it.mapRecoveryException() it.mapRecoveryException()
} }
@@ -28,7 +28,8 @@ class FakeEncryptionService(
private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() }, private val pinUserIdentityResult: (UserId) -> Result<Unit> = { lambdaError() },
private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() }, private val withdrawVerificationResult: (UserId) -> Result<Unit> = { lambdaError() },
private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() }, private val getUserIdentityResult: (UserId) -> Result<IdentityState?> = { lambdaError() },
private val enableRecoveryLambda: (Boolean) -> Result<Unit> = { lambdaError() }, private val enableRecoveryLambda: (Boolean, String?) -> Result<String> = { _, _ -> lambdaError() },
private val resetRecoveryKeyLambda: () -> Result<String> = { Result.success(FAKE_RECOVERY_KEY) },
) : EncryptionService { ) : EncryptionService {
private var disableRecoveryFailure: Exception? = null private var disableRecoveryFailure: Exception? = null
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN) override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
@@ -90,11 +91,11 @@ class FakeEncryptionService(
} }
override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask { override suspend fun resetRecoveryKey(): Result<String> = simulateLongTask {
return Result.success(FAKE_RECOVERY_KEY) return resetRecoveryKeyLambda()
} }
override suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result<Unit> = simulateLongTask { override suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String?): Result<String> = simulateLongTask {
return enableRecoveryLambda(waitForBackupsToUpload) return enableRecoveryLambda(waitForBackupsToUpload, passphrase)
} }
fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) { fun givenWaitForBackupUploadSteadyStateFlow(flow: Flow<BackupUploadState>) {