跳过主要内容

如何在Capacitor中加密SQLite数据库

在 Capacitor 中使用 SQLCipher 加密 SQLite 数据库:在 iOS 和 Android 上设置它,安全存储密钥,迁移现有数据并旋转密钥。

文章来源

马丁·多纳迪厄

作者

瓦莱里娅

审阅者

乔丹

编辑

如何在 Capacitor 中加密 SQLite 数据库

要在 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 TABLE statement 和重建索引而不是复制影子表。
  • 对于非常大的表,使用 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应用模块中添加

  1. 在iOS中添加子规范 sqlcipher-android 首次启动时生成一个随机256位密钥 SQLCipher 在首次启动时生成一个随机256位密钥
  2. 在Android应用模块中添加
  3. 存储它与 @capgo/capacitor-native-biometric (setData, 可选地与 accessControl).
  4. 打开它 encrypted: true, 然后只读取一次以验证密钥。
  5. 为密钥丢失做好准备:重置并重新同步。
  6. 将现有明文数据库迁移到一次,然后使用 PRAGMA rekey 如果您的政策要求它。

原生变化,如添加 SQLCipher 需要新建一个存储构建。 Capgo 构建 可以在没有本地 Mac 的情况下生成签名的 iOS 和 Android 二进制文件。 可以在没有本地 Mac 的情况下生成签名的 iOS 和 Android 二进制文件,和 快速 SQL 文档

Live updates for Capacitor apps

为Capgo应用提供即时更新

14天的无限免费试用,不需要信用卡

Capgo为您提供创建真正专业的移动应用所需的最佳见解。