feat(backup): add CoupleKeyTransfer (ECIES couple-key wrap + OOB verification code) and CoupleEncryptionManager export/store methods
This commit is contained in:
parent
4ac2c8f841
commit
c8b0130f1c
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue