From ebe625216d320bbd3cef1c289e98af4eccc1dc53 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 24 Oct 2026 22:26:44 -0400 Subject: [PATCH] feat(chat-cipher): encrypt and decrypt a group payment request's text EncryptedPaymentRequestText uses the group's chat key with the aad "flipcash-group-payment-request-e2ee-v1" || chat_id || creator_id. It sits beside encryptGroup/decryptGroup, which differ only in label and aad, so iOS gets it through the next shared-core build. PaymentRequestTextVectorTest reproduces payment_request_text.json: the four texts encrypt and decrypt byte for byte, and the five rejects fail. --- .../com/getcode/chatcipher/ChatCipher.kt | 12 ++ .../getcode/chatcipher/DefaultChatCipher.kt | 17 ++- .../PaymentRequestTextVectorTest.kt | 108 ++++++++++++++++++ .../resources/payment_request_text.json | 84 ++++++++++++++ .../flipcash/services/chat/FakeChatCipher.kt | 8 ++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/PaymentRequestTextVectorTest.kt create mode 100644 libs/encryption/chat-cipher/src/commonTest/resources/payment_request_text.json 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 064c1d6750..df5b8e9411 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 @@ -86,6 +86,18 @@ interface ChatCipher { @Throws(ChatCipherException::class) fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray + /** + * Encrypts a serialized `paymentrequest.v1.PaymentRequestText` as `EncryptedPaymentRequestText` + * (scheme `CHAT_KEY_XCHACHA20POLY1305`) under [groupKey] and a fresh random nonce. [creatorId] is + * the raw `UserId.value` of the request's creator. + */ + @Throws(ChatCipherException::class) + fun encryptPaymentRequestText(text: ByteArray, groupKey: ByteArray, chatId: ByteArray, creatorId: ByteArray): EncryptedPayload + + /** @throws ChatCipherException if authentication fails, including the wrong [creatorId] or [chatId]. */ + @Throws(ChatCipherException::class) + fun decryptPaymentRequestText(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, creatorId: 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 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 6bc7890ec3..3df52cc9fc 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 @@ -23,6 +23,7 @@ object DefaultChatCipher : ChatCipher { 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() + private val PAYMENT_REQUEST_LABEL = "flipcash-group-payment-request-e2ee-v1".encodeToByteArray() override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = pairKey(ownKeyPair, peerPublicKey, LABEL + chatId) @@ -158,6 +159,20 @@ object DefaultChatCipher : ChatCipher { override fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray = open(payload.ciphertext, GROUP_LABEL + chatId + senderId, payload.nonce, groupKey) + override fun encryptPaymentRequestText(text: ByteArray, groupKey: ByteArray, chatId: ByteArray, creatorId: ByteArray): EncryptedPayload = + encryptPaymentRequestText(text, groupKey, chatId, creatorId, randomNonce()) + + internal fun encryptPaymentRequestText( + text: ByteArray, + groupKey: ByteArray, + chatId: ByteArray, + creatorId: ByteArray, + nonce: ByteArray, + ): EncryptedPayload = EncryptedPayload(nonce, seal(text, PAYMENT_REQUEST_LABEL + chatId + creatorId, nonce, groupKey)) + + override fun decryptPaymentRequestText(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, creatorId: ByteArray): ByteArray = + open(payload.ciphertext, PAYMENT_REQUEST_LABEL + chatId + creatorId, payload.nonce, groupKey) + override fun encryptGroupBlob( image: ByteArray, groupKey: ByteArray, @@ -242,7 +257,7 @@ object DefaultChatCipher : ChatCipher { ).toByteArray() } - private fun open(cipher: ByteArray, aad: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + internal fun open(cipher: ByteArray, aad: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { checkKeyAndNonce(key, nonce) if (cipher.size < TAG_SIZE) throw ChatCipherException("ciphertext shorter than tag") ensureSodium() diff --git a/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/PaymentRequestTextVectorTest.kt b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/PaymentRequestTextVectorTest.kt new file mode 100644 index 0000000000..a88d1c56c1 --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/kotlin/com/getcode/chatcipher/PaymentRequestTextVectorTest.kt @@ -0,0 +1,108 @@ +package com.getcode.chatcipher + +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.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Reproduces `payment_request_text.json` (canonical copy and generator in the orchestrator's + * `test-vectors/`), which pins `EncryptedPaymentRequestText` against libsodium and an independent + * HChaCha20 reference. + */ +class PaymentRequestTextVectorTest { + private val root = Json.parseToJsonElement(readTestResource("payment_request_text.json")).jsonObject + private fun list(key: String) = root[key]!!.jsonArray.map { it.jsonObject } + private val chatKey = root.hex("chatKey") + private val chatId = root.hex("chatId") + private val creatorId = root.hex("creatorId") + + @Test + fun texts_encryptAndDecrypt() { + val vectors = list("texts") + assertEquals(4, vectors.size) + for (v in vectors) { + assertContentEquals(v.hex("aad"), root.string("label").encodeToByteArray() + chatId + creatorId, v.name()) + val payload = DefaultChatCipher.encryptPaymentRequestText(v.hex("plaintext"), chatKey, chatId, creatorId, v.hex("nonce")) + assertContentEquals(v.hex("ciphertext"), payload.ciphertext, v.name()) + + val plain = DefaultChatCipher.decryptPaymentRequestText( + EncryptedPayload(v.hex("nonce"), v.hex("ciphertext")), chatKey, chatId, creatorId, + ) + assertContentEquals(v.hex("plaintext"), plain, v.name()) + val fields = parseStrings(plain) + assertEquals(v.string("title"), fields[1].orEmpty(), v.name()) + assertEquals(v.string("description"), fields[2].orEmpty(), v.name()) + } + } + + @Test + fun rejects_failToDecrypt() { + val vectors = list("rejects") + assertEquals(5, vectors.size) + val label = root.string("label").encodeToByteArray() + for (v in vectors) { + val aad = v.hex("aad") + val key = v.hex("chatKey") + val payload = EncryptedPayload(v.hex("nonce"), v.hex("ciphertext")) + // Exactly the aad the vector names. + assertFailsWith(v.name()) { + DefaultChatCipher.open(payload.ciphertext, aad, payload.nonce, key) + } + // And through the public API, where the cipher builds the aad from chat and creator. + if (aad.copyOfRange(0, label.size).contentEquals(label)) { + val chat = aad.copyOfRange(label.size, label.size + 32) + val creator = aad.copyOfRange(label.size + 32, aad.size) + assertFailsWith(v.name()) { + DefaultChatCipher.decryptPaymentRequestText(payload, key, chat, creator) + } + } + } + } + + @Test + fun randomNonces_roundTripAndDiffer() { + val v = list("texts")[1] + val a = DefaultChatCipher.encryptPaymentRequestText(v.hex("plaintext"), chatKey, chatId, creatorId) + val b = DefaultChatCipher.encryptPaymentRequestText(v.hex("plaintext"), chatKey, chatId, creatorId) + assertTrue(!a.nonce.contentEquals(b.nonce)) + assertContentEquals(v.hex("plaintext"), DefaultChatCipher.decryptPaymentRequestText(a, chatKey, chatId, creatorId)) + } + + /** Length-delimited string fields of a proto message, by field number; enough for `PaymentRequestText`. */ + private fun parseStrings(bytes: ByteArray): Map { + val out = mutableMapOf() + var i = 0 + fun varint(): Int { + var shift = 0 + var result = 0 + while (true) { + val b = bytes[i++].toInt() and 0xFF + result = result or ((b and 0x7F) shl shift) + if (b and 0x80 == 0) return result + shift += 7 + } + } + while (i < bytes.size) { + val tag = varint() + assertEquals(2, tag and 7, "only length-delimited fields expected") + val len = varint() + out[tag shr 3] = bytes.copyOfRange(i, i + len).decodeToString() + i += len + } + return out + } + + private fun JsonObject.name() = string("name") + private fun JsonObject.string(key: String) = this[key]!!.jsonPrimitive.content + private fun JsonObject.hex(key: String): ByteArray { + val s = string(key) + 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/payment_request_text.json b/libs/encryption/chat-cipher/src/commonTest/resources/payment_request_text.json new file mode 100644 index 0000000000..1c12db3f1f --- /dev/null +++ b/libs/encryption/chat-cipher/src/commonTest/resources/payment_request_text.json @@ -0,0 +1,84 @@ +{ + "algorithm": "XChaCha20-Poly1305-IETF", + "spec": "docs/specs/group-requests.md; proto/paymentrequest/v1/model.proto EncryptedPaymentRequestText", + "note": "Encrypt each `texts` case with chatKey and its nonce and compare to ciphertext byte for byte; decrypt it and compare the parsed title and description. Every `rejects` case must fail to decrypt.", + "label": "flipcash-group-payment-request-e2ee-v1", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "chatId": "132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6", + "creatorId": "bf49c36befaf8d4827dcb9b4c22578f8", + "texts": [ + { + "name": "title only, empty description left off the wire", + "title": "Saturday Request", + "description": "", + "nonce": "d30390c86c2edec34f89a32cf57dcbfdbd90a7b7449c993a", + "plaintext": "0a1053617475726461792052657175657374", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "b292dfe53aa7003c77cfb742fbd7f51bcc7f00d2f88115ceb60a929885513bd56f5a" + }, + { + "name": "title and description", + "title": "Uber ride", + "description": "Airport run, split four ways.", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "plaintext": "0a09556265722072696465121d416972706f72742072756e2c2073706c697420666f757220776179732e", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6" + }, + { + "name": "non-ASCII title", + "title": "Café ☕ & 寿司 🍣", + "description": "", + "nonce": "60fbf57ac643214378c44edcd1d4b458c0dbf1d5e1c79548", + "plaintext": "0a17436166c3a920e29895202620e5afbfe58fb820f09f8da3", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "fb805623884104ac833aa2c79c4e8c8a07286f678eeb9b26acd7758bdcb104e9dadf6265c58d30f324" + }, + { + "name": "maximum lengths: 100-character title, 500-character description", + "title": "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT", + "description": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "nonce": "72911065c4dcb4aae27f48b742f559dbdf203623dd9c4793", + "plaintext": "0a645454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545454545412f4036464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8", + "ciphertext": "6368e73db1da5e28a31227272577e330e9bf87d98e28ff7ff21331ac4993815387a6c7c07b4fb8f63e017ef0999f9b995379ceb859eeff2753cc96cc8ed13360c1865815c7b4be4a49af5a4738ae77746bb2384eae6011c0ef6ade9e8b932d613da434f858fd9be8ff0831815aa9a3b2a882ef8f5eb9c63031625ebfce0589c5404859ebe3bed34307ac99dfc4d80cc4ca5ca47fcae6f04ec77f4623ebe170348e72827c1aed76b1b3ab7f163d2cd7b2b985521d9679ffd85a866977df2ba0251da1cf4063a4d8f3ea89835422d11403cfb0350bfc647639a67a8b77e473dedb2eaf461313fd17f215ad44b7eec517b0dd00bc17d298c80e83fb8d3a025916da93ac4d9bbb7d79e70d2a886f055afd1d5abae1799275ce105f949cd595e62e97817a62b4f9091ea2486edc9e1cad407d51f026f620416bb3fd78f4dfdb8a95ebd136e450f0413ed64b9b8fe42127d3e6705611f8927da4834448a4ea038be102255eb6334f949d5130a210a63abb8acf6cd6b8a7fe812a5ce5350be34647b6cf8a060b384f3ac00ffc12fab587cbf67a30b8cdb373a26bcc53053071152f0dcccbae8c8afa40567d8f97a6861722e04264a46b6dfaf3079ae64390ee96586f35d2161232a26b70577bfdfae6662e76cd3c80527c61e99dcd9bb4eaf58157775ea6cea1d798952a759c546849694bf65691620cf391af74cc78630869b38d94c12e6f01ec0a65b1fe890d3ad3cc5f554b8f1809e86a192e0f631e7d5faa956fccdc225efe66cf66a59efad996138b6fac00aebb16e7b78195e8772b150cf4689a684ee281d28b3c6f80b4a57328c1842112f904ae0fc7b5952055d36044a2bfcfc15fb687f8fe28418ebb187f6c" + } + ], + "rejects": [ + { + "name": "text moved to another chat", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631446235d7c7fe8352f2ac4ecc078c5bb98b9865ec9b5261bac4819d5e33177345bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "text attributed to another creator", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6ac04c96fcef918c9943261e7812c8098" + }, + { + "name": "tag tampered", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f7", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "wrong chat key", + "chatKey": "d030bc85a871ba3a903909a90118a3cd8e40e5352e9638476a9cb08e5b8d060f", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d7061796d656e742d726571756573742d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + }, + { + "name": "aad label of another payload kind", + "chatKey": "7df2c2ee8b67ac002be86863a18ce520470df690eec89285d52b1661e3c13159", + "nonce": "06ab9fa0d19628874f1e10c76bfbc25f2792edbac2fe8405", + "ciphertext": "8c659ad8182ed8f0ce2adbad856b64ae903c0a93c8b8589ad33249246d7a91258a5b8aa668d2b629fbb08ea4577693123a81e05486a529afa2f6", + "aad": "666c6970636173682d67726f75702d706f6c6c2d653265652d7631132006a6210d07ac34623d7d822a19eca26d2ca3f6e22cf4f6c4d1d1bce985f6bf49c36befaf8d4827dcb9b4c22578f8" + } + ] +} 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 910ae7b9bf..5cff19b829 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 @@ -12,6 +12,7 @@ import com.getcode.ed25519kmp.KeyPair */ object FakeChatCipher : ChatCipher { private const val HEADER = 32 * 3 + private const val PAYMENT_REQUEST: Byte = 0x50 override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = ByteArray(32) { i -> (ownKeyPair.publicKey[i].toInt() xor peerPublicKey[i].toInt() xor chatId[i].toInt()).toByte() } @@ -66,6 +67,13 @@ object FakeChatCipher : ChatCipher { override fun decryptGroup(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, senderId: ByteArray): ByteArray = openWithHeader(payload.ciphertext, groupKey + chatId + senderId) + // The leading byte stands in for the distinct aad label, so group message bytes never open as request text. + override fun encryptPaymentRequestText(text: ByteArray, groupKey: ByteArray, chatId: ByteArray, creatorId: ByteArray) = + EncryptedPayload(nonce = ByteArray(24), ciphertext = byteArrayOf(PAYMENT_REQUEST) + groupKey + chatId + creatorId + text) + + override fun decryptPaymentRequestText(payload: EncryptedPayload, groupKey: ByteArray, chatId: ByteArray, creatorId: ByteArray): ByteArray = + openWithHeader(payload.ciphertext, byteArrayOf(PAYMENT_REQUEST) + groupKey + chatId + creatorId) + override fun encryptGroupBlob(image: ByteArray, groupKey: ByteArray, chatId: ByteArray, blobId: ByteArray, senderId: ByteArray) = groupKey + chatId + blobId + senderId + image