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
|
|
@ -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';
|
||||
|
|
|
|||
98
packages/commons_core/lib/src/crypto/backup_box.dart
Normal file
98
packages/commons_core/lib/src/crypto/backup_box.dart
Normal 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 ·
|
||||
/// ciphertext‖16-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();
|
||||
}
|
||||
}
|
||||
}
|
||||
84
packages/commons_core/lib/src/crypto/recovery_code.dart
Normal file
84
packages/commons_core/lib/src/crypto/recovery_code.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue