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)
/**
* 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.
* Stores a couple keyset received via partner-assisted restore. If the sending partner included the
* couple's [recoveryPhrase] in the keybox, persist it too so the recovering device is no longer left
* 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.JsonKeysetWriter
import com.google.crypto.tink.KeysetHandle
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.Base64
@ -27,17 +28,26 @@ import javax.inject.Singleton
@Singleton
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(
coupleKeyset: KeysetHandle,
recipientPublicKeyB64: String,
coupleId: String,
senderUid: String,
recipientUid: String,
nonce: String
nonce: String,
recoveryPhrase: String? = null
): 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 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)
}
@ -48,13 +58,28 @@ class CoupleKeyTransfer @Inject constructor() {
senderUid: String,
recipientUid: String,
nonce: String
): KeysetHandle {
): TransferredKey {
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))
val plain = hybrid.decrypt(ciphertext, contextInfo(coupleId, senderUid, recipientUid, nonce))
.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 =
@ -70,6 +95,9 @@ class CoupleKeyTransfer @Inject constructor() {
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
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
* 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 org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
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.
val aad = coupleId.toByteArray()
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)
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)