← Flutter / Dart

Flutter / Dart · 17 / 18

Restore encrypted backups safely

Derive a key, verify authenticity and validate all data before import.

  • Large
  • Advanced
  • 120 min

Prerequisites: A small vehicle log from UI to database

A backup should be encrypted and checked for modification. AES-GCM provides both through an established library. PBKDF2 derives a key from a password. Random salt and nonce ensure repeated backups do not produce identical output. Our independent learning format stores only names. It is explicitly incompatible with published app backups. The real Vehicle File also backs up attachments, metadata and its own versioned structure.

Example files

01Set up a separate console app

Run dart create -t console backup_learning and add dart pub add cryptography:2.9.0. Save backup.dart in bin. Use only fictional names and a learning password. The code overwrites no file and imports nothing into a real app.

bin/backup.dart · dartOpen file
import 'dart:convert';
import 'dart:math';
import 'package:cryptography/cryptography.dart';

final aad = utf8.encode('AFLEARN1');
final algorithm = AesGcm.with256bits();
List<int> randomBytes(int length) {
  final random = Random.secure();
  return List.generate(length, (_) => random.nextInt(256));
}

Future<SecretKey> keyFor(String password, List<int> salt) => Pbkdf2(
  macAlgorithm: Hmac.sha256(),
  iterations: 600000,
  bits: 256,
).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt);
Future<String> encrypt(String password, List<String> names) async {
  final salt = randomBytes(16);
  final nonce = randomBytes(12);
  final box = await algorithm.encrypt(
    utf8.encode(jsonEncode(names)),
    secretKey: await keyFor(password, salt),
    nonce: nonce,
    aad: aad,
  );
  return jsonEncode({
    'format': 'vehicle_learning',
    'version': 1,
    'salt': base64Encode(salt),
    'nonce': base64Encode(nonce),
    'cipher': base64Encode(box.cipherText),
    'mac': base64Encode(box.mac.bytes),
  });
}

Future<List<String>> decrypt(String password, String input) async {
  if (utf8.encode(input).length > 1024 * 1024)
    throw FormatException('Backup too large');
  final value = jsonDecode(input);
  if (value is! Map<String, dynamic> ||
      value['format'] != 'vehicle_learning' ||
      value['version'] != 1) {
    throw FormatException('Unknown backup');
  }
  List<int> bytes(String key) {
    final text = value[key];
    if (text is! String) throw FormatException('Missing field');
    return base64Decode(text);
  }

  final salt = bytes('salt');
  final nonce = bytes('nonce');
  final mac = bytes('mac');
  if (salt.length != 16 || nonce.length != 12 || mac.length != 16)
    throw FormatException('Invalid header');
  final plaintext = await algorithm.decrypt(
    SecretBox(bytes('cipher'), nonce: nonce, mac: Mac(mac)),
    secretKey: await keyFor(password, salt),
    aad: aad,
  );
  final names = jsonDecode(utf8.decode(plaintext));
  if (names is! List ||
      names.any((name) => name is! String || name.trim().isEmpty)) {
    throw FormatException('Invalid records');
  }
  return names.cast<String>();
}

Future<void> main() async {
  final backup = await encrypt('learning-password', ['Family car']);
  print(await decrypt('learning-password', backup));
  try {
    await decrypt('wrong-password', backup);
    throw StateError('Accepted wrong password');
  } on SecretBoxAuthenticationError {
    print('Wrong password rejected');
  }
}

Run / check

dart run bin/backup.dart

02Separate keys and randomness

Salt is not a password and is stored. The key is not stored. PBKDF2 uses 600000 iterations like the referenced app strategy. A fresh 12-byte nonce is generated for every new AES-GCM encryption. Reusing a nonce with the same key would be an invalid construction.

03Describe a versioned learning format

format and version determine the permitted structure. Fixed additional data aad binds encryption to this learning format. Base64 represents bytes as JSON text. Base64 is encoding rather than encryption.

04Check bounds before decryption

Input is limited to 1 MiB. Format, version and byte lengths are checked before expensive derivation. A real importer also needs exact manifest, file and total limits before allocating uncontrolled memory.

05Authenticate before importing

decrypt checks the GCM tag. A wrong password or changed ciphertext causes failure. Only then do we validate every name. The app also checks references, checksums and documents. Existing database data is updated in a controlled transaction only after preparation fully succeeds.

Expected result

The correct password produces [Family car]. The wrong password is rejected.

06Distinguish migration from merging

A format migration translates an old backup structure into the current shape. A merge then decides which IDs and timestamps to import. Encryption does not resolve that domain conflict. Compare the real EncryptedBackupService and tests before changing an existing format.

Data flow
  1. Size / format check
  2. Key derivation
  3. GCM authentication
  4. Complete validation
  5. Transaction / import

Your exercise

Create two backups of the same names and verify both are readable but different. Then change one ciphertext byte and verify rejection.

Show the solution and explanation

Different salt and nonce values make the JSON outputs differ. For tampering, decode cipher, change one byte and encode it again. decrypt must fail authentication. A real restore test also verifies that existing data remains unchanged after that failure.

Project context and sources

The example is simplified for learning. The linked source version shows its use in the project and the checks applied there.