feat(backup): add ckx:v1 keybox envelope for recovery phrase transfer (R24-c)
This commit is contained in:
parent
37815af781
commit
209ad74532
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue