20 KiB
Business Rules -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform)
This document specifies invariants enforced by the Android and Desktop (Kotlin/Compose Multiplatform) clients.
Table of Contents
- Security (RULE-01 through RULE-05)
- Message Integrity (RULE-06 through RULE-09)
- Group Integrity (RULE-10 through RULE-13)
- File Transfer (RULE-14 through RULE-15)
- Notification Delivery (RULE-16 through RULE-17)
- Call Integrity (RULE-18)
- Online Search (RULE-19 through RULE-23)
1. Security
RULE-01: End-to-End Encryption is Mandatory
Invariant: Every message, file chunk, and call signaling payload MUST be encrypted end-to-end before transmission. The app MUST NOT transmit plaintext content to any relay server.
Enforcement: The Haskell core library handles all encryption. The Kotlin layer never constructs raw SMP messages. All communication flows through ChatController.sendCmd() which delegates to the FFI, ensuring the encryption layer cannot be bypassed.
Location: common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt -- ChatController.sendCmd(), chatSendCmd() FFI call
RULE-02: Database Encryption at Rest
Invariant: The local SQLite database MUST be encrypted. A passphrase (either user-chosen or randomly generated) MUST be set before the database is operational.
Enforcement: On first launch, a random passphrase is generated and stored encrypted via the platform keystore (CryptorInterface.encryptText). The initialRandomDBPassphrase preference tracks whether the user has set a custom passphrase. Database encryption state is tracked in ChatModel.chatDbEncrypted. Encryption/re-encryption is performed via CC.ApiStorageEncryption(config: DBEncryptionConfig).
Caveat: The user is not forced to set a custom passphrase -- the random passphrase is stored in app-accessible encrypted preferences. See GAP: "Database passphrase not enforced."
Location:
common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.ktcommon/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt--CryptorInterface- Android:
common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt-- Android Keystore - Desktop:
common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt-- placeholder, not implemented
RULE-03: Local Authentication Gating
Invariant: When local authentication is enabled (AppPreferences.performLA == true), the app MUST require biometric/PIN authentication before displaying any chat content. The lock engages after laLockDelay seconds of inactivity.
Enforcement: AppLock.setPerformLA controls the lock state. The lock delay is configurable via AppPreferences.laLockDelay (default 30 seconds). Authentication mode is set via AppPreferences.laMode (system biometric or passcode).
Location:
common/src/commonMain/kotlin/chat/simplex/common/AppLock.ktSimpleXAPI.kt--AppPreferences.performLA,AppPreferences.laMode,AppPreferences.laLockDelay
RULE-04: Self-Destruct Profile
Invariant: When self-destruct is enabled (AppPreferences.selfDestruct == true), entering the self-destruct passphrase instead of the real passphrase MUST wipe the database and present a clean profile with selfDestructDisplayName.
Enforcement: The self-destruct passphrase is stored separately (encryptedSelfDestructPassphrase / initializationVectorSelfDestructPassphrase). On Android, SimplexService checks for self-destruct on initialization. The comparison happens during the local authentication flow.
Location:
SimpleXAPI.kt--AppPreferences.selfDestruct,AppPreferences.selfDestructDisplayNameandroid/src/main/java/chat/simplex/app/SimplexService.kt-- initialization check
RULE-05: Screen Protection
Invariant: When AppPreferences.privacyProtectScreen == true (default), the app MUST prevent screenshots and screen recording. On Android this uses FLAG_SECURE; on Desktop this is advisory only.
Enforcement: The preference defaults to true. The Android activity applies FLAG_SECURE to its window based on this preference. The Desktop app cannot enforce this at the OS level.
Location: SimpleXAPI.kt -- AppPreferences.privacyProtectScreen
2. Message Integrity
RULE-06: Message Ordering Verification
Invariant: The app MUST detect and surface message integrity violations (gaps, duplicates, out-of-order delivery) to the user.
Enforcement: The Haskell core tracks message sequence numbers per connection. When a gap or integrity error is detected, a CIContent.RcvIntegrityError(msgError: MsgErrorType) chat item is inserted into the conversation. The UI renders these as system messages indicating the integrity issue.
Location: ChatModel.kt:3565 -- CIContent.RcvIntegrityError
RULE-07: Decryption Error Surfacing
Invariant: When a message cannot be decrypted, the app MUST display a RcvDecryptionError item showing the error type and count of affected messages. The app MUST NOT silently drop undecryptable messages.
Enforcement: The Haskell core emits CIContent.RcvDecryptionError(msgDecryptError, msgCount) which the UI renders with an explanation and count. Ratchet re-synchronization can be triggered via APISyncContactRatchet / APISyncGroupMemberRatchet.
Location: ChatModel.kt:3566 -- CIContent.RcvDecryptionError
RULE-08: Delivery Receipt Consistency
Invariant: Delivery receipt settings MUST be consistent: when a user enables/disables receipts globally, the change MUST propagate to all contacts/groups (optionally clearing per-chat overrides via clearOverrides).
Enforcement: Global receipt toggle triggers CC.SetAllContactReceipts(enable). Per-type settings use CC.ApiSetUserContactReceipts / CC.ApiSetUserGroupReceipts with UserMsgReceiptSettings(enable, clearOverrides). The privacyDeliveryReceiptsSet preference gates the initial setup prompt shown during onboarding.
Location:
SimpleXAPI.kt--CC.SetAllContactReceipts,CC.ApiSetUserContactReceipts,CC.ApiSetUserGroupReceiptsSimpleXAPI.kt--ChatController.startChat()-- triggerssetDeliveryReceiptsprompt
RULE-09: Chat Item TTL Enforcement
Invariant: When a chat item TTL (time-to-live) is set globally or per-chat, expired messages MUST be deleted by the core. The app MUST NOT display expired items.
Enforcement: Global TTL set via CC.APISetChatItemTTL(userId, seconds). Per-chat TTL set via CC.APISetChatTTL(userId, chatType, id, seconds). The Haskell core performs periodic cleanup. The current global TTL is stored in ChatModel.chatItemTTL.
Location: SimpleXAPI.kt -- CC.APISetChatItemTTL, CC.APISetChatTTL
3. Group Integrity
RULE-10: Role-Based Access Control
Invariant: Group operations MUST respect the member's role. Only members with sufficient role level can perform privileged operations:
- Owner: can delete group, change any member's role, transfer ownership
- Admin: can add/remove members, change roles (up to Admin), create/delete group links
- Moderator: can delete other members' messages, block members
- Member / Author / Observer: cannot perform administrative actions
Enforcement: The Haskell core validates role permissions server-side. The Kotlin UI layer uses GroupMemberRole comparisons (the enum is ordered: Observer < Author < Member < Moderator < Admin < Owner) to show/hide action buttons.
Location: ChatModel.kt:2369 -- enum class GroupMemberRole; various group management views
RULE-11: Group Member Removal Atomicity
Invariant: When removing members from a group, the removal command MUST specify all member IDs atomically. Partial removal MUST NOT leave the group in an inconsistent state.
Enforcement: CC.ApiRemoveMembers(groupId, memberIds: List<Long>, withMessages: Boolean) sends all member IDs in a single command. The withMessages flag controls whether the removed members' messages are also deleted.
Location: SimpleXAPI.kt -- CC.ApiRemoveMembers
RULE-12: Group Link Role Default
Invariant: When creating a group link, the default member role for joiners MUST be explicitly specified. The role can be updated after creation without regenerating the link.
Enforcement: CC.APICreateGroupLink(groupId, memberRole) requires a role. CC.APIGroupLinkMemberRole(groupId, memberRole) updates it. The link itself remains stable.
Location: SimpleXAPI.kt -- CC.APICreateGroupLink, CC.APIGroupLinkMemberRole
RULE-13: Member Blocking Scope
Invariant: Blocking a member (ApiBlockMembersForAll) MUST apply the block for all group members (not just the requester). The blocked flag is visible to all members. Only roles >= Moderator can block.
Enforcement: CC.ApiBlockMembersForAll(groupId, memberIds, blocked) sends the block/unblock to the core, which propagates it to all group members.
Location: SimpleXAPI.kt -- CC.ApiBlockMembersForAll; ChatModel.kt -- GroupMember.blockedByAdmin
4. File Transfer
RULE-14: File Encryption in Transit and at Rest
Invariant: Files sent via XFTP MUST be encrypted before upload. Files received MUST be decrypted only after download. When privacyEncryptLocalFiles is enabled (default true), files stored locally MUST be encrypted with per-file keys (CryptoFile.cryptoArgs).
Enforcement: The Haskell core handles XFTP encryption. Local file encryption is toggled via CC.ApiSetEncryptLocalFiles(enable). The CryptoFile type carries optional CryptoFileArgs (key + nonce) for local decryption. Files are decrypted on-demand for display via decryptCryptoFile().
Location:
SimpleXAPI.kt--CC.ApiSetEncryptLocalFiles,AppPreferences.privacyEncryptLocalFilesChatModel.kt--CryptoFile,CryptoFileArgsRecAndPlay.desktop.kt--decryptCryptoFile()usage in audio playback
RULE-15: Relay Approval for File Transfer
Invariant: When privacyAskToApproveRelays is enabled (default true), the app MUST prompt the user before using XFTP relay servers suggested by contacts (as opposed to the user's own configured servers). The userApprovedRelays flag on CC.ReceiveFile records the user's consent.
Enforcement: CC.ReceiveFile(fileId, userApprovedRelays, encrypt, inline) passes the approval flag. The UI prompts the user when the file is from an unapproved relay.
Location: SimpleXAPI.kt -- CC.ReceiveFile, AppPreferences.privacyAskToApproveRelays
5. Notification Delivery
RULE-16: Background Message Delivery (Android)
Invariant: On Android, when NotificationsMode.SERVICE is selected (default), the app MUST maintain a foreground service (SimplexService) to ensure continuous message delivery. The service MUST survive app backgrounding and device sleep. When NotificationsMode.PERIODIC is selected, MessagesFetcherWorker MUST periodically wake and fetch messages. When NotificationsMode.OFF, no background delivery occurs.
Enforcement:
SimplexServiceruns as a foreground service withSTART_STICKYand aWakeLock. It displays a persistent notification on theSIMPLEX_SERVICE_NOTIFICATIONchannel.MessagesFetcherWorkeris aPeriodicWorkRequestscheduled viaWorkManager.- The mode is stored in
AppPreferences.notificationsModeand checked at app startup.
Location:
android/src/main/java/chat/simplex/app/SimplexService.ktandroid/src/main/java/chat/simplex/app/MessagesFetcherWorker.ktSimpleXAPI.kt:7739--enum class NotificationsMode
RULE-17: Notification Preview Privacy
Invariant: Notification content MUST respect notificationPreviewMode:
HIDDEN-- notification shows no sender or message contentCONTACT-- notification shows sender name onlyMESSAGE-- notification shows sender name and message preview
Enforcement: NtfManager (Android) reads the preview mode from AppPreferences.notificationPreviewMode and constructs notifications accordingly. The CallService also respects this mode for call notifications (showing or hiding caller identity).
Location:
android/src/main/java/chat/simplex/app/model/NtfManager.android.kt--displayNotification(),notifyCallInvitation()android/src/main/java/chat/simplex/app/CallService.kt--updateNotification()SimpleXAPI.kt--AppPreferences.notificationPreviewMode
6. Call Integrity
RULE-18: Call Lifecycle Management
Invariant: An active call MUST be properly managed across the full lifecycle:
- Incoming calls MUST be reported via
CallManager.reportNewIncomingCall()which triggers a notification (and on Android, a full-screen intent for lock-screen display). - Only one call can be active at a time. Accepting a new call MUST end any existing call first (
CallManager.acceptIncomingCallchecksactiveCalland callsendCallif needed, guarded byswitchingCallflag). - Call state MUST progress through defined states:
WaitCapabilities->InvitationSent/InvitationAccepted->OfferSent/OfferReceived->Negotiated->Connected->Ended. - Call end MUST clean up all resources: send
WCallCommand.End, callapiEndCall, clearactiveCall, cancel call notifications, and release platform resources.
Android enforcement:
CallService(foreground service) keeps the call alive in background with aWakeLockand ongoing notification onCALL_SERVICE_NOTIFICATIONchannel.CallActivityhosts the WebRTC WebView.- Lock-screen behavior controlled by
AppPreferences.callOnLockScreen(DISABLE / SHOW / ACCEPT).
Desktop enforcement:
- Calls run in the system browser via the NanoWSD WebSocket server on
localhost:50395. - The
WebRTCControllercomposable manages the WebSocket lifecycle. - On dispose,
WCallCommand.Endis sent and the server is stopped.
Location:
common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.ktcommon/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt- Android:
android/src/main/java/chat/simplex/app/CallService.kt,android/src/main/java/chat/simplex/app/views/call/CallActivity.kt - Desktop:
common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt
7. Online Search
RULE-19: Online Results Are Never Saved
Invariant: Name and directory results MUST NOT be stored, and MUST live only as long as the search text and the active profile: any text change or profile switch clears them, and a reply that arrives after that (or after the search was cancelled) MUST NOT be shown.
Enforcement: OnlineSearchModel.reset() bumps generation, and every publish in resolveNames and request checks it; cancelRequest() bumps it too. ChatListSearchBar and NewChatSheetLayout call reset() from KeyChangeEffects on the search text and on chatModel.currentUser.value?.userId. The model is kept in Compose remember only, so it is also dropped when its screen leaves composition (e.g. an Android rotation).
Location:
common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt--OnlineSearchModelcommon/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt--ChatListSearchBarcommon/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt--NewChatSheetLayout
Spec: spec/client/chat-list.md
RULE-20: Only Group and Channel Links Are Accepted from the Directory
Invariant: A directory entry MUST be shown only when its link is exactly one SimpleX link of type group or channel; any other entry (no link, an invitation or contact link, text around the link) MUST be dropped while the rest of the page is kept. Such a link is never passed to the core, which would fetch an invitation link from its server even in a store-only plan.
Enforcement: DirectorySearchEntry.link is a @Transient property whose initializer, run by the decoder, takes groupLink.connShortLink ?: groupLink.connFullLink and throws SerializationException unless simplexLinkType(link) is SimplexLinkType.group or SimplexLinkType.channel; being @Transient, it cannot be set from the JSON. parseDirectorySearchResponse decodes entries one at a time inside runCatching, so a rejected entry is dropped alone.
Location: common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt -- DirectorySearchEntry, simplexLinkType(), parseDirectorySearchResponse()
Spec: spec/client/chat-list.md
RULE-21: Online Lookups Never Show Alerts
Invariant: Resolving a SimpleX name online and looking up directory entries locally MUST NOT show any alert, including the network retry alert; a failure gives no row.
Enforcement: Every lookup of the online search calls apiConnectPlan(…, inProgress = mutableStateOf(false), onFailure = ConnectPlanFailure.Log): Log only logs the failure, and a false inProgress also keeps sendCmdWithRetry from showing its retry alert.
Location:
common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt--ChatController.apiConnectPlan(),ConnectPlanFailurecommon/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt--OnlineSearchModel.resolveNames(),OnlineSearchModel.request()
Spec: spec/api.md
RULE-22: Nothing Is Shown Twice
Invariant: A name result whose chat is shown in the local list MUST be hidden. A directory result MUST be hidden when its link refers, in the local store, to a chat shown in the local list or found by name, or when its link is the link a name was resolved through. Only store-derived chat ids and links resolved by the core may hide a row. Limit: a regular group joined through another link (or the directory's full link) is not found by the lookup and still appears as a directory result (GAP-09).
Enforcement: OnlineSearchModel.rows(shownChatIds) filters at render time with the ids of the chats each screen shows; planLocalChatId derives a result's local chat id from the connection plan, and NameSearchResult.resolvedLink is the link the core resolved.
Location:
common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt--OnlineSearchModel.rows()common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt--planLocalChatId(),NameSearchResult
Spec: spec/client/chat-list.md
RULE-23: Consent Before the First Online Search
Invariant: The search text MUST NOT be sent online until the user has confirmed the first-search alert, which names both recipients (SimpleX Directory and, for a SimpleX name, the server that resolves names), unless the user chose not to be asked again.
Enforcement: Both triggers (the "Search in Directory" row and the Search key / Enter) on the chat list and in New chat go through confirmDirectorySearch(), which runs the search at once only when AppPreferences.directorySearchAlertShown is set and otherwise shows the alert. The preference is in AppPreferences.hintPreferences, so "Reset all hints" asks again.
Location:
common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt--confirmDirectorySearch(),showDirectorySearchAlert()common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt--AppPreferences.directorySearchAlertShown
Spec: spec/client/chat-list.md