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:
vjrj 2026-07-09 22:32:13 +02:00
parent ba87bf2719
commit d6781870d9
19 changed files with 1016 additions and 81 deletions

View file

@ -1,40 +1,111 @@
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_json_codec.dart';
import '../data/export_import/inventory_snapshot.dart';
import '../data/variety_repository.dart';
import '../security/secure_key_store.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
/// 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 {
ExportImportService({
required VarietyRepository repository,
required FileService files,
required SecureKeyStore keys,
DateTime Function()? now,
}) : _repository = repository,
_files = files,
_keys = keys,
_now = now ?? DateTime.now;
final VarietyRepository _repository;
final FileService _files;
final SecureKeyStore _keys;
final DateTime Function() _now;
static const _jsonCodec = InventoryJsonCodec();
static const _csvCodec = InventoryCsvCodec();
/// Exports the inventory as interchange JSON (data-model §7). Returns true
/// when saved, false when the user cancelled the save dialog.
Future<bool> exportJson() async {
/// The backup file extension. The content is the sealed interchange JSON.
static const backupExtension = 'tanemaki';
/// 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 plain = utf8.encode(_jsonCodec.encode(snapshot));
final sealed = await BackupBox().seal(
plain,
rootSeed: _hexToBytes(await _keys.rootSeedHex()),
);
final path = await _files.saveFile(
suggestedName: _fileName('json'),
bytes: utf8.encode(_jsonCodec.encode(snapshot)),
suggestedName: _fileName(backupExtension),
bytes: sealed,
);
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
/// saved, false when the user cancelled the save dialog.
Future<bool> exportCsv() async {
@ -46,16 +117,6 @@ class ExportImportService {
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;
/// see [VarietyRepository.importCsv]). Returns the summary (`inserted` =
/// varieties added), or null when the user cancelled the file dialog.
@ -68,6 +129,11 @@ class ExportImportService {
String _fileName(String extension) {
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),
];
}

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