Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d13b88aebe | |||
| b824f62878 | |||
| 00739ac8a0 | |||
| 94cfd72690 | |||
| e3a2378c34 | |||
| 8b452a5593 |
@@ -4,6 +4,44 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
|||||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||||
verzování dle [ajtak standardu](#verzování).
|
verzování dle [ajtak standardu](#verzování).
|
||||||
|
|
||||||
|
## [Nevydáno]
|
||||||
|
|
||||||
|
## [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
|
## [1.2.0] – 2026-10-01
|
||||||
|
|
||||||
### Nový vzhled
|
### 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.
|
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
|
## 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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **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
|
## 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
|
## License
|
||||||
|
|
||||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](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).
|
||||||
|
|||||||
+34
@@ -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
|
Šepot relies on that stated position and, as an AGPL work, keeps its
|
||||||
complete corresponding source available under the AGPL through channels
|
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
|
APK). A written clarification for the current Rust `libsignal` has been
|
||||||
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package cz.sepot.sepot
|
package cz.sepot.sepot
|
||||||
|
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
|
|||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
|
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
|
||||||
window.setFlags(
|
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
// odemčení vypnout (default zapnuto, jako u Signalu).
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
|
||||||
)
|
// by u FLAG_SECURE okna selhal); release vždy chráněný.
|
||||||
|
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
|
if (!debuggable) {
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
|
||||||
|
// průhledný overlay nad PIN klávesnicí) se zahodí.
|
||||||
|
window.decorView.filterTouchesWhenObscured = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
|
|||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
final legacyPin = await storage.hasLegacyPin();
|
final legacyPin = await storage.hasLegacyPin();
|
||||||
await storage.unlockWithoutPin();
|
await storage.unlockWithoutPin();
|
||||||
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
|
if (!mounted) return;
|
||||||
|
ref.invalidate(screenSecurityProvider);
|
||||||
|
context.go(legacyPin ? '/pin-setup' : '/chats');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
|
|||||||
_ringId = pushRing.id;
|
_ringId = pushRing.id;
|
||||||
if (pushRing.accepted) {
|
if (pushRing.accepted) {
|
||||||
await accept();
|
await accept();
|
||||||
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
return;
|
||||||
await reject();
|
|
||||||
}
|
}
|
||||||
|
if (CallRinger.declinedEarly.remove(pushRing.id)) {
|
||||||
|
await reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
|
||||||
|
// nahradí se jménem volajícího (bez značky push).
|
||||||
|
await _claimPushRing(pushRing.id, name, video);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final foreground =
|
final foreground =
|
||||||
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
|
||||||
|
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
|
||||||
|
Future<void> _claimPushRing(String pushId, String name, bool video) async {
|
||||||
|
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
|
||||||
|
await CallRinger.end(pushId);
|
||||||
|
_ringId = state.callId;
|
||||||
|
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
|
||||||
|
}
|
||||||
|
|
||||||
void _onRingAccept(String id) {
|
void _onRingAccept(String id) {
|
||||||
if (id == _ringId && state.phase == CallPhase.incoming) {
|
if (id == _ringId && state.phase == CallPhase.incoming) {
|
||||||
unawaited(accept());
|
unawaited(accept());
|
||||||
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
|
|||||||
return pc;
|
return pc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
|
||||||
|
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
|
||||||
|
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
|
||||||
|
/// kandidáti = hovor jen v jedné LAN.
|
||||||
|
static const _stunFallback = {
|
||||||
|
'urls': ['stun:turn.sepot.cz:3478'],
|
||||||
|
};
|
||||||
|
|
||||||
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
|
||||||
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
|
||||||
Future<List<Map<String, dynamic>>> _iceServers() async {
|
Future<List<Map<String, dynamic>>> _iceServers() async {
|
||||||
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
final turn = await ref.read(messagingServiceProvider).turnCredentials();
|
||||||
if (turn == null || turn.urls.isEmpty) return const [];
|
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'urls': turn.urls,
|
'urls': turn.urls,
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class CallRinger {
|
|||||||
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
|
||||||
static const ringMillis = 45000;
|
static const ringMillis = 45000;
|
||||||
|
|
||||||
|
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
|
||||||
|
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
|
||||||
|
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
|
||||||
|
/// 45 s na každou obálku s hintem CALL. Nabídka od
|
||||||
|
/// skutečného volajícího dorazí do pár sekund po připojení.
|
||||||
|
static const pushRingGrace = Duration(seconds: 12);
|
||||||
|
|
||||||
static StreamSubscription<CallEvent?>? _sub;
|
static StreamSubscription<CallEvent?>? _sub;
|
||||||
|
|
||||||
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
|
||||||
@@ -65,12 +72,41 @@ class CallRinger {
|
|||||||
_params(id: callId, name: name, video: video));
|
_params(id: callId, name: name, video: video));
|
||||||
|
|
||||||
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
|
||||||
static Future<void> ringFromPush() => FlutterCallkitIncoming
|
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
|
||||||
.showCallkitIncoming(_params(
|
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
|
||||||
id: const Uuid().v4(),
|
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
|
||||||
name: _appName,
|
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
|
||||||
video: false,
|
static Future<void> ringFromPush() async {
|
||||||
extra: {pushMarker: true}));
|
final id = const Uuid().v4();
|
||||||
|
await FlutterCallkitIncoming.showCallkitIncoming(_params(
|
||||||
|
id: id, name: _appName, video: false, extra: {pushMarker: true}));
|
||||||
|
await Future<void>.delayed(pushRingGrace);
|
||||||
|
await endIfUnmatched(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
|
||||||
|
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
|
||||||
|
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
|
||||||
|
static Future<void> expireStalePushRings() async {
|
||||||
|
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
for (final call in calls) {
|
||||||
|
if (call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||||
|
final id = call.id;
|
||||||
|
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
|
||||||
|
/// o zmeškaném hovoru — nebylo od koho).
|
||||||
|
static Future<void> endIfUnmatched(String id) async {
|
||||||
|
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
for (final call in calls) {
|
||||||
|
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
|
||||||
|
await FlutterCallkitIncoming.endCall(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
|
||||||
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
|
||||||
@@ -84,6 +120,14 @@ class CallRinger {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> isAccepted(String id) async {
|
||||||
|
final calls = await FlutterCallkitIncoming.activeCalls();
|
||||||
|
for (final call in calls) {
|
||||||
|
if (call.id == id) return call.isAccepted;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> connected(String id) =>
|
static Future<void> connected(String id) =>
|
||||||
FlutterCallkitIncoming.setCallConnected(id);
|
FlutterCallkitIncoming.setCallConnected(id);
|
||||||
|
|
||||||
|
|||||||
@@ -53,16 +53,20 @@ class DekWrap {
|
|||||||
int memoryKiB = defaultMemoryKiB,
|
int memoryKiB = defaultMemoryKiB,
|
||||||
int iterations = defaultIterations,
|
int iterations = defaultIterations,
|
||||||
int parallelism = defaultParallelism,
|
int parallelism = defaultParallelism,
|
||||||
|
Uint8List? salt,
|
||||||
}) async {
|
}) async {
|
||||||
if (dek.length != 32) {
|
if (dek.length != 32) {
|
||||||
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
|
||||||
}
|
}
|
||||||
_enforceFloor(memoryKiB, iterations);
|
_enforceFloor(memoryKiB, iterations);
|
||||||
|
|
||||||
final salt = _randomBytes(16);
|
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
|
||||||
|
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
|
||||||
|
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
|
||||||
|
final useSalt = salt ?? _randomBytes(16);
|
||||||
final unlockKey = await _deriveUnlockKey(
|
final unlockKey = await _deriveUnlockKey(
|
||||||
pin: pin,
|
pin: pin,
|
||||||
salt: salt,
|
salt: useSalt,
|
||||||
memoryKiB: memoryKiB,
|
memoryKiB: memoryKiB,
|
||||||
iterations: iterations,
|
iterations: iterations,
|
||||||
parallelism: parallelism,
|
parallelism: parallelism,
|
||||||
@@ -80,13 +84,72 @@ class DekWrap {
|
|||||||
'$memoryKiB',
|
'$memoryKiB',
|
||||||
'$iterations',
|
'$iterations',
|
||||||
'$parallelism',
|
'$parallelism',
|
||||||
base64Encode(salt),
|
base64Encode(useSalt),
|
||||||
base64Encode(box.nonce),
|
base64Encode(box.nonce),
|
||||||
base64Encode(box.cipherText),
|
base64Encode(box.cipherText),
|
||||||
base64Encode(box.mac.bytes),
|
base64Encode(box.mac.bytes),
|
||||||
].join(':');
|
].join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
|
||||||
|
static Uint8List saltOf(String wrapped) {
|
||||||
|
final parts = wrapped.split(':');
|
||||||
|
if (parts.length != 8 || parts[0] != _version) {
|
||||||
|
throw FormatException('Unknown wrapped DEK format', wrapped);
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(base64Decode(parts[4]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
|
||||||
|
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
|
||||||
|
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
|
||||||
|
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
|
||||||
|
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
|
||||||
|
/// [WrongPinException].
|
||||||
|
static Future<(int, Uint8List)> unwrapFirstMatching({
|
||||||
|
required List<String> wrapped,
|
||||||
|
required String pin,
|
||||||
|
}) async {
|
||||||
|
final parsed = <List<String>>[];
|
||||||
|
for (final w in wrapped) {
|
||||||
|
final parts = w.split(':');
|
||||||
|
if (parts.length != 8 || parts[0] != _version) {
|
||||||
|
throw FormatException('Unknown wrapped DEK format', w);
|
||||||
|
}
|
||||||
|
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
|
||||||
|
parsed.add(parts);
|
||||||
|
}
|
||||||
|
final keys = <String, Uint8List>{};
|
||||||
|
for (final parts in parsed) {
|
||||||
|
final group = parts.sublist(1, 5).join(':');
|
||||||
|
keys[group] ??= await _deriveUnlockKey(
|
||||||
|
pin: pin,
|
||||||
|
salt: base64Decode(parts[4]),
|
||||||
|
memoryKiB: int.parse(parts[1]),
|
||||||
|
iterations: int.parse(parts[2]),
|
||||||
|
parallelism: int.parse(parts[3]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final aes = AesGcm.with256bits();
|
||||||
|
for (var i = 0; i < parsed.length; i++) {
|
||||||
|
final parts = parsed[i];
|
||||||
|
try {
|
||||||
|
final dek = await aes.decrypt(
|
||||||
|
SecretBox(
|
||||||
|
base64Decode(parts[6]),
|
||||||
|
nonce: base64Decode(parts[5]),
|
||||||
|
mac: Mac(base64Decode(parts[7])),
|
||||||
|
),
|
||||||
|
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
|
||||||
|
);
|
||||||
|
return (i, Uint8List.fromList(dek));
|
||||||
|
} on SecretBoxAuthenticationError {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw const WrongPinException();
|
||||||
|
}
|
||||||
|
|
||||||
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
|
||||||
/// [WrongPinException]; na neplatném formátu [FormatException].
|
/// [WrongPinException]; na neplatném formátu [FormatException].
|
||||||
static Future<Uint8List> unwrap({
|
static Future<Uint8List> unwrap({
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ class SepotSealedMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Jeden one-time prekey k publikaci na relay.
|
/// Jeden one-time prekey k publikaci na relay.
|
||||||
|
/// Nový signed prekey k nahrání na relay (rotace).
|
||||||
|
class SepotSignedPreKey {
|
||||||
|
SepotSignedPreKey({
|
||||||
|
required this.id,
|
||||||
|
required this.publicKey,
|
||||||
|
required this.signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final Uint8List publicKey;
|
||||||
|
final Uint8List signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nový last-resort Kyber prekey k nahrání na relay.
|
||||||
|
class SepotKyberPreKey {
|
||||||
|
SepotKyberPreKey({
|
||||||
|
required this.id,
|
||||||
|
required this.publicKey,
|
||||||
|
required this.signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final Uint8List publicKey;
|
||||||
|
final Uint8List signature;
|
||||||
|
}
|
||||||
|
|
||||||
class SepotOneTimePreKey {
|
class SepotOneTimePreKey {
|
||||||
SepotOneTimePreKey({required this.id, required this.publicKey});
|
SepotOneTimePreKey({required this.id, required this.publicKey});
|
||||||
|
|
||||||
@@ -420,4 +446,64 @@ class SepotCrypto {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
|
||||||
|
/// z bundle těsně před rotací ho ještě potřebují.
|
||||||
|
Future<SepotSignedPreKey> generateSignedPreKey({
|
||||||
|
required int id,
|
||||||
|
required int timestampMs,
|
||||||
|
}) async {
|
||||||
|
final identityPrivate = PrivateKey.deserialize(
|
||||||
|
bytes: _identity.privateKey.toList(),
|
||||||
|
);
|
||||||
|
final private = PrivateKey.generate();
|
||||||
|
final public = private.getPublicKey();
|
||||||
|
final signature = identityPrivate.sign(
|
||||||
|
message: public.serialize().toList(),
|
||||||
|
);
|
||||||
|
await _stores.signedPreKey.storeSignedPreKey(
|
||||||
|
id,
|
||||||
|
SignedPreKeyRecord(
|
||||||
|
id: id,
|
||||||
|
publicKey: public,
|
||||||
|
privateKey: private,
|
||||||
|
signature: signature.toList(),
|
||||||
|
timestamp: BigInt.from(timestampMs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return SepotSignedPreKey(
|
||||||
|
id: id,
|
||||||
|
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||||
|
signature: Uint8List.fromList(signature.toList()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
|
||||||
|
Future<SepotKyberPreKey> generateKyberPreKey({
|
||||||
|
required int id,
|
||||||
|
required int timestampMs,
|
||||||
|
}) async {
|
||||||
|
final identityPrivate = PrivateKey.deserialize(
|
||||||
|
bytes: _identity.privateKey.toList(),
|
||||||
|
);
|
||||||
|
final keyPair = KyberKeyPair.generate();
|
||||||
|
final public = keyPair.getPublicKey();
|
||||||
|
final signature = identityPrivate.sign(
|
||||||
|
message: public.serialize().toList(),
|
||||||
|
);
|
||||||
|
await _stores.kyberPreKey.storeKyberPreKey(
|
||||||
|
id,
|
||||||
|
KyberPreKeyRecord.create(
|
||||||
|
id: id,
|
||||||
|
keyPair: keyPair,
|
||||||
|
signature: signature.toList(),
|
||||||
|
timestamp: BigInt.from(timestampMs),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return SepotKyberPreKey(
|
||||||
|
id: id,
|
||||||
|
publicKey: Uint8List.fromList(public.serialize().toList()),
|
||||||
|
signature: Uint8List.fromList(signature.toList()),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,17 @@ class RelayClient {
|
|||||||
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
final _deliverController = StreamController<pb.Envelope>.broadcast();
|
||||||
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
final _receiptController = StreamController<pb.Receipt>.broadcast();
|
||||||
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
|
||||||
|
final _prekeysLowController = StreamController<int>.broadcast();
|
||||||
|
|
||||||
var _state = RelayConnectionState.disconnected;
|
var _state = RelayConnectionState.disconnected;
|
||||||
|
|
||||||
|
/// Kdy naposled přišel aplikační rámec od relaye.
|
||||||
|
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
static const _staleAfter = Duration(seconds: 20);
|
||||||
|
|
||||||
|
/// Relay hlásí docházející one-time prekeys (zbývající počet).
|
||||||
|
Stream<int> get prekeysLow => _prekeysLowController.stream;
|
||||||
|
|
||||||
RelayConnectionState get state => _state;
|
RelayConnectionState get state => _state;
|
||||||
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
Stream<RelayConnectionState> get stateStream => _stateController.stream;
|
||||||
|
|
||||||
@@ -100,9 +108,18 @@ class RelayClient {
|
|||||||
required List<SepotOneTimePreKey> oneTimePreKeys,
|
required List<SepotOneTimePreKey> oneTimePreKeys,
|
||||||
required Uint8List deliveryAddress,
|
required Uint8List deliveryAddress,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
|
||||||
|
// narazí na relayi na už existující identitu (409) a bez tohohle
|
||||||
|
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
|
||||||
|
// ignoruje.
|
||||||
|
final proofTs = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
|
||||||
final resp = await _transport.httpClient().post(
|
final resp = await _transport.httpClient().post(
|
||||||
Uri.parse('$baseUrl/v1/register'),
|
Uri.parse('$baseUrl/v1/register'),
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
|
||||||
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'identity_key': base64Encode(_crypto.identityPublicKey),
|
'identity_key': base64Encode(_crypto.identityPublicKey),
|
||||||
'device_id': 1,
|
'device_id': 1,
|
||||||
@@ -144,6 +161,49 @@ class RelayClient {
|
|||||||
_crypto.bindAccount(accountId);
|
_crypto.bindAccount(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
|
||||||
|
/// prekey. Vrací počet OTP na relayi po nahrání.
|
||||||
|
Future<int> uploadKeys({
|
||||||
|
SepotSignedPreKey? signedPreKey,
|
||||||
|
SepotKyberPreKey? kyberPreKey,
|
||||||
|
List<SepotOneTimePreKey> oneTimePreKeys = const [],
|
||||||
|
}) async {
|
||||||
|
final deviceUuid = _requireDevice();
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
|
||||||
|
final resp = await _transport.httpClient().post(
|
||||||
|
Uri.parse('$baseUrl/v1/keys'),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
if (signedPreKey != null)
|
||||||
|
'signed_prekey': {
|
||||||
|
'key_id': signedPreKey.id,
|
||||||
|
'public_key': base64Encode(signedPreKey.publicKey),
|
||||||
|
'signature': base64Encode(signedPreKey.signature),
|
||||||
|
},
|
||||||
|
if (kyberPreKey != null)
|
||||||
|
'kyber_prekey': {
|
||||||
|
'key_id': kyberPreKey.id,
|
||||||
|
'public_key': base64Encode(kyberPreKey.publicKey),
|
||||||
|
'signature': base64Encode(kyberPreKey.signature),
|
||||||
|
'is_last_resort': true,
|
||||||
|
},
|
||||||
|
'one_time_prekeys': [
|
||||||
|
for (final otp in oneTimePreKeys)
|
||||||
|
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (resp.statusCode != 200) {
|
||||||
|
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
|
||||||
|
}
|
||||||
|
final body = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||||
|
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
|
||||||
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
|
||||||
final hexAddr = deliveryAddress
|
final hexAddr = deliveryAddress
|
||||||
@@ -280,6 +340,20 @@ class RelayClient {
|
|||||||
_setState(RelayConnectionState.disconnected);
|
_setState(RelayConnectionState.disconnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
|
||||||
|
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
|
||||||
|
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
|
||||||
|
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
|
||||||
|
void kick() {
|
||||||
|
if (_state == RelayConnectionState.connected &&
|
||||||
|
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
|
||||||
|
_reconnectDelayS = 1;
|
||||||
|
_onDisconnected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _teardownChannel() async {
|
Future<void> _teardownChannel() async {
|
||||||
await _wsSub?.cancel();
|
await _wsSub?.cancel();
|
||||||
_wsSub = null;
|
_wsSub = null;
|
||||||
@@ -313,6 +387,7 @@ class RelayClient {
|
|||||||
_wsSub = channel.stream.listen(
|
_wsSub = channel.stream.listen(
|
||||||
(data) {
|
(data) {
|
||||||
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
|
||||||
|
_lastFrameAt = DateTime.now();
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
if (frame.hasChallenge()) {
|
if (frame.hasChallenge()) {
|
||||||
final signature = _crypto.sign([
|
final signature = _crypto.sign([
|
||||||
@@ -345,6 +420,10 @@ class RelayClient {
|
|||||||
if (!_turnController.isClosed) {
|
if (!_turnController.isClosed) {
|
||||||
_turnController.add(frame.turnCredentials);
|
_turnController.add(frame.turnCredentials);
|
||||||
}
|
}
|
||||||
|
} else if (frame.hasPrekeysLow()) {
|
||||||
|
if (!_prekeysLowController.isClosed) {
|
||||||
|
_prekeysLowController.add(frame.prekeysLow.remaining);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: _onDisconnected,
|
onDone: _onDisconnected,
|
||||||
@@ -389,14 +468,21 @@ class RelayClient {
|
|||||||
final receiptFuture = _receiptController.stream
|
final receiptFuture = _receiptController.stream
|
||||||
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
|
||||||
.timeout(timeout);
|
.timeout(timeout);
|
||||||
_send(pb.ClientFrame(
|
try {
|
||||||
envelope: pb.Envelope(
|
_send(pb.ClientFrame(
|
||||||
dstAddress: dstAddress,
|
envelope: pb.Envelope(
|
||||||
msgUuid: msgUuid,
|
dstAddress: dstAddress,
|
||||||
contentHint: hint,
|
msgUuid: msgUuid,
|
||||||
payload: payload,
|
contentHint: hint,
|
||||||
),
|
payload: payload,
|
||||||
));
|
),
|
||||||
|
));
|
||||||
|
} catch (_) {
|
||||||
|
// Odeslání selhalo (kanál zavřený) — čekající future by za 10 s
|
||||||
|
// hodil neodchycený TimeoutException do zvonku chyb.
|
||||||
|
receiptFuture.ignore();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
return receiptFuture;
|
return receiptFuture;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +541,7 @@ class RelayClient {
|
|||||||
_transport.close();
|
_transport.close();
|
||||||
await _stateController.close();
|
await _stateController.close();
|
||||||
await _turnController.close();
|
await _turnController.close();
|
||||||
|
await _prekeysLowController.close();
|
||||||
await _deliverController.close();
|
await _deliverController.close();
|
||||||
await _receiptController.close();
|
await _receiptController.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class PushBootstrap {
|
|||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
ApnsBridge.onWake = _wake;
|
ApnsBridge.onWake = _wake;
|
||||||
ApnsBridge.ensureHandler();
|
ApnsBridge.ensureHandler();
|
||||||
|
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
|
||||||
|
unawaited(CallRinger.expireStalePushRings());
|
||||||
} else {
|
} else {
|
||||||
await UnifiedPush.initialize(
|
await UnifiedPush.initialize(
|
||||||
onNewEndpoint: _onNewEndpoint,
|
onNewEndpoint: _onNewEndpoint,
|
||||||
|
|||||||
@@ -52,7 +52,15 @@ class PushService {
|
|||||||
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
|
||||||
bool get canWake => _messaging.isStarted;
|
bool get canWake => _messaging.isStarted;
|
||||||
|
|
||||||
void wake() => _messaging.onPushWake();
|
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
|
||||||
|
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
|
||||||
|
void wake() {
|
||||||
|
if (canWake) {
|
||||||
|
_messaging.onPushWake();
|
||||||
|
} else {
|
||||||
|
unawaited(PushNotifier.showWakeNotification());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void handleNewEndpoint(String url) {
|
void handleNewEndpoint(String url) {
|
||||||
_messaging.registerPush(_registration(
|
_messaging.registerPush(_registration(
|
||||||
|
|||||||
@@ -107,6 +107,56 @@ class MessagingService {
|
|||||||
StreamSubscription<RelayConnectionState>? _stateSub;
|
StreamSubscription<RelayConnectionState>? _stateSub;
|
||||||
Timer? _registerRetryTimer;
|
Timer? _registerRetryTimer;
|
||||||
Timer? _purgeTimer;
|
Timer? _purgeTimer;
|
||||||
|
StreamSubscription<int>? _prekeysLowSub;
|
||||||
|
|
||||||
|
/// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se
|
||||||
|
/// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná
|
||||||
|
/// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka
|
||||||
|
/// hovoru se tiše zahodila — hovory vůbec nešly přijmout.
|
||||||
|
static const knownContentTypes = {
|
||||||
|
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
|
||||||
|
..._groupControlTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax].
|
||||||
|
Duration _registerRetryDelay = const Duration(seconds: 30);
|
||||||
|
static const _registerRetryMax = Duration(minutes: 10);
|
||||||
|
|
||||||
|
/// Relay odmítl registraci jako konflikt identity (409) — typicky starší
|
||||||
|
/// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit.
|
||||||
|
bool _registrationConflict = false;
|
||||||
|
bool get registrationConflict => _registrationConflict;
|
||||||
|
|
||||||
|
/// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB
|
||||||
|
/// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva
|
||||||
|
/// by byla nenávratně pryč.
|
||||||
|
int _inFlight = 0;
|
||||||
|
Completer<void>? _drained;
|
||||||
|
|
||||||
|
/// Operace nad ratchet stavem jdou postupně. libsignal načte relaci,
|
||||||
|
/// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva
|
||||||
|
/// souběžné běhy nad touž relací by odvodily stejný message key a druhá
|
||||||
|
/// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží
|
||||||
|
/// `unawaited` vedle uživatelského odeslání, takže zámek je nutný.
|
||||||
|
Future<void> _chain = Future.value();
|
||||||
|
|
||||||
|
Future<T> _serial<T>(Future<T> Function() op) {
|
||||||
|
final next = _chain.then((_) => op());
|
||||||
|
_chain = next.then((_) {}, onError: (_) {});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu.
|
||||||
|
static const maxDisplayNameLength = 64;
|
||||||
|
|
||||||
|
static String clipName(String name) => name.length > maxDisplayNameLength
|
||||||
|
? name.substring(0, maxDisplayNameLength)
|
||||||
|
: name;
|
||||||
|
|
||||||
|
/// Kolik one-time prekeys doplnit, když relay hlásí nedostatek.
|
||||||
|
static const _otpBatch = 50;
|
||||||
|
static const _spkRotation = Duration(days: 7);
|
||||||
|
static const _kyberRotation = Duration(days: 30);
|
||||||
|
|
||||||
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
|
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
|
||||||
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
|
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
|
||||||
@@ -266,11 +316,13 @@ class MessagingService {
|
|||||||
final relay = _buildRelay();
|
final relay = _buildRelay();
|
||||||
_relay = relay;
|
_relay = relay;
|
||||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||||
|
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
|
||||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||||
_stateSub = relay.stateStream.listen((state) {
|
_stateSub = relay.stateStream.listen((state) {
|
||||||
if (state == RelayConnectionState.connected) {
|
if (state == RelayConnectionState.connected) {
|
||||||
unawaited(_flushOutbox());
|
unawaited(_flushOutbox());
|
||||||
unawaited(_refreshMyAvatarIfOld());
|
unawaited(_refreshMyAvatarIfOld());
|
||||||
|
unawaited(_rotatePreKeysIfDue());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -337,11 +389,13 @@ class MessagingService {
|
|||||||
final relay = _buildRelay();
|
final relay = _buildRelay();
|
||||||
_relay = relay;
|
_relay = relay;
|
||||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||||
|
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
|
||||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||||
_stateSub = relay.stateStream.listen((state) {
|
_stateSub = relay.stateStream.listen((state) {
|
||||||
if (state == RelayConnectionState.connected) {
|
if (state == RelayConnectionState.connected) {
|
||||||
unawaited(_flushOutbox());
|
unawaited(_flushOutbox());
|
||||||
unawaited(_refreshMyAvatarIfOld());
|
unawaited(_refreshMyAvatarIfOld());
|
||||||
|
unawaited(_rotatePreKeysIfDue());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -376,16 +430,109 @@ class MessagingService {
|
|||||||
Future<void> _registerWithRetry() async {
|
Future<void> _registerWithRetry() async {
|
||||||
try {
|
try {
|
||||||
await _register();
|
await _register();
|
||||||
|
} on RelayException catch (e) {
|
||||||
|
// 409 = identita na relayi existuje a relay neumí převzetí (starší
|
||||||
|
// verze). Opakovat má smysl (relay se aktualizuje), ale ne každých
|
||||||
|
// 30 s navždy — a UI má vědět proč.
|
||||||
|
_registrationConflict = e.statusCode == 409;
|
||||||
|
_scheduleRegisterRetry();
|
||||||
|
return;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_registerRetryTimer?.cancel();
|
_scheduleRegisterRetry();
|
||||||
_registerRetryTimer =
|
|
||||||
Timer(const Duration(seconds: 30), _registerWithRetry);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
_registrationConflict = false;
|
||||||
|
_registerRetryDelay = const Duration(seconds: 30);
|
||||||
_relay!.start();
|
_relay!.start();
|
||||||
await _refreshSenderCert();
|
await _refreshSenderCert();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleRegisterRetry() {
|
||||||
|
_registerRetryTimer?.cancel();
|
||||||
|
_registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry);
|
||||||
|
final doubled = _registerRetryDelay * 2;
|
||||||
|
_registerRetryDelay =
|
||||||
|
doubled > _registerRetryMax ? _registerRetryMax : doubled;
|
||||||
|
onUpdate?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _kvInt(CryptoDb db, String key) {
|
||||||
|
final raw = db.kvGet(key);
|
||||||
|
return raw == null ? null : int.tryParse(utf8.decode(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku
|
||||||
|
/// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP
|
||||||
|
/// a každá nová relace k němu běžela bez jednorázového klíče.
|
||||||
|
Future<void> _replenishPreKeys(int remaining) async {
|
||||||
|
final db = _db;
|
||||||
|
final crypto = _crypto;
|
||||||
|
final relay = _relay;
|
||||||
|
if (db == null || crypto == null || relay == null || accountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Registrace nahrála id 1–100; další dávky navazují.
|
||||||
|
final next = _kvInt(db, 'otp_next_id') ?? 101;
|
||||||
|
final otps = await _serial(
|
||||||
|
() => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch));
|
||||||
|
await relay.uploadKeys(oneTimePreKeys: otps);
|
||||||
|
db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}'));
|
||||||
|
} catch (_) {
|
||||||
|
// Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně),
|
||||||
|
/// Staré klíče zůstávají ve store — relace z bundle
|
||||||
|
/// staženého těsně před rotací je ještě potřebují.
|
||||||
|
Future<void> _rotatePreKeysIfDue() async {
|
||||||
|
final db = _db;
|
||||||
|
final crypto = _crypto;
|
||||||
|
final relay = _relay;
|
||||||
|
if (db == null || crypto == null || relay == null || accountId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
bool due(String key, Duration period) {
|
||||||
|
final at = _kvInt(db, key);
|
||||||
|
return at == null ||
|
||||||
|
now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period;
|
||||||
|
}
|
||||||
|
|
||||||
|
final spkDue = due('spk_rotated_at', _spkRotation);
|
||||||
|
final kyberDue = due('kyber_rotated_at', _kyberRotation);
|
||||||
|
if (!spkDue && !kyberDue) return;
|
||||||
|
try {
|
||||||
|
SepotSignedPreKey? spk;
|
||||||
|
SepotKyberPreKey? kyber;
|
||||||
|
if (spkDue) {
|
||||||
|
final id = _kvInt(db, 'spk_next_id') ?? 2;
|
||||||
|
spk = await _serial(() => crypto.generateSignedPreKey(
|
||||||
|
id: id, timestampMs: now.millisecondsSinceEpoch));
|
||||||
|
}
|
||||||
|
if (kyberDue) {
|
||||||
|
final id = _kvInt(db, 'kyber_next_id') ?? 2;
|
||||||
|
kyber = await _serial(() => crypto.generateKyberPreKey(
|
||||||
|
id: id, timestampMs: now.millisecondsSinceEpoch));
|
||||||
|
}
|
||||||
|
await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber);
|
||||||
|
final stamp = utf8.encode('${now.millisecondsSinceEpoch}');
|
||||||
|
db.transaction(() {
|
||||||
|
if (spk != null) {
|
||||||
|
db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}'));
|
||||||
|
db.kvPut('spk_rotated_at', stamp);
|
||||||
|
}
|
||||||
|
if (kyber != null) {
|
||||||
|
db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}'));
|
||||||
|
db.kvPut('kyber_rotated_at', stamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _register() async {
|
Future<void> _register() async {
|
||||||
final db = _db!;
|
final db = _db!;
|
||||||
final crypto = _crypto!;
|
final crypto = _crypto!;
|
||||||
@@ -410,10 +557,17 @@ class MessagingService {
|
|||||||
deliveryAddress: deliveryAddress,
|
deliveryAddress: deliveryAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final stamp = utf8.encode('$nowMs');
|
||||||
db.transaction(() {
|
db.transaction(() {
|
||||||
db.kvPut('account_id', utf8.encode(reg.accountId));
|
db.kvPut('account_id', utf8.encode(reg.accountId));
|
||||||
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
|
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
|
||||||
db.kvPut('delivery_address', deliveryAddress);
|
db.kvPut('delivery_address', deliveryAddress);
|
||||||
|
// Id pro doplňování a rotace klíčů navazují na registraci.
|
||||||
|
db.kvPut('otp_next_id', utf8.encode('101'));
|
||||||
|
db.kvPut('spk_next_id', utf8.encode('2'));
|
||||||
|
db.kvPut('kyber_next_id', utf8.encode('2'));
|
||||||
|
db.kvPut('spk_rotated_at', stamp);
|
||||||
|
db.kvPut('kyber_rotated_at', stamp);
|
||||||
});
|
});
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
}
|
}
|
||||||
@@ -2009,6 +2163,10 @@ class MessagingService {
|
|||||||
'txt': caption ?? '',
|
'txt': caption ?? '',
|
||||||
're': ?message.replyToId,
|
're': ?message.replyToId,
|
||||||
'ttl': ?message.disappearAfterSeconds,
|
'ttl': ?message.disappearAfterSeconds,
|
||||||
|
// Anketa a zmínky patří k téže zprávě i při dodatečném odeslání —
|
||||||
|
// bez nich by po reconnectu dorazil jen holý záložní text.
|
||||||
|
'poll': ?message.poll?.toWire(),
|
||||||
|
if (message.mentions.isNotEmpty) 'mn': message.mentions,
|
||||||
'p': _profile(),
|
'p': _profile(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2053,22 +2211,27 @@ class MessagingService {
|
|||||||
}
|
}
|
||||||
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
|
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
|
||||||
|
|
||||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
// Krypto část jde přes [_serial] (viz komentář u pole): relace se
|
||||||
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
||||||
if (!await crypto.hasSession(peerAddr)) {
|
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||||
if (ephemeral) return null;
|
final sealed = await _serial<Uint8List?>(() async {
|
||||||
final bundle = await relay.fetchBundle(peerDa);
|
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
||||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||||
throw const IdentityKeyMismatchError();
|
if (!await crypto.hasSession(peerAddr)) {
|
||||||
|
if (ephemeral) return null;
|
||||||
|
final bundle = await relay.fetchBundle(peerDa);
|
||||||
|
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||||
|
throw const IdentityKeyMismatchError();
|
||||||
|
}
|
||||||
|
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||||
}
|
}
|
||||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
return crypto.sealedEncrypt(
|
||||||
}
|
recipient: peerAddr,
|
||||||
|
plaintext: utf8.encode(jsonEncode(content)),
|
||||||
final sealed = await crypto.sealedEncrypt(
|
senderCertificate: await _senderCertificate(),
|
||||||
recipient: peerAddr,
|
);
|
||||||
plaintext: utf8.encode(jsonEncode(content)),
|
});
|
||||||
senderCertificate: await _senderCertificate(),
|
if (sealed == null) return null;
|
||||||
);
|
|
||||||
|
|
||||||
final receipt = await relay.sendEnvelope(
|
final receipt = await relay.sendEnvelope(
|
||||||
dstAddress: peerDa,
|
dstAddress: peerDa,
|
||||||
@@ -2084,10 +2247,25 @@ class MessagingService {
|
|||||||
|
|
||||||
// ── Příjem ──────────────────────────────────────────────────────────
|
// ── Příjem ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _onDeliver(pb.Envelope envelope) async {
|
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
||||||
|
/// rozpracované, aby na ně [stop] počkal.
|
||||||
|
void _onDeliver(pb.Envelope envelope) {
|
||||||
|
_inFlight++;
|
||||||
|
unawaited(_serial(() => _handleDelivery(envelope))
|
||||||
|
.then((_) {}, onError: (_) {})
|
||||||
|
.whenComplete(() {
|
||||||
|
_inFlight--;
|
||||||
|
if (_inFlight == 0) _drained?.complete();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDelivery(pb.Envelope envelope) async {
|
||||||
final crypto = _crypto;
|
final crypto = _crypto;
|
||||||
final trustRoot = _trustRoot;
|
final trustRoot = _trustRoot;
|
||||||
if (crypto == null || trustRoot == null) return;
|
// Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali
|
||||||
|
// na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal.
|
||||||
|
// Relay obálku doručí znovu po odemčení.
|
||||||
|
if (crypto == null || trustRoot == null || !storage.isUnlocked) return;
|
||||||
|
|
||||||
final SepotSealedMessage opened;
|
final SepotSealedMessage opened;
|
||||||
try {
|
try {
|
||||||
@@ -2110,10 +2288,7 @@ class MessagingService {
|
|||||||
final content =
|
final content =
|
||||||
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
|
||||||
final type = content['t'] as String?;
|
final type = content['t'] as String?;
|
||||||
const known = {
|
const known = knownContentTypes;
|
||||||
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
|
|
||||||
..._groupControlTypes,
|
|
||||||
};
|
|
||||||
if (content['v'] != 1 || !known.contains(type)) {
|
if (content['v'] != 1 || !known.contains(type)) {
|
||||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||||
@@ -2228,13 +2403,18 @@ class MessagingService {
|
|||||||
final replyTo = content['re'] as String?;
|
final replyTo = content['re'] as String?;
|
||||||
final Message message;
|
final Message message;
|
||||||
if (type == 'txt') {
|
if (type == 'txt') {
|
||||||
final text = content['txt'];
|
final raw = content['txt'];
|
||||||
if (text is! String) {
|
if (raw is! String) {
|
||||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay!.ack(envelope.msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
||||||
|
// JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila.
|
||||||
|
const maxWireText = AppConstants.maxMessageLength * 4;
|
||||||
|
final text =
|
||||||
|
raw.length > maxWireText ? raw.substring(0, maxWireText) : raw;
|
||||||
message = Message(
|
message = Message(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
senderId: contact.publicIdentity.fingerprint.display,
|
senderId: contact.publicIdentity.fingerprint.display,
|
||||||
@@ -2567,7 +2747,7 @@ class MessagingService {
|
|||||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||||
final name = (profile?['n'] as String?)?.trim();
|
final name = (profile?['n'] as String?)?.trim();
|
||||||
final identity = PublicIdentity(
|
final identity = PublicIdentity(
|
||||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||||
identityKey: opened.senderIdentityKey,
|
identityKey: opened.senderIdentityKey,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
accountId: opened.senderName,
|
accountId: opened.senderName,
|
||||||
@@ -2603,7 +2783,7 @@ class MessagingService {
|
|||||||
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
|
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
|
||||||
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
|
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
|
||||||
void onPushWake() {
|
void onPushWake() {
|
||||||
_relay?.start();
|
_relay?.kick();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
|
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
|
||||||
@@ -2631,7 +2811,17 @@ class MessagingService {
|
|||||||
_purgeTimer?.cancel();
|
_purgeTimer?.cancel();
|
||||||
_purgeTimer = null;
|
_purgeTimer = null;
|
||||||
await _deliverSub?.cancel();
|
await _deliverSub?.cancel();
|
||||||
|
await _prekeysLowSub?.cancel();
|
||||||
await _stateSub?.cancel();
|
await _stateSub?.cancel();
|
||||||
|
// Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve
|
||||||
|
// pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje
|
||||||
|
// zámek navždy.
|
||||||
|
if (_inFlight > 0) {
|
||||||
|
final drained = _drained ??= Completer<void>();
|
||||||
|
await drained.future
|
||||||
|
.timeout(const Duration(seconds: 8), onTimeout: () {});
|
||||||
|
}
|
||||||
|
_drained = null;
|
||||||
await _relay?.dispose();
|
await _relay?.dispose();
|
||||||
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
||||||
_db = null;
|
_db = null;
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
|
|||||||
/// při onboardingu, který DB rovnou otevře).
|
/// při onboardingu, který DB rovnou otevře).
|
||||||
|
|
||||||
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
|
||||||
|
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
|
||||||
|
class PinLockedException implements Exception {
|
||||||
|
const PinLockedException(this.remaining);
|
||||||
|
|
||||||
|
final Duration remaining;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PIN locked for ${remaining.inSeconds}s';
|
||||||
|
}
|
||||||
|
|
||||||
class DecoyPinConflict implements Exception {
|
class DecoyPinConflict implements Exception {
|
||||||
const DecoyPinConflict();
|
const DecoyPinConflict();
|
||||||
@override
|
@override
|
||||||
@@ -51,6 +61,13 @@ class SecureStorageService {
|
|||||||
static const _legacyPinHashKey = 'pin_hash';
|
static const _legacyPinHashKey = 'pin_hash';
|
||||||
|
|
||||||
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
|
||||||
|
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
|
||||||
|
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
|
||||||
|
static const _pinFailCountKey = 'pin_fail_count';
|
||||||
|
static const _pinLockUntilKey = 'pin_lock_until';
|
||||||
|
static const _pinFreeAttempts = 5;
|
||||||
|
static const _pinLockBase = Duration(seconds: 30);
|
||||||
|
static const _pinLockMax = Duration(minutes: 15);
|
||||||
|
|
||||||
final FlutterSecureStorage _secureStorage;
|
final FlutterSecureStorage _secureStorage;
|
||||||
final String? _dbPathOverride;
|
final String? _dbPathOverride;
|
||||||
@@ -60,12 +77,24 @@ class SecureStorageService {
|
|||||||
bool _decoyMode = false;
|
bool _decoyMode = false;
|
||||||
|
|
||||||
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
|
||||||
|
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
|
||||||
|
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
|
||||||
bool get isDecoyMode => _decoyMode;
|
bool get isDecoyMode => _decoyMode;
|
||||||
|
|
||||||
|
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
|
||||||
|
LocalIdentity? _decoyIdentity;
|
||||||
|
|
||||||
|
void _requireRealSession() {
|
||||||
|
if (_decoyMode) throw StateError('Not available in a duress session');
|
||||||
|
}
|
||||||
|
|
||||||
SecureStorageService({String? dbPath})
|
SecureStorageService({String? dbPath})
|
||||||
: _secureStorage = const FlutterSecureStorage(
|
: _secureStorage = const FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
// this_device: identita a klíče nesmí putovat v iCloud Keychain
|
||||||
|
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
|
||||||
|
iOptions: IOSOptions(
|
||||||
|
accessibility: KeychainAccessibility.first_unlock_this_device),
|
||||||
),
|
),
|
||||||
_dbPathOverride = dbPath;
|
_dbPathOverride = dbPath;
|
||||||
|
|
||||||
@@ -98,21 +127,59 @@ class SecureStorageService {
|
|||||||
if (wrapped == null) {
|
if (wrapped == null) {
|
||||||
throw StateError('No PIN set — use unlockWithoutPin');
|
throw StateError('No PIN set — use unlockWithoutPin');
|
||||||
}
|
}
|
||||||
|
final locked = await pinLockRemaining();
|
||||||
|
if (locked != null) throw PinLockedException(locked);
|
||||||
|
|
||||||
|
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
||||||
try {
|
try {
|
||||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
|
||||||
await _openDb(dek);
|
// který PIN padl.
|
||||||
|
final (index, dek) = await DekWrap.unwrapFirstMatching(
|
||||||
|
wrapped: [wrapped, ?decoy],
|
||||||
|
pin: pin,
|
||||||
|
);
|
||||||
|
await _openDb(dek, decoy: index == 1);
|
||||||
|
await _resetPinFailures();
|
||||||
} on WrongPinException {
|
} on WrongPinException {
|
||||||
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
|
await _notePinFailure();
|
||||||
if (decoy == null) rethrow;
|
rethrow;
|
||||||
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
|
|
||||||
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
|
|
||||||
await _openDb(dek, decoy: true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zbývající zámek po sérii špatných PINů; null = volno.
|
||||||
|
Future<Duration?> pinLockRemaining() async {
|
||||||
|
final raw = await _secureStorage.read(key: _pinLockUntilKey);
|
||||||
|
final until = raw == null ? null : int.tryParse(raw);
|
||||||
|
if (until == null) return null;
|
||||||
|
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
|
||||||
|
.difference(DateTime.now());
|
||||||
|
return remaining > Duration.zero ? remaining : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notePinFailure() async {
|
||||||
|
final raw = await _secureStorage.read(key: _pinFailCountKey);
|
||||||
|
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
|
||||||
|
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
|
||||||
|
if (failures < _pinFreeAttempts) return;
|
||||||
|
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
|
||||||
|
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
|
||||||
|
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
|
||||||
|
if (lock > _pinLockMax) lock = _pinLockMax;
|
||||||
|
await _secureStorage.write(
|
||||||
|
key: _pinLockUntilKey,
|
||||||
|
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetPinFailures() async {
|
||||||
|
await _secureStorage.delete(key: _pinFailCountKey);
|
||||||
|
await _secureStorage.delete(key: _pinLockUntilKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
|
||||||
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
|
||||||
Future<void> enablePin(String pin) async {
|
Future<void> enablePin(String pin) async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? await _getOrCreateRawDek();
|
final dek = _dek ?? await _getOrCreateRawDek();
|
||||||
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
|
||||||
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
|
||||||
@@ -124,6 +191,7 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
/// Vypne PIN: DEK se vrátí raw do OS keystore.
|
||||||
Future<void> disablePin() async {
|
Future<void> disablePin() async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||||
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
|
||||||
await _secureStorage.delete(key: _wrappedDekKey);
|
await _secureStorage.delete(key: _wrappedDekKey);
|
||||||
@@ -151,6 +219,7 @@ class SecureStorageService {
|
|||||||
required String oldPin,
|
required String oldPin,
|
||||||
required String newPin,
|
required String newPin,
|
||||||
}) async {
|
}) async {
|
||||||
|
_requireRealSession();
|
||||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||||
if (wrapped == null) throw StateError('No PIN set');
|
if (wrapped == null) throw StateError('No PIN set');
|
||||||
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
|
||||||
@@ -165,7 +234,11 @@ class SecureStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
|
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
|
||||||
|
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
|
||||||
|
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
|
||||||
|
final rewrapped = await DekWrap.wrap(
|
||||||
|
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
|
||||||
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +249,7 @@ class SecureStorageService {
|
|||||||
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
|
||||||
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
|
||||||
Future<void> enableDecoyPin(String decoyPin) async {
|
Future<void> enableDecoyPin(String decoyPin) async {
|
||||||
|
_requireRealSession();
|
||||||
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||||
if (realWrapped == null) {
|
if (realWrapped == null) {
|
||||||
throw StateError('Set a regular PIN first');
|
throw StateError('Set a regular PIN first');
|
||||||
@@ -193,7 +267,9 @@ class SecureStorageService {
|
|||||||
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
|
||||||
final decoyPath = await _dbPath(decoy: true);
|
final decoyPath = await _dbPath(decoy: true);
|
||||||
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
|
||||||
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
|
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
|
||||||
|
final wrapped = await DekWrap.wrap(
|
||||||
|
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
|
||||||
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +279,20 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Zruší nátlakový PIN a smaže decoy DB.
|
/// Zruší nátlakový PIN a smaže decoy DB.
|
||||||
Future<void> disableDecoyPin() async {
|
Future<void> disableDecoyPin() async {
|
||||||
|
_requireRealSession();
|
||||||
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
await _secureStorage.delete(key: _decoyWrappedDekKey);
|
||||||
try {
|
await _deleteDbFiles(await _dbPath(decoy: true));
|
||||||
final f = File(await _dbPath(decoy: true));
|
}
|
||||||
if (f.existsSync()) f.deleteSync();
|
|
||||||
} catch (_) {}
|
Future<void> _deleteDbFiles(String path) async {
|
||||||
|
for (final suffix in ['', '-wal', '-shm']) {
|
||||||
|
try {
|
||||||
|
final f = File('$path$suffix');
|
||||||
|
if (f.existsSync()) f.deleteSync();
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort úklid
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
/// Starší instalace měla PIN jako PBKDF2 hash — starý formát neumíme
|
||||||
@@ -222,6 +307,8 @@ class SecureStorageService {
|
|||||||
_db?.close();
|
_db?.close();
|
||||||
_db = null;
|
_db = null;
|
||||||
_dek = null;
|
_dek = null;
|
||||||
|
_decoyMode = false;
|
||||||
|
_decoyIdentity = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
|
||||||
@@ -293,6 +380,11 @@ class SecureStorageService {
|
|||||||
|
|
||||||
/// Načti lokální identitu
|
/// Načti lokální identitu
|
||||||
Future<LocalIdentity?> loadIdentity() async {
|
Future<LocalIdentity?> loadIdentity() async {
|
||||||
|
if (_decoyMode) {
|
||||||
|
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
|
||||||
|
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
|
||||||
|
return _decoyIdentity ??= await LocalIdentity.create('');
|
||||||
|
}
|
||||||
final json = await _secureStorage.read(key: _identityKey);
|
final json = await _secureStorage.read(key: _identityKey);
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +412,7 @@ class SecureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>?> loadSeedPhrase() async {
|
Future<List<String>?> loadSeedPhrase() async {
|
||||||
|
if (_decoyMode) return null;
|
||||||
final json = await _secureStorage.read(key: _seedPhraseKey);
|
final json = await _secureStorage.read(key: _seedPhraseKey);
|
||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
return (jsonDecode(json) as List).cast<String>();
|
return (jsonDecode(json) as List).cast<String>();
|
||||||
@@ -331,6 +424,7 @@ class SecureStorageService {
|
|||||||
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
|
||||||
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
|
||||||
Future<void> enableBiometrics() async {
|
Future<void> enableBiometrics() async {
|
||||||
|
_requireRealSession();
|
||||||
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
|
||||||
await _secureStorage.write(
|
await _secureStorage.write(
|
||||||
key: _biometricDekKey,
|
key: _biometricDekKey,
|
||||||
@@ -342,6 +436,7 @@ class SecureStorageService {
|
|||||||
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
|
||||||
/// PIN-only model: DEK existuje jen za Argon2id).
|
/// PIN-only model: DEK existuje jen za Argon2id).
|
||||||
Future<void> disableBiometrics() async {
|
Future<void> disableBiometrics() async {
|
||||||
|
_requireRealSession();
|
||||||
await _secureStorage.delete(key: _biometricDekKey);
|
await _secureStorage.delete(key: _biometricDekKey);
|
||||||
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
|
||||||
}
|
}
|
||||||
@@ -475,24 +570,29 @@ class SecureStorageService {
|
|||||||
if (!isUnlocked) return false;
|
if (!isUnlocked) return false;
|
||||||
var changed = false;
|
var changed = false;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
for (final conv in await loadConversations()) {
|
bool isExpired(Message m) =>
|
||||||
final expired = conv.messages
|
m.disappearAfterSeconds != null &&
|
||||||
.where((m) =>
|
m.timestamp
|
||||||
m.disappearAfterSeconds != null &&
|
.add(Duration(seconds: m.disappearAfterSeconds!))
|
||||||
m.timestamp
|
.isBefore(now);
|
||||||
.add(Duration(seconds: m.disappearAfterSeconds!))
|
for (final snapshot in await loadConversations()) {
|
||||||
.isBefore(now))
|
if (!snapshot.messages.any(isExpired)) continue;
|
||||||
.toList();
|
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
|
||||||
|
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
|
||||||
|
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
|
||||||
|
final conv = await loadConversation(snapshot.contactId);
|
||||||
|
if (conv == null) continue;
|
||||||
|
final expired = conv.messages.where(isExpired).toList();
|
||||||
if (expired.isEmpty) continue;
|
if (expired.isEmpty) continue;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
conv.messages.removeWhere(isExpired);
|
||||||
|
await saveConversation(conv);
|
||||||
for (final m in expired) {
|
for (final m in expired) {
|
||||||
final blobId = m.attachment?.blobId;
|
final blobId = m.attachment?.blobId;
|
||||||
if (blobId != null) {
|
if (blobId != null) {
|
||||||
await AttachmentStore().delete(blobId);
|
await AttachmentStore().delete(blobId);
|
||||||
}
|
}
|
||||||
conv.messages.remove(m);
|
|
||||||
}
|
}
|
||||||
await saveConversation(conv);
|
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -512,12 +612,19 @@ class SecureStorageService {
|
|||||||
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
|
||||||
/// i lokální bloby příloh.
|
/// i lokální bloby příloh.
|
||||||
Future<void> deleteAll() async {
|
Future<void> deleteAll() async {
|
||||||
final path = await _dbPath();
|
if (_decoyMode) {
|
||||||
lock();
|
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
|
||||||
for (final suffix in ['', '-wal', '-shm']) {
|
// data a keystore zůstávají (dřív to smazalo reálný účet).
|
||||||
final f = File('$path$suffix');
|
final decoyPath = await _dbPath(decoy: true);
|
||||||
if (f.existsSync()) f.deleteSync();
|
lock();
|
||||||
|
await _deleteDbFiles(decoyPath);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
final path = await _dbPath();
|
||||||
|
final decoyPath = await _dbPath(decoy: true);
|
||||||
|
lock();
|
||||||
|
await _deleteDbFiles(path);
|
||||||
|
await _deleteDbFiles(decoyPath);
|
||||||
await AttachmentStore().deleteAll();
|
await AttachmentStore().deleteAll();
|
||||||
await _secureStorage.deleteAll();
|
await _secureStorage.deleteAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
import 'package:sepot/providers/messaging_provider.dart';
|
import 'package:sepot/providers/messaging_provider.dart';
|
||||||
import 'package:sepot/providers/push_provider.dart';
|
import 'package:sepot/providers/push_provider.dart';
|
||||||
|
import 'package:sepot/providers/settings_provider.dart';
|
||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
import 'package:sepot/widgets/sepot_logo.dart';
|
import 'package:sepot/widgets/sepot_logo.dart';
|
||||||
|
|
||||||
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
try {
|
try {
|
||||||
final ok = await LocalAuthentication().authenticate(
|
final ok = await LocalAuthentication().authenticate(
|
||||||
localizedReason: unlockReason,
|
localizedReason: unlockReason,
|
||||||
options: const AuthenticationOptions(stickyAuth: true),
|
// biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
|
||||||
|
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
|
||||||
|
options: const AuthenticationOptions(
|
||||||
|
stickyAuth: true, biometricOnly: true),
|
||||||
);
|
);
|
||||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||||
await storage.unlockWithBiometrics();
|
await storage.unlockWithBiometrics();
|
||||||
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
void _restartMessagingAfterUnlock() {
|
void _restartMessagingAfterUnlock() {
|
||||||
ref.invalidate(messagingStartProvider);
|
ref.invalidate(messagingStartProvider);
|
||||||
ref.invalidate(pushInitProvider);
|
ref.invalidate(pushInitProvider);
|
||||||
|
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
|
||||||
|
// neaplikoval, teď má skutečnou hodnotu.
|
||||||
|
ref.invalidate(screenSecurityProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _verifyPin() async {
|
Future<void> _verifyPin() async {
|
||||||
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
|||||||
_restartMessagingAfterUnlock();
|
_restartMessagingAfterUnlock();
|
||||||
if (mounted) context.go('/chats');
|
if (mounted) context.go('/chats');
|
||||||
} on WrongPinException {
|
} on WrongPinException {
|
||||||
|
final locked = await ref.read(storageProvider).pinLockRemaining();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = context.l10n.pinWrong2;
|
_error = locked == null
|
||||||
|
? context.l10n.pinWrong2
|
||||||
|
: context.l10n.pinLockedFor(locked.inSeconds);
|
||||||
_pin = '';
|
_pin = '';
|
||||||
_isVerifying = false;
|
_isVerifying = false;
|
||||||
});
|
});
|
||||||
HapticFeedback.heavyImpact();
|
HapticFeedback.heavyImpact();
|
||||||
|
} on PinLockedException catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
|
||||||
|
_pin = '';
|
||||||
|
_isVerifying = false;
|
||||||
|
});
|
||||||
} on StateError {
|
} on StateError {
|
||||||
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
|
||||||
if (mounted) context.go('/onboarding');
|
if (mounted) context.go('/onboarding');
|
||||||
|
|||||||
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
|||||||
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
|
||||||
child: Column(
|
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
|
||||||
children: [
|
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
|
||||||
|
Positioned.fill(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
if (!showVideo) ...[
|
if (!showVideo) ...[
|
||||||
SepotAvatar(
|
SepotAvatar(
|
||||||
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
|
|||||||
_InCallControls(call: call, controller: controller),
|
_InCallControls(call: call, controller: controller),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showVideo && !call.cameraOff)
|
if (showVideo && !call.cameraOff)
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
|||||||
}
|
}
|
||||||
final ok = await auth.authenticate(
|
final ok = await auth.authenticate(
|
||||||
localizedReason: l10n.biometricsEnrollPromptReason,
|
localizedReason: l10n.biometricsEnrollPromptReason,
|
||||||
options: const AuthenticationOptions(stickyAuth: true),
|
options: const AuthenticationOptions(
|
||||||
|
stickyAuth: true, biometricOnly: true),
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
await storage.enableBiometrics();
|
await storage.enableBiometrics();
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final identityAsync = ref.watch(identityProvider);
|
final identityAsync = ref.watch(identityProvider);
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
// Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet
|
||||||
|
// (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá.
|
||||||
|
final decoy = ref.watch(storageProvider).isDecoyMode;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Sekce Zabezpečení
|
// Sekce Zabezpečení
|
||||||
_buildSectionHeader(l10n.settingsSectionSecurity),
|
_buildSectionHeader(l10n.settingsSectionSecurity),
|
||||||
_buildTile(
|
if (!decoy) ...[
|
||||||
icon: Icons.pin_outlined,
|
_buildTile(
|
||||||
title: l10n.settingsChangePinTitle,
|
icon: Icons.pin_outlined,
|
||||||
subtitle: l10n.settingsChangePinSubtitle,
|
title: l10n.settingsChangePinTitle,
|
||||||
onTap: () => context.push('/change-pin'),
|
subtitle: l10n.settingsChangePinSubtitle,
|
||||||
),
|
onTap: () => context.push('/change-pin'),
|
||||||
const BiometricsTile(),
|
),
|
||||||
|
const BiometricsTile(),
|
||||||
|
],
|
||||||
_buildTile(
|
_buildTile(
|
||||||
icon: Icons.timer_outlined,
|
icon: Icons.timer_outlined,
|
||||||
title: l10n.settingsAutoLockTitle,
|
title: l10n.settingsAutoLockTitle,
|
||||||
@@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
onTap: () => _nastavAutoLock(context, ref),
|
onTap: () => _nastavAutoLock(context, ref),
|
||||||
),
|
),
|
||||||
_buildTile(
|
if (!decoy)
|
||||||
icon: Icons.password_outlined,
|
_buildTile(
|
||||||
title: l10n.settingsDecoyPinTitle,
|
icon: Icons.password_outlined,
|
||||||
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
title: l10n.settingsDecoyPinTitle,
|
||||||
? l10n.settingsDecoyPinSetSubtitle
|
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
||||||
: l10n.settingsDecoyPinUnsetSubtitle,
|
? l10n.settingsDecoyPinSetSubtitle
|
||||||
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
: l10n.settingsDecoyPinUnsetSubtitle,
|
||||||
? TextButton(
|
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
|
||||||
onPressed: () => _disableDecoyPin(context, ref),
|
? TextButton(
|
||||||
child: Text(l10n.commonCancel),
|
onPressed: () => _disableDecoyPin(context, ref),
|
||||||
)
|
child: Text(l10n.commonCancel),
|
||||||
: null,
|
)
|
||||||
onTap: () {
|
: null,
|
||||||
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
|
onTap: () {
|
||||||
_setDecoyPin(context, ref);
|
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
|
||||||
}
|
_setDecoyPin(context, ref);
|
||||||
},
|
}
|
||||||
),
|
},
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Sekce Notifikace
|
// Sekce Notifikace
|
||||||
_buildSectionHeader(l10n.settingsSectionNotifications),
|
_buildSectionHeader(l10n.settingsSectionNotifications),
|
||||||
@@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Sekce Záloha
|
// Sekce Záloha
|
||||||
_buildSectionHeader(l10n.settingsSectionBackup),
|
if (!decoy) ...[
|
||||||
_buildTile(
|
_buildSectionHeader(l10n.settingsSectionBackup),
|
||||||
icon: Icons.key_outlined,
|
_buildTile(
|
||||||
title: l10n.settingsSeedPhraseTitle,
|
icon: Icons.key_outlined,
|
||||||
subtitle: l10n.settingsSeedPhraseSubtitle,
|
title: l10n.settingsSeedPhraseTitle,
|
||||||
onTap: () => _showSeedPhrase(context, ref),
|
subtitle: l10n.settingsSeedPhraseSubtitle,
|
||||||
),
|
onTap: () => _showSeedPhrase(context, ref),
|
||||||
_buildTile(
|
),
|
||||||
icon: Icons.backup_outlined,
|
_buildTile(
|
||||||
title: l10n.settingsBackupExportTitle,
|
icon: Icons.backup_outlined,
|
||||||
subtitle: l10n.settingsBackupExportSubtitle,
|
title: l10n.settingsBackupExportTitle,
|
||||||
onTap: () => _exportBackup(context, ref),
|
subtitle: l10n.settingsBackupExportSubtitle,
|
||||||
),
|
onTap: () => _exportBackup(context, ref),
|
||||||
_buildTile(
|
),
|
||||||
icon: Icons.restore_outlined,
|
_buildTile(
|
||||||
title: l10n.settingsBackupImportTitle,
|
icon: Icons.restore_outlined,
|
||||||
subtitle: l10n.settingsBackupImportSubtitle,
|
title: l10n.settingsBackupImportTitle,
|
||||||
onTap: () => _importBackup(context, ref),
|
subtitle: l10n.settingsBackupImportSubtitle,
|
||||||
),
|
onTap: () => _importBackup(context, ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
// „Web klient" byl slib funkce, která neexistuje (multi-device je
|
// „Web klient" byl slib funkce, která neexistuje (multi-device je
|
||||||
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
|
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// Smazat účet
|
// Smazat účet
|
||||||
Padding(
|
if (!decoy)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
Padding(
|
||||||
child: OutlinedButton(
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
onPressed: () => _confirmDeleteAccount(context, ref),
|
child: OutlinedButton(
|
||||||
style: OutlinedButton.styleFrom(
|
onPressed: () => _confirmDeleteAccount(context, ref),
|
||||||
foregroundColor: SepotColors.error,
|
style: OutlinedButton.styleFrom(
|
||||||
side: const BorderSide(color: SepotColors.error),
|
foregroundColor: SepotColors.error,
|
||||||
|
side: const BorderSide(color: SepotColors.error),
|
||||||
|
),
|
||||||
|
child: Text(l10n.settingsDeleteAllDataButton),
|
||||||
),
|
),
|
||||||
child: Text(l10n.settingsDeleteAllDataButton),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
final path = '${dir.path}/sepot-zaloha.sepotbackup';
|
final path = '${dir.path}/sepot-zaloha.sepotbackup';
|
||||||
await File(path).writeAsBytes(bytes);
|
await File(path).writeAsBytes(bytes);
|
||||||
await Share.shareXFiles(
|
try {
|
||||||
[XFile(path, mimeType: 'application/octet-stream')],
|
await Share.shareXFiles(
|
||||||
subject: l10n.backupShareSubject,
|
[XFile(path, mimeType: 'application/octet-stream')],
|
||||||
);
|
subject: l10n.backupShareSubject,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení.
|
||||||
|
try {
|
||||||
|
await File(path).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -1481,6 +1481,15 @@
|
|||||||
"@pinUnlockError": {
|
"@pinUnlockError": {
|
||||||
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
|
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
|
||||||
},
|
},
|
||||||
|
"pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.",
|
||||||
|
"@pinLockedFor": {
|
||||||
|
"description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu",
|
||||||
|
"placeholders": {
|
||||||
|
"seconds": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"pushTitle": "Push notifikace",
|
"pushTitle": "Push notifikace",
|
||||||
"@pushTitle": {
|
"@pushTitle": {
|
||||||
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
|
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
|
||||||
|
|||||||
@@ -315,6 +315,7 @@
|
|||||||
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
|
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
|
||||||
"pinWrong2": "Falsche PIN",
|
"pinWrong2": "Falsche PIN",
|
||||||
"pinUnlockError": "Fehler beim Entsperren",
|
"pinUnlockError": "Fehler beim Entsperren",
|
||||||
|
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
|
||||||
"pushTitle": "Push-Benachrichtigungen",
|
"pushTitle": "Push-Benachrichtigungen",
|
||||||
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
|
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
|
||||||
"pushModeUp": "UnifiedPush (ohne Google)",
|
"pushModeUp": "UnifiedPush (ohne Google)",
|
||||||
|
|||||||
@@ -315,6 +315,7 @@
|
|||||||
"pinUnlockWithBiometrics": "Unlock with biometrics",
|
"pinUnlockWithBiometrics": "Unlock with biometrics",
|
||||||
"pinWrong2": "Wrong PIN",
|
"pinWrong2": "Wrong PIN",
|
||||||
"pinUnlockError": "Unlock error",
|
"pinUnlockError": "Unlock error",
|
||||||
|
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
|
||||||
"pushTitle": "Push notifications",
|
"pushTitle": "Push notifications",
|
||||||
"pushModeOffDesc": "Messages arrive only after you open the app.",
|
"pushModeOffDesc": "Messages arrive only after you open the app.",
|
||||||
"pushModeUp": "UnifiedPush (no Google)",
|
"pushModeUp": "UnifiedPush (no Google)",
|
||||||
|
|||||||
@@ -315,6 +315,7 @@
|
|||||||
"pinUnlockWithBiometrics": "Sblocca con biometria",
|
"pinUnlockWithBiometrics": "Sblocca con biometria",
|
||||||
"pinWrong2": "PIN errato",
|
"pinWrong2": "PIN errato",
|
||||||
"pinUnlockError": "Errore di sblocco",
|
"pinUnlockError": "Errore di sblocco",
|
||||||
|
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
|
||||||
"pushTitle": "Notifiche push",
|
"pushTitle": "Notifiche push",
|
||||||
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
|
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
|
||||||
"pushModeUp": "UnifiedPush (senza Google)",
|
"pushModeUp": "UnifiedPush (senza Google)",
|
||||||
|
|||||||
@@ -315,6 +315,7 @@
|
|||||||
"pinUnlockWithBiometrics": "Разблокировать биометрией",
|
"pinUnlockWithBiometrics": "Разблокировать биометрией",
|
||||||
"pinWrong2": "Неверный PIN",
|
"pinWrong2": "Неверный PIN",
|
||||||
"pinUnlockError": "Ошибка разблокировки",
|
"pinUnlockError": "Ошибка разблокировки",
|
||||||
|
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
|
||||||
"pushTitle": "Push-уведомления",
|
"pushTitle": "Push-уведомления",
|
||||||
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
|
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
|
||||||
"pushModeUp": "UnifiedPush (без Google)",
|
"pushModeUp": "UnifiedPush (без Google)",
|
||||||
|
|||||||
@@ -1994,6 +1994,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Chyba odemčení'**
|
/// **'Chyba odemčení'**
|
||||||
String get pinUnlockError;
|
String get pinUnlockError;
|
||||||
|
|
||||||
|
/// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'**
|
||||||
|
String pinLockedFor(int seconds);
|
||||||
|
|
||||||
/// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)
|
/// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)
|
||||||
///
|
///
|
||||||
/// In cs, this message translates to:
|
/// In cs, this message translates to:
|
||||||
|
|||||||
@@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pinUnlockError => 'Chyba odemčení';
|
String get pinUnlockError => 'Chyba odemčení';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pinLockedFor(int seconds) {
|
||||||
|
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pushTitle => 'Push notifikace';
|
String get pushTitle => 'Push notifikace';
|
||||||
|
|
||||||
|
|||||||
@@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pinUnlockError => 'Fehler beim Entsperren';
|
String get pinUnlockError => 'Fehler beim Entsperren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pinLockedFor(int seconds) {
|
||||||
|
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pushTitle => 'Push-Benachrichtigungen';
|
String get pushTitle => 'Push-Benachrichtigungen';
|
||||||
|
|
||||||
|
|||||||
@@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pinUnlockError => 'Unlock error';
|
String get pinUnlockError => 'Unlock error';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pinLockedFor(int seconds) {
|
||||||
|
return 'Too many attempts. Try again in $seconds s.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pushTitle => 'Push notifications';
|
String get pushTitle => 'Push notifications';
|
||||||
|
|
||||||
|
|||||||
@@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pinUnlockError => 'Errore di sblocco';
|
String get pinUnlockError => 'Errore di sblocco';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pinLockedFor(int seconds) {
|
||||||
|
return 'Troppi tentativi. Riprova tra $seconds s.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pushTitle => 'Notifiche push';
|
String get pushTitle => 'Notifiche push';
|
||||||
|
|
||||||
|
|||||||
@@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get pinUnlockError => 'Ошибка разблокировки';
|
String get pinUnlockError => 'Ошибка разблокировки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pinLockedFor(int seconds) {
|
||||||
|
return 'Слишком много попыток. Повторите через $seconds с.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pushTitle => 'Push-уведомления';
|
String get pushTitle => 'Push-уведомления';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:sepot/core/constants/app_constants.dart';
|
||||||
|
|
||||||
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
|
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
|
||||||
enum AttachmentTransferState {
|
enum AttachmentTransferState {
|
||||||
/// Přijatá zpráva, blob se ještě nezačal stahovat.
|
/// Přijatá zpráva, blob se ještě nezačal stahovat.
|
||||||
@@ -94,14 +96,22 @@ class AttachmentMeta {
|
|||||||
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
|
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
|
||||||
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
|
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
|
||||||
final size = wire['s'] as int;
|
final size = wire['s'] as int;
|
||||||
if (size <= 0 || size > 512 * 1024 * 1024) {
|
if (size <= 0 || size > AppConstants.maxAttachmentBytes) {
|
||||||
throw const FormatException('implausible attachment size');
|
throw const FormatException('implausible attachment size');
|
||||||
}
|
}
|
||||||
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
|
// Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
|
||||||
// vyhodí FormatException → zpráva se zahodí jako vadná.
|
// 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
|
||||||
base64Decode(wire['k'] as String);
|
// selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
|
||||||
base64Decode(wire['n'] as String);
|
if (base64Decode(wire['k'] as String).length != 32 ||
|
||||||
base64Decode(wire['d'] as String);
|
base64Decode(wire['n'] as String).length != 12 ||
|
||||||
|
base64Decode(wire['d'] as String).length != 32) {
|
||||||
|
throw const FormatException('invalid attachment key material');
|
||||||
|
}
|
||||||
|
final name = wire['name'] as String?;
|
||||||
|
final mime = wire['mime'] as String;
|
||||||
|
if ((name != null && name.length > 255) || mime.length > 100) {
|
||||||
|
throw const FormatException('attachment name or mime too long');
|
||||||
|
}
|
||||||
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
|
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
|
||||||
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
|
||||||
var thumb = wire['th'] as String?;
|
var thumb = wire['th'] as String?;
|
||||||
@@ -118,8 +128,8 @@ class AttachmentMeta {
|
|||||||
nonceB64: wire['n'] as String,
|
nonceB64: wire['n'] as String,
|
||||||
digestB64: wire['d'] as String,
|
digestB64: wire['d'] as String,
|
||||||
size: size,
|
size: size,
|
||||||
mime: wire['mime'] as String,
|
mime: mime,
|
||||||
name: wire['name'] as String?,
|
name: name,
|
||||||
width: wire['w'] as int?,
|
width: wire['w'] as int?,
|
||||||
height: wire['h'] as int?,
|
height: wire['h'] as int?,
|
||||||
thumbB64: thumb,
|
thumbB64: thumb,
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ class LocalIdentity {
|
|||||||
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
|
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
|
||||||
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
|
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
|
||||||
class PublicIdentity {
|
class PublicIdentity {
|
||||||
|
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
|
||||||
|
static const maxDisplayNameLength = 64;
|
||||||
|
|
||||||
PublicIdentity({
|
PublicIdentity({
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.identityKey,
|
required this.identityKey,
|
||||||
@@ -214,7 +217,14 @@ class PublicIdentity {
|
|||||||
as Map<String, dynamic>;
|
as Map<String, dynamic>;
|
||||||
if (map['v'] != 2) return null;
|
if (map['v'] != 2) return null;
|
||||||
final identityKey = base64Decode(map['ik'] as String);
|
final identityKey = base64Decode(map['ik'] as String);
|
||||||
if (identityKey.length != 33) return null;
|
// 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne
|
||||||
|
// až při první zprávě, tady ho odmítneme hned.
|
||||||
|
if (identityKey.length != 33 || identityKey[0] != 0x05) return null;
|
||||||
|
final aid = map['aid'] as String?;
|
||||||
|
// Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí.
|
||||||
|
if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null;
|
||||||
|
final rawName = (map['n'] as String).trim();
|
||||||
|
if (rawName.length > maxDisplayNameLength) return null;
|
||||||
final da = map['da'] as String?;
|
final da = map['da'] as String?;
|
||||||
Uint8List? deliveryAddress;
|
Uint8List? deliveryAddress;
|
||||||
if (da != null) {
|
if (da != null) {
|
||||||
@@ -224,10 +234,10 @@ class PublicIdentity {
|
|||||||
if (deliveryAddress.length != 32) return null;
|
if (deliveryAddress.length != 32) return null;
|
||||||
}
|
}
|
||||||
return PublicIdentity(
|
return PublicIdentity(
|
||||||
displayName: map['n'] as String,
|
displayName: rawName,
|
||||||
identityKey: identityKey,
|
identityKey: identityKey,
|
||||||
createdAt: DateTime.parse(map['ts'] as String),
|
createdAt: DateTime.parse(map['ts'] as String),
|
||||||
accountId: map['aid'] as String?,
|
accountId: aid,
|
||||||
deliveryAddress: deliveryAddress,
|
deliveryAddress: deliveryAddress,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
|||||||
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
if (!storage.isUnlocked) return;
|
if (!storage.isUnlocked) return;
|
||||||
|
// Nátlaková relace: žádná síť. Messaging by se registroval skutečným
|
||||||
|
// identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě).
|
||||||
|
if (storage.isDecoyMode) return;
|
||||||
|
|
||||||
final service = ref.read(messagingServiceProvider);
|
final service = ref.read(messagingServiceProvider);
|
||||||
if (service.isStarted) return;
|
if (service.isStarted) return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:screen_protector/screen_protector.dart';
|
import 'package:screen_protector/screen_protector.dart';
|
||||||
import 'package:sepot/core/backup/backup_service.dart';
|
import 'package:sepot/core/backup/backup_service.dart';
|
||||||
@@ -57,14 +58,21 @@ final typingIndicatorsProvider =
|
|||||||
TypingIndicatorsNotifier.new);
|
TypingIndicatorsNotifier.new);
|
||||||
|
|
||||||
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
|
||||||
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
|
/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
|
||||||
/// screenshoty, tak ať se uživatel rozhodne sám.
|
/// jako nativní default v MainActivity, takže přepínač už neukazuje
|
||||||
|
/// „vypnuto" u obrazovky, která chráněná je.
|
||||||
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
||||||
static const key = 'screen_security_enabled';
|
static const key = 'screen_security_enabled';
|
||||||
|
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
|
||||||
|
static const vychozi = !kDebugMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> build() async {
|
Future<bool> build() async {
|
||||||
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false;
|
final storage = ref.read(storageProvider);
|
||||||
|
// Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní
|
||||||
|
// default platí) a po unlocku se provider invaliduje.
|
||||||
|
if (!storage.isUnlocked) return vychozi;
|
||||||
|
final v = (await storage.getSetting<bool>(key)) ?? vychozi;
|
||||||
await _apply(v);
|
await _apply(v);
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: sepot
|
name: sepot
|
||||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.2.0+23
|
version: 1.2.1+24
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.8
|
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,49 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||||
|
|
||||||
|
/// Skutečný a nátlakový blob se sdílenou solí se zkouší jednou derivací:
|
||||||
|
/// který PIN padl, nejde poznat z času odemčení.
|
||||||
|
void main() {
|
||||||
|
final real = Uint8List.fromList(List.generate(32, (i) => i));
|
||||||
|
final decoy = Uint8List.fromList(List.generate(32, (i) => 255 - i));
|
||||||
|
// Nejslabší povolené parametry — test nemá čekat na 64 MiB derivaci.
|
||||||
|
const m = DekWrap.floorMemoryKiB;
|
||||||
|
const t = DekWrap.floorIterations;
|
||||||
|
|
||||||
|
test('sdílená sůl: oba PINy se najdou, špatný PIN padne', () async {
|
||||||
|
final wReal = await DekWrap.wrap(
|
||||||
|
dek: real, pin: '111111', memoryKiB: m, iterations: t);
|
||||||
|
final salt = DekWrap.saltOf(wReal);
|
||||||
|
final wDecoy = await DekWrap.wrap(
|
||||||
|
dek: decoy, pin: '222222', memoryKiB: m, iterations: t, salt: salt);
|
||||||
|
expect(DekWrap.saltOf(wDecoy), salt);
|
||||||
|
|
||||||
|
final (i1, d1) = await DekWrap.unwrapFirstMatching(
|
||||||
|
wrapped: [wReal, wDecoy], pin: '111111');
|
||||||
|
expect(i1, 0);
|
||||||
|
expect(d1, real);
|
||||||
|
|
||||||
|
final (i2, d2) = await DekWrap.unwrapFirstMatching(
|
||||||
|
wrapped: [wReal, wDecoy], pin: '222222');
|
||||||
|
expect(i2, 1);
|
||||||
|
expect(d2, decoy);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
DekWrap.unwrapFirstMatching(wrapped: [wReal, wDecoy], pin: '333333'),
|
||||||
|
throwsA(isA<WrongPinException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('různé soli (starší instalace) fungují dál', () async {
|
||||||
|
final wReal = await DekWrap.wrap(
|
||||||
|
dek: real, pin: '111111', memoryKiB: m, iterations: t);
|
||||||
|
final wDecoy = await DekWrap.wrap(
|
||||||
|
dek: decoy, pin: '222222', memoryKiB: m, iterations: t);
|
||||||
|
final (i, d) = await DekWrap.unwrapFirstMatching(
|
||||||
|
wrapped: [wReal, wDecoy], pin: '222222');
|
||||||
|
expect(i, 1);
|
||||||
|
expect(d, decoy);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Argon2id (64 MiB) ×6 pod zátěží celé sady: víc než výchozích 30 s.
|
||||||
|
@Timeout(Duration(minutes: 3))
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||||
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
|
||||||
|
/// Nátlaková (decoy) relace musí být izolovaná od skutečného účtu:
|
||||||
|
/// jiná identita, žádná fráze, „smazat" nesáhne na skutečná data,
|
||||||
|
/// a zámek režim resetuje. Keystore je nahrazený mockem, DB jde do temp.
|
||||||
|
/// Potřebuje nativní libsignal (LocalIdentity) a sqlcipher.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
late Directory tmp;
|
||||||
|
late SecureStorageService storage;
|
||||||
|
|
||||||
|
setUpAll(() async => SepotCrypto.init());
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
FlutterSecureStorage.setMockInitialValues({});
|
||||||
|
tmp = Directory.systemTemp.createTempSync('sepot_decoy');
|
||||||
|
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
|
||||||
|
await storage.unlockWithoutPin();
|
||||||
|
await storage.saveIdentity(await LocalIdentity.create('Alice'));
|
||||||
|
await storage.saveSeedPhrase(List.filled(24, 'abandon'));
|
||||||
|
await storage.enablePin('111111');
|
||||||
|
await storage.enableDecoyPin('222222');
|
||||||
|
storage.lock();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
storage.lock();
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decoy PIN otevře izolovanou relaci', () async {
|
||||||
|
await storage.unlockWithPin('222222');
|
||||||
|
expect(storage.isDecoyMode, isTrue);
|
||||||
|
expect(await storage.loadSeedPhrase(), isNull);
|
||||||
|
final shown = await storage.loadIdentity();
|
||||||
|
expect(shown, isNotNull);
|
||||||
|
|
||||||
|
storage.lock();
|
||||||
|
expect(storage.isDecoyMode, isFalse);
|
||||||
|
await storage.unlockWithPin('111111');
|
||||||
|
expect(storage.isDecoyMode, isFalse);
|
||||||
|
final real = await storage.loadIdentity();
|
||||||
|
expect(real!.id, isNot(shown!.id), reason: 'decoy ukazuje jinou identitu');
|
||||||
|
expect(await storage.loadSeedPhrase(), hasLength(24));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazání v decoy relaci nechá skutečná data', () async {
|
||||||
|
await storage.unlockWithPin('222222');
|
||||||
|
await storage.deleteAll();
|
||||||
|
expect(File('${tmp.path}/app.db').existsSync(), isTrue);
|
||||||
|
expect(await storage.hasIdentity(), isTrue);
|
||||||
|
expect(await storage.hasPin(), isTrue);
|
||||||
|
await storage.unlockWithPin('111111');
|
||||||
|
expect(storage.isDecoyMode, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('změny keystore jsou v decoy relaci zakázané', () async {
|
||||||
|
await storage.unlockWithPin('222222');
|
||||||
|
expect(() => storage.enableBiometrics(), throwsStateError);
|
||||||
|
expect(() => storage.disablePin(), throwsStateError);
|
||||||
|
expect(
|
||||||
|
() => storage.changePin(oldPin: '222222', newPin: '333333'),
|
||||||
|
throwsStateError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po pěti špatných PINech se zámek na čas zavře', () async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await expectLater(storage.unlockWithPin('000000'),
|
||||||
|
throwsA(isA<WrongPinException>()));
|
||||||
|
}
|
||||||
|
expect(await storage.pinLockRemaining(), isNotNull);
|
||||||
|
await expectLater(storage.unlockWithPin('111111'),
|
||||||
|
throwsA(isA<PinLockedException>()));
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
-2
@@ -4,7 +4,7 @@
|
|||||||
# Build stage potřebuje: protoc (prost-build generuje z proto/sepot.proto),
|
# Build stage potřebuje: protoc (prost-build generuje z proto/sepot.proto),
|
||||||
# git (libsignal-protocol je git dependency s [patch.crates-io] zrcadlem).
|
# git (libsignal-protocol je git dependency s [patch.crates-io] zrcadlem).
|
||||||
|
|
||||||
FROM rust:1.96-slim AS build
|
FROM rust:1.99-slim AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
protobuf-compiler git pkg-config build-essential cmake \
|
protobuf-compiler git pkg-config build-essential cmake \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -21,7 +21,16 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
|||||||
&& cp target/release/sepot-relay /sepot-relay
|
&& cp target/release/sepot-relay /sepot-relay
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# Revize zdroje: label pro `docker inspect` a env pro /v1/server-info
|
||||||
|
# (AGPL §13 — uživatel relaye má vědět, který commit běží).
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
LABEL org.opencontainers.image.revision=$GIT_SHA \
|
||||||
|
org.opencontainers.image.source="https://github.com/juliusjoska/sepot"
|
||||||
|
ENV SEPOT_REVISION=$GIT_SHA
|
||||||
|
# upgrade: základ bookworm-slim nese opravitelné CVE v perl-base a spol.;
|
||||||
|
# bez něj by každý build vozil zranitelnosti, které Debian už opravil.
|
||||||
|
RUN apt-get update && apt-get upgrade -y --no-install-recommends \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl \
|
ca-certificates curl \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& useradd -r -s /usr/sbin/nologin sepot \
|
&& useradd -r -s /usr/sbin/nologin sepot \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Tor hidden service pro sepot-relay — vlastní minimální image
|
# Tor hidden service pro sepot-relay — vlastní minimální image
|
||||||
# (žádný third-party tor image; privacy produkt nemá kam outsourcovat důvěru)
|
# (žádný third-party tor image; privacy produkt nemá kam outsourcovat důvěru)
|
||||||
FROM alpine:3.22
|
FROM alpine:3.24
|
||||||
RUN apk add --no-cache tor \
|
RUN apk add --no-cache tor \
|
||||||
&& mkdir -p /var/lib/tor \
|
&& mkdir -p /var/lib/tor \
|
||||||
&& chown -R tor:tor /var/lib/tor \
|
&& chown -R tor:tor /var/lib/tor \
|
||||||
|
|||||||
Generated
+13
-20
@@ -274,15 +274,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "concurrent-queue"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "const-oid"
|
name = "const-oid"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -348,9 +339,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -572,11 +563,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.1"
|
version = "5.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"concurrent-queue",
|
|
||||||
"parking",
|
"parking",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@@ -826,9 +816,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.15"
|
version = "0.4.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2380,9 +2370,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.41"
|
version = "0.23.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -2404,9 +2394,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -2456,8 +2446,10 @@ dependencies = [
|
|||||||
"dashmap",
|
"dashmap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hmac",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"libc",
|
||||||
"libsignal-core",
|
"libsignal-core",
|
||||||
"libsignal-protocol",
|
"libsignal-protocol",
|
||||||
"prost 0.13.5",
|
"prost 0.13.5",
|
||||||
@@ -2466,6 +2458,7 @@ dependencies = [
|
|||||||
"sepot-proto",
|
"sepot-proto",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ tower-http = { version = "0.7.0", features = ["timeout"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ impl ApnsSender {
|
|||||||
.header("apns-push-type", "background")
|
.header("apns-push-type", "background")
|
||||||
.header("apns-priority", "5")
|
.header("apns-priority", "5")
|
||||||
.header("apns-expiration", wake_expiration(Utc::now()).to_string())
|
.header("apns-expiration", wake_expiration(Utc::now()).to_string())
|
||||||
|
// Víc budíčků pro jedno zařízení APNs sloučí do jednoho.
|
||||||
|
.header("apns-collapse-id", "wake")
|
||||||
.body(r#"{"aps":{"content-available":1}}"#)
|
.body(r#"{"aps":{"content-available":1}}"#)
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
@@ -149,7 +151,8 @@ impl ApnsSender {
|
|||||||
Ok(r) if !r.status().is_success() => {
|
Ok(r) if !r.status().is_success() => {
|
||||||
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
|
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("APNs budíček selhal: {e}"),
|
// without_url: URL nese device token.
|
||||||
|
Err(e) => tracing::warn!("APNs budíček selhal: {}", e.without_url()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +181,7 @@ impl ApnsSender {
|
|||||||
Ok(r) if !r.status().is_success() => {
|
Ok(r) if !r.status().is_success() => {
|
||||||
tracing::warn!(status = %r.status(), "APNs VoIP push odmítnut");
|
tracing::warn!(status = %r.status(), "APNs VoIP push odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("APNs VoIP push selhal: {e}"),
|
Err(e) => tracing::warn!("APNs VoIP push selhal: {}", e.without_url()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,19 +200,5 @@ mod tests {
|
|||||||
assert_eq!(limit, crate::state::QUEUE_TTL_DAYS * 86_400);
|
assert_eq!(limit, crate::state::QUEUE_TTL_DAYS * 86_400);
|
||||||
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn budicek_drzi_apns_stejne_dlouho_jako_fronta() {
|
|
||||||
let ted = DateTime::parse_from_rfc3339("2026-09-29T18:00:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc);
|
|
||||||
let limit = wake_expiration(ted) - ted.timestamp();
|
|
||||||
assert_eq!(limit, crate::state::QUEUE_TTL_DAYS * 86_400);
|
|
||||||
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ pub const ATTACHMENT_TTL_DAYS: i64 = 30;
|
|||||||
/// Výchozí strop velikosti (šifrovaný blob). Přepis env `SEPOT_MAX_ATTACHMENT_BYTES`.
|
/// Výchozí strop velikosti (šifrovaný blob). Přepis env `SEPOT_MAX_ATTACHMENT_BYTES`.
|
||||||
const DEFAULT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
const DEFAULT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Výchozí strop součtu všech živých příloh na disku. Přepis env
|
||||||
|
/// `SEPOT_ATTACHMENTS_TOTAL_BYTES`. Bez něj jeden registrovaný klient
|
||||||
|
/// zaplní celý filesystem (v produkci 16 GB sdílených s Postgresem) za
|
||||||
|
/// minuty a relay přestane zapisovat do DB.
|
||||||
|
const DEFAULT_TOTAL_BYTES: u64 = 4 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
/// Osiřelé `.part` soubory (pád mezi zápisem a přejmenováním) starší než
|
/// Osiřelé `.part` soubory (pád mezi zápisem a přejmenováním) starší než
|
||||||
/// tohle se při úklidu mažou.
|
/// tohle se při úklidu mažou.
|
||||||
const PART_MAX_AGE: StdDuration = StdDuration::from_secs(24 * 3600);
|
const PART_MAX_AGE: StdDuration = StdDuration::from_secs(24 * 3600);
|
||||||
@@ -45,6 +51,7 @@ const PART_MAX_AGE: StdDuration = StdDuration::from_secs(24 * 3600);
|
|||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub dir: PathBuf,
|
pub dir: PathBuf,
|
||||||
pub max_bytes: u64,
|
pub max_bytes: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -55,9 +62,14 @@ impl Config {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(DEFAULT_MAX_BYTES);
|
.unwrap_or(DEFAULT_MAX_BYTES);
|
||||||
|
let total_bytes = std::env::var("SEPOT_ATTACHMENTS_TOTAL_BYTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(DEFAULT_TOTAL_BYTES);
|
||||||
Self {
|
Self {
|
||||||
dir: PathBuf::from(dir),
|
dir: PathBuf::from(dir),
|
||||||
max_bytes,
|
max_bytes,
|
||||||
|
total_bytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +129,21 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Globální strop: součet živých příloh + tahle nesmí přesáhnout
|
||||||
|
// total_bytes. Jeden SELECT nad malou tabulkou; nic o zařízení se
|
||||||
|
// k blobu neváže.
|
||||||
|
let (used,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT coalesce(sum(size_bytes), 0)::bigint FROM attachments")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
if (used.max(0) as u64).saturating_add(cfg.max_bytes) > cfg.total_bytes {
|
||||||
|
return Err((
|
||||||
|
StatusCode::INSUFFICIENT_STORAGE,
|
||||||
|
"úložiště příloh je plné, zkus to později".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ThreadRng je !Send — nesmí přežít přes await, proto vlastní scope.
|
// ThreadRng je !Send — nesmí přežít přes await, proto vlastní scope.
|
||||||
let blob_id = {
|
let blob_id = {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
@@ -229,6 +256,10 @@ pub async fn download(
|
|||||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||||
.header(header::CONTENT_LENGTH, size)
|
.header(header::CONTENT_LENGTH, size)
|
||||||
.header(header::CACHE_CONTROL, "no-store")
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
// Blob je bearer URL na důvěryhodné doméně — prohlížeč ho nesmí
|
||||||
|
// ani zkoušet vykreslit (nosniff + attachment).
|
||||||
|
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||||
|
.header(header::CONTENT_DISPOSITION, "attachment")
|
||||||
.body(Body::from_stream(stream))
|
.body(Body::from_stream(stream))
|
||||||
.map_err(internal)
|
.map_err(internal)
|
||||||
}
|
}
|
||||||
@@ -309,6 +340,7 @@ mod db_tests {
|
|||||||
Config {
|
Config {
|
||||||
dir,
|
dir,
|
||||||
max_bytes: 1024,
|
max_bytes: 1024,
|
||||||
|
total_bytes: 1 << 30,
|
||||||
},
|
},
|
||||||
trusted,
|
trusted,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ impl FcmSender {
|
|||||||
"message": {
|
"message": {
|
||||||
"token": device_token,
|
"token": device_token,
|
||||||
"data": { "v": "1" },
|
"data": { "v": "1" },
|
||||||
"android": { "priority": "HIGH" }
|
// collapse_key: čekající budíčky pro zařízení mimo síť se
|
||||||
|
// slijí do jednoho (třicet zpráv ≠ třicet pushů).
|
||||||
|
"android": { "priority": "HIGH", "collapse_key": "wake" }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.send(http, body).await;
|
self.send(http, body).await;
|
||||||
@@ -171,7 +173,7 @@ impl FcmSender {
|
|||||||
Ok(resp) if !resp.status().is_success() => {
|
Ok(resp) if !resp.status().is_success() => {
|
||||||
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
|
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("FCM budíček selhal: {e}"),
|
Err(e) => tracing::warn!("FCM budíček selhal: {}", e.without_url()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Úklid značek budíčků a OTP oken (jen paměť)
|
||||||
|
{
|
||||||
|
let state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(600));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
state.prune_marks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let register_routes = Router::new()
|
let register_routes = Router::new()
|
||||||
.route("/v1/register", post(rest::register))
|
.route("/v1/register", post(rest::register))
|
||||||
.layer(GovernorLayer::new(gov_register));
|
.layer(GovernorLayer::new(gov_register));
|
||||||
@@ -128,6 +140,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let general_routes = Router::new()
|
let general_routes = Router::new()
|
||||||
.route("/v1/server-info", get(rest::server_info))
|
.route("/v1/server-info", get(rest::server_info))
|
||||||
.route("/v1/keys/{delivery_address}", get(rest::fetch_bundle))
|
.route("/v1/keys/{delivery_address}", get(rest::fetch_bundle))
|
||||||
|
// doplnění one-time prekeys / rotace signed a Kyber prekey (auth)
|
||||||
|
.route("/v1/keys", post(rest::upload_keys))
|
||||||
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
||||||
.route("/v1/identity", delete(rest::delete_identity))
|
.route("/v1/identity", delete(rest::delete_identity))
|
||||||
.layer(GovernorLayer::new(gov_general));
|
.layer(GovernorLayer::new(gov_general));
|
||||||
@@ -152,16 +166,18 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
// health bez limitů — interní docker/uptime checky
|
// health bez limitů — interní docker/uptime checky; ready ověří i DB
|
||||||
.route("/v1/health", get(rest::health))
|
.route("/v1/health", get(rest::health))
|
||||||
|
.route("/v1/ready", get(rest::ready))
|
||||||
// WS má vlastní limity (conn cap per IP, auth deadline, frame rate)
|
// WS má vlastní limity (conn cap per IP, auth deadline, frame rate)
|
||||||
.route("/v1/ws", get(ws::ws_handler))
|
.route("/v1/ws", get(ws::ws_handler))
|
||||||
.merge(rest_routes)
|
.merge(rest_routes)
|
||||||
.merge(attachment_routes)
|
.merge(attachment_routes)
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||||
.with_state(state);
|
.with_state(state.clone());
|
||||||
|
|
||||||
tracing::info!(%bind, "sepot-relay startuje");
|
raise_nofile_limit();
|
||||||
|
tracing::info!(%bind, revision = %state.revision, "sepot-relay startuje");
|
||||||
let listener = tokio::net::TcpListener::bind(&bind).await?.tap_io(|tcp| {
|
let listener = tokio::net::TcpListener::bind(&bind).await?.tap_io(|tcp| {
|
||||||
// Bez TCP_NODELAY čeká každý další malý zápis na ACK klienta (Nagle
|
// Bez TCP_NODELAY čeká každý další malý zápis na ACK klienta (Nagle
|
||||||
// × zpožděné ACK = ~40 ms). Nejvíc to bolí hned po AUTH: AUTH_OK a
|
// × zpožděné ACK = ~40 ms). Nejvíc to bolí hned po AUTH: AUTH_OK a
|
||||||
@@ -171,11 +187,48 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::debug!("TCP_NODELAY nenastaven: {e}");
|
tracing::debug!("TCP_NODELAY nenastaven: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// ConnectInfo: peer adresa pro rate-limit fallback a WS conn cap
|
// ConnectInfo: peer adresa pro rate-limit fallback a WS conn cap.
|
||||||
|
// Graceful shutdown: SIGTERM od dockeru nechá doběhnout
|
||||||
|
// rozjeté uploady; WS klienti se jen přepojí.
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
)
|
)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = term.recv() => {}
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
}
|
||||||
|
tracing::info!("sepot-relay končí (signál)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft limit otevřených souborů na hard limit. Každé WS spojení je jeden
|
||||||
|
/// deskriptor; docker dává soft 1024 → strop ~1 000 klientů, zatímco hard
|
||||||
|
/// bývá 2^19. Selhání se jen zaloguje.
|
||||||
|
fn raise_nofile_limit() {
|
||||||
|
let mut lim = libc::rlimit {
|
||||||
|
rlim_cur: 0,
|
||||||
|
rlim_max: 0,
|
||||||
|
};
|
||||||
|
// SAFETY: getrlimit/setrlimit jen čtou a zapisují předaný struct.
|
||||||
|
unsafe {
|
||||||
|
if libc::getrlimit(libc::RLIMIT_NOFILE, &mut lim) != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if lim.rlim_cur < lim.rlim_max {
|
||||||
|
lim.rlim_cur = lim.rlim_max;
|
||||||
|
if libc::setrlimit(libc::RLIMIT_NOFILE, &lim) != 0 {
|
||||||
|
tracing::warn!("RLIMIT_NOFILE se nepodařilo zvednout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(nofile = lim.rlim_cur, "limit otevřených souborů");
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,11 +95,29 @@ pub async fn send_wake(
|
|||||||
match platform {
|
match platform {
|
||||||
PLATFORM_UNIFIEDPUSH | PLATFORM_WEBPUSH => {
|
PLATFORM_UNIFIEDPUSH | PLATFORM_WEBPUSH => {
|
||||||
let Ok(url) = std::str::from_utf8(token) else { return };
|
let Ok(url) = std::str::from_utf8(token) else { return };
|
||||||
if !endpoint_allowed(url).await {
|
let Some(pinned) = endpoint_allowed(url).await else {
|
||||||
tracing::debug!("push endpoint odmítnut (scheme/SSRF guard)");
|
tracing::debug!("push endpoint odmítnut (scheme/SSRF guard)");
|
||||||
return;
|
return;
|
||||||
|
};
|
||||||
|
// Adresy z guardu se připnou i pro vlastní POST — reqwest by
|
||||||
|
// jinak resolvoval znovu a DNS s TTL 0 by napodruhé vrátil
|
||||||
|
// vnitřní adresu (DNS rebinding).
|
||||||
|
match pinned {
|
||||||
|
Some((host, addrs)) => {
|
||||||
|
let pinned_client = Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.connect_timeout(Duration::from_secs(3))
|
||||||
|
.user_agent("sepot-relay")
|
||||||
|
.resolve_to_addrs(&host, &addrs)
|
||||||
|
.build();
|
||||||
|
match pinned_client {
|
||||||
|
Ok(c) => post_wake(&c, url).await,
|
||||||
|
Err(_) => post_wake(client, url).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => post_wake(client, url).await,
|
||||||
}
|
}
|
||||||
post_wake(client, url).await;
|
|
||||||
}
|
}
|
||||||
PLATFORM_FCM => {
|
PLATFORM_FCM => {
|
||||||
// Opt-in fast mode — jen když je relay nakonfigurován
|
// Opt-in fast mode — jen když je relay nakonfigurován
|
||||||
@@ -139,7 +157,9 @@ async fn post_wake(client: &Client, url: &str) {
|
|||||||
Ok(r) if !r.status().is_success() => {
|
Ok(r) if !r.status().is_success() => {
|
||||||
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
|
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {e}"),
|
// without_url: chyba reqwest nese URL = endpoint = identifikátor
|
||||||
|
// uživatele; do logu nesmí.
|
||||||
|
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {}", e.without_url()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,32 +167,24 @@ async fn post_wake(client: &Client, url: &str) {
|
|||||||
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
||||||
/// Odmítnout hostitele resolvující na interní adresy (blind-SSRF ochrana).
|
/// Odmítnout hostitele resolvující na interní adresy (blind-SSRF ochrana).
|
||||||
///
|
///
|
||||||
/// ZNÁMÁ LIMITACE (DNS rebinding / TOCTOU): guard resolvuje host přes
|
/// Vrací `None` = odmítnuto; `Some(None)` = povoleno bez DNS (.onion);
|
||||||
/// `lookup_host` zde, ale samotný POST v `post_wake` resolvuje znovu. Mezi
|
/// `Some(Some((host, adresy)))` = povoleno, a tyhle adresy se mají pro POST
|
||||||
/// kontrolou a requestem může útočníkem řízené DNS vrátit napřed veřejnou a
|
/// připnout (ochrana proti DNS rebindingu mezi kontrolou a požadavkem).
|
||||||
/// pak interní adresu, čímž guard obejde. Plný fix = resolvovat jednou a
|
async fn endpoint_allowed(url: &str) -> Option<Option<(String, Vec<std::net::SocketAddr>)>> {
|
||||||
/// připojit se přímo na ověřenou IP (reqwest `resolve()` / vlastní connector),
|
let parsed = reqwest::Url::parse(url).ok()?;
|
||||||
/// což je mimo rozsah tohoto vydání. Dopad je zúžen tím, že tělo je konstantní
|
let host = parsed.host_str()?;
|
||||||
/// `{"v":1}`, redirecty jsou vypnuté a odpověď se nikam nevrací (blind).
|
|
||||||
async fn endpoint_allowed(url: &str) -> bool {
|
|
||||||
let Ok(parsed) = reqwest::Url::parse(url) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(host) = parsed.host_str() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let is_onion = host.ends_with(".onion");
|
let is_onion = host.ends_with(".onion");
|
||||||
|
|
||||||
match parsed.scheme() {
|
match parsed.scheme() {
|
||||||
"https" => {}
|
"https" => {}
|
||||||
// .onion šifruje sám; relay stejně nemá tor egress, request tiše
|
// .onion šifruje sám; relay stejně nemá tor egress, request tiše
|
||||||
// selže — ale schéma povolíme, ať je to připravené.
|
// selže — ale schéma povolíme, ať je to připravené.
|
||||||
"http" if is_onion => return true,
|
"http" if is_onion => return Some(None),
|
||||||
_ => return false,
|
_ => return None,
|
||||||
}
|
}
|
||||||
// .onion přes https (neobvyklé) — neresolvuje se přes DNS, pustit.
|
// .onion přes https (neobvyklé) — neresolvuje se přes DNS, pustit.
|
||||||
if is_onion {
|
if is_onion {
|
||||||
return true;
|
return Some(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vlastnit host+port, ať `parsed` nepřežívá await na lookup_host.
|
// Vlastnit host+port, ať `parsed` nepřežívá await na lookup_host.
|
||||||
@@ -181,17 +193,18 @@ async fn endpoint_allowed(url: &str) -> bool {
|
|||||||
drop(parsed);
|
drop(parsed);
|
||||||
|
|
||||||
let resolved = tokio::net::lookup_host((host.as_str(), port)).await;
|
let resolved = tokio::net::lookup_host((host.as_str(), port)).await;
|
||||||
let Ok(addrs) = resolved else {
|
let addrs = resolved.ok()?; // host nejde resolvovat → nepouštět
|
||||||
return false; // host nejde resolvovat → nepouštět
|
let mut pinned = Vec::new();
|
||||||
};
|
|
||||||
let mut saw_any = false;
|
|
||||||
for a in addrs {
|
for a in addrs {
|
||||||
saw_any = true;
|
|
||||||
if is_internal_ip(&a.ip()) {
|
if is_internal_ip(&a.ip()) {
|
||||||
return false; // aspoň jedna interní adresa → celé odmítnout
|
return None; // aspoň jedna interní adresa → celé odmítnout
|
||||||
}
|
}
|
||||||
|
pinned.push(a);
|
||||||
}
|
}
|
||||||
saw_any
|
if pinned.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Some((host, pinned)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_internal_v4(v4: &std::net::Ipv4Addr) -> bool {
|
fn is_internal_v4(v4: &std::net::Ipv4Addr) -> bool {
|
||||||
@@ -214,7 +227,14 @@ fn is_internal_ip(ip: &IpAddr) -> bool {
|
|||||||
|| v6.is_unspecified()
|
|| v6.is_unspecified()
|
||||||
|| (o[0] & 0xfe) == 0xfc // fc00::/7 ULA
|
|| (o[0] & 0xfe) == 0xfc // fc00::/7 ULA
|
||||||
|| (o[0] == 0xfe && (o[1] & 0xc0) == 0x80) // fe80::/10 link-local
|
|| (o[0] == 0xfe && (o[1] & 0xc0) == 0x80) // fe80::/10 link-local
|
||||||
|
|| (o[0] == 0xfe && (o[1] & 0xc0) == 0xc0) // fec0::/10 site-local
|
||||||
|| v6.to_ipv4_mapped().map(|v4| is_internal_v4(&v4)).unwrap_or(false)
|
|| v6.to_ipv4_mapped().map(|v4| is_internal_v4(&v4)).unwrap_or(false)
|
||||||
|
// 64:ff9b::/96 NAT64 a 2002::/16 6to4 nesou vnořenou IPv4
|
||||||
|
|| (o[..12] == [0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||||
|
&& is_internal_v4(&std::net::Ipv4Addr::new(o[12], o[13], o[14], o[15])))
|
||||||
|
|| (o[0] == 0x20
|
||||||
|
&& o[1] == 0x02
|
||||||
|
&& is_internal_v4(&std::net::Ipv4Addr::new(o[2], o[3], o[4], o[5])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,20 +272,33 @@ mod tests {
|
|||||||
assert!(is_internal_ip(&mapped));
|
assert!(is_internal_ip(&mapped));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nat64_a_6to4_s_vnorenou_privatni_v4_jsou_interni() {
|
||||||
|
let nat64: IpAddr = "64:ff9b::10.0.0.1".parse().unwrap();
|
||||||
|
assert!(is_internal_ip(&nat64));
|
||||||
|
let sixtofour: IpAddr = "2002:c0a8:101::1".parse().unwrap(); // 192.168.1.1
|
||||||
|
assert!(is_internal_ip(&sixtofour));
|
||||||
|
let verejna_nat64: IpAddr = "64:ff9b::1.1.1.1".parse().unwrap();
|
||||||
|
assert!(!is_internal_ip(&verejna_nat64));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn schema_a_ssrf_guard() {
|
async fn schema_a_ssrf_guard() {
|
||||||
// http (ne-onion) se odmítne
|
// http (ne-onion) se odmítne
|
||||||
assert!(!endpoint_allowed("http://ntfy.sh/abc").await);
|
assert!(endpoint_allowed("http://ntfy.sh/abc").await.is_none());
|
||||||
// loopback se odmítne i přes https
|
// loopback se odmítne i přes https
|
||||||
assert!(!endpoint_allowed("https://127.0.0.1/abc").await);
|
assert!(endpoint_allowed("https://127.0.0.1/abc").await.is_none());
|
||||||
assert!(!endpoint_allowed("https://localhost/abc").await);
|
assert!(endpoint_allowed("https://localhost/abc").await.is_none());
|
||||||
// interní IP se odmítne
|
// interní IP se odmítne
|
||||||
assert!(!endpoint_allowed("https://10.0.0.5:8000/x").await);
|
assert!(endpoint_allowed("https://10.0.0.5:8000/x").await.is_none());
|
||||||
// http na .onion se pustí (schéma OK)
|
// http na .onion se pustí (schéma OK), bez DNS → nic k připnutí
|
||||||
assert!(endpoint_allowed("http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion/x").await);
|
assert_eq!(
|
||||||
|
endpoint_allowed("http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion/x").await,
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
// nesmysl se odmítne
|
// nesmysl se odmítne
|
||||||
assert!(!endpoint_allowed("není url").await);
|
assert!(endpoint_allowed("není url").await.is_none());
|
||||||
assert!(!endpoint_allowed("ftp://ntfy.sh/x").await);
|
assert!(endpoint_allowed("ftp://ntfy.sh/x").await.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+256
-17
@@ -40,14 +40,60 @@ pub async fn health() -> Json<serde_json::Value> {
|
|||||||
Json(serde_json::json!({ "status": "ok" }))
|
Json(serde_json::json!({ "status": "ok" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Readiness: health je jen „proces žije", tohle ověří i DB.
|
||||||
|
/// Pro sondy monitoringu — DB mimo provoz = 503, ne falešné 200.
|
||||||
|
pub async fn ready(State(state): State<Arc<AppState>>) -> ApiResult<Json<serde_json::Value>> {
|
||||||
|
let probe = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(1),
|
||||||
|
sqlx::query("SELECT 1").execute(&state.pool),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match probe {
|
||||||
|
Ok(Ok(_)) => Ok(Json(serde_json::json!({ "status": "ready" }))),
|
||||||
|
_ => Err((StatusCode::SERVICE_UNAVAILABLE, "databáze neodpovídá".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
|
||||||
|
/// s relayem mluví, má vědět, který zdroj právě běží.
|
||||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"frame_version": 1,
|
"frame_version": 1,
|
||||||
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
|
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
|
||||||
"capabilities": ["pqxdh", "sealed-sender"],
|
"capabilities": ["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"],
|
||||||
|
"source": state.source_url,
|
||||||
|
"revision": state.revision,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis
|
||||||
|
/// identity klíčem nad `"<method_path>\n<ts_ms>"`, stejné okno jako
|
||||||
|
/// X-Sepot-Auth. Důkaz držení privátního klíče bez znalosti device_uuid:
|
||||||
|
/// obnova ze 24 slov na novém telefonu nemá nic jiného.
|
||||||
|
fn identity_proof_valid(headers: &HeaderMap, identity: &PublicKey, method_path: &str) -> bool {
|
||||||
|
let Some(header) = headers
|
||||||
|
.get("x-sepot-identity-proof")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((ts_str, sig_b64)) = header.split_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(ts) = ts_str.parse::<i64>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(signature) = BASE64_STANDARD.decode(sig_b64) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
|
if now_ms.abs_diff(ts) > AUTH_WINDOW_MS as u64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let message = format!("{method_path}\n{ts}");
|
||||||
|
identity.verify_signature(message.as_bytes(), &signature)
|
||||||
|
}
|
||||||
|
|
||||||
// ── POST /v1/register ──────────────────────────────────────────────────
|
// ── POST /v1/register ──────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -83,11 +129,17 @@ const MAX_ONE_TIME_PREKEYS: usize = 100;
|
|||||||
|
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
Json(body): Json<RegisterIn>,
|
Json(body): Json<RegisterIn>,
|
||||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
||||||
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
||||||
return Err(bad("příliš mnoho one-time prekeys"));
|
return Err(bad("příliš mnoho one-time prekeys"));
|
||||||
}
|
}
|
||||||
|
// libsignal DeviceId je 1..=127; registration_id kladné 14bitové číslo.
|
||||||
|
// Mimo rozsah by registrace prošla a GET /v1/certificate pak padal na 500.
|
||||||
|
if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) {
|
||||||
|
return Err(bad("device_id nebo registration_id mimo rozsah"));
|
||||||
|
}
|
||||||
let identity_key = b64("identity_key", &body.identity_key, Some(33))?;
|
let identity_key = b64("identity_key", &body.identity_key, Some(33))?;
|
||||||
let delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?;
|
let delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?;
|
||||||
let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
|
let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
|
||||||
@@ -133,9 +185,39 @@ pub async fn register(
|
|||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
if inserted.rows_affected() == 0 {
|
// Identita už existuje = obnova ze 24 slov na novém telefonu (nebo
|
||||||
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
|
// reinstalace). Bez důkazu držení klíče zůstává 409 (anti-squatting);
|
||||||
}
|
// s důkazem účet převezme nové zařízení: stará zařízení, jejich
|
||||||
|
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
|
||||||
|
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
|
||||||
|
let account_id = if inserted.rows_affected() == 0 {
|
||||||
|
if !identity_proof_valid(&headers, &identity, "POST /v1/register") {
|
||||||
|
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
|
||||||
|
}
|
||||||
|
let (existing,): (Vec<u8>,) =
|
||||||
|
sqlx::query_as("SELECT account_id FROM identities WHERE identity_key = $1")
|
||||||
|
.bind(&identity_key)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM message_queue WHERE dst_address IN (
|
||||||
|
SELECT delivery_address FROM devices WHERE account_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(&existing)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
sqlx::query("DELETE FROM devices WHERE account_id = $1")
|
||||||
|
.bind(&existing)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
tracing::info!("identita převzata novým zařízením (obnova)");
|
||||||
|
existing
|
||||||
|
} else {
|
||||||
|
account_id
|
||||||
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO devices (device_uuid, account_id, device_id, registration_id, delivery_address)
|
"INSERT INTO devices (device_uuid, account_id, device_id, registration_id, delivery_address)
|
||||||
@@ -274,18 +356,25 @@ pub async fn fetch_bundle(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
|
|
||||||
// Spotřebuj jeden one-time prekey (DELETE ... RETURNING).
|
// Spotřebuj jeden one-time prekey (DELETE ... RETURNING) —
|
||||||
let otp: Option<(i32, Vec<u8>)> = sqlx::query_as(
|
// ale jen v mezích hodinového stropu na adresu: bundle bez OTP je
|
||||||
"DELETE FROM one_time_prekeys
|
// povolený fallback, vyčerpaná zásoba cizím ne.
|
||||||
WHERE (device_uuid, key_id) IN (
|
let otp: Option<(i32, Vec<u8>)> = if state.allow_otp_issue(&address) {
|
||||||
SELECT device_uuid, key_id FROM one_time_prekeys
|
sqlx::query_as(
|
||||||
WHERE device_uuid = $1 ORDER BY key_id LIMIT 1)
|
"DELETE FROM one_time_prekeys
|
||||||
RETURNING key_id, public_key",
|
WHERE (device_uuid, key_id) IN (
|
||||||
)
|
SELECT device_uuid, key_id FROM one_time_prekeys
|
||||||
.bind(&device_uuid)
|
WHERE device_uuid = $1 ORDER BY key_id
|
||||||
.fetch_optional(&state.pool)
|
FOR UPDATE SKIP LOCKED LIMIT 1)
|
||||||
.await
|
RETURNING key_id, public_key",
|
||||||
.map_err(internal)?;
|
)
|
||||||
|
.bind(&device_uuid)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
out.push(DeviceBundleOut {
|
out.push(DeviceBundleOut {
|
||||||
device_id,
|
device_id,
|
||||||
@@ -321,6 +410,7 @@ pub(crate) struct AuthedDevice {
|
|||||||
pub account_id: Vec<u8>,
|
pub account_id: Vec<u8>,
|
||||||
pub identity_key: Vec<u8>,
|
pub identity_key: Vec<u8>,
|
||||||
pub device_id: i32,
|
pub device_id: i32,
|
||||||
|
pub device_uuid: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ověří hlavičku `X-Sepot-Auth: <device_uuid_hex>.<ts_ms>.<sig_b64>`,
|
/// Ověří hlavičku `X-Sepot-Auth: <device_uuid_hex>.<ts_ms>.<sig_b64>`,
|
||||||
@@ -349,7 +439,8 @@ pub(crate) async fn authenticate_device(
|
|||||||
let signature = BASE64_STANDARD.decode(sig_b64).map_err(|_| unauthorized())?;
|
let signature = BASE64_STANDARD.decode(sig_b64).map_err(|_| unauthorized())?;
|
||||||
|
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
if (now_ms - ts).abs() > AUTH_WINDOW_MS {
|
// abs_diff: `(now - ts).abs()` by u extrémního ts v release přetekl.
|
||||||
|
if now_ms.abs_diff(ts) > AUTH_WINDOW_MS as u64 {
|
||||||
return Err(unauthorized());
|
return Err(unauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,9 +467,157 @@ pub(crate) async fn authenticate_device(
|
|||||||
account_id,
|
account_id,
|
||||||
identity_key,
|
identity_key,
|
||||||
device_id,
|
device_id,
|
||||||
|
device_uuid,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── POST /v1/keys ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UploadKeysIn {
|
||||||
|
#[serde(default)]
|
||||||
|
pub signed_prekey: Option<PreKeyIn>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kyber_prekey: Option<PreKeyIn>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub one_time_prekeys: Vec<PreKeyIn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UploadKeysOut {
|
||||||
|
/// Kolik OTP zařízení má po nahrání.
|
||||||
|
pub one_time_prekeys: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strop zásoby OTP na zařízení — nad něj se další zahodí (klient posílá
|
||||||
|
/// dávky po 50, relay zůstane u sta jako při registraci).
|
||||||
|
const MAX_STORED_ONE_TIME_PREKEYS: i64 = 100;
|
||||||
|
|
||||||
|
/// Po kolika dnech se starý signed prekey smaže (po rotaci ho relay ještě
|
||||||
|
/// drží, protože bundle s ním mohl někdo stáhnout těsně před výměnou).
|
||||||
|
const SIGNED_PREKEY_GRACE_DAYS: i64 = 30;
|
||||||
|
|
||||||
|
/// Doplnění one-time prekeys a rotace signed / Kyber prekey. Auth podpis: `"POST /v1/keys\n<ts>"`.
|
||||||
|
/// Podpisy nových prekeys se ověřují proti identity klíči zařízení.
|
||||||
|
pub async fn upload_keys(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<UploadKeysIn>,
|
||||||
|
) -> ApiResult<Json<UploadKeysOut>> {
|
||||||
|
let authed = authenticate_device(&state, &headers, "POST /v1/keys").await?;
|
||||||
|
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
||||||
|
return Err(bad("příliš mnoho one-time prekeys"));
|
||||||
|
}
|
||||||
|
let identity =
|
||||||
|
PublicKey::deserialize(&authed.identity_key).map_err(|_| bad("neplatný identity klíč"))?;
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await.map_err(internal)?;
|
||||||
|
|
||||||
|
if let Some(spk) = &body.signed_prekey {
|
||||||
|
let public = b64("signed_prekey.public_key", &spk.public_key, Some(33))?;
|
||||||
|
let sig = b64(
|
||||||
|
"signed_prekey.signature",
|
||||||
|
spk.signature.as_deref().ok_or_else(|| bad("chybí podpis signed prekey"))?,
|
||||||
|
Some(64),
|
||||||
|
)?;
|
||||||
|
if !identity.verify_signature(&public, &sig) {
|
||||||
|
return Err(bad("podpis signed prekey nesedí"));
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO signed_prekeys (device_uuid, key_id, public_key, signature)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (device_uuid, key_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.bind(spk.key_id)
|
||||||
|
.bind(&public)
|
||||||
|
.bind(&sig)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
// Starší než grace a zároveň ne nejnovější → pryč.
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM signed_prekeys WHERE device_uuid = $1
|
||||||
|
AND created_at < now() - make_interval(days => $2)
|
||||||
|
AND key_id <> (SELECT key_id FROM signed_prekeys WHERE device_uuid = $1
|
||||||
|
ORDER BY created_at DESC LIMIT 1)",
|
||||||
|
)
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.bind(SIGNED_PREKEY_GRACE_DAYS as i32)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(kyber) = &body.kyber_prekey {
|
||||||
|
let public = b64("kyber_prekey.public_key", &kyber.public_key, None)?;
|
||||||
|
if public.len() > 2048 {
|
||||||
|
return Err(bad("kyber prekey je příliš dlouhý"));
|
||||||
|
}
|
||||||
|
let sig = b64(
|
||||||
|
"kyber_prekey.signature",
|
||||||
|
kyber.signature.as_deref().ok_or_else(|| bad("chybí podpis kyber prekey"))?,
|
||||||
|
Some(64),
|
||||||
|
)?;
|
||||||
|
if !identity.verify_signature(&public, &sig) {
|
||||||
|
return Err(bad("podpis kyber prekey nesedí"));
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO kyber_prekeys (device_uuid, key_id, public_key, signature, is_last_resort)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (device_uuid, key_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.bind(kyber.key_id)
|
||||||
|
.bind(&public)
|
||||||
|
.bind(&sig)
|
||||||
|
.bind(kyber.is_last_resort)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM kyber_prekeys WHERE device_uuid = $1 AND is_last_resort
|
||||||
|
AND created_at < now() - make_interval(days => $2)
|
||||||
|
AND key_id <> (SELECT key_id FROM kyber_prekeys WHERE device_uuid = $1
|
||||||
|
AND is_last_resort ORDER BY created_at DESC LIMIT 1)",
|
||||||
|
)
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.bind(SIGNED_PREKEY_GRACE_DAYS as i32)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut stored,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT count(*) FROM one_time_prekeys WHERE device_uuid = $1")
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
for otp in &body.one_time_prekeys {
|
||||||
|
if stored >= MAX_STORED_ONE_TIME_PREKEYS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let public = b64("one_time_prekeys.public_key", &otp.public_key, Some(33))?;
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO one_time_prekeys (device_uuid, key_id, public_key) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (device_uuid, key_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(&authed.device_uuid)
|
||||||
|
.bind(otp.key_id)
|
||||||
|
.bind(&public)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(internal)?;
|
||||||
|
stored += res.rows_affected() as i64;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(internal)?;
|
||||||
|
Ok(Json(UploadKeysOut {
|
||||||
|
one_time_prekeys: stored,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ── DELETE /v1/identity ────────────────────────────────────────────────
|
// ── DELETE /v1/identity ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Deregistrace účtu (GDPR čl. 17, store požadavek na smazání účtu).
|
/// Deregistrace účtu (GDPR čl. 17, store požadavek na smazání účtu).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
@@ -39,6 +40,36 @@ const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
|||||||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||||||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||||||
|
|
||||||
|
/// Výchozí strop souběžných WS spojení z jedné IP. Env
|
||||||
|
/// `SEPOT_MAX_CONNS_PER_IP` ho přepisuje — za CGNAT mobilních operátorů
|
||||||
|
/// sdílí jednu IP stovky lidí.
|
||||||
|
const DEFAULT_MAX_CONNS_PER_IP: u32 = 32;
|
||||||
|
|
||||||
|
/// Běžný budíček na jeden cíl nejvýš jednou za tohle okno: třicet zpráv ve
|
||||||
|
/// skupině během výpadku sítě = jeden push, ne třicet (APNs i FCM nápor
|
||||||
|
/// tichých pushů škrtí, baterie taky).
|
||||||
|
const WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
/// Budíček hovoru (VoIP push / FCM k=call) zvoní na telefonu hned a bez
|
||||||
|
/// ověření — proto má vlastní, přísnější strop na cíl: nejvýš jeden za
|
||||||
|
/// CALL_WAKE_MIN_INTERVAL a CALL_WAKE_MAX_PER_WINDOW za CALL_WAKE_WINDOW.
|
||||||
|
/// Nad strop se obálka uloží a jde jen běžný budíček (phantom
|
||||||
|
/// hovory od kohokoli, kdo zná adresu).
|
||||||
|
const CALL_WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
const CALL_WAKE_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
|
const CALL_WAKE_MAX_PER_WINDOW: usize = 6;
|
||||||
|
/// Kolik one-time prekeys smí relay vydat na jednu adresu za hodinu.
|
||||||
|
/// Noví kontakti jsou jednotky za den; sto GET /v1/keys za dvacet sekund
|
||||||
|
/// je vyčerpání zásoby cizím. Nad strop jde bundle bez OTP.
|
||||||
|
const OTP_ISSUE_MAX_PER_HOUR: u32 = 30;
|
||||||
|
const OTP_ISSUE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
|
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct WakeMarks {
|
||||||
|
last_wake: Option<Instant>,
|
||||||
|
call_wakes: std::collections::VecDeque<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
|
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
|
||||||
///
|
///
|
||||||
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
|
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
|
||||||
@@ -126,6 +157,15 @@ pub struct AppState {
|
|||||||
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
||||||
/// TURN pro hovory (None = relay údaje nevydává).
|
/// TURN pro hovory (None = relay údaje nevydává).
|
||||||
pub turn: Option<crate::turn::TurnConfig>,
|
pub turn: Option<crate::turn::TurnConfig>,
|
||||||
|
/// Strop WS spojení na IP.
|
||||||
|
pub max_conns_per_ip: u32,
|
||||||
|
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
||||||
|
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||||||
|
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||||||
|
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
|
||||||
|
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
|
||||||
|
pub source_url: String,
|
||||||
|
pub revision: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -147,9 +187,96 @@ impl AppState {
|
|||||||
fcm: crate::fcm::FcmSender::from_env(),
|
fcm: crate::fcm::FcmSender::from_env(),
|
||||||
apns: crate::apns::ApnsSender::from_env(),
|
apns: crate::apns::ApnsSender::from_env(),
|
||||||
turn: crate::turn::TurnConfig::from_env(),
|
turn: crate::turn::TurnConfig::from_env(),
|
||||||
|
max_conns_per_ip: std::env::var("SEPOT_MAX_CONNS_PER_IP")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(DEFAULT_MAX_CONNS_PER_IP),
|
||||||
|
wake_marks: DashMap::new(),
|
||||||
|
otp_issued: DashMap::new(),
|
||||||
|
source_url: std::env::var("SEPOT_SOURCE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
||||||
|
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Úklid značek starších než největší okno — volá periodická úloha.
|
||||||
|
pub fn prune_marks(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.wake_marks.retain(|_, m| {
|
||||||
|
m.call_wakes
|
||||||
|
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||||||
|
m.last_wake
|
||||||
|
.map(|t| now.duration_since(t) < CALL_WAKE_WINDOW)
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| !m.call_wakes.is_empty()
|
||||||
|
});
|
||||||
|
self.otp_issued
|
||||||
|
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
||||||
|
pub fn allow_otp_issue(&self, dst_address: &[u8]) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut entry = self
|
||||||
|
.otp_issued
|
||||||
|
.entry(dst_address.to_vec())
|
||||||
|
.or_insert((now, 0));
|
||||||
|
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
||||||
|
*entry = (now, 0);
|
||||||
|
}
|
||||||
|
if entry.1 >= OTP_ISSUE_MAX_PER_HOUR {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.1 += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Běžný budíček smí odejít nejvýš jednou za WAKE_MIN_INTERVAL na cíl.
|
||||||
|
fn allow_wake(&self, dst_address: &[u8]) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut marks = self.wake_marks.entry(dst_address.to_vec()).or_default();
|
||||||
|
if marks
|
||||||
|
.last_wake
|
||||||
|
.map(|t| now.duration_since(t) < WAKE_MIN_INTERVAL)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
marks.last_wake = Some(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Budíček hovoru: vlastní strop na cíl (viz CALL_WAKE_*). Vrací false,
|
||||||
|
/// když je cíl v okně už vyčerpaný — pak jde nanejvýš běžný budíček.
|
||||||
|
fn allow_call_wake(&self, dst_address: &[u8]) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut marks = self.wake_marks.entry(dst_address.to_vec()).or_default();
|
||||||
|
marks
|
||||||
|
.call_wakes
|
||||||
|
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||||||
|
if let Some(last) = marks.call_wakes.back() {
|
||||||
|
if now.duration_since(*last) < CALL_WAKE_MIN_INTERVAL {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if marks.call_wakes.len() >= CALL_WAKE_MAX_PER_WINDOW {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
marks.call_wakes.push_back(now);
|
||||||
|
marks.last_wake = Some(now);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kolik one-time prekeys zařízení ještě má.
|
||||||
|
pub async fn one_time_prekey_count(&self, device_uuid: &[u8]) -> anyhow::Result<i64> {
|
||||||
|
let (n,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT count(*) FROM one_time_prekeys WHERE device_uuid = $1")
|
||||||
|
.bind(device_uuid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
|
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
|
||||||
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
|
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
|
||||||
/// až ACK příjemce, ne odeslání.
|
/// až ACK příjemce, ne odeslání.
|
||||||
@@ -192,7 +319,11 @@ impl AppState {
|
|||||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||||
};
|
};
|
||||||
let fresh = dev.is_fresh(self.now_ms());
|
let fresh = dev.is_fresh(self.now_ms());
|
||||||
let queued = dev.tx.send(deliver).await.is_ok();
|
// try_send, ne send().await: plný kanál znamená, že příjemce ze
|
||||||
|
// socketu nečte. Čekat na něj by drželo odesílatelovo spojení
|
||||||
|
// (a jeho paměť) rukojmím — nečtoucí příjemci by tak shodili
|
||||||
|
// relay. Obálka je v DB, půjde budíček.
|
||||||
|
let queued = dev.tx.try_send(deliver).is_ok();
|
||||||
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
|
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
|
||||||
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
|
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
|
||||||
// ale propadne se i na push — jinak by mrtvé spojení budíček
|
// ale propadne se i na push — jinak by mrtvé spojení budíček
|
||||||
@@ -201,10 +332,10 @@ impl AppState {
|
|||||||
return Ok(v1::receipt::Status::DeliveredOnline);
|
return Ok(v1::receipt::Status::DeliveredOnline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
|
// Offline, zatuchlé nebo plný kanál → zkusit push budíček.
|
||||||
if is_call {
|
if is_call && self.allow_call_wake(&dst) {
|
||||||
self.wake_call(&dst).await;
|
self.wake_call(&dst).await;
|
||||||
} else {
|
} else if self.allow_wake(&dst) {
|
||||||
self.wake(&dst).await;
|
self.wake(&dst).await;
|
||||||
}
|
}
|
||||||
Ok(v1::receipt::Status::StoredOffline)
|
Ok(v1::receipt::Status::StoredOffline)
|
||||||
@@ -223,7 +354,7 @@ impl AppState {
|
|||||||
let deliver = v1::ServerFrame {
|
let deliver = v1::ServerFrame {
|
||||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||||
};
|
};
|
||||||
if dev.tx.send(deliver).await.is_ok() {
|
if dev.tx.try_send(deliver).is_ok() {
|
||||||
v1::receipt::Status::DeliveredOnline
|
v1::receipt::Status::DeliveredOnline
|
||||||
} else {
|
} else {
|
||||||
v1::receipt::Status::Dropped
|
v1::receipt::Status::Dropped
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ use sepot_proto::v1;
|
|||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
|
|
||||||
/// Platnost údajů. Pokrývá i dlouhý hovor — coturn kontroluje expiraci jen
|
/// Platnost údajů. Pokrývá i dlouhý hovor — coturn kontroluje expiraci jen
|
||||||
/// při alokaci a jejím obnovení, ne u běžícího hovoru.
|
/// při alokaci a jejím obnovení, ne u běžícího hovoru. Dvanáct hodin stačí
|
||||||
pub const TTL_SECONDS: u32 = 24 * 3600;
|
/// (dřív 24 h: zbytečně dlouhé okno pro údaje, které přežijí i smazání účtu).
|
||||||
|
pub const TTL_SECONDS: u32 = 12 * 3600;
|
||||||
|
|
||||||
pub struct TurnConfig {
|
pub struct TurnConfig {
|
||||||
secret: Vec<u8>,
|
secret: Vec<u8>,
|
||||||
|
|||||||
+83
-24
@@ -27,15 +27,28 @@ use tokio::time::Instant;
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
|
/// Neautentizované spojení zavřít po tomto čase od navázání — absolutně,
|
||||||
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
|
/// ne „od posledního rámce": Pong rámce před AUTH by jinak držely spojení
|
||||||
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
|
/// bez konce.
|
||||||
const MAX_CONNS_PER_IP: u32 = 32;
|
|
||||||
/// Neautentizované spojení zavřít po tomto čase.
|
|
||||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
|
/// Kolik AUTH pokusů smí jedno spojení udělat (každý = dotaz do DB).
|
||||||
|
const MAX_AUTH_ATTEMPTS: u32 = 3;
|
||||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
||||||
|
/// Počítají se jen rámce, které něco stojí (obálka, push registrace, TURN);
|
||||||
|
/// ACK a Pong ne — klient po vylití velké fronty posílá ACK + doručenku na
|
||||||
|
/// každou obálku a 429 by ho odpojilo uprostřed, fronta by se po osmi
|
||||||
|
/// kolech smazala (ztráta zpráv bez útočníka).
|
||||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||||
const FRAME_WINDOW_MAX: u32 = 200;
|
const FRAME_WINDOW_MAX: u32 = 200;
|
||||||
|
/// Push endpoint (URL distributora / FCM token) a VoIP token mají rozumný
|
||||||
|
/// strop; APNs token je 64 hex, FCM do ~200 znaků, UnifiedPush URL < 1 KiB.
|
||||||
|
const MAX_PUSH_ENDPOINT_BYTES: usize = 2048;
|
||||||
|
const MAX_VOIP_TOKEN_BYTES: usize = 256;
|
||||||
|
/// TURN údaje platí hodiny — nové vydat nejdřív po tomhle čase, jinak se
|
||||||
|
/// na jednom spojení dají farmit stovky přihlášení (kvóta coturnu je na jméno).
|
||||||
|
const TURN_REISSUE_AFTER: Duration = Duration::from_secs(600);
|
||||||
|
/// Pod tímhle počtem one-time prekeys relay po AUTH pošle PrekeysLow.
|
||||||
|
pub const PREKEYS_LOW_THRESHOLD: i64 = 20;
|
||||||
/// Jak často server posílá WS Ping autentizovanému klientovi.
|
/// Jak často server posílá WS Ping autentizovanému klientovi.
|
||||||
///
|
///
|
||||||
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
|
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
|
||||||
@@ -73,7 +86,7 @@ pub async fn ws_handler(
|
|||||||
|
|
||||||
let over_cap = {
|
let over_cap = {
|
||||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||||
if *entry >= MAX_CONNS_PER_IP {
|
if *entry >= state.max_conns_per_ip {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
*entry += 1;
|
*entry += 1;
|
||||||
@@ -157,14 +170,18 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
// routing poznal živé spojení od zatuchlého.
|
// routing poznal živé spojení od zatuchlého.
|
||||||
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||||
|
|
||||||
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
|
// Anti-abuse okno: počítá rámce, které něco stojí (viz FRAME_WINDOW_MAX)
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
let mut window_frames: u32 = 0;
|
let mut window_frames: u32 = 0;
|
||||||
|
let auth_deadline = Instant::now() + AUTH_DEADLINE;
|
||||||
|
let mut auth_attempts: u32 = 0;
|
||||||
|
// Naposledy vydané TURN údaje — do TURN_REISSUE_AFTER se vrací tytéž.
|
||||||
|
let mut turn_issued: Option<(Instant, v1::TurnCredentials)> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Před AUTH platí deadline — viset neautentizovaný nejde
|
// Před AUTH platí absolutní deadline — viset neautentizovaný nejde
|
||||||
let next = if authed_address.is_none() {
|
let next = if authed_address.is_none() {
|
||||||
match tokio::time::timeout(AUTH_DEADLINE, stream.next()).await {
|
match tokio::time::timeout_at(auth_deadline, stream.next()).await {
|
||||||
Ok(next) => next,
|
Ok(next) => next,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::debug!("ws zavřeno: AUTH deadline");
|
tracing::debug!("ws zavřeno: AUTH deadline");
|
||||||
@@ -195,17 +212,6 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
dev.touch(state.now_ms());
|
dev.touch(state.now_ms());
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Instant::now();
|
|
||||||
if now.duration_since(window_start) > FRAME_WINDOW {
|
|
||||||
window_start = now;
|
|
||||||
window_frames = 0;
|
|
||||||
}
|
|
||||||
window_frames += 1;
|
|
||||||
if window_frames > FRAME_WINDOW_MAX {
|
|
||||||
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = match msg {
|
let data = match msg {
|
||||||
WsMessage::Binary(b) => b,
|
WsMessage::Binary(b) => b,
|
||||||
WsMessage::Close(_) => break,
|
WsMessage::Close(_) => break,
|
||||||
@@ -216,8 +222,30 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cheap = matches!(
|
||||||
|
frame.frame,
|
||||||
|
Some(v1::client_frame::Frame::Ack(_)) | Some(v1::client_frame::Frame::Pong(_))
|
||||||
|
);
|
||||||
|
if !cheap {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now.duration_since(window_start) > FRAME_WINDOW {
|
||||||
|
window_start = now;
|
||||||
|
window_frames = 0;
|
||||||
|
}
|
||||||
|
window_frames += 1;
|
||||||
|
if window_frames > FRAME_WINDOW_MAX {
|
||||||
|
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match frame.frame {
|
match frame.frame {
|
||||||
Some(v1::client_frame::Frame::Auth(auth)) if authed_address.is_none() => {
|
Some(v1::client_frame::Frame::Auth(auth)) if authed_address.is_none() => {
|
||||||
|
auth_attempts += 1;
|
||||||
|
if auth_attempts > MAX_AUTH_ATTEMPTS {
|
||||||
|
let _ = tx.send(error_frame(429, "příliš mnoho AUTH pokusů")).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if auth.device_uuid.len() != 16 || auth.nonce_client.len() != 16 {
|
if auth.device_uuid.len() != 16 || auth.nonce_client.len() != 16 {
|
||||||
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
||||||
break;
|
break;
|
||||||
@@ -303,14 +331,33 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
Err(e) => tracing::warn!("flush fronty selhal: {e}"),
|
Err(e) => tracing::warn!("flush fronty selhal: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Docházející one-time prekeys: klient si je doplní přes
|
||||||
|
// POST /v1/keys. Bez tohohle hintu zůstal účet po vyčerpání
|
||||||
|
// natrvalo bez OTP.
|
||||||
|
if let Ok(remaining) = state.one_time_prekey_count(&pd.device_uuid).await {
|
||||||
|
if remaining < PREKEYS_LOW_THRESHOLD {
|
||||||
|
let _ = tx
|
||||||
|
.send(server_frame(v1::server_frame::Frame::PrekeysLow(
|
||||||
|
v1::PrekeysLow {
|
||||||
|
remaining: remaining.max(0) as u32,
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
authed_device = Some(pd.device_uuid);
|
authed_device = Some(pd.device_uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
||||||
if let Some(device) = &authed_device {
|
if let Some(device) = &authed_device {
|
||||||
let platform = reg.platform as i16;
|
let platform = i16::try_from(reg.platform).unwrap_or(-1);
|
||||||
if platform < 0 || platform > crate::push::PLATFORM_MAX {
|
if platform < 0 || platform > crate::push::PLATFORM_MAX {
|
||||||
let _ = tx.send(error_frame(400, "neznámá push platforma")).await;
|
let _ = tx.send(error_frame(400, "neznámá push platforma")).await;
|
||||||
|
} else if reg.endpoint.len() > MAX_PUSH_ENDPOINT_BYTES
|
||||||
|
|| reg.voip_token.len() > MAX_VOIP_TOKEN_BYTES
|
||||||
|
{
|
||||||
|
let _ = tx.send(error_frame(400, "push endpoint je příliš dlouhý")).await;
|
||||||
} else {
|
} else {
|
||||||
let endpoint =
|
let endpoint =
|
||||||
(!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice());
|
(!reg.endpoint.is_empty()).then_some(reg.endpoint.as_slice());
|
||||||
@@ -357,9 +404,21 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
|
|
||||||
Some(v1::client_frame::Frame::TurnRequest(_)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::TurnRequest(_)) if authed_address.is_some() => {
|
||||||
let frame = match &state.turn {
|
let frame = match &state.turn {
|
||||||
Some(turn) => server_frame(v1::server_frame::Frame::TurnCredentials(
|
Some(turn) => {
|
||||||
turn.credentials(chrono::Utc::now().timestamp()),
|
let fresh = turn_issued
|
||||||
)),
|
.as_ref()
|
||||||
|
.filter(|(at, _)| at.elapsed() < TURN_REISSUE_AFTER)
|
||||||
|
.map(|(_, c)| c.clone());
|
||||||
|
let creds = match fresh {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
let c = turn.credentials(chrono::Utc::now().timestamp());
|
||||||
|
turn_issued = Some((Instant::now(), c.clone()));
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
server_frame(v1::server_frame::Frame::TurnCredentials(creds))
|
||||||
|
}
|
||||||
None => error_frame(503, "TURN není nakonfigurován"),
|
None => error_frame(503, "TURN není nakonfigurován"),
|
||||||
};
|
};
|
||||||
let _ = tx.send(frame).await;
|
let _ = tx.send(frame).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user