Perché parlare di crittografia in un'app Flutter
Molte app mobili gestiscono dati che non dovrebbero mai finire in chiaro su disco: note private, documenti, chiavi API, cache di risposte contenenti dati personali, backup esportabili. Il flutter_secure_storage è perfetto per pochi byte (un refresh token, un PIN), perché si appoggia a Keychain e Keystore, ma non è pensato per cifrare un database da 40 MB o un file allegato.
In questi casi serve crittografia applicativa vera e propria: generare o derivare una chiave, cifrare i dati con un algoritmo autenticato e conservare la chiave nel posto giusto. In questa guida vediamo come farlo in Dart con package:cryptography, la libreria più completa e mantenuta dell'ecosistema, con accelerazione nativa opzionale.
Nota importante: la crittografia lato client non sostituisce HTTPS/TLS. Protegge i dati a riposo sul dispositivo e, nel caso dell'end-to-end encryption, li protegge anche dal server. Il canale di rete va comunque protetto separatamente.
Setup
dependencies:
cryptography: ^2.7.0
cryptography_flutter: ^2.3.2 # opzionale: implementazioni native
crypto: ^3.0.0 # hash semplici (SHA-256, HMAC)
flutter_secure_storage: ^9.2.2
Il pacchetto cryptography è scritto in Dart puro e funziona ovunque (mobile, desktop, web). cryptography_flutter sostituisce alcune implementazioni con quelle di sistema (Android javax.crypto, iOS CryptoKit), con guadagni di performance notevoli su file grandi:
import 'package:cryptography_flutter/cryptography_flutter.dart';
void main() {
// Registra le implementazioni native dove disponibili,
// con fallback automatico su Dart puro.
FlutterCryptography.enable();
runApp(const MyApp());
}
Hash: quando servono (e quando no)
Un hash non è crittografia: è una funzione a senso unico utile per verificare l'integrità di un file o costruire una cache key stabile.
import 'dart:convert';
import 'package:crypto/crypto.dart';
String sha256Hex(String input) =>
sha256.convert(utf8.encode(input)).toString();
String hmacSha256(String message, List<int> key) =>
Hmac(sha256, key).convert(utf8.encode(message)).toString();
Regole pratiche:
- MD5 e SHA-1: solo per checksum non di sicurezza (es. nome file in cache). Mai per firme o password.
- SHA-256 + HMAC: ottimo per firmare payload o verificare webhook.
- Password: mai SHA-256 secco. Serve una KDF lenta (vedi sotto).
- Per confrontare due hash segreti usate un confronto a tempo costante, non
==su stringhe.
Cifratura simmetrica con AES-GCM
AES-GCM è la scelta di default moderna: cifra e autentica, quindi un ciphertext manomesso fa fallire la decifratura invece di restituire spazzatura. Evitate AES-CBC senza MAC e soprattutto AES-ECB (che lascia visibili i pattern nei dati).
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
class CryptoService {
final AesGcm _algorithm = AesGcm.with256bits();
Future<SecretKey> generateKey() => _algorithm.newSecretKey();
/// Restituisce nonce + ciphertext + MAC in un unico blob.
Future<List<int>> encrypt(String plainText, SecretKey key) async {
final secretBox = await _algorithm.encrypt(
utf8.encode(plainText),
secretKey: key,
);
return secretBox.concatenation();
}
Future<String> decrypt(List<int> blob, SecretKey key) async {
final secretBox = SecretBox.fromConcatenation(
blob,
nonceLength: _algorithm.nonceLength, // 12 byte
macLength: 16,
);
final clear = await _algorithm.decrypt(secretBox, secretKey: key);
return utf8.decode(clear);
}
}
Punti chiave:
- Il nonce (IV) viene generato automaticamente a ogni chiamata ed è casuale. Non riutilizzatelo mai con la stessa chiave: con GCM il riuso del nonce compromette la sicurezza in modo catastrofico.
- Il nonce non è segreto: va salvato insieme al ciphertext, ed è esattamente quello che fa
concatenation(). - Se la decifratura fallisce viene lanciata una
SecretBoxAuthenticationError: gestitela esplicitamente, è il segnale che i dati sono stati alterati o che la chiave è sbagliata.
Dati associati (AAD)
GCM permette di autenticare metadati non cifrati, utile per legare un blob al suo contesto (per esempio l'id dell'utente o la versione dello schema):
final box = await _algorithm.encrypt(
bytes,
secretKey: key,
aad: utf8.encode('user:42|v:2'),
);
In decifratura dovete passare lo stesso aad, altrimenti la verifica fallisce. È un modo elegante per impedire che un record cifrato venga "spostato" su un altro utente.
Dove mettere la chiave
Generare una chiave è facile; conservarla è il punto difficile. Due strategie.
1. Chiave casuale nel secure storage
La chiave AES viene generata al primo avvio e salvata nel Keychain/Keystore. Il resto dei dati (file, database) è cifrato con quella chiave.
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class KeyStore {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _keyName = 'master_key_v1';
Future<SecretKey> loadOrCreate() async {
final stored = await _storage.read(key: _keyName);
if (stored != null) {
return SecretKey(base64Decode(stored));
}
final key = await AesGcm.with256bits().newSecretKey();
final bytes = await key.extractBytes();
await _storage.write(key: _keyName, value: base64Encode(bytes));
return key;
}
}
Attenzione all'opzione accessibility: con first_unlock la chiave è leggibile anche a schermo bloccato (serve se cifrate dati in background), mentre first_unlock_this_device evita che finisca nei backup iCloud. Scegliete in base al caso d'uso.
2. Chiave derivata da una password
Se volete che nemmeno chi ha accesso fisico al dispositivo possa leggere i dati senza la passphrase dell'utente, la chiave va derivata al momento dello sblocco e tenuta solo in memoria.
Future<SecretKey> deriveKey(String password, List<int> salt) async {
final pbkdf2 = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: 210000, // allineato alle raccomandazioni OWASP
bits: 256,
);
return pbkdf2.deriveKeyFromPassword(password: password, nonce: salt);
}
Se volete qualcosa di più resistente agli attacchi con GPU, package:cryptography offre anche Argon2id:
final argon2 = Argon2id(
memory: 19 * 1024, // 19 MB
parallelism: 1,
iterations: 2,
hashLength: 32,
);
final key = await argon2.deriveKeyFromPassword(
password: password,
nonce: salt, // 16 byte casuali, salvati in chiaro
);
Il salt deve essere unico per utente, generato con Random.secure() e salvato in chiaro accanto ai dati:
import 'dart:math';
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (_) => random.nextInt(256));
}
Non usate mai Random() senza secure per materiale crittografico: è un generatore pseudo-casuale prevedibile.
Non bloccate la UI
Le KDF sono volutamente lente (centinaia di millisecondi per design). Le implementazioni Dart pure sono CPU-bound e bloccano l'isolate principale anche se l'API è async. Spostate la derivazione in un isolate:
final keyBytes = await Isolate.run(() async {
final key = await deriveKey(password, salt);
return key.extractBytes();
});
final secretKey = SecretKey(keyBytes);
Isolate.run (Dart 2.19+) è la via più diretta; in alternativa compute da package:flutter/foundation.dart.
Cifrare file di grandi dimensioni
Caricare in memoria un video da 500 MB per cifrarlo è una ricetta per l'OOM. La strategia corretta è dividere il file in chunk (es. 1 MB) e cifrare ogni chunk come un SecretBox indipendente, includendo l'indice del chunk nell'AAD per impedire riordinamenti:
Future<void> encryptFile(File source, File dest, SecretKey key) async {
final algorithm = AesGcm.with256bits();
final sink = dest.openWrite();
var index = 0;
await for (final chunk in source.openRead().transform(_chunker(1 << 20))) {
final box = await algorithm.encrypt(
chunk,
secretKey: key,
aad: utf8.encode('chunk:$index'),
);
final payload = box.concatenation();
// prefisso con la lunghezza per rileggere i blocchi
sink.add((ByteData(4)..setUint32(0, payload.length)).buffer.asUint8List());
sink.add(payload);
index++;
}
await sink.close();
}
Su file grandi l'accelerazione nativa di cryptography_flutter fa la differenza: le implementazioni di sistema sono tipicamente di un ordine di grandezza più veloci di AES in Dart puro.
Crittografia asimmetrica: scambio chiavi e firme
Quando due dispositivi devono condividere dati senza che il server possa leggerli, serve uno scambio di chiavi. X25519 (ECDH su Curve25519) è lo standard de facto:
final x25519 = X25519();
final myKeyPair = await x25519.newKeyPair();
final myPublicKey = await myKeyPair.extractPublicKey(); // inviabile al server
// Ricevuta la chiave pubblica dell'altro peer:
final sharedSecret = await x25519.sharedSecretKey(
keyPair: myKeyPair,
remotePublicKey: peerPublicKey,
);
// Il segreto condiviso non va usato direttamente: passatelo per una KDF.
final sessionKey = await Hkdf(hmac: Hmac.sha256(), outputLength: 32)
.deriveKey(secretKey: sharedSecret, info: utf8.encode('chat-session-v1'));
Per verificare l'autenticità di un messaggio (e non solo la sua integrità) si usa una firma Ed25519:
final ed25519 = Ed25519();
final keyPair = await ed25519.newKeyPair();
final signature = await ed25519.sign(message, keyPair: keyPair);
final isValid = await ed25519.verify(message, signature: signature);
Errori comuni da evitare
- Chiavi hardcoded nel codice: una stringa in Dart finisce nel binario ed è estraibile in pochi minuti, anche con l'offuscamento. Se la chiave è nell'app, la sicurezza è solo apparente.
- AES-ECB o CBC senza autenticazione: niente integrità, vulnerabile a padding oracle. Usate GCM o ChaCha20-Poly1305 (
Chacha20.poly1305Aead()), ottimo fallback su dispositivi senza accelerazione AES hardware. - Nonce fisso o incrementale condiviso tra dispositivi: con GCM è l'errore più grave in assoluto.
- Nessuna versione nel formato: prefissate sempre il blob con un byte di versione, così potrete migrare algoritmo o parametri KDF senza rompere i dati esistenti.
- Chiavi nei log o in Crashlytics: scrivete un
toString()custom sui modelli che le contengono, o evitate di inserirle in oggetti loggabili. - Dati sensibili nei backup: su Android valutate
android:allowBackup="false"o escludete i file cifrati dalle regole di backup.
Quando avete finito di usare materiale segreto in memoria potete liberarlo esplicitamente:
final data = await secretKey.extract();
// ... uso ...
data.destroy(); // azzera i byte, l'oggetto non è più utilizzabile
Come scegliere
| Esigenza | Soluzione |
|---|---|
| Token, PIN, piccoli segreti | flutter_secure_storage |
| File o DB cifrati su disco | AES-GCM + chiave nel secure storage |
| Dati protetti da passphrase utente | Argon2id/PBKDF2 + AES-GCM, chiave solo in RAM |
| Integrità di un payload | HMAC-SHA256 |
| End-to-end tra dispositivi | X25519 + HKDF + AES-GCM, firme Ed25519 |
Conclusione
Il 90% dei casi reali si risolve con tre mattoncini: una chiave a 256 bit generata o derivata correttamente, AES-GCM per cifrare, e un posto sicuro dove conservare la chiave. Il resto è disciplina: nonce sempre nuovi, formato versionato, nessun segreto nel codice sorgente e KDF fuori dal main isolate.
La regola d'oro resta quella di sempre: non scrivete primitive crittografiche da zero. Usate package:cryptography, abilitate cryptography_flutter per le performance e concentrate l'attenzione sul key management, che è dove si rompono quasi tutti i sistemi reali.