Page MenuHomePhorge

No OneTemporary

Size
184 KB
Referenced Files
None
Subscribers
None
diff --git a/src/client/actions/encryption.cpp b/src/client/actions/encryption.cpp
index f51b08d..39e73fc 100644
--- a/src/client/actions/encryption.cpp
+++ b/src/client/actions/encryption.cpp
@@ -1,841 +1,877 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <zug/transducer/filter.hpp>
#include <zug/transducer/cat.hpp>
#include "encryption.hpp"
+#include "send.hpp"
#include <immer-utils.hpp>
#include <debug.hpp>
#include "cursorutil.hpp"
#include "status-utils.hpp"
#include "key-export.hpp"
+#include "clientutil.hpp"
namespace Kazv
{
using namespace CryptoConstants;
ClientResult updateClient(ClientModel m, UploadIdentityKeysAction)
{
if (! m.crypto) {
kzo.client.warn() << "Client::crypto is invalid, ignoring it." << std::endl;
return { std::move(m), lager::noop };
}
auto k = m.makeSelfDeviceKeys();
auto job = m.job<UploadKeysJob>()
.make(k)
.withData(json{{"is", "identityKeys"}});
kzo.client.dbg() << "Uploading identity keys" << std::endl;
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, GenerateAndUploadOneTimeKeysAction a)
{
if (! m.crypto) {
kzo.client.warn() << "Client::crypto is invalid, ignoring it." << std::endl;
return { std::move(m), simpleFail };
}
kzo.client.dbg() << "Generating " << a.numToGen << " one-time keys..." << std::endl;
auto maxNumKeys = m.constCrypto().maxNumberOfOneTimeKeys();
auto numLocalKeys = m.constCrypto().numUnpublishedOneTimeKeys();
auto numStoredKeys = m.constCrypto().uploadedOneTimeKeysCount(signedCurve25519) + numLocalKeys;
auto numKeysToGenerate = a.numToGen;
auto genKeysLimit = maxNumKeys - numStoredKeys;
if (numKeysToGenerate > genKeysLimit) {
numKeysToGenerate = genKeysLimit;
}
if (numLocalKeys <= 0 && numKeysToGenerate <= 0) { // we have enough already
kzo.client.dbg() << "We have enough one-time keys. Ignoring this." << std::endl;
return { std::move(m), lager::noop };
}
if (numKeysToGenerate > 0) {
m.withCrypto([&](auto &c) { c.genOneTimeKeysWithRandom(a.random, numKeysToGenerate); });
}
kzo.client.dbg() << "Generating done." << std::endl;
auto keys = m.constCrypto().unpublishedOneTimeKeys();
auto cv25519Keys = keys.at(curve25519);
json oneTimeKeys = json::object();
for (auto [id, keyStr] : cv25519Keys.items()) {
json keyObject = json::object();
keyObject["key"] = keyStr;
keyObject["signatures"] = m.convertSignature(m.withCrypto([&](auto &c) { return c.sign(keyObject); }));
oneTimeKeys[signedCurve25519 + ":" + id] = keyObject;
}
auto job = m.job<UploadKeysJob>()
.make(
std::nullopt, // deviceKeys
oneTimeKeys)
.withData(json{{"is", "oneTimeKeys"}});
kzo.client.dbg() << "Uploading one time keys" << std::endl;
m.addJob(std::move(job));
return { std::move(m), lager::noop };
};
ClientResult processResponse(ClientModel m, UploadKeysResponse r)
{
if (! m.crypto) {
kzo.client.warn() << "Client::crypto is invalid, ignoring it." << std::endl;
return { std::move(m), lager::noop };
}
auto is = r.dataStr("is");
if (is == "identityKeys") {
if (! r.success()) {
kzo.client.dbg() << "Uploading identity keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Uploading identity keys successful" << std::endl;
m.identityKeysUploaded = true;
} else {
if (! r.success()) {
kzo.client.dbg() << "Uploading one-time keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Uploading one-time keys successful" << std::endl;
m.withCrypto([&](auto &c) { c.markOneTimeKeysAsPublished(); });
}
m.withCrypto([&](auto &c) { c.setUploadedOneTimeKeysCount(r.oneTimeKeyCounts()); });
return { std::move(m), lager::noop };
}
static JsonWrap cannotDecryptEvent(
const std::string &reason,
const std::string &errcode,
const json &raw)
{
return json{
{"type", "m.room.message"},
{"content", {
{"msgtype","moe.kazv.mxc.cannot.decrypt"},
{"body", "**This message cannot be decrypted due to " + reason + ".**"},
{"moe.kazv.mxc.error", reason},
{"moe.kazv.mxc.errcode", errcode},
{"moe.kazv.mxc.raw", raw},
}},
};
}
// returns std::nullopt on success, and an error event on failure
static std::optional<JsonWrap> verifyEvent(ClientModel &m, Event e, const json &plainJson)
{
try {
std::string algo = e.originalJson().get().at("content").at("algorithm");
if (algo == olmAlgo) {
// Perform checks described in
// https://spec.matrix.org/v1.18/client-server-api/#validation-of-incoming-decrypted-events
// (5) Where sender_device_keys is present in the decrypted content:
auto [status, deviceInfo] = m.deviceLists.findByOlmEvent(e.setDecryptedJson(plainJson, Event::Decrypted));
if (status == DeviceListTracker::NotFound) {
kzo.client.dbg() << "Device key unknown, thus invalid" << std::endl;
return cannotDecryptEvent(
"device key unknown",
"MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN",
plainJson
);
}
// (1) The sender property in the decrypted content must match the sender of the event.
if (! (plainJson.at("sender") == e.sender())) {
kzo.client.dbg() << "Sender does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"sender does not match",
"MOE.KAZV.MXC_BAD_SENDER",
plainJson
);
}
// (3) The recipient property in the decrypted content must match the user ID of the local user.
if (! (plainJson.at("recipient") == m.userId)) {
kzo.client.dbg() << "Recipient does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"recipient does not match",
"MOE.KAZV.MXC_BAD_RECIPIENT",
plainJson
);
}
// (4) The recipient_keys.ed25519 property in the decrypted content must match the client
// device's Ed25519 signing key.
if (! (plainJson.at("recipient_keys").at(ed25519) == m.constCrypto().ed25519IdentityKey())) {
kzo.client.dbg() << "Recipient key does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"recipient keys do not match",
"MOE.KAZV.MXC_BAD_RECIPIENT_KEYS",
plainJson
);
}
// (2) The keys.ed25519 property in the decrypted content must match the
// [CORRECTED: ed25519 identity key of the sending device]
auto thisEd25519Key = plainJson.at("keys").at(ed25519).get<std::string>();
// If `sender_device_keys` is present, this also checks:
// (3) [CORRECTED: sender_device_keys.keys.ed25519:<device_id> must be the same as the `keys.ed25519` property in the decrypted content.]
if (thisEd25519Key != deviceInfo.ed25519Key) {
kzo.client.dbg() << "Sender ed25519 key does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"sender keys do not match",
"MOE.KAZV.MXC_BAD_SENDER_KEYS",
plainJson
);
}
// if everything looks good, add the deviceInfo if it is not yet in tracker
if (status == DeviceListTracker::InEvent) {
m.deviceLists.addVerifiedDeviceKeyInfo(
e.sender(),
deviceInfo.deviceId,
deviceInfo
);
}
} else if (algo == megOlmAlgo) {
auto roomId = plainJson.at("room_id").get<std::string>();
if (roomId.empty() ||
roomId != e.originalJson().get().at("room_id").template get<std::string>()) {
kzo.client.dbg() << "Room id does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"room id does not match",
"MOE.KAZV.MXC_BAD_ROOM_ID",
plainJson
);
}
} else {
kzo.client.dbg() << "Unknown algorithm, thus invalid" << std::endl;
return cannotDecryptEvent(
"unknown algorithm",
"MOE.KAZV.MXC_UNKNOWN_ALGORITHM",
plainJson
);
}
} catch (const std::exception &exception) {
kzo.client.dbg() << "json format is not correct, thus invalid" << std::endl;
return cannotDecryptEvent(
exception.what(),
"M_BAD_JSON",
plainJson
);
}
return std::nullopt;
}
Event decryptEvent(ClientModel &m, Event e)
{
// no need for decryption
if (e.decrypted() || (! e.encrypted())) {
return e;
}
kzo.client.dbg() << "About to decrypt event: "
<< e.id() << std::endl;
auto maybePlainText = m.withCrypto([&](Crypto &c) {
return c.decrypt(e.originalJson().get());
});
if (! maybePlainText) {
kzo.client.dbg() << "Cannot decrypt: " << maybePlainText.reason() << std::endl;
return e.setDecryptedJson(
cannotDecryptEvent(
maybePlainText.reason(),
"MOE.KAZV.MXC_DECRYPT_ERROR",
json(nullptr)
),
Event::NotDecrypted);
} else {
try {
auto plainJson = json::parse(maybePlainText.value());
auto error = verifyEvent(m, e, plainJson);
auto valid = !error.has_value();
if (valid) {
kzo.client.dbg() << "The decrypted event is valid." << std::endl;
}
return valid
? e.setDecryptedJson(plainJson, Event::Decrypted)
: e.setDecryptedJson(
error.value(),
Event::NotDecrypted);
} catch (const std::exception &exception) {
return e.setDecryptedJson(
cannotDecryptEvent(
exception.what(),
"M_NOT_JSON",
maybePlainText.value()
),
Event::NotDecrypted
);
}
}
}
ClientModel tryDecryptEvents(ClientModel m)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring decryption request" << std::endl;
return m;
}
kzo.client.dbg() << "Trying to decrypt events..." << std::endl;
auto decryptFunc = [&](auto e) { return decryptEvent(m, e); };
auto takeOutRoomKeyEvents =
[&](auto e) {
if (e.type() != "m.room_key") {
// Leave it as it is
return true;
}
// It is a room key event, but unencrypted.
// Per matrix spec, we should not trust it as a E2EE key.
// matrix spec also says we should make sure it's Olm-encrypted.
// This is realized by verifying all MegOlm-encrypted events have
// a room_id.
if (!e.encrypted()) {
kzo.client.warn() << "Received an unencrypted room key event. Ignoring." << std::endl;
return false;
}
try {
auto content = e.content();
std::string roomId = content.get().at("room_id");
std::string sessionId = content.get().at("session_id");
kzo.client.dbg() << "Got a room key for room " << roomId
<< ", session id: " << sessionId << std::endl;
std::string sessionKey = content.get().at("session_key");
auto k = KeyOfGroupSession{roomId, sessionId};
std::string ed25519Key = e.decryptedJson().get().at("keys").at(ed25519);
if (m.withCrypto([&](auto &c) { return c.createInboundGroupSession(k, sessionKey, ed25519Key); })) {
return false; // such that this event is removed
} else {
kzo.client.warn() << "The session exists and cannot be merged. Someone is trying to do a session-replace attack." << std::endl;
kzo.client.dbg() << "sender key is " << ed25519Key << std::endl;
return true;
}
} catch (...) {
kzo.client.dbg() << "cannot create group session";
return false;
}
return true;
};
m.toDevice = intoImmer(
EventList{},
zug::map(decryptFunc)
| zug::filter(takeOutRoomKeyEvents),
std::move(m.toDevice));
auto decryptEventInRoom =
[&](auto id, auto room) {
if (! room.encrypted) {
return;
} else {
auto messages = room.messages;
auto undecryptedEvents = room.undecryptedEvents;
for (auto [sessionId, eventIds] : undecryptedEvents) {
if (m.constCrypto().hasInboundGroupSession({
room.roomId,
sessionId,
})) {
auto nextEventIds = intoImmer(
immer::flex_vector<std::string>{},
zug::filter([&](auto eventId) {
auto event = room.messages[eventId];
auto decrypted = decryptFunc(event);
room.messages = std::move(room.messages)
.set(eventId, decrypted);
return !decrypted.decrypted();
}),
eventIds
);
if (nextEventIds.empty()) {
room.undecryptedEvents = std::move(room.undecryptedEvents).erase(sessionId);
} else {
room.undecryptedEvents = std::move(room.undecryptedEvents).set(sessionId, nextEventIds);
}
}
}
m.roomList.rooms = std::move(m.roomList.rooms).set(id, room);
}
};
auto rooms = m.roomList.rooms;
for (auto [id, room]: rooms) {
decryptEventInRoom(id, room);
}
return m;
}
std::optional<BaseJob> clientPerform(ClientModel m, QueryKeysAction a)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return std::nullopt;
}
immer::map<std::string, immer::array<std::string>> deviceKeys;
auto encryptedUsers = m.deviceLists.outdatedUsers();
kzo.client.dbg() << "We need to query keys for: " << std::endl;
for (auto userId: encryptedUsers) {
kzo.client.dbg() << userId << std::endl;
deviceKeys = std::move(deviceKeys).set(userId, {});
}
kzo.client.dbg() << "^" << std::endl;
for (const auto &msg: m.toDevice) {
// If message cannot be verified because device key is unknown, try to fetch corresponding device key
if (msg.encrypted()
&& !msg.decrypted()
&& msg.decryptedJson().get().contains("/content/moe.kazv.mxc.errcode"_json_pointer)
&& msg.decryptedJson().get().at("/content/moe.kazv.mxc.errcode"_json_pointer) == "MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN"
// do not fetch if the (invalid) sender device keys is embedded
&& !msg.decryptedJson().get().contains("/content/moe.kazv.mxc.raw/sender_device_keys"_json_pointer)) {
// the event does not include the device id that it is sent from, so query all devices for the sender
deviceKeys = std::move(deviceKeys).set(msg.sender(), {});
}
}
if (deviceKeys.empty()) {
return std::nullopt;
}
auto job = m.job<QueryKeysJob>()
.make(deviceKeys,
std::nullopt, // timeout
a.isInitialSync ? std::nullopt : m.syncToken
)
.withData(json::object({
{"deviceKeys", deviceKeys},
}));
return job;
}
ClientResult updateClient(ClientModel m, QueryKeysAction a)
{
auto jobOpt = clientPerform(m, a);
if (jobOpt) {
m.addJob(jobOpt.value());
}
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, EnsureKeysFromDevicesAction a)
{
immer::map<std::string, immer::array<std::string>> deviceKeys;
for (auto [userId, deviceIds] : a.userIdToDeviceIdsMap) {
if (deviceIds.empty()) {
deviceKeys = std::move(deviceKeys).set(userId, {});
} else {
auto devicesToFetch = intoImmer(
immer::array<std::string>{},
zug::filter([&m, userId](const auto &deviceId) {
return !m.deviceLists.get(userId, deviceId).has_value();
}),
deviceIds
);
if (!devicesToFetch.empty()) {
// Originally we want to ensure a subset of the devices
// of some user, but we are still missing some
deviceKeys = std::move(deviceKeys).set(userId, devicesToFetch);
}
// Otherwise, we already have all the keys we need.
}
}
if (!deviceKeys.empty()) {
auto job = m.job<QueryKeysJob>()
.make(deviceKeys,
std::nullopt, // timeout
std::nullopt // sync token
)
.withData(json::object({
{"deviceKeys", deviceKeys},
}));
m.addJob(job);
}
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, QueryKeysResponse r)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return { std::move(m), simpleFail };
}
if (! r.success()) {
kzo.client.dbg() << "query keys failed: " << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Received a query key response" << std::endl;
auto requested = r.dataJson("deviceKeys").template get<immer::map<std::string, immer::array<std::string>>>();
auto wantedToFetchAllForUser = [&requested](const std::string &userId) {
return requested.count(userId) && requested[userId].empty();
};
auto usersMap = r.deviceKeys();
auto unsatisfied = json::object({
{"users", zug::into(
json::array(), zug::filter([usersMap](const auto &p) {
return !usersMap.count(p.first);
}),
requested
)},
{"devices", zug::into(
json::array(),
zug::filter([usersMap](const auto &p) {
return p.second.size() && usersMap.count(p.first);
})
| zug::map([usersMap](const auto &p) {
auto [userId, deviceIds] = p;
auto deviceMap = usersMap[p.first];
return zug::into(
std::vector<json>(),
zug::filter([deviceMap](const auto &deviceId) {
return !deviceMap.count(deviceId);
})
| zug::map([userId](const auto &deviceId) {
return json::array({userId, deviceId});
}),
deviceIds
);
})
| zug::cat,
requested
)},
});
for (auto [userId, deviceMap] : usersMap) {
for (auto [deviceId, deviceInfo] : deviceMap) {
kzo.client.dbg() << "Key for " << userId
<< "/" << deviceId
<< ": " << json(deviceInfo).dump()
<< std::endl;
m.deviceLists.addDevice(userId, deviceId, deviceInfo);
}
if (wantedToFetchAllForUser(userId)) {
// We requested all devices for this user, so any
// device currently tracked but absent from the
// response has been deleted by its owner. Mark it
// as deleted rather than removing it, so we can still
// verify past decrypted events from that device.
auto existingDevices = m.deviceLists.devicesFor(userId);
for (auto [deviceId, info] : existingDevices) {
if (!deviceMap.count(deviceId)) {
kzo.client.dbg() << "Marking deleted device " << userId
<< "/" << deviceId << std::endl;
m.deviceLists.markDeviceAsDeleted(userId, deviceId);
}
}
m.deviceLists.markUpToDate(userId);
}
}
return { std::move(m), detail::ReturnEffectStatusT{
EffectStatus{/* succ = */ true, json::object({
{"unsatisfied", unsatisfied}
})}
} };
}
ClientResult updateClient(ClientModel m, ClaimKeysAction a)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return { std::move(m), lager::noop };
}
kzo.client.dbg() << "claim keys for: " << json(a.devicesToSend).dump() << std::endl;
auto keyMap = immer::map<std::string, immer::map<std::string /* deviceId */,
std::string /* curve25519IdentityKey */>>{};
for (auto [userId, devices] : a.devicesToSend) {
kzo.client.dbg() << "Iterating through user " << userId << std::endl;
auto deviceToKey = immer::map<std::string, std::string>{};
for (auto deviceId : devices) {
kzo.client.dbg() << "Device: " << deviceId << std::endl;
auto infoOpt = m.deviceLists.get(userId, deviceId);
if (infoOpt) {
kzo.client.dbg() << "Got device info, curve25519 key is: " << infoOpt.value().curve25519Key << std::endl;
deviceToKey = std::move(deviceToKey)
.set(deviceId, infoOpt.value().curve25519Key);
} else {
kzo.client.dbg() << "Did not get device info" << std::endl;
}
}
keyMap = std::move(keyMap).set(userId, deviceToKey);
}
auto devicesToClaimKeys = m.withCrypto([&](auto &c) { return c.devicesMissingOutboundSessionKey(keyMap); });
kzo.client.dbg() << "Really claim keys for: " << json(devicesToClaimKeys).dump() << std::endl;
auto oneTimeKeys = immer::map<std::string, immer::map<std::string, std::string>>{};
for (auto [userId, devices] : devicesToClaimKeys) {
auto devKeys = immer::map<std::string, std::string>{};
for (auto deviceId: devices) {
devKeys = std::move(devKeys).set(deviceId, signedCurve25519);
}
oneTimeKeys = std::move(oneTimeKeys).set(userId, devKeys);
}
auto job = m.job<ClaimKeysJob>()
.make(std::move(oneTimeKeys))
.withData(json{
{"roomId", a.roomId},
{"sessionId", a.sessionId},
{"sessionKey", a.sessionKey},
{"devicesToSend", a.devicesToSend},
{"random", a.random}
});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, ClaimKeysResponse r)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return { std::move(m), simpleFail };
}
if (! r.success()) {
kzo.client.dbg() << "claim keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "claim keys successful" << std::endl;
kzo.client.dbg() << "Json body: " << r.jsonBody().get().dump() << std::endl;
auto roomId = r.dataStr("roomId");
auto sessionKey = r.dataStr("sessionKey");
auto sessionId = r.dataStr("sessionId");
auto devicesToSend =
immer::map<std::string, immer::flex_vector<std::string>>(r.dataJson("devicesToSend"));
auto random = r.dataJson("random").template get<RandomData>();
// create outbound sessions for those devices
auto oneTimeKeys = r.oneTimeKeys();
for (auto [userId, deviceMap] : oneTimeKeys) {
for (auto [deviceId, keyVar] : deviceMap) {
auto keys = keyVar.get();
for (auto [keyId, key] : keys.items()) {
auto deviceInfoOpt = m.deviceLists.get(userId, deviceId);
if (deviceInfoOpt) {
auto deviceInfo = deviceInfoOpt.value();
kzo.client.dbg() << "Verifying key for " << userId
<< "/" << deviceId
<< key.dump()
<< " with ed25519 key "
<< deviceInfo.ed25519Key << std::endl;
auto verified = m.withCrypto([&](auto &c) { return c.verify(key, userId, deviceId, deviceInfo.ed25519Key); });
kzo.client.dbg() << (verified ? "passed" : "did not pass") << std::endl;
if (verified && key.contains("key")) {
auto theirOneTimeKey = key.at("key");
kzo.client.dbg() << "creating outbound session for it" << std::endl;
m.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(random, deviceInfo.curve25519Key, theirOneTimeKey); });
random.erase(0, Crypto::createOutboundSessionRandomSize());
kzo.client.dbg() << "done" << std::endl;
}
}
}
}
}
auto eventJson = json{
{"content", {{"algorithm", megOlmAlgo},
{"room_id", roomId},
{"session_id", sessionId},
{"session_key", sessionKey}}},
{"type", "m.room_key"}
};
auto event = Event(JsonWrap(eventJson));
return {
std::move(m),
[event](auto &&) { return EffectStatus{ /* success = */ true, json{{ "keyEvent", event.originalJson() }} }; }
};
}
ClientResult updateClient(ClientModel m, EncryptMegOlmEventAction a)
{
auto [encryptedEvent, maybeKey] = m.megOlmEncrypt(a.e, a.roomId, a.timeMs, a.random);
return {
std::move(m),
[=](auto && /* ctx */) {
auto retJson = json::object({
{"encrypted", encryptedEvent.originalJson()},
});
if (maybeKey.has_value()) {
retJson["key"] = maybeKey.value();
}
return EffectStatus(/* succ = */ true, retJson);
}
};
}
ClientResult updateClient(ClientModel m, SetDeviceTrustLevelAction a)
{
auto maybeOldInfo = m.deviceLists.get(a.userId, a.deviceId);
if (!maybeOldInfo) {
return {
std::move(m),
[=](auto && /* ctx */) {
auto retJson = json::object({
{"error", "No such device"},
{"errorCode", "MOE_KAZV_MXC_KAZV_NO_SUCH_DEVICE"},
});
return EffectStatus(/* succ = */ false, retJson);
}
};
}
m.deviceLists.deviceLists = updateIn(
std::move(m.deviceLists.deviceLists),
[a](auto device) {
device.trustLevel = a.trustLevel;
return device;
},
a.userId,
a.deviceId
);
return { m, lager::noop };
}
ClientResult updateClient(ClientModel m, SetDevicesTrustLevelsAction a)
{
auto res = json::object({
{"notFound", json::array()},
});
auto succ = true;
for (const auto &[userId, devicesMap] : a.trustLevelMap) {
for (const auto &[deviceId, trustLevel] : devicesMap) {
kzo.client.dbg() << "SetDevicesTrustLevelsAction: user " << userId << ", device " << deviceId << ", trust level " << trustLevel << std::endl;
auto maybeOldInfo = m.deviceLists.get(userId, deviceId);
if (!maybeOldInfo) {
res.at("notFound").push_back(json::array({userId, deviceId}));
succ = false;
continue;
}
m.deviceLists.deviceLists = updateIn(
std::move(m.deviceLists.deviceLists),
[trustLevel](auto device) {
device.trustLevel = trustLevel;
return device;
},
userId,
deviceId
);
}
}
return {std::move(m), detail::ReturnEffectStatusT{{succ, res}}};
}
ClientResult updateClient(ClientModel m, SetTrustLevelNeededToSendKeysAction a)
{
m.trustLevelNeededToSendKeys = a.trustLevel;
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, PrepareForSharingRoomKeyAction a)
{
auto messages = m.olmEncryptSplit(a.e, a.devices, a.random);
auto txnId = getTxnId(Event(), m);
m.roomList = RoomListModel::update(
std::move(m.roomList),
UpdateRoomAction{
a.roomId,
AddPendingRoomKeyAction{
PendingRoomKeyEvent{txnId, messages}
}
}
);
return { std::move(m), [txnId](auto &&) {
return EffectStatus(/* succ = */ true, json::object({{"txnId", txnId}}));
} };
}
ClientResult updateClient(ClientModel m, ImportFromKeyBackupFileAction a)
{
auto maybeExportFile = decryptKeyExport(std::move(a.fileContent), std::move(a.password));
if (!maybeExportFile) {
return {std::move(m), Kazv::detail::ReturnEffectStatusT{{
/* succ = */ false,
json{
{"errorCode", maybeExportFile.reason()},
{"error", maybeExportFile.reason()},
},
}}};
}
std::size_t imported = 0;
m.withCrypto([&maybeExportFile, &imported](Crypto &c) {
imported = c.importInboundGroupSessions(std::move(maybeExportFile).value());
});
return {std::move(m), Kazv::detail::ReturnEffectStatusT{{
/* succ = */ true,
json{
{"imported", imported},
},
}}};
};
ClientResult updateClient(ClientModel m, [[maybe_unused]] NotifyVerificationTrackerModelAction a)
{
m.addTrigger(VerificationTrackerModelChanged{});
return {std::move(m), lager::noop};
}
+
+ ClientResult updateClient(ClientModel m, RequestShareRoomSessionKeyAction a)
+ {
+ auto devices = a.devices.empty() ? m.devicesToRequestKeys() : a.devices;
+ auto origEventJson = a.event.originalJson().get();
+ if (!(
+ origEventJson.contains("/content/algorithm"_json_pointer)
+ && origEventJson.contains("/content/session_id"_json_pointer)
+ )) {
+ return { std::move(m), failEffect("MOE.KAZV.MXC_BAD_EVENT", "Event is malformed") };
+ }
+ auto requestId = getTxnId(Event(), m);
+ auto requestJson = json{
+ {"type", "m.room_key_request"},
+ {"content", {
+ {"action", "request"},
+ {"body", {
+ {"algorithm", origEventJson.at("/content/algorithm"_json_pointer)},
+ {"room_id", a.roomId},
+ {"session_id", origEventJson.at("/content/session_id"_json_pointer)},
+ }},
+ {"request_id", requestId},
+ {"requesting_device_id", m.deviceId},
+ }},
+ };
+ if (origEventJson.contains("/content/sender_key"_json_pointer)) {
+ requestJson["content"]["body"]["sender_key"] = origEventJson["/content/sender_key"_json_pointer];
+ }
+
+ return updateClient(std::move(m), SendToDeviceMessageAction{
+ Event(std::move(requestJson)),
+ devices,
+ });
+ }
}
diff --git a/src/client/actions/encryption.hpp b/src/client/actions/encryption.hpp
index 18c0563..9792507 100644
--- a/src/client/actions/encryption.hpp
+++ b/src/client/actions/encryption.hpp
@@ -1,44 +1,46 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include "client-model.hpp"
#include "csapi/keys.hpp"
namespace Kazv
{
ClientResult updateClient(ClientModel m, UploadIdentityKeysAction a);
ClientResult updateClient(ClientModel m, GenerateAndUploadOneTimeKeysAction a);
ClientResult processResponse(ClientModel m, UploadKeysResponse r);
ClientModel tryDecryptEvents(ClientModel m);
Event decryptEvent(ClientModel &m, Event e);
std::optional<BaseJob> clientPerform(ClientModel m, QueryKeysAction a);
ClientResult updateClient(ClientModel m, QueryKeysAction a);
ClientResult updateClient(ClientModel m, EnsureKeysFromDevicesAction a);
ClientResult processResponse(ClientModel m, QueryKeysResponse r);
ClientResult updateClient(ClientModel m, ClaimKeysAction a);
ClientResult processResponse(ClientModel m, ClaimKeysResponse r);
ClientResult updateClient(ClientModel m, EncryptMegOlmEventAction a);
ClientResult updateClient(ClientModel m, SetDeviceTrustLevelAction a);
ClientResult updateClient(ClientModel m, SetDevicesTrustLevelsAction a);
ClientResult updateClient(ClientModel m, SetTrustLevelNeededToSendKeysAction a);
ClientResult updateClient(ClientModel m, PrepareForSharingRoomKeyAction a);
ClientResult updateClient(ClientModel m, ImportFromKeyBackupFileAction a);
ClientResult updateClient(ClientModel m, NotifyVerificationTrackerModelAction a);
+
+ ClientResult updateClient(ClientModel m, RequestShareRoomSessionKeyAction a);
}
diff --git a/src/client/client-model.cpp b/src/client/client-model.cpp
index dbd3611..71510c1 100644
--- a/src/client/client-model.cpp
+++ b/src/client/client-model.cpp
@@ -1,539 +1,557 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <immer/algorithm.hpp>
#include <lager/util.hpp>
#include <lager/context.hpp>
#include <functional>
#include <zug/transducer/filter.hpp>
#include <immer/flex_vector_transient.hpp>
#include "debug.hpp"
#include "immer-utils.hpp"
#include "json-utils.hpp"
#include "client-model.hpp"
#include "actions/states.hpp"
#include "actions/auth.hpp"
#include "actions/membership.hpp"
#include "actions/paginate.hpp"
#include "actions/send.hpp"
#include "actions/states.hpp"
#include "actions/account-data.hpp"
#include "actions/sync.hpp"
#include "actions/ephemeral.hpp"
#include "actions/content.hpp"
#include "actions/encryption.hpp"
#include "actions/profile.hpp"
#include "actions/storage.hpp"
namespace Kazv
{
auto ClientModel::update(ClientModel m, Action a) -> Result
{
auto oldClient = m;
auto oldDeviceLists = m.deviceLists;
auto actionIsStorage = std::holds_alternative<LoadEventsFromStorageAction>(a) || std::holds_alternative<PurgeRoomTimelineAction>(a);
auto [newClient, effect] = lager::match(std::move(a))(
[&](RoomListAction a) -> Result {
m.roomList = RoomListModel::update(std::move(m.roomList), a);
return {std::move(m), lager::noop};
},
[&](ResubmitJobAction a) -> Result {
m.addJob(std::move(a.job));
return { std::move(m), lager::noop };
},
[&](auto a) -> decltype(updateClient(m, a)) {
return updateClient(m, a);
},
#define RESPONSE_FOR(_jobId) \
if (r.jobId() == #_jobId) { \
return processResponse(m, _jobId##Response{std::move(r)}); \
}
[&](ProcessResponseAction a) -> Result {
auto r = std::move(a.response);
// auth
RESPONSE_FOR(Login);
RESPONSE_FOR(GetWellknown);
RESPONSE_FOR(GetVersions);
RESPONSE_FOR(Logout);
// paginate
RESPONSE_FOR(GetRoomEvents);
// sync
RESPONSE_FOR(Sync);
RESPONSE_FOR(DefineFilter);
// membership
RESPONSE_FOR(CreateRoom);
RESPONSE_FOR(InviteUser);
RESPONSE_FOR(JoinRoomById);
RESPONSE_FOR(JoinRoom);
RESPONSE_FOR(LeaveRoom);
RESPONSE_FOR(ForgetRoom);
RESPONSE_FOR(Kick);
RESPONSE_FOR(Ban);
RESPONSE_FOR(Unban);
// send
RESPONSE_FOR(SendMessage);
RESPONSE_FOR(SendToDevice);
RESPONSE_FOR(RedactEvent);
// states
RESPONSE_FOR(GetRoomState);
RESPONSE_FOR(SetRoomStateWithKey);
RESPONSE_FOR(GetRoomStateWithKey);
// account data
RESPONSE_FOR(SetAccountData);
RESPONSE_FOR(SetAccountDataPerRoom);
// ephemeral
RESPONSE_FOR(SetTyping);
RESPONSE_FOR(PostReceipt);
RESPONSE_FOR(SetReadMarker);
// content
RESPONSE_FOR(UploadContent);
RESPONSE_FOR(GetContent);
RESPONSE_FOR(GetContentThumbnail);
// encryption
RESPONSE_FOR(UploadKeys);
RESPONSE_FOR(QueryKeys);
RESPONSE_FOR(ClaimKeys);
// profile
RESPONSE_FOR(GetUserProfile);
RESPONSE_FOR(SetAvatarUrl);
RESPONSE_FOR(SetDisplayName);
m.addTrigger(UnrecognizedResponse{std::move(r)});
return { std::move(m), lager::noop };
}
#undef RESPONSE_FOR
);
newClient.maybeRotateSessions(oldClient);
// if it is an storage action, do not add it back because the things
// should be the same as the ones in storage
if (!actionIsStorage) {
newClient.maybeAddSaveEventsTrigger(oldClient);
}
return { std::move(newClient), std::move(effect) };
}
std::pair<Event, std::optional<std::string>> ClientModel::megOlmEncrypt(
Event e, std::string roomId, Timestamp timeMs, RandomData random)
{
if (!crypto) {
kzo.client.dbg() << "We do not have e2ee, so do not encrypt events" << std::endl;
return { e, std::nullopt };
}
if (e.encrypted()) {
kzo.client.dbg() << "The event is already encrypted. Ignoring it." << std::endl;
return { e, std::nullopt };
}
auto j = e.originalJson().get();
auto r = roomList[roomId];
if (! r.encrypted) {
kzo.client.dbg() << "The room " << roomId
<< " is not encrypted, so do not encrypt events" << std::endl;
return { e, std::nullopt };
}
auto desc = r.sessionRotateDesc();
auto keyOpt = std::optional<std::string>{};
if (r.shouldRotateSessionKey) {
kzo.client.dbg() << "We should rotate this session." << std::endl;
keyOpt = withCrypto([&](auto &c) { return c.rotateMegOlmSessionWithRandom(random, timeMs, roomId); });
} else {
keyOpt = withCrypto([&](auto &c) { return c.rotateMegOlmSessionWithRandomIfNeeded(random, timeMs, roomId, desc); });
}
// we no longer need to rotate session
// until next time a device change happens
roomList.rooms = std::move(roomList.rooms)
.update(roomId, [](auto r) { r.shouldRotateSessionKey = false; return r; });
auto relation = hasAtThat(j["content"], "m.relates_to", &json::is_object) ? j["content"]["m.relates_to"] : json(nullptr);
// so that Crypto::encryptMegOlm() can find room id
j["room_id"] = roomId;
auto content = withCrypto([&](auto &c) { return c.encryptMegOlm(j); });
j["type"] = "m.room.encrypted";
j["content"] = std::move(content);
j["content"]["device_id"] = deviceId;
// add relationship to plaintext
if (relation.is_object()) {
j["content"]["m.relates_to"] = relation;
}
return { Event(JsonWrap(j)), keyOpt };
}
immer::map<std::string, immer::map<std::string, Event>> ClientModel::olmEncryptSplit(
Event e,
immer::map<std::string, immer::flex_vector<std::string>> userIdToDeviceIdMap,
RandomData random,
bool attachSenderDeviceKeys
)
{
using ResT = immer::map<std::string, immer::map<std::string, Event>>;
if (!crypto) {
kzo.client.dbg() << "We do not have e2ee, so do not encrypt events" << std::endl;
return ResT{};
}
if (e.encrypted()) {
kzo.client.dbg() << "The event is already encrypted. Ignoring it." << std::endl;
return ResT{};
}
auto origJson = e.originalJson().get();
auto encJson = json::object();
encJson["content"] = json{
{"algorithm", CryptoConstants::olmAlgo},
{"ciphertext", json::object()},
{"sender_key", constCrypto().curve25519IdentityKey()},
};
encJson["type"] = "m.room.encrypted";
if (attachSenderDeviceKeys) {
auto senderDeviceKeys = makeSelfDeviceKeys();
origJson["sender_device_keys"] = senderDeviceKeys;
}
ResT messages;
for (auto [userId, devices] : userIdToDeviceIdMap) {
messages = std::move(messages).set(userId, immer::map<std::string, Event>());
for (auto dev : devices) {
auto devInfoOpt = deviceLists.get(userId, dev);
if (! devInfoOpt) {
kzo.client.warn() << "no device info for " << userId << " / " << dev << std::endl;
continue;
}
auto devInfo = devInfoOpt.value();
auto jsonForThisDevice = origJson;
jsonForThisDevice["sender"] = this->userId;
jsonForThisDevice["recipient"] = userId;
jsonForThisDevice["recipient_keys"] = json{
{CryptoConstants::ed25519, devInfo.ed25519Key}
};
jsonForThisDevice["keys"] = json{
{CryptoConstants::ed25519, constCrypto().ed25519IdentityKey()}
};
auto thisEventJson = encJson;
thisEventJson["content"]["ciphertext"]
.merge_patch(withCrypto([&](auto &c) { return c.encryptOlmWithRandom(random, jsonForThisDevice, devInfo.curve25519Key); }));
random.erase(0, Crypto::encryptOlmMaxRandomSize());
messages = setIn(std::move(messages), Event(thisEventJson), userId, dev);
}
}
return messages;
}
DeviceKeys ClientModel::makeSelfDeviceKeys()
{
using namespace CryptoConstants;
auto keys =
immer::map<std::string, std::string>{}
.set(ed25519 + ":" + deviceId, constCrypto().ed25519IdentityKey())
.set(curve25519 + ":" + deviceId, constCrypto().curve25519IdentityKey());
DeviceKeys k {
userId,
deviceId,
{olmAlgo, megOlmAlgo},
keys,
{} // signatures to be added soon
};
auto j = json(k);
auto sig = withCrypto([&](auto &crypto) { return crypto.sign(j); });
k.signatures = convertSignature(sig);
return k;
}
json ClientModel::convertSignature(std::string signature) const
{
auto j = json::object();
j[userId] = json::object();
j[userId][CryptoConstants::ed25519 + ":" + deviceId] = signature;
return j;
};
immer::flex_vector<std::string /* deviceId */> ClientModel::devicesToSendKeys(std::string userId) const
{
auto trustLevelNeeded = this->trustLevelNeededToSendKeys;
// XXX: preliminary approach
auto shouldSendP = [=](auto deviceInfo, auto /* deviceMap */) {
return deviceInfo.trustLevel >= trustLevelNeeded
&& !deviceInfo.deleted;
};
auto devices = deviceLists.devicesFor(userId);
return intoImmer(
immer::flex_vector<std::string>{},
zug::filter([=](auto n) {
auto [id, dev] = n;
return shouldSendP(dev, devices);
})
| zug::map([=](auto n) {
return n.first;
}),
devices);
}
+ immer::map<std::string /* userId */, immer::flex_vector<std::string>> ClientModel::devicesToRequestKeys() const
+ {
+ auto devices = deviceLists.devicesFor(userId);
+
+ return {
+ {userId, intoImmer(
+ immer::flex_vector<std::string>{},
+ zug::filter([=](auto n) {
+ auto [id, dev] = n;
+ return dev.trustLevel >= Verified && !dev.deleted;
+ })
+ | zug::map([](auto n) {
+ return n.first;
+ }),
+ devices)},
+ };
+ }
+
std::size_t ClientModel::numOneTimeKeysNeeded() const
{
const auto &crypto = constCrypto();
// Keep half of max supported number of keys
int numUploadedKeys = crypto.uploadedOneTimeKeysCount(CryptoConstants::signedCurve25519);
int numKeysNeeded = crypto.maxNumberOfOneTimeKeys() / 2
- numUploadedKeys;
// Subtract the number of existing one-time keys, in case
// the previous upload was not successful.
int numKeysToGenerate = numKeysNeeded - crypto.numUnpublishedOneTimeKeys();
if (numKeysToGenerate < 0) {
numKeysToGenerate = 0;
}
return numKeysToGenerate;
}
std::size_t EncryptMegOlmEventAction::maxRandomSize()
{
return Crypto::rotateMegOlmSessionRandomSize();
}
std::size_t EncryptMegOlmEventAction::minRandomSize()
{
return 0;
}
std::size_t PrepareForSharingRoomKeyAction::randomSize(PrepareForSharingRoomKeyAction::UserIdToDeviceIdMap devices)
{
auto singleRandomSize = Crypto::encryptOlmMaxRandomSize();
auto deviceNum = accumulate(devices, std::size_t{},
[](auto counter, auto pair) { return counter + pair.second.size(); });
return deviceNum * singleRandomSize;
}
std::size_t GenerateAndUploadOneTimeKeysAction::randomSize(std::size_t numToGen)
{
return Crypto::genOneTimeKeysRandomSize(numToGen);
}
std::size_t ClaimKeysAction::randomSize(immer::map<std::string, immer::flex_vector<std::string>> devicesToSend)
{
auto singleRandomSize = Crypto::createOutboundSessionRandomSize();
auto deviceNum = accumulate(devicesToSend, std::size_t{},
[](auto counter, auto pair) { return counter + pair.second.size(); });
return deviceNum * singleRandomSize;
}
void ClientModel::maybeRotateSessions(ClientModel oldClient)
{
auto roomIds = intoImmer(
immer::flex_vector<std::string>{},
zug::filter([](const auto &pair) {
return pair.second.encrypted && !pair.second.shouldRotateSessionKey;
})
| zug::map([](const auto &pair) { return pair.first; }),
roomList.rooms
);
auto markRotate = [this](const auto &roomId) {
roomList.rooms =
std::move(roomList.rooms)
.update(roomId, [](auto room) {
room.shouldRotateSessionKey = true;
return room;
});
};
// Rotate megolm keys for rooms whose users' device list has changed
auto changedUsers = deviceLists.diff(oldClient.deviceLists);
if (! changedUsers.empty()) {
for (auto roomId : roomIds) {
auto it = std::find_if(changedUsers.begin(), changedUsers.end(),
[=, this](auto userId) { return roomList.rooms[roomId].hasUser(userId); });
if (it != changedUsers.end()) {
kzo.client.dbg() << "rotate keys for room " << roomId << std::endl;
markRotate(roomId);
}
}
}
roomIds = intoImmer(
immer::flex_vector<std::string>{},
zug::filter([](const auto &pair) {
return pair.second.encrypted && !pair.second.shouldRotateSessionKey;
})
| zug::map([](const auto &pair) { return pair.first; }),
roomList.rooms
);
for (auto roomId : roomIds) {
auto userIds = roomList.rooms[roomId].joinedMemberIds();
auto devicesNotChanged = [oldClient, this](const auto &userId) {
return oldClient.devicesToSendKeys(userId) == devicesToSendKeys(userId);
};
// if any user has the device changes
if (!immer::all_of(userIds, devicesNotChanged)) {
kzo.client.dbg() << "rotate keys for room " << roomId << std::endl;
markRotate(roomId);
}
}
}
auto ClientModel::directRoomMap() const -> immer::map<std::string, std::string>
{
auto directs = accountData["m.direct"].content().get();
auto directItems = directs.items();
return std::accumulate(directItems.begin(), directItems.end(), immer::map<std::string, std::string>(),
[](auto acc, const auto &cur) {
auto [userId, roomIds] = cur;
if (!roomIds.is_array()) {
return acc;
}
for (auto roomId : roomIds) {
if (roomId.is_string()) {
acc = std::move(acc).set(roomId.template get<std::string>(), userId);
}
}
return acc;
}
);
}
auto ClientModel::roomIdsUnderTag(std::string tagId) const -> immer::map<std::string, double>
{
return std::accumulate(
roomList.rooms.begin(), roomList.rooms.end(),
immer::map<std::string, double>{},
[tagId](auto acc, auto cur) {
auto [roomId, room] = cur;
auto tags = room.tags();
if (tags.count(tagId)) {
acc = std::move(acc).set(roomId, tags[tagId]);
}
return acc;
}
);
}
auto ClientModel::roomIdsByTagId() const -> immer::map<std::string, immer::map<std::string, double>>
{
return std::accumulate(
roomList.rooms.begin(), roomList.rooms.end(),
immer::map<std::string, immer::map<std::string, double>>{},
[](auto acc, auto cur) {
auto [roomId, room] = cur;
auto tags = room.tags();
if (tags.empty()) {
acc = setIn(std::move(acc), ROOM_TAG_DEFAULT_ORDER, "", roomId);
} else {
for (const auto &[tagId, order] : tags) {
acc = setIn(std::move(acc), order, tagId, roomId);
}
}
return acc;
}
);
}
const Crypto &ClientModel::constCrypto() const
{
return crypto.value().get();
}
void ClientModel::maybeAddSaveEventsTrigger(const ClientModel &old)
{
SaveEventsRequested trigger;
using ELT = immer::flex_vector_transient<Event>;
auto addToTrigger = [&trigger](const std::string &roomId, ELT tl, ELT nonTl) {
if (!tl.empty()) {
trigger.timelineEvents = std::move(trigger.timelineEvents).set(roomId, std::move(tl).persistent());
}
if (!nonTl.empty()) {
trigger.nonTimelineEvents = std::move(trigger.nonTimelineEvents).set(roomId, std::move(nonTl).persistent());
}
};
immer::diff(old.roomList.rooms, roomList.rooms, immer::make_differ(
/* addedFn = */ [&addToTrigger](const auto &p) {
const auto &[roomId, room] = p;
ELT tl;
ELT nonTl;
for (const auto &[id, e] : room.messages) {
if (room.isInTimeline(id)) {
tl.push_back(e);
} else {
nonTl.push_back(e);
}
}
addToTrigger(roomId, std::move(tl), std::move(nonTl));
},
/* removedFn = */ [](auto &&) {},
/* changedFn = */ [&addToTrigger](const auto &p1, const auto &p2) {
const auto &roomId = p2.first;
const auto &newRoom = p2.second;
const auto &oldRoom = p1.second;
ELT tl;
ELT nonTl;
auto addEvent = [&tl, &nonTl, &newRoom](const auto &newPair) {
const auto &[eventId, event] = newPair;
if (newRoom.isInTimeline(eventId)) {
tl.push_back(event);
} else {
nonTl.push_back(event);
}
};
immer::diff(oldRoom.messages, newRoom.messages, immer::make_differ(
/* addedFn = */ addEvent,
/* removedFn = */ [](auto &&) {},
/* changedFn = */ [&addEvent](auto &&, auto &&newPair) {
addEvent(std::forward<decltype(newPair)>(newPair));
}
));
addToTrigger(roomId, std::move(tl), std::move(nonTl));
}
));
if (!trigger.timelineEvents.empty() || !trigger.nonTimelineEvents.empty()) {
nextTriggers = std::move(nextTriggers).push_back(trigger);
}
}
}
diff --git a/src/client/client-model.hpp b/src/client/client-model.hpp
index c3d46b2..d36fecf 100644
--- a/src/client/client-model.hpp
+++ b/src/client/client-model.hpp
@@ -1,756 +1,767 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <tuple>
#include <variant>
#include <string>
#include <optional>
#include <lager/context.hpp>
#include <boost/hana.hpp>
#include <serialization/std-optional.hpp>
#include <csapi/sync.hpp>
#include <file-desc.hpp>
#include <crypto.hpp>
#include <verification-tracker.hpp>
#include <serialization/immer-flex-vector.hpp>
#include <serialization/immer-box.hpp>
#include <serialization/immer-map.hpp>
#include <serialization/immer-array.hpp>
#include "clientfwd.hpp"
#include "device-list-tracker.hpp"
#include "room/room-model.hpp"
namespace Kazv
{
inline const std::string DEFTXNID{"0"};
enum RoomVisibility
{
Private,
Public,
};
enum CreateRoomPreset
{
PrivateChat,
PublicChat,
TrustedPrivateChat,
};
enum ThumbnailResizingMethod
{
Crop,
Scale,
};
struct ClientModel
{
std::string serverUrl;
std::string userId;
std::string token;
std::string deviceId;
bool loggedIn{false};
bool syncing{false};
bool shouldSync{true};
int firstRetryMs{1000};
int retryTimeFactor{2};
int maxRetryMs{30 * 1000};
int syncTimeoutMs{20000};
std::string initialSyncFilterId;
std::string incrementalSyncFilterId;
std::optional<std::string> syncToken;
RoomListModel roomList;
immer::map<std::string /* sender */, Event> presence;
immer::map<std::string /* type */, Event> accountData;
std::string nextTxnId{DEFTXNID};
immer::flex_vector<BaseJob> nextJobs;
immer::flex_vector<KazvTrigger> nextTriggers;
EventList toDevice;
std::optional<immer::box<Crypto>> crypto;
bool identityKeysUploaded{false};
DeviceListTracker deviceLists;
DeviceTrustLevel trustLevelNeededToSendKeys{DeviceTrustLevel::Unseen};
immer::array<std::string /* version */> versions;
immer::flex_vector<std::string /* deviceId */> devicesToSendKeys(std::string userId) const;
+ // Get the devices from which we are supposed to request keys.
+ // By the matrix specification, this is all verified devices of this user.
+ immer::map<std::string /* userId */, immer::flex_vector<std::string>> devicesToRequestKeys() const;
+
/// rotate sessions for a room if there is a user in the room with
/// devicesToSendKeys changes
void maybeRotateSessions(ClientModel oldClient);
std::pair<Event, std::optional<std::string> /* sessionKey */>
megOlmEncrypt(Event e, std::string roomId, Timestamp timeMs, RandomData random);
/// precondition: the one-time keys for those devices must already be claimed
/// @return A map from user id to device id to encrypted event for that device
immer::map<std::string, immer::map<std::string, Event>> olmEncryptSplit(Event e, immer::map<std::string, immer::flex_vector<std::string>> userIdToDeviceIdMap, RandomData random, bool attachSenderDeviceKeys = false);
/// Make a struct of DeviceKeys for identity keys of the current device.
DeviceKeys makeSelfDeviceKeys();
/**
* Convert a signature from Crypto into a json object.
*
* The returned object is a map from user id to key id to signature.
*/
json convertSignature(std::string signature) const;
/// @return number of one-time keys we need to generate
std::size_t numOneTimeKeysNeeded() const;
/// @return the mapping from room id to user id of direct rooms
auto directRoomMap() const -> immer::map<std::string, std::string>;
auto roomIdsUnderTag(std::string tagId) const -> immer::map<std::string, double>;
auto roomIdsByTagId() const -> immer::map<std::string, immer::map<std::string, double>>;
/// Get the const reference of crypto of this client.
///
/// `crypto.has_value()` must be true.
const Crypto &constCrypto() const;
/// Do func with crypto, returning its return value.
///
/// `crypto.has_value()` must be true.
template<class Func>
auto withCrypto(Func &&func) -> std::decay_t<std::invoke_result_t<Func &&, Crypto &>>
{
using ResT = std::decay_t<std::invoke_result_t<Func &&, Crypto &>>;
if constexpr (std::is_same_v<ResT, void>) {
crypto = std::move(crypto).value()
.update([f=std::forward<Func>(func)](Crypto c) mutable {
std::forward<Func>(f)(c);
return c;
});
} else {
std::optional<ResT> res;
crypto = std::move(crypto).value()
.update([f=std::forward<Func>(func), &res](Crypto c) mutable {
res = std::forward<Func>(f)(c);
return c;
});
return std::move(res).value();
}
}
// helpers
template<class Job>
struct MakeJobT
{
template<class ...Args>
constexpr auto make(Args &&...args) const {
if constexpr (Job::needsAuth()) {
return Job(
serverUrl,
token,
std::forward<Args>(args)...);
} else {
return Job(
serverUrl,
std::forward<Args>(args)...);
}
}
std::string serverUrl;
std::string token;
};
template<class Job>
constexpr auto job() const {
return MakeJobT<Job>{serverUrl, token};
}
inline void addJob(BaseJob j) {
nextJobs = std::move(nextJobs).push_back(std::move(j));
}
inline auto popAllJobs() {
auto jobs = std::move(nextJobs);
nextJobs = DEFVAL;
return jobs;
};
inline void addTrigger(KazvTrigger t) {
addTriggers({t});
}
inline void addTriggers(immer::flex_vector<KazvTrigger> c) {
nextTriggers = std::move(nextTriggers) + c;
}
inline auto popAllTriggers() {
auto triggers = std::move(nextTriggers);
nextTriggers = DEFVAL;
return triggers;
}
void maybeAddSaveEventsTrigger(const ClientModel &old);
using Action = ClientAction;
using Effect = ClientEffect;
using Result = ClientResult;
static Result update(ClientModel m, Action a);
};
// actions:
struct LoginAction {
std::string serverUrl;
std::string username;
std::string password;
std::optional<std::string> deviceName;
};
struct TokenLoginAction
{
std::string serverUrl;
std::string username;
std::string token;
std::string deviceId;
};
/**
* Login using the m.token.login flow.
*/
struct MLoginTokenLoginAction
{
std::string serverUrl;
std::string loginToken;
std::optional<std::string> deviceName;
};
struct LogoutAction {};
struct HardLogoutAction {};
struct GetWellknownAction
{
std::string userId;
};
struct GetVersionsAction
{
std::string serverUrl;
};
struct SyncAction {};
struct SetShouldSyncAction
{
bool shouldSync;
};
struct PaginateTimelineAction
{
std::string roomId;
/// Must be where the Gap is
std::string fromEventId;
std::optional<int> limit;
};
struct SendMessageAction
{
std::string roomId;
Event event;
std::optional<std::string> txnId{std::nullopt};
};
struct SendStateEventAction
{
std::string roomId;
Event event;
};
/**
* Saves an local echo.
*
* After dispatching this action, the result should be such that
* `result.dataStr("txnId")` contains the transaction id to be used
* in SendMessageAction.
*/
struct SaveLocalEchoAction
{
/// The room id
std::string roomId;
/// The event to send
Event event;
/// The chosen txnId for this event. If not specified, generate from the current ClientModel.
std::optional<std::string> txnId{std::nullopt};
};
/**
* Updates the status of an local echo.
*
* After dispatching this action, the local echo's status will be
* set to the one described in the action.
*/
struct UpdateLocalEchoStatusAction
{
/// The room id.
std::string roomId;
/// The chosen txnId for this event.
std::string txnId;
/// The updated status of this local echo.
LocalEchoDesc::Status status;
};
struct RedactEventAction
{
std::string roomId;
std::string eventId;
std::optional<std::string> reason;
};
struct CreateRoomAction
{
using Visibility = RoomVisibility;
using Preset = CreateRoomPreset;
Visibility visibility;
std::optional<std::string> roomAliasName;
std::optional<std::string> name;
std::optional<std::string> topic;
immer::array<std::string> invite;
//immer::array<Invite3pid> invite3pid;
std::optional<std::string> roomVersion;
JsonWrap creationContent;
immer::array<Event> initialState;
std::optional<Preset> preset;
std::optional<bool> isDirect;
JsonWrap powerLevelContentOverride;
};
struct GetRoomStatesAction
{
std::string roomId;
};
struct GetStateEventAction
{
std::string roomId;
std::string type;
std::string stateKey;
};
struct InviteToRoomAction
{
std::string roomId;
std::string userId;
};
struct JoinRoomByIdAction
{
std::string roomId;
};
struct JoinRoomAction
{
std::string roomIdOrAlias;
immer::array<std::string> serverName;
};
struct LeaveRoomAction
{
std::string roomId;
};
struct ForgetRoomAction
{
std::string roomId;
};
struct KickAction
{
std::string roomId;
std::string userId;
std::optional<std::string> reason;
};
struct BanAction
{
std::string roomId;
std::string userId;
std::optional<std::string> reason;
};
struct UnbanAction
{
std::string roomId;
std::string userId;
};
struct SetAccountDataPerRoomAction
{
std::string roomId;
Event accountDataEvent;
};
struct SetTypingAction
{
std::string roomId;
bool typing;
std::optional<int> timeoutMs;
};
struct PostReceiptAction
{
std::string roomId;
std::string eventId;
};
struct SetReadMarkerAction
{
std::string roomId;
std::string eventId;
};
struct UploadContentAction
{
FileDesc content;
std::optional<std::string> filename;
std::optional<std::string> contentType;
std::string uploadId; // to be used by library users
};
struct DownloadContentAction
{
std::string mxcUri;
std::optional<FileDesc> downloadTo;
};
struct DownloadThumbnailAction
{
std::string mxcUri;
int width;
int height;
std::optional<ThumbnailResizingMethod> method;
std::optional<bool> allowRemote;
std::optional<FileDesc> downloadTo;
};
struct ResubmitJobAction
{
BaseJob job;
};
struct ProcessResponseAction
{
Response response;
};
struct PostInitialFiltersAction
{
};
struct SetAccountDataAction
{
Event accountDataEvent;
};
struct SendToDeviceMessageAction
{
Event event;
immer::map<std::string, immer::flex_vector<std::string>> devicesToSend;
std::optional<std::string> txnId{std::nullopt};
};
/**
* Send multiple to device messages.
*
* Due to limitations of the spec, the type of the Events must be the same.
*/
struct SendMultipleToDeviceMessagesAction
{
/// A map from user id to device id to the event.
immer::map<std::string, immer::map<std::string, Event>> userToDeviceToEventMap;
/// An optional transaction id. Will be generated if not provided.
std::optional<std::string> txnId{std::nullopt};
};
struct UploadIdentityKeysAction
{
};
/**
* The action to generate one-time keys.
*
* `random.size()` must be at least `randomSize(numToGen)`.
*
* This action will not generate keys exceeding the local limit of olm.
*/
struct GenerateAndUploadOneTimeKeysAction
{
/// @return The size of random needed to generate
/// `numToGen` one-time keys
static std::size_t randomSize(std::size_t numToGen);
/// The number of keys to generate
std::size_t numToGen;
/// The random data used to generate keys
RandomData random;
};
struct QueryKeysAction
{
bool isInitialSync;
};
/**
* Ensure keys from devices of a user.
*
* After the reducer for this action completes,
* the ClientModel will contain information about the devices'
* keys, in the DeviceListTracker (ClientModel::deviceLists).
*
* The after receiving a response, the resulting EffectStatus will contain
* a data property `unsatisfied`. It is in the format:
*
* ```
* {
* "users": [userIds...], "devices": [[userId, deviceId]...]
* }
* ```
*
* If we requested all devices of a user `@foo:example.org` and the user is
* not available, then `data.at("unsatisfied").at("users")` will contain
* `@foo:example.org`.
*
* If we requested a device `Device1` of a user `@foo:example.org` and the
* device is not available, then `data.at("unsatisfied").at("devices")` will
* contain `["@foo:example.org", "Device1"]`.
*/
struct EnsureKeysFromDevicesAction
{
/**
* The map detailing the devices of which the keys to be fetched.
*
* This follows the same semantics as the query keys endpoint
* (/_matrix/client/v3/keys/query): if the device id list is
* empty, it will query all keys of that user. In this case,
* after receiving the response, we will also mark the device lists
* for that user as up-to-date.
*/
immer::map<
std::string /* userId */,
immer::flex_vector<std::string /* deviceId */>> userIdToDeviceIdsMap;
};
struct ClaimKeysAction
{
static std::size_t randomSize(immer::map<std::string, immer::flex_vector<std::string>> devicesToSend);
std::string roomId;
std::string sessionId;
std::string sessionKey;
immer::map<std::string, immer::flex_vector<std::string>> devicesToSend;
RandomData random;
};
/**
* The action to encrypt an megolm event for a room.
*
* If the action is successful, the result `r` will
* be such that `r.dataJson("encrypted")` contains the encrypted event *json*.
*
* If the megolm session is rotated, `r.dataStr("key")` will contain the key
* of the megolm session. Otherwise, `r.data().contains("key")` will be false.
*
* The Action may fail due to insufficient random data,
* when the megolm session needs to be rotated.
* In this case, the reducer for the Action will fail,
* and its result `r` will be such that
* `r.dataStr("reason") == "NotEnoughRandom"`.
* The user needs to provide random data of
* at least size `maxRandomSize()`.
*
*/
struct EncryptMegOlmEventAction
{
static std::size_t maxRandomSize();
static std::size_t minRandomSize();
/// The id of the room to encrypt for.
std::string roomId;
/// The event to encrypt.
Event e;
/// The timestamp, to determine whether the session should expire.
Timestamp timeMs;
/// Random data for the operation. Must be of at least size
/// `minRandomSize()`. If this is a retry of the previous operation
/// due to NotEnoughRandom, it must be of at least size `maxRandomSize()`.
RandomData random;
};
struct SetDeviceTrustLevelAction
{
std::string userId;
std::string deviceId;
DeviceTrustLevel trustLevel;
};
/**
* Set the trust levels of devices.
*
* If any of the devices is not found, this will return a
* failed EffectStatus with data
* `{"notFound": [[<userId>, <deviceId>]...]}`.
* Otherwise, it returns a successful EffectStatus.
*/
struct SetDevicesTrustLevelsAction
{
/// A map from userId to deviceId to the trust level to set.
immer::map<
std::string /* userId */,
immer::map<std::string /* deviceId */, DeviceTrustLevel>> trustLevelMap;
};
struct SetTrustLevelNeededToSendKeysAction
{
DeviceTrustLevel trustLevel;
};
/// Encrypt room key as olm and add it to the room's
/// pending keyshare slots.
/// This is to ensure atomicity and that we do not lose an olm-encrypted event.
struct PrepareForSharingRoomKeyAction
{
using UserIdToDeviceIdMap = immer::map<std::string, immer::flex_vector<std::string>>;
static std::size_t randomSize(UserIdToDeviceIdMap devices);
/// The room to share the key event in.
std::string roomId;
/// Devices to encrypt for.
UserIdToDeviceIdMap devices;
/// The key event to encrypt.
Event e;
/// The random data for the encryption. Must be of at least
/// size `randomSize(devices)`.
RandomData random;
};
/**
* Import keys from key backup file.
*
* On success, the reducer returns data with `imported` property
* being the number of keys imported. On failure, it returns data with
* `errorCode` and `error` properties set to the error in the process.
*/
struct ImportFromKeyBackupFileAction
{
/// The content of the key backup file.
std::string fileContent;
/// The password.
std::string password;
};
/**
* Notify that the verification tracker model has been changed.
*/
struct NotifyVerificationTrackerModelAction {};
+ struct RequestShareRoomSessionKeyAction
+ {
+ std::string roomId;
+ Event event;
+ immer::map<std::string, immer::flex_vector<std::string>> devices;
+ };
+
struct GetUserProfileAction
{
std::string userId;
};
struct SetAvatarUrlAction
{
std::optional<std::string> avatarUrl;
};
struct SetDisplayNameAction
{
std::optional<std::string> displayName;
};
/// Load events from the storage into the model
struct LoadEventsFromStorageAction
{
/// Map from room id to a list of
/// loaded events that should be put into the timeline. From oldest to latest.
immer::map<std::string, EventList> timelineEvents;
/// Map from room id to a list of
/// related events that should not be put into the timeline. From oldest to latest.
/// There might be events in the storage that is needed to display
/// existing events or room state (e.g. pinned events), but
/// the storage may not know its place in the timeline.
immer::map<std::string, EventList> relatedEvents;
};
/// Remove events from the model, keeping only the latest `maxToKeep` events.
/// For each room, this takes O(maxToKeep * log(maxToKeep)) time.
struct PurgeRoomTimelineAction
{
/// A map from roomId to maxToKeep
immer::map<std::string, std::size_t> roomIdToMaxToKeepMap;
};
template<class Archive>
void serialize(Archive &ar, ClientModel &m, std::uint32_t const version)
{
bool dummySyncing{false};
ar
& m.serverUrl
& m.userId
& m.token
& m.deviceId
& m.loggedIn
& dummySyncing
& m.firstRetryMs
& m.retryTimeFactor
& m.maxRetryMs
& m.syncTimeoutMs
& m.initialSyncFilterId
& m.incrementalSyncFilterId
& m.syncToken
& m.roomList
& m.presence
& m.accountData
& m.nextTxnId
& m.toDevice;
// version <= 1 uses std::optional<Crypto>
// while version >= 2 uses std::optional<immer::box<Crypto>>
if (version >= 2) {
ar & m.crypto;
} else {
if constexpr (typename Archive::is_loading()) {
std::optional<Crypto> crypto;
ar >> crypto;
if (crypto.has_value()) {
m.crypto = immer::box<Crypto>(std::move(crypto).value());
}
}
// otherwise is_saving, which will always use the latest version
// this is unreachable
}
ar
& m.identityKeysUploaded
& m.deviceLists
;
if (version >= 1) { ar & m.trustLevelNeededToSendKeys; }
}
}
BOOST_CLASS_VERSION(Kazv::ClientModel, 2)
diff --git a/src/client/client.cpp b/src/client/client.cpp
index 011b12d..139dfd3 100644
--- a/src/client/client.cpp
+++ b/src/client/client.cpp
@@ -1,800 +1,813 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <filesystem>
#include <algorithm>
#include <chrono>
#include <lager/constant.hpp>
#include "client.hpp"
#include "client-model.hpp"
#include "alias.hpp"
#include "immer-utils.hpp"
namespace Kazv
{
static Timestamp tsNow()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
}
static Client::PromiseT sendMultiVerificationEvents(Client::ContextT ctx, VerificationTracker::PendingEvents pendingEvents)
{
std::vector<Client::PromiseT> ps;
for (auto ed : pendingEvents) {
ps.push_back(ctx.dispatch(SendToDeviceMessageAction{
ed.event,
{{ed.toUserId, {ed.toDeviceId}}}
}));
}
return ctx.promiseInterface().all(ps);
}
static Client::PromiseT maybeMarkDevicesVerified(Client::ContextT ctx, VerificationTracker::PendingEvents pendingEvents)
{
immer::map<std::string, immer::map<std::string, DeviceTrustLevel>> trustLevelMap;
for (auto ed : pendingEvents) {
if (VerificationUtils::typeOf(ed.event) == VerificationEventTypes::tDone) {
// a process asking us to send an outbound done event means that we have verified them
trustLevelMap = setIn(trustLevelMap, DeviceTrustLevel::Verified, ed.toUserId, ed.toDeviceId);
kzo.client.dbg() << "maybeMarkDevicesVerified: verified " << ed.toUserId << "/" << ed.toDeviceId << std::endl;
}
}
if (!trustLevelMap.empty()) {
return ctx.dispatch(SetDevicesTrustLevelsAction{trustLevelMap});
}
return ctx.createResolvedPromise({});
}
static Client::PromiseT verificationChangePostProcess(Client::ContextT ctx, VerificationTracker::PendingEvents es)
{
return ctx.dispatch(NotifyVerificationTrackerModelAction{})
.then([ctx, es](auto &&) {
return maybeMarkDevicesVerified(ctx, es);
})
.then([ctx, es](auto &&) {
return sendMultiVerificationEvents(ctx, es);
});
}
Client::Client(lager::reader<SdkModel> sdk,
ContextT ctx, std::nullopt_t)
: m_sdk(sdk)
, m_client(sdk.map(&SdkModel::c))
, m_ctx(std::move(ctx))
{
}
Client::Client(lager::reader<SdkModel> sdk,
ContextWithDepsT ctx)
: m_sdk(sdk)
, m_client(sdk.map(&SdkModel::c))
, m_ctx(ctx)
, m_deps(std::move(ctx))
{
}
Client::Client(InEventLoopTag,
ContextWithDepsT ctx)
: m_sdk(std::nullopt)
, m_client(std::nullopt)
, m_ctx(ctx)
, m_deps(std::move(ctx))
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, KAZV_ON_EVENT_LOOP_VAR(true)
#endif
{
}
Client::Client(InEventLoopTag,
ContextT ctx, DepsT deps)
: m_sdk(std::nullopt)
, m_client(std::nullopt)
, m_ctx(std::move(ctx))
, m_deps(std::move(deps))
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, KAZV_ON_EVENT_LOOP_VAR(true)
#endif
{
}
Client Client::toEventLoop() const
{
return Client(InEventLoopTag{}, m_ctx, m_deps.value());
}
Room Client::room(std::string id) const
{
if (m_deps.has_value()) {
return Room(sdkCursor(), lager::make_constant(id), m_ctx, m_deps.value());
} else {
return Room(sdkCursor(), lager::make_constant(id), m_ctx);
}
}
Room Client::roomByCursor(lager::reader<std::string> id) const
{
if (m_deps.has_value()) {
return Room(sdkCursor(), id, m_ctx, m_deps.value());
} else {
return Room(sdkCursor(), id, m_ctx);
}
}
auto Client::passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName, bool startSyncingOnSuccess) const
-> PromiseT
{
auto p1 = m_ctx.dispatch(LoginAction{
homeserver, username, password, deviceName});
if (startSyncingOnSuccess) {
p1.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
// It is meaningless to wait for it in a Promise
// that is never exposed to the user.
that.startSyncing();
});
}
return p1;
}
auto Client::passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName) const
-> PromiseT
{
return passwordLogin(std::move(homeserver), std::move(username),
std::move(password), std::move(deviceName), /* startSyncingOnSuccess = */ true);
}
auto Client::mLoginTokenLogin(
std::string homeserver,
std::string loginToken,
std::optional<std::string> deviceName,
bool startSyncingOnSuccess
) const -> PromiseT
{
auto p1 = m_ctx.dispatch(MLoginTokenLoginAction{
homeserver, loginToken, deviceName});
if (startSyncingOnSuccess) {
p1.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
that.startSyncing();
});
}
return p1;
}
auto Client::mLoginTokenLogin(
std::string homeserver,
std::string loginToken,
std::optional<std::string> deviceName
) const -> PromiseT
{
return mLoginTokenLogin(std::move(homeserver), std::move(loginToken),
std::move(deviceName), /* startSyncingOnSuccess = */ true);
}
auto Client::tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId, bool startSyncingOnSuccess) const
-> PromiseT
{
auto p1 = m_ctx.dispatch(TokenLoginAction{
homeserver, username, token, deviceId});
if (startSyncingOnSuccess) {
p1.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
that.startSyncing();
});
}
return p1;
}
auto Client::tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId) const
-> PromiseT
{
return tokenLogin(std::move(homeserver), std::move(username),
std::move(token), std::move(deviceId), /* startSyncingOnSuccess = */ true);
}
auto Client::shouldSync() const -> lager::reader<bool> {
return this->clientCursor()[&ClientModel::shouldSync];
}
auto Client::logout() const
-> PromiseT
{
return stopSyncing().then([ctx=m_ctx] ([[maybe_unused]] auto stat) {
return ctx.dispatch(HardLogoutAction{});
});
}
auto Client::autoDiscover(std::string userId) const
-> PromiseT
{
return m_ctx.dispatch(GetWellknownAction{userId})
.then([that=toEventLoop()](auto stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
return that.m_ctx.dispatch(GetVersionsAction{stat.dataStr("homeserverUrl")})
.then([that, stat](auto stat2) {
if (!stat2.success()) {
return stat2;
} else {
return stat;
}
});
});
}
auto Client::createRoom(
RoomVisibility v,
std::optional<std::string> name,
std::optional<std::string> alias,
immer::array<std::string> invite,
std::optional<bool> isDirect,
bool allowFederate,
std::optional<std::string> topic,
JsonWrap powerLevelContentOverride,
std::optional<CreateRoomPreset> preset,
immer::array<Event> initialState
) const
-> PromiseT
{
CreateRoomAction a;
a.visibility = v;
a.name = name;
a.roomAliasName = alias;
a.invite = invite;
a.isDirect = isDirect;
a.topic = topic;
a.powerLevelContentOverride = powerLevelContentOverride;
// Synapse won't buy it if we do not provide
// a creationContent object.
a.creationContent = json{
{"m.federate", allowFederate}
};
a.preset = preset;
a.initialState = initialState;
return m_ctx.dispatch(std::move(a));
}
auto Client::joinRoomById(std::string roomId) const -> PromiseT
{
return m_ctx.dispatch(JoinRoomByIdAction{roomId});
}
auto Client::joinRoom(std::string roomId, immer::array<std::string> serverName) const
-> PromiseT
{
return m_ctx.dispatch(JoinRoomAction{roomId, serverName});
}
auto Client::uploadContent(immer::box<Bytes> content,
std::string uploadId,
std::optional<std::string> filename,
std::optional<std::string> contentType) const
-> PromiseT
{
return m_ctx.dispatch(UploadContentAction{
FileDesc(FileContent{content.get().begin(), content.get().end()}),
filename, contentType, uploadId});
}
auto Client::uploadContent(FileDesc file) const
-> PromiseT
{
auto basename = file.name()
? std::optional(std::filesystem::path(file.name().value()).filename().string())
: std::nullopt;
return m_ctx.dispatch(UploadContentAction{
file,
// use only basename to prevent path info being leaked
basename,
file.contentType(),
// uploadId unused
std::string{}});
}
std::string Client::mxcUriToHttpV1(std::string mxcUri) const {
using namespace CursorOp;
auto [serverName, mediaId] = mxcUriToMediaDesc(mxcUri);
return (+clientCursor())
.template job<GetContentJobV1>()
.make(serverName, mediaId).url();
}
auto Client::downloadContent(std::string mxcUri, std::optional<FileDesc> downloadTo) const
-> PromiseT
{
return m_ctx.dispatch(DownloadContentAction{mxcUri, downloadTo});
}
auto Client::downloadThumbnail(
std::string mxcUri,
int width,
int height,
std::optional<ThumbnailResizingMethod> method,
std::optional<FileDesc> downloadTo) const
-> PromiseT
{
return m_ctx.dispatch(DownloadThumbnailAction{mxcUri, width, height, method, std::nullopt, downloadTo});
}
auto Client::startSyncing() const -> PromiseT
{
KAZV_VERIFY_THREAD_ID();
using namespace Kazv::CursorOp;
if (+syncing()) {
return m_ctx.createResolvedPromise(true);
}
auto p1 = ensureReadyForSyncing();
p1
.then([m_ctx=m_ctx](auto stat) {
m_ctx.dispatch(SetShouldSyncAction{true});
return stat;
})
.then([that=toEventLoop()](auto stat) {
if (stat.success()) {
that.syncForever();
}
});
return p1;
}
auto Client::processVerificationEventsFromSync(EventList toDeviceEvents) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), ves=toDeviceEvents](auto &&stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto now = tsNow();
auto es = vt.processIncoming(now, ves);
auto p = maybeMarkDevicesVerified(that.m_ctx, es);
for (auto ed : es) {
// We don't actually need to wait for these events to
// be sent before we proceed into next sync cycle.
that.m_ctx.dispatch(SendToDeviceMessageAction{
ed.event,
{{ed.toUserId, {ed.toDeviceId}}}
});
}
return p.then([ctx=that.m_ctx](const auto &) {
return ctx.dispatch(NotifyVerificationTrackerModelAction{});
});
});
}
auto Client::requestOutgoingToDeviceVerification(std::string userId, std::string deviceId) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), userId, deviceId](auto &&stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
return that.m_ctx.dispatch(EnsureKeysFromDevicesAction{
{{userId, {deviceId}}},
});
})
.then([that=toEventLoop(), userId, deviceId](auto &&stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto deviceOpt = that.clientCursor().map([userId, deviceId](const ClientModel &client) {
return client.deviceLists.get(userId, deviceId);
}).make().get();
if (!deviceOpt) {
return that.m_ctx.createResolvedPromise({ /* succ = */ false, json::object({
{"errorCode", "MOE.KAZV.MXC.NO_DEVICE_KEYS"},
{"error", "Cannot obtain device keys"},
})});
}
auto device = deviceOpt.value();
auto es = vt.requestOutgoingToDevice(VerificationUtils::DeviceIdentity{
userId,
deviceId,
device.ed25519Key,
}, tsNow());
return verificationChangePostProcess(that.m_ctx, es);
});
}
auto Client::readyForVerification(std::string userId, std::string deviceId) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), userId, deviceId](auto &&stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
return that.m_ctx.dispatch(EnsureKeysFromDevicesAction{
{{userId, {deviceId}}},
});
})
.then([that=toEventLoop(), userId, deviceId](auto &&stat) {
if (!stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto deviceOpt = that.clientCursor().map([userId, deviceId](const ClientModel &client) {
return client.deviceLists.get(userId, deviceId);
}).make().get();
if (!deviceOpt) {
return that.m_ctx.createResolvedPromise({ /* succ = */ false, json::object({
{"errorCode", "MOE.KAZV.MXC.NO_DEVICE_KEYS"},
{"error", "Cannot obtain device keys"},
})});
}
auto device = deviceOpt.value();
vt.setTheirIdentity(VerificationUtils::DeviceIdentity{
userId,
deviceId,
device.ed25519Key,
});
auto es = vt.userReady(userId, deviceId);
return verificationChangePostProcess(that.m_ctx, es);
});
}
auto Client::cancelVerification(std::string userId, std::string deviceId) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), userId, deviceId](auto &&) {
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto es = vt.userCancel(userId, deviceId);
return verificationChangePostProcess(that.m_ctx, es);
});
}
auto Client::confirmVerificationSasMatch(std::string userId, std::string deviceId) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), userId, deviceId](auto &&) {
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto es = vt.userConfirmMatch(userId, deviceId);
return verificationChangePostProcess(that.m_ctx, es);
});
}
auto Client::denyVerificationSasMatch(std::string userId, std::string deviceId) const -> PromiseT
{
return ensureInitVerificationTracker()
.then([that=toEventLoop(), userId, deviceId](auto &&) {
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
auto es = vt.userDenyMatch(userId, deviceId);
return verificationChangePostProcess(that.m_ctx, es);
});
}
auto Client::ensureInitVerificationTracker() const -> PromiseT
{
if (!m_deps) {
return m_ctx.createResolvedPromise(false);
}
bool hasCrypto{clientCursor().map([](const auto &c) {
return c.crypto.has_value();
}).make().get()};
if (!hasCrypto) {
return m_ctx.createResolvedPromise(false);
}
return m_ctx.createResolvedPromise({})
.then([that=toEventLoop()](auto) {
auto &vt = lager::get<VerificationTracker>(that.m_deps.value());
if (vt.identity.userId.empty()) {
auto client = that.clientCursor().get();
const auto &crypto = client.constCrypto();
vt.identity = {
client.userId,
client.deviceId,
crypto.ed25519IdentityKey(),
};
}
return EffectStatus{/* succ = */ true};
});
}
+ auto Client::requestShareRoomSessionKey(
+ std::string roomId,
+ Event event,
+ immer::map<std::string, immer::flex_vector<std::string>> devices
+ ) const -> PromiseT
+ {
+ return m_ctx.dispatch(RequestShareRoomSessionKeyAction{
+ std::move(roomId),
+ std::move(event),
+ std::move(devices)
+ });
+ }
+
auto Client::syncForever(std::optional<int> retryTime) const -> void
{
KAZV_VERIFY_THREAD_ID();
// assert (m_deps);
using namespace CursorOp;
bool shouldSync = +clientCursor()[&ClientModel::shouldSync];
if (! shouldSync) {
return;
}
doOneSyncCycle()
.then([that=toEventLoop(), retryTime](auto stat) {
if (stat.success()) {
that.syncForever(); // reset retry time
} else {
auto firstRetryTime = +that.clientCursor()[&ClientModel::firstRetryMs];
auto retryTimeFactor = +that.clientCursor()[&ClientModel::retryTimeFactor];
auto maxRetryTime = +that.clientCursor()[&ClientModel::maxRetryMs];
auto curRetryTime = retryTime ? retryTime.value() : firstRetryTime;
if (curRetryTime > maxRetryTime) { curRetryTime = maxRetryTime; }
auto nextRetryTime = curRetryTime * retryTimeFactor;
kzo.client.warn() << "Sync failed, retrying in " << curRetryTime << "ms" << std::endl;
auto &jh = getJobHandler(that.m_deps.value());
jh.setTimeout([that=that.toEventLoop(), nextRetryTime]() { that.syncForever(nextRetryTime); },
curRetryTime);
}
});
}
auto Client::stopSyncing() const -> PromiseT
{
return m_ctx.dispatch(SetShouldSyncAction{false});
}
auto Client::ensureReadyForSyncing() const -> PromiseT
{
KAZV_VERIFY_THREAD_ID();
using namespace Kazv::CursorOp;
return m_ctx.createResolvedPromise(true)
.then([that=toEventLoop()](auto) {
// post filters, if filters are incomplete
if ((+that.clientCursor()[&ClientModel::initialSyncFilterId]).empty()
|| (+that.clientCursor()[&ClientModel::incrementalSyncFilterId]).empty()) {
return that.m_ctx.dispatch(PostInitialFiltersAction{});
}
return that.m_ctx.createResolvedPromise(true);
})
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
// Upload identity keys if we need to
if (+that.clientCursor()[&ClientModel::crypto]
&& ! +that.clientCursor()[&ClientModel::identityKeysUploaded]) {
return that.m_ctx.dispatch(UploadIdentityKeysAction{});
} else {
return that.m_ctx.createResolvedPromise(true);
}
});
}
auto Client::doOneSyncCycle() const -> PromiseT
{
KAZV_VERIFY_THREAD_ID();
using namespace CursorOp;
bool isInitialSync = !(+clientCursor()[&ClientModel::syncToken]).has_value();
auto syncRes = m_ctx.dispatch(SyncAction{});
auto uploadOneTimeKeysRes = syncRes
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
auto &rg = lager::get<RandomInterface &>(that.m_deps.value());
bool hasCrypto{+that.clientCursor()[&ClientModel::crypto]};
if (! hasCrypto) {
return that.m_ctx.createResolvedPromise(true);
}
auto numKeysToGenerate = (+that.clientCursor()).numOneTimeKeysNeeded();
return that.m_ctx.dispatch(GenerateAndUploadOneTimeKeysAction{
numKeysToGenerate,
rg.generateRange<RandomData>(GenerateAndUploadOneTimeKeysAction::randomSize(numKeysToGenerate))
});
});
auto queryKeysRes = syncRes
.then([that=toEventLoop(), isInitialSync](auto stat) {
if (! stat.success()) {
return that.m_ctx.createResolvedPromise(stat);
}
bool hasCrypto{+that.clientCursor()[&ClientModel::crypto]};
return hasCrypto
? that.m_ctx.dispatch(QueryKeysAction{isInitialSync})
: that.m_ctx.createResolvedPromise(true);
});
auto processVerificationEventsRes = syncRes
.then([that=toEventLoop()](EffectStatus stat) {
if (!stat.success() || !that.m_deps ||
!that.clientCursor().map([](const auto &c) {
return c.crypto.has_value();
}).make().get()) {
return that.m_ctx.createResolvedPromise(stat);
}
kzo.client.dbg() << "processVerificationEvents: " << stat.data().get().dump() << std::endl;
EventList ves = stat.data().get().at("verificationEvents").at("toDevice").template get<EventList>();
return that.processVerificationEventsFromSync(ves);
});
return m_ctx.promiseInterface()
.all(std::vector<PromiseT>{uploadOneTimeKeysRes, queryKeysRes, processVerificationEventsRes});
}
lager::reader<ClientModel> Client::clientCursor() const
{
KAZV_VERIFY_THREAD_ID();
if (m_client.has_value()) {
return m_client.value();
} else {
assert(m_deps.has_value());
return lager::get<SdkModelCursorKey>(m_deps.value())->map(&SdkModel::c);
}
}
const lager::reader<SdkModel> &Client::sdkCursor() const
{
KAZV_VERIFY_THREAD_ID();
if (m_sdk.has_value()) {
return m_sdk.value();
} else {
assert(m_deps.has_value());
return *(lager::get<SdkModelCursorKey>(m_deps.value()));
}
}
auto Client::getProfile(std::string userId) const -> PromiseT
{
return m_ctx.dispatch(GetUserProfileAction{userId});
}
auto Client::setAvatarUrl(std::optional<std::string> avatarUrl) const -> PromiseT
{
return m_ctx.dispatch(SetAvatarUrlAction{avatarUrl});
}
auto Client::setDisplayName(std::optional<std::string> displayName) const -> PromiseT
{
return m_ctx.dispatch(SetDisplayNameAction{displayName});
}
auto Client::devicesOfUser(std::string userId) const -> lager::reader<immer::flex_vector<DeviceKeyInfo>>
{
return clientCursor()
[&ClientModel::deviceLists]
[&DeviceListTracker::deviceLists]
[userId]
[lager::lenses::or_default]
.xform(containerMap(immer::flex_vector<DeviceKeyInfo>{}, zug::map([](const auto &pair) {
const auto &[deviceId, info] = pair;
(void)deviceId;
return info;
})));
}
auto Client::setDeviceTrustLevel(std::string userId, std::string deviceId, DeviceTrustLevel trustLevel) const -> PromiseT
{
return m_ctx.dispatch(SetDeviceTrustLevelAction{userId, deviceId, trustLevel});
}
auto Client::trustLevelNeededToSendKeys() const -> lager::reader<DeviceTrustLevel>
{
return clientCursor()[&ClientModel::trustLevelNeededToSendKeys];
}
auto Client::setTrustLevelNeededToSendKeys(DeviceTrustLevel trustLevel) const -> PromiseT
{
return m_ctx.dispatch(SetTrustLevelNeededToSendKeysAction{trustLevel});
}
auto Client::directRoomMap() const -> lager::reader<immer::map<std::string, std::string>>
{
return clientCursor().map(&ClientModel::directRoomMap);
}
auto Client::roomIdsUnderTag(std::string tagId) const -> lager::reader<immer::map<std::string, double>>
{
return clientCursor().map([tagId](const auto &c) {
return c.roomIdsUnderTag(tagId);
});
}
auto Client::roomIdsByTagId() const -> lager::reader<immer::map<std::string, immer::map<std::string, double>>>
{
return clientCursor().map(&ClientModel::roomIdsByTagId);
}
auto Client::accountData() const -> lager::reader<immer::map<std::string, Event>>
{
return clientCursor()[&ClientModel::accountData];
}
auto Client::setAccountData(Event accountDataEvent) const -> PromiseT
{
return m_ctx.dispatch(SetAccountDataAction{accountDataEvent});
}
NotificationHandler Client::notificationHandler() const
{
return NotificationHandler(clientCursor());
}
auto Client::getVersions(std::string homeserver) const -> PromiseT
{
return m_ctx.dispatch(GetVersionsAction{homeserver});
}
auto Client::supportVersions() const -> lager::reader<immer::array<std::string>>
{
return clientCursor()[&ClientModel::versions];
}
auto Client::addDirectRoom(std::string userId, std::string roomId) const -> PromiseT
{
auto content = this->accountData().get()["m.direct"].content().get();
if (content.contains(userId)) {
auto& rooms = content[userId];
if (rooms.is_array()) {
if (std::find(rooms.begin(), rooms.end(), roomId) != rooms.end()) {
// The roomId is already in the m.direct, do nothing
return m_ctx.createResolvedPromise(true);
}
} else {
rooms = json::array({});
}
} else {
content.emplace(userId, json::array({}));
}
content[userId].push_back(roomId);
return Client::setAccountData(json{
{"type", "m.direct"},
{"content", std::move(content)}
});
}
auto Client::getRoomIdByAliasJob(std::string roomAlias) const -> BaseJob
{
return Kazv::getRoomIdByAliasJob(clientCursor().get(), roomAlias);
}
auto Client::purgeRoomEvents(immer::map<std::string, std::size_t> roomIdToMaxToKeepMap) const -> PromiseT
{
return m_ctx.dispatch(PurgeRoomTimelineAction{roomIdToMaxToKeepMap});
}
auto Client::loadEventsFromStorage(immer::map<std::string, EventList> timelineEvents, immer::map<std::string, EventList> relatedEvents) const -> PromiseT
{
return m_ctx.dispatch(LoadEventsFromStorageAction{
std::move(timelineEvents),
std::move(relatedEvents),
});
}
auto Client::importFromKeyBackupFile(std::string fileContent, std::string password) const -> PromiseT
{
return m_ctx.dispatch(ImportFromKeyBackupFileAction{
std::move(fileContent),
std::move(password),
});
}
}
diff --git a/src/client/client.hpp b/src/client/client.hpp
index b40c5be..9c074ff 100644
--- a/src/client/client.hpp
+++ b/src/client/client.hpp
@@ -1,849 +1,866 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/reader.hpp>
#include <immer/box.hpp>
#include <immer/map.hpp>
#include <immer/flex_vector.hpp>
#include <immer/flex_vector_transient.hpp>
#include "sdk-model.hpp"
#include "client/client-model.hpp"
#include "client/actions/content.hpp"
#include "sdk-model-cursor-tag.hpp"
#include "get-content-job-v1.hpp"
#include "room/room.hpp"
#include "notification-handler.hpp"
#include <verification-tracker.hpp>
namespace Kazv
{
/**
* Represent a Matrix client.
*
* If the Client is constructed from a cursor originated from
* a root whose event loop is on thread A, then we say that
* the Client belongs to thread A. If the Client is not constructed
* from a cursor, then we say that the Client belongs to the thread
* where the event loop of the context runs.
*
* All methods in this class that take a cursor only take a cursor
* on the same thread as the Client. All methods in this class that
* return a cursor will return a cursor on the same thread as the Client.
*
* All methods in this class must be run on the same thread as the
* the Client. If the Client is not constructed from a cursor,
* copy-constructing another Client from this is safe from any thread.
* If the Client is constructed from a cursor, copy-constructing another
* Client is safe only from the same thread as this Client.
*
* ## Device verification integration
*
* The `startSyncing()` function will automatically feed verification events
* received from sync into the VerificationTracker, and send outbound events
* according to the result returned from the VerificationTracker.
*
* The verification processing functions in this class (
* requestOutgoingToDeviceVerification(),
* readyForVerification(),
* cancelVerification(), confirmVerificationSasMatch(),
* denyVerificationSasMatch()) will also automatically send outbound
* events according to the result returned from the VerificationTracker.
* They will also ensure the device keys of the devices to be verified
* is available during the verification process.
*
* Additionally, this class will cause the VerificationTrackerModelChanged
* trigger to be emitted when appropriate.
* If you use a `lager::sensor` to observe the `VerificationTracker::model`,
* you should call `lager::commit()` on the `lager::sensor` **in the event loop
* thread** after you received VerificationTrackerModelChanged.
*
* You must always access VerificationTracker::model from the event loop thread,
* because the modifications to VerificationTracker always happen in the event
* loop thread. However, once you have a copy of the VerificationTrackerModel,
* you are free to copy it and pass it onto other threads.
*
* ## Error handling
*
* A lot of functions in Client and Room are asynchronous actions.
* These actions return the result via a Promise.
* If an API request has failed, the Promise p will satisfy the following:
* - `!p.success()`
* - `p.dataStr("error")` will contain the error message from the response.
* - `p.dataStr("errorCode")` will contain the matrix error code, if available,
* or the HTTP status code otherwise.
*
* What information is resolved if the API request has succeeded is defined
* by individual functions.
*/
class Client
{
public:
using ActionT = ClientAction;
using DepsT = lager::deps<JobInterface &, EventInterface &, SdkModelCursorKey, RandomInterface &, VerificationTracker &
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, EventLoopThreadIdKeeper &
#endif
>;
using ContextT = Context<ActionT>;
using ContextWithDepsT = Context<ActionT, DepsT>;
using PromiseT = SingleTypePromise<DefaultRetType>;
struct InEventLoopTag {};
/**
* Constructor.
*
* Construct the client. Without Deps support.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(lager::reader<SdkModel> sdk,
ContextT ctx, std::nullopt_t);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* This enables startSyncing() to work properly.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(lager::reader<SdkModel> sdk,
ContextWithDepsT ctx);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* The constructed Client belongs to the thread of event loop.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(InEventLoopTag,
ContextWithDepsT ctx);
/**
* Constructor.
*
* Construct the client, with Deps support.
*
* The constructed Client belongs to the thread of event loop.
*
* @warning You should not use this directly. Use
* Sdk::client() instead.
*/
Client(InEventLoopTag, ContextT ctx, DepsT deps);
/**
* Create a Client that is not constructed from a cursor.
*
* The returned Client belongs to the thread of event loop.
*
* This function is thread-safe if every thread calls it
* using different objects.
*
* @return A Client not constructed from a cursor.
*/
Client toEventLoop() const;
/* lager::reader<immer::map<std::string, Room>> */
inline auto rooms() const {
return clientCursor()
[&ClientModel::roomList]
[&RoomListModel::rooms];
}
/* lager::reader<RangeT<std::string>> */
inline auto roomIds() const {
return rooms().xform(
zug::map([](auto m) {
return intoImmer(
immer::flex_vector<std::string>{},
zug::map([](auto val) { return val.first; }),
m);
}));
}
auto roomIdsUnderTag(std::string tagId) const -> lager::reader<immer::map<std::string, double>>;
/**
* Get the room ids under all tags.
*
* @return A lager::reader containing the map from tag id to a map from room id to order.
* Rooms without a tag will be under the tag id of the empty string.
*/
auto roomIdsByTagId() const -> lager::reader<immer::map<std::string, immer::map<std::string, double>>>;
KAZV_WRAP_ATTR(ClientModel, clientCursor(), serverUrl)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), loggedIn)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), userId)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), token)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), deviceId)
KAZV_WRAP_ATTR(ClientModel, clientCursor(), toDevice)
/**
* Get the room with @c id .
*
* This is equivalent to `roomByCursor(lager::make_constant(id))`.
*
* @param id The room id.
* @return A Room representing the room with `id`.
*/
Room room(std::string id) const;
/**
* Get the room with `id`.
*
* The Room returned will change as the content in `id` changes.
*
* For example, you can have the Room that is always the first
* alphabetically in all rooms by:
*
* \code{.cpp}
* auto someProcessing =
* zug::map([=](auto ids) {
* std::sort(ids.begin(), ids.end(), [=](auto id1, auto id2) {
* using namespace Kazv::CursorOp;
* return (+client.room(id1).name()) < (+client.room(id2).name());
* });
* return ids;
* });
* auto room =
* client.roomByCursor(
* client.roomIds().xform(someProcessing)[0]);
* \endcode
*
* @param id A lager::reader<std::string> containing the room id.
* @return A Room representing the room with `id`.
*/
Room roomByCursor(lager::reader<std::string> id) const;
/**
* Login using the password.
*
* This will create a new session on the homeserver.
*
* If startSyncingOnSuccess is true and the returned Promise
* resolves successfully, this will call `startSyncing()`.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @param username The username. This can be the full user id or
* just the local part. E.g. `tusooa`, `@tusooa:tusooa.xyz`.
* @param password The password.
* @param deviceName Optionally, a custom device name. If empty, `libkazv`
* will be used.
* @param startSyncingOnSuccess Start syncing immediately when
* the login is successful.
* @return A Promise that resolves when logging in successfully, or
* when there is an error.
*/
PromiseT passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName, bool startSyncingOnSuccess) const;
/**
* Equivalent to `passwordLogin(homeserver, username, password, deviceName, true)`.
*
* This is an overloaded function.
*/
[[deprecated("Specify startSyncingOnSuccess explicitly")]]
PromiseT passwordLogin(std::string homeserver, std::string username,
std::string password, std::string deviceName) const;
/**
* Login using `token` and `deviceId`.
*
* This will not make a request. Library users should make sure
* the information is correct and the token and the device id are valid.
*
* If startSyncingOnSuccess is true and the returned Promise resolves
* successfully, this will call `startSyncing()`.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @param username The full user id. E.g. `@tusooa:tusooa.xyz`.
* @param token The access token.
* @param deviceId The device id that is paired with `token`.
* @param startSyncingOnSuccess Start syncing immediately when
* the login is successful.
* @return A Promise that resolves when the account information is filled in.
*/
PromiseT tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId, bool startSyncingOnSuccess) const;
/**
* Equivalent to `tokenLogin(homeserver, username, token, deviceId, true)`.
*
* This is an overloaded function.
*/
[[deprecated("Specify startSyncingOnSuccess explicitly")]]
PromiseT tokenLogin(std::string homeserver, std::string username,
std::string token, std::string deviceId) const;
/**
* Login using a login token.
*
* This will create a new session on the homeserver.
*
* If startSyncingOnSuccess is true and the returned Promise resolves
* successfully, this will call `startSyncing()`.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @param loginToken The login token.
* @param deviceName Optionally, a custom device name. If empty, `libkazv`
* will be used.
* @param startSyncingOnSuccess Start syncing immediately when
* the login is successful.
* @return A Promise that resolves when logging in successfully, or
* when there is an error.
*/
PromiseT mLoginTokenLogin(
std::string homeserver,
std::string loginToken,
std::optional<std::string> deviceName,
bool startSyncingOnSuccess
) const;
/**
* Equivalent to `mLoginTokenLogin(homeserver, loginToken, deviceName, true)`.
*
* This is an overloaded function.
*/
[[deprecated("Specify startSyncingOnSuccess explicitly")]]
PromiseT mLoginTokenLogin(
std::string homeserver,
std::string loginToken,
std::optional<std::string> deviceName
) const;
/**
* Get the `shouldSync` field of current ClientModel.
*
* The shouldSync flag is only used internally by `startSyncing()`
* and `stopSyncing()`.
* If you use `doOneSyncCycle()` and implement your own sync loop,
* you probably do not need this.
*
* @return A lager::reader of bool of the `shouldSync` field of the ClientModel
*/
auto shouldSync() const -> lager::reader<bool>;
/**
* Set the `shouldSync` internal flag to false,
* and then logout current session.
*
* The access token in this client will no longer be valid.
*
* Meanwhile, clear the current token and set loggedIn to false.
*
* If you do not use `startSyncing()` and implement your own
* sync loop using `doOneSyncCycle()`, you need to stop syncing
* by yourself before calling this function.
*
* @return A promise that resolves when the syncing is stopped.
*/
PromiseT logout() const;
/**
* Automatically discover the homeserver for `userId`.
*
* If the operation succeeds, `r.dataStr("homeserverUrl")` will contain
* the url suitable to pass to `tokenLogin()` and `passwordLogin()`.
*
* If there is no well-known file (i.e. server responds with 404),
* `r.dataStr("homeserverUrl")` will contain the domain part of the user
* id (`https://example.org` for `@foo:example.org`).
*
* @param userId The full user id. E.g. `@foo:example.org`.
* @return A Promise that resolves when the auto-discovery finishes.
*/
PromiseT autoDiscover(std::string userId) const;
/**
* Create a room.
*
* @param v The visibility of the room.
* @param name The name of the room.
* @param alias The alias of the room.
* @param invite User ids to invite to this room.
* @param isDirect Whether this room is a direct chat.
* @param allowFederate Whether to allow users from other homeservers
* to join this room.
* @param topic The topic of the room.
* @param powerLevelContentOverride The content of the m.room.power_levels
* state event to override the default.
* @param preset The preset to create the room with.
* @return A Promise that resolves when the room is created,
* or when there is an error.
*/
PromiseT createRoom(
RoomVisibility v,
std::optional<std::string> name = {},
std::optional<std::string> alias = {},
immer::array<std::string> invite = {},
std::optional<bool> isDirect = {},
bool allowFederate = true,
std::optional<std::string> topic = {},
JsonWrap powerLevelContentOverride = json::object(),
std::optional<CreateRoomPreset> preset = std::nullopt,
immer::array<Event> initialState = immer::array<Event>()
) const;
/**
* Join a room by its id.
*
* @param roomId The id of the room to join.
* @return A Promise that resolves when the room is joined,
* or when there is an error.
*/
PromiseT joinRoomById(std::string roomId) const;
/**
* Join a room by its id or alias.
*
* @param roomId The id *or alias* of the room to join.
* @param serverName A list of servers to use when joining the room.
* This corresponds to the `via` parameter in a matrix.to url.
* @return A Promise that resolves when the room is joined,
* or when there is an error.
*/
PromiseT joinRoom(std::string roomId, immer::array<std::string> serverName) const;
/**
* Upload content to the content repository.
*
* @param content The content to upload.
* @param uploadId
* @param filename The name of the file.
* @param contentType The content type of the file.
* @return A Promise that resolves when the upload is successful,
* or when there is an error. If it successfully resolves to `r`,
* `r.dataStr("mxcUri")` will be the MXC URI of the uploaded
* content.
*/
PromiseT uploadContent(immer::box<Bytes> content,
std::string uploadId,
std::optional<std::string> filename = std::nullopt,
std::optional<std::string> contentType = std::nullopt) const;
/**
* Upload content to the content repository.
*
* @param file The file to upload.
* @return A Promise that resolves when the upload is successful,
* or when there is an error. If it successfully resolves to `r`,
* `r.dataStr("mxcUri")` will be the MXC URI of the uploaded
* content.
*/
PromiseT uploadContent(FileDesc file) const;
/**
* Convert a MXC URI to an HTTP(s) URI.
*
* The converted URI will be using the homeserver of
* this Client.
*
* @param mxcUri The MXC URI to convert.
* @return The HTTP(s) URI that has the content indicated
* by `mxcUri`.
*/
inline std::string mxcUriToHttp(std::string mxcUri) const {
using namespace CursorOp;
auto [serverName, mediaId] = mxcUriToMediaDesc(mxcUri);
return (+clientCursor())
.template job<GetContentJob>()
.make(serverName, mediaId).url();
}
/**
* Convert a MXC URI to an HTTP(s) URI that needs Authorization.
*
* The converted URI will be using the homeserver of
* this Client.
*
* @param mxcUri The MXC URI to convert.
* @return The HTTP(s) URI that has the content indicated
* by `mxcUri`.
*/
std::string mxcUriToHttpV1(std::string mxcUri) const;
/**
* Download content from the content repository
*
* After the returned Promise resolves successfully,
* if @c downloadTo is provided, the content will be available
* in that file; if it is not provided, `r.dataStr("content")`
* will contain the content of the downloaded file.
*
* @param mxcUri The MXC URI of the content.
* @param downloadTo The file to write the content to. Must not be
* an in-memory file.
* @return A Promise that is resolved after the content
* is downloaded, or when there is an error.
*/
PromiseT downloadContent(std::string mxcUri,
std::optional<FileDesc> downloadTo = std::nullopt) const;
/**
* Download a thumbnail from the content repository
*
* After the returned Promise resolves successfully,
* if @c downloadTo is provided, the content will be available
* in that file; if it is not provided, `r.dataStr("content")`
* will contain the content of the downloaded file.
*
* @param mxcUri The MXC URI of the content.
* @param width,height The dimension wanted for the thumbnail
* @param method The method to generate the thumbnail. Either `Crop`
* or `Scale`.
* @param downloadTo The file to write the content to. Must not be
* an in-memory file.
* @return A Promise that is resolved after the thumbnail
* is downloaded, or when there is an error.
*/
PromiseT downloadThumbnail(std::string mxcUri,
int width,
int height,
std::optional<ThumbnailResizingMethod> method = std::nullopt,
std::optional<FileDesc> downloadTo = std::nullopt) const;
/**
* Fetch the profile of a user.
*
* @param userId The id of the user to fetch.
* @return A Promise that resolves when the fetch is completed.
* If successful, `r.dataStr("avatarUrl")` will contain the
* avatar url of that user, and `r.dataStr("displayName")` will
* contain the display name of that user.
*/
PromiseT getProfile(std::string userId) const;
/**
* Change the avatar url of the current user.
*
* @param avatarUrl The url of the new avatar. Should be an MXC URI.
* If it is std::nullopt, remove the user avatar.
* @return A Promise that resolves when the request is completed.
*/
PromiseT setAvatarUrl(std::optional<std::string> avatarUrl) const;
/**
* Change the display name of the current user.
*
* @param displayName The new display name. If it is std::nullopt,
* remove the user avatar.
* @return A Promise that resolves when the request is completed.
*/
PromiseT setDisplayName(std::optional<std::string> displayName) const;
// lager::reader<bool>
inline auto syncing() const {
return clientCursor()[&ClientModel::syncing];
}
/**
* Start syncing if the Client is not syncing.
*
* Syncing will continue indefinitely, if the preparation of
* the sync (posting filters and uploading identity keys,
* if needed) is successful, or until stopSyncing() is called.
*
* This function internally calls ensureReadyForSyncing() and
* doOneSyncCycle().
*
* This will automatically retry the sync request if it fails,
* by first delaying `firstRetryMs`, then multiply the delay time
* by `retryTimeFactor`, until it reaches `maxRetryMs`. It also
* means that the application do not have a way to make the retry
* hit sooner. To do that, you need to implement your own sync loop
* using `doOneSyncCycle()`.
*
* @return A Promise that resolves when the Client is syncing
* (more exactly, when syncing() contains true), or when there
* is an error in the preparation of the sync.
*/
PromiseT startSyncing() const;
/**
* Stop the indefinite syncing started by `startSyncing()`.
*
* If you do not use `startSyncing()`, you probably do not need
* this.
*
* After this, it sets the `shouldSync` flag to false, so
* no more syncing actions will be dispatched by `startSyncing()`.
*
* @return A Promise that resolves when syncing is stopped.
* @sa shouldSync(), startSyncing()
*/
PromiseT stopSyncing() const;
/**
* Ensure the Client can start syncing using doOneSyncCycle().
*
* @return A Promise that resolves when the application can
* call doOneSyncCycle() or when there is an error. When there is
* an error, the Promise contains the standard error format.
*/
PromiseT ensureReadyForSyncing() const;
/**
* Call the sync endpoint once and process the response.
*
* @return A Promise that resolves when the sync cycle is finished,
* or when there is an error. If the Promise is successful, the
* application can call doOneSyncCycle() again. If the Promise is
* not successful, the application should wait some time before calling
* doOneSyncCycle() again, and exponentially increase the wait time
* between retries (or retry when the user manually requests it).
*/
PromiseT doOneSyncCycle() const;
/**
* Get the info of all devices of user `userId` that supports encryption.
*
* @param userId The id of the user to get the devices of.
*
* @return a lager::reader of a RangeT of DeviceKeyInfo representing the devices of that user.
*/
auto devicesOfUser(std::string userId) const -> lager::reader<immer::flex_vector<DeviceKeyInfo>>;
/**
* Set the trust level of a device.
*
* @param userId The id of the user to whom the device belongs.
* @param deviceId The id of the device.
*
* @return a Promise that resolves when the setting is changed.
*/
PromiseT setDeviceTrustLevel(std::string userId, std::string deviceId, DeviceTrustLevel trustLevel) const;
/**
* Get the trust level needed to send keys to a device.
*
* @return a lager::reader of the trust level threshold.
*/
auto trustLevelNeededToSendKeys() const -> lager::reader<DeviceTrustLevel>;
/**
* Set the trust level needed to send keys to a device.
*
* @param trustLevel The trust level threshold.
*
* @return a Promise that resolves when the setting is changed.
*/
PromiseT setTrustLevelNeededToSendKeys(DeviceTrustLevel trustLevel) const;
/**
* Get the map from direct messaging room ids to user ids.
*
* @return a lager::reader of such mapping.
*/
auto directRoomMap() const -> lager::reader<immer::map<std::string, std::string>>;
/**
* Get the account data that is not associated with any room.
*
* @return A lager::reader of a map from the type to the account data event.
*/
auto accountData() const -> lager::reader<immer::map<std::string, Event>>;
/**
* Set the account data that is not associated with any room.
*
* @return A Promise that resolves when the account data
* has been set, or when there is an error.
*/
PromiseT setAccountData(Event accountDataEvent) const;
/**
* Get a notification handler that works on this Client.
*
* @return A notification handler that works on this Client.
*/
NotificationHandler notificationHandler() const;
/**
* Serialize the model to a Boost.Serialization archive.
*
* @param ar A Boost.Serialization output archive.
*
* This function can be used to save the model. For loading,
* you should use the makeSdk function. For example:
*
* ```c++
* client.serializeTo(outputAr);
*
* SdkModel m;
* inputAr >> m;
* auto newSdk = makeSdk(m, ...);
* ```
*/
template<class Archive>
void serializeTo(Archive &ar) const {
ar << sdkCursor().get();
}
/**
* Get all supported versions.
*
* @param homeserver The base url of the homeserver. E.g. `https://tusooa.xyz`.
* @return A Promise that resolves when the versions has been set,
* or when there is an error.
*/
PromiseT getVersions(std::string homeserver) const;
/**
* Get all supported versions.
*
* @return A lager::reader of a array contains all supported versions.
*
* See https://spec.matrix.org/v1.14/#specification-versions
*/
auto supportVersions() const -> lager::reader<immer::array<std::string>>;
/**
* Mark a room as a direct chat by send the m.direct account data.
*
* @param userId The user id that direct to.
* @param roomId The direct chat room id.
* @return A Promise that resolves when the account data
* has been set, or when there is an error.
*/
PromiseT addDirectRoom(std::string userId, std::string roomId) const;
/**
* Get a GetRoomIdByAliasJob.
* Use Kazv::parseGetRoomIdByAliasResponse to parse its response.
*
* @param roomAlias The room alias.
* @return A GetRoomIdByAliasJob.
*/
BaseJob getRoomIdByAliasJob(std::string roomAlias) const;
/**
* Purge events in room, keeping the latest `numToKeep` events.
*
* The events are removed from the lager store. The timeline will
* contain at most `numToKeep` events, but the `messages` property
* may contain more in order to maintain the room invariants.
* @sa RoomModel
*
* @param roomIdToMaxToKeepMap A map from "room id" to "max number of timeline events to keep."
*/
PromiseT purgeRoomEvents(immer::map<std::string, std::size_t> roomIdToMaxToKeepMap) const;
/**
* Load events from storage into the model.
*
* @param timelineEvents Map from room id to a list of message events that
* should be put into the timeline.
* @param relatedEvents Map from room id to a list of message events that should
* not be put into the timeline (for example, because the storage does not
* know or care where it should go in the timeline).
* @return A Promise that resolves when the events are loaded into the store.
*/
PromiseT loadEventsFromStorage(immer::map<std::string, EventList> timelineEvents, immer::map<std::string, EventList> relatedEvents) const;
/**
* Import keys from a key backup file.
*
* @param fileContent The raw content of the file.
* @param password The password to decrypt the file.
* @return A Promise that resolves when the keys are imported or when there is an error. Assume the Promise resolves to `r`, if it is successful, `r.dataJson("imported")` contains the number of keys imported. Otherwise, `r` contains the standard error structure.
*/
PromiseT importFromKeyBackupFile(std::string fileContent, std::string password) const;
/**
* Process verification events from a sync result.
*
* This will be called automatically after a sync.
*
* @param toDeviceEvents The list of to-device verification events received from sync.
* @return A Promise that resolves when the processing is done. After it resolves,
* actions will be dispatched to send any pending to-device events in the
* VerificationTracker's model.
*/
PromiseT processVerificationEventsFromSync(EventList toDeviceEvents) const;
/**
* Request an outgoing verification using to-device message.
*
* This will automatically fetch the device keys if we do not yet have
* them.
*
* @return A Promise that resolves when the outgoing request is sent,
* or when there is an error.
*/
PromiseT requestOutgoingToDeviceVerification(std::string userId, std::string deviceId) const;
/**
* Signal that the user is ready for an incoming verification request.
*
* This will automatically fetch the device keys if we do not yet have
* them.
*
* @return A Promise that resolves when the ready event is sent,
* or when there is an error.
*/
PromiseT readyForVerification(std::string userId, std::string deviceId) const;
/**
* Cancel a verification process.
*
* @return A Promise that resolves when the cancel event is sent,
* or when there is an error.
*/
PromiseT cancelVerification(std::string userId, std::string deviceId) const;
/**
* Confirm an sas match for a verification process.
*
* @return A Promise that resolves when the next event is sent,
* or when there is an error.
*/
PromiseT confirmVerificationSasMatch(std::string userId, std::string deviceId) const;
/**
* Deny an sas match for a verification process.
*
* @return A Promise that resolves when the next event is sent,
* or when there is an error.
*/
PromiseT denyVerificationSasMatch(std::string userId, std::string deviceId) const;
/**
* Ensure the VerificationTracker is initialized.
*/
PromiseT ensureInitVerificationTracker() const;
+ /**
+ * Request a key sharing from other devices.
+ *
+ * @param roomId The room id of the undecryptable event.
+ * @param event The undecryptable event. Must be a room event.
+ * @param devices The devices to send to. A map from user id to
+ * a list of device ids. If empty,
+ * this function will automatically use verified devices of the same user.
+ *
+ * @return A Promise that resolves when the key request is sent.
+ */
+ PromiseT requestShareRoomSessionKey(
+ std::string roomId,
+ Event event,
+ immer::map<std::string, immer::flex_vector<std::string>> devices = {}
+ ) const;
+
private:
void syncForever(std::optional<int> retryTime = std::nullopt) const;
const lager::reader<SdkModel> &sdkCursor() const;
lager::reader<ClientModel> clientCursor() const;
std::optional<lager::reader<SdkModel>> m_sdk;
std::optional<lager::reader<ClientModel>> m_client;
ContextT m_ctx;
std::optional<DepsT> m_deps;
KAZV_DECLARE_THREAD_ID();
KAZV_DECLARE_EVENT_LOOP_THREAD_ID_KEEPER(m_deps.has_value() ? &lager::get<EventLoopThreadIdKeeper &>(m_deps.value()) : 0);
};
}
diff --git a/src/client/clientfwd.hpp b/src/client/clientfwd.hpp
index a5b88ac..d035638 100644
--- a/src/client/clientfwd.hpp
+++ b/src/client/clientfwd.hpp
@@ -1,165 +1,167 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <tuple>
#include <variant>
#include <lager/context.hpp>
#include <context.hpp>
#include "room/room-model.hpp"
namespace Kazv
{
using namespace Api;
struct JobInterface;
class EventInterface;
struct LoginAction;
struct TokenLoginAction;
struct MLoginTokenLoginAction;
struct LogoutAction;
struct HardLogoutAction;
struct GetWellknownAction;
struct GetVersionsAction;
struct SyncAction;
struct SetShouldSyncAction;
struct PostInitialFiltersAction;
struct SetAccountDataAction;
struct PaginateTimelineAction;
struct SendMessageAction;
struct SendStateEventAction;
struct SaveLocalEchoAction;
struct UpdateLocalEchoStatusAction;
struct RedactEventAction;
struct CreateRoomAction;
struct GetRoomStatesAction;
struct GetStateEventAction;
struct InviteToRoomAction;
struct JoinRoomByIdAction;
struct JoinRoomAction;
struct LeaveRoomAction;
struct ForgetRoomAction;
struct KickAction;
struct BanAction;
struct UnbanAction;
struct SetAccountDataPerRoomAction;
struct ProcessResponseAction;
struct SetTypingAction;
struct PostReceiptAction;
struct SetReadMarkerAction;
struct UploadContentAction;
struct DownloadContentAction;
struct DownloadThumbnailAction;
struct SendToDeviceMessageAction;
struct SendMultipleToDeviceMessagesAction;
struct UploadIdentityKeysAction;
struct GenerateAndUploadOneTimeKeysAction;
struct QueryKeysAction;
struct EnsureKeysFromDevicesAction;
struct ClaimKeysAction;
struct EncryptMegOlmEventAction;
struct SetDeviceTrustLevelAction;
struct SetDevicesTrustLevelsAction;
struct SetTrustLevelNeededToSendKeysAction;
struct PrepareForSharingRoomKeyAction;
struct ImportFromKeyBackupFileAction;
struct NotifyVerificationTrackerModelAction;
+ struct RequestShareRoomSessionKeyAction;
struct GetUserProfileAction;
struct SetAvatarUrlAction;
struct SetDisplayNameAction;
struct ResubmitJobAction;
struct LoadEventsFromStorageAction;
struct PurgeRoomTimelineAction;
struct ClientModel;
using ClientAction = std::variant<
RoomListAction,
LoginAction,
TokenLoginAction,
MLoginTokenLoginAction,
LogoutAction,
HardLogoutAction,
GetWellknownAction,
GetVersionsAction,
SyncAction,
SetShouldSyncAction,
PostInitialFiltersAction,
SetAccountDataAction,
PaginateTimelineAction,
SendMessageAction,
SendStateEventAction,
SaveLocalEchoAction,
UpdateLocalEchoStatusAction,
RedactEventAction,
CreateRoomAction,
GetRoomStatesAction,
GetStateEventAction,
InviteToRoomAction,
JoinRoomByIdAction,
JoinRoomAction,
LeaveRoomAction,
ForgetRoomAction,
KickAction,
BanAction,
UnbanAction,
SetAccountDataPerRoomAction,
ProcessResponseAction,
SetTypingAction,
PostReceiptAction,
SetReadMarkerAction,
UploadContentAction,
DownloadContentAction,
DownloadThumbnailAction,
SendToDeviceMessageAction,
SendMultipleToDeviceMessagesAction,
UploadIdentityKeysAction,
GenerateAndUploadOneTimeKeysAction,
QueryKeysAction,
EnsureKeysFromDevicesAction,
ClaimKeysAction,
EncryptMegOlmEventAction,
SetDeviceTrustLevelAction,
SetDevicesTrustLevelsAction,
SetTrustLevelNeededToSendKeysAction,
PrepareForSharingRoomKeyAction,
ImportFromKeyBackupFileAction,
NotifyVerificationTrackerModelAction,
+ RequestShareRoomSessionKeyAction,
GetUserProfileAction,
SetAvatarUrlAction,
SetDisplayNameAction,
ResubmitJobAction,
LoadEventsFromStorageAction,
PurgeRoomTimelineAction
>;
using ClientEffect = Effect<ClientAction, lager::deps<>>;
using ClientResult = std::pair<ClientModel, ClientEffect>;
}
diff --git a/src/tests/client/client-test-util.cpp b/src/tests/client/client-test-util.cpp
index b1b415d..4d4c5ec 100644
--- a/src/tests/client/client-test-util.cpp
+++ b/src/tests/client/client-test-util.cpp
@@ -1,54 +1,59 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include "client-test-util.hpp"
#include <asio-promise-handler.hpp>
#include "factory.hpp"
ClientModel createTestClientModel()
{
auto m = Factory::makeClient({});
return m;
}
bool hasAccessToken(const BaseJob &job)
{
auto header = job.requestHeader();
return header->find("Authorization") != header->end();
}
struct DumbPromise : public TypelessPromise
{
using DataT = EffectStatus;
template<class Func>
DumbPromise then(Func &&) { return DumbPromise(); }
bool ready() const { return true; }
DataT get() const { return DataT(); }
};
struct DumbPromiseHandler
{
using DataT = EffectStatus;
template<class Data, class Func>
auto create(Func &&) { return DumbPromise(); }
template<class Ignore>
auto createResolved(Ignore &&) { return DumbPromise(); }
};
Context<SdkAction> dumbContext()
{
return Context<SdkAction>(
[](auto &&) { return DumbPromise(); },
DumbPromiseHandler(),
lager::make_deps()
);
}
+
+json getRequestJsonBody(const BaseJob &job)
+{
+ return json::parse(std::get<Bytes>(job.requestBody()));
+}
diff --git a/src/tests/client/client-test-util.hpp b/src/tests/client/client-test-util.hpp
index 91b9ac1..a373c31 100644
--- a/src/tests/client/client-test-util.hpp
+++ b/src/tests/client/client-test-util.hpp
@@ -1,55 +1,57 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/store.hpp>
#include <lager/event_loop/manual.hpp>
#include <store.hpp>
#include <sdk.hpp>
#include <client/client-model.hpp>
#include <base/basejob.hpp>
#include <catch2/catch_test_macros.hpp>
using namespace Kazv;
ClientModel createTestClientModel();
inline auto createTestClientStore(SingleTypePromiseInterface<DefaultRetType> ph)
{
return makeStore<ClientAction>(
createTestClientModel(),
&ClientModel::update,
std::move(ph));
}
inline auto createTestClientStoreFrom(ClientModel m, SingleTypePromiseInterface<DefaultRetType> ph)
{
return makeStore<ClientAction>(
std::move(m),
&ClientModel::update,
std::move(ph));
}
using TestClientStoreT = decltype(createTestClientStoreFrom(std::declval<ClientModel>(), std::declval<SingleTypePromiseInterface<DefaultRetType>>()));
bool hasAccessToken(const BaseJob &job);
template<class Model>
void assert1Job(Model &&model)
{
REQUIRE(std::forward<Model>(model).nextJobs.size() == 1);
}
template<class Model, class Pred>
void for1stJob(Model &&model, Pred &&pred)
{
std::forward<Pred>(pred)(std::forward<Model>(model).nextJobs[0]);
}
Context<SdkAction> dumbContext();
+
+json getRequestJsonBody(const BaseJob &job);
diff --git a/src/tests/client/encryption-test.cpp b/src/tests/client/encryption-test.cpp
index 8b3bf27..6b14353 100644
--- a/src/tests/client/encryption-test.cpp
+++ b/src/tests/client/encryption-test.cpp
@@ -1,729 +1,802 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <catch2/catch_all.hpp>
#include <client/actions/encryption.hpp>
#include <client-model.hpp>
#include "key-export.hpp"
#include "client-test-util.hpp"
#include "action-mock-utils.hpp"
#include "encryption-test-utils.hpp"
#include "factory.hpp"
using namespace Kazv::Factory;
namespace
{
struct CreateE2EESessionResult
{
ClientModel receiver1;
ClientModel receiver2;
ClientModel sender;
};
struct CreateMegOlmSessionResult
{
Event encryptedRoomEvent;
Event encryptedKeyEvent;
Event unencryptedKeyEvent;
};
}
static json makeDeviceInfo(const ClientModel &client)
{
auto [next, _] = updateClient(client, UploadIdentityKeysAction{});
return json::parse(std::get<Bytes>(next.nextJobs[0].requestBody()))["device_keys"];
}
static CreateE2EESessionResult createE2EESession()
{
auto r1Crypto = makeCrypto();
r1Crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto r1 = makeClient(withCrypto(r1Crypto));
r1.userId = "@receiver:example.com";
r1.deviceId = "device1";
auto r2Crypto = makeCrypto();
r2Crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto r2 = makeClient(withCrypto(r2Crypto));
r2.userId = "@receiver:example.com";
r2.deviceId = "device2";
auto oneTimeKeys1 = r1Crypto.unpublishedOneTimeKeys();
auto cv25519Key1 = oneTimeKeys1["curve25519"].items().begin().value().template get<std::string>();
auto oneTimeKeys2 = r2Crypto.unpublishedOneTimeKeys();
auto cv25519Key2 = oneTimeKeys2["curve25519"].items().begin().value().template get<std::string>();
auto queryKeysRespJsonSender = json{
{"device_keys", {{"@receiver:example.com", {
{"device1", makeDeviceInfo(r1)},
{"device2", makeDeviceInfo(r2)},
}}}},
};
// Query keys
auto client = makeClient(withCrypto(makeCrypto()));
client.userId = "@sender:example.com";
client.deviceId = "device1";
auto queryKeysRespJsonReceiver = json{
{"device_keys", {{"@sender:example.com", {
{"device1", makeDeviceInfo(client)}
}}}},
};
std::tie(client, std::ignore) = processResponse(client, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonSender)
| withResponseDataKV("deviceKeys", json::object({{"@receiver:example.com", json::array()}})))
));
std::tie(r1, std::ignore) = processResponse(r1, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonReceiver)
| withResponseDataKV("deviceKeys", json::object({{"@sender:example.com", json::array()}})))
));
std::tie(r2, std::ignore) = processResponse(r2, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonReceiver)
| withResponseDataKV("deviceKeys", json::object({{"@sender:example.com", json::array()}})))
));
// Claim keys
client.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), r1Crypto.curve25519IdentityKey(), cv25519Key1); });
client.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), r2Crypto.curve25519IdentityKey(), cv25519Key2); });
return {
r1,
r2,
client,
};
}
static CreateMegOlmSessionResult createMegOlmSession(ClientModel &sender, ClientModel &receiver, std::string roomId, Event plainText)
{
auto sessionKey = sender.withCrypto([&](auto &c) {
return c.rotateMegOlmSessionWithRandom(genRandomData(Crypto::rotateMegOlmSessionRandomSize()), 0, roomId);
});
auto mod = withRoom(makeRoom(withRoomId(roomId) | withRoomEncrypted(true)));
mod(sender);
mod(receiver);
auto [encrypted, _noSessionKey] = sender.megOlmEncrypt(plainText, roomId, 0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto sessionId = encrypted.originalJson().get()["content"]["session_id"].template get<std::string>();
auto keyEventJson = json{
{"content", {{"algorithm", CryptoConstants::megOlmAlgo},
{"room_id", roomId},
{"session_id", sessionId},
{"session_key", sessionKey}}},
{"type", "m.room_key"}
};
auto res = sender.olmEncryptSplit(Event(keyEventJson),
{{receiver.userId, {receiver.deviceId}}},
genRandomData(Crypto::encryptOlmMaxRandomSize() * 2));
auto encryptedKeyEventJson = res[receiver.userId][receiver.deviceId].originalJson().get();
encryptedKeyEventJson["sender"] = sender.userId;
auto encryptedKeyEvent = Event(encryptedKeyEventJson);
std::cerr << "room event" << encrypted.originalJson().get().dump() << std::endl;
std::cerr << "key event" << encryptedKeyEvent.originalJson().get().dump() << std::endl;
keyEventJson["keys"] = json{
{CryptoConstants::ed25519, sender.constCrypto().ed25519IdentityKey()},
};
keyEventJson["sender"] = sender.userId;
return {encrypted, encryptedKeyEvent, Event(keyEventJson)};
}
static Response syncResponseFromToDevice(Event toDevice)
{
auto j = json{
{"next_batch", "something"},
{"to_device", {
{"events", json::array({toDevice.originalJson().get()})},
}},
};
return makeResponse(
"Sync",
withResponseJsonBody(j)
| withResponseDataKV("is", "incremental"));
}
TEST_CASE("PrepareForSharingRoomKeyAction: adds the encrypted event to pending events", "[client][encryption]")
{
ClientModel m;
m.crypto = Crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
RoomModel room;
room.encrypted = true;
room.roomId = "!exampleroomid:example.com";
m.roomList.rooms = m.roomList.rooms.set("!exampleroomid:example.com", room);
auto event = Event{json{
{"type", "m.room_key"},
{"content", {{"some", "thing"}}},
}};
auto [next, dontCareEffect] = ClientModel::update(m, PrepareForSharingRoomKeyAction{"!exampleroomid:example.com", {}, event, {}});
auto nextRoom = next.roomList.rooms.at("!exampleroomid:example.com");
REQUIRE(nextRoom.pendingRoomKeyEvents.size() == 1);
}
TEST_CASE("encrypted event will keep a copy of m.relates_to in plaintext", "[client][encryption]")
{
auto room = makeRoom(withRoomEncrypted(true));
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto eventToEncrypt = makeEvent(
withEventType("m.room.message")
| withEventRelationship("moe.kazv.mxc.custom-rel-type", "$some-event-id")
);
auto [encryptedEvent, maybeKey] = client.megOlmEncrypt(
eventToEncrypt,
room.roomId,
0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize())
);
// because we do not have session key yet, it should always be rotated
REQUIRE(maybeKey.has_value());
// check we can still access relationship
REQUIRE(encryptedEvent.relationship() == std::pair<std::string, std::string>{"moe.kazv.mxc.custom-rel-type", "$some-event-id"});
// check that the relationship is also in plaintext
REQUIRE(encryptedEvent.originalJson().get()["content"]["m.relates_to"] == json{
{"rel_type", "moe.kazv.mxc.custom-rel-type"},
{"event_id", "$some-event-id"},
});
}
TEST_CASE("encrypting event without relationship should not put m.relates_to key in plaintext", "[client][encryption]")
{
auto room = makeRoom(withRoomEncrypted(true));
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto eventToEncrypt = makeEvent(
withEventType("m.room.message")
);
auto [encryptedEvent, maybeKey] = client.megOlmEncrypt(
eventToEncrypt,
room.roomId,
0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize())
);
REQUIRE(maybeKey.has_value());
REQUIRE(!encryptedEvent.originalJson().get()["content"].contains("m.relates_to"));
}
TEST_CASE("ClientModel::olmEncryptSplit()", "[client][encryption]")
{
auto r = createE2EESession();
auto client = r.sender;
auto receiver1 = r.receiver1.constCrypto();
auto receiver2 = r.receiver2.constCrypto();
// encrypt
auto res = client.olmEncryptSplit(Event(json::object()),
{{"@receiver:example.com", {"device1", "device2"}}},
genRandomData(Crypto::encryptOlmMaxRandomSize() * 2));
REQUIRE(res["@receiver:example.com"]["device1"].originalJson().get().at("content").at("ciphertext").size() == 1);
REQUIRE(res["@receiver:example.com"]["device1"].originalJson().get().at("content").at("ciphertext").contains(receiver1.curve25519IdentityKey()));
REQUIRE(res["@receiver:example.com"]["device2"].originalJson().get().at("content").at("ciphertext").size() == 1);
REQUIRE(res["@receiver:example.com"]["device2"].originalJson().get().at("content").at("ciphertext").contains(receiver2.curve25519IdentityKey()));
}
TEST_CASE("tryDecryptEvents()", "[client][encryption]")
{
auto roomId = "!someroom:example.com";
auto room = makeRoom(
withRoomEncrypted(true)
| withRoomId(roomId)
);
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto plainText = makeEvent();
auto [encrypted, sessionId] = client.megOlmEncrypt(plainText, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto plainText2 = makeEvent();
// verify that we can decrypt events without sender_key or device_id
auto [encrypted2, sessionId2] = client.megOlmEncrypt(plainText2, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto j = encrypted2.originalJson().get();
j["content"].erase("sender_key");
j["content"].erase("device_id");
encrypted2 = Event(j);
auto events = EventList{
makeEvent(),
makeEvent(),
encrypted,
encrypted2,
};
withRoomTimeline(events)(room);
withRoom(room)(client);
auto nextClient = tryDecryptEvents(client);
auto decryptedEvent = nextClient.roomList.rooms[roomId].messages[encrypted.id()];
REQUIRE(decryptedEvent.encrypted());
REQUIRE(decryptedEvent.decrypted());
REQUIRE(decryptedEvent.type() == plainText.type());
REQUIRE(decryptedEvent.content() == plainText.content());
auto decryptedEvent2 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent2.encrypted());
REQUIRE(decryptedEvent2.decrypted());
REQUIRE(decryptedEvent2.type() == plainText2.type());
REQUIRE(decryptedEvent2.content() == plainText2.content());
REQUIRE(nextClient.roomList.rooms[roomId].undecryptedEvents
==
immer::map<std::string, immer::flex_vector<std::string>>{});
}
TEST_CASE("tryDecryptEvents() will decrypt to-device events and add group session key", "[client][encryption]")
{
auto r = createE2EESession();
auto sender = r.sender;
auto receiver = r.receiver1;
std::string roomId = "!someroom:example.com";
Event plainText = json{
{"type", "m.room.message"},
{"content", {
{"body", "mew"},
}},
{"room_id", roomId},
};
auto [encryptedRoomEvent, encryptedKeyEvent, unencryptedKeyEvent] = createMegOlmSession(sender, receiver, roomId, plainText);
auto sessionId = encryptedRoomEvent.originalJson().get()["content"]["session_id"].template get<std::string>();
SECTION("Process key event") {
auto resp = syncResponseFromToDevice(encryptedKeyEvent);
auto [next, _dontCareEffect] = ClientModel::update(receiver, ProcessResponseAction{resp});
REQUIRE(next.constCrypto().hasInboundGroupSession(KeyOfGroupSession{roomId, sessionId}));
REQUIRE(next.toDevice.size() == 0);
}
SECTION("Reject unencrypted key event") {
auto resp = syncResponseFromToDevice(unencryptedKeyEvent);
auto [next, _dontCareEffect] = ClientModel::update(receiver, ProcessResponseAction{resp});
REQUIRE(!next.constCrypto().hasInboundGroupSession(KeyOfGroupSession{roomId, sessionId}));
REQUIRE(next.toDevice.size() == 0);
}
}
TEST_CASE("tryDecryptEvents() will update room.undecryptedEvents", "[client][encryption]")
{
auto roomId = "!someroom:example.com";
auto room = makeRoom(
withRoomEncrypted(true)
| withRoomId(roomId)
);
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto plainText = makeEvent();
auto [encrypted, sessionId] = client.megOlmEncrypt(plainText, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto plainText2 = makeEvent();
auto [encrypted2, sessionId2] = client.megOlmEncrypt(plainText2, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto j = encrypted2.originalJson().get();
// simulate an undecryptable event with a known session id
j["content"]["/////"];
encrypted2 = Event(j);
// simulate an undecryptable event with an unknown session id
auto plainText3 = makeEvent();
auto [encrypted3, sessionId3] = client.megOlmEncrypt(plainText3, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
j["content"]["session_id"] = "some-session-id";
encrypted3 = Event(j);
auto events = EventList{
makeEvent(),
makeEvent(),
encrypted,
encrypted2,
encrypted3,
};
withRoomTimeline(events)(room);
withRoom(room)(client);
auto nextClient = tryDecryptEvents(client);
auto decryptedEvent = nextClient.roomList.rooms[roomId].messages[encrypted.id()];
REQUIRE(decryptedEvent.encrypted());
REQUIRE(decryptedEvent.decrypted());
REQUIRE(decryptedEvent.type() == plainText.type());
REQUIRE(decryptedEvent.content() == plainText.content());
auto decryptedEvent2 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent2.encrypted());
REQUIRE(!decryptedEvent2.decrypted());
auto decryptedEvent3 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent3.encrypted());
REQUIRE(!decryptedEvent3.decrypted());
REQUIRE(nextClient.roomList.rooms[roomId].undecryptedEvents
==
immer::map<std::string, immer::flex_vector<std::string>>{
{encrypted.originalJson().get()["content"]["session_id"], {encrypted2.id()}},
{encrypted3.originalJson().get()["content"]["session_id"], {encrypted3.id()}},
});
}
static const std::string password = "test";
static const std::string backupFile = R"(-----BEGIN MEGOLM SESSION DATA-----
AV6R43XMe68Ekf7jB4lYHLgAAAAAAAAAAAAAAAAAAAAAAA9CQG628kByLP7LApTtbvhnpgFnUUJ
+tRMkpw4zcGoTOJya9/lawfRWKjd8LZeuHKdNLkEhfIAE16Xmqv+uU8oEASPxjLDOMjsgBKLMRx
/iwUR7Aoe4wjuwEcdEEOW+T6ffjUz5LmEJcI14qZ1wXUPk1pnNmz+4nX8+a9UxgEpAN7vsmilwz
P4PXNubhvGsqtZpy44pP6Td0alYgwVfTXqWB1KokMjuQE+2q6/Jb6U/z5D5nv8ArcJL04cD0U6r
ySsRWI9Jra2OcKFQxgLeVpRAiP6/sRyl9k1n6eiSOfmGkZ+qnvOfZsQh7Wupgh6zRe8LNEtrYZh
FpSaCE+0U8I5hZJrWNBDFfHg+rtzB4BEk0YwpD3rVcWEsk8kKqHmEulEqIXckd1SbSG7y7H1ADB
7mjAY7qWetMizPXD+I8MDUnU1TF3Jv3CIZfZY7BHh2WukmiORlpN4H5s/Wwq2oCIk7qXhCHFvaF
uj+XytIz6TmkEVZfXK9zqUCwCU+VYSGl9GVAezO8CZ6aEJes95yYqRxfADdJG2Vtd0oXwrpR0xV
1GO+0JJ3xKicVX6U77iMtJbL1Lge32QvbAcv8o6mcaW28xeeYPrccMIRa3vLtuSDDqKC79S9bIP
2U5F+MHn+5dqMeXcG9K2hS91gsBQMAvX6
-----END MEGOLM SESSION DATA-----)";
TEST_CASE("import keys", "[client][encryption]")
{
auto u = makeMockSdkUtil(makeClient(
withCrypto(makeCrypto())
));
auto c = u.sdk.client();
SECTION("success")
{
c.importFromKeyBackupFile(backupFile, password)
.then([&u](auto stat) {
REQUIRE(stat);
// the example json in the spec is not a valid key
REQUIRE(stat.dataJson("imported") == 0);
u.io.stop();
});
u.io.run();
}
SECTION("failure")
{
c.importFromKeyBackupFile(backupFile, "wrongpass")
.then([&u](auto stat) {
REQUIRE(!stat);
REQUIRE(stat.dataStr("error") == DecryptKeyExportErrorCodes::HMAC_FAILED);
u.io.stop();
});
u.io.run();
}
}
TEST_CASE("tryDecryptEvents() rejects Olm-encrypted to-device event from unknown device, which causes QueryKeysAction to query relevant keys", "[client][encryption][olm]")
{
// Bob: the current user, with crypto enabled
auto bobCrypto = makeCrypto();
bobCrypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto bobOneTimeKeys = bobCrypto.unpublishedOneTimeKeys();
bobCrypto.markOneTimeKeysAsPublished();
auto bobClient = makeClient(withCrypto(bobCrypto));
bobClient.userId = "@bob:example.com";
bobClient.deviceId = "bobdevice";
auto bobIdentityKey = bobCrypto.curve25519IdentityKey();
auto bobOneTimeKey = std::string{};
for (auto [id, key] : bobOneTimeKeys[CryptoConstants::curve25519].items()) {
bobOneTimeKey = key;
}
// Alice: a device NOT known to Bob (not in Bob's device list)
auto aliceCrypto = makeCrypto();
auto aliceIdentityKey = aliceCrypto.curve25519IdentityKey();
auto aliceEdKey = aliceCrypto.ed25519IdentityKey();
// Alice creates an outbound session to Bob (using Bob's published one-time key)
aliceCrypto.createOutboundSessionWithRandom(
genRandomData(Crypto::createOutboundSessionRandomSize()),
bobIdentityKey, bobOneTimeKey);
// Alice encrypts an m.room_key event for Bob
auto plainJson = json{
{"content", {
{"algorithm", CryptoConstants::megOlmAlgo},
{"room_id", "!someroom:example.com"},
{"session_id", "somesessionid"},
{"session_key", "somesessionkey"},
}},
{"keys", {
{CryptoConstants::ed25519, aliceEdKey},
}},
{"sender", "@alice:example.com"},
{"recipient", "@bob:example.com"},
{"recipient_keys", {
{CryptoConstants::ed25519, bobCrypto.ed25519IdentityKey()},
}},
{"type", "m.room_key"},
};
auto encryptedCiphertext = aliceCrypto.encryptOlmWithRandom(
genRandomData(Crypto::encryptOlmMaxRandomSize()),
plainJson, bobIdentityKey);
auto toDeviceJson = json{
{"sender", "@alice:example.com"},
{"type", "m.room.encrypted"},
{"content", {
{"algorithm", CryptoConstants::olmAlgo},
{"sender_key", aliceIdentityKey},
{"ciphertext", encryptedCiphertext},
}},
};
auto toDeviceEvent = Event(toDeviceJson);
// Feed the to-device event to Bob's client via a sync response
auto resp = syncResponseFromToDevice(toDeviceEvent);
auto [next, _] = ClientModel::update(bobClient, ProcessResponseAction{resp});
// The event should be marked as NOT decrypted because Alice's device
// is not in Bob's device list
REQUIRE(next.toDevice.size() == 1);
auto processedEvent = next.toDevice[0];
REQUIRE(processedEvent.encrypted());
REQUIRE(!processedEvent.decrypted());
// Verify the error indicates the device key is unknown
auto decryptedContent = processedEvent.content().get();
REQUIRE(decryptedContent.contains("moe.kazv.mxc.error"));
REQUIRE(decryptedContent["moe.kazv.mxc.error"] == "device key unknown");
REQUIRE(decryptedContent["moe.kazv.mxc.errcode"] == "MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN");
REQUIRE(next.deviceLists.outdatedUsers().empty());
std::tie(next, std::ignore) = ClientModel::update(next, QueryKeysAction{/* isInitialSync = */ false});
assert1Job(next);
auto job = next.nextJobs.at(0);
REQUIRE(job.jobId() == "QueryKeys");
auto jsonBody = json::parse(std::get<BytesBody>(job.requestBody()));
auto expected = json{
{"@alice:example.com", json::array()},
};
REQUIRE(jsonBody["device_keys"] == expected);
}
TEST_CASE("decryptEvent() handles Olm-encrypted to-device event", "[client][encryption][olm]")
{
auto s = OlmFirstTimeDecryptTestSetup();
auto plainText = json{
{"content", json::object()},
{"type", "moe.kazv.mxc.xxx"},
};
SECTION("good keys, with sender_device_keys") {
auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ true);
auto ev = decryptEvent(s.clientNoDevice, encrypted);
REQUIRE(ev.decrypted());
// Then, after the decryption, device info should be added to the tracker
auto devOpt = s.clientNoDevice.deviceLists.findByCurve25519Key(
ev.sender(),
ev.originalJson().get().at("/content/sender_key"_json_pointer).template get<std::string>()
);
REQUIRE(devOpt.has_value());
}
SECTION("good keys, without sender_device_keys") {
auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
auto ev = decryptEvent(s.clientWithDevice, encrypted);
REQUIRE(ev.decrypted());
}
SECTION("sender mismatch") {
s.clientA.userId = "@bad:example.com";
auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
s.clientA.userId = s.sender;
withEventKV("/sender"_json_pointer, s.sender)(encrypted);
auto ev = decryptEvent(s.clientWithDevice, encrypted);
REQUIRE(!ev.decrypted());
REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_SENDER");
}
SECTION("recipient mismatch") {
auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
auto clientRec = s.clientWithDevice;
clientRec.userId = "@bad:example.com";
auto ev = decryptEvent(clientRec, encrypted);
REQUIRE(!ev.decrypted());
REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT");
}
SECTION("recipient keys mismatch") {
using namespace CryptoConstants;
auto encJson = json::object();
encJson["content"] = json{
{"algorithm", CryptoConstants::olmAlgo},
{"ciphertext", json::object()},
{"sender_key", s.clientA.constCrypto().curve25519IdentityKey()},
};
encJson["type"] = "m.room.encrypted";
auto c = makeCrypto();
auto toEncrypt = plainText;
toEncrypt["sender"] = s.sender;
toEncrypt["recipient"] = s.recipient;
toEncrypt["recipient_keys"] = json{
{ed25519, c.ed25519IdentityKey()},
};
toEncrypt["keys"] = json{
{ed25519, s.clientA.constCrypto().ed25519IdentityKey()},
};
encJson["content"]["ciphertext"] = s.clientA.withCrypto([&toEncrypt, &s](auto &c) {
auto key = s.clientNoDevice.constCrypto().curve25519IdentityKey();
return c.encryptOlmWithRandom(
genRandomData(c.encryptOlmRandomSize(key)),
toEncrypt,
key
);
});
encJson["sender"] = s.sender;
auto encrypted = Event(encJson);
auto ev = decryptEvent(s.clientWithDevice, encrypted);
REQUIRE(!ev.decrypted());
REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT_KEYS");
}
SECTION("bad sender ed25519 keys") {
using namespace CryptoConstants;
auto encJson = json::object();
encJson["content"] = json{
{"algorithm", CryptoConstants::olmAlgo},
{"ciphertext", json::object()},
{"sender_key", s.clientA.constCrypto().curve25519IdentityKey()},
};
encJson["type"] = "m.room.encrypted";
auto c = makeCrypto();
auto toEncrypt = plainText;
toEncrypt["sender"] = s.sender;
toEncrypt["recipient"] = s.recipient;
toEncrypt["recipient_keys"] = json{
{ed25519, s.clientNoDevice.constCrypto().ed25519IdentityKey()},
};
toEncrypt["keys"] = json{
{ed25519, c.ed25519IdentityKey()},
};
encJson["content"]["ciphertext"] = s.clientA.withCrypto([&toEncrypt, &s](auto &c) {
auto key = s.clientNoDevice.constCrypto().curve25519IdentityKey();
return c.encryptOlmWithRandom(
genRandomData(c.encryptOlmRandomSize(key)),
toEncrypt,
key
);
});
encJson["sender"] = s.sender;
auto encrypted = Event(encJson);
auto ev = decryptEvent(s.clientWithDevice, encrypted);
REQUIRE(!ev.decrypted());
REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_SENDER_KEYS");
}
SECTION("if verify event fails, device info is not added to tracker") {
auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ true);
auto clientRec = s.clientNoDevice;
clientRec.userId = "@bad:example.com";
auto ev = decryptEvent(clientRec, encrypted);
REQUIRE(!ev.decrypted());
REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT");
REQUIRE(s.clientNoDevice.deviceLists.deviceLists.empty());
}
}
TEST_CASE("EnsureKeysFromDevicesAction", "[client][encryption]")
{
auto client = makeClient(
withCrypto(makeCrypto())
);
auto u1Client = makeClient(withCrypto(makeCrypto()));
u1Client.userId = "@user:example.com";
u1Client.deviceId = "U1Device1";
auto [next, _] = updateClient(client, EnsureKeysFromDevicesAction{
{{"@user:example.com", {"U1Device1"}}},
});
assert1Job(next);
auto job = next.nextJobs.front();
next.nextJobs = {};
REQUIRE(job.jobId() == "QueryKeys");
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body.at("device_keys") == json::object({
{"@user:example.com", json::array({"U1Device1"})},
}));
auto u = makeMockSdkUtil(next);
auto md = u.getMockDispatcher(passDown<ProcessResponseAction>());
auto ctx = getMockContext(u.ph, md);
WHEN("good response") {
auto resp = makeResponse("QueryKeys", withResponseJsonBody(json::object({
{"device_keys", {
{"@user:example.com", {
{"U1Device1", makeDeviceInfo(u1Client)},
}},
}},
})) | withResponseDataKV("deviceKeys", job.dataJson("deviceKeys")));
ctx.dispatch(ProcessResponseAction{resp})
.then([&u](const EffectStatus &s) {
REQUIRE(s.success());
REQUIRE(s.dataJson("unsatisfied") == json::object({
{"users", json::array()},
{"devices", json::array()},
}));
u.io.stop();
});
u.io.run();
}
WHEN("missing device") {
auto resp = makeResponse("QueryKeys", withResponseJsonBody(json::object({
{"device_keys", {
{"@user:example.com", json::object()},
}},
})) | withResponseDataKV("deviceKeys", job.dataJson("deviceKeys")));
ctx.dispatch(ProcessResponseAction{resp})
.then([&u](const EffectStatus &s) {
REQUIRE(s.success());
REQUIRE(s.dataJson("unsatisfied") == json::object({
{"users", json::array()},
{"devices", json::array({{"@user:example.com", "U1Device1"}})},
}));
u.io.stop();
});
u.io.run();
}
}
+
+TEST_CASE("Client::devicesToRequestKeys()", "[client][encryption]")
+{
+ auto device1 = makeDeviceKeyInfo(withAttr(&DeviceKeyInfo::trustLevel, Verified));
+ auto device2 = makeDeviceKeyInfo();
+ auto client = makeClient(
+ withAttr(&ClientModel::userId, "@mew:example.org")
+ | withDevice("@foo:example.org", makeDeviceKeyInfo(withAttr(&DeviceKeyInfo::trustLevel, Verified)))
+ | withDevice("@mew:example.org", device1)
+ | withDevice("@mew:example.org", device2)
+ );
+
+ auto expected = immer::map<std::string, immer::flex_vector<std::string>>{
+ {"@mew:example.org", {device1.deviceId}},
+ };
+ REQUIRE(client.devicesToRequestKeys() == expected);
+}
+
+TEST_CASE("RequestShareRoomSessionKeyAction", "[client][encryption]")
+{
+ auto roomId = "!someroom:example.com";
+ auto room = makeRoom(
+ withRoomEncrypted(true)
+ | withRoomId(roomId)
+ );
+ auto sendingClient = makeClient(
+ withCrypto(makeCrypto())
+ | withRoom(room)
+ );
+
+ auto plainText = makeEvent();
+ auto [encrypted, sessionId] = sendingClient.megOlmEncrypt(plainText, roomId, 1719196953000,
+ genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
+
+ auto device1 = makeDeviceKeyInfo(withAttr(&DeviceKeyInfo::trustLevel, Verified));
+ auto client = makeClient(
+ withCrypto(makeCrypto())
+ | withAttr(&ClientModel::userId, "@mew:example.org")
+ | withDevice("@mew:example.org", device1)
+ | withRoom(room)
+ );
+
+ auto [next, _] = updateClient(client, RequestShareRoomSessionKeyAction{
+ roomId,
+ encrypted,
+ {},
+ });
+
+ assert1Job(next);
+ for1stJob(next, [device1, roomId, encrypted](const BaseJob &job) {
+ REQUIRE(job.jobId() == "SendToDevice");
+ auto body = getRequestJsonBody(job);
+ REQUIRE(job.url().find("/m.room_key_request/") != std::string::npos);
+ auto msg = body.at("messages").at("@mew:example.org").at(device1.deviceId);
+ REQUIRE(msg.at("action") == "request");
+ REQUIRE(msg.at("body").at("room_id") == roomId);
+ REQUIRE(msg.at("body").at("session_id") == encrypted.originalJson().get().at("/content/session_id"_json_pointer));
+ });
+}
+
+TEST_CASE("Client::requestShareRoomSessionKey", "[client][encryption]")
+{
+ auto u = makeMockSdkUtil(makeClient());
+ auto md = u.getMockDispatcher(returnResolved<RequestShareRoomSessionKeyAction>({true, {}}));
+ auto client = u.getClient(md);
+ client.requestShareRoomSessionKey("!roomid", makeEvent())
+ .then([&md, &u](auto) {
+ REQUIRE(md.template calledTimes<RequestShareRoomSessionKeyAction>() == 1);
+ u.io.stop();
+ });
+
+ u.io.run();
+}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Oct 10, 6:10 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785066
Default Alt Text
(184 KB)

Event Timeline