feat(backup): sealed backups + printable recovery sheet
'Save a backup' now writes a sealed .tanemaki file (AES-256-GCM under a key HKDF-derived from the root seed; format TANEBK v1, open and stable). Restoring on the same identity is silent; a copy sealed by another identity asks for the printed recovery code (TANE1 base32, typo-tolerant) and adopts that seed — recovering your bank recovers you. Legacy plain exports still restore. Settings gains a recovery sheet (QR + code PDF). BackupBox/RecoveryCode live in commons_core, pure Dart, TDD.
This commit is contained in:
parent
ba87bf2719
commit
d6781870d9
19 changed files with 1016 additions and 81 deletions
|
|
@ -22,6 +22,7 @@ import '../services/ocr/label_text_extractor.dart';
|
||||||
import '../services/ocr/ocr_language.dart';
|
import '../services/ocr/ocr_language.dart';
|
||||||
import '../services/ocr/tesseract_label_extractor.dart';
|
import '../services/ocr/tesseract_label_extractor.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
|
import '../services/recovery_sheet_service.dart';
|
||||||
import '../services/share_catalog_service.dart';
|
import '../services/share_catalog_service.dart';
|
||||||
|
|
||||||
/// The app's service locator. Kept to the composition root — widgets get their
|
/// The app's service locator. Kept to the composition root — widgets get their
|
||||||
|
|
@ -75,10 +76,17 @@ Future<void> configureDependencies() async {
|
||||||
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
..registerSingleton<LabelTextExtractor>(labelExtractor)
|
||||||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||||
..registerSingleton<ExportImportService>(
|
..registerSingleton<ExportImportService>(
|
||||||
ExportImportService(repository: varietyRepository, files: fileService),
|
ExportImportService(
|
||||||
|
repository: varietyRepository,
|
||||||
|
files: fileService,
|
||||||
|
keys: keyStore,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
..registerSingleton<ShareCatalogService>(
|
..registerSingleton<ShareCatalogService>(
|
||||||
ShareCatalogService(files: fileService),
|
ShareCatalogService(files: fileService),
|
||||||
|
)
|
||||||
|
..registerSingleton<RecoverySheetService>(
|
||||||
|
RecoverySheetService(files: fileService),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,16 @@
|
||||||
"importDone": "Imported: {added} new, {updated} updated",
|
"importDone": "Imported: {added} new, {updated} updated",
|
||||||
"importCsvDone": "Added {count} entries",
|
"importCsvDone": "Added {count} entries",
|
||||||
"importFailed": "This file could not be read as a Tanemaki copy",
|
"importFailed": "This file could not be read as a Tanemaki copy",
|
||||||
"failed": "Something went wrong"
|
"failed": "Something went wrong",
|
||||||
|
"recoveryTitle": "Your recovery code",
|
||||||
|
"recoverySubtitle": "Print it and keep it safe — it opens your copies on a new device",
|
||||||
|
"recoveryIntro": "This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.",
|
||||||
|
"recoveryCopy": "Copy",
|
||||||
|
"recoverySave": "Save the sheet",
|
||||||
|
"recoverySheetTitle": "Tanemaki — your recovery sheet",
|
||||||
|
"recoveryPromptTitle": "Enter your recovery code",
|
||||||
|
"recoveryPromptBody": "This copy was saved with another code. Type the code from your recovery sheet to open it.",
|
||||||
|
"recoveryWrongCode": "That code doesn't open this copy"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,16 @@
|
||||||
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
|
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
|
||||||
"importCsvDone": "Añadidas {count} entradas",
|
"importCsvDone": "Añadidas {count} entradas",
|
||||||
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
|
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
|
||||||
"failed": "Algo ha salido mal"
|
"failed": "Algo ha salido mal",
|
||||||
|
"recoveryTitle": "Tu código de recuperación",
|
||||||
|
"recoverySubtitle": "Imprímelo y guárdalo bien: abre tus copias en otro dispositivo",
|
||||||
|
"recoveryIntro": "Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.",
|
||||||
|
"recoveryCopy": "Copiar",
|
||||||
|
"recoverySave": "Guardar la hoja",
|
||||||
|
"recoverySheetTitle": "Tanemaki — tu hoja de recuperación",
|
||||||
|
"recoveryPromptTitle": "Escribe tu código de recuperación",
|
||||||
|
"recoveryPromptBody": "Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.",
|
||||||
|
"recoveryWrongCode": "Ese código no abre esta copia"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Acerca de",
|
"title": "Acerca de",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// Locales: 2
|
||||||
/// Strings: 554 (277 per locale)
|
/// Strings: 572 (286 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-09 at 20:14 UTC
|
/// Built on 2026-07-09 at 20:28 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,33 @@ class Translations$backup$en {
|
||||||
|
|
||||||
/// en: 'Something went wrong'
|
/// en: 'Something went wrong'
|
||||||
String get failed => 'Something went wrong';
|
String get failed => 'Something went wrong';
|
||||||
|
|
||||||
|
/// en: 'Your recovery code'
|
||||||
|
String get recoveryTitle => 'Your recovery code';
|
||||||
|
|
||||||
|
/// en: 'Print it and keep it safe — it opens your copies on a new device'
|
||||||
|
String get recoverySubtitle => 'Print it and keep it safe — it opens your copies on a new device';
|
||||||
|
|
||||||
|
/// en: 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.'
|
||||||
|
String get recoveryIntro => 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.';
|
||||||
|
|
||||||
|
/// en: 'Copy'
|
||||||
|
String get recoveryCopy => 'Copy';
|
||||||
|
|
||||||
|
/// en: 'Save the sheet'
|
||||||
|
String get recoverySave => 'Save the sheet';
|
||||||
|
|
||||||
|
/// en: 'Tanemaki — your recovery sheet'
|
||||||
|
String get recoverySheetTitle => 'Tanemaki — your recovery sheet';
|
||||||
|
|
||||||
|
/// en: 'Enter your recovery code'
|
||||||
|
String get recoveryPromptTitle => 'Enter your recovery code';
|
||||||
|
|
||||||
|
/// en: 'This copy was saved with another code. Type the code from your recovery sheet to open it.'
|
||||||
|
String get recoveryPromptBody => 'This copy was saved with another code. Type the code from your recovery sheet to open it.';
|
||||||
|
|
||||||
|
/// en: 'That code doesn't open this copy'
|
||||||
|
String get recoveryWrongCode => 'That code doesn\'t open this copy';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: about
|
// Path: about
|
||||||
|
|
@ -1504,6 +1531,15 @@ extension on Translations {
|
||||||
'backup.importCsvDone' => ({required Object count}) => 'Added ${count} entries',
|
'backup.importCsvDone' => ({required Object count}) => 'Added ${count} entries',
|
||||||
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
|
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
|
||||||
'backup.failed' => 'Something went wrong',
|
'backup.failed' => 'Something went wrong',
|
||||||
|
'backup.recoveryTitle' => 'Your recovery code',
|
||||||
|
'backup.recoverySubtitle' => 'Print it and keep it safe — it opens your copies on a new device',
|
||||||
|
'backup.recoveryIntro' => 'This code opens your saved copies and brings your bank back on any device. Keep two paper copies in safe places, like your best seed. Anyone holding it can read your copies, so share it with no one.',
|
||||||
|
'backup.recoveryCopy' => 'Copy',
|
||||||
|
'backup.recoverySave' => 'Save the sheet',
|
||||||
|
'backup.recoverySheetTitle' => 'Tanemaki — your recovery sheet',
|
||||||
|
'backup.recoveryPromptTitle' => 'Enter your recovery code',
|
||||||
|
'backup.recoveryPromptBody' => 'This copy was saved with another code. Type the code from your recovery sheet to open it.',
|
||||||
|
'backup.recoveryWrongCode' => 'That code doesn\'t open this copy',
|
||||||
'about.title' => 'About',
|
'about.title' => 'About',
|
||||||
'about.kanji' => '種まき',
|
'about.kanji' => '種まき',
|
||||||
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',
|
'about.tagline' => 'A local-first, decentralized app for managing and sharing traditional seeds and seedlings.',
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,15 @@ class _Translations$backup$es extends Translations$backup$en {
|
||||||
@override String importCsvDone({required Object count}) => 'Añadidas ${count} entradas';
|
@override String importCsvDone({required Object count}) => 'Añadidas ${count} entradas';
|
||||||
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
|
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
|
||||||
@override String get failed => 'Algo ha salido mal';
|
@override String get failed => 'Algo ha salido mal';
|
||||||
|
@override String get recoveryTitle => 'Tu código de recuperación';
|
||||||
|
@override String get recoverySubtitle => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo';
|
||||||
|
@override String get recoveryIntro => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.';
|
||||||
|
@override String get recoveryCopy => 'Copiar';
|
||||||
|
@override String get recoverySave => 'Guardar la hoja';
|
||||||
|
@override String get recoverySheetTitle => 'Tanemaki — tu hoja de recuperación';
|
||||||
|
@override String get recoveryPromptTitle => 'Escribe tu código de recuperación';
|
||||||
|
@override String get recoveryPromptBody => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.';
|
||||||
|
@override String get recoveryWrongCode => 'Ese código no abre esta copia';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: about
|
// Path: about
|
||||||
|
|
@ -970,6 +979,15 @@ extension on TranslationsEs {
|
||||||
'backup.importCsvDone' => ({required Object count}) => 'Añadidas ${count} entradas',
|
'backup.importCsvDone' => ({required Object count}) => 'Añadidas ${count} entradas',
|
||||||
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
|
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
|
||||||
'backup.failed' => 'Algo ha salido mal',
|
'backup.failed' => 'Algo ha salido mal',
|
||||||
|
'backup.recoveryTitle' => 'Tu código de recuperación',
|
||||||
|
'backup.recoverySubtitle' => 'Imprímelo y guárdalo bien: abre tus copias en otro dispositivo',
|
||||||
|
'backup.recoveryIntro' => 'Este código abre tus copias guardadas y recupera tu banco en cualquier dispositivo. Guarda dos copias en papel en sitios seguros, como tu mejor semilla. Quien lo tenga puede leer tus copias, así que no lo compartas con nadie.',
|
||||||
|
'backup.recoveryCopy' => 'Copiar',
|
||||||
|
'backup.recoverySave' => 'Guardar la hoja',
|
||||||
|
'backup.recoverySheetTitle' => 'Tanemaki — tu hoja de recuperación',
|
||||||
|
'backup.recoveryPromptTitle' => 'Escribe tu código de recuperación',
|
||||||
|
'backup.recoveryPromptBody' => 'Esta copia se guardó con otro código. Escribe el código de tu hoja de recuperación para abrirla.',
|
||||||
|
'backup.recoveryWrongCode' => 'Ese código no abre esta copia',
|
||||||
'about.title' => 'Acerca de',
|
'about.title' => 'Acerca de',
|
||||||
'about.kanji' => '種まき',
|
'about.kanji' => '種まき',
|
||||||
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',
|
'about.tagline' => 'Una app local-first y descentralizada para gestionar y compartir semillas y plantones tradicionales.',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ class SecureKeyStore {
|
||||||
Future<String> rootSeedHex() =>
|
Future<String> rootSeedHex() =>
|
||||||
_readOrCreate(rootSeedName, _identity.generateRootSeed);
|
_readOrCreate(rootSeedName, _identity.generateRootSeed);
|
||||||
|
|
||||||
|
/// Replaces the root seed with one recovered from a printed code (restoring
|
||||||
|
/// your bank on a new device recovers your identity too). The DB key is
|
||||||
|
/// untouched — it never leaves this device's keystore.
|
||||||
|
Future<void> adoptRootSeed(String seedHex) =>
|
||||||
|
_store.write(rootSeedName, seedHex);
|
||||||
|
|
||||||
Future<String> _readOrCreate(
|
Future<String> _readOrCreate(
|
||||||
String key,
|
String key,
|
||||||
List<int> Function() generate,
|
List<int> Function() generate,
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,111 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
|
||||||
import '../data/export_import/inventory_csv_codec.dart';
|
import '../data/export_import/inventory_csv_codec.dart';
|
||||||
import '../data/export_import/inventory_json_codec.dart';
|
import '../data/export_import/inventory_json_codec.dart';
|
||||||
import '../data/export_import/inventory_snapshot.dart';
|
import '../data/export_import/inventory_snapshot.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
import '../security/secure_key_store.dart';
|
||||||
import 'file_service.dart';
|
import 'file_service.dart';
|
||||||
|
|
||||||
|
/// A picked backup file awaiting restore — kept in memory so asking the user
|
||||||
|
/// for a recovery code never forces a second file dialog.
|
||||||
|
class PendingBackup {
|
||||||
|
const PendingBackup(this.bytes);
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
|
||||||
|
/// Sealed (encrypted) copy vs. a legacy plain interchange export.
|
||||||
|
bool get isSealed => BackupBox.looksSealed(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
/// End-to-end export/import flows: repository snapshot ↔ codec ↔ the file the
|
/// End-to-end export/import flows: repository snapshot ↔ codec ↔ the file the
|
||||||
/// user picked. UI-free — screens call these and render the outcome.
|
/// user picked. UI-free — screens call these and render the outcome.
|
||||||
|
///
|
||||||
|
/// Backups are **sealed** (encrypted under a key derived from the root seed;
|
||||||
|
/// backup-and-recovery.md): a copy that lands on someone's cloud folder
|
||||||
|
/// reveals nothing. The printed recovery code opens them on any device.
|
||||||
class ExportImportService {
|
class ExportImportService {
|
||||||
ExportImportService({
|
ExportImportService({
|
||||||
required VarietyRepository repository,
|
required VarietyRepository repository,
|
||||||
required FileService files,
|
required FileService files,
|
||||||
|
required SecureKeyStore keys,
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
}) : _repository = repository,
|
}) : _repository = repository,
|
||||||
_files = files,
|
_files = files,
|
||||||
|
_keys = keys,
|
||||||
_now = now ?? DateTime.now;
|
_now = now ?? DateTime.now;
|
||||||
|
|
||||||
final VarietyRepository _repository;
|
final VarietyRepository _repository;
|
||||||
final FileService _files;
|
final FileService _files;
|
||||||
|
final SecureKeyStore _keys;
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
|
|
||||||
static const _jsonCodec = InventoryJsonCodec();
|
static const _jsonCodec = InventoryJsonCodec();
|
||||||
static const _csvCodec = InventoryCsvCodec();
|
static const _csvCodec = InventoryCsvCodec();
|
||||||
|
|
||||||
/// Exports the inventory as interchange JSON (data-model §7). Returns true
|
/// The backup file extension. The content is the sealed interchange JSON.
|
||||||
/// when saved, false when the user cancelled the save dialog.
|
static const backupExtension = 'tanemaki';
|
||||||
Future<bool> exportJson() async {
|
|
||||||
|
/// Exports the full inventory as a sealed backup file. Returns true when
|
||||||
|
/// saved, false when the user cancelled the save dialog.
|
||||||
|
Future<bool> exportBackup() async {
|
||||||
final snapshot = await _repository.exportInventory();
|
final snapshot = await _repository.exportInventory();
|
||||||
|
final plain = utf8.encode(_jsonCodec.encode(snapshot));
|
||||||
|
final sealed = await BackupBox().seal(
|
||||||
|
plain,
|
||||||
|
rootSeed: _hexToBytes(await _keys.rootSeedHex()),
|
||||||
|
);
|
||||||
final path = await _files.saveFile(
|
final path = await _files.saveFile(
|
||||||
suggestedName: _fileName('json'),
|
suggestedName: _fileName(backupExtension),
|
||||||
bytes: utf8.encode(_jsonCodec.encode(snapshot)),
|
bytes: sealed,
|
||||||
);
|
);
|
||||||
return path != null;
|
return path != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks the user to pick a backup file. Returns null when they cancelled.
|
||||||
|
Future<PendingBackup?> pickBackup() async {
|
||||||
|
final bytes = await _files.pickFileBytes(
|
||||||
|
allowedExtensions: [backupExtension, 'json'],
|
||||||
|
);
|
||||||
|
return bytes == null ? null : PendingBackup(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores [pending], merging by id (LWW). A sealed copy opens with this
|
||||||
|
/// device's seed, or with the [recoveryCode] from the printed sheet — which
|
||||||
|
/// is then adopted as the identity (recovering your bank recovers *you*).
|
||||||
|
/// Legacy plain exports import directly.
|
||||||
|
///
|
||||||
|
/// Throws [BackupAuthException] when the copy was sealed under a different
|
||||||
|
/// seed (ask for the code and retry), and [FormatException] when the file or
|
||||||
|
/// code is unreadable.
|
||||||
|
Future<ImportSummary> restoreBackup(
|
||||||
|
PendingBackup pending, {
|
||||||
|
String? recoveryCode,
|
||||||
|
}) async {
|
||||||
|
Uint8List plain;
|
||||||
|
if (pending.isSealed) {
|
||||||
|
final seedHex = recoveryCode == null
|
||||||
|
? await _keys.rootSeedHex()
|
||||||
|
: RecoveryCode.decode(recoveryCode);
|
||||||
|
plain = await BackupBox().open(
|
||||||
|
pending.bytes,
|
||||||
|
rootSeed: _hexToBytes(seedHex),
|
||||||
|
);
|
||||||
|
if (recoveryCode != null) await _keys.adoptRootSeed(seedHex);
|
||||||
|
} else {
|
||||||
|
plain = pending.bytes;
|
||||||
|
}
|
||||||
|
final snapshot = _jsonCodec.decode(utf8.decode(plain));
|
||||||
|
return _repository.importInventory(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The printable recovery code for this device's identity seed.
|
||||||
|
Future<String> recoveryCode() async =>
|
||||||
|
RecoveryCode.encode(await _keys.rootSeedHex());
|
||||||
|
|
||||||
/// Exports the inventory as flat CSV (export-only). Returns true when
|
/// Exports the inventory as flat CSV (export-only). Returns true when
|
||||||
/// saved, false when the user cancelled the save dialog.
|
/// saved, false when the user cancelled the save dialog.
|
||||||
Future<bool> exportCsv() async {
|
Future<bool> exportCsv() async {
|
||||||
|
|
@ -46,16 +117,6 @@ class ExportImportService {
|
||||||
return path != null;
|
return path != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Imports an interchange JSON file, merging by id (LWW). Returns the
|
|
||||||
/// summary, or null when the user cancelled the file dialog. Throws
|
|
||||||
/// [FormatException] when the file is not a readable inventory export.
|
|
||||||
Future<ImportSummary?> importJson() async {
|
|
||||||
final bytes = await _files.pickFileBytes(allowedExtensions: ['json']);
|
|
||||||
if (bytes == null) return null;
|
|
||||||
final snapshot = _jsonCodec.decode(utf8.decode(bytes));
|
|
||||||
return _repository.importInventory(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Imports a spreadsheet CSV additively (every row becomes a new variety/lot;
|
/// Imports a spreadsheet CSV additively (every row becomes a new variety/lot;
|
||||||
/// see [VarietyRepository.importCsv]). Returns the summary (`inserted` =
|
/// see [VarietyRepository.importCsv]). Returns the summary (`inserted` =
|
||||||
/// varieties added), or null when the user cancelled the file dialog.
|
/// varieties added), or null when the user cancelled the file dialog.
|
||||||
|
|
@ -68,6 +129,11 @@ class ExportImportService {
|
||||||
|
|
||||||
String _fileName(String extension) {
|
String _fileName(String extension) {
|
||||||
final date = _now().toIso8601String().substring(0, 10);
|
final date = _now().toIso8601String().substring(0, 10);
|
||||||
return 'tanemaki-inventory-$date.$extension';
|
return 'tanemaki-backup-$date.$extension';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static List<int> _hexToBytes(String hex) => [
|
||||||
|
for (var i = 0; i < hex.length; i += 2)
|
||||||
|
int.parse(hex.substring(i, i + 2), radix: 16),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
apps/app_seeds/lib/services/recovery_sheet_service.dart
Normal file
82
apps/app_seeds/lib/services/recovery_sheet_service.dart
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import 'file_service.dart';
|
||||||
|
|
||||||
|
/// Renders the printable recovery sheet — a QR plus the typed code and short
|
||||||
|
/// instructions ("keep two paper copies, like your best seed"). All strings
|
||||||
|
/// arrive already localized.
|
||||||
|
class RecoverySheetService {
|
||||||
|
RecoverySheetService({required FileService files}) : _files = files;
|
||||||
|
|
||||||
|
final FileService _files;
|
||||||
|
|
||||||
|
Future<Uint8List> buildPdf({
|
||||||
|
required String title,
|
||||||
|
required String intro,
|
||||||
|
required String code,
|
||||||
|
}) async {
|
||||||
|
final base = pw.Font.ttf(
|
||||||
|
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
|
||||||
|
);
|
||||||
|
final bold = pw.Font.ttf(
|
||||||
|
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
|
||||||
|
);
|
||||||
|
final doc = pw.Document(
|
||||||
|
theme: pw.ThemeData.withFont(base: base, bold: bold),
|
||||||
|
);
|
||||||
|
doc.addPage(
|
||||||
|
pw.Page(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
build: (context) => pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
|
||||||
|
pw.SizedBox(height: 12),
|
||||||
|
pw.Text(intro, style: const pw.TextStyle(fontSize: 11)),
|
||||||
|
pw.SizedBox(height: 24),
|
||||||
|
pw.Center(
|
||||||
|
child: pw.BarcodeWidget(
|
||||||
|
barcode: pw.Barcode.qrCode(),
|
||||||
|
data: code,
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
// The code is printed below with the Unicode-capable face;
|
||||||
|
// the widget's built-in caption would fall back to Courier.
|
||||||
|
drawText: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.SizedBox(height: 24),
|
||||||
|
pw.Center(
|
||||||
|
child: pw.Text(
|
||||||
|
code,
|
||||||
|
textAlign: pw.TextAlign.center,
|
||||||
|
style: pw.TextStyle(font: bold, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the sheet and asks the user where to keep it. Returns true when
|
||||||
|
/// saved, false when they cancelled.
|
||||||
|
Future<bool> saveSheet({
|
||||||
|
required String title,
|
||||||
|
required String intro,
|
||||||
|
required String code,
|
||||||
|
required String suggestedName,
|
||||||
|
}) async {
|
||||||
|
final bytes = await buildPdf(title: title, intro: intro, code: code);
|
||||||
|
final path = await _files.saveFile(
|
||||||
|
suggestedName: suggestedName,
|
||||||
|
bytes: bytes,
|
||||||
|
);
|
||||||
|
return path != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,29 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../data/export_import/inventory_snapshot.dart';
|
||||||
import '../di/injector.dart';
|
import '../di/injector.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/export_import_service.dart';
|
import '../services/export_import_service.dart';
|
||||||
|
import '../services/recovery_sheet_service.dart';
|
||||||
|
|
||||||
/// The backup actions shown in Settings. The primary pair — save/restore a
|
/// The backup actions shown in Settings. The primary pair — save/restore a
|
||||||
/// full copy — comes first; the spreadsheet list export/import is a secondary
|
/// full copy — comes first, then the recovery sheet, then the spreadsheet
|
||||||
/// convenience below. File formats (JSON/CSV) are an implementation detail and
|
/// list export/import as a secondary convenience. File formats and crypto are
|
||||||
/// never surface in the UI. Restore/import asks for confirmation first (it
|
/// implementation details and never surface in the UI. Restore/import asks
|
||||||
/// merges into the live inventory), then reports what happened in a SnackBar.
|
/// for confirmation first (it merges into the live inventory), then reports
|
||||||
|
/// what happened in a SnackBar.
|
||||||
class BackupSection extends StatelessWidget {
|
class BackupSection extends StatelessWidget {
|
||||||
const BackupSection({this.service, super.key});
|
const BackupSection({this.service, this.sheet, super.key});
|
||||||
|
|
||||||
/// Injectable for widget tests; the app resolves it lazily (on tap) from
|
/// Injectable for widget tests; the app resolves them lazily (on tap) from
|
||||||
/// the service locator so merely mounting Settings needs no DI setup.
|
/// the service locator so merely mounting Settings needs no DI setup.
|
||||||
final ExportImportService? service;
|
final ExportImportService? service;
|
||||||
|
final RecoverySheetService? sheet;
|
||||||
|
|
||||||
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
ExportImportService get _service => service ?? getIt<ExportImportService>();
|
||||||
|
RecoverySheetService get _sheet => sheet ?? getIt<RecoverySheetService>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -27,13 +34,20 @@ class BackupSection extends StatelessWidget {
|
||||||
leading: const Icon(Icons.save_alt_outlined),
|
leading: const Icon(Icons.save_alt_outlined),
|
||||||
title: Text(t.backup.exportJson),
|
title: Text(t.backup.exportJson),
|
||||||
subtitle: Text(t.backup.exportJsonSubtitle),
|
subtitle: Text(t.backup.exportJsonSubtitle),
|
||||||
onTap: () => _runExport(context, () => _service.exportJson()),
|
onTap: () => _runExport(context, () => _service.exportBackup()),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.settings_backup_restore_outlined),
|
leading: const Icon(Icons.settings_backup_restore_outlined),
|
||||||
title: Text(t.backup.importJson),
|
title: Text(t.backup.importJson),
|
||||||
subtitle: Text(t.backup.importJsonSubtitle),
|
subtitle: Text(t.backup.importJsonSubtitle),
|
||||||
onTap: () => _runImport(context),
|
onTap: () => _runRestore(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
key: const Key('backup.recovery'),
|
||||||
|
leading: const Icon(Icons.qr_code_2_outlined),
|
||||||
|
title: Text(t.backup.recoveryTitle),
|
||||||
|
subtitle: Text(t.backup.recoverySubtitle),
|
||||||
|
onTap: () => _showRecoverySheet(context),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
@ -66,65 +80,169 @@ class BackupSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runImport(BuildContext context) async {
|
/// Shows this device's recovery code and offers to save the printable
|
||||||
|
/// sheet (QR + code + instructions).
|
||||||
|
Future<void> _showRecoverySheet(BuildContext context) async {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final confirmed = await showDialog<bool>(
|
final code = await _service.recoveryCode();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final save = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: Text(t.backup.importConfirmTitle),
|
title: Text(t.backup.recoveryTitle),
|
||||||
content: Text(t.backup.importConfirmBody),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(t.backup.recoveryIntro),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SelectableText(
|
||||||
|
code,
|
||||||
|
key: const Key('backup.recoveryCode'),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
key: const Key('backup.recoveryCopy'),
|
||||||
child: Text(t.common.cancel),
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: code));
|
||||||
|
if (dialogContext.mounted) Navigator.pop(dialogContext, false);
|
||||||
|
},
|
||||||
|
child: Text(t.backup.recoveryCopy),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
key: const Key('backup.recoverySave'),
|
||||||
child: Text(t.backup.importAction),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: Text(t.backup.recoverySave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (confirmed != true) return;
|
if (save != true) return;
|
||||||
try {
|
try {
|
||||||
final summary = await _service.importJson();
|
final saved = await _sheet.saveSheet(
|
||||||
_show(
|
title: t.backup.recoverySheetTitle,
|
||||||
messenger,
|
intro: t.backup.recoveryIntro,
|
||||||
summary == null
|
code: code,
|
||||||
? t.backup.cancelled
|
suggestedName: 'tanemaki-recovery.pdf',
|
||||||
: t.backup.importDone(
|
|
||||||
added: summary.inserted,
|
|
||||||
updated: summary.updated,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} on FormatException {
|
_show(messenger, saved ? t.backup.exportSaved : t.backup.cancelled);
|
||||||
_show(messenger, t.backup.importFailed);
|
|
||||||
} on Object {
|
} on Object {
|
||||||
_show(messenger, t.backup.failed);
|
_show(messenger, t.backup.failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runImportCsv(BuildContext context) async {
|
Future<void> _runRestore(BuildContext context) async {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await _confirm(
|
||||||
|
context,
|
||||||
|
title: t.backup.importConfirmTitle,
|
||||||
|
body: t.backup.importConfirmBody,
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final PendingBackup? pending;
|
||||||
|
try {
|
||||||
|
pending = await _service.pickBackup();
|
||||||
|
} on Object {
|
||||||
|
_show(messenger, t.backup.failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending == null) {
|
||||||
|
_show(messenger, t.backup.cancelled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_report(messenger, t, await _service.restoreBackup(pending));
|
||||||
|
return;
|
||||||
|
} on BackupAuthException {
|
||||||
|
// Sealed under another identity: ask for the printed code below.
|
||||||
|
} on FormatException {
|
||||||
|
_show(messenger, t.backup.importFailed);
|
||||||
|
return;
|
||||||
|
} on Object {
|
||||||
|
_show(messenger, t.backup.failed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final code = await _askRecoveryCode(context);
|
||||||
|
if (code == null) {
|
||||||
|
_show(messenger, t.backup.cancelled);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_report(
|
||||||
|
messenger,
|
||||||
|
t,
|
||||||
|
await _service.restoreBackup(pending, recoveryCode: code),
|
||||||
|
);
|
||||||
|
} on BackupAuthException {
|
||||||
|
_show(messenger, t.backup.recoveryWrongCode);
|
||||||
|
} on FormatException {
|
||||||
|
_show(messenger, t.backup.recoveryWrongCode);
|
||||||
|
} on Object {
|
||||||
|
_show(messenger, t.backup.failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for the code printed on the recovery sheet.
|
||||||
|
Future<String?> _askRecoveryCode(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final controller = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: Text(t.backup.importCsvConfirmTitle),
|
title: Text(t.backup.recoveryPromptTitle),
|
||||||
content: Text(t.backup.importCsvConfirmBody),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(t.backup.recoveryPromptBody),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
key: const Key('backup.recoveryField'),
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.backup.recoveryTitle,
|
||||||
|
border: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: Text(t.common.cancel),
|
child: Text(t.common.cancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
key: const Key('backup.recoveryConfirm'),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(dialogContext, controller.text.trim()),
|
||||||
child: Text(t.backup.importAction),
|
child: Text(t.backup.importAction),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runImportCsv(BuildContext context) async {
|
||||||
|
final t = context.t;
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final confirmed = await _confirm(
|
||||||
|
context,
|
||||||
|
title: t.backup.importCsvConfirmTitle,
|
||||||
|
body: t.backup.importCsvConfirmBody,
|
||||||
|
);
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
try {
|
try {
|
||||||
final summary = await _service.importCsv();
|
final summary = await _service.importCsv();
|
||||||
|
|
@ -141,6 +259,42 @@ class BackupSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirm(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) {
|
||||||
|
final t = context.t;
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(body),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: Text(t.backup.importAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _report(
|
||||||
|
ScaffoldMessengerState messenger,
|
||||||
|
Translations t,
|
||||||
|
ImportSummary summary,
|
||||||
|
) {
|
||||||
|
_show(
|
||||||
|
messenger,
|
||||||
|
t.backup.importDone(added: summary.inserted, updated: summary.updated),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _show(ScaffoldMessengerState messenger, String message) {
|
void _show(ScaffoldMessengerState messenger, String message) {
|
||||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
158
apps/app_seeds/test/services/export_import_service_test.dart
Normal file
158
apps/app_seeds/test/services/export_import_service_test.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/security/secure_key_store.dart';
|
||||||
|
import 'package:tane/services/export_import_service.dart';
|
||||||
|
import 'package:tane/services/file_service.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
class _FakeFiles implements FileService {
|
||||||
|
Uint8List? saved;
|
||||||
|
String? savedName;
|
||||||
|
Uint8List? toPick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> saveFile({
|
||||||
|
required String suggestedName,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async {
|
||||||
|
saved = bytes;
|
||||||
|
savedName = suggestedName;
|
||||||
|
return '/picked/$suggestedName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||||
|
toPick;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase dbA;
|
||||||
|
late AppDatabase dbB;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
dbA = newTestDatabase();
|
||||||
|
dbB = newTestDatabase();
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await dbA.close();
|
||||||
|
await dbB.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
(ExportImportService, _FakeFiles, SecureKeyStore) newService(
|
||||||
|
AppDatabase db, {
|
||||||
|
String nodeId = 'node',
|
||||||
|
}) {
|
||||||
|
final files = _FakeFiles();
|
||||||
|
final keys = SecureKeyStore(store: InMemorySecretStore());
|
||||||
|
final service = ExportImportService(
|
||||||
|
repository: newTestRepository(db, nodeId: nodeId),
|
||||||
|
files: files,
|
||||||
|
keys: keys,
|
||||||
|
);
|
||||||
|
return (service, files, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a saved backup is sealed — no plaintext leaves the app', () async {
|
||||||
|
final (service, files, _) = newService(dbA);
|
||||||
|
final repo = newTestRepository(dbA);
|
||||||
|
await repo.addQuickVariety(label: 'Secret heirloom');
|
||||||
|
|
||||||
|
await service.exportBackup();
|
||||||
|
|
||||||
|
expect(files.savedName, endsWith('.tanemaki'));
|
||||||
|
expect(BackupBox.looksSealed(files.saved!), isTrue);
|
||||||
|
expect(String.fromCharCodes(files.saved!).contains('Secret'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore with the local seed needs no code (same identity)', () async {
|
||||||
|
final (serviceA, filesA, keysA) = newService(dbA);
|
||||||
|
final repoA = newTestRepository(dbA);
|
||||||
|
await repoA.addQuickVariety(label: 'Maize');
|
||||||
|
await serviceA.exportBackup();
|
||||||
|
|
||||||
|
// A fresh DB but the SAME keystore = your device after a reinstall.
|
||||||
|
final filesB = _FakeFiles()..toPick = filesA.saved;
|
||||||
|
final serviceB = ExportImportService(
|
||||||
|
repository: newTestRepository(dbB),
|
||||||
|
files: filesB,
|
||||||
|
keys: keysA,
|
||||||
|
);
|
||||||
|
final summary = await serviceB.restoreBackup(
|
||||||
|
(await serviceB.pickBackup())!,
|
||||||
|
);
|
||||||
|
expect(summary.inserted, 1);
|
||||||
|
final restored = await dbB.select(dbB.varieties).get();
|
||||||
|
expect(restored.single.label, 'Maize');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'restore on a new device needs the recovery code and adopts the seed',
|
||||||
|
() async {
|
||||||
|
final (serviceA, filesA, keysA) = newService(dbA, nodeId: 'device-a');
|
||||||
|
final repoA = newTestRepository(dbA, nodeId: 'device-a');
|
||||||
|
await repoA.addQuickVariety(label: 'Grandma tomato');
|
||||||
|
await serviceA.exportBackup();
|
||||||
|
final code = await serviceA.recoveryCode();
|
||||||
|
|
||||||
|
final (serviceB, filesB, keysB) = newService(dbB, nodeId: 'device-b');
|
||||||
|
// Force device B to have its own (different) identity first.
|
||||||
|
final seedB = await keysB.rootSeedHex();
|
||||||
|
expect(seedB, isNot(await keysA.rootSeedHex()));
|
||||||
|
|
||||||
|
filesB.toPick = filesA.saved;
|
||||||
|
final pending = (await serviceB.pickBackup())!;
|
||||||
|
|
||||||
|
// Without the code: authentication fails, nothing imported.
|
||||||
|
await expectLater(
|
||||||
|
serviceB.restoreBackup(pending),
|
||||||
|
throwsA(isA<BackupAuthException>()),
|
||||||
|
);
|
||||||
|
expect(await dbB.select(dbB.varieties).get(), isEmpty);
|
||||||
|
|
||||||
|
// With the printed code: data restored AND identity recovered.
|
||||||
|
final summary = await serviceB.restoreBackup(pending, recoveryCode: code);
|
||||||
|
expect(summary.inserted, 1);
|
||||||
|
final restored = await dbB.select(dbB.varieties).get();
|
||||||
|
expect(restored.single.label, 'Grandma tomato');
|
||||||
|
expect(await keysB.rootSeedHex(), await keysA.rootSeedHex());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a sloppy typed code (lowercase, spaces) still opens', () async {
|
||||||
|
final (serviceA, filesA, _) = newService(dbA);
|
||||||
|
final repoA = newTestRepository(dbA);
|
||||||
|
await repoA.addQuickVariety(label: 'Bean');
|
||||||
|
await serviceA.exportBackup();
|
||||||
|
final code = await serviceA.recoveryCode();
|
||||||
|
|
||||||
|
final (serviceB, filesB, _) = newService(dbB);
|
||||||
|
filesB.toPick = filesA.saved;
|
||||||
|
final pending = (await serviceB.pickBackup())!;
|
||||||
|
final summary = await serviceB.restoreBackup(
|
||||||
|
pending,
|
||||||
|
recoveryCode: code.toLowerCase().replaceAll('-', ' '),
|
||||||
|
);
|
||||||
|
expect(summary.inserted, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy plain exports still restore (no seal, direct parse)', () async {
|
||||||
|
final repoA = newTestRepository(dbA);
|
||||||
|
await repoA.addQuickVariety(label: 'Old export');
|
||||||
|
final legacy = utf8.encode(
|
||||||
|
const InventoryJsonCodec().encode(await repoA.exportInventory()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final (serviceB, filesB, _) = newService(dbB);
|
||||||
|
filesB.toPick = Uint8List.fromList(legacy);
|
||||||
|
final summary = await serviceB.restoreBackup(
|
||||||
|
(await serviceB.pickBackup())!,
|
||||||
|
);
|
||||||
|
expect(summary.inserted, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
import 'package:tane/data/export_import/inventory_json_codec.dart';
|
||||||
import 'package:tane/data/export_import/inventory_snapshot.dart';
|
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/security/secure_key_store.dart';
|
||||||
import 'package:tane/services/export_import_service.dart';
|
import 'package:tane/services/export_import_service.dart';
|
||||||
import 'package:tane/services/file_service.dart';
|
import 'package:tane/services/file_service.dart';
|
||||||
|
import 'package:tane/services/recovery_sheet_service.dart';
|
||||||
import 'package:tane/ui/backup_section.dart';
|
import 'package:tane/ui/backup_section.dart';
|
||||||
import 'package:tane/ui/settings_screen.dart';
|
import 'package:tane/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
|
@ -39,15 +41,20 @@ class FakeFileService implements FileService {
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
late FakeFileService files;
|
late FakeFileService files;
|
||||||
|
late SecureKeyStore keys;
|
||||||
late ExportImportService service;
|
late ExportImportService service;
|
||||||
|
late RecoverySheetService sheet;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
db = newTestDatabase();
|
db = newTestDatabase();
|
||||||
files = FakeFileService();
|
files = FakeFileService();
|
||||||
|
keys = SecureKeyStore(store: InMemorySecretStore());
|
||||||
service = ExportImportService(
|
service = ExportImportService(
|
||||||
repository: newTestRepository(db),
|
repository: newTestRepository(db),
|
||||||
files: files,
|
files: files,
|
||||||
|
keys: keys,
|
||||||
);
|
);
|
||||||
|
sheet = RecoverySheetService(files: files);
|
||||||
});
|
});
|
||||||
tearDown(() => db.close());
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
|
@ -66,6 +73,12 @@ void main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget section() => wrap(
|
||||||
|
Scaffold(
|
||||||
|
body: BackupSection(service: service, sheet: sheet),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('settings shows the backup section with its actions', (
|
testWidgets('settings shows the backup section with its actions', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -76,6 +89,7 @@ void main() {
|
||||||
expect(find.text('Backup & restore'), findsOneWidget);
|
expect(find.text('Backup & restore'), findsOneWidget);
|
||||||
expect(find.text('Save a backup'), findsOneWidget);
|
expect(find.text('Save a backup'), findsOneWidget);
|
||||||
expect(find.text('Restore a backup'), findsOneWidget);
|
expect(find.text('Restore a backup'), findsOneWidget);
|
||||||
|
expect(find.text('Your recovery code'), findsOneWidget);
|
||||||
expect(find.text('Export to a spreadsheet'), findsOneWidget);
|
expect(find.text('Export to a spreadsheet'), findsOneWidget);
|
||||||
expect(find.text('Import a list'), findsOneWidget);
|
expect(find.text('Import a list'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
@ -83,9 +97,7 @@ void main() {
|
||||||
testWidgets('spreadsheet export saves a .csv file and confirms', (
|
testWidgets('spreadsheet export saves a .csv file and confirms', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(section());
|
||||||
wrap(Scaffold(body: BackupSection(service: service))),
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Export to a spreadsheet'));
|
await tester.tap(find.text('Export to a spreadsheet'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
@ -94,35 +106,31 @@ void main() {
|
||||||
expect(find.text('Copy saved'), findsOneWidget);
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('saving a backup writes a versioned .json file', (tester) async {
|
testWidgets('saving a backup writes a sealed .tanemaki file', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(section());
|
||||||
wrap(Scaffold(body: BackupSection(service: service))),
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Save a backup'));
|
await tester.tap(find.text('Save a backup'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(files.savedName, endsWith('.json'));
|
expect(files.savedName, endsWith('.tanemaki'));
|
||||||
final root =
|
expect(BackupBox.looksSealed(files.savedBytes!), isTrue);
|
||||||
jsonDecode(utf8.decode(files.savedBytes!)) as Map<String, dynamic>;
|
|
||||||
expect(root['formatVersion'], inventoryFormatVersion);
|
|
||||||
expect(find.text('Copy saved'), findsOneWidget);
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('import asks for confirmation, imports and reports counts', (
|
testWidgets('restoring my own sealed copy needs no code', (tester) async {
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
// A valid one-variety export produced by another repository.
|
|
||||||
final otherDb = newTestDatabase();
|
final otherDb = newTestDatabase();
|
||||||
addTearDown(otherDb.close);
|
addTearDown(otherDb.close);
|
||||||
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
final otherFiles = FakeFileService();
|
||||||
|
final mine = ExportImportService(
|
||||||
|
repository: newTestRepository(otherDb, nodeId: 'other'),
|
||||||
|
files: otherFiles,
|
||||||
|
keys: keys, // same identity
|
||||||
|
);
|
||||||
|
final other = newTestRepository(otherDb, nodeId: 'other');
|
||||||
await other.addQuickVariety(label: 'Imported bean');
|
await other.addQuickVariety(label: 'Imported bean');
|
||||||
files.bytesToPick = utf8.encode(
|
await mine.exportBackup();
|
||||||
const InventoryJsonCodec().encode(await other.exportInventory()),
|
files.bytesToPick = otherFiles.savedBytes;
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(section());
|
||||||
wrap(Scaffold(body: BackupSection(service: service))),
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Restore a backup'));
|
await tester.tap(find.text('Restore a backup'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Restore a backup?'), findsOneWidget);
|
expect(find.text('Restore a backup?'), findsOneWidget);
|
||||||
|
|
@ -135,11 +143,64 @@ void main() {
|
||||||
expect(varieties.single.label, 'Imported bean');
|
expect(varieties.single.label, 'Imported bean');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a copy from another identity asks for the recovery code', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final otherDb = newTestDatabase();
|
||||||
|
addTearDown(otherDb.close);
|
||||||
|
final otherFiles = FakeFileService();
|
||||||
|
final otherKeys = SecureKeyStore(store: InMemorySecretStore());
|
||||||
|
final theirs = ExportImportService(
|
||||||
|
repository: newTestRepository(otherDb, nodeId: 'other'),
|
||||||
|
files: otherFiles,
|
||||||
|
keys: otherKeys,
|
||||||
|
);
|
||||||
|
final other = newTestRepository(otherDb, nodeId: 'other');
|
||||||
|
await other.addQuickVariety(label: 'Recovered bean');
|
||||||
|
await theirs.exportBackup();
|
||||||
|
final code = await theirs.recoveryCode();
|
||||||
|
files.bytesToPick = otherFiles.savedBytes;
|
||||||
|
|
||||||
|
await tester.pumpWidget(section());
|
||||||
|
await tester.tap(find.text('Restore a backup'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Wrong seed → the code prompt appears; type the printed code.
|
||||||
|
expect(find.text('Enter your recovery code'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byKey(const Key('backup.recoveryField')), code);
|
||||||
|
await tester.tap(find.byKey(const Key('backup.recoveryConfirm')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
|
||||||
|
final varieties = await db.select(db.varieties).get();
|
||||||
|
expect(varieties.single.label, 'Recovered bean');
|
||||||
|
// The identity travelled with the code.
|
||||||
|
expect(await keys.rootSeedHex(), await otherKeys.rootSeedHex());
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('legacy plain exports still restore', (tester) async {
|
||||||
|
final otherDb = newTestDatabase();
|
||||||
|
addTearDown(otherDb.close);
|
||||||
|
final other = newTestRepository(otherDb, nodeId: 'node-other');
|
||||||
|
await other.addQuickVariety(label: 'Old bean');
|
||||||
|
files.bytesToPick = utf8.encode(
|
||||||
|
const InventoryJsonCodec().encode(await other.exportInventory()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(section());
|
||||||
|
await tester.tap(find.text('Restore a backup'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Imported: 1 new, 0 updated'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('an unreadable file reports a friendly error', (tester) async {
|
testWidgets('an unreadable file reports a friendly error', (tester) async {
|
||||||
files.bytesToPick = utf8.encode('this is not json');
|
files.bytesToPick = utf8.encode('this is not json');
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(section());
|
||||||
wrap(Scaffold(body: BackupSection(service: service))),
|
|
||||||
);
|
|
||||||
await tester.tap(find.text('Restore a backup'));
|
await tester.tap(find.text('Restore a backup'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Import'));
|
await tester.tap(find.text('Import'));
|
||||||
|
|
@ -150,4 +211,25 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the recovery dialog shows the code and saves the sheet', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(section());
|
||||||
|
await tester.tap(find.byKey(const Key('backup.recovery')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final code = await service.recoveryCode();
|
||||||
|
expect(find.text(code), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('backup.recoverySave')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(files.savedName, 'tanemaki-recovery.pdf');
|
||||||
|
expect(String.fromCharCodes(files.savedBytes!.take(5)), '%PDF-');
|
||||||
|
// The seed itself never appears in the clear in the PDF stream metadata;
|
||||||
|
// the QR encodes the human code, which IS the secret — that's the point
|
||||||
|
// of the sheet. Just assert it saved and confirmed.
|
||||||
|
expect(find.text('Copy saved'), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/clock/hlc.dart';
|
export 'src/clock/hlc.dart';
|
||||||
|
export 'src/crypto/backup_box.dart';
|
||||||
export 'src/crypto/random_bytes.dart';
|
export 'src/crypto/random_bytes.dart';
|
||||||
|
export 'src/crypto/recovery_code.dart';
|
||||||
export 'src/identity/identity_service.dart';
|
export 'src/identity/identity_service.dart';
|
||||||
export 'src/ids/id_gen.dart';
|
export 'src/ids/id_gen.dart';
|
||||||
export 'src/value/quantity.dart';
|
export 'src/value/quantity.dart';
|
||||||
|
|
|
||||||
98
packages/commons_core/lib/src/crypto/backup_box.dart
Normal file
98
packages/commons_core/lib/src/crypto/backup_box.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
/// Opening a sealed backup with the wrong seed (or a corrupted file) fails
|
||||||
|
/// authentication — the payload is never partially revealed.
|
||||||
|
class BackupAuthException implements Exception {
|
||||||
|
const BackupAuthException();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'BackupAuthException: wrong recovery seed or corrupted backup';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seals/opens backup payloads with a key derived from the root identity seed
|
||||||
|
/// (backup-and-recovery.md: the seed on the printed QR is the ONE thing that
|
||||||
|
/// unlocks your copies; the DB key never leaves the device keystore).
|
||||||
|
///
|
||||||
|
/// Wire format v1 (open and stable — old apps must keep opening new files of
|
||||||
|
/// the same version):
|
||||||
|
///
|
||||||
|
/// magic "TANEBK" · version byte (0x01) · 12-byte nonce ·
|
||||||
|
/// ciphertext‖16-byte MAC (AES-256-GCM)
|
||||||
|
///
|
||||||
|
/// The AES key is HKDF-SHA256(seed, info: "org.comunes.tane backup v1").
|
||||||
|
class BackupBox {
|
||||||
|
BackupBox();
|
||||||
|
|
||||||
|
static const magic = [0x54, 0x41, 0x4E, 0x45, 0x42, 0x4B]; // "TANEBK"
|
||||||
|
static const version = 0x01;
|
||||||
|
static const _nonceLength = 12;
|
||||||
|
|
||||||
|
static final _aead = AesGcm.with256bits();
|
||||||
|
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
|
static final _info = 'org.comunes.tane backup v1'.codeUnits;
|
||||||
|
|
||||||
|
/// Whether [bytes] carry the sealed-backup magic (callers fall back to the
|
||||||
|
/// legacy plain interchange format when they don't).
|
||||||
|
static bool looksSealed(List<int> bytes) {
|
||||||
|
if (bytes.length < magic.length) return false;
|
||||||
|
for (var i = 0; i < magic.length; i++) {
|
||||||
|
if (bytes[i] != magic[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SecretKey> _key(List<int> rootSeed) => _hkdf.deriveKey(
|
||||||
|
secretKey: SecretKey(rootSeed),
|
||||||
|
info: _info,
|
||||||
|
nonce: const [], // the AEAD nonce is per-file; the key itself is static
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Encrypts [plain] under a key derived from [rootSeed], with a fresh nonce.
|
||||||
|
Future<Uint8List> seal(List<int> plain, {required List<int> rootSeed}) async {
|
||||||
|
final box = await _aead.encrypt(plain, secretKey: await _key(rootSeed));
|
||||||
|
return Uint8List.fromList([
|
||||||
|
...magic,
|
||||||
|
version,
|
||||||
|
...box.nonce,
|
||||||
|
...box.cipherText,
|
||||||
|
...box.mac.bytes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts a [seal]ed payload. Throws [FormatException] when the bytes are
|
||||||
|
/// not a sealed backup (or an unknown version), and [BackupAuthException]
|
||||||
|
/// when the seed is wrong or the file was tampered with.
|
||||||
|
Future<Uint8List> open(
|
||||||
|
List<int> sealed, {
|
||||||
|
required List<int> rootSeed,
|
||||||
|
}) async {
|
||||||
|
if (!looksSealed(sealed)) {
|
||||||
|
throw const FormatException('Not a sealed backup (bad magic)');
|
||||||
|
}
|
||||||
|
final headerLength = magic.length + 1 + _nonceLength;
|
||||||
|
if (sealed.length < headerLength + 16) {
|
||||||
|
throw const FormatException('Sealed backup is truncated');
|
||||||
|
}
|
||||||
|
if (sealed[magic.length] != version) {
|
||||||
|
throw FormatException(
|
||||||
|
'Unknown backup format version ${sealed[magic.length]}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final nonce = sealed.sublist(magic.length + 1, headerLength);
|
||||||
|
final body = sealed.sublist(headerLength);
|
||||||
|
final box = SecretBox(
|
||||||
|
body.sublist(0, body.length - 16),
|
||||||
|
nonce: nonce,
|
||||||
|
mac: Mac(body.sublist(body.length - 16)),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final plain = await _aead.decrypt(box, secretKey: await _key(rootSeed));
|
||||||
|
return Uint8List.fromList(plain);
|
||||||
|
} on SecretBoxAuthenticationError {
|
||||||
|
throw const BackupAuthException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/commons_core/lib/src/crypto/recovery_code.dart
Normal file
84
packages/commons_core/lib/src/crypto/recovery_code.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/// The printable, human-typable form of the root identity seed — what goes on
|
||||||
|
/// the recovery QR/sheet ("keep two paper copies, like your best seed"). RFC
|
||||||
|
/// 4648 base32 (no ambiguous characters), grouped in fours, version-tagged so
|
||||||
|
/// future formats can coexist.
|
||||||
|
abstract final class RecoveryCode {
|
||||||
|
static const _tag = 'TANE1';
|
||||||
|
static const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
static const _seedLengthBytes = 32;
|
||||||
|
|
||||||
|
/// Encodes a 32-byte seed (as lowercase hex) into `TANE1-XXXX-XXXX-…`.
|
||||||
|
static String encode(String seedHex) {
|
||||||
|
final bytes = _fromHex(seedHex);
|
||||||
|
if (bytes.length != _seedLengthBytes) {
|
||||||
|
throw FormatException(
|
||||||
|
'Expected a $_seedLengthBytes-byte seed, got ${bytes.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final b32 = _base32(bytes);
|
||||||
|
final groups = <String>[
|
||||||
|
for (var i = 0; i < b32.length; i += 4)
|
||||||
|
b32.substring(i, i + 4 > b32.length ? b32.length : i + 4),
|
||||||
|
];
|
||||||
|
return '$_tag-${groups.join('-')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a code back to the seed hex. Case, spaces and dashes are
|
||||||
|
/// forgiven — people type these from paper. Throws [FormatException] when
|
||||||
|
/// it is not a valid TANE1 code.
|
||||||
|
static String decode(String code) {
|
||||||
|
final compact = code.toUpperCase().replaceAll(RegExp(r'[\s-]'), '');
|
||||||
|
if (!compact.startsWith(_tag)) {
|
||||||
|
throw const FormatException('Not a Tanemaki recovery code');
|
||||||
|
}
|
||||||
|
final payload = compact.substring(_tag.length);
|
||||||
|
final bytes = _unbase32(payload);
|
||||||
|
if (bytes.length != _seedLengthBytes) {
|
||||||
|
throw const FormatException('Recovery code is incomplete');
|
||||||
|
}
|
||||||
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _fromHex(String hex) {
|
||||||
|
if (hex.length.isOdd || !RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex)) {
|
||||||
|
throw const FormatException('Invalid hex seed');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < hex.length; i += 2)
|
||||||
|
int.parse(hex.substring(i, i + 2), radix: 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _base32(List<int> bytes) {
|
||||||
|
final out = StringBuffer();
|
||||||
|
var buffer = 0;
|
||||||
|
var bits = 0;
|
||||||
|
for (final byte in bytes) {
|
||||||
|
buffer = (buffer << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
bits -= 5;
|
||||||
|
out.write(_alphabet[(buffer >> bits) & 0x1F]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) out.write(_alphabet[(buffer << (5 - bits)) & 0x1F]);
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> _unbase32(String text) {
|
||||||
|
final out = <int>[];
|
||||||
|
var buffer = 0;
|
||||||
|
var bits = 0;
|
||||||
|
for (final char in text.split('')) {
|
||||||
|
final value = _alphabet.indexOf(char);
|
||||||
|
if (value < 0) throw FormatException('Invalid character "$char"');
|
||||||
|
buffer = (buffer << 5) | value;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out.add((buffer >> bits) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ dependencies:
|
||||||
uuid: ^4.5.0
|
uuid: ^4.5.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
meta: ^1.15.0
|
meta: ^1.15.0
|
||||||
|
# Pure-Dart AEAD + HKDF (Apache-2.0) for sealed backups. No FFI, host-testable.
|
||||||
|
cryptography: ^2.7.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
67
packages/commons_core/test/crypto/backup_box_test.dart
Normal file
67
packages/commons_core/test/crypto/backup_box_test.dart
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final seed = List<int>.generate(32, (i) => i);
|
||||||
|
final otherSeed = List<int>.generate(32, (i) => 255 - i);
|
||||||
|
final plain = Uint8List.fromList(
|
||||||
|
'{"schemaVersion":1,"varieties":[]}'.codeUnits,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('seal/open round-trips the payload', () async {
|
||||||
|
final box = BackupBox();
|
||||||
|
final sealed = await box.seal(plain, rootSeed: seed);
|
||||||
|
final opened = await box.open(sealed, rootSeed: seed);
|
||||||
|
expect(opened, plain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sealed bytes carry the magic and never the plaintext', () async {
|
||||||
|
final sealed = await BackupBox().seal(plain, rootSeed: seed);
|
||||||
|
expect(BackupBox.looksSealed(sealed), isTrue);
|
||||||
|
// No plaintext at rest: the payload must not survive in the clear.
|
||||||
|
expect(String.fromCharCodes(sealed).contains('schemaVersion'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two seals of the same payload differ (fresh nonce)', () async {
|
||||||
|
final box = BackupBox();
|
||||||
|
final a = await box.seal(plain, rootSeed: seed);
|
||||||
|
final b = await box.seal(plain, rootSeed: seed);
|
||||||
|
expect(a, isNot(equals(b)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening with the wrong seed fails authentication', () async {
|
||||||
|
final sealed = await BackupBox().seal(plain, rootSeed: seed);
|
||||||
|
expect(
|
||||||
|
() => BackupBox().open(sealed, rootSeed: otherSeed),
|
||||||
|
throwsA(isA<BackupAuthException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tampered box fails authentication', () async {
|
||||||
|
final sealed = await BackupBox().seal(plain, rootSeed: seed);
|
||||||
|
sealed[sealed.length - 1] ^= 0xFF;
|
||||||
|
expect(
|
||||||
|
() => BackupBox().open(sealed, rootSeed: seed),
|
||||||
|
throwsA(isA<BackupAuthException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bytes without the magic are not a sealed backup', () async {
|
||||||
|
expect(BackupBox.looksSealed(plain), isFalse);
|
||||||
|
expect(
|
||||||
|
() => BackupBox().open(plain, rootSeed: seed),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown format version is rejected, not misread', () async {
|
||||||
|
final sealed = await BackupBox().seal(plain, rootSeed: seed);
|
||||||
|
sealed[BackupBox.magic.length] = 99; // bump the version byte
|
||||||
|
expect(
|
||||||
|
() => BackupBox().open(sealed, rootSeed: seed),
|
||||||
|
throwsA(isA<FormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
46
packages/commons_core/test/crypto/recovery_code_test.dart
Normal file
46
packages/commons_core/test/crypto/recovery_code_test.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const seedHex =
|
||||||
|
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||||
|
|
||||||
|
test('encode/decode round-trips a 32-byte seed', () {
|
||||||
|
final code = RecoveryCode.encode(seedHex);
|
||||||
|
expect(RecoveryCode.decode(code), seedHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the code is grouped for humans and carries a version tag', () {
|
||||||
|
final code = RecoveryCode.encode(seedHex);
|
||||||
|
expect(code, startsWith('TANE1-'));
|
||||||
|
// After the tag: only unambiguous base32 characters and separators.
|
||||||
|
expect(RegExp(r'^TANE1-[A-Z2-7-]+$').hasMatch(code), isTrue);
|
||||||
|
// Groups stay short enough to type from paper.
|
||||||
|
for (final group in code.split('-').skip(1)) {
|
||||||
|
expect(group.length, lessThanOrEqualTo(4));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decode is tolerant to case, spaces and missing dashes', () {
|
||||||
|
final code = RecoveryCode.encode(seedHex);
|
||||||
|
final sloppy = code.toLowerCase().replaceAll('-', ' ');
|
||||||
|
expect(RecoveryCode.decode(sloppy), seedHex);
|
||||||
|
expect(RecoveryCode.decode(code.replaceAll('-', '')), seedHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garbage is rejected with a FormatException', () {
|
||||||
|
expect(() => RecoveryCode.decode('not a code'), throwsFormatException);
|
||||||
|
expect(() => RecoveryCode.decode('TANE1-ABCD'), throwsFormatException);
|
||||||
|
// Wrong version tag.
|
||||||
|
expect(
|
||||||
|
() => RecoveryCode.decode('XYZ9-${RecoveryCode.encode(seedHex)}'),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every 32-byte seed survives the trip', () {
|
||||||
|
final seed = List<int>.generate(32, (i) => (i * 37 + 11) % 256);
|
||||||
|
final hex = seed.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
expect(RecoveryCode.decode(RecoveryCode.encode(hex)), hex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -225,6 +225,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
cryptography:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cryptography
|
||||||
|
sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.0"
|
||||||
csslib:
|
csslib:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue