diff --git a/app/src/main/java/app/closer/data/backup/RestoreManager.kt b/app/src/main/java/app/closer/data/backup/RestoreManager.kt new file mode 100644 index 00000000..1123d98a --- /dev/null +++ b/app/src/main/java/app/closer/data/backup/RestoreManager.kt @@ -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 = 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 = 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 = 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 + } +} diff --git a/app/src/test/java/app/closer/data/backup/RestoreManagerTest.kt b/app/src/test/java/app/closer/data/backup/RestoreManagerTest.kt new file mode 100644 index 00000000..3135ea02 --- /dev/null +++ b/app/src/test/java/app/closer/data/backup/RestoreManagerTest.kt @@ -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) + } +}