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).
262 lines
8.9 KiB
Dart
262 lines
8.9 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|