8081d98c67
Initial public release. Anonymous on-device identity (no phone number, e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double Ratchet, sealed sender) via libsignal, a metadata-minimizing relay, group chats, disappearing messages, encrypted local storage, optional Tor transport, and a UI localized into Czech, English, German, Russian, and Italian. Monorepo: Flutter client (app/), Rust relay + wire types (rust/), protobuf definitions (proto/), self-host deploy (deploy/). Licensed under AGPL-3.0 with an app-store distribution exception (§7).
105 lines
3.7 KiB
Dart
105 lines
3.7 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:cryptography/cryptography.dart';
|
|
import 'package:sepot/core/errors.dart';
|
|
import 'package:sepot/core/storage/secure_storage.dart';
|
|
import 'package:sepot/models/contact.dart';
|
|
import 'package:sepot/models/conversation.dart';
|
|
import 'package:sepot/models/identity.dart';
|
|
|
|
/// Šifrovaná záloha historie (kontakty + konverzace).
|
|
///
|
|
/// Klíč se odvozuje z privátního klíče identity (HKDF-SHA256) → zálohu
|
|
/// dešifruje POUZE tvoje identita (po obnově ze slov). Přílohy (bloby)
|
|
/// v záloze nejsou — obnoví se metadata zpráv, samotný soubor se případně
|
|
/// dotáhne z relaye znovu (dokud tam je).
|
|
class BackupService {
|
|
BackupService(this._storage);
|
|
|
|
final SecureStorageService _storage;
|
|
|
|
// Formát: magic(8) ‖ nonce(12) ‖ ciphertext ‖ GCM tag(16)
|
|
static const _magic = 'SEPOTBK1';
|
|
static const _nonceLen = 12;
|
|
static const _tagLen = 16;
|
|
|
|
Future<SecretKey> _deriveKey(Uint8List privateKey) async {
|
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
|
return hkdf.deriveKey(
|
|
secretKey: SecretKey(privateKey),
|
|
nonce: utf8.encode('sepot-backup-salt-v1'),
|
|
info: utf8.encode('sepot-backup-v1'),
|
|
);
|
|
}
|
|
|
|
Uint8List _randomNonce() {
|
|
final r = Random.secure();
|
|
return Uint8List.fromList(List.generate(_nonceLen, (_) => r.nextInt(256)));
|
|
}
|
|
|
|
/// Vytvoř šifrovanou zálohu — vrací bajty souboru `.sepotbackup`.
|
|
Future<Uint8List> export(LocalIdentity identity) async {
|
|
final contacts = await _storage.loadContacts();
|
|
final conversations = await _storage.loadConversations();
|
|
final payload = utf8.encode(jsonEncode({
|
|
'v': 1,
|
|
'contacts': contacts.map((c) => c.toJson()).toList(),
|
|
'conversations': conversations.map((c) => c.toJson()).toList(),
|
|
}));
|
|
|
|
final key = await _deriveKey(identity.identityPrivateKey);
|
|
final nonce = _randomNonce();
|
|
final box = await AesGcm.with256bits()
|
|
.encrypt(payload, secretKey: key, nonce: nonce);
|
|
|
|
final out = BytesBuilder();
|
|
out.add(utf8.encode(_magic));
|
|
out.add(nonce);
|
|
out.add(box.cipherText);
|
|
out.add(box.mac.bytes);
|
|
return out.toBytes();
|
|
}
|
|
|
|
/// Obnov ze zálohy. Vrací počet obnovených konverzací. Vyhodí typovanou
|
|
/// chybu ([BackupCorruptedError], [BackupUnknownFormatError],
|
|
/// [BackupWrongIdentityError]) u poškozeného souboru nebo zálohy cizí
|
|
/// identity — UI ji překládá přes describeError.
|
|
Future<int> import(LocalIdentity identity, Uint8List bytes) async {
|
|
if (bytes.length < _magic.length + _nonceLen + _tagLen) {
|
|
throw const BackupCorruptedError();
|
|
}
|
|
if (utf8.decode(bytes.sublist(0, _magic.length)) != _magic) {
|
|
throw const BackupUnknownFormatError();
|
|
}
|
|
final nonce = bytes.sublist(_magic.length, _magic.length + _nonceLen);
|
|
final body = bytes.sublist(_magic.length + _nonceLen);
|
|
final ct = body.sublist(0, body.length - _tagLen);
|
|
final mac = Mac(body.sublist(body.length - _tagLen));
|
|
|
|
final key = await _deriveKey(identity.identityPrivateKey);
|
|
List<int> plain;
|
|
try {
|
|
plain = await AesGcm.with256bits().decrypt(
|
|
SecretBox(ct, nonce: nonce, mac: mac),
|
|
secretKey: key,
|
|
);
|
|
} on SecretBoxAuthenticationError {
|
|
throw const BackupWrongIdentityError();
|
|
}
|
|
|
|
final json = jsonDecode(utf8.decode(plain)) as Map<String, dynamic>;
|
|
for (final c in (json['contacts'] as List? ?? const [])) {
|
|
await _storage.saveContact(Contact.fromJson(c as Map<String, dynamic>));
|
|
}
|
|
var restored = 0;
|
|
for (final c in (json['conversations'] as List? ?? const [])) {
|
|
await _storage
|
|
.saveConversation(Conversation.fromJson(c as Map<String, dynamic>));
|
|
restored++;
|
|
}
|
|
return restored;
|
|
}
|
|
}
|