Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8780486eae | |||
| 10ca35e352 | |||
| 45f169c20d | |||
| 5222c301ff | |||
| 830d6290ca | |||
| cbb09afbb1 | |||
| 59992af778 | |||
| 717aeed4ff | |||
| 2f6b542f24 | |||
| 5b237c82f2 | |||
| d2eb0d4f8c | |||
| ae127decd9 | |||
| ab0e35ddf4 |
@@ -6,6 +6,42 @@ verzování dle [ajtak standardu](#verzování).
|
|||||||
|
|
||||||
## [Nevydáno]
|
## [Nevydáno]
|
||||||
|
|
||||||
|
## [1.2.3] – 2026-10-23
|
||||||
|
|
||||||
|
### Opraveno (iOS, build 27)
|
||||||
|
- **Na iOS 27 appka hned po spuštění spadla.** Build 26 vznikl novým
|
||||||
|
Xcode (SDK iOS 27) a ten u appek bez scénového životního cyklu (UIScene)
|
||||||
|
start zastaví. Appka teď scény používá; Flutter běží od startu procesu,
|
||||||
|
takže příchozí hovor na pozadí dál zazvoní i bez otevřeného okna.
|
||||||
|
|
||||||
|
### Bezpečnost
|
||||||
|
- **24 slov jen po čerstvém ověření.** Zálohovací fráze se z odemčené appky
|
||||||
|
ukáže až po zadání PINu (nebo otisku/obličeje, je-li zapnutý). Totéž platí
|
||||||
|
pro export a obnovu zálohy, nastavení nátlakového PINu, prodloužení
|
||||||
|
automatického zámku, vypnutí ochrany obrazovky, zapnutí biometrie
|
||||||
|
a smazání účtu. Kdo vezme odemčený telefon, k frázi se nedostane.
|
||||||
|
V nátlakové relaci se fráze nezobrazí nikdy. Ověření PINu podléhá stejnému
|
||||||
|
zpomalení po chybách jako odemykání.
|
||||||
|
- **Cizí klíč se jménem kontaktu už nesplyne s kontaktem.** Zpráva od
|
||||||
|
neznámého klíče je „žádost o zprávu“ se jménem, které si odesílatel zvolil
|
||||||
|
sám, a s označením „(neznámý)“. Dokud ji nepřijmete, nejde odpovědět,
|
||||||
|
nechodí od vás potvrzení přečtení ani psaní, nezvoní hovory a notifikace
|
||||||
|
neukazuje jméno. Když se jméno plete s existujícím kontaktem (i přes
|
||||||
|
cyrilici, diakritiku nebo neviditelné znaky), ukáže se varování i v chatu
|
||||||
|
skutečného kontaktu.
|
||||||
|
- **Změna bezpečnostního kódu.** Když se pod účtem známého kontaktu objeví
|
||||||
|
jiný klíč, chat ukáže „Bezpečnostní kód kontaktu … se změnil“, ověření
|
||||||
|
kontaktu se zruší a odpověď neodejde na nový klíč. Nový klíč přijde jako
|
||||||
|
samostatná žádost o zprávu.
|
||||||
|
|
||||||
|
### Opraveno
|
||||||
|
- **Na iPhonu nechodily budíčky ani hovory na pozadí.** Appka relayi vůbec
|
||||||
|
neposlala token pro zprávy (APNs) ani pro hovory (VoIP): nastavení
|
||||||
|
notifikací na iOS skončilo chybou, ta zastavila celou registraci a appka
|
||||||
|
ji tiše spolkla. Registrace u Apple teď proběhne i bez povolených
|
||||||
|
notifikací, token pro hovory jde zvlášť a případné selhání ukáže seznam
|
||||||
|
chatů.
|
||||||
|
|
||||||
## [1.2.2] – 2026-10-11
|
## [1.2.2] – 2026-10-11
|
||||||
|
|
||||||
### Opraveno
|
### Opraveno
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
|
**Šepot** is an end-to-end encrypted messenger with anonymous, on-device identity. There is no phone number, e-mail, or password: an account is a keypair generated on the device, your ID is the fingerprint of your public key, and the only backup is a mnemonic phrase.
|
||||||
|
|
||||||
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, does not learn who sent a message, and does not log IP addresses. What it *does* see is documented honestly below.
|
The design goal is **metadata minimization with a public threat model** — never "zero metadata". The relay is deliberately dumb: it cannot read message content, envelopes carry no sender, and it neither stores a sender–recipient link nor logs IP addresses. What it *does* see while a connection is open is documented honestly below.
|
||||||
|
|
||||||
> 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).
|
> Status: 1.2.x. Android is on [Google Play](https://play.google.com/store/apps/details?id=cz.sepot.sepot); iOS is on the App Store outside the EU, with EU availability pending Apple's trader verification under the Digital Services Act. See [`CHANGELOG.md`](CHANGELOG.md).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Signal-grade cryptography** via the official Rust `libsignal`: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
- **End-to-end encryption** via the open-source Rust `libsignal` library: X3DH + post-quantum PQXDH (Kyber-1024) key agreement, Double Ratchet, and sealed sender.
|
||||||
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
|
- **Anonymous identity** — no phone/e-mail/password. Recovery through a 24-word mnemonic that encodes the identity key itself (not a KDF seed).
|
||||||
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
|
- **Encrypted at rest** — all local data lives in SQLCipher, unlocked by a PIN (Argon2id-derived key) or biometrics. Optional **duress PIN** opens a separate empty database.
|
||||||
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; the relay never learns a group exists.
|
- **Group chats** — up to 128 members, pairwise fan-out so every member gets full forward secrecy; groups are not a server-side object and the relay keeps no member list.
|
||||||
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
- **Attachments** — images and files are encrypted client-side (AES-256-GCM) and uploaded as opaque blobs with a TTL; image metadata (EXIF/GPS) is stripped on send.
|
||||||
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
- **Disappearing messages**, read receipts (off by default), voice messages, replies, reactions, edits, contact verification (safety numbers).
|
||||||
- **Push 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.
|
- **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.
|
||||||
@@ -29,9 +29,11 @@ The design goal is **metadata minimization with a public threat model** — neve
|
|||||||
SQLCipher at rest opaque attachment blobs
|
SQLCipher at rest opaque attachment blobs
|
||||||
```
|
```
|
||||||
|
|
||||||
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the relay routes by recipient delivery address without learning the sender. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
Every message is encrypted end-to-end with libsignal and wrapped in a sealed-sender envelope, so the envelope itself carries no sender and the relay routes it by the recipient's delivery address. Undelivered messages sit in a per-recipient queue with a fixed TTL and are hard-deleted on acknowledgement.
|
||||||
|
|
||||||
**What the relay can see:** the recipient's delivery address, message size, and timing. **What it cannot see:** message content, the sender's identity, group membership, or (with logging disabled as shipped) client IP addresses. The full model — including where the trust boundary sits — is in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md) and the crypto facade `app/lib/core/crypto/`.
|
**What the relay can see:** the recipient's delivery address, envelope size and timing, push tokens, and — while a connection is open — the client IP address. Envelopes are submitted over an authenticated WebSocket, so at runtime the relay also knows which connected device hands an envelope to which delivery address; it needs that to deliver. **What it does not see or keep:** message content, names, contacts or group membership. It stores no sender–recipient link and no IP addresses, and writes neither to its logs. Someone who took over the relay could start recording runtime metadata; they still could not read messages. See [`SECURITY.md`](SECURITY.md) and the crypto facade `app/lib/core/crypto/`.
|
||||||
|
|
||||||
|
**Where data lives:** messages, accounts and keys are stored only on servers in the EU (Hetzner, Germany). Message content never leaves the device unencrypted. Nothing leaves the EU except an anonymous wake-up push via Google (FCM) or Apple (APNs), which carries no message content or sender; the website runs behind Cloudflare.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
@@ -71,10 +73,10 @@ UI strings live in `app/lib/l10n/app_*.arb` (`cs`, `en`, `de`, `ru`, `it`) and a
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Cryptography is implemented by Signal's `libsignal`, not by hand-rolled primitives. If you find a vulnerability, please report it privately to **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).
|
Cryptography is implemented by the open-source `libsignal` library, not by hand-rolled primitives. Šepot is not affiliated with Signal Messenger LLC; Signal is a trademark of its owner. The project runs regular internal security audits and penetration tests (August, September and October 2026); no independent third-party audit has taken place yet. If you find a vulnerability, please report it privately to **security@sepot.cz** rather than opening a public issue — see [`SECURITY.md`](SECURITY.md) and [`/.well-known/security.txt`](https://sepot.cz/.well-known/security.txt).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
Šepot is licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](LICENSE).
|
||||||
|
|
||||||
The license includes an **AGPL §7 additional permission for app-store distribution**: Šepot may be distributed through App Store / Google Play provided the complete corresponding source stays available under the AGPL through a channel *without* those store restrictions (this repository 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).
|
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 `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).
|
||||||
|
|||||||
+13
-4
@@ -28,7 +28,16 @@ reproduce almost everything.
|
|||||||
|
|
||||||
## What the relay can and cannot see
|
## What the relay can and cannot see
|
||||||
|
|
||||||
The threat model is documented in the README: the relay sees routing tokens,
|
The threat model is documented in the README. In short: envelopes carry no
|
||||||
message sizes, timing and push tokens, never content or sender identity.
|
sender (sealed sender) and the relay never sees content. It does see routing
|
||||||
Reports that demonstrate the relay learning more than that are the ones we
|
tokens, envelope sizes, timing and push tokens, and while a connection is
|
||||||
care about most.
|
open the client IP and which authenticated device submits an envelope to
|
||||||
|
which delivery address. It stores no sender–recipient link and no IP
|
||||||
|
addresses. Reports that demonstrate the relay learning or keeping more than
|
||||||
|
that are the ones we care about most.
|
||||||
|
|
||||||
|
## Audits
|
||||||
|
|
||||||
|
We run regular internal security audits and penetration tests (August,
|
||||||
|
September and October 2026); fixes are listed in `CHANGELOG.md`. No
|
||||||
|
independent third-party audit has taken place yet.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ post_install do |installer|
|
|||||||
# staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje
|
# staví jen pro arm64 (Apple Silicon Mac). Bez tohoto Xcode linkuje
|
||||||
# i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64".
|
# i x86_64 slice, který dylib nemá → „Undefined symbols for x86_64".
|
||||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'x86_64'
|
||||||
|
# Xcode se SDK 27 odmítá pody s deployment targetem pod 15.0 (archive
|
||||||
|
# skončí chybou místo varování); appka sama minimum 15.0 má.
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; };
|
AA00000000000000000000A2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000A1 /* PrivacyInfo.xcprivacy */; };
|
||||||
AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; };
|
AB10000000000000000000B1 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = AB10000000000000000000C0 /* InfoPlist.strings */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000000000000000000B1 /* SceneDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
AA00000000000000000000B1 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -126,6 +128,7 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
AA00000000000000000000B1 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
@@ -285,6 +288,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
AA00000000000000000000B2 /* SceneDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import flutter_callkit_incoming
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||||
|
// Engine startuje s procesem, ne se scénou. VoIP push umí appku spustit na
|
||||||
|
// pozadí, aniž by iOS připojil scénu (a tím vytvořil FlutterViewController).
|
||||||
|
// CallKit plugin musí být zaregistrovaný dřív, než push dorazí, a Dart musí
|
||||||
|
// běžet, aby vyzvedl nabídku hovoru z relaye. SceneDelegate tenhle engine
|
||||||
|
// jen zobrazí.
|
||||||
|
let flutterEngine = FlutterEngine(name: "sepot")
|
||||||
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
// PushKit registry musí žít po celou dobu běhu appky, jinak VoIP pushe nechodí.
|
||||||
private var voipRegistry: PKPushRegistry?
|
private var voipRegistry: PKPushRegistry?
|
||||||
private var apnsChannel: FlutterMethodChannel?
|
private var apnsChannel: FlutterMethodChannel?
|
||||||
@@ -17,14 +23,16 @@ import flutter_callkit_incoming
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let controller = window?.rootViewController as! FlutterViewController
|
flutterEngine.run()
|
||||||
|
GeneratedPluginRegistrant.register(with: flutterEngine)
|
||||||
|
|
||||||
let channel = FlutterMethodChannel(
|
let channel = FlutterMethodChannel(
|
||||||
name: "cz.sepot.sepot/apns", binaryMessenger: controller.binaryMessenger)
|
name: "cz.sepot.sepot/apns", binaryMessenger: flutterEngine.binaryMessenger)
|
||||||
channel.setMethodCallHandler { [weak self] call, result in
|
channel.setMethodCallHandler { [weak self] call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "register":
|
case "register":
|
||||||
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
|
// Nativní APNs registrace (bez Firebase) — token přijde v callbacku
|
||||||
application.registerForRemoteNotifications()
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
result(nil)
|
result(nil)
|
||||||
case "getToken":
|
case "getToken":
|
||||||
result(self?.pendingToken)
|
result(self?.pendingToken)
|
||||||
@@ -34,8 +42,6 @@ import flutter_callkit_incoming
|
|||||||
}
|
}
|
||||||
apnsChannel = channel
|
apnsChannel = channel
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
|
|
||||||
// Hovory: VoIP push z relaye → CallKit.
|
// Hovory: VoIP push z relaye → CallKit.
|
||||||
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
let registry = PKPushRegistry(queue: DispatchQueue.main)
|
||||||
registry.delegate = self
|
registry.delegate = self
|
||||||
|
|||||||
@@ -48,8 +48,25 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<string>Main</string>
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
// SDK iOS 27 bez scénového životního cyklu appku nespustí (TN3187). Scéna jen
|
||||||
|
// ukáže engine, který AppDelegate spustil při startu procesu — viz komentář
|
||||||
|
// u `AppDelegate.flutterEngine`.
|
||||||
class SceneDelegate: FlutterSceneDelegate {
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
override func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
guard let windowScene = scene as? UIWindowScene,
|
||||||
|
let appDelegate = UIApplication.shared.delegate as? AppDelegate
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.rootViewController = FlutterViewController(
|
||||||
|
engine: appDelegate.flutterEngine, nibName: nil, bundle: nil)
|
||||||
|
self.window = window
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
|
||||||
|
// Super podle okna scény přihlásí engine k událostem scény pro pluginy.
|
||||||
|
// Musí běžet dřív, než okno dostane i delegát aplikace: našel-li by ho
|
||||||
|
// tam, Flutter by ho bral jako starý způsob startu a přesunul do nového okna.
|
||||||
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
|
|
||||||
|
// Pluginy, které okno hledají přes delegáta aplikace (local_auth_darwin
|
||||||
|
// u dialogu „biometrie není nastavená“), by ho jinak nenašly.
|
||||||
|
appDelegate.window = window
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,18 @@ class SepotCrypto {
|
|||||||
Future<bool> hasSession(ProtocolAddress remote) =>
|
Future<bool> hasSession(ProtocolAddress remote) =>
|
||||||
_stores.session.containsSession(remote);
|
_stores.session.containsSession(remote);
|
||||||
|
|
||||||
|
/// Identity klíč, pod kterým naposledy vznikla relace s [remote]
|
||||||
|
/// (zapisuje ho libsignal při X3DH i při příjmu PreKey zprávy).
|
||||||
|
/// Null = zatím žádný.
|
||||||
|
Future<Uint8List?> sessionIdentity(ProtocolAddress remote) async {
|
||||||
|
final key = await _stores.identity.getIdentity(remote);
|
||||||
|
return key == null ? null : Uint8List.fromList(key.serialize().toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zahodí relaci s [remote]; další zpráva založí novou z bundle.
|
||||||
|
Future<void> dropSession(ProtocolAddress remote) =>
|
||||||
|
_stores.session.deleteSession(remote);
|
||||||
|
|
||||||
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
/// X3DH/PQXDH: zpracuje bundle protistrany a založí session.
|
||||||
Future<void> processRemoteBundle(
|
Future<void> processRemoteBundle(
|
||||||
ProtocolAddress remote,
|
ProtocolAddress remote,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
|
||||||
|
/// Jméno kontaktu pro UI. U žádosti o zprávu s viditelnou značkou, že si
|
||||||
|
/// jméno zvolil odesílatel sám: „Máma (neznámý)“ se nesmí na pohled
|
||||||
|
/// shodovat s přijatou „Mámou“ v seznamu, hlavičce ani ve skupině.
|
||||||
|
String contactName(AppLocalizations l10n, Contact contact) {
|
||||||
|
if (contact.displayName.isEmpty) return l10n.commonUnknownContact;
|
||||||
|
return contact.accepted
|
||||||
|
? contact.displayName
|
||||||
|
: l10n.contactRequestName(contact.displayName);
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ class PushBootstrap {
|
|||||||
static Future<bool> registrationFailed() async =>
|
static Future<bool> registrationFailed() async =>
|
||||||
await _store.read(key: _registrationFailedKey) == '1';
|
await _store.read(key: _registrationFailedKey) == '1';
|
||||||
|
|
||||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
static Future<void> setRegistrationFailed(bool failed) => failed
|
||||||
? _store.write(key: _registrationFailedKey, value: '1')
|
? _store.write(key: _registrationFailedKey, value: '1')
|
||||||
: _store.delete(key: _registrationFailedKey);
|
: _store.delete(key: _registrationFailedKey);
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class PushBootstrap {
|
|||||||
await UnifiedPush.initialize(
|
await UnifiedPush.initialize(
|
||||||
onNewEndpoint: _onNewEndpoint,
|
onNewEndpoint: _onNewEndpoint,
|
||||||
onRegistrationFailed: (_, _) =>
|
onRegistrationFailed: (_, _) =>
|
||||||
unawaited(_setRegistrationFailed(true)),
|
unawaited(setRegistrationFailed(true)),
|
||||||
onUnregistered: _onUnregistered,
|
onUnregistered: _onUnregistered,
|
||||||
onMessage: _onMessage,
|
onMessage: _onMessage,
|
||||||
);
|
);
|
||||||
@@ -138,7 +138,7 @@ class PushBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
unawaited(setRegistrationFailed(false)); // registrace se povedla
|
||||||
final s = _service;
|
final s = _service;
|
||||||
if (s != null) {
|
if (s != null) {
|
||||||
s.handleNewEndpoint(endpoint.url);
|
s.handleNewEndpoint(endpoint.url);
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class PushNotifier {
|
|||||||
await _plugin.initialize(
|
await _plugin.initialize(
|
||||||
InitializationSettings(
|
InitializationSettings(
|
||||||
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||||
|
// Bez iOS nastavení plugin hodí ArgumentError a s ním padala celá
|
||||||
|
// APNs registrace — iPhony relayi neposlaly token vůbec. O oprávnění
|
||||||
|
// se žádá až v [requestPermission], ne při první notifikaci.
|
||||||
|
iOS: const DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
requestSoundPermission: false,
|
||||||
|
),
|
||||||
linux: LinuxInitializationSettings(
|
linux: LinuxInitializationSettings(
|
||||||
defaultActionName: l10n.pushActionOpen,
|
defaultActionName: l10n.pushActionOpen,
|
||||||
),
|
),
|
||||||
@@ -48,17 +56,23 @@ class PushNotifier {
|
|||||||
priority: Priority.high,
|
priority: Priority.high,
|
||||||
category: AndroidNotificationCategory.message,
|
category: AndroidNotificationCategory.message,
|
||||||
),
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
linux: const LinuxNotificationDetails(),
|
linux: const LinuxNotificationDetails(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android 13+ runtime permission — volat při zapnutí pushe v nastavení.
|
/// Oprávnění k viditelným notifikacím (Android 13+, iOS) — volat při
|
||||||
|
/// zapnutí pushe. Tichý budíček na iOS ho nepotřebuje.
|
||||||
static Future<void> requestPermission() async {
|
static Future<void> requestPermission() async {
|
||||||
await _ensureInit();
|
await _ensureInit();
|
||||||
await _plugin
|
await _plugin
|
||||||
.resolvePlatformSpecificImplementation<
|
.resolvePlatformSpecificImplementation<
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
?.requestNotificationsPermission();
|
?.requestNotificationsPermission();
|
||||||
|
await _plugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
/// Notifikace o přijaté zprávě, když appka běží, ale není v popředí.
|
||||||
@@ -75,6 +89,19 @@ class PushNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zpráva od neznámého odesílatele (žádost o zprávu): bez jména, které
|
||||||
|
/// si zvolil sám.
|
||||||
|
static Future<void> showMessageRequestNotification() async {
|
||||||
|
await _ensureInit();
|
||||||
|
final l10n = l10nFor(await LocaleNotifier.readStored());
|
||||||
|
await _plugin.show(
|
||||||
|
_notificationId,
|
||||||
|
l10n.commonAppName,
|
||||||
|
l10n.pushMessageRequest,
|
||||||
|
_details(l10n),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
/// Generická notifikace bez obsahu. Klepnutí otevře appku (unlock →
|
||||||
/// WS připojení → fronta se vyleje normální cestou).
|
/// WS připojení → fronta se vyleje normální cestou).
|
||||||
static Future<void> showWakeNotification() async {
|
static Future<void> showWakeNotification() async {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||||
import 'package:sepot/core/calls/call_ringer.dart';
|
import 'package:sepot/core/calls/call_ringer.dart';
|
||||||
import 'package:sepot/core/push/apns_ios.dart';
|
import 'package:sepot/core/push/apns_ios.dart';
|
||||||
import 'package:sepot/core/push/push_bootstrap.dart';
|
import 'package:sepot/core/push/push_bootstrap.dart';
|
||||||
@@ -35,10 +36,27 @@ import 'package:sepot/core/services/messaging_service.dart';
|
|||||||
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||||
import 'package:unifiedpush/unifiedpush.dart';
|
import 'package:unifiedpush/unifiedpush.dart';
|
||||||
|
|
||||||
|
/// Nativní strana iOS pushů. Oddělená od [PushService], aby šlo pořadí
|
||||||
|
/// kroků a skládání registrace ověřit testem bez iPhonu.
|
||||||
|
class IosPushPlatform {
|
||||||
|
const IosPushPlatform();
|
||||||
|
|
||||||
|
bool get isIOS => Platform.isIOS;
|
||||||
|
Future<void> registerApns() => ApnsBridge.register();
|
||||||
|
Future<String?> apnsToken() => ApnsBridge.currentToken();
|
||||||
|
Future<String?> voipToken() => CallRinger.voipToken();
|
||||||
|
Future<void> requestPermission() => PushNotifier.requestPermission();
|
||||||
|
Future<void> writeMode(String mode) => PushBootstrap.writeMode(mode);
|
||||||
|
Future<void> setRegistrationFailed(bool failed) =>
|
||||||
|
PushBootstrap.setRegistrationFailed(failed);
|
||||||
|
}
|
||||||
|
|
||||||
class PushService {
|
class PushService {
|
||||||
PushService(this._messaging);
|
PushService(this._messaging, {IosPushPlatform ios = const IosPushPlatform()})
|
||||||
|
: _ios = ios;
|
||||||
|
|
||||||
final MessagingService _messaging;
|
final MessagingService _messaging;
|
||||||
|
final IosPushPlatform _ios;
|
||||||
StreamSubscription<String>? _fcmTokenSub;
|
StreamSubscription<String>? _fcmTokenSub;
|
||||||
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
StreamSubscription<RemoteMessage>? _fcmMessageSub;
|
||||||
|
|
||||||
@@ -94,34 +112,66 @@ class PushService {
|
|||||||
|
|
||||||
String? _apnsToken;
|
String? _apnsToken;
|
||||||
|
|
||||||
/// APNs token pro zprávy + VoIP token pro hovory v jedné registraci
|
/// Registrace iOS pushů pro relay: APNs token pro zprávy + VoIP token pro
|
||||||
/// (relay drží obojí u zařízení).
|
/// hovory (relay drží obojí u zařízení). VoIP token jde i sám, s prázdným
|
||||||
Future<void> _registerApnsToken(String token) async {
|
/// endpointem — PushKit ho vydá nezávisle na APNs a hovory nesmí čekat,
|
||||||
_apnsToken = token;
|
/// až Apple vydá i token pro zprávy. Null = zatím není co poslat.
|
||||||
final voip = await CallRinger.voipToken();
|
@visibleForTesting
|
||||||
final reg = _registration(
|
static pb.RegisterPush? apnsRegistration({
|
||||||
pb.RegisterPush_Platform.PLATFORM_APNS,
|
String? apnsToken,
|
||||||
Uint8List.fromList(utf8.encode(token)),
|
String? voipToken,
|
||||||
|
}) {
|
||||||
|
if (apnsToken == null && voipToken == null) return null;
|
||||||
|
final reg = pb.RegisterPush(
|
||||||
|
platform: pb.RegisterPush_Platform.PLATFORM_APNS,
|
||||||
|
endpoint: apnsToken == null
|
||||||
|
? Uint8List(0)
|
||||||
|
: Uint8List.fromList(utf8.encode(apnsToken)),
|
||||||
);
|
);
|
||||||
if (voip != null) reg.voipToken = utf8.encode(voip);
|
if (voipToken != null) reg.voipToken = utf8.encode(voipToken);
|
||||||
_messaging.registerPush(reg);
|
return reg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PushKit vydal (nový) VoIP token — přeregistrovat, pokud už APNs běží.
|
Future<void> _sendApnsRegistration() async {
|
||||||
Future<void> refreshVoipToken() async {
|
final reg = apnsRegistration(
|
||||||
final token = _apnsToken;
|
apnsToken: _apnsToken,
|
||||||
if (token != null) await _registerApnsToken(token);
|
voipToken: await _ios.voipToken(),
|
||||||
|
);
|
||||||
|
if (reg != null) _messaging.registerPush(reg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _registerApnsToken(String token) async {
|
||||||
|
_apnsToken = token;
|
||||||
|
await _sendApnsRegistration();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PushKit vydal (nový) VoIP token — poslat ho relayi, i když APNs token
|
||||||
|
/// pro zprávy ještě nedorazil.
|
||||||
|
Future<void> refreshVoipToken() => _sendApnsRegistration();
|
||||||
|
|
||||||
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
/// iOS nativní push (APNs) — zapne registraci u Apple; token přijde
|
||||||
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
/// asynchronně a sám se pošle relayi. Bez Googlu.
|
||||||
|
///
|
||||||
|
/// Registrace u Apple jde PŘED žádostí o oprávnění: tichý budíček
|
||||||
|
/// oprávnění nepotřebuje, takže odmítnutý (nebo selhaný) dialog nesmí
|
||||||
|
/// zastavit doručování zpráv.
|
||||||
Future<void> enableApns() async {
|
Future<void> enableApns() async {
|
||||||
await init();
|
await init();
|
||||||
await PushNotifier.requestPermission();
|
await _ios.registerApns();
|
||||||
await ApnsBridge.register();
|
await _ios.writeMode('apns');
|
||||||
final existing = await ApnsBridge.currentToken();
|
final existing = await _ios.apnsToken();
|
||||||
if (existing != null) _registerApnsToken(existing);
|
if (existing != null) {
|
||||||
await PushBootstrap.writeMode('apns');
|
await _registerApnsToken(existing);
|
||||||
|
} else {
|
||||||
|
// VoIP token mohl přijít dřív než APNs — hovory ať zvoní hned.
|
||||||
|
await _sendApnsRegistration();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await _ios.requestPermission();
|
||||||
|
} catch (e, s) {
|
||||||
|
// Bez oprávnění chodí budíčky dál, jen bez viditelné notifikace.
|
||||||
|
debugPrint('Oprávnění k notifikacím selhalo: $e\n$s');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
/// Zapne push: použije uloženého/výchozího distributora a zaregistruje se.
|
||||||
@@ -245,10 +295,15 @@ class PushService {
|
|||||||
Future<void> restoreMode() async {
|
Future<void> restoreMode() async {
|
||||||
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
// iOS: jediná smysluplná cesta je APNs (UnifiedPush je Android-only).
|
||||||
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
// Zapínáme ho automaticky — pro iOS messenger jsou notifikace základ.
|
||||||
if (Platform.isIOS) {
|
if (_ios.isIOS) {
|
||||||
try {
|
try {
|
||||||
await enableApns();
|
await enableApns();
|
||||||
} catch (_) {
|
await _ios.setRegistrationFailed(false);
|
||||||
|
} catch (e, s) {
|
||||||
|
// Dřív se chyba spolkla a iPhone tiše zůstal bez budíčků. Teď ji
|
||||||
|
// uvidí stav pushů v chat listu (PushHealth.failed).
|
||||||
|
debugPrint('APNs registrace selhala: $e\n$s');
|
||||||
|
await _ios.setRegistrationFailed(true);
|
||||||
await init();
|
await init();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/// Rozpoznání jmen, která se pletou s existujícím kontaktem.
|
||||||
|
///
|
||||||
|
/// Jméno v Šepotu si každý volí sám, takže „Máma“ od cizího klíče je
|
||||||
|
/// nejlevnější phishing. Porovnává se „kostra“ jména: bez velikosti
|
||||||
|
/// písmen, diakritiky, mezer, interpunkce a neviditelných znaků, s cyrilicí
|
||||||
|
/// a řečtinou převedenou na latinku. „MAMA“, „Мáма“ (cyrilské М) i
|
||||||
|
/// „Máma“ s neviditelnou mezerou uprostřed tak padnou na totéž jako „Máma“.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:sepot/core/utils/text_fold.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
|
||||||
|
// Znaky, které na displeji vypadají jako latinka. Jen ty, co se v malých
|
||||||
|
// písmenech opravdu pletou; ne celý Unicode confusables seznam — falešná
|
||||||
|
// shoda tu nic nerozbije, jen přidá varování navíc.
|
||||||
|
const _confusables = {
|
||||||
|
'а': 'a', 'в': 'b', 'е': 'e', 'ё': 'e', 'к': 'k', 'м': 'm', 'н': 'h',
|
||||||
|
'о': 'o', 'р': 'p', 'с': 'c', 'т': 't', 'у': 'y', 'х': 'x', 'і': 'l',
|
||||||
|
'ї': 'l', 'ј': 'j', 'ѕ': 's', 'ԁ': 'd', 'ɡ': 'g', 'α': 'a', 'β': 'b',
|
||||||
|
'ε': 'e', 'ι': 'l', 'κ': 'k', 'ν': 'v', 'ο': 'o', 'ρ': 'p', 'τ': 't',
|
||||||
|
'υ': 'u', 'χ': 'x', 'ү': 'y', '0': 'o', '1': 'l', 'i': 'l', '|': 'l',
|
||||||
|
// Latinská diakritika mimo češtinu a slovenštinu (tu skládá už fold).
|
||||||
|
'à': 'a', 'â': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ą': 'a', 'ç': 'c',
|
||||||
|
'ć': 'c', 'è': 'e', 'ê': 'e', 'ē': 'e', 'ę': 'e', 'ì': 'l', 'î': 'l',
|
||||||
|
'ï': 'l', 'ł': 'l', 'ñ': 'n', 'ń': 'n', 'ò': 'o', 'õ': 'o', 'ø': 'o',
|
||||||
|
'ő': 'o', 'ś': 's', 'ù': 'u', 'û': 'u', 'ű': 'u', 'ÿ': 'y', 'ź': 'z',
|
||||||
|
'ż': 'z',
|
||||||
|
};
|
||||||
|
|
||||||
|
final _invisible = RegExp(
|
||||||
|
'[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E'
|
||||||
|
'\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF]');
|
||||||
|
final _combining = RegExp('[\u0300-\u036F]');
|
||||||
|
final _separators = RegExp(r'[\s\p{P}\p{S}]', unicode: true);
|
||||||
|
|
||||||
|
/// Kostra jména pro porovnání. Prázdný výsledek = jméno nic nenese.
|
||||||
|
String nameSkeleton(String name) {
|
||||||
|
final folded = fold(name
|
||||||
|
.replaceAll(_invisible, '')
|
||||||
|
.replaceAll(_combining, '')
|
||||||
|
.replaceAll(_separators, ''));
|
||||||
|
final out = StringBuffer();
|
||||||
|
for (final ch in folded.split('')) {
|
||||||
|
out.write(_confusables[ch] ?? ch);
|
||||||
|
}
|
||||||
|
// „rn“ se v bezpatkovém písmu čte jako „m“.
|
||||||
|
return out.toString().replaceAll('rn', 'm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Přijatý kontakt, se kterým se [name] plete (null = nic podobného).
|
||||||
|
/// [exceptId] = sám odesílatel, ten se se sebou neporovnává.
|
||||||
|
Contact? lookalikeContact(
|
||||||
|
String name,
|
||||||
|
Iterable<Contact> contacts, {
|
||||||
|
String? exceptId,
|
||||||
|
}) {
|
||||||
|
final skeleton = nameSkeleton(name);
|
||||||
|
if (skeleton.isEmpty) return null;
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (!c.accepted || c.id == exceptId) continue;
|
||||||
|
if (nameSkeleton(c.displayName) == skeleton) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import 'package:sepot/core/errors.dart';
|
|||||||
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||||
import 'package:sepot/core/network/relay_client.dart';
|
import 'package:sepot/core/network/relay_client.dart';
|
||||||
import 'package:sepot/core/network/relay_transport.dart';
|
import 'package:sepot/core/network/relay_transport.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
import 'package:sepot/core/services/group_admin_policy.dart';
|
import 'package:sepot/core/services/group_admin_policy.dart';
|
||||||
import 'package:sepot/core/services/image_processor.dart';
|
import 'package:sepot/core/services/image_processor.dart';
|
||||||
import 'package:sepot/core/services/typing_tracker.dart';
|
import 'package:sepot/core/services/typing_tracker.dart';
|
||||||
@@ -218,7 +219,7 @@ class MessagingService {
|
|||||||
final targets = contacts.where((c) =>
|
final targets = contacts.where((c) =>
|
||||||
!c.blocked &&
|
!c.blocked &&
|
||||||
(group == null
|
(group == null
|
||||||
? c.id == convId
|
? c.id == convId && c.accepted
|
||||||
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
: group.memberIds.contains(c.id) && c.id != _identity?.id));
|
||||||
for (final c in targets) {
|
for (final c in targets) {
|
||||||
final until = _typingBackoffUntil[c.id];
|
final until = _typingBackoffUntil[c.id];
|
||||||
@@ -993,6 +994,8 @@ class MessagingService {
|
|||||||
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
Future<void> sendReadReceipts(Contact contact, List<String> messageIds,
|
||||||
{String? gid}) async {
|
{String? gid}) async {
|
||||||
if (messageIds.isEmpty) return;
|
if (messageIds.isEmpty) return;
|
||||||
|
// Neznámý odesílatel se nedozví, že jsem jeho zprávu otevřel.
|
||||||
|
if (gid == null && !contact.accepted) return;
|
||||||
final enabled =
|
final enabled =
|
||||||
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
(await storage.getSetting<bool>('read_receipts_enabled')) ?? false;
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
@@ -1948,19 +1951,26 @@ class MessagingService {
|
|||||||
verified: known.verified,
|
verified: known.verified,
|
||||||
notes: known.notes,
|
notes: known.notes,
|
||||||
blocked: known.blocked,
|
blocked: known.blocked,
|
||||||
|
avatar: known.avatar,
|
||||||
|
accepted: known.accepted,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final name = (e['n'] as String?)?.trim();
|
final name = (e['n'] as String?)?.trim();
|
||||||
await storage.saveContact(Contact.fromPublicIdentity(PublicIdentity(
|
// Člena skupiny jsem si nepřidal já: jméno mu dal on sám (nebo ten,
|
||||||
|
// kdo roster poslal). Jako u příchozí zprávy je to žádost.
|
||||||
|
await storage.saveContact(Contact.fromPublicIdentity(
|
||||||
|
PublicIdentity(
|
||||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||||
displayName: (name == null || name.isEmpty) ? '' : name,
|
displayName: (name == null || name.isEmpty) ? '' : clipName(name),
|
||||||
identityKey: base64Decode(e['ik'] as String),
|
identityKey: base64Decode(e['ik'] as String),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
accountId: aid,
|
accountId: aid,
|
||||||
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
deliveryAddress: daB64 == null ? null : base64Decode(daB64),
|
||||||
)));
|
),
|
||||||
|
accepted: false,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2215,12 +2225,14 @@ class MessagingService {
|
|||||||
// načte, zašifruje a uloží bez souběhu. Odeslání po síti už zámek
|
// 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í.
|
// nedrží — čekání na receipt by blokovalo všechny ostatní.
|
||||||
final sealed = await _serial<Uint8List?>(() async {
|
final sealed = await _serial<Uint8List?>(() async {
|
||||||
|
await _dropForeignSession(crypto, peerAddr, contact);
|
||||||
// První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět
|
// 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í).
|
// s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní).
|
||||||
if (!await crypto.hasSession(peerAddr)) {
|
if (!await crypto.hasSession(peerAddr)) {
|
||||||
if (ephemeral) return null;
|
if (ephemeral) return null;
|
||||||
final bundle = await relay.fetchBundle(peerDa);
|
final bundle = await relay.fetchBundle(peerDa);
|
||||||
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
if (!_listEquals(bundle.identityKey, peer.identityKey)) {
|
||||||
|
await _noteIdentityChanged(contact);
|
||||||
throw const IdentityKeyMismatchError();
|
throw const IdentityKeyMismatchError();
|
||||||
}
|
}
|
||||||
await crypto.processRemoteBundle(peerAddr, bundle);
|
await crypto.processRemoteBundle(peerAddr, bundle);
|
||||||
@@ -2245,6 +2257,29 @@ class MessagingService {
|
|||||||
return receipt.status;
|
return receipt.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// libsignal 5.0.9 nad prázdným paměťovým storem identit důvěřuje
|
||||||
|
/// čemukoli (TOFU bez paměti): příchozí PreKey zpráva s cizím klíčem pod
|
||||||
|
/// stejnou adresou relaci tiše přepíše a další odpověď by šla jemu.
|
||||||
|
/// Klíč relace proto porovnáváme s klíčem kontaktu sami; nesedí-li,
|
||||||
|
/// relace se zahodí (nová vznikne z bundle, který se ověřuje výše)
|
||||||
|
/// a kontakt dostane varování.
|
||||||
|
Future<void> _dropForeignSession(
|
||||||
|
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) async {
|
||||||
|
if (!await crypto.hasSession(peerAddr)) return;
|
||||||
|
final bound = await crypto.sessionIdentity(peerAddr);
|
||||||
|
if (bound == null ||
|
||||||
|
_listEquals(bound, contact.publicIdentity.identityKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await crypto.dropSession(peerAddr);
|
||||||
|
await _noteIdentityChanged(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugDropForeignSession(
|
||||||
|
SepotCrypto crypto, ProtocolAddress peerAddr, Contact contact) =>
|
||||||
|
_dropForeignSession(crypto, peerAddr, contact);
|
||||||
|
|
||||||
// ── Příjem ──────────────────────────────────────────────────────────
|
// ── Příjem ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
/// Dodávky se zpracovávají postupně (viz [_serial]) a evidují jako
|
||||||
@@ -2279,7 +2314,17 @@ class MessagingService {
|
|||||||
// (může jít o dočasný stav — např. cert refresh).
|
// (může jít o dočasný stav — např. cert refresh).
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await _processOpened(opened, envelope.msgUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> debugProcessOpened(
|
||||||
|
SepotSealedMessage opened, List<int> msgUuid) =>
|
||||||
|
_processOpened(opened, msgUuid);
|
||||||
|
|
||||||
|
/// Zpracuje už dešifrovanou obálku: kontakt, typ obsahu, uložení, ACK.
|
||||||
|
Future<void> _processOpened(
|
||||||
|
SepotSealedMessage opened, List<int> msgUuid) async {
|
||||||
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
// Od tohohle bodu ratchet postoupil — obálku na relayi už NIKDY znovu
|
||||||
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
// nedešifrujeme (duplicitní decrypt libsignal odmítne). Vadný OBSAH se
|
||||||
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
// proto ACKuje a zahazuje: redelivery nemůže nic spravit, jen by
|
||||||
@@ -2292,7 +2337,7 @@ class MessagingService {
|
|||||||
if (content['v'] != 1 || !known.contains(type)) {
|
if (content['v'] != 1 || !known.contains(type)) {
|
||||||
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
// Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný
|
||||||
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
// a po upgradu ho stejně nejde znovu dešifrovat — ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2302,15 +2347,19 @@ class MessagingService {
|
|||||||
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
// Blokovaný kontakt: zahoď cokoliv od něj (ACK, ať se nefrontuje),
|
||||||
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
// nic neukládej ani nezobrazuj a neposílej doručenku.
|
||||||
if (contact.blocked) {
|
if (contact.blocked) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
// Hovory jen 1:1; skupinový gid tu nemá co dělat.
|
||||||
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
// Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru.
|
||||||
|
// Žádost o zprávu nezvoní: cizí klíč by jinak rozezvonil telefon
|
||||||
|
// s vlastním zvoleným jménem („Máma volá“).
|
||||||
if (type == 'call') {
|
if (type == 'call') {
|
||||||
if (content['gid'] == null) onCallSignal?.call(contact, content);
|
if (content['gid'] == null && contact.accepted) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
onCallSignal?.call(contact, content);
|
||||||
|
}
|
||||||
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2320,7 +2369,7 @@ class MessagingService {
|
|||||||
// nepomůže).
|
// nepomůže).
|
||||||
if (_groupControlTypes.contains(type)) {
|
if (_groupControlTypes.contains(type)) {
|
||||||
await _handleGroupControl(senderFp, type, content);
|
await _handleGroupControl(senderFp, type, content);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2330,7 +2379,7 @@ class MessagingService {
|
|||||||
final group =
|
final group =
|
||||||
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
gid == null ? null : await _groupForIncoming(gid, senderFp);
|
||||||
if (gid != null && group == null) {
|
if (gid != null && group == null) {
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (group != null && _noteCapability(group, senderFp, content)) {
|
if (group != null && _noteCapability(group, senderFp, content)) {
|
||||||
@@ -2345,7 +2394,7 @@ class MessagingService {
|
|||||||
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
final fresh = DateTime.now().difference(wireSentAt(content['ts'])) <
|
||||||
TypingTracker.showFor;
|
TypingTracker.showFor;
|
||||||
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
if (fresh && await _typingEnabled()) typing.mark(convId, senderFp);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2380,7 +2429,7 @@ class MessagingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2389,7 +2438,7 @@ class MessagingService {
|
|||||||
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
// novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže).
|
||||||
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') {
|
||||||
await _applyIncomingControl(convId, type!, content, opened);
|
await _applyIncomingControl(convId, type!, content, opened);
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2407,7 +2456,7 @@ class MessagingService {
|
|||||||
if (raw is! String) {
|
if (raw is! String) {
|
||||||
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
// Trvale vadná obálka (chybí/netypové 'txt') — retry z fronty
|
||||||
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
// nepomůže, ACKnout a zahodit stejně jako u vadných příloh.
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
// Strop délky z drátu: obálka unese 256 KiB a konverzace je jeden
|
||||||
@@ -2441,7 +2490,7 @@ class MessagingService {
|
|||||||
}
|
}
|
||||||
} on Object {
|
} on Object {
|
||||||
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
// Trvale vadná metadata — retry z fronty nepomůže, ACKnout
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
message = Message(
|
message = Message(
|
||||||
@@ -2466,7 +2515,7 @@ class MessagingService {
|
|||||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
||||||
// neúspěchu má UI retry, ACK na něj nečeká.)
|
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||||
_relay!.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
|
|
||||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
||||||
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||||
@@ -2499,11 +2548,11 @@ class MessagingService {
|
|||||||
} on FormatException {
|
} on FormatException {
|
||||||
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
// Malformovaný obsah (ne-UTF-8/ne-JSON plaintext, vadná base64
|
||||||
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
// v profilu či rosteru) — trvale vadné, ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
} on TypeError {
|
} on TypeError {
|
||||||
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
// Pole se špatným typem (txt/ttl/id/re/p jako číslo apod.) —
|
||||||
// trvale vadné, ACK a zahodit.
|
// trvale vadné, ACK a zahodit.
|
||||||
_relay?.ack(envelope.msgUuid);
|
_relay?.ack(msgUuid);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
// Přechodné selhání zpracování (storage apod.): NEACKovat — další
|
||||||
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
// pokus po reconnectu za nic nedá, i když duplicitní decrypt
|
||||||
@@ -2733,6 +2782,7 @@ class MessagingService {
|
|||||||
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
// Bez tohohle se zablokovaný kontakt při změně adresy odblokoval.
|
||||||
blocked: c.blocked,
|
blocked: c.blocked,
|
||||||
avatar: avatarChanged ? avatar : c.avatar,
|
avatar: avatarChanged ? avatar : c.avatar,
|
||||||
|
accepted: c.accepted,
|
||||||
);
|
);
|
||||||
await storage.saveContact(updated);
|
await storage.saveContact(updated);
|
||||||
if (avatarChanged) {
|
if (avatarChanged) {
|
||||||
@@ -2743,7 +2793,9 @@ class MessagingService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neznámý odesílatel → nový (neověřený) kontakt
|
// Neznámý klíč = nový kontakt, NIKDY splynutí s existujícím. Identita
|
||||||
|
// je klíč: jméno ani fotka, které si odesílatel zvolil, neříkají nic
|
||||||
|
// o tom, kdo to je. Proto žádost o zprávu, ne kontakt.
|
||||||
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
// Prázdné jméno = UI si doplní lokalizovaný fallback
|
||||||
final name = (profile?['n'] as String?)?.trim();
|
final name = (profile?['n'] as String?)?.trim();
|
||||||
final identity = PublicIdentity(
|
final identity = PublicIdentity(
|
||||||
@@ -2753,12 +2805,62 @@ class MessagingService {
|
|||||||
accountId: opened.senderName,
|
accountId: opened.senderName,
|
||||||
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
deliveryAddress: profileDa == null ? null : base64Decode(profileDa),
|
||||||
);
|
);
|
||||||
final contact = Contact.fromPublicIdentity(identity)..avatar = avatar;
|
final contact = Contact.fromPublicIdentity(identity, accepted: false)
|
||||||
|
..avatar = avatar;
|
||||||
await storage.saveContact(contact);
|
await storage.saveContact(contact);
|
||||||
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
if (avatar != null) unawaited(_downloadAvatar(avatar));
|
||||||
|
|
||||||
|
// Účet na relayi patří jednomu klíči navždy (identities.identity_key je
|
||||||
|
// unikátní). Stejný účet s jiným klíčem = relay lže nebo byl převzat:
|
||||||
|
// dosavadní kontakt ztrácí ověření a dostane varování.
|
||||||
|
final previous = contacts
|
||||||
|
.where((c) => c.publicIdentity.accountId == opened.senderName)
|
||||||
|
.firstOrNull;
|
||||||
|
if (previous != null) await _noteIdentityChanged(previous);
|
||||||
|
|
||||||
|
final lookalike =
|
||||||
|
lookalikeContact(identity.displayName, contacts, exceptId: contact.id);
|
||||||
|
if (lookalike != null && lookalike.id != previous?.id) {
|
||||||
|
await _noteLookalike(lookalike, identity.displayName);
|
||||||
|
}
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bezpečnostní kód kontaktu se změnil: zrušit ověření a dát to najevo
|
||||||
|
/// v jeho konverzaci. Opakovaný výskyt za sebou nepřidává další řádky.
|
||||||
|
Future<void> _noteIdentityChanged(Contact contact) async {
|
||||||
|
if (contact.verified) {
|
||||||
|
contact.verified = false;
|
||||||
|
await storage.saveContact(contact);
|
||||||
|
}
|
||||||
|
await _addNotice(contact, MessageNotice.identityChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Někdo cizí se jmenuje skoro stejně jako [contact]. Připomínka do jeho
|
||||||
|
/// konverzace (jen když existuje — nezakládat chat kvůli útočníkovi).
|
||||||
|
Future<void> _noteLookalike(Contact contact, String claimedName) async {
|
||||||
|
if (await storage.loadConversation(contact.id) == null) return;
|
||||||
|
await _addNotice(contact, MessageNotice.lookalikeRequest,
|
||||||
|
content: claimedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNotice(Contact contact, MessageNotice notice,
|
||||||
|
{String? content}) async {
|
||||||
|
final me = _identity;
|
||||||
|
if (me == null) return;
|
||||||
|
final last = (await storage.loadConversation(contact.id))?.lastMessage;
|
||||||
|
if (last?.notice == notice && last?.content == content) return;
|
||||||
|
await storage.addMessage(
|
||||||
|
contact.id,
|
||||||
|
Message.notice(
|
||||||
|
contactId: contact.id,
|
||||||
|
myId: me.id,
|
||||||
|
notice: notice,
|
||||||
|
content: content),
|
||||||
|
);
|
||||||
|
onUpdate?.call();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
// ── Sdílení vlastního kontaktu ──────────────────────────────────────
|
||||||
|
|
||||||
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
/// Veřejná identita včetně routingu — tohle patří do QR/odkazu.
|
||||||
|
|||||||
@@ -197,15 +197,28 @@ class SecureStorageService {
|
|||||||
await _secureStorage.delete(key: _wrappedDekKey);
|
await _secureStorage.delete(key: _wrappedDekKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ověří PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
/// Ověří skutečný PIN, aniž by cokoli změnil (unwrap a zahození DEK).
|
||||||
/// Stojí jednu Argon2id derivaci (~2 s) — to je zároveň rate limit.
|
/// Tímhle se potvrzují citlivé akce v odemčené appce (fráze, záloha,
|
||||||
|
/// smazání účtu, změna PINu).
|
||||||
|
///
|
||||||
|
/// Sdílí brzdu se zámkovou obrazovkou: kdo dostane do ruky odemčený
|
||||||
|
/// telefon, nesmí tu hádat PIN rychleji než na zámku. Zamčeno =
|
||||||
|
/// [PinLockedException].
|
||||||
|
///
|
||||||
|
/// V nátlakové relaci vždy false: i kdyby někdo znal skutečný PIN, k frázi
|
||||||
|
/// skutečné identity se odtud dostat nesmí (otevře si appku normálně).
|
||||||
Future<bool> verifyPin(String pin) async {
|
Future<bool> verifyPin(String pin) async {
|
||||||
|
if (_decoyMode) return false;
|
||||||
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
final wrapped = await _secureStorage.read(key: _wrappedDekKey);
|
||||||
if (wrapped == null) return false;
|
if (wrapped == null) return false;
|
||||||
|
final locked = await pinLockRemaining();
|
||||||
|
if (locked != null) throw PinLockedException(locked);
|
||||||
try {
|
try {
|
||||||
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
await DekWrap.unwrap(wrapped: wrapped, pin: pin);
|
||||||
|
await _resetPinFailures();
|
||||||
return true;
|
return true;
|
||||||
} on WrongPinException {
|
} on WrongPinException {
|
||||||
|
await _notePinFailure();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,14 @@ class _ChangePinScreenState extends ConsumerState<ChangePinScreen> {
|
|||||||
switch (_krok) {
|
switch (_krok) {
|
||||||
case _Krok.stary:
|
case _Krok.stary:
|
||||||
setState(() => _pracuje = true);
|
setState(() => _pracuje = true);
|
||||||
final ok = await storage.verifyPin(_pin);
|
final bool ok;
|
||||||
|
try {
|
||||||
|
ok = await storage.verifyPin(_pin);
|
||||||
|
} on PinLockedException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_reset(context.l10n.pinLockedFor(e.remaining.inSeconds));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
_reset(context.l10n.pinWrong);
|
_reset(context.l10n.pinWrong);
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/// Čerstvé ověření před citlivou akcí v už odemčené appce.
|
||||||
|
///
|
||||||
|
/// Odemčený telefon v cizí ruce nesmí stačit na převzetí účtu: 24 slov,
|
||||||
|
/// záloha, smazání účtu nebo oslabení zámku chtějí PIN (Argon2id unwrap,
|
||||||
|
/// stejná brzda jako zámková obrazovka), případně skutečnou biometrii.
|
||||||
|
/// Žádné okno „před chvílí jsem ověřil“ — každá akce se ptá znovu.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
|
import 'package:sepot/widgets/pin_keypad.dart';
|
||||||
|
|
||||||
|
/// Systémová biometrická výzva. Provider kvůli testům — skutečný
|
||||||
|
/// `local_auth` ve widget testu neběží.
|
||||||
|
final biometricPromptProvider = Provider<Future<bool> Function(String reason)>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (reason) => LocalAuthentication().authenticate(
|
||||||
|
localizedReason: reason,
|
||||||
|
// biometricOnly: zámek telefonu (PIN/vzor zařízení) by obešel
|
||||||
|
// Argon2id ochranu slabším faktorem, který zná i „půjčovatel“.
|
||||||
|
options: const AuthenticationOptions(stickyAuth: true, biometricOnly: true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Vrátí true, jen když uživatel právě teď prokázal, že zná PIN, nebo
|
||||||
|
/// prošel biometrií. [reason] se ukáže ve výzvě.
|
||||||
|
///
|
||||||
|
/// [allowBiometrics] = false u akcí, které biometrii samy zakládají:
|
||||||
|
/// zapnutí biometrie se nesmí potvrdit tím, co se teprve zapíná.
|
||||||
|
Future<bool> confirmSensitiveAction(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required String reason,
|
||||||
|
bool allowBiometrics = true,
|
||||||
|
}) async {
|
||||||
|
final storage = ref.read(storageProvider);
|
||||||
|
if (storage.isDecoyMode) return false;
|
||||||
|
|
||||||
|
if (!await storage.hasPin()) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(context.l10n.reauthPinRequired),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: context.l10n.reauthSetPin,
|
||||||
|
onPressed: () => context.push('/pin-setup'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowBiometrics && await storage.isBiometricsEnabled()) {
|
||||||
|
try {
|
||||||
|
if (await ref.read(biometricPromptProvider)(reason)) return true;
|
||||||
|
} catch (_) {
|
||||||
|
// Biometrie nedostupná nebo zrušená — PIN je vždy cesta.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
final ok = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: SepotColors.background,
|
||||||
|
builder: (_) => _ReauthPinSheet(reason: reason),
|
||||||
|
);
|
||||||
|
return ok ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReauthPinSheet extends ConsumerStatefulWidget {
|
||||||
|
const _ReauthPinSheet({required this.reason});
|
||||||
|
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ReauthPinSheet> createState() => _ReauthPinSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReauthPinSheetState extends ConsumerState<_ReauthPinSheet> {
|
||||||
|
String _pin = '';
|
||||||
|
String? _error;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
void _onKey(String key) {
|
||||||
|
if (_busy) return;
|
||||||
|
setState(() {
|
||||||
|
_error = null;
|
||||||
|
if (key == 'DEL') {
|
||||||
|
if (_pin.isNotEmpty) _pin = _pin.substring(0, _pin.length - 1);
|
||||||
|
} else if (_pin.length < 6) {
|
||||||
|
_pin += key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (_pin.length == 6) _verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verify() async {
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final storage = ref.read(storageProvider);
|
||||||
|
String error;
|
||||||
|
try {
|
||||||
|
if (await storage.verifyPin(_pin)) {
|
||||||
|
if (mounted) Navigator.of(context).pop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final locked = await storage.pinLockRemaining();
|
||||||
|
error = locked == null
|
||||||
|
? l10n.pinWrong
|
||||||
|
: l10n.pinLockedFor(locked.inSeconds);
|
||||||
|
} on PinLockedException catch (e) {
|
||||||
|
error = l10n.pinLockedFor(e.remaining.inSeconds);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_pin = '';
|
||||||
|
_busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(32, 24, 32, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.reauthTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: kFontSans,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: SepotColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.reason,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: SepotColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
PinDots(filled: _pin.length),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_error!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: SepotColors.error, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_busy) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: SepotColors.ember,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
PinKeypad(onKey: _onKey, enabled: !_busy),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(l10n.commonCancel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -45,13 +46,15 @@ class ArchivedChatsScreen extends ConsumerWidget {
|
|||||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
final name = group?.name ??
|
final name = group?.name ??
|
||||||
contact?.displayName ??
|
(contact == null
|
||||||
context.l10n.commonUnknownContact;
|
? context.l10n.commonUnknownContact
|
||||||
|
: contactName(context.l10n, contact));
|
||||||
return ChatListTile(
|
return ChatListTile(
|
||||||
conversation: conv,
|
conversation: conv,
|
||||||
contactName: name,
|
contactName: name,
|
||||||
myId: myId,
|
myId: myId,
|
||||||
isGroup: group != null,
|
isGroup: group != null,
|
||||||
|
isRequest: group == null && contact?.accepted == false,
|
||||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||||
onLongPress: () => _actions(context, ref, conv),
|
onLongPress: () => _actions(context, ref, conv),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -206,7 +207,7 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
||||||
? l10n.notesTitle
|
? l10n.notesTitle
|
||||||
: groupOf(c.contactId)?.name ??
|
: groupOf(c.contactId)?.name ??
|
||||||
contactOf(c.contactId)?.displayName ??
|
_nameOrNull(l10n, contactOf(c.contactId)) ??
|
||||||
l10n.commonUnknownContact;
|
l10n.commonUnknownContact;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -265,7 +266,8 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
? l10n.chatTyping
|
? l10n.chatTyping
|
||||||
: writers.length == 1
|
: writers.length == 1
|
||||||
? l10n.chatTypingNamed(
|
? l10n.chatTypingNamed(
|
||||||
(contactOf(writers.first)?.displayName ?? '?')
|
(_nameOrNull(l10n, contactOf(writers.first)) ??
|
||||||
|
'?')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.first)
|
.first)
|
||||||
: l10n.chatTypingSeveral;
|
: l10n.chatTypingSeveral;
|
||||||
@@ -275,8 +277,10 @@ class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
|||||||
myId: myId,
|
myId: myId,
|
||||||
isGroup: group != null,
|
isGroup: group != null,
|
||||||
verified: contactOf(c.contactId)?.verified ?? false,
|
verified: contactOf(c.contactId)?.verified ?? false,
|
||||||
|
isRequest: group == null &&
|
||||||
|
contactOf(c.contactId)?.accepted == false,
|
||||||
lastAuthor: group != null && last != null
|
lastAuthor: group != null && last != null
|
||||||
? contactOf(last.senderId)?.displayName
|
? _nameOrNull(l10n, contactOf(last.senderId))
|
||||||
: null,
|
: null,
|
||||||
highlight: hits[c],
|
highlight: hits[c],
|
||||||
typingLabel: typingLabel,
|
typingLabel: typingLabel,
|
||||||
@@ -685,3 +689,6 @@ class _EmptyState extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _nameOrNull(AppLocalizations l10n, Contact? c) =>
|
||||||
|
c == null ? null : contactName(l10n, c);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final bool verified;
|
final bool verified;
|
||||||
|
|
||||||
|
/// Žádost o zprávu (neznámý odesílatel): náhled místo textu hlásí, o co
|
||||||
|
/// jde — první věta útočníka nemá dostat prostor v seznamu chatů.
|
||||||
|
final bool isRequest;
|
||||||
|
|
||||||
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
||||||
final String? lastAuthor;
|
final String? lastAuthor;
|
||||||
|
|
||||||
@@ -40,6 +44,7 @@ class ChatListTile extends StatelessWidget {
|
|||||||
required this.myId,
|
required this.myId,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.verified = false,
|
this.verified = false,
|
||||||
|
this.isRequest = false,
|
||||||
this.lastAuthor,
|
this.lastAuthor,
|
||||||
this.highlight,
|
this.highlight,
|
||||||
this.typingLabel,
|
this.typingLabel,
|
||||||
@@ -54,7 +59,9 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final unread = conversation.unreadCount(myId);
|
final unread = conversation.unreadCount(myId);
|
||||||
final muted = conversation.isMuted;
|
final muted = conversation.isMuted;
|
||||||
final mine = lastMsg != null && lastMsg.isMine(myId);
|
final mine = lastMsg != null && lastMsg.isMine(myId);
|
||||||
final preview = previewOf(context.l10n, lastMsg);
|
final preview = isRequest
|
||||||
|
? (icon: Icons.person_search_rounded, text: context.l10n.requestTitle)
|
||||||
|
: previewOf(context.l10n, lastMsg);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -245,6 +252,9 @@ class ChatListTile extends StatelessWidget {
|
|||||||
final label = callLogLabel(l10n, call);
|
final label = callLogLabel(l10n, call);
|
||||||
return (icon: label.icon, text: label.text);
|
return (icon: label.icon, text: label.text);
|
||||||
}
|
}
|
||||||
|
if (msg.notice != null) {
|
||||||
|
return (icon: Icons.gpp_maybe_rounded, text: l10n.noticePreview);
|
||||||
|
}
|
||||||
return switch (msg.type) {
|
return switch (msg.type) {
|
||||||
MessageType.text => (icon: null, text: msg.content ?? ''),
|
MessageType.text => (icon: null, text: msg.content ?? ''),
|
||||||
MessageType.image => (
|
MessageType.image => (
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: contactsAsync.when(
|
child: contactsAsync.when(
|
||||||
data: (contacts) {
|
data: (contacts) {
|
||||||
|
// Žádosti o zprávu kontakty nejsou — žijí jen v seznamu chatů.
|
||||||
final filtered = contacts.where((c) {
|
final filtered = contacts.where((c) {
|
||||||
|
if (!c.accepted) return false;
|
||||||
if (_searchQuery.isEmpty) return true;
|
if (_searchQuery.isEmpty) return true;
|
||||||
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
return c.displayName.toLowerCase().contains(_searchQuery) ||
|
||||||
c.sepotId.toLowerCase().contains(_searchQuery);
|
c.sepotId.toLowerCase().contains(_searchQuery);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
@@ -17,9 +18,7 @@ import 'package:sepot/providers/groups_provider.dart';
|
|||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
|
|
||||||
String contactLabel(BuildContext context, Contact contact) =>
|
String contactLabel(BuildContext context, Contact contact) =>
|
||||||
contact.displayName.isEmpty
|
contactName(context.l10n, contact);
|
||||||
? context.l10n.commonUnknownContact
|
|
||||||
: contact.displayName;
|
|
||||||
|
|
||||||
void _snack(BuildContext context, String text) {
|
void _snack(BuildContext context, String text) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/calls/call_controller.dart';
|
import 'package:sepot/core/calls/call_controller.dart';
|
||||||
import 'package:sepot/core/constants/app_constants.dart';
|
import 'package:sepot/core/constants/app_constants.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
@@ -21,6 +22,8 @@ import 'package:sepot/core/theme/app_theme.dart';
|
|||||||
import 'package:sepot/core/utils/text_fold.dart';
|
import 'package:sepot/core/utils/text_fold.dart';
|
||||||
import 'package:sepot/features/calls/call_log_text.dart';
|
import 'package:sepot/features/calls/call_log_text.dart';
|
||||||
import 'package:sepot/features/conversation/chat_actions.dart';
|
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||||
|
import 'package:sepot/features/conversation/message_request_panel.dart';
|
||||||
|
import 'package:sepot/features/conversation/widgets/safety_notice_row.dart';
|
||||||
import 'package:sepot/features/conversation/forward_sheet.dart';
|
import 'package:sepot/features/conversation/forward_sheet.dart';
|
||||||
import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
import 'package:sepot/features/conversation/widgets/attachment_content.dart';
|
||||||
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
|
import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart';
|
||||||
@@ -272,14 +275,13 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
|
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
|
||||||
String autorJmeno(String fingerprint) {
|
String autorJmeno(String fingerprint) {
|
||||||
if (fingerprint == identity?.id) return context.l10n.groupsYou;
|
if (fingerprint == identity?.id) return context.l10n.groupsYou;
|
||||||
final jmeno = contacts
|
final autor = contacts
|
||||||
.where((c) => c.publicIdentity.id == fingerprint)
|
.where((c) => c.publicIdentity.id == fingerprint)
|
||||||
.firstOrNull
|
.firstOrNull;
|
||||||
?.displayName;
|
if (autor == null) {
|
||||||
if (jmeno == null) {
|
|
||||||
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
|
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
|
||||||
}
|
}
|
||||||
return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno;
|
return contactName(context.l10n, autor);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Widget bottom;
|
final Widget bottom;
|
||||||
@@ -293,6 +295,8 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
child: Text(context.l10n.chatUnblock),
|
child: Text(context.l10n.chatUnblock),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (group == null && contact != null && !contact.accepted) {
|
||||||
|
bottom = MessageRequestPanel(contact: contact);
|
||||||
} else {
|
} else {
|
||||||
bottom = _buildComposer();
|
bottom = _buildComposer();
|
||||||
}
|
}
|
||||||
@@ -472,9 +476,9 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
|
Widget _buildContactHeader(Contact? contact, {bool typing = false}) {
|
||||||
final name = (contact == null || contact.displayName.isEmpty)
|
final name = contact == null
|
||||||
? context.l10n.commonUnknownContact
|
? context.l10n.commonUnknownContact
|
||||||
: contact.displayName;
|
: contactName(context.l10n, contact);
|
||||||
final verified = contact?.verified ?? false;
|
final verified = contact?.verified ?? false;
|
||||||
return _buildHeaderFrame(children: [
|
return _buildHeaderFrame(children: [
|
||||||
_backButton(),
|
_backButton(),
|
||||||
@@ -485,19 +489,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
// Stav ověření místo syrového ID (UX-FLOWS §3.1)
|
// Stav ověření místo syrového ID (UX-FLOWS §3.1)
|
||||||
subtitle: typing
|
subtitle: typing
|
||||||
? _typingLabel(context.l10n.chatTyping)
|
? _typingLabel(context.l10n.chatTyping)
|
||||||
|
: contact != null && !contact.accepted
|
||||||
|
? Text(
|
||||||
|
context.l10n.requestTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12, color: SepotColors.warning),
|
||||||
|
)
|
||||||
: Text(
|
: Text(
|
||||||
verified
|
verified
|
||||||
? context.l10n.verifyVerified
|
? context.l10n.verifyVerified
|
||||||
: context.l10n.verifyUnverified,
|
: context.l10n.verifyUnverified,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color:
|
color: verified
|
||||||
verified ? SepotColors.sage : SepotColors.textSecondary,
|
? SepotColors.sage
|
||||||
|
: SepotColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _openProfile('/contact/${widget.contactId}'),
|
onTap: () => _openProfile('/contact/${widget.contactId}'),
|
||||||
),
|
),
|
||||||
if (contact != null && !contact.blocked) ...[
|
// Žádosti o zprávu se nevolá — hovor by prozradil, že jsem u telefonu.
|
||||||
|
if (contact != null && !contact.blocked && contact.accepted) ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: context.l10n.callVoice,
|
tooltip: context.l10n.callVoice,
|
||||||
icon: const Icon(Icons.call_outlined,
|
icon: const Icon(Icons.call_outlined,
|
||||||
@@ -823,6 +835,27 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
? null
|
? null
|
||||||
: messages.where((m) => m.id == msg.replyToId).firstOrNull;
|
: messages.where((m) => m.id == msg.replyToId).firstOrNull;
|
||||||
|
|
||||||
|
final notice = msg.notice;
|
||||||
|
if (notice != null) {
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: _keys.putIfAbsent(msg.id, GlobalKey.new),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (showDate) _buildDateDivider(msg.timestamp),
|
||||||
|
SafetyNoticeRow(
|
||||||
|
notice: notice,
|
||||||
|
contactName: autorJmeno(widget.contactId),
|
||||||
|
claimedName: msg.content,
|
||||||
|
onVerify: group == null
|
||||||
|
? () => context.push('/verify/${widget.contactId}')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final callLog = msg.callLog;
|
final callLog = msg.callLog;
|
||||||
if (callLog != null) {
|
if (callLog != null) {
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
@@ -1745,9 +1778,7 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
|||||||
String jmeno(String f) {
|
String jmeno(String f) {
|
||||||
final c = contacts.where((c) => c.id == f).firstOrNull;
|
final c = contacts.where((c) => c.id == f).firstOrNull;
|
||||||
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
|
if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}';
|
||||||
return c.displayName.isEmpty
|
return contactName(context.l10n, c);
|
||||||
? context.l10n.commonUnknownContact
|
|
||||||
: c.displayName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
|
Widget radek(IconData icon, String label, DateTime? t, {Color? color}) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/constants/app_constants.dart';
|
import 'package:sepot/core/constants/app_constants.dart';
|
||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
@@ -37,8 +38,8 @@ Future<void> forwardMessage(
|
|||||||
for (final g in groups)
|
for (final g in groups)
|
||||||
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
||||||
for (final c in contacts)
|
for (final c in contacts)
|
||||||
if (!c.blocked && c.id != fromId)
|
if (!c.blocked && c.accepted && c.id != fromId)
|
||||||
_Target(c.id, c.displayName, verified: c.verified),
|
_Target(c.id, contactName(context.l10n, c), verified: c.verified),
|
||||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
||||||
if (!ConversationsNotifier.isNotes(fromId)) {
|
if (!ConversationsNotifier.isNotes(fromId)) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:sepot/core/l10n/contact_name.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
import 'package:sepot/models/attachment.dart';
|
import 'package:sepot/models/attachment.dart';
|
||||||
@@ -103,9 +104,9 @@ class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
|||||||
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
||||||
.where((c) => c.publicIdentity.id == m.senderId)
|
.where((c) => c.publicIdentity.id == m.senderId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
return contact?.displayName.isNotEmpty == true
|
return contact == null
|
||||||
? contact!.displayName
|
? context.l10n.commonUnknownContact
|
||||||
: context.l10n.commonUnknownContact;
|
: contactName(context.l10n, contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/// Žádost o zprávu: místo psacího pole u odesílatele, kterého jsem si
|
||||||
|
/// nepřidal. Jméno i fotku si zvolil sám, takže dokud ho nepřijmu, nesmí
|
||||||
|
/// vypadat jako můj kontakt a nesmí ode mě dostat odpověď omylem.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
|
import 'package:sepot/core/services/messaging_service.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/features/conversation/chat_actions.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
import 'package:sepot/providers/contacts_provider.dart';
|
||||||
|
|
||||||
|
class MessageRequestPanel extends ConsumerWidget {
|
||||||
|
const MessageRequestPanel({super.key, required this.contact});
|
||||||
|
|
||||||
|
final Contact contact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||||
|
final claimed = contact.displayName.isEmpty
|
||||||
|
? l10n.commonUnknownContact
|
||||||
|
: contact.displayName;
|
||||||
|
final lookalike =
|
||||||
|
lookalikeContact(contact.displayName, contacts, exceptId: contact.id);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: SepotColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: lookalike != null ? SepotColors.error : SepotColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
lookalike != null
|
||||||
|
? Icons.gpp_maybe_rounded
|
||||||
|
: Icons.person_search_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: lookalike != null
|
||||||
|
? SepotColors.error
|
||||||
|
: SepotColors.warning,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.requestTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.requestBody(claimed),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: SepotColors.textSecondary),
|
||||||
|
),
|
||||||
|
if (lookalike != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.requestLookalikeWarning(lookalike.displayName),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: SepotColors.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
setContactBlocked(context, ref, contact, true),
|
||||||
|
child: Text(l10n.chatMenuBlockContact,
|
||||||
|
style: const TextStyle(color: SepotColors.error)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => acceptMessageRequest(context, ref, contact),
|
||||||
|
child: Text(l10n.requestAccept),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Přijetí = uživatel kontakt pojmenuje sám. Jméno, které se plete
|
||||||
|
/// s jiným přijatým kontaktem, neprojde: dvě „Mámy“ v seznamu by
|
||||||
|
/// podvrh jen schovaly.
|
||||||
|
Future<void> acceptMessageRequest(
|
||||||
|
BuildContext context, WidgetRef ref, Contact contact) async {
|
||||||
|
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: lookalikeContact(contact.displayName, contacts,
|
||||||
|
exceptId: contact.id) ==
|
||||||
|
null
|
||||||
|
? contact.displayName
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _AcceptDialog(
|
||||||
|
controller: controller,
|
||||||
|
isTaken: (n) =>
|
||||||
|
lookalikeContact(n, contacts, exceptId: contact.id) != null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
if (name == null) return;
|
||||||
|
await ref.read(contactsProvider.notifier).accept(contact.id, name: name);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptDialog extends StatefulWidget {
|
||||||
|
const _AcceptDialog({required this.controller, required this.isTaken});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool Function(String name) isTaken;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AcceptDialog> createState() => _AcceptDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AcceptDialogState extends State<_AcceptDialog> {
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final name = widget.controller.text.trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
setState(() => _error = l10n.requestNameEmpty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.isTaken(name)) {
|
||||||
|
setState(() => _error = l10n.requestNameTaken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.requestAcceptTitle),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.requestAcceptBody, style: const TextStyle(fontSize: 13)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: widget.controller,
|
||||||
|
autofocus: true,
|
||||||
|
maxLength: MessagingService.maxDisplayNameLength,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.requestAcceptNameLabel,
|
||||||
|
errorText: _error,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.commonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _submit, child: Text(l10n.requestAccept)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/// Bezpečnostní upozornění uprostřed konverzace. Vypadá jinak než bublina
|
||||||
|
/// (nikdo ho neposlal), aby nešlo zaměnit se zprávou protistrany.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/models/message.dart';
|
||||||
|
|
||||||
|
class SafetyNoticeRow extends StatelessWidget {
|
||||||
|
const SafetyNoticeRow({
|
||||||
|
super.key,
|
||||||
|
required this.notice,
|
||||||
|
required this.contactName,
|
||||||
|
this.claimedName,
|
||||||
|
this.onVerify,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MessageNotice notice;
|
||||||
|
final String contactName;
|
||||||
|
|
||||||
|
/// Jméno, které si zvolil neznámý odesílatel ([MessageNotice.lookalikeRequest]).
|
||||||
|
final String? claimedName;
|
||||||
|
|
||||||
|
/// Odkaz na porovnání bezpečnostního kódu (null = bez tlačítka).
|
||||||
|
final VoidCallback? onVerify;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final text = switch (notice) {
|
||||||
|
MessageNotice.identityChanged => l10n.noticeIdentityChanged(contactName),
|
||||||
|
MessageNotice.lookalikeRequest =>
|
||||||
|
l10n.noticeLookalikeRequest(claimedName ?? '', contactName),
|
||||||
|
};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: SepotColors.surfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: SepotColors.warning),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.gpp_maybe_rounded,
|
||||||
|
size: 18, color: SepotColors.warning),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: SepotColors.textPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (onVerify != null &&
|
||||||
|
notice == MessageNotice.identityChanged)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: onVerify,
|
||||||
|
child: Text(l10n.noticeVerifyAction),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
|||||||
: null;
|
: null;
|
||||||
final contactsAsync = ref.watch(contactsProvider);
|
final contactsAsync = ref.watch(contactsProvider);
|
||||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||||
.where((c) => !c.blocked)
|
// Do skupiny jen lidi, které jsem přijal — žádost o zprávu by
|
||||||
|
// jinak dostala roster všech členů pod jménem, které si vymyslela.
|
||||||
|
.where((c) => !c.blocked && c.accepted)
|
||||||
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
||||||
.toList();
|
.toList();
|
||||||
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:local_auth/local_auth.dart';
|
|||||||
import 'package:sepot/core/l10n/error_messages.dart';
|
import 'package:sepot/core/l10n/error_messages.dart';
|
||||||
import 'package:sepot/core/l10n/l10n.dart';
|
import 'package:sepot/core/l10n/l10n.dart';
|
||||||
import 'package:sepot/core/theme/app_theme.dart';
|
import 'package:sepot/core/theme/app_theme.dart';
|
||||||
|
import 'package:sepot/features/auth/reauth.dart';
|
||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
|
|
||||||
class BiometricsTile extends ConsumerStatefulWidget {
|
class BiometricsTile extends ConsumerStatefulWidget {
|
||||||
@@ -48,6 +49,14 @@ class _BiometricsTileState extends ConsumerState<BiometricsTile> {
|
|||||||
_snack(l10n.biometricsStorageLocked);
|
_snack(l10n.biometricsStorageLocked);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Biometrie dostane kopii DEK — zapnout ji smí jen ten, kdo zná PIN.
|
||||||
|
// Otisk sám nestačí: v telefonu může být zapsaný i cizí prst.
|
||||||
|
if (!mounted ||
|
||||||
|
!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonBiometrics,
|
||||||
|
allowBiometrics: false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final auth = LocalAuthentication();
|
final auth = LocalAuthentication();
|
||||||
if (!await auth.isDeviceSupported()) {
|
if (!await auth.isDeviceSupported()) {
|
||||||
_snack(l10n.biometricsNotAvailable);
|
_snack(l10n.biometricsNotAvailable);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:sepot/providers/identity_provider.dart';
|
|||||||
import 'package:sepot/providers/messaging_provider.dart';
|
import 'package:sepot/providers/messaging_provider.dart';
|
||||||
import 'package:sepot/providers/storage_provider.dart';
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
import 'package:sepot/providers/settings_provider.dart';
|
import 'package:sepot/providers/settings_provider.dart';
|
||||||
|
import 'package:sepot/features/auth/reauth.dart';
|
||||||
import 'package:sepot/features/settings/biometrics_tile.dart';
|
import 'package:sepot/features/settings/biometrics_tile.dart';
|
||||||
import 'package:sepot/features/settings/language_settings_tile.dart';
|
import 'package:sepot/features/settings/language_settings_tile.dart';
|
||||||
import 'package:sepot/features/settings/push_settings_tile.dart';
|
import 'package:sepot/features/settings/push_settings_tile.dart';
|
||||||
@@ -151,8 +152,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
subtitle: l10n.settingsScreenSecuritySubtitle,
|
subtitle: l10n.settingsScreenSecuritySubtitle,
|
||||||
trailing: Switch(
|
trailing: Switch(
|
||||||
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
|
value: ref.watch(screenSecurityProvider).valueOrNull ?? false,
|
||||||
onChanged: (v) =>
|
onChanged: (v) => _setScreenSecurity(context, ref, v),
|
||||||
ref.read(screenSecurityProvider.notifier).set(v),
|
|
||||||
activeTrackColor: SepotColors.ember,
|
activeTrackColor: SepotColors.ember,
|
||||||
),
|
),
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
@@ -319,9 +319,15 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (vybrano != null && vybrano != ted) {
|
if (vybrano == null || vybrano == ted || !context.mounted) return;
|
||||||
await ref.read(autoLockProvider.notifier).set(vybrano);
|
// Delší interval nebo „nikdy“ = slabší zámek. Kratší projde bez ptaní.
|
||||||
|
final weakens = vybrano == 0 || (ted != 0 && vybrano > ted);
|
||||||
|
if (weakens &&
|
||||||
|
!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: context.l10n.reauthReasonAutoLock)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await ref.read(autoLockProvider.notifier).set(vybrano);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
|
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
|
||||||
@@ -518,6 +524,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final identity = ref.read(identityProvider).valueOrNull;
|
final identity = ref.read(identityProvider).valueOrNull;
|
||||||
if (identity == null) return;
|
if (identity == null) return;
|
||||||
|
if (!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonBackupExport)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final bytes = await ref.read(backupServiceProvider).export(identity);
|
final bytes = await ref.read(backupServiceProvider).export(identity);
|
||||||
final dir = await getTemporaryDirectory();
|
final dir = await getTemporaryDirectory();
|
||||||
@@ -547,6 +557,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final identity = ref.read(identityProvider).valueOrNull;
|
final identity = ref.read(identityProvider).valueOrNull;
|
||||||
if (identity == null) return;
|
if (identity == null) return;
|
||||||
|
if (!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonBackupImport)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final res = await FilePicker.pickFiles(withData: true);
|
final res = await FilePicker.pickFiles(withData: true);
|
||||||
final data = res?.files.firstOrNull?.bytes;
|
final data = res?.files.firstOrNull?.bytes;
|
||||||
@@ -570,6 +584,11 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
|
Future<void> _setDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
if (!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonDecoyPin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
final pinCtrl = TextEditingController();
|
final pinCtrl = TextEditingController();
|
||||||
final confirmCtrl = TextEditingController();
|
final confirmCtrl = TextEditingController();
|
||||||
const numberField = TextInputType.number;
|
const numberField = TextInputType.number;
|
||||||
@@ -645,6 +664,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
|
Future<void> _disableDecoyPin(BuildContext context, WidgetRef ref) async {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
if (!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonDecoyPin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await ref.read(storageProvider).disableDecoyPin();
|
await ref.read(storageProvider).disableDecoyPin();
|
||||||
ref.invalidate(hasDecoyPinProvider);
|
ref.invalidate(hasDecoyPinProvider);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -654,8 +677,15 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSeedPhrase(BuildContext context, WidgetRef ref) async {
|
/// Fráze = celý účet navždy. Načte se z keystore až po čerstvém ověření
|
||||||
|
/// a kopírovat ji nejde záměrně: schránku čtou klávesnice a synchronizace
|
||||||
|
/// do cloudu, takže by 24 slov opustilo telefon mimo kontrolu uživatele.
|
||||||
|
Future<void> _showSeedPhrase(BuildContext context, WidgetRef ref) async {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
if (!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: l10n.reauthReasonSeed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
final seed = await storage.loadSeedPhrase();
|
final seed = await storage.loadSeedPhrase();
|
||||||
|
|
||||||
@@ -670,11 +700,14 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
showDialog(
|
await ref.read(screenSecurityProvider.notifier).enforceWhile(
|
||||||
|
() => showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
title: Text(ctx.l10n.settingsSeedPhraseTitle),
|
||||||
content: Column(
|
// 24 čipů se na malý displej nevejde — posouvat, ne přetéct.
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -701,6 +734,7 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
@@ -708,9 +742,22 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vypnutí ochrany obrazovky pouští obsah (i frázi) do snímků a náhledu
|
||||||
|
/// v přepínači aplikací — stejné oslabení jako delší auto-zámek.
|
||||||
|
Future<void> _setScreenSecurity(
|
||||||
|
BuildContext context, WidgetRef ref, bool enabled) async {
|
||||||
|
if (!enabled &&
|
||||||
|
!await confirmSensitiveAction(context, ref,
|
||||||
|
reason: context.l10n.reauthReasonScreenSecurity)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ref.read(screenSecurityProvider.notifier).set(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
void _showAbout(BuildContext context) {
|
void _showAbout(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -767,6 +814,10 @@ class SettingsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
if (!await confirmSensitiveAction(ctx, ref,
|
||||||
|
reason: ctx.l10n.reauthReasonDeleteAccount)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final messaging = ref.read(messagingServiceProvider);
|
final messaging = ref.read(messagingServiceProvider);
|
||||||
// Serverová deregistrace PŘED lokálním wipe — podpis
|
// Serverová deregistrace PŘED lokálním wipe — podpis
|
||||||
// požadavku potřebuje identity klíč, který wipe zahodí.
|
// požadavku potřebuje identity klíč, který wipe zahodí.
|
||||||
|
|||||||
@@ -2339,5 +2339,137 @@
|
|||||||
"type": "String"
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reauthTitle": "Potvrď, že jsi to ty",
|
||||||
|
"@reauthTitle": {
|
||||||
|
"description": "Nadpis výzvy k PINu před citlivou akcí v odemčené appce"
|
||||||
|
},
|
||||||
|
"reauthPinRequired": "Tahle akce potřebuje PIN. Nejdřív si ho nastav.",
|
||||||
|
"@reauthPinRequired": {
|
||||||
|
"description": "Snackbar: citlivá akce bez nastaveného PINu"
|
||||||
|
},
|
||||||
|
"reauthSetPin": "Nastavit PIN",
|
||||||
|
"@reauthSetPin": {
|
||||||
|
"description": "Akce snackbaru: nastavit PIN"
|
||||||
|
},
|
||||||
|
"reauthReasonSeed": "Zobrazení 24 slov pro obnovu účtu",
|
||||||
|
"@reauthReasonSeed": {
|
||||||
|
"description": "Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze"
|
||||||
|
},
|
||||||
|
"reauthReasonBackupExport": "Export zálohy",
|
||||||
|
"@reauthReasonBackupExport": {
|
||||||
|
"description": "Důvod: export zálohy"
|
||||||
|
},
|
||||||
|
"reauthReasonBackupImport": "Obnova ze zálohy",
|
||||||
|
"@reauthReasonBackupImport": {
|
||||||
|
"description": "Důvod: obnova ze zálohy"
|
||||||
|
},
|
||||||
|
"reauthReasonDecoyPin": "Změna nátlakového PINu",
|
||||||
|
"@reauthReasonDecoyPin": {
|
||||||
|
"description": "Důvod: nastavení nebo zrušení nátlakového PINu"
|
||||||
|
},
|
||||||
|
"reauthReasonAutoLock": "Prodloužení automatického zámku",
|
||||||
|
"@reauthReasonAutoLock": {
|
||||||
|
"description": "Důvod: prodloužení nebo vypnutí automatického zámku"
|
||||||
|
},
|
||||||
|
"reauthReasonScreenSecurity": "Vypnutí ochrany obrazovky",
|
||||||
|
"@reauthReasonScreenSecurity": {
|
||||||
|
"description": "Důvod: vypnutí ochrany obrazovky"
|
||||||
|
},
|
||||||
|
"reauthReasonDeleteAccount": "Smazání účtu a všech dat",
|
||||||
|
"@reauthReasonDeleteAccount": {
|
||||||
|
"description": "Důvod: smazání účtu"
|
||||||
|
},
|
||||||
|
"reauthReasonBiometrics": "Zapnutí biometrie",
|
||||||
|
"@reauthReasonBiometrics": {
|
||||||
|
"description": "Důvod: zapnutí biometrického odemčení"
|
||||||
|
},
|
||||||
|
"contactRequestName": "{name} (neznámý)",
|
||||||
|
"@contactRequestName": {
|
||||||
|
"description": "Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pushMessageRequest": "Žádost o zprávu od neznámého odesílatele",
|
||||||
|
"@pushMessageRequest": {
|
||||||
|
"description": "Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil"
|
||||||
|
},
|
||||||
|
"requestTitle": "Žádost o zprávu",
|
||||||
|
"@requestTitle": {
|
||||||
|
"description": "Nadpis panelu / štítek žádosti o zprávu"
|
||||||
|
},
|
||||||
|
"requestBody": "„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.",
|
||||||
|
"@requestBody": {
|
||||||
|
"description": "Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestLookalikeWarning": "Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).",
|
||||||
|
"@requestLookalikeWarning": {
|
||||||
|
"description": "Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestAccept": "Přijmout",
|
||||||
|
"@requestAccept": {
|
||||||
|
"description": "Tlačítko: přijmout žádost o zprávu"
|
||||||
|
},
|
||||||
|
"requestAcceptTitle": "Přijmout jako kontakt",
|
||||||
|
"@requestAcceptTitle": {
|
||||||
|
"description": "Nadpis dialogu přijetí žádosti"
|
||||||
|
},
|
||||||
|
"requestAcceptBody": "Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.",
|
||||||
|
"@requestAcceptBody": {
|
||||||
|
"description": "Text dialogu přijetí: kontakt pojmenuje uživatel sám"
|
||||||
|
},
|
||||||
|
"requestAcceptNameLabel": "Jméno kontaktu",
|
||||||
|
"@requestAcceptNameLabel": {
|
||||||
|
"description": "Popisek pole se jménem kontaktu"
|
||||||
|
},
|
||||||
|
"requestNameEmpty": "Zadej jméno.",
|
||||||
|
"@requestNameEmpty": {
|
||||||
|
"description": "Chyba: prázdné jméno"
|
||||||
|
},
|
||||||
|
"requestNameTaken": "Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.",
|
||||||
|
"@requestNameTaken": {
|
||||||
|
"description": "Chyba: jméno se plete s jiným kontaktem"
|
||||||
|
},
|
||||||
|
"noticeIdentityChanged": "Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.",
|
||||||
|
"@noticeIdentityChanged": {
|
||||||
|
"description": "Upozornění v konverzaci: kontaktu {name} se změnil identity klíč",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noticeLookalikeRequest": "Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.",
|
||||||
|
"@noticeLookalikeRequest": {
|
||||||
|
"description": "Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}",
|
||||||
|
"placeholders": {
|
||||||
|
"claimed": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noticeVerifyAction": "Ověřit kód",
|
||||||
|
"@noticeVerifyAction": {
|
||||||
|
"description": "Tlačítko u upozornění na změnu kódu"
|
||||||
|
},
|
||||||
|
"noticePreview": "Bezpečnostní upozornění",
|
||||||
|
"@noticePreview": {
|
||||||
|
"description": "Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -486,5 +486,31 @@
|
|||||||
"profilePhotoPick": "Foto auswählen",
|
"profilePhotoPick": "Foto auswählen",
|
||||||
"profilePhotoRemove": "Foto entfernen",
|
"profilePhotoRemove": "Foto entfernen",
|
||||||
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
|
"profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.",
|
||||||
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}"
|
"profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}",
|
||||||
|
"reauthTitle": "Bestätige, dass du es bist",
|
||||||
|
"reauthPinRequired": "Diese Aktion erfordert eine PIN. Lege zuerst eine fest.",
|
||||||
|
"reauthSetPin": "PIN festlegen",
|
||||||
|
"reauthReasonSeed": "24 Wiederherstellungswörter anzeigen",
|
||||||
|
"reauthReasonBackupExport": "Backup exportieren",
|
||||||
|
"reauthReasonBackupImport": "Aus Backup wiederherstellen",
|
||||||
|
"reauthReasonDecoyPin": "Notfall-PIN ändern",
|
||||||
|
"reauthReasonAutoLock": "Automatische Sperre verlängern",
|
||||||
|
"reauthReasonScreenSecurity": "Bildschirmschutz ausschalten",
|
||||||
|
"reauthReasonDeleteAccount": "Konto und alle Daten löschen",
|
||||||
|
"reauthReasonBiometrics": "Biometrie einschalten",
|
||||||
|
"contactRequestName": "{name} (unbekannt)",
|
||||||
|
"pushMessageRequest": "Nachrichtenanfrage von unbekanntem Absender",
|
||||||
|
"requestTitle": "Nachrichtenanfrage",
|
||||||
|
"requestBody": "„{name}“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.",
|
||||||
|
"requestLookalikeWarning": "Achtung: Dein Kontakt {name} heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).",
|
||||||
|
"requestAccept": "Annehmen",
|
||||||
|
"requestAcceptTitle": "Als Kontakt annehmen",
|
||||||
|
"requestAcceptBody": "Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.",
|
||||||
|
"requestAcceptNameLabel": "Kontaktname",
|
||||||
|
"requestNameEmpty": "Gib einen Namen ein.",
|
||||||
|
"requestNameTaken": "Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.",
|
||||||
|
"noticeIdentityChanged": "Der Sicherheitscode von {name} hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.",
|
||||||
|
"noticeLookalikeRequest": "Ein unbekannter Absender nennt sich „{claimed}“, was nach deinem Kontakt {name} aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.",
|
||||||
|
"noticeVerifyAction": "Code prüfen",
|
||||||
|
"noticePreview": "Sicherheitshinweis"
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -486,5 +486,31 @@
|
|||||||
"profilePhotoPick": "Choose photo",
|
"profilePhotoPick": "Choose photo",
|
||||||
"profilePhotoRemove": "Remove photo",
|
"profilePhotoRemove": "Remove photo",
|
||||||
"profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.",
|
"profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.",
|
||||||
"profilePhotoFailed": "Could not set photo: {error}"
|
"profilePhotoFailed": "Could not set photo: {error}",
|
||||||
|
"reauthTitle": "Confirm it's you",
|
||||||
|
"reauthPinRequired": "This action needs a PIN. Set one first.",
|
||||||
|
"reauthSetPin": "Set PIN",
|
||||||
|
"reauthReasonSeed": "Show the 24 recovery words",
|
||||||
|
"reauthReasonBackupExport": "Export backup",
|
||||||
|
"reauthReasonBackupImport": "Restore from backup",
|
||||||
|
"reauthReasonDecoyPin": "Change the duress PIN",
|
||||||
|
"reauthReasonAutoLock": "Extend the auto-lock",
|
||||||
|
"reauthReasonScreenSecurity": "Turn off screen security",
|
||||||
|
"reauthReasonDeleteAccount": "Delete the account and all data",
|
||||||
|
"reauthReasonBiometrics": "Turn on biometrics",
|
||||||
|
"contactRequestName": "{name} (unknown)",
|
||||||
|
"pushMessageRequest": "Message request from an unknown sender",
|
||||||
|
"requestTitle": "Message request",
|
||||||
|
"requestBody": "“{name}” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can't see that you read the message and can't call you.",
|
||||||
|
"requestLookalikeWarning": "Careful: your contact {name} has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).",
|
||||||
|
"requestAccept": "Accept",
|
||||||
|
"requestAcceptTitle": "Accept as contact",
|
||||||
|
"requestAcceptBody": "Name the contact yourself. The name the sender chose is not taken over automatically.",
|
||||||
|
"requestAcceptNameLabel": "Contact name",
|
||||||
|
"requestNameEmpty": "Enter a name.",
|
||||||
|
"requestNameTaken": "Another contact already has this (or a very similar) name. Choose one that can't be confused.",
|
||||||
|
"noticeIdentityChanged": "The safety code of {name} has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.",
|
||||||
|
"noticeLookalikeRequest": "An unknown sender named themselves “{claimed}”, which looks like your contact {name}. It is not this contact, the message came from a different key. You'll find it in your chats as a message request.",
|
||||||
|
"noticeVerifyAction": "Verify code",
|
||||||
|
"noticePreview": "Security notice"
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -486,5 +486,31 @@
|
|||||||
"profilePhotoPick": "Scegli foto",
|
"profilePhotoPick": "Scegli foto",
|
||||||
"profilePhotoRemove": "Rimuovi foto",
|
"profilePhotoRemove": "Rimuovi foto",
|
||||||
"profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.",
|
"profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.",
|
||||||
"profilePhotoFailed": "Impossibile impostare la foto: {error}"
|
"profilePhotoFailed": "Impossibile impostare la foto: {error}",
|
||||||
|
"reauthTitle": "Conferma che sei tu",
|
||||||
|
"reauthPinRequired": "Questa azione richiede un PIN. Impostalo prima.",
|
||||||
|
"reauthSetPin": "Imposta PIN",
|
||||||
|
"reauthReasonSeed": "Mostra le 24 parole di recupero",
|
||||||
|
"reauthReasonBackupExport": "Esporta backup",
|
||||||
|
"reauthReasonBackupImport": "Ripristina da backup",
|
||||||
|
"reauthReasonDecoyPin": "Modifica il PIN di emergenza",
|
||||||
|
"reauthReasonAutoLock": "Prolunga il blocco automatico",
|
||||||
|
"reauthReasonScreenSecurity": "Disattiva la protezione schermo",
|
||||||
|
"reauthReasonDeleteAccount": "Elimina l'account e tutti i dati",
|
||||||
|
"reauthReasonBiometrics": "Attiva la biometria",
|
||||||
|
"contactRequestName": "{name} (sconosciuto)",
|
||||||
|
"pushMessageRequest": "Richiesta di messaggio da un mittente sconosciuto",
|
||||||
|
"requestTitle": "Richiesta di messaggio",
|
||||||
|
"requestBody": "«{name}» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.",
|
||||||
|
"requestLookalikeWarning": "Attenzione: il tuo contatto {name} ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).",
|
||||||
|
"requestAccept": "Accetta",
|
||||||
|
"requestAcceptTitle": "Accetta come contatto",
|
||||||
|
"requestAcceptBody": "Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.",
|
||||||
|
"requestAcceptNameLabel": "Nome del contatto",
|
||||||
|
"requestNameEmpty": "Inserisci un nome.",
|
||||||
|
"requestNameTaken": "Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.",
|
||||||
|
"noticeIdentityChanged": "Il codice di sicurezza di {name} è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.",
|
||||||
|
"noticeLookalikeRequest": "Un mittente sconosciuto si è chiamato «{claimed}», un nome che si confonde con il tuo contatto {name}. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.",
|
||||||
|
"noticeVerifyAction": "Verifica codice",
|
||||||
|
"noticePreview": "Avviso di sicurezza"
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -486,5 +486,31 @@
|
|||||||
"profilePhotoPick": "Выбрать фото",
|
"profilePhotoPick": "Выбрать фото",
|
||||||
"profilePhotoRemove": "Удалить фото",
|
"profilePhotoRemove": "Удалить фото",
|
||||||
"profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.",
|
"profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.",
|
||||||
"profilePhotoFailed": "Не удалось установить фото: {error}"
|
"profilePhotoFailed": "Не удалось установить фото: {error}",
|
||||||
|
"reauthTitle": "Подтвердите, что это вы",
|
||||||
|
"reauthPinRequired": "Для этого действия нужен PIN-код. Сначала задайте его.",
|
||||||
|
"reauthSetPin": "Задать PIN",
|
||||||
|
"reauthReasonSeed": "Показать 24 слова для восстановления",
|
||||||
|
"reauthReasonBackupExport": "Экспорт резервной копии",
|
||||||
|
"reauthReasonBackupImport": "Восстановление из резервной копии",
|
||||||
|
"reauthReasonDecoyPin": "Изменение PIN-кода под принуждением",
|
||||||
|
"reauthReasonAutoLock": "Увеличение времени автоблокировки",
|
||||||
|
"reauthReasonScreenSecurity": "Отключение защиты экрана",
|
||||||
|
"reauthReasonDeleteAccount": "Удаление аккаунта и всех данных",
|
||||||
|
"reauthReasonBiometrics": "Включение биометрии",
|
||||||
|
"contactRequestName": "{name} (неизвестный)",
|
||||||
|
"pushMessageRequest": "Запрос на переписку от неизвестного отправителя",
|
||||||
|
"requestTitle": "Запрос на переписку",
|
||||||
|
"requestBody": "«{name}» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.",
|
||||||
|
"requestLookalikeWarning": "Осторожно: так же зовут ваш контакт {name}. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).",
|
||||||
|
"requestAccept": "Принять",
|
||||||
|
"requestAcceptTitle": "Принять как контакт",
|
||||||
|
"requestAcceptBody": "Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.",
|
||||||
|
"requestAcceptNameLabel": "Имя контакта",
|
||||||
|
"requestNameEmpty": "Введите имя.",
|
||||||
|
"requestNameTaken": "Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.",
|
||||||
|
"noticeIdentityChanged": "Код безопасности контакта {name} изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.",
|
||||||
|
"noticeLookalikeRequest": "Неизвестный отправитель назвался «{claimed}», что похоже на ваш контакт {name}. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.",
|
||||||
|
"noticeVerifyAction": "Проверить код",
|
||||||
|
"noticePreview": "Предупреждение безопасности"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3025,6 +3025,162 @@ abstract class AppLocalizations {
|
|||||||
/// In cs, this message translates to:
|
/// In cs, this message translates to:
|
||||||
/// **'Fotku se nepodařilo nastavit: {error}'**
|
/// **'Fotku se nepodařilo nastavit: {error}'**
|
||||||
String profilePhotoFailed(String error);
|
String profilePhotoFailed(String error);
|
||||||
|
|
||||||
|
/// Nadpis výzvy k PINu před citlivou akcí v odemčené appce
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Potvrď, že jsi to ty'**
|
||||||
|
String get reauthTitle;
|
||||||
|
|
||||||
|
/// Snackbar: citlivá akce bez nastaveného PINu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Tahle akce potřebuje PIN. Nejdřív si ho nastav.'**
|
||||||
|
String get reauthPinRequired;
|
||||||
|
|
||||||
|
/// Akce snackbaru: nastavit PIN
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Nastavit PIN'**
|
||||||
|
String get reauthSetPin;
|
||||||
|
|
||||||
|
/// Důvod ve výzvě k PINu/biometrii: zobrazení obnovovací fráze
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Zobrazení 24 slov pro obnovu účtu'**
|
||||||
|
String get reauthReasonSeed;
|
||||||
|
|
||||||
|
/// Důvod: export zálohy
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Export zálohy'**
|
||||||
|
String get reauthReasonBackupExport;
|
||||||
|
|
||||||
|
/// Důvod: obnova ze zálohy
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Obnova ze zálohy'**
|
||||||
|
String get reauthReasonBackupImport;
|
||||||
|
|
||||||
|
/// Důvod: nastavení nebo zrušení nátlakového PINu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Změna nátlakového PINu'**
|
||||||
|
String get reauthReasonDecoyPin;
|
||||||
|
|
||||||
|
/// Důvod: prodloužení nebo vypnutí automatického zámku
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Prodloužení automatického zámku'**
|
||||||
|
String get reauthReasonAutoLock;
|
||||||
|
|
||||||
|
/// Důvod: vypnutí ochrany obrazovky
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Vypnutí ochrany obrazovky'**
|
||||||
|
String get reauthReasonScreenSecurity;
|
||||||
|
|
||||||
|
/// Důvod: smazání účtu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Smazání účtu a všech dat'**
|
||||||
|
String get reauthReasonDeleteAccount;
|
||||||
|
|
||||||
|
/// Důvod: zapnutí biometrického odemčení
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Zapnutí biometrie'**
|
||||||
|
String get reauthReasonBiometrics;
|
||||||
|
|
||||||
|
/// Jméno neznámého odesílatele (žádost o zprávu) všude v UI; {name} si zvolil sám
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'{name} (neznámý)'**
|
||||||
|
String contactRequestName(String name);
|
||||||
|
|
||||||
|
/// Text notifikace pro zprávu od neznámého odesílatele — bez jména, které si zvolil
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Žádost o zprávu od neznámého odesílatele'**
|
||||||
|
String get pushMessageRequest;
|
||||||
|
|
||||||
|
/// Nadpis panelu / štítek žádosti o zprávu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Žádost o zprávu'**
|
||||||
|
String get requestTitle;
|
||||||
|
|
||||||
|
/// Panel žádosti o zprávu; {name} = jméno, které si odesílatel zvolil
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'„{name}“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.'**
|
||||||
|
String requestBody(String name);
|
||||||
|
|
||||||
|
/// Varování v panelu žádosti: jméno se plete s existujícím kontaktem {name}
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Pozor: stejně se jmenuje tvůj kontakt {name}. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).'**
|
||||||
|
String requestLookalikeWarning(String name);
|
||||||
|
|
||||||
|
/// Tlačítko: přijmout žádost o zprávu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Přijmout'**
|
||||||
|
String get requestAccept;
|
||||||
|
|
||||||
|
/// Nadpis dialogu přijetí žádosti
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Přijmout jako kontakt'**
|
||||||
|
String get requestAcceptTitle;
|
||||||
|
|
||||||
|
/// Text dialogu přijetí: kontakt pojmenuje uživatel sám
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.'**
|
||||||
|
String get requestAcceptBody;
|
||||||
|
|
||||||
|
/// Popisek pole se jménem kontaktu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Jméno kontaktu'**
|
||||||
|
String get requestAcceptNameLabel;
|
||||||
|
|
||||||
|
/// Chyba: prázdné jméno
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Zadej jméno.'**
|
||||||
|
String get requestNameEmpty;
|
||||||
|
|
||||||
|
/// Chyba: jméno se plete s jiným kontaktem
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.'**
|
||||||
|
String get requestNameTaken;
|
||||||
|
|
||||||
|
/// Upozornění v konverzaci: kontaktu {name} se změnil identity klíč
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Bezpečnostní kód kontaktu {name} se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.'**
|
||||||
|
String noticeIdentityChanged(String name);
|
||||||
|
|
||||||
|
/// Upozornění v konverzaci existujícího kontaktu {name}: neznámý si zvolil podobné jméno {claimed}
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Neznámý odesílatel si dal jméno „{claimed}“, které se plete s kontaktem {name}. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.'**
|
||||||
|
String noticeLookalikeRequest(String claimed, String name);
|
||||||
|
|
||||||
|
/// Tlačítko u upozornění na změnu kódu
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Ověřit kód'**
|
||||||
|
String get noticeVerifyAction;
|
||||||
|
|
||||||
|
/// Náhled v seznamu chatů, když je poslední položkou bezpečnostní upozornění
|
||||||
|
///
|
||||||
|
/// In cs, this message translates to:
|
||||||
|
/// **'Bezpečnostní upozornění'**
|
||||||
|
String get noticePreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -1726,4 +1726,95 @@ class AppLocalizationsCs extends AppLocalizations {
|
|||||||
String profilePhotoFailed(String error) {
|
String profilePhotoFailed(String error) {
|
||||||
return 'Fotku se nepodařilo nastavit: $error';
|
return 'Fotku se nepodařilo nastavit: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthTitle => 'Potvrď, že jsi to ty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthPinRequired =>
|
||||||
|
'Tahle akce potřebuje PIN. Nejdřív si ho nastav.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthSetPin => 'Nastavit PIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonSeed => 'Zobrazení 24 slov pro obnovu účtu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupExport => 'Export zálohy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupImport => 'Obnova ze zálohy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDecoyPin => 'Změna nátlakového PINu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonAutoLock => 'Prodloužení automatického zámku';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonScreenSecurity => 'Vypnutí ochrany obrazovky';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDeleteAccount => 'Smazání účtu a všech dat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBiometrics => 'Zapnutí biometrie';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRequestName(String name) {
|
||||||
|
return '$name (neznámý)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pushMessageRequest => 'Žádost o zprávu od neznámého odesílatele';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestTitle => 'Žádost o zprávu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestBody(String name) {
|
||||||
|
return '„$name“ není ve tvých kontaktech. Jméno i fotku si zvolil sám, takže nemusí jít o toho, za koho se vydává. Dokud ho nepřijmeš, neuvidí, že jsi zprávu četl, a nemůže ti volat.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestLookalikeWarning(String name) {
|
||||||
|
return 'Pozor: stejně se jmenuje tvůj kontakt $name. Tohle je jiný klíč, tedy jiný telefon nebo jiný člověk. Než odpovíš, ověř si to jinou cestou (zavolej, potkejte se).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAccept => 'Přijmout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptTitle => 'Přijmout jako kontakt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptBody =>
|
||||||
|
'Pojmenuj kontakt sám. Jméno, které si odesílatel zvolil, se nepřebírá automaticky.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptNameLabel => 'Jméno kontaktu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameEmpty => 'Zadej jméno.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameTaken =>
|
||||||
|
'Tak (nebo skoro tak) se už jmenuje jiný kontakt. Zvol jméno, které je nezamění.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeIdentityChanged(String name) {
|
||||||
|
return 'Bezpečnostní kód kontaktu $name se změnil. Pod jeho adresou se ozval jiný klíč: nový telefon, přeinstalace, nebo podvrh. Ověření je zrušené. Než pošleš něco citlivého, porovnejte kód znovu.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeLookalikeRequest(String claimed, String name) {
|
||||||
|
return 'Neznámý odesílatel si dal jméno „$claimed“, které se plete s kontaktem $name. Tento kontakt to není, zpráva přišla z jiného klíče. Najdeš ji v chatech jako žádost o zprávu.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeVerifyAction => 'Ověřit kód';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticePreview => 'Bezpečnostní upozornění';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1748,4 +1748,96 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String profilePhotoFailed(String error) {
|
String profilePhotoFailed(String error) {
|
||||||
return 'Foto konnte nicht gesetzt werden: $error';
|
return 'Foto konnte nicht gesetzt werden: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthTitle => 'Bestätige, dass du es bist';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthPinRequired =>
|
||||||
|
'Diese Aktion erfordert eine PIN. Lege zuerst eine fest.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthSetPin => 'PIN festlegen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonSeed => '24 Wiederherstellungswörter anzeigen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupExport => 'Backup exportieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupImport => 'Aus Backup wiederherstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDecoyPin => 'Notfall-PIN ändern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonAutoLock => 'Automatische Sperre verlängern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonScreenSecurity => 'Bildschirmschutz ausschalten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDeleteAccount => 'Konto und alle Daten löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBiometrics => 'Biometrie einschalten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRequestName(String name) {
|
||||||
|
return '$name (unbekannt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pushMessageRequest =>
|
||||||
|
'Nachrichtenanfrage von unbekanntem Absender';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestTitle => 'Nachrichtenanfrage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestBody(String name) {
|
||||||
|
return '„$name“ ist nicht in deinen Kontakten. Name und Foto hat die Person selbst gewählt, sie ist also vielleicht nicht, wer sie vorgibt zu sein. Bis du annimmst, sieht sie nicht, dass du die Nachricht gelesen hast, und kann dich nicht anrufen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestLookalikeWarning(String name) {
|
||||||
|
return 'Achtung: Dein Kontakt $name heißt genauso. Das ist ein anderer Schlüssel, also ein anderes Telefon oder eine andere Person. Prüfe das auf anderem Weg, bevor du antwortest (anrufen, persönlich treffen).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAccept => 'Annehmen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptTitle => 'Als Kontakt annehmen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptBody =>
|
||||||
|
'Benenne den Kontakt selbst. Der vom Absender gewählte Name wird nicht automatisch übernommen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptNameLabel => 'Kontaktname';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameEmpty => 'Gib einen Namen ein.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameTaken =>
|
||||||
|
'Ein anderer Kontakt heißt schon so (oder fast so). Wähle einen Namen, der nicht verwechselt werden kann.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeIdentityChanged(String name) {
|
||||||
|
return 'Der Sicherheitscode von $name hat sich geändert. Unter dieser Adresse meldet sich ein anderer Schlüssel: neues Telefon, Neuinstallation oder Fälschung. Die Verifizierung wurde aufgehoben. Vergleicht den Code erneut, bevor du etwas Sensibles sendest.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeLookalikeRequest(String claimed, String name) {
|
||||||
|
return 'Ein unbekannter Absender nennt sich „$claimed“, was nach deinem Kontakt $name aussieht. Es ist nicht dieser Kontakt, die Nachricht kam von einem anderen Schlüssel. Du findest sie in den Chats als Nachrichtenanfrage.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeVerifyAction => 'Code prüfen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticePreview => 'Sicherheitshinweis';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1720,4 +1720,94 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String profilePhotoFailed(String error) {
|
String profilePhotoFailed(String error) {
|
||||||
return 'Could not set photo: $error';
|
return 'Could not set photo: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthTitle => 'Confirm it\'s you';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthPinRequired => 'This action needs a PIN. Set one first.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthSetPin => 'Set PIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonSeed => 'Show the 24 recovery words';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupExport => 'Export backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupImport => 'Restore from backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDecoyPin => 'Change the duress PIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonAutoLock => 'Extend the auto-lock';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonScreenSecurity => 'Turn off screen security';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDeleteAccount => 'Delete the account and all data';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBiometrics => 'Turn on biometrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRequestName(String name) {
|
||||||
|
return '$name (unknown)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pushMessageRequest => 'Message request from an unknown sender';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestTitle => 'Message request';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestBody(String name) {
|
||||||
|
return '“$name” is not in your contacts. They chose the name and photo themselves, so they may not be who they claim to be. Until you accept, they can\'t see that you read the message and can\'t call you.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestLookalikeWarning(String name) {
|
||||||
|
return 'Careful: your contact $name has the same name. This is a different key, so a different phone or a different person. Check another way before you reply (call, meet in person).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAccept => 'Accept';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptTitle => 'Accept as contact';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptBody =>
|
||||||
|
'Name the contact yourself. The name the sender chose is not taken over automatically.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptNameLabel => 'Contact name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameEmpty => 'Enter a name.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameTaken =>
|
||||||
|
'Another contact already has this (or a very similar) name. Choose one that can\'t be confused.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeIdentityChanged(String name) {
|
||||||
|
return 'The safety code of $name has changed. A different key showed up at their address: a new phone, a reinstall, or an impostor. Verification has been removed. Compare the code again before you send anything sensitive.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeLookalikeRequest(String claimed, String name) {
|
||||||
|
return 'An unknown sender named themselves “$claimed”, which looks like your contact $name. It is not this contact, the message came from a different key. You\'ll find it in your chats as a message request.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeVerifyAction => 'Verify code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticePreview => 'Security notice';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1735,4 +1735,96 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String profilePhotoFailed(String error) {
|
String profilePhotoFailed(String error) {
|
||||||
return 'Impossibile impostare la foto: $error';
|
return 'Impossibile impostare la foto: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthTitle => 'Conferma che sei tu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthPinRequired =>
|
||||||
|
'Questa azione richiede un PIN. Impostalo prima.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthSetPin => 'Imposta PIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonSeed => 'Mostra le 24 parole di recupero';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupExport => 'Esporta backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupImport => 'Ripristina da backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDecoyPin => 'Modifica il PIN di emergenza';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonAutoLock => 'Prolunga il blocco automatico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonScreenSecurity => 'Disattiva la protezione schermo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDeleteAccount => 'Elimina l\'account e tutti i dati';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBiometrics => 'Attiva la biometria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRequestName(String name) {
|
||||||
|
return '$name (sconosciuto)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pushMessageRequest =>
|
||||||
|
'Richiesta di messaggio da un mittente sconosciuto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestTitle => 'Richiesta di messaggio';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestBody(String name) {
|
||||||
|
return '«$name» non è tra i tuoi contatti. Nome e foto li ha scelti da sé, quindi potrebbe non essere chi dice di essere. Finché non accetti, non vede che hai letto il messaggio e non può chiamarti.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestLookalikeWarning(String name) {
|
||||||
|
return 'Attenzione: il tuo contatto $name ha lo stesso nome. Questa è una chiave diversa, quindi un altro telefono o un\'altra persona. Verifica in un altro modo prima di rispondere (chiama, incontratevi).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAccept => 'Accetta';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptTitle => 'Accetta come contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptBody =>
|
||||||
|
'Dai tu un nome al contatto. Il nome scelto dal mittente non viene ripreso automaticamente.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptNameLabel => 'Nome del contatto';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameEmpty => 'Inserisci un nome.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameTaken =>
|
||||||
|
'Un altro contatto ha già questo nome (o quasi). Scegline uno che non si possa confondere.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeIdentityChanged(String name) {
|
||||||
|
return 'Il codice di sicurezza di $name è cambiato. Al suo indirizzo si è presentata una chiave diversa: nuovo telefono, reinstallazione o un impostore. La verifica è stata annullata. Confrontate di nuovo il codice prima di inviare qualcosa di sensibile.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeLookalikeRequest(String claimed, String name) {
|
||||||
|
return 'Un mittente sconosciuto si è chiamato «$claimed», un nome che si confonde con il tuo contatto $name. Non è questo contatto, il messaggio è arrivato da una chiave diversa. Lo trovi nelle chat come richiesta di messaggio.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeVerifyAction => 'Verifica codice';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticePreview => 'Avviso di sicurezza';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1761,4 +1761,96 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
String profilePhotoFailed(String error) {
|
String profilePhotoFailed(String error) {
|
||||||
return 'Не удалось установить фото: $error';
|
return 'Не удалось установить фото: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthTitle => 'Подтвердите, что это вы';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthPinRequired =>
|
||||||
|
'Для этого действия нужен PIN-код. Сначала задайте его.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthSetPin => 'Задать PIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonSeed => 'Показать 24 слова для восстановления';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupExport => 'Экспорт резервной копии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBackupImport => 'Восстановление из резервной копии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDecoyPin => 'Изменение PIN-кода под принуждением';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonAutoLock => 'Увеличение времени автоблокировки';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonScreenSecurity => 'Отключение защиты экрана';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonDeleteAccount => 'Удаление аккаунта и всех данных';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get reauthReasonBiometrics => 'Включение биометрии';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRequestName(String name) {
|
||||||
|
return '$name (неизвестный)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pushMessageRequest =>
|
||||||
|
'Запрос на переписку от неизвестного отправителя';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestTitle => 'Запрос на переписку';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestBody(String name) {
|
||||||
|
return '«$name» нет в ваших контактах. Имя и фото отправитель выбрал сам, поэтому это может быть не тот, за кого он себя выдаёт. Пока вы не примете запрос, он не увидит, что вы прочитали сообщение, и не сможет вам позвонить.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String requestLookalikeWarning(String name) {
|
||||||
|
return 'Осторожно: так же зовут ваш контакт $name. Это другой ключ, то есть другой телефон или другой человек. Прежде чем отвечать, проверьте это другим способом (позвоните, встретьтесь).';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAccept => 'Принять';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptTitle => 'Принять как контакт';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptBody =>
|
||||||
|
'Назовите контакт сами. Имя, выбранное отправителем, автоматически не используется.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestAcceptNameLabel => 'Имя контакта';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameEmpty => 'Введите имя.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get requestNameTaken =>
|
||||||
|
'Так (или почти так) уже называется другой контакт. Выберите имя, которое нельзя перепутать.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeIdentityChanged(String name) {
|
||||||
|
return 'Код безопасности контакта $name изменился. По его адресу появился другой ключ: новый телефон, переустановка или подделка. Подтверждение отменено. Прежде чем отправлять что-то важное, сверьте код заново.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String noticeLookalikeRequest(String claimed, String name) {
|
||||||
|
return 'Неизвестный отправитель назвался «$claimed», что похоже на ваш контакт $name. Это не он: сообщение пришло с другого ключа. Оно в чатах как запрос на переписку.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticeVerifyAction => 'Проверить код';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noticePreview => 'Предупреждение безопасности';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ class Contact {
|
|||||||
/// Profilová fotka, kterou kontakt sdílí (null = iniciály).
|
/// Profilová fotka, kterou kontakt sdílí (null = iniciály).
|
||||||
AvatarRef? avatar;
|
AvatarRef? avatar;
|
||||||
|
|
||||||
|
/// Přijatý kontakt. False = žádost o zprávu: napsal mi někdo, koho jsem
|
||||||
|
/// si sám nepřidal (nebo jsem ho potkal jen ve skupině), takže jméno
|
||||||
|
/// i fotku si zvolil on. Dokud ho nepřijmu, appka ho značí jako
|
||||||
|
/// neznámého, neukazuje jeho fotku, nezvoní jeho hovory a nedává mu
|
||||||
|
/// vědět, že čtu nebo píšu.
|
||||||
|
bool accepted;
|
||||||
|
|
||||||
Contact({
|
Contact({
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.publicIdentity,
|
required this.publicIdentity,
|
||||||
@@ -26,6 +33,7 @@ class Contact {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.blocked = false,
|
this.blocked = false,
|
||||||
this.avatar,
|
this.avatar,
|
||||||
|
this.accepted = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
/// ID kontaktu = fingerprint identity klíče (identita = klíč).
|
||||||
@@ -41,12 +49,14 @@ class Contact {
|
|||||||
factory Contact.fromPublicIdentity(
|
factory Contact.fromPublicIdentity(
|
||||||
PublicIdentity identity, {
|
PublicIdentity identity, {
|
||||||
bool verified = false,
|
bool verified = false,
|
||||||
|
bool accepted = true,
|
||||||
}) {
|
}) {
|
||||||
return Contact(
|
return Contact(
|
||||||
displayName: identity.displayName,
|
displayName: identity.displayName,
|
||||||
publicIdentity: identity,
|
publicIdentity: identity,
|
||||||
addedAt: DateTime.now(),
|
addedAt: DateTime.now(),
|
||||||
verified: verified,
|
verified: verified,
|
||||||
|
accepted: accepted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +74,7 @@ class Contact {
|
|||||||
'notes': notes,
|
'notes': notes,
|
||||||
'blocked': blocked,
|
'blocked': blocked,
|
||||||
'avatar': avatar?.toJson(),
|
'avatar': avatar?.toJson(),
|
||||||
|
'accepted': accepted,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory Contact.fromJson(Map<String, dynamic> json) {
|
factory Contact.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -77,6 +88,9 @@ class Contact {
|
|||||||
notes: json['notes'] as String?,
|
notes: json['notes'] as String?,
|
||||||
blocked: json['blocked'] as bool? ?? false,
|
blocked: json['blocked'] as bool? ?? false,
|
||||||
avatar: AvatarRef.fromJson(json['avatar']),
|
avatar: AvatarRef.fromJson(json['avatar']),
|
||||||
|
// Kontakty z doby před žádostmi o zprávu uživatel už léta používá —
|
||||||
|
// zpětně je vydávat za neznámé by jen naučilo varování odklikávat.
|
||||||
|
accepted: json['accepted'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import 'package:sepot/models/group.dart';
|
|||||||
import 'package:sepot/models/poll.dart';
|
import 'package:sepot/models/poll.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Bezpečnostní upozornění, které appka sama vloží do konverzace.
|
||||||
|
/// Nikdy nejde po drátě — protistrana ho podvrhnout nemůže.
|
||||||
|
enum MessageNotice {
|
||||||
|
/// Pod adresou tohohle kontaktu se ozval jiný identity klíč (relay
|
||||||
|
/// hlásí jiný klíč, nebo relace patří cizímu klíči). Ověření se ruší.
|
||||||
|
identityChanged,
|
||||||
|
|
||||||
|
/// Neznámý odesílatel si zvolil jméno, které se plete s tímhle
|
||||||
|
/// kontaktem. [Message.content] nese jméno, které si zvolil.
|
||||||
|
lookalikeRequest,
|
||||||
|
}
|
||||||
|
|
||||||
/// Typy zpráv — odpovídá Rust: MessageType
|
/// Typy zpráv — odpovídá Rust: MessageType
|
||||||
enum MessageType {
|
enum MessageType {
|
||||||
text,
|
text,
|
||||||
@@ -62,6 +74,9 @@ class Message {
|
|||||||
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
|
/// Lokální záznam hovoru — taková zpráva nemá text ani přílohu.
|
||||||
final CallLog? callLog;
|
final CallLog? callLog;
|
||||||
|
|
||||||
|
/// Bezpečnostní upozornění appky (není to zpráva od nikoho).
|
||||||
|
final MessageNotice? notice;
|
||||||
|
|
||||||
const Message({
|
const Message({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.senderId,
|
required this.senderId,
|
||||||
@@ -85,8 +100,30 @@ class Message {
|
|||||||
this.poll,
|
this.poll,
|
||||||
this.mentions = const [],
|
this.mentions = const [],
|
||||||
this.callLog,
|
this.callLog,
|
||||||
|
this.notice,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Upozornění appky v konverzaci [contactId]. Odesílatel = kontakt, aby
|
||||||
|
/// se konverzace v seznamu ukázala jako nepřečtená a upozornění si
|
||||||
|
/// uživatel nepřehlédl.
|
||||||
|
factory Message.notice({
|
||||||
|
required String contactId,
|
||||||
|
required String myId,
|
||||||
|
required MessageNotice notice,
|
||||||
|
String? content,
|
||||||
|
}) {
|
||||||
|
return Message(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
senderId: contactId,
|
||||||
|
recipientId: myId,
|
||||||
|
type: MessageType.text,
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isDelivered: true,
|
||||||
|
notice: notice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Vytvoř textovou zprávu
|
/// Vytvoř textovou zprávu
|
||||||
factory Message.text({
|
factory Message.text({
|
||||||
required String senderId,
|
required String senderId,
|
||||||
@@ -191,6 +228,7 @@ class Message {
|
|||||||
poll: poll ?? this.poll,
|
poll: poll ?? this.poll,
|
||||||
mentions: mentions,
|
mentions: mentions,
|
||||||
callLog: callLog,
|
callLog: callLog,
|
||||||
|
notice: notice,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +269,7 @@ class Message {
|
|||||||
'poll': poll?.toJson(),
|
'poll': poll?.toJson(),
|
||||||
if (mentions.isNotEmpty) 'mentions': mentions,
|
if (mentions.isNotEmpty) 'mentions': mentions,
|
||||||
if (callLog != null) 'call': callLog!.toJson(),
|
if (callLog != null) 'call': callLog!.toJson(),
|
||||||
|
if (notice != null) 'notice': notice!.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
|
static Map<String, int> _timesToJson(Map<String, DateTime> times) =>
|
||||||
@@ -282,6 +321,7 @@ class Message {
|
|||||||
poll: Poll.fromJson(json['poll']),
|
poll: Poll.fromJson(json['poll']),
|
||||||
mentions: mentionsFromWire(json['mentions']),
|
mentions: mentionsFromWire(json['mentions']),
|
||||||
callLog: CallLog.fromJson(json['call']),
|
callLog: CallLog.fromJson(json['call']),
|
||||||
|
notice: MessageNotice.values.asNameMap()[json['notice']],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,19 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
|||||||
throw const ContactLinkIncompleteError();
|
throw const ContactLinkIncompleteError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kontrola duplicity
|
// Kontrola duplicity. Výjimka: žádost o zprávu od téhož klíče — ruční
|
||||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
// přidání (QR, odkaz) je právě to přijetí, na které čekala.
|
||||||
throw const ContactAlreadyExistsError();
|
final existing =
|
||||||
|
contacts.where((c) => c.publicIdentity.id == identity.id).firstOrNull;
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.accepted) throw const ContactAlreadyExistsError();
|
||||||
|
await accept(existing.id,
|
||||||
|
name: identity.displayName.isEmpty
|
||||||
|
? existing.displayName
|
||||||
|
: identity.displayName);
|
||||||
|
if (verified) existing.verified = true;
|
||||||
|
await storage.saveContact(existing);
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
final contact = Contact.fromPublicIdentity(identity, verified: verified);
|
final contact = Contact.fromPublicIdentity(identity, verified: verified);
|
||||||
@@ -38,6 +48,20 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
|||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Přijme žádost o zprávu pod jménem, které jí dal uživatel (jméno
|
||||||
|
/// zvolené odesílatelem se tím nepřebírá automaticky).
|
||||||
|
Future<void> accept(String contactId, {required String name}) async {
|
||||||
|
final storage = ref.read(storageProvider);
|
||||||
|
final contacts = await future;
|
||||||
|
final contact = contacts.where((c) => c.id == contactId).firstOrNull;
|
||||||
|
if (contact == null) return;
|
||||||
|
contact
|
||||||
|
..displayName = name.trim()
|
||||||
|
..accepted = true;
|
||||||
|
await storage.saveContact(contact);
|
||||||
|
state = AsyncData([...contacts]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Smaž kontakt
|
/// Smaž kontakt
|
||||||
Future<void> removeContact(String contactId) async {
|
Future<void> removeContact(String contactId) async {
|
||||||
final storage = ref.read(storageProvider);
|
final storage = ref.read(storageProvider);
|
||||||
|
|||||||
@@ -341,8 +341,10 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|||||||
|
|
||||||
// Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení
|
// Příchozí zprávy, které jsem právě přečetl → pošlu za ně potvrzení
|
||||||
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
// přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá).
|
||||||
|
// Upozornění appky (notice) nikdo neposlal — potvrzovat ho nelze.
|
||||||
final justRead = conv.messages
|
final justRead = conv.messages
|
||||||
.where((m) => !m.isMine(identity.id) && !m.isRead)
|
.where((m) =>
|
||||||
|
!m.isMine(identity.id) && !m.isRead && m.notice == null)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
conv.markAllRead(identity.id);
|
conv.markAllRead(identity.id);
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ final avatarProvider =
|
|||||||
final me = ref.watch(identityProvider).valueOrNull;
|
final me = ref.watch(identityProvider).valueOrNull;
|
||||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||||
if (!service.isStarted) return null;
|
if (!service.isStarted) return null;
|
||||||
|
final contact = contacts.where((c) => c.id == fingerprint).firstOrNull;
|
||||||
|
// Fotka neznámého odesílatele může být fotka mámy z Facebooku — do
|
||||||
|
// přijetí jen iniciály na tónu z klíče, ne jeho obrázek.
|
||||||
final avatar = fingerprint == me?.id
|
final avatar = fingerprint == me?.id
|
||||||
? service.myAvatar
|
? service.myAvatar
|
||||||
: contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar;
|
: contact != null && contact.accepted
|
||||||
|
? contact.avatar
|
||||||
|
: null;
|
||||||
return avatar == null ? null : service.loadAvatar(avatar);
|
return avatar == null ? null : service.loadAvatar(avatar);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +88,15 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
|||||||
final conv = await ref.read(storageProvider).loadConversation(convId);
|
final conv = await ref.read(storageProvider).loadConversation(convId);
|
||||||
final mentionedMe = message.mentions.contains(identity.id);
|
final mentionedMe = message.mentions.contains(identity.id);
|
||||||
if (conv?.isMuted == true && !mentionedMe) return;
|
if (conv?.isMuted == true && !mentionedMe) return;
|
||||||
|
// Jméno, které si neznámý zvolil sám, nepatří do notifikace —
|
||||||
|
// „Nová zpráva od Máma“ by byl phishing ještě před otevřením appky.
|
||||||
|
if (contact.accepted) {
|
||||||
PushNotifier.showMessageNotification(contact.displayName);
|
PushNotifier.showMessageNotification(contact.displayName);
|
||||||
|
} else if (convId != contact.id) {
|
||||||
|
PushNotifier.showWakeNotification();
|
||||||
|
} else {
|
||||||
|
PushNotifier.showMessageRequestNotification();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
service.onCallSignal = (contact, signal) =>
|
service.onCallSignal = (contact, signal) =>
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ class ScreenSecurityNotifier extends AsyncNotifier<bool> {
|
|||||||
state = AsyncData(value);
|
state = AsyncData(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Po dobu [body] drží ochranu zapnutou bez ohledu na nastavení a pak
|
||||||
|
/// vrátí uložený stav. Pro 24 slov: vypnutý přepínač nesmí pustit frázi
|
||||||
|
/// do screenshotu ani do náhledu v přepínači aplikací. Ladicí build
|
||||||
|
/// výjimku má stejně jako MainActivity — QA potřebuje snímky z emulátoru.
|
||||||
|
Future<T> enforceWhile<T>(Future<T> Function() body) async {
|
||||||
|
if (kDebugMode) return body();
|
||||||
|
await _apply(true);
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} finally {
|
||||||
|
await _apply(state.valueOrNull ?? vychozi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _apply(bool on) async {
|
Future<void> _apply(bool on) async {
|
||||||
try {
|
try {
|
||||||
if (on) {
|
if (on) {
|
||||||
|
|||||||
@@ -99,12 +99,20 @@ class SepotAvatar extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _leadingPunct = RegExp(r'^\p{P}', unicode: true);
|
||||||
|
|
||||||
/// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod.
|
/// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod.
|
||||||
static String initialsOf(String name) {
|
static String initialsOf(String name) {
|
||||||
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
// Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render.
|
||||||
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
// Grafémy (characters), ne code units — jméno začínající emoji by se
|
||||||
// jinak rozpůlilo na rozbitý surrogate.
|
// jinak rozpůlilo na rozbitý surrogate.
|
||||||
final parts = name.trim().split(' ').where((p) => p.isNotEmpty).toList();
|
// Slova začínající interpunkcí přeskočit: přívlastek „(neznámý)“ u žádosti
|
||||||
|
// o zprávu by jinak dal iniciály „M(“.
|
||||||
|
final parts = name
|
||||||
|
.trim()
|
||||||
|
.split(' ')
|
||||||
|
.where((p) => p.isNotEmpty && !_leadingPunct.hasMatch(p))
|
||||||
|
.toList();
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
return '${parts[0].characters.first}${parts[1].characters.first}'
|
return '${parts[0].characters.first}${parts[1].characters.first}'
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: sepot
|
name: sepot
|
||||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.2.2+25
|
version: 1.2.3+27
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.8
|
sdk: ^3.10.8
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/safety/lookalike_names.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
import 'package:sepot/widgets/avatar.dart';
|
||||||
|
|
||||||
|
/// Jméno zvolené odesílatelem se nesmí dát „obejít“ diakritikou, velikostí
|
||||||
|
/// písmen, cizí abecedou ani neviditelnými znaky.
|
||||||
|
void main() {
|
||||||
|
Contact kontakt(String jmeno, {bool accepted = true, int seed = 1}) =>
|
||||||
|
Contact.fromPublicIdentity(
|
||||||
|
PublicIdentity(
|
||||||
|
displayName: jmeno,
|
||||||
|
identityKey: Uint8List.fromList(
|
||||||
|
[0x05, ...List.generate(32, (i) => (i * seed + 3) & 0xff)]),
|
||||||
|
createdAt: DateTime.utc(2026),
|
||||||
|
),
|
||||||
|
accepted: accepted,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('stejná kostra pro varianty téhož jména', () {
|
||||||
|
final mama = nameSkeleton('Máma');
|
||||||
|
for (final varianta in [
|
||||||
|
'mama',
|
||||||
|
'MAMA',
|
||||||
|
' Má ma ',
|
||||||
|
'Máma!',
|
||||||
|
'M\u00E1\u200Bma',
|
||||||
|
'\u041C\u00E1\u043C\u0430', // cyrilské М, м, а
|
||||||
|
'Ma\u0301ma', // kombinující čárka
|
||||||
|
'Mámа\u202E',
|
||||||
|
]) {
|
||||||
|
expect(nameSkeleton(varianta), mama, reason: varianta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homoglyfy l/I/1 a rn/m', () {
|
||||||
|
expect(nameSkeleton('Pavel'), nameSkeleton('PaveI'));
|
||||||
|
expect(nameSkeleton('Pavel'), nameSkeleton('Pave1'));
|
||||||
|
expect(nameSkeleton('Marek'), nameSkeleton('rnarek'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('různá jména se nepletou', () {
|
||||||
|
expect(nameSkeleton('Máma'), isNot(nameSkeleton('Táta')));
|
||||||
|
expect(nameSkeleton('Jana'), isNot(nameSkeleton('Jan')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('porovnává jen s přijatými kontakty a ne se sebou', () {
|
||||||
|
final mama = kontakt('Máma');
|
||||||
|
final zadost = kontakt('MAMA', accepted: false, seed: 2);
|
||||||
|
expect(lookalikeContact('mama', [mama, zadost]), same(mama));
|
||||||
|
expect(lookalikeContact('mama', [zadost]), isNull);
|
||||||
|
expect(lookalikeContact('mama', [mama], exceptId: mama.id), isNull);
|
||||||
|
expect(lookalikeContact('', [kontakt('')]), isNull,
|
||||||
|
reason: 'prázdné jméno nic nenese');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('přívlastek žádosti v závorce nekazí iniciály avataru', () {
|
||||||
|
expect(SepotAvatar.initialsOf('Máma (neznámý)'), 'MÁ');
|
||||||
|
expect(SepotAvatar.initialsOf('Jan Novák (unknown)'), 'JN');
|
||||||
|
expect(SepotAvatar.initialsOf('(neznámý)'), '?');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Potřebuje nativní libsignal (FFI) — na builderu bez Rust toolchainu se vynechává (--exclude-tags ffi).
|
||||||
|
@Tags(['ffi'])
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:libsignal/libsignal.dart' show ProtocolAddress;
|
||||||
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
||||||
|
import 'package:sepot/core/services/messaging_service.dart';
|
||||||
|
import 'package:sepot/core/storage/crypto_db.dart';
|
||||||
|
import 'package:sepot/core/storage/persistent_stores.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
import 'package:sepot/models/contact.dart';
|
||||||
|
import 'package:sepot/models/conversation.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
import 'package:sepot/models/message.dart';
|
||||||
|
|
||||||
|
/// Phishing „Máma“: cizí klíč se jménem existujícího kontaktu nesmí
|
||||||
|
/// splynout s kontaktem ani převzít jeho jméno, ověření nebo hovory.
|
||||||
|
/// Změna klíče pod adresou známého kontaktu ruší ověření a varuje.
|
||||||
|
void main() {
|
||||||
|
late Directory tmp;
|
||||||
|
late CryptoDb db;
|
||||||
|
late SecureStorageService storage;
|
||||||
|
late MessagingService service;
|
||||||
|
late LocalIdentity ja;
|
||||||
|
late LocalIdentity mama;
|
||||||
|
late Contact mamaContact;
|
||||||
|
final rng = Random.secure();
|
||||||
|
final sent = <(String, Map<String, dynamic>)>[];
|
||||||
|
final calls = <String>[];
|
||||||
|
|
||||||
|
Uint8List randomBytes(int n) =>
|
||||||
|
Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
|
||||||
|
|
||||||
|
const mamaAccount = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
|
||||||
|
setUpAll(() async => SepotCrypto.init());
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
tmp = Directory.systemTemp.createTempSync('sepot_request');
|
||||||
|
db = CryptoDb.open(path: '${tmp.path}/app.db', dek: randomBytes(32));
|
||||||
|
storage = SecureStorageService(dbPath: '${tmp.path}/app.db')
|
||||||
|
..debugAttachDb(db);
|
||||||
|
service = MessagingService(storage: storage);
|
||||||
|
ja = await LocalIdentity.create('Já');
|
||||||
|
service.debugSetIdentity(ja);
|
||||||
|
sent.clear();
|
||||||
|
calls.clear();
|
||||||
|
service.debugSendTap = (to, content) => sent.add((to, content));
|
||||||
|
service.onCallSignal = (contact, _) => calls.add(contact.id);
|
||||||
|
|
||||||
|
mama = await LocalIdentity.create('Máma');
|
||||||
|
mamaContact = Contact.fromPublicIdentity(
|
||||||
|
mama.publicIdentity
|
||||||
|
.withRouting(accountId: mamaAccount, deliveryAddress: randomBytes(32)),
|
||||||
|
verified: true,
|
||||||
|
);
|
||||||
|
await storage.saveContact(mamaContact);
|
||||||
|
await storage.saveConversation(Conversation(contactId: mamaContact.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
db.close();
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Obálka od [from] po dešifrování (sealed sender už ověřil klíč a účet).
|
||||||
|
Future<void> deliver(
|
||||||
|
LocalIdentity from,
|
||||||
|
String account,
|
||||||
|
Map<String, dynamic> content,
|
||||||
|
) =>
|
||||||
|
service.debugProcessOpened(
|
||||||
|
SepotSealedMessage(
|
||||||
|
plaintext: utf8.encode(jsonEncode({'v': 1, ...content})),
|
||||||
|
senderName: account,
|
||||||
|
senderDeviceId: 1,
|
||||||
|
senderIdentityKey: from.publicIdentity.identityKey,
|
||||||
|
),
|
||||||
|
randomBytes(16),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> text(String name, String txt) => {
|
||||||
|
't': 'txt',
|
||||||
|
'id': 'm-${rng.nextInt(1 << 30)}',
|
||||||
|
'txt': txt,
|
||||||
|
'p': {'n': name, 'da': base64Encode(randomBytes(32))},
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Contact> contactOf(LocalIdentity id) async =>
|
||||||
|
(await storage.loadContacts()).singleWhere((c) => c.id == id.id);
|
||||||
|
|
||||||
|
test('cizí klíč se jménem „Máma“ = žádost o zprávu, ne splynutí', () async {
|
||||||
|
final podvrh = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(podvrh, 'b' * 32, text('Máma', 'Pošli mi kód z SMS'));
|
||||||
|
|
||||||
|
final kontakty = await storage.loadContacts();
|
||||||
|
expect(kontakty, hasLength(2), reason: 'nový klíč = nový kontakt');
|
||||||
|
|
||||||
|
final skutecna = await contactOf(mama);
|
||||||
|
expect(skutecna.displayName, 'Máma');
|
||||||
|
expect(skutecna.verified, isTrue, reason: 'cizí klíč ověření nemění');
|
||||||
|
expect(skutecna.publicIdentity.identityKey, mama.publicIdentity.identityKey);
|
||||||
|
|
||||||
|
final zadost = await contactOf(podvrh);
|
||||||
|
expect(zadost.accepted, isFalse);
|
||||||
|
expect(zadost.verified, isFalse);
|
||||||
|
|
||||||
|
final zprava = (await storage.loadConversation(zadost.id))!.messages.single;
|
||||||
|
expect(zprava.content, 'Pošli mi kód z SMS');
|
||||||
|
expect(zprava.senderId, podvrh.id, reason: 'zpráva patří podvrhu');
|
||||||
|
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(skutecna.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
|
||||||
|
expect(upozorneni.content, 'Máma');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('podobné jméno (cyrilice, velká písmena, mezery) se taky pozná',
|
||||||
|
() async {
|
||||||
|
final podvrh = await LocalIdentity.create('x');
|
||||||
|
await deliver(podvrh, 'c' * 32, text(' МÁМА ', 'ahoj'));
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(mamaContact.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.lookalikeRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stejný účet na relayi s jiným klíčem = změna bezpečnostního kódu',
|
||||||
|
() async {
|
||||||
|
final novyKlic = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', 'nový telefon'));
|
||||||
|
|
||||||
|
final skutecna = await contactOf(mama);
|
||||||
|
expect(skutecna.verified, isFalse, reason: 'ověření se ruší');
|
||||||
|
expect(skutecna.displayName, 'Máma');
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(skutecna.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.identityChanged);
|
||||||
|
|
||||||
|
final zadost = await contactOf(novyKlic);
|
||||||
|
expect(zadost.accepted, isFalse, reason: 'nový klíč se nepřebírá');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opakovaná změna nepřidává další a další upozornění', () async {
|
||||||
|
final novyKlic = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', '1'));
|
||||||
|
await deliver(novyKlic, mamaAccount, text('Máma', '2'));
|
||||||
|
final zpravy = (await storage.loadConversation(mamaContact.id))!.messages;
|
||||||
|
expect(zpravy.where((m) => m.notice != null), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('žádost o zprávu nezvoní, přijatý kontakt ano', () async {
|
||||||
|
final cizi = await LocalIdentity.create('Máma');
|
||||||
|
await deliver(cizi, 'd' * 32, text('Máma', 'ahoj'));
|
||||||
|
await deliver(cizi, 'd' * 32, {'t': 'call', 'id': 'c1', 'k': 'offer'});
|
||||||
|
expect(calls, isEmpty);
|
||||||
|
|
||||||
|
await deliver(mama, mamaAccount, {'t': 'call', 'id': 'c2', 'k': 'offer'});
|
||||||
|
expect(calls, [mama.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neznámý odesílatel nedostane potvrzení přečtení', () async {
|
||||||
|
await storage.setSetting('read_receipts_enabled', true);
|
||||||
|
final cizi = await LocalIdentity.create('Někdo');
|
||||||
|
await deliver(cizi, 'e' * 32, text('Někdo', 'ahoj'));
|
||||||
|
final zadost = await contactOf(cizi);
|
||||||
|
sent.clear();
|
||||||
|
await service.sendReadReceipts(zadost, ['m1']);
|
||||||
|
expect(sent, isEmpty);
|
||||||
|
|
||||||
|
await service.sendReadReceipts(await contactOf(mama), ['m2']);
|
||||||
|
expect(sent.single.$2['t'], 'rr');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('známý kontakt dál chodí do své konverzace beze změny', () async {
|
||||||
|
await deliver(mama, mamaAccount, text('Úplně jiné jméno', 'ahoj'));
|
||||||
|
final kontakty = await storage.loadContacts();
|
||||||
|
expect(kontakty, hasLength(1));
|
||||||
|
expect(kontakty.single.displayName, 'Máma',
|
||||||
|
reason: 'jméno z profilu odesílatele nepřepisuje moje');
|
||||||
|
expect(kontakty.single.accepted, isTrue);
|
||||||
|
expect(kontakty.single.verified, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kontakt uložený před 1.2.3 (bez příznaku) je přijatý', () {
|
||||||
|
final json = mamaContact.toJson()..remove('accepted');
|
||||||
|
expect(Contact.fromJson(json).accepted, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('odesílání: relace patřící cizímu klíči', () {
|
||||||
|
late SepotCrypto ja2;
|
||||||
|
late ProtocolAddress addr;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
addr = ProtocolAddress(name: mamaAccount, deviceId: 1);
|
||||||
|
ja2 = SepotCrypto.fromPrivateKey(
|
||||||
|
privateKey: ja.identityPrivateKey,
|
||||||
|
accountId: 'f' * 32,
|
||||||
|
registrationId: 7,
|
||||||
|
storesFactory: (pair) => SepotStores(
|
||||||
|
session: DbSessionStore(db),
|
||||||
|
identity: DbIdentityKeyStore(db,
|
||||||
|
identityKeyPair: pair, registrationId: 7),
|
||||||
|
preKey: DbPreKeyStore(db),
|
||||||
|
signedPreKey: DbSignedPreKeyStore(db),
|
||||||
|
kyberPreKey: DbKyberPreKeyStore(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<SepotPreKeyBundle> bundleOf(LocalIdentity id) =>
|
||||||
|
SepotCrypto.fromPrivateKey(
|
||||||
|
privateKey: id.identityPrivateKey,
|
||||||
|
accountId: mamaAccount,
|
||||||
|
registrationId: 9,
|
||||||
|
).generatePreKeyBundle(
|
||||||
|
preKeyId: 1,
|
||||||
|
signedPreKeyId: 1,
|
||||||
|
kyberPreKeyId: 1,
|
||||||
|
timestampMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('relace s klíčem kontaktu zůstává', () async {
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(mama));
|
||||||
|
await service.debugDropForeignSession(ja2, addr, mamaContact);
|
||||||
|
expect(await ja2.hasSession(addr), isTrue);
|
||||||
|
expect((await contactOf(mama)).verified, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relace přepsaná cizím klíčem se zahodí a kontakt varuje', () async {
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(mama));
|
||||||
|
final utocnik = await LocalIdentity.create('Máma');
|
||||||
|
await ja2.processRemoteBundle(addr, await bundleOf(utocnik));
|
||||||
|
|
||||||
|
await service.debugDropForeignSession(ja2, addr, mamaContact);
|
||||||
|
|
||||||
|
expect(await ja2.hasSession(addr), isFalse,
|
||||||
|
reason: 'odpověď nesmí odejít útočníkovi');
|
||||||
|
final kontakt = await contactOf(mama);
|
||||||
|
expect(kontakt.verified, isFalse);
|
||||||
|
final upozorneni =
|
||||||
|
(await storage.loadConversation(kontakt.id))!.messages.single;
|
||||||
|
expect(upozorneni.notice, MessageNotice.identityChanged);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/push/push_service.dart';
|
||||||
|
import 'package:sepot/core/services/messaging_service.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
import 'package:sepot/proto/sepot.pb.dart' as pb;
|
||||||
|
|
||||||
|
/// iPhony relayi neposílaly APNs ani VoIP token: init lokálních notifikací
|
||||||
|
/// na iOS házel výjimku, ta zastavila registraci u Apple a restoreMode ji
|
||||||
|
/// spolkl. Tady se hlídá pořadí kroků a skládání registrace bez iPhonu.
|
||||||
|
class _FakeMessaging extends MessagingService {
|
||||||
|
_FakeMessaging() : super(storage: SecureStorageService());
|
||||||
|
|
||||||
|
final registrations = <pb.RegisterPush>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void registerPush(pb.RegisterPush registration) =>
|
||||||
|
registrations.add(registration);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeIos extends IosPushPlatform {
|
||||||
|
_FakeIos({
|
||||||
|
this.apns,
|
||||||
|
this.voip,
|
||||||
|
this.permissionFails = false,
|
||||||
|
this.registerFails = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? apns;
|
||||||
|
final String? voip;
|
||||||
|
final bool permissionFails;
|
||||||
|
final bool registerFails;
|
||||||
|
final calls = <String>[];
|
||||||
|
bool? failedFlag;
|
||||||
|
String? mode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isIOS => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> registerApns() async {
|
||||||
|
calls.add('register');
|
||||||
|
if (registerFails) throw StateError('apns channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> apnsToken() async => apns;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> voipToken() async => voip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> requestPermission() async {
|
||||||
|
calls.add('permission');
|
||||||
|
if (permissionFails) {
|
||||||
|
throw ArgumentError('iOS settings must be set');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> writeMode(String m) async => mode = m;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setRegistrationFailed(bool failed) async => failedFlag = failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||||
|
|
||||||
|
String text(List<int> bytes) => utf8.decode(bytes);
|
||||||
|
|
||||||
|
group('apnsRegistration', () {
|
||||||
|
test('APNs i VoIP v jedné registraci', () {
|
||||||
|
final reg = PushService.apnsRegistration(
|
||||||
|
apnsToken: 'ab12',
|
||||||
|
voipToken: 'cd34',
|
||||||
|
)!;
|
||||||
|
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
|
||||||
|
expect(text(reg.endpoint), 'ab12');
|
||||||
|
expect(text(reg.voipToken), 'cd34');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('samotný VoIP token jde s prázdným endpointem', () {
|
||||||
|
final reg = PushService.apnsRegistration(voipToken: 'cd34')!;
|
||||||
|
expect(reg.platform, pb.RegisterPush_Platform.PLATFORM_APNS);
|
||||||
|
expect(reg.endpoint, isEmpty);
|
||||||
|
expect(text(reg.voipToken), 'cd34');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bez tokenů se nic neposílá', () {
|
||||||
|
expect(PushService.apnsRegistration(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'selhané oprávnění nezastaví registraci u Apple ani token pro relay',
|
||||||
|
() async {
|
||||||
|
final messaging = _FakeMessaging();
|
||||||
|
final ios = _FakeIos(apns: 'ab12', voip: 'cd34', permissionFails: true);
|
||||||
|
await PushService(messaging, ios: ios).restoreMode();
|
||||||
|
|
||||||
|
expect(ios.calls, [
|
||||||
|
'register',
|
||||||
|
'permission',
|
||||||
|
], reason: 'registrace u Apple před dotazem na oprávnění');
|
||||||
|
expect(ios.mode, 'apns');
|
||||||
|
expect(ios.failedFlag, isFalse);
|
||||||
|
final reg = messaging.registrations.single;
|
||||||
|
expect(text(reg.endpoint), 'ab12');
|
||||||
|
expect(text(reg.voipToken), 'cd34');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('VoIP token odejde, i když APNs token ještě nepřišel', () async {
|
||||||
|
final messaging = _FakeMessaging();
|
||||||
|
final push = PushService(messaging, ios: _FakeIos(voip: 'cd34'));
|
||||||
|
await push.refreshVoipToken();
|
||||||
|
final reg = messaging.registrations.single;
|
||||||
|
expect(reg.endpoint, isEmpty);
|
||||||
|
expect(text(reg.voipToken), 'cd34');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selhaná registrace se nespolkne — stav pushů ukáže chybu', () async {
|
||||||
|
final ios = _FakeIos(registerFails: true);
|
||||||
|
await PushService(_FakeMessaging(), ios: ios).restoreMode();
|
||||||
|
expect(ios.failedFlag, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Potřebuje nativní sqlcipher a Argon2id — na builderu se vynechává (--exclude-tags ffi).
|
||||||
|
@Tags(['ffi'])
|
||||||
|
// Argon2id (64 MiB) ~2 s na pokus, test jich dělá přes deset.
|
||||||
|
@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/sepot_crypto.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
|
||||||
|
/// Opětovné ověření PINu v odemčené appce (fráze, záloha, smazání účtu):
|
||||||
|
/// stejná brzda jako zámek, nátlakový PIN ani nátlaková relace nic neodemknou.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
late Directory tmp;
|
||||||
|
late SecureStorageService storage;
|
||||||
|
|
||||||
|
setUpAll(() async => SepotCrypto.init());
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
FlutterSecureStorage.setMockInitialValues({});
|
||||||
|
tmp = Directory.systemTemp.createTempSync('sepot_verify_pin');
|
||||||
|
storage = SecureStorageService(dbPath: '${tmp.path}/app.db');
|
||||||
|
await storage.unlockWithoutPin();
|
||||||
|
await storage.enablePin('111111');
|
||||||
|
await storage.enableDecoyPin('222222');
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
storage.lock();
|
||||||
|
tmp.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skutečný PIN projde, nátlakový ne', () async {
|
||||||
|
expect(await storage.verifyPin('111111'), isTrue);
|
||||||
|
expect(await storage.verifyPin('222222'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v nátlakové relaci neprojde ani skutečný PIN', () async {
|
||||||
|
storage.lock();
|
||||||
|
await storage.unlockWithPin('222222');
|
||||||
|
expect(storage.isDecoyMode, isTrue);
|
||||||
|
expect(await storage.verifyPin('111111'), isFalse);
|
||||||
|
expect(await storage.loadSeedPhrase(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('po pěti chybách zámek, hádání se nedá obejít', () async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
expect(await storage.verifyPin('99999$i'), isFalse);
|
||||||
|
}
|
||||||
|
expect(await storage.pinLockRemaining(), isNotNull);
|
||||||
|
expect(() => storage.verifyPin('111111'),
|
||||||
|
throwsA(isA<PinLockedException>()),
|
||||||
|
reason: 'ani správný PIN během zámku');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('správný PIN vynuluje počítadlo chyb', () async {
|
||||||
|
for (var i = 0; i < 4; i++) {
|
||||||
|
await storage.verifyPin('99999$i');
|
||||||
|
}
|
||||||
|
expect(await storage.verifyPin('111111'), isTrue);
|
||||||
|
expect(await storage.verifyPin('999990'), isFalse);
|
||||||
|
expect(await storage.pinLockRemaining(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/core/storage/secure_storage.dart';
|
||||||
|
import 'package:sepot/features/auth/reauth.dart';
|
||||||
|
import 'package:sepot/features/settings/settings_screen.dart';
|
||||||
|
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:sepot/models/identity.dart';
|
||||||
|
import 'package:sepot/providers/identity_provider.dart';
|
||||||
|
import 'package:sepot/providers/storage_provider.dart';
|
||||||
|
import 'package:sepot/widgets/pin_keypad.dart';
|
||||||
|
|
||||||
|
/// 24 slov z odemčené appky jen po čerstvém PINu nebo biometrii; v nátlakové
|
||||||
|
/// relaci vůbec. Úložiště je podvržené — Argon2id a keystore testuje
|
||||||
|
/// `test/storage/verify_pin_test.dart`.
|
||||||
|
class _FakeStorage extends SecureStorageService {
|
||||||
|
_FakeStorage({this.pin = true, this.bio = false, this.decoy = false});
|
||||||
|
|
||||||
|
final bool pin;
|
||||||
|
final bool bio;
|
||||||
|
final bool decoy;
|
||||||
|
int seedReads = 0;
|
||||||
|
|
||||||
|
static const correctPin = '123456';
|
||||||
|
static final words = List.generate(24, (i) => 'slovo$i');
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isDecoyMode => decoy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasPin() async => pin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasDecoyPin() async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isBiometricsEnabled() async => bio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Duration?> pinLockRemaining() async => null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> verifyPin(String pin) async => pin == correctPin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>?> loadSeedPhrase() async {
|
||||||
|
seedReads++;
|
||||||
|
return decoy ? null : words;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeIdentityNotifier extends IdentityNotifier {
|
||||||
|
@override
|
||||||
|
Future<LocalIdentity?> build() async => LocalIdentity(
|
||||||
|
displayName: 'Alice',
|
||||||
|
identityPrivateKey: Uint8List(32),
|
||||||
|
identityPublicKey: Uint8List(33),
|
||||||
|
createdAt: DateTime.utc(2026, 1, 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
|
||||||
|
|
||||||
|
Future<_FakeStorage> pump(
|
||||||
|
WidgetTester tester, {
|
||||||
|
bool pin = true,
|
||||||
|
bool bio = false,
|
||||||
|
bool bioResult = false,
|
||||||
|
bool decoy = false,
|
||||||
|
}) async {
|
||||||
|
final storage = _FakeStorage(pin: pin, bio: bio, decoy: decoy);
|
||||||
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
|
tester.view.devicePixelRatio = 2.5;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
storageProvider.overrideWithValue(storage),
|
||||||
|
identityProvider.overrideWith(_FakeIdentityNotifier.new),
|
||||||
|
biometricPromptProvider.overrideWithValue((_) async => bioResult),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('cs'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const SettingsScreen(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> openSeed(WidgetTester tester) async {
|
||||||
|
final tile = find.text('Zálohovací fráze');
|
||||||
|
await tester.scrollUntilVisible(tile, 200,
|
||||||
|
scrollable: find.byType(Scrollable).first);
|
||||||
|
await tester.tap(tile);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> typePin(WidgetTester tester, String pin) async {
|
||||||
|
for (final d in pin.split('')) {
|
||||||
|
await tester.tap(find.descendant(
|
||||||
|
of: find.byType(PinKeypad), matching: find.text(d)));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstWord = find.textContaining('slovo0');
|
||||||
|
|
||||||
|
testWidgets('fráze se bez PINu neukáže ani nenačte', (tester) async {
|
||||||
|
final storage = await pump(tester);
|
||||||
|
await openSeed(tester);
|
||||||
|
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
expect(storage.seedReads, 0, reason: 'z keystore až po ověření');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Zrušit'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
expect(storage.seedReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('špatný PIN frázi neukáže', (tester) async {
|
||||||
|
final storage = await pump(tester);
|
||||||
|
await openSeed(tester);
|
||||||
|
await typePin(tester, '000000');
|
||||||
|
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
expect(storage.seedReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('správný PIN frázi ukáže', (tester) async {
|
||||||
|
final storage = await pump(tester);
|
||||||
|
await openSeed(tester);
|
||||||
|
await typePin(tester, _FakeStorage.correctPin);
|
||||||
|
expect(firstWord, findsOneWidget);
|
||||||
|
expect(storage.seedReads, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('každé zobrazení chce PIN znovu', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
await openSeed(tester);
|
||||||
|
await typePin(tester, _FakeStorage.correctPin);
|
||||||
|
await tester.tap(find.text('Zavřít'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await openSeed(tester);
|
||||||
|
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('úspěšná biometrie stačí', (tester) async {
|
||||||
|
await pump(tester, bio: true, bioResult: true);
|
||||||
|
await openSeed(tester);
|
||||||
|
expect(firstWord, findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('zrušená biometrie spadne na PIN', (tester) async {
|
||||||
|
await pump(tester, bio: true, bioResult: false);
|
||||||
|
await openSeed(tester);
|
||||||
|
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('bez nastaveného PINu fráze nejde', (tester) async {
|
||||||
|
final storage = await pump(tester, pin: false);
|
||||||
|
await openSeed(tester);
|
||||||
|
expect(find.textContaining('Nejdřív si ho nastav'), findsOneWidget);
|
||||||
|
expect(firstWord, findsNothing);
|
||||||
|
expect(storage.seedReads, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('nátlaková relace frázi vůbec nenabízí', (tester) async {
|
||||||
|
await pump(tester, decoy: true);
|
||||||
|
expect(find.text('Zálohovací fráze', skipOffstage: false), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('smazání účtu bez PINu neproběhne', (tester) async {
|
||||||
|
await pump(tester);
|
||||||
|
final button = find.text('Smazat všechna data');
|
||||||
|
await tester.scrollUntilVisible(button, 200,
|
||||||
|
scrollable: find.byType(Scrollable).first);
|
||||||
|
await tester.tap(button);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(ElevatedButton).last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Potvrď, že jsi to ty'), findsOneWidget);
|
||||||
|
await typePin(tester, '999999');
|
||||||
|
expect(find.text('Špatný PIN.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Pinned native crypto library (libsignal_frb) shipped in the Android app.
|
||||||
|
#
|
||||||
|
# The Dart package `libsignal` downloads a prebuilt `libsignal_frb` from the
|
||||||
|
# GitHub releases of djx-y-z/libsignal_dart during the build. Its own check
|
||||||
|
# only compares against a checksum file from the same release, so a replaced
|
||||||
|
# release would go unnoticed. These pins live in our repository instead and
|
||||||
|
# tool/verify_native_libs.sh fails the release build on any mismatch.
|
||||||
|
#
|
||||||
|
# package = version of the `libsignal` Dart package in pubspec.lock
|
||||||
|
# archive = SHA-256 of the upstream release archive
|
||||||
|
# https://github.com/djx-y-z/libsignal_dart/releases/tag/libsignal_frb-4.0.9
|
||||||
|
# shipped = SHA-256 of liblibsignal_frb.so inside our AAB/APK. The Android
|
||||||
|
# Gradle plugin strips the library while packaging; the value equals
|
||||||
|
# `llvm-strip --strip-unneeded` (NDK 28) applied to the file from the
|
||||||
|
# archive. `verify_native_libs.sh --from-upstream` re-derives it.
|
||||||
|
#
|
||||||
|
# Updating libsignal = update every line here in the same commit.
|
||||||
|
|
||||||
|
package libsignal 5.0.9
|
||||||
|
archive android-arm64-v8a b03c1f47b07ded392a9a44b217b3d24470f0dce69901c67061c1f1295f96065f
|
||||||
|
archive android-armeabi-v7a 0dc16fbbfe2bf5b3e7758fd2b297c831fee296c55ac19b4f94a9e0086d26b9b0
|
||||||
|
archive android-x86_64 cdb1623a855fa508dd84c025b267eaac846f882637450f00a74199bdf2d85264
|
||||||
|
shipped arm64-v8a 39612f422ed1dc2c7576165c5c169d7911f64de093eeea4ee8d3047b884c1c0a
|
||||||
|
shipped armeabi-v7a b442795a864fe0ae15a1e04a151863244fedda3b8854b2d22ec9b23e59227e78
|
||||||
|
shipped x86_64 59e5925c2d6f5e9496cbe86d1a7d19965dc4060efe94cd452d58ba651fe7a122
|
||||||
Executable
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Checks the libsignal native library against the pins in native_libs.sha256.
|
||||||
|
#
|
||||||
|
# verify_native_libs.sh <app.aab|app.apk> every ABI in the artifact must carry
|
||||||
|
# exactly the pinned library
|
||||||
|
# verify_native_libs.sh --from-upstream download the upstream archives, check
|
||||||
|
# their pins and re-derive the shipped
|
||||||
|
# hashes (needs llvm-strip from the NDK)
|
||||||
|
#
|
||||||
|
# Exit code 0 = match, anything else = do not ship.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOOL_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
PINS="$TOOL_DIR/native_libs.sha256"
|
||||||
|
LOCK="$TOOL_DIR/../pubspec.lock"
|
||||||
|
LIB=liblibsignal_frb.so
|
||||||
|
|
||||||
|
die() { echo "verify_native_libs: $*" >&2; exit 1; }
|
||||||
|
pin() { awk -v k="$1" -v n="$2" '$1 == k && $2 == n { print $3 }' "$PINS"; }
|
||||||
|
|
||||||
|
pinned_package=$(pin package libsignal)
|
||||||
|
[ -n "$pinned_package" ] || die "no package line in $PINS"
|
||||||
|
|
||||||
|
# A libsignal upgrade without new pins must stop here with a clear reason,
|
||||||
|
# not later as an unexplained hash mismatch.
|
||||||
|
if [ -f "$LOCK" ]; then
|
||||||
|
locked=$(awk '/^ libsignal:$/ { f = 1 } f && /^ version:/ { gsub(/"/, "", $2); print $2; exit }' "$LOCK")
|
||||||
|
[ "$locked" = "$pinned_package" ] ||
|
||||||
|
die "pubspec.lock has libsignal $locked, pins are for $pinned_package — update $PINS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sha() { sha256sum "$1" | cut -d' ' -f1; }
|
||||||
|
|
||||||
|
verify_artifact() {
|
||||||
|
local artifact=$1 prefix entries abi want got checked=0
|
||||||
|
[ -f "$artifact" ] || die "file not found: $artifact"
|
||||||
|
case "$artifact" in
|
||||||
|
*.aab) prefix=base/lib ;;
|
||||||
|
*.apk) prefix=lib ;;
|
||||||
|
*) die "expected .aab or .apk: $artifact" ;;
|
||||||
|
esac
|
||||||
|
entries=$(unzip -Z1 "$artifact" | grep -E "^$prefix/[^/]+/$LIB\$" || true)
|
||||||
|
[ -n "$entries" ] || die "$LIB not found in $artifact"
|
||||||
|
for entry in $entries; do
|
||||||
|
abi=$(basename "$(dirname "$entry")")
|
||||||
|
want=$(pin shipped "$abi")
|
||||||
|
[ -n "$want" ] || die "no pin for ABI $abi"
|
||||||
|
got=$(unzip -p "$artifact" "$entry" | sha256sum | cut -d' ' -f1)
|
||||||
|
[ "$got" = "$want" ] || die "$abi: $got does not match pinned $want"
|
||||||
|
echo "ok $abi $got"
|
||||||
|
checked=$((checked + 1))
|
||||||
|
done
|
||||||
|
# Every pinned ABI has to be present: a missing one would crash on those devices.
|
||||||
|
for abi in $(awk '$1 == "shipped" { print $2 }' "$PINS"); do
|
||||||
|
grep -q "^$prefix/$abi/$LIB\$" <<<"$entries" || die "$abi missing in $artifact"
|
||||||
|
done
|
||||||
|
echo "verify_native_libs: $checked libraries match the pins"
|
||||||
|
}
|
||||||
|
|
||||||
|
from_upstream() {
|
||||||
|
local strip=${LLVM_STRIP:-} work version url abi want got
|
||||||
|
if [ -z "$strip" ]; then
|
||||||
|
strip=$(ls "${ANDROID_HOME:-/opt/android-sdk}"/ndk/*/toolchains/llvm/prebuilt/*/bin/llvm-strip 2>/dev/null | sort -V | tail -1)
|
||||||
|
fi
|
||||||
|
[ -x "$strip" ] || die "llvm-strip not found, set LLVM_STRIP"
|
||||||
|
# Archive names carry the version of the native crate, not of the Dart package.
|
||||||
|
version=$(sed -n 's/.*releases\/tag\/libsignal_frb-\([0-9.]*\).*/\1/p' "$PINS" | head -1)
|
||||||
|
[ -n "$version" ] || die "no release tag in $PINS"
|
||||||
|
url="https://github.com/djx-y-z/libsignal_dart/releases/download/libsignal_frb-$version"
|
||||||
|
work=$(mktemp -d); trap 'rm -rf "$work"' RETURN
|
||||||
|
for abi in $(awk '$1 == "shipped" { print $2 }' "$PINS"); do
|
||||||
|
curl -fsSL -o "$work/$abi.tar.gz" "$url/libsignal_frb-$version-android-$abi.tar.gz"
|
||||||
|
want=$(pin archive "android-$abi"); got=$(sha "$work/$abi.tar.gz")
|
||||||
|
[ "$got" = "$want" ] || die "archive android-$abi: $got does not match pinned $want"
|
||||||
|
mkdir "$work/$abi" && tar -xzf "$work/$abi.tar.gz" -C "$work/$abi" "$LIB"
|
||||||
|
"$strip" --strip-unneeded "$work/$abi/$LIB"
|
||||||
|
want=$(pin shipped "$abi"); got=$(sha "$work/$abi/$LIB")
|
||||||
|
[ "$got" = "$want" ] || die "stripped $abi: $got does not match pinned $want"
|
||||||
|
echo "ok $abi archive and stripped library match"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--from-upstream) from_upstream ;;
|
||||||
|
""|-h|--help) sed -n '2,10p' "$0"; exit 64 ;;
|
||||||
|
*) verify_artifact "$1" ;;
|
||||||
|
esac
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
# Verifying Šepot
|
||||||
|
|
||||||
|
This document lists what you can check yourself today, how to do it, and what
|
||||||
|
cannot be checked yet. If something here does not match what you observe,
|
||||||
|
please report it to security@sepot.cz.
|
||||||
|
|
||||||
|
## 1. Which relay code is running
|
||||||
|
|
||||||
|
The relay announces its source and revision:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s https://relay.sepot.cz/v1/server-info
|
||||||
|
# {"source":"https://github.com/juliusjoska/sepot","revision":"<commit>", ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
The revision is baked into the container image at build time
|
||||||
|
(`deploy/relay/Dockerfile`, build argument `GIT_SHA`, also stored as the OCI
|
||||||
|
label `org.opencontainers.image.revision`). To read the code behind it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/juliusjoska/sepot && cd sepot
|
||||||
|
git show --stat <commit>
|
||||||
|
git log --oneline <commit> -- rust/ proto/ deploy/relay/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current state (October 2026).** The relay deployed on 9 October 2026 reports
|
||||||
|
`4b3f4bb`. That is a commit of our private development repository, so GitHub
|
||||||
|
does not know it. The public commit with the same relay code is
|
||||||
|
[`b824f62`](https://github.com/juliusjoska/sepot/commit/b824f628788afa3e1a3b64fa90de1fc7bf50feaf).
|
||||||
|
The two trees differ only in source comments, test fixture IP addresses in
|
||||||
|
`push.rs`, the `repository` URL in `rust/Cargo.toml`, and migration
|
||||||
|
`0001_init.sql`, which in the private tree already contained the
|
||||||
|
`attempt_count` column that `0003_queue_attempt_count.sql` adds with
|
||||||
|
`ADD COLUMN IF NOT EXISTS`. The resulting schema and behaviour are the same.
|
||||||
|
|
||||||
|
**From the next relay deployment on**, images are built from this public
|
||||||
|
repository only. The build script refuses a commit that is not on `main` here
|
||||||
|
and not yet mirrored to GitHub, and refuses to build when the relay sources in
|
||||||
|
the public and the development repository differ. The reported revision is
|
||||||
|
then a full commit hash you can open on GitHub.
|
||||||
|
|
||||||
|
What this does *not* prove: the revision is reported by the server itself.
|
||||||
|
There is no remote attestation, so you are trusting us that the binary was
|
||||||
|
built from that commit. The relay is designed so that this trust matters as
|
||||||
|
little as possible: message content is end-to-end encrypted on your device and
|
||||||
|
the relay never holds the keys.
|
||||||
|
|
||||||
|
## 2. Android APK
|
||||||
|
|
||||||
|
Direct downloads are attached to the
|
||||||
|
[GitHub releases](https://github.com/juliusjoska/sepot/releases), together
|
||||||
|
with a `SHA256SUMS` file.
|
||||||
|
|
||||||
|
Since 1.2.1 the APK on GitHub is the universal APK that Google Play generates
|
||||||
|
from the same app bundle it serves to Play users, signed with the same key.
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- it installs over the Google Play version and vice versa, and
|
||||||
|
- its signing certificate must be:
|
||||||
|
|
||||||
|
```
|
||||||
|
SHA-256: 9B:4C:DA:44:A1:80:81:26:3D:79:86:01:2B:9D:11:CA:72:AB:D0:24:9C:D9:DC:AB:82:5F:BE:AE:95:40:2D:02
|
||||||
|
apksigner prints it as 9b4cda44a18081263d7986012b9d11ca72abd0249cd9dcab825fbeae95402d02
|
||||||
|
```
|
||||||
|
|
||||||
|
Check a download:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sha256sum -c sepot-1.2.1-SHA256SUMS.txt
|
||||||
|
apksigner verify --print-certs sepot-1.2.1-universal.apk | grep 'SHA-256'
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the app already installed on your phone (USB debugging enabled):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb pull "$(adb shell pm path cz.sepot.sepot | head -1 | cut -d: -f2)" installed.apk
|
||||||
|
apksigner verify --print-certs installed.apk | grep 'SHA-256'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Older direct APKs (1.1.1 and earlier)** were signed with our upload key
|
||||||
|
(`E2:13:D6:60:63:B9:0E:A1:73:90:1C:37:9E:4B:43:9C:76:8A:F1:AF:C2:D6:55:68:CC:46:CF:31:36:72:3F:31`).
|
||||||
|
Android will not update such an install with the 1.2.1 APK. Write down your
|
||||||
|
24-word recovery phrase, uninstall, install the new APK and restore the
|
||||||
|
account from the phrase. Message history is not carried over.
|
||||||
|
|
||||||
|
Source of each release is the tag of the same name (`v1.2.1`, …). The public
|
||||||
|
tree is an export of our development tree: comments are rewritten, deployment
|
||||||
|
configuration is left out, and so is a store-screenshot demo mode. That mode
|
||||||
|
is behind a compile-time flag (`SEPOT_DEMO`) that is not set for release
|
||||||
|
builds, so the compiler removes it.
|
||||||
|
|
||||||
|
## 3. Native cryptography library
|
||||||
|
|
||||||
|
End-to-end encryption uses [libsignal](https://github.com/signalapp/libsignal)
|
||||||
|
through the Dart package [`libsignal`](https://pub.dev/packages/libsignal)
|
||||||
|
(5.0.9 at the moment). The Dart package itself is pinned by its SHA-256 in
|
||||||
|
`app/pubspec.lock`. Its build hook downloads a prebuilt native library,
|
||||||
|
`libsignal_frb`, from the GitHub releases of
|
||||||
|
[djx-y-z/libsignal_dart](https://github.com/djx-y-z/libsignal_dart) and only
|
||||||
|
compares it with a checksum file from the same release.
|
||||||
|
|
||||||
|
We pin those binaries ourselves in
|
||||||
|
[`app/tool/native_libs.sha256`](../app/tool/native_libs.sha256): the upstream
|
||||||
|
archives and the library exactly as it ends up in the APK (the Android Gradle
|
||||||
|
plugin strips it while packaging). Our release build runs
|
||||||
|
`app/tool/verify_native_libs.sh` on the finished bundle and does not ship it on
|
||||||
|
any mismatch.
|
||||||
|
|
||||||
|
You can repeat both checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# the library inside an APK or AAB matches the pins
|
||||||
|
app/tool/verify_native_libs.sh sepot-1.2.1-universal.apk
|
||||||
|
|
||||||
|
# the pins follow from the upstream release: download, check archive hashes,
|
||||||
|
# strip with llvm-strip from the Android NDK and compare
|
||||||
|
LLVM_STRIP=/path/to/ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip \
|
||||||
|
app/tool/verify_native_libs.sh --from-upstream
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. What is not reproducible (yet)
|
||||||
|
|
||||||
|
- **Android builds are not bit-for-bit reproducible.** A build from the tag
|
||||||
|
gives you a working app, but not the same bytes: the Flutter AOT snapshot
|
||||||
|
depends on the build environment, and Google Play re-signs and splits our
|
||||||
|
bundle. Today you can compare the signing certificate, the APK hash between
|
||||||
|
download channels, and the native crypto library. You cannot compare the
|
||||||
|
compiled Dart code against your own build.
|
||||||
|
- **`libsignal_frb` is a third-party prebuilt binary.** The pins guarantee
|
||||||
|
that every build ships the same file and that the file is the published
|
||||||
|
upstream release. They do not prove that the release was built from the
|
||||||
|
`libsignal_frb` source; the upstream release has no build provenance
|
||||||
|
attestation.
|
||||||
|
- **iOS** binaries from the App Store are encrypted by Apple and cannot be
|
||||||
|
compared with anything.
|
||||||
|
- **The relay image** is not reproducible either: the base images are
|
||||||
|
referenced by tag and the build applies current Debian security updates.
|
||||||
|
|
||||||
|
## 5. Plan
|
||||||
|
|
||||||
|
1. Relay revision equal to a public commit: build tooling is in place, the next
|
||||||
|
relay deployment will report it.
|
||||||
|
2. Build `libsignal_frb` from source on our own build server together with the
|
||||||
|
upgrade to the current libsignal release, and publish the resulting hashes
|
||||||
|
next to the pins above.
|
||||||
|
3. Relay: pin base images by digest and publish the image digest with each
|
||||||
|
relay deployment.
|
||||||
|
4. Work towards a reproducible Android build that independent rebuilders
|
||||||
|
(for example F-Droid) can confirm.
|
||||||
Generated
+16
-132
@@ -633,16 +633,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "forwarded-header-value"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9"
|
|
||||||
dependencies = [
|
|
||||||
"nonempty",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1059,19 +1049,6 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.8",
|
"webpki-roots 1.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hyper-timeout"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
|
||||||
dependencies = [
|
|
||||||
"hyper",
|
|
||||||
"hyper-util",
|
|
||||||
"pin-project-lite",
|
|
||||||
"tokio",
|
|
||||||
"tower-service",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1440,7 +1417,7 @@ dependencies = [
|
|||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
@@ -1488,7 +1465,7 @@ dependencies = [
|
|||||||
"signal-crypto",
|
"signal-crypto",
|
||||||
"spqr",
|
"spqr",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
@@ -1590,12 +1567,6 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nonempty"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nonzero_ext"
|
name = "nonzero_ext"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1788,26 +1759,6 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project"
|
|
||||||
version = "1.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
|
||||||
dependencies = [
|
|
||||||
"pin-project-internal",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-internal"
|
|
||||||
version = "1.1.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2071,7 +2022,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2092,7 +2043,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -2277,6 +2228,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -2445,6 +2397,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"governor",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
@@ -2459,11 +2412,11 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower-http 0.7.0",
|
"tower-http 0.7.0",
|
||||||
"tower_governor",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -2603,7 +2556,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2635,7 +2588,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2718,7 +2671,7 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sorted-vec",
|
"sorted-vec",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2763,7 +2716,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2847,7 +2800,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -2885,7 +2838,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"whoami",
|
"whoami",
|
||||||
]
|
]
|
||||||
@@ -2910,7 +2863,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -2982,33 +2935,13 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3190,35 +3123,6 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tonic"
|
|
||||||
version = "0.14.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"h2",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-timeout",
|
|
||||||
"hyper-util",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project",
|
|
||||||
"socket2",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tower",
|
|
||||||
"tower-layer",
|
|
||||||
"tower-service",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -3227,12 +3131,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"indexmap",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3285,23 +3186,6 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tower_governor"
|
|
||||||
version = "0.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "44de9b94d849d3c46e06a883d72d408c2de6403367b39df2b1c9d9e7b6736fe6"
|
|
||||||
dependencies = [
|
|
||||||
"axum",
|
|
||||||
"forwarded-header-value",
|
|
||||||
"governor",
|
|
||||||
"http",
|
|
||||||
"pin-project",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tonic",
|
|
||||||
"tower",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -3383,7 +3267,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ serde_json = "1"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
|
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
# Proof-of-work registrace (pow.rs); už v grafu závislostí přes libsignal
|
||||||
|
sha2 = "0.10"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
@@ -42,13 +44,15 @@ tokio-util = { version = "0.7", features = ["io"] }
|
|||||||
# při AUTH. Relay NIKDY nešifruje/nedešifruje zprávy (hloupý relay).
|
# při AUTH. Relay NIKDY nešifruje/nedešifruje zprávy (hloupý relay).
|
||||||
libsignal-protocol = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
libsignal-protocol = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
||||||
libsignal-core = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
libsignal-core = { git = "https://github.com/signalapp/libsignal", tag = "v0.96.4" }
|
||||||
tower_governor = "0.8.0"
|
# Rate limity (token bucket per IP / Tor / globálně, limits.rs)
|
||||||
|
governor = "0.10"
|
||||||
tower-http = { version = "0.7.0", features = ["timeout"] }
|
tower-http = { version = "0.7.0", features = ["timeout"] }
|
||||||
|
|
||||||
# Push budíčky (UnifiedPush/WebPush = POST na endpoint). rustls, ať build
|
# Push budíčky (UnifiedPush/WebPush = POST na endpoint). rustls, ať build
|
||||||
# zůstane bez OpenSSL (stejně jako sqlx tls-rustls). Bez default featur —
|
# zůstane bez OpenSSL (stejně jako sqlx tls-rustls). Bez default featur —
|
||||||
# nechceme native-tls ani zbytečné kodeky.
|
# nechceme native-tls ani zbytečné kodeky. http2: APNs jiný protokol
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
# nepřijme (HTTP/1.1 spojení odmítne) — bez něj nedorazil žádný iOS budíček.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "http2"] }
|
||||||
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
# FCM fast mode: RS256 JWT pro OAuth2 service account flow
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
|
# setrlimit(RLIMIT_NOFILE) při startu — docker dává soft limit 1024
|
||||||
|
|||||||
@@ -201,4 +201,26 @@ mod tests {
|
|||||||
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
assert!(limit <= 30 * 86_400, "APNs nedrží notifikace déle než 30 dní");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// APNs mluví jen HTTP/2. Bez featury `http2` v reqwestu relay spojení
|
||||||
|
/// vůbec nenaváže a iPhone žádný budíček nedostane. Bez platného JWT
|
||||||
|
/// vrátí Apple 403 (nebo 400) — to stačí: spojení i protokol sedí.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje síť (api.push.apple.com)"]
|
||||||
|
async fn apns_spojeni_jde_pres_http2() {
|
||||||
|
let client = crate::push::build_client();
|
||||||
|
let res = client
|
||||||
|
.post(format!("{PROD_HOST}/3/device/00"))
|
||||||
|
.header("apns-topic", "cz.sepot.sepot")
|
||||||
|
.body("{}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("spojení s APNs");
|
||||||
|
assert_eq!(res.version(), reqwest::Version::HTTP_2);
|
||||||
|
assert!(
|
||||||
|
matches!(res.status().as_u16(), 400 | 403),
|
||||||
|
"čekána odpověď APNs bez platného tokenu, přišlo {}",
|
||||||
|
res.status()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,24 +111,86 @@ pub async fn upload(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Body,
|
body: Body,
|
||||||
) -> ApiResult<(StatusCode, Json<UploadOut>)> {
|
) -> ApiResult<(StatusCode, Json<UploadOut>)> {
|
||||||
|
let authed =
|
||||||
crate::rest::authenticate_device(&state, &headers, "POST /v1/attachments").await?;
|
crate::rest::authenticate_device(&state, &headers, "POST /v1/attachments").await?;
|
||||||
|
|
||||||
let cfg = &state.attachments;
|
let cfg = &state.attachments;
|
||||||
|
|
||||||
// Rychlé odmítnutí podle Content-Length, než tečou data
|
// Rychlé odmítnutí podle Content-Length, než tečou data
|
||||||
if let Some(len) = headers
|
let declared = headers
|
||||||
.get(header::CONTENT_LENGTH)
|
.get(header::CONTENT_LENGTH)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok());
|
||||||
{
|
if declared.is_some_and(|len| len > cfg.max_bytes) {
|
||||||
if len > cfg.max_bytes {
|
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::PAYLOAD_TOO_LARGE,
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
"příloha je příliš velká".into(),
|
"příloha je příliš velká".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Místo na disku dřív než cokoli dalšího: přílohy a DB sdílí filesystém
|
||||||
|
// a plný disk by zastavil i zprávy (disk.rs).
|
||||||
|
if state
|
||||||
|
.disk
|
||||||
|
.below(state.limits.disk_min_free_pct_attachments)
|
||||||
|
{
|
||||||
|
return Err((
|
||||||
|
StatusCode::INSUFFICIENT_STORAGE,
|
||||||
|
"úložiště příloh je plné, zkus to později".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kvóta účtu: rezervuje se ohlášená velikost (bez Content-Length strop
|
||||||
|
// jedné přílohy) a po zápisu se nespotřebovaný zbytek vrátí. Jen
|
||||||
|
// v paměti — vazba účet → blob se neukládá (viz hlavička modulu).
|
||||||
|
let reserved = declared.unwrap_or(cfg.max_bytes);
|
||||||
|
let account = authed.account_id;
|
||||||
|
if !state.attachment_budget.try_take(&account, reserved) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"denní limit příloh vyčerpán, zkus to později".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !state.attachment_stored.try_reserve(&account, reserved) {
|
||||||
|
state.attachment_budget.refund(&account, reserved);
|
||||||
|
return Err((
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"příliš mnoho uložených příloh, počkej, až je příjemci stáhnou".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let refund = |unused: u64| {
|
||||||
|
state.attachment_budget.refund(&account, unused);
|
||||||
|
state.attachment_stored.release(&account, unused);
|
||||||
|
};
|
||||||
|
let result = store_upload(&state, body).await;
|
||||||
|
match &result {
|
||||||
|
Ok(stored) => refund(reserved.saturating_sub(stored.size_bytes)),
|
||||||
|
Err(_) => refund(reserved),
|
||||||
|
}
|
||||||
|
result.map(|stored| (StatusCode::CREATED, Json(stored.into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Výsledek zápisu včetně skutečné velikosti (pro vyúčtování kvóty).
|
||||||
|
struct Stored {
|
||||||
|
blob_id: String,
|
||||||
|
expires_at_ms: i64,
|
||||||
|
max_bytes: u64,
|
||||||
|
size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Stored> for UploadOut {
|
||||||
|
fn from(s: Stored) -> Self {
|
||||||
|
Self {
|
||||||
|
blob_id: s.blob_id,
|
||||||
|
expires_at_ms: s.expires_at_ms,
|
||||||
|
max_bytes: s.max_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn store_upload(state: &AppState, body: Body) -> ApiResult<Stored> {
|
||||||
|
let cfg = &state.attachments;
|
||||||
|
|
||||||
// Globální strop: součet živých příloh + tahle nesmí přesáhnout
|
// 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
|
// total_bytes. Jeden SELECT nad malou tabulkou; nic o zařízení se
|
||||||
// k blobu neváže.
|
// k blobu neváže.
|
||||||
@@ -192,14 +254,12 @@ pub async fn upload(
|
|||||||
// Log bez blob_id — jen velikost (kapacitní metrika)
|
// Log bez blob_id — jen velikost (kapacitní metrika)
|
||||||
tracing::debug!(size = written, "příloha uložena");
|
tracing::debug!(size = written, "příloha uložena");
|
||||||
|
|
||||||
Ok((
|
Ok(Stored {
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(UploadOut {
|
|
||||||
blob_id: blob_hex,
|
blob_id: blob_hex,
|
||||||
expires_at_ms: expires_at.timestamp_millis(),
|
expires_at_ms: expires_at.timestamp_millis(),
|
||||||
max_bytes: cfg.max_bytes,
|
max_bytes: cfg.max_bytes,
|
||||||
}),
|
size_bytes: written,
|
||||||
))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_stream(body: Body, path: &FsPath, max_bytes: u64) -> ApiResult<u64> {
|
async fn write_stream(body: Body, path: &FsPath, max_bytes: u64) -> ApiResult<u64> {
|
||||||
@@ -343,6 +403,7 @@ mod db_tests {
|
|||||||
total_bytes: 1 << 30,
|
total_bytes: 1 << 30,
|
||||||
},
|
},
|
||||||
trusted,
|
trusted,
|
||||||
|
crate::limits::Limits::from_env(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! Hlídání volného místa na datovém filesystému.
|
||||||
|
//!
|
||||||
|
//! Přílohy i DB leží v produkci na jednom filesystému (svazky Dockeru v CT).
|
||||||
|
//! Když dojde místo, Postgres přestane zapisovat a relay je mrtvý pro všechny
|
||||||
|
//! — i pro registrace a doručení online. Relay proto přestane přijímat to, co
|
||||||
|
//! disk plní (přílohy, pak offline fronta) o kus dřív, než místo dojde.
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Jak často se místo měří. Zápis 25 MiB přílohy trvá déle než tohle okno,
|
||||||
|
/// takže práh nepřeskočí ani souběžný nápor.
|
||||||
|
const MEASURE_EVERY: Duration = Duration::from_secs(5);
|
||||||
|
/// Pod 20 % volna se do logu zapíše varování (dřív, než zabere kterýkoli
|
||||||
|
/// práh odmítání), aby si toho Loki alert všiml s předstihem.
|
||||||
|
const WARN_BELOW_BP: u32 = 2000;
|
||||||
|
|
||||||
|
/// Volné místo v setinách procenta; `u32::MAX` = ještě neměřeno / nejde měřit
|
||||||
|
/// (pak se nic neblokuje — chyba měření nesmí shodit provoz).
|
||||||
|
pub struct DiskWatch {
|
||||||
|
free_bp: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskWatch {
|
||||||
|
pub fn unknown() -> Self {
|
||||||
|
Self {
|
||||||
|
free_bp: AtomicU32::new(u32::MAX),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volné místo v procentech, `None` = neznámé.
|
||||||
|
pub fn free_pct(&self) -> Option<f64> {
|
||||||
|
match self.free_bp.load(Ordering::Relaxed) {
|
||||||
|
u32::MAX => None,
|
||||||
|
bp => Some(f64::from(bp) / 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Je volného místa méně než `min_pct`?
|
||||||
|
pub fn below(&self, min_pct: f64) -> bool {
|
||||||
|
self.free_pct().is_some_and(|free| free < min_pct)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn set_free_pct(&self, pct: f64) {
|
||||||
|
self.free_bp.store((pct * 100.0) as u32, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(self: &Arc<Self>, path: &Path) {
|
||||||
|
let watch = self.clone();
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(MEASURE_EVERY);
|
||||||
|
let mut was_low = false;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let measured = {
|
||||||
|
let path = path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || free_basis_points(&path))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
let Some(bp) = measured else {
|
||||||
|
tracing::warn!("volné místo na disku nejde změřit");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
watch.free_bp.store(bp, Ordering::Relaxed);
|
||||||
|
// Změnu stavu zalogovat jednou, ne každých 5 s.
|
||||||
|
let low = bp < WARN_BELOW_BP;
|
||||||
|
if low != was_low {
|
||||||
|
tracing::warn!(volno_pct = f64::from(bp) / 100.0, "volné místo na disku");
|
||||||
|
was_low = low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// statvfs: místo dostupné neprivilegovanému procesu (`f_bavail`) vůči
|
||||||
|
/// velikosti filesystému. Na ZFS s refquota je velikost = kvóta datasetu.
|
||||||
|
fn free_basis_points(path: &Path) -> Option<u32> {
|
||||||
|
let c_path = CString::new(path.as_os_str().as_encoded_bytes()).ok()?;
|
||||||
|
// SAFETY: statvfs jen zapisuje do předaného structu, cesta je platný C řetězec.
|
||||||
|
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { libc::statvfs(c_path.as_ptr(), &mut st) } != 0 || st.f_blocks == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((st.f_bavail as u128 * 10_000) / st.f_blocks as u128) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mereni_vrati_rozumne_cislo() {
|
||||||
|
let bp = free_basis_points(Path::new("/")).expect("statvfs /");
|
||||||
|
assert!(bp <= 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nezname_misto_nic_neblokuje() {
|
||||||
|
let w = DiskWatch::unknown();
|
||||||
|
assert!(!w.below(99.0));
|
||||||
|
w.set_free_pct(5.0);
|
||||||
|
assert!(w.below(8.0));
|
||||||
|
assert!(!w.below(4.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
//! Limity relaye — všechny nastavitelné přes env.
|
||||||
|
//!
|
||||||
|
//! Proč env a ne konstanty: limit, který se pod skutečnou zátěží ukáže
|
||||||
|
//! špatně (sdílené CGNAT adresy, Tor, nápor po zveřejnění), se musí dát
|
||||||
|
//! změnit úpravou `.env` a restartem (sekundy), ne novým buildem.
|
||||||
|
//!
|
||||||
|
//! Výchozí hodnoty drží chování dřívějších verzí se třemi vědomými
|
||||||
|
//! výjimkami popsanými u polí: registrace per IP je výrazně volnější (mobilní
|
||||||
|
//! CGNAT dá za jednu IPv4 stovky lidí) a DB místo toho chrání globální strop;
|
||||||
|
//! Tor klienti mají vlastní, větší buckety (všichni přicházejí z jedné
|
||||||
|
//! adresy); a nové kvóty (odesílatel, přílohy na účet, místo na disku) dřív
|
||||||
|
//! neexistovaly.
|
||||||
|
//!
|
||||||
|
//! Rate limity jsou token buckety: `*_BURST` tokenů naráz, jeden token zpět
|
||||||
|
//! každých `*_PERIOD_MS` ms. Kvóty bajtů/kusů: 0 = bez omezení.
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use axum::extract::{ConnectInfo, Request, State};
|
||||||
|
use axum::http::{header, HeaderValue, StatusCode};
|
||||||
|
use axum::middleware::Next;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use governor::clock::{Clock, DefaultClock};
|
||||||
|
use governor::{DefaultDirectRateLimiter, DefaultKeyedRateLimiter, Quota};
|
||||||
|
|
||||||
|
use crate::net::TrustedProxies;
|
||||||
|
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
fn env_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
|
||||||
|
match std::env::var(name) {
|
||||||
|
Ok(raw) => match raw.trim().parse() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(name, "neplatná hodnota, platí výchozí");
|
||||||
|
default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token bucket: `burst` požadavků naráz, pak jeden za `period`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Rate {
|
||||||
|
pub burst: u32,
|
||||||
|
pub period: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rate {
|
||||||
|
pub const fn new(burst: u32, period_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
burst,
|
||||||
|
period: Duration::from_millis(period_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `{prefix}_BURST` a `{prefix}_PERIOD_MS`, každá s vlastní výchozí hodnotou.
|
||||||
|
fn from_env(prefix: &str, default: Rate) -> Self {
|
||||||
|
let burst = env_parse(&format!("{prefix}_BURST"), default.burst).max(1);
|
||||||
|
let period_ms = env_parse(
|
||||||
|
&format!("{prefix}_PERIOD_MS"),
|
||||||
|
default.period.as_millis() as u64,
|
||||||
|
)
|
||||||
|
.max(1);
|
||||||
|
Self::new(burst, period_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quota(&self) -> Quota {
|
||||||
|
Quota::with_period(self.period)
|
||||||
|
.expect("period > 0")
|
||||||
|
.allow_burst(NonZeroU32::new(self.burst).expect("burst > 0"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oddělené buckety: clearnet klienti (podle IP klienta) a všichni Tor
|
||||||
|
/// klienti dohromady (sdílí adresu kontejneru Toru).
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct RatePair {
|
||||||
|
pub clearnet: Rate,
|
||||||
|
pub tor: Rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Limits {
|
||||||
|
/// Spojení relaye do Postgresu (`SEPOT_DB_POOL`, výchozí 8).
|
||||||
|
pub db_pool: u32,
|
||||||
|
|
||||||
|
/// `SEPOT_RL_REGISTER_*` na IP: 50 naráz, pak 1 za 10 s (360/h).
|
||||||
|
/// Dřív 10 + 1/5 min: za CGNAT mobilního operátora čekal jedenáctý
|
||||||
|
/// člověk na účet minuty.
|
||||||
|
pub register: RatePair,
|
||||||
|
/// `SEPOT_RL_REGISTER_POW_*` na IP pro registrace s platným proof-of-work
|
||||||
|
/// (pow.rs): 200 naráz, pak 1 za 2 s (1 800/h). Výpočet zdražuje každou
|
||||||
|
/// registraci, takže velký CGNAT může dostat víc bez rizika levné záplavy
|
||||||
|
/// z jedné adresy. Globální strop platí společně s registracemi bez PoW.
|
||||||
|
pub register_pow: RatePair,
|
||||||
|
/// `SEPOT_RL_POW_CHALLENGE_*`: vydávání výzev (jen HMAC, nic do DB).
|
||||||
|
pub pow_challenge: RatePair,
|
||||||
|
/// `SEPOT_RL_REGISTER_GLOBAL_*`: všechny registrace dohromady, 600 naráz,
|
||||||
|
/// pak 40/s. Chrání DB (změřeno 117–147 registrací/s na 2 jádrech), když
|
||||||
|
/// registruje mnoho adres naráz; BURST 0 = vypnuto.
|
||||||
|
pub register_global: Option<Rate>,
|
||||||
|
/// `SEPOT_REGISTRATION_PAUSED=1`: nové registrace dostanou 503 + Retry-After
|
||||||
|
/// (nouzový vypínač při záplavě registrací); existující zařízení jedou dál.
|
||||||
|
pub registration_paused: bool,
|
||||||
|
/// `SEPOT_RL_GENERAL_*`: server-info, bundly klíčů, certifikát, smazání.
|
||||||
|
pub general: RatePair,
|
||||||
|
/// `SEPOT_RL_ATTACHMENTS_*`: upload i stažení příloh.
|
||||||
|
pub attachments: RatePair,
|
||||||
|
/// `SEPOT_RL_WS_*`: otevírání WS spojení (každý AUTH = dotaz do DB).
|
||||||
|
pub ws_upgrade: RatePair,
|
||||||
|
|
||||||
|
/// `SEPOT_MAX_CONNS_PER_IP` (výchozí 32) a `SEPOT_TOR_MAX_CONNS`
|
||||||
|
/// (výchozí 5000, všichni Tor klienti dohromady).
|
||||||
|
pub max_conns_per_ip: u32,
|
||||||
|
pub max_conns_tor: u32,
|
||||||
|
/// `SEPOT_WS_FRAMES_PER_10S`: drahé rámce na spojení (výchozí 200).
|
||||||
|
pub ws_frames_per_window: u32,
|
||||||
|
|
||||||
|
/// `SEPOT_OTP_ISSUE_PER_HOUR`: vydaných one-time prekeys na adresu za hodinu.
|
||||||
|
pub otp_issue_per_hour: u32,
|
||||||
|
/// `SEPOT_MAX_STORED_OTP`: zásoba one-time prekeys na zařízení.
|
||||||
|
pub max_stored_otp: i64,
|
||||||
|
/// `SEPOT_CALL_WAKES_PER_10MIN` a `SEPOT_CALL_WAKE_MIN_INTERVAL_S`.
|
||||||
|
pub call_wakes_per_window: usize,
|
||||||
|
pub call_wake_min_interval: Duration,
|
||||||
|
|
||||||
|
/// `SEPOT_QUEUE_MAX_BYTES_PER_RECIPIENT` (50 MiB) a
|
||||||
|
/// `SEPOT_QUEUE_MAX_PER_RECIPIENT` (10 000 obálek): přebytek padá od nejstarších.
|
||||||
|
pub queue_max_bytes_per_recipient: i64,
|
||||||
|
pub queue_max_per_recipient: i64,
|
||||||
|
/// `SEPOT_SEND_MAX_PER_HOUR` / `SEPOT_SEND_MAX_BYTES_PER_HOUR` na odesílající
|
||||||
|
/// účet (ukládané obálky; „píše…“ se nepočítá).
|
||||||
|
pub send_per_hour: u64,
|
||||||
|
pub send_bytes_per_hour: u64,
|
||||||
|
|
||||||
|
/// `SEPOT_ATT_ACCOUNT_BYTES_PER_DAY` (100 MiB) a
|
||||||
|
/// `SEPOT_ATT_ACCOUNT_STORED_BYTES` (250 MiB živých v rámci TTL).
|
||||||
|
pub att_account_bytes_per_day: u64,
|
||||||
|
pub att_account_stored_bytes: u64,
|
||||||
|
|
||||||
|
/// `SEPOT_DISK_MIN_FREE_PCT_ATTACHMENTS` (15) a `SEPOT_DISK_MIN_FREE_PCT_QUEUE`
|
||||||
|
/// (8): pod tímto podílem volného místa na datovém filesystému relay
|
||||||
|
/// odmítá nové přílohy, resp. nové offline obálky (507) — Postgres si tak
|
||||||
|
/// nechá místo na WAL a na všechno, co už přijal.
|
||||||
|
pub disk_min_free_pct_attachments: f64,
|
||||||
|
pub disk_min_free_pct_queue: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Limits {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let pair = |prefix: &str, clearnet: Rate, tor: Rate| RatePair {
|
||||||
|
clearnet: Rate::from_env(prefix, clearnet),
|
||||||
|
tor: Rate::from_env(&format!("{prefix}_TOR"), tor),
|
||||||
|
};
|
||||||
|
let global = Rate::from_env("SEPOT_RL_REGISTER_GLOBAL", Rate::new(600, 25));
|
||||||
|
let global_off = env_parse("SEPOT_RL_REGISTER_GLOBAL_BURST", 600u32) == 0;
|
||||||
|
Self {
|
||||||
|
db_pool: env_parse("SEPOT_DB_POOL", 8u32).max(1),
|
||||||
|
register: pair(
|
||||||
|
"SEPOT_RL_REGISTER",
|
||||||
|
Rate::new(50, 10_000),
|
||||||
|
Rate::new(100, 3_000),
|
||||||
|
),
|
||||||
|
register_pow: pair(
|
||||||
|
"SEPOT_RL_REGISTER_POW",
|
||||||
|
Rate::new(200, 2_000),
|
||||||
|
Rate::new(300, 1_000),
|
||||||
|
),
|
||||||
|
pow_challenge: pair(
|
||||||
|
"SEPOT_RL_POW_CHALLENGE",
|
||||||
|
Rate::new(120, 100),
|
||||||
|
Rate::new(600, 20),
|
||||||
|
),
|
||||||
|
register_global: (!global_off).then_some(global),
|
||||||
|
registration_paused: matches!(
|
||||||
|
std::env::var("SEPOT_REGISTRATION_PAUSED").as_deref(),
|
||||||
|
Ok("1") | Ok("true")
|
||||||
|
),
|
||||||
|
general: pair("SEPOT_RL_GENERAL", Rate::new(60, 500), Rate::new(600, 20)),
|
||||||
|
attachments: pair(
|
||||||
|
"SEPOT_RL_ATTACHMENTS",
|
||||||
|
Rate::new(30, 1_000),
|
||||||
|
Rate::new(300, 100),
|
||||||
|
),
|
||||||
|
ws_upgrade: pair("SEPOT_RL_WS", Rate::new(120, 100), Rate::new(600, 20)),
|
||||||
|
max_conns_per_ip: env_parse("SEPOT_MAX_CONNS_PER_IP", 32u32),
|
||||||
|
max_conns_tor: env_parse("SEPOT_TOR_MAX_CONNS", 5000u32),
|
||||||
|
ws_frames_per_window: env_parse("SEPOT_WS_FRAMES_PER_10S", 200u32),
|
||||||
|
otp_issue_per_hour: env_parse("SEPOT_OTP_ISSUE_PER_HOUR", 30u32),
|
||||||
|
max_stored_otp: env_parse("SEPOT_MAX_STORED_OTP", 100i64),
|
||||||
|
call_wakes_per_window: env_parse("SEPOT_CALL_WAKES_PER_10MIN", 6usize),
|
||||||
|
call_wake_min_interval: Duration::from_secs(env_parse(
|
||||||
|
"SEPOT_CALL_WAKE_MIN_INTERVAL_S",
|
||||||
|
10u64,
|
||||||
|
)),
|
||||||
|
queue_max_bytes_per_recipient: env_parse(
|
||||||
|
"SEPOT_QUEUE_MAX_BYTES_PER_RECIPIENT",
|
||||||
|
(50 * MIB) as i64,
|
||||||
|
),
|
||||||
|
queue_max_per_recipient: env_parse("SEPOT_QUEUE_MAX_PER_RECIPIENT", 10_000i64),
|
||||||
|
send_per_hour: env_parse("SEPOT_SEND_MAX_PER_HOUR", 5_000u64),
|
||||||
|
send_bytes_per_hour: env_parse("SEPOT_SEND_MAX_BYTES_PER_HOUR", 32 * MIB),
|
||||||
|
att_account_bytes_per_day: env_parse("SEPOT_ATT_ACCOUNT_BYTES_PER_DAY", 100 * MIB),
|
||||||
|
att_account_stored_bytes: env_parse("SEPOT_ATT_ACCOUNT_STORED_BYTES", 250 * MIB),
|
||||||
|
disk_min_free_pct_attachments: env_parse("SEPOT_DISK_MIN_FREE_PCT_ATTACHMENTS", 15.0),
|
||||||
|
disk_min_free_pct_queue: env_parse("SEPOT_DISK_MIN_FREE_PCT_QUEUE", 8.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate limiter skupiny rout, jen v paměti. Nic se nezapisuje ani neloguje
|
||||||
|
/// (adresy klientů jsou osobní údaj).
|
||||||
|
pub struct RateLimit {
|
||||||
|
clearnet: DefaultKeyedRateLimiter<IpAddr>,
|
||||||
|
tor: DefaultDirectRateLimiter,
|
||||||
|
global: Option<Arc<DefaultDirectRateLimiter>>,
|
||||||
|
trusted: Arc<TrustedProxies>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registrace bez a s proof-of-work: vlastní buckety per IP, společný
|
||||||
|
/// globální strop (chrání DB bez ohledu na to, kdo PoW přinesl).
|
||||||
|
pub struct RegisterLimits {
|
||||||
|
pub base: Arc<RateLimit>,
|
||||||
|
pub pow: Arc<RateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterLimits {
|
||||||
|
pub fn new(limits: &Limits, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
||||||
|
let base = RateLimit::new(limits.register, limits.register_global, trusted);
|
||||||
|
let pow = base.sibling(limits.register_pow);
|
||||||
|
Arc::new(Self { base, pow })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
pub fn new(rates: RatePair, global: Option<Rate>, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
||||||
|
let global = global.map(|g| Arc::new(DefaultDirectRateLimiter::direct(g.quota())));
|
||||||
|
Self::build(rates, global, trusted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limiter s vlastními buckety per IP a stejným globálním stropem.
|
||||||
|
pub fn sibling(&self, rates: RatePair) -> Arc<Self> {
|
||||||
|
Self::build(rates, self.global.clone(), self.trusted.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
rates: RatePair,
|
||||||
|
global: Option<Arc<DefaultDirectRateLimiter>>,
|
||||||
|
trusted: Arc<TrustedProxies>,
|
||||||
|
) -> Arc<Self> {
|
||||||
|
let limiter = Arc::new(Self {
|
||||||
|
clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()),
|
||||||
|
tor: DefaultDirectRateLimiter::direct(rates.tor.quota()),
|
||||||
|
global,
|
||||||
|
trusted,
|
||||||
|
});
|
||||||
|
// Zapomenout nečinné buckety, ať mapa neroste s každou kdy viděnou IP.
|
||||||
|
let weak = Arc::downgrade(&limiter);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
let Some(limiter) = weak.upgrade() else { break };
|
||||||
|
limiter.clearnet.retain_recent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ok` = povoleno, `Err(wait)` = odmítnuto, zkusit znovu za `wait`.
|
||||||
|
pub fn check(&self, peer: IpAddr, headers: &axum::http::HeaderMap) -> Result<(), Duration> {
|
||||||
|
let now = DefaultClock::default().now();
|
||||||
|
let own = if self.trusted.is_tor(peer) {
|
||||||
|
self.tor.check()
|
||||||
|
} else {
|
||||||
|
self.clearnet
|
||||||
|
.check_key(&self.trusted.client_ip(peer, headers))
|
||||||
|
};
|
||||||
|
own.map_err(|not_until| not_until.wait_time_from(now))?;
|
||||||
|
if let Some(global) = &self.global {
|
||||||
|
global.check().map_err(|not_until| not_until.wait_time_from(now))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// axum middleware: 429 s Retry-After, když je bucket prázdný.
|
||||||
|
pub async fn enforce(
|
||||||
|
State(limit): State<Arc<RateLimit>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
match limit.check(peer.ip(), request.headers()) {
|
||||||
|
Ok(()) => next.run(request).await,
|
||||||
|
Err(wait) => too_many(wait),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn too_many(wait: Duration) -> Response {
|
||||||
|
let secs = wait.as_secs() + u64::from(wait.subsec_nanos() > 0);
|
||||||
|
let mut response = (StatusCode::TOO_MANY_REQUESTS, "příliš mnoho požadavků").into_response();
|
||||||
|
if let Ok(value) = HeaderValue::from_str(&secs.max(1).to_string()) {
|
||||||
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
|
fn trusted(proxies: &str, tor: &str) -> Arc<TrustedProxies> {
|
||||||
|
Arc::new(TrustedProxies::parse(proxies, tor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xff(ip: &str) -> HeaderMap {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-forwarded-for", ip.parse().unwrap());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clearnet_bucket_je_na_ip_klienta() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(2, 3_600_000),
|
||||||
|
tor: Rate::new(100, 1),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, None, trusted("10.0.0.1", ""));
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
let wait = rl.check(proxy, &xff("198.51.100.1")).unwrap_err();
|
||||||
|
assert!(wait > Duration::from_secs(3000), "Retry-After odpovídá periodě");
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.2")).is_ok(), "jiný klient limit nesdílí");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tor_ma_vlastni_bucket_a_nepodvrhne_ip() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(1, 3_600_000),
|
||||||
|
tor: Rate::new(3, 3_600_000),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, None, trusted("10.0.0.1", "172.20.1.0/24"));
|
||||||
|
let tor_peer: IpAddr = "172.20.1.3".parse().unwrap();
|
||||||
|
for i in 0..3 {
|
||||||
|
// podvržená XFF z Toru nesmí založit nové buckety
|
||||||
|
assert!(rl.check(tor_peer, &xff(&format!("203.0.113.{i}"))).is_ok());
|
||||||
|
}
|
||||||
|
assert!(rl.check(tor_peer, &xff("203.0.113.99")).is_err());
|
||||||
|
// vyčerpaný Tor bucket clearnet klienta neomezí
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn globalni_strop_plati_pres_klienty() {
|
||||||
|
let rates = RatePair {
|
||||||
|
clearnet: Rate::new(100, 1),
|
||||||
|
tor: Rate::new(100, 1),
|
||||||
|
};
|
||||||
|
let rl = RateLimit::new(rates, Some(Rate::new(2, 3_600_000)), trusted("10.0.0.1", ""));
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.2")).is_ok());
|
||||||
|
assert!(rl.check(proxy, &xff("198.51.100.3")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sourozenec_sdili_globalni_strop_ne_ip_bucket() {
|
||||||
|
let base_rates = RatePair {
|
||||||
|
clearnet: Rate::new(1, 3_600_000),
|
||||||
|
tor: Rate::new(1, 3_600_000),
|
||||||
|
};
|
||||||
|
let pow_rates = RatePair {
|
||||||
|
clearnet: Rate::new(5, 3_600_000),
|
||||||
|
tor: Rate::new(5, 3_600_000),
|
||||||
|
};
|
||||||
|
let base = RateLimit::new(base_rates, Some(Rate::new(3, 3_600_000)), trusted("10.0.0.1", ""));
|
||||||
|
let pow = base.sibling(pow_rates);
|
||||||
|
let proxy: IpAddr = "10.0.0.1".parse().unwrap();
|
||||||
|
assert!(base.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(base.check(proxy, &xff("198.51.100.1")).is_err(), "IP bucket bez PoW vyčerpán");
|
||||||
|
assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok(), "s PoW vlastní bucket");
|
||||||
|
assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok());
|
||||||
|
assert!(pow.check(proxy, &xff("198.51.100.1")).is_err(), "globální strop 3 společný");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rate_z_env_padne_na_vychozi_po_polich() {
|
||||||
|
std::env::set_var("SEPOT_TEST_RL_BURST", "7");
|
||||||
|
std::env::set_var("SEPOT_TEST_RL_PERIOD_MS", "nonsense");
|
||||||
|
let rate = Rate::from_env("SEPOT_TEST_RL", Rate::new(1, 250));
|
||||||
|
assert_eq!(rate, Rate::new(7, 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,13 @@
|
|||||||
mod apns;
|
mod apns;
|
||||||
mod attachments;
|
mod attachments;
|
||||||
mod certs;
|
mod certs;
|
||||||
|
mod disk;
|
||||||
mod fcm;
|
mod fcm;
|
||||||
|
mod limits;
|
||||||
mod net;
|
mod net;
|
||||||
|
mod pow;
|
||||||
mod push;
|
mod push;
|
||||||
|
mod quota;
|
||||||
mod rest;
|
mod rest;
|
||||||
mod state;
|
mod state;
|
||||||
mod turn;
|
mod turn;
|
||||||
@@ -22,14 +26,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::middleware::from_fn_with_state;
|
||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::serve::ListenerExt;
|
use axum::serve::ListenerExt;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use tower_governor::governor::GovernorConfigBuilder;
|
|
||||||
use tower_governor::GovernorLayer;
|
|
||||||
|
|
||||||
use crate::net::{TrustedProxies, TrustedProxyIp};
|
use crate::limits::{Limits, RateLimit, RegisterLimits};
|
||||||
|
use crate::net::TrustedProxies;
|
||||||
use tower_http::timeout::TimeoutLayer;
|
use tower_http::timeout::TimeoutLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -49,8 +53,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
||||||
let bind = std::env::var("SEPOT_BIND").unwrap_or_else(|_| "127.0.0.1:8470".into());
|
let bind = std::env::var("SEPOT_BIND").unwrap_or_else(|_| "127.0.0.1:8470".into());
|
||||||
|
|
||||||
|
let limits = Limits::from_env();
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(8)
|
.max_connections(limits.db_pool)
|
||||||
.connect(&database_url)
|
.connect(&database_url)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||||
@@ -66,60 +71,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
certs,
|
certs,
|
||||||
attachments_cfg,
|
attachments_cfg,
|
||||||
trusted.clone(),
|
trusted.clone(),
|
||||||
|
limits.clone(),
|
||||||
));
|
));
|
||||||
attachments::spawn_cleanup(state.clone());
|
attachments::spawn_cleanup(state.clone());
|
||||||
|
state.disk.spawn(&state.attachments.dir);
|
||||||
|
tracing::info!(?limits, "limity");
|
||||||
|
tracing::info!(pow = ?state.pow.cfg, "proof-of-work registrace");
|
||||||
|
|
||||||
// ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ──
|
// ── Rate limiting ──
|
||||||
//
|
//
|
||||||
// Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
|
// Klíč = klientská IP: X-Forwarded-For se uzná JEN od trusted proxy
|
||||||
// uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti
|
// (traefik), jinak peer adresa. Onion klienti přicházejí z kontejneru Toru
|
||||||
// přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket
|
// (SEPOT_TOR_PEERS) a mají vlastní společný bucket — hlavičkou si IP
|
||||||
// a nemohou si IP nastavit hlavičkou (SECURITY audit #1).
|
// nastavit nemohou (SECURITY audit #1). Jen v paměti, nikdy na disk.
|
||||||
// In-memory token bucket — nikdy se nezapisuje na disk (privacy).
|
// Hodnoty a jejich zdůvodnění: limits.rs.
|
||||||
|
// Registrace: bucket per IP volí handler podle proof-of-work (rest.rs),
|
||||||
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
|
// proto limiter jako Extension, ne middleware.
|
||||||
let gov_register = Arc::new(
|
let rl_register = RegisterLimits::new(&limits, trusted.clone());
|
||||||
GovernorConfigBuilder::default()
|
let rl_challenge = RateLimit::new(limits.pow_challenge, None, trusted.clone());
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
let rl_general = RateLimit::new(limits.general, None, trusted.clone());
|
||||||
.burst_size(10)
|
let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone());
|
||||||
.period(Duration::from_secs(300))
|
let rl_ws = RateLimit::new(limits.ws_upgrade, None, trusted.clone());
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
|
|
||||||
let gov_general = Arc::new(
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
|
||||||
.burst_size(60)
|
|
||||||
.per_second(2)
|
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
|
|
||||||
let gov_attachments = Arc::new(
|
|
||||||
GovernorConfigBuilder::default()
|
|
||||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
|
||||||
.burst_size(30)
|
|
||||||
.per_second(1)
|
|
||||||
.finish()
|
|
||||||
.expect("governor config"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Governor drží mapu IP→bucket v paměti — periodicky vyhazovat staré
|
|
||||||
for limiter in [
|
|
||||||
gov_register.limiter().clone(),
|
|
||||||
gov_general.limiter().clone(),
|
|
||||||
gov_attachments.limiter().clone(),
|
|
||||||
] {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
|
||||||
loop {
|
|
||||||
tick.tick().await;
|
|
||||||
limiter.retain_recent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Úklid značek budíčků a OTP oken (jen paměť)
|
// Úklid značek budíčků a OTP oken (jen paměť)
|
||||||
{
|
{
|
||||||
@@ -135,7 +107,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let register_routes = Router::new()
|
let register_routes = Router::new()
|
||||||
.route("/v1/register", post(rest::register))
|
.route("/v1/register", post(rest::register))
|
||||||
.layer(GovernorLayer::new(gov_register));
|
.layer(axum::Extension(rl_register))
|
||||||
|
.merge(
|
||||||
|
Router::new()
|
||||||
|
.route("/v1/register/challenge", get(rest::register_challenge))
|
||||||
|
.layer(from_fn_with_state(rl_challenge, crate::limits::enforce)),
|
||||||
|
);
|
||||||
|
|
||||||
let general_routes = Router::new()
|
let general_routes = Router::new()
|
||||||
.route("/v1/server-info", get(rest::server_info))
|
.route("/v1/server-info", get(rest::server_info))
|
||||||
@@ -144,7 +121,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/v1/keys", post(rest::upload_keys))
|
.route("/v1/keys", post(rest::upload_keys))
|
||||||
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
.route("/v1/certificate/delivery", get(rest::certificate_delivery))
|
||||||
.route("/v1/identity", delete(rest::delete_identity))
|
.route("/v1/identity", delete(rest::delete_identity))
|
||||||
.layer(GovernorLayer::new(gov_general));
|
.layer(from_fn_with_state(rl_general, crate::limits::enforce));
|
||||||
|
|
||||||
let rest_routes = register_routes
|
let rest_routes = register_routes
|
||||||
.merge(general_routes)
|
.merge(general_routes)
|
||||||
@@ -159,7 +136,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let attachment_routes = Router::new()
|
let attachment_routes = Router::new()
|
||||||
.route("/v1/attachments", post(attachments::upload))
|
.route("/v1/attachments", post(attachments::upload))
|
||||||
.route("/v1/attachments/{blob_id}", get(attachments::download))
|
.route("/v1/attachments/{blob_id}", get(attachments::download))
|
||||||
.layer(GovernorLayer::new(gov_attachments))
|
.layer(from_fn_with_state(rl_attachments, crate::limits::enforce))
|
||||||
.layer(TimeoutLayer::with_status_code(
|
.layer(TimeoutLayer::with_status_code(
|
||||||
StatusCode::REQUEST_TIMEOUT,
|
StatusCode::REQUEST_TIMEOUT,
|
||||||
Duration::from_secs(120),
|
Duration::from_secs(120),
|
||||||
@@ -169,8 +146,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// health bez limitů — interní docker/uptime checky; ready ověří i DB
|
// health bez limitů — interní docker/uptime checky; ready ověří i DB
|
||||||
.route("/v1/health", get(rest::health))
|
.route("/v1/health", get(rest::health))
|
||||||
.route("/v1/ready", get(rest::ready))
|
.route("/v1/ready", get(rest::ready))
|
||||||
// WS má vlastní limity (conn cap per IP, auth deadline, frame rate)
|
// WS: tempo otevírání spojení + vlastní limity v ws.rs (strop
|
||||||
.route("/v1/ws", get(ws::ws_handler))
|
// souběžných spojení na IP, AUTH deadline, tempo rámců)
|
||||||
|
.route(
|
||||||
|
"/v1/ws",
|
||||||
|
get(ws::ws_handler).layer(from_fn_with_state(rl_ws, crate::limits::enforce)),
|
||||||
|
)
|
||||||
.merge(rest_routes)
|
.merge(rest_routes)
|
||||||
.merge(attachment_routes)
|
.merge(attachment_routes)
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||||
|
|||||||
+49
-45
@@ -14,29 +14,23 @@
|
|||||||
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
||||||
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
||||||
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::extract::ConnectInfo;
|
use axum::http::HeaderMap;
|
||||||
use axum::http::{HeaderMap, Request};
|
|
||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
use tower_governor::key_extractor::KeyExtractor;
|
|
||||||
use tower_governor::GovernorError;
|
|
||||||
|
|
||||||
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
||||||
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
||||||
pub struct TrustedProxies {
|
pub struct TrustedProxies {
|
||||||
nets: Vec<IpNet>,
|
nets: Vec<IpNet>,
|
||||||
|
/// Adresy, ze kterých přichází Tor (kontejner `tor` v síti tornet,
|
||||||
|
/// `SEPOT_TOR_PEERS`). Takový peer nikdy není trusted proxy a dostává
|
||||||
|
/// vlastní, společné limity — všichni onion klienti mají jeho IP.
|
||||||
|
tor: Vec<IpNet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrustedProxies {
|
fn parse_nets(raw: &str) -> Vec<IpNet> {
|
||||||
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
raw.split(',')
|
||||||
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
|
||||||
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
|
||||||
pub fn from_env() -> Self {
|
|
||||||
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
|
|
||||||
let nets: Vec<IpNet> = raw
|
|
||||||
.split(',')
|
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
@@ -47,18 +41,45 @@ impl TrustedProxies {
|
|||||||
.ok()
|
.ok()
|
||||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
if nets.is_empty() {
|
}
|
||||||
|
|
||||||
|
impl TrustedProxies {
|
||||||
|
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
||||||
|
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
||||||
|
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let proxies = Self::parse(
|
||||||
|
&std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default(),
|
||||||
|
&std::env::var("SEPOT_TOR_PEERS").unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if proxies.nets.is_empty() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
||||||
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Self { nets }
|
proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prázdné/nenastavené proxy = nevěří se nikomu (vždy peer IP) — fail-safe:
|
||||||
|
/// chybějící konfigurace nesmí otevřít spoofing, maximálně sloučí clearnet
|
||||||
|
/// klienty do jednoho bucketu. Tor má přednost: síť, která je zároveň
|
||||||
|
/// v obou sadách, se jako proxy nebere (onion klient by si jinak IP
|
||||||
|
/// nastavil hlavičkou).
|
||||||
|
pub fn parse(proxies: &str, tor: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
nets: parse_nets(proxies),
|
||||||
|
tor: parse_nets(tor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_tor(&self, peer: IpAddr) -> bool {
|
||||||
|
self.tor.iter().any(|n| n.contains(&peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trusts(&self, peer: IpAddr) -> bool {
|
fn trusts(&self, peer: IpAddr) -> bool {
|
||||||
self.nets.iter().any(|n| n.contains(&peer))
|
!self.is_tor(peer) && self.nets.iter().any(|n| n.contains(&peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
||||||
@@ -83,24 +104,6 @@ impl TrustedProxies {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Governor key extractor s ověřením trusted proxy. Nahrazuje
|
|
||||||
/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TrustedProxyIp(pub Arc<TrustedProxies>);
|
|
||||||
|
|
||||||
impl KeyExtractor for TrustedProxyIp {
|
|
||||||
type Key = IpAddr;
|
|
||||||
|
|
||||||
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
|
||||||
let peer = req
|
|
||||||
.extensions()
|
|
||||||
.get::<ConnectInfo<SocketAddr>>()
|
|
||||||
.map(|ci| ci.0.ip())
|
|
||||||
.ok_or(GovernorError::UnableToExtractKey)?;
|
|
||||||
Ok(self.0.client_ip(peer, req.headers()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -108,15 +111,7 @@ mod tests {
|
|||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
||||||
let nets = cfg
|
TrustedProxies::parse(&cfg.join(","), "")
|
||||||
.iter()
|
|
||||||
.filter_map(|s| {
|
|
||||||
s.parse::<IpNet>()
|
|
||||||
.ok()
|
|
||||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
TrustedProxies { nets }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ip(s: &str) -> IpAddr {
|
fn ip(s: &str) -> IpAddr {
|
||||||
@@ -191,4 +186,13 @@ mod tests {
|
|||||||
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
||||||
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Síť Toru v sadě proxy omylem: onion peer si IP hlavičkou nastavit nesmí.
|
||||||
|
#[test]
|
||||||
|
fn tor_peer_neni_proxy_ani_pri_prekryvu() {
|
||||||
|
let tp = TrustedProxies::parse("172.20.0.0/16", "172.20.1.0/24");
|
||||||
|
let tor = ip("172.20.1.3");
|
||||||
|
assert!(tp.is_tor(tor));
|
||||||
|
assert_eq!(tp.client_ip(tor, &headers(&[("x-forwarded-for", "1.2.3.4")])), tor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
//! Proof-of-work při registraci (`GET /v1/register/challenge`).
|
||||||
|
//!
|
||||||
|
//! Proč: registrace je jediný anonymní zápis do DB. Limit per IP nejde
|
||||||
|
//! utáhnout (za CGNAT mobilního operátora sdílí jednu IPv4 stovky lidí)
|
||||||
|
//! a proti záplavě z mnoha adres stojí jen globální strop, který zablokuje
|
||||||
|
//! i skutečné lidi. PoW zdraží každou registraci výpočtem na straně klienta;
|
||||||
|
//! kdo ho přinese, dostane volnější limit per IP.
|
||||||
|
//!
|
||||||
|
//! Úloha (hashcash): `SHA-256("sepot-register-pow-v1" ‖ výzva ‖ identity_key
|
||||||
|
//! ‖ counter_be64)` má aspoň `bits` úvodních nulových bitů. Ověření = jedna
|
||||||
|
//! HMAC a jedna SHA-256 — levné i pod náporem, proto ne Argon2 (paměťově
|
||||||
|
//! náročné ověření by relay pod záplavou sám přetížilo).
|
||||||
|
//!
|
||||||
|
//! Výzva je bezstavová: verze, obtížnost, expirace a náhodná nonce podepsané
|
||||||
|
//! HMAC klíčem, který vzniká náhodně při startu procesu. Nic se neukládá do DB
|
||||||
|
//! ani do `.env`; restart relaye zneplatní rozdané výzvy (klient si vyžádá
|
||||||
|
//! novou) a tím i znovupoužití řešení přes restart. Jednorázovost v rámci
|
||||||
|
//! běhu hlídá paměťová sada spotřebovaných nonce, mazaná po expiraci.
|
||||||
|
//!
|
||||||
|
//! Stejné testovací vektory má klient (app/lib/core/network/register_pow.dart).
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use base64::prelude::*;
|
||||||
|
use dashmap::DashMap;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const DOMAIN: &[u8] = b"sepot-register-pow-v1";
|
||||||
|
const VERSION: u8 = 1;
|
||||||
|
const NONCE_LEN: usize = 16;
|
||||||
|
const TAG_LEN: usize = 16;
|
||||||
|
/// verze(1) ‖ bits(1) ‖ expirace unix s (8, BE) ‖ nonce(16) ‖ HMAC(16)
|
||||||
|
pub const TOKEN_LEN: usize = 2 + 8 + NONCE_LEN + TAG_LEN;
|
||||||
|
const SIGNED_LEN: usize = TOKEN_LEN - TAG_LEN;
|
||||||
|
/// Strop obtížnosti: 2^32 pokusů je na telefonu přes hodinu — víc nemá smysl
|
||||||
|
/// nastavovat ani adaptivně.
|
||||||
|
const MAX_BITS: u8 = 32;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
/// Relay výzvy nevydává (`/v1/register/challenge` = 404), registruje jako dřív.
|
||||||
|
Off,
|
||||||
|
/// Výzvy vydává, registrace bez PoW projde s běžným limitem per IP,
|
||||||
|
/// s platným PoW s volnějším. Výchozí — appky v obchodech PoW neumí.
|
||||||
|
Optional,
|
||||||
|
/// Registrace bez platného PoW = 428. Nouzová páka; staré appky se
|
||||||
|
/// nezaregistrují.
|
||||||
|
Required,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Mode::Off => "off",
|
||||||
|
Mode::Optional => "optional",
|
||||||
|
Mode::Required => "required",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(raw: &str) -> Option<Self> {
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"off" | "0" => Some(Mode::Off),
|
||||||
|
"optional" => Some(Mode::Optional),
|
||||||
|
"required" | "1" => Some(Mode::Required),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
/// `SEPOT_REGISTER_POW` (off | optional | required), výchozí optional.
|
||||||
|
pub mode: Mode,
|
||||||
|
/// `SEPOT_REGISTER_POW_BITS`: základní obtížnost (výchozí 18 ≈ 0,3 s na
|
||||||
|
/// x86 jádře, ~1 s na středním Androidu — měření v README relaye).
|
||||||
|
pub bits: u8,
|
||||||
|
/// `SEPOT_REGISTER_POW_MAX_BITS`: strop adaptivní obtížnosti (výchozí 22).
|
||||||
|
pub max_bits: u8,
|
||||||
|
/// `SEPOT_REGISTER_POW_ADAPT_PER_MIN`: od kolika registrací za minutu
|
||||||
|
/// (všech, s PoW i bez) přidávat obtížnost — +1 bit za každé zdvojnásobení
|
||||||
|
/// nad prahem. 0 = obtížnost pevná (výchozí 600 = 10/s, ~10× nad odhadem
|
||||||
|
/// běžné špičky; při útoku se zdraží registrace jen těm, kdo PoW posílají,
|
||||||
|
/// ostatní drží limity per IP a globální strop).
|
||||||
|
pub adapt_per_min: u32,
|
||||||
|
/// `SEPOT_REGISTER_POW_TTL_S`: platnost výzvy (výchozí 120 s; pomalý telefon
|
||||||
|
/// při 20 bitech potřebuje v 99 % případů pod 60 s).
|
||||||
|
pub ttl: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let mode = match std::env::var("SEPOT_REGISTER_POW") {
|
||||||
|
Ok(raw) => Mode::parse(&raw).unwrap_or_else(|| {
|
||||||
|
tracing::warn!("SEPOT_REGISTER_POW neplatná hodnota, platí optional");
|
||||||
|
Mode::Optional
|
||||||
|
}),
|
||||||
|
Err(_) => Mode::Optional,
|
||||||
|
};
|
||||||
|
let bits = env_u64("SEPOT_REGISTER_POW_BITS", 18).clamp(1, MAX_BITS as u64) as u8;
|
||||||
|
let max_bits =
|
||||||
|
env_u64("SEPOT_REGISTER_POW_MAX_BITS", 22).clamp(bits as u64, MAX_BITS as u64) as u8;
|
||||||
|
Self {
|
||||||
|
mode,
|
||||||
|
bits,
|
||||||
|
max_bits,
|
||||||
|
adapt_per_min: env_u64("SEPOT_REGISTER_POW_ADAPT_PER_MIN", 600) as u32,
|
||||||
|
ttl: Duration::from_secs(env_u64("SEPOT_REGISTER_POW_TTL_S", 120).clamp(10, 3600)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_u64(name: &str, default: u64) -> u64 {
|
||||||
|
match std::env::var(name) {
|
||||||
|
Ok(raw) => raw.trim().parse().unwrap_or_else(|_| {
|
||||||
|
tracing::warn!(name, "neplatná hodnota, platí výchozí");
|
||||||
|
default
|
||||||
|
}),
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Výsledek kontroly hlavičky `X-Sepot-Pow`.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Verdict {
|
||||||
|
Valid,
|
||||||
|
Missing,
|
||||||
|
Invalid(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Výzva pro klienta (`GET /v1/register/challenge`).
|
||||||
|
pub struct Challenge {
|
||||||
|
pub token: [u8; TOKEN_LEN],
|
||||||
|
pub bits: u8,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegisterPow {
|
||||||
|
pub cfg: Config,
|
||||||
|
key: [u8; 32],
|
||||||
|
/// nonce → expirace (unix s). Jen spotřebované výzvy; velikost drží
|
||||||
|
/// globální strop registrací × TTL.
|
||||||
|
used: DashMap<[u8; NONCE_LEN], u64>,
|
||||||
|
/// (začátek aktuální minuty, registrace v ní, registrace v minulé minutě)
|
||||||
|
load: Mutex<(Instant, u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterPow {
|
||||||
|
pub fn new(cfg: Config) -> Self {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut key);
|
||||||
|
Self {
|
||||||
|
cfg,
|
||||||
|
key,
|
||||||
|
used: DashMap::new(),
|
||||||
|
load: Mutex::new((Instant::now(), 0, 0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mode(&self) -> Mode {
|
||||||
|
self.cfg.mode
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mac(&self, signed: &[u8]) -> Hmac<Sha256> {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(&self.key).expect("HMAC bere libovolný klíč");
|
||||||
|
mac.update(signed);
|
||||||
|
mac
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issue(&self, now_unix: u64) -> Challenge {
|
||||||
|
let bits = self.current_bits();
|
||||||
|
let expires_at = now_unix + self.cfg.ttl.as_secs();
|
||||||
|
let mut token = [0u8; TOKEN_LEN];
|
||||||
|
token[0] = VERSION;
|
||||||
|
token[1] = bits;
|
||||||
|
token[2..10].copy_from_slice(&expires_at.to_be_bytes());
|
||||||
|
rand::rng().fill_bytes(&mut token[10..10 + NONCE_LEN]);
|
||||||
|
let tag = self.mac(&token[..SIGNED_LEN]).finalize().into_bytes();
|
||||||
|
token[SIGNED_LEN..].copy_from_slice(&tag[..TAG_LEN]);
|
||||||
|
Challenge {
|
||||||
|
token,
|
||||||
|
bits,
|
||||||
|
expires_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ověří `X-Sepot-Pow: <výzva base64url>.<counter>` vůči identity klíči
|
||||||
|
/// registrace. Platné řešení se tím spotřebuje (druhé použití = Invalid).
|
||||||
|
pub fn verify(&self, headers: &HeaderMap, identity_key: &[u8], now_unix: u64) -> Verdict {
|
||||||
|
let Some(raw) = headers.get("x-sepot-pow").and_then(|v| v.to_str().ok()) else {
|
||||||
|
return Verdict::Missing;
|
||||||
|
};
|
||||||
|
let Some((token_b64, counter_str)) = raw.trim().split_once('.') else {
|
||||||
|
return Verdict::Invalid("formát");
|
||||||
|
};
|
||||||
|
let Ok(token) = BASE64_URL_SAFE_NO_PAD.decode(token_b64) else {
|
||||||
|
return Verdict::Invalid("formát");
|
||||||
|
};
|
||||||
|
let Ok(counter) = counter_str.parse::<u64>() else {
|
||||||
|
return Verdict::Invalid("formát");
|
||||||
|
};
|
||||||
|
if token.len() != TOKEN_LEN || token[0] != VERSION {
|
||||||
|
return Verdict::Invalid("formát");
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.mac(&token[..SIGNED_LEN])
|
||||||
|
.verify_truncated_left(&token[SIGNED_LEN..])
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Verdict::Invalid("podpis");
|
||||||
|
}
|
||||||
|
let expires_at = u64::from_be_bytes(token[2..10].try_into().expect("8 B"));
|
||||||
|
if now_unix > expires_at {
|
||||||
|
return Verdict::Invalid("prošlá");
|
||||||
|
}
|
||||||
|
if leading_zero_bits(&digest(&token, identity_key, counter)) < token[1] as u32 {
|
||||||
|
return Verdict::Invalid("řešení");
|
||||||
|
}
|
||||||
|
let nonce: [u8; NONCE_LEN] = token[10..10 + NONCE_LEN].try_into().expect("16 B");
|
||||||
|
// entry = atomické vložení: dva souběžné požadavky se stejným řešením
|
||||||
|
// neprojdou oba.
|
||||||
|
match self.used.entry(nonce) {
|
||||||
|
dashmap::Entry::Occupied(_) => Verdict::Invalid("použitá"),
|
||||||
|
dashmap::Entry::Vacant(slot) => {
|
||||||
|
slot.insert(expires_at);
|
||||||
|
Verdict::Valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Započítá registraci do zátěže pro adaptivní obtížnost.
|
||||||
|
pub fn note_registration(&self) {
|
||||||
|
let mut load = self.load.lock().expect("zámek zátěže");
|
||||||
|
roll_window(&mut load, Instant::now());
|
||||||
|
load.1 = load.1.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtížnost pro novou výzvu: základ + 1 bit za každé zdvojnásobení
|
||||||
|
/// zátěže nad prahem (větší z aktuální a minulé minuty, ať obtížnost
|
||||||
|
/// nespadne hned na začátku nové minuty).
|
||||||
|
pub fn current_bits(&self) -> u8 {
|
||||||
|
if self.cfg.adapt_per_min == 0 {
|
||||||
|
return self.cfg.bits;
|
||||||
|
}
|
||||||
|
let per_min = {
|
||||||
|
let mut load = self.load.lock().expect("zámek zátěže");
|
||||||
|
roll_window(&mut load, Instant::now());
|
||||||
|
load.1.max(load.2)
|
||||||
|
};
|
||||||
|
self.cfg
|
||||||
|
.bits
|
||||||
|
.saturating_add(extra_bits(per_min, self.cfg.adapt_per_min))
|
||||||
|
.min(self.cfg.max_bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zapomene spotřebované výzvy po expiraci (periodická úloha).
|
||||||
|
pub fn prune(&self, now_unix: u64) {
|
||||||
|
self.used.retain(|_, expires_at| *expires_at >= now_unix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roll_window(load: &mut (Instant, u32, u32), now: Instant) {
|
||||||
|
const MINUTE: Duration = Duration::from_secs(60);
|
||||||
|
let elapsed = now.duration_since(load.0);
|
||||||
|
if elapsed >= MINUTE {
|
||||||
|
// Víc než dvě minuty bez registrace = minulá minuta byla prázdná.
|
||||||
|
load.2 = if elapsed >= 2 * MINUTE { 0 } else { load.1 };
|
||||||
|
load.1 = 0;
|
||||||
|
load.0 = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_bits(per_min: u32, threshold: u32) -> u8 {
|
||||||
|
if per_min < threshold {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// per_min ≥ práh → +1, ≥ 2× práh → +2, ≥ 4× → +3 …
|
||||||
|
let ratio = per_min / threshold;
|
||||||
|
(32 - ratio.leading_zeros()) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn digest(token: &[u8], identity_key: &[u8], counter: u64) -> [u8; 32] {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(DOMAIN);
|
||||||
|
h.update(token);
|
||||||
|
h.update(identity_key);
|
||||||
|
h.update(counter.to_be_bytes());
|
||||||
|
h.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leading_zero_bits(digest: &[u8; 32]) -> u32 {
|
||||||
|
let mut bits = 0;
|
||||||
|
for byte in digest {
|
||||||
|
if *byte == 0 {
|
||||||
|
bits += 8;
|
||||||
|
} else {
|
||||||
|
return bits + byte.leading_zeros();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
fn cfg(mode: Mode, bits: u8) -> Config {
|
||||||
|
Config {
|
||||||
|
mode,
|
||||||
|
bits,
|
||||||
|
max_bits: 22,
|
||||||
|
adapt_per_min: 0,
|
||||||
|
ttl: Duration::from_secs(120),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn solve(token: &[u8], identity_key: &[u8], bits: u8) -> u64 {
|
||||||
|
(0u64..)
|
||||||
|
.find(|c| leading_zero_bits(&digest(token, identity_key, *c)) >= bits as u32)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(token: &[u8], counter: u64) -> HeaderMap {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
let value = format!("{}.{counter}", BASE64_URL_SAFE_NO_PAD.encode(token));
|
||||||
|
h.insert("x-sepot-pow", HeaderValue::from_str(&value).unwrap());
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: u64 = 1_800_000_000;
|
||||||
|
const IK: [u8; 33] = [5u8; 33];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platne_reseni_projde_jednou() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
|
||||||
|
let ch = pow.issue(NOW);
|
||||||
|
assert_eq!(ch.expires_at, NOW + 120);
|
||||||
|
let counter = solve(&ch.token, &IK, ch.bits);
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW),
|
||||||
|
Verdict::Valid
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW),
|
||||||
|
Verdict::Invalid("použitá")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chybejici_a_poskozena_hlavicka() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
|
||||||
|
assert_eq!(pow.verify(&HeaderMap::new(), &IK, NOW), Verdict::Missing);
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
h.insert("x-sepot-pow", HeaderValue::from_static("bez-tecky"));
|
||||||
|
assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát"));
|
||||||
|
h.insert("x-sepot-pow", HeaderValue::from_static("AAAA.12"));
|
||||||
|
assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spatne_reseni_a_jiny_identity_klic() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 12));
|
||||||
|
let ch = pow.issue(NOW);
|
||||||
|
let counter = solve(&ch.token, &IK, ch.bits);
|
||||||
|
let bad = (0u64..)
|
||||||
|
.find(|c| leading_zero_bits(&digest(&ch.token, &IK, *c)) < 12)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, bad), &IK, NOW),
|
||||||
|
Verdict::Invalid("řešení")
|
||||||
|
);
|
||||||
|
// řešení je vázané na identitu — pro jiný účet neplatí (bez spotřeby)
|
||||||
|
let other = [6u8; 33];
|
||||||
|
if leading_zero_bits(&digest(&ch.token, &other, counter)) < 12 {
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &other, NOW),
|
||||||
|
Verdict::Invalid("řešení")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW),
|
||||||
|
Verdict::Valid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prosla_vyzva() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 8));
|
||||||
|
let ch = pow.issue(NOW);
|
||||||
|
let counter = solve(&ch.token, &IK, ch.bits);
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW + 121),
|
||||||
|
Verdict::Invalid("prošlá")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn podvrzena_obtiznost_nebo_cizi_klic() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 16));
|
||||||
|
let mut ch = pow.issue(NOW);
|
||||||
|
// klient si sníží obtížnost v tokenu → HMAC nesedí
|
||||||
|
ch.token[1] = 1;
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, 0), &IK, NOW),
|
||||||
|
Verdict::Invalid("podpis")
|
||||||
|
);
|
||||||
|
// výzva jiného procesu relaye (jiný klíč, např. před restartem)
|
||||||
|
let jiny = RegisterPow::new(cfg(Mode::Optional, 8));
|
||||||
|
let ch = jiny.issue(NOW);
|
||||||
|
let counter = solve(&ch.token, &IK, ch.bits);
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW),
|
||||||
|
Verdict::Invalid("podpis")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_maze_jen_prosle() {
|
||||||
|
let pow = RegisterPow::new(cfg(Mode::Optional, 4));
|
||||||
|
let ch = pow.issue(NOW);
|
||||||
|
let counter = solve(&ch.token, &IK, ch.bits);
|
||||||
|
assert_eq!(
|
||||||
|
pow.verify(&header(&ch.token, counter), &IK, NOW),
|
||||||
|
Verdict::Valid
|
||||||
|
);
|
||||||
|
pow.prune(NOW);
|
||||||
|
assert_eq!(pow.used.len(), 1);
|
||||||
|
pow.prune(NOW + 121);
|
||||||
|
assert_eq!(pow.used.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adaptivni_obtiznost() {
|
||||||
|
assert_eq!(extra_bits(0, 600), 0);
|
||||||
|
assert_eq!(extra_bits(599, 600), 0);
|
||||||
|
assert_eq!(extra_bits(600, 600), 1);
|
||||||
|
assert_eq!(extra_bits(1200, 600), 2);
|
||||||
|
assert_eq!(extra_bits(2399, 600), 2);
|
||||||
|
assert_eq!(extra_bits(2400, 600), 3);
|
||||||
|
let pow = RegisterPow::new(Config {
|
||||||
|
adapt_per_min: 2,
|
||||||
|
..cfg(Mode::Optional, 18)
|
||||||
|
});
|
||||||
|
assert_eq!(pow.current_bits(), 18);
|
||||||
|
for _ in 0..4 {
|
||||||
|
pow.note_registration();
|
||||||
|
}
|
||||||
|
assert_eq!(pow.current_bits(), 20);
|
||||||
|
for _ in 0..1000 {
|
||||||
|
pow.note_registration();
|
||||||
|
}
|
||||||
|
assert_eq!(pow.current_bits(), 22, "strop max_bits");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rezim_z_env_hodnoty() {
|
||||||
|
assert_eq!(Mode::parse("OFF"), Some(Mode::Off));
|
||||||
|
assert_eq!(Mode::parse(" optional "), Some(Mode::Optional));
|
||||||
|
assert_eq!(Mode::parse("required"), Some(Mode::Required));
|
||||||
|
assert_eq!(Mode::parse("ano"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sdílené vektory s klientem (app/test/network/register_pow_test.dart):
|
||||||
|
/// pevný token (42 B = 0..41), identity klíč 33 × 0x05.
|
||||||
|
#[test]
|
||||||
|
fn vektory_kompatibilni_s_klientem() {
|
||||||
|
let token: Vec<u8> = (0u8..42).collect();
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(digest(&token, &IK, 0)),
|
||||||
|
"ab9d9008c6ad674271da019b142f25cba7045107da67ad0938fd90a3d096d90a"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(digest(&token, &IK, 0x0123_4567_89ab_cdef)),
|
||||||
|
"3fc46d7b70b5c23fef86d979503e03a723929f31e52d2c49e84349e07cfe7d46"
|
||||||
|
);
|
||||||
|
assert_eq!(solve(&token, &IK, 16), 145_733);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Kvóty na účet — jen v paměti, nikam se neukládají ani nelogují.
|
||||||
|
//!
|
||||||
|
//! Proč v paměti: relay záměrně nikde nedrží vazbu „účet → co poslal / jakou
|
||||||
|
//! přílohu nahrál“ (metadata). Cena: restart relaye kvóty vynuluje. Proti
|
||||||
|
//! zaplnění disku proto stojí ještě globální strop příloh a hlídání volného
|
||||||
|
//! místa (`disk.rs`), které restart nevynuluje.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
/// Token bucket počtu i bajtů na klíč: plný na `count`/`bytes`, za `period`
|
||||||
|
/// se doplní celý. 0 = daná dimenze bez omezení.
|
||||||
|
pub struct Budgets {
|
||||||
|
count: u64,
|
||||||
|
bytes: u64,
|
||||||
|
period: Duration,
|
||||||
|
map: DashMap<Vec<u8>, Bucket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Bucket {
|
||||||
|
count: f64,
|
||||||
|
bytes: f64,
|
||||||
|
last: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budgets {
|
||||||
|
pub fn new(count: u64, bytes: u64, period: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
count,
|
||||||
|
bytes,
|
||||||
|
period,
|
||||||
|
map: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refill(&self, b: &mut Bucket, now: Instant) {
|
||||||
|
let share = now.duration_since(b.last).as_secs_f64() / self.period.as_secs_f64();
|
||||||
|
b.count = (b.count + share * self.count as f64).min(self.count as f64);
|
||||||
|
b.bytes = (b.bytes + share * self.bytes as f64).min(self.bytes as f64);
|
||||||
|
b.last = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Odečte jednu položku o `bytes` bajtech; `false` = kvóta vyčerpaná
|
||||||
|
/// (nic se neodečte).
|
||||||
|
pub fn try_take(&self, key: &[u8], bytes: u64) -> bool {
|
||||||
|
self.try_take_at(key, bytes, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_take_at(&self, key: &[u8], bytes: u64, now: Instant) -> bool {
|
||||||
|
let mut b = self.map.entry(key.to_vec()).or_insert_with(|| Bucket {
|
||||||
|
count: self.count as f64,
|
||||||
|
bytes: self.bytes as f64,
|
||||||
|
last: now,
|
||||||
|
});
|
||||||
|
self.refill(&mut b, now);
|
||||||
|
let count_ok = self.count == 0 || b.count >= 1.0;
|
||||||
|
let bytes_ok = self.bytes == 0 || b.bytes >= bytes as f64;
|
||||||
|
if !(count_ok && bytes_ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b.count -= 1.0;
|
||||||
|
b.bytes -= bytes as f64;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vrátí bajty, které se nakonec nespotřebovaly (upload menší než
|
||||||
|
/// rezervace, chyba zápisu).
|
||||||
|
pub fn refund(&self, key: &[u8], bytes: u64) {
|
||||||
|
if let Some(mut b) = self.map.get_mut(key) {
|
||||||
|
b.bytes = (b.bytes + bytes as f64).min(self.bytes as f64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Úklid plných bucketů (účet dlouho nic neposlal) — paměť jen pro aktivní.
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.map.retain(|_, b| {
|
||||||
|
let share = now.duration_since(b.last).as_secs_f64() / self.period.as_secs_f64();
|
||||||
|
share < 1.0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kolik bajtů má účet právě uložených (příloh v rámci TTL). Účetnictví po
|
||||||
|
/// dnech: na účet nejvýš `ttl_days` čísel, ne seznam blobů — paměť je pevná
|
||||||
|
/// a vazba na jednotlivé bloby nevzniká.
|
||||||
|
pub struct StoredLedger {
|
||||||
|
cap: u64,
|
||||||
|
ttl_days: i64,
|
||||||
|
map: DashMap<Vec<u8>, VecDeque<(i64, u64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredLedger {
|
||||||
|
pub fn new(cap: u64, ttl_days: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
cap,
|
||||||
|
ttl_days,
|
||||||
|
map: DashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp().div_euclid(86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zarezervuje `bytes`; `false` = účet by přesáhl strop uloženého.
|
||||||
|
pub fn try_reserve(&self, key: &[u8], bytes: u64) -> bool {
|
||||||
|
self.try_reserve_on(key, bytes, Self::today())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_reserve_on(&self, key: &[u8], bytes: u64, day: i64) -> bool {
|
||||||
|
if self.cap == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut days = self.map.entry(key.to_vec()).or_default();
|
||||||
|
// Den nahrání + TTL dní: pak je blob úklidem pryč (zaokrouhleno nahoru).
|
||||||
|
while days.front().is_some_and(|(d, _)| *d + self.ttl_days < day) {
|
||||||
|
days.pop_front();
|
||||||
|
}
|
||||||
|
let used: u64 = days.iter().map(|(_, b)| b).sum();
|
||||||
|
if used.saturating_add(bytes) > self.cap {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match days.back_mut() {
|
||||||
|
Some((d, b)) if *d == day => *b += bytes,
|
||||||
|
_ => days.push_back((day, bytes)),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(&self, key: &[u8], bytes: u64) {
|
||||||
|
if let Some(mut days) = self.map.get_mut(key) {
|
||||||
|
if let Some((_, b)) = days.back_mut() {
|
||||||
|
*b = b.saturating_sub(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let horizon = Self::today() - self.ttl_days;
|
||||||
|
self.map
|
||||||
|
.retain(|_, days| days.back().is_some_and(|(d, _)| *d >= horizon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_odmitne_nad_bajty_i_kusy_a_doplni_se() {
|
||||||
|
let q = Budgets::new(3, 1000, Duration::from_secs(3600));
|
||||||
|
let t0 = Instant::now();
|
||||||
|
assert!(q.try_take_at(b"a", 600, t0));
|
||||||
|
assert!(!q.try_take_at(b"a", 600, t0), "bajty došly");
|
||||||
|
assert!(q.try_take_at(b"a", 100, t0));
|
||||||
|
assert!(q.try_take_at(b"a", 100, t0));
|
||||||
|
assert!(!q.try_take_at(b"a", 1, t0), "kusy došly");
|
||||||
|
assert!(q.try_take_at(b"b", 600, t0), "jiný účet má vlastní kvótu");
|
||||||
|
// za půl periody se doplní polovina
|
||||||
|
let t1 = t0 + Duration::from_secs(1800);
|
||||||
|
assert!(q.try_take_at(b"a", 400, t1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn budget_nula_znamena_bez_omezeni() {
|
||||||
|
let q = Budgets::new(0, 0, Duration::from_secs(1));
|
||||||
|
for _ in 0..1000 {
|
||||||
|
assert!(q.try_take(b"a", u64::MAX / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refund_vrati_nespotrebovane() {
|
||||||
|
let q = Budgets::new(0, 1000, Duration::from_secs(3600));
|
||||||
|
assert!(q.try_take(b"a", 1000));
|
||||||
|
q.refund(b"a", 700);
|
||||||
|
assert!(q.try_take(b"a", 700));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_drzi_strop_a_stare_dny_uvolni() {
|
||||||
|
let l = StoredLedger::new(1000, 30);
|
||||||
|
assert!(l.try_reserve_on(b"a", 800, 100));
|
||||||
|
assert!(!l.try_reserve_on(b"a", 300, 100));
|
||||||
|
assert!(l.try_reserve_on(b"b", 300, 100), "jiný účet");
|
||||||
|
l.release(b"a", 500);
|
||||||
|
assert!(l.try_reserve_on(b"a", 600, 101));
|
||||||
|
// po TTL dnech od nahrání je místo zase volné
|
||||||
|
assert!(l.try_reserve_on(b"a", 1000, 132));
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
-11
@@ -1,16 +1,20 @@
|
|||||||
//! REST endpointy — registrace, prekey bundle, sender cert.
|
//! REST endpointy — registrace, prekey bundle, sender cert.
|
||||||
//! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů.
|
//! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{ConnectInfo, Path, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::Json;
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
use libsignal_protocol::PublicKey;
|
use libsignal_protocol::PublicKey;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::limits::RegisterLimits;
|
||||||
|
use crate::pow::{Mode as PowMode, Verdict};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
type ApiResult<T> = Result<T, (StatusCode, String)>;
|
type ApiResult<T> = Result<T, (StatusCode, String)>;
|
||||||
@@ -57,15 +61,50 @@ pub async fn ready(State(state): State<Arc<AppState>>) -> ApiResult<Json<serde_j
|
|||||||
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
|
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
|
||||||
/// s relayem mluví, má vědět, který zdroj právě běží.
|
/// s relayem mluví, má vědět, který zdroj právě běží.
|
||||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||||
|
let mut capabilities = vec!["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"];
|
||||||
|
if state.pow.mode() != PowMode::Off {
|
||||||
|
capabilities.push("register-pow");
|
||||||
|
}
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"frame_version": 1,
|
"frame_version": 1,
|
||||||
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
|
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
|
||||||
"capabilities": ["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"],
|
"capabilities": capabilities,
|
||||||
|
"register_pow": state.pow.mode().as_str(),
|
||||||
"source": state.source_url,
|
"source": state.source_url,
|
||||||
"revision": state.revision,
|
"revision": state.revision,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> u64 {
|
||||||
|
chrono::Utc::now().timestamp().max(0) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /v1/register/challenge ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Výzva proof-of-work pro registraci (pow.rs). `no-store`: každá výzva je
|
||||||
|
/// jednorázová, sdílená cache by ji rozdala víc klientům.
|
||||||
|
pub async fn register_challenge(State(state): State<Arc<AppState>>) -> Response {
|
||||||
|
if state.pow.mode() == PowMode::Off {
|
||||||
|
return (StatusCode::NOT_FOUND, "proof-of-work je vypnutý").into_response();
|
||||||
|
}
|
||||||
|
let now = unix_now();
|
||||||
|
let challenge = state.pow.issue(now);
|
||||||
|
let mut response = Json(serde_json::json!({
|
||||||
|
"algorithm": "sha256-v1",
|
||||||
|
"challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge.token),
|
||||||
|
"bits": challenge.bits,
|
||||||
|
"expires_at": challenge.expires_at,
|
||||||
|
// relativně: hodiny telefonu bývají mimo, platnost si klient měří sám
|
||||||
|
"expires_in": challenge.expires_at - now,
|
||||||
|
"mode": state.pow.mode().as_str(),
|
||||||
|
}))
|
||||||
|
.into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
/// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis
|
/// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis
|
||||||
/// identity klíčem nad `"<method_path>\n<ts_ms>"`, stejné okno jako
|
/// 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:
|
/// X-Sepot-Auth. Důkaz držení privátního klíče bez znalosti device_uuid:
|
||||||
@@ -127,10 +166,70 @@ pub struct RegisterOut {
|
|||||||
/// Strop OTP klíčů v jedné registraci (klient posílá 100).
|
/// Strop OTP klíčů v jedné registraci (klient posílá 100).
|
||||||
const MAX_ONE_TIME_PREKEYS: usize = 100;
|
const MAX_ONE_TIME_PREKEYS: usize = 100;
|
||||||
|
|
||||||
|
/// Limit registrací se vybírá až v handleru: bucket per IP závisí na tom,
|
||||||
|
/// jestli klient přinesl platný proof-of-work, a ten je vázaný na identity
|
||||||
|
/// klíč z těla. Ověření PoW je jedna HMAC a jedna SHA-256, takže pořadí
|
||||||
|
/// „parsovat → ověřit → limit → podpisy a DB“ nic drahého nepouští dřív.
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(limits): Extension<Arc<RegisterLimits>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<RegisterIn>,
|
Json(body): Json<RegisterIn>,
|
||||||
|
) -> Response {
|
||||||
|
if state.limits.registration_paused {
|
||||||
|
return (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
[(header::RETRY_AFTER, "300")],
|
||||||
|
"registrace jsou dočasně pozastavené, zkus to za pár minut",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
let identity_key = match b64("identity_key", &body.identity_key, Some(33)) {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(e) => return e.into_response(),
|
||||||
|
};
|
||||||
|
let with_pow = match (state.pow.mode(), state.pow.verify(&headers, &identity_key, unix_now())) {
|
||||||
|
(PowMode::Off, _) => false,
|
||||||
|
(_, Verdict::Valid) => true,
|
||||||
|
(PowMode::Optional, Verdict::Missing) => false,
|
||||||
|
(PowMode::Optional, Verdict::Invalid(why)) => {
|
||||||
|
// Prošlá nebo poškozená výzva v režimu optional = registrace jako
|
||||||
|
// bez PoW (běžný limit), ne chyba: starší výzva po restartu relaye
|
||||||
|
// nesmí člověka zablokovat.
|
||||||
|
tracing::debug!(why, "neplatný proof-of-work, běžný limit");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
(PowMode::Required, verdict) => {
|
||||||
|
if let Verdict::Invalid(why) = verdict {
|
||||||
|
tracing::debug!(why, "neplatný proof-of-work");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
StatusCode::PRECONDITION_REQUIRED,
|
||||||
|
"registrace vyžaduje proof-of-work (GET /v1/register/challenge)",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let limiter = if with_pow { &limits.pow } else { &limits.base };
|
||||||
|
if let Err(wait) = limiter.check(peer.ip(), &headers) {
|
||||||
|
return crate::limits::too_many(wait);
|
||||||
|
}
|
||||||
|
match register_device(&state, &headers, identity_key, body).await {
|
||||||
|
Ok(created) => {
|
||||||
|
state.pow.note_registration();
|
||||||
|
tracing::info!(pow = with_pow, "nová registrace zařízení");
|
||||||
|
created.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => e.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_device(
|
||||||
|
state: &AppState,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
identity_key: Vec<u8>,
|
||||||
|
body: RegisterIn,
|
||||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
||||||
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
||||||
return Err(bad("příliš mnoho one-time prekeys"));
|
return Err(bad("příliš mnoho one-time prekeys"));
|
||||||
@@ -140,7 +239,6 @@ pub async fn register(
|
|||||||
if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) {
|
if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) {
|
||||||
return Err(bad("device_id nebo registration_id mimo rozsah"));
|
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 delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?;
|
||||||
let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
|
let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
|
||||||
let spk_sig = b64(
|
let spk_sig = b64(
|
||||||
@@ -191,7 +289,7 @@ pub async fn register(
|
|||||||
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
|
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
|
||||||
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
|
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
|
||||||
let account_id = if inserted.rows_affected() == 0 {
|
let account_id = if inserted.rows_affected() == 0 {
|
||||||
if !identity_proof_valid(&headers, &identity, "POST /v1/register") {
|
if !identity_proof_valid(headers, &identity, "POST /v1/register") {
|
||||||
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
|
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
|
||||||
}
|
}
|
||||||
let (existing,): (Vec<u8>,) =
|
let (existing,): (Vec<u8>,) =
|
||||||
@@ -271,7 +369,6 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await.map_err(internal)?;
|
tx.commit().await.map_err(internal)?;
|
||||||
tracing::info!("nová registrace zařízení");
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
@@ -489,9 +586,6 @@ pub struct UploadKeysOut {
|
|||||||
pub one_time_prekeys: i64,
|
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ě
|
/// 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).
|
/// drží, protože bundle s ním mohl někdo stáhnout těsně před výměnou).
|
||||||
@@ -595,7 +689,9 @@ pub async fn upload_keys(
|
|||||||
.await
|
.await
|
||||||
.map_err(internal)?;
|
.map_err(internal)?;
|
||||||
for otp in &body.one_time_prekeys {
|
for otp in &body.one_time_prekeys {
|
||||||
if stored >= MAX_STORED_ONE_TIME_PREKEYS {
|
// Strop zásoby OTP (`Limits::max_stored_otp`, výchozí 100) — nad něj se
|
||||||
|
// další zahodí (klient posílá dávky po 50, jako při registraci sto).
|
||||||
|
if stored >= state.limits.max_stored_otp {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let public = b64("one_time_prekeys.public_key", &otp.public_key, Some(33))?;
|
let public = b64("one_time_prekeys.public_key", &otp.public_key, Some(33))?;
|
||||||
|
|||||||
+295
-50
@@ -15,11 +15,8 @@ use crate::net::TrustedProxies;
|
|||||||
/// TTL offline fronty.
|
/// TTL offline fronty.
|
||||||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
pub const QUEUE_TTL_DAYS: i64 = 30;
|
||||||
|
|
||||||
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
|
/// Největší zakódovaná obálka, kterou fronta přijme (CHECK v migraci 0001).
|
||||||
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
|
const MAX_STORED_ENVELOPE_BYTES: usize = 262_144;
|
||||||
/// z toho vznikne OOM, na disku neomezený růst. Přetečení = FIFO zahození
|
|
||||||
/// nejstarších. 50 MiB ≈ 200 max obálek na příjemce.
|
|
||||||
const MAX_QUEUE_BYTES_PER_RECIPIENT: i64 = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
||||||
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
||||||
@@ -40,29 +37,61 @@ const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
|||||||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||||||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||||||
|
|
||||||
/// Výchozí strop souběžných WS spojení z jedné IP. Env
|
|
||||||
/// `SEPOT_MAX_CONNS_PER_IP` ho přepisuje — za CGNAT mobilních operátorů
|
|
||||||
/// sdílí jednu IP stovky lidí.
|
|
||||||
const DEFAULT_MAX_CONNS_PER_IP: u32 = 32;
|
|
||||||
|
|
||||||
/// Běžný budíček na jeden cíl nejvýš jednou za tohle okno: třicet zpráv ve
|
/// 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
|
/// skupině během výpadku sítě = jeden push, ne třicet (APNs i FCM nápor
|
||||||
/// tichých pushů škrtí, baterie taky).
|
/// tichých pushů škrtí, baterie taky).
|
||||||
const WAKE_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
|
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
|
/// 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
|
/// 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.
|
/// `Limits::call_wake_min_interval` a `Limits::call_wakes_per_window` za
|
||||||
/// Nad strop se obálka uloží a jde jen běžný budíček (phantom
|
/// CALL_WAKE_WINDOW. Nad strop se obálka uloží a jde jen běžný budíček
|
||||||
/// hovory od kohokoli, kdo zná adresu).
|
/// (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_WINDOW: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
const CALL_WAKE_MAX_PER_WINDOW: usize = 6;
|
/// Okno pro `Limits::otp_issue_per_hour`: noví kontakti jsou jednotky za den;
|
||||||
/// Kolik one-time prekeys smí relay vydat na jednu adresu za hodinu.
|
/// sto GET /v1/keys za dvacet sekund je vyčerpání zásoby cizím. Nad strop
|
||||||
/// Noví kontakti jsou jednotky za den; sto GET /v1/keys za dvacet sekund
|
/// jde bundle bez OTP.
|
||||||
/// 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);
|
const OTP_ISSUE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
|
||||||
|
/// Okno kvóty odesílatele (`Limits::send_*_per_hour`).
|
||||||
|
const SEND_BUDGET_PERIOD: std::time::Duration = std::time::Duration::from_secs(3600);
|
||||||
|
/// Okno kvóty příloh na účet (`Limits::att_account_bytes_per_day`).
|
||||||
|
const ATTACHMENT_BUDGET_PERIOD: std::time::Duration = std::time::Duration::from_secs(86_400);
|
||||||
|
|
||||||
|
/// Proč relay obálku nepřijal. Klient dostane Error rámec s kódem, spojení
|
||||||
|
/// zůstává otevřené.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RouteError {
|
||||||
|
/// 400: formát (délky, prázdný payload, nad strop velikosti).
|
||||||
|
Invalid,
|
||||||
|
/// 404: adresa nepatří žádnému zařízení. Ukládat by ji nikdo nevyzvedl
|
||||||
|
/// — a fronta na náhodné adresy byla nejlevnější cesta, jak zaplnit disk.
|
||||||
|
/// Nic nového to neprozradí: GET /v1/keys vrací na neznámou adresu 404 taky.
|
||||||
|
UnknownRecipient,
|
||||||
|
/// 429: odesílající účet vyčerpal hodinovou kvótu.
|
||||||
|
SenderQuota,
|
||||||
|
/// 507: na disku zbývá méně než `Limits::disk_min_free_pct_queue`.
|
||||||
|
DiskFull,
|
||||||
|
Internal(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RouteError {
|
||||||
|
pub fn code_and_message(&self) -> (u32, &'static str) {
|
||||||
|
match self {
|
||||||
|
Self::Invalid => (400, "obálka odmítnuta"),
|
||||||
|
Self::UnknownRecipient => (404, "příjemce neexistuje"),
|
||||||
|
Self::SenderQuota => (429, "příliš mnoho zpráv, zkus to později"),
|
||||||
|
Self::DiskFull => (507, "relay je dočasně plný, zkus to později"),
|
||||||
|
Self::Internal(_) => (500, "interní chyba"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for RouteError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
Self::Internal(e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
/// Značky budíčků jednoho cíle (jen v paměti, nikam se neukládají).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct WakeMarks {
|
pub struct WakeMarks {
|
||||||
@@ -157,12 +186,21 @@ pub struct AppState {
|
|||||||
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
pub apns: Option<std::sync::Arc<crate::apns::ApnsSender>>,
|
||||||
/// TURN pro hovory (None = relay údaje nevydává).
|
/// TURN pro hovory (None = relay údaje nevydává).
|
||||||
pub turn: Option<crate::turn::TurnConfig>,
|
pub turn: Option<crate::turn::TurnConfig>,
|
||||||
/// Strop WS spojení na IP.
|
/// Všechny limity relaye (env, viz limits.rs).
|
||||||
pub max_conns_per_ip: u32,
|
pub limits: crate::limits::Limits,
|
||||||
|
/// Kvóta ukládaných obálek na odesílající účet (paměť).
|
||||||
|
pub send_budget: crate::quota::Budgets,
|
||||||
|
/// Kvóta příloh na účet: tempo za den a uloženo celkem (paměť).
|
||||||
|
pub attachment_budget: crate::quota::Budgets,
|
||||||
|
pub attachment_stored: crate::quota::StoredLedger,
|
||||||
|
/// Volné místo na datovém filesystému (přílohy i DB).
|
||||||
|
pub disk: Arc<crate::disk::DiskWatch>,
|
||||||
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
/// delivery_address → značky budíčků (throttling pushů a hovorů).
|
||||||
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||||||
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||||||
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
|
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
|
||||||
|
/// Proof-of-work registrace: klíč výzev, spotřebované výzvy, zátěž (paměť).
|
||||||
|
pub pow: crate::pow::RegisterPow,
|
||||||
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
|
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
|
||||||
pub source_url: String,
|
pub source_url: String,
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
@@ -174,6 +212,7 @@ impl AppState {
|
|||||||
certs: CertAuthority,
|
certs: CertAuthority,
|
||||||
attachments: crate::attachments::Config,
|
attachments: crate::attachments::Config,
|
||||||
trusted_proxies: Arc<TrustedProxies>,
|
trusted_proxies: Arc<TrustedProxies>,
|
||||||
|
limits: crate::limits::Limits,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -187,12 +226,25 @@ impl AppState {
|
|||||||
fcm: crate::fcm::FcmSender::from_env(),
|
fcm: crate::fcm::FcmSender::from_env(),
|
||||||
apns: crate::apns::ApnsSender::from_env(),
|
apns: crate::apns::ApnsSender::from_env(),
|
||||||
turn: crate::turn::TurnConfig::from_env(),
|
turn: crate::turn::TurnConfig::from_env(),
|
||||||
max_conns_per_ip: std::env::var("SEPOT_MAX_CONNS_PER_IP")
|
send_budget: crate::quota::Budgets::new(
|
||||||
.ok()
|
limits.send_per_hour,
|
||||||
.and_then(|v| v.parse().ok())
|
limits.send_bytes_per_hour,
|
||||||
.unwrap_or(DEFAULT_MAX_CONNS_PER_IP),
|
SEND_BUDGET_PERIOD,
|
||||||
|
),
|
||||||
|
attachment_budget: crate::quota::Budgets::new(
|
||||||
|
0,
|
||||||
|
limits.att_account_bytes_per_day,
|
||||||
|
ATTACHMENT_BUDGET_PERIOD,
|
||||||
|
),
|
||||||
|
attachment_stored: crate::quota::StoredLedger::new(
|
||||||
|
limits.att_account_stored_bytes,
|
||||||
|
crate::attachments::ATTACHMENT_TTL_DAYS,
|
||||||
|
),
|
||||||
|
disk: Arc::new(crate::disk::DiskWatch::unknown()),
|
||||||
|
limits,
|
||||||
wake_marks: DashMap::new(),
|
wake_marks: DashMap::new(),
|
||||||
otp_issued: DashMap::new(),
|
otp_issued: DashMap::new(),
|
||||||
|
pow: crate::pow::RegisterPow::new(crate::pow::Config::from_env()),
|
||||||
source_url: std::env::var("SEPOT_SOURCE_URL")
|
source_url: std::env::var("SEPOT_SOURCE_URL")
|
||||||
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
||||||
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
||||||
@@ -212,6 +264,11 @@ impl AppState {
|
|||||||
});
|
});
|
||||||
self.otp_issued
|
self.otp_issued
|
||||||
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
.retain(|_, (start, _)| now.duration_since(*start) < OTP_ISSUE_WINDOW);
|
||||||
|
self.send_budget.prune();
|
||||||
|
self.attachment_budget.prune();
|
||||||
|
self.attachment_stored.prune();
|
||||||
|
self.pow
|
||||||
|
.prune(chrono::Utc::now().timestamp().max(0) as u64);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
||||||
@@ -224,7 +281,7 @@ impl AppState {
|
|||||||
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
if now.duration_since(entry.0) >= OTP_ISSUE_WINDOW {
|
||||||
*entry = (now, 0);
|
*entry = (now, 0);
|
||||||
}
|
}
|
||||||
if entry.1 >= OTP_ISSUE_MAX_PER_HOUR {
|
if entry.1 >= self.limits.otp_issue_per_hour {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.1 += 1;
|
entry.1 += 1;
|
||||||
@@ -255,11 +312,11 @@ impl AppState {
|
|||||||
.call_wakes
|
.call_wakes
|
||||||
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
.retain(|t| now.duration_since(*t) < CALL_WAKE_WINDOW);
|
||||||
if let Some(last) = marks.call_wakes.back() {
|
if let Some(last) = marks.call_wakes.back() {
|
||||||
if now.duration_since(*last) < CALL_WAKE_MIN_INTERVAL {
|
if now.duration_since(*last) < self.limits.call_wake_min_interval {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if marks.call_wakes.len() >= CALL_WAKE_MAX_PER_WINDOW {
|
if marks.call_wakes.len() >= self.limits.call_wakes_per_window {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
marks.call_wakes.push_back(now);
|
marks.call_wakes.push_back(now);
|
||||||
@@ -277,19 +334,22 @@ impl AppState {
|
|||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Přijme obálku: validace → perzistentní fronta (dedup) → pokud je
|
/// Přijme obálku: validace → existence příjemce → kvóta odesílatele a
|
||||||
/// příjemce online, doručí hned. At-least-once: záznam z fronty maže
|
/// místo na disku → perzistentní fronta (dedup) → pokud je příjemce
|
||||||
/// až ACK příjemce, ne odeslání.
|
/// online, doručí hned. At-least-once: záznam z fronty maže až ACK
|
||||||
|
/// příjemce, ne odeslání. `sender_account` = ověřený účet spojení (jen
|
||||||
|
/// klíč kvóty v paměti; do fronty se neukládá — sealed sender).
|
||||||
pub async fn route_envelope(
|
pub async fn route_envelope(
|
||||||
&self,
|
&self,
|
||||||
env: v1::Envelope,
|
env: v1::Envelope,
|
||||||
) -> anyhow::Result<v1::receipt::Status> {
|
sender_account: &[u8],
|
||||||
|
) -> Result<v1::receipt::Status, RouteError> {
|
||||||
if env.dst_address.len() != 32
|
if env.dst_address.len() != 32
|
||||||
|| env.msg_uuid.len() != 16
|
|| env.msg_uuid.len() != 16
|
||||||
|| env.payload.is_empty()
|
|| env.payload.is_empty()
|
||||||
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
|
|| env.payload.len() > MAX_ENVELOPE_PAYLOAD
|
||||||
{
|
{
|
||||||
anyhow::bail!("neplatná obálka");
|
return Err(RouteError::Invalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
|
if env.content_hint == v1::ContentHint::Ephemeral as i32 {
|
||||||
@@ -297,7 +357,26 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let dst = env.dst_address.clone();
|
let dst = env.dst_address.clone();
|
||||||
|
// Online příjemce existuje; jinak jeden indexovaný dotaz (UNIQUE).
|
||||||
|
if self.online.get(&dst).is_none() && !self.address_exists(&dst).await? {
|
||||||
|
return Err(RouteError::UnknownRecipient);
|
||||||
|
}
|
||||||
|
if self.disk.below(self.limits.disk_min_free_pct_queue) {
|
||||||
|
return Err(RouteError::DiskFull);
|
||||||
|
}
|
||||||
let encoded = env.encode_to_vec();
|
let encoded = env.encode_to_vec();
|
||||||
|
// Strop zakódované obálky = CHECK message_queue.envelope v 0001; payload
|
||||||
|
// těsně pod MAX_ENVELOPE_PAYLOAD ho s hlavičkou protobufu přesáhne a DB
|
||||||
|
// by zápis odmítla až po odečtení kvóty (dřív jako 400 přes chybu DB).
|
||||||
|
if encoded.len() > MAX_STORED_ENVELOPE_BYTES {
|
||||||
|
return Err(RouteError::Invalid);
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.send_budget
|
||||||
|
.try_take(sender_account, encoded.len() as u64)
|
||||||
|
{
|
||||||
|
return Err(RouteError::SenderQuota);
|
||||||
|
}
|
||||||
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
|
let expires = Utc::now() + Duration::days(QUEUE_TTL_DAYS);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||||||
@@ -311,7 +390,13 @@ impl AppState {
|
|||||||
.await?;
|
.await?;
|
||||||
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
||||||
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
||||||
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
|
self.trim_queue(
|
||||||
|
&dst,
|
||||||
|
self.limits.queue_max_bytes_per_recipient,
|
||||||
|
self.limits.queue_max_per_recipient,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(RouteError::Internal)?;
|
||||||
|
|
||||||
let is_call = env.content_hint == v1::ContentHint::Call as i32;
|
let is_call = env.content_hint == v1::ContentHint::Call as i32;
|
||||||
if let Some(dev) = self.online.get(&dst) {
|
if let Some(dev) = self.online.get(&dst) {
|
||||||
@@ -341,6 +426,13 @@ impl AppState {
|
|||||||
Ok(v1::receipt::Status::StoredOffline)
|
Ok(v1::receipt::Status::StoredOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn address_exists(&self, dst: &[u8]) -> Result<bool, sqlx::Error> {
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM devices WHERE delivery_address = $1)")
|
||||||
|
.bind(dst)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// Jen živé doručení („píše…“): do fronty nic, push nic. Zatuchlé
|
/// Jen živé doručení („píše…“): do fronty nic, push nic. Zatuchlé
|
||||||
/// spojení se nepočítá — obálka by v mrtvém kanálu zmizela stejně
|
/// spojení se nepočítá — obálka by v mrtvém kanálu zmizela stejně
|
||||||
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
|
/// a odesílatel se má dozvědět, že příjemce nikdo neposlouchá.
|
||||||
@@ -362,23 +454,27 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
||||||
/// (běžný součet od nejnovější), starší přebytek zahoď. Vrací počet
|
/// (běžný součet od nejnovější) a nejvýš `cap_count` kusů, starší
|
||||||
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
|
/// přebytek zahoď. Strop počtu chrání před miliony drobných obálek, kde
|
||||||
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64) -> anyhow::Result<u64> {
|
/// řádek v DB stojí víc než payload. Vrací počet zahozených. Oddělené
|
||||||
|
/// kvůli přímému testu bez vkládání 50 MiB.
|
||||||
|
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64, cap_count: i64) -> anyhow::Result<u64> {
|
||||||
let res = sqlx::query(
|
let res = sqlx::query(
|
||||||
"DELETE FROM message_queue
|
"DELETE FROM message_queue
|
||||||
WHERE dst_address = $1 AND msg_uuid IN (
|
WHERE dst_address = $1 AND msg_uuid IN (
|
||||||
SELECT msg_uuid FROM (
|
SELECT msg_uuid FROM (
|
||||||
SELECT msg_uuid,
|
SELECT msg_uuid,
|
||||||
sum(octet_length(envelope)) OVER (
|
sum(octet_length(envelope)) OVER newest AS running,
|
||||||
ORDER BY enqueued_at DESC, msg_uuid DESC
|
row_number() OVER newest AS position
|
||||||
ROWS UNBOUNDED PRECEDING) AS running
|
|
||||||
FROM message_queue WHERE dst_address = $1
|
FROM message_queue WHERE dst_address = $1
|
||||||
) s WHERE running > $2
|
WINDOW newest AS (ORDER BY enqueued_at DESC, msg_uuid DESC
|
||||||
|
ROWS UNBOUNDED PRECEDING)
|
||||||
|
) s WHERE running > $2 OR position > $3
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
.bind(dst)
|
.bind(dst)
|
||||||
.bind(cap_bytes)
|
.bind(cap_bytes)
|
||||||
|
.bind(cap_count)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(res.rows_affected())
|
Ok(res.rows_affected())
|
||||||
@@ -475,7 +571,11 @@ impl AppState {
|
|||||||
endpoint: Option<&[u8]>,
|
endpoint: Option<&[u8]>,
|
||||||
voip_token: Option<&[u8]>,
|
voip_token: Option<&[u8]>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
if platform == crate::push::PLATFORM_NONE || endpoint.is_none() {
|
// APNs s jediným VoIP tokenem (appka hovory zvoní přes PushKit, běžný
|
||||||
|
// APNs token ještě nemá) se ukládá taky — dřív by ji tahle větev
|
||||||
|
// smazala a hovor by offline iPhone nikdy nevzbudil.
|
||||||
|
let voip_only = platform == crate::push::PLATFORM_APNS && voip_token.is_some();
|
||||||
|
if platform == crate::push::PLATFORM_NONE || (endpoint.is_none() && !voip_only) {
|
||||||
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
|
sqlx::query("DELETE FROM push_tokens WHERE device_uuid = $1")
|
||||||
.bind(device_uuid)
|
.bind(device_uuid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
@@ -693,6 +793,9 @@ mod db_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
|
|
||||||
|
/// Účet odesílatele v testech — kvóta je jen v paměti, DB ho nezná.
|
||||||
|
const SENDER: &[u8] = b"test-odesilatel";
|
||||||
|
|
||||||
async fn test_state() -> AppState {
|
async fn test_state() -> AppState {
|
||||||
let url = std::env::var("DATABASE_URL")
|
let url = std::env::var("DATABASE_URL")
|
||||||
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
.unwrap_or_else(|_| "postgres://sepot:sepot_dev@127.0.0.1/sepot_relay".into());
|
||||||
@@ -703,7 +806,13 @@ mod db_tests {
|
|||||||
.expect("dev DB");
|
.expect("dev DB");
|
||||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||||
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
||||||
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
|
AppState::new(
|
||||||
|
pool,
|
||||||
|
certs,
|
||||||
|
crate::attachments::Config::from_env(),
|
||||||
|
trusted,
|
||||||
|
crate::limits::Limits::from_env(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rand_bytes(n: usize) -> Vec<u8> {
|
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||||
@@ -826,7 +935,7 @@ mod db_tests {
|
|||||||
content_hint: v1::ContentHint::Call as i32,
|
content_hint: v1::ContentHint::Call as i32,
|
||||||
payload: vec![0xCD; 64],
|
payload: vec![0xCD; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
||||||
let queued: i64 =
|
let queued: i64 =
|
||||||
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||||
@@ -875,7 +984,7 @@ mod db_tests {
|
|||||||
content_hint: 0,
|
content_hint: 0,
|
||||||
payload: vec![0xAB; 64],
|
payload: vec![0xAB; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
assert_eq!(status, v1::receipt::Status::StoredOffline);
|
||||||
|
|
||||||
// obálka je ve frontě (příjemce byl offline)
|
// obálka je ve frontě (příjemce byl offline)
|
||||||
@@ -902,7 +1011,7 @@ mod db_tests {
|
|||||||
content_hint: v1::ContentHint::Ephemeral as i32,
|
content_hint: v1::ContentHint::Ephemeral as i32,
|
||||||
payload: vec![0xAB; 64],
|
payload: vec![0xAB; 64],
|
||||||
};
|
};
|
||||||
let status = state.route_envelope(env).await.unwrap();
|
let status = state.route_envelope(env, SENDER).await.unwrap();
|
||||||
assert_eq!(status, v1::receipt::Status::Dropped);
|
assert_eq!(status, v1::receipt::Status::Dropped);
|
||||||
|
|
||||||
// „píše…“ pro offline příjemce nesmí skončit ve frontě
|
// „píše…“ pro offline příjemce nesmí skončit ve frontě
|
||||||
@@ -1055,7 +1164,7 @@ mod db_tests {
|
|||||||
payload: vec![0xCD; 64],
|
payload: vec![0xCD; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env).await.unwrap(),
|
state.route_envelope(env, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::StoredOffline,
|
v1::receipt::Status::StoredOffline,
|
||||||
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
||||||
);
|
);
|
||||||
@@ -1069,7 +1178,7 @@ mod db_tests {
|
|||||||
payload: vec![0xCE; 64],
|
payload: vec![0xCE; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env2).await.unwrap(),
|
state.route_envelope(env2, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::DeliveredOnline,
|
v1::receipt::Status::DeliveredOnline,
|
||||||
"živé spojení má být online"
|
"živé spojení má být online"
|
||||||
);
|
);
|
||||||
@@ -1109,7 +1218,7 @@ mod db_tests {
|
|||||||
payload: vec![0xEF; 64],
|
payload: vec![0xEF; 64],
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.route_envelope(env).await.unwrap(),
|
state.route_envelope(env, SENDER).await.unwrap(),
|
||||||
v1::receipt::Status::DeliveredOnline,
|
v1::receipt::Status::DeliveredOnline,
|
||||||
"konec zombie spojení nesmí vyřadit nové spojení"
|
"konec zombie spojení nesmí vyřadit nové spojení"
|
||||||
);
|
);
|
||||||
@@ -1237,7 +1346,7 @@ mod db_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let dropped = state.trim_queue(&dst, 250).await.unwrap();
|
let dropped = state.trim_queue(&dst, 250, 1000).await.unwrap();
|
||||||
assert_eq!(dropped, 3);
|
assert_eq!(dropped, 3);
|
||||||
|
|
||||||
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||||
@@ -1255,4 +1364,140 @@ mod db_tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn envelope(dst: &[u8], payload_len: usize) -> v1::Envelope {
|
||||||
|
v1::Envelope {
|
||||||
|
dst_address: dst.to_vec(),
|
||||||
|
msg_uuid: rand_bytes(16),
|
||||||
|
content_hint: 0,
|
||||||
|
payload: vec![0x5Au8; payload_len],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn queued(state: &AppState, dst: &[u8]) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||||
|
.bind(dst)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obálka na adresu bez zařízení se neuloží (dřív 30 dní ve frontě =
|
||||||
|
/// nejlevnější cesta, jak zaplnit disk relaye).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn obalka_na_neexistujici_adresu_se_neulozi() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let dst = rand_bytes(32);
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), SENDER).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::UnknownRecipient), "{err:?}");
|
||||||
|
assert_eq!(err.code_and_message().0, 404);
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kvóta odesílatele: nad hodinový strop 429, jiný účet jede dál.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn kvota_odesilatele_odmitne_nad_strop() {
|
||||||
|
let mut state = test_state().await;
|
||||||
|
state.send_budget = crate::quota::Budgets::new(3, 0, SEND_BUDGET_PERIOD);
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
let sender = rand_bytes(16);
|
||||||
|
for _ in 0..3 {
|
||||||
|
state.route_envelope(envelope(&dst, 64), &sender).await.unwrap();
|
||||||
|
}
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), &sender).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::SenderQuota), "{err:?}");
|
||||||
|
state
|
||||||
|
.route_envelope(envelope(&dst, 64), &rand_bytes(16))
|
||||||
|
.await
|
||||||
|
.expect("jiný odesílatel má vlastní kvótu");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 4);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Málo místa na disku: offline fronta stojí (507), nic se nezapíše.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn plny_disk_zastavi_frontu() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
state.disk.set_free_pct(state.limits.disk_min_free_pct_queue - 1.0);
|
||||||
|
let err = state.route_envelope(envelope(&dst, 64), SENDER).await.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::DiskFull), "{err:?}");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strop počtu obálek na příjemce drží nejnovější, i když bajty stačí.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn trim_queue_strop_poctu() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let dst = rand_bytes(32);
|
||||||
|
let mut uuids = Vec::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
let msg_uuid = rand_bytes(16);
|
||||||
|
uuids.push(msg_uuid.clone());
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||||
|
VALUES ($1, $2, $3, now() + (interval '1 second' * $4), now() + interval '30 days')",
|
||||||
|
)
|
||||||
|
.bind(&msg_uuid)
|
||||||
|
.bind(&dst)
|
||||||
|
.bind(vec![0u8; 10])
|
||||||
|
.bind(i as i32)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(state.trim_queue(&dst, 1 << 30, 2).await.unwrap(), 3);
|
||||||
|
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||||
|
"SELECT msg_uuid FROM message_queue WHERE dst_address = $1 ORDER BY enqueued_at",
|
||||||
|
)
|
||||||
|
.bind(&dst)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, vec![uuids[3].clone(), uuids[4].clone()]);
|
||||||
|
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||||
|
.bind(&dst)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iPhone s jediným VoIP tokenem (běžný APNs token ještě nemá) musí jít
|
||||||
|
/// vzbudit hovorem — registrace se nesmí zahodit.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn apns_jen_s_voip_tokenem_se_ulozi() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, device_uuid, dst) = seed_device(&state).await;
|
||||||
|
state
|
||||||
|
.register_push(&device_uuid, crate::push::PLATFORM_APNS, None, Some(b"voip"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let target = state.push_target_full(&dst).await.expect("VoIP cíl");
|
||||||
|
assert_eq!(target.voip_token.as_deref(), Some(&b"voip"[..]));
|
||||||
|
assert!(target.token.is_none());
|
||||||
|
assert!(state.push_target(&dst).await.is_none(), "běžný budíček nemá kam");
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obálka, která se zakódovaná nevejde do CHECK fronty, je 400 dřív, než
|
||||||
|
/// ukrojí z kvóty odesílatele — ne 500 z databáze.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||||
|
async fn obalka_nad_strop_fronty_je_neplatna() {
|
||||||
|
let state = test_state().await;
|
||||||
|
let (account_id, _device, dst) = seed_device(&state).await;
|
||||||
|
let err = state
|
||||||
|
.route_envelope(envelope(&dst, MAX_ENVELOPE_PAYLOAD), SENDER)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, RouteError::Invalid), "{err:?}");
|
||||||
|
assert_eq!(queued(&state, &dst).await, 0);
|
||||||
|
cleanup(&state, &account_id, &dst).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-11
@@ -33,13 +33,20 @@ use crate::state::AppState;
|
|||||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
/// Kolik AUTH pokusů smí jedno spojení udělat (každý = dotaz do DB).
|
/// Kolik AUTH pokusů smí jedno spojení udělat (každý = dotaz do DB).
|
||||||
const MAX_AUTH_ATTEMPTS: u32 = 3;
|
const MAX_AUTH_ATTEMPTS: u32 = 3;
|
||||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
/// Rate limit rámců od klienta: max `Limits::ws_frames_per_window` za FRAME_WINDOW.
|
||||||
/// Počítají se jen rámce, které něco stojí (obálka, push registrace, TURN);
|
/// 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
|
/// 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
|
/// 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).
|
/// kolech smazala (ztráta zpráv bez útočníka).
|
||||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||||
const FRAME_WINDOW_MAX: u32 = 200;
|
/// Buffery jednoho spojení. Výchozí v axum (tungstenite) jsou 128 KiB čtecí
|
||||||
|
/// + 128 KiB zapisovací = ~137 KiB RAM na spojení; při 512 MiB stropu
|
||||||
|
/// kontejneru OOM kolem 3,7–7 tisíc připojených (změřeno). 4 KiB čtecí buffer
|
||||||
|
/// a zápis bez bufferu (každý rámec se hned flushne) = ~16,6 KiB na spojení,
|
||||||
|
/// 8 990 spojení = 149 MiB. Velké obálky (256 KiB) tím netrpí: čtecí buffer
|
||||||
|
/// je jen blok, zprávu skládá tungstenite zvlášť do `max_message_size`.
|
||||||
|
const WS_READ_BUFFER: usize = 4096;
|
||||||
|
const WS_WRITE_BUFFER: usize = 0;
|
||||||
/// Push endpoint (URL distributora / FCM token) a VoIP token mají rozumný
|
/// 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.
|
/// strop; APNs token je 64 hex, FCM do ~200 znaků, UnifiedPush URL < 1 KiB.
|
||||||
const MAX_PUSH_ENDPOINT_BYTES: usize = 2048;
|
const MAX_PUSH_ENDPOINT_BYTES: usize = 2048;
|
||||||
@@ -83,10 +90,16 @@ pub async fn ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
||||||
|
// Všichni onion klienti mají IP kontejneru Toru → vlastní, vyšší strop.
|
||||||
|
let cap = if state.trusted_proxies.is_tor(peer.ip()) {
|
||||||
|
state.limits.max_conns_tor
|
||||||
|
} else {
|
||||||
|
state.limits.max_conns_per_ip
|
||||||
|
};
|
||||||
|
|
||||||
let over_cap = {
|
let over_cap = {
|
||||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||||
if *entry >= state.max_conns_per_ip {
|
if *entry >= cap {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
*entry += 1;
|
*entry += 1;
|
||||||
@@ -103,7 +116,9 @@ pub async fn ws_handler(
|
|||||||
ip,
|
ip,
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.max_message_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
ws.read_buffer_size(WS_READ_BUFFER)
|
||||||
|
.write_buffer_size(WS_WRITE_BUFFER)
|
||||||
|
.max_message_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
||||||
.max_frame_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
.max_frame_size(MAX_ENVELOPE_PAYLOAD + 4096)
|
||||||
.on_upgrade(move |socket| async move {
|
.on_upgrade(move |socket| async move {
|
||||||
let _guard = guard;
|
let _guard = guard;
|
||||||
@@ -119,6 +134,7 @@ struct Pending {
|
|||||||
delivery_address: Vec<u8>,
|
delivery_address: Vec<u8>,
|
||||||
identity_key: Vec<u8>,
|
identity_key: Vec<u8>,
|
||||||
device_uuid: Vec<u8>,
|
device_uuid: Vec<u8>,
|
||||||
|
account_id: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_frame(frame: v1::server_frame::Frame) -> v1::ServerFrame {
|
fn server_frame(frame: v1::server_frame::Frame) -> v1::ServerFrame {
|
||||||
@@ -166,6 +182,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let mut authed_address: Option<Vec<u8>> = None;
|
let mut authed_address: Option<Vec<u8>> = None;
|
||||||
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
||||||
let mut authed_device: Option<Vec<u8>> = None;
|
let mut authed_device: Option<Vec<u8>> = None;
|
||||||
|
// Účet ověřeného zařízení — klíč kvóty odesílatele (jen v paměti).
|
||||||
|
let mut authed_account: Option<Vec<u8>> = None;
|
||||||
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
||||||
// routing poznal živé spojení od zatuchlého.
|
// routing poznal živé spojení od zatuchlého.
|
||||||
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||||
@@ -233,7 +251,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
window_frames = 0;
|
window_frames = 0;
|
||||||
}
|
}
|
||||||
window_frames += 1;
|
window_frames += 1;
|
||||||
if window_frames > FRAME_WINDOW_MAX {
|
if window_frames > state.limits.ws_frames_per_window {
|
||||||
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
let _ = tx.send(error_frame(429, "příliš mnoho rámců")).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -250,8 +268,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
let _ = tx.send(error_frame(400, "neplatný AUTH")).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let row: Option<(Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||||
"SELECT d.delivery_address, i.identity_key
|
"SELECT d.delivery_address, i.identity_key, d.account_id
|
||||||
FROM devices d JOIN identities i USING (account_id)
|
FROM devices d JOIN identities i USING (account_id)
|
||||||
WHERE d.device_uuid = $1",
|
WHERE d.device_uuid = $1",
|
||||||
)
|
)
|
||||||
@@ -260,7 +278,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
let Some((delivery_address, identity_key)) = row else {
|
let Some((delivery_address, identity_key, account_id)) = row else {
|
||||||
let _ = tx.send(error_frame(401, "neautorizováno")).await;
|
let _ = tx.send(error_frame(401, "neautorizováno")).await;
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -279,6 +297,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
delivery_address,
|
delivery_address,
|
||||||
identity_key,
|
identity_key,
|
||||||
device_uuid: auth.device_uuid,
|
device_uuid: auth.device_uuid,
|
||||||
|
account_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +366,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authed_device = Some(pd.device_uuid);
|
authed_device = Some(pd.device_uuid);
|
||||||
|
authed_account = Some(pd.account_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::RegisterPush(reg)) if authed_address.is_some() => {
|
||||||
@@ -377,7 +397,8 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
|
|
||||||
Some(v1::client_frame::Frame::Envelope(envelope)) if authed_address.is_some() => {
|
Some(v1::client_frame::Frame::Envelope(envelope)) if authed_address.is_some() => {
|
||||||
let msg_uuid = envelope.msg_uuid.clone();
|
let msg_uuid = envelope.msg_uuid.clone();
|
||||||
match state.route_envelope(envelope).await {
|
let sender = authed_account.as_deref().unwrap_or_default();
|
||||||
|
match state.route_envelope(envelope, sender).await {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
let _ = tx
|
let _ = tx
|
||||||
.send(server_frame(v1::server_frame::Frame::Receipt(
|
.send(server_frame(v1::server_frame::Frame::Receipt(
|
||||||
@@ -388,8 +409,12 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
|||||||
)))
|
)))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
let _ = tx.send(error_frame(400, "obálka odmítnuta")).await;
|
if let crate::state::RouteError::Internal(err) = &e {
|
||||||
|
tracing::warn!("obálka neuložena: {err}");
|
||||||
|
}
|
||||||
|
let (code, message) = e.code_and_message();
|
||||||
|
let _ = tx.send(error_frame(code, message)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user