要在 Capacitor 中加密 SQLite 数据库,请使用 SQLCipher 构建的插件,使用加密密钥打开数据库,并将该密钥存储在 iOS Keychain 或 Android Keystore 中,而不是在您的 code 中。这篇指南使用 @capgo/capacitor-fast-sql 在 Capacitor 8:为两种平台建立设置,密钥生成和存储,已有用户数据的数据库加密,密钥旋转,以及您必须处理的故障案例。
SQLCipher 保护了什么,以及它不保护什么
SQLCipher 是 SQLite 的一个开源扩展,它使用 AES-256 对数据库文件中的每个页面进行加密。没有密钥,文件就是随机字节。它还保护了 WAL 和日志文件。
它保护了以下内容:
- 从一个越狱或根设备上复制应用程序的文件、未加密的备份或一个弱密码的丢失手机、
- 对应用程序容器进行的法医工具、
- 通过一个配置错误的共享目录读取文件的其他应用程序。
它不保护以下内容:
- code 在您的应用程序内部运行(一个恶意依赖项、一个在 WebView 中的 XSS 漏洞)可以调用您的数据层、
- 一个可以提取密钥的攻击者,这就是为什么密钥存储比加密更重要的原因、
- 您在其他地方以明文存储的数据:日志、
localStorage,缓存、分析事件。
iOS 和 Android 已经在设备锁定时对应用程序存储进行加密。SQLCipher 添加了一个不依赖于设备密码且在备份中生存的层。它适用于健康、财务和受到监管的任何内容。要了解存储威胁的更广泛视图,请参见 安全数据库存储.
前提条件
Fast SQL已安装并配置为在 如何在Capacitor应用中使用SQLite,包括iOS本地网络和Androidlocalhost清文设置。加密是可选的,因此基本安装不包括SQLCipher。
步骤 1:将 SQLCipher 添加到本机构建中
Android
Android 将依赖项添加到 android/app/build.gradle:
dependencies {
implementation 'net.zetetic:sqlcipher-android:4.13.0'
}
级别 encrypted: true 插件 README 将 4.13.0 固定。最新的 4.x 版本只要主版本号保持为 4,就可以正常工作。没有这个依赖项
会以明确的错误(“SQLCipher 本机库未找到”)而不是崩溃。 .so SQLCipher 提供本机
iOS
将插件切换到其SQLCipher子集成件在 ios/App/Podfile, 之后 bunx cap sync has 写入了默认条目:
pod 'CapgoCapacitorFastSql/SQLCipher', :path => '../../node_modules/@capgo/capacitor-fast-sql'
然后:
cd ios/App && pod install
bunx cap sync 重新生成Capacitor Podfile 部分,因此在每次同步后检查此行。没有子集成件,插件将使用系统SQLite进行构建,并使用 encrypted: true 返回 Encryption is not available in this build.
Web
没有在Web上有SQLCipher。Fast SQL的 encrypted 选项仅限iOS和Android。如果您的应用程序也以PWA形式发布,请在 Capacitor.isNativePlatform() 并且不要在浏览器构建中持久化敏感表格。
步骤2:生成密钥
使用 Web Crypto API,它在 iOS 和 Android WebViews 中可用:
export function generateKey(): string {
const bytes = new Uint8Array(32); // 256 bits
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
这会得到一个 64 个字符的十六进制字符串。 SQLCipher 将该字符串视为一个密钥并使用 PBKDF2-HMAC-SHA512 (256,000 次迭代,SQLCipher 4 中的默认值) 来派生实际密钥。该派生过程每次打开时都会运行,耗时约为旧手机的一小部分秒,这是正常的。
不要直接使用用户密码作为密钥,除非你想将数据库锁定到该密码。用户会忘记密码,会在另一个设备上更改密码,并选择弱密码。随机设备密钥加上安全存储是通常的设计。如果你需要用户控制访问,控制访问到密钥(使用生物识别)而不是数据库本身。
步骤 3:将密钥存储在 Keychain 或 Keystore 中
@capgo/capacitor-native-biometric 存储在 iOS Keychain 和 Android Keystore 中的任意字符串都被以休眠状态加密。 setData 并 getData (插件 8.6.0 和更高版本) 不会提示用户。添加 accessControl 要求 Face ID、Touch ID 或指纹才能读取密钥。
bun add @capgo/capacitor-native-biometric
bunx cap sync
// src/db/key.ts
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { generateKey } from './generate-key';
const KEY_NAME = 'app.db.key';
export async function getOrCreateDbKey(): Promise<{ key: string; created: boolean }> {
const { isSaved } = await NativeBiometric.isDataSaved({ key: KEY_NAME });
if (isSaved) {
const { value } = await NativeBiometric.getData({ key: KEY_NAME });
return { key: value, created: false };
}
const key = generateKey();
await NativeBiometric.setData({ key: KEY_NAME, value: key });
return { key, created: true };
}
export async function deleteDbKey(): Promise<void> {
await NativeBiometric.deleteData({ key: KEY_NAME });
}
可选:要求生物识别来解锁数据库
import { AccessControl, NativeBiometric } from '@capgo/capacitor-native-biometric';
// When creating the key
await NativeBiometric.setData({
key: 'app.db.key',
value: generateKey(),
accessControl: AccessControl.BIOMETRY_ANY,
});
// When opening the database
const { value: key } = await NativeBiometric.getSecureData({
key: 'app.db.key',
reason: 'Unlock your records',
});
BIOMETRY_ANY 当用户添加指纹或面部识别时,密钥会失效。只有当数据可以从你的服务器重新同步时才选择它。关于提示和回退的更多信息,请参见 BIOMETRY_CURRENT_SET 生物识别在 __CAPGO_KEEP_0__ 应用中的认证 生物识别认证在Capacitor应用中.
在哪里不应该放置密钥
- 在你的JavaScript
.env文件中,或capacitor.config.ts。所有这些都在应用程序包中打包,任何人都可以解压IPA或APK。 - 在
@capacitor/preferences或localStorage。这些是明文。 - 从你的API上获取并在每次启动时获取一次,而不需要本地副本。应用程序无法在离线状态下打开,这违背了本地数据库的目的。只需在登录后获取一次并将其存储在钥匙串或密钥库中即可。
步骤 4:打开加密数据库
// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { getOrCreateDbKey } from './key';
import { migrate } from './migrate';
let dbPromise: Promise<SQLConnection> | null = null;
export function getDb(): Promise<SQLConnection> {
if (!dbPromise) {
dbPromise = (async () => {
const { key } = await getOrCreateDbKey();
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: key,
walMode: true,
});
await verifyKey(db);
await migrate(db);
return db;
})();
}
return dbPromise;
}
// SQLCipher only notices a wrong key when it reads the first page
async function verifyKey(db: SQLConnection): Promise<void> {
await db.query('SELECT count(*) AS n FROM sqlite_master');
}
verifyKey 很重要。 SQLCipher 在打开时不验证密钥。第一个真正的读取失败 file is not a database 如果密钥错误,则在一个地方处理错误,而不是在屏幕的中间。
要检查加密是否处于活动状态,请运行 PRAGMA cipher_version 在连接上。它返回 SQLCipher 版本,并在普通 SQLite 中返回空结果。
步骤 5:处理丢失或错误的密钥
在正常使用中,密钥可能会丢失:
- 用户恢复备份到新手机。数据库文件恢复,但设备仅创建的 Keychain 项和 Android Keystore 密钥不会移动到新硬件。
- Android Auto Backup 恢复应用文件,但用于加密存储的秘密的 Keystore 密钥未恢复。
- 用户更改生物识别注册,并且您使用
BIOMETRY_CURRENT_SET.
恢复总是相同的:数据在本地丢失,所以删除文件并重新开始,然后从您的后端重新同步。
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL } from '@capgo/capacitor-fast-sql';
import { deleteDbKey } from './key';
export async function resetEncryptedDatabase(name = 'vault'): Promise<void> {
try {
await FastSQL.disconnect(name);
} catch {
// not connected
}
// Fast SQL stores <name>.db in Documents on iOS and in files/ on Android.
// Directory.Data maps to those two locations.
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${name}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
await deleteDbKey();
}
在 verifyKey 失败时 file is not a database, 重置 dbPromise, 然后再次打开。生成一个新密钥并创建一个空数据库。
两个相关的设置需要检查:
- On Android,考虑在Auto Backup中排除数据库在
data_extraction_rules.xml如果无法在恢复后解密数据库, - 在iOS中,
Documents文件在
去iCloud备份。加密后,只有使用密钥才能读取,但排除它们可以保持备份小,如果数据可以重新同步。
对现有明文数据库进行加密 encrypted: true 如果您的应用程序已经发布并使用未加密的数据库,则无法在同一文件上切换。
// src/db/encrypt-existing.ts
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
export async function encryptExistingDatabase(
plainName: string,
encryptedName: string,
key: string,
): Promise<SQLConnection> {
const plain = await FastSQL.connect({ database: plainName });
const enc = await FastSQL.connect({ database: encryptedName, encrypted: true, encryptionKey: key });
const objects = (await plain.query(
`SELECT type, name, sql FROM sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END`,
)) as { type: string; name: string; sql: string }[];
const [{ user_version }] = await plain.query('PRAGMA user_version');
const tables = objects.filter((o) => o.type === 'table');
const others = objects.filter((o) => o.type !== 'table'); // indexes, triggers, views
await enc.transaction(async (tx) => {
for (const t of tables) {
await tx.execute(t.sql);
}
for (const t of tables) {
const rows = await plain.query(`SELECT * FROM "${t.name}"`);
if (rows.length === 0) continue;
const columns = Object.keys(rows[0]);
const statement = `INSERT INTO "${t.name}" (${columns.map((c) => `"${c}"`).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`;
await tx.executeBatch(rows.map((r) => ({ statement, params: columns.map((c) => r[c]) })));
}
// Indexes and triggers after the data: faster, and triggers do not fire during the copy
for (const o of others) {
await tx.execute(o.sql);
}
await tx.execute(`PRAGMA user_version = ${Number(user_version)}`);
});
await FastSQL.disconnect(plainName);
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${plainName}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
return enc;
}
SQLCipher将尝试解密明文页面并失败。将数据复制到新加密数据库中一次:
- 运行一次,使用成功后存储的标志来保护它,并显示进度状态,因为它读取每行。注意:
sqlite_sequence表创建和填充首先,索引、触发器和视图之后。一次性建立索引更快,触发器不会在复制现有行时触发。AUTOINCREMENT跳过。SQLite会自动更新它,当有明确ID的行被插入时,因此- 继续从最高ID处。
CREATE VIRTUAL TABLEstatement 和重建索引而不是复制影子表。 - 对于非常大的表,使用
WHERE rowid > ? ORDER BY rowid LIMIT 5000而不是单个SELECT *. - 删除明文文件是本练习的目的。已删除的闪存存储仍然可以保留旧块,但文件不再通过文件系统可达。
旋转密钥
SQLCipher 重新加密整个数据库使用 PRAGMA rekey。使用 Fast SQL,您可以将其作为一个 statement 在一个打开的加密连接上运行:
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { getDb } from './index';
import { generateKey } from './generate-key';
export async function rotateKey(): Promise<void> {
const db = await getDb();
const newKey = generateKey(); // hex only, safe to inline
await db.execute(`PRAGMA rekey = '${newKey}'`);
await NativeBiometric.setData({ key: 'app.db.key', value: newKey });
}
顺序很重要。如果在两个行之间杀死应用程序,数据库将使用新密钥,而 Keychain 还保留旧密钥。为了避免这种情况,请在第二个名称下写入新密钥,重新加密,然后促进它并删除旧条目,在启动时尝试挂起密钥,如果主密钥失败。 PRAGMA 不接受绑定参数,这就是为什么密钥被内联的原因。只内联您生成的值,永远不要使用用户输入。测试旋转在两种平台上都使用生产大小的数据,因为 rekey 重写每个页面。
使用其他插件进行加密
| 插件 | 加密原理 | 密钥管理 |
|---|---|---|
@capgo/capacitor-fast-sql |
iOS 和 Android 上启用 SQLCipher | 您将密钥传递给 connect() |
@capacitor-community/sqlite |
SQLCipher 总是打包, capacitor.config |
插件存储一个应用程序的每个密钥 setEncryptionSecret(), 模式 secret, encryption (加密现有文件) newsecret |
@capgo/capacitor-data-storage-sqlite |
SQLCipher 为其键值存储 | 密钥设置在 capacitor.config, 因此它在应用程序中 |
社区插件的 encryption mode 可以在原地对现有的数据库进行加密,这对于已经在使用它的用户来说是方便的。使用一个密钥在 capacitor.config (data-storage-sqlite), 将加密视为混淆:密钥在 bundle 中。对于真正敏感的值,建议使用 Fast SQL,使用从安全存储中获取的密钥。见 Capacitor 中关于该权衡的说明。
合规性说明
- App Store 导出合规性。 将 SQLCipher 发布到 App Store 意味着您的应用包含了加密功能。App Store Connect 对每个构建都会询问关于加密的信息(或您设置了
ITSAppUsesNonExemptEncryption在Info.plist)。用户设备上的用户数据加密通常会被视为豁免,但答案取决于您的应用和您要分发的国家。请查看 Apple 的指南并寻求建议,如果您不确定。我们的 应用加密概述 涵盖了更广泛的内容。 - SQLCipher 许可证。 社区版使用BSD风格的许可证,需要在您的应用中包含版权声明。将其添加到您的开源许可证屏幕上。
- Google Play数据安全。 您可以声明数据为静态加密。确保每个副本,包括缓存和日志,都为真。
故障排除
file is not a database 在第一个查询中。 错误密钥,或者文件是纯文本,您打开了 encrypted: true。检查存储在安全存储中的密钥是否与创建时使用的密钥相同,然后使用上述迁移文件的纯文本文件。
Encryption is not available in this build 在iOS中。 Podfile仍然使用默认的子规范。重新添加 CapgoCapacitorFastSql/SQLCipher 行后 cap sync 并运行 pod install.
SQLCipher native library not found 在Android中。 The sqlcipher-android 打开数据库速度慢。 build.gradle instead.
数据库在一个设备上打开,但在恢复后却无法打开。 密钥未随备份一起传输。使用重置流程并从服务器重新同步。
总结 添加SQLCipher:
在Android应用模块中添加
- 在iOS中添加子规范
sqlcipher-android首次启动时生成一个随机256位密钥SQLCipher在首次启动时生成一个随机256位密钥 - 在Android应用模块中添加
- 存储它与
@capgo/capacitor-native-biometric(setData, 可选地与accessControl). - 打开它
encrypted: true, 然后只读取一次以验证密钥。 - 为密钥丢失做好准备:重置并重新同步。
- 将现有明文数据库迁移到一次,然后使用
PRAGMA rekey如果您的政策要求它。
原生变化,如添加 SQLCipher 需要新建一个存储构建。 Capgo 构建 可以在没有本地 Mac 的情况下生成签名的 iOS 和 Android 二进制文件。 可以在没有本地 Mac 的情况下生成签名的 iOS 和 Android 二进制文件,和 快速 SQL 文档