Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatCipherException> {
DefaultChatCipher.unwrapGroupKey(member, stranger.publicKey, chatId, envelope)
}
assertFailsWith<ChatCipherException> {
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<ChatCipherException> {
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<ChatCipherException> {
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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading