Skip to content

Commit d2c5cb6

Browse files
authored
feat(chat-cipher): wrap and unwrap a private group's key (#1745)
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.
1 parent 7155e83 commit d2c5cb6

4 files changed

Lines changed: 177 additions & 2 deletions

File tree

‎libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/ChatCipher.kt‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,26 @@ interface ChatCipher {
7272
chatId: ByteArray,
7373
blobId: ByteArray,
7474
): ByteArray
75+
76+
/** 32 fresh random bytes: a private group's chat key, generated once by its creator. */
77+
fun newGroupKey(): ByteArray
78+
79+
/**
80+
* Wraps [groupKey] for [recipientPk] as `chat.v1.KeyEnvelope` scheme `X25519_XCHACHA20POLY1305`,
81+
* under a fresh random nonce. [ownKeyPair] is the wrapper; pass its own public key as
82+
* [recipientPk] to wrap for oneself.
83+
*
84+
* @throws ChatCipherException under the same key rules as [chatKey], or if [groupKey] is not 32 bytes.
85+
*/
86+
@Throws(ChatCipherException::class)
87+
fun wrapGroupKey(ownKeyPair: KeyPair, recipientPk: ByteArray, chatId: ByteArray, groupKey: ByteArray): EncryptedPayload
88+
89+
/**
90+
* Opens an envelope [wrapperPk] made for [ownKeyPair]. [wrapperPk] is the viewer's own key for a
91+
* self-wrapped envelope, otherwise the group creator's.
92+
*
93+
* @throws ChatCipherException if the envelope fails authentication or the keys are rejected.
94+
*/
95+
@Throws(ChatCipherException::class)
96+
fun unwrapGroupKey(ownKeyPair: KeyPair, wrapperPk: ByteArray, chatId: ByteArray, envelope: EncryptedPayload): ByteArray
7597
}

‎libs/encryption/chat-cipher/src/commonMain/kotlin/com/getcode/chatcipher/DefaultChatCipher.kt‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,54 @@ object DefaultChatCipher : ChatCipher {
2020
private const val TAG_SIZE = 16
2121
private val LABEL = "flipcash-dm-e2ee-v1".encodeToByteArray()
2222
private val BLOB_LABEL = "flipcash-dm-e2ee-blob-v1".encodeToByteArray()
23+
private val WRAP_LABEL = "flipcash-group-key-wrap-v1".encodeToByteArray()
2324

24-
override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray {
25+
override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray =
26+
pairKey(ownKeyPair, peerPublicKey, LABEL + chatId)
27+
28+
override fun newGroupKey(): ByteArray {
29+
ensureSodium()
30+
return LibsodiumRandom.buf(KEY_SIZE).toByteArray()
31+
}
32+
33+
override fun wrapGroupKey(
34+
ownKeyPair: KeyPair,
35+
recipientPk: ByteArray,
36+
chatId: ByteArray,
37+
groupKey: ByteArray,
38+
): EncryptedPayload = wrapGroupKey(ownKeyPair, recipientPk, chatId, groupKey, randomNonce())
39+
40+
internal fun wrapGroupKey(
41+
ownKeyPair: KeyPair,
42+
recipientPk: ByteArray,
43+
chatId: ByteArray,
44+
groupKey: ByteArray,
45+
nonce: ByteArray,
46+
): EncryptedPayload {
47+
if (groupKey.size != KEY_SIZE) throw ChatCipherException("group key must be 32 bytes")
48+
val wrappingKey = pairKey(ownKeyPair, recipientPk, WRAP_LABEL + chatId)
49+
val aad = WRAP_LABEL + chatId + ownKeyPair.publicKey + recipientPk
50+
return EncryptedPayload(nonce, seal(groupKey, aad, nonce, wrappingKey))
51+
}
52+
53+
override fun unwrapGroupKey(
54+
ownKeyPair: KeyPair,
55+
wrapperPk: ByteArray,
56+
chatId: ByteArray,
57+
envelope: EncryptedPayload,
58+
): ByteArray {
59+
val wrappingKey = pairKey(ownKeyPair, wrapperPk, WRAP_LABEL + chatId)
60+
val aad = WRAP_LABEL + chatId + wrapperPk + ownKeyPair.publicKey
61+
val key = open(envelope.ciphertext, aad, envelope.nonce, wrappingKey)
62+
if (key.size != KEY_SIZE) throw ChatCipherException("group key must be 32 bytes")
63+
return key
64+
}
65+
66+
/**
67+
* Steps 1–3 shared by the DM chat key and the group key wrap: X25519 between the two members,
68+
* then HKDF-SHA256 salted with both Ed25519 public keys in bytewise order.
69+
*/
70+
private fun pairKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, info: ByteArray): ByteArray {
2571
if (ownKeyPair.publicKey.size != KEY_SIZE || ownKeyPair.privateKey.size != 64) {
2672
throw ChatCipherException("own key pair must be a 32-byte public and 64-byte private key")
2773
}
@@ -30,7 +76,7 @@ object DefaultChatCipher : ChatCipher {
3076
val a = ownKeyPair.publicKey
3177
val b = peerPublicKey
3278
val salt = if (compareBytes(a, b) <= 0) a + b else b + a
33-
return hkdfSha256(ikm = ss, salt = salt, info = LABEL + chatId, length = KEY_SIZE)
79+
return hkdfSha256(ikm = ss, salt = salt, info = info, length = KEY_SIZE)
3480
}
3581

3682
override fun encrypt(
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.getcode.chatcipher
2+
3+
import com.getcode.ed25519kmp.Ed25519Kmp
4+
import kotlin.test.Test
5+
import kotlin.test.assertContentEquals
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFailsWith
8+
9+
/**
10+
* `chat.v1.KeyEnvelope` scheme `X25519_XCHACHA20POLY1305`. There are no shared vectors for it yet,
11+
* so these pin the properties the contract states rather than exact bytes.
12+
*/
13+
class GroupKeyWrapTest {
14+
private val creator = Ed25519Kmp.createKeyPair(ByteArray(32) { 1 })
15+
private val member = Ed25519Kmp.createKeyPair(ByteArray(32) { 2 })
16+
private val stranger = Ed25519Kmp.createKeyPair(ByteArray(32) { 3 })
17+
private val chatId = ByteArray(32) { it.toByte() }
18+
private val groupKey = ByteArray(32) { (0xA0 + it).toByte() }
19+
20+
@Test
21+
fun creatorWrap_opensForTheRecipient() {
22+
val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey)
23+
24+
assertEquals(24, envelope.nonce.size)
25+
assertEquals(48, envelope.ciphertext.size)
26+
assertContentEquals(groupKey, DefaultChatCipher.unwrapGroupKey(member, creator.publicKey, chatId, envelope))
27+
}
28+
29+
@Test
30+
fun selfWrap_opensForTheWrapper() {
31+
val envelope = DefaultChatCipher.wrapGroupKey(creator, creator.publicKey, chatId, groupKey)
32+
33+
assertContentEquals(groupKey, DefaultChatCipher.unwrapGroupKey(creator, creator.publicKey, chatId, envelope))
34+
}
35+
36+
@Test
37+
fun envelope_rejectsAnyOtherWrapperOrRecipient() {
38+
val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey)
39+
40+
assertFailsWith<ChatCipherException> {
41+
DefaultChatCipher.unwrapGroupKey(member, stranger.publicKey, chatId, envelope)
42+
}
43+
assertFailsWith<ChatCipherException> {
44+
DefaultChatCipher.unwrapGroupKey(stranger, creator.publicKey, chatId, envelope)
45+
}
46+
}
47+
48+
@Test
49+
fun envelope_isBoundToTheChat() {
50+
val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey)
51+
val otherChat = chatId.copyOf().also { it[0] = 0x7F }
52+
53+
assertFailsWith<ChatCipherException> {
54+
DefaultChatCipher.unwrapGroupKey(member, creator.publicKey, otherChat, envelope)
55+
}
56+
}
57+
58+
@Test
59+
fun wrappingKey_isNotTheDmChatKey() {
60+
// Same pair and chat, different label: a DM key must never open a group envelope.
61+
val nonce = ByteArray(24)
62+
val envelope = DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, groupKey, nonce)
63+
val dmKey = DefaultChatCipher.chatKey(creator, member.publicKey, chatId)
64+
val dmSealed = DefaultChatCipher.encrypt(groupKey, dmKey, creator.publicKey, member.publicKey, chatId, nonce)
65+
66+
assertEquals(false, envelope.ciphertext.contentEquals(dmSealed.ciphertext))
67+
}
68+
69+
@Test
70+
fun wrap_rejectsAShortKey() {
71+
assertFailsWith<ChatCipherException> {
72+
DefaultChatCipher.wrapGroupKey(creator, member.publicKey, chatId, ByteArray(16))
73+
}
74+
}
75+
76+
@Test
77+
fun newGroupKey_isThirtyTwoFreshBytes() {
78+
val a = DefaultChatCipher.newGroupKey()
79+
val b = DefaultChatCipher.newGroupKey()
80+
81+
assertEquals(32, a.size)
82+
assertEquals(false, a.contentEquals(b))
83+
}
84+
}

‎services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,27 @@ object FakeChatCipher : ChatCipher {
5959
}
6060
return blob.copyOfRange(header.size, blob.size)
6161
}
62+
63+
override fun newGroupKey(): ByteArray = ByteArray(32) { it.toByte() }
64+
65+
override fun wrapGroupKey(
66+
ownKeyPair: KeyPair,
67+
recipientPk: ByteArray,
68+
chatId: ByteArray,
69+
groupKey: ByteArray,
70+
) = EncryptedPayload(nonce = ByteArray(24), ciphertext = ownKeyPair.publicKey + recipientPk + groupKey)
71+
72+
override fun unwrapGroupKey(
73+
ownKeyPair: KeyPair,
74+
wrapperPk: ByteArray,
75+
chatId: ByteArray,
76+
envelope: EncryptedPayload,
77+
): ByteArray {
78+
val header = wrapperPk + ownKeyPair.publicKey
79+
val sealed = envelope.ciphertext
80+
if (sealed.size < header.size || !sealed.copyOfRange(0, header.size).contentEquals(header)) {
81+
throw ChatCipherException("authentication failed")
82+
}
83+
return sealed.copyOfRange(header.size, sealed.size)
84+
}
6285
}

0 commit comments

Comments
 (0)