fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss

Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
This commit is contained in:
Julius Joska
2026-10-09 14:02:33 +02:00
parent 8b452a5593
commit e3a2378c34
33 changed files with 1055 additions and 162 deletions
@@ -1,5 +1,6 @@
package cz.sepot.sepot package cz.sepot.sepot
import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací // Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
window.setFlags( // Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
WindowManager.LayoutParams.FLAG_SECURE, // odemčení vypnout (default zapnuto, jako u Signalu).
WindowManager.LayoutParams.FLAG_SECURE, // Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
) // by u FLAG_SECURE okna selhal); release vždy chráněný.
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (!debuggable) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
}
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
// průhledný overlay nad PIN klávesnicí) se zahodí.
window.decorView.filterTouchesWhenObscured = true
} }
} }
+3 -1
View File
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final legacyPin = await storage.hasLegacyPin(); final legacyPin = await storage.hasLegacyPin();
await storage.unlockWithoutPin(); await storage.unlockWithoutPin();
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats'); if (!mounted) return;
ref.invalidate(screenSecurityProvider);
context.go(legacyPin ? '/pin-setup' : '/chats');
} }
} }
+26 -3
View File
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
_ringId = pushRing.id; _ringId = pushRing.id;
if (pushRing.accepted) { if (pushRing.accepted) {
await accept(); await accept();
} else if (CallRinger.declinedEarly.remove(pushRing.id)) { return;
await reject();
} }
if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
// nahradí se jménem volajícího (bez značky push).
await _claimPushRing(pushRing.id, name, video);
return; return;
} }
final foreground = final foreground =
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
} }
} }
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
Future<void> _claimPushRing(String pushId, String name, bool video) async {
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
await CallRinger.end(pushId);
_ringId = state.callId;
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
}
void _onRingAccept(String id) { void _onRingAccept(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) { if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept()); unawaited(accept());
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
return pc; return pc;
} }
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
/// kandidáti = hovor jen v jedné LAN.
static const _stunFallback = {
'urls': ['stun:turn.sepot.cz:3478'],
};
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN /// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
/// (přímé spojení, když obě strany nejsou za symetrickým NATem). /// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async { Future<List<Map<String, dynamic>>> _iceServers() async {
final turn = await ref.read(messagingServiceProvider).turnCredentials(); final turn = await ref.read(messagingServiceProvider).turnCredentials();
if (turn == null || turn.urls.isEmpty) return const []; if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
return [ return [
{ {
'urls': turn.urls, 'urls': turn.urls,
+50 -6
View File
@@ -22,6 +22,13 @@ class CallRinger {
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi. /// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
static const ringMillis = 45000; static const ringMillis = 45000;
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
/// 45 s na každou obálku s hintem CALL. Nabídka od
/// skutečného volajícího dorazí do pár sekund po připojení.
static const pushRingGrace = Duration(seconds: 12);
static StreamSubscription<CallEvent?>? _sub; static StreamSubscription<CallEvent?>? _sub;
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka. /// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
@@ -65,12 +72,41 @@ class CallRinger {
_params(id: callId, name: name, video: video)); _params(id: callId, name: name, video: video));
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme. /// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
static Future<void> ringFromPush() => FlutterCallkitIncoming /// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
.showCallkitIncoming(_params( /// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
id: const Uuid().v4(), /// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
name: _appName, /// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
video: false, static Future<void> ringFromPush() async {
extra: {pushMarker: true})); final id = const Uuid().v4();
await FlutterCallkitIncoming.showCallkitIncoming(_params(
id: id, name: _appName, video: false, extra: {pushMarker: true}));
await Future<void>.delayed(pushRingGrace);
await endIfUnmatched(id);
}
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
static Future<void> expireStalePushRings() async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.extra?[pushMarker] == true && !call.isAccepted) {
final id = call.id;
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
}
}
}
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
/// o zmeškaném hovoru — nebylo od koho).
static Future<void> endIfUnmatched(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
await FlutterCallkitIncoming.endCall(id);
}
}
}
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho /// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla. /// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
@@ -84,6 +120,14 @@ class CallRinger {
return null; return null;
} }
static Future<bool> isAccepted(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id) return call.isAccepted;
}
return false;
}
static Future<void> connected(String id) => static Future<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id); FlutterCallkitIncoming.setCallConnected(id);
+66 -3
View File
@@ -53,16 +53,20 @@ class DekWrap {
int memoryKiB = defaultMemoryKiB, int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations, int iterations = defaultIterations,
int parallelism = defaultParallelism, int parallelism = defaultParallelism,
Uint8List? salt,
}) async { }) async {
if (dek.length != 32) { if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes'); throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
} }
_enforceFloor(memoryKiB, iterations); _enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16); // Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
final useSalt = salt ?? _randomBytes(16);
final unlockKey = await _deriveUnlockKey( final unlockKey = await _deriveUnlockKey(
pin: pin, pin: pin,
salt: salt, salt: useSalt,
memoryKiB: memoryKiB, memoryKiB: memoryKiB,
iterations: iterations, iterations: iterations,
parallelism: parallelism, parallelism: parallelism,
@@ -80,13 +84,72 @@ class DekWrap {
'$memoryKiB', '$memoryKiB',
'$iterations', '$iterations',
'$parallelism', '$parallelism',
base64Encode(salt), base64Encode(useSalt),
base64Encode(box.nonce), base64Encode(box.nonce),
base64Encode(box.cipherText), base64Encode(box.cipherText),
base64Encode(box.mac.bytes), base64Encode(box.mac.bytes),
].join(':'); ].join(':');
} }
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
static Uint8List saltOf(String wrapped) {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
return Uint8List.fromList(base64Decode(parts[4]));
}
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
/// [WrongPinException].
static Future<(int, Uint8List)> unwrapFirstMatching({
required List<String> wrapped,
required String pin,
}) async {
final parsed = <List<String>>[];
for (final w in wrapped) {
final parts = w.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', w);
}
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
parsed.add(parts);
}
final keys = <String, Uint8List>{};
for (final parts in parsed) {
final group = parts.sublist(1, 5).join(':');
keys[group] ??= await _deriveUnlockKey(
pin: pin,
salt: base64Decode(parts[4]),
memoryKiB: int.parse(parts[1]),
iterations: int.parse(parts[2]),
parallelism: int.parse(parts[3]),
);
}
final aes = AesGcm.with256bits();
for (var i = 0; i < parsed.length; i++) {
final parts = parsed[i];
try {
final dek = await aes.decrypt(
SecretBox(
base64Decode(parts[6]),
nonce: base64Decode(parts[5]),
mac: Mac(base64Decode(parts[7])),
),
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
);
return (i, Uint8List.fromList(dek));
} on SecretBoxAuthenticationError {
continue;
}
}
throw const WrongPinException();
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí /// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException]. /// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({ static Future<Uint8List> unwrap({
+86
View File
@@ -81,6 +81,32 @@ class SepotSealedMessage {
} }
/// Jeden one-time prekey k publikaci na relay. /// Jeden one-time prekey k publikaci na relay.
/// Nový signed prekey k nahrání na relay (rotace).
class SepotSignedPreKey {
SepotSignedPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
/// Nový last-resort Kyber prekey k nahrání na relay.
class SepotKyberPreKey {
SepotKyberPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
class SepotOneTimePreKey { class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey}); SepotOneTimePreKey({required this.id, required this.publicKey});
@@ -420,4 +446,64 @@ class SepotCrypto {
} }
return result; return result;
} }
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
} }
+96 -9
View File
@@ -64,9 +64,17 @@ class RelayClient {
final _deliverController = StreamController<pb.Envelope>.broadcast(); final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast(); final _receiptController = StreamController<pb.Receipt>.broadcast();
final _turnController = StreamController<pb.TurnCredentials>.broadcast(); final _turnController = StreamController<pb.TurnCredentials>.broadcast();
final _prekeysLowController = StreamController<int>.broadcast();
var _state = RelayConnectionState.disconnected; var _state = RelayConnectionState.disconnected;
/// Kdy naposled přišel aplikační rámec od relaye.
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _staleAfter = Duration(seconds: 20);
/// Relay hlásí docházející one-time prekeys (zbývající počet).
Stream<int> get prekeysLow => _prekeysLowController.stream;
RelayConnectionState get state => _state; RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream; Stream<RelayConnectionState> get stateStream => _stateController.stream;
@@ -100,9 +108,18 @@ class RelayClient {
required List<SepotOneTimePreKey> oneTimePreKeys, required List<SepotOneTimePreKey> oneTimePreKeys,
required Uint8List deliveryAddress, required Uint8List deliveryAddress,
}) async { }) async {
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
// narazí na relayi na už existující identitu (409) a bez tohohle
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
// ignoruje.
final proofTs = DateTime.now().millisecondsSinceEpoch;
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
final resp = await _transport.httpClient().post( final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/register'), Uri.parse('$baseUrl/v1/register'),
headers: {'content-type': 'application/json'}, headers: {
'content-type': 'application/json',
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
},
body: jsonEncode({ body: jsonEncode({
'identity_key': base64Encode(_crypto.identityPublicKey), 'identity_key': base64Encode(_crypto.identityPublicKey),
'device_id': 1, 'device_id': 1,
@@ -144,6 +161,49 @@ class RelayClient {
_crypto.bindAccount(accountId); _crypto.bindAccount(accountId);
} }
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
/// prekey. Vrací počet OTP na relayi po nahrání.
Future<int> uploadKeys({
SepotSignedPreKey? signedPreKey,
SepotKyberPreKey? kyberPreKey,
List<SepotOneTimePreKey> oneTimePreKeys = const [],
}) async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/keys'),
headers: {
'content-type': 'application/json',
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
},
body: jsonEncode({
if (signedPreKey != null)
'signed_prekey': {
'key_id': signedPreKey.id,
'public_key': base64Encode(signedPreKey.publicKey),
'signature': base64Encode(signedPreKey.signature),
},
if (kyberPreKey != null)
'kyber_prekey': {
'key_id': kyberPreKey.id,
'public_key': base64Encode(kyberPreKey.publicKey),
'signature': base64Encode(kyberPreKey.signature),
'is_last_resort': true,
},
'one_time_prekeys': [
for (final otp in oneTimePreKeys)
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
],
}),
);
if (resp.statusCode != 200) {
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
}
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP). /// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async { Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
final hexAddr = deliveryAddress final hexAddr = deliveryAddress
@@ -280,6 +340,20 @@ class RelayClient {
_setState(RelayConnectionState.disconnected); _setState(RelayConnectionState.disconnected);
} }
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
void kick() {
if (_state == RelayConnectionState.connected &&
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
_reconnectDelayS = 1;
_onDisconnected();
return;
}
start();
}
Future<void> _teardownChannel() async { Future<void> _teardownChannel() async {
await _wsSub?.cancel(); await _wsSub?.cancel();
_wsSub = null; _wsSub = null;
@@ -313,6 +387,7 @@ class RelayClient {
_wsSub = channel.stream.listen( _wsSub = channel.stream.listen(
(data) { (data) {
final frame = pb.ServerFrame.fromBuffer(data as List<int>); final frame = pb.ServerFrame.fromBuffer(data as List<int>);
_lastFrameAt = DateTime.now();
if (!authenticated) { if (!authenticated) {
if (frame.hasChallenge()) { if (frame.hasChallenge()) {
final signature = _crypto.sign([ final signature = _crypto.sign([
@@ -345,6 +420,10 @@ class RelayClient {
if (!_turnController.isClosed) { if (!_turnController.isClosed) {
_turnController.add(frame.turnCredentials); _turnController.add(frame.turnCredentials);
} }
} else if (frame.hasPrekeysLow()) {
if (!_prekeysLowController.isClosed) {
_prekeysLowController.add(frame.prekeysLow.remaining);
}
} }
}, },
onDone: _onDisconnected, onDone: _onDisconnected,
@@ -389,14 +468,21 @@ class RelayClient {
final receiptFuture = _receiptController.stream final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid)) .firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout); .timeout(timeout);
_send(pb.ClientFrame( try {
envelope: pb.Envelope( _send(pb.ClientFrame(
dstAddress: dstAddress, envelope: pb.Envelope(
msgUuid: msgUuid, dstAddress: dstAddress,
contentHint: hint, msgUuid: msgUuid,
payload: payload, contentHint: hint,
), payload: payload,
)); ),
));
} catch (_) {
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
// hodil neodchycený TimeoutException do zvonku chyb.
receiptFuture.ignore();
rethrow;
}
return receiptFuture; return receiptFuture;
} }
@@ -455,6 +541,7 @@ class RelayClient {
_transport.close(); _transport.close();
await _stateController.close(); await _stateController.close();
await _turnController.close(); await _turnController.close();
await _prekeysLowController.close();
await _deliverController.close(); await _deliverController.close();
await _receiptController.close(); await _receiptController.close();
} }
+2
View File
@@ -87,6 +87,8 @@ class PushBootstrap {
if (Platform.isIOS) { if (Platform.isIOS) {
ApnsBridge.onWake = _wake; ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler(); ApnsBridge.ensureHandler();
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
unawaited(CallRinger.expireStalePushRings());
} else { } else {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
+9 -1
View File
@@ -52,7 +52,15 @@ class PushService {
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.) /// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted; bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake(); /// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) { void handleNewEndpoint(String url) {
_messaging.registerPush(_registration( _messaging.registerPush(_registration(
+218 -28
View File
@@ -107,6 +107,56 @@ class MessagingService {
StreamSubscription<RelayConnectionState>? _stateSub; StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer; Timer? _registerRetryTimer;
Timer? _purgeTimer; Timer? _purgeTimer;
StreamSubscription<int>? _prekeysLowSub;
/// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se
/// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná
/// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka
/// hovoru se tiše zahodila — hovory vůbec nešly přijmout.
static const knownContentTypes = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
..._groupControlTypes,
};
/// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax].
Duration _registerRetryDelay = const Duration(seconds: 30);
static const _registerRetryMax = Duration(minutes: 10);
/// Relay odmítl registraci jako konflikt identity (409) — typicky starší
/// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit.
bool _registrationConflict = false;
bool get registrationConflict => _registrationConflict;
/// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB
/// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva
/// by byla nenávratně pryč.
int _inFlight = 0;
Completer<void>? _drained;
/// Operace nad ratchet stavem jdou postupně. libsignal načte relaci,
/// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva
/// souběžné běhy nad touž relací by odvodily stejný message key a druhá
/// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží
/// `unawaited` vedle uživatelského odeslání, takže zámek je nutný.
Future<void> _chain = Future.value();
Future<T> _serial<T>(Future<T> Function() op) {
final next = _chain.then((_) => op());
_chain = next.then((_) {}, onError: (_) {});
return next;
}
/// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu.
static const maxDisplayNameLength = 64;
static String clipName(String name) => name.length > maxDisplayNameLength
? name.substring(0, maxDisplayNameLength)
: name;
/// Kolik one-time prekeys doplnit, když relay hlásí nedostatek.
static const _otpBatch = 50;
static const _spkRotation = Duration(days: 7);
static const _kyberRotation = Duration(days: 30);
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít /// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly /// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
@@ -266,11 +316,13 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) { _stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) { if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox()); unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld()); unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
} }
}); });
@@ -337,11 +389,13 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) { _stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) { if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox()); unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld()); unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
} }
}); });
@@ -376,16 +430,109 @@ class MessagingService {
Future<void> _registerWithRetry() async { Future<void> _registerWithRetry() async {
try { try {
await _register(); await _register();
} on RelayException catch (e) {
// 409 = identita na relayi existuje a relay neumí převzetí (starší
// verze). Opakovat má smysl (relay se aktualizuje), ale ne každých
// 30 s navždy — a UI má vědět proč.
_registrationConflict = e.statusCode == 409;
_scheduleRegisterRetry();
return;
} catch (_) { } catch (_) {
_registerRetryTimer?.cancel(); _scheduleRegisterRetry();
_registerRetryTimer =
Timer(const Duration(seconds: 30), _registerWithRetry);
return; return;
} }
_registrationConflict = false;
_registerRetryDelay = const Duration(seconds: 30);
_relay!.start(); _relay!.start();
await _refreshSenderCert(); await _refreshSenderCert();
} }
void _scheduleRegisterRetry() {
_registerRetryTimer?.cancel();
_registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry);
final doubled = _registerRetryDelay * 2;
_registerRetryDelay =
doubled > _registerRetryMax ? _registerRetryMax : doubled;
onUpdate?.call();
}
int? _kvInt(CryptoDb db, String key) {
final raw = db.kvGet(key);
return raw == null ? null : int.tryParse(utf8.decode(raw));
}
/// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku
/// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP
/// a každá nová relace k němu běžela bez jednorázového klíče.
Future<void> _replenishPreKeys(int remaining) async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
try {
// Registrace nahrála id 1–100; další dávky navazují.
final next = _kvInt(db, 'otp_next_id') ?? 101;
final otps = await _serial(
() => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch));
await relay.uploadKeys(oneTimePreKeys: otps);
db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}'));
} catch (_) {
// Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH.
}
}
/// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně),
/// Staré klíče zůstávají ve store — relace z bundle
/// staženého těsně před rotací je ještě potřebují.
Future<void> _rotatePreKeysIfDue() async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
final now = DateTime.now();
bool due(String key, Duration period) {
final at = _kvInt(db, key);
return at == null ||
now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period;
}
final spkDue = due('spk_rotated_at', _spkRotation);
final kyberDue = due('kyber_rotated_at', _kyberRotation);
if (!spkDue && !kyberDue) return;
try {
SepotSignedPreKey? spk;
SepotKyberPreKey? kyber;
if (spkDue) {
final id = _kvInt(db, 'spk_next_id') ?? 2;
spk = await _serial(() => crypto.generateSignedPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
if (kyberDue) {
final id = _kvInt(db, 'kyber_next_id') ?? 2;
kyber = await _serial(() => crypto.generateKyberPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber);
final stamp = utf8.encode('${now.millisecondsSinceEpoch}');
db.transaction(() {
if (spk != null) {
db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}'));
db.kvPut('spk_rotated_at', stamp);
}
if (kyber != null) {
db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}'));
db.kvPut('kyber_rotated_at', stamp);
}
});
} catch (_) {
// Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu.
}
}
Future<void> _register() async { Future<void> _register() async {
final db = _db!; final db = _db!;
final crypto = _crypto!; final crypto = _crypto!;
@@ -410,10 +557,17 @@ class MessagingService {
deliveryAddress: deliveryAddress, deliveryAddress: deliveryAddress,
); );
final stamp = utf8.encode('$nowMs');
db.transaction(() { db.transaction(() {
db.kvPut('account_id', utf8.encode(reg.accountId)); db.kvPut('account_id', utf8.encode(reg.accountId));
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid)); db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
db.kvPut('delivery_address', deliveryAddress); db.kvPut('delivery_address', deliveryAddress);
// Id pro doplňování a rotace klíčů navazují na registraci.
db.kvPut('otp_next_id', utf8.encode('101'));
db.kvPut('spk_next_id', utf8.encode('2'));
db.kvPut('kyber_next_id', utf8.encode('2'));
db.kvPut('spk_rotated_at', stamp);
db.kvPut('kyber_rotated_at', stamp);
}); });
onUpdate?.call(); onUpdate?.call();
} }
@@ -2009,6 +2163,10 @@ class MessagingService {
'txt': caption ?? '', 'txt': caption ?? '',
're': ?message.replyToId, 're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds, 'ttl': ?message.disappearAfterSeconds,
// Anketa a zmínky patří k téže zprávě i při dodatečném odeslání —
// bez nich by po reconnectu dorazil jen holý záložní text.
'poll': ?message.poll?.toWire(),
if (message.mentions.isNotEmpty) 'mn': message.mentions,
'p': _profile(), 'p': _profile(),
}; };
} }
@@ -2053,22 +2211,27 @@ class MessagingService {
} }
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1); final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět // Krypto část jde přes [_serial] (viz komentář u pole): relace se
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní). // načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
if (!await crypto.hasSession(peerAddr)) { // nedrží — čekání na receipt by blokovalo všechny ostatní.
if (ephemeral) return null; final sealed = await _serial<Uint8List?>(() async {
final bundle = await relay.fetchBundle(peerDa); // První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
if (!_listEquals(bundle.identityKey, peer.identityKey)) { // s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
throw const IdentityKeyMismatchError(); if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
throw const IdentityKeyMismatchError();
}
await crypto.processRemoteBundle(peerAddr, bundle);
} }
await crypto.processRemoteBundle(peerAddr, bundle); return crypto.sealedEncrypt(
} recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
final sealed = await crypto.sealedEncrypt( senderCertificate: await _senderCertificate(),
recipient: peerAddr, );
plaintext: utf8.encode(jsonEncode(content)), });
senderCertificate: await _senderCertificate(), if (sealed == null) return null;
);
final receipt = await relay.sendEnvelope( final receipt = await relay.sendEnvelope(
dstAddress: peerDa, dstAddress: peerDa,
@@ -2084,10 +2247,25 @@ class MessagingService {
// ── Příjem ────────────────────────────────────────────────────────── // ── Příjem ──────────────────────────────────────────────────────────
Future<void> _onDeliver(pb.Envelope envelope) async { /// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
/// rozpracované, aby na ně [stop] počkal.
void _onDeliver(pb.Envelope envelope) {
_inFlight++;
unawaited(_serial(() => _handleDelivery(envelope))
.then((_) {}, onError: (_) {})
.whenComplete(() {
_inFlight--;
if (_inFlight == 0) _drained?.complete();
}));
}
Future<void> _handleDelivery(pb.Envelope envelope) async {
final crypto = _crypto; final crypto = _crypto;
final trustRoot = _trustRoot; final trustRoot = _trustRoot;
if (crypto == null || trustRoot == null) return; // Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali
// na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal.
// Relay obálku doručí znovu po odemčení.
if (crypto == null || trustRoot == null || !storage.isUnlocked) return;
final SepotSealedMessage opened; final SepotSealedMessage opened;
try { try {
@@ -2110,10 +2288,7 @@ class MessagingService {
final content = final content =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>; jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
final type = content['t'] as String?; final type = content['t'] as String?;
const known = { const known = knownContentTypes;
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
..._groupControlTypes,
};
if (content['v'] != 1 || !known.contains(type)) { if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný // Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit. // a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
@@ -2228,13 +2403,18 @@ class MessagingService {
final replyTo = content['re'] as String?; final replyTo = content['re'] as String?;
final Message message; final Message message;
if (type == 'txt') { if (type == 'txt') {
final text = content['txt']; final raw = content['txt'];
if (text is! String) { if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty // Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh. // nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid); _relay!.ack(envelope.msgUuid);
return; return;
} }
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
// JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila.
const maxWireText = AppConstants.maxMessageLength * 4;
final text =
raw.length > maxWireText ? raw.substring(0, maxWireText) : raw;
message = Message( message = Message(
id: messageId, id: messageId,
senderId: contact.publicIdentity.fingerprint.display, senderId: contact.publicIdentity.fingerprint.display,
@@ -2567,7 +2747,7 @@ class MessagingService {
// Prázdné jméno = UI si doplní lokalizovaný fallback // Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim(); final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity( final identity = PublicIdentity(
displayName: (name == null || name.isEmpty) ? '' : name, displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: opened.senderIdentityKey, identityKey: opened.senderIdentityKey,
createdAt: DateTime.now(), createdAt: DateTime.now(),
accountId: opened.senderName, accountId: opened.senderName,
@@ -2603,7 +2783,7 @@ class MessagingService {
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se /// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní. /// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
void onPushWake() { void onPushWake() {
_relay?.start(); _relay?.kick();
} }
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu"). /// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
@@ -2631,7 +2811,17 @@ class MessagingService {
_purgeTimer?.cancel(); _purgeTimer?.cancel();
_purgeTimer = null; _purgeTimer = null;
await _deliverSub?.cancel(); await _deliverSub?.cancel();
await _prekeysLowSub?.cancel();
await _stateSub?.cancel(); await _stateSub?.cancel();
// Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve
// pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje
// zámek navždy.
if (_inFlight > 0) {
final drained = _drained ??= Completer<void>();
await drained.future
.timeout(const Duration(seconds: 8), onTimeout: () {});
}
_drained = null;
await _relay?.dispose(); await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit // DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null; _db = null;
+136 -29
View File
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
/// při onboardingu, který DB rovnou otevře). /// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit. /// Nátlakový PIN je shodný se skutečným — nelze nastavit.
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
class PinLockedException implements Exception {
const PinLockedException(this.remaining);
final Duration remaining;
@override
String toString() => 'PIN locked for ${remaining.inSeconds}s';
}
class DecoyPinConflict implements Exception { class DecoyPinConflict implements Exception {
const DecoyPinConflict(); const DecoyPinConflict();
@override @override
@@ -51,6 +61,13 @@ class SecureStorageService {
static const _legacyPinHashKey = 'pin_hash'; static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy'; static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
static const _pinFailCountKey = 'pin_fail_count';
static const _pinLockUntilKey = 'pin_lock_until';
static const _pinFreeAttempts = 5;
static const _pinLockBase = Duration(seconds: 30);
static const _pinLockMax = Duration(minutes: 15);
final FlutterSecureStorage _secureStorage; final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride; final String? _dbPathOverride;
@@ -60,12 +77,24 @@ class SecureStorageService {
bool _decoyMode = false; bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB. /// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
bool get isDecoyMode => _decoyMode; bool get isDecoyMode => _decoyMode;
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
LocalIdentity? _decoyIdentity;
void _requireRealSession() {
if (_decoyMode) throw StateError('Not available in a duress session');
}
SecureStorageService({String? dbPath}) SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage( : _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock), // this_device: identita a klíče nesmí putovat v iCloud Keychain
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device),
), ),
_dbPathOverride = dbPath; _dbPathOverride = dbPath;
@@ -98,21 +127,59 @@ class SecureStorageService {
if (wrapped == null) { if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin'); throw StateError('No PIN set — use unlockWithoutPin');
} }
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
try { try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin); // Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
await _openDb(dek); // který PIN padl.
final (index, dek) = await DekWrap.unwrapFirstMatching(
wrapped: [wrapped, ?decoy],
pin: pin,
);
await _openDb(dek, decoy: index == 1);
await _resetPinFailures();
} on WrongPinException { } on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey); await _notePinFailure();
if (decoy == null) rethrow; rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
} }
} }
/// Zbývající zámek po sérii špatných PINů; null = volno.
Future<Duration?> pinLockRemaining() async {
final raw = await _secureStorage.read(key: _pinLockUntilKey);
final until = raw == null ? null : int.tryParse(raw);
if (until == null) return null;
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
.difference(DateTime.now());
return remaining > Duration.zero ? remaining : null;
}
Future<void> _notePinFailure() async {
final raw = await _secureStorage.read(key: _pinFailCountKey);
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
if (failures < _pinFreeAttempts) return;
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
if (lock > _pinLockMax) lock = _pinLockMax;
await _secureStorage.write(
key: _pinLockUntilKey,
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
);
}
Future<void> _resetPinFailures() async {
await _secureStorage.delete(key: _pinFailCountKey);
await _secureStorage.delete(key: _pinLockUntilKey);
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže. /// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře. /// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async { Future<void> enablePin(String pin) async {
_requireRealSession();
final dek = _dek ?? await _getOrCreateRawDek(); final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin); final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped); await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
@@ -124,6 +191,7 @@ class SecureStorageService {
/// Vypne PIN: DEK se vrátí raw do OS keystore. /// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async { Future<void> disablePin() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek)); await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey); await _secureStorage.delete(key: _wrappedDekKey);
@@ -151,6 +219,7 @@ class SecureStorageService {
required String oldPin, required String oldPin,
required String newPin, required String newPin,
}) async { }) async {
_requireRealSession();
final wrapped = await _secureStorage.read(key: _wrappedDekKey); final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set'); if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin); final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
@@ -165,7 +234,11 @@ class SecureStorageService {
} }
} }
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin); // Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
final rewrapped = await DekWrap.wrap(
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped); await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
} }
@@ -176,6 +249,7 @@ class SecureStorageService {
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy /// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit. /// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async { Future<void> enableDecoyPin(String decoyPin) async {
_requireRealSession();
final realWrapped = await _secureStorage.read(key: _wrappedDekKey); final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) { if (realWrapped == null) {
throw StateError('Set a regular PIN first'); throw StateError('Set a regular PIN first');
@@ -193,7 +267,9 @@ class SecureStorageService {
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB // Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true); final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close(); CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin); // Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
final wrapped = await DekWrap.wrap(
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped); await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
} }
@@ -203,11 +279,20 @@ class SecureStorageService {
/// Zruší nátlakový PIN a smaže decoy DB. /// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async { Future<void> disableDecoyPin() async {
_requireRealSession();
await _secureStorage.delete(key: _decoyWrappedDekKey); await _secureStorage.delete(key: _decoyWrappedDekKey);
try { await _deleteDbFiles(await _dbPath(decoy: true));
final f = File(await _dbPath(decoy: true)); }
if (f.existsSync()) f.deleteSync();
} catch (_) {} Future<void> _deleteDbFiles(String path) async {
for (final suffix in ['', '-wal', '-shm']) {
try {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
} catch (_) {
// best-effort úklid
}
}
} }
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme /// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
@@ -222,6 +307,8 @@ class SecureStorageService {
_db?.close(); _db?.close();
_db = null; _db = null;
_dek = null; _dek = null;
_decoyMode = false;
_decoyIdentity = null;
} }
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async { Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
@@ -293,6 +380,11 @@ class SecureStorageService {
/// Načti lokální identitu /// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async { Future<LocalIdentity?> loadIdentity() async {
if (_decoyMode) {
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
return _decoyIdentity ??= await LocalIdentity.create('');
}
final json = await _secureStorage.read(key: _identityKey); final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null; if (json == null) return null;
try { try {
@@ -320,6 +412,7 @@ class SecureStorageService {
} }
Future<List<String>?> loadSeedPhrase() async { Future<List<String>?> loadSeedPhrase() async {
if (_decoyMode) return null;
final json = await _secureStorage.read(key: _seedPhraseKey); final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null; if (json == null) return null;
return (jsonDecode(json) as List).cast<String>(); return (jsonDecode(json) as List).cast<String>();
@@ -331,6 +424,7 @@ class SecureStorageService {
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings). /// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním. /// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async { Future<void> enableBiometrics() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage')); final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write( await _secureStorage.write(
key: _biometricDekKey, key: _biometricDekKey,
@@ -342,6 +436,7 @@ class SecureStorageService {
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý /// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id). /// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async { Future<void> disableBiometrics() async {
_requireRealSession();
await _secureStorage.delete(key: _biometricDekKey); await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false'); await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
} }
@@ -475,24 +570,29 @@ class SecureStorageService {
if (!isUnlocked) return false; if (!isUnlocked) return false;
var changed = false; var changed = false;
final now = DateTime.now(); final now = DateTime.now();
for (final conv in await loadConversations()) { bool isExpired(Message m) =>
final expired = conv.messages m.disappearAfterSeconds != null &&
.where((m) => m.timestamp
m.disappearAfterSeconds != null && .add(Duration(seconds: m.disappearAfterSeconds!))
m.timestamp .isBefore(now);
.add(Duration(seconds: m.disappearAfterSeconds!)) for (final snapshot in await loadConversations()) {
.isBefore(now)) if (!snapshot.messages.any(isExpired)) continue;
.toList(); // Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
final conv = await loadConversation(snapshot.contactId);
if (conv == null) continue;
final expired = conv.messages.where(isExpired).toList();
if (expired.isEmpty) continue; if (expired.isEmpty) continue;
changed = true; changed = true;
conv.messages.removeWhere(isExpired);
await saveConversation(conv);
for (final m in expired) { for (final m in expired) {
final blobId = m.attachment?.blobId; final blobId = m.attachment?.blobId;
if (blobId != null) { if (blobId != null) {
await AttachmentStore().delete(blobId); await AttachmentStore().delete(blobId);
} }
conv.messages.remove(m);
} }
await saveConversation(conv);
} }
return changed; return changed;
} }
@@ -512,12 +612,19 @@ class SecureStorageService {
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore /// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh. /// i lokální bloby příloh.
Future<void> deleteAll() async { Future<void> deleteAll() async {
final path = await _dbPath(); if (_decoyMode) {
lock(); // Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
for (final suffix in ['', '-wal', '-shm']) { // data a keystore zůstávají (dřív to smazalo reálný účet).
final f = File('$path$suffix'); final decoyPath = await _dbPath(decoy: true);
if (f.existsSync()) f.deleteSync(); lock();
await _deleteDbFiles(decoyPath);
return;
} }
final path = await _dbPath();
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(path);
await _deleteDbFiles(decoyPath);
await AttachmentStore().deleteAll(); await AttachmentStore().deleteAll();
await _secureStorage.deleteAll(); await _secureStorage.deleteAll();
} }
+20 -2
View File
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/crypto/dek_wrap.dart'; import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart'; import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart'; import 'package:sepot/widgets/sepot_logo.dart';
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
try { try {
final ok = await LocalAuthentication().authenticate( final ok = await LocalAuthentication().authenticate(
localizedReason: unlockReason, localizedReason: unlockReason,
options: const AuthenticationOptions(stickyAuth: true), // biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
); );
if (!ok) return; // uživatel zrušil → zůstává PIN if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics(); await storage.unlockWithBiometrics();
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
void _restartMessagingAfterUnlock() { void _restartMessagingAfterUnlock() {
ref.invalidate(messagingStartProvider); ref.invalidate(messagingStartProvider);
ref.invalidate(pushInitProvider); ref.invalidate(pushInitProvider);
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
// neaplikoval, teď má skutečnou hodnotu.
ref.invalidate(screenSecurityProvider);
} }
Future<void> _verifyPin() async { Future<void> _verifyPin() async {
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
_restartMessagingAfterUnlock(); _restartMessagingAfterUnlock();
if (mounted) context.go('/chats'); if (mounted) context.go('/chats');
} on WrongPinException { } on WrongPinException {
final locked = await ref.read(storageProvider).pinLockRemaining();
if (!mounted) return;
setState(() { setState(() {
_error = context.l10n.pinWrong2; _error = locked == null
? context.l10n.pinWrong2
: context.l10n.pinLockedFor(locked.inSeconds);
_pin = ''; _pin = '';
_isVerifying = false; _isVerifying = false;
}); });
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
} on PinLockedException catch (e) {
setState(() {
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
_pin = '';
_isVerifying = false;
});
} on StateError { } on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding // Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding'); if (mounted) context.go('/onboarding');
+8 -3
View File
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
), ),
), ),
SafeArea( // Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
child: Column( // fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
children: [ // smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
Positioned.fill(
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 48), const SizedBox(height: 48),
if (!showVideo) ...[ if (!showVideo) ...[
SepotAvatar( SepotAvatar(
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_InCallControls(call: call, controller: controller), _InCallControls(call: call, controller: controller),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
),
), ),
), ),
if (showVideo && !call.cameraOff) if (showVideo && !call.cameraOff)
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
} }
final ok = await auth.authenticate( final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason, localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true), options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
); );
if (!ok) return; if (!ok) return;
await storage.enableBiometrics(); await storage.enableBiometrics();
+73 -57
View File
@@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider); final identityAsync = ref.watch(identityProvider);
final l10n = context.l10n; final l10n = context.l10n;
// Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet
// (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá.
final decoy = ref.watch(storageProvider).isDecoyMode;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Zabezpečení // Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity), _buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile( if (!decoy) ...[
icon: Icons.pin_outlined, _buildTile(
title: l10n.settingsChangePinTitle, icon: Icons.pin_outlined,
subtitle: l10n.settingsChangePinSubtitle, title: l10n.settingsChangePinTitle,
onTap: () => context.push('/change-pin'), subtitle: l10n.settingsChangePinSubtitle,
), onTap: () => context.push('/change-pin'),
const BiometricsTile(), ),
const BiometricsTile(),
],
_buildTile( _buildTile(
icon: Icons.timer_outlined, icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle, title: l10n.settingsAutoLockTitle,
@@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget {
), ),
onTap: () => _nastavAutoLock(context, ref), onTap: () => _nastavAutoLock(context, ref),
), ),
_buildTile( if (!decoy)
icon: Icons.password_outlined, _buildTile(
title: l10n.settingsDecoyPinTitle, icon: Icons.password_outlined,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true title: l10n.settingsDecoyPinTitle,
? l10n.settingsDecoyPinSetSubtitle subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
: l10n.settingsDecoyPinUnsetSubtitle, ? l10n.settingsDecoyPinSetSubtitle
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true : l10n.settingsDecoyPinUnsetSubtitle,
? TextButton( trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
onPressed: () => _disableDecoyPin(context, ref), ? TextButton(
child: Text(l10n.commonCancel), onPressed: () => _disableDecoyPin(context, ref),
) child: Text(l10n.commonCancel),
: null, )
onTap: () { : null,
if (ref.read(hasDecoyPinProvider).valueOrNull != true) { onTap: () {
_setDecoyPin(context, ref); if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
} _setDecoyPin(context, ref);
}, }
), },
),
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Notifikace // Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications), _buildSectionHeader(l10n.settingsSectionNotifications),
@@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Sekce Záloha // Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup), if (!decoy) ...[
_buildTile( _buildSectionHeader(l10n.settingsSectionBackup),
icon: Icons.key_outlined, _buildTile(
title: l10n.settingsSeedPhraseTitle, icon: Icons.key_outlined,
subtitle: l10n.settingsSeedPhraseSubtitle, title: l10n.settingsSeedPhraseTitle,
onTap: () => _showSeedPhrase(context, ref), subtitle: l10n.settingsSeedPhraseSubtitle,
), onTap: () => _showSeedPhrase(context, ref),
_buildTile( ),
icon: Icons.backup_outlined, _buildTile(
title: l10n.settingsBackupExportTitle, icon: Icons.backup_outlined,
subtitle: l10n.settingsBackupExportSubtitle, title: l10n.settingsBackupExportTitle,
onTap: () => _exportBackup(context, ref), subtitle: l10n.settingsBackupExportSubtitle,
), onTap: () => _exportBackup(context, ref),
_buildTile( ),
icon: Icons.restore_outlined, _buildTile(
title: l10n.settingsBackupImportTitle, icon: Icons.restore_outlined,
subtitle: l10n.settingsBackupImportSubtitle, title: l10n.settingsBackupImportTitle,
onTap: () => _importBackup(context, ref), subtitle: l10n.settingsBackupImportSubtitle,
), onTap: () => _importBackup(context, ref),
),
],
// „Web klient" byl slib funkce, která neexistuje (multi-device je // „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek. // vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Smazat účet // Smazat účet
Padding( if (!decoy)
padding: const EdgeInsets.symmetric(horizontal: 16), Padding(
child: OutlinedButton( padding: const EdgeInsets.symmetric(horizontal: 16),
onPressed: () => _confirmDeleteAccount(context, ref), child: OutlinedButton(
style: OutlinedButton.styleFrom( onPressed: () => _confirmDeleteAccount(context, ref),
foregroundColor: SepotColors.error, style: OutlinedButton.styleFrom(
side: const BorderSide(color: SepotColors.error), foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
), ),
child: Text(l10n.settingsDeleteAllDataButton),
), ),
),
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
), ),
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup'; final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes); await File(path).writeAsBytes(bytes);
await Share.shareXFiles( try {
[XFile(path, mimeType: 'application/octet-stream')], await Share.shareXFiles(
subject: l10n.backupShareSubject, [XFile(path, mimeType: 'application/octet-stream')],
); subject: l10n.backupShareSubject,
);
} finally {
// Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení.
try {
await File(path).delete();
} catch (_) {}
}
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
+9
View File
@@ -1481,6 +1481,15 @@
"@pinUnlockError": { "@pinUnlockError": {
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN" "description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
}, },
"pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.",
"@pinLockedFor": {
"description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu",
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"pushTitle": "Push notifikace", "pushTitle": "Push notifikace",
"@pushTitle": { "@pushTitle": {
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)" "description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Mit Biometrie entsperren", "pinUnlockWithBiometrics": "Mit Biometrie entsperren",
"pinWrong2": "Falsche PIN", "pinWrong2": "Falsche PIN",
"pinUnlockError": "Fehler beim Entsperren", "pinUnlockError": "Fehler beim Entsperren",
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
"pushTitle": "Push-Benachrichtigungen", "pushTitle": "Push-Benachrichtigungen",
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.", "pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
"pushModeUp": "UnifiedPush (ohne Google)", "pushModeUp": "UnifiedPush (ohne Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Unlock with biometrics", "pinUnlockWithBiometrics": "Unlock with biometrics",
"pinWrong2": "Wrong PIN", "pinWrong2": "Wrong PIN",
"pinUnlockError": "Unlock error", "pinUnlockError": "Unlock error",
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
"pushTitle": "Push notifications", "pushTitle": "Push notifications",
"pushModeOffDesc": "Messages arrive only after you open the app.", "pushModeOffDesc": "Messages arrive only after you open the app.",
"pushModeUp": "UnifiedPush (no Google)", "pushModeUp": "UnifiedPush (no Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Sblocca con biometria", "pinUnlockWithBiometrics": "Sblocca con biometria",
"pinWrong2": "PIN errato", "pinWrong2": "PIN errato",
"pinUnlockError": "Errore di sblocco", "pinUnlockError": "Errore di sblocco",
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
"pushTitle": "Notifiche push", "pushTitle": "Notifiche push",
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.", "pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
"pushModeUp": "UnifiedPush (senza Google)", "pushModeUp": "UnifiedPush (senza Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Разблокировать биометрией", "pinUnlockWithBiometrics": "Разблокировать биометрией",
"pinWrong2": "Неверный PIN", "pinWrong2": "Неверный PIN",
"pinUnlockError": "Ошибка разблокировки", "pinUnlockError": "Ошибка разблокировки",
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
"pushTitle": "Push-уведомления", "pushTitle": "Push-уведомления",
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.", "pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
"pushModeUp": "UnifiedPush (без Google)", "pushModeUp": "UnifiedPush (без Google)",
@@ -1994,6 +1994,12 @@ abstract class AppLocalizations {
/// **'Chyba odemčení'** /// **'Chyba odemčení'**
String get pinUnlockError; String get pinUnlockError;
/// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu
///
/// In cs, this message translates to:
/// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'**
String pinLockedFor(int seconds);
/// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44) /// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)
/// ///
/// In cs, this message translates to: /// In cs, this message translates to:
@@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
@override @override
String get pinUnlockError => 'Chyba odemčení'; String get pinUnlockError => 'Chyba odemčení';
@override
String pinLockedFor(int seconds) {
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
}
@override @override
String get pushTitle => 'Push notifikace'; String get pushTitle => 'Push notifikace';
@@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pinUnlockError => 'Fehler beim Entsperren'; String get pinUnlockError => 'Fehler beim Entsperren';
@override
String pinLockedFor(int seconds) {
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
}
@override @override
String get pushTitle => 'Push-Benachrichtigungen'; String get pushTitle => 'Push-Benachrichtigungen';
@@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pinUnlockError => 'Unlock error'; String get pinUnlockError => 'Unlock error';
@override
String pinLockedFor(int seconds) {
return 'Too many attempts. Try again in $seconds s.';
}
@override @override
String get pushTitle => 'Push notifications'; String get pushTitle => 'Push notifications';
@@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get pinUnlockError => 'Errore di sblocco'; String get pinUnlockError => 'Errore di sblocco';
@override
String pinLockedFor(int seconds) {
return 'Troppi tentativi. Riprova tra $seconds s.';
}
@override @override
String get pushTitle => 'Notifiche push'; String get pushTitle => 'Notifiche push';
@@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get pinUnlockError => 'Ошибка разблокировки'; String get pinUnlockError => 'Ошибка разблокировки';
@override
String pinLockedFor(int seconds) {
return 'Слишком много попыток. Повторите через $seconds с.';
}
@override @override
String get pushTitle => 'Push-уведомления'; String get pushTitle => 'Push-уведомления';
+18 -8
View File
@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:sepot/core/constants/app_constants.dart';
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde). /// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
enum AttachmentTransferState { enum AttachmentTransferState {
/// Přijatá zpráva, blob se ještě nezačal stahovat. /// Přijatá zpráva, blob se ještě nezačal stahovat.
@@ -94,14 +96,22 @@ class AttachmentMeta {
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy). /// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) { factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
final size = wire['s'] as int; final size = wire['s'] as int;
if (size <= 0 || size > 512 * 1024 * 1024) { if (size <= 0 || size > AppConstants.maxAttachmentBytes) {
throw const FormatException('implausible attachment size'); throw const FormatException('implausible attachment size');
} }
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu); // Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
// vyhodí FormatException → zpráva se zahodí jako vadná. // 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
base64Decode(wire['k'] as String); // selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
base64Decode(wire['n'] as String); if (base64Decode(wire['k'] as String).length != 32 ||
base64Decode(wire['d'] as String); base64Decode(wire['n'] as String).length != 12 ||
base64Decode(wire['d'] as String).length != 32) {
throw const FormatException('invalid attachment key material');
}
final name = wire['name'] as String?;
final mime = wire['mime'] as String;
if ((name != null && name.length > 255) || mime.length > 100) {
throw const FormatException('attachment name or mime too long');
}
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka // Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu. // se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
var thumb = wire['th'] as String?; var thumb = wire['th'] as String?;
@@ -118,8 +128,8 @@ class AttachmentMeta {
nonceB64: wire['n'] as String, nonceB64: wire['n'] as String,
digestB64: wire['d'] as String, digestB64: wire['d'] as String,
size: size, size: size,
mime: wire['mime'] as String, mime: mime,
name: wire['name'] as String?, name: name,
width: wire['w'] as int?, width: wire['w'] as int?,
height: wire['h'] as int?, height: wire['h'] as int?,
thumbB64: thumb, thumbB64: thumb,
+13 -3
View File
@@ -146,6 +146,9 @@ class LocalIdentity {
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je /// 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. /// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
class PublicIdentity { class PublicIdentity {
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
static const maxDisplayNameLength = 64;
PublicIdentity({ PublicIdentity({
required this.displayName, required this.displayName,
required this.identityKey, required this.identityKey,
@@ -214,7 +217,14 @@ class PublicIdentity {
as Map<String, dynamic>; as Map<String, dynamic>;
if (map['v'] != 2) return null; if (map['v'] != 2) return null;
final identityKey = base64Decode(map['ik'] as String); final identityKey = base64Decode(map['ik'] as String);
if (identityKey.length != 33) return null; // 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne
// až při první zprávě, tady ho odmítneme hned.
if (identityKey.length != 33 || identityKey[0] != 0x05) return null;
final aid = map['aid'] as String?;
// Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí.
if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null;
final rawName = (map['n'] as String).trim();
if (rawName.length > maxDisplayNameLength) return null;
final da = map['da'] as String?; final da = map['da'] as String?;
Uint8List? deliveryAddress; Uint8List? deliveryAddress;
if (da != null) { if (da != null) {
@@ -224,10 +234,10 @@ class PublicIdentity {
if (deliveryAddress.length != 32) return null; if (deliveryAddress.length != 32) return null;
} }
return PublicIdentity( return PublicIdentity(
displayName: map['n'] as String, displayName: rawName,
identityKey: identityKey, identityKey: identityKey,
createdAt: DateTime.parse(map['ts'] as String), createdAt: DateTime.parse(map['ts'] as String),
accountId: map['aid'] as String?, accountId: aid,
deliveryAddress: deliveryAddress, deliveryAddress: deliveryAddress,
); );
} catch (_) { } catch (_) {
@@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
// se provider invaliduje (viz unlock cesty) a spustí start znovu. // se provider invaliduje (viz unlock cesty) a spustí start znovu.
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
if (!storage.isUnlocked) return; if (!storage.isUnlocked) return;
// Nátlaková relace: žádná síť. Messaging by se registroval skutečným
// identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě).
if (storage.isDecoyMode) return;
final service = ref.read(messagingServiceProvider); final service = ref.read(messagingServiceProvider);
if (service.isStarted) return; if (service.isStarted) return;
+11 -3
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_protector/screen_protector.dart'; import 'package:screen_protector/screen_protector.dart';
import 'package:sepot/core/backup/backup_service.dart'; import 'package:sepot/core/backup/backup_service.dart';
@@ -57,14 +58,21 @@ final typingIndicatorsProvider =
TypingIndicatorsNotifier.new); TypingIndicatorsNotifier.new);
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí /// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní /// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
/// screenshoty, tak ať se uživatel rozhodne sám. /// jako nativní default v MainActivity, takže přepínač už neukazuje
/// „vypnuto" u obrazovky, která chráněná je.
class ScreenSecurityNotifier extends AsyncNotifier<bool> { class ScreenSecurityNotifier extends AsyncNotifier<bool> {
static const key = 'screen_security_enabled'; static const key = 'screen_security_enabled';
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
static const vychozi = !kDebugMode;
@override @override
Future<bool> build() async { Future<bool> build() async {
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false; final storage = ref.read(storageProvider);
// Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní
// default platí) a po unlocku se provider invaliduje.
if (!storage.isUnlocked) return vychozi;
final v = (await storage.getSetting<bool>(key)) ?? vychozi;
await _apply(v); await _apply(v);
return v; return v;
} }
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/services/messaging_service.dart';
/// Přijímací cesta zahazuje obsah neznámého typu (ACK + drop, protože po
/// upgradu už nejde dešifrovat znovu). Každý typ, který appka sama posílá,
/// proto MUSÍ být v množině známých typů. V 1.2.0 tu chyběl `call` — každá
/// nabídka hovoru se tiše zahodila a hovory nešly přijmout.
void main() {
test('všechny odesílané typy obsahu umí přijímací cesta zpracovat', () {
const sent = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
'ginv', 'gleave', 'gmeta', 'gop', 'gsync',
};
expect(MessagingService.knownContentTypes, containsAll(sent));
});
test('signalizace hovoru je známý typ', () {
expect(MessagingService.knownContentTypes, contains('call'));
});
}
@@ -0,0 +1,49 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
/// Skutečný a nátlakový blob se sdílenou solí se zkouší jednou derivací:
/// který PIN padl, nejde poznat z času odemčení.
void main() {
final real = Uint8List.fromList(List.generate(32, (i) => i));
final decoy = Uint8List.fromList(List.generate(32, (i) => 255 - i));
// Nejslabší povolené parametry — test nemá čekat na 64 MiB derivaci.
const m = DekWrap.floorMemoryKiB;
const t = DekWrap.floorIterations;
test('sdílená sůl: oba PINy se najdou, špatný PIN padne', () async {
final wReal = await DekWrap.wrap(
dek: real, pin: '111111', memoryKiB: m, iterations: t);
final salt = DekWrap.saltOf(wReal);
final wDecoy = await DekWrap.wrap(
dek: decoy, pin: '222222', memoryKiB: m, iterations: t, salt: salt);
expect(DekWrap.saltOf(wDecoy), salt);
final (i1, d1) = await DekWrap.unwrapFirstMatching(
wrapped: [wReal, wDecoy], pin: '111111');
expect(i1, 0);
expect(d1, real);
final (i2, d2) = await DekWrap.unwrapFirstMatching(
wrapped: [wReal, wDecoy], pin: '222222');
expect(i2, 1);
expect(d2, decoy);
await expectLater(
DekWrap.unwrapFirstMatching(wrapped: [wReal, wDecoy], pin: '333333'),
throwsA(isA<WrongPinException>()),
);
});
test('různé soli (starší instalace) fungují dál', () async {
final wReal = await DekWrap.wrap(
dek: real, pin: '111111', memoryKiB: m, iterations: t);
final wDecoy = await DekWrap.wrap(
dek: decoy, pin: '222222', memoryKiB: m, iterations: t);
final (i, d) = await DekWrap.unwrapFirstMatching(
wrapped: [wReal, wDecoy], pin: '222222');
expect(i, 1);
expect(d, decoy);
});
}
@@ -0,0 +1,86 @@
// Argon2id (64 MiB) ×6 pod zátěží celé sady: víc než výchozích 30 s.
@Timeout(Duration(minutes: 3))
library;
import 'dart:io';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/crypto/sepot_crypto.dart';
import 'package:sepot/core/storage/secure_storage.dart';
import 'package:sepot/models/identity.dart';
/// Nátlaková (decoy) relace musí být izolovaná od skutečného účtu:
/// jiná identita, žádná fráze, „smazat" nesáhne na skutečná data,
/// a zámek režim resetuje. Keystore je nahrazený mockem, DB jde do temp.
/// Potřebuje nativní libsignal (LocalIdentity) a sqlcipher.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tmp;
late SecureStorageService storage;
setUpAll(() async => SepotCrypto.init());
setUp(() async {
FlutterSecureStorage.setMockInitialValues({});
tmp = Directory.systemTemp.createTempSync('sepot_decoy');
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
await storage.unlockWithoutPin();
await storage.saveIdentity(await LocalIdentity.create('Alice'));
await storage.saveSeedPhrase(List.filled(24, 'abandon'));
await storage.enablePin('111111');
await storage.enableDecoyPin('222222');
storage.lock();
});
tearDown(() {
storage.lock();
tmp.deleteSync(recursive: true);
});
test('decoy PIN otevře izolovanou relaci', () async {
await storage.unlockWithPin('222222');
expect(storage.isDecoyMode, isTrue);
expect(await storage.loadSeedPhrase(), isNull);
final shown = await storage.loadIdentity();
expect(shown, isNotNull);
storage.lock();
expect(storage.isDecoyMode, isFalse);
await storage.unlockWithPin('111111');
expect(storage.isDecoyMode, isFalse);
final real = await storage.loadIdentity();
expect(real!.id, isNot(shown!.id), reason: 'decoy ukazuje jinou identitu');
expect(await storage.loadSeedPhrase(), hasLength(24));
});
test('smazání v decoy relaci nechá skutečná data', () async {
await storage.unlockWithPin('222222');
await storage.deleteAll();
expect(File('${tmp.path}/app.db').existsSync(), isTrue);
expect(await storage.hasIdentity(), isTrue);
expect(await storage.hasPin(), isTrue);
await storage.unlockWithPin('111111');
expect(storage.isDecoyMode, isFalse);
});
test('změny keystore jsou v decoy relaci zakázané', () async {
await storage.unlockWithPin('222222');
expect(() => storage.enableBiometrics(), throwsStateError);
expect(() => storage.disablePin(), throwsStateError);
expect(
() => storage.changePin(oldPin: '222222', newPin: '333333'),
throwsStateError);
});
test('po pěti špatných PINech se zámek na čas zavře', () async {
for (var i = 0; i < 5; i++) {
await expectLater(storage.unlockWithPin('000000'),
throwsA(isA<WrongPinException>()));
}
expect(await storage.pinLockRemaining(), isNotNull);
await expectLater(storage.unlockWithPin('111111'),
throwsA(isA<PinLockedException>()));
});
}