aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt5
-rw-r--r--lib/connection.cpp157
-rw-r--r--lib/connection.h32
-rw-r--r--lib/database.cpp31
-rw-r--r--lib/database.h11
-rw-r--r--lib/events/keyverificationevent.h143
-rw-r--r--lib/events/roomevent.h2
-rw-r--r--lib/events/roomkeyevent.cpp21
-rw-r--r--lib/events/roomkeyevent.h3
-rw-r--r--lib/keyverificationsession.cpp512
-rw-r--r--lib/keyverificationsession.h147
-rw-r--r--lib/room.cpp1
-rw-r--r--res.qrc5
-rw-r--r--sas-emoji.json2178
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": "图钉"
+ }
+ }
+]