From 4b5dea2dc304e1671a9c7ca6d6b0775d9e946860 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 14:00:59 -0400 Subject: [PATCH] feat(chat-cipher): wrap and unwrap a private group's key ChatCipher gains newGroupKey, wrapGroupKey and unwrapGroupKey for the chat.v1.KeyEnvelope X25519_XCHACHA20POLY1305 scheme, following chat/v1/model.proto steps 1-4. The wrap reuses the DM key's X25519 + HKDF-SHA256 steps under the flipcash-group-key-wrap-v1 label; the AAD is the label, chat id, wrapper key, then recipient key. There are no shared vectors for the wrap yet, so GroupKeyWrapTest checks round trips and rejections (another chat, another wrapper or recipient, a short key), and that the wrapping key differs from the DM chat key, rather than fixed bytes. FakeChatCipher implements the new methods so the services test fixtures still compile. --- .../com/getcode/chatcipher/ChatCipher.kt | 22 +++++ .../getcode/chatcipher/DefaultChatCipher.kt | 50 ++++++++++- .../getcode/chatcipher/GroupKeyWrapTest.kt | 84 +++++++++++++++++++ .../flipcash/services/chat/FakeChatCipher.kt | 23 +++++ 4 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupKeyWrapTest.kt 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) + } }