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