Šepot — end-to-end encrypted messenger

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).
This commit is contained in:
Julius Joska
2026-07-11 17:06:39 +02:00
commit 8081d98c67
309 changed files with 44102 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
/// Šifrování příloh (model Signal).
///
/// Každá příloha má vlastní náhodný klíč: soubor se zašifruje
/// AES-256-GCM, na relay jde UŽ ŠIFROVANÝ blob a klíč + nonce + digest
/// putují k příjemci uvnitř E2E zprávy (relay je nikdy nevidí).
///
/// Wire formát blobu: `ciphertext || GCM tag (16 B)`.
/// Digest = SHA-256 celého blobu — váže zprávu na konkrétní bajty na
/// relayi (relay nemůže blob vyměnit) a příjemce jím ověří stažená data
/// ještě PŘED dešifrováním.
///
/// Šifrování i dešifrování běží v samostatném isolate — soubory mají
/// megabajty a čistě dartový AES by jinak blokoval UI.
library;
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
/// Blob nesedí (digest mismatch / GCM tag fail) — poškozená nebo
/// podvržená příloha. Nikdy nepředávat obsah dál.
class AttachmentIntegrityException implements Exception {
const AttachmentIntegrityException(this.reason);
final String reason;
@override
String toString() => 'AttachmentIntegrityException: $reason';
}
/// Výsledek zašifrování přílohy — vše, co potřebuje odesílatel:
/// [blob] se nahraje na relay, zbytek jde do E2E zprávy.
class EncryptedAttachment {
const EncryptedAttachment({
required this.key,
required this.nonce,
required this.blob,
required this.digest,
required this.plaintextSize,
});
final Uint8List key; // 32 B, per-file
final Uint8List nonce; // 12 B
final Uint8List blob; // ciphertext || tag
final Uint8List digest; // SHA-256(blob)
final int plaintextSize;
}
class AttachmentCipher {
AttachmentCipher._();
static const int keyLength = 32;
static const int nonceLength = 12;
static const int tagLength = 16;
static Future<EncryptedAttachment> encrypt(Uint8List plaintext) async {
if (plaintext.isEmpty) {
throw ArgumentError('empty attachment');
}
final key = _randomBytes(keyLength);
final nonce = _randomBytes(nonceLength);
final blob = await Isolate.run(() async {
final aes = AesGcm.with256bits();
final box = await aes.encrypt(
plaintext,
secretKey: SecretKey(key),
nonce: nonce,
);
final out = Uint8List(box.cipherText.length + box.mac.bytes.length);
out.setAll(0, box.cipherText);
out.setAll(box.cipherText.length, box.mac.bytes);
return out;
});
return EncryptedAttachment(
key: key,
nonce: nonce,
blob: blob,
digest: await sha256(blob),
plaintextSize: plaintext.length,
);
}
/// Digest se ověřuje PŘED dešifrováním (stažený blob nemusí být ten,
/// na který zpráva ukazuje); GCM tag pak autentizuje obsah klíčem.
static Future<Uint8List> decrypt({
required Uint8List blob,
required Uint8List key,
required Uint8List nonce,
required Uint8List expectedDigest,
}) async {
if (blob.length <= tagLength) {
throw const AttachmentIntegrityException('blob too short');
}
final actualDigest = await sha256(blob);
if (!_bytesEqual(actualDigest, expectedDigest)) {
throw const AttachmentIntegrityException('digest mismatch');
}
try {
final plaintext = await Isolate.run(() async {
final aes = AesGcm.with256bits();
final cipherText = blob.sublist(0, blob.length - tagLength);
final mac = Mac(blob.sublist(blob.length - tagLength));
final out = await aes.decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: SecretKey(key),
);
return Uint8List.fromList(out);
});
return plaintext;
} on SecretBoxAuthenticationError {
throw const AttachmentIntegrityException('GCM tag mismatch');
}
}
static Future<Uint8List> sha256(Uint8List data) async {
final hash = await Sha256().hash(data);
return Uint8List.fromList(hash.bytes);
}
static Uint8List _randomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(List.generate(length, (_) => rng.nextInt(256)));
}
static bool _bytesEqual(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}
+172
View File
@@ -0,0 +1,172 @@
/// Argon2id wrap databázového klíče (DEK) za PIN.
///
/// Schéma:
/// unlockKey = Argon2id(PIN, salt)
/// wrappedDek = AES-256-GCM(dek, unlockKey)
///
/// PIN se NIKDE neukládá ani nehashuje na porovnání — „ověření PINu" je
/// úspěšné rozbalení DEK (GCM auth tag). Špatný PIN = nečitelná DB.
///
/// Argon2id parametry jsou součástí blobu, takže budoucí rekalibrace
/// nerozbije stará data. Při wrap i unwrap se vynucuje dolní mez
/// (OWASP floor): m ≥ 19 MiB, t ≥ 2 — slabší
/// parametry se odmítnou (žádný tichý downgrade).
library;
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:cryptography/dart.dart';
/// GCM auth tag nesedí — špatný PIN, nebo poškozený/podvržený blob.
/// (AES-GCM tyhle dva případy principiálně nerozliší.)
class WrongPinException implements Exception {
const WrongPinException();
@override
String toString() => 'WrongPinException: wrong PIN or corrupted blob';
}
class DekWrap {
DekWrap._();
/// Výchozí parametry (m=64MiB, t=3, p=1).
/// Řádově ~2–3 s čistým Dartem na středně výkonném zařízení.
static const int defaultMemoryKiB = 64 * 1024;
static const int defaultIterations = 3;
static const int defaultParallelism = 1;
/// Dolní bezpečnostní mez (OWASP) — pod ni kalibrace NIKDY nesmí.
static const int floorMemoryKiB = 19 * 1024;
static const int floorIterations = 2;
static const String _version = 'sepot-dek-v1';
/// Zabal 32B DEK za PIN. Výstup je samopopisný string pro keystore:
/// `sepot-dek-v1:<mKiB>:<t>:<p>:<b64 salt>:<b64 nonce>:<b64 ct>:<b64 tag>`
static Future<String> wrap({
required Uint8List dek,
required String pin,
int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations,
int parallelism = defaultParallelism,
}) async {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
_enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
final box = await aes.encrypt(
dek,
secretKey: SecretKey(unlockKey),
nonce: _randomBytes(12),
);
return [
_version,
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(salt),
base64Encode(box.nonce),
base64Encode(box.cipherText),
base64Encode(box.mac.bytes),
].join(':');
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({
required String wrapped,
required String pin,
}) async {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
final memoryKiB = int.parse(parts[1]);
final iterations = int.parse(parts[2]);
final parallelism = int.parse(parts[3]);
// Floor PŘED odvozením — podvržený blob se slabými parametry
// nesmí projít, ani kdyby měl platný tag
_enforceFloor(memoryKiB, iterations);
final salt = base64Decode(parts[4]);
final nonce = base64Decode(parts[5]);
final cipherText = base64Decode(parts[6]);
final mac = base64Decode(parts[7]);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
);
final aes = AesGcm.with256bits();
try {
final dek = await aes.decrypt(
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
secretKey: SecretKey(unlockKey),
);
return Uint8List.fromList(dek);
} on SecretBoxAuthenticationError {
throw const WrongPinException();
}
}
static void _enforceFloor(int memoryKiB, int iterations) {
if (memoryKiB < floorMemoryKiB || iterations < floorIterations) {
throw StateError(
'Argon2id parameters below security floor '
'(m=${memoryKiB}KiB t=$iterations, floor m=${floorMemoryKiB}KiB '
't=$floorIterations)',
);
}
}
/// Argon2id běží v samostatném isolate — 64 MiB memory-hard výpočet
/// by jinak na sekundy zmrazil UI.
static Future<Uint8List> _deriveUnlockKey({
required String pin,
required Uint8List salt,
required int memoryKiB,
required int iterations,
required int parallelism,
}) {
return Isolate.run(() async {
final algo = DartArgon2id(
memory: memoryKiB,
iterations: iterations,
parallelism: parallelism,
hashLength: 32,
);
final key = await algo.deriveKey(
secretKey: SecretKey(utf8.encode(pin)),
nonce: salt,
);
return Uint8List.fromList(await key.extractBytes());
});
}
static Uint8List _randomBytes(int length) {
final rng = Random.secure();
return Uint8List.fromList(
List.generate(length, (_) => rng.nextInt(256)),
);
}
}
+71
View File
@@ -0,0 +1,71 @@
/// Obnova identity přes mnemonic — BIP39 wordlist jako REVERZIBILNÍ
/// ENKÓDOVÁNÍ 32B privátního identity klíče.
///
/// Žádné KDF, žádný seed: 24 slov je přímý zápis bajtů klíče + checksum.
/// `decode(encode(klic)) == klic` platí bit po bitu — hlídají to KAT testy.
///
/// DŮLEŽITÉ: enkóduje se SERIALIZOVANÝ privátní klíč (výstup fasády
/// SepotCrypto, už clampnutý pro Curve25519). Díky tomu je
/// deserialize→serialize stabilní a mnemonic vždy obnoví identický klíč.
library;
import 'dart:typed_data';
import 'package:bip39/bip39.dart' as bip39;
import 'package:convert/convert.dart' show hex;
import 'package:sepot/core/errors.dart';
class SepotMnemonic {
SepotMnemonic._();
/// 32 B entropie = 24 slov BIP39 (256 bitů + 8bit checksum).
static const int wordCount = 24;
static const int keyLength = 32;
/// Serializovaný privátní klíč (32 B) → 24 slov.
static List<String> encode(Uint8List privateKey) {
if (privateKey.length != keyLength) {
throw ArgumentError.value(
privateKey.length,
'privateKey',
'Expected $keyLength bytes of serialized private key',
);
}
return bip39.entropyToMnemonic(hex.encode(privateKey)).split(' ');
}
/// 24 slov → 32 B privátního klíče.
///
/// Vyhodí [MnemonicWordCountError] při špatném počtu slov a
/// [MnemonicInvalidError] při neznámém slovu nebo nesedícím checksumu
/// (překlep uživatele) — UI je překládá přes describeError.
static Uint8List decode(List<String> words) {
final normalized = _normalize(words);
if (normalized.length != wordCount) {
throw MnemonicWordCountError(normalized.length, wordCount);
}
final String entropyHex;
try {
entropyHex = bip39.mnemonicToEntropy(normalized.join(' '));
} on ArgumentError catch (e) {
// bip39 hlásí neznámé slovo / špatný checksum přes ArgumentError a
// StateError — pro UI sjednocujeme na typovanou chybu
throw MnemonicInvalidError('${e.message}');
} on StateError catch (e) {
throw MnemonicInvalidError(e.message);
}
return Uint8List.fromList(hex.decode(entropyHex));
}
/// Rychlá validace pro UI (živá kontrola při zadávání).
static bool isValid(List<String> words) {
final normalized = _normalize(words);
if (normalized.length != wordCount) return false;
return bip39.validateMnemonic(normalized.join(' '));
}
static List<String> _normalize(List<String> words) => words
.expand((w) => w.trim().toLowerCase().split(RegExp(r'\s+')))
.where((w) => w.isNotEmpty)
.toList();
}
+70
View File
@@ -0,0 +1,70 @@
/// Safety number — vizuální ověření kontaktu.
///
/// Používá libsignal `Fingerprint` (NumericFingerprintGenerator), žádný
/// vlastní výpočet: iterovaný SHA-512 (5200 iterací jako Signal) nad
/// (verze ‖ identifikátor ‖ identity klíč) pro obě strany, zobrazení
/// 60 dekadických číslic (12 bloků po 5). Menší polovina jde první,
/// takže OBĚ strany vidí stejné číslo.
///
/// Stabilní identifikátor strany = BLAKE2b-256 fingerprint jejího
/// identity klíče (deterministický z klíče — žádný telefon, e-mail ani
/// server handle, přesně v duchu „identita = klíč").
///
/// QR ověření: [scannable] bajty do QR; sken protistrany se porovná přes
/// [SafetyNumber.compare] (libsignal kontroluje křížem local↔remote).
library;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart' as signal;
import 'package:sepot/models/identity.dart';
class SafetyNumber {
SafetyNumber._(this.display, this.scannable);
/// Iterace hashe jako Signal (bezpečnostní parametr — NEměnit,
/// změna = všem se změní safety numbers).
static const int iterations = 5200;
/// Verze scannable formátu (libsignal proto).
static const int version = 2;
/// 60 číslic bez mezer.
final String display;
/// Binární encoding pro QR + [compare].
final Uint8List scannable;
/// Spočítá safety number páru identity klíčů (33 B serializované).
factory SafetyNumber.compute({
required Uint8List localIdentityKey,
required Uint8List remoteIdentityKey,
}) {
final fp = signal.Fingerprint(
iterations: iterations,
version: version,
localIdentifier: SepotFingerprint.ofPublicKey(localIdentityKey).bytes,
localPublicKey: localIdentityKey,
remoteIdentifier: SepotFingerprint.ofPublicKey(remoteIdentityKey).bytes,
remotePublicKey: remoteIdentityKey,
);
return SafetyNumber._(fp.displayString(), fp.scannableEncoding());
}
/// 12 bloků po 5 číslicích pro zobrazení.
List<String> get groups => [
for (var i = 0; i < display.length; i += 5)
display.substring(i, i + 5),
];
/// Porovná můj scannable s naskenovaným od protistrany. True = klíče
/// si křížem odpovídají (žádný MITM), false = NESHODA.
static bool compare(Uint8List mine, Uint8List scanned) {
try {
return signal.fingerprintCompare(fingerprint1: mine, fingerprint2: scanned);
} on Object {
// vadný/cizí QR formát = neshoda, ne pád
return false;
}
}
}
+423
View File
@@ -0,0 +1,423 @@
/// Fasáda SepotCrypto — port/adapter nad krypto backendem (libsignal).
///
/// Smysl: veškerý kód Šepotu mluví JEN s touhle fasádou,
/// nikdy přímo s balíčkem `libsignal`. Backend tak zůstává vyměnitelný
/// (libsignal FFI ↔ libsignal_protocol_dart ↔ vlastní FRB fork) bez zásahu
/// do zbytku aplikace.
///
/// Stores jsou zatím in-memory; později je nahradí
/// perzistence nad SQLCipher.
library;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:libsignal/libsignal.dart';
/// Pár serializovaných identity klíčů — jediný tvar, ve kterém identity
/// klíče opouštějí fasádu (žádné backend-specifické typy ven).
class SepotIdentityKeys {
SepotIdentityKeys({required this.privateKey, required this.publicKey});
/// 32 B, serializovaný (clampnutý) privátní klíč. Přesně tyhle bajty
/// se enkódují do mnemonic — díky clampingu při serializaci je
/// deserialize→serialize bitově stabilní roundtrip.
final Uint8List privateKey;
/// 33 B, serializovaný veřejný klíč (libsignal type byte + 32 B).
/// Z něj se počítá fingerprint.
final Uint8List publicKey;
}
/// Serializovatelný obsah prekey bundle — přesně to, co klient nahraje
/// na relay a co si druhá strana stáhne. Jen bajty a čísla, žádné
/// backend-specifické typy (musí projít protobufem beze změny).
class SepotPreKeyBundle {
SepotPreKeyBundle({
required this.registrationId,
required this.deviceId,
required this.preKeyId,
required this.preKeyPublic,
required this.signedPreKeyId,
required this.signedPreKeyPublic,
required this.signedPreKeySignature,
required this.identityKey,
required this.kyberPreKeyId,
required this.kyberPreKeyPublic,
required this.kyberPreKeySignature,
});
final int registrationId;
final int deviceId;
final int? preKeyId;
final Uint8List? preKeyPublic;
final int signedPreKeyId;
final Uint8List signedPreKeyPublic;
final Uint8List signedPreKeySignature;
final Uint8List identityKey;
final int kyberPreKeyId;
final Uint8List kyberPreKeyPublic;
final Uint8List kyberPreKeySignature;
}
/// Výsledek dešifrování sealed-sender obálky.
class SepotSealedMessage {
SepotSealedMessage({
required this.plaintext,
required this.senderName,
required this.senderDeviceId,
required this.senderIdentityKey,
});
final Uint8List plaintext;
/// accountId odesílatele (hex) ze SenderCertificate — zná ho JEN příjemce.
final String senderName;
final int senderDeviceId;
/// Serializovaný identity klíč odesílatele (33 B) — kryptograficky
/// ověřený certifikátem. Fingerprint z něj mapuje zprávu na kontakt.
final Uint8List senderIdentityKey;
}
/// Jeden one-time prekey k publikaci na relay.
class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey});
final int id;
final Uint8List publicKey;
}
/// Sada stores, nad kterými krypto instance pracuje. Produkce dodává
/// perzistentní (SQLCipher, `persistent_stores.dart`), testy in-memory.
class SepotStores {
SepotStores({
required this.session,
required this.identity,
required this.preKey,
required this.signedPreKey,
required this.kyberPreKey,
});
/// In-memory sada (spike, testy) — po zabití procesu je pryč.
factory SepotStores.inMemory(IdentityKeyPair identity, int registrationId) {
return SepotStores(
session: InMemorySessionStore(),
identity: InMemoryIdentityKeyStore(identity, registrationId),
preKey: InMemoryPreKeyStore(),
signedPreKey: InMemorySignedPreKeyStore(),
kyberPreKey: InMemoryKyberPreKeyStore(),
);
}
final SessionStore session;
final IdentityKeyStore identity;
final PreKeyStore preKey;
final SignedPreKeyStore signedPreKey;
final KyberPreKeyStore kyberPreKey;
}
/// Jedna krypto identita (účet na jednom zařízení) se všemi stores.
class SepotCrypto {
SepotCrypto._({
required this.address,
required IdentityKeyPair identity,
required int registrationId,
SepotStores? stores,
}) : _identity = identity,
_registrationId = registrationId,
_stores = stores ?? SepotStores.inMemory(identity, registrationId);
/// Inicializace nativní knihovny — volat jednou per proces, před
/// jakoukoli jinou operací fasády (v `main()` před `runApp`).
///
/// iOS: libsignal doručuje `libsignal_frb` výhradně přes Flutter native
/// assets a jeho podspec framework nelinkuje. Výchozí FRB loader proto
/// dlopen-uje bez `@rpath` prefixu, dyld hledá v kořeni `.app` místo ve
/// `Frameworks/` a selže → němá bílá obrazovka. Předaná cesta se přesně
/// shoduje s install name, které frameworku nastaví flutter_tools, a
/// Runner má `@executable_path/Frameworks` v LD_RUNPATH_SEARCH_PATHS.
static Future<void> init() {
if (Platform.isIOS) {
return LibSignal.init(
libraryPath: '@rpath/libsignal_frb.framework/libsignal_frb',
);
}
return LibSignal.init();
}
/// Vytvoří novou identitu (keypair + registration ID).
factory SepotCrypto.generate({
required String accountId,
int deviceId = 1,
required int registrationId,
SepotStores? stores,
}) {
return SepotCrypto._(
address: ProtocolAddress(name: accountId, deviceId: deviceId),
identity: IdentityKeyPair.generate(),
registrationId: registrationId,
stores: stores,
);
}
/// Obnoví krypto instanci z existujícího privátního identity klíče
/// (onboarding po recovery z mnemonic, běžný start aplikace).
///
/// [storesFactory] dostává rekonstruovaný keypair — produkce z něj
/// postaví perzistentní stores (DbIdentityKeyStore keypair potřebuje).
factory SepotCrypto.fromPrivateKey({
required Uint8List privateKey,
required String accountId,
int deviceId = 1,
required int registrationId,
SepotStores Function(IdentityKeyPair identity)? storesFactory,
}) {
final private = PrivateKey.deserialize(bytes: privateKey.toList());
final identity = IdentityKeyPair.fromKeys(
privateKey: private,
publicKey: private.getPublicKey(),
);
return SepotCrypto._(
address: ProtocolAddress(name: accountId, deviceId: deviceId),
identity: identity,
registrationId: registrationId,
stores: storesFactory?.call(identity),
);
}
/// Vygeneruje nový identity keypair a vrátí ho serializovaný.
/// Používá onboarding; privátní část jde do secure storage
/// a do mnemonic, nic z toho nedrží backend typy.
static SepotIdentityKeys generateIdentityKeys() {
final pair = IdentityKeyPair.generate();
return SepotIdentityKeys(
privateKey: Uint8List.fromList(pair.privateKey.toList()),
publicKey: Uint8List.fromList(pair.publicKey.toList()),
);
}
/// Odvodí veřejný klíč (33 B serialized) z privátního (32 B).
/// Deterministické — základ recovery z mnemonic.
static Uint8List derivePublicKey(Uint8List privateKey) {
final private = PrivateKey.deserialize(bytes: privateKey.toList());
return Uint8List.fromList(private.getPublicKey().serialize().toList());
}
/// Lokální adresa. Po registraci na relay se přepíná na hex(account_id)
/// přes [bindAccount] — musí sedět se sender_uuid v SenderCertificate.
ProtocolAddress address;
final IdentityKeyPair _identity;
final int _registrationId;
final SepotStores _stores;
Uint8List get identityPublicKey => Uint8List.fromList(_identity.publicKey);
int get registrationId => _registrationId;
/// Po registraci: server přidělil account_id → lokální adresa se váže
/// na hex(account_id), aby seděla se sender_uuid v SenderCertificate.
void bindAccount(String accountHex) {
address = ProtocolAddress(name: accountHex, deviceId: address.deviceId());
}
/// XEdDSA podpis identity klíčem (AUTH výzva, REST auth hlavička).
Uint8List sign(List<int> message) {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
return Uint8List.fromList(identityPrivate.sign(message: message).toList());
}
/// Vygeneruje sadu prekeys (one-time + signed + Kyber), uloží je do
/// vlastních stores a vrátí bundle k publikaci na relay.
Future<SepotPreKeyBundle> generatePreKeyBundle({
required int preKeyId,
required int signedPreKeyId,
required int kyberPreKeyId,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final timestamp = BigInt.from(timestampMs);
final preKeyPrivate = PrivateKey.generate();
final preKeyPublic = preKeyPrivate.getPublicKey();
await _stores.preKey.storePreKey(
preKeyId,
PreKeyRecord(
id: preKeyId,
publicKey: preKeyPublic,
privateKey: preKeyPrivate,
),
);
final signedPreKeyPrivate = PrivateKey.generate();
final signedPreKeyPublic = signedPreKeyPrivate.getPublicKey();
final signedPreKeySignature = identityPrivate.sign(
message: signedPreKeyPublic.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
signedPreKeyId,
SignedPreKeyRecord(
id: signedPreKeyId,
publicKey: signedPreKeyPublic,
privateKey: signedPreKeyPrivate,
signature: signedPreKeySignature.toList(),
timestamp: timestamp,
),
);
final kyberKeyPair = KyberKeyPair.generate();
final kyberPublic = kyberKeyPair.getPublicKey();
final kyberSignature = identityPrivate.sign(
message: kyberPublic.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
kyberPreKeyId,
KyberPreKeyRecord.create(
id: kyberPreKeyId,
keyPair: kyberKeyPair,
signature: kyberSignature.toList(),
timestamp: timestamp,
),
);
return SepotPreKeyBundle(
registrationId: _registrationId,
deviceId: address.deviceId(),
preKeyId: preKeyId,
preKeyPublic: preKeyPublic.serialize(),
signedPreKeyId: signedPreKeyId,
signedPreKeyPublic: Uint8List.fromList(
signedPreKeyPublic.serialize().toList(),
),
signedPreKeySignature: Uint8List.fromList(signedPreKeySignature.toList()),
identityKey: Uint8List.fromList(_identity.publicKey.toList()),
kyberPreKeyId: kyberPreKeyId,
kyberPreKeyPublic: Uint8List.fromList(kyberPublic.serialize().toList()),
kyberPreKeySignature: Uint8List.fromList(kyberSignature.toList()),
);
}
/// Existuje už ratchet session s protistranou?
Future<bool> hasSession(ProtocolAddress remote) =>
_stores.session.containsSession(remote);
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
Future<void> processRemoteBundle(
ProtocolAddress remote,
SepotPreKeyBundle bundle,
) async {
final preKeyBundle = PreKeyBundle(
registrationId: bundle.registrationId,
deviceId: bundle.deviceId,
preKeyId: bundle.preKeyId,
preKeyPublic: bundle.preKeyPublic,
signedPreKeyId: bundle.signedPreKeyId,
signedPreKeyPublic: bundle.signedPreKeyPublic.toList(),
signedPreKeySignature: bundle.signedPreKeySignature.toList(),
identityKey: bundle.identityKey.toList(),
kyberPreKeyId: bundle.kyberPreKeyId,
kyberPreKeyPublic: bundle.kyberPreKeyPublic.toList(),
kyberPreKeySignature: bundle.kyberPreKeySignature.toList(),
);
final builder = SessionBuilder(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
);
await builder.processPreKeyBundle(remote, preKeyBundle);
}
SessionCipher get _cipher => SessionCipher(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
preKeyStore: _stores.preKey,
signedPreKeyStore: _stores.signedPreKey,
kyberPreKeyStore: _stores.kyberPreKey,
);
/// Double Ratchet šifrování 1:1.
Future<CiphertextMessage> encryptTo(
ProtocolAddress remote,
List<int> plaintext,
) => _cipher.encrypt(remote, Uint8List.fromList(plaintext));
/// Double Ratchet dešifrování 1:1.
Future<Uint8List> decryptFrom(
ProtocolAddress remote,
CiphertextMessage message,
) => _cipher.decrypt(remote, message);
SealedSenderCipher get _sealedCipher => SealedSenderCipher(
localAddress: address,
sessionStore: _stores.session,
identityKeyStore: _stores.identity,
preKeyStore: _stores.preKey,
signedPreKeyStore: _stores.signedPreKey,
kyberPreKeyStore: _stores.kyberPreKey,
);
/// Sealed sender: zašifruje tak, že relay nezjistí odesílatele.
/// [senderCertificate] vydává relay (server-signed).
Future<Uint8List> sealedEncrypt({
required ProtocolAddress recipient,
required List<int> plaintext,
required Uint8List senderCertificate,
}) => _sealedCipher.encrypt(
recipientAddress: recipient,
plaintext: Uint8List.fromList(plaintext),
senderCertificate: senderCertificate,
);
/// Sealed sender: dešifruje obálku a ověří sender certificate proti
/// pinnutému trust rootu relaye.
Future<SepotSealedMessage> sealedDecrypt({
required Uint8List ciphertext,
required Uint8List trustRoot,
required int timestampMs,
}) async {
final result = await _sealedCipher.decrypt(
ciphertext: ciphertext,
trustRoot: trustRoot,
timestamp: timestampMs,
);
return SepotSealedMessage(
plaintext: result.plaintext,
senderName: result.senderAddress.name(),
senderDeviceId: result.senderAddress.deviceId(),
senderIdentityKey: Uint8List.fromList(
result.senderIdentityKey.serialize().toList(),
),
);
}
/// Vygeneruje dávku one-time prekeys (uloží privátní části do store,
/// vrátí veřejné k publikaci). Zásoba 100 ks, doplnění pod
/// prahem.
Future<List<SepotOneTimePreKey>> generateOneTimePreKeys({
required int startId,
required int count,
}) async {
final result = <SepotOneTimePreKey>[];
for (var i = 0; i < count; i++) {
final id = startId + i;
final private = PrivateKey.generate();
final public = private.getPublicKey();
await _stores.preKey.storePreKey(
id,
PreKeyRecord(id: id, publicKey: public, privateKey: private),
);
result.add(
SepotOneTimePreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
),
);
}
return result;
}
}