Skip to content

feat(encryption): add ChatCipher for end-to-end-encrypted DMs - #1616

Merged
bmc08gt merged 3 commits into
code/cashfrom
feat/chat-cipher
Sep 29, 2026
Merged

bmc08gt merged 3 commits into
code/cashfrom
feat/chat-cipher

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Adds :libs:encryption:chat-cipher, the crypto for end-to-end-encrypted DMs. ChatCipher is the interface for scheme X25519_XCHACHA20POLY1305 as specified on EncryptedContent in flipcash2-protobuf-api, and DefaultChatCipher is its libsodium implementation. The interface is there so chat code on either platform can use a fake; Swift sees it as a ChatCipher protocol. It's exported from :kmp:shared-core, so iOS runs the same Kotlin rather than a Swift copy. Nothing in the app calls it yet.

  • chatKey(ownKeyPair, peerPublicKey, chatId) converts both Ed25519 keys to X25519 and runs HKDF over the shared secret. The result is the same from either member's side.
  • encrypt / decrypt handle message content, and encryptBlob / decryptBlob handle uploads. Each binds the chat, sender and recipient (plus the blob id for blobs) into the AAD. Blobs use their own label, so blob bytes can't be decrypted as a message.

The send rule is separate, in ChatEncryptionPolicy.shouldEncrypt(isDirectMessage, useE2ee, peerUserId): a DM with use_e2ee set whose peer isn't @Flipcash (FLIPCASH_USER_ID). use_e2ee is transitional and leaves the rule after launch.

Primitives come from multiplatform-crypto-libsodium-bindings 0.9.5, which publishes Android and all of shared-core's Apple targets. HKDF is built on :libs:encryption:hmac. Every public function is @Throws(ChatCipherException::class). Without that, a tampered message would terminate the iOS app instead of rendering as unsupported.

The tests read chat_cipher.json, generated from PyNaCl and cryptography in code-payments/flipcash-client-orchestrator (test-vectors/gen_chat_cipher.py, code-payments/flipcash-client-orchestrator#29). There is no host test, because libsodium's Android binding and the Ed25519 JNI don't load on a JVM host. The suite runs as connectedAndroidDeviceTest and in shared-core-tests.yml on the iOS simulator. On Android that means it runs only when someone runs it on a device, like the other vector suites under the commented-out TODO(cross-platform-vectors) lane.

Implements scheme X25519_XCHACHA20POLY1305 as specified on
EncryptedContent in flipcash2-protobuf-api messaging/v1/model.proto:
Ed25519 to X25519 conversion, the HKDF-SHA256 chat key, message
encrypt/decrypt, and blob encrypt/decrypt with the blob aad.

The module is KMP over multiplatform-crypto-libsodium-bindings 0.9.5,
which publishes every target shared-core builds, and reuses the
ed25519 KeyPair and the hmac module for HKDF. It is exported from
:kmp:shared-core so iOS runs the same code.

Every public function is @throws(ChatCipherException) so a failed
decrypt reaches Swift as an NSError rather than terminating the app.

Tests read chat_cipher.json, synced from the orchestrator's
test-vectors. There is no host test: libsodium's Android binding and
the Ed25519 JNI do not load on a JVM host, so the suite runs as a
device test and on the iOS simulator, which shared-core-tests.yml now
includes.
A message goes out as EncryptedContent only in a DM (CONTACT_DM or
TIP_DM) whose use_e2ee flag is set and whose peer is not @Flipcash.
The @Flipcash account (FLIPCASH_USER_ID) still sends its onboarding
DMs in plaintext. use_e2ee is transitional: after launch the rule
drops it, and this is the only place that reads it.

peerUserId is the raw 16-byte UserId. An id of any other length is
not @Flipcash; it is not an error. FLIPCASH_USER_ID returns a copy so
callers cannot change the constant.

The policy section of chat_cipher.json covers the rule, and the vector
suite checks it on the iOS simulator and on device.
@bmc08gt bmc08gt self-assigned this Sep 29, 2026
@bmc08gt
bmc08gt requested a review from jeffyanta as a code owner September 29, 2026 16:30
@github-actions github-actions Bot added type: feature New functionality area: crypto Solana, keys, encryption, signing area: build-system Gradle, convention plugins, build-logic labels Sep 29, 2026
…e out

ChatCipher is now an interface, so chat code on either platform can
fake it; Swift sees a ChatCipher protocol. DefaultChatCipher is the
libsodium implementation and is still a stateless object, reached from
Swift as DefaultChatCipher.shared. The crypto is unchanged.

shouldEncrypt and FLIPCASH_USER_ID move to ChatEncryptionPolicy. They
are a product rule about which chats encrypt, not part of the
X25519_XCHACHA20POLY1305 scheme.
@bmc08gt
bmc08gt merged commit cf124aa into code/cash Sep 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: crypto Solana, keys, encryption, signing type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant