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