Android Lollipop 安全使用 SQLCipher 加密数据库
2025-01-19 07:41:42
Lollipop 上安全使用 Android SQLCipher 的策略
为保证数据安全,许多应用选择使用 SQLCipher 进行数据库加密。SQLCipher 依赖于一个密钥来加解密数据库。关键在于,开发者需要妥善管理此密钥,避免密钥泄露。 在 Android 5.0(Lollipop)及以上版本中,通常利用 Android Keystore 来生成一个不可提取的 AES 密钥。此 AES 密钥用于加密一个实际的、随机生成的数据库密码,这个密码才真正用来打开数据库,这是更安全的设计方案。
但是,Android KeyStore
和相关 API 在 Android 5.0 上并不完全可用,这给需要在 Lollipop 版本上支持数据库加密的应用带来了挑战。 使用硬编码密码并非长久之计,这无疑是极不安全的做法。 本文将探讨如何在 Android Lollipop 上安全使用 SQLCipher,同时避免直接从用户输入导出密钥。
问题分析
在 Lollipop 系统上,Android Keystore
的部分安全特性缺失,比如强安全硬件支持(StrongBox)。 这意味着我们不能依赖其直接生成并安全存储密钥。一个普遍的错误做法是从用户的输入来导出密码,比如通过对用户的某个静态信息做哈希运算。这样做存在显著的安全隐患。一旦攻击者知道了算法和用户的某个静态信息,就可以反向推出密钥。因此,我们必须找到不依赖用户输入,又能在Lollipop上安全生成和使用SQLCipher密钥的方法。
解决办法一: 使用固定密钥,并做好密钥混淆和管理
虽然避免硬编码密码是安全首要目标,但如果真的别无选择,可考虑生成一个静态的强随机密码(比如使用Java内置的SecureRandom
类),并将其以某种方式混淆存储在应用内,例如将其分散在多个资源文件中,或者使用字节码混淆工具。这个方案的缺点很明显:虽然增加了密钥泄露难度,但一旦密钥被泄漏,所有的数据将面临风险,并不能保证完全的安全。但是相较于硬编码直接字符串,安全性能已明显提升。
操作步骤:
-
生成密钥 : 使用
SecureRandom
生成一个长度适当的字节数组。 例如 32 字节(256位),确保足够安全。 -
存储密钥 : 不将密钥直接以字符串或字节数组存储在代码中,可以采用下面两种方法之一:
- 分片存储 : 将密钥切分成多个小部分,分布在不同的地方存储。然后在运行时组装。
- 混淆存储 : 使用一种简单的加密方法(例如简单的异或操作)加密字节码中的密钥。 编译时进行加密,运行时再解密。
-
使用密钥 : 获取密钥后,在SQLCipher中使用。
import java.security.SecureRandom;
import java.util.Arrays;
public class KeyManager {
private static final String KEY_PART1 = "ABCDEFGH";
private static final String KEY_PART2 = "IJKLMNOP";
private static final String KEY_PART3 = "QRSTUVWX";
private static final String KEY_PART4 = "YZ012345";
private static byte[] createKey() {
SecureRandom random = new SecureRandom();
byte[] key = new byte[32];
random.nextBytes(key);
return key;
}
private static byte[] combineKeys(){
String key = KEY_PART1 + KEY_PART2 + KEY_PART3 + KEY_PART4;
byte[] byteArrayKey = new byte[32];
System.arraycopy(key.getBytes(), 0, byteArrayKey,0,32);
byte[] combined = createKey();
for (int i = 0; i < byteArrayKey.length; i++) {
combined[i] = (byte) (combined[i] ^ byteArrayKey[i]);
}
return combined;
}
public static byte[] getSQLCipherKey() {
//1. 从分片的地方或者混淆过的资源中得到key
// byte[] secretKey = getSecretKeyFromSplitedPart(); 或者 byte[] secretKey = getSecretKeyFromMixKey();
//2. 将分片的Key整合或者混淆后的解密后得到正确的SQLCipher Key
return combineKeys();
}
//TODO 可以从 SharedPreferences 或者 res/values 下面读取
private byte[] getSecretKeyFromSplitedPart() {
String part1 = "A";
String part2 = "B";
//..... 将所有分散存储的片段组合成一个完整的key
return null;
}
//TODO 还可以从经过简单的异或运算过的 res/value 读取key,然后再反向解密
private byte[] getSecretKeyFromMixKey() {
return null;
}
}
SQLiteDatabase.loadLibs(getApplicationContext());
//get key form keymanager
byte[] key = KeyManager.getSQLCipherKey();
File databasePath = getDatabasePath("my_database.db");
SQLiteDatabase database = SQLiteDatabase.openOrCreateDatabase(
databasePath,
key,
null
);
解决办法二: 动态密钥生成与存储,但风险稍高
可以考虑在首次运行时生成一个随机密钥,并将其存储在内部存储空间的某个文件中,然后设置文件的访问权限为应用私有。每次启动时都读取此密钥。 需要格外小心保护文件和确保密钥的唯一性和随机性。同时也要提防ROOT设备攻击者对密钥文件直接读取。 这个方案相对更安全,但密钥文件仍可能被获取,并且存储的文件本身也可能会造成数据安全风险。 并且这个方法不能规避Android 5.0对硬件安全模块的不支持的本质。
操作步骤:
-
密钥生成 : 应用首次运行时,使用
SecureRandom
生成一个随机密钥,长度确保符合要求。 -
密钥存储 : 将生成的密钥以字节数组形式写入应用内部存储空间中的一个私有文件。例如
/data/data/<packagename>/files
-
权限设置 : 将该文件的权限设置为仅限当前应用访问,拒绝外部访问。 例如
file.setReadable(false,false)
,注意参数false,false
代表着所有者不具有读权限
,所有人不具有读权限
。 这里权限设置并非是所有情况都能有效防止攻击,只能算是加大攻击难度,降低了安全风险,并且可能因Rom的不同而有差异。 -
读取密钥 : 在每次需要打开SQLCipher数据库时,读取存储的密钥。
private byte[] getKey(Context context) {
File keyFile = new File(context.getFilesDir(), "database.key");
byte[] key = null;
if (!keyFile.exists()) {
key = createKey();
try(FileOutputStream fileOutputStream = new FileOutputStream(keyFile)) {
fileOutputStream.write(key);
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M){
// api <=23 不存在file.setReadable() 导致的NullPointerException
keyFile.setReadable(false,false);
}else{
//如果设置所有都不读 也会导致java.nio.file.FileSystemException: Read-only file system 异常
Path keyPath = keyFile.toPath();
Files.setAttribute(keyPath, "unix:rw-------", new Object[] {});
}
} catch (Exception e) {
e.printStackTrace();
key = null;
}
}else {
try(FileInputStream fileInputStream = new FileInputStream(keyFile)){
key = new byte[(int)keyFile.length()];
fileInputStream.read(key);
}catch (Exception ex){
ex.printStackTrace();
}
}
return key;
}
// 获取和打开SQLCipher
public SQLiteDatabase getDatabase() {
byte[] key = getKey(getContext());
if(key==null){
// 密钥为空,说明初始化或者读取失败,此处处理可能引发的异常或者直接返回Null
return null;
}
File databasePath = getContext().getDatabasePath("my_database.db");
return SQLiteDatabase.openOrCreateDatabase(databasePath, key, null);
}
// 使用此数据库的时候要用类似
// SQLiteDatabase sqLiteDatabase = this.getDatabase(); 注意对返回值进行非Null判断
安全建议
- 使用最新的SQLCipher库 :定期检查是否有SQLCipher库更新,保证使用最新的安全版本。
- 不要存储用户数据 :任何不敏感的输入和用户信息都不要用来生成SQLCipher 密钥。
- 加固设备 : 应用使用者也应该增强设备的安全性,防止设备被ROOT。 确保使用足够复杂的PIN码或者生物信息特征解锁设备,增加攻击者的成本。
综上,在 Android Lollipop 上安全地使用 SQLCipher 需要格外谨慎。 选择合适的策略非常重要,并充分权衡其安全性和实用性。 需要不断提升应用的安全性能,更好地保护用户数据。