feat(answers): replace sealed-key exchange with couple-key encryption (schemaVersion 2) — reveal on both-answered, no key handshake
This commit is contained in:
parent
47867b5663
commit
df32229f3b
|
|
@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
|
@ -62,7 +63,108 @@ class FirestoreAnswerDataSource @Inject constructor(
|
|||
userId: String,
|
||||
answer: LocalAnswer,
|
||||
date: String = todayLocalDateString()
|
||||
): Unit = saveAnswerSealed(coupleId, questionId, userId, answer, date)
|
||||
): Unit = saveAnswerCoupleKey(coupleId, questionId, userId, answer, date)
|
||||
|
||||
/** Decoded answer payload (mirror of the sealed AnswerPayload, decrypted with the couple key). */
|
||||
data class DecodedAnswer(
|
||||
val writtenText: String?,
|
||||
val selectedOptionIds: List<String>,
|
||||
val scaleValue: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Daily answers are encrypted with the shared couple key (enc:v1:, schemaVersion 2). Both
|
||||
* partners already hold the couple key, so reveal decrypts reliably the moment both have
|
||||
* answered — privacy ("not until both answer") is enforced by the Firestore read rule, not a
|
||||
* per-answer key handshake. (Replaced the fragile per-device sealed key exchange.)
|
||||
*/
|
||||
private suspend fun saveAnswerCoupleKey(
|
||||
coupleId: String,
|
||||
questionId: String,
|
||||
userId: String,
|
||||
answer: LocalAnswer,
|
||||
date: String
|
||||
) {
|
||||
val aead = encryptionManager.aeadFor(coupleId)
|
||||
?: throw IllegalStateException("Couple key unavailable for $coupleId")
|
||||
val encryptedPayload = fieldEncryptor.encrypt(
|
||||
encodeAnswerPayload(answer.writtenText, answer.selectedOptionIds, answer.scaleValue),
|
||||
aead, coupleId
|
||||
)
|
||||
// Metadata doc (readable by both → drives the partner's "your turn" indicator); content-free.
|
||||
val metadata = mapOf(
|
||||
"userId" to userId,
|
||||
"questionId" to questionId,
|
||||
"answerType" to answer.answerType,
|
||||
"schemaVersion" to 2,
|
||||
"answerDate" to date,
|
||||
"createdAt" to answer.createdAt,
|
||||
"updatedAt" to answer.updatedAt,
|
||||
"isRevealed" to false
|
||||
)
|
||||
// Encrypted content lives in the read-gated `secure` subdoc (no peek before both answer).
|
||||
suspendCancellableCoroutine { cont ->
|
||||
securePayloadRef(coupleId, date, userId)
|
||||
.set(mapOf("encryptedPayload" to encryptedPayload))
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
suspendCancellableCoroutine { cont ->
|
||||
answerRef(coupleId, date, userId)
|
||||
.set(metadata)
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun securePayloadRef(coupleId: String, date: String, userId: String) =
|
||||
answerRef(coupleId, date, userId).collection("secure").document("payload")
|
||||
|
||||
/** Reads + decrypts a partner's couple-key answer content from the read-gated `secure` subdoc. */
|
||||
suspend fun decryptCoupleKeyAnswerFor(coupleId: String, date: String, userId: String): DecodedAnswer? {
|
||||
val encryptedPayload = runCatching {
|
||||
suspendCancellableCoroutine<String?> { cont ->
|
||||
securePayloadRef(coupleId, date, userId)
|
||||
.get()
|
||||
.addOnSuccessListener { snap -> cont.resume(snap.getString("encryptedPayload")) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
}.getOrNull()
|
||||
return decryptCoupleKeyAnswer(coupleId, encryptedPayload)
|
||||
}
|
||||
|
||||
/** Marks the caller's own answer revealed in Firestore — drives the partner-opened push. */
|
||||
suspend fun markRevealed(coupleId: String, date: String, userId: String): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
answerRef(coupleId, date, userId)
|
||||
.update(mapOf("isRevealed" to true, "updatedAt" to System.currentTimeMillis()))
|
||||
.addOnSuccessListener { cont.resume(Unit) }
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
/** Decrypts a couple-key (enc:v1:) answer payload. Returns null if the key/payload is unavailable. */
|
||||
fun decryptCoupleKeyAnswer(coupleId: String, encryptedPayload: String?): DecodedAnswer? {
|
||||
if (encryptedPayload == null) return null
|
||||
val aead = encryptionManager.aeadFor(coupleId) ?: return null
|
||||
val json = fieldEncryptor.decrypt(encryptedPayload, aead, coupleId) ?: return null
|
||||
return runCatching {
|
||||
val o = JSONObject(json)
|
||||
val arr = o.optJSONArray("selectedOptionIds")
|
||||
val ids = if (arr == null) emptyList() else (0 until arr.length()).map { arr.getString(it) }
|
||||
DecodedAnswer(
|
||||
writtenText = if (o.isNull("writtenText")) null else o.optString("writtenText"),
|
||||
selectedOptionIds = ids,
|
||||
scaleValue = if (o.isNull("scaleValue")) null else o.optInt("scaleValue")
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun encodeAnswerPayload(writtenText: String?, ids: List<String>, scale: Int?): String =
|
||||
JSONObject().apply {
|
||||
put("writtenText", writtenText ?: JSONObject.NULL)
|
||||
put("selectedOptionIds", JSONArray(ids))
|
||||
put("scaleValue", scale ?: JSONObject.NULL)
|
||||
}.toString()
|
||||
|
||||
private suspend fun saveAnswerSealed(
|
||||
coupleId: String,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,31 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
||||
)
|
||||
|
||||
// Couple-key answers: if this answer is ALREADY revealed (e.g. re-opening the
|
||||
// screen after a previous reveal), decrypt the partner's content now — the
|
||||
// partner-answer fetch above only carries metadata (content is in the gated subdoc).
|
||||
if (answer?.schemaVersion == 2 && answer.isRevealed &&
|
||||
partnerAnswer != null && coupleId != null && partnerId != null
|
||||
) {
|
||||
val decoded = firestoreAnswerDataSource.decryptCoupleKeyAnswerFor(coupleId, effectiveDate(answer), partnerId)
|
||||
val partnerTexts = question
|
||||
?.let { app.closer.ui.questions.selectedOptionTexts(it, decoded?.selectedOptionIds ?: emptyList()) }
|
||||
?: emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
partnerAnswer = partnerAnswer.copy(
|
||||
writtenText = decoded?.writtenText,
|
||||
selectedOptionIds = decoded?.selectedOptionIds ?: emptyList(),
|
||||
selectedOptionTexts = partnerTexts,
|
||||
scaleValue = decoded?.scaleValue,
|
||||
isSealed = false,
|
||||
isRevealed = true
|
||||
),
|
||||
sealedRevealPhase = SealedRevealPhase.REVEALED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Live-watch the partner's answer doc so the reveal completes on this device
|
||||
// the moment they release their key — no manual refresh needed.
|
||||
if (coupleId != null && partnerId != null) {
|
||||
|
|
@ -141,7 +166,17 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
||||
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
|
||||
if (answer == null) return SealedRevealPhase.NONE
|
||||
// Couple-key answers (schemaVersion 2): reveal the instant BOTH have answered — no per-answer
|
||||
// key handshake. Privacy ("not until both answer") is enforced by the Firestore read rule.
|
||||
if (answer.schemaVersion == 2) {
|
||||
return when {
|
||||
answer.isRevealed -> SealedRevealPhase.REVEALED
|
||||
partnerAnswer != null -> SealedRevealPhase.BOTH_ANSWERED
|
||||
else -> SealedRevealPhase.ANSWER_SEALED // waiting for the partner to answer
|
||||
}
|
||||
}
|
||||
if (answer.schemaVersion != 3) return SealedRevealPhase.NONE
|
||||
if (answer.isRevealed) return SealedRevealPhase.REVEALED
|
||||
// isSealed == false means our key was already released; we are waiting for the partner's key.
|
||||
// Only check the local key store while the key is still pending (isSealed == true).
|
||||
|
|
@ -230,12 +265,60 @@ class AnswerRevealViewModel @Inject constructor(
|
|||
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||
))
|
||||
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
||||
performSealedReveal(state)
|
||||
if (state.answer?.schemaVersion == 2) performCoupleKeyReveal(state)
|
||||
else performSealedReveal(state)
|
||||
} else {
|
||||
performLegacyReveal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Couple-key reveal (schemaVersion 2): both partners have answered, so the partner's payload is
|
||||
* already readable + decryptable with the shared couple key — show it immediately, no waiting on
|
||||
* the partner to also reveal. Also flags our own answer revealed in Firestore so the partner gets
|
||||
* the "opened your answers" push.
|
||||
*/
|
||||
private fun performCoupleKeyReveal(state: AnswerRevealUiState) {
|
||||
val coupleId = state.coupleId ?: return
|
||||
val partnerId = state.partnerId ?: return
|
||||
val userId = authRepository.currentUserId ?: return
|
||||
val date = effectiveDate(state.answer)
|
||||
viewModelScope.launch {
|
||||
// Content lives in the read-gated `secure` subdoc; we can read it now that both answered.
|
||||
val decoded = firestoreAnswerDataSource.decryptCoupleKeyAnswerFor(coupleId, date, partnerId)
|
||||
val partnerTexts = state.question
|
||||
?.let { app.closer.ui.questions.selectedOptionTexts(it, decoded?.selectedOptionIds ?: emptyList()) }
|
||||
?: emptyList()
|
||||
val decryptedPartner = state.partnerAnswer?.copy(
|
||||
writtenText = decoded?.writtenText,
|
||||
selectedOptionIds = decoded?.selectedOptionIds ?: emptyList(),
|
||||
selectedOptionTexts = partnerTexts,
|
||||
scaleValue = decoded?.scaleValue,
|
||||
isSealed = false,
|
||||
isRevealed = true
|
||||
)
|
||||
|
||||
localAnswerRepository.markRevealed(questionId)
|
||||
runCatching { firestoreAnswerDataSource.markRevealed(coupleId, date, userId) }
|
||||
.onFailure { crashReporter.recordException(it) }
|
||||
|
||||
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
||||
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
||||
retentionAnalytics.track(RetentionEvent.RevealCompleted(
|
||||
categoryId = category,
|
||||
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||
))
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
answer = ownAnswer,
|
||||
partnerAnswer = decryptedPartner,
|
||||
sealedRevealPhase = SealedRevealPhase.REVEALED,
|
||||
followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartner, category)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSealedReveal(state: AnswerRevealUiState) {
|
||||
val coupleId = state.coupleId ?: return
|
||||
val partnerId = state.partnerId ?: return
|
||||
|
|
|
|||
|
|
@ -200,9 +200,9 @@ class DailyQuestionViewModel @Inject constructor(
|
|||
if (dailyQuestionDate.isNullOrBlank()) return
|
||||
runCatching {
|
||||
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
|
||||
// Mirror the sealed schema fields into local storage so the reveal screen can
|
||||
// enter the sealed reveal state machine on subsequent launches.
|
||||
localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 3, isSealed = true, answerDate = dailyQuestionDate))
|
||||
// Mirror the couple-key schema (v2) into local storage so the reveal screen resolves the
|
||||
// right path on subsequent launches: reveal once BOTH have answered, no key handshake.
|
||||
localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 2, isSealed = false, answerDate = dailyQuestionDate))
|
||||
}.onFailure {
|
||||
crashReporter.recordException(it)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue