Closer/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt

129 lines
4.8 KiB
Kotlin

package app.closer.crypto
import android.content.Context
import android.content.SharedPreferences
import app.closer.data.local.SecurePreferencesFactory
import com.google.crypto.tink.Aead
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeysetHandle
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayOutputStream
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists Tink keyset handles in EncryptedSharedPreferences (Keystore-backed).
* Keys are namespaced by coupleId ("keyset_{coupleId}") or invite slot
* ("keyset_invite_{inviteCode}") for the inviter reconciliation path.
*/
@Singleton
class CoupleKeyStore @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs: SharedPreferences =
SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME)
private val aeadCache = ConcurrentHashMap<String, Aead>()
fun hasKeyset(coupleId: String): Boolean =
prefs.contains(prefKey(coupleId))
fun hasInviteKeyset(inviteCode: String): Boolean =
prefs.contains(invitePrefKey(inviteCode))
fun storeKeyset(coupleId: String, handle: KeysetHandle) {
val json = serialize(handle)
prefs.edit().putString(prefKey(coupleId), json).apply()
aeadCache[coupleId] = handle.getPrimitive(Aead::class.java)
}
fun storeInviteKeyset(inviteCode: String, handle: KeysetHandle) {
val json = serialize(handle)
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
}
fun storeInvitePhrase(inviteCode: String, phrase: String) {
prefs.edit().putString(invitePhrasePrefKey(inviteCode), phrase).apply()
}
fun storeRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(recoveryPhraseKey(coupleId), phrase).apply()
}
fun loadRecoveryPhrase(coupleId: String): String? =
prefs.getString(recoveryPhraseKey(coupleId), null)
fun loadKeyset(coupleId: String): KeysetHandle? =
load(prefKey(coupleId))
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
load(invitePrefKey(inviteCode))
/** Moves the invite-slot keyset (and phrase) to the coupleId slot and removes the invite slots. */
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
val handle = loadInviteKeyset(inviteCode) ?: return false
storeKeyset(coupleId, handle)
prefs.edit().remove(invitePrefKey(inviteCode)).also { editor ->
val phrase = prefs.getString(invitePhrasePrefKey(inviteCode), null)
if (phrase != null) {
editor.putString(recoveryPhraseKey(coupleId), phrase)
editor.remove(invitePhrasePrefKey(inviteCode))
}
}.apply()
return true
}
fun deleteKeyset(coupleId: String) {
prefs.edit()
.remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId))
.remove(recoveryPhraseKey(coupleId))
.apply()
aeadCache.remove(coupleId)
}
fun storePendingRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(pendingPhraseKey(coupleId), phrase).apply()
}
fun pendingRecoveryPhrase(coupleId: String): String? =
prefs.getString(pendingPhraseKey(coupleId), null)
fun clearPendingRecoveryPhrase(coupleId: String) {
prefs.edit().remove(pendingPhraseKey(coupleId)).apply()
}
fun aeadFor(coupleId: String): Aead? {
aeadCache[coupleId]?.let { return it }
val handle = loadKeyset(coupleId) ?: return null
val aead = handle.getPrimitive(Aead::class.java)
aeadCache[coupleId] = aead
return aead
}
private fun prefKey(coupleId: String) = "keyset_$coupleId"
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream()
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos))
return baos.toString(Charsets.UTF_8.name())
}
private fun load(key: String): KeysetHandle? =
runCatching {
val json = prefs.getString(key, null) ?: return null
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
}.getOrNull()
private companion object {
const val PREFS_NAME = "couple_crypto_secure"
}
}