feat(answers): replace sealed-key exchange with couple-key encryption (schemaVersion 2) — reveal on both-answered, no key handshake

This commit is contained in:
null 2026-06-26 12:40:49 -05:00
parent 47867b5663
commit df32229f3b
3 changed files with 191 additions and 6 deletions

View File

@ -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,

View File

@ -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

View File

@ -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)
}