From e28a08e5f16a2eea4a377f3781a1e4c8b2c65c8a Mon Sep 17 00:00:00 2001 From: null Date: Tue, 16 Jun 2026 22:58:27 -0500 Subject: [PATCH] security: hash question IDs and coarsen categories in Firebase Analytics (SecurityReport #9) --- .../analytics/FirebaseAnalyticsTracker.kt | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt b/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt index 0561d56c..d3d7c9b1 100644 --- a/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt +++ b/app/src/main/java/app/closer/core/analytics/FirebaseAnalyticsTracker.kt @@ -3,6 +3,7 @@ package app.closer.core.analytics import android.os.Bundle import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.logEvent +import java.security.MessageDigest import javax.inject.Inject import javax.inject.Singleton @@ -11,6 +12,41 @@ class FirebaseAnalyticsTracker @Inject constructor( private val analytics: FirebaseAnalytics ) : AnalyticsTracker { + /** + * Hashes an identifier for analytics logging. + * + * Uses SHA-256 and truncates to the first 16 hex characters. This is a one-way, + * deterministic transform — enough for aggregate analytics correlation while making + * it infeasible to recover the original question content from the logged value. + */ + private fun hashForAnalytics(value: String): String { + if (value.isBlank()) return "empty" + return try { + val digest = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + digest.joinToString("", limit = 8, truncated = "") { "%02x".format(it) } + } catch (e: Exception) { + "hash_error" + } + } + + /** + * Maps fine-grained category identifiers to a coarse-grained set safe for analytics. + * + * This preserves useful aggregate behavioral data (e.g. which coarse areas drive + * engagement) without leaking the specific, often intimate, category names. + */ + private fun coarseCategory(category: String): String { + return when (category.lowercase().trim()) { + "communication", "conflict", "trust", "intimacy", "emotional_intimacy", + "physical_intimacy", "sex", "desire", "rebuilding_trust" -> "intimacy" + "fun", "play", "adventure", "date_night", "travel", "creativity" -> "fun" + "money", "future", "goals", "family", "values", "lifestyle" -> "life" + "daily_check_in", "app_usage" -> "meta" + else -> "other" + } + } + override fun trackScreenViewed(screenName: String) { analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW) { param(FirebaseAnalytics.Param.SCREEN_NAME, screenName) @@ -23,23 +59,23 @@ class FirebaseAnalyticsTracker @Inject constructor( override fun trackQuestionViewed(questionId: String, category: String) { analytics.logEvent("question_viewed") { - param("question_id", questionId) - param("category", category) + param("question_hash", hashForAnalytics(questionId)) + param("category", coarseCategory(category)) } } override fun trackAnswerSubmitted(questionId: String, category: String, answerType: String) { analytics.logEvent("answer_submitted") { - param("question_id", questionId) - param("category", category) + param("question_hash", hashForAnalytics(questionId)) + param("category", coarseCategory(category)) param("answer_type", answerType) } } override fun trackAnswerRevealed(questionId: String, category: String) { analytics.logEvent("answer_revealed") { - param("question_id", questionId) - param("category", category) + param("question_hash", hashForAnalytics(questionId)) + param("category", coarseCategory(category)) } } @@ -59,13 +95,13 @@ class FirebaseAnalyticsTracker @Inject constructor( override fun trackSpinWheelStarted(categoryId: String) { analytics.logEvent("spin_wheel_started") { - param("category_id", categoryId) + param("category_hash", hashForAnalytics(categoryId)) } } override fun trackSpinWheelCompleted(categoryId: String, answeredCount: Int) { analytics.logEvent("spin_wheel_completed") { - param("category_id", categoryId) + param("category_hash", hashForAnalytics(categoryId)) param("answered_count", answeredCount.toLong()) } }