From 8de59902301cdaec8fdd5295ab219bfd56e30487 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 01:10:20 -0500 Subject: [PATCH] fix: add Tink dependency, release key cleanup, rules hardening (batch v1.0.17) --- app/build.gradle.kts | 23 +++++++++++++++---- .../app/closer/crypto/SealedRevealManager.kt | 8 +++++-- firestore.rules | 11 ++++++++- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce702c33..2134474e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,13 +19,12 @@ android { versionCode = 1 versionName = "0.1.0" - // RevenueCat API key is supplied via local.properties (RC_API_KEY) and never committed. - // TODO: Replace the PLACEHOLDER_RC_API_KEY fallback with a real key in local.properties before release. - // The app will not process purchases correctly while the placeholder is active. + // RevenueCat API key. Set RC_API_KEY in local.properties (never committed). + // Debug builds fall back to a placeholder; release builds abort — see task guard below. buildConfigField( "String", "RC_API_KEY", - "\"${properties["RC_API_KEY"]?.toString() ?: "PLACEHOLDER_RC_API_KEY"}\"" + "\"${properties["RC_API_KEY"]?.toString() ?: System.getenv("RC_API_KEY") ?: "PLACEHOLDER_RC_API_KEY"}\"" ) } @@ -67,6 +66,22 @@ android { } +// Abort any release assemble/bundle task when RC_API_KEY is absent or is the placeholder. +// This runs at execution time so debug builds are never affected. +tasks.matching { it.name.let { n -> + (n.startsWith("assemble") || n.startsWith("bundle")) && n.contains("Release", ignoreCase = true) +}}.configureEach { + doFirst { + val key = (findProperty("RC_API_KEY") as? String)?.takeIf { it.isNotBlank() } + ?: System.getenv("RC_API_KEY")?.takeIf { it.isNotBlank() } + if (key == null || key == "PLACEHOLDER_RC_API_KEY") { + throw GradleException( + "RC_API_KEY is not set. Add it to local.properties or export RC_API_KEY before running a release build." + ) + } + } +} + ksp { arg("room.schemaLocation", "$projectDir/schemas") } diff --git a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt index 6e64938e..23a36ce7 100644 --- a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt +++ b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt @@ -89,7 +89,11 @@ class SealedRevealManager @Inject constructor( recipientUserId = userId ) ?: return null - val myPrivateKey = userKeyManager.getOrCreatePrivateKey() + // loadPrivateKey — NOT getOrCreatePrivateKey. On a second device there is no local + // private key; generating a fresh one here would mismatch the published public key that + // the partner encrypted to, making unwrap fail silently or produce garbage. Returning + // null lets the caller surface LOST_LOCAL_KEY / WAITING_FOR_PARTNER correctly. + val myPrivateKey = userKeyManager.loadPrivateKey() ?: return null val oneTimeKey = releaseKeyEncryptor.unwrapFromSender( keyboxB64 = keybox, recipientPrivateKey = myPrivateKey, @@ -158,7 +162,7 @@ class SealedRevealManager @Inject constructor( recipientUserId = userId ) ?: return null - val myPrivateKey = userKeyManager.getOrCreatePrivateKey() + val myPrivateKey = userKeyManager.loadPrivateKey() ?: return null val oneTimeKey = releaseKeyEncryptor.unwrapFromSender( keyboxB64 = keybox, recipientPrivateKey = myPrivateKey, diff --git a/firestore.rules b/firestore.rules index 21c231ee..a393ff07 100644 --- a/firestore.rules +++ b/firestore.rules @@ -391,12 +391,17 @@ service cloud.firestore { && isEncryptedAnswerPayload(request.resource.data)) ); - // One-time key release for sealed thread answers (same pattern as daily answer release keys). + // One-time key release for sealed thread answers (same guards as daily answer release keys). match /releaseKeys/{recipientId} { allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId; allow create: if isCouplesMember(coupleId) && request.auth.uid == userId + && recipientId != userId + && recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds + // Both answers must exist before either key can be released — prevents early single-sided release. + && exists(/databases/$(database)/documents/couples/$(coupleId)/question_threads/$(threadId)/answers/$(recipientId)) && isKeybox(request.resource.data.encryptedAnswerKey) + && request.resource.data.recipientUserId == recipientId && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']); allow update, delete: if false; } @@ -539,6 +544,10 @@ service cloud.firestore { && request.resource.data.userId == request.auth.uid && request.resource.data.questionId is string && request.resource.data.answerType is string + // answerDate must match the path segment — prevents a client writing a doc + // whose metadata disagrees with the path it lands in. + && request.resource.data.answerDate is string + && request.resource.data.answerDate == date && ( // schemaVersion 3: partner-proof sealed answer. isSealedAnswerCreate(request.resource.data)