feat(backup): add ckx:v1 keybox envelope for recovery phrase transfer (R24-c)

This commit is contained in:
null 2026-06-30 21:24:26 -05:00
parent 37815af781
commit 209ad74532
3 changed files with 61 additions and 11 deletions

View File

@ -125,9 +125,13 @@ class CoupleEncryptionManager @Inject constructor(
fun exportKeysetForTransfer(coupleId: String): KeysetHandle? = keyStore.loadKeyset(coupleId) fun exportKeysetForTransfer(coupleId: String): KeysetHandle? = keyStore.loadKeyset(coupleId)
/** /**
* Stores a couple keyset received via partner-assisted restore (no recovery phrase involved). The * Stores a couple keyset received via partner-assisted restore. If the sending partner included the
* phrase is intentionally NOT set here the recovering device may not have it, and the key works * couple's [recoveryPhrase] in the keybox, persist it too so the recovering device is no longer left
* without it. The user can view/re-derive the phrase later via the partner. * without the self-recovery phrase (Settings Security can then reveal it). A null/blank phrase keeps
* the legacy behavior (key restored, phrase absent).
*/ */
fun storeTransferredKeyset(coupleId: String, handle: KeysetHandle) = keyStore.storeKeyset(coupleId, handle) fun storeTransferredKeyset(coupleId: String, handle: KeysetHandle, recoveryPhrase: String? = null) {
keyStore.storeKeyset(coupleId, handle)
if (!recoveryPhrase.isNullOrBlank()) keyStore.storeRecoveryPhrase(coupleId, recoveryPhrase)
}
} }

View File

@ -4,6 +4,7 @@ import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.JsonKeysetReader import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeysetHandle import com.google.crypto.tink.KeysetHandle
import org.json.JSONObject
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.util.Base64 import java.util.Base64
@ -27,17 +28,26 @@ import javax.inject.Singleton
@Singleton @Singleton
class CoupleKeyTransfer @Inject constructor() { class CoupleKeyTransfer @Inject constructor() {
/** Result of unwrapping a keybox: always the couple keyset, plus the recovery phrase if the sender
* chose to include it (so a partner-restored device is no longer left without the self-recovery phrase). */
data class TransferredKey(val keyset: KeysetHandle, val recoveryPhrase: String?)
/**
* @param recoveryPhrase optionally included so the recovering device also regains the couple's
* recovery phrase (the sender reads it from their own store; null if they don't have it either).
*/
fun wrapCoupleKey( fun wrapCoupleKey(
coupleKeyset: KeysetHandle, coupleKeyset: KeysetHandle,
recipientPublicKeyB64: String, recipientPublicKeyB64: String,
coupleId: String, coupleId: String,
senderUid: String, senderUid: String,
recipientUid: String, recipientUid: String,
nonce: String nonce: String,
recoveryPhrase: String? = null
): String { ): String {
val keyBytes = serialize(coupleKeyset).toByteArray(Charsets.UTF_8) val payload = encodePayload(serialize(coupleKeyset), recoveryPhrase).toByteArray(Charsets.UTF_8)
val hybrid = UserKeyManager.hybridEncryptFrom(recipientPublicKeyB64) val hybrid = UserKeyManager.hybridEncryptFrom(recipientPublicKeyB64)
val ciphertext = hybrid.encrypt(keyBytes, contextInfo(coupleId, senderUid, recipientUid, nonce)) val ciphertext = hybrid.encrypt(payload, contextInfo(coupleId, senderUid, recipientUid, nonce))
return ReleaseKeyEncryptor.KEYBOX_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext) return ReleaseKeyEncryptor.KEYBOX_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext)
} }
@ -48,13 +58,28 @@ class CoupleKeyTransfer @Inject constructor() {
senderUid: String, senderUid: String,
recipientUid: String, recipientUid: String,
nonce: String nonce: String
): KeysetHandle { ): TransferredKey {
require(keyboxB64.startsWith(ReleaseKeyEncryptor.KEYBOX_PREFIX)) { "Not a keybox payload" } require(keyboxB64.startsWith(ReleaseKeyEncryptor.KEYBOX_PREFIX)) { "Not a keybox payload" }
val ciphertext = Base64.getUrlDecoder().decode(keyboxB64.removePrefix(ReleaseKeyEncryptor.KEYBOX_PREFIX)) val ciphertext = Base64.getUrlDecoder().decode(keyboxB64.removePrefix(ReleaseKeyEncryptor.KEYBOX_PREFIX))
val hybrid = UserKeyManager.hybridDecryptFor(recipientPrivateKey) val hybrid = UserKeyManager.hybridDecryptFor(recipientPrivateKey)
val keyJson = hybrid.decrypt(ciphertext, contextInfo(coupleId, senderUid, recipientUid, nonce)) val plain = hybrid.decrypt(ciphertext, contextInfo(coupleId, senderUid, recipientUid, nonce))
.toString(Charsets.UTF_8) .toString(Charsets.UTF_8)
return deserialize(keyJson) return decodePayload(plain)
}
// Payload envelope. New keyboxes are `ckx:v1:{json}` carrying the keyset + optional phrase; a legacy
// keybox (plaintext == the raw keyset JSON, no prefix) still decodes to a keyset with no phrase.
private fun encodePayload(keysetJson: String, phrase: String?): String {
val obj = JSONObject().put("keyset", keysetJson)
if (!phrase.isNullOrBlank()) obj.put("phrase", phrase)
return ENVELOPE_PREFIX + obj.toString()
}
private fun decodePayload(plain: String): TransferredKey {
if (!plain.startsWith(ENVELOPE_PREFIX)) return TransferredKey(deserialize(plain), null) // legacy
val obj = JSONObject(plain.removePrefix(ENVELOPE_PREFIX))
val phrase = if (obj.has("phrase")) obj.getString("phrase").ifBlank { null } else null
return TransferredKey(deserialize(obj.getString("keyset")), phrase)
} }
private fun contextInfo(coupleId: String, senderUid: String, recipientUid: String, nonce: String): ByteArray = private fun contextInfo(coupleId: String, senderUid: String, recipientUid: String, nonce: String): ByteArray =
@ -70,6 +95,9 @@ class CoupleKeyTransfer @Inject constructor() {
CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
companion object { companion object {
/** Envelope tag for the v1 keybox payload (keyset + optional phrase). Absence ⇒ legacy keyset-only. */
private const val ENVELOPE_PREFIX = "ckx:v1:"
/** /**
* 6-digit out-of-band verification code binding the partner's approval to the EXACT recovering * 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 * pubkey + nonce. The recipient reads it aloud; the partner must type the matching code before

View File

@ -8,6 +8,8 @@ import com.google.crypto.tink.hybrid.HybridConfig
import com.google.crypto.tink.hybrid.HybridKeyTemplates import com.google.crypto.tink.hybrid.HybridKeyTemplates
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.BeforeClass import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
@ -50,9 +52,25 @@ class CoupleKeyTransferTest {
// The recovered keyset is the SAME couple key: ciphertext from the original decrypts with it. // The recovered keyset is the SAME couple key: ciphertext from the original decrypts with it.
val aad = coupleId.toByteArray() val aad = coupleId.toByteArray()
val original = couple.getPrimitive(Aead::class.java) val original = couple.getPrimitive(Aead::class.java)
val recoveredAead = recovered.getPrimitive(Aead::class.java) val recoveredAead = recovered.keyset.getPrimitive(Aead::class.java)
val ct = original.encrypt("secret message".toByteArray(), aad) val ct = original.encrypt("secret message".toByteArray(), aad)
assertEquals("secret message", String(recoveredAead.decrypt(ct, aad))) assertEquals("secret message", String(recoveredAead.decrypt(ct, aad)))
// No phrase was included, so none is recovered (key-only restore).
assertNull(recovered.recoveryPhrase)
}
@Test
fun `the recovery phrase round-trips through the keybox when the partner includes it`() {
val aliceDevice = ecies()
val alicePub = UserKeyManager.publicKeyB64Companion(aliceDevice)
val phrase = "lion fair card like foot good full fame disk flat"
val keybox = subject.wrapCoupleKey(coupleKeyset(), alicePub, coupleId, bob, alice, nonce, phrase)
val recovered = subject.unwrapCoupleKey(keybox, aliceDevice, coupleId, bob, alice, nonce)
assertEquals(phrase, recovered.recoveryPhrase)
// The key still works alongside the phrase.
assertNotNull(recovered.keyset.getPrimitive(Aead::class.java))
} }
@Test(expected = GeneralSecurityException::class) @Test(expected = GeneralSecurityException::class)