Š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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user