feat(backup): add CoupleKeyTransfer (ECIES couple-key wrap + OOB verification code) and CoupleEncryptionManager export/store methods

This commit is contained in:
null 2026-06-30 20:42:54 -05:00
parent 4ac2c8f841
commit c8b0130f1c
3 changed files with 191 additions and 0 deletions

View File

@ -117,4 +117,17 @@ class CoupleEncryptionManager @Inject constructor(
} }
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) 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)
} }

View File

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

View File

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