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

View File

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

View File

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