跳转至正文

如何在 Capacitor 中加密 SQLite 数据库

在 Capacitor 中使用 SQLCipher 加密 SQLite 数据库:完成 iOS 和 Android 的配置、安全存储密钥、迁移现有数据并实现密钥轮换。

Capgo 标志:关于如何在 Capacitor 中使用 SQLCipher 加密 SQLite 数据库的指南

要在 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)在每次打开时都会运行。请在启动时打开一次并复用连接,而不是每次查询都重新连接。

数据库在某台设备上可以打开,但在恢复备份后无法打开。 密钥未随备份一起传输。请通过重置流程进行处理,并从服务器重新同步。

总结

  1. 添加 SQLCipher: sqlcipher-android 在 Android app 模块中, SQLCipher 在 iOS 的 subspec 中。
  2. 首次启动时生成随机 256 位密钥。
  3. 将其存储在 @capgo/capacitor-native-biometric (setData,或者可选地使用 accessControl).
  4. Open with encrypted: true,然后读取一次以验证密钥。
  5. 做好密钥丢失的预案:重置并重新同步。
  6. 对现有明文数据库进行一次性迁移,并根据 PRAGMA rekey 的要求定期轮换密钥。

像添加 SQLCipher 这样的 Native 变更需要重新构建 Store 版本。 Capgo 构建 可以在没有本地 Mac 的情况下生成已签名的 iOS 和 Android 二进制文件,并且 Fast SQL 文档 列出所有连接选项。

为 Capacitor 应用提供 Live Updates

当 Web 层出现线上 Bug 时,通过 Capgo 发布修复,而不用等 App Store 审核好几天。用户会在后台收到更新,原生变更则仍走正常审核流程。

博客最新动态

Capgo 为你提供打造真正专业的移动应用所需的最有价值的洞察。