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

@ -5,7 +5,9 @@
library;
export 'src/clock/hlc.dart';
export 'src/crypto/backup_box.dart';
export 'src/crypto/random_bytes.dart';
export 'src/crypto/recovery_code.dart';
export 'src/identity/identity_service.dart';
export 'src/ids/id_gen.dart';
export 'src/value/quantity.dart';

View 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 ·
/// ciphertext16-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();
}
}
}

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

View file

@ -15,6 +15,8 @@ dependencies:
uuid: ^4.5.0
equatable: ^2.0.5
meta: ^1.15.0
# Pure-Dart AEAD + HKDF (Apache-2.0) for sealed backups. No FFI, host-testable.
cryptography: ^2.7.0
dev_dependencies:
lints: ^6.0.0

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