feat(backup): add RestoreManager (request/fulfill/complete partner-assisted restore with OOB code gate)
This commit is contained in:
parent
c8b0130f1c
commit
ed3c3e4d22
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue