43 Commits

Author SHA1 Message Date
Julius Joska 830d6290ca release: 1.2.3 (26) v1.2.3 2026-10-23 10:53:46 +02:00
Julius Joska cbb09afbb1 fix(ui): skip the "(unknown)" suffix when building avatar initials
A message request named "Mom (unknown)" showed the initials "M(".
2026-10-23 10:51:02 +02:00
Julius Joska 59992af778 fix(push): register APNs and VoIP tokens on iOS
The local notifications plugin was initialised without iOS settings, so
flutter_local_notifications threw "iOS settings must be set" before the
app registered with Apple, and restoreMode swallowed the error. iPhones
never sent the relay an APNs token for message wake-ups or a VoIP token
for calls.

- add DarwinInitializationSettings and request iOS permission explicitly
- register with Apple before asking for notification permission (silent
  wake-ups do not need it)
- send the VoIP token on its own (PLATFORM_APNS with an empty endpoint)
  when no APNs token is available yet
- record a failed registration so the chat list shows it instead of
  failing silently
- drop the force cast of rootViewController in AppDelegate and the unused
  SceneDelegate
2026-10-23 10:51:02 +02:00
Julius Joska 717aeed4ff fix(security): treat a new identity key as a message request, never as a known contact
A stranger could pick the display name of an existing contact ("Mom")
and appear as that contact. Messages from a key that is not in the
contact list now open a message request labelled "(unknown)": no
replies, read receipts, typing indicators, calls or name in
notifications until the user accepts it and names the contact
themselves. Names that look like an existing contact (case, diacritics,
Cyrillic/Greek homoglyphs, invisible characters) also show a warning in
the real contact's chat.

When the relay account of a known contact shows up with a different
key, the chat shows "safety number changed", verification is cleared
and the session that now belongs to the other key is dropped before
sending, so a reply never goes to the new key.
2026-10-23 10:50:42 +02:00
Julius Joska 2f6b542f24 fix(security): require a fresh PIN or biometric check for sensitive actions
An unlocked phone was enough to read the 24-word recovery phrase. The
phrase, backup export and import, the duress PIN, a longer auto-lock,
turning off screen security, enabling biometrics and deleting the
account now ask for the PIN (or biometrics with biometricOnly) every
time. The duress session never shows the phrase. PIN checks outside the
lock screen count towards the same failure throttle.
2026-10-23 10:50:16 +02:00
Julius Joska 5b237c82f2 docs: describe relay metadata and audit status precisely
State what the relay sees at runtime (authenticated submission reveals
which device hands an envelope to which address) instead of claiming it
never learns the sender, note where data is stored, drop the
"Signal-grade" label and add a non-affiliation notice, and document
that only internal audits and penetration tests have been done so far.
2026-10-23 09:17:44 +02:00
Julius Joska d2eb0d4f8c docs: describe how to verify the relay revision, APK and native libraries
Also state plainly what is not reproducible today and what we plan to do
about it.
2026-10-23 09:15:15 +02:00
Julius Joska ae127decd9 build: pin the native libsignal library by SHA-256
The libsignal Dart package downloads a prebuilt libsignal_frb from the
GitHub releases of djx-y-z/libsignal_dart and only checks it against a
checksum file from the same release. Keep our own pins in
app/tool/native_libs.sha256 and verify the finished AAB/APK with
app/tool/verify_native_libs.sh; any mismatch fails the release build.

The pins cover the upstream archives and the library as packaged after
the Android Gradle plugin strips it. `--from-upstream` re-derives the
packaged hashes from the upstream archives with llvm-strip.
2026-10-23 09:15:15 +02:00
Julius Joska ab0e35ddf4 docs(readme): update release status for Android and iOS 2026-10-15 09:32:53 +02:00
Julius Joska d431501b74 docs(changelog): note the iOS push settings fix in 1.2.2 v1.2.2 2026-10-11 16:12:46 +02:00
Julius Joska 6f9b08e844 fix(push): do not call UnifiedPush outside Android
UnifiedPush only exists on Android. On iOS getDistributor() and
getDistributors() throw UnimplementedError, which surfaced as an uncaught
error from the Push tile in Settings. Report an empty state instead.
2026-10-11 16:12:41 +02:00
Julius Joska 09ef54ef69 release: 1.2.2 (25)
New app icon: the Š that fades into dots. Launcher icons, adaptive icon
foreground and splash regenerated from assets/icon.
2026-10-11 15:31:53 +02:00
Julius Joska bfea7201f7 fix(chat): clear unread state for messages read in an open chat
Messages arriving while a conversation was open were never marked as read,
so the chat list kept showing them as unread after going back. The screen
now marks incoming messages read while it is on top and the app is in the
foreground, again when the app returns to the foreground, and on leaving.

Read state is now monotonic in Conversation.updateMessage, so a stale copy
of a message (attachment download, delayed resend) can no longer flip it
back to unread.

Also create the swipe-to-reply animation controller lazily instead of in
dispose().
2026-10-11 15:31:53 +02:00
Julius Joska 05eeccd83b test: tag suites that need the native libsignal library 2026-10-09 15:15:34 +02:00
Julius Joska d13b88aebe release: 1.2.1 (24)
Incoming calls, recovery from the 24-word phrase on a new device, duress
session isolation and three message-loss paths; see CHANGELOG.md.
v1.2.1
2026-10-09 15:07:38 +02:00
Julius Joska b824f62878 deploy(relay): upgrade base packages in the runtime image
The bookworm-slim base ships fixable CVEs (perl-base and friends); apply
Debian's security updates at build time.
2026-10-09 14:06:42 +02:00
Julius Joska 00739ac8a0 docs: security policy, accurate push and status notes, unreleased changelog
- SECURITY.md with the reporting address and expectations; README points to
  security@sepot.cz and security.txt
- README: FCM is the Android default since 1.2.0 when no UnifiedPush
  distributor is installed; status reflects the Play release
- the "F-Droid" channel is not available yet and is no longer listed
- CHANGELOG: unreleased fixes and security hardening
2026-10-09 14:05:39 +02:00
Julius Joska 94cfd72690 chore(relay): merge the duplicated test module in apns.rs
cargo test on the published tree failed with E0428 since 1.2.0.
2026-10-09 14:05:39 +02:00
Julius Joska e3a2378c34 fix(app): accept incoming calls, restore from recovery phrase, duress session isolation, message loss
Calls: `call` was missing from the known content types, so every offer was
acknowledged and dropped; the call screen column now fills the width (the
avatar jumped left after hang-up); a ring started from a push ends after
12 s when no offer arrives; STUN fallback when no TURN credentials.
Registration: X-Sepot-Identity-Proof lets a new device reclaim an existing
identity; retry backs off from 30 s to 10 min and exposes a conflict flag.
Prekeys: PrekeysLow triggers a batch of 50 one-time prekeys via POST
/v1/keys; the signed prekey rotates weekly, the Kyber prekey monthly.
Messaging: crypto operations run serially (concurrent encrypts derived the
same message key); stop() waits for in-flight deliveries (auto-lock between
decrypt and persist lost messages); disappearing-message cleanup no longer
overwrites fresh messages; a push wake-up reconnects stale connections; the
outbox carries polls and mentions; length limits on wire input.
Storage: a duress session never exposes the real identity or phrase, does
not start networking and cannot touch the keystore; deleting data in a
duress session leaves the real account alone; PIN guessing is throttled
(5 misses, then 30 s doubling up to 15 min); the duress blob shares the salt
so unlock time does not reveal which PIN was entered; iOS keychain items are
this-device only; biometrics are biometric-only; screen security defaults
to on and is applied after unlock; tapjacking filter in MainActivity.
Tests: content types, shared-salt unwrap, duress isolation.
2026-10-09 14:02:33 +02:00
Julius Joska 8b452a5593 fix(relay): message loss on large queues, call wake-up throttling, identity reclaim, prekey upload
- stop counting ACK and Pong frames toward the per-connection frame window;
  draining a large queue used to trip the 429 limit, disconnect the client
  and, after eight rounds, drop the remaining envelopes
- deliver to online recipients with try_send: a recipient that stops reading
  its socket no longer pins the sender's task and memory
- throttle call wake-ups per recipient (1 per 10 s, 6 per 10 min) and
  ordinary wake-ups (1 per 5 s); collapse ids for APNs and FCM
- POST /v1/register accepts X-Sepot-Identity-Proof and lets a new device take
  over an existing identity (restore from the recovery phrase)
- POST /v1/keys uploads one-time prekeys and rotates signed/Kyber prekeys;
  PrekeysLow is sent after AUTH when fewer than 20 remain
- GET /v1/keys hands out at most 30 one-time prekeys per address per hour
- reqwest errors are logged without the URL; UnifiedPush POSTs pin the
  addresses the SSRF guard resolved; NAT64/6to4 embedded addresses are checked
- absolute AUTH deadline, at most 3 AUTH attempts, length limits on push
  endpoints and VoIP tokens
- TURN credentials live 12 h and are reissued per connection every 10 min
- global attachment storage cap (SEPOT_ATTACHMENTS_TOTAL_BYTES), nosniff and
  attachment disposition on downloads
- SEPOT_MAX_CONNS_PER_IP, /v1/ready backed by the database, graceful shutdown,
  RLIMIT_NOFILE raised to the hard limit, server-info reports source and revision
- Dockerfile: rust 1.99, GIT_SHA build arg for the image label and
  SEPOT_REVISION; Tor image on alpine 3.24
2026-10-09 14:02:33 +02:00
Julius Joska 492db55a04 chore(relay): state the logging invariant directly v1.2.0 2026-10-03 22:47:32 +02:00
Julius Joska 617dd05267 release: 1.2.0 — new look, group admins, calls
Bumps the app to 1.2.0 (build 23) and adds the changelog entry.

The iOS export compliance flag is now false: Šepot uses only standard,
publicly available encryption (the Signal protocol via libsignal and
SQLCipher), which qualifies for the mass-market exemption and needs no
export compliance documentation from Apple.
2026-10-03 22:44:59 +02:00
Julius Joska 5246a97db2 feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
2026-10-03 22:44:53 +02:00
Julius Joska 933a74c900 feat(app): 1:1 voice and video calls
End-to-end encrypted calls between two contacts over WebRTC. Call
signalling (offer, answer, hang-up) travels as ordinary Signal-encrypted
messages through the relay; media is DTLS-SRTP, peer to peer when
possible and through the operator's TURN server otherwise.

- Non-trickle ICE (candidates gathered for up to 3 s), 45 s ring
  timeout, busy handling, and a call log entry in the conversation.
- The system call UI rings even when the app is closed: CallKit from a
  PushKit VoIP push on iOS, a full-screen incoming call from an FCM
  wake-up on Android. The wake-up carries no caller; the app learns who
  is calling from the queued offer once it is unlocked.
- New permissions: camera for video, audio routing and Bluetooth
  headsets, network state; iOS background modes audio and voip.

Push on Android: a fresh install now picks UnifiedPush when a
distributor is installed and FCM otherwise, instead of leaving wake-ups
off. Without a wake-up Android puts the app to sleep and messages wait
until it is opened. Users can switch at any time in Settings.
2026-10-03 22:44:39 +02:00
Julius Joska 9a638545dd feat(app): admin-signed group membership
Until now any member could change a group's roster or name. Groups now
have admins, and every change to membership, roles or the name is an
operation signed by an admin's identity key (XEdDSA). Recipients verify
the signature against the admin's key from their own contacts, never a
key carried in the same message; the relay plays no part.

- Operations form a hash chain with an epoch; gaps or forks are held back
  and resolved by a signed state from an admin.
- Groups created by older versions migrate with the creator as admin; if
  the creator has left, the smallest fingerprint takes over
  deterministically on every device.
- Mixed groups with older clients keep working in a compatibility mode
  until every member advertises admin support; that capability can only
  be added, never withdrawn.
- The last admin cannot step down or be removed.

The verification rules live in a network-free policy module with its own
tests.
2026-10-03 22:44:11 +02:00
Julius Joska 69687d0acf feat(relay): ephemeral delivery, call wake-ups and TURN credentials
Protocol and relay groundwork for typing indicators and 1:1 calls.

- ContentHint.EPHEMERAL: delivered only to an online recipient, never
  queued or pushed; the sender gets Receipt.STATUS_DROPPED otherwise.
- ContentHint.CALL: a call offer is queued like a normal message, but an
  offline recipient is woken immediately — a PushKit VoIP push on iOS
  (priority 10, 45 s expiry) or a short-lived FCM data message on
  Android. The relay learns that a device is being called, not by whom.
- RegisterPush.voip_token stores the iOS VoIP token next to the regular
  APNs token (migration 0004).
- TurnRequest/TurnCredentials: logged-in devices receive time-limited
  coturn credentials (use-auth-secret REST scheme, HMAC-SHA1), so the
  TURN server is never an open relay. Configured with SEPOT_TURN_URLS and
  SEPOT_TURN_SECRET or SEPOT_TURN_SECRET_FILE; without them calls fall
  back to direct connections and STUN.
2026-10-03 22:43:53 +02:00
Julius Joska 4e3d7de32c fix(app): strip EXIF and text metadata from sent images
The image library copies EXIF through resize and orientation baking, and
the JPEG/PNG encoders write it back out. Re-encoding alone therefore did
not remove metadata: sent photos and their thumbnails could carry GPS
coordinates, the camera model and the capture time.

- Clear EXIF and text chunks on both the full image and the thumbnail
  right before encoding.
- Treat decoder exceptions on malformed data like an undecodable image.
- Add canStripMetadata() so the UI can warn before a format the library
  cannot read (HEIC, RAW) goes out as an untouched document.
2026-10-03 22:43:43 +02:00
Julius Joska 414d39c835 fix(relay): let APNs hold wake-ups for devices that are offline
The APNs wake-up was sent with apns-expiration 0, so a phone without
connectivity at that moment (tunnel, flight) never received it and did
not learn about the message until someone opened the app. APNs now keeps
the wake-up for as long as the envelope stays in the relay queue.

Priority stays at 5: APNs rejects priority 10 for background pushes.
Immediate delivery on iOS needs a visible alert push handled by a
Notification Service Extension, which is an app change.
2026-09-29 18:30:11 +02:00
Julius Joska 2fd4c2e988 perf(relay): disable Nagle's algorithm on accepted connections
axum does not set TCP_NODELAY. Right after AUTH the relay writes
AUTH_OK and immediately the first queued DELIVER; with Nagle enabled the
second write waited for the peer's delayed ACK, adding about 40 ms to
the path a woken-up app takes to fetch its messages.
2026-09-29 18:30:11 +02:00
Julius Joska e5d0e54b80 fix(relay): keep a reconnected device online when its stale connection times out
After a network change (Wi-Fi to mobile data) the app reconnects right
away, while the relay only notices the old connection is dead once the
idle timeout expires. When that stale handler finally exited it removed
the device from the online map by address alone, which dropped the new,
healthy connection. From then on every message was stored as offline and
only a push wake-up could fetch it, even though the app was connected.

The online map is now its own type: a newly authenticated connection
takes over delivery for the address, and a closing connection removes
the entry only if it still points at itself. Tests cover the registry
and the full route after a stale connection ends.
2026-09-29 18:30:11 +02:00
Julius Joska bd51fef5cc fix(relay): add the attempt_count column the delivery cap relies on
The delivery-attempt cap reads and increments message_queue.attempt_count,
but the published init schema never created that column. On a fresh
database every queue flush failed, so queued messages were never handed
out after a reconnect.

The column is added in a new migration with IF NOT EXISTS, so installs
that already carry it from an older init schema migrate as a no-op.
2026-09-29 18:30:11 +02:00
Julius Joska 79ad411d7e fix(app): read the version from the installed package
The version was a hand-written constant that had to be kept in sync with
`version:` in pubspec.yaml. It was not: the constant still said 1.1.0 while
the shipped build was 1.1.2+22, so every abuse report carried a version that
no build ever had — and reports could not be traced back to a build.

AppInfo reads versionName / CFBundleShortVersionString, which Flutter derives
from the pubspec, so the two cannot drift apart again. Reports now carry the
build number as well; when the version is not known yet the field is omitted
rather than guessed.

Also adds the timestamp regression test that covers MessagingService.wireSentAt,
which shipped in 1.1.2 without its test.
2026-09-04 10:18:37 +02:00
Julius Joska f903e21e3a release: 1.1.2 — reliable message delivery v1.1.2 2026-08-13 12:33:24 +02:00
Julius Joska 923a6a01bd fix(app): say it out loud when wake-ups do not work
Registration failures from the distributor were swallowed, so push looked
enabled while nothing arrived. Without wake-ups Android suspends the app
and messages only show up once the user opens it — which looks like broken
delivery even though the relay has them queued.

The failure is now remembered and surfaced in Settings, and the chat list
carries a dismissible line explaining the state.
2026-08-13 12:23:30 +02:00
Julius Joska 9985b79aa6 fix(app): ping the relay, retry stuck messages, drop duplicates
A message sent into a dead socket ended up as failed — attachments even
stayed on "sending" forever — and only the user could send it again by
hand. Mobile NAT drops idle TCP silently, so neither side knew.

- pingInterval on the WebSocket, so the client notices a dead link itself
- outbox: after every reconnect, stuck messages are re-sent (batch of 30,
  up to a day old); permanent errors are not retried
- sendAttachment gained the try/catch it was missing, otherwise the
  attachment never reached the outbox
- de-duplicate by message id: a retry re-sends the SAME message, which
  would otherwise show up twice on the other side
2026-08-13 12:23:24 +02:00
Julius Joska b82f50f888 fix(relay): keep-alive on WebSocket, cap delivery attempts, honest receipts
Messages arrived late or not at all, for two independent reasons.

Dead connections: neither side sent a ping, and after AUTH the server
waited for a frame without a timeout. Mobile NAT drops idle TCP silently,
so the client wrote into a void while the relay still held the device in
its online map — and therefore never sent a push wake-up either. The ping
is a protocol-level one rather than v1::Ping, because dart:io answers
those on its own; the fix works with clients already in the wild.

Poisoned queue: a client decrypts an envelope (ratchet advances), stores
it and acknowledges it. If that ACK is lost, a second decryption attempt
can only fail, so the envelope is never acknowledged — and was redelivered
on every connect until its 30-day TTL, without ever reaching the recipient.
attempt_count existed from the first migration but was never counted; it
now bounds redelivery.

Receipts reported DELIVERED_ONLINE whenever a write into the bounded mpsc
channel succeeded, which it does even for a long-dead socket. Online is now
claimed only for devices that recently sent something.

Push send failures were logged at debug while the relay runs at info, so a
rejected wake-up looked exactly like a working one.
2026-08-13 12:23:17 +02:00
Julius Joska 4f2cbdbdc4 harden(relay): gate forwarded-IP on trusted proxies, bound queue flush
Rate limiting and the per-IP WebSocket cap trusted X-Forwarded-For and
X-Real-Ip unconditionally. Onion traffic bypasses the reverse proxy, so a
client could set the header itself and slip past every limit. Forwarded
headers are now honoured only from peers inside SEPOT_TRUSTED_PROXIES;
everyone else is keyed by peer address, and onion clients share one bucket.

The queue flush also read the whole backlog into memory at once, and the
certificate authority kept the trust-root private key in the database even
though it is only ever needed offline.
2026-08-13 12:23:07 +02:00
Julius Joska 90a1390716 fix: reliable first-message send, QR routing, auto-lock recovery
Fixes reported by testers and found in an app review:

- First message right after install no longer fails. Before the app
  finished registering with the relay, the outgoing message could crash on
  a null delivery address. The profile field is now optional and the send
  path throws a clear "still connecting, try again shortly" error while
  registration completes in the background.
- The QR code and share link now appear only once the delivery address is
  ready. Previously a link generated before the first connection lacked
  routing, so the other side could not send the first message. The screen
  now shows a connecting state and reactively swaps in the QR when ready.
- Incomplete contact links are rejected on import instead of failing at
  first send, with a clear prompt to ask for a fresh link.
- Auto-lock no longer silences the app: after locking and unlocking,
  messaging is restarted (it previously stayed dead until app restart).
- Group messages are marked failed on a non-per-member send error instead
  of hanging in the "sending" state forever.
- Hardened the relay client against a rare duplicate connection when
  toggling Tor or locking mid-connect, and fromShareString now rejects a
  delivery address of the wrong length.
- Pin unifiedpush below 6.2.0 to drop the webcrypto dependency (its native
  framework lacks LC_ENCRYPTION_INFO and blocked the iOS upload).
- Skip Android release lint (network dependency check hangs on the build
  host and adds no value for a Flutter app).
v1.1.1
2026-07-18 19:53:41 +02:00
Julius Joska 7d7f589fc5 release: 1.1.0 — photo preview, Settings fix, incoming-content hardening
Version 1.1.0 (build 20). Also fixes the hardcoded version string shown
in Settings → About (it still said 0.3.2).
v1.1.0
2026-07-13 08:37:16 +02:00
Julius Joska 641d70b76e fix: Settings crash, photo preview before sending, incoming-content hardening
Settings crashed with a TypeError: the profile card passed a
SepotFingerprint object where the localized string formatter expects
a String (hidden behind a `dynamic` parameter). Also fixes RangeError
in avatar initials (empty names, double spaces) and switches initials
to grapheme clusters so emoji don't get split.

New photo preview screen: a captured or picked photo no longer sends
immediately — it opens in a preview with freehand drawing (7 colours,
undo), draggable text overlay, 90° rotation and a caption field.
Nothing leaves the device until Send is tapped.

Incoming-content hardening: decryption and content processing are now
separated in the delivery path. Once an envelope is decrypted the
ratchet has advanced, so redelivery can never help — permanently
malformed content (FormatException/TypeError) is acked and dropped
instead of poisoning the queue on every reconnect. Attachment metadata
now validates base64 of the crypto fields up front, and a corrupt
inline thumbnail no longer breaks bubble rendering.

PNG without real transparency (screenshots, edited photos) is now
re-encoded as JPEG — several times smaller on the wire.

Tests: 116 (new widget tests for Settings and the photo preview,
AttachmentMeta wire validation).
2026-07-13 08:15:01 +02:00
Julius Joska a60aaca326 release: 1.0.0 — five languages, Tor transport, group chats
First production release. See CHANGELOG.md for details.
2026-07-11 23:56:30 +02:00
Julius Joska 92eb316a83 tests: make design-rationale comments self-contained
Test comments referenced internal design documents that are not part
of this repository. The relevant invariants are now stated inline.
2026-07-11 20:02:56 +02:00
Julius Joska 8081d98c67 Šepot — end-to-end encrypted messenger
Initial public release. Anonymous on-device identity (no phone number,
e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double
Ratchet, sealed sender) via libsignal, a metadata-minimizing relay,
group chats, disappearing messages, encrypted local storage, optional
Tor transport, and a UI localized into Czech, English, German, Russian,
and Italian.

Monorepo: Flutter client (app/), Rust relay + wire types (rust/),
protobuf definitions (proto/), self-host deploy (deploy/).

Licensed under AGPL-3.0 with an app-store distribution exception (§7).
v0.3.2
2026-07-11 17:21:47 +02:00