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
104 changes: 104 additions & 0 deletions libs/encryption/chat-cipher/scripts/gen_group_cipher.py
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatCipherException>(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() }
}
}
Loading
Loading