如何将SQLCipher加密的Room数据库迁移至新加密口令?
解决方案
能否直接修改现有SQLCipher数据库的加密口令?
完全支持,SQLCipher原生提供PRAGMA rekey能力,可直接为已打开的加密数据库更换密钥,不需要全量迁移数据,是官方推荐的最高效、风险最低的方案,完全适配Room+SQLCipher的使用场景。
注意你当前代码中配置的fallbackToDestructiveMigration()在存量用户升级阶段必须移除,否则Room检测到密钥不匹配会直接删除旧库,造成用户数据永久丢失。
方案一:原生Rekey直接换密(推荐优先使用)
实现逻辑核心是在Room初始化之前完成换密操作,不要让Room拿着新密钥直接打开旧库触发损坏报错,步骤如下:
- 检测到当前存量库使用旧非安全口令时,先拿旧口令直接打开原数据库文件
- 打开数据库连接后执行
PRAGMA rekey指令,传入新生成的符合安全规范的密钥,SQLCipher会自动按页重写整个数据库的加密内容,不需要手动处理表数据 - 换密完成后关闭数据库连接,标记迁移状态,后续即可正常使用新的安全密钥配置Room的SupportFactory,正常加载数据库
对应实现代码参考:
val dbFile = context.getDatabasePath("你的数据库名.db") if (DatabaseEncryptor.isUnsecure()) { // 用旧口令打开存量库 val oldDb = SQLiteDatabase.openOrCreateDatabase( dbFile, DatabaseEncryptor.getOldUnsecurePassphrase(), // 你原有旧口令的获取逻辑 null ) // 执行换密,传入新的安全口令 oldDb.execSQL("PRAGMA rekey = '${DatabaseEncryptor.getNewSecurePassphrase()}'") oldDb.close() // 标记迁移完成,后续启动不再走换密逻辑 DatabaseEncryptor.markSecureMigrationDone() } // 迁移完成后,统一用安全密钥工厂配置Room builder.openHelperFactory(DatabaseEncryptor.getSupportFactory(context)) // 存量升级阶段务必注释掉 destructive 迁移配置,避免误删库 // .fallbackToDestructiveMigration()
该方案对大库非常友好,换密速度远快于全量数据迁移,SQLCipher内部做了页级加密优化,不需要逐行读取写入数据。
方案二:双库挂载全量数据迁移(仅Rekey失败时使用)
如果遇到SQLCipher版本跨度过大、旧库加密格式不兼容导致Rekey执行失败的场景,可以走双库迁移方案,步骤如下:
- 检测到旧非安全加密库时,先将原数据库文件重命名为备份文件,避免操作过程中损坏原始数据
- 用新的安全密钥初始化新的Room数据库,生成使用安全口令加密的空库
- 同时持有两个数据库连接:用旧口令打开备份的旧库,用新口令打开刚创建的空新库
- 遍历旧库中所有业务表,逐表将全量数据批量插入新库对应表中,注意过滤SQLite系统表、Room内部生成的系统表
- 校验新库各表数据量和旧库完全一致后,删除备份的旧库文件,标记迁移完成即可
核心迁移代码参考:
val dbFile = context.getDatabasePath("你的数据库名.db") if (DatabaseEncryptor.isUnsecure()) { val oldBackupFile = File(dbFile.parent, "你的数据库名_old_backup.db") // 1. 备份旧库 if (dbFile.exists()) { dbFile.renameTo(oldBackupFile) } // 2. 初始化新的空安全加密库 val newDb = SQLiteDatabase.openOrCreateDatabase( dbFile, DatabaseEncryptor.getNewSecurePassphrase(), null ) // 3. 打开备份的旧非安全加密库 val oldDb = SQLiteDatabase.openOrCreateDatabase( oldBackupFile, DatabaseEncryptor.getOldUnsecurePassphrase(), null ) // 4. 遍历所有业务表迁移数据 val tableCursor = oldDb.rawQuery( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'room_%'", null ) tableCursor.use { while (it.moveToNext()) { val tableName = it.getString(0) newDb.beginTransaction() try { val dataCursor = oldDb.query(tableName, null, null, null, null, null, null) dataCursor.use { cur -> val columns = cur.columnNames.joinToString(",") val placeholders = Array(cur.columnCount) { "?" }.joinToString(",") val insertStatement = newDb.compileStatement( "INSERT INTO $tableName ($columns) VALUES ($placeholders)" ) while (cur.moveToNext()) { insertStatement.clearBindings() for (colIndex in 0 until cur.columnCount) { when (cur.getType(colIndex)) { Cursor.FIELD_TYPE_STRING -> insertStatement.bindString(colIndex + 1, cur.getString(colIndex)) Cursor.FIELD_TYPE_INTEGER -> insertStatement.bindLong(colIndex + 1, cur.getLong(colIndex)) Cursor.FIELD_TYPE_FLOAT -> insertStatement.bindDouble(colIndex + 1, cur.getDouble(colIndex)) Cursor.FIELD_TYPE_BLOB -> insertStatement.bindBlob(colIndex + 1, cur.getBlob(colIndex)) Cursor.FIELD_TYPE_NULL -> insertStatement.bindNull(colIndex + 1) } } insertStatement.executeInsert() } } newDb.setTransactionSuccessful() } finally { newDb.endTransaction() } } } // 5. 自行补充数据一致性校验逻辑,确认数据无丢失后关闭连接、删除备份 oldDb.close() newDb.close() if (checkDataConsistent()) { oldBackupFile.delete() DatabaseEncryptor.markSecureMigrationDone() } }
两种方案都建议在外层增加异常捕获和回滚逻辑,一旦迁移过程中出现报错,可以将备份文件恢复为原数据库文件名,避免用户数据损坏。迁移逻辑需要做幂等判断,已经完成迁移的用户不需要重复执行。
内容的提问来源于stack exchange,提问作者manabreak
相关产品推荐
相关产品推荐

