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.flow.callbackFlow
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
@ -62,7 +63,108 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
date: String = todayLocalDateString()
|
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(
|
private suspend fun saveAnswerSealed(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,31 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
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
|
// Live-watch the partner's answer doc so the reveal completes on this device
|
||||||
// the moment they release their key — no manual refresh needed.
|
// the moment they release their key — no manual refresh needed.
|
||||||
if (coupleId != null && partnerId != null) {
|
if (coupleId != null && partnerId != null) {
|
||||||
|
|
@ -141,7 +166,17 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
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
|
if (answer.isRevealed) return SealedRevealPhase.REVEALED
|
||||||
// isSealed == false means our key was already released; we are waiting for the partner's key.
|
// 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).
|
// 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()
|
coupleIdHash = state.coupleId?.coupleLoreHash()
|
||||||
))
|
))
|
||||||
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
||||||
performSealedReveal(state)
|
if (state.answer?.schemaVersion == 2) performCoupleKeyReveal(state)
|
||||||
|
else performSealedReveal(state)
|
||||||
} else {
|
} else {
|
||||||
performLegacyReveal()
|
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) {
|
private fun performSealedReveal(state: AnswerRevealUiState) {
|
||||||
val coupleId = state.coupleId ?: return
|
val coupleId = state.coupleId ?: return
|
||||||
val partnerId = state.partnerId ?: return
|
val partnerId = state.partnerId ?: return
|
||||||
|
|
|
||||||
|
|
@ -200,9 +200,9 @@ class DailyQuestionViewModel @Inject constructor(
|
||||||
if (dailyQuestionDate.isNullOrBlank()) return
|
if (dailyQuestionDate.isNullOrBlank()) return
|
||||||
runCatching {
|
runCatching {
|
||||||
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
|
firestoreAnswerDataSource.saveAnswer(coupleId, questionId, userId, answer, dailyQuestionDate)
|
||||||
// Mirror the sealed schema fields into local storage so the reveal screen can
|
// Mirror the couple-key schema (v2) into local storage so the reveal screen resolves the
|
||||||
// enter the sealed reveal state machine on subsequent launches.
|
// right path on subsequent launches: reveal once BOTH have answered, no key handshake.
|
||||||
localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 3, isSealed = true, answerDate = dailyQuestionDate))
|
localAnswerRepository.saveAnswer(answer.copy(schemaVersion = 2, isSealed = false, answerDate = dailyQuestionDate))
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
crashReporter.recordException(it)
|
crashReporter.recordException(it)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue