Š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
+261
View File
@@ -0,0 +1,261 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:base32/base32.dart';
import 'package:base32/encodings.dart';
import 'package:pointycastle/digests/blake2b.dart';
import 'package:sepot/core/crypto/mnemonic.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
/// Fingerprint identity — jediné user-facing ID Šepotu.
///
/// fingerprint = BLAKE2b-256( serializovaný identity pubkey, 33 B )
///
/// Zobrazuje se jako Crockford base32 (52 znaků), zkráceně „SEP-XXXXXXXXXX".
/// POZOR: NEPLÉST s `account_id` — to je náhodný 16B routing handle, který
/// přiděluje relay při registraci a nemá k identitě kryptografický vztah.
class SepotFingerprint {
SepotFingerprint(this.bytes)
: assert(bytes.length == 32, 'BLAKE2b-256 = 32 bytes');
/// Spočítá fingerprint ze serializovaného veřejného identity klíče.
factory SepotFingerprint.ofPublicKey(Uint8List identityPublicKey) {
final digest = Blake2bDigest(digestSize: 32);
return SepotFingerprint(digest.process(identityPublicKey));
}
final Uint8List bytes;
/// Plné zobrazení — 52 znaků Crockford base32, bez paddingu.
String get display =>
base32.encode(bytes, encoding: Encoding.crockford).toUpperCase();
/// UX zkratka do titulků, QR popisků a seznamů.
String get short => 'SEP-${display.substring(0, 10)}';
@override
String toString() => display;
@override
bool operator ==(Object other) =>
other is SepotFingerprint && display == other.display;
@override
int get hashCode => display.hashCode;
}
/// Lokální identita uživatele — identity keypair + zobrazované jméno.
///
/// Identita = klíč. Žádné UUID, žádný telefon, žádný e-mail. Obnova
/// přes 24slovný mnemonic (reverzibilní enkódování privátního klíče).
class LocalIdentity {
LocalIdentity({
required this.displayName,
required this.identityPrivateKey,
required this.identityPublicKey,
required this.createdAt,
}) : fingerprint = SepotFingerprint.ofPublicKey(identityPublicKey);
final String displayName;
/// 32 B, serializovaný (clampnutý). Nikdy neopouští zařízení jinak než
/// jako mnemonic záloha uživatele. ZÁKAZ kopírování do schránky.
final Uint8List identityPrivateKey;
/// 33 B, serializovaný (libsignal type byte + 32 B).
final Uint8List identityPublicKey;
final DateTime createdAt;
final SepotFingerprint fingerprint;
/// Vytvoř novou identitu (onboarding).
static Future<LocalIdentity> create(String displayName) async {
final keys = SepotCrypto.generateIdentityKeys();
return LocalIdentity(
displayName: displayName,
identityPrivateKey: keys.privateKey,
identityPublicKey: keys.publicKey,
createdAt: DateTime.now(),
);
}
/// Obnov identitu z 24slovného mnemonic (recovery flow).
///
/// Obnoví IDENTITU (stejný klíč → stejný fingerprint → „verified"
/// kontakty drží), ne historii zpráv — ta je díky forward secrecy pryč
/// a UI to musí říct.
static Future<LocalIdentity> recover({
required List<String> mnemonicWords,
required String displayName,
}) async {
final privateKey = SepotMnemonic.decode(mnemonicWords);
return LocalIdentity(
displayName: displayName,
identityPrivateKey: privateKey,
identityPublicKey: SepotCrypto.derivePublicKey(privateKey),
createdAt: DateTime.now(),
);
}
/// 24 slov zálohy identity. Zobrazit jen v chráněném recovery UI.
List<String> get mnemonic => SepotMnemonic.encode(identityPrivateKey);
/// Kompatibilní alias — dřív tu bylo UUID, teď je ID totožné
/// s fingerprintem (deterministicky z klíče).
String get id => fingerprint.display;
/// Šepot ID pro zobrazení v UI.
String get sepotId => fingerprint.short;
/// Veřejná identita (bezpečné ke sdílení).
PublicIdentity get publicIdentity => PublicIdentity(
displayName: displayName,
identityKey: identityPublicKey,
createdAt: createdAt,
);
Map<String, dynamic> toJson() => {
'v': 2,
'displayName': displayName,
'identityPrivateKey': base64Encode(identityPrivateKey),
'identityPublicKey': base64Encode(identityPublicKey),
'createdAt': createdAt.toIso8601String(),
};
factory LocalIdentity.fromJson(Map<String, dynamic> json) {
if (json['v'] != 2) {
throw const FormatException(
'Unsupported identity format (pre-libsignal era) — new onboarding required',
);
}
return LocalIdentity(
displayName: json['displayName'] as String,
identityPrivateKey: base64Decode(json['identityPrivateKey'] as String),
identityPublicKey: base64Decode(json['identityPublicKey'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
}
/// Veřejná identita — bezpečné ke sdílení, pro QR kódy a `sepot://` odkazy.
///
/// Vedle krypto identity (identity klíč → fingerprint) nese i ROUTING:
/// `accountId` (náhodný server handle — pojmenovává libsignal session
/// odesílatele, musí sedět se SenderCertificate) a `deliveryAddress`
/// (32B blind token, podle kterého relay směruje obálky). Bez nich nejde
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
class PublicIdentity {
PublicIdentity({
required this.displayName,
required this.identityKey,
required this.createdAt,
this.accountId,
this.deliveryAddress,
}) : fingerprint = SepotFingerprint.ofPublicKey(identityKey);
final String displayName;
/// 33 B, serializovaný veřejný identity klíč.
final Uint8List identityKey;
final DateTime createdAt;
final SepotFingerprint fingerprint;
/// Hex accountId na relayi (null = ještě neregistrováno / neznámo).
final String? accountId;
/// 32 B routing token na relayi (null = neznámo).
final Uint8List? deliveryAddress;
static const _linkPrefix = 'sepot://add/';
/// Kompatibilní alias na fingerprint (dřív UUID).
String get id => fingerprint.display;
/// Šepot ID pro zobrazení.
String get sepotId => fingerprint.short;
PublicIdentity withRouting({
required String accountId,
required Uint8List deliveryAddress,
}) =>
PublicIdentity(
displayName: displayName,
identityKey: identityKey,
createdAt: createdAt,
accountId: accountId,
deliveryAddress: deliveryAddress,
);
/// Export jako `sepot://add/...` odkaz (obsah QR kódu i sdílený link).
String toShareString() {
final payload = base64UrlEncode(
utf8.encode(jsonEncode({
'v': 2,
'n': displayName,
'ik': base64Encode(identityKey),
'ts': createdAt.toIso8601String(),
if (accountId != null) 'aid': accountId,
if (deliveryAddress != null) 'da': base64Encode(deliveryAddress!),
})),
);
return '$_linkPrefix$payload';
}
/// Import z odkazu / QR. Vrací null, pokud vstup není platný.
static PublicIdentity? fromShareString(String s) {
try {
var payload = s.trim();
if (payload.startsWith(_linkPrefix)) {
payload = payload.substring(_linkPrefix.length);
}
final map = jsonDecode(utf8.decode(base64Url.decode(payload)))
as Map<String, dynamic>;
if (map['v'] != 2) return null;
final identityKey = base64Decode(map['ik'] as String);
if (identityKey.length != 33) return null;
final da = map['da'] as String?;
Uint8List? deliveryAddress;
if (da != null) {
deliveryAddress = base64Decode(da);
// deliveryAddress je 32B routing token — vadný odkaz odmítni
// (stejně jako u identityKey), ať se do kontaktů nedostane nesmysl.
if (deliveryAddress.length != 32) return null;
}
return PublicIdentity(
displayName: map['n'] as String,
identityKey: identityKey,
createdAt: DateTime.parse(map['ts'] as String),
accountId: map['aid'] as String?,
deliveryAddress: deliveryAddress,
);
} catch (_) {
return null;
}
}
Map<String, dynamic> toJson() => {
'v': 2,
'displayName': displayName,
'identityKey': base64Encode(identityKey),
'createdAt': createdAt.toIso8601String(),
'accountId': accountId,
'deliveryAddress':
deliveryAddress == null ? null : base64Encode(deliveryAddress!),
};
factory PublicIdentity.fromJson(Map<String, dynamic> json) {
if (json['v'] != 2) {
throw const FormatException('Unsupported contact format');
}
final da = json['deliveryAddress'] as String?;
return PublicIdentity(
displayName: json['displayName'] as String,
identityKey: base64Decode(json['identityKey'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
accountId: json['accountId'] as String?,
deliveryAddress: da == null ? null : base64Decode(da),
);
}
}