From 2d643e8d29e573851833f10d593df12edcad8f65 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 09:27:55 +0000 Subject: [PATCH 1/4] rfc: amend PQ double ratchet RFC --- docs/rfcs/2023-09-30-pq-double-ratchet.md | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/2023-09-30-pq-double-ratchet.md b/docs/rfcs/2023-09-30-pq-double-ratchet.md index 255051320..95e7aa3d1 100644 --- a/docs/rfcs/2023-09-30-pq-double-ratchet.md +++ b/docs/rfcs/2023-09-30-pq-double-ratchet.md @@ -82,7 +82,7 @@ def RatchetInitAlicePQ2HE(state, SK, bob_dh_public_key, shared_hka, shared_nhkb, state.PQRs = GENERATE_PQKEM() state.PQRr = bob_pq_kem_encapsulation_key state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret // above added for KEM // below augments DH key agreement with PQ shared secret state.RK, state.CKs, state.NHKs = KDF_RK_HE(SK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -103,7 +103,7 @@ def RatchetInitBobPQ2HE(state, SK, bob_dh_key_pair, shared_hka, shared_nhkb, bob state.PQRs = bob_pq_kem_key_pair state.PQRr = None state.PQRss = None - state.PQRenc_ss = None + state.PQRct = None // above added for KEM state.RK = SK state.CKs = None @@ -132,10 +132,10 @@ def RatchetEncryptPQ2HE(state, plaintext, AD): // encapsulation key from PQRs and encapsulated shared secret is added to header header = HEADER_PQ2( dh = state.DHRs.public, + kem = state.PQRs.public, // added for KEM #2 + ct = state.PQRct // added for KEM #1 pn = state.PN, n = state.Ns, - encaps = state.PQRs.encaps, // added for KEM #1 - enc_ss = state.PQRenc_ss // added for KEM #2 ) enc_header = HENCRYPT(state.HKs, header) state.Ns += 1 @@ -162,6 +162,16 @@ def RatchetDecryptPQ2HE(state, enc_header, ciphertext, AD): state.Nr += 1 return DECRYPT(mk, ciphertext, CONCAT(AD, enc_header)) +// DecryptHeader is the same as in double ratchet specification +def DecryptHeader(state, enc_header): + header = HDECRYPT(state.HKr, enc_header) + if header != None: + return header, False + header = HDECRYPT(state.NHKr, enc_header) + if header != None: + return header, True + raise Error() + def DHRatchetPQ2HE(state, header): state.PN = state.Ns state.Ns = 0 @@ -170,16 +180,16 @@ def DHRatchetPQ2HE(state, header): state.HKr = state.NHKr state.DHRr = header.dh // save new encapsulation key from header - state.PQRr = header.encaps + state.PQRr = header.kem // decapsulate shared secret from header - KEM #2 - ss = PQKEM-DEC(state.PQRs.decaps, header.enc_ss) + ss = PQKEM-DEC(state.PQRs.private, header.ct) // use decapsulated shared secret with receiving ratchet state.RK, state.CKr, state.NHKr = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || ss) state.DHRs = GENERATE_DH() // below is added for KEM state.PQRs = GENERATE_PQKEM() // generate new PQ key pair state.PQRss = random // shared secret for KEM - state.PQRenc_ss = PQKEM-ENC(state.PQRr.encaps, state.PQRss) // encapsulated additional shared secret KEM #1 + state.PQRct = PQKEM-ENC(state.PQRr, state.PQRss) // encapsulated additional shared secret KEM #1 // above is added for KEM // use new shared secret with sending ratchet state.RK, state.CKs, state.NHKs = KDF_RK_HE(state.RK, DH(state.DHRs, state.DHRr) || state.PQRss) @@ -201,7 +211,7 @@ The main downside is the absense of performance-efficient implementation for aar ## Implementation considerations for SimpleX Chat -As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality) or sending additional messages. +As SimpleX Chat pads messages to a fixed size, using 16kb transport blocks, the size increase introduced by this scheme will not cause additional traffic in most cases. For large texts it may require additional messages to be sent. Similarly, for media previews it may require either reducing the preview size (and quality), or sending additional messages, or compressing the current JSON encoding, e.g. with zstd algorithm. That might be the primary reason why this scheme was not adopted by Signal, as it would have resulted in substantial traffic growth – to the best of our knowledge, Signal messages are not padded to a fixed size. @@ -209,6 +219,8 @@ Sharing the initial keys in case of SimpleX Chat it is equivalent to sharing the It is possible to postpone sharing the encapsulation key until the first message from Alice (confirmation message in SMP protocol), the party sending connection request. The upside here is that the invitation link size would not increase. The downside is that the user profile shared in this confirmation will not be encrypted with PQ-resistant algorithm. To mitigate it, the hadnshake protocol can be modified to postpone sending the user profile until the second message from Alice (HELLO message in SMP protocol). +Another consideration is pairwise ratchets in groups. Key generation in sntrup761 is quite slow - on slow devices it can probably be as slow as 10 keys per second, so using this primitive in groups larger than 10 members would result in slow performance. An option could be not to use ratchets in groups at all, but that would result in the lack of protection in small groups that simply combine multiple devices of 1-3 people. So a better option would be to support dynamically adding and removing sntrup761 keys for pairwise ratchets in groups, which means that when sending each message a boolean flag needs to be passed whether to use PQ KEM or not. + ## Summary If chosen PQ KEM proves secure against quantum computer attacks, then the proposed augmented double ratchet will also be secure against quantum computer attack, including break-in recovery property, while keeping deniability and forward secrecy, because the [same proof](https://eprint.iacr.org/2016/1013.pdf) as for double ratchet algorithm would hold here, provided KEM is secure. From 395654098c6c4ca14ce6e97aea2add043f41060e Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 13:37:09 +0000 Subject: [PATCH 2/4] core: do not mark store as changed after passphrase test (#3833) * core: do not mark store as changed after passphrase test * fix --- src/Simplex/Chat.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9b0741613..9d5c23d7a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -599,7 +599,7 @@ processChatCommand' vr = \case pure $ CRArchiveImported fileErrs APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg - TestStorageEncryption key -> withStoreChanged $ sqlCipherTestKey key + TestStorageEncryption key -> sqlCipherTestKey key >> ok_ ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) SlowSQLQueries -> do From b7709c59d34cca986f7bd286eea331bc73acc247 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 24 Feb 2024 13:42:11 +0000 Subject: [PATCH 3/4] docs: include update instructions (#3825) --- docs/SERVER.md | 61 ++++++++++++++++++++++++++++++++++++++++++-- docs/XFTP-SERVER.md | 62 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 00e3e0f6e..61d2a981d 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -24,7 +24,7 @@ _Please note_: when you change the servers in the app configuration, it only aff - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) - - [Linode StackScript](https://github.com/simplex-chat/simplexmq#deploy-smp-server-on-linode) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: @@ -33,7 +33,7 @@ Manual installation requires some preliminary actions: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server ``` - Compiling from source: @@ -417,6 +417,63 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) +# Updating your SMP server + +To update your smp-server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop smp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start smp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="{{.ID}}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/smp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 5223:5223 \ + -v $HOME/simplex/smp/config:/etc/opt/simplex:z \ + -v $HOME/simplex/smp/logs:/var/opt/simplex:z \ + simplexchat/smp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server To configure the app to use your messaging server copy it's full address, including password, and add it to the app. You have an option to use your server together with preset servers or without them - you can remove or disable them. diff --git a/docs/XFTP-SERVER.md b/docs/XFTP-SERVER.md index 2977ff15d..8e2e03c19 100644 --- a/docs/XFTP-SERVER.md +++ b/docs/XFTP-SERVER.md @@ -24,6 +24,7 @@ XFTP is a new file transfer protocol focussed on meta-data protection - it is ba - Semi-automatic deployment: - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) Manual installation requires some preliminary actions: @@ -32,7 +33,7 @@ Manual installation requires some preliminary actions: - Using offical binaries: ```sh - curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server ``` - Compiling from source: @@ -418,6 +419,65 @@ To import `csv` to `Grafana` one should: For further documentation, see: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/) + +# Updating your XFTP server + +To update your XFTP server to latest version, choose your installation method and follow the steps: + + - Manual deployment + 1. Stop the server: + ```sh + sudo systemctl stop xftp-server + ``` + 2. Update the binary: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/xftp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/xftp-server && chmod +x /usr/local/bin/xftp-server + ``` + 3. Start the server: + ```sh + sudo systemctl start xftp-server + ``` + + - [Offical installation script](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Execute the followin command: + ```sh + sudo simplex-servers-update + ``` + 2. Done! + + - [Docker container](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Stop and remove the container: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/xftp-server --format="{{.ID}}")) + ``` + 2. Pull latest image: + ```sh + docker pull simplexchat/xftp-server:latest + ``` + 3. Start new container: + ```sh + docker run -d \ + -p 443:443 \ + -v $HOME/simplex/xftp/config:/etc/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/logs:/var/opt/simplex-xftp:z \ + -v $HOME/simplex/xftp/files:/srv/xftp:z \ + simplexchat/xftp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pull latest images: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Restart the containers: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Remove obsolete images: + ```sh + docker image prune + ``` + ### Configuring the app to use the server Please see: [SMP Server: Configuring the app to use the server](./SERVER.md#configuring-the-app-to-use-the-server). From e37654772f868375d404b667257dea6b2c1cc6e9 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sat, 24 Feb 2024 15:00:16 +0000 Subject: [PATCH 4/4] core: api to save/get app settings to migrate them as part of the database (#3824) * rfc: migrate app settings as part of export/import/migration * export/import app settings * test, fix * chat: store app settings in db (#3834) * chat: store app settings in db * add combining with app-defaults * commit schema * test with tweaked settings * remove unused error --------- Co-authored-by: Evgeny Poberezkin * remove app settings from export/import * test, more settings --------- Co-authored-by: Alexander Bondarenko <486682+dpwiz@users.noreply.github.com> --- docs/rfcs/2024-02-19-settings.md | 60 ++++++ simplex-chat.cabal | 3 + src/Simplex/Chat.hs | 5 + src/Simplex/Chat/AppSettings.hs | 190 ++++++++++++++++++ src/Simplex/Chat/Controller.hs | 4 + .../Chat/Migrations/M20240222_app_settings.hs | 20 ++ src/Simplex/Chat/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/AppSettings.hs | 22 ++ src/Simplex/Chat/Store/Migrations.hs | 4 +- src/Simplex/Chat/View.hs | 1 + tests/ChatTests/Direct.hs | 27 ++- 11 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 docs/rfcs/2024-02-19-settings.md create mode 100644 src/Simplex/Chat/AppSettings.hs create mode 100644 src/Simplex/Chat/Migrations/M20240222_app_settings.hs create mode 100644 src/Simplex/Chat/Store/AppSettings.hs diff --git a/docs/rfcs/2024-02-19-settings.md b/docs/rfcs/2024-02-19-settings.md new file mode 100644 index 000000000..002e381ce --- /dev/null +++ b/docs/rfcs/2024-02-19-settings.md @@ -0,0 +1,60 @@ +# Migrating app settings to another device + +## Problem + +This is related to simplified database migration UX in the [previous RFC](./2024-02-12-database-migration.md). + +Currently, when database is imported after the onboarding is complete, users can configure the app prior to the import. + +Some of the settings are particularly important for privacy and security: +- SOCKS proxy settings +- Automatic image etc. downloads +- Link previews + +With the new UX, the chat will start automatically, without giving users a chance to configure the app. That means that we have to migrate settings to a new device as well, as part of the archive. + +## Solution + +There are several possible approaches: +- put settings to the database via the API +- save settings as some file with cross-platform format (e.g. JSON or YAML or properties used on desktop). + +The second approach seems much simpler than maintaining the settings in the database. + +If we save a file, then there are two options: +- native apps maintain cross-platform schemas for this file, support any JSON and parse it in a safe way (so that even invalid or incorrect JSON - e.g., array instead of object - or invalid types in some properties do not cause the failure of properties that are correct). +- this schema and type will be maintained in the core library, that will be responsible for storing and reading the settings and passing to native UI as correct record of a given type. + +The downside of the second approach is that addition of any property that needs to be migrated will have to be done on any change in either of the platforms. The downside of the first approach is that neither app platform will be self-sufficient any more, and not only iOS/Android would have to take into account code, but also each other code. + +If we go with the second approach, there will be these types: + +```haskell +data AppSettings = AppSettings + { networkConfig :: NetworkConfig, -- existing type in Haskell and all UIs + privacyConfig :: PrivacyConfig -- new type, etc. + -- ... additional properties after the initial release should be added as Maybe, as all extensions + } + +data ArchiveConfig = ArchiveConfig + { -- existing properties + archivePath :: FilePath, + disableCompression :: Maybe Bool, + parentTempDirectory :: Maybe FilePath, + -- new property + appSettings :: AppSettings + -- for export, these settings will contain the settings passed from the UI and will be saved to JSON file as simplex_v1_settings.json in the archive + -- for import, these settings will contain the defaults that will be used if some property or subproperty is missing in JSON + } + +-- importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError] -- current type +importArchive :: ChatMonad m => ArchiveConfig -> m ArchiveImportResult -- new type + +-- | CRArchiveImported {archiveErrors :: [ArchiveError]} -- current type + | CRArchiveImported {importResult :: ArchiveImportResult} -- new type + +data ArchiveImportResult = ArchiveImportResult + { archiveErrors :: [ArchiveError], + appSettings :: Maybe AppSettings + } +``` \ No newline at end of file diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 961328208..cc98e1a8f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -26,6 +26,7 @@ flag swift library exposed-modules: Simplex.Chat + Simplex.Chat.AppSettings Simplex.Chat.Archive Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts @@ -134,6 +135,7 @@ library Simplex.Chat.Migrations.M20240115_block_member_for_all Simplex.Chat.Migrations.M20240122_indexes Simplex.Chat.Migrations.M20240214_redirect_file_id + Simplex.Chat.Migrations.M20240222_app_settings Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -149,6 +151,7 @@ library Simplex.Chat.Remote.Transport Simplex.Chat.Remote.Types Simplex.Chat.Store + Simplex.Chat.Store.AppSettings Simplex.Chat.Store.Connections Simplex.Chat.Store.Direct Simplex.Chat.Store.Files diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9d5c23d7a..e5b4af670 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -68,6 +68,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote import Simplex.Chat.Remote.Types import Simplex.Chat.Store +import Simplex.Chat.Store.AppSettings import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files @@ -597,6 +598,8 @@ processChatCommand' vr = \case fileErrs <- importArchive cfg setStoreChanged pure $ CRArchiveImported fileErrs + APISaveAppSettings as -> withStore' (`saveAppSettings` as) >> ok_ + APIGetAppSettings platformDefaults -> CRAppSettings <$> withStore' (`getAppSettings` platformDefaults) APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg TestStorageEncryption key -> sqlCipherTestKey key >> ok_ @@ -6469,6 +6472,8 @@ chatCommandP = "/db key " *> (APIStorageEncryption <$> (dbEncryptionConfig <$> dbKeyP <* A.space <*> dbKeyP)), "/db decrypt " *> (APIStorageEncryption . (`dbEncryptionConfig` "") <$> dbKeyP), "/db test key " *> (TestStorageEncryption <$> dbKeyP), + "/_save app settings" *> (APISaveAppSettings <$> jsonP), + "/_get app settings" *> (APIGetAppSettings <$> optional (A.space *> jsonP)), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), "/sql slow" $> SlowSQLQueries, diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs new file mode 100644 index 000000000..572ce0c67 --- /dev/null +++ b/src/Simplex/Chat/AppSettings.hs @@ -0,0 +1,190 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.AppSettings where + +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), (.:?)) +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Simplex.Messaging.Client (NetworkConfig, defaultNetworkConfig) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) +import Simplex.Messaging.Util (catchAll_) + +data AppPlatform = APIOS | APAndroid | APDesktop deriving (Show) + +data NotificationMode = NMOff | NMPeriodic | NMInstant deriving (Show) + +data NotificationPreviewMode = NPMHidden | NPMContact | NPMMessage deriving (Show) + +data LockScreenCalls = LSCDisable | LSCShow | LSCAccept deriving (Show) + +data AppSettings = AppSettings + { appPlatform :: Maybe AppPlatform, + networkConfig :: Maybe NetworkConfig, + privacyEncryptLocalFiles :: Maybe Bool, + privacyAcceptImages :: Maybe Bool, + privacyLinkPreviews :: Maybe Bool, + privacyShowChatPreviews :: Maybe Bool, + privacySaveLastDraft :: Maybe Bool, + privacyProtectScreen :: Maybe Bool, + notificationMode :: Maybe NotificationMode, + notificationPreviewMode :: Maybe NotificationPreviewMode, + webrtcPolicyRelay :: Maybe Bool, + webrtcICEServers :: Maybe [Text], + confirmRemoteSessions :: Maybe Bool, + connectRemoteViaMulticast :: Maybe Bool, + connectRemoteViaMulticastAuto :: Maybe Bool, + developerTools :: Maybe Bool, + confirmDBUpgrades :: Maybe Bool, + androidCallOnLockScreen :: Maybe LockScreenCalls, + iosCallKitEnabled :: Maybe Bool, + iosCallKitCallsInRecents :: Maybe Bool + } + deriving (Show) + +defaultAppSettings :: AppSettings +defaultAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Just defaultNetworkConfig, + privacyEncryptLocalFiles = Just True, + privacyAcceptImages = Just True, + privacyLinkPreviews = Just True, + privacyShowChatPreviews = Just True, + privacySaveLastDraft = Just True, + privacyProtectScreen = Just False, + notificationMode = Just NMInstant, + notificationPreviewMode = Just NPMMessage, + webrtcPolicyRelay = Just True, + webrtcICEServers = Just [], + confirmRemoteSessions = Just False, + connectRemoteViaMulticast = Just True, + connectRemoteViaMulticastAuto = Just True, + developerTools = Just False, + confirmDBUpgrades = Just False, + androidCallOnLockScreen = Just LSCShow, + iosCallKitEnabled = Just True, + iosCallKitCallsInRecents = Just False + } + +defaultParseAppSettings :: AppSettings +defaultParseAppSettings = + AppSettings + { appPlatform = Nothing, + networkConfig = Nothing, + privacyEncryptLocalFiles = Nothing, + privacyAcceptImages = Nothing, + privacyLinkPreviews = Nothing, + privacyShowChatPreviews = Nothing, + privacySaveLastDraft = Nothing, + privacyProtectScreen = Nothing, + notificationMode = Nothing, + notificationPreviewMode = Nothing, + webrtcPolicyRelay = Nothing, + webrtcICEServers = Nothing, + confirmRemoteSessions = Nothing, + connectRemoteViaMulticast = Nothing, + connectRemoteViaMulticastAuto = Nothing, + developerTools = Nothing, + confirmDBUpgrades = Nothing, + androidCallOnLockScreen = Nothing, + iosCallKitEnabled = Nothing, + iosCallKitCallsInRecents = Nothing + } + +combineAppSettings :: AppSettings -> AppSettings -> AppSettings +combineAppSettings platformDefaults storedSettings = + AppSettings + { appPlatform = p appPlatform, + networkConfig = p networkConfig, + privacyEncryptLocalFiles = p privacyEncryptLocalFiles, + privacyAcceptImages = p privacyAcceptImages, + privacyLinkPreviews = p privacyLinkPreviews, + privacyShowChatPreviews = p privacyShowChatPreviews, + privacySaveLastDraft = p privacySaveLastDraft, + privacyProtectScreen = p privacyProtectScreen, + notificationMode = p notificationMode, + notificationPreviewMode = p notificationPreviewMode, + webrtcPolicyRelay = p webrtcPolicyRelay, + webrtcICEServers = p webrtcICEServers, + confirmRemoteSessions = p confirmRemoteSessions, + connectRemoteViaMulticast = p connectRemoteViaMulticast, + connectRemoteViaMulticastAuto = p connectRemoteViaMulticastAuto, + developerTools = p developerTools, + confirmDBUpgrades = p confirmDBUpgrades, + iosCallKitEnabled = p iosCallKitEnabled, + iosCallKitCallsInRecents = p iosCallKitCallsInRecents, + androidCallOnLockScreen = p androidCallOnLockScreen + } + where + p :: (AppSettings -> Maybe a) -> Maybe a + p sel = sel storedSettings <|> sel platformDefaults <|> sel defaultAppSettings + +$(JQ.deriveJSON (enumJSON $ dropPrefix "AP") ''AppPlatform) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NM") ''NotificationMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "NPM") ''NotificationPreviewMode) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "LSC") ''LockScreenCalls) + +$(JQ.deriveToJSON defaultJSON ''AppSettings) + +instance FromJSON AppSettings where + parseJSON (J.Object v) = do + appPlatform <- p "appPlatform" + networkConfig <- p "networkConfig" + privacyEncryptLocalFiles <- p "privacyEncryptLocalFiles" + privacyAcceptImages <- p "privacyAcceptImages" + privacyLinkPreviews <- p "privacyLinkPreviews" + privacyShowChatPreviews <- p "privacyShowChatPreviews" + privacySaveLastDraft <- p "privacySaveLastDraft" + privacyProtectScreen <- p "privacyProtectScreen" + notificationMode <- p "notificationMode" + notificationPreviewMode <- p "notificationPreviewMode" + webrtcPolicyRelay <- p "webrtcPolicyRelay" + webrtcICEServers <- p "webrtcICEServers" + confirmRemoteSessions <- p "confirmRemoteSessions" + connectRemoteViaMulticast <- p "connectRemoteViaMulticast" + connectRemoteViaMulticastAuto <- p "connectRemoteViaMulticastAuto" + developerTools <- p "developerTools" + confirmDBUpgrades <- p "confirmDBUpgrades" + iosCallKitEnabled <- p "iosCallKitEnabled" + iosCallKitCallsInRecents <- p "iosCallKitCallsInRecents" + androidCallOnLockScreen <- p "androidCallOnLockScreen" + pure + AppSettings + { appPlatform, + networkConfig, + privacyEncryptLocalFiles, + privacyAcceptImages, + privacyLinkPreviews, + privacyShowChatPreviews, + privacySaveLastDraft, + privacyProtectScreen, + notificationMode, + notificationPreviewMode, + webrtcPolicyRelay, + webrtcICEServers, + confirmRemoteSessions, + connectRemoteViaMulticast, + connectRemoteViaMulticastAuto, + developerTools, + confirmDBUpgrades, + iosCallKitEnabled, + iosCallKitCallsInRecents, + androidCallOnLockScreen + } + where + p key = v .:? key <|> pure Nothing + parseJSON _ = pure defaultParseAppSettings + +readAppSettings :: FilePath -> Maybe AppSettings -> IO AppSettings +readAppSettings f platformDefaults = + combineAppSettings (fromMaybe defaultAppSettings platformDefaults) . fromMaybe defaultParseAppSettings + <$> (J.decodeFileStrict f `catchAll_` pure Nothing) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7d030a49f..cdecfa315 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -49,6 +49,7 @@ import Data.Word (Word16) import Language.Haskell.TH (Exp, Q, runIO) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.AppSettings import Simplex.Chat.Call import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages @@ -245,6 +246,8 @@ data ChatCommand | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig + | APISaveAppSettings AppSettings + | APIGetAppSettings (Maybe AppSettings) | APIDeleteStorage | APIStorageEncryption DBEncryptionConfig | TestStorageEncryption DBEncryptionKey @@ -711,6 +714,7 @@ data ChatResponse | CRChatError {user_ :: Maybe User, chatError :: ChatError} | CRChatErrors {user_ :: Maybe User, chatErrors :: [ChatError]} | CRArchiveImported {archiveErrors :: [ArchiveError]} + | CRAppSettings {appSettings :: AppSettings} | CRTimedAction {action :: String, durationMilliseconds :: Int64} deriving (Show) diff --git a/src/Simplex/Chat/Migrations/M20240222_app_settings.hs b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs new file mode 100644 index 000000000..e7fda06a2 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240222_app_settings.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240222_app_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240222_app_settings :: Query +m20240222_app_settings = + [sql| +CREATE TABLE app_settings ( + app_settings TEXT NOT NULL +); +|] + +down_m20240222_app_settings :: Query +down_m20240222_app_settings = + [sql| +DROP TABLE app_settings; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index b5726cae2..36f01d06b 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -562,6 +562,7 @@ CREATE TABLE note_folders( favorite INTEGER NOT NULL DEFAULT 0, unread_chat INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE app_settings(app_settings TEXT NOT NULL); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name diff --git a/src/Simplex/Chat/Store/AppSettings.hs b/src/Simplex/Chat/Store/AppSettings.hs new file mode 100644 index 000000000..ee0dd3018 --- /dev/null +++ b/src/Simplex/Chat/Store/AppSettings.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Store.AppSettings where + +import Control.Monad (join) +import Control.Monad.IO.Class (liftIO) +import qualified Data.Aeson as J +import Data.Maybe (fromMaybe) +import Database.SQLite.Simple (Only (..)) +import Simplex.Chat.AppSettings (AppSettings (..), combineAppSettings, defaultAppSettings, defaultParseAppSettings) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB + +saveAppSettings :: DB.Connection -> AppSettings -> IO () +saveAppSettings db appSettings = do + DB.execute_ db "DELETE FROM app_settings" + DB.execute db "INSERT INTO app_settings (app_settings) VALUES (?)" (Only $ J.encode appSettings) + +getAppSettings :: DB.Connection -> Maybe AppSettings -> IO AppSettings +getAppSettings db platformDefaults = do + stored_ <- join <$> liftIO (maybeFirstRow (J.decodeStrict . fromOnly) $ DB.query_ db "SELECT app_settings FROM app_settings") + pure $ combineAppSettings (fromMaybe defaultAppSettings platformDefaults) (fromMaybe defaultParseAppSettings stored_) diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 832f07dcb..32b003afd 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -99,6 +99,7 @@ import Simplex.Chat.Migrations.M20240104_members_profile_update import Simplex.Chat.Migrations.M20240115_block_member_for_all import Simplex.Chat.Migrations.M20240122_indexes import Simplex.Chat.Migrations.M20240214_redirect_file_id +import Simplex.Chat.Migrations.M20240222_app_settings import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -197,7 +198,8 @@ schemaMigrations = ("20240104_members_profile_update", m20240104_members_profile_update, Just down_m20240104_members_profile_update), ("20240115_block_member_for_all", m20240115_block_member_for_all, Just down_m20240115_block_member_for_all), ("20240122_indexes", m20240122_indexes, Just down_m20240122_indexes), - ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id) + ("20240214_redirect_file_id", m20240214_redirect_file_id, Just down_m20240214_redirect_file_id), + ("20240222_app_settings", m20240222_app_settings, Just down_m20240222_app_settings) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b6bb7807c..667613ba6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -385,6 +385,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRChatError u e -> ttyUser' u $ viewChatError logLevel testView e CRChatErrors u errs -> ttyUser' u $ concatMap (viewChatError logLevel testView) errs CRArchiveImported archiveErrs -> if null archiveErrs then ["ok"] else ["archive import errors: " <> plain (show archiveErrs)] + CRAppSettings as -> ["app settings: " <> plain (LB.unpack $ J.encode as)] CRTimedAction _ _ -> [] where ttyUser :: User -> [StyledString] -> [StyledString] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 4ad4e862d..44bfb543f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -14,6 +14,9 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB +import qualified Data.Text as T +import Simplex.Chat.AppSettings (defaultAppSettings) +import qualified Simplex.Chat.AppSettings as AS import Simplex.Chat.Call import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Options (ChatOpts (..)) @@ -21,6 +24,7 @@ import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) import Simplex.Chat.Types (authErrDisableCount, sameVerificationCode, verificationCode) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Util (safeDecodeUtf8) import Simplex.Messaging.Version import System.Directory (copyFile, doesDirectoryExist, doesFileExist) import System.FilePath (()) @@ -84,8 +88,9 @@ chatDirectTests = do it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages it "user profile privacy: hide profiles and notificaitons" testUserPrivacy - describe "chat item expiration" $ do - it "set chat item TTL" testSetChatItemTTL + describe "settings" $ do + it "set chat item expiration TTL" testSetChatItemTTL + it "save/get app settings" testAppSettings describe "connection switch" $ do it "switch contact to a different queue" testSwitchContact it "stop switching contact to a different queue" testAbortSwitchContact @@ -2195,6 +2200,24 @@ testSetChatItemTTL = alice #$> ("/ttl none", id, "ok") alice #$> ("/ttl", id, "old messages are not being deleted") +testAppSettings :: HasCallStack => FilePath -> IO () +testAppSettings tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + let settings = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings + settingsApp = T.unpack . safeDecodeUtf8 . LB.toStrict $ J.encode defaultAppSettings {AS.webrtcICEServers = Just ["non-default.value.com"]} + -- app-provided defaults + alice ##> ("/_get app settings " <> settingsApp) + alice <## ("app settings: " <> settingsApp) + -- parser defaults fallback + alice ##> "/_get app settings" + alice <## ("app settings: " <> settings) + -- store + alice ##> ("/_save app settings " <> settingsApp) + alice <## "ok" + -- read back + alice ##> "/_get app settings" + alice <## ("app settings: " <> settingsApp) + testSwitchContact :: HasCallStack => FilePath -> IO () testSwitchContact = testChat2 aliceProfile bobProfile $