From c8b0130f1cbd3e3908db662e4d429cf760a341fe Mon Sep 17 00:00:00 2001 From: null Date: Tue, 30 Jun 2026 20:42:54 -0500 Subject: [PATCH] feat(backup): add CoupleKeyTransfer (ECIES couple-key wrap + OOB verification code) and CoupleEncryptionManager export/store methods --- .../closer/crypto/CoupleEncryptionManager.kt | 13 +++ .../app/closer/crypto/CoupleKeyTransfer.kt | 91 +++++++++++++++++++ .../closer/crypto/CoupleKeyTransferTest.kt | 87 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt create mode 100644 app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index c762ed48..999e3e4d 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -117,4 +117,17 @@ class CoupleEncryptionManager @Inject constructor( } fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) + + /** + * The locally-held couple keyset, for wrapping to a partner during partner-assisted restore + * ([CoupleKeyTransfer.wrapCoupleKey]). Null when this device doesn't hold the key. Never log it. + */ + fun exportKeysetForTransfer(coupleId: String): KeysetHandle? = keyStore.loadKeyset(coupleId) + + /** + * Stores a couple keyset received via partner-assisted restore (no recovery phrase involved). The + * phrase is intentionally NOT set here — the recovering device may not have it, and the key works + * without it. The user can view/re-derive the phrase later via the partner. + */ + fun storeTransferredKeyset(coupleId: String, handle: KeysetHandle) = keyStore.storeKeyset(coupleId, handle) } diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt b/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt new file mode 100644 index 00000000..600a1650 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt @@ -0,0 +1,91 @@ +package app.closer.crypto + +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 java.io.ByteArrayOutputStream +import java.security.MessageDigest +import java.util.Base64 +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Partner-assisted restore crypto: wraps the **couple keyset** to a recovering device's fresh ECIES + * public key so a partner can hand back the couple key with **no recovery phrase**. + * + * This is a sibling to [ReleaseKeyEncryptor] (which wraps a one-time *answer* key with an answer- + * specific context). Here the context binds to the restore: `"{coupleId}|restore|{sender}|{recipient}|{nonce}"`. + * Encrypting to a per-request public key + a random nonce makes a stale/replayed keybox undecryptable. + * + * Wire format: `keybox:v1:{urlsafe-base64-no-padding}` — same as ReleaseKeyEncryptor so the same + * Firestore rule (`isKeybox`) applies. + * + * SECURITY: the returned keybox contains the couple key (the crown jewels) — it is only ever ECIES + * ciphertext to the recipient's key; never log it or the keyset. + */ +@Singleton +class CoupleKeyTransfer @Inject constructor() { + + fun wrapCoupleKey( + coupleKeyset: KeysetHandle, + recipientPublicKeyB64: String, + coupleId: String, + senderUid: String, + recipientUid: String, + nonce: String + ): String { + val keyBytes = serialize(coupleKeyset).toByteArray(Charsets.UTF_8) + val hybrid = UserKeyManager.hybridEncryptFrom(recipientPublicKeyB64) + val ciphertext = hybrid.encrypt(keyBytes, contextInfo(coupleId, senderUid, recipientUid, nonce)) + return ReleaseKeyEncryptor.KEYBOX_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext) + } + + fun unwrapCoupleKey( + keyboxB64: String, + recipientPrivateKey: KeysetHandle, + coupleId: String, + senderUid: String, + recipientUid: String, + nonce: String + ): KeysetHandle { + require(keyboxB64.startsWith(ReleaseKeyEncryptor.KEYBOX_PREFIX)) { "Not a keybox payload" } + val ciphertext = Base64.getUrlDecoder().decode(keyboxB64.removePrefix(ReleaseKeyEncryptor.KEYBOX_PREFIX)) + val hybrid = UserKeyManager.hybridDecryptFor(recipientPrivateKey) + val keyJson = hybrid.decrypt(ciphertext, contextInfo(coupleId, senderUid, recipientUid, nonce)) + .toString(Charsets.UTF_8) + return deserialize(keyJson) + } + + private fun contextInfo(coupleId: String, senderUid: String, recipientUid: String, nonce: String): ByteArray = + "$coupleId|restore|$senderUid|$recipientUid|$nonce".toByteArray(Charsets.UTF_8) + + private fun serialize(handle: KeysetHandle): String { + val baos = ByteArrayOutputStream() + CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos)) + return baos.toString(Charsets.UTF_8.name()) + } + + private fun deserialize(json: String): KeysetHandle = + CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) + + companion object { + /** + * 6-digit out-of-band verification code binding the partner's approval to the EXACT recovering + * pubkey + nonce. The recipient reads it aloud; the partner must type the matching code before + * their device wraps the couple key — defeating a server/MITM pubkey swap and a remote + * account-takeover attacker (who can't supply the code over the recipient's voice channel). + */ + fun verificationCode(recipientPublicKeyB64: String, nonce: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest("$recipientPublicKeyB64|$nonce".toByteArray(Charsets.UTF_8)) + // First 4 bytes → unsigned int → 6 digits. + val n = ((digest[0].toInt() and 0xFF) shl 24) or + ((digest[1].toInt() and 0xFF) shl 16) or + ((digest[2].toInt() and 0xFF) shl 8) or + (digest[3].toInt() and 0xFF) + val code = (n.toLong() and 0xFFFFFFFFL) % 1_000_000L + return code.toString().padStart(6, '0') + } + } +} diff --git a/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt b/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt new file mode 100644 index 00000000..f6ee0f27 --- /dev/null +++ b/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt @@ -0,0 +1,87 @@ +package app.closer.crypto + +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.aead.AeadKeyTemplates +import com.google.crypto.tink.hybrid.HybridConfig +import com.google.crypto.tink.hybrid.HybridKeyTemplates +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import java.security.GeneralSecurityException + +/** + * Security-critical coverage for partner-assisted restore: the couple key must round-trip only to the + * intended recipient device + context, and the out-of-band verification code must bind to the exact + * pubkey+nonce (so a swap is detectable). + */ +class CoupleKeyTransferTest { + + companion object { + @BeforeClass @JvmStatic fun setup() { + AeadConfig.register() + HybridConfig.register() + } + private fun ecies(): KeysetHandle = + KeysetHandle.generateNew(HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM) + private fun coupleKeyset(): KeysetHandle = KeysetHandle.generateNew(AeadKeyTemplates.AES256_GCM) + } + + private val subject = CoupleKeyTransfer() + private val coupleId = "couple-xyz" + private val alice = "user-a" // recipient (recovering device) + private val bob = "user-b" // partner (fulfiller) + private val nonce = "nonce-123" + + @Test + fun `wrap produces a keybox and round-trips the couple key to the recipient device`() { + val aliceDevice = ecies() + val alicePub = UserKeyManager.publicKeyB64Companion(aliceDevice) + val couple = coupleKeyset() + + val keybox = subject.wrapCoupleKey(couple, alicePub, coupleId, bob, alice, nonce) + assertTrue(keybox.startsWith(ReleaseKeyEncryptor.KEYBOX_PREFIX)) + + val recovered = subject.unwrapCoupleKey(keybox, aliceDevice, coupleId, bob, alice, nonce) + + // The recovered keyset is the SAME couple key: ciphertext from the original decrypts with it. + val aad = coupleId.toByteArray() + val original = couple.getPrimitive(Aead::class.java) + val recoveredAead = recovered.getPrimitive(Aead::class.java) + val ct = original.encrypt("secret message".toByteArray(), aad) + assertEquals("secret message", String(recoveredAead.decrypt(ct, aad))) + } + + @Test(expected = GeneralSecurityException::class) + fun `a different device key cannot unwrap`() { + val aliceDevice = ecies() + val attacker = ecies() + val alicePub = UserKeyManager.publicKeyB64Companion(aliceDevice) + val keybox = subject.wrapCoupleKey(coupleKeyset(), alicePub, coupleId, bob, alice, nonce) + subject.unwrapCoupleKey(keybox, attacker, coupleId, bob, alice, nonce) + } + + @Test(expected = GeneralSecurityException::class) + fun `a replayed keybox with a different nonce cannot unwrap`() { + val aliceDevice = ecies() + val alicePub = UserKeyManager.publicKeyB64Companion(aliceDevice) + val keybox = subject.wrapCoupleKey(coupleKeyset(), alicePub, coupleId, bob, alice, nonce) + subject.unwrapCoupleKey(keybox, aliceDevice, coupleId, bob, alice, "different-nonce") + } + + @Test + fun `verification code is stable for a pubkey+nonce and changes when either changes`() { + val pub = UserKeyManager.publicKeyB64Companion(ecies()) + val pub2 = UserKeyManager.publicKeyB64Companion(ecies()) + val code = CoupleKeyTransfer.verificationCode(pub, nonce) + + assertEquals(6, code.length) + assertTrue(code.all { it.isDigit() }) + assertEquals(code, CoupleKeyTransfer.verificationCode(pub, nonce)) // deterministic + assertNotEquals(code, CoupleKeyTransfer.verificationCode(pub, "other-nonce")) // nonce-bound + assertNotEquals(code, CoupleKeyTransfer.verificationCode(pub2, nonce)) // pubkey-bound (swap detect) + } +}