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