Š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
+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)),
);
}
}