要在 Capacitor 中加密 SQLite 数据库,请使用基于 SQLCipher 构建的 plugin,通过加密密钥打开数据库,并将该密钥存储在 iOS Keychain 或 Android Keystore 中,而不是存储在您的 code 中。本指南将演示 @capgo/capacitor-fast-sql 在 Capacitor 上:构建双平台环境、密钥生成与存储、加密已存用户数据的数据库、密钥轮换以及必须处理的异常情况。
SQLCipher 的保护范围及其局限性
SQLCipher 是 SQLite 的开源扩展,它使用 AES-256 加密数据库文件的每一页。如果没有密钥,文件将表现为随机字节。它同时也涵盖了 WAL 和 journal 文件。
它可以防御:
- 从越狱或 Root 设备中拷贝应用文件、未加密的备份,或因密码强度过低导致的手机丢失风险;
- 读取应用容器的取证工具;
- 通过配置错误的共享目录读取文件的其他应用。
它无法防御:
- 在你的应用中运行的 code(例如恶意依赖项或 WebView 中的 XSS 漏洞)可能会调用你的数据层,
- 攻击者可以提取密钥,这也是为什么密钥存储比加密算法本身更重要的原因,
- 你还在其他地方以明文形式保存的数据:日志、
localStorage缓存、分析事件。
iOS 和 Android 在设备锁定状态下已经会对应用存储进行加密。SQLCipher 则增加了一个不依赖设备锁屏密码且在备份后依然有效的加密层。对于医疗、金融及任何受监管的行业,这都是值得的。若要全面应对存储威胁,还需考虑 SQLite 之外的备份、日志和明文缓存。
前提条件
Fast SQL 已按如下方式安装并配置 如何在 Capacitor 应用中使用 SQLite,包括 iOS 本地网络和 Android localhost 明文设置。加密需手动启用,因此基础安装不包含 SQLCipher。
第 1 步:将 SQLCipher 添加到 native build 中
Android
将依赖项添加到 App 层级的 android/app/build.gradle:
dependencies {
implementation 'net.zetetic:sqlcipher-android:4.13.0'
}
插件 README 固定为 4.13.0。只要主版本号保持为 4,较新的 4.x 版本均可使用。如果没有此依赖项, encrypted: true 会失败并抛出明确错误(“SQLCipher native library not found”),而不是崩溃。
SQLCipher 自带原生 .so 文件,覆盖每个 ABI。预计 APK 会增大几 MB。App Bundles 只会分发每台设备所需的 ABI。
iOS
在以下文件中将插件切换至其 SQLCipher subspec: ios/App/Podfile,放在……之后 bunx cap sync 已写入默认条目:
pod 'CapgoCapacitorFastSql/SQLCipher', :path => '../../node_modules/@capgo/capacitor-fast-sql'
然后:
cd ios/App && pod install
bunx cap sync 会重新生成 Podfile 中的 Capacitor 部分,因此每次同步后请检查此行。若不使用该 subspec,插件将基于系统 SQLite 进行构建,且在使用时无法打开 encrypted: true 返回 Encryption is not available in this build.
Web 端
Web 端无法使用 SQLCipher。Fast SQL 的 encrypted 选项仅限 iOS 和 Android。如果您的应用也提供 PWA 版本,请在 Capacitor.isNativePlatform() 分支上进行处理,切勿在浏览器构建版本中持久化敏感表。
第 2 步:生成密钥
使用 iOS 和 Android WebView 中均可用的 Web Crypto API:
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(SQLCipher 4 默认迭代 256,000 次)派生实际密钥。该派生过程每次打开数据库时运行一次,在旧款手机上会占用可感知的零点几秒,这属于正常现象。
不要直接把用户密码当作密钥,除非你希望数据库被这个密码锁死。用户会忘记密码、会在其他设备上改密码,也会选弱密码。通常的做法是随机设备密钥加安全存储。如果你需要用户把关的访问,请改为用生物识别来把关对密钥的访问。
第 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 在录入信息变更时使密钥失效,这种方式更严格,但意味着用户添加指纹后本地数据库将无法读取。仅当数据可以从服务器重新同步时才选择此项。有关提示和回退机制的更多信息,请参阅 Capacitor 应用中的生物识别认证.
不要将密钥放在哪里
- 在你的 JavaScript
.env文件中,或者capacitor.config.ts。它们都会打包在 app bundle 中,任何人都可以解压 IPA 或 APK。 - 在
@capacitor/preferences或localStorage。这些都是明文。 - 每次启动时都会从你的 API 获取,而不保留任何本地副本。这样会导致应用无法离线运行,违背了使用本地数据库的初衷。建议在登录后获取一次并将其存储在 Keychain 或 Keystore 中。
步骤 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 如果密钥错误,请立即进行一次低成本读取,并在统一的地方处理错误,而不是在 UI 渲染过程中处理。
要检查加密是否已启用,请运行 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,然后重新打开。系统会生成新密钥并创建一个空的数据库。
需要检查的两项相关设置:
- 在 Android 上,如果恢复后无法解密,请考虑在以下位置将数据库排除在 Auto Backup 之外:
data_extraction_rules.xml这样可以避免用户遇到令人困惑的重置问题。 - 在 iOS 上,
Documents中的文件会进入 iCloud 备份。虽然加密后只能通过密钥读取,但如果数据可以重新同步,排除它们可以减小备份体积。
加密现有的明文数据库
如果您的应用已经发布了未加密的数据库,您无法直接在 encrypted: true 同一个文件上进行切换。SQLCipher 会尝试解密明文页并导致失败。请执行一次数据迁移,将数据复制到新的加密数据库中:
// 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;
}
仅运行一次,通过成功后存储的 flag 进行保护,并显示进度状态,因为需要读取每一行。注意:
- 先创建并填充表,随后再创建索引、触发器和视图。最后统一构建索引速度更快,且在复制现有行时不会触发触发器。
sqlite_sequence将被跳过。当插入带有显式 ID 的行时,SQLite 会自动更新它,因此AUTOINCREMENT将从最高的 ID 继续。- 虚拟表 (FTS) 会创建内部影子表。请根据其
CREATE VIRTUAL TABLE执行语句并重建索引,而不是复制影子表。 - 对于超大型表,请分页遍历,使用
WHERE rowid > ? ORDER BY rowid LIMIT 5000而不是使用单个SELECT *. - 删除明文文件正是这一步的关键。已删除的闪存存储仍可能保留旧数据块,但文件系统已无法再访问该文件。
轮换密钥
SQLCipher 会使用 PRAGMA rekey。使用 Fast SQL 时,你可以在已打开的加密连接上把它作为一条语句执行:
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 里还是旧密钥的情况。为避免这一点,先把新密钥写到另一个名称下,执行 rekey,再将其提升为正式密钥并删除旧条目;启动时如果主密钥失败,就尝试使用待处理的密钥。 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 模式可以原地加密现有数据库,如果你已经在使用它,这会很方便。配合 passphrase 使用, capacitor.config (data-storage-sqlite),把加密当作混淆:密钥就在 bundle 里。对于真正敏感的值,优先使用 Fast SQL,并使用安全存储中的密钥。请参阅 Capacitor 中的键值存储选项 来权衡利弊。
合规说明
- App Store 出口合规。 集成 SQLCipher 意味着你的 App 包含加密功能。App Store Connect 会在每次构建时询问相关信息(或者你设置
ITSAppUsesNonExemptEncryption中的Info.plist)。设备上用户自有数据的加密通常属于豁免范围,但答案取决于你的 App 以及你分发到的国家/地区。如果不确定,请查阅 Apple 的指南并寻求建议。我们的 App 加密概览 涵盖更全面的情况。 - SQLCipher 许可证。 社区版采用 BSD 协议,要求在应用中保留其版权声明。请将其添加到您的开源许可界面中。
- Google Play 数据安全说明。 您可以声明数据在静态存储时已加密。请确保所有副本(包括缓存和日志)均符合此项。
故障排除
file is not a database 在首次查询时。 密钥错误,或者文件为明文,而您尝试使用 encrypted: true进行打开。请检查安全存储中的密钥是否为创建时使用的密钥,然后针对明文文件使用上述迁移方案。
Encryption is not available in this build 在 iOS 上协调。 Podfile 仍在使用默认 subspec。请重新添加该 CapgoCapacitorFastSql/SQLCipher 行,并在 cap sync 并运行 pod install.
SQLCipher native library not found 在 Android 上。 本指南中介绍的 sqlcipher-android 依赖项在 app 模块中缺失,或者你将其添加到了插件或根目录中 build.gradle 请改用这种方式。
打开数据库速度较慢。 密钥派生(Key derivation)在每次打开时都会运行。请在启动时打开一次并复用连接,而不是每次查询都重新连接。
数据库在某台设备上可以打开,但在恢复备份后无法打开。 密钥未随备份一起传输。请通过重置流程进行处理,并从服务器重新同步。
总结
- 添加 SQLCipher:
sqlcipher-android在 Android app 模块中,SQLCipher在 iOS 的 subspec 中。 - 首次启动时生成随机 256 位密钥。
- 将其存储在
@capgo/capacitor-native-biometric(setData,或者可选地使用accessControl). - Open with
encrypted: true,然后读取一次以验证密钥。 - 做好密钥丢失的预案:重置并重新同步。
- 对现有明文数据库进行一次性迁移,并根据
PRAGMA rekey的要求定期轮换密钥。
像添加 SQLCipher 这样的 Native 变更需要重新构建 Store 版本。 Capgo 构建 可以在没有本地 Mac 的情况下生成已签名的 iOS 和 Android 二进制文件,并且 Fast SQL 文档 列出所有连接选项。