From fb57b34e7b7c42fbdf720e4ad6744c659186cd8f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 15:47:18 -0400 Subject: [PATCH] feat(chat-cipher): encrypt and decrypt private group messages and blobs Private groups send scheme 2 (CHAT_KEY_XCHACHA20POLY1305): XChaCha20-Poly1305 under the shared group key, with aad "flipcash-group-e2ee-v1" || chat_id || sender_id for messages and "flipcash-group-e2ee-blob-v1" || chat_id || blob_id || sender_id for blobs (nonce || ct). sender_id is the raw UserId.value, not a public key. group_cipher.json holds fixed vectors for messages, blobs and the group key wrap, generated by scripts/gen_group_cipher.py against libsodium rather than DefaultChatCipher, so another implementation can match bytes. --- .../chat-cipher/scripts/gen_group_cipher.py | 104 +++++++++++++ .../com/getcode/chatcipher/ChatCipher.kt | 23 ++- .../getcode/chatcipher/DefaultChatCipher.kt | 47 +++++- .../chatcipher/GroupCipherVectorTest.kt | 90 +++++++++++ .../commonTest/resources/group_cipher.json | 147 ++++++++++++++++++ .../flipcash/services/chat/FakeChatCipher.kt | 19 +++ 6 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 libs/encryption/chat-cipher/scripts/gen_group_cipher.py create mode 100644 libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupCipherVectorTest.kt create mode 100644 libs/encryption/chat-cipher/src/commonTest/resources/group_cipher.json diff --git a/libs/encryption/chat-cipher/scripts/gen_group_cipher.py b/libs/encryption/chat-cipher/scripts/gen_group_cipher.py new file mode 100644 index 0000000000..7da9ba9009 --- /dev/null +++ b/libs/encryption/chat-cipher/scripts/gen_group_cipher.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Generates src/commonTest/resources/group_cipher.json from libsodium directly (ctypes), independent of +DefaultChatCipher. Usage: python3 scripts/gen_group_cipher.py [path/to/libsodium.dylib]""" +import ctypes, ctypes.util, hashlib, hmac, json, os, sys + +lib = ctypes.CDLL(sys.argv[1] if len(sys.argv) > 1 else (ctypes.util.find_library("sodium") or "/opt/homebrew/lib/libsodium.dylib")) +assert lib.sodium_init() >= 0 +ull = ctypes.c_ulonglong + +def buf(n): return ctypes.create_string_buffer(n) + +def seal(key, nonce, plain, aad): + out = buf(len(plain) + 16); n = ull() + assert lib.crypto_aead_xchacha20poly1305_ietf_encrypt(out, ctypes.byref(n), plain, ull(len(plain)), aad, ull(len(aad)), None, nonce, key) == 0 + return out.raw[: n.value] + +def keypair(seed): + pk, sk = buf(32), buf(64) + assert lib.crypto_sign_seed_keypair(pk, sk, seed) == 0 + return pk.raw, sk.raw + +def x_priv(sk): + o = buf(32); assert lib.crypto_sign_ed25519_sk_to_curve25519(o, sk) == 0; return o.raw + +def x_pub(pk): + o = buf(32); assert lib.crypto_sign_ed25519_pk_to_curve25519(o, pk) == 0; return o.raw + +def x25519(priv, pub): + o = buf(32); assert lib.crypto_scalarmult(o, priv, pub) == 0; return o.raw + +def hkdf(ikm, salt, info, length=32): + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + t, out, i = b"", b"", 1 + while len(out) < length: + t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest(); out += t; i += 1 + return out[:length] + +GROUP = b"flipcash-group-e2ee-v1" +GROUP_BLOB = b"flipcash-group-e2ee-blob-v1" +WRAP = b"flipcash-group-key-wrap-v1" +h = lambda b: b.hex() +alice_seed = bytes(range(1, 33)); bob_seed = bytes(range(0x65, 0x85)) +alice_pk, alice_sk = keypair(alice_seed); bob_pk, bob_sk = keypair(bob_seed) +chat_id = bytes.fromhex("97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e") +group_key = bytes(range(0xa0, 0xc0)) +nonce = bytes(range(0x78, 0x90)) +sender16 = bytes.fromhex("0f1e2d3c4b5a69788796a5b4c3d2e1f0") +sender32 = bytes(range(0x40, 0x60)) + +messages = [] +for name, content, sender in [ + ("empty", b"", sender16), + ("text", bytes.fromhex("0a070a0568656c6c6f"), sender16), + ("longer-sender-id", bytes(range(256)), sender32), + ("one-byte-sender-id", b"hi", b"\x07"), +]: + aad = GROUP + chat_id + sender + messages.append({"name": name, "groupKey": h(group_key), "chatId": h(chat_id), "senderId": h(sender), + "content": h(content), "nonce": h(nonce), "aad": h(aad), "ciphertext": h(seal(group_key, nonce, content, aad))}) + +blob_id = bytes.fromhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf") +blobs = [] +for name, image in [("empty", b""), ("image", bytes(range(200)))]: + aad = GROUP_BLOB + chat_id + blob_id + sender16 + blobs.append({"name": name, "groupKey": h(group_key), "chatId": h(chat_id), "blobId": h(blob_id), "senderId": h(sender16), + "image": h(image), "nonce": h(nonce), "aad": h(aad), "blob": h(nonce + seal(group_key, nonce, image, aad))}) + +def wrap(w_pk, w_sk, r_pk): + ss = x25519(x_priv(w_sk), x_pub(r_pk)) + salt = min(w_pk, r_pk) + max(w_pk, r_pk) + k = hkdf(ss, salt, WRAP + chat_id) + aad = WRAP + chat_id + w_pk + r_pk + return k, aad, seal(k, nonce, group_key, aad) + +wraps = [] +for name, (w_seed, w_pk, w_sk), r_pk in [ + ("creator-for-member", (alice_seed, alice_pk, alice_sk), bob_pk), + ("self", (alice_seed, alice_pk, alice_sk), alice_pk), +]: + k, aad, ct = wrap(w_pk, w_sk, r_pk) + wraps.append({"name": name, "wrapperSeed": h(w_seed), "wrapperPublicKey": h(w_pk), "recipientPublicKey": h(r_pk), + "recipientSeed": h(bob_seed if r_pk == bob_pk else alice_seed), "chatId": h(chat_id), + "groupKey": h(group_key), "wrappingKey": h(k), "nonce": h(nonce), "aad": h(aad), "ciphertext": h(ct)}) + +m = messages[1]; ct = bytes.fromhex(m["ciphertext"]) +rejects = [ + dict(m, name="wrong-sender-id", senderId=h(sender32)), + dict(m, name="wrong-chat-id", chatId=h(bytes(32))), + dict(m, name="tampered", ciphertext=h(bytes([ct[0] ^ 1]) + ct[1:])), + dict(m, name="dm-label", ciphertext=h(seal(group_key, nonce, bytes.fromhex(m["content"]), b"flipcash-dm-e2ee-v1" + chat_id + sender16))), + dict(m, name="blob-as-message", ciphertext=h(seal(group_key, nonce, bytes.fromhex(m["content"]), GROUP_BLOB + chat_id + blob_id + sender16))), +] +for r in rejects: r.pop("aad", None) + +out = { + "algorithm": "flipcash-group-e2ee-v1 / CHAT_KEY_XCHACHA20POLY1305, flipcash-group-key-wrap-v1", + "spec": "EncryptedContent (messaging/v1/model.proto) and KeyEnvelope (chat/v1/model.proto) in flipcash2-protobuf-api. " + "aad = 'flipcash-group-e2ee-v1' || chatId || senderId; blob aad = 'flipcash-group-e2ee-blob-v1' || chatId || blobId || senderId; " + "senderId is the raw UserId.value bytes. Generated by scripts/gen_group_cipher.py against libsodium, not DefaultChatCipher.", + "messages": messages, "blobs": blobs, "wraps": wraps, "rejects": {"decryptGroup": rejects}, +} +dst = os.path.join(os.path.dirname(__file__), "..", "src", "commonTest", "resources", "group_cipher.json") +open(dst, "w").write(json.dumps(out, indent=2) + "\n") +print("wrote", os.path.normpath(dst)) 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 d1245da12b..064c1d6750 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 @@ -9,7 +9,8 @@ class ChatCipherException(message: String, cause: Throwable? = null) : Exception class EncryptedPayload(val nonce: ByteArray, val ciphertext: ByteArray) /** - * Scheme `X25519_XCHACHA20POLY1305` from `messaging.v1.EncryptedContent` in flipcash2-protobuf-api. + * Schemes `X25519_XCHACHA20POLY1305` (DMs) and `CHAT_KEY_XCHACHA20POLY1305` (private groups) from + * `messaging.v1.EncryptedContent` in flipcash2-protobuf-api, plus the group key wrap from `chat.v1.KeyEnvelope`. * Shared by both apps; `test-vectors/chat_cipher.json` in the orchestrator repo pins it. * [DefaultChatCipher] is the implementation; the interface is the seam for fakes in either app. * @@ -73,6 +74,26 @@ interface ChatCipher { blobId: ByteArray, ): ByteArray + /** + * Encrypts serialized `Content` bytes for a private group (scheme `CHAT_KEY_XCHACHA20POLY1305`) + * under [groupKey] and a fresh random nonce. [senderId] is the raw `UserId.value` of the sender, + * not a public key. + */ + @Throws(ChatCipherException::class) + fun encryptGroup(content: ByteArray, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): EncryptedPayload + + /** @throws ChatCipherException if authentication fails, including the wrong [senderId] or [chatId]. */ + @Throws(ChatCipherException::class) + fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray + + /** Returns `nonce || ciphertext || tag` for a private group's blob. [senderId] is the uploader. */ + @Throws(ChatCipherException::class) + fun encryptGroupBlob(image: ByteArray, groupKey: ByteArray, chatId: ByteArray, blobId: ByteArray, senderId: ByteArray): ByteArray + + /** @throws ChatCipherException if [blob] is too short or fails authentication. */ + @Throws(ChatCipherException::class) + fun decryptGroupBlob(blob: ByteArray, groupKey: ByteArray, chatId: ByteArray, blobId: ByteArray, senderId: ByteArray): ByteArray + /** 32 fresh random bytes: a private group's chat key, generated once by its creator. */ fun newGroupKey(): 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 ed2e9d3777..6bc7890ec3 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 @@ -11,7 +11,7 @@ import com.ionspin.kotlin.crypto.signature.Signature import com.ionspin.kotlin.crypto.util.LibsodiumRandom /** - * [ChatCipher] for scheme `X25519_XCHACHA20POLY1305`, on libsodium. Stateless; libsodium is + * [ChatCipher] for schemes `X25519_XCHACHA20POLY1305` and `CHAT_KEY_XCHACHA20POLY1305`, on libsodium. Stateless; libsodium is * initialized on first use if the app has not done it already. */ object DefaultChatCipher : ChatCipher { @@ -21,6 +21,8 @@ object DefaultChatCipher : ChatCipher { 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() + private val GROUP_LABEL = "flipcash-group-e2ee-v1".encodeToByteArray() + private val GROUP_BLOB_LABEL = "flipcash-group-e2ee-blob-v1".encodeToByteArray() override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = pairKey(ownKeyPair, peerPublicKey, LABEL + chatId) @@ -142,6 +144,49 @@ object DefaultChatCipher : ChatCipher { return open(blob.copyOfRange(NONCE_SIZE, blob.size), aad, blob.copyOfRange(0, NONCE_SIZE), chatKey) } + override fun encryptGroup(content: ByteArray, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): EncryptedPayload = + encryptGroup(content, groupKey, chatId, senderId, randomNonce()) + + internal fun encryptGroup( + content: ByteArray, + groupKey: ByteArray, + chatId: ByteArray, + senderId: ByteArray, + nonce: ByteArray, + ): EncryptedPayload = EncryptedPayload(nonce, seal(content, GROUP_LABEL + chatId + senderId, nonce, groupKey)) + + override fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray = + open(payload.ciphertext, GROUP_LABEL + chatId + senderId, payload.nonce, groupKey) + + override fun encryptGroupBlob( + image: ByteArray, + groupKey: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + senderId: ByteArray, + ): ByteArray = encryptGroupBlob(image, groupKey, chatId, blobId, senderId, randomNonce()) + + internal fun encryptGroupBlob( + image: ByteArray, + groupKey: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + senderId: ByteArray, + nonce: ByteArray, + ): ByteArray = nonce + seal(image, GROUP_BLOB_LABEL + chatId + blobId + senderId, nonce, groupKey) + + override fun decryptGroupBlob( + blob: ByteArray, + groupKey: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + senderId: ByteArray, + ): ByteArray { + if (blob.size < NONCE_SIZE + TAG_SIZE) throw ChatCipherException("blob shorter than nonce and tag") + val aad = GROUP_BLOB_LABEL + chatId + blobId + senderId + return open(blob.copyOfRange(NONCE_SIZE, blob.size), aad, blob.copyOfRange(0, NONCE_SIZE), groupKey) + } + // -- steps ------------------------------------------------------------------------------- /** `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[0..32]). The orlp private key already is that expansion. */ diff --git a/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupCipherVectorTest.kt b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupCipherVectorTest.kt new file mode 100644 index 0000000000..5af480349e --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/GroupCipherVectorTest.kt @@ -0,0 +1,90 @@ +package com.getcode.chatcipher + +import com.getcode.ed25519kmp.Ed25519Kmp +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Reproduces `group_cipher.json`, which `scripts/gen_group_cipher.py` builds from libsodium directly. */ +class GroupCipherVectorTest { + private val root = Json.parseToJsonElement(readTestResource("group_cipher.json")).jsonObject + private fun list(key: String) = root[key]!!.jsonArray.map { it.jsonObject } + + @Test + fun messages_encryptAndDecrypt() { + val vectors = list("messages") + assertTrue(vectors.size >= 4) + for (v in vectors) { + val payload = DefaultChatCipher.encryptGroup( + v.hex("content"), v.hex("groupKey"), v.hex("chatId"), v.hex("senderId"), v.hex("nonce"), + ) + assertContentEquals(v.hex("ciphertext"), payload.ciphertext, v.name()) + val plain = DefaultChatCipher.decryptGroup(v.payload(), v.hex("groupKey"), v.hex("chatId"), v.hex("senderId")) + assertContentEquals(v.hex("content"), plain, v.name()) + } + } + + @Test + fun blobs_encryptAndDecrypt() { + val vectors = list("blobs") + assertTrue(vectors.isNotEmpty()) + for (v in vectors) { + val blob = DefaultChatCipher.encryptGroupBlob( + v.hex("image"), v.hex("groupKey"), v.hex("chatId"), v.hex("blobId"), v.hex("senderId"), v.hex("nonce"), + ) + assertContentEquals(v.hex("blob"), blob, v.name()) + val plain = DefaultChatCipher.decryptGroupBlob( + v.hex("blob"), v.hex("groupKey"), v.hex("chatId"), v.hex("blobId"), v.hex("senderId"), + ) + assertContentEquals(v.hex("image"), plain, v.name()) + } + } + + @Test + fun wraps_matchAndOpen() { + val vectors = list("wraps") + assertTrue(vectors.size >= 2) + for (v in vectors) { + val wrapper = Ed25519Kmp.createKeyPair(v.hex("wrapperSeed")) + val recipient = Ed25519Kmp.createKeyPair(v.hex("recipientSeed")) + assertContentEquals(v.hex("wrapperPublicKey"), wrapper.publicKey, v.name()) + val envelope = DefaultChatCipher.wrapGroupKey(wrapper, recipient.publicKey, v.hex("chatId"), v.hex("groupKey"), v.hex("nonce")) + assertContentEquals(v.hex("ciphertext"), envelope.ciphertext, v.name()) + val key = DefaultChatCipher.unwrapGroupKey(recipient, wrapper.publicKey, v.hex("chatId"), v.payload()) + assertContentEquals(v.hex("groupKey"), key, v.name()) + } + } + + @Test + fun rejects_messages() { + val vectors = root["rejects"]!!.jsonObject["decryptGroup"]!!.jsonArray.map { it.jsonObject } + assertTrue(vectors.size >= 5) + for (v in vectors) { + assertFailsWith(v.name()) { + DefaultChatCipher.decryptGroup(v.payload(), v.hex("groupKey"), v.hex("chatId"), v.hex("senderId")) + } + } + } + + @Test + fun randomNonces_roundTripAndDiffer() { + val v = list("messages")[1] + val a = DefaultChatCipher.encryptGroup(v.hex("content"), v.hex("groupKey"), v.hex("chatId"), v.hex("senderId")) + val b = DefaultChatCipher.encryptGroup(v.hex("content"), v.hex("groupKey"), v.hex("chatId"), v.hex("senderId")) + assertTrue(!a.nonce.contentEquals(b.nonce)) + assertContentEquals(v.hex("content"), DefaultChatCipher.decryptGroup(a, v.hex("groupKey"), v.hex("chatId"), v.hex("senderId"))) + } + + private fun JsonObject.payload() = EncryptedPayload(hex("nonce"), hex("ciphertext")) + private fun JsonObject.name() = this["name"]!!.jsonPrimitive.content + private fun JsonObject.hex(key: String): ByteArray { + val s = this[key]!!.jsonPrimitive.content + return ByteArray(s.length / 2) { i -> ((s[i * 2].digitToInt(16) shl 4) or s[i * 2 + 1].digitToInt(16)).toByte() } + } +} diff --git a/libs/encryption/chat-cipher/src/commonTest/resources/group_cipher.json b/libs/encryption/chat-cipher/src/commonTest/resources/group_cipher.json new file mode 100644 index 0000000000..dcf15f53dd --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/resources/group_cipher.json @@ -0,0 +1,147 @@ +{ + "algorithm": "flipcash-group-e2ee-v1 / CHAT_KEY_XCHACHA20POLY1305, flipcash-group-key-wrap-v1", + "spec": "EncryptedContent (messaging/v1/model.proto) and KeyEnvelope (chat/v1/model.proto) in flipcash2-protobuf-api. aad = 'flipcash-group-e2ee-v1' || chatId || senderId; blob aad = 'flipcash-group-e2ee-blob-v1' || chatId || blobId || senderId; senderId is the raw UserId.value bytes. Generated by scripts/gen_group_cipher.py against libsodium, not DefaultChatCipher.", + "messages": [ + { + "name": "empty", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "ciphertext": "a1d8008dbe15fd98bb53c6c201b2c172" + }, + { + "name": "text", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "ciphertext": "d1e8e9af7541371627adf05c511ebd521fe7d2f0f92011a6e9" + }, + { + "name": "longer-sender-id", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "content": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "ciphertext": "dbeee1a919215d7d40eb6a4cfcd042a5e609e37ca9308e31f61f7162d7406c6bc3ccb0a4ab073460de8571099efe490fae29ea7363129dd875baf4baa6fbceb59003183d12b9b6a551f7f587d27ac74645d665deae6a7d05103565b701f9fc35a2114719ac2461b96d52196bfee43f90188ce84d8eaa9a9d6fe97d27a648599c00864f8f3860fd832e99322a08a6468d32245045a40bb260c311a96ffac8f6c39b6f9108a786083c6d113cd48ef7aa56b7796f1b2d6712c2aedecb5ee525ab26e25bc8a6f1262bf6e6668dc9b57465a55f747d65ea13ceb6cd93dabc4b47ecc5700b9bcacc638c9e50b3afecb3294a1223cb9eefbf13eb88be8547199298a5dac885d164bacea5956e7560053d4c22a8" + }, + { + "name": "one-byte-sender-id", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "07", + "content": "6869", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e07", + "ciphertext": "b3862b5ad24f7da9de5aa6010140a201c643" + } + ], + "blobs": [ + { + "name": "empty", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "image": "", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d626c6f622d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15ea0a1a2a3a4a5a6a7a8a9aaabacadaeaf0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "blob": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f737e3a46804d3b5b0ff9f9b2cf87abe4" + }, + { + "name": "image", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "blobId": "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "image": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d653265652d626c6f622d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15ea0a1a2a3a4a5a6a7a8a9aaabacadaeaf0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "blob": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8fdbeee1a919215d7d40eb6a4cfcd042a5e609e37ca9308e31f61f7162d7406c6bc3ccb0a4ab073460de8571099efe490fae29ea7363129dd875baf4baa6fbceb59003183d12b9b6a551f7f587d27ac74645d665deae6a7d05103565b701f9fc35a2114719ac2461b96d52196bfee43f90188ce84d8eaa9a9d6fe97d27a648599c00864f8f3860fd832e99322a08a6468d32245045a40bb260c311a96ffac8f6c39b6f9108a786083c6d113cd48ef7aa56b7796f1b2d6712c2aedecb5ee525ab26e25bc8a6f1262bf62b4e8440779904dd2559c0b0ec8eeed3" + } + ], + "wraps": [ + { + "name": "creator-for-member", + "wrapperSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "wrapperPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "recipientSeed": "65666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f8081828384", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "wrappingKey": "bebd24568f5cb949bc8c6962c626b2e174fdd3c677a66b8efcf35289abf4e41f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d6b65792d777261702d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664da29e95b02e00ffa15645775fb1d2ba222a1943395eea06b94e2c057b7be69d0", + "ciphertext": "f7823c719916d6636a8348a8c1c4254409172fedc830f56c6061e01fc1e3f956bf4faadd73fc0865388b4dc5d5fa96cd" + }, + { + "name": "self", + "wrapperSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "wrapperPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientPublicKey": "79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "recipientSeed": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "wrappingKey": "5f81456d0d259bf134072a064b60658972a00f492b3ae3089a6aea37514a75c3", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "aad": "666c6970636173682d67726f75702d6b65792d777261702d763197dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad04966479b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664", + "ciphertext": "61580137239facdc36c36cf0288bf0f628fbb2fafac5d3576231423b6cafb0f22c6853482ae6e17c70c90aedc979bf00" + } + ], + "rejects": { + "decryptGroup": [ + { + "name": "wrong-sender-id", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "ciphertext": "d1e8e9af7541371627adf05c511ebd521fe7d2f0f92011a6e9" + }, + { + "name": "wrong-chat-id", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "0000000000000000000000000000000000000000000000000000000000000000", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "ciphertext": "d1e8e9af7541371627adf05c511ebd521fe7d2f0f92011a6e9" + }, + { + "name": "tampered", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "ciphertext": "d0e8e9af7541371627adf05c511ebd521fe7d2f0f92011a6e9" + }, + { + "name": "dm-label", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "ciphertext": "d1e8e9af7541371627eadd030b6937e8125de40ed7aaaa684c" + }, + { + "name": "blob-as-message", + "groupKey": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "chatId": "97dfb79f2c2c44a9bd581b3b4fd30e7aee470d3b53062288886a976dd8ade15e", + "senderId": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "content": "0a070a0568656c6c6f", + "nonce": "78797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f", + "ciphertext": "d1e8e9af7541371627b8d2cc53d95e2741ee682602cc93bc28" + } + ] + } +} 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 7b17960736..910ae7b9bf 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 @@ -60,6 +60,25 @@ object FakeChatCipher : ChatCipher { return blob.copyOfRange(header.size, blob.size) } + override fun encryptGroup(content: ByteArray, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray) = + EncryptedPayload(nonce = ByteArray(24), ciphertext = groupKey + chatId + senderId + content) + + override fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray = + openWithHeader(payload.ciphertext, groupKey + chatId + senderId) + + override fun encryptGroupBlob(image: ByteArray, groupKey: ByteArray, chatId: ByteArray, blobId: ByteArray, senderId: ByteArray) = + groupKey + chatId + blobId + senderId + image + + override fun decryptGroupBlob(blob: ByteArray, groupKey: ByteArray, chatId: ByteArray, blobId: ByteArray, senderId: ByteArray) = + openWithHeader(blob, groupKey + chatId + blobId + senderId) + + private fun openWithHeader(sealed: ByteArray, header: ByteArray): ByteArray { + if (sealed.size < header.size || !sealed.copyOfRange(0, header.size).contentEquals(header)) { + throw ChatCipherException("authentication failed") + } + return sealed.copyOfRange(header.size, sealed.size) + } + override fun newGroupKey(): ByteArray = ByteArray(32) { it.toByte() } override fun wrapGroupKey(