6 Commits

Author SHA1 Message Date
Julius Joska d13b88aebe release: 1.2.1 (24)
Incoming calls, recovery from the 24-word phrase on a new device, duress
session isolation and three message-loss paths; see CHANGELOG.md.
2026-10-09 15:07:38 +02:00
Julius Joska b824f62878 deploy(relay): upgrade base packages in the runtime image
The bookworm-slim base ships fixable CVEs (perl-base and friends); apply
Debian's security updates at build time.
2026-10-09 14:06:42 +02:00
Julius Joska 00739ac8a0 docs: security policy, accurate push and status notes, unreleased changelog
- SECURITY.md with the reporting address and expectations; README points to
  security@sepot.cz and security.txt
- README: FCM is the Android default since 1.2.0 when no UnifiedPush
  distributor is installed; status reflects the Play release
- the "F-Droid" channel is not available yet and is no longer listed
- CHANGELOG: unreleased fixes and security hardening
2026-10-09 14:05:39 +02:00
Julius Joska 94cfd72690 chore(relay): merge the duplicated test module in apns.rs
cargo test on the published tree failed with E0428 since 1.2.0.
2026-10-09 14:05:39 +02:00
Julius Joska e3a2378c34 fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
2026-10-09 14:02:33 +02:00
Julius Joska 8b452a5593 fix(relay): message loss on large queues, call wake-up throttling, identity reclaim, prekey upload
- stop counting ACK and Pong frames toward the per-connection frame window;
  draining a large queue used to trip the 429 limit, disconnect the client
  and, after eight rounds, drop the remaining envelopes
- deliver to online recipients with try_send: a recipient that stops reading
  its socket no longer pins the sender's task and memory
- throttle call wake-ups per recipient (1 per 10 s, 6 per 10 min) and
  ordinary wake-ups (1 per 5 s); collapse ids for APNs and FCM
- POST /v1/register accepts X-Sepot-Identity-Proof and lets a new device take
  over an existing identity (restore from the recovery phrase)
- POST /v1/keys uploads one-time prekeys and rotates signed/Kyber prekeys;
  PrekeysLow is sent after AUTH when fewer than 20 remain
- GET /v1/keys hands out at most 30 one-time prekeys per address per hour
- reqwest errors are logged without the URL; UnifiedPush POSTs pin the
  addresses the SSRF guard resolved; NAT64/6to4 embedded addresses are checked
- absolute AUTH deadline, at most 3 AUTH attempts, length limits on push
  endpoints and VoIP tokens
- TURN credentials live 12 h and are reissued per connection every 10 min
- global attachment storage cap (SEPOT_ATTACHMENTS_TOTAL_BYTES), nosniff and
  attachment disposition on downloads
- SEPOT_MAX_CONNS_PER_IP, /v1/ready backed by the database, graceful shutdown,
  RLIMIT_NOFILE raised to the hard limit, server-info reports source and revision
- Dockerfile: rust 1.99, GIT_SHA build arg for the image label and
  SEPOT_REVISION; Tor image on alpine 3.24
2026-10-09 14:02:33 +02:00
51 changed files with 1805 additions and 297 deletions
+38
View File
@@ -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/),
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
### Nový vzhled
+4 -4
View File
@@ -4,7 +4,7 @@
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
> Status: pre-1.0. The Android client is the primary target; iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
> Status: 1.2.x. Android is on Google Play (production 1.1.2, 1.2.0 in internal testing); iOS builds run through TestFlight. See [`CHANGELOG.md`](CHANGELOG.md).
## Features
@@ -14,7 +14,7 @@ The design goal is **metadata minimization with a public threat model** — neve
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
- **Push without Google by default** — UnifiedPush/ntfy; FCM (Android) and APNs (iOS) are opt-in. A push wake-up never carries content, sender, or count.
- **Push with or without Google** — UnifiedPush/ntfy when a distributor is installed, otherwise FCM on Android (default since 1.2.0, can be switched off) and APNs on iOS. A push wake-up never carries content, sender, or count.
- **Tor transport** (Android, via Orbot) — routes relay traffic over a `.onion` to hide the client IP. Off by default.
- **Five languages** — Czech, English, German, Russian, Italian. The UI follows the system language and can be overridden in Settings.
@@ -71,10 +71,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
## Security
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **bezpecnost@sepot.cz** rather than opening a public issue.
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
## License
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository, F-Droid, direct APK). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository and the direct APK releases). That permission covers only Šepot's own code. The bundled Signal `libsignal` is AGPL-3.0 held by Signal Messenger, LLC — its app-store status is documented in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+34
View File
@@ -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.
+1 -1
View File
@@ -26,7 +26,7 @@ their app-store status is documented in detail below.
Šepot relies on that stated position and, as an AGPL work, keeps its
complete corresponding source available under the AGPL through channels
without app-store restrictions (this public repository, F-Droid, direct
without app-store restrictions (this public repository, direct
APK). A written clarification for the current Rust `libsignal` has been
requested from Signal (tracking: <https://github.com/signalapp/libsignal/issues/684>).
@@ -1,5 +1,6 @@
package cz.sepot.sepot
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.view.WindowManager
import io.flutter.embedding.android.FlutterFragmentActivity
@@ -7,10 +8,20 @@ import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
// Soukromí: zákaz screenshotů a náhledu obsahu v přepínači aplikací.
// Výchozí stav; Dart přepínač „Zabezpečení obrazovky" ho může po
// odemčení vypnout (default zapnuto, jako u Signalu).
// Debug build: screenshoty povolené kvůli QA na emulátoru (adb screencap
// by u FLAG_SECURE okna selhal); release vždy chráněný.
val debuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (!debuggable) {
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE,
)
}
// Tapjacking: dotyky přes překryvné okno jiné appky (částečně
// průhledný overlay nad PIN klávesnicí) se zahodí.
window.decorView.filterTouchesWhenObscured = true
}
}
+3 -1
View File
@@ -289,7 +289,9 @@ class _LoadingScreenState extends ConsumerState<_LoadingScreen> {
final storage = ref.read(storageProvider);
final legacyPin = await storage.hasLegacyPin();
await storage.unlockWithoutPin();
if (mounted) context.go(legacyPin ? '/pin-setup' : '/chats');
if (!mounted) return;
ref.invalidate(screenSecurityProvider);
context.go(legacyPin ? '/pin-setup' : '/chats');
}
}
+26 -3
View File
@@ -236,9 +236,15 @@ class CallController extends Notifier<CallState> {
_ringId = pushRing.id;
if (pushRing.accepted) {
await accept();
} else if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
if (CallRinger.declinedEarly.remove(pushRing.id)) {
await reject();
return;
}
// Spárováno s nabídkou: aby grace z `ringFromPush` zvonění neukončila,
// nahradí se jménem volajícího (bez značky push).
await _claimPushRing(pushRing.id, name, video);
return;
}
final foreground =
@@ -249,6 +255,15 @@ class CallController extends Notifier<CallState> {
}
}
/// Převezme zvonění z budíčku: jméno volajícího místo „Šepot" a bez
/// značky push (grace v [CallRinger.ringFromPush] ho pak nechá být).
Future<void> _claimPushRing(String pushId, String name, bool video) async {
if (await CallRinger.isAccepted(pushId)) return; // uživatel už zvedl
await CallRinger.end(pushId);
_ringId = state.callId;
await CallRinger.ringFor(callId: state.callId!, name: name, video: video);
}
void _onRingAccept(String id) {
if (id == _ringId && state.phase == CallPhase.incoming) {
unawaited(accept());
@@ -389,11 +404,19 @@ class CallController extends Notifier<CallState> {
return pc;
}
/// STUN bez údajů: coturn na témž hostiteli odpovídá na STUN bez
/// přihlášení, takže i když relay TURN údaje nevydá (503, timeout),
/// jde aspoň přímé spojení přes NAT. Dřív byl seznam prázdný = jen host
/// kandidáti = hovor jen v jedné LAN.
static const _stunFallback = {
'urls': ['stun:turn.sepot.cz:3478'],
};
/// STUN i TURN běží na stejném serveru; bez údajů z relaye aspoň STUN
/// (přímé spojení, když obě strany nejsou za symetrickým NATem).
Future<List<Map<String, dynamic>>> _iceServers() async {
final turn = await ref.read(messagingServiceProvider).turnCredentials();
if (turn == null || turn.urls.isEmpty) return const [];
if (turn == null || turn.urls.isEmpty) return const [_stunFallback];
return [
{
'urls': turn.urls,
+50 -6
View File
@@ -22,6 +22,13 @@ class CallRinger {
/// Jak dlouho zvoní — stejné jako expirace budíčku na relayi.
static const ringMillis = 45000;
/// Zvonění z budíčku, ke kterému do té doby nedorazila nabídka, se
/// ukončí samo. Relay budíček hovoru nijak neověřuje (sealed sender),
/// takže kdokoli se znalostí adresy by jinak nechal telefon zvonit
/// 45 s na každou obálku s hintem CALL. Nabídka od
/// skutečného volajícího dorazí do pár sekund po připojení.
static const pushRingGrace = Duration(seconds: 12);
static StreamSubscription<CallEvent?>? _sub;
/// Id zvonění, která uživatel odmítl dřív, než dorazila nabídka.
@@ -65,12 +72,41 @@ class CallRinger {
_params(id: callId, name: name, video: video));
/// Zvonění z budíčku relaye — jméno ani typ hovoru ještě neznáme.
static Future<void> ringFromPush() => FlutterCallkitIncoming
.showCallkitIncoming(_params(
id: const Uuid().v4(),
name: _appName,
video: false,
extra: {pushMarker: true}));
/// Vrací až po [pushRingGrace]: pokud se zvonění do té doby nespárovalo
/// s nabídkou (přijetí nebo ukončení ho z aktivních hovorů odebere, nebo
/// ho [CallController] převezme), tiše se ukončí. Background isolate FCM
/// handleru tím zůstane naživu přesně tak dlouho, jak je potřeba.
static Future<void> ringFromPush() async {
final id = const Uuid().v4();
await FlutterCallkitIncoming.showCallkitIncoming(_params(
id: id, name: _appName, video: false, extra: {pushMarker: true}));
await Future<void>.delayed(pushRingGrace);
await endIfUnmatched(id);
}
/// iOS: zvonění z VoIP pushe zakládá nativní AppDelegate (CallKit musí
/// zazvonit hned), takže grace z [ringFromPush] tam neběží. Po startu
/// Dartu se proto každé nepřevzaté zvonění z budíčku ohlídá odsud.
static Future<void> expireStalePushRings() async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.extra?[pushMarker] == true && !call.isAccepted) {
final id = call.id;
unawaited(Future<void>.delayed(pushRingGrace, () => endIfUnmatched(id)));
}
}
}
/// Ukončí zvonění z budíčku, které nikdo nepřevzal (bez záznamu
/// o zmeškaném hovoru — nebylo od koho).
static Future<void> endIfUnmatched(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id && call.extra?[pushMarker] == true && !call.isAccepted) {
await FlutterCallkitIncoming.endCall(id);
}
}
}
/// Zvonění z budíčku, které čeká na nabídku (nejnovější), a jestli ho
/// uživatel už přijal — typicky na zamčené obrazovce, než appka naběhla.
@@ -84,6 +120,14 @@ class CallRinger {
return null;
}
static Future<bool> isAccepted(String id) async {
final calls = await FlutterCallkitIncoming.activeCalls();
for (final call in calls) {
if (call.id == id) return call.isAccepted;
}
return false;
}
static Future<void> connected(String id) =>
FlutterCallkitIncoming.setCallConnected(id);
+66 -3
View File
@@ -53,16 +53,20 @@ class DekWrap {
int memoryKiB = defaultMemoryKiB,
int iterations = defaultIterations,
int parallelism = defaultParallelism,
Uint8List? salt,
}) async {
if (dek.length != 32) {
throw ArgumentError.value(dek.length, 'dek', 'DEK must be 32 bytes');
}
_enforceFloor(memoryKiB, iterations);
final salt = _randomBytes(16);
// Volitelná sdílená sůl: nátlakový blob se stejnou solí a parametry
// jako skutečný odemyká jedinou derivací (viz unwrapFirstMatching) —
// bez ní by decoy PIN trval 2× déle a šel poznat stopkami.
final useSalt = salt ?? _randomBytes(16);
final unlockKey = await _deriveUnlockKey(
pin: pin,
salt: salt,
salt: useSalt,
memoryKiB: memoryKiB,
iterations: iterations,
parallelism: parallelism,
@@ -80,13 +84,72 @@ class DekWrap {
'$memoryKiB',
'$iterations',
'$parallelism',
base64Encode(salt),
base64Encode(useSalt),
base64Encode(box.nonce),
base64Encode(box.cipherText),
base64Encode(box.mac.bytes),
].join(':');
}
/// Sůl použitá v zabaleném blobu (pro sdílení s nátlakovým blobem).
static Uint8List saltOf(String wrapped) {
final parts = wrapped.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', wrapped);
}
return Uint8List.fromList(base64Decode(parts[4]));
}
/// Zkusí PIN proti několika blobům (skutečný + nátlakový) a vrátí index
/// prvního, který sedí, s rozbaleným DEK. Argon2id se počítá jen jednou
/// pro každou odlišnou kombinaci parametrů a soli, takže u blobů se
/// sdílenou solí stojí odemčení stejně, ať sedí kterýkoli — čas
/// odemčení neprozradí, který PIN byl zadán. Nic nesedí →
/// [WrongPinException].
static Future<(int, Uint8List)> unwrapFirstMatching({
required List<String> wrapped,
required String pin,
}) async {
final parsed = <List<String>>[];
for (final w in wrapped) {
final parts = w.split(':');
if (parts.length != 8 || parts[0] != _version) {
throw FormatException('Unknown wrapped DEK format', w);
}
_enforceFloor(int.parse(parts[1]), int.parse(parts[2]));
parsed.add(parts);
}
final keys = <String, Uint8List>{};
for (final parts in parsed) {
final group = parts.sublist(1, 5).join(':');
keys[group] ??= await _deriveUnlockKey(
pin: pin,
salt: base64Decode(parts[4]),
memoryKiB: int.parse(parts[1]),
iterations: int.parse(parts[2]),
parallelism: int.parse(parts[3]),
);
}
final aes = AesGcm.with256bits();
for (var i = 0; i < parsed.length; i++) {
final parts = parsed[i];
try {
final dek = await aes.decrypt(
SecretBox(
base64Decode(parts[6]),
nonce: base64Decode(parts[5]),
mac: Mac(base64Decode(parts[7])),
),
secretKey: SecretKey(keys[parts.sublist(1, 5).join(':')]!),
);
return (i, Uint8List.fromList(dek));
} on SecretBoxAuthenticationError {
continue;
}
}
throw const WrongPinException();
}
/// Rozbal DEK. Na špatném PINu (nebo poškozeném blobu) vyhodí
/// [WrongPinException]; na neplatném formátu [FormatException].
static Future<Uint8List> unwrap({
+86
View File
@@ -81,6 +81,32 @@ class SepotSealedMessage {
}
/// Jeden one-time prekey k publikaci na relay.
/// Nový signed prekey k nahrání na relay (rotace).
class SepotSignedPreKey {
SepotSignedPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
/// Nový last-resort Kyber prekey k nahrání na relay.
class SepotKyberPreKey {
SepotKyberPreKey({
required this.id,
required this.publicKey,
required this.signature,
});
final int id;
final Uint8List publicKey;
final Uint8List signature;
}
class SepotOneTimePreKey {
SepotOneTimePreKey({required this.id, required this.publicKey});
@@ -420,4 +446,64 @@ class SepotCrypto {
}
return result;
}
/// Nový signed prekey (rotace). Starý zůstává ve store — relace založené
/// z bundle těsně před rotací ho ještě potřebují.
Future<SepotSignedPreKey> generateSignedPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final private = PrivateKey.generate();
final public = private.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.signedPreKey.storeSignedPreKey(
id,
SignedPreKeyRecord(
id: id,
publicKey: public,
privateKey: private,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotSignedPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
/// Nový last-resort Kyber prekey (rotace PQXDH kotvy).
Future<SepotKyberPreKey> generateKyberPreKey({
required int id,
required int timestampMs,
}) async {
final identityPrivate = PrivateKey.deserialize(
bytes: _identity.privateKey.toList(),
);
final keyPair = KyberKeyPair.generate();
final public = keyPair.getPublicKey();
final signature = identityPrivate.sign(
message: public.serialize().toList(),
);
await _stores.kyberPreKey.storeKyberPreKey(
id,
KyberPreKeyRecord.create(
id: id,
keyPair: keyPair,
signature: signature.toList(),
timestamp: BigInt.from(timestampMs),
),
);
return SepotKyberPreKey(
id: id,
publicKey: Uint8List.fromList(public.serialize().toList()),
signature: Uint8List.fromList(signature.toList()),
);
}
}
+96 -9
View File
@@ -64,9 +64,17 @@ class RelayClient {
final _deliverController = StreamController<pb.Envelope>.broadcast();
final _receiptController = StreamController<pb.Receipt>.broadcast();
final _turnController = StreamController<pb.TurnCredentials>.broadcast();
final _prekeysLowController = StreamController<int>.broadcast();
var _state = RelayConnectionState.disconnected;
/// Kdy naposled přišel aplikační rámec od relaye.
DateTime _lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0);
static const _staleAfter = Duration(seconds: 20);
/// Relay hlásí docházející one-time prekeys (zbývající počet).
Stream<int> get prekeysLow => _prekeysLowController.stream;
RelayConnectionState get state => _state;
Stream<RelayConnectionState> get stateStream => _stateController.stream;
@@ -100,9 +108,18 @@ class RelayClient {
required List<SepotOneTimePreKey> oneTimePreKeys,
required Uint8List deliveryAddress,
}) async {
// Důkaz držení identity klíče: obnova ze 24 slov na novém telefonu
// narazí na relayi na už existující identitu (409) a bez tohohle
// podpisu by ji nikdy nepřevzala. Starší relay hlavičku
// ignoruje.
final proofTs = DateTime.now().millisecondsSinceEpoch;
final proof = _crypto.sign(utf8.encode('POST /v1/register\n$proofTs'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/register'),
headers: {'content-type': 'application/json'},
headers: {
'content-type': 'application/json',
'x-sepot-identity-proof': '$proofTs.${base64Encode(proof)}',
},
body: jsonEncode({
'identity_key': base64Encode(_crypto.identityPublicKey),
'device_id': 1,
@@ -144,6 +161,49 @@ class RelayClient {
_crypto.bindAccount(accountId);
}
/// POST /v1/keys — doplnění one-time prekeys a rotace signed / Kyber
/// prekey. Vrací počet OTP na relayi po nahrání.
Future<int> uploadKeys({
SepotSignedPreKey? signedPreKey,
SepotKyberPreKey? kyberPreKey,
List<SepotOneTimePreKey> oneTimePreKeys = const [],
}) async {
final deviceUuid = _requireDevice();
final ts = DateTime.now().millisecondsSinceEpoch;
final signature = _crypto.sign(utf8.encode('POST /v1/keys\n$ts'));
final resp = await _transport.httpClient().post(
Uri.parse('$baseUrl/v1/keys'),
headers: {
'content-type': 'application/json',
'x-sepot-auth': '$deviceUuid.$ts.${base64Encode(signature)}',
},
body: jsonEncode({
if (signedPreKey != null)
'signed_prekey': {
'key_id': signedPreKey.id,
'public_key': base64Encode(signedPreKey.publicKey),
'signature': base64Encode(signedPreKey.signature),
},
if (kyberPreKey != null)
'kyber_prekey': {
'key_id': kyberPreKey.id,
'public_key': base64Encode(kyberPreKey.publicKey),
'signature': base64Encode(kyberPreKey.signature),
'is_last_resort': true,
},
'one_time_prekeys': [
for (final otp in oneTimePreKeys)
{'key_id': otp.id, 'public_key': base64Encode(otp.publicKey)},
],
}),
);
if (resp.statusCode != 200) {
throw RelayException('prekey upload failed', resp.statusCode, resp.body);
}
final body = jsonDecode(resp.body) as Map<String, dynamic>;
return (body['one_time_prekeys'] as num?)?.toInt() ?? 0;
}
/// GET /v1/keys/{addr} — bundle protistrany (spotřebuje 1 OTP).
Future<SepotPreKeyBundle> fetchBundle(Uint8List deliveryAddress) async {
final hexAddr = deliveryAddress
@@ -280,6 +340,20 @@ class RelayClient {
_setState(RelayConnectionState.disconnected);
}
/// Budíček od relaye znamená, že nás nevidí jako živé. Spojení, které
/// hlásí „připojeno", ale [_staleAfter] nic nepřijalo, je po změně sítě
/// nejspíš zombie — zavřít a navázat hned, ne až po ping timeoutu
/// (pojistka proti zombie spojení na straně appky). Jinak jen [start].
void kick() {
if (_state == RelayConnectionState.connected &&
DateTime.now().difference(_lastFrameAt) > _staleAfter) {
_reconnectDelayS = 1;
_onDisconnected();
return;
}
start();
}
Future<void> _teardownChannel() async {
await _wsSub?.cancel();
_wsSub = null;
@@ -313,6 +387,7 @@ class RelayClient {
_wsSub = channel.stream.listen(
(data) {
final frame = pb.ServerFrame.fromBuffer(data as List<int>);
_lastFrameAt = DateTime.now();
if (!authenticated) {
if (frame.hasChallenge()) {
final signature = _crypto.sign([
@@ -345,6 +420,10 @@ class RelayClient {
if (!_turnController.isClosed) {
_turnController.add(frame.turnCredentials);
}
} else if (frame.hasPrekeysLow()) {
if (!_prekeysLowController.isClosed) {
_prekeysLowController.add(frame.prekeysLow.remaining);
}
}
},
onDone: _onDisconnected,
@@ -389,14 +468,21 @@ class RelayClient {
final receiptFuture = _receiptController.stream
.firstWhere((r) => _bytesEqual(r.msgUuid, msgUuid))
.timeout(timeout);
_send(pb.ClientFrame(
envelope: pb.Envelope(
dstAddress: dstAddress,
msgUuid: msgUuid,
contentHint: hint,
payload: payload,
),
));
try {
_send(pb.ClientFrame(
envelope: pb.Envelope(
dstAddress: dstAddress,
msgUuid: msgUuid,
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;
}
@@ -455,6 +541,7 @@ class RelayClient {
_transport.close();
await _stateController.close();
await _turnController.close();
await _prekeysLowController.close();
await _deliverController.close();
await _receiptController.close();
}
+2
View File
@@ -87,6 +87,8 @@ class PushBootstrap {
if (Platform.isIOS) {
ApnsBridge.onWake = _wake;
ApnsBridge.ensureHandler();
// Zvonění z VoIP pushe bez nabídky nesmí zvonit celých 45 s.
unawaited(CallRinger.expireStalePushRings());
} else {
await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint,
+9 -1
View File
@@ -52,7 +52,15 @@ class PushService {
/// Může budíček rovnou probudit WS? (Jinak jde generická notifikace.)
bool get canWake => _messaging.isStarted;
void wake() => _messaging.onPushWake();
/// Budíček: běžící messaging připojí WS; po auto-locku (zamčená DB) aspoň
/// generická notifikace bez obsahu, ať se uživatel o zprávě dozví.
void wake() {
if (canWake) {
_messaging.onPushWake();
} else {
unawaited(PushNotifier.showWakeNotification());
}
}
void handleNewEndpoint(String url) {
_messaging.registerPush(_registration(
+218 -28
View File
@@ -107,6 +107,56 @@ class MessagingService {
StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer;
Timer? _purgeTimer;
StreamSubscription<int>? _prekeysLowSub;
/// Typy E2E obsahu, které tahle verze umí zpracovat. Cokoli jiného se
/// ACKne a zahodí (po upgradu už nejde dešifrovat znovu). Veřejná
/// konstanta kvůli testu: v 1.2.0 tu chyběl `call` a každá nabídka
/// hovoru se tiše zahodila — hovory vůbec nešly přijmout.
static const knownContentTypes = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', 'call',
..._groupControlTypes,
};
/// Prodleva dalšího pokusu o registraci; roste do [_registerRetryMax].
Duration _registerRetryDelay = const Duration(seconds: 30);
static const _registerRetryMax = Duration(minutes: 10);
/// Relay odmítl registraci jako konflikt identity (409) — typicky starší
/// relay bez převzetí identity po obnově ze 24 slov. UI může vysvětlit.
bool _registrationConflict = false;
bool get registrationConflict => _registrationConflict;
/// Rozpracované dodávky: `stop()` (auto-lock) na ně počká, jinak by DB
/// zmizela mezi dešifrováním (ratchet už postoupil) a zápisem a zpráva
/// by byla nenávratně pryč.
int _inFlight = 0;
Completer<void>? _drained;
/// Operace nad ratchet stavem jdou postupně. libsignal načte relaci,
/// šifruje a uloží ji zpět s `await` na Dart store uprostřed — dva
/// souběžné běhy nad touž relací by odvodily stejný message key a druhá
/// obálka by u příjemce propadla. Doručenky, „píše…“ i outbox běží
/// `unawaited` vedle uživatelského odeslání, takže zámek je nutný.
Future<void> _chain = Future.value();
Future<T> _serial<T>(Future<T> Function() op) {
final next = _chain.then((_) => op());
_chain = next.then((_) {}, onError: (_) {});
return next;
}
/// Nejvyšší počet znaků jména kontaktu/profilu z drátu nebo odkazu.
static const maxDisplayNameLength = 64;
static String clipName(String name) => name.length > maxDisplayNameLength
? name.substring(0, maxDisplayNameLength)
: name;
/// Kolik one-time prekeys doplnit, když relay hlásí nedostatek.
static const _otpBatch = 50;
static const _spkRotation = Duration(days: 7);
static const _kyberRotation = Duration(days: 30);
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
@@ -266,11 +316,13 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
}
});
@@ -337,11 +389,13 @@ class MessagingService {
final relay = _buildRelay();
_relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver);
_prekeysLowSub = relay.prekeysLow.listen(_replenishPreKeys);
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
_stateSub = relay.stateStream.listen((state) {
if (state == RelayConnectionState.connected) {
unawaited(_flushOutbox());
unawaited(_refreshMyAvatarIfOld());
unawaited(_rotatePreKeysIfDue());
}
});
@@ -376,16 +430,109 @@ class MessagingService {
Future<void> _registerWithRetry() async {
try {
await _register();
} on RelayException catch (e) {
// 409 = identita na relayi existuje a relay neumí převzetí (starší
// verze). Opakovat má smysl (relay se aktualizuje), ale ne každých
// 30 s navždy — a UI má vědět proč.
_registrationConflict = e.statusCode == 409;
_scheduleRegisterRetry();
return;
} catch (_) {
_registerRetryTimer?.cancel();
_registerRetryTimer =
Timer(const Duration(seconds: 30), _registerWithRetry);
_scheduleRegisterRetry();
return;
}
_registrationConflict = false;
_registerRetryDelay = const Duration(seconds: 30);
_relay!.start();
await _refreshSenderCert();
}
void _scheduleRegisterRetry() {
_registerRetryTimer?.cancel();
_registerRetryTimer = Timer(_registerRetryDelay, _registerWithRetry);
final doubled = _registerRetryDelay * 2;
_registerRetryDelay =
doubled > _registerRetryMax ? _registerRetryMax : doubled;
onUpdate?.call();
}
int? _kvInt(CryptoDb db, String key) {
final raw = db.kvGet(key);
return raw == null ? null : int.tryParse(utf8.decode(raw));
}
/// Relay po AUTH hlásí docházející one-time prekeys → vygenerovat dávku
/// a nahrát. Bez toho byl účet po vyčerpání zásoby natrvalo bez OTP
/// a každá nová relace k němu běžela bez jednorázového klíče.
Future<void> _replenishPreKeys(int remaining) async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
try {
// Registrace nahrála id 1–100; další dávky navazují.
final next = _kvInt(db, 'otp_next_id') ?? 101;
final otps = await _serial(
() => crypto.generateOneTimePreKeys(startId: next, count: _otpBatch));
await relay.uploadKeys(oneTimePreKeys: otps);
db.kvPut('otp_next_id', utf8.encode('${next + _otpBatch}'));
} catch (_) {
// Síť / starší relay bez /v1/keys — relay to zopakuje po dalším AUTH.
}
}
/// Rotace signed prekey (týdně) a last-resort Kyber prekey (měsíčně),
/// Staré klíče zůstávají ve store — relace z bundle
/// staženého těsně před rotací je ještě potřebují.
Future<void> _rotatePreKeysIfDue() async {
final db = _db;
final crypto = _crypto;
final relay = _relay;
if (db == null || crypto == null || relay == null || accountId == null) {
return;
}
final now = DateTime.now();
bool due(String key, Duration period) {
final at = _kvInt(db, key);
return at == null ||
now.difference(DateTime.fromMillisecondsSinceEpoch(at)) > period;
}
final spkDue = due('spk_rotated_at', _spkRotation);
final kyberDue = due('kyber_rotated_at', _kyberRotation);
if (!spkDue && !kyberDue) return;
try {
SepotSignedPreKey? spk;
SepotKyberPreKey? kyber;
if (spkDue) {
final id = _kvInt(db, 'spk_next_id') ?? 2;
spk = await _serial(() => crypto.generateSignedPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
if (kyberDue) {
final id = _kvInt(db, 'kyber_next_id') ?? 2;
kyber = await _serial(() => crypto.generateKyberPreKey(
id: id, timestampMs: now.millisecondsSinceEpoch));
}
await relay.uploadKeys(signedPreKey: spk, kyberPreKey: kyber);
final stamp = utf8.encode('${now.millisecondsSinceEpoch}');
db.transaction(() {
if (spk != null) {
db.kvPut('spk_next_id', utf8.encode('${spk.id + 1}'));
db.kvPut('spk_rotated_at', stamp);
}
if (kyber != null) {
db.kvPut('kyber_next_id', utf8.encode('${kyber.id + 1}'));
db.kvPut('kyber_rotated_at', stamp);
}
});
} catch (_) {
// Starší relay bez /v1/keys nebo offline — zkusí se po dalším connectu.
}
}
Future<void> _register() async {
final db = _db!;
final crypto = _crypto!;
@@ -410,10 +557,17 @@ class MessagingService {
deliveryAddress: deliveryAddress,
);
final stamp = utf8.encode('$nowMs');
db.transaction(() {
db.kvPut('account_id', utf8.encode(reg.accountId));
db.kvPut('device_uuid', utf8.encode(reg.deviceUuid));
db.kvPut('delivery_address', deliveryAddress);
// Id pro doplňování a rotace klíčů navazují na registraci.
db.kvPut('otp_next_id', utf8.encode('101'));
db.kvPut('spk_next_id', utf8.encode('2'));
db.kvPut('kyber_next_id', utf8.encode('2'));
db.kvPut('spk_rotated_at', stamp);
db.kvPut('kyber_rotated_at', stamp);
});
onUpdate?.call();
}
@@ -2009,6 +2163,10 @@ class MessagingService {
'txt': caption ?? '',
're': ?message.replyToId,
'ttl': ?message.disappearAfterSeconds,
// Anketa a zmínky patří k téže zprávě i při dodatečném odeslání —
// bez nich by po reconnectu dorazil jen holý záložní text.
'poll': ?message.poll?.toWire(),
if (message.mentions.isNotEmpty) 'mn': message.mentions,
'p': _profile(),
};
}
@@ -2053,22 +2211,27 @@ class MessagingService {
}
final peerAddr = ProtocolAddress(name: peerAid, deviceId: 1);
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
if (ephemeral) return null;
final bundle = await relay.fetchBundle(peerDa);
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
throw const IdentityKeyMismatchError();
// Krypto část jde přes [_serial] (viz komentář u pole): relace se
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
// nedrží — čekání na receipt by blokovalo všechny ostatní.
final sealed = await _serial<Uint8List?>(() async {
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
if (!await crypto.hasSession(peerAddr)) {
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);
}
final sealed = await crypto.sealedEncrypt(
recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
senderCertificate: await _senderCertificate(),
);
return crypto.sealedEncrypt(
recipient: peerAddr,
plaintext: utf8.encode(jsonEncode(content)),
senderCertificate: await _senderCertificate(),
);
});
if (sealed == null) return null;
final receipt = await relay.sendEnvelope(
dstAddress: peerDa,
@@ -2084,10 +2247,25 @@ class MessagingService {
// ── Příjem ──────────────────────────────────────────────────────────
Future<void> _onDeliver(pb.Envelope envelope) async {
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
/// rozpracované, aby na ně [stop] počkal.
void _onDeliver(pb.Envelope envelope) {
_inFlight++;
unawaited(_serial(() => _handleDelivery(envelope))
.then((_) {}, onError: (_) {})
.whenComplete(() {
_inFlight--;
if (_inFlight == 0) _drained?.complete();
}));
}
Future<void> _handleDelivery(pb.Envelope envelope) async {
final crypto = _crypto;
final trustRoot = _trustRoot;
if (crypto == null || trustRoot == null) return;
// Zamčené úložiště (auto-lock stihl zamknout dřív, než jsme se dostali
// na řadu): nedešifrovat — ratchet by postoupil a zápis by selhal.
// Relay obálku doručí znovu po odemčení.
if (crypto == null || trustRoot == null || !storage.isUnlocked) return;
final SepotSealedMessage opened;
try {
@@ -2110,10 +2288,7 @@ class MessagingService {
final content =
jsonDecode(utf8.decode(opened.plaintext)) as Map<String, dynamic>;
final type = content['t'] as String?;
const known = {
'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote',
..._groupControlTypes,
};
const known = knownContentTypes;
if (content['v'] != 1 || !known.contains(type)) {
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
@@ -2228,13 +2403,18 @@ class MessagingService {
final replyTo = content['re'] as String?;
final Message message;
if (type == 'txt') {
final text = content['txt'];
if (text is! String) {
final raw = content['txt'];
if (raw is! String) {
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
_relay!.ack(envelope.msgUuid);
return;
}
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
// JSON blob — čtvrtmegabajtová bublina by ji trvale zpomalila.
const maxWireText = AppConstants.maxMessageLength * 4;
final text =
raw.length > maxWireText ? raw.substring(0, maxWireText) : raw;
message = Message(
id: messageId,
senderId: contact.publicIdentity.fingerprint.display,
@@ -2567,7 +2747,7 @@ class MessagingService {
// Prázdné jméno = UI si doplní lokalizovaný fallback
final name = (profile?['n'] as String?)?.trim();
final identity = PublicIdentity(
displayName: (name == null || name.isEmpty) ? '' : name,
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
identityKey: opened.senderIdentityKey,
createdAt: DateTime.now(),
accountId: opened.senderName,
@@ -2603,7 +2783,7 @@ class MessagingService {
/// Probuzení z push budíčku: ujisti se, že jsme připojení — fronta se
/// vyleje po AUTH (relay pošle DELIVER stream). Idempotentní.
void onPushWake() {
_relay?.start();
_relay?.kick();
}
/// Deregistrace účtu na relayi (GDPR čl. 17 / store „smazání účtu").
@@ -2631,7 +2811,17 @@ class MessagingService {
_purgeTimer?.cancel();
_purgeTimer = null;
await _deliverSub?.cancel();
await _prekeysLowSub?.cancel();
await _stateSub?.cancel();
// Rozpracované dodávky doběhnou (dešifrováno → zapsáno → ACK), teprve
// pak se úložiště smí zamknout. Strop, ať zaseklá dodávka nezablokuje
// zámek navždy.
if (_inFlight > 0) {
final drained = _drained ??= Completer<void>();
await drained.future
.timeout(const Duration(seconds: 8), onTimeout: () {});
}
_drained = null;
await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null;
+136 -29
View File
@@ -30,6 +30,16 @@ import 'package:sepot/models/message.dart';
/// při onboardingu, který DB rovnou otevře).
/// Nátlakový PIN je shodný se skutečným — nelze nastavit.
/// Příliš mnoho špatných PINů za sebou — další pokus až za [remaining].
class PinLockedException implements Exception {
const PinLockedException(this.remaining);
final Duration remaining;
@override
String toString() => 'PIN locked for ${remaining.inSeconds}s';
}
class DecoyPinConflict implements Exception {
const DecoyPinConflict();
@override
@@ -51,6 +61,13 @@ class SecureStorageService {
static const _legacyPinHashKey = 'pin_hash';
static const _decoyWrappedDekKey = 'wrapped_dek_decoy';
// Brzda hádání PINu: počet chyb za sebou a čas, do kdy je zámek uzamčený.
// V keystore (ne v DB) — DB je právě to, co se hádáním otevírá.
static const _pinFailCountKey = 'pin_fail_count';
static const _pinLockUntilKey = 'pin_lock_until';
static const _pinFreeAttempts = 5;
static const _pinLockBase = Duration(seconds: 30);
static const _pinLockMax = Duration(minutes: 15);
final FlutterSecureStorage _secureStorage;
final String? _dbPathOverride;
@@ -60,12 +77,24 @@ class SecureStorageService {
bool _decoyMode = false;
/// Odemčeno nátlakovým (decoy) PINem → běžíme nad prázdnou decoy DB.
/// V tomhle režimu úložiště nevydá skutečnou identitu ani frázi,
/// nestartuje síť a nic z keystore nemění (viz jednotlivé metody).
bool get isDecoyMode => _decoyMode;
/// Identita ukazovaná v nátlakové relaci: náhodná, jen v paměti.
LocalIdentity? _decoyIdentity;
void _requireRealSession() {
if (_decoyMode) throw StateError('Not available in a duress session');
}
SecureStorageService({String? dbPath})
: _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
// this_device: identita a klíče nesmí putovat v iCloud Keychain
// ani v záloze na jiný telefon — obnova jde přes 24 slov.
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device),
),
_dbPathOverride = dbPath;
@@ -98,21 +127,59 @@ class SecureStorageService {
if (wrapped == null) {
throw StateError('No PIN set — use unlockWithoutPin');
}
final locked = await pinLockRemaining();
if (locked != null) throw PinLockedException(locked);
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
try {
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: pin);
await _openDb(dek);
// Oba bloby jednou derivací (sdílená sůl) — čas odemčení neprozradí,
// který PIN padl.
final (index, dek) = await DekWrap.unwrapFirstMatching(
wrapped: [wrapped, ?decoy],
pin: pin,
);
await _openDb(dek, decoy: index == 1);
await _resetPinFailures();
} on WrongPinException {
final decoy = await _secureStorage.read(key: _decoyWrappedDekKey);
if (decoy == null) rethrow;
// Když ani decoy nesedí, unwrap vyhodí WrongPinException dál.
final dek = await DekWrap.unwrap(wrapped: decoy, pin: pin);
await _openDb(dek, decoy: true);
await _notePinFailure();
rethrow;
}
}
/// Zbývající zámek po sérii špatných PINů; null = volno.
Future<Duration?> pinLockRemaining() async {
final raw = await _secureStorage.read(key: _pinLockUntilKey);
final until = raw == null ? null : int.tryParse(raw);
if (until == null) return null;
final remaining = DateTime.fromMillisecondsSinceEpoch(until)
.difference(DateTime.now());
return remaining > Duration.zero ? remaining : null;
}
Future<void> _notePinFailure() async {
final raw = await _secureStorage.read(key: _pinFailCountKey);
final failures = (raw == null ? 0 : int.tryParse(raw) ?? 0) + 1;
await _secureStorage.write(key: _pinFailCountKey, value: '$failures');
if (failures < _pinFreeAttempts) return;
// 30 s po páté chybě, pak dvojnásobně až do 15 minut: šest číslic
// (10⁶) tak trvá roky, Argon2id sám by brzdil jen ~2 s na pokus.
var lock = _pinLockBase * (1 << (failures - _pinFreeAttempts));
if (lock > _pinLockMax) lock = _pinLockMax;
await _secureStorage.write(
key: _pinLockUntilKey,
value: '${DateTime.now().add(lock).millisecondsSinceEpoch}',
);
}
Future<void> _resetPinFailures() async {
await _secureStorage.delete(key: _pinFailCountKey);
await _secureStorage.delete(key: _pinLockUntilKey);
}
/// Zapne PIN: zabalí DEK za Argon2id(PIN) a raw kopii z keystore smaže.
/// Pokud DB ještě není otevřená (onboarding), rovnou ji otevře.
Future<void> enablePin(String pin) async {
_requireRealSession();
final dek = _dek ?? await _getOrCreateRawDek();
final wrapped = await DekWrap.wrap(dek: dek, pin: pin);
await _secureStorage.write(key: _wrappedDekKey, value: wrapped);
@@ -124,6 +191,7 @@ class SecureStorageService {
/// Vypne PIN: DEK se vrátí raw do OS keystore.
Future<void> disablePin() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(key: _rawDekKey, value: base64Encode(dek));
await _secureStorage.delete(key: _wrappedDekKey);
@@ -151,6 +219,7 @@ class SecureStorageService {
required String oldPin,
required String newPin,
}) async {
_requireRealSession();
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
if (wrapped == null) throw StateError('No PIN set');
final dek = await DekWrap.unwrap(wrapped: wrapped, pin: oldPin);
@@ -165,7 +234,11 @@ class SecureStorageService {
}
}
final rewrapped = await DekWrap.wrap(dek: dek, pin: newPin);
// Sůl zůstává: nátlakový blob ji sdílí (jedna derivace pro oba PINy)
// a bez jeho PINu by se přebalit nedal. Jedna sůl pro dva PINy téhož
// uživatele nic neoslabuje — sůl brání předpočítání napříč uživateli.
final rewrapped = await DekWrap.wrap(
dek: dek, pin: newPin, salt: DekWrap.saltOf(wrapped));
await _secureStorage.write(key: _wrappedDekKey, value: rewrapped);
}
@@ -176,6 +249,7 @@ class SecureStorageService {
/// Nastaví nátlakový (decoy) PIN — jeho zadáním se otevře PRÁZDNÁ decoy
/// DB. Vyžaduje nastavený běžný PIN a MUSÍ se od něj lišit.
Future<void> enableDecoyPin(String decoyPin) async {
_requireRealSession();
final realWrapped = await _secureStorage.read(key: _wrappedDekKey);
if (realWrapped == null) {
throw StateError('Set a regular PIN first');
@@ -193,7 +267,9 @@ class SecureStorageService {
// Inicializuj prázdnou decoy DB (schéma) bez zásahu do aktuální DB
final decoyPath = await _dbPath(decoy: true);
CryptoDb.open(path: decoyPath, dek: decoyDek).close();
final wrapped = await DekWrap.wrap(dek: decoyDek, pin: decoyPin);
// Stejná sůl jako skutečný blob → jedna Argon2id derivace pro oba PINy.
final wrapped = await DekWrap.wrap(
dek: decoyDek, pin: decoyPin, salt: DekWrap.saltOf(realWrapped));
await _secureStorage.write(key: _decoyWrappedDekKey, value: wrapped);
}
@@ -203,11 +279,20 @@ class SecureStorageService {
/// Zruší nátlakový PIN a smaže decoy DB.
Future<void> disableDecoyPin() async {
_requireRealSession();
await _secureStorage.delete(key: _decoyWrappedDekKey);
try {
final f = File(await _dbPath(decoy: true));
if (f.existsSync()) f.deleteSync();
} catch (_) {}
await _deleteDbFiles(await _dbPath(decoy: true));
}
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
@@ -222,6 +307,8 @@ class SecureStorageService {
_db?.close();
_db = null;
_dek = null;
_decoyMode = false;
_decoyIdentity = null;
}
Future<void> _openDb(Uint8List dek, {bool decoy = false}) async {
@@ -293,6 +380,11 @@ class SecureStorageService {
/// Načti lokální identitu
Future<LocalIdentity?> loadIdentity() async {
if (_decoyMode) {
// Nátlaková relace ukazuje náhodnou identitu jen z paměti — skutečný
// klíč z keystore se do ní nikdy nedostane (nátlakový PIN musí být izolovaný).
return _decoyIdentity ??= await LocalIdentity.create('');
}
final json = await _secureStorage.read(key: _identityKey);
if (json == null) return null;
try {
@@ -320,6 +412,7 @@ class SecureStorageService {
}
Future<List<String>?> loadSeedPhrase() async {
if (_decoyMode) return null;
final json = await _secureStorage.read(key: _seedPhraseKey);
if (json == null) return null;
return (jsonDecode(json) as List).cast<String>();
@@ -331,6 +424,7 @@ class SecureStorageService {
/// Vyžaduje odemčené úložiště (typicky hned po zadání PINu v settings).
/// Samotné ověření otisku/obličeje řeší UI přes local_auth PŘED voláním.
Future<void> enableBiometrics() async {
_requireRealSession();
final dek = _dek ?? (throw StateError('Requires unlocked storage'));
await _secureStorage.write(
key: _biometricDekKey,
@@ -342,6 +436,7 @@ class SecureStorageService {
/// Vypne biometrii a kopii DEK z keystore smaže (zpět na čistý
/// PIN-only model: DEK existuje jen za Argon2id).
Future<void> disableBiometrics() async {
_requireRealSession();
await _secureStorage.delete(key: _biometricDekKey);
await _secureStorage.write(key: _biometricsEnabledKey, value: 'false');
}
@@ -475,24 +570,29 @@ class SecureStorageService {
if (!isUnlocked) return false;
var changed = false;
final now = DateTime.now();
for (final conv in await loadConversations()) {
final expired = conv.messages
.where((m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now))
.toList();
bool isExpired(Message m) =>
m.disappearAfterSeconds != null &&
m.timestamp
.add(Duration(seconds: m.disappearAfterSeconds!))
.isBefore(now);
for (final snapshot in await loadConversations()) {
if (!snapshot.messages.any(isExpired)) continue;
// Konverzace se před zápisem načte znovu: mezi snímkem a zápisem
// běží event loop (mazání blobů, příjem zpráv) a starý snímek by
// přepsal zprávu přijatou mezitím — už ACKnutou, tedy navždy pryč.
final conv = await loadConversation(snapshot.contactId);
if (conv == null) continue;
final expired = conv.messages.where(isExpired).toList();
if (expired.isEmpty) continue;
changed = true;
conv.messages.removeWhere(isExpired);
await saveConversation(conv);
for (final m in expired) {
final blobId = m.attachment?.blobId;
if (blobId != null) {
await AttachmentStore().delete(blobId);
}
conv.messages.remove(m);
}
await saveConversation(conv);
}
return changed;
}
@@ -512,12 +612,19 @@ class SecureStorageService {
/// Smaž všechna data (factory reset) — DB soubor, klíče, keystore
/// i lokální bloby příloh.
Future<void> deleteAll() async {
final path = await _dbPath();
lock();
for (final suffix in ['', '-wal', '-shm']) {
final f = File('$path$suffix');
if (f.existsSync()) f.deleteSync();
if (_decoyMode) {
// Nátlaková relace: „smazat" smí jen prázdnou decoy DB — skutečná
// data a keystore zůstávají (dřív to smazalo reálný účet).
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(decoyPath);
return;
}
final path = await _dbPath();
final decoyPath = await _dbPath(decoy: true);
lock();
await _deleteDbFiles(path);
await _deleteDbFiles(decoyPath);
await AttachmentStore().deleteAll();
await _secureStorage.deleteAll();
}
+20 -2
View File
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:local_auth/local_auth.dart';
import 'package:sepot/core/crypto/dek_wrap.dart';
import 'package:sepot/core/storage/secure_storage.dart' show PinLockedException;
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/push_provider.dart';
import 'package:sepot/providers/settings_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.dart';
@@ -41,7 +43,10 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
try {
final ok = await LocalAuthentication().authenticate(
localizedReason: unlockReason,
options: const AuthenticationOptions(stickyAuth: true),
// biometricOnly: zámek telefonu (PIN/vzor zařízení) nesmí Šepot
// odemknout — obešel by Argon2id ochranu DEK slabším faktorem.
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
);
if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics();
@@ -222,6 +227,9 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
void _restartMessagingAfterUnlock() {
ref.invalidate(messagingStartProvider);
ref.invalidate(pushInitProvider);
// Zabezpečení obrazovky se čte z DB — před odemčením provider nic
// neaplikoval, teď má skutečnou hodnotu.
ref.invalidate(screenSecurityProvider);
}
Future<void> _verifyPin() async {
@@ -234,12 +242,22 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats');
} on WrongPinException {
final locked = await ref.read(storageProvider).pinLockRemaining();
if (!mounted) return;
setState(() {
_error = context.l10n.pinWrong2;
_error = locked == null
? context.l10n.pinWrong2
: context.l10n.pinLockedFor(locked.inSeconds);
_pin = '';
_isVerifying = false;
});
HapticFeedback.heavyImpact();
} on PinLockedException catch (e) {
setState(() {
_error = context.l10n.pinLockedFor(e.remaining.inSeconds);
_pin = '';
_isVerifying = false;
});
} on StateError {
// Wrapped DEK neexistuje — nekonzistentní stav, zpět na onboarding
if (mounted) context.go('/onboarding');
+8 -3
View File
@@ -89,9 +89,13 @@ class _CallScreenState extends ConsumerState<CallScreen> {
RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
),
),
SafeArea(
child: Column(
children: [
// Positioned.fill: bez něj si Stack dá sloupci volné rozměry a ve
// fázi „ukončeno" (žádný ovládací řádek přes celou šířku) se sloupec
// smrskne na šířku jména a zarovná vlevo nahoru — avatar uskočí.
Positioned.fill(
child: SafeArea(
child: Column(
children: [
const SizedBox(height: 48),
if (!showVideo) ...[
SepotAvatar(
@@ -136,6 +140,7 @@ class _CallScreenState extends ConsumerState<CallScreen> {
_InCallControls(call: call, controller: controller),
const SizedBox(height: 32),
],
),
),
),
if (showVideo && !call.cameraOff)
@@ -55,7 +55,8 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
}
final ok = await auth.authenticate(
localizedReason: l10n.biometricsEnrollPromptReason,
options: const AuthenticationOptions(stickyAuth: true),
options: const AuthenticationOptions(
stickyAuth: true, biometricOnly: true),
);
if (!ok) return;
await storage.enableBiometrics();
+73 -57
View File
@@ -38,6 +38,9 @@ class SettingsScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final identityAsync = ref.watch(identityProvider);
final l10n = context.l10n;
// Nátlaková relace: nic, co by prozradilo nebo poškodilo skutečný účet
// (fráze, biometrie, PIN, záloha, smazání) — appka vypadá jako čistá.
final decoy = ref.watch(storageProvider).isDecoyMode;
return Scaffold(
appBar: AppBar(
@@ -63,13 +66,15 @@ class SettingsScreen extends ConsumerWidget {
const SizedBox(height: 8),
// Sekce Zabezpečení
_buildSectionHeader(l10n.settingsSectionSecurity),
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
if (!decoy) ...[
_buildTile(
icon: Icons.pin_outlined,
title: l10n.settingsChangePinTitle,
subtitle: l10n.settingsChangePinSubtitle,
onTap: () => context.push('/change-pin'),
),
const BiometricsTile(),
],
_buildTile(
icon: Icons.timer_outlined,
title: l10n.settingsAutoLockTitle,
@@ -80,24 +85,25 @@ class SettingsScreen extends ConsumerWidget {
),
onTap: () => _nastavAutoLock(context, ref),
),
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
if (!decoy)
_buildTile(
icon: Icons.password_outlined,
title: l10n.settingsDecoyPinTitle,
subtitle: ref.watch(hasDecoyPinProvider).valueOrNull == true
? l10n.settingsDecoyPinSetSubtitle
: l10n.settingsDecoyPinUnsetSubtitle,
trailing: ref.watch(hasDecoyPinProvider).valueOrNull == true
? TextButton(
onPressed: () => _disableDecoyPin(context, ref),
child: Text(l10n.commonCancel),
)
: null,
onTap: () {
if (ref.read(hasDecoyPinProvider).valueOrNull != true) {
_setDecoyPin(context, ref);
}
},
),
const SizedBox(height: 8),
// Sekce Notifikace
_buildSectionHeader(l10n.settingsSectionNotifications),
@@ -153,25 +159,27 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 8),
// Sekce Záloha
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
if (!decoy) ...[
_buildSectionHeader(l10n.settingsSectionBackup),
_buildTile(
icon: Icons.key_outlined,
title: l10n.settingsSeedPhraseTitle,
subtitle: l10n.settingsSeedPhraseSubtitle,
onTap: () => _showSeedPhrase(context, ref),
),
_buildTile(
icon: Icons.backup_outlined,
title: l10n.settingsBackupExportTitle,
subtitle: l10n.settingsBackupExportSubtitle,
onTap: () => _exportBackup(context, ref),
),
_buildTile(
icon: Icons.restore_outlined,
title: l10n.settingsBackupImportTitle,
subtitle: l10n.settingsBackupImportSubtitle,
onTap: () => _importBackup(context, ref),
),
],
// „Web klient" byl slib funkce, která neexistuje (multi-device je
// vlna 3). Apple Guideline 2.1 takové dlaždice bere jako nedodělek.
const SizedBox(height: 8),
@@ -230,17 +238,18 @@ class SettingsScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
// Smazat účet
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
if (!decoy)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _confirmDeleteAccount(context, ref),
style: OutlinedButton.styleFrom(
foregroundColor: SepotColors.error,
side: const BorderSide(color: SepotColors.error),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
child: Text(l10n.settingsDeleteAllDataButton),
),
),
const SizedBox(height: 48),
],
),
@@ -514,10 +523,17 @@ class SettingsScreen extends ConsumerWidget {
final dir = await getTemporaryDirectory();
final path = '${dir.path}/sepot-zaloha.sepotbackup';
await File(path).writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(path, mimeType: 'application/octet-stream')],
subject: l10n.backupShareSubject,
);
try {
await Share.shareXFiles(
[XFile(path, mimeType: 'application/octet-stream')],
subject: l10n.backupShareSubject,
);
} finally {
// Šifrovaná, ale v cache nemá co pohledávat po skončení sdílení.
try {
await File(path).delete();
} catch (_) {}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+9
View File
@@ -1481,6 +1481,15 @@
"@pinUnlockError": {
"description": "Červená chybová hláška na odemykací obrazovce při jiné chybě odemčení než špatný PIN"
},
"pinLockedFor": "Příliš mnoho pokusů. Zkus to za {seconds} s.",
"@pinLockedFor": {
"description": "Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu",
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"pushTitle": "Push notifikace",
"@pushTitle": {
"description": "Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)"
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Mit Biometrie entsperren",
"pinWrong2": "Falsche PIN",
"pinUnlockError": "Fehler beim Entsperren",
"pinLockedFor": "Zu viele Versuche. Versuche es in {seconds} s erneut.",
"pushTitle": "Push-Benachrichtigungen",
"pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.",
"pushModeUp": "UnifiedPush (ohne Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Unlock with biometrics",
"pinWrong2": "Wrong PIN",
"pinUnlockError": "Unlock error",
"pinLockedFor": "Too many attempts. Try again in {seconds} s.",
"pushTitle": "Push notifications",
"pushModeOffDesc": "Messages arrive only after you open the app.",
"pushModeUp": "UnifiedPush (no Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Sblocca con biometria",
"pinWrong2": "PIN errato",
"pinUnlockError": "Errore di sblocco",
"pinLockedFor": "Troppi tentativi. Riprova tra {seconds} s.",
"pushTitle": "Notifiche push",
"pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.",
"pushModeUp": "UnifiedPush (senza Google)",
+1
View File
@@ -315,6 +315,7 @@
"pinUnlockWithBiometrics": "Разблокировать биометрией",
"pinWrong2": "Неверный PIN",
"pinUnlockError": "Ошибка разблокировки",
"pinLockedFor": "Слишком много попыток. Повторите через {seconds} с.",
"pushTitle": "Push-уведомления",
"pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.",
"pushModeUp": "UnifiedPush (без Google)",
@@ -1994,6 +1994,12 @@ abstract class AppLocalizations {
/// **'Chyba odemčení'**
String get pinUnlockError;
/// Hláška na odemykací obrazovce po sérii špatných PINů; {seconds} = počet sekund do dalšího pokusu
///
/// In cs, this message translates to:
/// **'Příliš mnoho pokusů. Zkus to za {seconds} s.'**
String pinLockedFor(int seconds);
/// Titulek SimpleDialog výběru režimu pushů. Stejný text jako title ListTile v nastavení (ř. 184) → stejný klíč. | Title ListTile v nastavení — stejný text a klíč jako titulek dialogu (ř. 44)
///
/// In cs, this message translates to:
@@ -1102,6 +1102,11 @@ class AppLocalizationsCs extends AppLocalizations {
@override
String get pinUnlockError => 'Chyba odemčení';
@override
String pinLockedFor(int seconds) {
return 'Příliš mnoho pokusů. Zkus to za $seconds s.';
}
@override
String get pushTitle => 'Push notifikace';
@@ -1121,6 +1121,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pinUnlockError => 'Fehler beim Entsperren';
@override
String pinLockedFor(int seconds) {
return 'Zu viele Versuche. Versuche es in $seconds s erneut.';
}
@override
String get pushTitle => 'Push-Benachrichtigungen';
@@ -1101,6 +1101,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pinUnlockError => 'Unlock error';
@override
String pinLockedFor(int seconds) {
return 'Too many attempts. Try again in $seconds s.';
}
@override
String get pushTitle => 'Push notifications';
@@ -1111,6 +1111,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get pinUnlockError => 'Errore di sblocco';
@override
String pinLockedFor(int seconds) {
return 'Troppi tentativi. Riprova tra $seconds s.';
}
@override
String get pushTitle => 'Notifiche push';
@@ -1118,6 +1118,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get pinUnlockError => 'Ошибка разблокировки';
@override
String pinLockedFor(int seconds) {
return 'Слишком много попыток. Повторите через $seconds с.';
}
@override
String get pushTitle => 'Push-уведомления';
+18 -8
View File
@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:sepot/core/constants/app_constants.dart';
/// Stav přenosu přílohy — čistě lokální (na drát nikdy nejde).
enum AttachmentTransferState {
/// Přijatá zpráva, blob se ještě nezačal stahovat.
@@ -94,14 +96,22 @@ class AttachmentMeta {
/// (blob_id formát hlídá AttachmentStore, tady rozsahy a typy).
factory AttachmentMeta.fromWire(Map<String, dynamic> wire) {
final size = wire['s'] as int;
if (size <= 0 || size > 512 * 1024 * 1024) {
if (size <= 0 || size > AppConstants.maxAttachmentBytes) {
throw const FormatException('implausible attachment size');
}
// Krypto pole MUSÍ být validní base64 (jinak je meta k ničemu);
// vyhodí FormatException → zpráva se zahodí jako vadná.
base64Decode(wire['k'] as String);
base64Decode(wire['n'] as String);
base64Decode(wire['d'] as String);
// Krypto pole MUSÍ být validní base64 správné délky (AES-256-GCM klíč,
// 96bitová nonce, SHA-256 digest) — jinak by vadná meta prošla a
// selhala až při otevření přílohy, kdy je obálka dávno ACKnutá.
if (base64Decode(wire['k'] as String).length != 32 ||
base64Decode(wire['n'] as String).length != 12 ||
base64Decode(wire['d'] as String).length != 32) {
throw const FormatException('invalid attachment key material');
}
final name = wire['name'] as String?;
final mime = wire['mime'] as String;
if ((name != null && name.length > 255) || mime.length > 100) {
throw const FormatException('attachment name or mime too long');
}
// Vadný náhled zprávu nezabíjí — UI ukáže placeholder a ostrá fotka
// se stáhne z blobu. base64Decode v build() by shodil celou bublinu.
var thumb = wire['th'] as String?;
@@ -118,8 +128,8 @@ class AttachmentMeta {
nonceB64: wire['n'] as String,
digestB64: wire['d'] as String,
size: size,
mime: wire['mime'] as String,
name: wire['name'] as String?,
mime: mime,
name: name,
width: wire['w'] as int?,
height: wire['h'] as int?,
thumbB64: thumb,
+13 -3
View File
@@ -146,6 +146,9 @@ class LocalIdentity {
/// kontaktu poslat první zpráva. Obě pole jsou náhodná — s identitou je
/// spojuje jen sám uživatel tím, že je sdílí ve svém odkazu.
class PublicIdentity {
/// Nejvyšší délka jména z odkazu/QR — delší odkaz je vadný vstup.
static const maxDisplayNameLength = 64;
PublicIdentity({
required this.displayName,
required this.identityKey,
@@ -214,7 +217,14 @@ class PublicIdentity {
as Map<String, dynamic>;
if (map['v'] != 2) return null;
final identityKey = base64Decode(map['ik'] as String);
if (identityKey.length != 33) return null;
// 33 B = typový bajt 0x05 + Curve25519; jiný prefix libsignal odmítne
// až při první zprávě, tady ho odmítneme hned.
if (identityKey.length != 33 || identityKey[0] != 0x05) return null;
final aid = map['aid'] as String?;
// Relay dává 32 hex znaků; starší testovací odkazy kratší — hex a strop stačí.
if (aid != null && !RegExp(r'^[0-9a-f]{8,64}$').hasMatch(aid)) return null;
final rawName = (map['n'] as String).trim();
if (rawName.length > maxDisplayNameLength) return null;
final da = map['da'] as String?;
Uint8List? deliveryAddress;
if (da != null) {
@@ -224,10 +234,10 @@ class PublicIdentity {
if (deliveryAddress.length != 32) return null;
}
return PublicIdentity(
displayName: map['n'] as String,
displayName: rawName,
identityKey: identityKey,
createdAt: DateTime.parse(map['ts'] as String),
accountId: map['aid'] as String?,
accountId: aid,
deliveryAddress: deliveryAddress,
);
} catch (_) {
@@ -55,6 +55,9 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
final storage = ref.read(storageProvider);
if (!storage.isUnlocked) return;
// Nátlaková relace: žádná síť. Messaging by se registroval skutečným
// identity klíčem (409 každých 30 s = důkaz pro pozorovatele sítě).
if (storage.isDecoyMode) return;
final service = ref.read(messagingServiceProvider);
if (service.isStarted) return;
+11 -3
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:screen_protector/screen_protector.dart';
import 'package:sepot/core/backup/backup_service.dart';
@@ -57,14 +58,21 @@ final typingIndicatorsProvider =
TypingIndicatorsNotifier.new);
/// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí
/// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní
/// screenshoty, tak ať se uživatel rozhodne sám.
/// obsahu v přepínači appek (iOS). Výchozí ZAPNUTO jako u Signalu — stejné
/// jako nativní default v MainActivity, takže přepínač už neukazuje
/// „vypnuto" u obrazovky, která chráněná je.
class ScreenSecurityNotifier extends AsyncNotifier<bool> {
static const key = 'screen_security_enabled';
// Debug build bez ochrany: QA na emulátoru potřebuje screenshoty.
static const vychozi = !kDebugMode;
@override
Future<bool> build() async {
final v = (await ref.read(storageProvider).getSetting<bool>(key)) ?? false;
final storage = ref.read(storageProvider);
// Před odemčením se nastavení přečíst nedá; nic neaplikovat (nativní
// default platí) a po unlocku se provider invaliduje.
if (!storage.isUnlocked) return vychozi;
final v = (await storage.getSetting<bool>(key)) ?? vychozi;
await _apply(v);
return v;
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none'
version: 1.2.0+23
version: 1.2.1+24
environment:
sdk: ^3.10.8
+20
View File
@@ -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
View File
@@ -4,7 +4,7 @@
# Build stage potřebuje: protoc (prost-build generuje z proto/sepot.proto),
# 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 \
protobuf-compiler git pkg-config build-essential cmake \
&& 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
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 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -r -s /usr/sbin/nologin sepot \
+1 -1
View File
@@ -1,6 +1,6 @@
# Tor hidden service pro sepot-relay — vlastní minimální image
# (žá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 \
&& mkdir -p /var/lib/tor \
&& chown -R tor:tor /var/lib/tor \
+13 -20
View File
@@ -274,15 +274,6 @@ dependencies = [
"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]]
name = "const-oid"
version = "0.9.6"
@@ -348,9 +339,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
@@ -572,11 +563,10 @@ dependencies = [
[[package]]
name = "event-listener"
version = "5.4.1"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
@@ -826,9 +816,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
dependencies = [
"atomic-waker",
"bytes",
@@ -2380,9 +2370,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.41"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
@@ -2404,9 +2394,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -2456,8 +2446,10 @@ dependencies = [
"dashmap",
"futures-util",
"hex",
"hmac",
"ipnet",
"jsonwebtoken",
"libc",
"libsignal-core",
"libsignal-protocol",
"prost 0.13.5",
@@ -2466,6 +2458,7 @@ dependencies = [
"sepot-proto",
"serde",
"serde_json",
"sha1",
"sqlx",
"tokio",
"tokio-util",
+2
View File
@@ -51,6 +51,8 @@ tower-http = { version = "0.7.0", features = ["timeout"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
jsonwebtoken = "9"
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
libc = "0.2"
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
+5 -16
View File
@@ -140,6 +140,8 @@ impl ApnsSender {
.header("apns-push-type", "background")
.header("apns-priority", "5")
.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}}"#)
.send()
.await;
@@ -149,7 +151,8 @@ impl ApnsSender {
Ok(r) if !r.status().is_success() => {
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() => {
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!(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í");
}
}
+32
View File
@@ -37,6 +37,12 @@ pub const ATTACHMENT_TTL_DAYS: i64 = 30;
/// Výchozí strop velikosti (šifrovaný blob). Přepis env `SEPOT_MAX_ATTACHMENT_BYTES`.
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ž
/// tohle se při úklidu mažou.
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 dir: PathBuf,
pub max_bytes: u64,
pub total_bytes: u64,
}
impl Config {
@@ -55,9 +62,14 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.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 {
dir: PathBuf::from(dir),
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.
let blob_id = {
let mut rng = rand::rng();
@@ -229,6 +256,10 @@ pub async fn download(
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(header::CONTENT_LENGTH, size)
.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))
.map_err(internal)
}
@@ -309,6 +340,7 @@ mod db_tests {
Config {
dir,
max_bytes: 1024,
total_bytes: 1 << 30,
},
trusted,
)
+4 -2
View File
@@ -133,7 +133,9 @@ impl FcmSender {
"message": {
"token": device_token,
"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;
@@ -171,7 +173,7 @@ impl FcmSender {
Ok(resp) if !resp.status().is_success() => {
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()),
_ => {}
}
}
+57 -4
View File
@@ -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()
.route("/v1/register", post(rest::register))
.layer(GovernorLayer::new(gov_register));
@@ -128,6 +140,8 @@ async fn main() -> anyhow::Result<()> {
let general_routes = Router::new()
.route("/v1/server-info", get(rest::server_info))
.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/identity", delete(rest::delete_identity))
.layer(GovernorLayer::new(gov_general));
@@ -152,16 +166,18 @@ async fn main() -> anyhow::Result<()> {
));
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/ready", get(rest::ready))
// WS má vlastní limity (conn cap per IP, auth deadline, frame rate)
.route("/v1/ws", get(ws::ws_handler))
.merge(rest_routes)
.merge(attachment_routes)
.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| {
// 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
@@ -171,11 +187,48 @@ async fn main() -> anyhow::Result<()> {
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(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.await?;
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ů");
}
+68 -35
View File
@@ -95,11 +95,29 @@ pub async fn send_wake(
match platform {
PLATFORM_UNIFIEDPUSH | PLATFORM_WEBPUSH => {
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)");
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 => {
// 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() => {
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).
/// Odmítnout hostitele resolvující na interní adresy (blind-SSRF ochrana).
///
/// ZNÁMÁ LIMITACE (DNS rebinding / TOCTOU): guard resolvuje host přes
/// `lookup_host` zde, ale samotný POST v `post_wake` resolvuje znovu. Mezi
/// kontrolou a requestem může útočníkem řízené DNS vrátit napřed veřejnou a
/// pak interní adresu, čímž guard obejde. Plný fix = resolvovat jednou a
/// připojit se přímo na ověřenou IP (reqwest `resolve()` / vlastní connector),
/// což je mimo rozsah tohoto vydání. Dopad je zúžen tím, že tělo je konstantní
/// `{"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;
};
/// Vrací `None` = odmítnuto; `Some(None)` = povoleno bez DNS (.onion);
/// `Some(Some((host, adresy)))` = povoleno, a tyhle adresy se mají pro POST
/// připnout (ochrana proti DNS rebindingu mezi kontrolou a požadavkem).
async fn endpoint_allowed(url: &str) -> Option<Option<(String, Vec<std::net::SocketAddr>)>> {
let parsed = reqwest::Url::parse(url).ok()?;
let host = parsed.host_str()?;
let is_onion = host.ends_with(".onion");
match parsed.scheme() {
"https" => {}
// .onion šifruje sám; relay stejně nemá tor egress, request tiše
// selže — ale schéma povolíme, ať je to připravené.
"http" if is_onion => return true,
_ => return false,
"http" if is_onion => return Some(None),
_ => return None,
}
// .onion přes https (neobvyklé) — neresolvuje se přes DNS, pustit.
if is_onion {
return true;
return Some(None);
}
// 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);
let resolved = tokio::net::lookup_host((host.as_str(), port)).await;
let Ok(addrs) = resolved else {
return false; // host nejde resolvovat → nepouštět
};
let mut saw_any = false;
let addrs = resolved.ok()?; // host nejde resolvovat → nepouštět
let mut pinned = Vec::new();
for a in addrs {
saw_any = true;
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 {
@@ -214,7 +227,14 @@ fn is_internal_ip(ip: &IpAddr) -> bool {
|| v6.is_unspecified()
|| (o[0] & 0xfe) == 0xfc // fc00::/7 ULA
|| (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)
// 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));
}
#[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]
async fn schema_a_ssrf_guard() {
// 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
assert!(!endpoint_allowed("https://127.0.0.1/abc").await);
assert!(!endpoint_allowed("https://localhost/abc").await);
assert!(endpoint_allowed("https://127.0.0.1/abc").await.is_none());
assert!(endpoint_allowed("https://localhost/abc").await.is_none());
// interní IP se odmítne
assert!(!endpoint_allowed("https://10.0.0.5:8000/x").await);
// http na .onion se pustí (schéma OK)
assert!(endpoint_allowed("http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion/x").await);
assert!(endpoint_allowed("https://10.0.0.5:8000/x").await.is_none());
// http na .onion se pustí (schéma OK), bez DNS → nic k připnutí
assert_eq!(
endpoint_allowed("http://cipjsctoeyf4fcrntvdvt22o2ansgr73qvthmewo246vuntd7cqmykyd.onion/x").await,
Some(None)
);
// nesmysl se odmítne
assert!(!endpoint_allowed("není url").await);
assert!(!endpoint_allowed("ftp://ntfy.sh/x").await);
assert!(endpoint_allowed("není url").await.is_none());
assert!(endpoint_allowed("ftp://ntfy.sh/x").await.is_none());
}
#[test]
+256 -17
View File
@@ -40,14 +40,60 @@ pub async fn health() -> Json<serde_json::Value> {
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> {
Json(serde_json::json!({
"frame_version": 1,
"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 ──────────────────────────────────────────────────
#[derive(Deserialize)]
@@ -83,11 +129,17 @@ const MAX_ONE_TIME_PREKEYS: usize = 100;
pub async fn register(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<RegisterIn>,
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
if body.one_time_prekeys.len() > MAX_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 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))?;
@@ -133,9 +185,39 @@ pub async fn register(
.execute(&mut *tx)
.await
.map_err(internal)?;
if inserted.rows_affected() == 0 {
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
}
// Identita už existuje = obnova ze 24 slov na novém telefonu (nebo
// 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(
"INSERT INTO devices (device_uuid, account_id, device_id, registration_id, delivery_address)
@@ -274,18 +356,25 @@ pub async fn fetch_bundle(
.await
.map_err(internal)?;
// Spotřebuj jeden one-time prekey (DELETE ... RETURNING).
let otp: Option<(i32, Vec<u8>)> = sqlx::query_as(
"DELETE FROM one_time_prekeys
WHERE (device_uuid, key_id) IN (
SELECT device_uuid, key_id FROM one_time_prekeys
WHERE device_uuid = $1 ORDER BY key_id LIMIT 1)
RETURNING key_id, public_key",
)
.bind(&device_uuid)
.fetch_optional(&state.pool)
.await
.map_err(internal)?;
// Spotřebuj jeden one-time prekey (DELETE ... RETURNING) —
// ale jen v mezích hodinového stropu na adresu: bundle bez OTP je
// povolený fallback, vyčerpaná zásoba cizím ne.
let otp: Option<(i32, Vec<u8>)> = if state.allow_otp_issue(&address) {
sqlx::query_as(
"DELETE FROM one_time_prekeys
WHERE (device_uuid, key_id) IN (
SELECT device_uuid, key_id FROM one_time_prekeys
WHERE device_uuid = $1 ORDER BY key_id
FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING key_id, public_key",
)
.bind(&device_uuid)
.fetch_optional(&state.pool)
.await
.map_err(internal)?
} else {
None
};
out.push(DeviceBundleOut {
device_id,
@@ -321,6 +410,7 @@ pub(crate) struct AuthedDevice {
pub account_id: Vec<u8>,
pub identity_key: Vec<u8>,
pub device_id: i32,
pub device_uuid: Vec<u8>,
}
/// 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 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());
}
@@ -376,9 +467,157 @@ pub(crate) async fn authenticate_device(
account_id,
identity_key,
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 ────────────────────────────────────────────────
/// Deregistrace účtu (GDPR čl. 17, store požadavek na smazání účtu).
+136 -5
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Instant;
use chrono::{DateTime, Duration, Utc};
use dashmap::DashMap;
@@ -39,6 +40,36 @@ const MAX_DELIVERY_ATTEMPTS: i16 = 8;
/// takže dva zmeškané cykly znamenají, že už nedýchá.
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.
///
/// 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>>,
/// TURN pro hovory (None = relay údaje nevydává).
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 {
@@ -147,9 +187,96 @@ impl AppState {
fcm: crate::fcm::FcmSender::from_env(),
apns: crate::apns::ApnsSender::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říjemce online, doručí hned. At-least-once: záznam z fronty maže
/// až ACK příjemce, ne odeslání.
@@ -192,7 +319,11 @@ impl AppState {
frame: Some(v1::server_frame::Frame::Deliver(env)),
};
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
// 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
@@ -201,10 +332,10 @@ impl AppState {
return Ok(v1::receipt::Status::DeliveredOnline);
}
}
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
if is_call {
// Offline, zatuchlé nebo plný kanál → zkusit push budíček.
if is_call && self.allow_call_wake(&dst) {
self.wake_call(&dst).await;
} else {
} else if self.allow_wake(&dst) {
self.wake(&dst).await;
}
Ok(v1::receipt::Status::StoredOffline)
@@ -223,7 +354,7 @@ impl AppState {
let deliver = v1::ServerFrame {
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
} else {
v1::receipt::Status::Dropped
+3 -2
View File
@@ -12,8 +12,9 @@ use sepot_proto::v1;
use sha1::Sha1;
/// Platnost údajů. Pokrývá i dlouhý hovor — coturn kontroluje expiraci jen
/// při alokaci a jejím obnovení, ne u běžícího hovoru.
pub const TTL_SECONDS: u32 = 24 * 3600;
/// při alokaci a jejím obnovení, ne u běžícího hovoru. Dvanáct hodin stačí
/// (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 {
secret: Vec<u8>,
+83 -24
View File
@@ -27,15 +27,28 @@ use tokio::time::Instant;
use crate::state::AppState;
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
const MAX_CONNS_PER_IP: u32 = 32;
/// Neautentizované spojení zavřít po tomto čase.
/// Neautentizované spojení zavřít po tomto čase od navázání — absolutně,
/// ne „od posledního rámce": Pong rámce před AUTH by jinak držely spojení
/// bez konce.
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.
/// 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_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.
///
/// 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 mut entry = state.ws_conns.entry(ip).or_insert(0);
if *entry >= MAX_CONNS_PER_IP {
if *entry >= state.max_conns_per_ip {
true
} else {
*entry += 1;
@@ -157,14 +170,18 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
// routing poznal živé spojení od zatuchlého.
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_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 {
// Před AUTH platí deadline — viset neautentizovaný nejde
// Před AUTH platí absolutní deadline — viset neautentizovaný nejde
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,
Err(_) => {
tracing::debug!("ws zavřeno: AUTH deadline");
@@ -195,17 +212,6 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
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 {
WsMessage::Binary(b) => b,
WsMessage::Close(_) => break,
@@ -216,8 +222,30 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
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 {
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 {
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
break;
@@ -303,14 +331,33 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
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);
}
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
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 {
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 {
let endpoint =
(!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() => {
let frame = match &state.turn {
Some(turn) => server_frame(v1::server_frame::Frame::TurnCredentials(
turn.credentials(chrono::Utc::now().timestamp()),
)),
Some(turn) => {
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"),
};
let _ = tx.send(frame).await;