diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 999e3e4d..5de6832a 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -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) + } } diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt b/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt index 600a1650..7e0fd63f 100644 --- a/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt +++ b/app/src/main/java/app/closer/crypto/CoupleKeyTransfer.kt @@ -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 diff --git a/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt b/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt index f6ee0f27..8bec3b9b 100644 --- a/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt +++ b/app/src/test/java/app/closer/crypto/CoupleKeyTransferTest.kt @@ -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)