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

@ -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>()),
);
});
}

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