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).
139 lines
4.3 KiB
Dart
139 lines
4.3 KiB
Dart
/// Š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;
|
|
}
|
|
}
|