e3a2378c34
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.
108 lines
3.3 KiB
Dart
108 lines
3.3 KiB
Dart
/// Přepínač biometrického odemčení.
|
|
///
|
|
/// Zapnutí = kopie DEK do OS keystore (TEE) — vědomé změkčení čistého
|
|
/// PIN-only modelu (DEK jinak existuje jen za Argon2id) výměnou za
|
|
/// pohodlí. Vyžaduje nastavený PIN a odemčené úložiště; enrolment se
|
|
/// potvrzuje systémovou biometrickou výzvou.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:local_auth/local_auth.dart';
|
|
import 'package:sepot/core/l10n/error_messages.dart';
|
|
import 'package:sepot/core/l10n/l10n.dart';
|
|
import 'package:sepot/core/theme/app_theme.dart';
|
|
import 'package:sepot/providers/storage_provider.dart';
|
|
|
|
class BiometricsTile extends ConsumerStatefulWidget {
|
|
const BiometricsTile({super.key});
|
|
|
|
@override
|
|
ConsumerState<BiometricsTile> createState() => _BiometricsTileState();
|
|
}
|
|
|
|
class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
|
bool _enabled = false;
|
|
bool _busy = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
ref.read(storageProvider).isBiometricsEnabled().then((v) {
|
|
if (mounted) setState(() => _enabled = v);
|
|
});
|
|
}
|
|
|
|
Future<void> _toggle(bool value) async {
|
|
if (_busy) return;
|
|
setState(() => _busy = true);
|
|
final l10n = context.l10n;
|
|
final storage = ref.read(storageProvider);
|
|
try {
|
|
if (value) {
|
|
if (!await storage.hasPin()) {
|
|
_snack(l10n.biometricsPinRequiredFirst);
|
|
return;
|
|
}
|
|
if (!storage.isUnlocked) {
|
|
_snack(l10n.biometricsStorageLocked);
|
|
return;
|
|
}
|
|
final auth = LocalAuthentication();
|
|
if (!await auth.isDeviceSupported()) {
|
|
_snack(l10n.biometricsNotAvailable);
|
|
return;
|
|
}
|
|
final ok = await auth.authenticate(
|
|
localizedReason: l10n.biometricsEnrollPromptReason,
|
|
options: const AuthenticationOptions(
|
|
stickyAuth: true, biometricOnly: true),
|
|
);
|
|
if (!ok) return;
|
|
await storage.enableBiometrics();
|
|
if (mounted) setState(() => _enabled = true);
|
|
} else {
|
|
await storage.disableBiometrics();
|
|
if (mounted) setState(() => _enabled = false);
|
|
}
|
|
} catch (e) {
|
|
_snack(l10n.biometricsSetupFailed(describeError(l10n, e)));
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
void _snack(String text) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListTile(
|
|
leading: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
color: SepotColors.surfaceVariant,
|
|
),
|
|
child: const Icon(Icons.fingerprint,
|
|
size: 20, color: SepotColors.textSecondary),
|
|
),
|
|
title: Text(context.l10n.biometricsTileTitle,
|
|
style: const TextStyle(fontSize: 15)),
|
|
subtitle: Text(
|
|
context.l10n.biometricsTileSubtitle,
|
|
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
|
),
|
|
trailing: Switch(
|
|
value: _enabled,
|
|
onChanged: _busy ? null : _toggle,
|
|
activeTrackColor: SepotColors.ember,
|
|
),
|
|
onTap: () => _toggle(!_enabled),
|
|
);
|
|
}
|
|
}
|