feat(backup): add RestoreManager (request/fulfill/complete partner-assisted restore with OOB code gate)

This commit is contained in:
null 2026-06-30 20:42:58 -05:00
parent c8b0130f1c
commit ed3c3e4d22
2 changed files with 243 additions and 0 deletions

View File

@ -0,0 +1,131 @@
package app.closer.data.backup
import android.util.Log
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.CoupleKeyTransfer
import app.closer.crypto.UserKeyManager
import app.closer.data.remote.FirestoreBackupDataSource
import app.closer.domain.model.RestoreRequest
import app.closer.domain.model.RestoreStatus
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import java.security.SecureRandom
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates **full partner-assisted restore** (key + content) for both roles:
* - Recipient A ([requestRestore] [completeRestore]): publish a fresh pubkey, then on the partner's
* keybox, unwrap the couple key with no phrase and pull the content backup.
* - Partner B ([fulfillRestore]): after confirming the out-of-band code, wrap the couple key to A's
* pubkey and refresh the backup.
*
* All key material stays client-side (ECIES); the server only relays a `keybox:v1:` blob. Never logs keys.
*/
@Singleton
class RestoreManager @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager,
private val userKeyManager: UserKeyManager,
private val coupleKeyTransfer: CoupleKeyTransfer,
private val backupDataSource: FirestoreBackupDataSource,
private val backupManager: BackupManager,
private val backupRestoreManager: BackupRestoreManager
) {
/** What A shows on screen while waiting: the code to read aloud + who to observe. */
data class RestoreSession(
val coupleId: String,
val recipientUid: String,
val partnerUid: String,
val verificationCode: String
)
// ─── Recipient A ─────────────────────────────────────────────────────────
/** A creates a restore request carrying a fresh public key; returns the OOB code to read aloud. */
suspend fun requestRestore(): Result<RestoreSession> = runCatching {
val uid = authRepository.currentUserId ?: error("not signed in")
val couple = coupleRepository.getCoupleForUser(uid) ?: error("no couple")
val partnerUid = couple.userIds.firstOrNull { it != uid } ?: error("no partner")
val pubKey = userKeyManager.publicKeyB64(userKeyManager.getOrCreatePrivateKey())
val nonce = randomNonce()
val expiresAt = System.currentTimeMillis() + REQUEST_TTL_MS
// A stale prior request (expired/abandoned/READY) would turn createRestoreRequest's set() into an
// UPDATE that changes the pubkey/nonce — which no rule permits (keybox-write needs a different actor;
// status-only update can't touch keys) → PERMISSION_DENIED. Clear it first so a retry always works.
// The recipient may delete their own request, and a fresh pubkey/nonce fully supersedes the old one.
backupDataSource.deleteRestoreRequest(couple.id, uid)
backupDataSource.createRestoreRequest(couple.id, uid, pubKey, nonce, expiresAt)
RestoreSession(
coupleId = couple.id,
recipientUid = uid,
partnerUid = partnerUid,
verificationCode = CoupleKeyTransfer.verificationCode(pubKey, nonce)
)
}
/**
* A completes the restore once the partner has written the keybox (status READY): unwrap the couple
* key with A's private key (no phrase), store it, consume the request, then pull the content backup.
*/
suspend fun completeRestore(
session: RestoreSession,
request: RestoreRequest
): Result<BackupRestoreManager.RestoreResult> = runCatching {
val keybox = request.keybox ?: error("no keybox yet")
val privateKey = userKeyManager.loadPrivateKey() ?: error("device key missing")
val keyset = coupleKeyTransfer.unwrapCoupleKey(
keyboxB64 = keybox,
recipientPrivateKey = privateKey,
coupleId = session.coupleId,
senderUid = session.partnerUid,
recipientUid = session.recipientUid,
nonce = request.requestNonce
)
encryptionManager.storeTransferredKeyset(session.coupleId, keyset)
// Consume the request so no wrapped couple key lingers server-side.
backupDataSource.deleteRestoreRequest(session.coupleId, session.recipientUid)
backupRestoreManager.restore()
}.onFailure { Log.w(TAG, "completeRestore failed", it) }
// ─── Partner B ───────────────────────────────────────────────────────────
/**
* B fulfils A's request AFTER typing the code A read aloud. Verifies the code against A's actual
* pubkey+nonce (defeats a swapped pubkey / account-takeover), wraps the couple key, refreshes the
* backup so A has fresh content, and marks the request READY.
*/
suspend fun fulfillRestore(coupleId: String, recipientUid: String, enteredCode: String): Result<Unit> = runCatching {
val uid = authRepository.currentUserId ?: error("not signed in")
val request = backupDataSource.getRestoreRequest(coupleId, recipientUid) ?: error("no request")
// Never wrap the couple key to a stale request: an expired pubkey may belong to a device the
// recipient has since replaced. Force them to start a fresh request.
require(request.expiresAt <= 0L || request.expiresAt > System.currentTimeMillis()) { "restore request expired" }
val expected = CoupleKeyTransfer.verificationCode(request.recipientPublicKey, request.requestNonce)
require(enteredCode.trim() == expected) { "verification code does not match" }
val coupleKeyset = encryptionManager.exportKeysetForTransfer(coupleId) ?: error("couple key missing on this device")
val keybox = coupleKeyTransfer.wrapCoupleKey(
coupleKeyset = coupleKeyset,
recipientPublicKeyB64 = request.recipientPublicKey,
coupleId = coupleId,
senderUid = uid,
recipientUid = recipientUid,
nonce = request.requestNonce
)
// Make sure the partner can also pull fresh content right after unlocking the key.
runCatching { backupManager.backupNow(force = true) }
backupDataSource.fulfillRestoreRequest(coupleId, recipientUid, keybox)
}.onFailure { Log.w(TAG, "fulfillRestore failed", it) }
private fun randomNonce(): String {
val bytes = ByteArray(16).also { SecureRandom().nextBytes(it) }
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private companion object {
const val TAG = "RestoreManager"
const val REQUEST_TTL_MS = 30 * 60 * 1000L // 30 min
}
}

View File

@ -0,0 +1,112 @@
package app.closer.data.backup
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.CoupleKeyTransfer
import app.closer.crypto.UserKeyManager
import app.closer.data.remote.FirestoreBackupDataSource
import app.closer.domain.model.Couple
import app.closer.domain.model.RestoreRequest
import app.closer.domain.model.RestoreStatus
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Covers the two request-lifecycle fixes:
* - Bug A: [RestoreManager.requestRestore] must DELETE any stale request before creating a fresh one,
* otherwise the create's `set()` becomes a rule-denied key-changing update (a silent retry failure).
* - Bug B: [RestoreManager.fulfillRestore] must REJECT an expired request before wrapping the couple key.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RestoreManagerTest {
private val authRepository: AuthRepository = mockk()
private val coupleRepository: CoupleRepository = mockk()
private val encryptionManager: CoupleEncryptionManager = mockk(relaxed = true)
private val userKeyManager: UserKeyManager = mockk()
private val coupleKeyTransfer: CoupleKeyTransfer = mockk(relaxed = true)
private val backupDataSource: FirestoreBackupDataSource = mockk(relaxed = true)
private val backupManager: BackupManager = mockk(relaxed = true)
private val backupRestoreManager: BackupRestoreManager = mockk(relaxed = true)
private val couple = Couple(id = "c1", userIds = listOf("uidSam", "uidQA"))
private fun manager() = RestoreManager(
authRepository, coupleRepository, encryptionManager, userKeyManager,
coupleKeyTransfer, backupDataSource, backupManager, backupRestoreManager
)
@Test
fun `requestRestore clears a stale request before creating a fresh one`() = runTest {
every { authRepository.currentUserId } returns "uidQA"
coEvery { coupleRepository.getCoupleForUser("uidQA") } returns couple
every { userKeyManager.getOrCreatePrivateKey() } returns mockk()
every { userKeyManager.publicKeyB64(any()) } returns "pub:v1:x"
val result = manager().requestRestore()
assertTrue(result.isSuccess)
coVerifyOrder {
backupDataSource.deleteRestoreRequest("c1", "uidQA")
backupDataSource.createRestoreRequest("c1", "uidQA", "pub:v1:x", any(), any())
}
}
@Test
fun `fulfillRestore rejects an expired request and never writes the keybox`() = runTest {
every { authRepository.currentUserId } returns "uidSam"
coEvery { backupDataSource.getRestoreRequest("c1", "uidQA") } returns RestoreRequest(
recipientUid = "uidQA", recipientPublicKey = "pub:v1:x", requestNonce = "n",
status = RestoreStatus.REQUESTED, createdAt = 1L,
expiresAt = System.currentTimeMillis() - 1_000
)
val result = manager().fulfillRestore("c1", "uidQA", "123456")
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message?.contains("expired", ignoreCase = true) == true)
coVerify(exactly = 0) { encryptionManager.exportKeysetForTransfer(any()) }
coVerify(exactly = 0) { backupDataSource.fulfillRestoreRequest(any(), any(), any()) }
}
@Test
fun `fulfillRestore rejects a wrong code and never writes the keybox`() = runTest {
every { authRepository.currentUserId } returns "uidSam"
coEvery { backupDataSource.getRestoreRequest("c1", "uidQA") } returns RestoreRequest(
recipientUid = "uidQA", recipientPublicKey = "pub:v1:x", requestNonce = "n",
status = RestoreStatus.REQUESTED, createdAt = 1L,
expiresAt = System.currentTimeMillis() + 60_000
)
// "000000" will not match the real fingerprint of (pub:v1:x, n).
val result = manager().fulfillRestore("c1", "uidQA", "000000")
assertTrue(result.isFailure)
coVerify(exactly = 0) { backupDataSource.fulfillRestoreRequest(any(), any(), any()) }
}
@Test
fun `expired guard allows a request with no explicit expiry`() = runTest {
// A legacy/never-expiring request (expiresAt == 0) must NOT be treated as expired.
every { authRepository.currentUserId } returns "uidSam"
coEvery { backupDataSource.getRestoreRequest("c1", "uidQA") } returns RestoreRequest(
recipientUid = "uidQA", recipientPublicKey = "pub:v1:x", requestNonce = "n",
status = RestoreStatus.REQUESTED, createdAt = 1L, expiresAt = 0L
)
// Wrong code still fails, but NOT for expiry — proves the expiry guard passed (expiresAt==0).
val result = manager().fulfillRestore("c1", "uidQA", "000000")
assertTrue(result.isFailure)
assertFalse(result.exceptionOrNull()?.message?.contains("expired", ignoreCase = true) == true)
}
}