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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatCipherException>(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<ChatCipherException>(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<Int, String> {
val out = mutableMapOf<Int, String>()
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() }
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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

Expand Down
Loading