Zum Hauptinhalt springen

So verschlüsselst du eine SQLite-Datenbank in Capacitor

SQLite-Datenbank in Capacitor mit SQLCipher verschlüsseln: Einrichtung auf iOS und Android, sichere Schlüsselverwaltung, Migration vorhandener Daten und Schlüsselrotation.

Capgo-Logo für einen Leitfaden zum Verschlüsseln einer SQLite-Datenbank in Capacitor mit SQLCipher

Um eine SQLite-Datenbank in Capacitor zu verschlüsseln, verwenden Sie ein Plugin, das mit SQLCipher erstellt wurde, öffnen Sie die Datenbank mit einem Verschlüsselungsschlüssel und speichern Sie diesen Schlüssel im iOS Keychain oder Android Keystore, nicht in Ihrem code. Dieser Leitfaden zeigt, wie Sie das mit @capgo/capacitor-fast-sql auf Capacitor 8: Build-Setup für beide Plattformen, Schlüsselgenerierung und -speicherung, Verschlüsselung einer Datenbank, die bereits Benutzerdaten enthält, Schlüsselrotation und die Fehlerfälle, die Sie behandeln müssen.

Was SQLCipher schützt – und was nicht

SQLCipher ist eine Open-Source-Erweiterung von SQLite, die jede Seite der Datenbankdatei mit AES-256 verschlüsselt. Ohne den Key besteht die Datei nur aus Zufallsbytes. Das gilt auch für die WAL- und Journal-Dateien.

Es schützt vor:

  • jemandem, der App-Dateien von einem jailbroken oder gerooteten Gerät, einem unverschlüsselten Backup oder einem verlorenen Handy mit schwachem Passcode kopiert,
  • Forensic-Tools, die App-Container auslesen,
  • anderen Apps, die die Datei über ein falsch konfiguriertes Shared Directory lesen.

Es schützt nicht vor:

  • code in deiner App (eine bösartige Dependency, ein XSS-Bug im WebView), das deine Data Layer aufrufen kann,
  • einem Angreifer, der den Key extrahieren kann – deshalb ist die Key-Speicherung wichtiger als die Verschlüsselung selbst,
  • Daten, die du an anderer Stelle im Klartext speicherst: Logs, localStorage, Caches, Analytics-Events.

iOS und Android verschlüsseln den App-Speicher bereits, wenn das Gerät gesperrt ist. SQLCipher fügt eine Ebene hinzu, die nicht vom Geräte-Passcode abhängt und Backups übersteht. Das lohnt sich für Health-, Finance- und alles Regulierte. Für einen breiteren Blick auf Storage-Bedrohungen solltest du Backups, Logs und Klartext-Caches außerhalb von SQLite mitdenken.

Voraussetzungen

Fast SQL installiert und konfiguriert wie in So nutzt du SQLite in einer Capacitor Appeinschließlich der iOS-Local-Networking- und Android-Localhost-Cleartext-Einstellungen. Die Verschlüsselung ist opt-in, daher enthält die Basisinstallation kein SQLCipher.

Schritt 1: SQLCipher zu den Native Builds hinzufügen

Android

Füge die Dependency zur App-Ebene hinzu android/app/build.gradle:

dependencies {
    implementation 'net.zetetic:sqlcipher-android:4.13.0'
}

Das Plugin-README fixiert die Version 4.13.0. Neuere 4.x-Releases funktionieren, solange die Major-Version bei 4 bleibt. Ohne diese Dependency, encrypted: true schlägt der Vorgang mit einer klaren Fehlermeldung fehl („SQLCipher native library not found“), anstatt abzustürzen.

SQLCipher liefert native .so Dateien für jedes ABI mit. Die APK wird dadurch um einige Megabyte größer. App Bundles liefern nur das ABI aus, das das jeweilige Gerät benötigt.

iOS

Plugin auf das SQLCipher-Subspec in ios/App/Podfile, nach bunx cap sync den Standard-Eintrag geschrieben hat:

pod 'CapgoCapacitorFastSql/SQLCipher', :path => '../../node_modules/@capgo/capacitor-fast-sql'

Danach:

cd ios/App && pod install

bunx cap sync regeneriert den Capacitor-Abschnitt der Podfile. Prüfe diese Zeile nach jedem Sync. Ohne das Subspec baut das Plugin gegen das System-SQLite, und das Öffnen mit encrypted: true gibt zurück Encryption is not available in this build.

Web

Auf dem Web gibt es kein SQLCipher. Die encrypted Option von Fast SQL ist nur für iOS und Android. Wenn deine App auch als PWA ausgeliefert wird, verzweige auf Capacitor.isNativePlatform() und speichere sensible Tabellen nicht im Browser-Build.

Schritt 2: Schlüssel generieren

Verwende die Web Crypto API, die in den WebViews von iOS und Android verfügbar ist:

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('');
}

Dies ergibt eine 64-stellige Hex-Zeichenfolge. SQLCipher behandelt die Zeichenfolge als Passphrase und leitet den tatsächlichen Schlüssel mit PBKDF2-HMAC-SHA512 ab (standardmäßig 256.000 Iterationen in SQLCipher 4). Diese Ableitung erfolgt einmal pro Öffnung und kostet auf älteren Handys einen spürbaren Bruchteil einer Sekunde, was normal ist.

Verwende kein Benutzerpasswort direkt als Schlüssel, es sei denn, du möchtest die Datenbank an dieses Passwort binden. Benutzer vergessen Passwörter, ändern sie auf einem anderen Gerät oder wählen schwache Passwörter. Ein zufälliger Geräteschlüssel plus sichere Speicherung ist das übliche Design. Wenn du benutzergesteuerten Zugriff benötigst, schütze stattdessen den Zugriff auf den Schlüssel (mit Biometrie).

Schritt 3: Schlüssel im Keychain oder Keystore speichern

@capgo/capacitor-native-biometric speichert beliebige Zeichenfolgen verschlüsselt im Ruhezustand mit dem iOS Keychain und Android Keystore. setData und getData (Plugin 8.6.0 und neuer) fragt den Benutzer nicht ab. Füge hinzu accessControl um Face ID, Touch ID oder Fingerabdruck zu verlangen, bevor der Key gelesen werden kann.

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 });
}

Optional: Biometrie zum Entsperren der Datenbank verlangen

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 übersteht das Hinzufügen neuer Fingerabdrücke oder Gesichtsmuster. BIOMETRY_CURRENT_SET invalidiert den Key bei Änderungen an der Registrierung. Das ist strenger, bedeutet aber, dass die lokale Datenbank unlesbar wird, wenn der Benutzer einen Fingerabdruck hinzufügt. Wähle diese Option nur, wenn die Daten von deinem Server neu synchronisiert werden können. Mehr zu Prompts und Fallbacks in biometrische Authentifizierung in Capacitor-Apps.

Wo der Key nicht hingehört

  • In deinem JavaScript, .env Dateien oder capacitor.config.ts. All das wird im App-Bundle ausgeliefert und jeder kann eine IPA oder APK entpacken.
  • Unter @capacitor/preferences oder localStorage. Das sind Klartext.
  • Wird bei jedem Start von deinem API geladen, ohne lokale Kopie. Die App würde offline nicht öffnen, was den Zweck einer lokalen Datenbank zunichtemacht. Es ist in Ordnung, sie einmal nach dem Login abzurufen und im Keychain oder Keystore zu speichern.

Schritt 4: Die verschlüsselte Datenbank öffnen

// 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 ist entscheidend. SQLCipher validiert den Schlüssel nicht beim Öffnen. Der erste echte Read schlägt fehl mit file is not a database wenn der Schlüssel falsch ist. Führe daher sofort einen günstigen Read aus und behandle den Fehler an einer Stelle, nicht mitten auf einem Screen.

Um zu prüfen, ob die Verschlüsselung aktiv ist, führe aus PRAGMA cipher_version auf der Verbindung. Es gibt die SQLCipher-Version zurück, bei normalem SQLite ein leeres Ergebnis.

Schritt 5: Verlorene oder falsche Schlüssel

A key can go missing in normal use:

  • Der Nutzer stellt ein Backup auf einem neuen Telefon wieder her. Die Datenbankdatei kommt zurück, aber Keychain-Einträge, die als gerätespezifisch erstellt wurden, und Android-Keystore-Schlüssel werden nicht auf neue Hardware übertragen.
  • Android Auto Backup restores the app’s files but the Keystore key used to encrypt the stored secret was not restored.
  • Der Nutzer ändert die biometrische Registrierung und du hast verwendet BIOMETRY_CURRENT_SET.

Die Wiederherstellung ist immer gleich: Die Daten sind lokal weg, also die Datei löschen und von vorne beginnen, dann aus deinem Backend neu synchronisieren.

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();
}

Rufe es auf, wenn verifyKey fehlschlägt mit file is not a database, setze die dbPromisezurück und öffne sie erneut. Es wird ein neuer Schlüssel generiert und eine leere Datenbank erstellt.

Zwei verwandte Einstellungen, die du prüfen solltest:

  • Auf Android sollten Sie die Datenbank von der Auto-Backup-Funktion ausschließen, in data_extraction_rules.xml falls sie nach dem Restore ohnehin nicht entschlüsselt werden kann. So ersparen Sie Ihren Nutzern einen verwirrenden Reset.
  • Auf iOS landen Dateien in Documents in iCloud-Backups. Sie sind verschlüsselt und nur mit dem Schlüssel lesbar, aber der Ausschluss hält Backups klein, wenn die Daten neu synchronisiert werden können.

Verschlüsseln einer vorhandenen Klartext-Datenbank

Wenn deine App bereits mit einer unverschlüsselten Datenbank ausgeliefert wurde, kannst du encrypted: true in derselben Datei. SQLCipher würde versuchen, Klartextseiten zu entschlüsseln und fehlschlagen. Kopieren Sie die Daten einmalig in eine neue, verschlüsselte Datenbank:

// 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;
}

Führe es einmal aus, abgesichert durch eine Flagge, die nach Erfolg gespeichert wird, und zeige einen Fortschrittsstatus an, da jede Zeile gelesen wird. Hinweise:

  • Tabellen werden zuerst erstellt und befüllt, Indizes, Trigger und Views danach. Indizes am Ende einmalig zu erstellen ist schneller, und Trigger feuern nicht, während vorhandene Zeilen kopiert werden.
  • sqlite_sequence wird übersprungen. SQLite aktualisiert sie automatisch, wenn Zeilen mit expliziten IDs eingefügt werden, also AUTOINCREMENT setzt bei der höchsten ID fort.
  • Virtuelle Tabellen (FTS) erstellen interne Shadow-Tabellen. Erstelle FTS-Tabellen aus ihrer CREATE VIRTUAL TABLE Aussage neu und baue den Index um, anstatt die Shadow-Tabellen zu kopieren.
  • Für sehr große Tabellen durchblättere mit WHERE rowid > ? ORDER BY rowid LIMIT 5000 anstatt eines einzelnen SELECT *.
  • Das Löschen der Klartextdatei ist der eigentliche Zweck. Gelöschter Flash-Speicher kann alte Blöcke noch enthalten, aber die Datei ist über das Dateisystem nicht mehr erreichbar.

Schlüsselrotation

SQLCipher verschlüsselt die gesamte Datenbank neu mit PRAGMA rekey. Mit Fast SQL führst du es als Aussage auf einer offenen verschlüsselten Verbindung aus:

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 });
}

Die Reihenfolge ist entscheidend. Wenn die App zwischen den beiden Zeilen beendet wird, verwendet die Datenbank den neuen Schlüssel, während im Keychain noch der alte liegt. Um das zu vermeiden, schreibst du den neuen Schlüssel zuerst unter einem zweiten Namen, führst das Rekeying durch, machst ihn dann zum Hauptschlüssel und löschst den alten Eintrag. Beim Start versuchst du den ausstehenden Schlüssel, falls der Hauptkey fehlschlägt. PRAGMA akzeptiert keine gebundenen Parameter, weshalb der Schlüssel inline gesetzt wird. Setze nur inline Werte, die du selbst generiert hast, niemals Benutzereingaben. Teste die Rotation auf beiden Plattformen mit einer Kopie produktionsgroßer Daten, da rekey jede Seite neu schreibt.

Verschlüsselung mit anderen Plugins

Plugin So funktioniert die Verschlüsselung Key-Handling
@capgo/capacitor-fast-sql Opt-in-SQLCipher unter iOS und Android Du gibst den Key weiter connect()
@capacitor-community/sqlite SQLCipher ist immer enthalten, aktiviert in capacitor.config Das Plugin speichert eine Passphrase pro App mit setEncryptionSecret(), Modi secret, encryption (eine bestehende Datei verschlüsseln), newsecret
@capgo/capacitor-data-storage-sqlite SQLCipher für seinen Key-Value-Store Passphrase festgelegt in capacitor.config, damit es mit der App ausgeliefert wird

Des Community-Plugins encryption kann eine bestehende Datenbank direkt verschlüsseln, was praktisch ist, wenn du bereits damit arbeitest. Mit einer Passphrase in capacitor.config (data-storage-sqlite) solltest du Verschlüsselung als Obfuskation betrachten: Das Secret steckt im Bundle. Für wirklich sensible Werte nimm lieber Fast SQL mit einem Key aus dem Secure Storage. Siehe die Key-Value-Storage-Optionen in Capacitor für diese Abwägung.

Compliance-Hinweise

  • Export-Compliance für den App Store. Wenn du SQLCipher auslieferst, enthält deine App Verschlüsselung. App Store Connect fragt bei jedem Build danach (oder du setzt ITSAppUsesNonExemptEncryption in einem Info.plist). Die Verschlüsselung der eigenen Nutzerdaten auf dem Gerät fällt häufig unter Ausnahmen, aber die Antwort hängt von deiner App und den Ländern ab, in denen du sie vertreibst. Prüfe die Richtlinien von Apple und hole dir bei Unsicherheit Rat. Unser Überblick zur App-Verschlüsselung deckt das größere Bild ab.
  • SQLCipher-Lizenz. Die Community Edition nutzt eine BSD-artige Lizenz, die den Copyright-Hinweis in deiner App erfordert. Füge ihn zu deinem Bildschirm für Open-Source-Lizenzen hinzu.
  • Google Play Data Safety. Du kannst Daten als verschlüsselt im Ruhezustand deklarieren. Stelle sicher, dass dies für jede Kopie zutrifft, einschließlich Caches und Logs.

Fehlerbehebung

file is not a database bei der ersten Abfrage. Falscher Schlüssel, oder die Datei ist Klartext und du hast sie geöffnet mit encrypted: true. Prüfe, ob der Schlüssel im sicheren Speicher derselbe ist, der bei der Erstellung verwendet wurde, und nutze dann die oben genannte Migration für Klartext-Dateien.

Encryption is not available in this build auf iOS. Die Podfile verwendet weiterhin den Standard-Subspec. Füge die CapgoCapacitorFastSql/SQLCipher Zeile nach cap sync und ausführen pod install.

SQLCipher native library not found auf Android. Der sqlcipher-android Die Abhängigkeit fehlt im App-Modul, oder du hast sie dem Plugin oder der Root- build.gradle stattdessen.

Das Öffnen der Datenbank ist langsam. Die Key-Ableitung wird bei jedem Öffnen ausgeführt. Öffne die Verbindung einmal beim Start und wiederverwende sie, statt pro Query neu zu verbinden.

Die Datenbank öffnet sich auf einem Gerät, aber nicht nach einem Restore. Der Key wurde nicht mit dem Backup übertragen. Behandle dies über den Reset-Flow und synchronisiere erneut vom Server.

Zusammenfassung

  1. SQLCipher hinzufügen: sqlcipher-android im Android-App-Modul, die SQLCipher Subspec auf iOS.
  2. Erstelle beim ersten Start einen zufälligen 256-Bit-Schlüssel.
  3. Speichere ihn mit @capgo/capacitor-native-biometric (setData, optional mit accessControl).
  4. Öffne mit encrypted: true, dann einmal lesen, um den Schlüssel zu überprüfen.
  5. Plan for key loss: reset and resync.
  6. Migriere bestehende Klartext-Datenbanken einmalig und rotiere Schlüssel mit PRAGMA rekey , wenn es deine Richtlinie erfordert.

Native Änderungen wie das Hinzufügen von SQLCipher erfordern einen neuen Store-Build. Capgo Build kann signierte iOS- und Android-Binaries ohne lokalen Mac erstellen, und die Fast SQL Docs listet alle Verbindungsoptionen auf.

Live-Updates für Capacitor-Apps

Wenn ein Bug im Web-Layer live ist, liefere den Fix über Capgo aus, statt Tage auf die App-Store-Freigabe zu warten. Nutzer bekommen das Update im Hintergrund, während native Änderungen im normalen Review-Prozess bleiben.

Neues aus unserem Blog

Capgo gibt dir die besten Insights, um eine wirklich professionelle Mobile App zu erstellen.