Salta al contenuto principale

Come crittografare un database SQLite in Capacitor

Crittografa un database SQLite in Capacitor con SQLCipher: configurazione su iOS e Android, archiviazione sicura della chiave, migrazione dei dati esistenti e rotazione delle chiavi.

Logo Capgo per una guida sulla crittografia di un database SQLite in Capacitor con SQLCipher

Per crittografare un database SQLite in Capacitor, usa un plugin basato su SQLCipher, apri il database con una chiave di crittografia e conservala nel Keychain di iOS o nel Keystore di Android invece che nel tuo code. Questa guida lo fa con @capgo/capacitor-fast-sql su Capacitor 8: configurazione della build per entrambe le piattaforme, generazione e archiviazione della chiave, crittografia di un database che contiene già dati utente, rotazione delle chiavi e i casi di errore da gestire.

Cosa protegge SQLCipher e cosa no

SQLCipher è un'estensione open source di SQLite che cripta ogni pagina del file del database con AES-256. Senza la chiave, il file è solo byte casuali. Copre anche i file WAL e journal.

It protects against:

  • chi copia i file dell'app da un dispositivo jailbroken o rooted, da un backup non criptato o da un telefono smarrito con un passcode debole,
  • strumenti forensi che leggono i container delle app,
  • altre app che leggono il file tramite una directory condivisa mal configurata.

It does not protect against:

  • code in esecuzione nella tua app (una dipendenza malevola, un bug XSS nella WebView) che può chiamare il tuo data layer,
  • un attaccante che riesca a estrarre la chiave, motivo per cui la gestione della chiave è più importante del cifrario,
  • dati che tieni anche in chiaro altrove: log, localStorage, cache ed eventi di analytics.

iOS e Android criptano già lo storage delle app quando il dispositivo è bloccato. SQLCipher aggiunge uno strato che non dipende dal passcode del dispositivo e che sopravvive ai backup. Vale la pena per la salute, la finanza e tutto ciò che è regolamentato. Per una visione più ampia delle minacce allo storage, considera backup, log e cache in chiaro al di fuori di SQLite.

Prerequisiti

Fast SQL installato e configurato come in Come usare SQLite in un'app Capacitor, incluse le impostazioni di rete locale iOS e cleartext localhost Android. La crittografia è opt-in, quindi l'installazione di base non include SQLCipher.

Passo 1: aggiungi SQLCipher alle build native

Android

Aggiungi la dipendenza al app-level android/app/build.gradle:

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

Il README del plugin fissa la versione 4.13.0. Le release 4.x più recenti funzionano purché la major version rimanga 4. Senza questa dipendenza, encrypted: true fallisce con un errore chiaro (“SQLCipher native library not found”) invece di andare in crash.

SQLCipher include file nativi .so per ogni ABI. Aspettati che l'APK cresca di qualche megabyte. Gli App Bundle consegnano solo l'ABI necessaria a ciascun dispositivo.

iOS

Passa il plugin alla sua subspec SQLCipher in ios/App/Podfile, dopo che bunx cap sync ha scritto la voce predefinita:

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

Poi:

cd ios/App && pod install

bunx cap sync rigenera la sezione Capacitor del Podfile, quindi controlla questa riga dopo ogni sync. Senza la subspec il plugin viene compilato contro lo SQLite di sistema, e aprendo con encrypted: true restituisce Encryption is not available in this build.

Web

SQLCipher non esiste sul web. L'opzione Fast SQL di encrypted è disponibile solo su iOS e Android. Se la tua app viene distribuita anche come PWA, fai un branch su Capacitor.isNativePlatform() e non persistere tabelle sensibili nella build per il browser.

Passaggio 2: genera una chiave

Usa l'API Web Crypto API, disponibile nelle WebView di iOS e 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('');
}

Questo produce una stringa esadecimale di 64 caratteri. SQLCipher tratta la stringa come passphrase e deriva la chiave effettiva con PBKDF2-HMAC-SHA512 (256.000 iterazioni di default in SQLCipher 4). La derivazione avviene una volta per ogni apertura e costa una frazione di secondo percepibile sui telefoni più vecchi, il che è normale.

Non usare direttamente la password dell'utente come chiave, a meno che tu non voglia che il database sia bloccato da quella password. Gli utenti dimenticano le password, le cambiano su un altro dispositivo e ne scelgono di deboli. La soluzione tipica è una chiave casuale del dispositivo salvata in uno storage sicuro. Se serve un accesso protetto dall'utente, proteggi l'accesso alla chiave (con la biometria) invece.

Passo 3: salva la chiave nel Keychain o Keystore

@capgo/capacitor-native-biometric memorizza stringhe arbitrarie cifrate a riposo con iOS Keychain e Android Keystore. setData e getData (plugin 8.6.0 e successivi) non mostrano un prompt all'utente. Aggiungi accessControl per richiedere Face ID, Touch ID o impronta digitale prima di poter leggere la chiave.

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

Opzionale: richiedi l'autenticazione biometrica per sbloccare il database

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 sopravvive all'iscrizione di una nuova impronta o volto. BIOMETRY_CURRENT_SET invalida la chiave quando la registrazione cambia: è più restrittivo, ma significa che il database locale diventa illeggibile se l'utente aggiunge un'impronta. Sceglilo solo se i dati possono essere risincronizzati dal server. Maggiori info su prompt e fallback in l'autenticazione biometrica nelle app Capacitor.

Dove non inserire la chiave

  • Nel tuo JavaScript, .env file, o capacitor.config.ts. Tutti vengono inclusi nel bundle dell'app e chiunque può scompattare un IPA o un APK.
  • In @capacitor/preferences oppure localStorage. Sono in chiaro.
  • Recuperata dal tuo API a ogni avvio senza copie locali. L'app non si aprirebbe offline, vanificando lo scopo di un database locale. Recuperarla una volta dopo il login e salvarla nel Keychain o Keystore va bene.

Passaggio 4: apri il database crittografato

// 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 è fondamentale. SQLCipher non convalida la chiave al momento dell'apertura. La prima lettura reale fallisce con file is not a database se la chiave è errata, quindi esegui subito una lettura rapida e gestisci l'errore in un unico punto invece che a metà di una schermata.

Per verificare che la crittografia sia attiva, esegui PRAGMA cipher_version sulla connessione. Restituisce la versione di SQLCipher e un risultato vuoto con SQLite standard.

Passaggio 5: gestire una chiave persa o errata

Una chiave può andare persa durante l'uso normale:

  • L'utente ripristina un backup su un nuovo telefono. Il file del database torna, ma gli elementi Keychain creati come device-only e le chiavi Android Keystore non si spostano sul nuovo hardware.
  • Android Auto Backup ripristina i file dell'app, ma la chiave Keystore usata per cifrare il segreto salvato non è stata ripristinata.
  • L'utente modifica la registrazione biometrica e tu hai usato BIOMETRY_CURRENT_SET.

Il recupero è sempre lo stesso: i dati in locale non ci sono più, quindi elimina il file e ricomincia, poi risincronizza dal tuo 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();
}

Chiamalo quando verifyKey fallisce con file is not a database, resetta il dbPromise, quindi riapri. Viene generata una nuova chiave e viene creato un database vuoto.

Due impostazioni correlate da controllare:

  • Su Android, valuta di escludere il database dall'Auto Backup in data_extraction_rules.xml se non può comunque essere decriptato dopo il ripristino. Risparmi ai tuoi utenti un reset che crea confusione.
  • Su iOS, i file in Documents vanno nei backup iCloud. Sono criptati, quindi leggibili solo con la chiave, ma escluderli mantiene i backup piccoli se i dati possono essere risincronizzati.

Criptare un database esistente in plaintext

Se la tua app è già stata rilasciata con un database non criptato, non puoi passare encrypted: true sullo stesso file. SQLCipher proverebbe a decriptare pagine plaintext e fallirebbe. Copia i dati in un nuovo database criptato una sola volta:

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

Eseguilo una volta, protetto da un flag salvato dopo il successo, e mostra uno stato di avanzamento perché legge ogni riga. Note:

  • Prima vengono create e popolate le tabelle, poi gli indici, i trigger e le view. Creare gli indici una volta alla fine è più veloce e i trigger non si attivano durante la copia delle righe.
  • sqlite_sequence viene saltato. SQLite lo aggiorna automaticamente quando vengono inserite righe con ID espliciti, quindi AUTOINCREMENT continua dall'ID più alto.
  • Le tabelle virtuali (FTS) creano tabelle shadow interne. Ricrea le tabelle FTS dai loro CREATE VIRTUAL TABLE statement e ricostruisce l'indice invece di copiare le shadow tables.
  • Per tabelle molto grandi, usa la paginazione con WHERE rowid > ? ORDER BY rowid LIMIT 5000 invece di un singolo SELECT *.
  • L'obiettivo dell'esercizio è eliminare il file in chiaro. La memoria flash cancellata può ancora contenere vecchi blocchi, ma il file non è più raggiungibile tramite il file system.

Rotazione della chiave

SQLCipher ricripta l'intero database con PRAGMA rekey. Con Fast SQL la esegui come statement su una connessione criptata aperta:

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'ordine è fondamentale. Se l'app viene terminata tra le due righe, il database userà la nuova chiave mentre il Keychain avrà ancora la vecchia. Per evitarlo, scrivi prima la nuova chiave con un secondo nome, esegui il rekey, poi promuovila e cancella la vecchia voce; all'avvio, prova la chiave in sospeso se la principale fallisce. PRAGMA non accetta parametri bind, motivo per cui la chiave è inline. Inserisci inline solo valori generati da te, mai input dell'utente. Testa la rotazione su entrambe le piattaforme con una copia dei dati di dimensioni pari a quelli di produzione, poiché rekey riscrive ogni pagina.

Crittografia con altri plugin

Plugin Come funziona la crittografia Gestione delle chiavi
@capgo/capacitor-fast-sql SQLCipher opt-in su iOS e Android Passi la chiave in connect()
@capacitor-community/sqlite SQLCipher sempre incluso, abilitato in capacitor.config Il plugin memorizza una passphrase per app con setEncryptionSecret(), modalità secret, encryption (cifrare un file esistente), newsecret
@capgo/capacitor-data-storage-sqlite SQLCipher per il suo key-value store Passphrase impostata in capacitor.config, quindi viene inclusa nell'app

Il plugin della community encryption la modalità può cifrare un database esistente in loco, il che è comodo se lo stai già usando. Con una passphrase in capacitor.config (data-storage-sqlite), considera la cifratura come obfuscation: il segreto è nel bundle. Per valori davvero sensibili, preferisci Fast SQL con una chiave da secure storage. Vedi le opzioni di key-value storage in Capacitor per quel trade-off.

Note sulla compliance

  • Conformità all'export per l'App Store. Includere SQLCipher significa che la tua app contiene cifratura. App Store Connect lo chiede per ogni build (oppure lo imposti tu) ITSAppUsesNonExemptEncryption nella Info.plist). La crittografia dei dati dell'utente stesso sul dispositivo rientra comunemente nelle esenzioni, ma la risposta dipende dalla tua app e dai paesi in cui la distribuisci. Consulta le linee guida di Apple e chiedi un parere se non sei sicuro. Il nostro panoramica sulla crittografia delle app copre il quadro generale.
  • Licenza SQLCipher. L'edizione community usa una licenza in stile BSD che richiede l'avviso di copyright nella tua app. Aggiungilo alla schermata delle licenze open source.
  • Sicurezza dei dati di Google Play. Puoi dichiarare i dati come crittografati a riposo. Assicurati che sia vero per ogni copia, incluse le cache e i log.

Risoluzione problemi

file is not a database alla prima query. Chiave errata, oppure il file è in chiaro e lo hai aperto con encrypted: true. Verifica che la chiave nello storage sicuro sia quella usata alla creazione, poi usa la migrazione sopra per i file in chiaro.

Encryption is not available in this build su iOS. Il Podfile usa ancora il subspec predefinito. Aggiungi di nuovo la CapgoCapacitorFastSql/SQLCipher riga dopo cap sync e esegui pod install.

SQLCipher native library not found su Android. Il sqlcipher-android la dipendenza manca dal modulo dell'app, oppure l'hai aggiunta a quello del plugin o alla root build.gradle invece.

L'apertura del database è lenta. La derivazione della chiave avviene a ogni apertura. Apri una volta all'avvio e riusa la connessione invece di connetterti per ogni query.

Il database si apre su un dispositivo ma non dopo il ripristino. La chiave non è stata inclusa nel backup. Gestiscila con il flusso di reset e sincronizza nuovamente dal server.

Riassunto

  1. Aggiungi SQLCipher: sqlcipher-android nel modulo dell'app Android, il SQLCipher subspec su iOS.
  2. Genera una chiave casuale a 256 bit al primo avvio.
  3. Salvalo con @capgo/capacitor-native-biometric (setData, opzionalmente con accessControl).
  4. Apri con encrypted: true, quindi leggi una volta per verificare la chiave.
  5. Prevedi la perdita della chiave: reset e resync.
  6. Migra i database in chiaro esistenti una sola volta e ruota le chiavi con PRAGMA rekey se la tua policy lo richiede.

Le modifiche native come l'aggiunta di SQLCipher richiedono una nuova build dello store. Build Capgo può generare binari iOS e Android firmati senza un Mac locale, e il Docs Fast SQL elenca ogni opzione di connessione.

Live updates per le app Capacitor

Quando un bug nel web-layer è live, rilascia la fix tramite Capgo invece di aspettare giorni l'approvazione degli store. Gli utenti ricevono l'update in background, mentre le modifiche native restano nel normale processo di review.

Ultimi articoli dal blog

Capgo ti offre gli insight migliori che ti servono per creare un'app mobile davvero professionale.