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
1 change: 1 addition & 0 deletions .github/workflows/shared-core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
:libs:codes:kikcode:iosSimulatorArm64Test \
:libs:currency-math:discrete-curve:iosSimulatorArm64Test \
:libs:encryption:base58:iosSimulatorArm64Test \
:libs:encryption:chat-cipher:iosSimulatorArm64Test \
:libs:encryption:ed25519:iosSimulatorArm64Test \
:libs:encryption:hmac:iosSimulatorArm64Test \
:libs:encryption:mnemonic:iosSimulatorArm64Test \
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ mixpanel = { module = "com.mixpanel.android:mixpanel-android", version.ref = "mi

# Crypto
sodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings-android", version.ref = "sodium-bindings" }
sodium-bindings-kmp = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings", version.ref = "sodium-bindings" }
ionspin-bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" }
eddsa = { module = "net.i2p.crypto:eddsa", version = "0.3.0" }
kotlincrypto-hash-sha2 = { module = "org.kotlincrypto.hash:sha2", version.ref = "kotlincrypto-hash" }
Expand Down
2 changes: 2 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ kotlin {
export(project(":libs:encryption:sha512"))
export(project(":libs:encryption:hmac"))
export(project(":libs:encryption:ed25519"))
export(project(":libs:encryption:chat-cipher"))
export(project(":libs:encryption:mnemonic"))
export(project(":libs:currency-math:discrete-curve"))
export(project(":libs:reporting"))
Expand All @@ -59,6 +60,7 @@ kotlin {
api(project(":libs:encryption:sha512"))
api(project(":libs:encryption:hmac"))
api(project(":libs:encryption:ed25519"))
api(project(":libs:encryption:chat-cipher"))
api(project(":libs:encryption:mnemonic"))
api(project(":libs:currency-math:discrete-curve"))
api(project(":libs:reporting"))
Expand Down
1 change: 1 addition & 0 deletions libs/encryption/chat-cipher/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
62 changes: 62 additions & 0 deletions libs/encryption/chat-cipher/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree

plugins {
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
alias(libs.plugins.flipcash.kmp.test.fixtures)
}

// Compiles `src/commonTest/resources` (chat_cipher.json, canonical copy in the orchestrator's
// `test-vectors/`) into a generated `TestFixtures.kt` on `commonTest`, readable from every target.
testFixtures {
packageName = "com.getcode.chatcipher"
}

kotlin {
android {
namespace = "com.getcode.encryption.chatcipher"
compileSdk {
version = release(libs.versions.android.compileSdk.get().toInt()) {
minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt()
}
}
minSdk = 29
// No host test: libsodium's Android binding loads its .so from the APK and the Ed25519
// actual is JNI, so neither loads on a JVM host. The device test runs commonTest instead,
// and the Apple targets run it natively.
withDeviceTestBuilder {
sourceSetTreeName = KotlinSourceSetTree.test.name
}.configure {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}

iosArm64()
iosSimulatorArm64()
iosX64()
macosArm64()
macosX64()

sourceSets {
commonMain {
dependencies {
// KeyPair is part of this module's API.
api(project(":libs:encryption:ed25519"))
implementation(project(":libs:encryption:hmac"))
implementation(libs.sodium.bindings.kmp)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.serialization.json)
}
}
getByName("androidDeviceTest") {
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.test.runner)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.getcode.chatcipher

import com.getcode.ed25519kmp.KeyPair

/** A message or blob could not be encrypted or decrypted. Callers render it as unsupported. */
class ChatCipherException(message: String, cause: Throwable? = null) : Exception(message, cause)

/** A message ciphertext (with its 16-byte tag) and its 24-byte nonce, as carried by `EncryptedContent`. */
class EncryptedPayload(val nonce: ByteArray, val ciphertext: ByteArray)

/**
* Scheme `X25519_XCHACHA20POLY1305` from `messaging.v1.EncryptedContent` in flipcash2-protobuf-api.
* 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.
*
* All keys are raw bytes: Ed25519 public keys are 32 bytes, the chat key is 32 bytes. `chatId` and
* `blobId` are the raw `value` bytes of `ChatId` and `BlobId`. The chat key depends only on the two
* members and the chat, so callers derive it once per chat and cache it.
*/
interface ChatCipher {
/**
* The chat key for [ownKeyPair] and [peerPublicKey] (an Ed25519 public key) in [chatId].
* Both members derive the same key.
*
* @throws ChatCipherException if the own key pair is malformed, the peer key is not a valid point, is low-order, or the shared secret is all zeros.
*/
@Throws(ChatCipherException::class)
fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray

/** Encrypts serialized `Content` bytes from [senderPk] to [recipientPk] under a fresh random nonce. */
@Throws(ChatCipherException::class)
fun encrypt(
content: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
): EncryptedPayload

/**
* Decrypts [payload] to the serialized `Content` bytes.
*
* @throws ChatCipherException if authentication fails, including a swapped [senderPk]/[recipientPk].
*/
@Throws(ChatCipherException::class)
fun decrypt(
payload: EncryptedPayload,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
): ByteArray

/** Returns `nonce || ciphertext || tag`, the bytes to upload. [senderPk] is the uploader. */
@Throws(ChatCipherException::class)
fun encryptBlob(
image: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
blobId: ByteArray,
): ByteArray

/** @throws ChatCipherException if [blob] is too short or fails authentication. */
@Throws(ChatCipherException::class)
fun decryptBlob(
blob: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
blobId: ByteArray,
): ByteArray
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.getcode.chatcipher

/** Which chats send `EncryptedContent`. A product rule, separate from the [ChatCipher] scheme. */
object ChatEncryptionPolicy {
private val FLIPCASH_USER_ID_BYTES = "70c4a3df54af439a88fea7de606d04cb".chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()

/**
* The raw 16-byte user id of the @flipcash account (UUID `70c4a3df-54af-439a-88fe-a7de606d04cb`).
* DMs with it are not end-to-end encrypted for now: the backend sends its onboarding messages in
* plaintext. Returns a copy.
*/
val FLIPCASH_USER_ID: ByteArray get() = FLIPCASH_USER_ID_BYTES.copyOf()

/**
* Whether a message to [peerUserId] should be sent as `EncryptedContent`: the chat is a DM
* (`CONTACT_DM` or `TIP_DM`), the chat's `use_e2ee` flag is set, and the peer is not @flipcash.
*
* [useE2ee] is a transitional server flag. After launch the rule drops it and becomes "a DM with
* anyone but @flipcash"; this is the only place that reads it.
*
* [peerUserId] is the raw `UserId.value`, a 16-byte UUID. An id of any other length is treated
* as not @flipcash rather than rejected.
*/
fun shouldEncrypt(isDirectMessage: Boolean, useE2ee: Boolean, peerUserId: ByteArray): Boolean =
isDirectMessage && useE2ee && !peerUserId.contentEquals(FLIPCASH_USER_ID_BYTES)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
@file:OptIn(ExperimentalUnsignedTypes::class)

package com.getcode.chatcipher

import com.getcode.crypt.Hmac
import com.getcode.ed25519kmp.KeyPair
import com.ionspin.kotlin.crypto.LibsodiumInitializer
import com.ionspin.kotlin.crypto.aead.AuthenticatedEncryptionWithAssociatedData
import com.ionspin.kotlin.crypto.scalarmult.ScalarMultiplication
import com.ionspin.kotlin.crypto.signature.Signature
import com.ionspin.kotlin.crypto.util.LibsodiumRandom

/**
* [ChatCipher] for scheme `X25519_XCHACHA20POLY1305`, on libsodium. Stateless; libsodium is
* initialized on first use if the app has not done it already.
*/
object DefaultChatCipher : ChatCipher {
private const val KEY_SIZE = 32
private const val NONCE_SIZE = 24
private const val TAG_SIZE = 16
private val LABEL = "flipcash-dm-e2ee-v1".encodeToByteArray()
private val BLOB_LABEL = "flipcash-dm-e2ee-blob-v1".encodeToByteArray()

override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: 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")
}
val peerX = peerToX25519(peerPublicKey)
val ss = sharedSecret(ownToX25519(ownKeyPair), peerX)
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)
}

override fun encrypt(
content: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
): EncryptedPayload = encrypt(content, chatKey, senderPk, recipientPk, chatId, randomNonce())

internal fun encrypt(
content: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
nonce: ByteArray,
): EncryptedPayload {
val aad = LABEL + chatId + senderPk + recipientPk
return EncryptedPayload(nonce, seal(content, aad, nonce, chatKey))
}

override fun decrypt(
payload: EncryptedPayload,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
): ByteArray = open(payload.ciphertext, LABEL + chatId + senderPk + recipientPk, payload.nonce, chatKey)

override fun encryptBlob(
image: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
blobId: ByteArray,
): ByteArray = encryptBlob(image, chatKey, senderPk, recipientPk, chatId, blobId, randomNonce())

internal fun encryptBlob(
image: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
blobId: ByteArray,
nonce: ByteArray,
): ByteArray {
val aad = BLOB_LABEL + chatId + senderPk + recipientPk + blobId
return nonce + seal(image, aad, nonce, chatKey)
}

override fun decryptBlob(
blob: ByteArray,
chatKey: ByteArray,
senderPk: ByteArray,
recipientPk: ByteArray,
chatId: ByteArray,
blobId: ByteArray,
): ByteArray {
if (blob.size < NONCE_SIZE + TAG_SIZE) throw ChatCipherException("blob shorter than nonce and tag")
val aad = BLOB_LABEL + chatId + senderPk + recipientPk + blobId
return open(blob.copyOfRange(NONCE_SIZE, blob.size), aad, blob.copyOfRange(0, NONCE_SIZE), chatKey)
}

// -- steps -------------------------------------------------------------------------------

/** `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[0..32]). The orlp private key already is that expansion. */
internal fun ownToX25519(own: KeyPair): ByteArray {
val x = own.privateKey.copyOfRange(0, KEY_SIZE)
x[0] = (x[0].toInt() and 248).toByte()
x[31] = ((x[31].toInt() and 127) or 64).toByte()
return x
}

/** `crypto_sign_ed25519_pk_to_curve25519`; libsodium rejects invalid, low-order and non-prime-order points. */
internal fun peerToX25519(edPublicKey: ByteArray): ByteArray {
if (edPublicKey.size != KEY_SIZE) throw ChatCipherException("public key must be 32 bytes")
ensureSodium()
return try {
Signature.ed25519PkToCurve25519(edPublicKey.toUByteArray()).toByteArray()
} catch (e: Throwable) {
throw ChatCipherException("peer public key rejected", e)
}
}

/** `X25519(priv, pub)`, aborting on an all-zero result. */
internal fun sharedSecret(xPrivate: ByteArray, xPublic: ByteArray): ByteArray {
ensureSodium()
val ss = try {
ScalarMultiplication.scalarMultiplication(xPrivate.toUByteArray(), xPublic.toUByteArray()).toByteArray()
} catch (e: Throwable) {
throw ChatCipherException("X25519 rejected the peer key", e)
}
if (ss.all { it.toInt() == 0 }) throw ChatCipherException("all-zero shared secret")
return ss
}

/** HKDF-SHA256 (RFC 5869) on the shared HMAC module. */
internal fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
val prk = Hmac.hmac("HmacSHA256", salt, ikm)
var t = ByteArray(0)
var out = ByteArray(0)
var counter = 1
while (out.size < length) {
t = Hmac.hmac("HmacSHA256", prk, t + info + byteArrayOf(counter.toByte()))
out += t
counter++
}
return out.copyOfRange(0, length)
}

private fun seal(plain: ByteArray, aad: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray {
checkKeyAndNonce(key, nonce)
ensureSodium()
return AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfEncrypt(
plain.toUByteArray(), aad.toUByteArray(), nonce.toUByteArray(), key.toUByteArray(),
).toByteArray()
}

private 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()
return try {
AuthenticatedEncryptionWithAssociatedData.xChaCha20Poly1305IetfDecrypt(
cipher.toUByteArray(), aad.toUByteArray(), nonce.toUByteArray(), key.toUByteArray(),
).toByteArray()
} catch (e: Throwable) {
throw ChatCipherException("authentication failed", e)
}
}

private fun checkKeyAndNonce(key: ByteArray, nonce: ByteArray) {
if (key.size != KEY_SIZE) throw ChatCipherException("chat key must be 32 bytes")
if (nonce.size != NONCE_SIZE) throw ChatCipherException("nonce must be 24 bytes")
}

private fun randomNonce(): ByteArray {
ensureSodium()
return LibsodiumRandom.buf(NONCE_SIZE).toByteArray()
}

/** Bytewise unsigned comparison, as the spec orders the two public keys. */
private fun compareBytes(a: ByteArray, b: ByteArray): Int {
for (i in 0 until minOf(a.size, b.size)) {
val d = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)
if (d != 0) return d
}
return a.size - b.size
}

private fun ensureSodium() {
// Synchronous on JVM/Android/Native; the app also initializes it at startup.
if (!LibsodiumInitializer.isInitialized()) LibsodiumInitializer.initializeWithCallback { }
}
}
Loading
Loading