Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d431501b74 | |||
| 6f9b08e844 | |||
| 09ef54ef69 | |||
| bfea7201f7 | |||
| 05eeccd83b | |||
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 |
@@ -4,6 +4,59 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
## [1.2.2] – 2026-10-11
|
||||
|
||||
### Opraveno
|
||||
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
|
||||
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
|
||||
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
|
||||
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
|
||||
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
|
||||
vytvoření animace nad zrušeným prvkem.
|
||||
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
|
||||
který existuje jen na Androidu).
|
||||
|
||||
### Změněno
|
||||
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
|
||||
|
||||
## [1.2.1] – 2026-10-09
|
||||
|
||||
### Opraveno
|
||||
- **Hovory se nedaly přijmout.** Příchozí signalizace hovoru se zahazovala dřív,
|
||||
než se dostala k obsluze hovoru; volaný jen „zazvonil“ z budíčku a pak nic.
|
||||
- **Obrazovka hovoru po zavěšení:** avatar a jméno uskakovaly vlevo nahoru.
|
||||
- **Obnova ze 24 slov na novém telefonu** končila nekonečným čekáním — server
|
||||
odmítal už existující identitu. Nové zařízení ji teď převezme podpisem.
|
||||
- **Tichá ztráta zpráv:** úklid mizících zpráv mohl přepsat čerstvě přijatou
|
||||
zprávu; automatický zámek během zpracování zprávy ji mohl nenávratně zahodit;
|
||||
souběžné šifrování pro téhož příjemce dávalo obálky, které druhá strana
|
||||
zahodila. Všechny tři cesty opraveny.
|
||||
- **Velká fronta po delším výpadku** (stovky zpráv) se už neodpojuje v půlce
|
||||
a server ji po osmi pokusech nemaže.
|
||||
- **Nátlakový PIN** otevíral relaci se skutečnou identitou, frází i přístupem ke
|
||||
smazání účtu. Teď je izolovaná: náhodná identita, žádná síť, žádný zásah do
|
||||
klíčů; smazání v ní nechá skutečný účet být.
|
||||
- Zvonění z budíčku bez skutečné nabídky končí po 12 s; server budíčky hovoru
|
||||
omezuje na cíl, takže cizí člověk se znalostí adresy telefon nerozezvoní
|
||||
pořád dokola.
|
||||
- Dodatečné odeslání z fronty neztrácí anketu ani zmínky; budíček při
|
||||
„připojeno“ bez provozu obnoví spojení.
|
||||
|
||||
### Bezpečnost
|
||||
- Hádání PINu: po pěti chybách prodleva 30 s, dál dvojnásobná až 15 minut.
|
||||
- Nátlakový a skutečný PIN se ověřují jednou derivací — čas odemčení neprozradí,
|
||||
který byl zadán.
|
||||
- Biometrie jen biometricky (zámek telefonu appku neodemkne); zabezpečení
|
||||
obrazovky je výchozí zapnuté a po odemčení skutečně platí; na iOS klíče
|
||||
nepřecházejí do iCloud Keychain ani záloh; filtr proti dotykům přes překryvná okna.
|
||||
- Server: doplňování jednorázových klíčů a rotace podepsaných klíčů
|
||||
(`POST /v1/keys`), omezení výdeje jednorázových klíčů na adresu, chyby v logu
|
||||
bez adres push endpointů, připnutí DNS u budíčků, globální strop příloh,
|
||||
limity délek, `/v1/ready`, uvolnění spojení při vypnutí, revize a zdroj
|
||||
v `/v1/server-info`.
|
||||
|
||||
## [1.2.0] – 2026-10-01
|
||||
|
||||
### Nový vzhled
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
|
||||
|
||||
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
|
||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
|
||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
|
||||
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
|
||||
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
|
||||
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
|
||||
|
||||
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
||||
|
||||
## Security
|
||||
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
|
||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
|
||||
|
||||
## License
|
||||
|
||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
||||
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report security issues privately — do not open a public issue.
|
||||
|
||||
- E-mail: **security@sepot.cz**
|
||||
- Web form and policy: <https://sepot.cz/zodpovedne-hlaseni>
|
||||
- Machine-readable contact: <https://sepot.cz/.well-known/security.txt>
|
||||
|
||||
You will get an acknowledgement within 72 hours and a status update at least
|
||||
every 14 days until the issue is resolved. We aim to ship a fix within 30 days
|
||||
for issues that affect message confidentiality, authentication, or the relay's
|
||||
metadata guarantees, and we credit reporters in the changelog unless they
|
||||
prefer otherwise.
|
||||
|
||||
## Scope
|
||||
|
||||
- the Flutter client (`app/`), including the Signal protocol integration,
|
||||
local storage (SQLCipher, PIN/biometrics, duress session) and calls;
|
||||
- the relay (`rust/sepot-relay/`): authentication, routing, push wake-ups,
|
||||
attachments, rate limiting, TURN credentials;
|
||||
- the public relay at `relay.sepot.cz` and the website `sepot.cz`.
|
||||
|
||||
Please do not run denial-of-service tests, mass registrations or scans
|
||||
against the public relay; a local instance (`deploy/relay/`) is enough to
|
||||
reproduce almost everything.
|
||||
|
||||
## What the relay can and cannot see
|
||||
|
||||
The threat model is documented in the README: the relay sees routing tokens,
|
||||
message sizes, timing and push tokens, never content or sender identity.
|
||||
Reports that demonstrate the relay learning more than that are the ones we
|
||||
care about most.
|
||||
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
|
||||
|
||||
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||
complete corresponding source available under the AGPL through channels
|
||||
without app-store restrictions (this public repository, F-Droid, direct
|
||||
without app-store restrictions (this public repository, direct
|
||||
APK). A written clarification for the current Rust `libsignal` has been
|
||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cz.sepot.sepot
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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í.
|
||||
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0a0e17</color>
|
||||
<color name="ic_launcher_background">#0B0C12</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,9 +1,9 @@
|
||||
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta).
|
||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
||||
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||
# dart run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/icon-1024.png
|
||||
adaptive_icon_background: "#0a0e17"
|
||||
adaptive_icon_background: "#0B0C12"
|
||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 560 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final legacyPin = await storage.hasLegacyPin();
|
||||
await storage.unlockWithoutPin();
|
||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
if (!mounted) return;
|
||||
ref.invalidate(screenSecurityProvider);
|
||||
context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,6 +22,13 @@ class CallRinger {
|
||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||
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;
|
||||
|
||||
/// 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));
|
||||
|
||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||
static Future<void> ringFromPush() => FlutterCallkitIncoming
|
||||
.showCallkitIncoming(_params(
|
||||
id: const Uuid().v4(),
|
||||
name: _appName,
|
||||
video: false,
|
||||
extra: {pushMarker: true}));
|
||||
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||
static Future<void> ringFromPush() async {
|
||||
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
|
||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||
@@ -84,6 +120,14 @@ class CallRinger {
|
||||
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) =>
|
||||
FlutterCallkitIncoming.setCallConnected(id);
|
||||
|
||||
|
||||
@@ -53,16 +53,20 @@ class DekWrap {
|
||||
int memoryKiB = defaultMemoryKiB,
|
||||
int iterations = defaultIterations,
|
||||
int parallelism = defaultParallelism,
|
||||
Uint8List? salt,
|
||||
}) async {
|
||||
if (dek.length != 32) {
|
||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||
}
|
||||
_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(
|
||||
pin: pin,
|
||||
salt: salt,
|
||||
salt: useSalt,
|
||||
memoryKiB: memoryKiB,
|
||||
iterations: iterations,
|
||||
parallelism: parallelism,
|
||||
@@ -80,13 +84,72 @@ class DekWrap {
|
||||
'$memoryKiB',
|
||||
'$iterations',
|
||||
'$parallelism',
|
||||
base64Encode(salt),
|
||||
base64Encode(useSalt),
|
||||
base64Encode(box.nonce),
|
||||
base64Encode(box.cipherText),
|
||||
base64Encode(box.mac.bytes),
|
||||
].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í
|
||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||
static Future<Uint8List> unwrap({
|
||||
|
||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||
|
||||
@@ -420,4 +446,64 @@ class SepotCrypto {
|
||||
}
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,17 @@ class RelayClient {
|
||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||
final _prekeysLowController = StreamController<int>.broadcast();
|
||||
|
||||
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;
|
||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||
|
||||
@@ -100,9 +108,18 @@ class RelayClient {
|
||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||
required Uint8List deliveryAddress,
|
||||
}) 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(
|
||||
Uri.parse('$baseUrl/v1/register'),
|
||||
headers: {'content-type': 'application/json'},
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||
'device_id': 1,
|
||||
@@ -144,6 +161,49 @@ class RelayClient {
|
||||
_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).
|
||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||
final hexAddr = deliveryAddress
|
||||
@@ -280,6 +340,20 @@ class RelayClient {
|
||||
_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 {
|
||||
await _wsSub?.cancel();
|
||||
_wsSub = null;
|
||||
@@ -313,6 +387,7 @@ class RelayClient {
|
||||
_wsSub = channel.stream.listen(
|
||||
(data) {
|
||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||
_lastFrameAt = DateTime.now();
|
||||
if (!authenticated) {
|
||||
if (frame.hasChallenge()) {
|
||||
final signature = _crypto.sign([
|
||||
@@ -345,6 +420,10 @@ class RelayClient {
|
||||
if (!_turnController.isClosed) {
|
||||
_turnController.add(frame.turnCredentials);
|
||||
}
|
||||
} else if (frame.hasPrekeysLow()) {
|
||||
if (!_prekeysLowController.isClosed) {
|
||||
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
@@ -389,6 +468,7 @@ class RelayClient {
|
||||
final receiptFuture = _receiptController.stream
|
||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||
.timeout(timeout);
|
||||
try {
|
||||
_send(pb.ClientFrame(
|
||||
envelope: pb.Envelope(
|
||||
dstAddress: dstAddress,
|
||||
@@ -397,6 +477,12 @@ class RelayClient {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -455,6 +541,7 @@ class RelayClient {
|
||||
_transport.close();
|
||||
await _stateController.close();
|
||||
await _turnController.close();
|
||||
await _prekeysLowController.close();
|
||||
await _deliverController.close();
|
||||
await _receiptController.close();
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ class PushBootstrap {
|
||||
if (Platform.isIOS) {
|
||||
ApnsBridge.onWake = _wake;
|
||||
ApnsBridge.ensureHandler();
|
||||
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||
unawaited(CallRinger.expireStalePushRings());
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
|
||||
@@ -52,7 +52,15 @@ class PushService {
|
||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||
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) {
|
||||
_messaging.registerPush(_registration(
|
||||
@@ -130,10 +138,14 @@ class PushService {
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
|
||||
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
|
||||
Future<List<String>> distributors() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
Future<String?> currentDistributor() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
@@ -150,7 +162,7 @@ class PushService {
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
@@ -159,6 +171,7 @@ class PushService {
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
if (!Platform.isAndroid) return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,56 @@ class MessagingService {
|
||||
StreamSubscription<RelayConnectionState>? _stateSub;
|
||||
Timer? _registerRetryTimer;
|
||||
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
|
||||
/// 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();
|
||||
_relay = relay;
|
||||
_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.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) {
|
||||
unawaited(_flushOutbox());
|
||||
unawaited(_refreshMyAvatarIfOld());
|
||||
unawaited(_rotatePreKeysIfDue());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -337,11 +389,13 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_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.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) {
|
||||
unawaited(_flushOutbox());
|
||||
unawaited(_refreshMyAvatarIfOld());
|
||||
unawaited(_rotatePreKeysIfDue());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,16 +430,109 @@ class MessagingService {
|
||||
Future<void> _registerWithRetry() async {
|
||||
try {
|
||||
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 (_) {
|
||||
_registerRetryTimer?.cancel();
|
||||
_registerRetryTimer =
|
||||
Timer(const Duration(seconds: 30), _registerWithRetry);
|
||||
_scheduleRegisterRetry();
|
||||
return;
|
||||
}
|
||||
_registrationConflict = false;
|
||||
_registerRetryDelay = const Duration(seconds: 30);
|
||||
_relay!.start();
|
||||
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 {
|
||||
final db = _db!;
|
||||
final crypto = _crypto!;
|
||||
@@ -410,10 +557,17 @@ class MessagingService {
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
|
||||
final stamp = utf8.encode('$nowMs');
|
||||
db.transaction(() {
|
||||
db.kvPut('account_id', utf8.encode(reg.accountId));
|
||||
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
|
||||
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();
|
||||
}
|
||||
@@ -2009,6 +2163,10 @@ class MessagingService {
|
||||
'txt': caption ?? '',
|
||||
're': ?message.replyToId,
|
||||
'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(),
|
||||
};
|
||||
}
|
||||
@@ -2053,6 +2211,10 @@ class MessagingService {
|
||||
}
|
||||
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
|
||||
|
||||
// Krypto část jde přes [_serial] (viz komentář u pole): relace se
|
||||
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
||||
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||
final sealed = await _serial<Uint8List?>(() async {
|
||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
||||
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||
if (!await crypto.hasSession(peerAddr)) {
|
||||
@@ -2063,12 +2225,13 @@ class MessagingService {
|
||||
}
|
||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||
}
|
||||
|
||||
final sealed = await crypto.sealedEncrypt(
|
||||
return crypto.sealedEncrypt(
|
||||
recipient: peerAddr,
|
||||
plaintext: utf8.encode(jsonEncode(content)),
|
||||
senderCertificate: await _senderCertificate(),
|
||||
);
|
||||
});
|
||||
if (sealed == null) return null;
|
||||
|
||||
final receipt = await relay.sendEnvelope(
|
||||
dstAddress: peerDa,
|
||||
@@ -2084,10 +2247,25 @@ class MessagingService {
|
||||
|
||||
// ── 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 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;
|
||||
try {
|
||||
@@ -2110,10 +2288,7 @@ class MessagingService {
|
||||
final content =
|
||||
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
||||
final type = content['t'] as String?;
|
||||
const known = {
|
||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
|
||||
..._groupControlTypes,
|
||||
};
|
||||
const known = knownContentTypes;
|
||||
if (content['v'] != 1 || !known.contains(type)) {
|
||||
// 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.
|
||||
@@ -2228,13 +2403,18 @@ class MessagingService {
|
||||
final replyTo = content['re'] as String?;
|
||||
final Message message;
|
||||
if (type == 'txt') {
|
||||
final text = content['txt'];
|
||||
if (text is! String) {
|
||||
final raw = content['txt'];
|
||||
if (raw is! String) {
|
||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
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(
|
||||
id: messageId,
|
||||
senderId: contact.publicIdentity.fingerprint.display,
|
||||
@@ -2567,7 +2747,7 @@ class MessagingService {
|
||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||
final name = (profile?['n'] as String?)?.trim();
|
||||
final identity = PublicIdentity(
|
||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
||||
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||
identityKey: opened.senderIdentityKey,
|
||||
createdAt: DateTime.now(),
|
||||
accountId: opened.senderName,
|
||||
@@ -2603,7 +2783,7 @@ class MessagingService {
|
||||
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
|
||||
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
|
||||
void onPushWake() {
|
||||
_relay?.start();
|
||||
_relay?.kick();
|
||||
}
|
||||
|
||||
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
|
||||
@@ -2631,7 +2811,17 @@ class MessagingService {
|
||||
_purgeTimer?.cancel();
|
||||
_purgeTimer = null;
|
||||
await _deliverSub?.cancel();
|
||||
await _prekeysLowSub?.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();
|
||||
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
||||
_db = null;
|
||||
|
||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
||||
/// při onboardingu, který DB rovnou otevře).
|
||||
|
||||
/// 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 {
|
||||
const DecoyPinConflict();
|
||||
@override
|
||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
||||
static const _legacyPinHashKey = 'pin_hash';
|
||||
|
||||
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 String? _dbPathOverride;
|
||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
||||
bool _decoyMode = false;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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})
|
||||
: _secureStorage = const FlutterSecureStorage(
|
||||
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;
|
||||
|
||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
||||
if (wrapped == null) {
|
||||
throw StateError('No PIN set — use unlockWithoutPin');
|
||||
}
|
||||
try {
|
||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||
await _openDb(dek);
|
||||
} on WrongPinException {
|
||||
final locked = await pinLockRemaining();
|
||||
if (locked != null) throw PinLockedException(locked);
|
||||
|
||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||
if (decoy == null) rethrow;
|
||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
||||
await _openDb(dek, decoy: true);
|
||||
try {
|
||||
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||
// který PIN padl.
|
||||
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||
wrapped: [wrapped, ?decoy],
|
||||
pin: pin,
|
||||
);
|
||||
await _openDb(dek, decoy: index == 1);
|
||||
await _resetPinFailures();
|
||||
} on WrongPinException {
|
||||
await _notePinFailure();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||
Future<void> enablePin(String pin) async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? await _getOrCreateRawDek();
|
||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||
@@ -124,6 +191,7 @@ class SecureStorageService {
|
||||
|
||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||
Future<void> disablePin() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||
await _secureStorage.delete(key: _wrappedDekKey);
|
||||
@@ -151,6 +219,7 @@ class SecureStorageService {
|
||||
required String oldPin,
|
||||
required String newPin,
|
||||
}) async {
|
||||
_requireRealSession();
|
||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (wrapped == null) throw StateError('No PIN set');
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -176,6 +249,7 @@ class SecureStorageService {
|
||||
/// 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.
|
||||
Future<void> enableDecoyPin(String decoyPin) async {
|
||||
_requireRealSession();
|
||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||
if (realWrapped == null) {
|
||||
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
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -203,11 +279,20 @@ class SecureStorageService {
|
||||
|
||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||
Future<void> disableDecoyPin() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||
}
|
||||
|
||||
Future<void> _deleteDbFiles(String path) async {
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
try {
|
||||
final f = File(await _dbPath(decoy: true));
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
// best-effort úklid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||
@@ -222,6 +307,8 @@ class SecureStorageService {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
_dek = null;
|
||||
_decoyMode = false;
|
||||
_decoyIdentity = null;
|
||||
}
|
||||
|
||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||
@@ -293,6 +380,11 @@ class SecureStorageService {
|
||||
|
||||
/// Načti lokální identitu
|
||||
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);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
@@ -320,6 +412,7 @@ class SecureStorageService {
|
||||
}
|
||||
|
||||
Future<List<String>?> loadSeedPhrase() async {
|
||||
if (_decoyMode) return null;
|
||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||
if (json == null) return null;
|
||||
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).
|
||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||
Future<void> enableBiometrics() async {
|
||||
_requireRealSession();
|
||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||
await _secureStorage.write(
|
||||
key: _biometricDekKey,
|
||||
@@ -342,6 +436,7 @@ class SecureStorageService {
|
||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||
Future<void> disableBiometrics() async {
|
||||
_requireRealSession();
|
||||
await _secureStorage.delete(key: _biometricDekKey);
|
||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||
}
|
||||
@@ -475,24 +570,29 @@ class SecureStorageService {
|
||||
if (!isUnlocked) return false;
|
||||
var changed = false;
|
||||
final now = DateTime.now();
|
||||
for (final conv in await loadConversations()) {
|
||||
final expired = conv.messages
|
||||
.where((m) =>
|
||||
bool isExpired(Message m) =>
|
||||
m.disappearAfterSeconds != null &&
|
||||
m.timestamp
|
||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||
.isBefore(now))
|
||||
.toList();
|
||||
.isBefore(now);
|
||||
for (final snapshot in await loadConversations()) {
|
||||
if (!snapshot.messages.any(isExpired)) continue;
|
||||
// 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;
|
||||
changed = true;
|
||||
conv.messages.removeWhere(isExpired);
|
||||
await saveConversation(conv);
|
||||
for (final m in expired) {
|
||||
final blobId = m.attachment?.blobId;
|
||||
if (blobId != null) {
|
||||
await AttachmentStore().delete(blobId);
|
||||
}
|
||||
conv.messages.remove(m);
|
||||
}
|
||||
await saveConversation(conv);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -512,12 +612,19 @@ class SecureStorageService {
|
||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||
/// i lokální bloby příloh.
|
||||
Future<void> deleteAll() async {
|
||||
final path = await _dbPath();
|
||||
if (_decoyMode) {
|
||||
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||
final decoyPath = await _dbPath(decoy: true);
|
||||
lock();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final f = File('$path$suffix');
|
||||
if (f.existsSync()) f.deleteSync();
|
||||
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 _secureStorage.deleteAll();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -89,7 +89,11 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
// 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),
|
||||
@@ -138,6 +142,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showVideo && !call.cameraOff)
|
||||
Positioned(
|
||||
right: 16,
|
||||
|
||||
@@ -64,7 +64,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
|
||||
}
|
||||
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _searchController = TextEditingController();
|
||||
@@ -133,9 +134,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
_drafts = ref.read(draftsProvider.notifier);
|
||||
_restoreDraft();
|
||||
_messageController.addListener(_scheduleDraftSave);
|
||||
_conversations = ref.read(conversationsProvider.notifier);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark());
|
||||
}
|
||||
|
||||
/// Notifier zachycený v initState: v dispose už `ref` použít nejde.
|
||||
late final ConversationsNotifier _conversations;
|
||||
|
||||
/// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji
|
||||
/// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu
|
||||
/// (ne pod profilem kontaktu nebo náhledem fotky).
|
||||
bool get _isSeenNow =>
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed &&
|
||||
(_route?.isCurrent ?? false);
|
||||
|
||||
// Route uložená v didChangeDependencies: lifecycle callback může přijít
|
||||
// i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí.
|
||||
ModalRoute<Object?>? _route;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_route = ModalRoute.of(context);
|
||||
}
|
||||
|
||||
void _markIfSeen() {
|
||||
if (!mounted || !_isSeenNow) return;
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem,
|
||||
// uživatel uvidí až po návratu do appky.
|
||||
if (state == AppLifecycleState.resumed) _markIfSeen();
|
||||
}
|
||||
|
||||
/// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí
|
||||
/// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát.
|
||||
Future<void> _captureUnreadAndMark() async {
|
||||
@@ -185,6 +220,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy
|
||||
// dorazivší těsně před odchodem, které listener nestihl označit.
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
_draftTimer?.cancel();
|
||||
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
|
||||
// upravované zprávy.
|
||||
@@ -214,6 +253,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.where((c) => c.publicIdentity.id == widget.contactId)
|
||||
.firstOrNull;
|
||||
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
|
||||
// Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po
|
||||
// návratu do seznamu svítila jako nepřečtená.
|
||||
// První načtení vynechá: to obslouží _captureUnreadAndMark, které si
|
||||
// nejdřív zapamatuje řádek „N nových zpráv“.
|
||||
ref.listen(conversationProvider(widget.contactId), (prev, next) {
|
||||
if (prev?.valueOrNull == null) return;
|
||||
final conv = next.valueOrNull;
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (conv == null || myId == null || conv.unreadCount(myId) == 0) return;
|
||||
_markIfSeen();
|
||||
});
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
// Skupinová konverzace: contactId == gid
|
||||
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
|
||||
@@ -2150,7 +2200,11 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
static const _max = 84.0;
|
||||
static const _trigger = 58.0;
|
||||
|
||||
late final AnimationController _back = AnimationController(
|
||||
// Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil
|
||||
// teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným
|
||||
// elementem, což v debug buildu padá na hledání TickerMode.
|
||||
AnimationController? _backCtl;
|
||||
AnimationController get _back => _backCtl ??= AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 180))
|
||||
..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
|
||||
double _dx = 0;
|
||||
@@ -2159,7 +2213,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_back.dispose();
|
||||
_backCtl?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +66,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Zabezpečení
|
||||
_buildSectionHeader(l10n.settingsSectionSecurity),
|
||||
if (!decoy) ...[
|
||||
_buildTile(
|
||||
icon: Icons.pin_outlined,
|
||||
title: l10n.settingsChangePinTitle,
|
||||
@@ -70,6 +74,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
onTap: () => context.push('/change-pin'),
|
||||
),
|
||||
const BiometricsTile(),
|
||||
],
|
||||
_buildTile(
|
||||
icon: Icons.timer_outlined,
|
||||
title: l10n.settingsAutoLockTitle,
|
||||
@@ -80,6 +85,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
onTap: () => _nastavAutoLock(context, ref),
|
||||
),
|
||||
if (!decoy)
|
||||
_buildTile(
|
||||
icon: Icons.password_outlined,
|
||||
title: l10n.settingsDecoyPinTitle,
|
||||
@@ -153,6 +159,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Sekce Záloha
|
||||
if (!decoy) ...[
|
||||
_buildSectionHeader(l10n.settingsSectionBackup),
|
||||
_buildTile(
|
||||
icon: Icons.key_outlined,
|
||||
@@ -172,6 +179,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
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,6 +238,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Smazat účet
|
||||
if (!decoy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton(
|
||||
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final path = '${dir.path}/sepot-zaloha.sepotbackup';
|
||||
await File(path).writeAsBytes(bytes);
|
||||
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(
|
||||
|
||||
@@ -1481,6 +1481,15 @@
|
||||
"@pinUnlockError": {
|
||||
"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": {
|
||||
"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)"
|
||||
|
||||
@@ -315,6 +315,7 @@
|
||||
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
|
||||
"pinWrong2": "Falsche PIN",
|
||||
"pinUnlockError": "Fehler beim Entsperren",
|
||||
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
|
||||
"pushTitle": "Push-Benachrichtigungen",
|
||||
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
|
||||
"pushModeUp": "UnifiedPush (ohne Google)",
|
||||
|
||||
@@ -315,6 +315,7 @@
|
||||
"pinUnlockWithBiometrics": "Unlock with biometrics",
|
||||
"pinWrong2": "Wrong PIN",
|
||||
"pinUnlockError": "Unlock error",
|
||||
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
|
||||
"pushTitle": "Push notifications",
|
||||
"pushModeOffDesc": "Messages arrive only after you open the app.",
|
||||
"pushModeUp": "UnifiedPush (no Google)",
|
||||
|
||||
@@ -315,6 +315,7 @@
|
||||
"pinUnlockWithBiometrics": "Sblocca con biometria",
|
||||
"pinWrong2": "PIN errato",
|
||||
"pinUnlockError": "Errore di sblocco",
|
||||
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
|
||||
"pushTitle": "Notifiche push",
|
||||
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
|
||||
"pushModeUp": "UnifiedPush (senza Google)",
|
||||
|
||||
@@ -315,6 +315,7 @@
|
||||
"pinUnlockWithBiometrics": "Разблокировать биометрией",
|
||||
"pinWrong2": "Неверный PIN",
|
||||
"pinUnlockError": "Ошибка разблокировки",
|
||||
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
|
||||
"pushTitle": "Push-уведомления",
|
||||
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
|
||||
"pushModeUp": "UnifiedPush (без Google)",
|
||||
|
||||
@@ -1994,6 +1994,12 @@ abstract class AppLocalizations {
|
||||
/// **'Chyba odemčení'**
|
||||
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)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
|
||||
@@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Chyba odemčení';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push notifikace';
|
||||
|
||||
|
||||
@@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Fehler beim Entsperren';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push-Benachrichtigungen';
|
||||
|
||||
|
||||
@@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Unlock error';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Too many attempts. Try again in $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push notifications';
|
||||
|
||||
|
||||
@@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Errore di sblocco';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Troppi tentativi. Riprova tra $seconds s.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Notifiche push';
|
||||
|
||||
|
||||
@@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get pinUnlockError => 'Ошибка разблокировки';
|
||||
|
||||
@override
|
||||
String pinLockedFor(int seconds) {
|
||||
return 'Слишком много попыток. Повторите через $seconds с.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushTitle => 'Push-уведомления';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
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).
|
||||
enum AttachmentTransferState {
|
||||
/// 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).
|
||||
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
|
||||
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');
|
||||
}
|
||||
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
|
||||
// vyhodí FormatException → zpráva se zahodí jako vadná.
|
||||
base64Decode(wire['k'] as String);
|
||||
base64Decode(wire['n'] as String);
|
||||
base64Decode(wire['d'] as String);
|
||||
// Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
|
||||
// 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
|
||||
// selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
|
||||
if (base64Decode(wire['k'] as String).length != 32 ||
|
||||
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
|
||||
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
||||
var thumb = wire['th'] as String?;
|
||||
@@ -118,8 +128,8 @@ class AttachmentMeta {
|
||||
nonceB64: wire['n'] as String,
|
||||
digestB64: wire['d'] as String,
|
||||
size: size,
|
||||
mime: wire['mime'] as String,
|
||||
name: wire['name'] as String?,
|
||||
mime: mime,
|
||||
name: name,
|
||||
width: wire['w'] as int?,
|
||||
height: wire['h'] as int?,
|
||||
thumbB64: thumb,
|
||||
|
||||
@@ -55,9 +55,17 @@ class Conversation {
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
/// Neexistující id se tiše ignoruje (zpráva mohla být smazána).
|
||||
///
|
||||
/// Přečtení je jednosměrné: aktualizace nesmí vrátit už přečtenou zprávu
|
||||
/// mezi nepřečtené. Volající často drží kopii zprávy načtenou před
|
||||
/// otevřením chatu (stahování přílohy, dodatečné odeslání) a bez téhle
|
||||
/// pojistky by zápis po návratu ze stahování obnovil odznak nepřečtených.
|
||||
void updateMessage(Message message) {
|
||||
final index = messages.indexWhere((m) => m.id == message.id);
|
||||
if (index >= 0) messages[index] = message;
|
||||
if (index < 0) return;
|
||||
messages[index] = messages[index].isRead && !message.isRead
|
||||
? message.copyWith(isRead: true)
|
||||
: message;
|
||||
}
|
||||
|
||||
/// Označ všechny zprávy jako přečtené
|
||||
|
||||
@@ -146,6 +146,9 @@ class LocalIdentity {
|
||||
/// 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.
|
||||
class PublicIdentity {
|
||||
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
|
||||
static const maxDisplayNameLength = 64;
|
||||
|
||||
PublicIdentity({
|
||||
required this.displayName,
|
||||
required this.identityKey,
|
||||
@@ -214,7 +217,14 @@ class PublicIdentity {
|
||||
as Map<String, dynamic>;
|
||||
if (map['v'] != 2) return null;
|
||||
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?;
|
||||
Uint8List? deliveryAddress;
|
||||
if (da != null) {
|
||||
@@ -224,10 +234,10 @@ class PublicIdentity {
|
||||
if (deliveryAddress.length != 32) return null;
|
||||
}
|
||||
return PublicIdentity(
|
||||
displayName: map['n'] as String,
|
||||
displayName: rawName,
|
||||
identityKey: identityKey,
|
||||
createdAt: DateTime.parse(map['ts'] as String),
|
||||
accountId: map['aid'] as String?,
|
||||
accountId: aid,
|
||||
deliveryAddress: deliveryAddress,
|
||||
);
|
||||
} catch (_) {
|
||||
|
||||
@@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
||||
final storage = ref.read(storageProvider);
|
||||
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);
|
||||
if (service.isStarted) return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:screen_protector/screen_protector.dart';
|
||||
import 'package:sepot/core/backup/backup_service.dart';
|
||||
@@ -57,14 +58,21 @@ final typingIndicatorsProvider =
|
||||
TypingIndicatorsNotifier.new);
|
||||
|
||||
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
||||
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
|
||||
/// screenshoty, tak ať se uživatel rozhodne sám.
|
||||
/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
|
||||
/// 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> {
|
||||
static const key = 'screen_security_enabled';
|
||||
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
|
||||
static const vychozi = !kDebugMode;
|
||||
|
||||
@override
|
||||
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);
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: sepot
|
||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||
publish_to: 'none'
|
||||
version: 1.2.0+23
|
||||
version: 1.2.2+25
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
|
||||
@@ -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,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Read state only moves forward. Updates that carry an older copy of the
|
||||
/// message (attachment download finishing, delivery status) must not flip a
|
||||
/// message the user has already read back to unread.
|
||||
void main() {
|
||||
Message zprava({bool isRead = false}) => Message(
|
||||
id: 'm1',
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
type: MessageType.text,
|
||||
content: 'hi',
|
||||
timestamp: DateTime.parse('2026-10-10T09:00:00.000'),
|
||||
isRead: isRead,
|
||||
);
|
||||
|
||||
test('stale unread copy does not undo a read message', () {
|
||||
final c = Conversation(contactId: 'alice')..addMessage(zprava());
|
||||
c.markAllRead('bob');
|
||||
|
||||
c.updateMessage(zprava().copyWith(content: 'hi (edited)'));
|
||||
|
||||
expect(c.messages.single.isRead, isTrue);
|
||||
expect(c.messages.single.content, 'hi (edited)');
|
||||
expect(c.unreadCount('bob'), 0);
|
||||
});
|
||||
|
||||
test('update can still mark an unread message as read', () {
|
||||
final c = Conversation(contactId: 'alice')..addMessage(zprava());
|
||||
|
||||
c.updateMessage(zprava(isRead: true));
|
||||
|
||||
expect(c.unreadCount('bob'), 0);
|
||||
});
|
||||
}
|
||||