diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/ChatCipher.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/ChatCipher.kt index 42863888b8..d1245da12b 100644 --- a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/ChatCipher.kt +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/ChatCipher.kt @@ -72,4 +72,26 @@ interface ChatCipher { chatId: ByteArray, blobId: ByteArray, ): ByteArray + + /** 32 fresh random bytes: a private group's chat key, generated once by its creator. */ + fun newGroupKey(): ByteArray + + /** + * Wraps [groupKey] for [recipientPk] as `chat.v1.KeyEnvelope` scheme `X25519_XCHACHA20POLY1305`, + * under a fresh random nonce. [ownKeyPair] is the wrapper; pass its own public key as + * [recipientPk] to wrap for oneself. + * + * @throws ChatCipherException under the same key rules as [chatKey], or if [groupKey] is not 32 bytes. + */ + @Throws(ChatCipherException::class) + fun wrapGroupKey(ownKeyPair: KeyPair, recipientPk: ByteArray, chatId: ByteArray, groupKey: ByteArray): EncryptedPayload + + /** + * Opens an envelope [wrapperPk] made for [ownKeyPair]. [wrapperPk] is the viewer's own key for a + * self-wrapped envelope, otherwise the group creator's. + * + * @throws ChatCipherException if the envelope fails authentication or the keys are rejected. + */ + @Throws(ChatCipherException::class) + fun unwrapGroupKey(ownKeyPair: KeyPair, wrapperPk: ByteArray, chatId: ByteArray, envelope: EncryptedPayload): ByteArray } diff --git a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt index ce376a0c31..ed2e9d3777 100644 --- a/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt +++ b/libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt @@ -20,8 +20,54 @@ object DefaultChatCipher : ChatCipher { private const val TAG_SIZE = 16 private val LABEL = "flipcash-dm-e2ee-v1".encodeToByteArray() private val BLOB_LABEL = "flipcash-dm-e2ee-blob-v1".encodeToByteArray() + private val WRAP_LABEL = "flipcash-group-key-wrap-v1".encodeToByteArray() - override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray { + override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = + pairKey(ownKeyPair, peerPublicKey, LABEL + chatId) + + override fun newGroupKey(): ByteArray { + ensureSodium() + return LibsodiumRandom.buf(KEY_SIZE).toByteArray() + } + + override fun wrapGroupKey( + ownKeyPair: KeyPair, + recipientPk: ByteArray, + chatId: ByteArray, + groupKey: ByteArray, + ): EncryptedPayload = wrapGroupKey(ownKeyPair, recipientPk, chatId, groupKey, randomNonce()) + + internal fun wrapGroupKey( + ownKeyPair: KeyPair, + recipientPk: ByteArray, + chatId: ByteArray, + groupKey: ByteArray, + nonce: ByteArray, + ): EncryptedPayload { + if (groupKey.size != KEY_SIZE) throw ChatCipherException("group key must be 32 bytes") + val wrappingKey = pairKey(ownKeyPair, recipientPk, WRAP_LABEL + chatId) + val aad = WRAP_LABEL + chatId + ownKeyPair.publicKey + recipientPk + return EncryptedPayload(nonce, seal(groupKey, aad, nonce, wrappingKey)) + } + + override fun unwrapGroupKey( + ownKeyPair: KeyPair, + wrapperPk: ByteArray, + chatId: ByteArray, + envelope: EncryptedPayload, + ): ByteArray { + val wrappingKey = pairKey(ownKeyPair, wrapperPk, WRAP_LABEL + chatId) + val aad = WRAP_LABEL + chatId + wrapperPk + ownKeyPair.publicKey + val key = open(envelope.ciphertext, aad, envelope.nonce, wrappingKey) + if (key.size != KEY_SIZE) throw ChatCipherException("group key must be 32 bytes") + return key + } + + /** + * Steps 1–3 shared by the DM chat key and the group key wrap: X25519 between the two members, + * then HKDF-SHA256 salted with both Ed25519 public keys in bytewise order. + */ + private fun pairKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, info: ByteArray): ByteArray { if (ownKeyPair.publicKey.size != KEY_SIZE || ownKeyPair.privateKey.size != 64) { throw ChatCipherException("own key pair must be a 32-byte public and 64-byte private key") } @@ -30,7 +76,7 @@ object DefaultChatCipher : ChatCipher { val a = ownKeyPair.publicKey val b = peerPublicKey val salt = if (compareBytes(a, b) <= 0) a + b else b + a - return hkdfSha256(ikm = ss, salt = salt, info = LABEL + chatId, length = KEY_SIZE) + return hkdfSha256(ikm = ss, salt = salt, info = info, length = KEY_SIZE) } override fun encrypt( diff --git a/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupKeyWrapTest.kt b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupKeyWrapTest.kt new file mode 100644 index 0000000000..216f766a66 --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupKeyWrapTest.kt @@ -0,0 +1,84 @@ +package com.getcode.chatcipher + +import com.getcode.ed25519kmp.Ed25519Kmp +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * `chat.v1.KeyEnvelope` scheme `X25519_XCHACHA20POLY1305`. There are no shared vectors for it yet, + * so these pin the properties the contract states rather than exact bytes. + */ +class GroupKeyWrapTest { + private val creator = Ed25519Kmp.createKeyPair(ByteArray(32) { 1 }) + private val member = Ed25519Kmp.createKeyPair(ByteArray(32) { 2 }) + private val stranger = Ed25519Kmp.createKeyPair(ByteArray(32) { 3 }) + private val chatId = ByteArray(32) { it.toByte() } + private val groupKey = ByteArray(32) { (0xA0 + it).toByte() } + + @Test + fun creatorWrap_opensForTheRecipient() { + val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey) + + assertEquals(24, envelope.nonce.size) + assertEquals(48, envelope.ciphertext.size) + assertContentEquals(groupKey, DefaultChatCipher.unwrapGroupKey(member, creator.publicKey, chatId, envelope)) + } + + @Test + fun selfWrap_opensForTheWrapper() { + val envelope = DefaultChatCipher.wrapGroupKey(creator, creator.publicKey, chatId, groupKey) + + assertContentEquals(groupKey, DefaultChatCipher.unwrapGroupKey(creator, creator.publicKey, chatId, envelope)) + } + + @Test + fun envelope_rejectsAnyOtherWrapperOrRecipient() { + val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey) + + assertFailsWith { + DefaultChatCipher.unwrapGroupKey(member, stranger.publicKey, chatId, envelope) + } + assertFailsWith { + DefaultChatCipher.unwrapGroupKey(stranger, creator.publicKey, chatId, envelope) + } + } + + @Test + fun envelope_isBoundToTheChat() { + val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey) + val otherChat = chatId.copyOf().also { it[0] = 0x7F } + + assertFailsWith { + DefaultChatCipher.unwrapGroupKey(member, creator.publicKey, otherChat, envelope) + } + } + + @Test + fun wrappingKey_isNotTheDmChatKey() { + // Same pair and chat, different label: a DM key must never open a group envelope. + val nonce = ByteArray(24) + val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey, nonce) + val dmKey = DefaultChatCipher.chatKey(creator, member.publicKey, chatId) + val dmSealed = DefaultChatCipher.encrypt(groupKey, dmKey, creator.publicKey, member.publicKey, chatId, nonce) + + assertEquals(false, envelope.ciphertext.contentEquals(dmSealed.ciphertext)) + } + + @Test + fun wrap_rejectsAShortKey() { + assertFailsWith { + DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, ByteArray(16)) + } + } + + @Test + fun newGroupKey_isThirtyTwoFreshBytes() { + val a = DefaultChatCipher.newGroupKey() + val b = DefaultChatCipher.newGroupKey() + + assertEquals(32, a.size) + assertEquals(false, a.contentEquals(b)) + } +} diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt index 17ef7b1251..7b17960736 100644 --- a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt @@ -59,4 +59,27 @@ object FakeChatCipher : ChatCipher { } return blob.copyOfRange(header.size, blob.size) } + + override fun newGroupKey(): ByteArray = ByteArray(32) { it.toByte() } + + override fun wrapGroupKey( + ownKeyPair: KeyPair, + recipientPk: ByteArray, + chatId: ByteArray, + groupKey: ByteArray, + ) = EncryptedPayload(nonce = ByteArray(24), ciphertext = ownKeyPair.publicKey + recipientPk + groupKey) + + override fun unwrapGroupKey( + ownKeyPair: KeyPair, + wrapperPk: ByteArray, + chatId: ByteArray, + envelope: EncryptedPayload, + ): ByteArray { + val header = wrapperPk + ownKeyPair.publicKey + val sealed = envelope.ciphertext + if (sealed.size < header.size || !sealed.copyOfRange(0, header.size).contentEquals(header)) { + throw ChatCipherException("authentication failed") + } + return sealed.copyOfRange(header.size, sealed.size) + } }