fix: add Tink dependency, release key cleanup, rules hardening (batch v1.0.17)

This commit is contained in:
null 2026-06-20 01:10:20 -05:00
parent 84eab1825b
commit 8de5990230
3 changed files with 35 additions and 7 deletions

View File

@ -19,13 +19,12 @@ android {
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
// RevenueCat API key is supplied via local.properties (RC_API_KEY) and never committed. // RevenueCat API key. Set RC_API_KEY in local.properties (never committed).
// TODO: Replace the PLACEHOLDER_RC_API_KEY fallback with a real key in local.properties before release. // Debug builds fall back to a placeholder; release builds abort — see task guard below.
// The app will not process purchases correctly while the placeholder is active.
buildConfigField( buildConfigField(
"String", "String",
"RC_API_KEY", "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 { ksp {
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }

View File

@ -89,7 +89,11 @@ class SealedRevealManager @Inject constructor(
recipientUserId = userId recipientUserId = userId
) ?: return null ) ?: 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( val oneTimeKey = releaseKeyEncryptor.unwrapFromSender(
keyboxB64 = keybox, keyboxB64 = keybox,
recipientPrivateKey = myPrivateKey, recipientPrivateKey = myPrivateKey,
@ -158,7 +162,7 @@ class SealedRevealManager @Inject constructor(
recipientUserId = userId recipientUserId = userId
) ?: return null ) ?: return null
val myPrivateKey = userKeyManager.getOrCreatePrivateKey() val myPrivateKey = userKeyManager.loadPrivateKey() ?: return null
val oneTimeKey = releaseKeyEncryptor.unwrapFromSender( val oneTimeKey = releaseKeyEncryptor.unwrapFromSender(
keyboxB64 = keybox, keyboxB64 = keybox,
recipientPrivateKey = myPrivateKey, recipientPrivateKey = myPrivateKey,

View File

@ -391,12 +391,17 @@ service cloud.firestore {
&& isEncryptedAnswerPayload(request.resource.data)) && 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} { match /releaseKeys/{recipientId} {
allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId; allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId;
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId && 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) && isKeybox(request.resource.data.encryptedAnswerKey)
&& request.resource.data.recipientUserId == recipientId
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']); && request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
allow update, delete: if false; allow update, delete: if false;
} }
@ -539,6 +544,10 @@ service cloud.firestore {
&& request.resource.data.userId == request.auth.uid && request.resource.data.userId == request.auth.uid
&& request.resource.data.questionId is string && request.resource.data.questionId is string
&& request.resource.data.answerType 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. // schemaVersion 3: partner-proof sealed answer.
isSealedAnswerCreate(request.resource.data) isSealedAnswerCreate(request.resource.data)