diff options
-rw-r--r-- | CMakeLists.txt | 5 | ||||
-rw-r--r-- | lib/connection.cpp | 157 | ||||
-rw-r--r-- | lib/connection.h | 32 | ||||
-rw-r--r-- | lib/database.cpp | 31 | ||||
-rw-r--r-- | lib/database.h | 11 | ||||
-rw-r--r-- | lib/events/keyverificationevent.h | 143 | ||||
-rw-r--r-- | lib/events/roomevent.h | 2 | ||||
-rw-r--r-- | lib/events/roomkeyevent.cpp | 21 | ||||
-rw-r--r-- | lib/events/roomkeyevent.h | 3 | ||||
-rw-r--r-- | lib/keyverificationsession.cpp | 512 | ||||
-rw-r--r-- | lib/keyverificationsession.h | 147 | ||||
-rw-r--r-- | lib/room.cpp | 1 | ||||
-rw-r--r-- | res.qrc | 5 | ||||
-rw-r--r-- | sas-emoji.json | 2178 |
14 files changed, 3174 insertions, 74 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index b2c4ff83..f8667645 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,7 @@ message(STATUS " Header files will be installed to ${CMAKE_INSTALL_PREFIX}/${${ # Instruct CMake to run moc automatically when needed. set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) option(BUILD_WITH_QT6 "Build Quotient with Qt 6 (EXPERIMENTAL)" OFF) @@ -166,17 +167,18 @@ list(APPEND lib_SRCS lib/events/encryptedevent.h lib/events/encryptedevent.cpp lib/events/roomkeyevent.h lib/events/roomkeyevent.cpp lib/events/stickerevent.h - lib/events/keyverificationevent.h lib/events/filesourceinfo.h lib/events/filesourceinfo.cpp lib/jobs/requestdata.h lib/jobs/requestdata.cpp lib/jobs/basejob.h lib/jobs/basejob.cpp lib/jobs/syncjob.h lib/jobs/syncjob.cpp lib/jobs/mediathumbnailjob.h lib/jobs/mediathumbnailjob.cpp lib/jobs/downloadfilejob.h lib/jobs/downloadfilejob.cpp + res.qrc ) if (${PROJECT_NAME}_ENABLE_E2EE) list(APPEND lib_SRCS lib/database.h lib/database.cpp + lib/keyverificationsession.h lib/keyverificationsession.cpp lib/e2ee/qolmaccount.h lib/e2ee/qolmaccount.cpp lib/e2ee/qolmsession.h lib/e2ee/qolmsession.cpp lib/e2ee/qolminboundsession.h lib/e2ee/qolminboundsession.cpp @@ -186,6 +188,7 @@ if (${PROJECT_NAME}_ENABLE_E2EE) lib/e2ee/qolmerrors.h lib/e2ee/qolmerrors.cpp lib/e2ee/qolmsession.h lib/e2ee/qolmsession.cpp lib/e2ee/qolmmessage.h lib/e2ee/qolmmessage.cpp + lib/events/keyverificationevent.h ) endif() diff --git a/lib/connection.cpp b/lib/connection.cpp index 98720f3b..79d7ae55 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -32,16 +32,21 @@ #include "jobs/downloadfilejob.h" #include "jobs/mediathumbnailjob.h" #include "jobs/syncjob.h" +#include <variant> #ifdef Quotient_E2EE_ENABLED # include "database.h" +# include "keyverificationsession.h" + # include "e2ee/qolmaccount.h" # include "e2ee/qolminboundsession.h" # include "e2ee/qolmsession.h" # include "e2ee/qolmutility.h" # include "e2ee/qolmutils.h" +# include "events/keyverificationevent.h" #endif // Quotient_E2EE_ENABLED + #if QT_VERSION_MAJOR >= 6 # include <qt6keychain/keychain.h> #else @@ -115,6 +120,7 @@ public: QHash<QString, int> oneTimeKeysCount; std::vector<std::unique_ptr<EncryptedEvent>> pendingEncryptedEvents; void handleEncryptedToDeviceEvent(const EncryptedEvent& event); + bool processIfVerificationEvent(const Event &evt, bool encrypted); // A map from SenderKey to vector of InboundSession UnorderedMap<QString, std::vector<QOlmSessionPtr>> olmSessions; @@ -365,11 +371,9 @@ public: const OneTimeKeys &oneTimeKeyObject); QString curveKeyForUserDevice(const QString& userId, const QString& device) const; - QString edKeyForUserDevice(const QString& userId, - const QString& device) const; - QJsonObject encryptSessionKeyEvent(QJsonObject payloadJson, - const QString& targetUserId, - const QString& targetDeviceId) const; + QJsonObject assembleEncryptedContent(QJsonObject payloadJson, + const QString& targetUserId, + const QString& targetDeviceId) const; #endif void saveAccessTokenToKeychain() const @@ -967,7 +971,7 @@ void Connection::Private::consumeToDeviceEvents(Events&& toDeviceEvents) if (!toDeviceEvents.empty()) { qCDebug(E2EE) << "Consuming" << toDeviceEvents.size() << "to-device events"; - for (auto&& tdEvt : toDeviceEvents) + for (auto&& tdEvt : toDeviceEvents) { if (auto&& event = eventCast<EncryptedEvent>(std::move(tdEvt))) { if (event->algorithm() != OlmV1Curve25519AesSha2AlgoKey) { qCDebug(E2EE) << "Unsupported algorithm" << event->id() @@ -982,12 +986,48 @@ void Connection::Private::consumeToDeviceEvents(Events&& toDeviceEvents) outdatedUsers += event->senderId(); encryptionUpdateRequired = true; pendingEncryptedEvents.push_back(std::move(event)); + continue; } + processIfVerificationEvent(*tdEvt, false); + } } #endif } #ifdef Quotient_E2EE_ENABLED +bool Connection::Private::processIfVerificationEvent(const Event& evt, + bool encrypted) +{ + return switchOnType(evt, + [this, encrypted](const KeyVerificationRequestEvent& event) { + auto session = + new KeyVerificationSession(q->userId(), event, q, encrypted); + emit q->newKeyVerificationSession(session); + return true; + }, [this](const KeyVerificationReadyEvent& event) { + emit q->incomingKeyVerificationReady(event); + return true; + }, [this](const KeyVerificationStartEvent& event) { + emit q->incomingKeyVerificationStart(event); + return true; + }, [this](const KeyVerificationAcceptEvent& event) { + emit q->incomingKeyVerificationAccept(event); + return true; + }, [this](const KeyVerificationKeyEvent& event) { + emit q->incomingKeyVerificationKey(event); + return true; + }, [this](const KeyVerificationMacEvent& event) { + emit q->incomingKeyVerificationMac(event); + return true; + }, [this](const KeyVerificationDoneEvent& event) { + emit q->incomingKeyVerificationDone(event); + return true; + }, [this](const KeyVerificationCancelEvent& event) { + emit q->incomingKeyVerificationCancel(event); + return true; + }, false); +} + void Connection::Private::handleEncryptedToDeviceEvent(const EncryptedEvent& event) { const auto [decryptedEvent, olmSessionId] = sessionDecryptMessage(event); @@ -996,18 +1036,23 @@ void Connection::Private::handleEncryptedToDeviceEvent(const EncryptedEvent& eve return; } + if (processIfVerificationEvent(*decryptedEvent, true)) + return; switchOnType(*decryptedEvent, - [this, &event, olmSessionId = olmSessionId](const RoomKeyEvent& roomKeyEvent) { + [this, &event, + olmSessionId = olmSessionId](const RoomKeyEvent& roomKeyEvent) { if (auto* detectedRoom = q->room(roomKeyEvent.roomId())) { - detectedRoom->handleRoomKeyEvent(roomKeyEvent, event.senderId(), olmSessionId); + detectedRoom->handleRoomKeyEvent(roomKeyEvent, event.senderId(), + olmSessionId); } else { - qCDebug(E2EE) << "Encrypted event room id" << roomKeyEvent.roomId() + qCDebug(E2EE) + << "Encrypted event room id" << roomKeyEvent.roomId() << "is not found at the connection" << q->objectName(); } }, [](const Event& evt) { - qCDebug(E2EE) << "Skipping encrypted to_device event, type" - << evt.matrixType(); + qCWarning(E2EE) << "Skipping encrypted to_device event, type" + << evt.matrixType(); }); } #endif @@ -2113,8 +2158,8 @@ void Connection::Private::saveDevicesList() query.prepare(QStringLiteral( "INSERT INTO tracked_devices" - "(matrixId, deviceId, curveKeyId, curveKey, edKeyId, edKey) " - "VALUES(:matrixId, :deviceId, :curveKeyId, :curveKey, :edKeyId, :edKey);" + "(matrixId, deviceId, curveKeyId, curveKey, edKeyId, edKey, verified) " + "SELECT :matrixId, :deviceId, :curveKeyId, :curveKey, :edKeyId, :edKey, :verified WHERE NOT EXISTS(SELECT 1 FROM tracked_devices WHERE matrixId=:matrixId AND deviceId=:deviceId);" )); for (const auto& user : deviceKeys.keys()) { for (const auto& device : deviceKeys[user]) { @@ -2128,6 +2173,8 @@ void Connection::Private::saveDevicesList() query.bindValue(":curveKey", device.keys[curveKeyId]); query.bindValue(":edKeyId", edKeyId); query.bindValue(":edKey", device.keys[edKeyId]); + // If the device gets saved here, it can't be verified + query.bindValue(":verified", false); q->database()->execute(query); } @@ -2232,10 +2279,10 @@ QString Connection::Private::curveKeyForUserDevice(const QString& userId, return deviceKeys[userId][device].keys["curve25519:" % device]; } -QString Connection::Private::edKeyForUserDevice(const QString& userId, - const QString& device) const +QString Connection::edKeyForUserDevice(const QString& userId, + const QString& deviceId) const { - return deviceKeys[userId][device].keys["ed25519:" % device]; + return d->deviceKeys[userId][deviceId].keys["ed25519:" % deviceId]; } bool Connection::Private::isKnownCurveKey(const QString& userId, @@ -2297,7 +2344,7 @@ bool Connection::Private::createOlmSession(const QString& targetUserId, const auto signature = signedOneTimeKey->signature(targetUserId, targetDeviceId); if (!verifier.ed25519Verify( - edKeyForUserDevice(targetUserId, targetDeviceId).toLatin1(), + q->edKeyForUserDevice(targetUserId, targetDeviceId).toLatin1(), signedOneTimeKey->toJsonForVerification(), signature)) { qWarning(E2EE) << "Failed to verify one-time-key signature for" @@ -2320,15 +2367,21 @@ bool Connection::Private::createOlmSession(const QString& targetUserId, return true; } -QJsonObject Connection::Private::encryptSessionKeyEvent( +QJsonObject Connection::Private::assembleEncryptedContent( QJsonObject payloadJson, const QString& targetUserId, const QString& targetDeviceId) const { + payloadJson.insert(SenderKeyL, data->userId()); +// eventJson.insert("sender_device"_ls, data->deviceId()); + payloadJson.insert("keys"_ls, + QJsonObject{ + { Ed25519Key, + QString(olmAccount->identityKeys().ed25519) } }); payloadJson.insert("recipient"_ls, targetUserId); - payloadJson.insert("recipient_keys"_ls, - QJsonObject { { Ed25519Key, - edKeyForUserDevice(targetUserId, - targetDeviceId) } }); + payloadJson.insert( + "recipient_keys"_ls, + QJsonObject{ { Ed25519Key, + q->edKeyForUserDevice(targetUserId, targetDeviceId) } }); const auto [type, cipherText] = olmEncryptMessage( targetUserId, targetDeviceId, QJsonDocument(payloadJson).toJson(QJsonDocument::Compact)); @@ -2337,7 +2390,6 @@ QJsonObject Connection::Private::encryptSessionKeyEvent( QJsonObject { { "type"_ls, type }, { "body"_ls, QString(cipherText) } } } }; - return EncryptedEvent(encrypted, olmAccount->identityKeys().curve25519) .contentJson(); } @@ -2360,18 +2412,8 @@ void Connection::sendSessionKeyToDevices( if (hash.isEmpty()) return; - auto keyEventJson = RoomKeyEvent(MegolmV1AesSha2AlgoKey, roomId, sessionId, - sessionKey, userId()) - .fullJson(); - keyEventJson.insert(SenderKeyL, userId()); - keyEventJson.insert("sender_device"_ls, deviceId()); - keyEventJson.insert( - "keys"_ls, - QJsonObject { - { Ed25519Key, QString(olmAccount()->identityKeys().ed25519) } }); - auto job = callApi<ClaimKeysJob>(hash); - connect(job, &BaseJob::success, this, [job, this, roomId, sessionId, keyEventJson, devices, index] { + connect(job, &BaseJob::success, this, [job, this, roomId, sessionId, sessionKey, devices, index] { QHash<QString, QHash<QString, QJsonObject>> usersToDevicesToContent; for (const auto oneTimeKeys = job->oneTimeKeys(); const auto& [targetUserId, targetDeviceId] : @@ -2385,10 +2427,14 @@ void Connection::sendSessionKeyToDevices( // Noisy but nice for debugging // qDebug(E2EE) << "Creating the payload for" << targetUserId // << targetDeviceId << sessionId << sessionKey.toHex(); + const auto keyEventJson = RoomKeyEvent(MegolmV1AesSha2AlgoKey, + roomId, sessionId, sessionKey) + .fullJson(); + usersToDevicesToContent[targetUserId][targetDeviceId] = - d->encryptSessionKeyEvent(keyEventJson, targetUserId, + d->assembleEncryptedContent(keyEventJson, targetUserId, targetDeviceId); - } + } if (!usersToDevicesToContent.empty()) { sendToDevices(EncryptedEvent::TypeId, usersToDevicesToContent); QVector<std::tuple<QString, QString, QString>> receivedDevices; @@ -2417,4 +2463,43 @@ void Connection::saveCurrentOutboundMegolmSession( session); } +void Connection::startKeyVerificationSession(const QString& deviceId) +{ + auto* const session = new KeyVerificationSession(userId(), deviceId, this); + emit newKeyVerificationSession(session); +} + +void Connection::sendToDevice(const QString& targetUserId, + const QString& targetDeviceId, Event event, + bool encrypted) +{ + const auto contentJson = + encrypted ? d->assembleEncryptedContent(event.fullJson(), targetUserId, + targetDeviceId) + : event.contentJson(); + sendToDevices(encrypted ? EncryptedEvent::TypeId : event.type(), + { { targetUserId, { { targetDeviceId, contentJson } } } }); +} + +bool Connection::isVerifiedSession(const QString& megolmSessionId) const +{ + auto query = database()->prepareQuery("SELECT olmSessionId FROM inbound_megolm_sessions WHERE sessionId=:sessionId;"_ls); + query.bindValue(":sessionId", megolmSessionId); + database()->execute(query); + if (!query.next()) { + return false; + } + auto olmSessionId = query.value("olmSessionId").toString(); + query.prepare("SELECT senderKey FROM olm_sessions WHERE sessionId=:sessionId;"_ls); + query.bindValue(":sessionId", olmSessionId); + database()->execute(query); + if (!query.next()) { + return false; + } + auto curveKey = query.value("senderKey"_ls).toString(); + query.prepare("SELECT verified FROM tracked_devices WHERE curveKey=:curveKey;"_ls); + query.bindValue(":curveKey", curveKey); + database()->execute(query); + return query.next() && query.value("verified").toBool(); +} #endif diff --git a/lib/connection.h b/lib/connection.h index 0e0abc39..39921938 100644 --- a/lib/connection.h +++ b/lib/connection.h @@ -23,8 +23,9 @@ #ifdef Quotient_E2EE_ENABLED #include "e2ee/e2ee.h" -#include "e2ee/qolmmessage.h" #include "e2ee/qolmoutboundsession.h" +#include "keyverificationsession.h" +#include "events/keyverificationevent.h" #endif Q_DECLARE_METATYPE(Quotient::GetLoginFlowsJob::LoginFlow) @@ -319,17 +320,27 @@ public: QOlmAccount* olmAccount() const; Database* database() const; PicklingMode picklingMode() const; + UnorderedMap<QString, QOlmInboundGroupSessionPtr> loadRoomMegolmSessions( const Room* room) const; void saveMegolmSession(const Room* room, const QOlmInboundGroupSession& session) const; - bool hasOlmSession(const QString& user, const QString& deviceId) const; - QOlmOutboundGroupSessionPtr loadCurrentOutboundMegolmSession( const QString& roomId) const; void saveCurrentOutboundMegolmSession( const QString& roomId, const QOlmOutboundGroupSession& session) const; + QString edKeyForUserDevice(const QString& userId, + const QString& deviceId) const; + bool hasOlmSession(const QString& user, const QString& deviceId) const; + + // This assumes that an olm session already exists. If it doesn't, no message is sent. + void sendToDevice(const QString& targetUserId, const QString& targetDeviceId, + Event event, bool encrypted); + + /// Returns true if this megolm session comes from a verified device + bool isVerifiedSession(const QString& megolmSessionId) const; + void sendSessionKeyToDevices(const QString& roomId, const QByteArray& sessionId, const QByteArray& sessionKey, @@ -689,6 +700,8 @@ public Q_SLOTS: virtual LeaveRoomJob* leaveRoom(Room* room); #ifdef Quotient_E2EE_ENABLED + void startKeyVerificationSession(const QString& deviceId); + void encryptionUpdate(Room *room); #endif @@ -850,6 +863,19 @@ Q_SIGNALS: void turnServersChanged(const QJsonObject& servers); void devicesListLoaded(); +#ifdef Quotient_E2EE_ENABLED + void incomingKeyVerificationReady(const KeyVerificationReadyEvent& event); + void incomingKeyVerificationStart(const KeyVerificationStartEvent& event); + void incomingKeyVerificationAccept(const KeyVerificationAcceptEvent& event); + void incomingKeyVerificationKey(const KeyVerificationKeyEvent& event); + void incomingKeyVerificationMac(const KeyVerificationMacEvent& event); + void incomingKeyVerificationDone(const KeyVerificationDoneEvent& event); + void incomingKeyVerificationCancel(const KeyVerificationCancelEvent& event); + + void newKeyVerificationSession(KeyVerificationSession* session); + void sessionVerified(const QString& userId, const QString& deviceId); +#endif + protected: /** * @brief Access the underlying ConnectionData class diff --git a/lib/database.cpp b/lib/database.cpp index ed7bd794..4eb82cf5 100644 --- a/lib/database.cpp +++ b/lib/database.cpp @@ -31,7 +31,8 @@ Database::Database(const QString& matrixId, const QString& deviceId, QObject* pa case 0: migrateTo1(); [[fallthrough]]; case 1: migrateTo2(); [[fallthrough]]; case 2: migrateTo3(); [[fallthrough]]; - case 3: migrateTo4(); + case 3: migrateTo4(); [[fallthrough]]; + case 4: migrateTo5(); } } @@ -102,7 +103,7 @@ void Database::migrateTo2() { qCDebug(DATABASE) << "Migrating database to version 2"; transaction(); - //TODO remove this column again - we don't need it after all + execute(QStringLiteral("ALTER TABLE inbound_megolm_sessions ADD ed25519Key TEXT")); execute(QStringLiteral("ALTER TABLE olm_sessions ADD lastReceived TEXT")); @@ -141,6 +142,16 @@ void Database::migrateTo4() commit(); } +void Database::migrateTo5() +{ + qCDebug(DATABASE) << "Migrating database to version 5"; + transaction(); + + execute(QStringLiteral("ALTER TABLE tracked_devices ADD verified BOOL;")); + execute(QStringLiteral("PRAGMA user_version = 5")); + commit(); +} + QByteArray Database::accountPickle() { auto query = prepareQuery(QStringLiteral("SELECT pickle FROM accounts;")); @@ -392,3 +403,19 @@ void Database::updateOlmSession(const QString& senderKey, const QString& session commit(); } +void Database::setSessionVerified(const QString& edKeyId) +{ + auto query = prepareQuery(QStringLiteral("UPDATE tracked_devices SET verified=true WHERE edKeyId=:edKeyId;")); + query.bindValue(":edKeyId", edKeyId); + transaction(); + execute(query); + commit(); +} + +bool Database::isSessionVerified(const QString& edKey) +{ + auto query = prepareQuery(QStringLiteral("SELECT verified FROM tracked_devices WHERE edKey=:edKey")); + query.bindValue(":edKey", edKey); + execute(query); + return query.next() && query.value("verified").toBool(); +} diff --git a/lib/database.h b/lib/database.h index 4091d61b..8a133f8e 100644 --- a/lib/database.h +++ b/lib/database.h @@ -12,8 +12,6 @@ #include "e2ee/e2ee.h" namespace Quotient { -class User; -class Room; class QUOTIENT_API Database : public QObject { @@ -59,7 +57,8 @@ public: const QByteArray& pickle); // Returns a map UserId -> [DeviceId] that have not received key yet - QMultiHash<QString, QString> devicesWithoutKey(const QString& roomId, QMultiHash<QString, QString> devices, + QMultiHash<QString, QString> devicesWithoutKey( + const QString& roomId, QMultiHash<QString, QString> devices, const QString& sessionId); // 'devices' contains tuples {userId, deviceId, curveKey} void setDevicesReceivedKey( @@ -67,12 +66,16 @@ public: const QVector<std::tuple<QString, QString, QString>>& devices, const QString& sessionId, int index); + bool isSessionVerified(const QString& edKey); + void setSessionVerified(const QString& edKeyId); + private: void migrateTo1(); void migrateTo2(); void migrateTo3(); void migrateTo4(); + void migrateTo5(); QString m_matrixId; }; -} +} // namespace Quotient diff --git a/lib/events/keyverificationevent.h b/lib/events/keyverificationevent.h index 78457e0c..f635d07b 100644 --- a/lib/events/keyverificationevent.h +++ b/lib/events/keyverificationevent.h @@ -1,17 +1,33 @@ // SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org> // SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once + #include "event.h" namespace Quotient { +static constexpr auto SasV1Method = "m.sas.v1"_ls; + /// Requests a key verification with another user's devices. /// Typically sent as a to-device event. class QUOTIENT_API KeyVerificationRequestEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.request", KeyVerificationRequestEvent) - explicit KeyVerificationRequestEvent(const QJsonObject& obj); + explicit KeyVerificationRequestEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationRequestEvent(const QString& transactionId, + const QString& fromDevice, + const QStringList& methods, + const QDateTime& timestamp) + : KeyVerificationRequestEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId }, + { "from_device"_ls, fromDevice }, + { "methods"_ls, toJson(methods) }, + { "timestamp"_ls, toJson(timestamp) } })) + {} /// The device ID which is initiating the request. QUO_CONTENT_GETTER(QString, fromDevice) @@ -27,16 +43,60 @@ public: /// made. If the request is in the future by more than 5 minutes or /// more than 10 minutes in the past, the message should be ignored /// by the receiver. - QUO_CONTENT_GETTER(uint64_t, timestamp) + QUO_CONTENT_GETTER(QDateTime, timestamp) }; REGISTER_EVENT_TYPE(KeyVerificationRequestEvent) +class QUOTIENT_API KeyVerificationReadyEvent : public Event { +public: + DEFINE_EVENT_TYPEID("m.key.verification.ready", KeyVerificationReadyEvent) + + explicit KeyVerificationReadyEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationReadyEvent(const QString& transactionId, + const QString& fromDevice, + const QStringList& methods) + : KeyVerificationReadyEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId }, + { "from_device"_ls, fromDevice }, + { "methods"_ls, toJson(methods) } })) + {} + + /// The device ID which is accepting the request. + QUO_CONTENT_GETTER(QString, fromDevice) + + /// The transaction id of the verification request + QUO_CONTENT_GETTER(QString, transactionId) + + /// The verification methods supported by the sender. + QUO_CONTENT_GETTER(QStringList, methods) +}; +REGISTER_EVENT_TYPE(KeyVerificationReadyEvent) + + /// Begins a key verification process. class QUOTIENT_API KeyVerificationStartEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.start", KeyVerificationStartEvent) - explicit KeyVerificationStartEvent(const QJsonObject &obj); + explicit KeyVerificationStartEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationStartEvent(const QString& transactionId, + const QString& fromDevice) + : KeyVerificationStartEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId }, + { "from_device"_ls, fromDevice }, + { "method"_ls, SasV1Method }, + { "hashes"_ls, QJsonArray{ "sha256"_ls } }, + { "key_agreement_protocols"_ls, + QJsonArray{ "curve25519-hkdf-sha256"_ls } }, + { "message_authentication_codes"_ls, + QJsonArray{ "hkdf-hmac-sha256"_ls } }, + { "short_authentication_string"_ls, + QJsonArray{ "decimal"_ls, "emoji"_ls } } })) + {} /// The device ID which is initiating the process. QUO_CONTENT_GETTER(QString, fromDevice) @@ -57,7 +117,7 @@ public: /// \note Only exist if method is m.sas.v1 QStringList keyAgreementProtocols() const { - Q_ASSERT(method() == QStringLiteral("m.sas.v1")); + Q_ASSERT(method() == SasV1Method); return contentPart<QStringList>("key_agreement_protocols"_ls); } @@ -65,7 +125,7 @@ public: /// \note Only exist if method is m.sas.v1 QStringList hashes() const { - Q_ASSERT(method() == QStringLiteral("m.sas.v1")); + Q_ASSERT(method() == SasV1Method); return contentPart<QStringList>("hashes"_ls); } @@ -73,7 +133,7 @@ public: /// \note Only exist if method is m.sas.v1 QStringList messageAuthenticationCodes() const { - Q_ASSERT(method() == QStringLiteral("m.sas.v1")); + Q_ASSERT(method() == SasV1Method); return contentPart<QStringList>("message_authentication_codes"_ls); } @@ -82,7 +142,7 @@ public: /// \note Only exist if method is m.sas.v1 QString shortAuthenticationString() const { - Q_ASSERT(method() == QStringLiteral("m.sas.v1")); + Q_ASSERT(method() == SasV1Method); return contentPart<QString>("short_authentification_string"_ls); } }; @@ -94,7 +154,21 @@ class QUOTIENT_API KeyVerificationAcceptEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.accept", KeyVerificationAcceptEvent) - explicit KeyVerificationAcceptEvent(const QJsonObject& obj); + explicit KeyVerificationAcceptEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationAcceptEvent(const QString& transactionId, + const QString& commitment) + : KeyVerificationAcceptEvent(basicJson( + TypeId, { { "transaction_id"_ls, transactionId }, + { "method"_ls, SasV1Method }, + { "key_agreement_protocol"_ls, "curve25519-hkdf-sha256" }, + { "hash"_ls, "sha256" }, + { "message_authentication_code"_ls, "hkdf-hmac-sha256" }, + { "short_authentication_string"_ls, + QJsonArray{ "decimal"_ls, "emoji"_ls, } }, + { "commitment"_ls, commitment } })) + {} /// An opaque identifier for the verification process. QUO_CONTENT_GETTER(QString, transactionId) @@ -131,7 +205,18 @@ class QUOTIENT_API KeyVerificationCancelEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.cancel", KeyVerificationCancelEvent) - explicit KeyVerificationCancelEvent(const QJsonObject &obj); + explicit KeyVerificationCancelEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationCancelEvent(const QString& transactionId, + const QString& reason) + : KeyVerificationCancelEvent( + basicJson(TypeId, { + { "transaction_id"_ls, transactionId }, + { "reason"_ls, reason }, + { "code"_ls, reason } // Not a typo + })) + {} /// An opaque identifier for the verification process. QUO_CONTENT_GETTER(QString, transactionId) @@ -147,11 +232,18 @@ REGISTER_EVENT_TYPE(KeyVerificationCancelEvent) /// Sends the ephemeral public key for a device to the partner device. /// Typically sent as a to-device event. -class KeyVerificationKeyEvent : public Event { +class QUOTIENT_API KeyVerificationKeyEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.key", KeyVerificationKeyEvent) - explicit KeyVerificationKeyEvent(const QJsonObject &obj); + explicit KeyVerificationKeyEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationKeyEvent(const QString& transactionId, const QString& key) + : KeyVerificationKeyEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId }, + { "key"_ls, key } })) + {} /// An opaque identifier for the verification process. QUO_CONTENT_GETTER(QString, transactionId) @@ -166,7 +258,16 @@ class QUOTIENT_API KeyVerificationMacEvent : public Event { public: DEFINE_EVENT_TYPEID("m.key.verification.mac", KeyVerificationMacEvent) - explicit KeyVerificationMacEvent(const QJsonObject &obj); + explicit KeyVerificationMacEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + KeyVerificationMacEvent(const QString& transactionId, const QString& keys, + const QJsonObject& mac) + : KeyVerificationMacEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId }, + { "keys"_ls, keys }, + { "mac"_ls, mac } })) + {} /// An opaque identifier for the verification process. QUO_CONTENT_GETTER(QString, transactionId) @@ -180,4 +281,22 @@ public: } }; REGISTER_EVENT_TYPE(KeyVerificationMacEvent) + +class QUOTIENT_API KeyVerificationDoneEvent : public Event { +public: + DEFINE_EVENT_TYPEID("m.key.verification.done", KeyVerificationRequestEvent) + + explicit KeyVerificationDoneEvent(const QJsonObject& obj) + : Event(TypeId, obj) + {} + explicit KeyVerificationDoneEvent(const QString& transactionId) + : KeyVerificationDoneEvent( + basicJson(TypeId, { { "transaction_id"_ls, transactionId } })) + {} + + /// The same transactionId as before + QUO_CONTENT_GETTER(QString, transactionId) +}; +REGISTER_EVENT_TYPE(KeyVerificationDoneEvent) + } // namespace Quotient diff --git a/lib/events/roomevent.h b/lib/events/roomevent.h index 7f724689..9461340b 100644 --- a/lib/events/roomevent.h +++ b/lib/events/roomevent.h @@ -62,7 +62,7 @@ public: #ifdef Quotient_E2EE_ENABLED void setOriginalEvent(event_ptr_tt<RoomEvent>&& originalEvent); - const RoomEvent* originalEvent() { return _originalEvent.get(); } + const RoomEvent* originalEvent() const { return _originalEvent.get(); } const QJsonObject encryptedJson() const; #endif diff --git a/lib/events/roomkeyevent.cpp b/lib/events/roomkeyevent.cpp index 68962950..3a8601d1 100644 --- a/lib/events/roomkeyevent.cpp +++ b/lib/events/roomkeyevent.cpp @@ -5,21 +5,18 @@ using namespace Quotient; -RoomKeyEvent::RoomKeyEvent(const QJsonObject &obj) : Event(typeId(), obj) +RoomKeyEvent::RoomKeyEvent(const QJsonObject &obj) : Event(TypeId, obj) { if (roomId().isEmpty()) qCWarning(E2EE) << "Room key event has empty room id"; } -RoomKeyEvent::RoomKeyEvent(const QString& algorithm, const QString& roomId, const QString& sessionId, const QString& sessionKey, const QString& senderId) - : Event(typeId(), { - {"content", QJsonObject{ - {"algorithm", algorithm}, - {"room_id", roomId}, - {"session_id", sessionId}, - {"session_key", sessionKey}, - }}, - {"sender", senderId}, - {"type", "m.room_key"}, - }) +RoomKeyEvent::RoomKeyEvent(const QString& algorithm, const QString& roomId, + const QString& sessionId, const QString& sessionKey) + : Event(TypeId, basicJson(TypeId, { + { "algorithm", algorithm }, + { "room_id", roomId }, + { "session_id", sessionId }, + { "session_key", sessionKey }, + })) {} diff --git a/lib/events/roomkeyevent.h b/lib/events/roomkeyevent.h index 9eb2854b..0dfdf383 100644 --- a/lib/events/roomkeyevent.h +++ b/lib/events/roomkeyevent.h @@ -13,8 +13,7 @@ public: explicit RoomKeyEvent(const QJsonObject& obj); explicit RoomKeyEvent(const QString& algorithm, const QString& roomId, - const QString& sessionId, const QString& sessionKey, - const QString& senderId); + const QString& sessionId, const QString& sessionKey); QUO_CONTENT_GETTER(QString, algorithm) QUO_CONTENT_GETTER(QString, roomId) diff --git a/lib/keyverificationsession.cpp b/lib/keyverificationsession.cpp new file mode 100644 index 00000000..2c468c3e --- /dev/null +++ b/lib/keyverificationsession.cpp @@ -0,0 +1,512 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de> +// SPDX-License-Identifier: LGPL-2.1-or-later + +#include "keyverificationsession.h" + +#include "connection.h" +#include "database.h" +#include "e2ee/qolmaccount.h" +#include "e2ee/qolmutils.h" +#include "olm/sas.h" + +#include "events/event.h" + +#include <QtCore/QCryptographicHash> +#include <QtCore/QTimer> +#include <QtCore/QUuid> + +#include <chrono> + +using namespace Quotient; +using namespace std::chrono; + +const QStringList supportedMethods = { SasV1Method }; + +QStringList commonSupportedMethods(const QStringList& remoteMethods) +{ + QStringList result; + for (const auto& method : remoteMethods) { + if (supportedMethods.contains(method)) { + result += method; + } + } + return result; +} + +KeyVerificationSession::KeyVerificationSession( + QString remoteUserId, const KeyVerificationRequestEvent& event, + Connection* connection, bool encrypted) + : QObject(connection) + , m_remoteUserId(std::move(remoteUserId)) + , m_remoteDeviceId(event.fromDevice()) + , m_transactionId(event.transactionId()) + , m_connection(connection) + , m_encrypted(encrypted) + , m_remoteSupportedMethods(event.methods()) +{ + const auto& currentTime = QDateTime::currentDateTime(); + const auto timeoutTime = + std::min(event.timestamp().addSecs(600), currentTime.addSecs(120)); + const milliseconds timeout{ currentTime.msecsTo(timeoutTime) }; + if (timeout > 5s) + init(timeout); + // Otherwise don't even bother starting up +} + +KeyVerificationSession::KeyVerificationSession(QString userId, QString deviceId, + Connection* connection) + : QObject(connection) + , m_remoteUserId(std::move(userId)) + , m_remoteDeviceId(std::move(deviceId)) + , m_transactionId(QUuid::createUuid().toString()) + , m_connection(connection) + , m_encrypted(false) +{ + init(600s); + QMetaObject::invokeMethod(this, &KeyVerificationSession::sendRequest); +} + +void KeyVerificationSession::init(milliseconds timeout) +{ + connect(m_connection, &Connection::incomingKeyVerificationReady, this, [this](const KeyVerificationReadyEvent& event) { + if (event.transactionId() == m_transactionId && event.fromDevice() == m_remoteDeviceId) { + handleReady(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationStart, this, [this](const KeyVerificationStartEvent& event) { + if (event.transactionId() == m_transactionId && event.fromDevice() == m_remoteDeviceId) { + handleStart(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationAccept, this, [this](const KeyVerificationAcceptEvent& event) { + if (event.transactionId() == m_transactionId) { + handleAccept(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationKey, this, [this](const KeyVerificationKeyEvent& event) { + if (event.transactionId() == m_transactionId) { + handleKey(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationMac, this, [this](const KeyVerificationMacEvent& event) { + if (event.transactionId() == m_transactionId) { + handleMac(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationDone, this, [this](const KeyVerificationDoneEvent& event) { + if (event.transactionId() == m_transactionId) { + handleDone(event); + } + }); + connect(m_connection, &Connection::incomingKeyVerificationCancel, this, [this](const KeyVerificationCancelEvent& event) { + if (event.transactionId() == m_transactionId) { + handleCancel(event); + } + }); + + QTimer::singleShot(timeout, this, [this] { cancelVerification(TIMEOUT); }); + + m_sas = olm_sas(new std::byte[olm_sas_size()]); + auto randomSize = olm_create_sas_random_length(m_sas); + auto random = getRandom(randomSize); + olm_create_sas(m_sas, random.data(), randomSize); +} + +KeyVerificationSession::~KeyVerificationSession() +{ + olm_clear_sas(m_sas); + delete[] reinterpret_cast<std::byte*>(m_sas); +} + +struct EmojiStoreEntry : EmojiEntry { + QHash<QString, QString> translatedDescriptions; + + explicit EmojiStoreEntry(const QJsonObject& json) + : EmojiEntry{ fromJson<QString>(json["emoji"]), + fromJson<QString>(json["description"]) } + , translatedDescriptions{ fromJson<QHash<QString, QString>>( + json["translated_descriptions"]) } + {} +}; + +using EmojiStore = QVector<EmojiStoreEntry>; + +EmojiStore loadEmojiStore() +{ + QFile dataFile(":/sas-emoji.json"); + dataFile.open(QFile::ReadOnly); + return fromJson<EmojiStore>( + QJsonDocument::fromJson(dataFile.readAll()).array()); +} + +EmojiEntry emojiForCode(int code, const QString& language) +{ + static const EmojiStore emojiStore = loadEmojiStore(); + const auto& entry = emojiStore[code]; + if (!language.isEmpty()) + if (const auto translatedDescription = + emojiStore[code].translatedDescriptions.value(language); + !translatedDescription.isNull()) + return { entry.emoji, translatedDescription }; + + return SLICE(entry, EmojiEntry); +} + +void KeyVerificationSession::handleKey(const KeyVerificationKeyEvent& event) +{ + if (state() != WAITINGFORKEY && state() != WAITINGFORVERIFICATION) { + cancelVerification(UNEXPECTED_MESSAGE); + return; + } + auto eventKey = event.key().toLatin1(); + olm_sas_set_their_key(m_sas, eventKey.data(), eventKey.size()); + + if (startSentByUs) { + const auto paddedCommitment = + QCryptographicHash::hash((eventKey % m_startEvent).toLatin1(), + QCryptographicHash::Sha256) + .toBase64(); + const QLatin1String unpaddedCommitment(paddedCommitment.constData(), + paddedCommitment.indexOf('=')); + if (unpaddedCommitment != m_commitment) { + qCWarning(E2EE) << "Commitment mismatch; aborting verification"; + cancelVerification(MISMATCHED_COMMITMENT); + return; + } + } else { + sendKey(); + } + setState(WAITINGFORVERIFICATION); + + std::string key(olm_sas_pubkey_length(m_sas), '\0'); + olm_sas_get_pubkey(m_sas, key.data(), key.size()); + + std::array<std::byte, 6> output{}; + const auto infoTemplate = + startSentByUs ? "MATRIX_KEY_VERIFICATION_SAS|%1|%2|%3|%4|%5|%6|%7"_ls + : "MATRIX_KEY_VERIFICATION_SAS|%4|%5|%6|%1|%2|%3|%7"_ls; + + const auto info = infoTemplate + .arg(m_connection->userId(), m_connection->deviceId(), + key.data(), m_remoteUserId, m_remoteDeviceId, + eventKey, m_transactionId) + .toLatin1(); + olm_sas_generate_bytes(m_sas, info.data(), info.size(), output.data(), + output.size()); + + static constexpr auto x3f = std::byte{ 0x3f }; + const std::array<std::byte, 7> code{ + output[0] >> 2, + (output[0] << 4 & x3f) | output[1] >> 4, + (output[1] << 2 & x3f) | output[2] >> 6, + output[2] & x3f, + output[3] >> 2, + (output[3] << 4 & x3f) | output[4] >> 4, + (output[4] << 2 & x3f) | output[5] >> 6 + }; + + const auto uiLanguages = QLocale().uiLanguages(); + const auto preferredLanguage = uiLanguages.isEmpty() + ? QString() + : uiLanguages.front().section('-', 0, 0); + for (const auto& c : code) + m_sasEmojis += emojiForCode(std::to_integer<int>(c), preferredLanguage); + + emit sasEmojisChanged(); + emit keyReceived(); +} + +QString KeyVerificationSession::calculateMac(const QString& input, + bool verifying, + const QString& keyId) +{ + QByteArray inputBytes = input.toLatin1(); + QByteArray outputBytes(olm_sas_mac_length(m_sas), '\0'); + const auto macInfo = + (verifying ? "MATRIX_KEY_VERIFICATION_MAC%3%4%1%2%5%6"_ls + : "MATRIX_KEY_VERIFICATION_MAC%1%2%3%4%5%6"_ls) + .arg(m_connection->userId(), m_connection->deviceId(), + m_remoteUserId, m_remoteDeviceId, m_transactionId, keyId) + .toLatin1(); + olm_sas_calculate_mac(m_sas, inputBytes.data(), inputBytes.size(), + macInfo.data(), macInfo.size(), outputBytes.data(), + outputBytes.size()); + return QString::fromLatin1(outputBytes.data(), outputBytes.indexOf('=')); +} + +void KeyVerificationSession::sendMac() +{ + QString edKeyId = "ed25519:" % m_connection->deviceId(); + + auto keys = calculateMac(edKeyId, false); + + QJsonObject mac; + auto key = m_connection->olmAccount()->deviceKeys().keys[edKeyId]; + mac[edKeyId] = calculateMac(key, false, edKeyId); + + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + KeyVerificationMacEvent(m_transactionId, keys, + mac), + m_encrypted); + setState (macReceived ? DONE : WAITINGFORMAC); +} + +void KeyVerificationSession::sendDone() +{ + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + KeyVerificationDoneEvent(m_transactionId), + m_encrypted); +} + +void KeyVerificationSession::sendKey() +{ + QByteArray keyBytes(olm_sas_pubkey_length(m_sas), '\0'); + olm_sas_get_pubkey(m_sas, keyBytes.data(), keyBytes.size()); + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + KeyVerificationKeyEvent(m_transactionId, + keyBytes), + m_encrypted); +} + + +void KeyVerificationSession::cancelVerification(Error error) +{ + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + KeyVerificationCancelEvent(m_transactionId, + errorToString(error)), + m_encrypted); + setState(CANCELED); + setError(error); + emit finished(); + deleteLater(); +} + +void KeyVerificationSession::sendReady() +{ + auto methods = commonSupportedMethods(m_remoteSupportedMethods); + + if (methods.isEmpty()) { + cancelVerification(UNKNOWN_METHOD); + return; + } + + m_connection->sendToDevice( + m_remoteUserId, m_remoteDeviceId, + KeyVerificationReadyEvent(m_transactionId, m_connection->deviceId(), + methods), + m_encrypted); + setState(READY); + + if (methods.size() == 1) { + sendStartSas(); + } +} + +void KeyVerificationSession::sendStartSas() +{ + startSentByUs = true; + KeyVerificationStartEvent event(m_transactionId, m_connection->deviceId()); + m_startEvent = QJsonDocument(event.contentJson()).toJson(QJsonDocument::Compact); + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + std::move(event), m_encrypted); + setState(WAITINGFORACCEPT); +} + +void KeyVerificationSession::handleReady(const KeyVerificationReadyEvent& event) +{ + if (state() != WAITINGFORREADY) { + cancelVerification(UNEXPECTED_MESSAGE); + return; + } + setState(READY); + m_remoteSupportedMethods = event.methods(); + auto methods = commonSupportedMethods(m_remoteSupportedMethods); + + if (methods.isEmpty()) { + cancelVerification(UNKNOWN_METHOD); + return; + } + + if (methods.size() == 1) { + sendStartSas(); + } +} + +void KeyVerificationSession::handleStart(const KeyVerificationStartEvent& event) +{ + if (state() != READY) { + cancelVerification(UNEXPECTED_MESSAGE); + return; + } + if (startSentByUs) { + if (m_remoteUserId > m_connection->userId() || (m_remoteUserId == m_connection->userId() && m_remoteDeviceId > m_connection->deviceId())) { + return; + } else { + startSentByUs = false; + } + } + QByteArray publicKey(olm_sas_pubkey_length(m_sas), '\0'); + olm_sas_get_pubkey(m_sas, publicKey.data(), publicKey.size()); + const auto canonicalEvent = QString(QJsonDocument(event.contentJson()).toJson(QJsonDocument::Compact)); + auto commitment = QString(QCryptographicHash::hash((QString(publicKey) % canonicalEvent).toLatin1(), QCryptographicHash::Sha256).toBase64()); + commitment = commitment.left(commitment.indexOf('=')); + + m_connection->sendToDevice(m_remoteUserId, m_remoteDeviceId, + KeyVerificationAcceptEvent(m_transactionId, + commitment), + m_encrypted); + setState(ACCEPTED); +} + +void KeyVerificationSession::handleAccept(const KeyVerificationAcceptEvent& event) +{ + if(state() != WAITINGFORACCEPT) { + cancelVerification(UNEXPECTED_MESSAGE); + return; + } + m_commitment = event.commitment(); + sendKey(); + setState(WAITINGFORKEY); +} + +void KeyVerificationSession::handleMac(const KeyVerificationMacEvent& event) +{ + QStringList keys = event.mac().keys(); + keys.sort(); + const auto& key = keys.join(","); + const QString edKeyId = "ed25519:"_ls % m_remoteDeviceId; + + if (calculateMac(m_connection->edKeyForUserDevice(m_remoteUserId, m_remoteDeviceId), true, edKeyId) != event.mac()[edKeyId]) { + cancelVerification(KEY_MISMATCH); + return; + } + + if (calculateMac(key, true) != event.keys()) { + cancelVerification(KEY_MISMATCH); + return; + } + + m_connection->database()->setSessionVerified(edKeyId); + emit m_connection->sessionVerified(m_remoteUserId, m_remoteDeviceId); + macReceived = true; + + if (state() == WAITINGFORMAC) { + setState(DONE); + sendDone(); + emit finished(); + deleteLater(); + } +} + +void KeyVerificationSession::handleDone(const KeyVerificationDoneEvent&) +{ + if (state() != DONE) { + cancelVerification(UNEXPECTED_MESSAGE); + } +} + +void KeyVerificationSession::handleCancel(const KeyVerificationCancelEvent& event) +{ + setError(stringToError(event.code())); + setState(CANCELED); +} + +QVector<EmojiEntry> KeyVerificationSession::sasEmojis() const +{ + return m_sasEmojis; +} + +void KeyVerificationSession::sendRequest() +{ + m_connection->sendToDevice( + m_remoteUserId, m_remoteDeviceId, + KeyVerificationRequestEvent(m_transactionId, m_connection->deviceId(), + supportedMethods, + QDateTime::currentDateTime()), + m_encrypted); + setState(WAITINGFORREADY); +} + +KeyVerificationSession::State KeyVerificationSession::state() const +{ + return m_state; +} + +void KeyVerificationSession::setState(KeyVerificationSession::State state) +{ + m_state = state; + emit stateChanged(); +} + +KeyVerificationSession::Error KeyVerificationSession::error() const +{ + return m_error; +} + +void KeyVerificationSession::setError(Error error) +{ + m_error = error; + emit errorChanged(); +} + +QString KeyVerificationSession::errorToString(Error error) +{ + switch(error) { + case NONE: + return "none"_ls; + case TIMEOUT: + return "m.timeout"_ls; + case USER: + return "m.user"_ls; + case UNEXPECTED_MESSAGE: + return "m.unexpected_message"_ls; + case UNKNOWN_TRANSACTION: + return "m.unknown_transaction"_ls; + case UNKNOWN_METHOD: + return "m.unknown_method"_ls; + case KEY_MISMATCH: + return "m.key_mismatch"_ls; + case USER_MISMATCH: + return "m.user_mismatch"_ls; + case INVALID_MESSAGE: + return "m.invalid_message"_ls; + case SESSION_ACCEPTED: + return "m.accepted"_ls; + case MISMATCHED_COMMITMENT: + return "m.mismatched_commitment"_ls; + case MISMATCHED_SAS: + return "m.mismatched_sas"_ls; + default: + return "m.user"_ls; + } +} + +KeyVerificationSession::Error KeyVerificationSession::stringToError(const QString& error) +{ + if (error == "m.timeout"_ls) { + return REMOTE_TIMEOUT; + } else if (error == "m.user"_ls) { + return REMOTE_USER; + } else if (error == "m.unexpected_message"_ls) { + return REMOTE_UNEXPECTED_MESSAGE; + } else if (error == "m.unknown_message"_ls) { + return REMOTE_UNEXPECTED_MESSAGE; + } else if (error == "m.unknown_transaction"_ls) { + return REMOTE_UNKNOWN_TRANSACTION; + } else if (error == "m.unknown_method"_ls) { + return REMOTE_UNKNOWN_METHOD; + } else if (error == "m.key_mismatch"_ls) { + return REMOTE_KEY_MISMATCH; + } else if (error == "m.user_mismatch"_ls) { + return REMOTE_USER_MISMATCH; + } else if (error == "m.invalid_message"_ls) { + return REMOTE_INVALID_MESSAGE; + } else if (error == "m.accepted"_ls) { + return REMOTE_SESSION_ACCEPTED; + } else if (error == "m.mismatched_commitment"_ls) { + return REMOTE_MISMATCHED_COMMITMENT; + } else if (error == "m.mismatched_sas"_ls) { + return REMOTE_MISMATCHED_SAS; + } + return NONE; +} diff --git a/lib/keyverificationsession.h b/lib/keyverificationsession.h new file mode 100644 index 00000000..aa0295cb --- /dev/null +++ b/lib/keyverificationsession.h @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2022 Tobias Fella <fella@posteo.de> +// SPDX-License-Identifier: LGPL-2.1-or-later + +#pragma once + +#include "events/keyverificationevent.h" + +#include <QtCore/QObject> + +struct OlmSAS; + +namespace Quotient { +class Connection; + +struct QUOTIENT_API EmojiEntry { + QString emoji; + QString description; + + Q_GADGET + Q_PROPERTY(QString emoji MEMBER emoji CONSTANT) + Q_PROPERTY(QString description MEMBER description CONSTANT) +}; + +/** A key verification session. Listen for incoming sessions by connecting to Connection::newKeyVerificationSession. + Start a new session using Connection::startKeyVerificationSession. + The object is delete after finished is emitted. +*/ +class QUOTIENT_API KeyVerificationSession : public QObject +{ + Q_OBJECT + +public: + enum State { + INCOMING, ///< There is a request for verification incoming + //! We sent a request for verification and are waiting for ready + WAITINGFORREADY, + //! Either party sent a ready as a response to a request; the user + //! selects a method + READY, + WAITINGFORACCEPT, ///< We sent a start and are waiting for an accept + ACCEPTED, ///< The other party sent an accept and is waiting for a key + WAITINGFORKEY, ///< We're waiting for a key + //! We're waiting for the *user* to verify the emojis + WAITINGFORVERIFICATION, + WAITINGFORMAC, ///< We're waiting for the mac + CANCELED, ///< The session has been canceled + DONE, ///< The verification is done + }; + Q_ENUM(State) + + enum Error { + NONE, + TIMEOUT, + REMOTE_TIMEOUT, + USER, + REMOTE_USER, + UNEXPECTED_MESSAGE, + REMOTE_UNEXPECTED_MESSAGE, + UNKNOWN_TRANSACTION, + REMOTE_UNKNOWN_TRANSACTION, + UNKNOWN_METHOD, + REMOTE_UNKNOWN_METHOD, + KEY_MISMATCH, + REMOTE_KEY_MISMATCH, + USER_MISMATCH, + REMOTE_USER_MISMATCH, + INVALID_MESSAGE, + REMOTE_INVALID_MESSAGE, + SESSION_ACCEPTED, + REMOTE_SESSION_ACCEPTED, + MISMATCHED_COMMITMENT, + REMOTE_MISMATCHED_COMMITMENT, + MISMATCHED_SAS, + REMOTE_MISMATCHED_SAS, + }; + Q_ENUM(Error) + + Q_PROPERTY(QString remoteDeviceId MEMBER m_remoteDeviceId CONSTANT) + Q_PROPERTY(QVector<EmojiEntry> sasEmojis READ sasEmojis NOTIFY sasEmojisChanged) + Q_PROPERTY(State state READ state NOTIFY stateChanged) + Q_PROPERTY(Error error READ error NOTIFY errorChanged) + + KeyVerificationSession(QString remoteUserId, + const KeyVerificationRequestEvent& event, + Connection* connection, bool encrypted); + KeyVerificationSession(QString userId, QString deviceId, + Connection* connection); + ~KeyVerificationSession() override; + Q_DISABLE_COPY_MOVE(KeyVerificationSession) + + QVector<EmojiEntry> sasEmojis() const; + State state() const; + + Error error() const; + +public Q_SLOTS: + void sendRequest(); + void sendReady(); + void sendMac(); + void sendStartSas(); + void sendKey(); + void sendDone(); + void cancelVerification(Error error); + +Q_SIGNALS: + void startReceived(); + void keyReceived(); + void sasEmojisChanged(); + void stateChanged(); + void errorChanged(); + void finished(); + +private: + const QString m_remoteUserId; + const QString m_remoteDeviceId; + const QString m_transactionId; + Connection* m_connection; + OlmSAS* m_sas = nullptr; + QVector<EmojiEntry> m_sasEmojis; + bool startSentByUs = false; + State m_state = INCOMING; + Error m_error = NONE; + QString m_startEvent; + QString m_commitment; + bool macReceived = false; + bool m_encrypted; + QStringList m_remoteSupportedMethods; + + void handleReady(const KeyVerificationReadyEvent& event); + void handleStart(const KeyVerificationStartEvent& event); + void handleAccept(const KeyVerificationAcceptEvent& event); + void handleKey(const KeyVerificationKeyEvent& event); + void handleMac(const KeyVerificationMacEvent& event); + void handleDone(const KeyVerificationDoneEvent&); + void handleCancel(const KeyVerificationCancelEvent& event); + void init(std::chrono::milliseconds timeout); + void setState(State state); + void setError(Error error); + static QString errorToString(Error error); + static Error stringToError(const QString& error); + + QByteArray macInfo(bool verifying, const QString& key = "KEY_IDS"_ls); + QString calculateMac(const QString& input, bool verifying, const QString& keyId= "KEY_IDS"_ls); +}; + +} // namespace Quotient +Q_DECLARE_METATYPE(Quotient::EmojiEntry) diff --git a/lib/room.cpp b/lib/room.cpp index 007446dc..ebc6e6af 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -70,7 +70,6 @@ #include "e2ee/qolmaccount.h" #include "e2ee/qolmerrors.h" #include "e2ee/qolminboundsession.h" -#include "e2ee/qolmoutboundsession.h" #include "e2ee/qolmutility.h" #include "database.h" #endif // Quotient_E2EE_ENABLED diff --git a/res.qrc b/res.qrc new file mode 100644 index 00000000..f6769103 --- /dev/null +++ b/res.qrc @@ -0,0 +1,5 @@ +<!DOCTYPE RCC><RCC version="1.0"> +<qresource prefix="/"> + <file>sas-emoji.json</file> +</qresource> +</RCC> diff --git a/sas-emoji.json b/sas-emoji.json new file mode 100644 index 00000000..06e1e4b3 --- /dev/null +++ b/sas-emoji.json @@ -0,0 +1,2178 @@ +[ + { + "number": 0, + "emoji": "🐶", + "description": "Dog", + "unicode": "U+1F436", + "translated_descriptions": { + "ar": "كَلب", + "bg": "Куче", + "ca": "Gos", + "cs": "Pes", + "de": "Hund", + "eo": "Hundo", + "es": "Perro", + "et": "Koer", + "fi": "Koira", + "fr": "Chien", + "hr": "pas", + "hu": "Kutya", + "it": "Cane", + "ja": "犬", + "nb_NO": "Hund", + "nl": "Hond", + "pt_BR": "Cachorro", + "ru": "Собака", + "si": "බල්ලා", + "sk": "Hlava psa", + "sr": "пас", + "sv": "Hund", + "szl": null, + "tzm": "Aydi", + "uk": "Пес", + "zh_Hans": "狗" + } + }, + { + "number": 1, + "emoji": "🐱", + "description": "Cat", + "unicode": "U+1F431", + "translated_descriptions": { + "ar": "هِرَّة", + "bg": "Котка", + "ca": "Gat", + "cs": "Kočka", + "de": "Katze", + "eo": "Kato", + "es": "Gato", + "et": "Kass", + "fi": "Kissa", + "fr": "Chat", + "hr": "mačka", + "hu": "Macska", + "it": "Gatto", + "ja": "猫", + "nb_NO": "Katt", + "nl": "Kat", + "pt_BR": "Gato", + "ru": "Кошка", + "si": "පූසා", + "sk": "Hlava mačky", + "sr": "мачка", + "sv": "Katt", + "szl": null, + "tzm": "Amuc", + "uk": "Кіт", + "zh_Hans": "猫" + } + }, + { + "number": 2, + "emoji": "🦁", + "description": "Lion", + "unicode": "U+1F981", + "translated_descriptions": { + "ar": "أَسَد", + "bg": "Лъв", + "ca": "Lleó", + "cs": "Lev", + "de": "Löwe", + "eo": "Leono", + "es": "León", + "et": "Lõvi", + "fi": "Leijona", + "fr": "Lion", + "hr": "lav", + "hu": "Oroszlán", + "it": "Leone", + "ja": "ライオン", + "nb_NO": "Løve", + "nl": "Leeuw", + "pt_BR": "Leão", + "ru": "Лев", + "si": "සිංහයා", + "sk": "Hlava leva", + "sr": "лав", + "sv": "Lejon", + "szl": null, + "tzm": "Izem", + "uk": "Лев", + "zh_Hans": "狮子" + } + }, + { + "number": 3, + "emoji": "🐎", + "description": "Horse", + "unicode": "U+1F40E", + "translated_descriptions": { + "ar": "حِصَان", + "bg": "Кон", + "ca": "Cavall", + "cs": "Kůň", + "de": "Pferd", + "eo": "Ĉevalo", + "es": "Caballo", + "et": "Hobune", + "fi": "Hevonen", + "fr": "Cheval", + "hr": "konj", + "hu": "Ló", + "it": "Cavallo", + "ja": "馬", + "nb_NO": "Hest", + "nl": "Paard", + "pt_BR": "Cavalo", + "ru": "Лошадь", + "si": "අශ්වයා", + "sk": "Kôň", + "sr": "коњ", + "sv": "Häst", + "szl": null, + "tzm": "Ayyis", + "uk": "Кінь", + "zh_Hans": "马" + } + }, + { + "number": 4, + "emoji": "🦄", + "description": "Unicorn", + "unicode": "U+1F984", + "translated_descriptions": { + "ar": "حِصَانٌ بِقَرن", + "bg": "Еднорог", + "ca": "Unicorn", + "cs": "Jednorožec", + "de": "Einhorn", + "eo": "Unukorno", + "es": "Unicornio", + "et": "Ükssarvik", + "fi": "Yksisarvinen", + "fr": "Licorne", + "hr": "jednorog", + "hu": "Egyszarvú", + "it": "Unicorno", + "ja": "ユニコーン", + "nb_NO": "Enhjørning", + "nl": "Eenhoorn", + "pt_BR": "Unicórnio", + "ru": "Единорог", + "si": null, + "sk": "Hlava jednorožca", + "sr": "једнорог", + "sv": "Enhörning", + "szl": null, + "tzm": null, + "uk": "Єдиноріг", + "zh_Hans": "独角兽" + } + }, + { + "number": 5, + "emoji": "🐷", + "description": "Pig", + "unicode": "U+1F437", + "translated_descriptions": { + "ar": "خِنزِير", + "bg": "Прасе", + "ca": "Porc", + "cs": "Prase", + "de": "Schwein", + "eo": "Porko", + "es": "Cerdo", + "et": "Siga", + "fi": "Sika", + "fr": "Cochon", + "hr": "svinja", + "hu": "Malac", + "it": "Maiale", + "ja": "ブタ", + "nb_NO": "Gris", + "nl": "Varken", + "pt_BR": "Porco", + "ru": "Свинья", + "si": null, + "sk": "Hlava prasaťa", + "sr": "прасе", + "sv": "Gris", + "szl": null, + "tzm": "Ilef", + "uk": "Свиня", + "zh_Hans": "猪" + } + }, + { + "number": 6, + "emoji": "🐘", + "description": "Elephant", + "unicode": "U+1F418", + "translated_descriptions": { + "ar": "فِيل", + "bg": "Слон", + "ca": "Elefant", + "cs": "Slon", + "de": "Elefant", + "eo": "Elefanto", + "es": "Elefante", + "et": "Elevant", + "fi": "Norsu", + "fr": "Éléphant", + "hr": "slon", + "hu": "Elefánt", + "it": "Elefante", + "ja": "ゾウ", + "nb_NO": "Elefant", + "nl": "Olifant", + "pt_BR": "Elefante", + "ru": "Слон", + "si": null, + "sk": "Slon", + "sr": "слон", + "sv": "Elefant", + "szl": null, + "tzm": "Ilu", + "uk": "Слон", + "zh_Hans": "大象" + } + }, + { + "number": 7, + "emoji": "🐰", + "description": "Rabbit", + "unicode": "U+1F430", + "translated_descriptions": { + "ar": "أَرنَب", + "bg": "Заек", + "ca": "Conill", + "cs": "Králík", + "de": "Hase", + "eo": "Kuniklo", + "es": "Conejo", + "et": "Jänes", + "fi": "Kani", + "fr": "Lapin", + "hr": "zec", + "hu": "Nyúl", + "it": "Coniglio", + "ja": "うさぎ", + "nb_NO": "Kanin", + "nl": "Konijn", + "pt_BR": "Coelho", + "ru": "Кролик", + "si": null, + "sk": "Hlava zajaca", + "sr": "зец", + "sv": "Kanin", + "szl": null, + "tzm": "Agnin", + "uk": "Кріль", + "zh_Hans": "兔子" + } + }, + { + "number": 8, + "emoji": "🐼", + "description": "Panda", + "unicode": "U+1F43C", + "translated_descriptions": { + "ar": "باندَا", + "bg": "Панда", + "ca": "Panda", + "cs": "Panda", + "de": "Panda", + "eo": "Pando", + "es": "Panda", + "et": "Panda", + "fi": "Panda", + "fr": "Panda", + "hr": "panda", + "hu": "Panda", + "it": "Panda", + "ja": "パンダ", + "nb_NO": "Panda", + "nl": "Panda", + "pt_BR": "Panda", + "ru": "Панда", + "si": null, + "sk": "Hlava pandy", + "sr": "панда", + "sv": "Panda", + "szl": null, + "tzm": null, + "uk": "Панда", + "zh_Hans": "熊猫" + } + }, + { + "number": 9, + "emoji": "🐓", + "description": "Rooster", + "unicode": "U+1F413", + "translated_descriptions": { + "ar": "دِيك", + "bg": "Петел", + "ca": "Gall", + "cs": "Kohout", + "de": "Hahn", + "eo": "Virkoko", + "es": "Gallo", + "et": "Kukk", + "fi": "Kukko", + "fr": "Coq", + "hr": "kokot", + "hu": "Kakas", + "it": "Gallo", + "ja": "ニワトリ", + "nb_NO": "Hane", + "nl": "Haan", + "pt_BR": "Galo", + "ru": "Петух", + "si": null, + "sk": "Kohút", + "sr": "петао", + "sv": "Tupp", + "szl": null, + "tzm": "Ayaẓiḍ", + "uk": "Когут", + "zh_Hans": "公鸡" + } + }, + { + "number": 10, + "emoji": "🐧", + "description": "Penguin", + "unicode": "U+1F427", + "translated_descriptions": { + "ar": "بِطريق", + "bg": "Пингвин", + "ca": "Pingüí", + "cs": "Tučňák", + "de": "Pinguin", + "eo": "Pingveno", + "es": "Pingüino", + "et": "Pingviin", + "fi": "Pingviini", + "fr": "Manchot", + "hr": "pingvin", + "hu": "Pingvin", + "it": "Pinguino", + "ja": "ペンギン", + "nb_NO": "Pingvin", + "nl": "Pinguïn", + "pt_BR": "Pinguim", + "ru": "Пингвин", + "si": null, + "sk": "Tučniak", + "sr": "пингвин", + "sv": "Pingvin", + "szl": null, + "tzm": null, + "uk": "Пінгвін", + "zh_Hans": "企鹅" + } + }, + { + "number": 11, + "emoji": "🐢", + "description": "Turtle", + "unicode": "U+1F422", + "translated_descriptions": { + "ar": "سُلحفاة", + "bg": "Костенурка", + "ca": "Tortuga", + "cs": "Želva", + "de": "Schildkröte", + "eo": "Testudo", + "es": "Tortuga", + "et": "Kilpkonn", + "fi": "Kilpikonna", + "fr": "Tortue", + "hr": "kornjača", + "hu": "Teknős", + "it": "Tartaruga", + "ja": "亀", + "nb_NO": "Skilpadde", + "nl": "Schildpad", + "pt_BR": "Tartaruga", + "ru": "Черепаха", + "si": null, + "sk": "Korytnačka", + "sr": "корњача", + "sv": "Sköldpadda", + "szl": null, + "tzm": "Ifker", + "uk": "Черепаха", + "zh_Hans": "乌龟" + } + }, + { + "number": 12, + "emoji": "🐟", + "description": "Fish", + "unicode": "U+1F41F", + "translated_descriptions": { + "ar": "سَمَكَة", + "bg": "Риба", + "ca": "Peix", + "cs": "Ryba", + "de": "Fisch", + "eo": "Fiŝo", + "es": "Pez", + "et": "Kala", + "fi": "Kala", + "fr": "Poisson", + "hr": "riba", + "hu": "Hal", + "it": "Pesce", + "ja": "魚", + "nb_NO": "Fisk", + "nl": "Vis", + "pt_BR": "Peixe", + "ru": "Рыба", + "si": null, + "sk": "Ryba", + "sr": "риба", + "sv": "Fisk", + "szl": null, + "tzm": "Aselm", + "uk": "Риба", + "zh_Hans": "鱼" + } + }, + { + "number": 13, + "emoji": "🐙", + "description": "Octopus", + "unicode": "U+1F419", + "translated_descriptions": { + "ar": "أُخطُبُوط", + "bg": "Октопод", + "ca": "Pop", + "cs": "Chobotnice", + "de": "Oktopus", + "eo": "Polpo", + "es": "Pulpo", + "et": "Kaheksajalg", + "fi": "Tursas", + "fr": "Poulpe", + "hr": "hobotnica", + "hu": "Polip", + "it": "Polpo", + "ja": "たこ", + "nb_NO": "Blekksprut", + "nl": "Octopus", + "pt_BR": "Polvo", + "ru": "Осьминог", + "si": null, + "sk": "Chobotnica", + "sr": "октопод", + "sv": "Bläckfisk", + "szl": null, + "tzm": null, + "uk": "Восьминіг", + "zh_Hans": "章鱼" + } + }, + { + "number": 14, + "emoji": "🦋", + "description": "Butterfly", + "unicode": "U+1F98B", + "translated_descriptions": { + "ar": "فَرَاشَة", + "bg": "Пеперуда", + "ca": "Papallona", + "cs": "Motýl", + "de": "Schmetterling", + "eo": "Papilio", + "es": "Mariposa", + "et": "Liblikas", + "fi": "Perhonen", + "fr": "Papillon", + "hr": "leptir", + "hu": "Pillangó", + "it": "Farfalla", + "ja": "ちょうちょ", + "nb_NO": "Sommerfugl", + "nl": "Vlinder", + "pt_BR": "Borboleta", + "ru": "Бабочка", + "si": null, + "sk": "Motýľ", + "sr": "лептир", + "sv": "Fjäril", + "szl": null, + "tzm": null, + "uk": "Метелик", + "zh_Hans": "蝴蝶" + } + }, + { + "number": 15, + "emoji": "🌷", + "description": "Flower", + "unicode": "U+1F337", + "translated_descriptions": { + "ar": "زَهرَة", + "bg": "Цвете", + "ca": "Flor", + "cs": "Květina", + "de": "Blume", + "eo": "Floro", + "es": "Flor", + "et": "Lill", + "fi": "Kukka", + "fr": "Fleur", + "hr": "svijet", + "hu": "Virág", + "it": "Fiore", + "ja": "花", + "nb_NO": "Blomst", + "nl": "Bloem", + "pt_BR": "Flor", + "ru": "Цветок", + "si": null, + "sk": "Tulipán", + "sr": "цвет", + "sv": "Blomma", + "szl": null, + "tzm": null, + "uk": "Квітка", + "zh_Hans": "花" + } + }, + { + "number": 16, + "emoji": "🌳", + "description": "Tree", + "unicode": "U+1F333", + "translated_descriptions": { + "ar": "شَجَرَة", + "bg": "Дърво", + "ca": "Arbre", + "cs": "Strom", + "de": "Baum", + "eo": "Arbo", + "es": "Árbol", + "et": "Puu", + "fi": "Puu", + "fr": "Arbre", + "hr": "drvo", + "hu": "Fa", + "it": "Albero", + "ja": "木", + "nb_NO": "Tre", + "nl": "Boom", + "pt_BR": "Árvore", + "ru": "Дерево", + "si": null, + "sk": "Listnatý strom", + "sr": "дрво", + "sv": "Träd", + "szl": null, + "tzm": "Aseklu", + "uk": "Дерево", + "zh_Hans": "树" + } + }, + { + "number": 17, + "emoji": "🌵", + "description": "Cactus", + "unicode": "U+1F335", + "translated_descriptions": { + "ar": "صبار", + "bg": "Кактус", + "ca": "Cactus", + "cs": "Kaktus", + "de": "Kaktus", + "eo": "Kakto", + "es": "Cactus", + "et": "Kaktus", + "fi": "Kaktus", + "fr": "Cactus", + "hr": "kaktus", + "hu": "Kaktusz", + "it": "Cactus", + "ja": "サボテン", + "nb_NO": "Kaktus", + "nl": "Cactus", + "pt_BR": "Cacto", + "ru": "Кактус", + "si": null, + "sk": "Kaktus", + "sr": "кактус", + "sv": "Kaktus", + "szl": null, + "tzm": null, + "uk": "Кактус", + "zh_Hans": "仙人掌" + } + }, + { + "number": 18, + "emoji": "🍄", + "description": "Mushroom", + "unicode": "U+1F344", + "translated_descriptions": { + "ar": "فُطر", + "bg": "Гъба", + "ca": "Bolet", + "cs": "Houba", + "de": "Pilz", + "eo": "Fungo", + "es": "Seta", + "et": "Seen", + "fi": "Sieni", + "fr": "Champignon", + "hr": "gljiva", + "hu": "Gomba", + "it": "Fungo", + "ja": "きのこ", + "nb_NO": "Sopp", + "nl": "Paddenstoel", + "pt_BR": "Cogumelo", + "ru": "Гриб", + "si": null, + "sk": "Huba", + "sr": "печурка", + "sv": "Svamp", + "szl": null, + "tzm": "Agursel", + "uk": "Гриб", + "zh_Hans": "蘑菇" + } + }, + { + "number": 19, + "emoji": "🌏", + "description": "Globe", + "unicode": "U+1F30F", + "translated_descriptions": { + "ar": "كُرَةٌ أرضِيَّة", + "bg": "Глобус", + "ca": "Globus terraqüi", + "cs": "Zeměkoule", + "de": "Globus", + "eo": "Globo", + "es": "Globo", + "et": "Maakera", + "fi": "Maapallo", + "fr": "Globe", + "hr": "Globus", + "hu": "Földgömb", + "it": "Globo", + "ja": "地球", + "nb_NO": "Globus", + "nl": "Wereldbol", + "pt_BR": "Globo", + "ru": "Глобус", + "si": null, + "sk": "Zemeguľa", + "sr": "глобус", + "sv": "Jordklot", + "szl": null, + "tzm": null, + "uk": "Глобус", + "zh_Hans": "地球" + } + }, + { + "number": 20, + "emoji": "🌙", + "description": "Moon", + "unicode": "U+1F319", + "translated_descriptions": { + "ar": "قَمَر", + "bg": "Луна", + "ca": "Lluna", + "cs": "Měsíc", + "de": "Mond", + "eo": "Luno", + "es": "Luna", + "et": "Kuu", + "fi": "Kuu", + "fr": "Lune", + "hr": "mjesec", + "hu": "Hold", + "it": "Luna", + "ja": "月", + "nb_NO": "Måne", + "nl": "Maan", + "pt_BR": "Lua", + "ru": "Луна", + "si": null, + "sk": "Polmesiac", + "sr": "месец", + "sv": "Måne", + "szl": null, + "tzm": "Ayyur", + "uk": "Місяць", + "zh_Hans": "月亮" + } + }, + { + "number": 21, + "emoji": "☁️", + "description": "Cloud", + "unicode": "U+2601U+FE0F", + "translated_descriptions": { + "ar": "سَحابَة", + "bg": "Облак", + "ca": "Núvol", + "cs": "Mrak", + "de": "Wolke", + "eo": "Nubo", + "es": "Nube", + "et": "Pilv", + "fi": "Pilvi", + "fr": "Nuage", + "hr": "oblak", + "hu": "Felhő", + "it": "Nuvola", + "ja": "雲", + "nb_NO": "Sky", + "nl": "Wolk", + "pt_BR": "Nuvem", + "ru": "Облако", + "si": null, + "sk": "Oblak", + "sr": "облак", + "sv": "Moln", + "szl": null, + "tzm": null, + "uk": "Хмара", + "zh_Hans": "云" + } + }, + { + "number": 22, + "emoji": "🔥", + "description": "Fire", + "unicode": "U+1F525", + "translated_descriptions": { + "ar": "نار", + "bg": "Огън", + "ca": "Foc", + "cs": "Oheň", + "de": "Feuer", + "eo": "Fajro", + "es": "Fuego", + "et": "Tuli", + "fi": "Tuli", + "fr": "Feu", + "hr": "vatra", + "hu": "Tűz", + "it": "Fuoco", + "ja": "炎", + "nb_NO": "Flamme", + "nl": "Vuur", + "pt_BR": "Fogo", + "ru": "Огонь", + "si": null, + "sk": "Oheň", + "sr": "ватра", + "sv": "Eld", + "szl": null, + "tzm": "Timessi", + "uk": "Вогонь", + "zh_Hans": "火" + } + }, + { + "number": 23, + "emoji": "🍌", + "description": "Banana", + "unicode": "U+1F34C", + "translated_descriptions": { + "ar": "مَوزَة", + "bg": "Банан", + "ca": "Plàtan", + "cs": "Banán", + "de": "Banane", + "eo": "Banano", + "es": "Plátano", + "et": "Banaan", + "fi": "Banaani", + "fr": "Banane", + "hr": "banana", + "hu": "Banán", + "it": "Banana", + "ja": "バナナ", + "nb_NO": "Banan", + "nl": "Banaan", + "pt_BR": "Banana", + "ru": "Банан", + "si": null, + "sk": "Banán", + "sr": "банана", + "sv": "Banan", + "szl": null, + "tzm": "Tabanant", + "uk": "Банан", + "zh_Hans": "香蕉" + } + }, + { + "number": 24, + "emoji": "🍎", + "description": "Apple", + "unicode": "U+1F34E", + "translated_descriptions": { + "ar": "تُفَّاحَة", + "bg": "Ябълка", + "ca": "Poma", + "cs": "Jablko", + "de": "Apfel", + "eo": "Pomo", + "es": "Manzana", + "et": "Õun", + "fi": "Omena", + "fr": "Pomme", + "hr": "jabuka", + "hu": "Alma", + "it": "Mela", + "ja": "リンゴ", + "nb_NO": "Eple", + "nl": "Appel", + "pt_BR": "Maçã", + "ru": "Яблоко", + "si": null, + "sk": "Červené jablko", + "sr": "јабука", + "sv": "Äpple", + "szl": null, + "tzm": "Tadeffuyt", + "uk": "Яблуко", + "zh_Hans": "苹果" + } + }, + { + "number": 25, + "emoji": "🍓", + "description": "Strawberry", + "unicode": "U+1F353", + "translated_descriptions": { + "ar": "فَراوِلَة", + "bg": "Ягода", + "ca": "Maduixa", + "cs": "Jahoda", + "de": "Erdbeere", + "eo": "Frago", + "es": "Fresa", + "et": "Maasikas", + "fi": "Mansikka", + "fr": "Fraise", + "hr": "jagoda", + "hu": "Eper", + "it": "Fragola", + "ja": "いちご", + "nb_NO": "Jordbær", + "nl": "Aardbei", + "pt_BR": "Morango", + "ru": "Клубника", + "si": null, + "sk": "Jahoda", + "sr": "јагода", + "sv": "Jordgubbe", + "szl": null, + "tzm": null, + "uk": "Полуниця", + "zh_Hans": "草莓" + } + }, + { + "number": 26, + "emoji": "🌽", + "description": "Corn", + "unicode": "U+1F33D", + "translated_descriptions": { + "ar": "ذُرَة", + "bg": "Царевица", + "ca": "Blat de moro", + "cs": "Kukuřice", + "de": "Mais", + "eo": "Maizo", + "es": "Maíz", + "et": "Mais", + "fi": "Maissi", + "fr": "Maïs", + "hr": "kukuruza", + "hu": "Kukorica", + "it": "Mais", + "ja": "とうもろこし", + "nb_NO": "Mais", + "nl": "Maïs", + "pt_BR": "Milho", + "ru": "Кукуруза", + "si": null, + "sk": "Kukuričný klas", + "sr": "кукуруз", + "sv": "Majs", + "szl": null, + "tzm": null, + "uk": "Кукурудза", + "zh_Hans": "玉米" + } + }, + { + "number": 27, + "emoji": "🍕", + "description": "Pizza", + "unicode": "U+1F355", + "translated_descriptions": { + "ar": "بِيتزا", + "bg": "Пица", + "ca": "Pizza", + "cs": "Pizza", + "de": "Pizza", + "eo": "Pico", + "es": "Pizza", + "et": "Pitsa", + "fi": "Pizza", + "fr": "Pizza", + "hr": "pizza", + "hu": "Pizza", + "it": "Pizza", + "ja": "ピザ", + "nb_NO": "Pizza", + "nl": "Pizza", + "pt_BR": "Pizza", + "ru": "Пицца", + "si": null, + "sk": "Pizza", + "sr": "пица", + "sv": "Pizza", + "szl": null, + "tzm": null, + "uk": "Піца", + "zh_Hans": "披萨" + } + }, + { + "number": 28, + "emoji": "🎂", + "description": "Cake", + "unicode": "U+1F382", + "translated_descriptions": { + "ar": "كَعكَة", + "bg": "Торта", + "ca": "Pastís", + "cs": "Dort", + "de": "Kuchen", + "eo": "Torto", + "es": "Tarta", + "et": "Kook", + "fi": "Kakku", + "fr": "Gâteau", + "hr": "torta", + "hu": "Süti", + "it": "Torta", + "ja": "ケーキ", + "nb_NO": "Kake", + "nl": "Taart", + "pt_BR": "Bolo", + "ru": "Торт", + "si": null, + "sk": "Narodeninová torta", + "sr": "торта", + "sv": "Tårta", + "szl": null, + "tzm": null, + "uk": "Пиріг", + "zh_Hans": "蛋糕" + } + }, + { + "number": 29, + "emoji": "❤️", + "description": "Heart", + "unicode": "U+2764U+FE0F", + "translated_descriptions": { + "ar": "قَلب", + "bg": "Сърце", + "ca": "Cor", + "cs": "Srdce", + "de": "Herz", + "eo": "Koro", + "es": "Corazón", + "et": "Süda", + "fi": "Sydän", + "fr": "Cœur", + "hr": "srca", + "hu": "Szív", + "it": "Cuore", + "ja": "ハート", + "nb_NO": "Hjerte", + "nl": "Hart", + "pt_BR": "Coração", + "ru": "Сердце", + "si": null, + "sk": "červené srdce", + "sr": "срце", + "sv": "Hjärta", + "szl": null, + "tzm": "Ul", + "uk": "Серце", + "zh_Hans": "心" + } + }, + { + "number": 30, + "emoji": "😀", + "description": "Smiley", + "unicode": "U+1F600", + "translated_descriptions": { + "ar": "اِبتِسَامَة", + "bg": "Усмивка", + "ca": "Somrient", + "cs": "Smajlík", + "de": "Lächeln", + "eo": "Rideto", + "es": "Emoticono", + "et": "Smaili", + "fi": "Hymynaama", + "fr": "Sourire", + "hr": "smajlića", + "hu": "Mosoly", + "it": "Faccina sorridente", + "ja": "スマイル", + "nb_NO": "Smilefjes", + "nl": "Smiley", + "pt_BR": "Sorriso", + "ru": "Улыбка", + "si": null, + "sk": "Škeriaca sa tvár", + "sr": "смајли", + "sv": "Smiley", + "szl": null, + "tzm": null, + "uk": "Посмішка", + "zh_Hans": "笑脸" + } + }, + { + "number": 31, + "emoji": "🤖", + "description": "Robot", + "unicode": "U+1F916", + "translated_descriptions": { + "ar": "رُوبُوت", + "bg": "Робот", + "ca": "Robot", + "cs": "Robot", + "de": "Roboter", + "eo": "Roboto", + "es": "Robot", + "et": "Robot", + "fi": "Robotti", + "fr": "Robot", + "hr": "robot", + "hu": "Robot", + "it": "Robot", + "ja": "ロボと", + "nb_NO": "Robot", + "nl": "Robot", + "pt_BR": "Robô", + "ru": "Робот", + "si": null, + "sk": "Robot", + "sr": "робот", + "sv": "Robot", + "szl": null, + "tzm": "Aṛubu", + "uk": "Робот", + "zh_Hans": "机器人" + } + }, + { + "number": 32, + "emoji": "🎩", + "description": "Hat", + "unicode": "U+1F3A9", + "translated_descriptions": { + "ar": "قُبَّعَة", + "bg": "Шапка", + "ca": "Barret", + "cs": "Klobouk", + "de": "Hut", + "eo": "Ĉapelo", + "es": "Sombrero", + "et": "Kübar", + "fi": "Hattu", + "fr": "Chapeau", + "hr": "kapa", + "hu": "Kalap", + "it": "Cappello", + "ja": "帽子", + "nb_NO": "Hatt", + "nl": "Hoed", + "pt_BR": "Chapéu", + "ru": "Шляпа", + "si": null, + "sk": "Cilinder", + "sr": "шешир", + "sv": "Hatt", + "szl": null, + "tzm": "Taraza", + "uk": "Капелюх", + "zh_Hans": "帽子" + } + }, + { + "number": 33, + "emoji": "👓", + "description": "Glasses", + "unicode": "U+1F453", + "translated_descriptions": { + "ar": "نَظَّارَة", + "bg": "Очила", + "ca": "Ulleres", + "cs": "Brýle", + "de": "Brille", + "eo": "Okulvitroj", + "es": "Gafas", + "et": "Prillid", + "fi": "Silmälasit", + "fr": "Lunettes", + "hr": "naočale", + "hu": "Szemüveg", + "it": "Occhiali", + "ja": "めがね", + "nb_NO": "Briller", + "nl": "Bril", + "pt_BR": "Óculos", + "ru": "Очки", + "si": null, + "sk": "Okuliare", + "sr": "наочаре", + "sv": "Glasögon", + "szl": null, + "tzm": null, + "uk": "Окуляри", + "zh_Hans": "眼镜" + } + }, + { + "number": 34, + "emoji": "🔧", + "description": "Spanner", + "unicode": "U+1F527", + "translated_descriptions": { + "ar": "مِفتَاحُ رَبط", + "bg": "Гаечен ключ", + "ca": "Clau anglesa", + "cs": "Klíč", + "de": "Schraubenschlüssel", + "eo": "Ŝraŭbŝlosilo", + "es": "Llave inglesa", + "et": "Mutrivõti", + "fi": "Kiintoavain", + "fr": "Clé à molette", + "hr": "ključ", + "hu": "Csavarkulcs", + "it": "Chiave inglese", + "ja": "スパナ", + "nb_NO": "Fastnøkkel", + "nl": "Moersleutel", + "pt_BR": "Chave inglesa", + "ru": "Ключ", + "si": null, + "sk": "Francúzsky kľúč", + "sr": "кључ", + "sv": "Skruvnyckel", + "szl": null, + "tzm": null, + "uk": "Гайковий ключ", + "zh_Hans": "扳手" + } + }, + { + "number": 35, + "emoji": "🎅", + "description": "Santa", + "unicode": "U+1F385", + "translated_descriptions": { + "ar": "سانتا", + "bg": "Дядо Коледа", + "ca": "Pare Noél", + "cs": "Mikuláš", + "de": "Weihnachtsmann", + "eo": "Kristnaska viro", + "es": "Papá Noel", + "et": "Jõuluvana", + "fi": "Joulupukki", + "fr": "Père Noël", + "hr": "deda Mraz", + "hu": "Télapó", + "it": "Babbo Natale", + "ja": "サンタ", + "nb_NO": "Julenisse", + "nl": "Kerstman", + "pt_BR": "Papai-noel", + "ru": "Санта", + "si": null, + "sk": "Santa Claus", + "sr": "деда Мраз", + "sv": "Tomte", + "szl": null, + "tzm": null, + "uk": "Санта Клаус", + "zh_Hans": "圣诞老人" + } + }, + { + "number": 36, + "emoji": "👍", + "description": "Thumbs Up", + "unicode": "U+1F44D", + "translated_descriptions": { + "ar": "رَفعُ إِبهَام", + "bg": "Палец нагоре", + "ca": "Polzes amunt", + "cs": "Palec nahoru", + "de": "Daumen Hoch", + "eo": "Dikfingro supren", + "es": "Pulgar arriba", + "et": "Pöidlad püsti", + "fi": "Peukalo ylös", + "fr": "Pouce en l’air", + "hr": "palac gore", + "hu": "Hüvelykujj fel", + "it": "Pollice alzato", + "ja": "いいね", + "nb_NO": "Tommel Opp", + "nl": "Duim omhoog", + "pt_BR": "Joinha", + "ru": "Большой палец вверх", + "si": null, + "sk": "Palec nahor", + "sr": "палчић горе", + "sv": "Tummen upp", + "szl": null, + "tzm": null, + "uk": "Великий палець вгору", + "zh_Hans": "赞" + } + }, + { + "number": 37, + "emoji": "☂️", + "description": "Umbrella", + "unicode": "U+2602U+FE0F", + "translated_descriptions": { + "ar": "مِظَلَّة", + "bg": "Чадър", + "ca": "Paraigües", + "cs": "Deštník", + "de": "Regenschirm", + "eo": "Ombrelo", + "es": "Paraguas", + "et": "Vihmavari", + "fi": "Sateenvarjo", + "fr": "Parapluie", + "hr": "kišobran", + "hu": "Esernyő", + "it": "Ombrello", + "ja": "傘", + "nb_NO": "Paraply", + "nl": "Paraplu", + "pt_BR": "Guarda-chuva", + "ru": "Зонт", + "si": null, + "sk": "Dáždnik", + "sr": "кишобран", + "sv": "Paraply", + "szl": null, + "tzm": null, + "uk": "Парасолька", + "zh_Hans": "伞" + } + }, + { + "number": 38, + "emoji": "⌛", + "description": "Hourglass", + "unicode": "U+231B", + "translated_descriptions": { + "ar": "سَاعَةٌ رَملِيَّة", + "bg": "Пясъчен часовник", + "ca": "Rellotge de sorra", + "cs": "Přesýpací hodiny", + "de": "Sanduhr", + "eo": "Sablohorloĝo", + "es": "Reloj de arena", + "et": "Liivakell", + "fi": "Tiimalasi", + "fr": "Sablier", + "hr": "pješčani sat", + "hu": "Homokóra", + "it": "Clessidra", + "ja": "砂時計", + "nb_NO": "Timeglass", + "nl": "Zandloper", + "pt_BR": "Ampulheta", + "ru": "Песочные часы", + "si": null, + "sk": "Presýpacie hodiny", + "sr": "пешчаник", + "sv": "Timglas", + "szl": null, + "tzm": null, + "uk": "Пісковий годинник", + "zh_Hans": "沙漏" + } + }, + { + "number": 39, + "emoji": "⏰", + "description": "Clock", + "unicode": "U+23F0", + "translated_descriptions": { + "ar": "سَاعَة", + "bg": "Часовник", + "ca": "Rellotge", + "cs": "Hodiny", + "de": "Uhr", + "eo": "Horloĝo", + "es": "Reloj", + "et": "Kell", + "fi": "Pöytäkello", + "fr": "Réveil", + "hr": "sat", + "hu": "Óra", + "it": "Orologio", + "ja": "時計", + "nb_NO": "Klokke", + "nl": "Wekker", + "pt_BR": "Relógio", + "ru": "Часы", + "si": null, + "sk": "Budík", + "sr": "сат", + "sv": "Klocka", + "szl": null, + "tzm": null, + "uk": "Годинник", + "zh_Hans": "时钟" + } + }, + { + "number": 40, + "emoji": "🎁", + "description": "Gift", + "unicode": "U+1F381", + "translated_descriptions": { + "ar": "هَدِيَّة", + "bg": "Подарък", + "ca": "Regal", + "cs": "Dárek", + "de": "Geschenk", + "eo": "Donaco", + "es": "Regalo", + "et": "Kingitus", + "fi": "Lahja", + "fr": "Cadeau", + "hr": "poklon", + "hu": "Ajándék", + "it": "Regalo", + "ja": "ギフト", + "nb_NO": "Gave", + "nl": "Geschenk", + "pt_BR": "Presente", + "ru": "Подарок", + "si": null, + "sk": "Zabalený darček", + "sr": "поклон", + "sv": "Present", + "szl": null, + "tzm": null, + "uk": "Подарунок", + "zh_Hans": "礼物" + } + }, + { + "number": 41, + "emoji": "💡", + "description": "Light Bulb", + "unicode": "U+1F4A1", + "translated_descriptions": { + "ar": "مِصبَاح", + "bg": "Лампа", + "ca": "Bombeta", + "cs": "Žárovka", + "de": "Glühbirne", + "eo": "Lampo", + "es": "Bombilla", + "et": "Lambipirn", + "fi": "Hehkulamppu", + "fr": "Ampoule", + "hr": "žarulja", + "hu": "Égő", + "it": "Lampadina", + "ja": "電球", + "nb_NO": "Lyspære", + "nl": "Gloeilamp", + "pt_BR": "Lâmpada", + "ru": "Лампочка", + "si": null, + "sk": "Žiarovka", + "sr": "сијалица", + "sv": "Lampa", + "szl": null, + "tzm": null, + "uk": "Лампочка", + "zh_Hans": "灯泡" + } + }, + { + "number": 42, + "emoji": "📕", + "description": "Book", + "unicode": "U+1F4D5", + "translated_descriptions": { + "ar": "كِتَاب", + "bg": "Книга", + "ca": "Llibre", + "cs": "Kniha", + "de": "Buch", + "eo": "Libro", + "es": "Libro", + "et": "Raamat", + "fi": "Kirja", + "fr": "Livre", + "hr": "knjiga", + "hu": "Könyv", + "it": "Libro", + "ja": "本", + "nb_NO": "Bok", + "nl": "Boek", + "pt_BR": "Livro", + "ru": "Книга", + "si": null, + "sk": "Zatvorená kniha", + "sr": "књига", + "sv": "Bok", + "szl": null, + "tzm": "Adlis", + "uk": "Книга", + "zh_Hans": "书" + } + }, + { + "number": 43, + "emoji": "✏️", + "description": "Pencil", + "unicode": "U+270FU+FE0F", + "translated_descriptions": { + "ar": "قَلَمُ رَصاص", + "bg": "Молив", + "ca": "Llapis", + "cs": "Tužka", + "de": "Bleistift", + "eo": "Krajono", + "es": "Lápiz", + "et": "Pliiats", + "fi": "Lyijykynä", + "fr": "Crayon", + "hr": "olovka", + "hu": "Ceruza", + "it": "Matita", + "ja": "鉛筆", + "nb_NO": "Blyant", + "nl": "Potlood", + "pt_BR": "Lápis", + "ru": "Карандаш", + "si": null, + "sk": "Ceruzka", + "sr": "оловка", + "sv": "Penna", + "szl": null, + "tzm": null, + "uk": "Олівець", + "zh_Hans": "铅笔" + } + }, + { + "number": 44, + "emoji": "📎", + "description": "Paperclip", + "unicode": "U+1F4CE", + "translated_descriptions": { + "ar": "مِشبَكُ وَرَق", + "bg": "Кламер", + "ca": "Clip", + "cs": "Sponka", + "de": "Büroklammer", + "eo": "Paperkuntenilo", + "es": "Clip", + "et": "Kirjaklamber", + "fi": "Paperiliitin", + "fr": "Trombone", + "hr": "spajalica", + "hu": "Gémkapocs", + "it": "Graffetta", + "ja": "クリップ", + "nb_NO": "BInders", + "nl": "Papierklemmetje", + "pt_BR": "Clipe de papel", + "ru": "Скрепка", + "si": null, + "sk": "Sponka na papier", + "sr": "спајалица", + "sv": "Gem", + "szl": null, + "tzm": null, + "uk": "Спиначка", + "zh_Hans": "回形针" + } + }, + { + "number": 45, + "emoji": "✂️", + "description": "Scissors", + "unicode": "U+2702U+FE0F", + "translated_descriptions": { + "ar": "مِقَصّ", + "bg": "Ножици", + "ca": "Tisores", + "cs": "Nůžky", + "de": "Schere", + "eo": "Tondilo", + "es": "Tijeras", + "et": "Käärid", + "fi": "Sakset", + "fr": "Ciseaux", + "hr": "škare", + "hu": "Olló", + "it": "Forbici", + "ja": "はさみ", + "nb_NO": "Saks", + "nl": "Schaar", + "pt_BR": "Tesoura", + "ru": "Ножницы", + "si": null, + "sk": "Nožnice", + "sr": "маказе", + "sv": "Sax", + "szl": null, + "tzm": null, + "uk": "Ножиці", + "zh_Hans": "剪刀" + } + }, + { + "number": 46, + "emoji": "🔒", + "description": "Lock", + "unicode": "U+1F512", + "translated_descriptions": { + "ar": "قُفل", + "bg": "Катинар", + "ca": "Cadenat", + "cs": "Zámek", + "de": "Schloss", + "eo": "Seruro", + "es": "Candado", + "et": "Lukk", + "fi": "Lukko", + "fr": "Cadenas", + "hr": "zaključati", + "hu": "Lakat", + "it": "Lucchetto", + "ja": "錠前", + "nb_NO": "Lås", + "nl": "Slot", + "pt_BR": "Cadeado", + "ru": "Замок", + "si": null, + "sk": "Zatvorená zámka", + "sr": "катанац", + "sv": "Lås", + "szl": null, + "tzm": null, + "uk": "Замок", + "zh_Hans": "锁" + } + }, + { + "number": 47, + "emoji": "🔑", + "description": "Key", + "unicode": "U+1F511", + "translated_descriptions": { + "ar": "مِفتَاح", + "bg": "Ключ", + "ca": "Clau", + "cs": "Klíč", + "de": "Schlüssel", + "eo": "Ŝlosilo", + "es": "Llave", + "et": "Võti", + "fi": "Avain", + "fr": "Clé", + "hr": "ključ", + "hu": "Kulcs", + "it": "Chiave", + "ja": "鍵", + "nb_NO": "Nøkkel", + "nl": "Sleutel", + "pt_BR": "Chave", + "ru": "Ключ", + "si": null, + "sk": "Kľúč", + "sr": "кључ", + "sv": "Nyckel", + "szl": null, + "tzm": "Tasarut", + "uk": "Ключ", + "zh_Hans": "钥匙" + } + }, + { + "number": 48, + "emoji": "🔨", + "description": "Hammer", + "unicode": "U+1F528", + "translated_descriptions": { + "ar": "مِطرَقَة", + "bg": "Чук", + "ca": "Martell", + "cs": "Kladivo", + "de": "Hammer", + "eo": "Martelo", + "es": "Martillo", + "et": "Haamer", + "fi": "Vasara", + "fr": "Marteau", + "hr": "čekić", + "hu": "Kalapács", + "it": "Martello", + "ja": "金槌", + "nb_NO": "Hammer", + "nl": "Hamer", + "pt_BR": "Martelo", + "ru": "Молоток", + "si": null, + "sk": "Kladivo", + "sr": "чекић", + "sv": "Hammare", + "szl": null, + "tzm": null, + "uk": "Молоток", + "zh_Hans": "锤子" + } + }, + { + "number": 49, + "emoji": "☎️", + "description": "Telephone", + "unicode": "U+260EU+FE0F", + "translated_descriptions": { + "ar": "تِلِفُون", + "bg": "Телефон", + "ca": "Telèfon", + "cs": "Telefon", + "de": "Telefon", + "eo": "Telefono", + "es": "Telefono", + "et": "Telefon", + "fi": "Puhelin", + "fr": "Téléphone", + "hr": "telefon", + "hu": "Telefon", + "it": "Telefono", + "ja": "電話機", + "nb_NO": "Telefon", + "nl": "Telefoon", + "pt_BR": "Telefone", + "ru": "Телефон", + "si": null, + "sk": "Telefón", + "sr": "телефон", + "sv": "Telefon", + "szl": null, + "tzm": "Atilifun", + "uk": "Телефон", + "zh_Hans": "电话" + } + }, + { + "number": 50, + "emoji": "🏁", + "description": "Flag", + "unicode": "U+1F3C1", + "translated_descriptions": { + "ar": "عَلَم", + "bg": "Флаг", + "ca": "Bandera", + "cs": "Vlajka", + "de": "Flagge", + "eo": "Flago", + "es": "Bandera", + "et": "Lipp", + "fi": "Lippu", + "fr": "Drapeau", + "hr": "zastava", + "hu": "Zászló", + "it": "Bandiera", + "ja": "旗", + "nb_NO": "Flagg", + "nl": "Vlag", + "pt_BR": "Bandeira", + "ru": "Флаг", + "si": null, + "sk": "Kockovaná zástava", + "sr": "застава", + "sv": "Flagga", + "szl": null, + "tzm": "Acenyal", + "uk": "Прапор", + "zh_Hans": "旗帜" + } + }, + { + "number": 51, + "emoji": "🚂", + "description": "Train", + "unicode": "U+1F682", + "translated_descriptions": { + "ar": "قِطَار", + "bg": "Влак", + "ca": "Tren", + "cs": "Vlak", + "de": "Zug", + "eo": "Vagonaro", + "es": "Tren", + "et": "Rong", + "fi": "Juna", + "fr": "Train", + "hr": "vlak", + "hu": "Vonat", + "it": "Treno", + "ja": "電車", + "nb_NO": "Tog", + "nl": "Trein", + "pt_BR": "Trem", + "ru": "Поезд", + "si": null, + "sk": "Rušeň", + "sr": "воз", + "sv": "Tåg", + "szl": null, + "tzm": null, + "uk": "Потяг", + "zh_Hans": "火车" + } + }, + { + "number": 52, + "emoji": "🚲", + "description": "Bicycle", + "unicode": "U+1F6B2", + "translated_descriptions": { + "ar": "دَرّاجَة", + "bg": "Колело", + "ca": "Bicicleta", + "cs": "Kolo", + "de": "Fahrrad", + "eo": "Biciklo", + "es": "Bicicleta", + "et": "Jalgratas", + "fi": "Polkupyörä", + "fr": "Vélo", + "hr": "bicikl", + "hu": "Kerékpár", + "it": "Bicicletta", + "ja": "自転車", + "nb_NO": "Sykkel", + "nl": "Fiets", + "pt_BR": "Bicicleta", + "ru": "Велосипед", + "si": null, + "sk": "Bicykel", + "sr": "бицикл", + "sv": "Cykel", + "szl": null, + "tzm": null, + "uk": "Велосипед", + "zh_Hans": "自行车" + } + }, + { + "number": 53, + "emoji": "✈️", + "description": "Aeroplane", + "unicode": "U+2708U+FE0F", + "translated_descriptions": { + "ar": "طَائِرة", + "bg": "Самолет", + "ca": "Avió", + "cs": "Letadlo", + "de": "Flugzeug", + "eo": "Aviadilo", + "es": "Avión", + "et": "Lennuk", + "fi": "Lentokone", + "fr": "Avion", + "hr": "avion", + "hu": "Repülő", + "it": "Aeroplano", + "ja": "飛行機", + "nb_NO": "Fly", + "nl": "Vliegtuig", + "pt_BR": "Avião", + "ru": "Самолет", + "si": null, + "sk": "Lietadlo", + "sr": "авион", + "sv": "Flygplan", + "szl": null, + "tzm": null, + "uk": "Літак", + "zh_Hans": "飞机" + } + }, + { + "number": 54, + "emoji": "🚀", + "description": "Rocket", + "unicode": "U+1F680", + "translated_descriptions": { + "ar": "صَارُوخ", + "bg": "Ракета", + "ca": "Coet", + "cs": "Raketa", + "de": "Rakete", + "eo": "Raketo", + "es": "Cohete", + "et": "Rakett", + "fi": "Raketti", + "fr": "Fusée", + "hr": "raketa", + "hu": "Rakáta", + "it": "Razzo", + "ja": "ロケット", + "nb_NO": "Rakett", + "nl": "Raket", + "pt_BR": "Foguete", + "ru": "Ракета", + "si": null, + "sk": "Raketa", + "sr": "ракета", + "sv": "Raket", + "szl": null, + "tzm": null, + "uk": "Ракета", + "zh_Hans": "火箭" + } + }, + { + "number": 55, + "emoji": "🏆", + "description": "Trophy", + "unicode": "U+1F3C6", + "translated_descriptions": { + "ar": "كَأسُ النَّصر", + "bg": "Трофей", + "ca": "Trofeu", + "cs": "Pohár", + "de": "Pokal", + "eo": "Trofeo", + "es": "Trofeo", + "et": "Auhind", + "fi": "Palkinto", + "fr": "Trophée", + "hr": "trofej", + "hu": "Trófea", + "it": "Trofeo", + "ja": "トロフィー", + "nb_NO": "Pokal", + "nl": "Trofee", + "pt_BR": "Troféu", + "ru": "Кубок", + "si": null, + "sk": "Trofej", + "sr": "пехар", + "sv": "Trofé", + "szl": null, + "tzm": null, + "uk": "Приз", + "zh_Hans": "奖杯" + } + }, + { + "number": 56, + "emoji": "⚽", + "description": "Ball", + "unicode": "U+26BD", + "translated_descriptions": { + "ar": "كُرَة", + "bg": "Топка", + "ca": "Pilota", + "cs": "Míč", + "de": "Ball", + "eo": "Pilko", + "es": "Bola", + "et": "Pall", + "fi": "Pallo", + "fr": "Ballon", + "hr": "lopta", + "hu": "Labda", + "it": "Palla", + "ja": "ボール", + "nb_NO": "Ball", + "nl": "Bal", + "pt_BR": "Bola", + "ru": "Мяч", + "si": null, + "sk": "Futbal", + "sr": "лопта", + "sv": "Boll", + "szl": null, + "tzm": "Tcama", + "uk": "М'яч", + "zh_Hans": "球" + } + }, + { + "number": 57, + "emoji": "🎸", + "description": "Guitar", + "unicode": "U+1F3B8", + "translated_descriptions": { + "ar": "غيتار", + "bg": "Китара", + "ca": "Guitarra", + "cs": "Kytara", + "de": "Gitarre", + "eo": "Gitaro", + "es": "Guitarra", + "et": "Kitarr", + "fi": "Kitara", + "fr": "Guitare", + "hr": "gitara", + "hu": "Gitár", + "it": "Chitarra", + "ja": "ギター", + "nb_NO": "Gitar", + "nl": "Gitaar", + "pt_BR": "Guitarra", + "ru": "Гитара", + "si": null, + "sk": "Gitara", + "sr": "гитара", + "sv": "Gitarr", + "szl": null, + "tzm": "Agiṭaṛ", + "uk": "Гітара", + "zh_Hans": "吉他" + } + }, + { + "number": 58, + "emoji": "🎺", + "description": "Trumpet", + "unicode": "U+1F3BA", + "translated_descriptions": { + "ar": "بُوق", + "bg": "Тромпет", + "ca": "Trompeta", + "cs": "Trumpeta", + "de": "Trompete", + "eo": "Trumpeto", + "es": "Trompeta", + "et": "Trompet", + "fi": "Trumpetti", + "fr": "Trompette", + "hr": "truba", + "hu": "Trombita", + "it": "Trombetta", + "ja": "トランペット", + "nb_NO": "Trompet", + "nl": "Trompet", + "pt_BR": "Trombeta", + "ru": "Труба", + "si": null, + "sk": "Trúbka", + "sr": "труба", + "sv": "Trumpet", + "szl": null, + "tzm": null, + "uk": "Труба", + "zh_Hans": "喇叭" + } + }, + { + "number": 59, + "emoji": "🔔", + "description": "Bell", + "unicode": "U+1F514", + "translated_descriptions": { + "ar": "جَرَس", + "bg": "Звънец", + "ca": "Campana", + "cs": "Zvonek", + "de": "Glocke", + "eo": "Sonorilo", + "es": "Campana", + "et": "Kelluke", + "fi": "Soittokello", + "fr": "Cloche", + "hr": "zvono", + "hu": "Harang", + "it": "Campana", + "ja": "ベル", + "nb_NO": "Bjelle", + "nl": "Bel", + "pt_BR": "Sino", + "ru": "Колокол", + "si": null, + "sk": "Zvon", + "sr": "звоно", + "sv": "Bjällra", + "szl": null, + "tzm": null, + "uk": "Дзвін", + "zh_Hans": "铃铛" + } + }, + { + "number": 60, + "emoji": "⚓", + "description": "Anchor", + "unicode": "U+2693", + "translated_descriptions": { + "ar": "مِرسَاة", + "bg": "Котва", + "ca": "Àncora", + "cs": "Kotva", + "de": "Anker", + "eo": "Ankro", + "es": "Ancla", + "et": "Ankur", + "fi": "Ankkuri", + "fr": "Ancre", + "hr": "sidro", + "hu": "Horgony", + "it": "Ancora", + "ja": "いかり", + "nb_NO": "Anker", + "nl": "Anker", + "pt_BR": "Âncora", + "ru": "Якорь", + "si": null, + "sk": "Kotva", + "sr": "сидро", + "sv": "Ankare", + "szl": null, + "tzm": null, + "uk": "Якір", + "zh_Hans": "锚" + } + }, + { + "number": 61, + "emoji": "🎧", + "description": "Headphones", + "unicode": "U+1F3A7", + "translated_descriptions": { + "ar": "سَمّاعَة رَأس", + "bg": "Слушалки", + "ca": "Auriculars", + "cs": "Sluchátka", + "de": "Kopfhörer", + "eo": "Kapaŭdilo", + "es": "Cascos", + "et": "Kõrvaklapid", + "fi": "Kuulokkeet", + "fr": "Casque audio", + "hr": "slušalice", + "hu": "Fejhallgató", + "it": "Cuffie", + "ja": "ヘッドホン", + "nb_NO": "Hodetelefoner", + "nl": "Koptelefoon", + "pt_BR": "Fones de ouvido", + "ru": "Наушники", + "si": null, + "sk": "Slúchadlá", + "sr": "слушалице", + "sv": "Hörlurar", + "szl": null, + "tzm": null, + "uk": "Навушники", + "zh_Hans": "耳机" + } + }, + { + "number": 62, + "emoji": "📁", + "description": "Folder", + "unicode": "U+1F4C1", + "translated_descriptions": { + "ar": "مُجَلَّد", + "bg": "Папка", + "ca": "Carpeta", + "cs": "Složka", + "de": "Ordner", + "eo": "Dosierujo", + "es": "Carpeta", + "et": "Kaust", + "fi": "Kansio", + "fr": "Dossier", + "hr": "mapu", + "hu": "Mappa", + "it": "Cartella", + "ja": "フォルダ", + "nb_NO": "Mappe", + "nl": "Map", + "pt_BR": "Pasta", + "ru": "Папка", + "si": null, + "sk": "Fascikel", + "sr": "фасцикла", + "sv": "Mapp", + "szl": null, + "tzm": "Asdaw", + "uk": "Тека", + "zh_Hans": "文件夹" + } + }, + { + "number": 63, + "emoji": "📌", + "description": "Pin", + "unicode": "U+1F4CC", + "translated_descriptions": { + "ar": "دَبُّوس", + "bg": "Кабърче", + "ca": "Xinxeta", + "cs": "Špendlík", + "de": "Stecknadel", + "eo": "Pinglo", + "es": "Alfiler", + "et": "Nööpnõel", + "fi": "Nuppineula", + "fr": "Punaise", + "hr": "pribadača", + "hu": "Rajszeg", + "it": "Puntina", + "ja": "ピン", + "nb_NO": "Tegnestift", + "nl": "Duimspijker", + "pt_BR": "Alfinete", + "ru": "Булавка", + "si": null, + "sk": "Špendlík", + "sr": "чиода", + "sv": "Häftstift", + "szl": null, + "tzm": null, + "uk": "Кнопка", + "zh_Hans": "图钉" + } + } +] |