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
+26 -3
View File
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
_ringId = pushRing.id;
if (pushRing.accepted) {
await accept();
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
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;
}
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) {
if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept());
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
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
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async {
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 [
{
'urls': turn.urls,