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
+20 -2
View File
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.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/theme/app_theme.dart';
import 'package:sepot/providers/messaging_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/widgets/sepot_logo.dart';
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
try {
final ok = await LocalAuthentication().authenticate(
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
await storage.unlockWithBiometrics();
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
void _restartMessagingAfterUnlock() {
ref.invalidate(messagingStartProvider);
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 {
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats');
} on WrongPinException {
final locked = await ref.read(storageProvider).pinLockRemaining();
if (!mounted) return;
setState(() {
_error = context.l10n.pinWrong2;
_error = locked == null
? context.l10n.pinWrong2
: context.l10n.pinLockedFor(locked.inSeconds);
_pin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
} on PinLockedException catch (e) {
setState(() {
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
_pin = '';
_isVerifying = false;
});
} on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding');
+8 -3
View File
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
SafeArea(
child: Column(
children: [
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
Positioned.fill(
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 48),
if (!showVideo) ...[
SepotAvatar(
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_InCallControls(call: call, controller: controller),
const SizedBox(height: 32),
],
),
),
),
if (showVideo && !call.cameraOff)
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
}
final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true),
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
);
if (!ok) return;
await storage.enableBiometrics();
+73 -57
View File
@@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider);
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(
appBar: AppBar(
@@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: 8),
// Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
if (!decoy) ...[
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
],
_buildTile(
icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle,
@@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget {
),
onTap: () => _nastavAutoLock(context, ref),
),
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
if (!decoy)
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
const SizedBox(height: 8),
// Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications),
@@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 8),
// Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
if (!decoy) ...[
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
],
// „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8),
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
// Smazat účet
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
if (!decoy)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
),
const SizedBox(height: 48),
],
),
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(path, mimeType: 'application/octet-stream')],
subject: l10n.backupShareSubject,
);
try {
await Share.shareXFiles(
[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) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(