Android插件化数据库加密-sqlcipher

目录

1.概括[1]

2.踏坑历程[2]

3.基础使用[3]

4.修改框架对SO文件的使用[4]

5.sqlcipher附加库的使用[5]

概括

首先说一下我们是基于Android-Framework-Plug插件化框架,由于要保证本地数据SQLite的数据安全又得考虑到数据库的执行效率;所以就屏蔽了对数据库中数据加密的念头,转而向数据库文件加密的方向做研究,最后定下了使用sqlcipher数据库加密方案实现。

踏坑历程

  • 因为我们使用的是插件化框架,插件之前彼此独立,所以这就意味着每个插件都需要在libs文件夹下放置一份so文件,因为我们的插件是存放在assets资源文件夹下的这无疑是增大了APK文件的大小,所以为了解决这个我动手修改了框架,详情请见 修改框架对SO文件的使用 标题内容的介绍
  • 由于我们的本地业务比较复杂,使用了分库技术,主要是为了实现业务在不同数据库处理,提升了数据库的执行效率;那么问题就来,在未加密之前我们业务需求需要将两个不同的数据库文件连接起来查询数据 即:附加库技术,但是数据库文件加密后我们发现,附加库总是失败,报错如下: Failure 26 (file is encrypted or is not a database) on 0xdf995e08 when executing 'attach database,网上并没有搜到相关解决方案,那么我将在 sqlcipher附加库的使用 标题内容下做详细讲解

基础使用

目录结构图

sqlcipher-结构图.png

官方下载地址:https://s3.amazonaws.com/sqlcipher/3.2.0/sqlcipher-for-android-community-v3.2.0.zip

对于sqlcipher的基础使用不是我们讲解的重点,若不了解基础使用的请移步,博主对sqlcipher使用写的非常详细.https://www.cnblogs.com/iwanghang/p/8478443.html

修改框架对SO文件的使用

简要介绍

使用过Android-Framework-Plugin插件化框架的朋友知道,这个框架的对SO文件规则是在Android-Framework-Plguin的其他使用指南下的-注意事项-标题下有做讲解,大致意思是说:

若插件中包含so,则需要在宿主的相应目录下添加至少一个so文件,以确保插件和宿主支持的so种类完全相同
例如:插件包含armv7a、arm64两种so文件,则宿主必须至少包含一个armv7a的so以及一个arm64的so。
若宿主本身不需要so文件,可随意创建一个假so文件放在宿主的相应目录下。例如pluginMain工程中的libstub.so其实只是一个txt文件。
需要占位so的原因是,宿主在安装时,系统会扫描宿主中的so的(根据文件夹判断)类型,决定宿主在哪种cpu模式下运行、并保持到系统设置里面。
(系统源码可查看com.android.server.pm.PackageManagerService.setBundledAppAbisAndRoots()方法)
例如32、64、还是x86等等。如果宿主中不包含so,系统默认会选择一个最适合当前设备的模式。
那么问题来了,如果系统默认选择的模式,和将来下载安装的插件中支持的so模式不匹配,则会出现so异常。
因此需要提前通知系统,宿主需要在哪些cpu模式下运行。提前通知的方式即内置占位so。

情景介绍

数据库的使用遍布各个插件和宿主,所以就需要在各个插件和宿主的 libs文件夹 下拷贝全部的SO文件和jar包,上面说过我们的插件都是放在assets资源文件夹下的这无疑是增大了APK文件的大小,所以是不可取的。为此我们对源码框架做了修改,以符合我们的需求

解决方案

  • 插件中所有使用的SO文件都在宿主libs文件夹下中存储一份
  • 插件中所使用的SO只存储以.so结尾的空文件
  • 在安装过程中会与宿主安装位置下的lib文件夹中的SO文件,作文件名对比
  • 匹配成功后会将宿主安装位置下的lib文件夹下的相应SO文件 拷贝到插件安装目录下的lib文件夹下
  • 总结: 这样只需要一份SO文件即可.

源码查看

源码类 com\limpoxe\fairy\manager\PluginManagerService.java

public class PluginManagerService {
      /**
     * 安装一个插件
     *
     * @param srcPluginFile
     * @return
     */
    synchronized InstallResult installPlugin(String srcPluginFile) {
        LogUtil.w("开始安装插件", srcPluginFile);
        ...
        // 解析Manifest,获得插件详情
        ...
        //判断插件适用系统版本
        ...
        // 检查当前宿主版本是否匹配此非独立插件需要的版本
        ...
        // 检查插件是否已经存在,若存在删除旧的
        ...
        // 复制插件到插件目录
        String destApkPath = genInstallPath(pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
        boolean isCopySuccess = FileUtil.copyFile(srcPluginFile, destApkPath);

        if (!isCopySuccess) {

            LogUtil.e("复制插件到安装目录失败", srcPluginFile);
            //删掉临时文件
            new File(srcPluginFile).delete();
            return new InstallResult(PluginManagerHelper.COPY_FILE_FAIL, pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
        } else {
            //**重点**
            //第5步,先解压so到临时目录,再从临时目录复制到插件so目录。 在构造插件Dexclassloader的时候,会使用这个so目录作为参数
            File apkParent = new File(destApkPath).getParentFile();
            //6.0以上系统 apkParent = data/user/0/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk     以6.0为准
            //6.0以下系统 apkParent= data/data/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk
            File tempSoDir = new File(apkParent, "temp"); 
            //tempSoDir=data/data/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk/temp
            //解压base-1.apk/libs目录下的的So到 ./base-1.apk/temp临时目录下
            Set soList = FileUtil.unZipSo(srcPluginFile, tempSoDir);
            if (soList != null) {
                for (String soName : soList) {
                   //将./base-1.apk/temp临时目录下的so文件复制到 ./base-1.apk的同等目录下
                    FileUtil.copySo(tempSoDir, soName, apkParent.getAbsolutePath());
                }
                //删掉./base-1.apk/temp临时文件
                FileUtil.deleteAll(tempSoDir);
            }
             ...
                return new InstallResult(PluginManagerHelper.SUCCESS, pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
            }
        }
    }
}
拷贝SO文件的具体实现

源码类 com\limpoxe\fairy\util\FileUtil.java

public class FileUtil {
        public static boolean copySo(File sourceDir, String so, String dest) {

        try {

            boolean isSuccess = false;
                       
            if (Build.VERSION.SDK_INT >= 21) {
                String[] abis = Build.SUPPORTED_ABIS;
                if (abis != null) {
                    for (String abi: abis) {
                        LogUtil.d("try supported abi:", abi);
                        String name = "lib" + File.separator + abi + File.separator + so;
                        File sourceFile = new File(sourceDir, name);
                        if (sourceFile.exists()) {
                            isSuccess = copyFile(sourceFile.getAbsolutePath(), dest + File.separator +  "lib" + File.separator + so);
                            //api21 64位系统的目录可能有些不同
                            //copyFile(sourceFile.getAbsolutePath(), dest + File.separator +  name);
                            break;
                        }
                    }
                }
            } else {
                LogUtil.d("supported api:", Build.CPU_ABI, Build.CPU_ABI2);

                String name = "lib" + File.separator + Build.CPU_ABI + File.separator + so;
                File sourceFile = new File(sourceDir, name);

                if (!sourceFile.exists() && Build.CPU_ABI2 != null) {
                    name = "lib" + File.separator + Build.CPU_ABI2 + File.separator + so;
                    sourceFile = new File(sourceDir, name);

                    if (!sourceFile.exists()) {
                        name = "lib" + File.separator + "armeabi" + File.separator + so;
                        sourceFile = new File(sourceDir, name);
                    }
                }
                if (sourceFile.exists()) {
                    isSuccess = copyFile(sourceFile.getAbsolutePath(), dest + File.separator + "lib" + File.separator + so);
                }
            }

            if (!isSuccess) {
                LogUtil.e("安装 " + so + " 失败: NO_MATCHING_ABIS");
                if (Thread.currentThread() == Looper.getMainLooper().getThread()) {
                    Toast.makeText(FairyGlobal.getHostApplication(), "安装 " + so + " 失败: NO_MATCHING_ABIS", Toast.LENGTH_LONG).show();
                }
            }
        } catch(Exception e) {
            LogUtil.printException("FileUtil.copySo", e);
        }

        return true;
    }
}

源码修改

源码类 com\limpoxe\fairy\manager\PluginManagerService.java

 /**
     * 安装一个插件
     *
     * @param srcPluginFile
     * @return
     */
    synchronized InstallResult installPlugin(String srcPluginFile) {
        LogUtil.w("开始安装插件", srcPluginFile);
        ...
        // 解析Manifest,获得插件详情
        ...
        //判断插件适用系统版本
        ...
        // 检查当前宿主版本是否匹配此非独立插件需要的版本
        ...
        // 检查插件是否已经存在,若存在删除旧的
        ...
        // 复制插件到插件目录
        String destApkPath = genInstallPath(pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
        boolean isCopySuccess = FileUtil.copyFile(srcPluginFile, destApkPath);

        if (!isCopySuccess) {

            LogUtil.e("复制插件到安装目录失败", srcPluginFile);
            //删掉临时文件
            new File(srcPluginFile).delete();
            return new InstallResult(PluginManagerHelper.COPY_FILE_FAIL, pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
        } else {
            //**重点**
            //第5步,先解压so到临时目录,再从临时目录复制到插件so目录。 在构造插件Dexclassloader的时候,会使用这个so目录作为参数
            File apkParent = new File(destApkPath).getParentFile();
            //6.0以上系统 apkParent = data/user/0/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk     以6.0为准
            //6.0以下系统 apkParent= data/data/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk
            File tempSoDir = new File(apkParent, "temp"); 
            //tempSoDir=data/data/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk/temp
            //解压base-1.apk/libs目录下的的So到 ./base-1.apk/temp临时目录下
            Set soList = FileUtil.unZipSo(srcPluginFile, tempSoDir);
              if (soList != null) {
                for (String soName : soList) {
                    //修改点,我们自定义Copy So操作
                    customCopySoMethod(tempSoDir, soName, apkParent.getAbsolutePath());
                }
                FileUtil.deleteAll(tempSoDir);
            }
             ...
                return new InstallResult(PluginManagerHelper.SUCCESS, pluginDescriptor.getPackageName(), pluginDescriptor.getVersion());
            }
        }
    }
    /**
     *todo 自定义So拷贝
     * @param sourceDir  data/user/0/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk/temp
     * @param soName   插件 base-1.apk/libs/下的的SO文件名
     * @param dest         data/user/0/宿主完整包名/app_plugin/插件完成包名/插件版本号/base-1.apk
     */
    private void customCopySoMethod(File sourceDir, String soName, String dest) {
          //获取宿主安装路径
          //6.0为基准  hostSoDir=  data/user/0/宿主完整包名/lib/
        String hostSoDir = sourceDir.getAbsolutePath().substring(0, sourceDir.getAbsolutePath().indexOf("/app_plugin_dir")) + File.separator + "lib";
        File hostSoLibDirectory = new File(hostSoDir);
        if (!hostSoLibDirectory.exists())
            return;
        if (!hostSoLibDirectory.isDirectory())
            return;

        File[] hostSoFileArray = hostSoLibDirectory.listFiles();
        if (hostSoFileArray == null || hostSoFileArray.length == 0)
            return;
        //遍历宿主lib目录下的SO,若与插件的SO名相同则将宿主下的SO文件 复制到插件lib目录下
        for (File hostSoFile : hostSoFileArray) {
            if (soName.equals(hostSoFile.getName())) {
                //将宿主下的SO文件 复制到插件lib目录下
                FileUtil.copyFile(hostSoFile.getAbsolutePath(), dest + File.separator + "lib"+File.separator + hostSoFile.getName());
                break;
            }
        }
    }
}

sqlcipher附加库的使用

简介

根据 菜鸟教程 对附加库的有详细讲解

实现方式

菜鸟教程实现方式

ATTACH DATABASE '数据库名' As '别名';

Android SQLite实现方式

ATTACH DATABASE 'SD卡下的数据库具体路径/数据库名.db' AS '别名'

sqlcipher数据库加密之后的坑

数据库加密之前我们同上面 Android SQLite实现方式连接附加库是没毛病的,可是加密之后呢会报这样的错误:

附加库failuer.png

即:Failure 26 (file is encrypted or is not a database) on 0xdf995e08 when executing 'attach database

sqlcipher加密数据库正确连接附加库姿势

sqlcipher加密过程

在sqlcipher加密库过程中我们会执行这样SQL语句

     //看这一句是不是很熟悉[1]
    unEncryptDB.rawExecSQL(String.format("ATTACH DATABASE '%s' AS encrypted KEY '%s';", encryptDBFile.getAbsolutePath(), password));
    unEncryptDB.rawExecSQL("SELECT sqlcipher_export('encrypted')");
    unEncryptDB.rawExecSQL("DETACH DATABASE encrypted;");

标识[1]中的这句话是一句连库操作,与之前不同的是 此处有一个 KEY,而 KEY后面跟的值就是我们的数据库密码,我们之所以会连库失败原因就在于 KEY.原因是我们既然使用了sqlcipher加密框架就得遵循sqlcipher框架的规则标准.

sqlcipher加密后连接附加库实现

"ATTACH DATABASE 'SD卡下的数据库具体路径/数据库名.db' AS '别名' KEY '密码'

注意

  • sqlcipher加密数据库时若给定密码,即为加密数据库;若给定的密码是一个空字符串,则仍为一个未加密数据库
  • 我们使用未加密的数据库时 读写操作 都可以重新打开数据库;而加密过后的数据库的 读写操作 则没有这样的权限,若数据库已关闭需要重新声明数据库实例。

This ALL! Thanks EveryBody!


  1. 概括 ↩

  2. 踏坑历程 ↩

  3. 基础使用 ↩

  4. 修改框架对SO文件的使用 ↩

  5. sqlcipher附加库的使用 ↩

你可能感兴趣的:(Android插件化数据库加密-sqlcipher)