Aller au contenu principal

Comment chiffrer une base de données SQLite dans Capacitor

Chiffrez une base de données SQLite dans Capacitor avec SQLCipher : configurez-la sur iOS et Android, stockez la clé en toute sécurité, migrez les données existantes et effectuez une rotation des clés.

Logo Capgo pour un guide sur le chiffrement d'une base de données SQLite dans Capacitor avec SQLCipher

Pour chiffrer une base de données SQLite dans Capacitor, utilisez un plugin basé sur SQLCipher, ouvrez la base de données avec une clé de chiffrement et conservez cette clé dans l'iOS Keychain ou l'Android Keystore plutôt que dans votre code. Ce guide vous montre comment faire avec @capgo/capacitor-fast-sql sur Capacitor 8 : configuration du build pour les deux plateformes, génération et stockage de la clé, chiffrement d'une base de données contenant déjà des données utilisateur, rotation des clés et les cas d'échec à gérer.

Ce que SQLCipher protège, et ce qu'il ne protège pas

SQLCipher est une extension open source de SQLite qui chiffre chaque page du fichier de base de données en AES-256. Sans la clé, le fichier n'est qu'une suite d'octets aléatoires. Il couvre aussi les fichiers WAL et journal.

Il protège contre :

  • la copie des fichiers de l'app depuis un appareil jailbreaké ou rooté, une sauvegarde non chiffrée, ou un téléphone perdu avec un code de déverrouillage faible,
  • les outils forensiques qui lisent les conteneurs d'app,
  • d'autres apps qui lisent le fichier via un répertoire partagé mal configuré.

Il ne protège pas contre :

  • code exécuté dans votre app (une dépendance malveillante, une faille XSS dans la WebView) qui peut appeler votre couche de données,
  • un attaquant capable d'extraire la clé, c'est pourquoi le stockage de la clé compte plus que le chiffrement lui-même,
  • les données que vous conservez aussi en clair ailleurs : les logs, localStorage, les caches, les événements analytics.

iOS et Android chiffrent déjà le stockage des apps quand l'appareil est verrouillé. SQLCipher ajoute une couche qui ne dépend pas du code de déverrouillage de l'appareil et qui survit aux sauvegardes. Cela vaut le coup pour la santé, la finance et tout secteur régulé. Pour une vision plus large des menaces sur le stockage, pensez aux sauvegardes, aux logs et aux caches en clair hors de SQLite.

Prerequisites

Fast SQL installé et configuré comme dans Comment utiliser SQLite dans une application Capacitor, y compris les réglages de réseau local iOS et de cleartext localhost Android. Le chiffrement est optionnel : l'installation de base n'inclut donc pas SQLCipher.

Étape 1 : ajouter SQLCipher aux builds natifs

Android

Ajoutez la dépendance au niveau de l'app android/app/build.gradle:

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

Le README du plugin fixe la version à 4.13.0. Les versions 4.x plus récentes fonctionnent tant que la version majeure reste 4. Sans cette dépendance, encrypted: true échoue avec une erreur explicite (« SQLCipher native library not found ») au lieu de planter.

SQLCipher inclut des fichiers natifs .so pour chaque ABI. L'APK grossira de quelques Mo. Les App Bundles ne livrent que l'ABI dont chaque appareil a besoin.

iOS

Basculez le plugin sur sa subspec SQLCipher dans ios/App/Podfile, après bunx cap sync a écrit l’entrée par défaut :

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

Ensuite :

cd ios/App && pod install

bunx cap sync régénère la section Capacitor du Podfile, vérifiez donc cette ligne après chaque sync. Sans la subspec, le plugin est compilé contre le SQLite système, et l’ouverture avec encrypted: true renvoie Encryption is not available in this build.

Le Web

Il n’y a pas de SQLCipher sur le web. L’option Fast SQL’s encrypted est réservée à iOS et Android. Si votre app est aussi publiée en PWA, ajoutez une condition sur Capacitor.isNativePlatform() et ne persistez pas les tables sensibles dans le build navigateur.

Étape 2 : générer une clé

Utilisez Web Crypto API, disponible dans les WebViews iOS et Android :

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

Cela produit une chaîne hexadécimale de 64 caractères. SQLCipher traite cette chaîne comme une phrase secrète et dérive la clé réelle avec PBKDF2-HMAC-SHA512 (256 000 itérations par défaut dans SQLCipher 4). Cette dérivation s’exécute une fois par ouverture et prend une fraction de seconde perceptible sur les téléphones anciens, ce qui est normal.

N’utilisez pas directement le mot de passe utilisateur comme clé, sauf si vous voulez que la base soit verrouillée sur ce mot de passe. Les utilisateurs oublient leurs mots de passe, les changent sur un autre appareil et en choisissent de faibles. Une clé d’appareil aléatoire associée à un stockage sécurisé est le design habituel. Si vous avez besoin d’un accès protégé par l’utilisateur, protégez plutôt l’accès à la clé (avec la biométrie).

Étape 3 : stocker la clé dans le Keychain ou le Keystore

@capgo/capacitor-native-biometric stocke des chaînes arbitraires chiffrées au repos avec le Keychain iOS et le Keystore Android. setData et getData (plugin 8.6.0 and later) do not prompt the user. Add accessControl pour exiger Face ID, Touch ID ou une empreinte avant que la clé ne puisse être lue.

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

Optionnel : exiger la biométrie pour déverrouiller la base de données

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 lorsqu’une nouvelle empreinte ou un nouveau visage est enregistré. BIOMETRY_CURRENT_SET invalide la clé lorsque l’enregistrement change, ce qui est plus strict mais rend la base de données locale illisible si l’utilisateur ajoute une empreinte. Ne l’utilisez que si les données peuvent être resynchronisées depuis votre serveur. En savoir plus sur les prompts et les replis dans l’authentification biométrique dans les apps Capacitor.

Où ne pas stocker la clé

  • Dans votre JavaScript, .env fichiers, ou capacitor.config.ts. Ils sont tous inclus dans le bundle de l'app et n'importe qui peut dézipper un IPA ou un APK.
  • Dans @capacitor/preferences ou localStorage. Ils sont en texte clair.
  • Récupérées depuis votre API à chaque lancement sans copie locale. L'app ne s'ouvrirait pas hors ligne, ce qui va à l'encontre de l'intérêt d'une base de données locale. La récupérer une fois après connexion et la stocker dans le Keychain ou le Keystore suffit.

Étape 4 : ouvrir la base de données chiffrée

// 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 compte. SQLCipher ne valide pas la clé à l'ouverture. La première lecture réelle échoue avec file is not a database si la clé est incorrecte, alors faites une lecture peu coûteuse immédiatement et gérez l'erreur à un seul endroit plutôt qu'au milieu d'un écran.

Pour vérifier que le chiffrement est actif, exécutez PRAGMA cipher_version sur la connexion. Il renvoie la version de SQLCipher, et un résultat vide pour SQLite standard.

Étape 5 : gérer une clé perdue ou erronée

Une clé peut se perdre en usage normal :

  • L’utilisateur restaure une sauvegarde sur un nouveau téléphone. Le fichier de base de données revient, mais les éléments Keychain créés en local et les clés Android Keystore ne migrent pas vers le nouvel appareil.
  • Android Auto Backup restaure les fichiers de l’application, mais la clé Keystore utilisée pour chiffrer le secret stocké n’a pas été restaurée.
  • L’utilisateur modifie son inscription biométrique et vous avez utilisé BIOMETRY_CURRENT_SET.

La récupération est toujours la même : les données sont perdues localement, donc supprimez le fichier et recommencez, puis resynchronisez depuis votre backend.

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

Appeler quand verifyKey échoue avec file is not a database, réinitialiser le dbPromise, puis rouvrir. Une nouvelle clé est générée et une base de données vide est créée.

Deux paramètres liés à vérifier :

  • Sur Android, envisagez d'exclure la base de données de l'Auto Backup dans data_extraction_rules.xml si elle ne peut pas être déchiffrée après la restauration. Cela évite à vos utilisateurs une réinitialisation confuse.
  • Sur iOS, les fichiers dans Documents vont dans les sauvegardes iCloud. Chiffrés, donc lisibles uniquement avec la clé, mais les exclure permet de garder des sauvegardes légères si les données peuvent être resynchronisées.

Chiffrer une base de données existante en clair

Si votre application a déjà été publiée avec une base de données non chiffrée, vous ne pouvez pas basculer encrypted: true sur le même fichier. SQLCipher tenterait de décrypter des pages en clair et échouerait. Copiez les données dans une nouvelle base de données chiffrée une seule fois :

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

Exécutez-l’une seule fois, protégé par un flag enregistré après succès, et affichez un état de progression car cela lit chaque ligne. Notes :

  • Les tables sont d'abord créées et remplies, puis les index, triggers et vues. Créer les index une seule fois à la fin est plus rapide, et les triggers ne se déclenchent pas pendant la copie des lignes existantes.
  • sqlite_sequence est ignoré. SQLite le met à jour automatiquement lors de l'insertion de lignes avec des ids explicites, donc AUTOINCREMENT reprend à partir de l'id le plus élevé.
  • Les tables virtuelles (FTS) créent des tables shadow internes. Recréez les tables FTS à partir de leurs CREATE VIRTUAL TABLE statement et reconstruisez l’index au lieu de copier les tables d’ombre.
  • Pour les très grandes tables, parcourez par pages avec WHERE rowid > ? ORDER BY rowid LIMIT 5000 au lieu d’un seul SELECT *.
  • Supprimer le fichier en clair est l’objectif de l’exercice. Le stockage flash supprimé peut encore contenir d’anciens blocs, mais le fichier n’est plus accessible via le système de fichiers.

Rotation de la clé

SQLCipher ré-encrypte toute la base de données avec PRAGMA rekey. Avec Fast SQL, vous l’exécutez en tant que statement sur une connexion chiffrée ouverte :

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

L'ordre compte. Si l'app est tuée entre les deux lignes, la base de données utilise la nouvelle clé tandis que le Keychain conserve encore l'ancienne. Pour éviter cela, écrivez d'abord la nouvelle clé sous un second nom, effectuez le rekey, puis promouvez-l’et supprimez l'ancienne entrée. Au démarrage, essayez la clé en attente si la principale échoue. PRAGMA n'accepte pas les paramètres liés, c'est pourquoi la clé est insérée en ligne. N'insérez en ligne que des valeurs que vous avez générées, jamais d'entrée utilisateur. Testez la rotation sur les deux plateformes avec une copie de données de taille production, car rekey réécrit chaque page.

Chiffrement avec d'autres plugins

Plugin Comment fonctionne le chiffrement Gestion des clés
@capgo/capacitor-fast-sql SQLCipher en option sur iOS et Android Vous passez la clé sur connect()
@capacitor-community/sqlite SQLCipher toujours inclus, activé dans capacitor.config Le plugin stocke un mot de passe par application avec setEncryptionSecret(), modes secret, encryption (chiffrer un fichier existant), newsecret
@capgo/capacitor-data-storage-sqlite SQLCipher pour son stockage clé-valeur Phrase de passe définie dans capacitor.config, elle est donc embarquée dans l’app

du plugin communautaire encryption mode peut chiffrer une base de données existante sur place, ce qui est pratique si vous l’utilisez déjà. Avec une phrase de passe dans capacitor.config (data-storage-sqlite), considérez le chiffrement comme de l’obfuscation : le secret est dans le bundle. Pour les valeurs vraiment sensibles, préférez Fast SQL avec une clé issue du stockage sécurisé. Voir les options de stockage clé-valeur dans Capacitor pour ce compromis.

Notes de conformité

  • Conformité export de l’App Store. Intégrer SQLCipher signifie que votre app contient du chiffrement. App Store Connect vous pose la question à chaque build (ou vous définissez ITSAppUsesNonExemptEncryption dans Info.plist). Le chiffrement des données de l’utilisateur sur l’appareil relève souvent des exemptions, mais la réponse dépend de votre application et des pays où vous la distribuez. Consultez les recommandations d’Apple et demandez conseil en cas de doute. Notre vue d’ensemble du chiffrement d’applications couvre l’ensemble du sujet.
  • Licence SQLCipher. L'édition communautaire utilise une licence de type BSD qui exige d'inclure son avis de copyright dans votre application. Ajoutez-l’à votre écran de licences open source.
  • Sécurité des données Google Play. Vous pouvez déclarer que les données sont chiffrées au repos. Assurez-vous que c'est bien le cas pour chaque copie, y compris les caches et les logs.

Troubleshooting

file is not a database sur la première requête. Clé incorrecte, ou le fichier est en texte clair et vous l'avez ouvert avec encrypted: trueVérifiez que la clé dans le stockage sécurisé est bien celle utilisée lors de la création, puis utilisez la migration ci-dessus pour les fichiers en clair.

Encryption is not available in this build sur iOS. Le Podfile utilise toujours le subspec par défaut. Réajoutez la CapgoCapacitorFastSql/SQLCipher ligne après cap sync et lancez pod install.

SQLCipher native library not found sur Android. Le sqlcipher-android la dépendance est manquante du module de l’app, ou vous l’avez ajoutée au niveau du plugin ou de la racine build.gradle plutôt.

L’ouverture de la base de données est lente. La dérivation de clé s’exécute à chaque ouverture. Ouvrez une seule fois au démarrage et réutilisez la connexion plutôt que d’en créer une par requête.

La base de données s’ouvre sur un appareil, mais pas après une restauration. La clé n’a pas été incluse dans la sauvegarde. Gérez-la via le flux de réinitialisation et resynchronisez depuis le serveur.

Résumé

  1. Ajouter SQLCipher : sqlcipher-android dans le module d’application Android, le SQLCipher subspec sur iOS.
  2. Générer une clé aléatoire de 256 bits au premier lancement.
  3. Stockez-l’avec @capgo/capacitor-native-biometric (setData, éventuellement avec accessControl).
  4. Ouvrez avec encrypted: true, puis lisez une fois pour vérifier la clé.
  5. Plan for key loss: reset and resync.
  6. Migrez une fois les bases en clair existantes et faites tourner les clés avec PRAGMA rekey si votre politique l'exige.

Native changes like adding SQLCipher need a new store build. Build Capgo peut générer des binaires signés pour iOS et Android sans Mac local, et le Docs Fast SQL liste toutes les options de connexion.

Live updates pour les apps Capacitor

Lorsqu'un bug de la couche web est en ligne, déployez le correctif via Capgo au lieu d'attendre des jours l'approbation des stores. Les utilisateurs reçoivent la mise à jour en arrière-plan, tandis que les changements natifs suivent le circuit de validation habituel.

Les derniers articles du blog

Capgo vous offre les meilleures informations pour créer une application mobile vraiment professionnelle.