Compose Multiplatform: Securing Your App Across Platforms

As Compose Multiplatform gains traction, building secure applications becomes increasingly important. This post covers various aspects of securing your Compose Multiplatform applications. From protecting sensitive data to ensuring secure communication, we will explore strategies and best practices to keep your apps safe.

What is Compose Multiplatform?

Compose Multiplatform is a declarative UI framework developed by JetBrains, built on top of Jetpack Compose. It enables developers to write UI code once and deploy it across multiple platforms, including Android, iOS, Web, and Desktop. The cross-platform capabilities make it essential to implement robust security measures applicable to all environments.

Why is Security Important in Compose Multiplatform Apps?

  • Data Protection: Protect user data from unauthorized access.
  • User Trust: Maintain user trust by ensuring the app’s integrity and security.
  • Compliance: Meet regulatory requirements for data protection.
  • Prevent Financial Loss: Avoid potential financial losses due to security breaches.
  • Reputation Management: Protect the organization’s reputation.

Security Best Practices for Compose Multiplatform Apps

1. Data Encryption

Data encryption is critical to protect sensitive information stored on devices or transmitted over networks.

Encryption at Rest

Encrypting data at rest ensures that data stored on the device (such as user profiles, settings, or cache) remains protected if the device is compromised.

Example: Using SQLCipher for Encrypted SQLite Databases


// Add SQLCipher dependency to your build.gradle.kts
dependencies {
    implementation("net.zetetic:sqlcipher:4.5.3")
}

import net.sqlcipher.database.SQLiteDatabase
import net.sqlcipher.database.SQLiteOpenHelper
import android.content.Context
import android.content.ContentValues

class DatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

    init {
        SQLiteDatabase.loadLibs(context)
    }

    override fun onCreate(db: SQLiteDatabase) {
        db.execSQL("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)")
    }

    override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
        db.execSQL("DROP TABLE IF EXISTS users")
        onCreate(db)
    }

    fun insertUser(username: String, password: String) {
        val db = writableDatabase
        val values = ContentValues().apply {
            put("username", username)
            put("password", password)
        }
        db.insert("users", null, values)
        db.close()
    }

    fun getUser(username: String): String? {
        val db = readableDatabase
        val cursor = db.rawQuery("SELECT password FROM users WHERE username = ?", arrayOf(username))
        var password: String? = null
        if (cursor.moveToFirst()) {
            password = cursor.getString(0)
        }
        cursor.close()
        db.close()
        return password
    }

    companion object {
        private const val DATABASE_NAME = "mydatabase.db"
        private const val DATABASE_VERSION = 1
    }
}

fun main() {
    val context = ... // Obtain your application context
    val databaseHelper = DatabaseHelper(context)

    // Example of inserting an encrypted user
    databaseHelper.insertUser("testUser", "securePassword")

    // Example of retrieving an encrypted user
    val password = databaseHelper.getUser("testUser")
    println("Retrieved password: $password")
}

In this example:

  • The `DatabaseHelper` class extends `SQLiteOpenHelper` to manage the SQLite database.
  • `SQLiteDatabase.loadLibs(context)` loads the necessary SQLCipher libraries.
  • Sensitive data like passwords are stored in an encrypted format in the database.
Encryption in Transit

Use HTTPS to secure data transmitted between the app and remote servers.


import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
        })
    }
}

suspend fun fetchData(): String {
    val url = "https://api.example.com/data" // HTTPS endpoint
    return client.get(url)
}

fun main() {
    // Usage in a Kotlin Coroutine
    // Remember this is a suspend function, invoke within a coroutine scope
    suspend {
        val data = fetchData()
        println("Fetched data: $data")
    }
}

Explanation:

  • Ensures that all data transmitted between the app and remote servers is encrypted using HTTPS, protecting it from eavesdropping and tampering.

2. Secure Authentication

Secure authentication mechanisms are crucial to verifying user identities and protecting access to app features and data.

Multi-Factor Authentication (MFA)

Implement MFA to add an extra layer of security beyond username and password.

Example: Implementing MFA using a Time-Based One-Time Password (TOTP)


import dev.turingcomplete.kotlinonetimepassword.HmacAlgorithm
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordConfig
import dev.turingcomplete.kotlinonetimepassword.TimeBasedOneTimePasswordGenerator
import java.time.Duration

// Sample implementation for generating a TOTP
fun generateTOTP(secretKey: String): String {
    val config = TimeBasedOneTimePasswordConfig(
            codeLength = 6,
            hmacAlgorithm = HmacAlgorithm.SHA512,
            timeStep = Duration.ofSeconds(30)
    )

    val totpGenerator = TimeBasedOneTimePasswordGenerator(config, secretKey.toByteArray())
    return totpGenerator.generate()
}

// Sample usage to verify the TOTP provided by the user
fun verifyTOTP(secretKey: String, userProvidedCode: String): Boolean {
    val currentCode = generateTOTP(secretKey)
    return currentCode == userProvidedCode
}

fun main() {
    // Generate a unique secret key for each user
    val secretKey = "YourStrongUniqueSecretKey"

    // Generate a TOTP
    val totp = generateTOTP(secretKey)
    println("Generated TOTP: $totp")

    // Simulate user input of TOTP
    val userInput = totp
    val isVerified = verifyTOTP(secretKey, userInput)
    println("Is the TOTP verified? $isVerified")
}

Key points:

  • The example demonstrates how to generate a time-based one-time password (TOTP) using a shared secret key.
  • User validation requires entering the TOTP generated by an authenticator app.
  • MFA implementation can protect against password breaches by requiring a second verification factor.
Secure Password Storage

Store passwords using strong hashing algorithms like bcrypt or Argon2.


import org.mindrot.jbcrypt.BCrypt

fun hashPassword(password: String): String {
    return BCrypt.hashpw(password, BCrypt.gensalt())
}

fun verifyPassword(password: String, hashedPassword: String): Boolean {
    return BCrypt.checkpw(password, hashedPassword)
}

fun main() {
    val password = "userPassword123"
    val hashedPassword = hashPassword(password)
    println("Hashed password: $hashedPassword")

    val isPasswordCorrect = verifyPassword(password, hashedPassword)
    println("Is password correct? $isPasswordCorrect")
}

Key elements:

  • BCrypt is used to hash the password.
  • BCrypt.hashpw() generates a new salt and hashes the password.
  • BCrypt.checkpw() verifies the password against the hash.

3. Secure Data Handling

Secure handling of sensitive data is essential for protecting user privacy and complying with data protection regulations.

Input Validation

Validate all user inputs to prevent injection attacks.


fun isValidUsername(username: String): Boolean {
    val regex = "^[a-zA-Z0-9_-]{3,16}\$".toRegex()
    return regex.matches(username)
}

fun isValidEmail(email: String): Boolean {
    val regex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}\$".toRegex()
    return regex.matches(email)
}

fun main() {
    val username1 = "validUser"
    val username2 = "invalid!User"

    val email1 = "test@example.com"
    val email2 = "invalid-email"

    println("Is '$username1' a valid username? ${isValidUsername(username1)}") // true
    println("Is '$username2' a valid username? ${isValidUsername(username2)}") // false

    println("Is '$email1' a valid email? ${isValidEmail(email1)}") // true
    println("Is '$email2' a valid email? ${isValidEmail(email2)}")   // false
}

Important points:

  • The isValidUsername and isValidEmail functions validate inputs using regular expressions.
  • Prevents malicious inputs such as script injections.
Proper Data Sanitization

Sanitize data to prevent cross-site scripting (XSS) and other injection attacks.


import org.owasp.encoder.Encode

fun sanitizeHTML(input: String): String {
    return Encode.forHtml(input)
}

fun sanitizeURL(input: String): String {
    return Encode.forUriComponent(input)
}

fun main() {
    val htmlInput = "<script>alert('XSS');</script>"
    val urlInput = "https://example.com?param=<script>alert('XSS');</script>"

    val sanitizedHTML = sanitizeHTML(htmlInput)
    val sanitizedURL = sanitizeURL(urlInput)

    println("Sanitized HTML: $sanitizedHTML")
    println("Sanitized URL: $sanitizedURL")
}

Explanation:

  • Using OWASP Encoder to sanitize data.
  • Ensures that inputs are safe to render and process.

4. Secure Storage of API Keys

Never hardcode API keys in the source code. Instead, use secure methods for storing and accessing them.

Using Environment Variables

Store API keys as environment variables and access them at runtime.


import kotlin.system.getenv

fun getApiKey(): String? {
    return getenv("API_KEY")
}

fun main() {
    val apiKey = getApiKey()
    if (apiKey != null) {
        println("API Key: $apiKey")
    } else {
        println("API Key not found")
    }
}

Best practices:

  • API keys should be stored as environment variables and accessed at runtime.
  • Prevents keys from being exposed in the codebase.

5. Code Obfuscation and Tamper Detection

Protect the app from reverse engineering and tampering.

Code Obfuscation

Use code obfuscation tools to make it harder to reverse engineer the app’s code.

Example: Using ProGuard/R8 in Android

  1. Enable ProGuard or R8 in your build.gradle.kts:

android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}
  1. Configure ProGuard rules in proguard-rules.pro:

-keep class your.package.name.** { *; }
-keepnames class your.package.name.YourImportantClass

Key considerations:

  • Enabling ProGuard/R8 obfuscates the code and reduces the app size.
  • Keep important classes and methods from being obfuscated by adding specific rules.
Tamper Detection

Implement mechanisms to detect if the app has been tampered with, such as checksum validation.


import java.security.MessageDigest

fun calculateSHA256(input: ByteArray): String {
    val digest = MessageDigest.getInstance("SHA-256")
    val hashBytes = digest.digest(input)
    return hashBytes.joinToString("") { "%02x".format(it) }
}

fun main() {
    val data = "This is the original content".toByteArray()
    val sha256Hash = calculateSHA256(data)
    println("SHA-256 Hash: $sha256Hash")
}

Remarks:

  • Calculate the SHA-255 hash of critical components to check for tampering.

6. Dependency Management

Secure your app’s dependencies by using dependency management tools.

Software Composition Analysis (SCA) Tools

Utilize SCA tools to identify vulnerabilities in third-party libraries.

Keeping Dependencies Updated

Regularly update dependencies to patch known vulnerabilities.

Example of keeping dependencies up to date in Gradle:


dependencies {
    implementation("androidx.core:core-ktx:1.12.0") // Use the latest version
    implementation("com.google.android.material:material:1.11.0") // Use the latest version
}

Importance:

  • Staying up-to-date with the latest versions mitigates vulnerabilities in third-party libraries.

Additional Tips

  • Regular Security Audits: Conduct regular security audits to identify vulnerabilities.
  • Principle of Least Privilege: Grant users only the minimum level of access required.
  • Secure Logging: Implement secure logging mechanisms to track activities without exposing sensitive data.

Conclusion

Securing Compose Multiplatform applications requires a comprehensive approach that addresses data encryption, authentication, data handling, and more. By following best practices, you can significantly reduce the risk of security breaches and protect user data and privacy. Regularly update your knowledge of security practices to keep pace with evolving threats, and ensure you maintain robust security measures throughout your application’s lifecycle.