diff --git a/.gitignore b/.gitignore index 5d343cc..135d8ea 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /build /Testing /cmake-build-debug -.vscode \ No newline at end of file +.vscode +*.db +*.sqlite +*.sqlite3 +*.sqlite-wal +*.sqlite-shm diff --git a/README.md b/README.md index 03314ab..a964ec7 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,33 @@ # Messenger -Messenger is a cross-platform client-server messenger application based on the RSA algorithm. The project is still in an early stage and currently provides the foundation for the client and server. +Messenger is a simple chat program with a client and a server. Users can connect to a server, send messages, receive messages, and see the previous chat history. The messenger is intentionally kept simple because it is mainly a learning project focused on networking and encryption, including RSA-based authentication built with a self-implemented RSA library. + +

+ Start screen + Client chat +

+ +## Functionality + +- **Client connection:** enter a server host, connect to the server, disconnect again when terminating the program, and see the current connection status in the UI. +- **Group chat authentication:** each server represents one group chat; clients request access with a name and their public key, without passwords. After server-side approval, they authenticate through RSA-based verification using the stored public key. +- **Message sending:** compose text messages and send them to the connected server. +- **Message receiving:** display incoming messages in the client window as they arrive from the server. +- **Group chat forwarding:** the server keeps track of the clients connected to its group chat and broadcasts chat messages to every active session. +- **Chat history:** the server stores the group chat history and sends all previous messages to newly connected clients. +- **Protocol validation:** client and server exchange structured messages and reject unsupported protocol versions. +- **Error handling:** connection errors and invalid client-side input are surfaced through the status text and message log. ## Architecture -The repository contains two applications: `Messenger-Client` and `Messenger-Server`. The client is built with Qt/QML; the server is a terminal program. +The repository contains two applications, `Messenger-Client` and `Messenger-Server`, as well as a shared protocol library. Communication is based on TCP and serialized with `QDataStream`. + +- **Client:** the graphical Qt Quick/QML interface delegates connection handling to `NetworkManager`. It manages the TCP socket, the authentication state machine, RSA challenge signing, and the sending and receiving of protocol messages. `ConnectionStore` stores past connection and handles RSA keys. +- **Shared protocol:** `src/shared` defines the versioned message format, message types, protocol limits, nonce generation, and the canonical authentication transcript used by both applications. +- **Server:** the terminal-based server accepts connections through `QTcpServer` and creates one `Session` per client. A session owns its socket and authentication state, while the central server validates incoming messages, persists chat messages, and broadcasts them to authenticated sessions. +- **Persistence:** `MessageStore` encapsulates the SQLite database. It applies migrations and stores users, public keys, registration requests, and chat history. Private RSA keys remain exclusively on the clients. -Both applications use a singleton through `getInstance()`. The project is built with CMake and C++20, with the RSA library included as a submodule. +After a TCP connection is established, the client and server perform a challenge-response handshake. The client signs a transcript containing fresh client and server nonces with its private RSA key; the server verifies the signature with the stored public key. Only then is the session marked as authenticated and allowed to receive the chat history or exchange chat messages. Unknown users first create a registration request that must be approved through the server CLI. A detailed sequence is documented in [Connection Establishment and Client Authentication](docs/connection-establishment.md). ## Dependencies @@ -45,7 +66,7 @@ git clone --recurse-submodules https://github.com/ParallelEngineering/Messenger. If the repository has already been cloned without submodules, they can be initialized recursively with remote updates: ```bash -git submodule update --init --recursive --remote +git submodule update --init --recursive ``` ## Build diff --git a/docs/connection-establishment.md b/docs/connection-establishment.md new file mode 100644 index 0000000..81fc4b9 --- /dev/null +++ b/docs/connection-establishment.md @@ -0,0 +1,204 @@ +# Connection Establishment and Client Authentication + +## Purpose + +Every client must authenticate immediately after establishing a TCP connection. Authentication proves that the client owns the private RSA key matching the public key stored for its user account in the server database. + +The private key remains on the client. The server stores only public keys. There is no predefined administrator account: an unknown username creates a pending registration request that is approved or rejected with the server command-line interface. + +## Protocol overview + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + participant DB as Database + + C->>S: Establish TCP connection + Note over C,S: Session state: AwaitingHello + + C->>S: AuthHello(username, clientNonce) + S->>DB: Load user ID and public key + alt User exists + DB-->>S: User ID and public key + S->>S: Generate authId and serverNonce + S-->>C: AuthChallenge(authId, serverNonce) + Note over C,S: Session state: AwaitingProof + + C->>C: Hash canonical authentication transcript + C->>C: Sign digest with private RSA key + C->>S: AuthProof(authId, signature) + S->>S: Verify signature with stored public key + + alt Signature is valid + S->>S: Bind user ID and username to session + S->>S: Session state: Authenticated + S-->>C: AuthSuccess + S-->>C: Chat history + else Signature is invalid + S-->>C: AuthFailure + S->>S: Session state: Rejected + S->>S: Close connection + end + else User is unknown + C->>S: Public key included in AuthHello + S->>DB: Store pending registration request + S-->>C: RegistrationPending + S->>S: Close connection + end +``` + +## Authentication messages + +Protocol version 3 defines the following message types: + +```cpp +enum class MessageType : quint32 { + AuthHello = 1, + AuthChallenge = 2, + AuthProof = 3, + AuthSuccess = 4, + AuthFailure = 5, + RegistrationPending = 6, + RegistrationRejected = 7, + ChatMessage = 100, + SystemMessage = 101, + ErrorMessage = 102, +}; +``` + +The handshake uses these fields: + +| Message | Content | +|---|---| +| `AuthHello` | Username, a 32-byte `clientNonce`, and the client's public key | +| `AuthChallenge` | 16-byte `authId` and 32-byte `serverNonce` | +| `AuthProof` | Matching `authId` and RSA signature | +| `AuthSuccess` | Canonical authenticated username | +| `AuthFailure` | Generic authentication error | +| `RegistrationPending` | The unknown user's access request is waiting for server approval | +| `RegistrationRejected` | The matching access request was rejected | + +All messages include the protocol version and message type. Field sizes and the expected message order are validated by both sides. + +## Authentication proof + +Client and server independently create the same canonical authentication transcript: + +```text +domainSeparator = "MessengerAuth/v1" +protocolVersion = CurrentProtocolVersion +username = normalized username +authId = random authentication attempt ID +clientNonce = random client nonce +serverNonce = random server nonce +``` + +The values are encoded in a fixed order with `QDataStream`. The client calculates the digest with Qt: + +```text +digest = SHA-256(authenticationTranscript) +``` + +The RSA library performs the signature operation using its existing `BigInt` and `modPow` implementation: + +```text +digestInteger = BigInt(digest) +signature = digestInteger^d mod n +``` + +The server loads the user's public key from the database and verifies: + +```text +verifiedDigest = signature^e mod n +valid = verifiedDigest == digestInteger +``` + +The signature has the fixed byte length of the RSA modulus. Empty, incorrectly sized, or out-of-range signatures are rejected. + +## Session states + +Each server-side connection has an authentication state: + +```cpp +enum class AuthenticationState { + AwaitingHello, + AwaitingProof, + Authenticated, + Rejected, +}; +``` + +| Session state | Accepted client message | Other messages | +|---|---|---| +| `AwaitingHello` | `AuthHello` | Reject and disconnect | +| `AwaitingProof` | `AuthProof` | Reject and disconnect | +| `Authenticated` | Authorized application messages | Reject invalid messages | +| `Rejected` | None | Disconnect | + +Authentication must finish within 30 seconds. The challenge belongs to one TCP session, matches one `authId`, and can be used only once. Temporary authentication data is cleared after success, failure, timeout, or disconnect. + +## Server behavior + +After accepting a TCP connection, the server waits for `AuthHello` without sending chat history or other application data. + +For `AuthHello`, the server: + +1. validates the username and client nonce; +2. loads the user ID, canonical username, and public key from the database; +3. generates a random authentication ID and server nonce; +4. stores the authentication context in the session; +5. sends `AuthChallenge`. + +If the username does not exist, the server validates the public key from `AuthHello`, stores an idempotent pending request for that username and key, sends `RegistrationPending`, and closes the connection. A rejected matching request produces `RegistrationRejected`. The administrator reviews requests with `Messenger-Server requests` and decides with `Messenger-Server approve ` or `Messenger-Server reject `. + +The administrator can list accounts with `Messenger-Server users` and remove an account with `Messenger-Server delete-user `. Deletion removes the user, their stored messages, and all registration requests for that username in one transaction. + +The command `Messenger-Server clear-history` deletes all stored chat messages without changing users or registration requests. + +For a known user and `AuthProof`, the server reconstructs the transcript, calculates its SHA-256 digest, and verifies the signature with the stored public key. On success, it binds the user ID and username to the session, sends `AuthSuccess`, and then sends the chat history. On failure, it sends `AuthFailure` and closes the connection. + +Only authenticated sessions may send or receive chat messages. The server sets the sender name and timestamp itself: + +```cpp +Message verifiedMessage = incomingMessage; +verifiedMessage.senderName = session->userName(); +verifiedMessage.timestamp = QDateTime::currentDateTimeUtc(); +``` + +Chat history and broadcasts are sent only to authenticated sessions. + +## Client behavior + +The client distinguishes an established TCP socket from an authenticated connection: + +```cpp +enum class ConnectionState { + Disconnected, + Connecting, + AwaitingChallenge, + SigningChallenge, + AwaitingAuthenticationResult, + Authenticated, +}; +``` + +After the TCP socket connects, the client generates `clientNonce` and sends `AuthHello`, including the selected public key. After receiving `AuthChallenge`, it creates and signs the authentication digest and sends `AuthProof`. + +For `RegistrationPending` or `RegistrationRejected`, the client shows the corresponding access status and disconnects. It does not reconnect automatically; after an approval the user simply tries to connect again. + +The chat UI remains disabled until `AuthSuccess` is received. The existing status display reports the current step, including connection, challenge processing, signature creation, verification, success, timeout, and failure. + +## Replay protection and failure handling + +Every authentication attempt uses a new client nonce, server nonce, and authentication ID. All three values are covered by the signature, so a recorded `AuthProof` cannot authenticate another connection. + +The server closes the connection for malformed messages, unexpected message types, invalid keys, an incorrect authentication ID, timeout, or an invalid signature. Unknown users receive a pending or rejected registration result. Private key material is never logged or transmitted. + +## Connection rule + +```text +TCP connected != authenticated +``` + +Before RSA verification succeeds, the connection may process only authentication messages. After verification, the database user is bound to the session and application messages are permitted. diff --git a/docs/img/client_chat.png b/docs/img/client_chat.png new file mode 100644 index 0000000..97565c0 Binary files /dev/null and b/docs/img/client_chat.png differ diff --git a/docs/img/client_start_screen.png b/docs/img/client_start_screen.png new file mode 100644 index 0000000..7d12683 Binary files /dev/null and b/docs/img/client_start_screen.png differ diff --git a/lib/RSA b/lib/RSA index 480dfb0..0350bb7 160000 --- a/lib/RSA +++ b/lib/RSA @@ -1 +1 @@ -Subproject commit 480dfb017249f79769978e23fe9bd5167e5a6b4d +Subproject commit 0350bb79a2287c6fb5e1c9317e1333be69d1b76e diff --git a/src/client/CMakeLists.txt b/src/client/CMakeLists.txt index 8775839..112df00 100644 --- a/src/client/CMakeLists.txt +++ b/src/client/CMakeLists.txt @@ -1,10 +1,12 @@ -find_package(Qt6 COMPONENTS Quick Qml Network REQUIRED) +find_package(Qt6 COMPONENTS Quick Qml QuickControls2 Network REQUIRED) qt_standard_project_setup(REQUIRES 6.5) qt_add_executable(Messenger-Client WIN32 MACOSX_BUNDLE + connection_store.cpp + network_manager.cpp client.cpp ) @@ -24,5 +26,8 @@ target_link_libraries(Messenger-Client PRIVATE Qt6::Quick Qt6::Qml + Qt6::QuickControls2 Qt6::Network + Messenger-Shared + RSA ) diff --git a/src/client/Main.qml b/src/client/Main.qml index f71ebff..c8f99a1 100644 --- a/src/client/Main.qml +++ b/src/client/Main.qml @@ -1,10 +1,1167 @@ import QtQuick +import QtQuick.Controls +import QtQml Window { width: 960 height: 640 + minimumWidth: 720 + minimumHeight: 520 visible: true title: qsTr("Messenger Client") color: "#f7f8fa" + + property string pendingDeleteKeyName: "" + property bool connectionInputValid: userNameInput.text.trim().length > 0 + && hostInput.text.trim().length > 0 + && portInput.acceptableInput + && connectionStore.selectedKeyName.trim().length > 0 + + ListModel { + id: chatMessages + } + + function connectWithInput() { + if (userNameInput.text.trim().length === 0 + || hostInput.text.trim().length === 0 + || !portInput.acceptableInput + || connectionStore.selectedKeyName.trim().length === 0) { + return + } + + networkManager.userName = userNameInput.text + if (!connectionStore.saveLastConnection(hostInput.text, + Number(portInput.text), + userNameInput.text, + connectionStore.selectedKeyName)) { + return + } + + networkManager.connectToServer(hostInput.text, Number(portInput.text)) + } + + function syncKeyComboBox() { + if (!keyComboBox) { + return + } + + keyComboBox.currentIndex = connectionStore.selectedKeyName.length > 0 + ? keyComboBox.find(connectionStore.selectedKeyName) + : -1 + } + + component FieldLabel: Text { + color: "#43515f" + font.pixelSize: 14 + } + + component TextInputBox: Rectangle { + id: inputBox + + property alias text: input.text + property alias validator: input.validator + property alias acceptableInput: input.acceptableInput + property bool passwordMode: false + signal accepted() + + height: 44 + radius: 6 + color: "#ffffff" + border.color: input.activeFocus ? "#205493" : "#c8d0d9" + + TextInput { + id: input + anchors.fill: parent + anchors.margins: 11 + color: "#1f2933" + font.pixelSize: 15 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + echoMode: passwordMode ? TextInput.Password : TextInput.Normal + onAccepted: inputBox.accepted() + } + } + + component ActionButton: Rectangle { + property alias text: label.text + property bool enabledState: true + property color activeColor: "#205493" + signal clicked() + + height: 44 + radius: 6 + color: enabledState ? activeColor : "#d7dde5" + + Text { + id: label + anchors.centerIn: parent + color: enabledState ? "#ffffff" : "#43515f" + font.pixelSize: 15 + } + + MouseArea { + anchors.fill: parent + enabled: enabledState + cursorShape: enabledState ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: parent.clicked() + } + } + + component QuietButton: Rectangle { + id: quietButton + + property alias text: label.text + property bool enabledState: true + property color normalColor: "#ffffff" + property color hoverColor: "#f7f8fa" + property color disabledColor: "#d7dde5" + property color borderColor: "#c8d0d9" + property color textColor: "#1f2933" + signal clicked() + + height: 44 + radius: 6 + color: enabledState ? (quietButtonMouseArea.containsMouse ? hoverColor : normalColor) : disabledColor + border.color: enabledState ? borderColor : "#c8d0d9" + + Text { + id: label + anchors.centerIn: parent + color: enabledState ? quietButton.textColor : "#43515f" + font.pixelSize: 15 + } + + MouseArea { + id: quietButtonMouseArea + anchors.fill: parent + enabled: quietButton.enabledState + hoverEnabled: true + cursorShape: quietButton.enabledState ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: quietButton.clicked() + } + } + + component IconButton: Rectangle { + id: iconButton + + property bool enabledState: true + property string iconName: "edit" + property color activeColor: "#205493" + property color iconColor: enabledState ? "#ffffff" : "#43515f" + signal clicked() + + width: 44 + height: 44 + radius: 6 + color: enabledState ? activeColor : "#d7dde5" + + Canvas { + id: iconCanvas + anchors.centerIn: parent + width: 24 + height: 24 + + onPaint: { + const context = getContext("2d") + context.clearRect(0, 0, width, height) + context.strokeStyle = iconButton.iconColor + context.fillStyle = iconButton.iconColor + context.lineWidth = 2 + context.lineCap = "round" + context.lineJoin = "round" + + if (iconButton.iconName === "close") { + context.beginPath() + context.moveTo(7, 7) + context.lineTo(17, 17) + context.moveTo(17, 7) + context.lineTo(7, 17) + context.stroke() + } else { + context.beginPath() + context.moveTo(5, 19) + context.lineTo(9, 18) + context.lineTo(18, 9) + context.lineTo(15, 6) + context.lineTo(6, 15) + context.closePath() + context.stroke() + + context.beginPath() + context.moveTo(14, 7) + context.lineTo(17, 10) + context.stroke() + } + } + } + + MouseArea { + id: iconMouseArea + anchors.fill: parent + enabled: iconButton.enabledState + hoverEnabled: true + cursorShape: iconButton.enabledState ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: iconButton.clicked() + } + + onIconNameChanged: iconCanvas.requestPaint() + onIconColorChanged: iconCanvas.requestPaint() + } + + Item { + id: connectionPage + anchors.fill: parent + visible: !networkManager.connected + + Column { + anchors.centerIn: parent + width: Math.min(420, parent.width - 64) + spacing: 18 + + Text { + width: parent.width + text: qsTr("Messenger Client") + color: "#1f2933" + font.pixelSize: 36 + horizontalAlignment: Text.AlignHCenter + } + + Text { + width: parent.width + text: networkManager.statusText + color: "#7a2830" + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + + Column { + width: parent.width + spacing: 8 + + FieldLabel { + text: qsTr("Name") + } + + TextInputBox { + id: userNameInput + width: parent.width + text: connectionStore.userName + onAccepted: connectWithInput() + onTextChanged: { + networkManager.userName = text + connectionStore.userName = text + } + } + } + + Column { + width: parent.width + spacing: 8 + + FieldLabel { + text: qsTr("RSA Key") + } + + Row { + id: keySelectionRow + width: parent.width + spacing: 12 + + ComboBox { + id: keyComboBox + width: Math.max(120, keySelectionRow.width - manageKeysButton.width - keySelectionRow.spacing) + height: 44 + model: connectionStore.availableKeyNames + currentIndex: -1 + + background: Rectangle { + radius: 8 + color: "#ffffff" + border.color: keyComboBox.activeFocus ? "#205493" : "#c8d0d9" + } + + contentItem: Text { + leftPadding: 11 + rightPadding: 36 + text: keyComboBox.displayText + color: "#1f2933" + font.pixelSize: 15 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + indicator: Canvas { + x: keyComboBox.width - width - 12 + y: (keyComboBox.height - height) / 2 + width: 16 + height: 16 + + onPaint: { + const context = getContext("2d") + context.clearRect(0, 0, width, height) + context.strokeStyle = "#43515f" + context.lineWidth = 2 + context.lineCap = "round" + context.lineJoin = "round" + context.beginPath() + context.moveTo(4, 6) + context.lineTo(8, 10) + context.lineTo(12, 6) + context.stroke() + } + } + + delegate: ItemDelegate { + required property string modelData + required property int index + + width: keyComboBox.width - 8 + height: 40 + highlighted: keyComboBox.highlightedIndex === index + + contentItem: Text { + text: modelData + color: "#1f2933" + font.pixelSize: 15 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 6 + color: parent.highlighted ? "#eef4fb" : "#ffffff" + } + } + + popup: Popup { + y: keyComboBox.height + 4 + width: keyComboBox.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 220) + padding: 4 + + background: Rectangle { + radius: 8 + color: "#ffffff" + border.color: "#c8d0d9" + } + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: keyComboBox.popup.visible ? keyComboBox.delegateModel : null + currentIndex: keyComboBox.highlightedIndex + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } + + onActivated: function(index) { + connectionStore.selectedKeyName = textAt(index) + } + + Component.onCompleted: syncKeyComboBox() + } + + IconButton { + id: manageKeysButton + iconName: "edit" + onClicked: keyManagementPopup.open() + } + } + } + + Column { + width: parent.width + spacing: 8 + + FieldLabel { + text: qsTr("Server IP") + } + + TextInputBox { + id: hostInput + width: parent.width + text: connectionStore.host + onAccepted: connectWithInput() + onTextChanged: connectionStore.host = text + } + } + + Column { + width: parent.width + spacing: 8 + + FieldLabel { + text: qsTr("Port") + } + + TextInputBox { + id: portInput + width: parent.width + text: String(connectionStore.port) + onAccepted: connectWithInput() + onTextChanged: { + if (acceptableInput) { + connectionStore.port = Number(text) + } + } + validator: IntValidator { + bottom: 1 + top: 65535 + } + } + } + + ActionButton { + width: parent.width + text: networkManager.busy ? qsTr("Authenticating ...") : qsTr("Connect") + enabledState: connectionInputValid && !networkManager.busy + onClicked: connectWithInput() + } + } + } + + Popup { + id: keyManagementPopup + width: Math.min(476, parent.width - 64) + height: Math.min(446, parent.height - 64) + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + modal: true + focus: true + padding: 8 + closePolicy: connectionStore.keyGenerationInProgress + ? Popup.NoAutoClose + : Popup.CloseOnEscape | Popup.CloseOnPressOutside + transformOrigin: Item.Center + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 140 + easing.type: Easing.OutCubic + } + + NumberAnimation { + property: "scale" + from: 0.98 + to: 1 + duration: 140 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 100 + easing.type: Easing.InCubic + } + + NumberAnimation { + property: "scale" + from: 1 + to: 0.98 + duration: 100 + easing.type: Easing.InCubic + } + } + + function submit() { + if (newKeyNameInput.text.trim().length === 0) { + return + } + + if (connectionStore.startKeyPairCreation(newKeyNameInput.text)) { + newKeyNameInput.text = "" + } + } + + onOpened: { + connectionStore.clearErrorText() + pendingDeleteKeyName = "" + newKeyNameInput.text = "" + newKeyNameInput.forceActiveFocus() + } + + background: Rectangle { + anchors { + fill: parent + margins: 8 + } + radius: 8 + color: "#ffffff" + border.color: "#c8d0d9" + } + + Overlay.modal: Rectangle { + color: "#1f2933" + opacity: keyManagementPopup.visible ? 0.28 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 140 + easing.type: Easing.OutCubic + } + } + } + + contentItem: Item { + anchors.fill: parent + + Column { + anchors { + fill: parent + margins: 20 + } + spacing: 16 + + Row { + width: parent.width + height: 32 + spacing: 12 + + Text { + width: Math.max(120, parent.width - closeKeyManagementButton.width - parent.spacing) + text: qsTr("RSA Keys") + color: "#1f2933" + font.pixelSize: 22 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + IconButton { + id: closeKeyManagementButton + width: 32 + height: 32 + iconName: "close" + enabledState: !connectionStore.keyGenerationInProgress + activeColor: "#d7dde5" + iconColor: "#43515f" + onClicked: keyManagementPopup.close() + } + } + + Rectangle { + width: parent.width + height: 1 + color: "#e3e8ef" + } + + Item { + width: parent.width + height: Math.max(92, parent.height - 32 - 1 + - (connectionStore.keyGenerationInProgress + ? keyGenerationSection.height : createKeySection.height) + - errorMessage.height - parent.spacing * 4) + + Text { + anchors.centerIn: parent + width: parent.width + text: qsTr("No RSA keys available yet.") + color: "#607080" + font.pixelSize: 14 + horizontalAlignment: Text.AlignHCenter + visible: connectionStore.availableKeyNames.length === 0 + } + + ListView { + id: keysListView + anchors.fill: parent + visible: connectionStore.availableKeyNames.length > 0 + clip: true + spacing: 8 + model: connectionStore.availableKeyNames + + delegate: Rectangle { + required property string modelData + + width: keysListView.width + height: 48 + radius: 6 + color: "#f7f8fa" + border.color: "#d7dde5" + + Text { + anchors { + left: parent.left + right: deleteKeyButton.left + verticalCenter: parent.verticalCenter + leftMargin: 12 + rightMargin: 12 + } + text: modelData + color: "#1f2933" + font.pixelSize: 15 + elide: Text.ElideRight + } + + Rectangle { + id: deleteKeyButton + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: 8 + } + width: 76 + height: 32 + radius: 6 + color: deleteKeyMouseArea.containsMouse ? "#ead8da" : "#f3e7e8" + border.color: "#ddb9bd" + + Text { + anchors.centerIn: parent + text: qsTr("Delete") + color: "#7a2830" + font.pixelSize: 13 + } + + MouseArea { + id: deleteKeyMouseArea + anchors.fill: parent + hoverEnabled: true + enabled: !connectionStore.keyGenerationInProgress + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + connectionStore.clearErrorText() + pendingDeleteKeyName = modelData + deleteKeyConfirmationPopup.open() + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } + + Column { + id: createKeySection + width: parent.width + spacing: 8 + visible: !connectionStore.keyGenerationInProgress + + FieldLabel { + text: qsTr("New Key") + } + + Row { + width: parent.width + spacing: 12 + + TextInputBox { + id: newKeyNameInput + width: Math.max(120, parent.width - createManagedKeyButton.width - parent.spacing) + onAccepted: keyManagementPopup.submit() + } + + ActionButton { + id: createManagedKeyButton + width: 112 + text: qsTr("Create") + enabledState: newKeyNameInput.text.trim().length > 0 + onClicked: keyManagementPopup.submit() + } + } + } + + Column { + id: keyGenerationSection + width: parent.width + spacing: 10 + visible: connectionStore.keyGenerationInProgress + + FieldLabel { + width: parent.width + text: qsTr("Generating key ...") + elide: Text.ElideRight + } + + Canvas { + id: progressTrack + width: parent.width + height: 12 + antialiasing: true + property real barX: -width * 0.32 + + onBarXChanged: requestPaint() + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + onPaint: { + const context = getContext("2d") + const radius = height / 2 + const barWidth = width * 0.32 + context.reset() + + context.beginPath() + context.roundedRect(0, 0, width, height, radius, radius) + context.fillStyle = "#e3e8ef" + context.fill() + context.clip() + + context.beginPath() + context.roundedRect(barX, 0, barWidth, height, radius, radius) + context.fillStyle = "#205493" + context.fill() + } + + NumberAnimation on barX { + running: connectionStore.keyGenerationInProgress + loops: Animation.Infinite + from: -progressTrack.width * 0.32 + to: progressTrack.width + duration: 1100 + easing.type: Easing.InOutCubic + } + } + + Item { + width: parent.width + height: 44 + + QuietButton { + id: cancelKeyGenerationButton + anchors.right: parent.right + width: 112 + text: qsTr("Cancel") + normalColor: "#ffffff" + hoverColor: "#f7f8fa" + borderColor: "#c8d0d9" + textColor: "#43515f" + onClicked: connectionStore.cancelKeyPairCreation() + } + } + } + + Text { + id: errorMessage + width: parent.width + height: visible ? paintedHeight : 0 + text: connectionStore.errorText + color: "#7a2830" + font.pixelSize: 13 + wrapMode: Text.Wrap + visible: text.length > 0 + } + } + } + } + + Popup { + id: deleteKeyConfirmationPopup + width: Math.min(396, parent.width - 64) + height: Math.min(276, parent.height - 64) + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + modal: true + focus: true + padding: 8 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + transformOrigin: Item.Center + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 120 + easing.type: Easing.OutCubic + } + + NumberAnimation { + property: "scale" + from: 0.98 + to: 1 + duration: 120 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 90 + easing.type: Easing.InCubic + } + + NumberAnimation { + property: "scale" + from: 1 + to: 0.98 + duration: 90 + easing.type: Easing.InCubic + } + } + + background: Rectangle { + anchors { + fill: parent + margins: 8 + } + radius: 8 + color: "#ffffff" + border.color: "#c8d0d9" + } + + Overlay.modal: Rectangle { + color: "#1f2933" + opacity: deleteKeyConfirmationPopup.visible ? 0.34 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } + + onClosed: { + if (!visible) { + pendingDeleteKeyName = "" + } + } + + contentItem: Item { + anchors.fill: parent + + Column { + anchors { + fill: parent + margins: 20 + } + spacing: 14 + + Text { + width: parent.width + text: qsTr("Delete Key") + color: "#1f2933" + font.pixelSize: 22 + elide: Text.ElideRight + } + + Text { + width: parent.width + text: qsTr("The RSA key \"%1\" cannot be recovered. After deletion, connecting to the server as this user will no longer be possible.").arg(pendingDeleteKeyName) + color: "#43515f" + font.pixelSize: 14 + wrapMode: Text.Wrap + } + + Text { + width: parent.width + height: visible ? paintedHeight : 0 + text: connectionStore.errorText + color: "#7a2830" + font.pixelSize: 13 + wrapMode: Text.Wrap + visible: text.length > 0 + } + + Item { + width: parent.width + height: Math.max(0, parent.height - 22 - 56 - 44 - parent.spacing * 3) + } + + Row { + width: parent.width + spacing: 12 + + QuietButton { + width: Math.max(120, (parent.width - parent.spacing) / 2) + text: qsTr("Cancel") + normalColor: "#ffffff" + hoverColor: "#f7f8fa" + borderColor: "#c8d0d9" + textColor: "#43515f" + onClicked: deleteKeyConfirmationPopup.close() + } + + QuietButton { + width: Math.max(120, (parent.width - parent.spacing) / 2) + text: qsTr("Delete") + normalColor: "#f3e7e8" + hoverColor: "#ead8da" + borderColor: "#ddb9bd" + textColor: "#7a2830" + enabledState: pendingDeleteKeyName.length > 0 + onClicked: { + if (connectionStore.deleteKeyPair(pendingDeleteKeyName)) { + deleteKeyConfirmationPopup.close() + } + } + } + } + } + } + } + + Column { + id: chatPage + anchors { + fill: parent + margins: 32 + } + spacing: 16 + visible: networkManager.connected + + Row { + width: parent.width + spacing: 12 + + Column { + width: Math.max(180, parent.width - disconnectButton.width - parent.spacing) + spacing: 4 + + Text { + text: qsTr("Messenger Client") + color: "#1f2933" + font.pixelSize: 32 + } + + Text { + text: networkManager.statusText + color: "#127a3a" + font.pixelSize: 16 + } + } + + Rectangle { + id: disconnectButton + width: 44 + height: 44 + radius: 6 + color: disconnectMouseArea.containsMouse ? "#c8d0d9" : "#d7dde5" + border.color: "#b7c0ca" + + Canvas { + anchors.centerIn: parent + width: 24 + height: 24 + + onPaint: { + const context = getContext("2d") + context.clearRect(0, 0, width, height) + context.strokeStyle = "#43515f" + context.lineWidth = 2 + context.lineCap = "round" + context.lineJoin = "round" + + context.beginPath() + context.moveTo(10, 5) + context.lineTo(5, 5) + context.lineTo(5, 19) + context.lineTo(10, 19) + context.stroke() + + context.beginPath() + context.moveTo(12, 12) + context.lineTo(21, 12) + context.stroke() + + context.beginPath() + context.moveTo(17, 8) + context.lineTo(21, 12) + context.lineTo(17, 16) + context.stroke() + } + } + + MouseArea { + id: disconnectMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: networkManager.disconnectFromServer() + } + } + } + + Rectangle { + width: parent.width + height: Math.max(120, parent.height - y - composeRow.height - parent.spacing) + radius: 12 + color: "#f0f3f7" + border.color: "#d7dde5" + clip: true + + Text { + anchors.centerIn: parent + text: qsTr("No messages yet") + color: "#7b8794" + font.pixelSize: 15 + visible: chatMessages.count === 0 + } + + ListView { + id: messageList + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + margins: 16 + } + height: Math.min(parent.height - 32, contentHeight) + clip: true + spacing: 4 + model: chatMessages + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + delegate: Item { + required property string senderName + required property string messageText + required property string sentAt + required property bool ownMessage + required property bool systemMessage + + width: messageList.width + height: messageBubble.height + 8 + + Rectangle { + id: messageBubble + anchors { + right: ownMessage ? parent.right : undefined + left: ownMessage ? undefined : parent.left + } + width: Math.min(parent.width * 0.72, + Math.max(messageBody.implicitWidth, + ownMessage ? 0 : senderLabel.implicitWidth) + 28) + height: messageContent.implicitHeight + 20 + radius: 14 + color: systemMessage ? "#fff4d6" + : ownMessage ? "#205493" : "#ffffff" + border.color: systemMessage ? "#ead59a" + : ownMessage ? "#205493" : "#d7dde5" + + Column { + id: messageContent + anchors { + fill: parent + margins: 10 + leftMargin: 14 + rightMargin: 14 + } + spacing: 4 + + Text { + id: senderLabel + width: parent.width + text: senderName + color: systemMessage ? "#806000" + : ownMessage ? "#dcecff" : "#607080" + font.pixelSize: 12 + font.weight: Font.DemiBold + visible: !ownMessage + wrapMode: Text.Wrap + } + + Text { + id: messageBody + width: parent.width + text: messageText + color: ownMessage ? "#ffffff" : "#1f2933" + font.pixelSize: 15 + wrapMode: Text.Wrap + } + } + } + } + } + } + + Row { + id: composeRow + width: parent.width + spacing: 12 + + TextInputBox { + id: messageInput + width: Math.max(120, composeRow.width - sendButton.width - composeRow.spacing) + onAccepted: { + if (networkManager.connected && messageInput.text.length > 0) { + networkManager.sendChatMessage(messageInput.text) + messageInput.text = "" + } + } + } + + ActionButton { + id: sendButton + width: 140 + text: qsTr("Send") + enabledState: networkManager.connected && messageInput.text.length > 0 + onClicked: { + networkManager.sendChatMessage(messageInput.text) + messageInput.text = "" + } + } + } + } + + Connections { + target: networkManager + + function onConnectedChanged() { + if (!networkManager.connected) { + chatMessages.clear() + } + } + + function onMessageReceived(senderName, text, sentAt) { + chatMessages.append({ + "senderName": senderName, + "messageText": text, + "sentAt": sentAt, + "ownMessage": senderName === networkManager.userName, + "systemMessage": false + }) + Qt.callLater(function() { + messageList.positionViewAtEnd() + }) + } + + function onConnectionError(message) { + if (!networkManager.connected) { + return + } + chatMessages.append({ + "senderName": qsTr("System"), + "messageText": message, + "sentAt": "", + "ownMessage": false, + "systemMessage": true + }) + Qt.callLater(function() { + messageList.positionViewAtEnd() + }) + } + } + + Connections { + target: connectionStore + + function onAvailableKeyNamesChanged() { + syncKeyComboBox() + } + + function onSelectedKeyNameChanged() { + syncKeyComboBox() + } + } } diff --git a/src/client/client.cpp b/src/client/client.cpp index c9ff8d3..2b2cd7b 100644 --- a/src/client/client.cpp +++ b/src/client/client.cpp @@ -1,7 +1,13 @@ #include "client.h" +#include #include #include +#include +#include + +#include "connection_store.h" +#include "network_manager.h" client& client::getInstance() { static client instance; @@ -13,21 +19,35 @@ client::client() = default; client::~client() = default; int client::run(QGuiApplication& app) { + QCoreApplication::setOrganizationName(QStringLiteral("ParallelEngineering")); + QCoreApplication::setApplicationName(QStringLiteral("Messenger")); + + connectionStore_ = std::make_unique(); + networkManager_ = std::make_unique(connectionStore_.get()); + networkManager_->setUserName(connectionStore_->userName()); + engine_ = std::make_unique(); + engine_->rootContext()->setContextProperty("networkManager", networkManager_.get()); + engine_->rootContext()->setContextProperty("connectionStore", connectionStore_.get()); engine_->loadFromModule("Messenger.Client", "Main"); if (engine_->rootObjects().isEmpty()) { engine_.reset(); + networkManager_.reset(); + connectionStore_.reset(); return -1; } const auto exitCode = app.exec(); engine_.reset(); + networkManager_.reset(); + connectionStore_.reset(); return exitCode; } int main(int argc, char* argv[]) { + QQuickStyle::setStyle(QStringLiteral("Material")); QGuiApplication app(argc, argv); auto& clientInstance = client::getInstance(); diff --git a/src/client/client.h b/src/client/client.h index 6eec7b3..011f8f2 100644 --- a/src/client/client.h +++ b/src/client/client.h @@ -3,6 +3,8 @@ #include +class ConnectionStore; +class NetworkManager; class QGuiApplication; class QQmlApplicationEngine; @@ -22,6 +24,8 @@ class client { client(); std::unique_ptr engine_; + std::unique_ptr networkManager_; + std::unique_ptr connectionStore_; }; #endif // MESSENGER_CLIENT_H diff --git a/src/client/connection_store.cpp b/src/client/connection_store.cpp new file mode 100644 index 0000000..9d2d6c2 --- /dev/null +++ b/src/client/connection_store.cpp @@ -0,0 +1,464 @@ +#include "connection_store.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "keyPair.h" +#include "message.h" + +namespace { + +constexpr auto KeyFolderName = "rsa-keys"; +constexpr auto SettingsFileName = "client-connection.json"; +constexpr auto PublicKeySuffix = ".public.rsa"; +constexpr auto PrivateKeySuffix = ".private.rsa"; + +QByteArray toByteArray(const std::vector& bytes) { + return QByteArray(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +std::vector toByteVector(const QByteArray& bytes) { + const auto* begin = reinterpret_cast(bytes.constData()); + return {begin, begin + bytes.size()}; +} + +} // namespace + +ConnectionStore::ConnectionStore(QObject* parent) + : QObject(parent), + host_(QStringLiteral("127.0.0.1")), + port_(messenger::protocol::DefaultPort), + userName_(QStringLiteral("anonymous")) { + refreshAvailableKeyNames(); + loadLastConnection(); + clearInvalidSelectedKey(); +} + +ConnectionStore::~ConnectionStore() { cancelKeyPairCreation(); } + +QString ConnectionStore::host() const { return host_; } + +int ConnectionStore::port() const { return port_; } + +QString ConnectionStore::userName() const { return userName_; } + +QString ConnectionStore::selectedKeyName() const { return selectedKeyName_; } + +const keyPair* ConnectionStore::currentKeyPair() const { + return currentKeyPair_ ? &*currentKeyPair_ : nullptr; +} + +QStringList ConnectionStore::availableKeyNames() const { return availableKeyNames_; } + +QString ConnectionStore::errorText() const { return errorText_; } + +bool ConnectionStore::keyGenerationInProgress() const { return keyGenerationInProgress_; } + +void ConnectionStore::setHost(const QString& host) { + const auto trimmedHost = host.trimmed(); + if (host_ == trimmedHost) { + return; + } + + host_ = trimmedHost; + emit hostChanged(); +} + +void ConnectionStore::setPort(int port) { + if (port_ == port) { + return; + } + + port_ = port; + emit portChanged(); +} + +void ConnectionStore::setUserName(const QString& userName) { + const auto trimmedUserName = userName.trimmed(); + if (userName_ == trimmedUserName) { + return; + } + + userName_ = trimmedUserName; + emit userNameChanged(); +} + +void ConnectionStore::setSelectedKeyName(const QString& selectedKeyName) { + const auto trimmedKeyName = selectedKeyName.trimmed(); + + if (trimmedKeyName.isEmpty()) { + const auto selectionChanged = !selectedKeyName_.isEmpty(); + selectedKeyName_.clear(); + currentKeyPair_.reset(); + if (selectionChanged) { + emit selectedKeyNameChanged(); + } + return; + } + + if (selectedKeyName_ == trimmedKeyName && currentKeyPair_) { + return; + } + + auto loadedKeyPair = loadKeyPair(trimmedKeyName); + if (!loadedKeyPair) { + // Re-emit the current value so QML controls return to the still-active selection. + emit selectedKeyNameChanged(); + return; + } + + const auto selectionChanged = selectedKeyName_ != trimmedKeyName; + selectedKeyName_ = trimmedKeyName; + currentKeyPair_ = std::move(*loadedKeyPair); + setErrorText({}); + if (selectionChanged) { + emit selectedKeyNameChanged(); + } +} + +bool ConnectionStore::startKeyPairCreation(const QString& name) { + const auto keyName = name.trimmed(); + setErrorText({}); + + if (!keyNameIsValid(keyName)) { + setErrorText(tr("The key name is invalid.")); + return false; + } + + if (!ensureKeyDirectory()) { + setErrorText(tr("The key directory could not be created.")); + return false; + } + + if (keyExists(keyName)) { + setErrorText(tr("A key with this name already exists.")); + return false; + } + + if (discardGeneratedKey_) { + discardGeneratedKey_->store(true, std::memory_order_relaxed); + } + discardGeneratedKey_ = std::make_shared(false); + keyGenerationInProgress_ = true; + emit keyGenerationInProgressChanged(); + + const QPointer guardedThis(this); + const auto discardGeneratedKey = discardGeneratedKey_; + keyGenerationThreads_.emplace_back([guardedThis, discardGeneratedKey, keyName]() { + QByteArray publicBytes; + QByteArray privateBytes; + QString error; + try { + keyPair generated; + if (!discardGeneratedKey->load(std::memory_order_relaxed)) { + publicBytes = toByteArray(generated.getPublicKey().serialize()); + privateBytes = toByteArray(generated.getPrivateKey().serialize()); + } + } catch (const std::exception& exception) { + error = QString::fromUtf8(exception.what()); + } + if (!guardedThis) return; + const auto discarded = discardGeneratedKey->load(std::memory_order_relaxed); + QMetaObject::invokeMethod( + guardedThis, + [guardedThis, keyName, publicBytes, privateBytes, error, discarded, + discardGeneratedKey]() { + if (guardedThis) { + guardedThis->finishKeyPairCreation(keyName, publicBytes, privateBytes, error, + discarded, discardGeneratedKey); + } + }, + Qt::QueuedConnection); + }); + return true; +} + +void ConnectionStore::cancelKeyPairCreation() { + if (!keyGenerationInProgress_) return; + discardGeneratedKey_->store(true, std::memory_order_relaxed); + keyGenerationInProgress_ = false; + emit keyGenerationInProgressChanged(); +} + +void ConnectionStore::finishKeyPairCreation( + const QString& keyName, const QByteArray& publicKeyBytes, const QByteArray& privateKeyBytes, + const QString& workerError, bool discarded, + const std::shared_ptr& discardGeneratedKey) { + discarded = discarded || discardGeneratedKey->load(std::memory_order_relaxed); + + if (!discarded && workerError.isEmpty()) { + if (keyExists(keyName)) { + setErrorText(tr("A key with this name already exists.")); + } else { + QFile publicKeyFile(publicKeyPath(keyName)); + if (!publicKeyFile.open(QIODevice::WriteOnly | QIODevice::NewOnly) || + publicKeyFile.write(publicKeyBytes) != publicKeyBytes.size()) { + publicKeyFile.remove(); + setErrorText(tr("The public key could not be saved.")); + } else { + publicKeyFile.close(); + QFile privateKeyFile(privateKeyPath(keyName)); + if (!privateKeyFile.open(QIODevice::WriteOnly | QIODevice::NewOnly) || + privateKeyFile.write(privateKeyBytes) != privateKeyBytes.size()) { + privateKeyFile.remove(); + QFile::remove(publicKeyPath(keyName)); + setErrorText(tr("The private key could not be saved.")); + } else { + privateKeyFile.close(); + refreshAvailableKeyNames(); + setSelectedKeyName(keyName); + } + } + } + } else if (!discarded) { + setErrorText(tr("The key pair could not be created: %1").arg(workerError)); + } + + if (discardGeneratedKey_ == discardGeneratedKey) { + discardGeneratedKey_.reset(); + keyGenerationInProgress_ = false; + emit keyGenerationInProgressChanged(); + } +} + +bool ConnectionStore::deleteKeyPair(const QString& name) { + const auto keyName = name.trimmed(); + setErrorText({}); + + if (keyGenerationInProgress_) { + setErrorText(tr("Keys cannot be deleted while a key is being generated.")); + return false; + } + + if (!availableKeyNames_.contains(keyName)) { + setErrorText(tr("The key was not found.")); + return false; + } + + const auto publicRemoved = QFile::remove(publicKeyPath(keyName)); + const auto privateRemoved = QFile::remove(privateKeyPath(keyName)); + if (!publicRemoved || !privateRemoved) { + setErrorText(tr("The key could not be deleted completely.")); + refreshAvailableKeyNames(); + clearInvalidSelectedKey(); + return false; + } + + refreshAvailableKeyNames(); + if (selectedKeyName_ == keyName) { + setSelectedKeyName(availableKeyNames_.isEmpty() ? QString() : availableKeyNames_.first()); + } + + return true; +} + +void ConnectionStore::clearErrorText() { setErrorText({}); } + +bool ConnectionStore::saveLastConnection(const QString& host, int port, const QString& userName, + const QString& selectedKeyName) { + const auto trimmedHost = host.trimmed(); + const auto trimmedUserName = userName.trimmed(); + const auto trimmedKeyName = selectedKeyName.trimmed(); + setErrorText({}); + + if (trimmedHost.isEmpty() || trimmedUserName.isEmpty() || port < 1 || port > 65535 || + !availableKeyNames_.contains(trimmedKeyName)) { + setErrorText(tr("The connection details are incomplete.")); + return false; + } + + if (selectedKeyName_ != trimmedKeyName || !currentKeyPair_) { + setSelectedKeyName(trimmedKeyName); + if (selectedKeyName_ != trimmedKeyName || !currentKeyPair_) { + return false; + } + } + + const auto settingsFileInfo = QFileInfo(settingsFilePath()); + if (!settingsFileInfo.absoluteDir().exists() && + !QDir().mkpath(settingsFileInfo.absolutePath())) { + setErrorText(tr("The storage directory could not be created.")); + return false; + } + + QJsonObject root; + root.insert(QStringLiteral("host"), trimmedHost); + root.insert(QStringLiteral("port"), port); + root.insert(QStringLiteral("userName"), trimmedUserName); + root.insert(QStringLiteral("selectedKeyName"), trimmedKeyName); + + QSaveFile settingsFile(settingsFilePath()); + if (!settingsFile.open(QIODevice::WriteOnly)) { + setErrorText(tr("The most recent connection details could not be saved.")); + return false; + } + + settingsFile.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!settingsFile.commit()) { + setErrorText(tr("The most recent connection details could not be written.")); + return false; + } + + setHost(trimmedHost); + setPort(port); + setUserName(trimmedUserName); + setSelectedKeyName(trimmedKeyName); + return true; +} + +QString ConnectionStore::appDataPath() const { + auto path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (path.isEmpty()) { + path = QDir::homePath() + QStringLiteral("/.messenger"); + } + + return path; +} + +QString ConnectionStore::keyDirectoryPath() const { + return QDir(appDataPath()).filePath(QString::fromLatin1(KeyFolderName)); +} + +QString ConnectionStore::settingsFilePath() const { + return QDir(appDataPath()).filePath(QString::fromLatin1(SettingsFileName)); +} + +bool ConnectionStore::ensureKeyDirectory() { + const QDir keyDirectory(keyDirectoryPath()); + if (keyDirectory.exists()) { + return true; + } + + return QDir().mkpath(keyDirectory.absolutePath()); +} + +bool ConnectionStore::keyNameIsValid(const QString& keyName) const { + static const QRegularExpression invalidCharacters(QStringLiteral(R"([\\/:*?"<>|\x00-\x1F])")); + + return !keyName.isEmpty() && keyName != QStringLiteral(".") && + keyName != QStringLiteral("..") && !keyName.contains(invalidCharacters); +} + +bool ConnectionStore::keyExists(const QString& keyName) const { + return QFileInfo::exists(publicKeyPath(keyName)) || QFileInfo::exists(privateKeyPath(keyName)); +} + +QString ConnectionStore::publicKeyPath(const QString& keyName) const { + return QDir(keyDirectoryPath()).filePath(keyName + QString::fromLatin1(PublicKeySuffix)); +} + +QString ConnectionStore::privateKeyPath(const QString& keyName) const { + return QDir(keyDirectoryPath()).filePath(keyName + QString::fromLatin1(PrivateKeySuffix)); +} + +std::optional ConnectionStore::loadKeyPair(const QString& keyName) { + QFile publicKeyFile(publicKeyPath(keyName)); + if (!publicKeyFile.open(QIODevice::ReadOnly)) { + setErrorText(tr("The public key could not be read.")); + return std::nullopt; + } + const auto publicKeyBytes = toByteVector(publicKeyFile.readAll()); + + QFile privateKeyFile(privateKeyPath(keyName)); + if (!privateKeyFile.open(QIODevice::ReadOnly)) { + setErrorText(tr("The private key could not be read.")); + return std::nullopt; + } + const auto privateKeyBytes = toByteVector(privateKeyFile.readAll()); + + try { + return keyPair::create(publicKeyBytes, privateKeyBytes); + } catch (const std::exception&) { + setErrorText(tr("The key pair could not be deserialized.")); + return std::nullopt; + } +} + +void ConnectionStore::refreshAvailableKeyNames() { + const QDir keyDirectory(keyDirectoryPath()); + const auto publicKeyFiles = + keyDirectory.entryList({QStringLiteral("*") + QString::fromLatin1(PublicKeySuffix)}, + QDir::Files, QDir::Name | QDir::IgnoreCase); + + QStringList keyNames; + for (const auto& publicKeyFile : publicKeyFiles) { + auto keyName = publicKeyFile; + keyName.chop(QString::fromLatin1(PublicKeySuffix).size()); + if (QFileInfo::exists(privateKeyPath(keyName))) { + keyNames.append(keyName); + } + } + + if (availableKeyNames_ == keyNames) { + return; + } + + availableKeyNames_ = keyNames; + emit availableKeyNamesChanged(); +} + +void ConnectionStore::loadLastConnection() { + QFile settingsFile(settingsFilePath()); + if (!settingsFile.open(QIODevice::ReadOnly)) { + return; + } + + const auto document = QJsonDocument::fromJson(settingsFile.readAll()); + if (!document.isObject()) { + return; + } + + const auto root = document.object(); + if (root.value(QStringLiteral("host")).isString()) { + setHost(root.value(QStringLiteral("host")).toString()); + } + + if (root.value(QStringLiteral("port")).isDouble()) { + const auto loadedPort = root.value(QStringLiteral("port")).toInt(); + if (loadedPort >= 1 && loadedPort <= 65535) { + setPort(loadedPort); + } + } + + if (root.value(QStringLiteral("userName")).isString()) { + setUserName(root.value(QStringLiteral("userName")).toString()); + } + + if (root.value(QStringLiteral("selectedKeyName")).isString()) { + setSelectedKeyName(root.value(QStringLiteral("selectedKeyName")).toString()); + } +} + +void ConnectionStore::clearInvalidSelectedKey() { + if (selectedKeyName_.isEmpty()) { + currentKeyPair_.reset(); + return; + } + + if (availableKeyNames_.contains(selectedKeyName_) && currentKeyPair_) { + return; + } + + setSelectedKeyName({}); +} + +void ConnectionStore::setErrorText(const QString& errorText) { + if (errorText_ == errorText) { + return; + } + + errorText_ = errorText; + emit errorTextChanged(); +} diff --git a/src/client/connection_store.h b/src/client/connection_store.h new file mode 100644 index 0000000..e9f912a --- /dev/null +++ b/src/client/connection_store.h @@ -0,0 +1,93 @@ +#ifndef MESSENGER_CONNECTION_STORE_H +#define MESSENGER_CONNECTION_STORE_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "keyPair.h" + +class ConnectionStore final : public QObject { + Q_OBJECT + Q_PROPERTY(QString host READ host WRITE setHost NOTIFY hostChanged) + Q_PROPERTY(int port READ port WRITE setPort NOTIFY portChanged) + Q_PROPERTY(QString userName READ userName WRITE setUserName NOTIFY userNameChanged) + Q_PROPERTY(QString selectedKeyName READ selectedKeyName WRITE setSelectedKeyName NOTIFY + selectedKeyNameChanged) + Q_PROPERTY(QStringList availableKeyNames READ availableKeyNames NOTIFY availableKeyNamesChanged) + Q_PROPERTY(QString errorText READ errorText NOTIFY errorTextChanged) + Q_PROPERTY(bool keyGenerationInProgress READ keyGenerationInProgress NOTIFY + keyGenerationInProgressChanged) + + public: + explicit ConnectionStore(QObject* parent = nullptr); + ~ConnectionStore() override; + + [[nodiscard]] QString host() const; + [[nodiscard]] int port() const; + [[nodiscard]] QString userName() const; + [[nodiscard]] QString selectedKeyName() const; + [[nodiscard]] const keyPair* currentKeyPair() const; + [[nodiscard]] QStringList availableKeyNames() const; + [[nodiscard]] QString errorText() const; + [[nodiscard]] bool keyGenerationInProgress() const; + + void setHost(const QString& host); + void setPort(int port); + void setUserName(const QString& userName); + void setSelectedKeyName(const QString& selectedKeyName); + + Q_INVOKABLE bool startKeyPairCreation(const QString& name); + Q_INVOKABLE void cancelKeyPairCreation(); + Q_INVOKABLE bool deleteKeyPair(const QString& name); + Q_INVOKABLE void clearErrorText(); + Q_INVOKABLE bool saveLastConnection(const QString& host, int port, const QString& userName, + const QString& selectedKeyName); + + signals: + void hostChanged(); + void portChanged(); + void userNameChanged(); + void selectedKeyNameChanged(); + void availableKeyNamesChanged(); + void errorTextChanged(); + void keyGenerationInProgressChanged(); + + private: + [[nodiscard]] QString appDataPath() const; + [[nodiscard]] QString keyDirectoryPath() const; + [[nodiscard]] QString settingsFilePath() const; + [[nodiscard]] bool ensureKeyDirectory(); + [[nodiscard]] bool keyNameIsValid(const QString& keyName) const; + [[nodiscard]] bool keyExists(const QString& keyName) const; + [[nodiscard]] QString publicKeyPath(const QString& keyName) const; + [[nodiscard]] QString privateKeyPath(const QString& keyName) const; + [[nodiscard]] std::optional loadKeyPair(const QString& keyName); + + void refreshAvailableKeyNames(); + void loadLastConnection(); + void clearInvalidSelectedKey(); + void setErrorText(const QString& errorText); + void finishKeyPairCreation(const QString& keyName, const QByteArray& publicKeyBytes, + const QByteArray& privateKeyBytes, const QString& workerError, + bool discarded, + const std::shared_ptr& discardGeneratedKey); + + QString host_; + int port_; + QString userName_; + QString selectedKeyName_; + std::optional currentKeyPair_; + QStringList availableKeyNames_; + QString errorText_; + bool keyGenerationInProgress_ = false; + std::shared_ptr discardGeneratedKey_; + std::vector keyGenerationThreads_; +}; + +#endif // MESSENGER_CONNECTION_STORE_H diff --git a/src/client/network_manager.cpp b/src/client/network_manager.cpp new file mode 100644 index 0000000..39a5362 --- /dev/null +++ b/src/client/network_manager.cpp @@ -0,0 +1,376 @@ +#include "network_manager.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "connection_store.h" +#include "signature.h" + +using messenger::protocol::CurrentProtocolVersion; +using messenger::protocol::DataStreamVersion; +using messenger::protocol::DefaultPort; +using messenger::protocol::Message; +using messenger::protocol::MessageType; +using messenger::protocol::ReadBufferSize; + +namespace { + +std::vector toByteVector(const QByteArray& bytes) { + const auto* begin = reinterpret_cast(bytes.constData()); + return {begin, begin + bytes.size()}; +} + +QByteArray toByteArray(const std::vector& bytes) { + return QByteArray(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +} // namespace + +NetworkManager::NetworkManager(ConnectionStore* connectionStore, QObject* parent) + : QObject(parent), + connectionStore_(connectionStore), + stream_(&socket_), + statusText_(tr("Disconnected")), + userName_(QStringLiteral("anonymous")) { + socket_.setReadBufferSize(ReadBufferSize); + stream_.setVersion(DataStreamVersion); + + connect(&socket_, &QTcpSocket::connected, this, &NetworkManager::handleConnected); + connect(&socket_, &QTcpSocket::disconnected, this, &NetworkManager::handleDisconnected); + connect(&socket_, &QTcpSocket::errorOccurred, this, &NetworkManager::handleError); + connect(&socket_, &QTcpSocket::readyRead, this, &NetworkManager::readAvailable); + + authenticationTimer_.setSingleShot(true); + connect(&authenticationTimer_, &QTimer::timeout, this, [this]() { + if (!connected() && connectionState_ != ConnectionState::Disconnected) { + failAuthentication(tr("Authentication timed out.")); + } + }); +} + +bool NetworkManager::connected() const { + return connectionState_ == ConnectionState::Authenticated; +} + +bool NetworkManager::busy() const { + return connectionState_ != ConnectionState::Disconnected && !connected(); +} + +QString NetworkManager::statusText() const { return statusText_; } + +QString NetworkManager::userName() const { return userName_; } + +int NetworkManager::defaultPort() const { return DefaultPort; } + +void NetworkManager::setUserName(const QString& userName) { + const auto trimmedUserName = userName.trimmed(); + if (userName_ == trimmedUserName) { + return; + } + + userName_ = trimmedUserName; + emit userNameChanged(); +} + +void NetworkManager::connectToServer(const QString& host, quint16 port) { + const auto trimmedHost = host.trimmed(); + if (trimmedHost.isEmpty()) { + const auto message = tr("Host must not be empty."); + setStatusText(message); + emit connectionError(message); + return; + } + + if (userName_.trimmed().isEmpty()) { + const auto message = tr("User name must not be empty."); + setStatusText(message); + emit connectionError(message); + return; + } + + const auto* selectedKeyPair = connectionStore_ ? connectionStore_->currentKeyPair() : nullptr; + if (selectedKeyPair == nullptr) { + const auto message = tr("Select a valid RSA key pair before connecting."); + setStatusText(message); + emit connectionError(message); + return; + } + + if (socket_.state() != QAbstractSocket::UnconnectedState) { + socket_.abort(); + } + + clearAuthenticationData(); + authenticationKeyPair_ = *selectedKeyPair; + authenticationUserName_ = userName_.trimmed(); + preserveStatusOnDisconnect_ = false; + userRequestedDisconnect_ = false; + setConnectionState(ConnectionState::Connecting); + setStatusText(tr("Connecting to %1:%2 ...").arg(trimmedHost).arg(port)); + socket_.connectToHost(trimmedHost, port); +} + +void NetworkManager::disconnectFromServer() { + if (socket_.state() == QAbstractSocket::UnconnectedState) { + clearAuthenticationData(); + setConnectionState(ConnectionState::Disconnected); + setStatusText(tr("Disconnected")); + return; + } + + userRequestedDisconnect_ = true; + setStatusText(tr("Disconnecting ...")); + socket_.disconnectFromHost(); +} + +void NetworkManager::sendChatMessage(const QString& text) { + if (!connected()) { + const auto message = tr("Not connected to a server."); + setStatusText(message); + emit connectionError(message); + return; + } + + const auto senderName = userName_.trimmed(); + if (senderName.isEmpty()) { + const auto message = tr("User name must not be empty."); + setStatusText(message); + emit connectionError(message); + return; + } + + Message message; + message.messageType = static_cast(MessageType::ChatMessage); + message.senderName = senderName; + message.text = text; + message.timestamp = QDateTime::currentDateTimeUtc(); + + QDataStream out(&socket_); + out.setVersion(DataStreamVersion); + out << message; + socket_.flush(); +} + +void NetworkManager::handleConnected() { + setConnectionState(ConnectionState::AwaitingChallenge); + setStatusText(tr("Connected to server. Starting authentication ...")); + authenticationTimer_.start(messenger::protocol::AuthenticationTimeoutMs); + sendAuthenticationHello(); +} + +void NetworkManager::handleDisconnected() { + const auto wasAuthenticated = connected(); + const auto wasUserRequested = userRequestedDisconnect_; + clearAuthenticationData(); + setConnectionState(ConnectionState::Disconnected); + + if (wasAuthenticated || wasUserRequested || !preserveStatusOnDisconnect_) { + setStatusText(wasAuthenticated || wasUserRequested + ? tr("Disconnected") + : tr("Connection closed before authentication completed.")); + } + + preserveStatusOnDisconnect_ = false; + userRequestedDisconnect_ = false; +} + +void NetworkManager::handleError() { + if (socket_.error() == QAbstractSocket::RemoteHostClosedError) { + return; + } + + const auto message = tr("Connection error: %1").arg(socket_.errorString()); + preserveStatusOnDisconnect_ = true; + setStatusText(message); + emit connectionError(message); +} + +void NetworkManager::readAvailable() { + while (socket_.bytesAvailable() > 0) { + stream_.startTransaction(); + + Message message; + stream_ >> message; + + if (!stream_.commitTransaction()) { + return; + } + + if (message.protocolVersion != CurrentProtocolVersion) { + const auto errorMessage = + tr("Unsupported protocol version: %1").arg(message.protocolVersion); + setStatusText(errorMessage); + emit connectionError(errorMessage); + socket_.disconnectFromHost(); + return; + } + + const auto messageType = static_cast(message.messageType); + if (!connected()) { + if (messageType == MessageType::AuthChallenge) { + handleAuthenticationChallenge(message); + } else if (messageType == MessageType::AuthSuccess) { + handleAuthenticationSuccess(message); + } else if (messageType == MessageType::AuthFailure) { + failAuthentication(message.text.isEmpty() ? tr("Authentication failed.") + : message.text); + } else if (messageType == MessageType::RegistrationPending) { + failAuthentication( + tr("You do not have access yet. A registration request was created and must " + "be approved on the server. Please try again afterwards.")); + } else if (messageType == MessageType::RegistrationRejected) { + failAuthentication(tr("Your registration request was rejected on the server.")); + } else { + failAuthentication(tr("Server sent an unexpected message during authentication.")); + } + continue; + } + + if (messageType != MessageType::ChatMessage && messageType != MessageType::SystemMessage && + messageType != MessageType::ErrorMessage) { + const auto errorMessage = tr("Server sent an unexpected message."); + setStatusText(errorMessage); + emit connectionError(errorMessage); + socket_.disconnectFromHost(); + return; + } + + emit messageReceived(message.senderName, message.text, + message.timestamp.toLocalTime().toString(QStringLiteral("HH:mm"))); + } +} + +void NetworkManager::setConnectionState(ConnectionState state) { + if (connectionState_ == state) { + return; + } + + const auto wasConnected = connected(); + const auto wasBusy = busy(); + connectionState_ = state; + if (wasConnected != connected()) { + emit connectedChanged(); + } + if (wasBusy != busy()) { + emit busyChanged(); + } +} + +void NetworkManager::setStatusText(const QString& statusText) { + if (statusText_ == statusText) { + return; + } + + statusText_ = statusText; + emit statusTextChanged(); +} + +void NetworkManager::sendAuthenticationHello() { + if (!authenticationKeyPair_ || authenticationUserName_.isEmpty()) { + failAuthentication(tr("No user or RSA key is available for authentication.")); + return; + } + + clientNonce_ = messenger::protocol::generateSecureRandomBytes( + messenger::protocol::AuthenticationNonceSize); + + Message hello; + hello.messageType = static_cast(MessageType::AuthHello); + hello.senderName = authenticationUserName_; + hello.clientNonce = clientNonce_; + hello.publicKey = toByteArray(authenticationKeyPair_->getPublicKey().serialize()); + + QDataStream out(&socket_); + out.setVersion(DataStreamVersion); + out << hello; + socket_.flush(); + setStatusText(tr("Authentication request sent. Waiting for server challenge ...")); +} + +void NetworkManager::handleAuthenticationChallenge(const Message& message) { + if (connectionState_ != ConnectionState::AwaitingChallenge || + message.authenticationId.size() != messenger::protocol::AuthenticationIdSize || + message.serverNonce.size() != messenger::protocol::AuthenticationNonceSize) { + failAuthentication(tr("The server sent an invalid authentication challenge.")); + return; + } + + authenticationId_ = message.authenticationId; + serverNonce_ = message.serverNonce; + setConnectionState(ConnectionState::SigningChallenge); + setStatusText(tr("Challenge received. Signing authentication proof ...")); + + QTimer::singleShot(0, this, [this]() { + if (connectionState_ != ConnectionState::SigningChallenge || !authenticationKeyPair_) { + return; + } + + const auto transcript = messenger::protocol::authenticationTranscript( + authenticationUserName_, authenticationId_, clientNonce_, serverNonce_); + const auto digest = QCryptographicHash::hash(transcript, QCryptographicHash::Sha256); + const auto signature = core::signature::signDigest(authenticationKeyPair_->getPrivateKey(), + toByteVector(digest)); + if (signature.empty()) { + failAuthentication(tr("The RSA authentication proof could not be created.")); + return; + } + + Message proof; + proof.messageType = static_cast(MessageType::AuthProof); + proof.authenticationId = authenticationId_; + proof.signature = toByteArray(signature); + + QDataStream out(&socket_); + out.setVersion(DataStreamVersion); + out << proof; + socket_.flush(); + setConnectionState(ConnectionState::AwaitingAuthenticationResult); + setStatusText(tr("Authentication proof sent. Waiting for verification ...")); + }); +} + +void NetworkManager::handleAuthenticationSuccess(const Message& message) { + if (connectionState_ != ConnectionState::AwaitingAuthenticationResult) { + failAuthentication(tr("The server confirmed authentication unexpectedly.")); + return; + } + + authenticationTimer_.stop(); + if (!message.senderName.trimmed().isEmpty() && userName_ != message.senderName.trimmed()) { + userName_ = message.senderName.trimmed(); + emit userNameChanged(); + } + clearAuthenticationData(); + setStatusText(tr("Authenticated as %1.").arg(userName_)); + setConnectionState(ConnectionState::Authenticated); +} + +void NetworkManager::failAuthentication(const QString& message) { + if (connected()) { + return; + } + + authenticationTimer_.stop(); + preserveStatusOnDisconnect_ = true; + const auto visibleMessage = message.isEmpty() ? tr("Authentication failed.") : message; + setStatusText(visibleMessage); + emit connectionError(visibleMessage); + if (socket_.state() != QAbstractSocket::UnconnectedState) { + socket_.disconnectFromHost(); + } +} + +void NetworkManager::clearAuthenticationData() { + authenticationTimer_.stop(); + authenticationUserName_.clear(); + authenticationId_.clear(); + clientNonce_.clear(); + serverNonce_.clear(); + authenticationKeyPair_.reset(); +} diff --git a/src/client/network_manager.h b/src/client/network_manager.h new file mode 100644 index 0000000..1069960 --- /dev/null +++ b/src/client/network_manager.h @@ -0,0 +1,87 @@ +#ifndef MESSENGER_NETWORK_MANAGER_H +#define MESSENGER_NETWORK_MANAGER_H + +#include +#include +#include +#include +#include + +#include "keyPair.h" +#include "message.h" + +class ConnectionStore; + +class NetworkManager final : public QObject { + Q_OBJECT + Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) + Q_PROPERTY(QString userName READ userName WRITE setUserName NOTIFY userNameChanged) + Q_PROPERTY(int defaultPort READ defaultPort CONSTANT) + + public: + explicit NetworkManager(ConnectionStore* connectionStore, QObject* parent = nullptr); + + [[nodiscard]] bool connected() const; + [[nodiscard]] bool busy() const; + [[nodiscard]] QString statusText() const; + [[nodiscard]] QString userName() const; + [[nodiscard]] int defaultPort() const; + void setUserName(const QString& userName); + + // Methode that can be called from QML + Q_INVOKABLE void connectToServer(const QString& host, quint16 port); + Q_INVOKABLE void disconnectFromServer(); + Q_INVOKABLE void sendChatMessage(const QString& text); + + signals: + // These methods are implemented in qt + void connectedChanged(); + void busyChanged(); + void statusTextChanged(); + void userNameChanged(); + void messageReceived(const QString& senderName, const QString& text, const QString& sentAt); + void connectionError(const QString& message); + + private slots: + void handleConnected(); + void handleDisconnected(); + void handleError(); + void readAvailable(); + + private: + enum class ConnectionState { + Disconnected, + Connecting, + AwaitingChallenge, + SigningChallenge, + AwaitingAuthenticationResult, + Authenticated, + }; + + void setConnectionState(ConnectionState state); + void setStatusText(const QString& statusText); + void sendAuthenticationHello(); + void handleAuthenticationChallenge(const messenger::protocol::Message& message); + void handleAuthenticationSuccess(const messenger::protocol::Message& message); + void failAuthentication(const QString& message); + void clearAuthenticationData(); + + ConnectionStore* connectionStore_; + QTcpSocket socket_; + QDataStream stream_; + QTimer authenticationTimer_; + ConnectionState connectionState_ = ConnectionState::Disconnected; + QString statusText_; + QString userName_; + QString authenticationUserName_; + QByteArray authenticationId_; + QByteArray clientNonce_; + QByteArray serverNonce_; + std::optional authenticationKeyPair_; + bool preserveStatusOnDisconnect_ = false; + bool userRequestedDisconnect_ = false; +}; + +#endif // MESSENGER_NETWORK_MANAGER_H diff --git a/src/server/CMakeLists.txt b/src/server/CMakeLists.txt index ba9761d..7ac98c7 100644 --- a/src/server/CMakeLists.txt +++ b/src/server/CMakeLists.txt @@ -1,5 +1,27 @@ -find_package(Qt6 REQUIRED COMPONENTS Network) +find_package(Qt6 REQUIRED COMPONENTS Core Network Sql) -add_executable(Messenger-Server server.cpp) +set(CMAKE_AUTOMOC ON) -target_link_libraries(Messenger-Server PRIVATE Qt6::Network) +add_executable(Messenger-Server + Storage/message_store.cpp + Storage/message_store.h + session.cpp + session.h + server.cpp + server.h +) + +add_custom_command(TARGET Messenger-Server POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_SOURCE_DIR}/Migrations" + "$/Migrations" +) + +target_link_libraries(Messenger-Server + PRIVATE + Qt6::Core + Qt6::Network + Qt6::Sql + Messenger-Shared + RSA +) diff --git a/src/server/Migrations/001_create_messages.sql b/src/server/Migrations/001_create_messages.sql new file mode 100644 index 0000000..4bd34cf --- /dev/null +++ b/src/server/Migrations/001_create_messages.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + message_type INTEGER NOT NULL, + body TEXT NOT NULL, + client_timestamp TEXT NOT NULL, + stored_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_messages_stored_at +ON messages(stored_at, id); + +CREATE INDEX IF NOT EXISTS idx_messages_user_id +ON messages(user_id); diff --git a/src/server/Migrations/002_create_registration_requests.sql b/src/server/Migrations/002_create_registration_requests.sql new file mode 100644 index 0000000..950bfdd --- /dev/null +++ b/src/server/Migrations/002_create_registration_requests.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS registration_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + public_key BLOB NOT NULL, + source_address TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected')), + created_at TEXT NOT NULL, + reviewed_at TEXT, + UNIQUE(username, public_key) +); + +CREATE INDEX IF NOT EXISTS idx_registration_requests_status +ON registration_requests(status, created_at); diff --git a/src/server/Storage/message_store.cpp b/src/server/Storage/message_store.cpp new file mode 100644 index 0000000..9ffc6d4 --- /dev/null +++ b/src/server/Storage/message_store.cpp @@ -0,0 +1,703 @@ +#include "message_store.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using messenger::protocol::CurrentProtocolVersion; +using messenger::protocol::Message; +using messenger::protocol::MessageType; + +namespace { + +constexpr auto InvalidUserId = -1; + +QString lastErrorText(const QSqlQuery& query) { return query.lastError().text(); } + +QList splitSqlStatements(const QString& script) { + QList statements; + QString current; + bool inSingleQuote = false; + bool inDoubleQuote = false; + bool inLineComment = false; + + for (qsizetype i = 0; i < script.size(); ++i) { + const auto currentChar = script.at(i); + const auto nextChar = i + 1 < script.size() ? script.at(i + 1) : QChar(); + + if (inLineComment) { + if (currentChar == QLatin1Char('\n')) { + inLineComment = false; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && currentChar == QLatin1Char('-') && + nextChar == QLatin1Char('-')) { + inLineComment = true; + ++i; + continue; + } + + if (currentChar == QLatin1Char('\'') && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (currentChar == QLatin1Char('"') && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } + + if (currentChar == QLatin1Char(';') && !inSingleQuote && !inDoubleQuote) { + const auto statement = current.trimmed(); + if (!statement.isEmpty()) { + statements.append(statement); + } + current.clear(); + continue; + } + + current.append(currentChar); + } + + const auto statement = current.trimmed(); + if (!statement.isEmpty()) { + statements.append(statement); + } + + return statements; +} + +} // namespace + +MessageStore::MessageStore() : connectionName_(QStringLiteral("messenger_server_storage")) {} + +MessageStore::~MessageStore() { + if (QCoreApplication::instance() == nullptr) { + return; + } + + close(); +} + +void MessageStore::close() { + if (!QSqlDatabase::contains(connectionName_)) { + return; + } + + { + auto database = QSqlDatabase::database(connectionName_, false); + if (database.isValid()) { + database.close(); + } + } + + QSqlDatabase::removeDatabase(connectionName_); + initialized_ = false; +} + +bool MessageStore::initialize() { + if (initialized_) { + return true; + } + + if (!openDatabase()) { + return false; + } + + if (!runMigrations()) { + return false; + } + + initialized_ = true; + return true; +} + +bool MessageStore::saveMessage(const Message& message) { + if (!initialized_) { + qWarning() << "Cannot save message before message store initialization"; + return false; + } + + const auto userId = userIdForUserName(message.senderName); + if (userId == InvalidUserId) { + qWarning() << "Cannot save message for unknown user:" << message.senderName; + return false; + } + + auto database = QSqlDatabase::database(connectionName_); + QSqlQuery query(database); + query.prepare(R"( + INSERT INTO messages ( + user_id, + message_type, + body, + client_timestamp, + stored_at + ) VALUES ( + :user_id, + :message_type, + :body, + :client_timestamp, + :stored_at + ) + )"); + query.bindValue(QStringLiteral(":user_id"), userId); + query.bindValue(QStringLiteral(":message_type"), message.messageType); + query.bindValue(QStringLiteral(":body"), message.text); + query.bindValue(QStringLiteral(":client_timestamp"), + message.timestamp.toUTC().toString(Qt::ISODateWithMs)); + query.bindValue(QStringLiteral(":stored_at"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + + if (!query.exec()) { + qWarning() << "Could not save chat message:" << lastErrorText(query); + return false; + } + + return true; +} + +QList MessageStore::loadMessages() const { + QList messages; + + if (!initialized_) { + qWarning() << "Cannot load messages before message store initialization"; + return messages; + } + + auto database = QSqlDatabase::database(connectionName_); + QSqlQuery query(database); + query.prepare(R"( + SELECT users.username, messages.message_type, messages.body, messages.client_timestamp + FROM messages + INNER JOIN users ON users.id = messages.user_id + ORDER BY messages.stored_at ASC, messages.id ASC + )"); + + if (!query.exec()) { + qWarning() << "Could not load chat messages:" << lastErrorText(query); + return messages; + } + + while (query.next()) { + Message message; + message.protocolVersion = CurrentProtocolVersion; + message.senderName = query.value(QStringLiteral("username")).toString(); + const auto storedMessageType = query.value(QStringLiteral("message_type")).toUInt(); + switch (storedMessageType) { + case 1: // Protocol version 1 ChatMessage + message.messageType = static_cast(MessageType::ChatMessage); + break; + case 2: // Protocol version 1 SystemMessage + message.messageType = static_cast(MessageType::SystemMessage); + break; + case 3: // Protocol version 1 ErrorMessage + message.messageType = static_cast(MessageType::ErrorMessage); + break; + case static_cast(MessageType::ChatMessage): + case static_cast(MessageType::SystemMessage): + case static_cast(MessageType::ErrorMessage): + message.messageType = storedMessageType; + break; + default: + qWarning() << "Skipping stored message with unsupported type" << storedMessageType; + continue; + } + message.text = query.value(QStringLiteral("body")).toString(); + message.timestamp = QDateTime::fromString( + query.value(QStringLiteral("client_timestamp")).toString(), Qt::ISODateWithMs); + if (!message.timestamp.isValid()) { + message.timestamp = QDateTime::currentDateTimeUtc(); + } + messages.append(message); + } + + return messages; +} + +bool MessageStore::hasUser(const QString& userName) const { + return userIdForUserName(userName) != InvalidUserId; +} + +std::optional MessageStore::findUserForAuthentication( + const QString& userName) const { + if (!initialized_) { + qWarning() << "Cannot authenticate a user before message store initialization"; + return std::nullopt; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + query.prepare( + QStringLiteral("SELECT id, username, public_key FROM users WHERE username = :username")); + query.bindValue(QStringLiteral(":username"), userName.trimmed()); + + if (!query.exec()) { + qWarning() << "Could not load user authentication data:" << lastErrorText(query); + return std::nullopt; + } + + if (!query.next()) { + return std::nullopt; + } + + return UserAuthenticationRecord{ + query.value(QStringLiteral("id")).toInt(), + query.value(QStringLiteral("username")).toString(), + query.value(QStringLiteral("public_key")).toByteArray(), + }; +} + +RegistrationRequestResult MessageStore::requestRegistration(const QString& userName, + const QByteArray& publicKey, + const QString& sourceAddress) const { + if (!initialized_) { + return {}; + } + + auto database = QSqlDatabase::database(connectionName_); + QSqlQuery existingQuery(database); + existingQuery.prepare(R"( + SELECT id, status + FROM registration_requests + WHERE username = :username AND public_key = :public_key + )"); + existingQuery.bindValue(QStringLiteral(":username"), userName.trimmed()); + existingQuery.bindValue(QStringLiteral(":public_key"), publicKey); + if (!existingQuery.exec()) { + qWarning() << "Could not look up registration request:" << lastErrorText(existingQuery); + return {}; + } + if (existingQuery.next()) { + const auto status = existingQuery.value(QStringLiteral("status")).toString(); + return { + status == QStringLiteral("rejected") ? RegistrationRequestResult::Status::Rejected + : RegistrationRequestResult::Status::Pending, + existingQuery.value(QStringLiteral("id")).toLongLong(), + }; + } + + QSqlQuery insertQuery(database); + insertQuery.prepare(R"( + INSERT INTO registration_requests ( + username, public_key, source_address, status, created_at + ) VALUES ( + :username, :public_key, :source_address, 'pending', :created_at + ) + )"); + insertQuery.bindValue(QStringLiteral(":username"), userName.trimmed()); + insertQuery.bindValue(QStringLiteral(":public_key"), publicKey); + insertQuery.bindValue(QStringLiteral(":source_address"), sourceAddress); + insertQuery.bindValue(QStringLiteral(":created_at"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + if (!insertQuery.exec()) { + qWarning() << "Could not create registration request:" << lastErrorText(insertQuery); + return {}; + } + + return {RegistrationRequestResult::Status::Created, insertQuery.lastInsertId().toLongLong()}; +} + +QList MessageStore::pendingRegistrationRequests() const { + QList requests; + if (!initialized_) { + return requests; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + query.prepare(R"( + SELECT id, username, public_key, source_address, created_at + FROM registration_requests + WHERE status = 'pending' + ORDER BY created_at ASC, id ASC + )"); + if (!query.exec()) { + qWarning() << "Could not list registration requests:" << lastErrorText(query); + return requests; + } + + while (query.next()) { + requests.append({ + query.value(QStringLiteral("id")).toLongLong(), + query.value(QStringLiteral("username")).toString(), + query.value(QStringLiteral("public_key")).toByteArray(), + query.value(QStringLiteral("source_address")).toString(), + query.value(QStringLiteral("created_at")).toString(), + }); + } + return requests; +} + +bool MessageStore::approveRegistrationRequest(qint64 requestId) const { + if (!initialized_) { + return false; + } + + auto database = QSqlDatabase::database(connectionName_); + if (!database.transaction()) { + return false; + } + + QSqlQuery requestQuery(database); + requestQuery.prepare(R"( + SELECT username, public_key + FROM registration_requests + WHERE id = :id AND status = 'pending' + )"); + requestQuery.bindValue(QStringLiteral(":id"), requestId); + if (!requestQuery.exec() || !requestQuery.next()) { + qWarning() << "Pending registration request not found:" << requestId; + database.rollback(); + return false; + } + + const auto userName = requestQuery.value(QStringLiteral("username")).toString(); + const auto publicKey = requestQuery.value(QStringLiteral("public_key")).toByteArray(); + const auto now = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs); + + QSqlQuery insertUserQuery(database); + insertUserQuery.prepare(R"( + INSERT INTO users (username, public_key, created_at) + VALUES (:username, :public_key, :created_at) + )"); + insertUserQuery.bindValue(QStringLiteral(":username"), userName); + insertUserQuery.bindValue(QStringLiteral(":public_key"), publicKey); + insertUserQuery.bindValue(QStringLiteral(":created_at"), now); + if (!insertUserQuery.exec()) { + qWarning() << "Could not create user from registration request:" + << lastErrorText(insertUserQuery); + database.rollback(); + return false; + } + + QSqlQuery approveQuery(database); + approveQuery.prepare(R"( + UPDATE registration_requests + SET status = 'approved', reviewed_at = :reviewed_at + WHERE id = :id AND status = 'pending' + )"); + approveQuery.bindValue(QStringLiteral(":reviewed_at"), now); + approveQuery.bindValue(QStringLiteral(":id"), requestId); + if (!approveQuery.exec() || approveQuery.numRowsAffected() != 1) { + database.rollback(); + return false; + } + + QSqlQuery rejectOthersQuery(database); + rejectOthersQuery.prepare(R"( + UPDATE registration_requests + SET status = 'rejected', reviewed_at = :reviewed_at + WHERE username = :username AND id <> :id AND status = 'pending' + )"); + rejectOthersQuery.bindValue(QStringLiteral(":reviewed_at"), now); + rejectOthersQuery.bindValue(QStringLiteral(":username"), userName); + rejectOthersQuery.bindValue(QStringLiteral(":id"), requestId); + if (!rejectOthersQuery.exec()) { + database.rollback(); + return false; + } + + if (!database.commit()) { + database.rollback(); + return false; + } + qInfo() << "Approved registration request" << requestId << "and created user" << userName; + return true; +} + +bool MessageStore::rejectRegistrationRequest(qint64 requestId) const { + if (!initialized_) { + return false; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + query.prepare(R"( + UPDATE registration_requests + SET status = 'rejected', reviewed_at = :reviewed_at + WHERE id = :id AND status = 'pending' + )"); + query.bindValue(QStringLiteral(":reviewed_at"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + query.bindValue(QStringLiteral(":id"), requestId); + if (!query.exec() || query.numRowsAffected() != 1) { + qWarning() << "Pending registration request not found:" << requestId; + return false; + } + qInfo() << "Rejected registration request" << requestId; + return true; +} + +QList MessageStore::users() const { + QList users; + if (!initialized_) { + return users; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + query.prepare(R"( + SELECT id, username, created_at + FROM users + ORDER BY username COLLATE NOCASE ASC, id ASC + )"); + if (!query.exec()) { + qWarning() << "Could not list users:" << lastErrorText(query); + return users; + } + + while (query.next()) { + users.append({ + query.value(QStringLiteral("id")).toLongLong(), + query.value(QStringLiteral("username")).toString(), + query.value(QStringLiteral("created_at")).toString(), + }); + } + return users; +} + +bool MessageStore::deleteUser(qint64 userId) const { + if (!initialized_) { + return false; + } + + auto database = QSqlDatabase::database(connectionName_); + if (!database.transaction()) { + return false; + } + + QSqlQuery userQuery(database); + userQuery.prepare(QStringLiteral("SELECT username FROM users WHERE id = :id")); + userQuery.bindValue(QStringLiteral(":id"), userId); + if (!userQuery.exec() || !userQuery.next()) { + qWarning() << "User not found:" << userId; + database.rollback(); + return false; + } + const auto userName = userQuery.value(QStringLiteral("username")).toString(); + + QSqlQuery messagesQuery(database); + messagesQuery.prepare(QStringLiteral("DELETE FROM messages WHERE user_id = :user_id")); + messagesQuery.bindValue(QStringLiteral(":user_id"), userId); + if (!messagesQuery.exec()) { + qWarning() << "Could not delete messages for user:" << lastErrorText(messagesQuery); + database.rollback(); + return false; + } + + QSqlQuery requestsQuery(database); + requestsQuery.prepare( + QStringLiteral("DELETE FROM registration_requests WHERE username = :username")); + requestsQuery.bindValue(QStringLiteral(":username"), userName); + if (!requestsQuery.exec()) { + qWarning() << "Could not delete registration requests for user:" + << lastErrorText(requestsQuery); + database.rollback(); + return false; + } + + QSqlQuery deleteUserQuery(database); + deleteUserQuery.prepare(QStringLiteral("DELETE FROM users WHERE id = :id")); + deleteUserQuery.bindValue(QStringLiteral(":id"), userId); + if (!deleteUserQuery.exec() || deleteUserQuery.numRowsAffected() != 1) { + database.rollback(); + return false; + } + + if (!database.commit()) { + database.rollback(); + return false; + } + qInfo() << "Deleted user" << userName << "with ID" << userId; + return true; +} + +bool MessageStore::clearMessages() const { + if (!initialized_) { + return false; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + if (!query.exec(QStringLiteral("DELETE FROM messages"))) { + qWarning() << "Could not clear chat history:" << lastErrorText(query); + return false; + } + + qInfo() << "Cleared chat history; deleted" << query.numRowsAffected() << "messages"; + return true; +} + +int MessageStore::userIdForUserName(const QString& userName) const { + if (!initialized_) { + qWarning() << "Cannot look up user before message store initialization"; + return InvalidUserId; + } + + QSqlQuery query(QSqlDatabase::database(connectionName_)); + query.prepare(QStringLiteral("SELECT id FROM users WHERE username = :username")); + query.bindValue(QStringLiteral(":username"), userName.trimmed()); + + if (!query.exec()) { + qWarning() << "Could not look up user:" << lastErrorText(query); + return InvalidUserId; + } + + if (!query.next()) { + return InvalidUserId; + } + + return query.value(QStringLiteral("id")).toInt(); +} + +bool MessageStore::openDatabase() { + if (QSqlDatabase::contains(connectionName_)) { + return QSqlDatabase::database(connectionName_).isOpen(); + } + + auto database = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName_); + database.setDatabaseName(databasePath()); + + const auto databaseDirectory = QFileInfo(database.databaseName()).absoluteDir(); + if (!databaseDirectory.exists() && !QDir().mkpath(databaseDirectory.absolutePath())) { + qWarning() << "Could not create database directory:" << databaseDirectory.absolutePath(); + return false; + } + + if (!database.open()) { + qWarning() << "Could not open SQLite database:" << database.lastError().text(); + return false; + } + + QSqlQuery query(database); + if (!query.exec(QStringLiteral("PRAGMA foreign_keys = ON"))) { + qWarning() << "Could not enable SQLite foreign keys:" << lastErrorText(query); + return false; + } + + if (!query.exec(QStringLiteral("PRAGMA journal_mode = WAL"))) { + qWarning() << "Could not enable SQLite WAL mode:" << lastErrorText(query); + return false; + } + + qInfo() << "Using SQLite database" << database.databaseName(); + return true; +} + +bool MessageStore::runMigrations() { + auto database = QSqlDatabase::database(connectionName_); + QSqlQuery query(database); + + if (!query.exec(R"( + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ) + )")) { + qWarning() << "Could not create schema_migrations table:" << lastErrorText(query); + return false; + } + + const QDir migrationsDirectory(QCoreApplication::applicationDirPath() + + QStringLiteral("/Migrations")); + const auto migrationFiles = + migrationsDirectory.entryList({QStringLiteral("*.sql")}, QDir::Files, QDir::Name); + + if (migrationFiles.isEmpty()) { + qWarning() << "No database migrations found in" << migrationsDirectory.absolutePath(); + return false; + } + + for (const auto& migrationFile : migrationFiles) { + const auto version = migrationFile.section(QLatin1Char('_'), 0, 0); + + QSqlQuery appliedQuery(database); + appliedQuery.prepare( + QStringLiteral("SELECT COUNT(*) FROM schema_migrations WHERE version = :version")); + appliedQuery.bindValue(QStringLiteral(":version"), version); + if (!appliedQuery.exec() || !appliedQuery.next()) { + qWarning() << "Could not check migration state for" << migrationFile + << lastErrorText(appliedQuery); + return false; + } + + if (appliedQuery.value(0).toInt() > 0) { + continue; + } + + QFile file(migrationsDirectory.filePath(migrationFile)); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "Could not open migration" << file.fileName() << file.errorString(); + return false; + } + + const auto script = QString::fromUtf8(file.readAll()); + if (!database.transaction()) { + qWarning() << "Could not start migration transaction:" << database.lastError().text(); + return false; + } + + if (!executeSqlScript(script)) { + database.rollback(); + return false; + } + + QSqlQuery insertQuery(database); + insertQuery.prepare(R"( + INSERT INTO schema_migrations (version, applied_at) + VALUES (:version, :applied_at) + )"); + insertQuery.bindValue(QStringLiteral(":version"), version); + insertQuery.bindValue(QStringLiteral(":applied_at"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + + if (!insertQuery.exec()) { + qWarning() << "Could not record migration" << migrationFile + << lastErrorText(insertQuery); + database.rollback(); + return false; + } + + if (!database.commit()) { + qWarning() << "Could not commit migration" << migrationFile + << database.lastError().text(); + database.rollback(); + return false; + } + + qInfo() << "Applied database migration" << migrationFile; + } + + return true; +} + +bool MessageStore::executeSqlScript(const QString& script) const { + auto database = QSqlDatabase::database(connectionName_); + + for (const auto& statement : splitSqlStatements(script)) { + QSqlQuery query(database); + if (!query.exec(statement)) { + qWarning() << "Could not execute SQL migration statement:" << lastErrorText(query); + qWarning() << statement; + return false; + } + } + + return true; +} + +QString MessageStore::databasePath() const { + const auto configuredPath = qEnvironmentVariable("MESSENGER_DB_PATH"); + if (!configuredPath.isEmpty()) { + return configuredPath; + } + + auto appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + if (appDataPath.isEmpty()) { + appDataPath = QDir::homePath() + QStringLiteral("/.messenger"); + } + + return QDir(appDataPath).filePath(QStringLiteral("messenger.sqlite3")); +} diff --git a/src/server/Storage/message_store.h b/src/server/Storage/message_store.h new file mode 100644 index 0000000..a9ac53c --- /dev/null +++ b/src/server/Storage/message_store.h @@ -0,0 +1,81 @@ +#ifndef MESSENGER_MESSAGE_STORE_H +#define MESSENGER_MESSAGE_STORE_H + +#include +#include +#include +#include + +#include "message.h" + +struct UserAuthenticationRecord { + int userId; + QString userName; + QByteArray publicKey; +}; + +struct StoredUser { + qint64 userId; + QString userName; + QString createdAt; +}; + +struct RegistrationRequest { + qint64 requestId; + QString userName; + QByteArray publicKey; + QString sourceAddress; + QString createdAt; +}; + +struct RegistrationRequestResult { + enum class Status { + Created, + Pending, + Rejected, + Failed, + }; + + Status status = Status::Failed; + qint64 requestId = -1; +}; + +class MessageStore { + public: + MessageStore(); + ~MessageStore(); + + MessageStore(const MessageStore&) = delete; + MessageStore& operator=(const MessageStore&) = delete; + MessageStore(MessageStore&&) = delete; + MessageStore& operator=(MessageStore&&) = delete; + + bool initialize(); + void close(); + bool hasUser(const QString& userName) const; + [[nodiscard]] std::optional findUserForAuthentication( + const QString& userName) const; + [[nodiscard]] RegistrationRequestResult requestRegistration(const QString& userName, + const QByteArray& publicKey, + const QString& sourceAddress) const; + [[nodiscard]] QList pendingRegistrationRequests() const; + bool approveRegistrationRequest(qint64 requestId) const; + bool rejectRegistrationRequest(qint64 requestId) const; + [[nodiscard]] QList users() const; + bool deleteUser(qint64 userId) const; + bool clearMessages() const; + bool saveMessage(const messenger::protocol::Message& message); + QList loadMessages() const; + + private: + int userIdForUserName(const QString& userName) const; + bool openDatabase(); + bool runMigrations(); + bool executeSqlScript(const QString& script) const; + QString databasePath() const; + + QString connectionName_; + bool initialized_ = false; +}; + +#endif // MESSENGER_MESSAGE_STORE_H diff --git a/src/server/server.cpp b/src/server/server.cpp index 7a1b330..0420305 100644 --- a/src/server/server.cpp +++ b/src/server/server.cpp @@ -1,19 +1,396 @@ #include "server.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "keyPair.h" +#include "session.h" +#include "signature.h" + +using messenger::protocol::CurrentProtocolVersion; +using messenger::protocol::DefaultPort; +using messenger::protocol::Message; +using messenger::protocol::MessageType; + +namespace { + +bool isValidChatMessage(const Message& message) { + return message.messageType == static_cast(MessageType::ChatMessage) && + !message.text.trimmed().isEmpty() && + message.text.size() <= messenger::protocol::MaximumMessageSize; +} + +std::vector toByteVector(const QByteArray& bytes) { + const auto* begin = reinterpret_cast(bytes.constData()); + return {begin, begin + bytes.size()}; +} + +std::optional deserializePublicKey(const QByteArray& serializedKey) { + try { + PublicKey publicKey; + const auto bytes = toByteVector(serializedKey); + const operations::BigInt one(1); + if (!keyPair::s_deserialize(bytes, publicKey.n, publicKey.e) || publicKey.n <= one || + publicKey.e <= one || publicKey.serialize() != bytes) { + return std::nullopt; + } + return publicKey; + } catch (const std::exception&) { + return std::nullopt; + } +} + +} // namespace server& server::getInstance() { static server instance; return instance; } -server::server() { std::cout << "Starting Messenger Server ...\n"; } +server::server() { + connect(&tcpServer_, &QTcpServer::newConnection, this, &server::handleNewConnection); + connect(&tcpServer_, &QTcpServer::acceptError, this, &server::handleAcceptError); +} + +server::~server() { shutdown(); } + +void server::shutdown() { + tcpServer_.close(); + qDeleteAll(sessions_); + sessions_.clear(); + if (QCoreApplication::instance() != nullptr) { + messageStore_.close(); + } +} + +bool server::listen(const QHostAddress& address, quint16 port) { + if (!messageStore_.initialize()) { + qCritical() << "Could not initialize message storage"; + return false; + } + + if (tcpServer_.listen(address, port)) { + qInfo() << "Messenger server listening on" << tcpServer_.serverAddress().toString() + << tcpServer_.serverPort(); + return true; + } + + qCritical() << "Could not start Messenger server:" << tcpServer_.errorString(); + return false; +} + +void server::handleNewConnection() { + while (tcpServer_.hasPendingConnections()) { + auto* socket = tcpServer_.nextPendingConnection(); + auto* session = new Session(socket, this); + sessions_.insert(session); + + connect(session, &Session::messageReceived, this, &server::handleMessageReceived); + connect(session, &Session::disconnected, this, &server::handleSessionDisconnected); + } +} + +void server::handleAcceptError(QAbstractSocket::SocketError socketError) { + qWarning() << "Server accept error:" << socketError << tcpServer_.errorString(); +} + +void server::handleMessageReceived(const Message& message, Session* session) { + if (message.protocolVersion != CurrentProtocolVersion) { + qWarning() << "Ignoring message with unsupported protocol version" + << message.protocolVersion; + return; + } + + const auto messageType = static_cast(message.messageType); + + if (session->authenticationState() == Session::AuthenticationState::AwaitingHello) { + if (messageType != MessageType::AuthHello) { + qWarning() << "Rejecting unexpected message before AuthHello"; + session->rejectAuthentication(); + return; + } + + const auto requestedUserName = message.senderName.trimmed(); + if (requestedUserName.isEmpty() || + requestedUserName.size() > messenger::protocol::MaximumUserNameSize || + message.clientNonce.size() != messenger::protocol::AuthenticationNonceSize) { + qWarning() << "Rejecting malformed AuthHello"; + session->rejectAuthentication(); + return; + } + + const auto user = messageStore_.findUserForAuthentication(requestedUserName); + if (!user) { + if (!deserializePublicKey(message.publicKey)) { + qWarning() << "Registration requested with an invalid public key for" + << requestedUserName; + session->rejectAuthentication(QStringLiteral("The public key is invalid.")); + return; + } + + const auto request = messageStore_.requestRegistration( + requestedUserName, message.publicKey, session->peerAddress()); + switch (request.status) { + case RegistrationRequestResult::Status::Created: + qInfo().noquote() << QStringLiteral( + "New registration request %1 for \"%2\" from %3. " + "Run 'Messenger-Server requests' to review it.") + .arg(request.requestId) + .arg(requestedUserName, session->peerAddress()); + [[fallthrough]]; + case RegistrationRequestResult::Status::Pending: + session->rejectAuthentication( + QStringLiteral("Access is pending server approval."), + MessageType::RegistrationPending); + break; + case RegistrationRequestResult::Status::Rejected: + session->rejectAuthentication( + QStringLiteral("The registration request was rejected."), + MessageType::RegistrationRejected); + break; + case RegistrationRequestResult::Status::Failed: + session->rejectAuthentication(); + break; + } + return; + } + + const auto publicKey = deserializePublicKey(user->publicKey); + if (!publicKey) { + qWarning() << "Stored public key is invalid for user:" << user->userName; + session->rejectAuthentication(); + return; + } + + const auto authenticationId = messenger::protocol::generateSecureRandomBytes( + messenger::protocol::AuthenticationIdSize); + const auto serverNonce = messenger::protocol::generateSecureRandomBytes( + messenger::protocol::AuthenticationNonceSize); + session->beginAuthentication(user->userId, user->userName, *publicKey, authenticationId, + message.clientNonce, serverNonce); + + Message challenge; + challenge.messageType = static_cast(MessageType::AuthChallenge); + challenge.authenticationId = authenticationId; + challenge.serverNonce = serverNonce; + session->sendMessage(challenge); + qInfo() << "Sent authentication challenge for user" << user->userName; + return; + } + + if (session->authenticationState() == Session::AuthenticationState::AwaitingProof) { + if (messageType != MessageType::AuthProof || session->authenticationExpired() || + message.authenticationId.size() != messenger::protocol::AuthenticationIdSize || + message.authenticationId != session->authenticationId() || + message.signature.isEmpty()) { + qWarning() << "Rejecting malformed or expired AuthProof for" << session->userName(); + session->rejectAuthentication(); + return; + } + + const auto transcript = messenger::protocol::authenticationTranscript( + session->userName(), session->authenticationId(), session->clientNonce(), + session->serverNonce()); + const auto digest = QCryptographicHash::hash(transcript, QCryptographicHash::Sha256); + const auto signatureValid = + core::signature::verifyDigest(session->authenticationPublicKey(), toByteVector(digest), + toByteVector(message.signature)); + if (!signatureValid) { + qWarning() << "Authentication signature verification failed for" << session->userName(); + session->rejectAuthentication(); + return; + } + + session->completeAuthentication(); + qInfo() << "Authenticated user" << session->userName(); + + Message success; + success.messageType = static_cast(MessageType::AuthSuccess); + success.senderName = session->userName(); + success.text = QStringLiteral("Authentication successful."); + session->sendMessage(success); + + const auto previousMessages = messageStore_.loadMessages(); + for (const auto& previousMessage : previousMessages) { + session->sendMessage(previousMessage); + } + return; + } + + if (!session->isAuthenticated()) { + session->disconnectFromHost(); + return; + } + + if (!messageStore_.hasUser(session->userName())) { + qInfo() << "Disconnecting session for deleted user" << session->userName(); + session->disconnectFromHost(); + return; + } + + if (messageType != MessageType::ChatMessage || !isValidChatMessage(message)) { + qWarning() << "Disconnecting authenticated session after invalid message from" + << session->userName(); + session->disconnectFromHost(); + return; + } + + Message verifiedMessage = message; + verifiedMessage.senderName = session->userName(); + verifiedMessage.text = message.text.trimmed(); + verifiedMessage.timestamp = QDateTime::currentDateTimeUtc(); + verifiedMessage.authenticationId.clear(); + verifiedMessage.clientNonce.clear(); + verifiedMessage.serverNonce.clear(); + verifiedMessage.signature.clear(); + verifiedMessage.publicKey.clear(); + + qInfo() << "Message from" << verifiedMessage.senderName << ":" << verifiedMessage.text; + + if (!messageStore_.saveMessage(verifiedMessage)) { + qWarning() << "Message was not persisted"; + return; + } + + QList deletedUserSessions; + for (auto* connectedSession : std::as_const(sessions_)) { + if (connectedSession->isAuthenticated() && + !messageStore_.hasUser(connectedSession->userName())) { + deletedUserSessions.append(connectedSession); + } else if (connectedSession->isAuthenticated()) { + connectedSession->sendMessage(verifiedMessage); + } + } + for (auto* deletedUserSession : deletedUserSessions) { + deletedUserSession->disconnectFromHost(); + } +} + +void server::handleSessionDisconnected(Session* session) { + sessions_.remove(session); + session->deleteLater(); +} + +namespace { -server::~server() { std::cout << "Stopping Messenger Server ...\n"; } +void printServerUsage(const QString& executable) { + qInfo().noquote() << QStringLiteral( + "Usage:\n" + " %1 Start the server\n" + " %1 requests List pending registration requests\n" + " %1 approve Approve a registration request\n" + " %1 reject Reject a registration request\n" + " %1 users List all users\n" + " %1 delete-user Delete a user and all associated data\n" + " %1 clear-history Delete all stored chat messages") + .arg(executable); +} + +int runAdministrationCommand(const QStringList& arguments) { + MessageStore store; + if (!store.initialize()) { + qCritical() << "Could not initialize message storage"; + return 1; + } + + const auto command = arguments.at(1); + if (command == QStringLiteral("requests") && arguments.size() == 2) { + const auto requests = store.pendingRegistrationRequests(); + if (requests.isEmpty()) { + qInfo() << "There are no pending registration requests."; + return 0; + } + + for (const auto& request : requests) { + const auto fingerprint = + QCryptographicHash::hash(request.publicKey, QCryptographicHash::Sha256) + .toHex(':') + .toUpper(); + qInfo().noquote() << QStringLiteral( + "[%1] %2\n From: %3\n Created: %4\n Key: %5") + .arg(request.requestId) + .arg(request.userName, request.sourceAddress, + request.createdAt, QString::fromLatin1(fingerprint)); + } + return 0; + } + + if (command == QStringLiteral("users") && arguments.size() == 2) { + const auto users = store.users(); + if (users.isEmpty()) { + qInfo() << "There are no users."; + return 0; + } + + for (const auto& user : users) { + qInfo().noquote() << QStringLiteral("[%1] %2\n Created: %3") + .arg(user.userId) + .arg(user.userName, user.createdAt); + } + return 0; + } + + if ((command == QStringLiteral("approve") || command == QStringLiteral("reject")) && + arguments.size() == 3) { + bool validId = false; + const auto requestId = arguments.at(2).toLongLong(&validId); + if (!validId || requestId <= 0) { + qCritical() << "The request ID must be a positive number."; + return 2; + } + + const auto success = command == QStringLiteral("approve") + ? store.approveRegistrationRequest(requestId) + : store.rejectRegistrationRequest(requestId); + return success ? 0 : 1; + } + + if (command == QStringLiteral("delete-user") && arguments.size() == 3) { + bool validId = false; + const auto userId = arguments.at(2).toLongLong(&validId); + if (!validId || userId <= 0) { + qCritical() << "The user ID must be a positive number."; + return 2; + } + return store.deleteUser(userId) ? 0 : 1; + } + + if (command == QStringLiteral("clear-history") && arguments.size() == 2) { + return store.clearMessages() ? 0 : 1; + } + + printServerUsage(arguments.first()); + return 2; +} + +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("Messenger")); + QCoreApplication::setOrganizationName(QStringLiteral("ParallelEngineering")); + + const auto arguments = QCoreApplication::arguments(); + if (arguments.size() > 1) { + return runAdministrationCommand(arguments); + } -int main() { auto& serverInstance = server::getInstance(); - (void)serverInstance; + if (!serverInstance.listen(QHostAddress::Any, DefaultPort)) { + serverInstance.shutdown(); + return 1; + } + + QObject::connect(&app, &QCoreApplication::aboutToQuit, &serverInstance, &server::shutdown); - return 0; + return app.exec(); } diff --git a/src/server/server.h b/src/server/server.h index 2825dfe..323c730 100644 --- a/src/server/server.h +++ b/src/server/server.h @@ -1,18 +1,44 @@ #ifndef MESSENGER_SERVER_H #define MESSENGER_SERVER_H -class server { +#include +#include +#include +#include + +#include "Storage/message_store.h" +#include "message.h" + +class Session; +class QHostAddress; + +class server final : public QObject { + Q_OBJECT + public: static server& getInstance(); - ~server(); + ~server() override; + + bool listen(const QHostAddress& address, quint16 port); + void shutdown(); server(const server&) = delete; server& operator=(const server&) = delete; server(server&&) = delete; server& operator=(server&&) = delete; + private slots: + void handleNewConnection(); + void handleAcceptError(QAbstractSocket::SocketError socketError); + void handleMessageReceived(const messenger::protocol::Message& message, Session* session); + void handleSessionDisconnected(Session* session); + private: server(); + + QTcpServer tcpServer_; + QSet sessions_; + MessageStore messageStore_; }; #endif // MESSENGER_SERVER_H diff --git a/src/server/session.cpp b/src/server/session.cpp new file mode 100644 index 0000000..a327b5e --- /dev/null +++ b/src/server/session.cpp @@ -0,0 +1,156 @@ +#include "session.h" + +#include +#include +#include +#include + +using messenger::protocol::AuthenticationTimeoutMs; +using messenger::protocol::CurrentProtocolVersion; +using messenger::protocol::DataStreamVersion; +using messenger::protocol::Message; +using messenger::protocol::MessageType; +using messenger::protocol::ReadBufferSize; + +Session::Session(QTcpSocket* socket, QObject* parent) + : QObject(parent), socket_(socket), stream_(socket) { + socket_->setParent(this); + socket_->setReadBufferSize(ReadBufferSize); + stream_.setVersion(DataStreamVersion); + + connect(socket_, &QTcpSocket::readyRead, this, &Session::readAvailable); + connect(socket_, &QTcpSocket::disconnected, this, &Session::handleDisconnected); + connect(socket_, &QTcpSocket::errorOccurred, this, &Session::handleError); + + authenticationDeadline_.setRemainingTime(AuthenticationTimeoutMs); + authenticationTimer_.setSingleShot(true); + authenticationTimer_.start(AuthenticationTimeoutMs); + connect(&authenticationTimer_, &QTimer::timeout, this, [this]() { + if (!isAuthenticated()) { + qWarning() << "Client authentication timed out for" + << socket_->peerAddress().toString(); + rejectAuthentication(QStringLiteral("Authentication timed out.")); + } + }); + + qInfo() << "Client connected from" << socket_->peerAddress().toString() << socket_->peerPort(); +} + +void Session::sendMessage(const Message& message) { + if (socket_->state() != QAbstractSocket::ConnectedState) { + return; + } + + QDataStream out(socket_); + out.setVersion(DataStreamVersion); + out << message; + socket_->flush(); +} + +void Session::beginAuthentication(int userId, const QString& userName, const PublicKey& publicKey, + const QByteArray& authenticationId, const QByteArray& clientNonce, + const QByteArray& serverNonce) { + if (authenticationState_ != AuthenticationState::AwaitingHello) { + return; + } + + userId_ = userId; + userName_ = userName.trimmed(); + authenticationPublicKey_ = publicKey; + authenticationId_ = authenticationId; + clientNonce_ = clientNonce; + serverNonce_ = serverNonce; + authenticationState_ = AuthenticationState::AwaitingProof; +} + +void Session::completeAuthentication() { + if (authenticationState_ != AuthenticationState::AwaitingProof) { + return; + } + + authenticationState_ = AuthenticationState::Authenticated; + authenticationTimer_.stop(); + authenticationPublicKey_ = {}; + authenticationId_.clear(); + clientNonce_.clear(); + serverNonce_.clear(); +} + +void Session::rejectAuthentication(const QString& reason, MessageType messageType) { + if (authenticationState_ == AuthenticationState::Rejected) { + return; + } + + authenticationState_ = AuthenticationState::Rejected; + authenticationTimer_.stop(); + authenticationPublicKey_ = {}; + authenticationId_.clear(); + clientNonce_.clear(); + serverNonce_.clear(); + + Message failure; + failure.messageType = static_cast(messageType); + failure.text = reason; + sendMessage(failure); + socket_->disconnectFromHost(); +} + +void Session::disconnectFromHost() { socket_->disconnectFromHost(); } + +Session::AuthenticationState Session::authenticationState() const { return authenticationState_; } + +bool Session::isAuthenticated() const { + return authenticationState_ == AuthenticationState::Authenticated; +} + +bool Session::authenticationExpired() const { return authenticationDeadline_.hasExpired(); } + +int Session::userId() const { return userId_; } + +QString Session::userName() const { return userName_; } + +const PublicKey& Session::authenticationPublicKey() const { return authenticationPublicKey_; } + +const QByteArray& Session::authenticationId() const { return authenticationId_; } + +const QByteArray& Session::clientNonce() const { return clientNonce_; } + +const QByteArray& Session::serverNonce() const { return serverNonce_; } + +QString Session::peerAddress() const { return socket_->peerAddress().toString(); } + +void Session::readAvailable() { + while (socket_->bytesAvailable() > 0) { + stream_.startTransaction(); + + Message message; + stream_ >> message; + + if (!stream_.commitTransaction()) { + return; + } + + if (message.protocolVersion != CurrentProtocolVersion) { + qWarning() << "Unsupported protocol version" << message.protocolVersion << "from" + << socket_->peerAddress().toString(); + socket_->disconnectFromHost(); + return; + } + + emit messageReceived(message, this); + } +} + +void Session::handleDisconnected() { + qInfo() << "Client disconnected from" << socket_->peerAddress().toString() + << socket_->peerPort(); + emit disconnected(this); +} + +void Session::handleError() { + if (socket_->error() == QAbstractSocket::RemoteHostClosedError) { + return; + } + + qWarning() << "Client socket error:" << socket_->errorString(); +} diff --git a/src/server/session.h b/src/server/session.h new file mode 100644 index 0000000..45d4f62 --- /dev/null +++ b/src/server/session.h @@ -0,0 +1,71 @@ +#ifndef MESSENGER_SESSION_H +#define MESSENGER_SESSION_H + +#include +#include +#include +#include + +#include "keyPair.h" +#include "message.h" + +class QTcpSocket; + +class Session final : public QObject { + Q_OBJECT + + public: + enum class AuthenticationState { + AwaitingHello, + AwaitingProof, + Authenticated, + Rejected, + }; + + explicit Session(QTcpSocket* socket, QObject* parent = nullptr); + + void sendMessage(const messenger::protocol::Message& message); + void beginAuthentication(int userId, const QString& userName, const PublicKey& publicKey, + const QByteArray& authenticationId, const QByteArray& clientNonce, + const QByteArray& serverNonce); + void completeAuthentication(); + void rejectAuthentication(const QString& reason = QStringLiteral("Authentication failed."), + messenger::protocol::MessageType messageType = + messenger::protocol::MessageType::AuthFailure); + void disconnectFromHost(); + + [[nodiscard]] AuthenticationState authenticationState() const; + [[nodiscard]] bool isAuthenticated() const; + [[nodiscard]] bool authenticationExpired() const; + [[nodiscard]] int userId() const; + [[nodiscard]] QString userName() const; + [[nodiscard]] const PublicKey& authenticationPublicKey() const; + [[nodiscard]] const QByteArray& authenticationId() const; + [[nodiscard]] const QByteArray& clientNonce() const; + [[nodiscard]] const QByteArray& serverNonce() const; + [[nodiscard]] QString peerAddress() const; + + signals: + void messageReceived(const messenger::protocol::Message& message, Session* session); + void disconnected(Session* session); + + private slots: + void readAvailable(); + void handleDisconnected(); + void handleError(); + + private: + QTcpSocket* socket_; + QDataStream stream_; + AuthenticationState authenticationState_ = AuthenticationState::AwaitingHello; + int userId_ = -1; + QString userName_; + PublicKey authenticationPublicKey_; + QByteArray authenticationId_; + QByteArray clientNonce_; + QByteArray serverNonce_; + QDeadlineTimer authenticationDeadline_; + QTimer authenticationTimer_; +}; + +#endif // MESSENGER_SESSION_H diff --git a/src/shared/CMakeLists.txt b/src/shared/CMakeLists.txt index e69de29..0d6dcb0 100644 --- a/src/shared/CMakeLists.txt +++ b/src/shared/CMakeLists.txt @@ -0,0 +1,16 @@ +find_package(Qt6 REQUIRED COMPONENTS Core) + +add_library(Messenger-Shared STATIC + message.cpp + message.h +) + +target_link_libraries(Messenger-Shared + PUBLIC + Qt6::Core +) + +target_include_directories(Messenger-Shared + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} +) diff --git a/src/shared/message.cpp b/src/shared/message.cpp new file mode 100644 index 0000000..e57ae8a --- /dev/null +++ b/src/shared/message.cpp @@ -0,0 +1,66 @@ +#include "message.h" + +#include +#include + +namespace messenger::protocol { + +QDataStream& operator<<(QDataStream& out, const Message& message) { + out << message.protocolVersion; + out << message.messageType; + out << message.senderName; + out << message.text; + out << message.timestamp; + out << message.authenticationId; + out << message.clientNonce; + out << message.serverNonce; + out << message.signature; + out << message.publicKey; + + return out; +} + +QDataStream& operator>>(QDataStream& in, Message& message) { + in >> message.protocolVersion; + in >> message.messageType; + in >> message.senderName; + in >> message.text; + in >> message.timestamp; + in >> message.authenticationId; + in >> message.clientNonce; + in >> message.serverNonce; + in >> message.signature; + in >> message.publicKey; + + return in; +} + +QByteArray authenticationTranscript(const QString& userName, const QByteArray& authenticationId, + const QByteArray& clientNonce, const QByteArray& serverNonce) { + QByteArray transcript; + QDataStream stream(&transcript, QIODevice::WriteOnly); + stream.setVersion(DataStreamVersion); + stream.setByteOrder(QDataStream::BigEndian); + stream << QByteArrayLiteral("MessengerAuth/v1"); + stream << CurrentProtocolVersion; + stream << userName.trimmed().toUtf8(); + stream << authenticationId; + stream << clientNonce; + stream << serverNonce; + return transcript; +} + +QByteArray generateSecureRandomBytes(qsizetype size) { + if (size <= 0) { + return {}; + } + + QByteArray bytes(size, Qt::Uninitialized); + auto* generator = QRandomGenerator::system(); + for (qsizetype index = 0; index < size; ++index) { + bytes[index] = static_cast(generator->generate() & 0xFFU); + } + return bytes; +} + +} // namespace messenger::protocol diff --git a/src/shared/message.h b/src/shared/message.h new file mode 100644 index 0000000..221741c --- /dev/null +++ b/src/shared/message.h @@ -0,0 +1,60 @@ +#ifndef MESSENGER_MESSAGE_H +#define MESSENGER_MESSAGE_H + +#include +#include +#include +#include +#include + +namespace messenger::protocol { + +inline constexpr quint32 CurrentProtocolVersion = 3; +inline constexpr quint16 DefaultPort = 4242; +inline constexpr qint64 ReadBufferSize = 1024 * 1024; +inline constexpr auto DataStreamVersion = QDataStream::Qt_6_5; +inline constexpr qsizetype AuthenticationIdSize = 16; +inline constexpr qsizetype AuthenticationNonceSize = 32; +inline constexpr int AuthenticationTimeoutMs = 30'000; +inline constexpr qsizetype MaximumUserNameSize = 64; +inline constexpr qsizetype MaximumMessageSize = 16 * 1024; + +enum class MessageType : quint32 { + AuthHello = 1, + AuthChallenge = 2, + AuthProof = 3, + AuthSuccess = 4, + AuthFailure = 5, + RegistrationPending = 6, + RegistrationRejected = 7, + ChatMessage = 100, + SystemMessage = 101, + ErrorMessage = 102, +}; + +struct Message { + quint32 protocolVersion = CurrentProtocolVersion; + quint32 messageType = static_cast(MessageType::ChatMessage); + QString senderName; + QString text; + QDateTime timestamp = QDateTime::currentDateTimeUtc(); + QByteArray authenticationId; + QByteArray clientNonce; + QByteArray serverNonce; + QByteArray signature; + QByteArray publicKey; +}; + +QDataStream& operator<<(QDataStream& out, const Message& message); +QDataStream& operator>>(QDataStream& in, Message& message); + +[[nodiscard]] QByteArray authenticationTranscript(const QString& userName, + const QByteArray& authenticationId, + const QByteArray& clientNonce, + const QByteArray& serverNonce); + +[[nodiscard]] QByteArray generateSecureRandomBytes(qsizetype size); + +} // namespace messenger::protocol + +#endif // MESSENGER_MESSAGE_H