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
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue