امنیت دیتابیس Room با رمزگذاری مبتنی بر Passcode

ترجمه و تالیف : پوریا شریفی
تاریخ انتشار : 13 شهریور 99
خواندن در 4 دقیقه
دسته بندی ها : اندروید

ما به عنوان توسعه دهنده اغلب وظیفه داریم از داده‌هایی که در برنامه‌های خود ذخیره می‌کنیم محافظت کنیم. در این مقاله قصد داریم در مورد رمزنگاری Room با SQLCipher با استفاده از PBE(رمزنگاری مبتنی بر Passcode) صحبت کنیم.

PBE چیست؟

به طور خلاصه PBE روشی رایج برای ایجاد کلیدهای رمزنگاری قوی براساس رمز عبور تهیه شده توسط کاربر است. بدون دسترسی به رمز عبور کاربر، شما نمی‌توانید پایگاه‌داده را رمزگشایی کنید، به این معنی که اطلاعات کاربر ایمن است. در زیر نمودار توضیح می‌دهد که چگونه از رمز عبور کاربر برای رمزنگاری کلید پایگاه داده استفاده می‌شود که می‌تواند با خیال راحت در حافظه ناامن نگهداری شود.

SQLCipher چیست؟

SQLCipher یک افزونه برای SQLite است که امکان رمزگذاری  256بیتی از پایگاه‌داده SQLite را فراهم می‌کند. می‌توانید به جای APIهای SQLite با نام یکسان از فضای نام SQLCipher برای انتقال آسان استفاده کنید.

وابستگی‌های مورد نیاز

برای افزودن پشتیبانی برای رمزگذاری پایگاه‌داده Room خود، باید این وابستگی‌ها را اضافه کنید.

implementation "net.zetetic:android-database-sqlcipher:4.4.0"
implementation "androidx.sqlite:sqlite:2.1.0"

فرمت‌های کلید SQLCipher

سه فرمت کلیدی مختلف وجود دارد که SQLCipher از آن‌ها پشتیبانی می‌کند. آن‌ها در اینجا لیست شده‌اند، بنابراین شما از گزینه‌های خود آگاه هستید، اما برای راهنمایی می‌خواهیم روی فرمت "Raw Key data" تمرکر کنیم.

عبارات کلیدی با مشتق کلید

این گزینه رمز عبور کاربر را گرفته و با استفاده از کلید مشتق PBKDF2 آن را به یک کلید تبدیل می‌کند. این کندترین گزینه است زیرا باعث تاخیر در باز شدن بانک اطلاعاتی به منظور انجام این تبدیل است.

Raw Key data

این روش برای رمزگشایی پایگاه داده از بایت‌های کلید خام که ما در زیر ایجاد خواهیم کرد، استفاده می‌کند. فرمت مورد انتظار یک رشته رمزگذاری شده با 64 کاراکتر است که به طور مستقیم به 32 بایت (256 بیت) از داده‌های اصلی تبدیل می‌شود.

داده‌های خام کلید با salt صریح

و در آخرین گزینه در صورت تمایل می‌توانید salt پایگاه داده را به همراه کلید خام تهیه کنید. به طور معمول SQLCipher یک salt از پایگاه داده ایجاد می‌کند و آن را در 16 بایت اول پایگاه داده ذخیره می‌کند.در این روش، کلید نیاز به 96 کاراکتر رمزنگاری شده در یک BLOB دارد.

بررسی کلی

در زیر بخشی از آنچه که در مورد آن بحث خواهیم کرد آورده شده است:

  • ایجاد یک کلید تصادفی
  • فرمت‌های کلید SQLCipher
  • ایجاد کلید رمزنگاری شده پایگاه داده
  • PBE رمزنگاری شده + ذخیره کلید پایگاه داده
  • بازیابی + رمزگشایی کلید پایگاه داده
  • رمزگذاری پایگاه داده Room
  • چیزهایی که باید در مورد PBE بدانید
  • خلاصه

ایجاد یک کلید تصادفی

/**
 * Generates a random 32 byte key.
 *
 * @return a byte array containing random values
 */
fun generateRandomKey(): ByteArray =
    ByteArray(32).apply {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            SecureRandom.getInstanceStrong().nextBytes(this)
        } else {
            SecureRandom().nextBytes(this)
        }
    }

بگذارید آنچه را که در بالا انجام دادیم را توضیح دهم. در API 26(Oreo) و بالاتر به SecureRandom.getInstanceStrong دسترسی داریم، که یک نمونه مناسب SecureRandom برای تولید یک مقدار تصادفی استفاده می‌شود. ما 32 را به آن پاس دادیم که یعنی چند بایت می‌خواهیم که کلید باشد(256 بیت). برای APIهای قدیمی‌تر ما فقط از PRNG پیش‌فرض سیستم استفاده خواهیم کرد.

نکته: موارد فوق یک عدد تصادفی کاذب تولید می‌کند. مشکلی که وجود دارد این است که بر روی برخی از APIها، entropy کافی وجود ندارد که منجر به آسیب‌پذیری می‌شود در حالی که شماره تصادفی ایجاد شده به اندازه کافی تصادفی نیست و قابل پیش‌بینی است. این روی همه APIهای جاوا در اندروید تاثیر می‌گذارد.  

ایجاد کلید رمزنگاری شده پایگاه داده

اکنون ما یک کلید خام بایتی را داریم، بگذارید آن را در یک فرمت رمزنگاری شده قرار دهیم که SQLCipher به دنبال آن است.

بیایید ابتدا یک تابع برای تبدیل ByteArray به CharArray رمزنگاری شده ایجاد کنیم.

private val HEX_CHARS = "0123456789ABCDEF".toCharArray()

/**
 * Extension function that converts a ByteArray to a hex encoded String
 */
fun ByteArray.toHex(): String {
    val result = StringBuilder()
    forEach {
        val octet = it.toInt()
        val firstIndex = (octet and 0xF0).ushr(4)
        val secondIndex = octet and 0x0F
        result.append(HEX_CHARS[firstIndex])
        result.append(HEX_CHARS[secondIndex])
    }
    return result.toString()
}

حال ما می‌توانیم از این عملکرد برای ایجاد یک کلید تصادفی استفاده کرده و سپس آن را به CharArray رمزنگاری شده تبدیل کنیم.

private lateinit var rawByteKey: ByteArray
private lateinit var dbCharKey: CharArray

/**
 * Generates a new database key.
 */
fun createNewKey() {
    // This is the raw key that we'll be encrypting + storing
    rawByteKey = generateRandomKey()
    // This is the key that will be used by Room
    dbCharKey = rawByteKey.toHex()
}

PBE رمزنگاری شده + ذخیره کلید پایگاه داده

در این مرحله ما یک کلید پایگاه داده معتبر داریم که از آن برای برای کلید پایگاه داده Room خود استفاده کنید و استفاده از آن را شروع کنید. اما می‌خواهیم این کلید را در دیسک ذخیره کنیم، بنابراین اگر برنامه از بین برود یا دستگاه دوباره راه‌‌اندازی شود آن را داریم. بنابراین بدون چیز اضافه‌ای ado اجازه ورود به آن را می‌دهد.

داده‌هایی که نیاز به ذخیره دارند را کپسوله کنیم

/**
 * Container for everything needed for decrypting the database.
 *
 * @param iv initialization vector
 * @param key encrypted database key
 * @param salt cryptographic salt
 */
data class Storable(val iv: String, val key: String, val salt: String)

در اینجا ما یک کلاس داده ساده داریم که همه موارد لازم برای اینکه پایگاه داده را رمزگشایی کنیم کپسوله می‌کند.

کلید پایگاه داده را با استفاده از PBE رمزنگاری کنید

اینجا قسمت جالب کار است. بیایید رمز عبور کاربر را بکار گرفته و کلید پایگاه داده را رمزنگاری کنیم تا بتوانیم به طور ایمن در SharedPrefrences ذخیره کنیم.

fun persistRawKey(userPasscode: CharArray) {
    val storable = toStorable(rawByteKey, userPasscode)
    // Implementation explained in next step
    saveToPrefs(storable)
}

/**
 * Returns a [Storable] instance with the db key encrypted using PBE.
 *
 * @param rawDbKey the raw database key
 * @param userPasscode the user's passcode
 * @return storable instance
 */
fun toStorable(rawDbKey: ByteArray, userPasscode: CharArray): Storable {
    // Generate a random 8 byte salt
    val salt = ByteArray(8).apply {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            SecureRandom.getInstanceStrong().nextBytes(this)
        } else {
            SecureRandom().nextBytes(this)
        }
    }
    val secret: SecretKey = generateSecretKey(userPasscode, salt)

    // Now encrypt the database key with PBE
    val cipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, secret)
    val params: AlgorithmParameters = cipher.parameters
    val iv: ByteArray = params.getParameterSpec(IvParameterSpec::class.java).iv
    val ciphertext: ByteArray = cipher.doFinal(key)

    // Return the IV and CipherText which can be stored to disk
    return Storable(
        Base64.encodeToString(iv, Base64.DEFAULT),
        Base64.encodeToString(ciphertext, Base64.DEFAULT),
        Base64.encodeToString(salt, Base64.DEFAULT)
    )
}

private fun generateSecretKey(passcode: CharArray, salt: ByteArray): SecretKey {
    // Initialize PBE with password
    val factory: SecretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
    val spec: KeySpec = PBEKeySpec(passcode, salt, 65536, 256)
    val tmp: SecretKey = factory.generateSecret(spec)
    return SecretKeySpec(tmp.encoded, "AES")
}

آنچه ما انجام داده‌ایم رمزگذاری پایگاه داده با رمز عبور کاربر و با استفاده الگوریتم AES است، سپس وکتور اولیه + کلید رمزگذاری شده + salt را به فرمت base64 تبدیل کرده تا بتوان آن را به صورت یک رشته ذخیره کرد.

نگه‌داری کلید در دیسک

اکنون بیایید این شیء قابل ذخیره را بگیریم و ان را در SharedPrefrences نگه‌داری کنیم.

/**
 * Save the storable instance to preferences.
 *
 * @param storable a storable instance
 */
fun saveToPrefs(context: Context, storable: Storable) {
    val serialized = Gson().toJson(storable)
    val prefs = context.getSharedPreferences("database",
        Context.MODE_PRIVATE)
    prefs.edit().putString("key", serialized).apply()
}

اکنون کلید دیتابیس ما با استفاده از KEK رمزنگاری شده است و در دیسک نگه‌داری می‌شود، بنابراین می‌توانیم وقتی برنامه از بین رفت یا دستگاه دوباره راه‌‌اندازی شد، پایگاه داده را رمزگشایی کنیم.

بازیابی + رمزگشایی کلید پایگاه داده

حالا بیایید نشان دهیم که چگونه همه کارها بطور معکوس انجام دهیم تا بتوانید کلید پایگاه داده را رمزگشایی کرده و از آن استفاده کنید.

بازیابی نمونه قابل ذخیره از prefs

ما قبلاً یک نسخه serializes شده از نمونه قابل ذخیره خود را در SharedPrefrences به عنوان یک رشته JSON ذخیره کردیم. بیایید آن رشته JSON را بازیابی کنیم و آن را به نمونه قابل ذخیره deserialize کرده.

/**
 * Retrieves the [Storable] instance from prefs.
 *
 * @param context the caller's context
 * @return the storable instance
 */
fun getStorable(context: Context): Storable? {
    val prefs = context.getSharedPreferences("database",
        Context.MODE_PRIVATE)
    val serialized = prefs.getString("key", null)
    if (serialized.isNullOrBlank()) {
        return null
    }

    return try {
        Gson().fromGson(serialized,
            object: TypeToken<Storable>() {}.type)
    } catch (ex: JsonSyntaxException) {
        null
    }
}

کلید خام بایتی را رمزگشایی کنید

ما نمونه قابل ذخیره خود را به همراه رمز عبور کاربر را می‌گیریم و aesWrappedKey را رمزگشایی می‌کنیم تا کلید خام بایتی را بدست آوریم.

/**
 * Decrypts the [Storable] instance using the [passcode].
 *
 * @pararm passcode the user's passcode
 * @param storable the storable instance previously saved with [saveToPrefs]
 * @return the raw byte key previously generated with [generateRandomKey]
 */
fun getRawByteKey(passcode: CharArray, storable: Storable): ByteArray {
    val aesWrappedKey = Base64.decode(storable.key, Base64.DEFAULT)
    val iv = Base64.decode(storable.iv, Base64.DEFAULT)
    val salt = Base64.decode(storable.salt, Base64.DEFAULT)
    val secret: SecretKey = generateSecretKey(passcode, salt)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.DECRYPT_MODE, secret, IvParameterSpec(iv))
    return cipher.doFinal(aesWrappedKey)
}
/**
 * Returns the database key suitable for using with Room.
 *
 * @param passcode the user's passcode
 * @param context the caller's context
 */
fun getCharKey(passcode: CharArray, context: Context): CharArray {
    if (dbCharKey == null) {
        initKey(passcode, context)
    }
    return dbCharKey ?: error("Failed to decrypt database key")
}

private fun initKey(passcode: CharArray, context: Context) {
    val storable = getStorable(context)
    if (storable == null) {
        createNewKey()
        persistRawKey(passcode)
    } else {
        rawByteKey = getRawByteKey(passcode, storable)
        dbCharKey = rawByteKey.toHex()
    }
}

در اینجا ما نمونه قابل ذخیره serialized شده را از prefs بازیابی کردیم، کلید خام بایتی را رمزگشایی کردیم و سپس یک کلید رمزگذاری شده پایگاه داده از کلید خام بایتی ایجاد کردیم. اکنون همه موارد لازم برای رمزگشایی دوباره پایگاه داده را داریم.

رمزنگاری پایگاه داده Room

حالا بیایید نحوه رمزنگاری پایگاه داده Room را نشان دهیم. خوشبختانه SQLCipher اکنون پشتیبانی از رمزگذاری Room را اضافه کرده است، بنابراین بیایید ببینیم که چگونه این کار را انجام دهیم.

abstract class EncryptedDatabase : RoomDatabase() {
    companion object {
        fun getInstance(passcode: CharArray, context: Context):
            EncryptedDatabase = buildDatabase(passcode, context)
        
        private fun buildDatabase(
            passcode: CharArray,
            context: Context
        ): EncryptedDatabase {
            // DatabaseKeyMgr is a singleton that all of the above code is wrapped into.
            // Ideally this should be injected through DI but to simplify the sample code
            // we'll retrieve it as follows
            val dbKey = DatabaseKeyMgr.getInstance().getCharKey(passcode, context)
            val supportFactory = SupportFactory(SQLiteDatabase.getBytes(dbKey))
            return Room.databaseBuilder(context, EncryptedDatabase::class.java,
                "encrypted-db").openHelperFactory(supportFactory).build()
        }
    }
}

اکنون پایگاه داده شما رمزگذاری شده است، به همین سادگی.

چیزهایی که باید در مورد PBE بدانید

رمزگذاری بانک اطلاعاتی خود با PBE به این معنی است که بدون رمز عبور کاربر قادر به رمزگشایی بانک اطلاعاتی خود نخواهید بود. این بدان معناست که منطق برنامه شما برای تغییر این نیاز به تغییر کامل دارد. هرگونه پرس و جو از این پایگاه داده قبل از رمزگشایی منجر به خرابی برنامه می‌شود. برای واضح بودن، هنگامی که پایگاه داده را رمزگشایی کردید، همه چیز خوب خواهد بود. اما اگر برنامه از بین برود یا دستگاه دوباره راه‌اندازی شود، دیگر نیازی به رمزگشایی پایگاه داده ندارید و فقط نیاز دارید که رمز عبور را دوباره از کاربر دریافت کنید. بنابراین شما باید در مورد طراحی برنامه خود بیاندیشید تا اطمینان حاصل شود که در صورت رمزگذاری پایگاه داده، هیچ کدی برای دسترسی به آن ندارید. بعد از پیاده‌سازی PBE حتماً در مورد این موضوع تست کاملی انجام دهید. این امر باعث می‌شود که یک سینگلتون ایجاد کنید که وضعیت قفل شده برنامه شما را مدیریت کند، بنابراین پس از تهیه رمز عبور و رمزگشایی پایگاه داده با موفقیت، حالت قفل به حالت غیر قفل تغییر پیدا می‌کند و دسترسی پایگاه داده آزاد خواهد بود.

خلاصه

برای اتمام، فقط کافی است یک صفحه رمز عبور بنویسید و اطمینان حاصل کنید که هیچ‌کدام تا زمانی که برنامه را با رمز عبور کاربر باز نکردید به پایگاه داده دسترسی ندشته باشند. تحت هیچ شرایطی نباید رمز عبور کاربر را ذخیره کنید زیرا این باعث می‌شود امنیت به کار گرفته شده شکست بخورد. می‌توانید این مرحله را بهبود ببخشید و یک زمانبندی برای جلسه خود پیاده‌سازی کنید به طوری که پس از پنج دقیقه یا یک ساعت rawByteKey و dbCharKey را بردارید، پایگاه داده را خاموش کنید، و کاربر را مجبور به تایید اعتبار مجدد کنید.

منبع

گردآوری و تالیف پوریا شریفی
آفلاین
user-avatar

ابتدا که با برنامه‌نویسی آشنا شدم به سمت php و طراحی وب رفتم، بعد از اون به توسعه‌ی اندروید علاقه‌مند شدم و تقریبا ۲ سال است که مشغول به برنامه‌نویسی اندروید هستم، همچنین عاشق یادگیری چیزهای جدید هستم.

دیدگاه‌ها و پرسش‌ها

برای ارسال نظر لازم است ابتدا وارد سایت شوید
در حال دریافت نظرات از سرور، لطفا منتظر بمانید