Conversation
There was a problem hiding this comment.
Pull request overview
This PR claims to be a documentation-only README refocus for developers, but it actually contains extensive security infrastructure changes that fundamentally alter how the application handles cryptography and data storage. The changes introduce per-conversation shared secrets with RSA key exchange, HKDF-based key derivation, improved MAC verification with canonical payloads, encrypted DataStore for local persistence, and Compose interop utilities.
Changes:
- Complete cryptographic system overhaul with RSA key exchange, HKDF key derivation, and improved AES-GCM encryption
- Migration from EncryptedSharedPreferences to DataStore with Android Keystore backing
- Developer-focused README rewrite with architecture overview and contribution guidelines
Reviewed changes
Copilot reviewed 31 out of 32 changed files in this pull request and generated 25 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Rewritten with developer-oriented content including architecture, layout, and setup instructions |
| gradlew | Standard Gradle wrapper script added for build reproducibility |
| app/build.gradle | Added Compose dependencies and fixed output filename configuration |
| app/src/main/res/values/strings.xml | Added error messages and update dialog strings |
| app/src/main/res/layout/dialog_download_progress.xml | New Material3 download progress dialog layout |
| app/src/main/java/com/devusercode/upchat/utils/Util.kt | Improved keyboard toggle with safer casting and fallback view handling |
| app/src/main/java/com/devusercode/upchat/utils/StorageController.kt | Complete refactor to use DataStore with Android Keystore encryption instead of EncryptedSharedPreferences |
| app/src/main/java/com/devusercode/upchat/utils/ConversationUtil.kt | Updated to use shared secrets, async conversation creation, and canonical MAC payloads |
| app/src/main/java/com/devusercode/upchat/utils/ComposeInterop.kt | New utility to wrap View layouts in Compose (questionable approach) |
| app/src/main/java/com/devusercode/upchat/utils/ActivityTransitions.kt | Helper functions for API 34+ transition compatibility |
| app/src/main/java/com/devusercode/upchat/security/MessageIntegrity.kt | New canonical payload generation for MAC coverage |
| app/src/main/java/com/devusercode/upchat/security/MAC.kt | Refactored to use HKDF-derived keys and proper Base64 handling |
| app/src/main/java/com/devusercode/upchat/security/Hkdf.kt | New HKDF implementation for key derivation |
| app/src/main/java/com/devusercode/upchat/security/ETE.kt | Converted to object with static methods and key encoding utilities |
| app/src/main/java/com/devusercode/upchat/security/ConversationKeyManager.kt | New RSA key management and shared secret distribution |
| app/src/main/java/com/devusercode/upchat/security/AES.kt | Updated to use HKDF-derived keys with random IVs per message |
| app/src/main/java/com/devusercode/upchat/models/User.kt | Added publicKey field for RSA key storage |
| app/src/main/java/com/devusercode/upchat/models/Message.kt | Added username and deviceId fields for MAC coverage |
| app/src/main/java/com/devusercode/upchat/adapter/viewholder/*.kt | Updated to use shared secrets and canonical MAC verification |
| app/src/main/java/com/devusercode/upchat/adapter/MessageAdapter.kt | Added conversation secret handling with null checks |
| app/src/main/java/com/devusercode/upchat/*Activity.kt | Updated to use setComposeContent wrapper and new transition helpers |
| app/src/main/java/com/devusercode/upchat/MessagingService.kt | Fixed log statement from "to" to "from" |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private fun ensureUserKeyPair( | ||
| storage: StorageController, | ||
| user: User, | ||
| onError: (Exception) -> Unit | ||
| ): KeyMaterial? { | ||
| val storedPublic = storage.getString(STORAGE_PUBLIC_KEY) | ||
| val storedPrivate = storage.getString(STORAGE_PRIVATE_KEY) | ||
|
|
||
| if (!storedPublic.isNullOrBlank() && !storedPrivate.isNullOrBlank()) { | ||
| try { | ||
| val publicKey = ETE.decodePublicKey(storedPublic) | ||
| val privateKey = ETE.decodePrivateKey(storedPrivate) | ||
| ensureRemotePublicKey(user.uid, storedPublic) | ||
| return KeyMaterial(publicKey, privateKey, storedPublic) | ||
| } catch (error: Exception) { | ||
| Log.w(TAG, "Existing key pair invalid; generating a new one", error) | ||
| } | ||
| } | ||
|
|
||
| return try { | ||
| val (publicKey, privateKey) = ETE.generateKeyPair() | ||
| val encodedPublic = Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP) | ||
| val encodedPrivate = Base64.encodeToString(privateKey.encoded, Base64.NO_WRAP) | ||
|
|
||
| storage[STORAGE_PUBLIC_KEY] = encodedPublic | ||
| storage[STORAGE_PRIVATE_KEY] = encodedPrivate | ||
|
|
||
| ensureRemotePublicKey(user.uid, encodedPublic) | ||
|
|
||
| KeyMaterial(publicKey, privateKey, encodedPublic) | ||
| } catch (error: Exception) { | ||
| onError(error) | ||
| null | ||
| } | ||
| } |
There was a problem hiding this comment.
The private RSA key is stored encrypted in DataStore using Android Keystore, which is good. However, if a user uninstalls and reinstalls the app, the Keystore keys are lost but the DataStore file may remain (if backed up). On restoration, decryption will fail with the old key still in storage. Consider detecting this scenario and clearing stale encrypted data, or regenerating keys and warning about message history loss.
| fun canonicalize(message: Message, conversationId: String): String { | ||
| val values = linkedMapOf<String, String?>() | ||
|
|
||
| message.message?.let { values[Key.Message.MESSAGE] = it } | ||
| message.messageId?.let { values[Key.Message.ID] = it } | ||
|
|
||
| normalizeType(message.type)?.let { values[Key.Message.TYPE] = it } | ||
|
|
||
| message.url?.let { values[Key.Message.URL] = it } | ||
| message.checksum?.let { values[Key.Message.CHECKSUM] = it } | ||
| message.senderId?.let { values[Key.Message.SENDER_ID] = it } | ||
| message.timestamp?.let { values[Key.Message.TIMESTAMP] = it } | ||
| message.username?.let { values[Key.User.USERNAME] = it } | ||
| message.deviceId?.let { values[Key.User.DEVICE_ID] = it } | ||
| values[Key.Conversation.CONVERSATION_ID] = conversationId | ||
|
|
||
| return canonicalize(values) |
There was a problem hiding this comment.
The MessageIntegrity.canonicalize method for Message objects doesn't include the conversationId field in the values map before calling the generic canonicalize method, but line 42 sets it directly. However, the values map is a linkedMapOf which maintains insertion order, but the generic canonicalize sorts by key. Setting conversationId after sorting may not preserve the intended order, potentially causing MAC verification mismatches between sender and receiver.
| val encryptedForCurrent = ETE.encrypt(secret, keyMaterial.publicKey) | ||
| val encryptedForParticipant = ETE.encrypt(secret, participantPublicKey) |
There was a problem hiding this comment.
The ETE.encrypt and decrypt methods now require passing keys as parameters, but the method is called with a publicKey to encrypt a message for yourself (line 102). This means you're encrypting the shared secret with your own public key. The correct approach is to encrypt with the recipient's public key. Line 102 should use participantPublicKey, not keyMaterial.publicKey.
| while (bytesGenerated < size) { | ||
| mac.update(previous) | ||
| mac.update(info) | ||
| mac.update(counter.toByte()) | ||
|
|
||
| val output = mac.doFinal() | ||
| val bytesToCopy = minOf(output.size, size - bytesGenerated) | ||
| System.arraycopy(output, 0, result, bytesGenerated, bytesToCopy) | ||
|
|
||
| previous = output | ||
| bytesGenerated += bytesToCopy | ||
| counter++ | ||
| } |
There was a problem hiding this comment.
The HKDF implementation uses a counter as a single byte (toByte()), which means it can only generate up to 255 iterations. For keys larger than 255 * hash_length bytes, this will silently produce incorrect output or loop indefinitely. While the current usage only derives 32-byte keys, this is a latent bug. The counter should be properly encoded as specified in RFC 5869.
|
|
||
| ## Development tips | ||
|
|
||
| - **Cryptography** – `ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalPayload` to keep MAC coverage consistent. |
There was a problem hiding this comment.
Spelling error: 'canonicalPayload' should be 'canonicalize' in the README development tips section to match the actual method name used in the code.
| - **Cryptography** – `ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalPayload` to keep MAC coverage consistent. | |
| - **Cryptography** – `ConversationKeyManager` publishes RSA public keys and distributes shared secrets. When extending message types, use `MessageIntegrity.canonicalize` to keep MAC coverage consistent. |
| private fun prepareConversation(conversationId: String) { | ||
| val previousSecret = conversationSecret | ||
| conversationSecret = null | ||
|
|
||
| ConversationKeyManager.ensureConversationSecret( | ||
| this, | ||
| conversationId, | ||
| user!!, | ||
| participant!!, | ||
| onSuccess = success@{ secret -> | ||
| if (previousSecret == secret && ::conversationUtil.isInitialized) { | ||
| conversationSecret = secret | ||
| adapter?.setConversationSecret(secret) | ||
| return@success | ||
| } | ||
|
|
||
| conversationSecret = secret | ||
| conversationUtil = ConversationUtil(this, conversationId, user!!, participant!!, secret) | ||
| loadConversation(conversationId) | ||
| }, | ||
| onError = { error -> | ||
| Log.e(TAG, "Failed to establish secure channel", error) | ||
| conversationSecret = previousSecret | ||
| previousSecret?.let { adapter?.setConversationSecret(it) } | ||
| Util.showMessage(this, "Unable to secure the conversation. Please try again.") | ||
| } | ||
| ) | ||
| } |
There was a problem hiding this comment.
The prepareConversation method is called every time the send button is clicked when conversationUtil is not initialized. This means multiple concurrent key establishment flows could be triggered if the user clicks send multiple times quickly. This could lead to race conditions where multiple secrets are generated and stored, causing MAC verification failures. Add a flag to prevent concurrent secret establishment.
| private lateinit var contentView: View | ||
| private lateinit var storageController: StorageController | ||
| private lateinit var conversationUtil: ConversationUtil | ||
| private var conversationSecret: String? = null |
There was a problem hiding this comment.
The conversationSecret is stored as a mutable var at the activity level but can be accessed and modified from multiple async callbacks (prepareConversation success/error handlers, send button click). This could lead to race conditions where the secret is cleared while being used, or where stale secrets are used after new ones are established. Consider using StateFlow or synchronized access patterns.
| private var conversationSecret: String? = null | |
| @Volatile private var conversationSecret: String? = null |
| fun verify(message: String, receivedMAC: String): Boolean { | ||
| val expectedMAC = generate(message) | ||
| val received = try { | ||
| Base64.decode(receivedMAC, Base64.DEFAULT) |
There was a problem hiding this comment.
The MAC verification uses Base64.DEFAULT for decoding the received MAC but Base64.NO_WRAP for encoding when generating. If the received MAC contains newlines (which DEFAULT mode allows), this will cause verification failures. Use Base64.NO_WRAP consistently for both encoding and decoding to ensure compatibility.
| Base64.decode(receivedMAC, Base64.DEFAULT) | |
| Base64.decode(receivedMAC, Base64.NO_WRAP) |
| private class DownloadProgressDialog(activity: StartActivity) { | ||
| private val dialogView = activity.layoutInflater.inflate(R.layout.dialog_download_progress, null) | ||
| private val progressIndicator: com.google.android.material.progressindicator.LinearProgressIndicator = | ||
| dialogView.findViewById(R.id.download_progress_indicator) | ||
| private val progressMessage: android.widget.TextView = | ||
| dialogView.findViewById(R.id.download_progress_message) | ||
|
|
||
| private val dialog = com.google.android.material.dialog.MaterialAlertDialogBuilder(activity) | ||
| .setTitle(R.string.start__update_dialog_title) | ||
| .setView(dialogView) | ||
| .setCancelable(false) | ||
| .create() | ||
|
|
||
| fun show() { | ||
| dialog.show() | ||
| } | ||
|
|
||
| fun dismiss() { | ||
| dialog.dismiss() | ||
| } | ||
|
|
||
| fun update(downloaded: Long, total: Long) { | ||
| val messageRes = R.string.start__update_downloading | ||
| if (total <= 0) { | ||
| progressIndicator.isIndeterminate = true | ||
| progressMessage.setText(messageRes) | ||
| return | ||
| } | ||
|
|
||
| val progress = ((downloaded.toDouble() / total.toDouble()) * 100).toInt().coerceIn(0, 100) | ||
| progressIndicator.isIndeterminate = false | ||
| progressIndicator.setProgressCompat(progress, true) | ||
| progressMessage.text = dialog.context.getString( | ||
| R.string.start__update_download_progress, | ||
| progress | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
The download progress dialog references Material3 components and styles (MaterialAlertDialogBuilder, Material3 styles) but the TextView and ProgressIndicator are found via findViewById after inflating the layout. If the Material3 dependencies are not properly configured or if the dialog context doesn't use a Material3 theme, this could cause runtime crashes. Verify theme compatibility.
| fun bind(model: Message, cid: String, sharedSecret: String) { | ||
| val aes = AES(sharedSecret, cid) | ||
| val mac = MAC(sharedSecret, cid) | ||
|
|
||
| var message = model.message | ||
| val decrypted = model.message?.let { aes.decrypt(it) } ?: "" | ||
| val payload = MessageIntegrity.canonicalize(model, cid) | ||
| val hasMac = model.mac != null | ||
| val isVerified = mac.verify(payload, model.mac) | ||
|
|
||
| if (model.message != null) { | ||
| message = aes.decrypt(model.message!!) | ||
| if (hasMac) { | ||
| verified.visibility = View.VISIBLE | ||
| verified.setImageResource( | ||
| if (isVerified) R.drawable.ic_verified_white else R.drawable.ic_round_error_white | ||
| ) | ||
| } else { | ||
| verified.visibility = View.GONE | ||
| } |
There was a problem hiding this comment.
In the message view holders, MAC verification now uses MessageIntegrity.canonicalize which includes fields from the Message object. However, the checksum field is used in the MAC for image messages. If an image message has no checksum (network error, legacy message, etc.), the MAC will fail verification even for legitimate messages. Ensure the canonicalization handles missing checksums consistently with how messages are sent.
Summary
Testing
Codex Task