Page MenuHomePhorge

No OneTemporary

Size
242 KB
Referenced Files
None
Subscribers
None
diff --git a/src/base/kazv-triggers.hpp b/src/base/kazv-triggers.hpp
index 56a146e..0a222b4 100644
--- a/src/base/kazv-triggers.hpp
+++ b/src/base/kazv-triggers.hpp
@@ -1,104 +1,108 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include "libkazv-config.hpp"
#include <variant>
#include "types.hpp"
#include "event.hpp"
#include "basejob.hpp"
namespace Kazv
{
struct LoginSuccessful {};
struct LoginFailed
{
std::string errorCode;
std::string error;
};
struct ReceivingPresenceEvent { Event event; };
struct ReceivingAccountDataEvent { Event event; };
struct ReceivingRoomStateEvent {
Event event;
std::string roomId;
};
struct ReceivingRoomTimelineEvent {
Event event;
std::string roomId;
};
struct ReceivingRoomAccountDataEvent {
Event event;
std::string roomId;
};
struct ReceivingToDeviceMessage
{
Event event;
};
struct RoomMembershipChanged {
RoomMembership membership;
std::string roomId;
};
/**
* Indicate that there are events to be saved.
*/
struct SaveEventsRequested
{
/**
* The events to be saved in the timeline.
* The events saved in the timeline part of the storage can be considered continuous, i.e. they can be
* loaded to fully re-construct the timeline. There can be gaps,
* but all gaps are properly recorded in the client state so that
* it can be easily paginated.
*/
immer::map<std::string /* roomId */, EventList> timelineEvents;
/**
* The events that should be saved, but not in the timeline.
*/
immer::map<std::string /* roomId */, EventList> nonTimelineEvents;
};
struct UnrecognizedResponse
{
Response response;
};
+ struct VerificationTrackerModelChanged {};
+
using KazvTrigger = std::variant<
// use this for placeholder of "no events yet"
// otherwise the first LoginSuccessful event cannot be detected
std::monostate,
// matrix events
ReceivingPresenceEvent,
ReceivingAccountDataEvent,
ReceivingRoomTimelineEvent,
ReceivingRoomStateEvent,
RoomMembershipChanged,
ReceivingRoomAccountDataEvent,
ReceivingToDeviceMessage,
// auth
LoginSuccessful, LoginFailed,
// storage
SaveEventsRequested,
+ // encryption
+ VerificationTrackerModelChanged,
// general
UnrecognizedResponse
>;
using KazvEvent [[deprecated("renamed to KazvTrigger")]] = KazvTrigger;
using KazvTriggerList = immer::flex_vector<KazvTrigger>;
using KazvEventList [[deprecated("renamed to KazvTriggerList")]] = KazvTriggerList;
}
diff --git a/src/client/actions/encryption.cpp b/src/client/actions/encryption.cpp
index 3545596..be1c37a 100644
--- a/src/client/actions/encryption.cpp
+++ b/src/client/actions/encryption.cpp
@@ -1,712 +1,826 @@
/*
* 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 <immer-utils.hpp>
#include <debug.hpp>
#include "cursorutil.hpp"
#include "status-utils.hpp"
#include "key-export.hpp"
namespace Kazv
{
using namespace CryptoConstants;
static json convertSignature(const ClientModel &m, std::string signature)
{
auto j = json::object();
j[m.userId] = json::object();
j[m.userId][ed25519 + ":" + m.deviceId] = signature;
return j;
}
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 keys =
immer::map<std::string, std::string>{}
.set(ed25519 + ":" + m.deviceId, m.constCrypto().ed25519IdentityKey())
.set(curve25519 + ":" + m.deviceId, m.constCrypto().curve25519IdentityKey());
DeviceKeys k {
m.userId,
m.deviceId,
{olmAlgo, megOlmAlgo},
keys,
{} // signatures to be added soon
};
auto j = json(k);
auto sig = m.withCrypto([&](auto &crypto) { return crypto.sign(j); });
k.signatures = convertSignature(m, sig);
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"] = convertSignature(m, 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) {
std::string senderCurve25519Key = e.originalJson().get()
.at("content").at("sender_key");
auto deviceInfoOpt = m.deviceLists.findByCurve25519Key(e.sender(), senderCurve25519Key);
if (! deviceInfoOpt) {
kzo.client.dbg() << "Device key " << senderCurve25519Key
<< " unknown, thus invalid" << std::endl;
return cannotDecryptEvent(
"device key unknown",
"MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN",
plainJson
);
}
auto deviceInfo = deviceInfoOpt.value();
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
);
}
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
);
}
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
);
}
auto thisEd25519Key = plainJson.at("keys").at(ed25519).get<std::string>();
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
);
}
} 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;
}
static 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();
if (encryptedUsers.empty()) {
kzo.client.dbg() << "Keys are up-to-date." << std::endl;
return std::nullopt;
}
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;
auto job = m.job<QueryKeysJob>()
- .make(std::move(deviceKeys),
+ .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.withCrypto([&](Crypto &c) {
m.deviceLists.addDevice(userId, deviceId, deviceInfo, c);
});
}
- m.deviceLists.markUpToDate(userId);
+ if (wantedToFetchAllForUser(userId)) {
+ m.deviceLists.markUpToDate(userId);
+ }
}
- return { std::move(m), lager::noop };
+ 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};
+ }
}
diff --git a/src/client/actions/encryption.hpp b/src/client/actions/encryption.hpp
index 5ee4bd7..dcff335 100644
--- a/src/client/actions/encryption.hpp
+++ b/src/client/actions/encryption.hpp
@@ -1,38 +1,43 @@
/*
* 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);
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);
}
diff --git a/src/client/actions/sync.cpp b/src/client/actions/sync.cpp
index 404c1a7..8000ab9 100644
--- a/src/client/actions/sync.cpp
+++ b/src/client/actions/sync.cpp
@@ -1,379 +1,408 @@
/*
* 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 <lager/util.hpp>
#include <zug/transducer/map.hpp>
#include <zug/transducer/cat.hpp>
#include <zug/transducer/filter.hpp>
#include <zug/sequence.hpp>
#include <jobinterface.hpp>
#include <debug.hpp>
#include "cursorutil.hpp"
#include "sync.hpp"
#include "encryption.hpp"
#include "status-utils.hpp"
+#include "verification-tracker.hpp"
namespace Kazv
{
// Atomicity guaranteed: if the sync action is created
// before an action that reasonably changes Client
// (e.g. roll back to an earlier state, obtain other
// events), but executed
// after that action, the sync will still give continuous
// data about the events. (Sync will not "skip" events)
// This is because this function takes the sync token
// from the ClientModel model it is passed.
ClientResult updateClient(ClientModel m, SyncAction)
{
kzo.client.dbg() << "Start syncing with token " <<
(m.syncToken ? m.syncToken.value() : "<null>") << std::endl;
bool isInitialSync = ! m.syncToken;
m.syncing = true;
std::string filter = m.syncToken ? m.incrementalSyncFilterId : m.initialSyncFilterId;
m.addJob(m.job<SyncJob>()
.make(filter,
m.syncToken,
std::nullopt, // fullState
std::nullopt, // setPresence
// Let initial sync return immediately
isInitialSync ? 0 : m.syncTimeoutMs
)
.withData(json{{"is", isInitialSync ? "initial" : "incremental"}}));
return { m, lager::noop };
}
static KazvTriggerList loadRoomsFromSyncInPlace(ClientModel &m, SyncJob::Rooms rooms)
{
auto l = std::move(m.roomList);
auto eventsToEmit = KazvTriggerList{}.transient();
auto pushRules = PushRulesDesc(m.accountData["m.push_rules"]);
auto updateRoomImpl =
[&l](auto id, auto a) {
l = RoomListModel::update(
std::move(l),
UpdateRoomAction{std::move(id), std::move(a)});
};
auto updateSingleRoom =
[&, updateRoomImpl](const auto &id, const auto &room, auto membership) {
if (!l.has(id) || l[id].membership != membership) {
eventsToEmit.push_back(RoomMembershipChanged{membership, id});
}
updateRoomImpl(id, ChangeMembershipAction{membership});
auto timelineEvents =
intoImmer(
EventList{},
zug::map([=](Event e) {
return Event::fromSync(e, id);
}),
room.timeline.events);
eventsToEmit.append(
intoImmer(
KazvTriggerList{},
zug::map([=](Event e) -> KazvTrigger {
return ReceivingRoomTimelineEvent{std::move(e), id};
}),
timelineEvents).transient());
updateRoomImpl(id, AddToTimelineAction{timelineEvents,
room.timeline.prevBatch,
room.timeline.limited,
std::nullopt // we do not have a gapEventId
});
if (room.state) {
eventsToEmit.append(
intoImmer(
KazvTriggerList{},
zug::map([=](Event e) -> KazvTrigger {
return ReceivingRoomStateEvent{std::move(e), id};
}),
room.state.value().events).transient());
updateRoomImpl(id, AddStateEventsAction{room.state.value().events});
}
// Process state events in timeline, which should have arrived later
// than those in room.state .
updateRoomImpl(id, AddStateEventsAction{
intoImmer(EventList{},
zug::filter([=](Event e) {
return e.isState();
}),
timelineEvents)});
if (room.accountData) {
eventsToEmit.append(
intoImmer(
KazvTriggerList{},
zug::map([=](Event e) -> KazvTrigger {
return ReceivingRoomAccountDataEvent{std::move(e), id};
}),
room.accountData.value().events).transient());
updateRoomImpl(id, AddAccountDataAction{room.accountData.value().events});
}
};
auto updateRoomSummary =
[=](const auto &id, const auto &room) {
if (!room.summary.has_value()) {
return;
}
if (!room.summary->mHeroes.empty()) {
auto newHeroes = room.summary->mHeroes;
updateRoomImpl(id, SetHeroIdsAction{immer::flex_vector<std::string>(newHeroes.begin(), newHeroes.end())});
}
if (room.summary->mJoinedMemberCount.has_value()) {
updateRoomImpl(id, UpdateJoinedMemberCountAction{static_cast<std::size_t>(room.summary->mJoinedMemberCount.value())});
}
if (room.summary->mInvitedMemberCount.has_value()) {
updateRoomImpl(id, UpdateInvitedMemberCountAction{static_cast<std::size_t>(room.summary->mInvitedMemberCount.value())});
}
};
auto updateRoomNotifications = [=, &l](const auto &id, const auto &room) {
auto oldRoom = m.roomList.rooms[id];
const auto &newRoom = l.rooms[id];
updateRoomImpl(id, AddLocalNotificationsAction{
room.timeline.events,
pushRules,
m.userId,
});
if (oldRoom.readReceipts[m.userId].eventId
!= newRoom.readReceipts[m.userId].eventId) {
updateRoomImpl(id, RemoveReadLocalNotificationsAction{m.userId});
}
};
auto updateJoinedRoom =
[=](const auto &id, const auto &room) {
updateSingleRoom(id, room, RoomMembership::Join);
if (room.ephemeral) {
updateRoomImpl(id, AddEphemeralAction{room.ephemeral.value().events});
}
updateRoomNotifications(id, room);
updateRoomSummary(id, room);
};
auto updateInvitedRoom =
[=](const auto &id, const auto &room) {
updateRoomImpl(id, ChangeMembershipAction{RoomMembership::Invite});
if (room.inviteState) {
updateRoomImpl(id, ChangeInviteStateAction{room.inviteState.value().events});
}
};
auto updateLeftRoom =
[=](const auto &id, const auto &room) {
updateSingleRoom(id, room, RoomMembership::Leave);
};
for (const auto &[id, room]: rooms.join) {
updateJoinedRoom(id, room);
}
// TODO update info for invited rooms
for (const auto &[id, room]: rooms.invite) {
updateInvitedRoom(id, room);
}
for (const auto &[id, room]: rooms.leave) {
updateLeftRoom(id, room);
}
m.roomList = std::move(l);
return eventsToEmit.persistent();
}
static KazvTriggerList loadPresenceFromSyncInPlace(ClientModel &m, EventList presence)
{
auto eventsToEmit = intoImmer(
KazvTriggerList{},
zug::map([](Event e) { return ReceivingPresenceEvent{e}; }),
presence);
m.presence = merge(std::move(m.presence), presence, keyOfPresence);
return eventsToEmit;
}
static KazvTriggerList loadAccountDataFromSyncInPlace(ClientModel &m, EventList accountData)
{
auto eventsToEmit = intoImmer(
KazvTriggerList{},
zug::map([](Event e) { return ReceivingPresenceEvent{e}; }),
accountData);
m.accountData = merge(std::move(m.accountData), accountData, keyOfAccountData);
return eventsToEmit;
}
static KazvTriggerList loadToDeviceFromSyncInPlace(ClientModel &m, JsonWrap toDevice)
{
if (toDevice.get().contains("events")) {
auto events = toDevice.get()["events"];
auto msgs = intoImmer(
EventList{},
zug::map([](json j) {
// Prevent malicious server from injecting a room id
j.erase("room_id");
return Event(j);
}),
events);
m.toDevice = std::move(m.toDevice) + msgs;
return intoImmer(
KazvTriggerList{},
zug::map([](Event e) { return ReceivingToDeviceMessage{e}; }),
msgs);
}
return {};
}
+ [[nodiscard]] static json popVerificationEvents(ClientModel &m)
+ {
+ auto oldToDevice = std::move(m.toDevice);
+ auto toDeviceVerificationEvents = intoImmer(
+ EventList{},
+ zug::filter([](const auto &e) {
+ return VerificationTracker::isVerificationEvent(e);
+ }),
+ oldToDevice);
+ m.toDevice = intoImmer(
+ EventList{},
+ zug::filter([](const auto &e) {
+ return !VerificationTracker::isVerificationEvent(e);
+ }),
+ std::move(oldToDevice));
+ return json::object({
+ {"toDevice", toDeviceVerificationEvents}
+ });
+ }
+
ClientResult processResponse(ClientModel m, SyncResponse r)
{
if (! r.success()) {
kzo.client.dbg() << "Sync failed" << std::endl;
kzo.client.dbg() << r.statusCode << std::endl;
if (isBodyJson(r.body)) {
auto j = r.jsonBody();
kzo.client.dbg() << "Json says: " << j.get().dump() << std::endl;
} else {
kzo.client.dbg() << "Response body: "
<< std::get<BaseJob::BytesBody>(r.body) << std::endl;
}
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Sync successful" << std::endl;
auto rooms = r.rooms();
auto accountData = r.accountData();
auto presence = r.presence();
// load the info that has been sync'd
m.syncToken = r.nextBatch();
// Load account data first because it contains push rules
// which can affect the processing of rooms
if (accountData) {
m.addTriggers(loadAccountDataFromSyncInPlace(m, std::move(accountData.value().events)));
}
if (rooms) {
m.addTriggers(loadRoomsFromSyncInPlace(m, std::move(rooms.value())));
}
if (presence) {
m.addTriggers(loadPresenceFromSyncInPlace(m, std::move(presence.value().events)));
}
m.addTriggers(loadToDeviceFromSyncInPlace(m, r.toDevice()));
auto is = r.dataStr("is");
auto isInitialSync = is == "initial";
+ json ve = json::object({
+ {"toDevice", json::array()},
+ });
if (m.crypto) {
kzo.client.dbg() << "E2EE is on. Processing device lists and one-time key counts." << std::endl;
// process deviceLists
if (isInitialSync) {
auto encryptedUsers =
zug::sequence(
zug::map([](auto n) { return n.second; })
| zug::filter([](auto room) { return room.encrypted; })
| zug::map([](auto room) { return room.joinedMemberIds(); })
| zug::cat,
// no need to use distinct here as the map will overwrite
m.roomList.rooms);
m.deviceLists.track(std::move(encryptedUsers));
} else {
auto l = r.deviceLists().get();
if (l.contains("changed")) {
const auto &changed = l.at("changed");
m.deviceLists.track(changed);
}
if (l.contains("left")) {
const auto &left = l.at("left");
m.deviceLists.untrack(left);
}
}
// deviceOneTimeKeysCount
m.withCrypto([&](auto &c) { c.setUploadedOneTimeKeysCount(r.deviceOneTimeKeysCount()); });
auto model = tryDecryptEvents(std::move(m));
m = std::move(model);
+ ve = popVerificationEvents(m);
}
- return { std::move(m), lager::noop };
+ return { std::move(m), [ve=std::move(ve)](auto &&) {
+ return EffectStatus(true, json{
+ {"verificationEvents", ve},
+ });
+ } };
}
ClientResult updateClient(ClientModel m, SetShouldSyncAction a)
{
m.shouldSync = a.shouldSync;
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, PostInitialFiltersAction)
{
if (m.syncing) {
return { std::move(m), lager::noop };
}
Filter initialSyncFilter;
initialSyncFilter.room.timeline.limit = 1;
initialSyncFilter.room.state.lazyLoadMembers = true;
auto firstJob = m.job<DefineFilterJob>()
.make(m.userId, initialSyncFilter)
.withData(json{{"is", "initialSyncFilter"}})
.withQueue("post-filter", CancelFutureIfFailed);
m.addJob(firstJob);
Filter incrementalSyncFilter;
incrementalSyncFilter.room.timeline.limit = 20;
incrementalSyncFilter.room.state.lazyLoadMembers = true;
m.addJob(m.job<DefineFilterJob>()
.make(m.userId, incrementalSyncFilter)
.withData(json{{"is", "incrementalSyncFilter"}})
.withQueue("post-filter", CancelFutureIfFailed));
m.syncing = true;
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, DefineFilterResponse r)
{
auto is = r.dataStr("is");
if (! r.success()) {
m.syncing = false;
kzo.client.dbg() << "posting filter failed: " << r.errorCode() << r.errorMessage() << std::endl;
return { std::move(m), lager::noop };
}
kzo.client.dbg() << "filter " << is << " is posted" << std::endl;
if (is == "incrementalSyncFilter") {
m.incrementalSyncFilterId = r.filterId();
} else {
m.initialSyncFilterId = r.filterId();
}
return { std::move(m), lager::noop };
}
}
diff --git a/src/client/client-model.hpp b/src/client/client-model.hpp
index ebea6d6..61ff83d 100644
--- a/src/client/client-model.hpp
+++ b/src/client/client-model.hpp
@@ -1,685 +1,746 @@
/*
* 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;
/// 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);
/// @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 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 0a8beaa..2210c5d 100644
--- a/src/client/client.cpp
+++ b/src/client/client.cpp
@@ -1,544 +1,757 @@
/*
* 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) const
-> PromiseT
{
auto p1 = m_ctx.dispatch(LoginAction{
homeserver, username, password, deviceName});
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::mLoginTokenLogin(
std::string homeserver,
std::string loginToken,
std::optional<std::string> deviceName
) const -> PromiseT
{
auto p1 = m_ctx.dispatch(MLoginTokenLoginAction{
homeserver, loginToken, deviceName});
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
{
auto p1 = m_ctx.dispatch(TokenLoginAction{
homeserver, username, token, deviceId});
p1
.then([that=toEventLoop()](auto stat) {
if (! stat.success()) {
return;
}
that.startSyncing();
});
return p1;
}
auto Client::shouldSync() const -> lager::reader<bool> {
return this->clientCursor()[&ClientModel::shouldSync];
}
auto Client::logout() const
-> PromiseT
{
return stopSyncing().then([ctx=m_ctx] (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 = 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);
}
});
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::syncForever(std::optional<int> retryTime) const -> void
{
KAZV_VERIFY_THREAD_ID();
// assert (m_deps);
using namespace CursorOp;
bool isInitialSync = ! (+clientCursor()[&ClientModel::syncToken]).has_value();
bool shouldSync = +clientCursor()[&ClientModel::shouldSync];
if (! shouldSync) {
return;
}
//
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);
+ });
+
m_ctx.promiseInterface()
- .all(std::vector<PromiseT>{uploadOneTimeKeysRes, queryKeysRes})
+ .all(std::vector<PromiseT>{uploadOneTimeKeysRes, queryKeysRes, processVerificationEventsRes})
.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});
}
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 89d0c78..eccb9f2 100644
--- a/src/client/client.hpp
+++ b/src/client/client.hpp
@@ -1,674 +1,764 @@
/*
* 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 &
+ 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 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.
* @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) 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 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`.
* @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) const;
/**
* Login using a login token.
*
* This will create a new session on the homeserver.
*
* If 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.
* @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
) const;
/**
* Get the `shouldSync` field of current ClientModel.
*
* @return A lager::reader of bool of the `shouldSync` field of the ClientModel
*/
auto shouldSync() const -> lager::reader<bool>;
/**
* Stop syncing and then logout current session.
*
* Meanwhile, clear the current token and set loggedIn to false.
*
* @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.
*
* @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.
*
* After this, no more syncing actions will be dispatched.
*
* @return A Promise that resolves when syncing is stopped.
*/
PromiseT stopSyncing() 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;
+
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 761da7f..fe57a78 100644
--- a/src/client/clientfwd.hpp
+++ b/src/client/clientfwd.hpp
@@ -1,159 +1,165 @@
/*
* 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;
class 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 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,
GetUserProfileAction,
SetAvatarUrlAction,
SetDisplayNameAction,
ResubmitJobAction,
LoadEventsFromStorageAction,
PurgeRoomTimelineAction
>;
using ClientEffect = Effect<ClientAction, lager::deps<>>;
using ClientResult = std::pair<ClientModel, ClientEffect>;
}
diff --git a/src/client/device-list-tracker.cpp b/src/client/device-list-tracker.cpp
index 362be1e..c896b2d 100644
--- a/src/client/device-list-tracker.cpp
+++ b/src/client/device-list-tracker.cpp
@@ -1,182 +1,187 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include "device-list-tracker.hpp"
#include <algorithm>
#include <immer/flex_vector_transient.hpp>
#include <zug/transducer/filter.hpp>
#include <zug/sequence.hpp>
#include <zug/transducer/distinct.hpp>
#include <zug/transducer/chain.hpp>
#include <debug.hpp>
namespace Kazv
{
immer::flex_vector<std::string> DeviceListTracker::outdatedUsers() const
{
return intoImmer(
immer::flex_vector<std::string>{},
zug::filter([](auto n) {
auto [userId, outdated] = n;
return outdated;
})
| zug::map([](auto n) {
auto [userId, outdated] = n;
return userId;
}),
usersToTrackDeviceLists);
}
bool DeviceListTracker::addDevice(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo, Crypto &crypto)
{
using namespace CryptoConstants;
if (userId != deviceInfo.userId
|| deviceId != deviceInfo.deviceId) {
return false;
}
// if the ed25519 key changed, reject
auto curEd25519Key = deviceInfo.keys[ed25519 + ":" + deviceId];
- if (deviceLists[userId].find(deviceId)
- && curEd25519Key != deviceLists[userId][deviceId].ed25519Key) {
- return false;
+ DeviceTrustLevel trustLevel{Unseen};
+ if (deviceLists[userId].find(deviceId)) {
+ if (curEd25519Key != deviceLists[userId][deviceId].ed25519Key) {
+ return false;
+ }
+ // keep device trust level when adding device
+ trustLevel = deviceLists[userId][deviceId].trustLevel;
}
kzo.client.dbg() << "verifying device info" << std::endl;
if (crypto.verify(deviceInfo, userId, deviceId, curEd25519Key)) {
kzo.client.dbg() << "passed verification" << std::endl;
auto info = DeviceKeyInfo{
deviceId,
deviceInfo.keys[ed25519 + ":" + deviceId],
deviceInfo.keys[curve25519 + ":" + deviceId],
- deviceInfo.unsignedData ? deviceInfo.unsignedData.value().deviceDisplayName : std::nullopt
+ deviceInfo.unsignedData ? deviceInfo.unsignedData.value().deviceDisplayName : std::nullopt,
+ trustLevel,
};
deviceLists = std::move(deviceLists)
.update(userId, [=](auto deviceMap) {
return std::move(deviceMap).set(deviceId, info);
});
return true;
}
kzo.client.dbg() << "did not pass verification" << std::endl;
return false;
}
void DeviceListTracker::markUpToDate(std::string userId)
{
usersToTrackDeviceLists = std::move(usersToTrackDeviceLists).set(userId, false);
}
std::optional<DeviceKeyInfo> DeviceListTracker::get(std::string userId, std::string deviceId) const
{
try {
return deviceLists.at(userId).at(deviceId);
} catch (const std::exception &) {
return std::nullopt;
}
}
std::optional<DeviceKeyInfo> DeviceListTracker::findByEd25519Key(
std::string userId, std::string ed25519Key) const
{
auto devices = deviceLists.at(userId);
auto it = std::find_if(devices.begin(), devices.end(),
[=](auto n) {
auto [deviceId, info] = n;
return info.ed25519Key == ed25519Key;
});
if (it != devices.end()) {
return it->second;
} else {
return std::nullopt;
}
}
std::optional<DeviceKeyInfo> DeviceListTracker::findByCurve25519Key(
std::string userId, std::string curve25519Key) const
{
if (!deviceLists.count(userId)) {
return std::nullopt;
}
auto devices = deviceLists.at(userId);
auto it = std::find_if(devices.begin(), devices.end(),
[=](auto n) {
auto [deviceId, info] = n;
return info.curve25519Key == curve25519Key;
});
if (it != devices.end()) {
return it->second;
} else {
return std::nullopt;
}
}
static bool cryptographicallyEqual(DeviceKeyInfo a, DeviceKeyInfo b)
{
a.displayName = std::nullopt;
b.displayName = std::nullopt;
a.trustLevel = Unseen;
b.trustLevel = Unseen;
return std::move(a) == std::move(b);
}
static bool cryptographicallyEqual(const DeviceListTracker::DeviceMapT &a, const DeviceListTracker::DeviceMapT &b)
{
auto changed = false;
auto markChanged = [&changed](const auto &) { changed = true; };
immer::diff(a, b, immer::make_differ(
/* added = */ markChanged,
/* removed = */ markChanged,
/* changed = */ [&changed](const auto &x, const auto &y) {
if (!cryptographicallyEqual(x.second, y.second)) {
changed = true;
}
}
));
return !changed;
}
immer::flex_vector<std::string> DeviceListTracker::diff(DeviceListTracker that) const
{
auto changedUsers = immer::flex_vector_transient<std::string>{};
immer::diff(
that.deviceLists, deviceLists,
immer::make_differ(
/* addedFn = */ [&changedUsers](const auto &pair) {
changedUsers.push_back(pair.first);
},
/* removedFn = */ [&changedUsers](const auto &pair) {
changedUsers.push_back(pair.first);
},
/* changedFn = */ [&changedUsers](const auto &pairA, const auto &pairB) {
// add to changed user list only if device id or trust level changes
if (!cryptographicallyEqual(pairA.second, pairB.second)) {
changedUsers.push_back(pairA.first);
}
}
)
);
return changedUsers.persistent();
}
auto DeviceListTracker::devicesFor(std::string userId) const -> DeviceMapT
{
return deviceLists[userId];
}
}
diff --git a/src/client/sdk.hpp b/src/client/sdk.hpp
index ddcf9da..ea6c31b 100644
--- a/src/client/sdk.hpp
+++ b/src/client/sdk.hpp
@@ -1,298 +1,302 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2021 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <lager/store.hpp>
#include <random>
#include <store.hpp>
#include "sdk-model.hpp"
#include "sdk-model-cursor-tag.hpp"
#include "client.hpp"
#include "thread-safety-helper.hpp"
-
+#include "verification-tracker.hpp"
#include "random-generator.hpp"
namespace Kazv
{
/**
* Contain the single source of truth of a matrix sdk.
*/
template<class EventLoop, class Xform, class ...Enhancers>
class Sdk
{
using ModelT = ::Kazv::SdkModel;
using ClientT = ::Kazv::ClientModel;
using ActionT = typename ModelT::Action;
using CursorT = lager::reader<ModelT>;
using CursorTSP = std::shared_ptr<CursorT>;
using StoreT = decltype(
makeStore<ActionT>(
std::declval<ModelT>(),
&ModelT::update,
std::declval<EventLoop>(),
lager::with_deps(
std::ref(detail::declref<JobInterface>()),
std::ref(detail::declref<EventInterface>()),
lager::dep::as<SdkModelCursorKey>(std::declval<std::function<CursorTSP()>>()),
- std::ref(detail::declref<RandomInterface>())
+ std::ref(detail::declref<RandomInterface>()),
+ std::ref(detail::declref<VerificationTracker>())
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, std::ref(detail::declref<EventLoopThreadIdKeeper>())
#endif
),
std::declval<Enhancers>()...)
);
- using DepsT = lager::deps<JobInterface &, EventInterface &, SdkModelCursorKey, RandomInterface &
+ using DepsT = lager::deps<JobInterface &, EventInterface &, SdkModelCursorKey, RandomInterface &, VerificationTracker &
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, EventLoopThreadIdKeeper &
#endif
>;
using ContextT = Context<ActionT, DepsT>;
public:
Sdk(ModelT model,
JobInterface &jobHandler,
EventInterface &eventEmitter,
EventLoop &&eventLoop,
Xform &&xform,
Enhancers &&...enhancers)
: m_d(std::make_unique<Private>(
std::move(model), jobHandler, eventEmitter,
std::forward<EventLoop>(eventLoop), std::forward<Xform>(xform),
std::forward<Enhancers>(enhancers)...)) {}
/**
* Get the context associated with this.
*
* The returned context is thread-safe if every thread calls with
* different instances.
*/
ContextT context() const {
return m_d->store;
}
/**
* Get a Client representing this.
*
* The returned Client belongs to the thread where the promise handler runs.
*/
Client client() const {
return {Client::InEventLoopTag{}, ContextT(m_d->store)};
}
/**
* Create a secondary root for this Sdk.
*
* @param eventLoop An event loop passed to `lager::make_store`.
* The resulting secondary root will belong to the thread of this event loop.
* @param initialModel The initial value for the model on the secondary root.
*
* @return A lager::store that belongs to the thread of `eventLoop`. The
* store will be kept update with this sdk.
*/
template<class EL>
auto createSecondaryRoot(EL &&eventLoop, ModelT initialModel = ModelT{}) const {
auto secondaryStore = lager::make_store<ModelT>(
std::move(initialModel),
std::forward<EL>(eventLoop),
lager::with_reducer([](auto &&, auto next) { return next; }));
lager::context<ModelT> secondaryCtx = secondaryStore;
context().createResolvedPromise({})
.then([secondaryCtx, d=m_d.get()](auto &&) {
lager::watch(*(d->sdk),
[secondaryCtx](auto next) { secondaryCtx.dispatch(std::move(next)); });
});
return secondaryStore;
}
/**
* Get a Client representing this.
*
* The returned Client belongs to the same thread as `sr`.
*
* This function is thread-safe, but it must be called from the thread
* where `sr` belongs.
*
* @param sr The secondary root cursor that represents this sdk.
*
* @return A Client representing this in the same thread as `sr`.
*/
Client clientFromSecondaryRoot(lager::reader<ModelT> sr) const {
return Client(sr, ContextT(m_d->store));
}
private:
struct Private
{
Private(ModelT model,
JobInterface &jobHandler,
EventInterface &eventEmitter,
EventLoop &&eventLoop,
Xform &&xform,
Enhancers &&...enhancers)
: rg(RandomInterface{RandomDeviceGenerator{}})
+ , vt(VerificationUtils::DeviceIdentity())
, store(makeStore<ActionT>(
std::move(model),
&ModelT::update,
std::forward<EventLoop>(eventLoop),
lager::with_deps(
std::ref(jobHandler),
std::ref(eventEmitter),
lager::dep::as<SdkModelCursorKey>(
std::function<CursorTSP()>([this] { return sdk; })),
- std::ref(rg.value())
+ std::ref(rg.value()),
+ std::ref(vt)
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
, std::ref(keeper)
#endif
),
std::forward<Enhancers>(enhancers)...))
, sdk(std::make_shared<lager::reader<ModelT>>(store.reader().xform(std::forward<Xform>(xform))))
{
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
store.context().createResolvedPromise(EffectStatus{})
.then([this](auto &&) {
keeper.set(std::this_thread::get_id());
});
#endif
}
#ifdef KAZV_USE_THREAD_SAFETY_HELPER
EventLoopThreadIdKeeper keeper;
#endif
std::optional<RandomInterface> rg;
+ VerificationTracker vt;
StoreT store;
CursorTSP sdk;
};
std::unique_ptr<Private> m_d;
};
/**
* Create an sdk with the provided model.
*
* @param sdk The initial SdkModel.
* @param jobHandler The job handler for the sdk.
* @param eventEmitter The event emitter for the sdk.
* @param ph The Promise handler for the sdk.
* @param xform A function to extract the SdkModel from
* the model type of the created Store. This is to take into
* account any enhancer that changes the model type. If you
* do not use any enhancer that changes the model type, put
* an identity function (e.g. `zug::identity`) here.
* @param enhancers The enhancers to pass to `makeStore()`.
*
* @return An Sdk created with these parameters.
*
* @sa JobInterface, EventInterface, PromiseInterface
*/
template<class EventLoop, class Xform, class ...Enhancers>
inline auto makeSdk(SdkModel sdk,
JobInterface &jobHandler,
EventInterface &eventEmitter,
EventLoop &&eventLoop,
Xform &&xform,
Enhancers &&...enhancers)
-> Sdk<EventLoop, Xform, Enhancers...>
{
return { std::move(sdk),
jobHandler,
eventEmitter,
std::forward<EventLoop>(eventLoop),
std::forward<Xform>(xform),
std::forward<Enhancers>(enhancers)... };
}
/**
* @return The size of random data needed for makeDefaultSdkWithCryptoRandom
*/
inline std::size_t makeDefaultSdkWithCryptoRandomSize()
{
return Crypto::constructRandomSize();
}
template<class EventLoop, class Xform, class ...Enhancers>
[[deprecated("Use deterministic makeDefaultSdkWithCryptoRandom instead. In the future, this will be removed.")]]
inline auto makeDefaultEncryptedSdk(
JobInterface &jobHandler,
EventInterface &eventEmitter,
EventLoop &&eventLoop,
Xform &&xform,
Enhancers &&...enhancers)
-> Sdk<EventLoop, Xform, Enhancers...>
{
auto m = SdkModel{};
m.client.crypto = Crypto(RandomTag{}, genRandomData(makeDefaultSdkWithCryptoRandomSize()));
return makeSdk(std::move(m),
jobHandler,
eventEmitter,
std::forward<EventLoop>(eventLoop),
std::forward<Xform>(xform),
std::forward<Enhancers>(enhancers)...);
}
/**
* Create an sdk with a default-constructed model, and
* a Crypto constructed with user-provided random data.
*
* @param random The random data to construct Crypto.
* Must be of at least size `makeDefaultSdkWithCryptoRandomSize()`.
* @param jobHandler The job handler for the sdk.
* @param eventEmitter The event emitter for the sdk.
* @param ph The Promise handler for the sdk.
* @param xform A function to extract the SdkModel from
* the model type of the created Store. This is to take into
* account any enhancer that changes the model type. If you
* do not use any enhancer that changes the model type, put
* an identity function (e.g. `zug::identity`) here.
* @param enhancers The enhancers to pass to `makeStore()`.
*
* @return An Sdk created with these parameters.
*
* @sa JobInterface, EventInterface, PromiseInterface
*/
template<class PH, class Xform, class ...Enhancers>
inline auto makeDefaultSdkWithCryptoRandom(
RandomData random,
JobInterface &jobHandler,
EventInterface &eventEmitter,
PH &&ph,
Xform &&xform,
Enhancers &&...enhancers)
-> Sdk<PH, Xform, Enhancers...>
{
auto m = SdkModel{};
m.client.crypto = Crypto(RandomTag{}, std::move(random));
return makeSdk(std::move(m),
jobHandler,
eventEmitter,
std::forward<PH>(ph),
std::forward<Xform>(xform),
std::forward<Enhancers>(enhancers)...);
}
/**
* An enhancer to use a custom random generator.
*
* This is to be used with `makeSdk()`-series functions.
*
* @param random The random generator to use.
*/
inline auto withRandomGenerator(RandomInterface &random)
{
return lager::with_deps(std::ref(random));
}
}
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 04d076d..f90e5dd 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -1,132 +1,133 @@
include(CTest)
set(KAZVTEST_RESPATH ${CMAKE_CURRENT_SOURCE_DIR}/resources)
configure_file(kazvtest-respath.hpp.in kazvtest-respath.hpp)
function(libkazv_add_tests)
set(options "")
set(oneValueArgs "")
set(multiValueArgs EXTRA_LINK_LIBRARIES EXTRA_INCLUDE_DIRECTORIES)
cmake_parse_arguments(PARSE_ARGV 0 libkazv_add_tests "${options}" "${oneValueArgs}" "${multiValueArgs}")
foreach(test_source ${libkazv_add_tests_UNPARSED_ARGUMENTS})
string(REGEX REPLACE "\\.cpp$" "" test_executable "${test_source}")
string(REGEX REPLACE "/|\\\\" "--" test_executable "${test_executable}")
message(STATUS "Test ${test_executable} added")
add_executable("${test_executable}" "${test_source}")
target_link_libraries("${test_executable}"
PRIVATE Catch2::Catch2WithMain
Threads::Threads
${libkazv_add_tests_EXTRA_LINK_LIBRARIES}
)
target_include_directories(
"${test_executable}"
PRIVATE ${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/..
${libkazv_add_tests_EXTRA_INCLUDE_DIRECTORIES}
)
target_compile_definitions("${test_executable}" PRIVATE CATCH_CONFIG_ENABLE_ALL_STRINGMAKERS)
add_test(NAME "${test_executable}" COMMAND "${test_executable}" "--allow-running-no-tests" "~[needs-internet]")
endforeach()
endfunction()
libkazv_add_tests(
event-test.cpp
cursorutiltest.cpp
base/serialization-test.cpp
base/types-test.cpp
base/immer-utils-test.cpp
base/json-utils-test.cpp
EXTRA_LINK_LIBRARIES kazvbase
)
add_library(client-test-lib SHARED client/client-test-util.cpp)
target_link_libraries(client-test-lib PUBLIC kazvjob kazvclient)
libkazv_add_tests(
client/discovery-test.cpp
client/sync-test.cpp
client/content-test.cpp
client/paginate-test.cpp
client/storage-actions-test.cpp
client/util-test.cpp
client/serialization-test.cpp
client/encrypted-file-test.cpp
client/sdk-test.cpp
client/thread-safety-test.cpp
client/room-test.cpp
client/random-generator-test.cpp
client/profile-test.cpp
client/kick-test.cpp
client/ban-test.cpp
client/join-test.cpp
client/keys-test.cpp
client/device-ops-test.cpp
client/send-test.cpp
client/encryption-test.cpp
client/redact-test.cpp
client/tagging-test.cpp
client/account-data-test.cpp
client/room/room-actions-test.cpp
client/room/local-echo-test.cpp
client/room/event-relationships-test.cpp
client/room/member-membership-test.cpp
client/room/purge-test.cpp
client/push-rules-desc-test.cpp
client/notification-handler-test.cpp
client/validator-test.cpp
client/power-levels-desc-test.cpp
client/client-test.cpp
client/create-room-test.cpp
client/device-list-tracker-test.cpp
client/device-list-tracker-benchmark-test.cpp
client/room/read-receipt-test.cpp
client/room/undecrypted-events-test.cpp
client/encryption-benchmark-test.cpp
client/login-test.cpp
client/logout-test.cpp
client/room/pinned-events-test.cpp
client/get-versions-test.cpp
client/alias-test.cpp
client/encode-test.cpp
client/maybe-add-save-events-trigger-benchmark-test.cpp
+ client/verification-processing-test.cpp
EXTRA_LINK_LIBRARIES kazvclient kazveventemitter kazvjob client-test-lib kazvtestfixtures
EXTRA_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/client
)
libkazv_add_tests(
basejobtest.cpp
kazvjobtest.cpp
file-desc-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazvjob
)
libkazv_add_tests(
promise-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazvjob kazvstore
)
libkazv_add_tests(
event-emitter-test.cpp
EXTRA_LINK_LIBRARIES kazvbase kazveventemitter
)
libkazv_add_tests(
crypto-test.cpp
crypto/inbound-group-session-test.cpp
crypto/outbound-group-session-test.cpp
crypto/session-test.cpp
crypto/key-export-test.cpp
crypto/verification-process-test.cpp
crypto/verification-utils-test.cpp
crypto/verification-tracker-test.cpp
EXTRA_LINK_LIBRARIES kazvcrypto
)
libkazv_add_tests(
store-test.cpp
EXTRA_LINK_LIBRARIES kazvstore kazvjob
)
diff --git a/src/tests/client/action-mock-utils.hpp b/src/tests/client/action-mock-utils.hpp
index 95afbfe..62b4882 100644
--- a/src/tests/client/action-mock-utils.hpp
+++ b/src/tests/client/action-mock-utils.hpp
@@ -1,359 +1,372 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <vector>
#include <boost/hana.hpp>
#include <boost/core/demangle.hpp>
#include <zug/into_vector.hpp>
#include <zug/transducer/map.hpp>
#include <zug/transducer/filter.hpp>
#include <store/store.hpp>
#include <client/client.hpp>
#include <client/sdk.hpp>
#include <cprjobhandler.hpp>
#include <lagerstoreeventemitter.hpp>
+#include <promise-interface.hpp>
#include <asio-promise-handler.hpp>
#include <lager/event_loop/boost_asio.hpp>
template<class Action>
struct PassDownTag
{};
template<class Action>
constexpr PassDownTag<Action> passDown()
{
return {};
};
template<class Action, class DataT = Kazv::EffectStatus>
struct ReturnResolvedTag
{
DataT data;
};
template<class Action, class DataT = Kazv::EffectStatus>
constexpr ReturnResolvedTag<Action, DataT> returnResolved(DataT data)
{
return {data};
}
template<class Action, class DataT = Kazv::EffectStatus>
constexpr ReturnResolvedTag<Action, DataT> returnEmpty()
{
return {{}};
}
template<class SubAction, class FuncT>
struct HandlerTag : public FuncT
{
HandlerTag(FuncT f) : FuncT(std::move(f)) {}
};
template<class SubAction, class FuncT>
constexpr auto makeHandler(FuncT f)
{
return HandlerTag<SubAction, FuncT>(std::move(f));
};
template<class R, class F, class = int, class ...Args>
struct IsExactInvocableHelper : public std::false_type
{};
template<class R, class F, class ...Args>
struct IsExactInvocableHelper<
R,
F,
std::enable_if_t<std::is_same_v<std::invoke_result_t<F, Args...>, R>, int>,
Args...> : public std::true_type
{};
template<class R, class F, class ...Args>
constexpr auto isExactInvocable = IsExactInvocableHelper<R, F, int, Args...>::value;
static_assert(isExactInvocable<int, std::function<int(long long)>, long long>);
template<class T>
std::string getTypeName(const T &a)
{
return boost::core::demangle(typeid(a).name());
}
template<class Variant>
std::string getVariantTypeName(const Variant &v)
{
return std::visit([](const auto &a) {
return getTypeName(a);
}, v);
}
template<class Promise>
struct HandlerResult
{
std::optional<Promise> retVal;
};
template<class PH, class Context, class Action = Kazv::Client::ActionT>
struct MockDispatcher
{
using ContextT = Context;
using ActionT = Action;
using PromiseT = typename ContextT::PromiseT;
using DataT = typename PromiseT::DataT;
using HandlerResultT = HandlerResult<PromiseT>;
/**
* A handler for mock dispatchers.
*
* It can be constructed from a function with the signature
* `HandlerResult<ContextT::PromiseT>(PH &, ContextT &, ActionT)`
* or it can be constructed from the following convenient
* forms called handler tags, where `ActionType` is a type in the variant ActionT:
*
* - `passDown<ActionType>()` will dispatch the action to the underlying context, and return the promise returned from the dispatch. Actions other than ActionType will not be matched.
* - `returnResolved<ActionType>(DataT data)` will return a Promise that is resolved and contains `data` when the action is of ActionType. Actions other than ActionType will not be matched.
* - `returnEmpty<ActionType>()` is equivalent to `returnResolved<ActionType>({}).
* - `makeHandler<ActionType>(Func func)`, where `func` has any of the following signature:
* 1. `HandlerResult<PromiseT>(PH &, ContextT &, ActionType)`. It will be called when the action is of ActionType, and the return value from it will be the result. Actions other than ActionType will not be matched and will not be executed on this function.
* 2. `PromiseT(PH &, ContextT &, ActionType)`. It will be called when the action is of ActionType, and in this case it will always be a match, and the result is a HandlerResult with the returned promise. Actions other than ActionType will not be matched and will not be executed on this function.
* 3. `HandlerResult<PromiseT>(ActionType)`. Same as the first case, but only the action is passed to the function, and not the promise handler and the context.
* 4. `PromiseT(ActionType)`. Same as the second case, but only the action is passed to the function, and not the promise handler and the context.
* 5. `DataT(ActionType)`. Same as the fourth case, but the promise returned is one that is resolved and contains the return value of the function.
* 6. `void(ActionType)`. Same as the fourth case, but the promise returned is one that is resolved and contains a default-constructed `DataT`.
*/
struct Handler : public std::function<HandlerResultT(PH &, ContextT &, ActionT)>
{
using BaseT = std::function<HandlerResultT(PH &, ContextT &, ActionT)>;
using BaseT::BaseT;
using BaseT::operator();
template<class SubAction, class FuncT>
static BaseT make(FuncT func)
{
using Func = std::decay_t<FuncT>;
if constexpr (isExactInvocable<HandlerResultT, Func, PH &, ContextT &, SubAction>) {
return [func](PH &ph, ContextT &ctx, ActionT action) mutable -> HandlerResultT {
static_assert(boost::hana::is_valid([]() {
return boost::hana::type_c<
decltype(std::get<SubAction>(action))>;
})(),
"SubAction must be a variant alternative of ActionT");
if (std::holds_alternative<SubAction>(action)) {
return func(ph, ctx, std::get<SubAction>(action));
}
return {std::nullopt};
};
} else if constexpr (isExactInvocable<PromiseT, Func, PH &, ContextT &, SubAction>) {
return make<SubAction>([func](PH &ph, ContextT &ctx, SubAction subAction) mutable {
return HandlerResultT{func(ph, ctx, subAction)};
});
} else if constexpr (isExactInvocable<HandlerResultT, Func, SubAction>) {
return make<SubAction>([func](PH &, ContextT &, SubAction subAction) mutable {
return func(subAction);
});
} else if constexpr (isExactInvocable<PromiseT, Func, SubAction>) {
return make<SubAction>([func](PH &, ContextT &, SubAction subAction) mutable {
return func(subAction);
});
} else if constexpr (isExactInvocable<DataT, Func, SubAction>) {
return make<SubAction>([func](PH &ph, ContextT &, SubAction subAction) mutable {
return ph.createResolved(func(subAction));
});
} else if constexpr (isExactInvocable<void, Func, SubAction>) {
return make<SubAction>([func](PH &ph, ContextT &, SubAction subAction) mutable {
func(subAction);
return ph.createResolved({});
});
} else {
// This is a trick to avoid compilers reporting failure
// even when this branch is never executed for any Func
// https://stackoverflow.com/questions/38304847/how-does-a-failed-static-assert-work-in-an-if-constexpr-false-block
static_assert(!sizeof(Func), "Function is not convertible to an action handler");
return [func](PH &, ContextT &, ActionT) mutable -> HandlerResultT {
return {std::nullopt};
};
}
}
template<class SubAction>
Handler(PassDownTag<SubAction>)
: BaseT(make<SubAction>([](PH &, ContextT &ctx, SubAction action) mutable {
Kazv::kzo.client.dbg() << "PassDown" << std::endl;
return ctx.dispatch(action);
}))
{}
template<class SubAction>
Handler(ReturnResolvedTag<SubAction, DataT> tag)
: BaseT(make<SubAction>([data=tag.data](SubAction) mutable {
Kazv::kzo.client.dbg() << "ReturnResolved" << std::endl;
return data;
}))
{}
template<class SubAction, class FuncT>
Handler(HandlerTag<SubAction, FuncT> func)
: BaseT(make<SubAction>(func))
{}
};
PromiseT operator()(ActionT action)
{
actions->push_back(action);
std::string typeName = getVariantTypeName(action);
Kazv::kzo.client.dbg() << "Handling action " << typeName << std::endl;
for (auto &handler : handlers) {
auto res = handler(ph, ctx, action);
if (res.retVal.has_value()) {
return res.retVal.value();
}
}
throw std::runtime_error{"unhandled action: " + typeName};
}
template<class SubAction>
auto calledTimes()
{
return std::accumulate(actions->begin(), actions->end(),
0,
[](auto acc, auto cur) {
return acc + (std::holds_alternative<SubAction>(cur) ? 1 : 0);
}
);
}
template<class SubAction>
std::vector<SubAction> of()
{
return zug::into_vector(
zug::filter([](const auto &a) { return std::holds_alternative<SubAction>(a); })
| zug::map([](const auto &a) { return std::get<SubAction>(a); }),
*actions
);
}
auto clear() { actions->clear(); }
PH &ph;
ContextT &ctx;
std::vector<Handler> handlers;
std::shared_ptr<std::vector<ActionT>> actions{std::make_shared<std::vector<ActionT>>()};
};
/**
* This is the main entry for getting a mocked dispatcher.
*
* The handlers are added sequentially, and each of them will
* be run with the action as the argument. It will stop at the
* first handler that *declares* a match. See
* MockDispatcher::Handler for more information on how to make
* a handler.
*
* A handler is a function of the signature `HandlerResult<ContextT::PromiseT>(PH &, ContextT &, ActionT)`.
* In the case of making a dispatcher for `Client`, `ActionT` is `ClientAction`.
*
* For a handler to match, it needs to return a `HandlerResult`
* that contains a Promise. If it returns a `HandlerResult`
* with `std::nullopt`, it is considered a no-match and the
* next handlers will continue execute until it gets a match.
* If no matches are found, it will throw an exception saying
* `unhandled action: <type>`.
*
* For example,
* ```
* getMockDispatcher(ph, ctx,
* passDown<A>(),
* returnResolved<B>({false, {}}),
* makeHandler<C>([](C) {
* std::cerr << "C" << std::endl;
* return HandlerResult<Promise>(std::nullopt);
* }),
* makeHandler<D>([&](D) {
* std::cerr << "D" << std::endl;
* return ctx.createResolved({});
* }),
* returnEmpty<B>())
* ```
*
* This should pass down action `A` to `ctx`, return a resolved
* failed promise for action `B`. For action `C`, it will print out "C" and throw an exception saying `unhandled action: C`
* (because the handler does not match, as it returns a result
* containing std::nullopt). For action `D`, it will print out
* "D" and return a resolved empty promise. For all other actions
* it will throw an exception saying `unhandled action: <type>`.
*
* @param ph The promise handler.
* @param ctx The original context that can be used to dispatch
* actions. This is needed for `passDown` handlers.
* @param handlers The handlers you want to add.
* @return A mocked dispatcher.
*/
template<class PH, class ContextT, class ...Handlers>
auto getMockDispatcher(PH &ph, ContextT &ctx, Handlers ...handlers)
{
using ResType = MockDispatcher<PH, ContextT>;
return ResType{
ph,
ctx,
std::vector<typename ResType::Handler>{ {handlers...} }
};
}
template<
class ContextT = typename Kazv::Client::ContextT,
class ActionT = typename Kazv::Client::ActionT,
class PH,
class Func>
auto getMockContext(PH &ph, Func &&func)
{
return ContextT(std::forward<Func>(func), ph, lager::deps<>{});
};
struct MockSdkUtil
{
using PH = Kazv::AsioPromiseHandler<boost::asio::io_context::executor_type>;
using ContextT = Kazv::Context<Kazv::SdkAction>;
using SdkT = decltype(Kazv::makeSdk(
Kazv::SdkModel{},
Kazv::detail::declref<Kazv::CprJobHandler>(),
Kazv::detail::declref<Kazv::LagerStoreEventEmitter>(),
Kazv::detail::declref<PH>(),
zug::identity));
MockSdkUtil(Kazv::ClientModel m)
: io()
, jh{io.get_executor()}
, ee(lager::with_boost_asio_event_loop{io.get_executor()})
, ph{io.get_executor()}
+ , sgph(ph)
+ , sdk(Kazv::makeSdk(Kazv::SdkModel{m}, jh, ee, ph, zug::identity))
+ , ctx(sdk.context())
+ {}
+
+ MockSdkUtil(boost::asio::io_context::executor_type ex, Kazv::ClientModel m)
+ : io()
+ , jh{ex}
+ , ee(lager::with_boost_asio_event_loop{ex})
+ , ph{ex}
+ , sgph(ph)
, sdk(Kazv::makeSdk(Kazv::SdkModel{m}, jh, ee, ph, zug::identity))
, ctx(sdk.context())
{}
template<class ...Handlers>
auto getMockDispatcher(Handlers &&...handlers)
{
- return ::getMockDispatcher(ph, ctx, std::forward<Handlers>(handlers)...);
+ return ::getMockDispatcher(sgph, ctx, std::forward<Handlers>(handlers)...);
}
template<class MD>
auto getClient(MD &d)
{
auto mockContext = getMockContext(ph, d);
return Kazv::Client(Kazv::Client::InEventLoopTag{}, mockContext, sdk.context());
}
boost::asio::io_context io;
Kazv::CprJobHandler jh;
Kazv::LagerStoreEventEmitter ee;
PH ph;
+ Kazv::SingleTypePromiseInterface<Kazv::EffectStatus> sgph;
SdkT sdk;
ContextT ctx;
};
MockSdkUtil makeMockSdkUtil(Kazv::ClientModel m)
{
return MockSdkUtil(m);
}
diff --git a/src/tests/client/encryption-test.cpp b/src/tests/client/encryption-test.cpp
index 32ecff3..8597b5d 100644
--- a/src/tests/client/encryption-test.cpp
+++ b/src/tests/client/encryption-test.cpp
@@ -1,441 +1,511 @@
/*
* 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 "factory.hpp"
using namespace Kazv::Factory;
namespace
{
struct CreateE2EESessionResult
{
ClientModel receiver1;
ClientModel receiver2;
ClientModel sender;
};
struct CreateMegOlmSessionResult
{
Event encryptedRoomEvent;
Event encryptedKeyEvent;
Event unencryptedKeyEvent;
};
}
-static CreateE2EESessionResult createE2EESession()
+static json makeDeviceInfo(const ClientModel &client)
{
- auto makeDeviceInfo = [](const ClientModel &client) {
- auto [next, _] = updateClient(client, UploadIdentityKeysAction{});
- return json::parse(std::get<Bytes>(next.nextJobs[0].requestBody()))["device_keys"];
- };
+ 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))
+ 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))
+ 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))
+ 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("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();
+ }
+}
diff --git a/src/tests/client/sync-test.cpp b/src/tests/client/sync-test.cpp
index 4d802ce..12ff38b 100644
--- a/src/tests/client/sync-test.cpp
+++ b/src/tests/client/sync-test.cpp
@@ -1,766 +1,798 @@
/*
* 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 <catch2/catch_all.hpp>
#include <boost/asio.hpp>
#include <zug/into_vector.hpp>
#include <asio-promise-handler.hpp>
#include <cursorutil.hpp>
#include <sdk-model.hpp>
#include <client/client.hpp>
#include <client/actions/sync.hpp>
+#include <client/actions/encryption.hpp>
#include <outbound-group-session.hpp>
#include "client-test-util.hpp"
+#include "action-mock-utils.hpp"
#include "factory.hpp"
using namespace Kazv::Factory;
// The example response is adapted from https://matrix.org/docs/spec/client_server/latest
static json syncResponseJson = R"({
"next_batch": "s72595_4483_1934",
"presence": {
"events": [
{
"content": {
"avatar_url": "mxc://localhost:wefuiwegh8742w",
"last_active_ago": 2478593,
"presence": "online",
"currently_active": false,
"status_msg": "Making cupcakes"
},
"type": "m.presence",
"sender": "@example:localhost"
}
]
},
"account_data": {
"events": [
{
"type": "org.example.custom.config",
"content": {
"custom_config_key": "custom_config_value"
}
}
]
},
"rooms": {
"join": {
"!726s6s6q:example.com": {
"summary": {
"m.heroes": [
"@alice:example.com",
"@bob:example.com"
],
"m.joined_member_count": 2,
"m.invited_member_count": 1
},
"state": {
"events": [
{
"content": {
"membership": "join",
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid"
},
"type": "m.room.member",
"event_id": "$143273582443PhrSn:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
},
"state_key": "@alice:example.org"
}
]
},
"timeline": {
"events": [
{
"content": {
"membership": "join",
"avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF",
"displayname": "Alice Margatroid"
},
"type": "m.room.member",
"event_id": "$143273582443PhrSn:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
},
"state_key": "@alice:example.org"
},
{
"content": {
"body": "This is an example text message",
"msgtype": "m.text",
"format": "org.matrix.custom.html",
"formatted_body": "<b>This is an example text message</b>"
},
"type": "m.room.message",
"event_id": "$anothermessageevent:example.org",
"room_id": "!726s6s6q:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": {
"age": 1234
}
}
],
"limited": true,
"prev_batch": "t34-23535_0_0"
},
"ephemeral": {
"events": [
{
"content": {
"user_ids": [
"@alice:matrix.org",
"@bob:example.com"
]
},
"type": "m.typing",
"room_id": "!jEsUZKDJdhlrceRyVU:example.org"
}
]
},
"account_data": {
"events": [
{
"content": {
"tags": {
"u.work": {
"order": 0.9
}
}
},
"type": "m.tag"
},
{
"type": "org.example.custom.room.config",
"content": {
"custom_config_key": "custom_config_value"
}
},
{
"type": "m.fully_read",
"content": {
"event_id": "$anothermessageevent:example.org"
}
}
]
}
}
},
"invite": {
"!696r7674:example.com": {
"invite_state": {
"events": [
{
"sender": "@alice:example.com",
"type": "m.room.name",
"state_key": "",
"content": {
"name": "My Room Name"
}
},
{
"sender": "@alice:example.com",
"type": "m.room.member",
"state_key": "@bob:example.com",
"content": {
"membership": "invite"
}
}
]
}
}
},
"leave": {}
},
"to_device": {
"events": [
{
"sender": "@alice:example.com",
"type": "m.new_device",
"content": {
"device_id": "XYZABCDE",
"rooms": ["!726s6s6q:example.com"]
}
}
]
}
})"_json;
static json stateInTimelineResponseJson = R"({
"next_batch": "some-example-value",
"rooms": {
"join": {
"!exampleroomid:example.com": {
"timeline": {
"events": [
{
"content": { "example": "foo" },
"state_key": "",
"event_id": "$example:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": { "age": 1234 },
"type": "moe.kazv.mxc.custom.state.type"
}
],
"limited": false
}
}
}
}
})"_json;
static json txnIdResponseJson = R"({
"next_batch": "some-example-value",
"rooms": {
"join": {
"!exampleroomid:example.com": {
"timeline": {
"events": [
{
"content": { "example": "foo" },
"event_id": "$example:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"unsigned": { "age": 1234, "transaction_id": "some-example-txnid" },
"type": "m.room.message"
}
],
"limited": false
}
}
}
}
})"_json;
static auto addNotificationsJson = R"({
"next_batch": "some-example-value",
"account_data": {
"events": [
{
"type": "m.push_rules",
"content": {
"global": {
"override": [{
"rule_id": "moe.kazv.mxc.catch_all",
"default": true,
"enabled": true,
"conditions": [],
"actions": ["notify"]
}]
}
}
}
]
},
"rooms": {
"join": {
"!exampleroomid:example.com": {
"timeline": {
"events": [
{
"content": { "example": "foo" },
"event_id": "$example:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824653,
"type": "m.room.message"
},
{
"content": { "example": "foo2" },
"event_id": "$example2:example.com",
"sender": "@example:example.org",
"origin_server_ts": 1432735824953,
"type": "m.room.message"
}
],
"limited": false
}
}
}
}
})"_json;
static auto receiptJson = R"({
"type": "m.receipt",
"content": {
"$example:example.com": {
"m.read": {
"@bob:example.com": {
"ts": 1432735824653
}
}
}
}
})"_json;
static auto addAndRemoveNotificationsJson = [](auto a, auto r) {
a["rooms"]["join"]["!exampleroomid:example.com"]["ephemeral"] = {
{"events", {
r,
}}
};
return a;
}(addNotificationsJson, receiptJson);
static auto removeNotificationsJson = [](auto r) {
auto j = R"({
"next_batch": "some-example-value",
"rooms": {
"join": {
"!exampleroomid:example.com": {
"timeline": {
"events": [],
"limited": false
}
}
}
}
})"_json;
j["rooms"]["join"]["!exampleroomid:example.com"]["ephemeral"] = {
{"events", {
r,
}}
};
return j;
}(receiptJson);
TEST_CASE("use sync response to update client model", "[client][sync]")
{
using namespace Kazv::CursorOp;
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
auto resp = makeResponse(
"Sync",
withResponseJsonBody(syncResponseJson)
| withResponseDataKV("is", "initial")
);
auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), store,
std::nullopt);
store.dispatch(ProcessResponseAction{resp});
io.run();
auto rooms = +client.rooms();
std::string roomId = "!726s6s6q:example.com";
SECTION("rooms should be added") {
REQUIRE(rooms.find(roomId));
}
auto r = client.room(roomId);
SECTION("room members should be updated") {
auto members = +r.members();
auto hasAlice = zug::into_vector(
zug::filter([](auto id) { return id == "@alice:example.org"; }),
members)
.size() > 0;
REQUIRE(hasAlice);
}
SECTION("heroes should be updated") {
auto heroIds = +r.heroIds();
REQUIRE(heroIds == immer::flex_vector<std::string>{"@alice:example.com", "@bob:example.com"});
}
SECTION("joined and invited member counts should be updated") {
REQUIRE(r.joinedMemberCount().get() == 2);
REQUIRE(r.invitedMemberCount().get() == 1);
}
SECTION("ephemeral events should be updated") {
auto users = +r.typingUsers();
REQUIRE((users == immer::flex_vector<std::string>{
"@alice:matrix.org",
"@bob:example.com"
}));
}
auto eventId = "$anothermessageevent:example.org"s;
SECTION("timeline should be updated") {
auto timeline = +r.timelineEvents();
auto filtered = zug::into_vector(
zug::filter([=](auto event) { return event.id() == eventId; }),
timeline);
auto hasEvent = filtered.size() > 0;
REQUIRE(hasEvent);
auto onlyOneEvent = filtered.size() == 1;
REQUIRE(onlyOneEvent);
auto ev = filtered[0];
auto eventHasRoomId = ev.originalJson().get().contains("room_id"s);
REQUIRE(eventHasRoomId);
auto gaps = +r.timelineGaps();
// first event in the batch, correspond to its prevBatch
REQUIRE(gaps.at("$143273582443PhrSn:example.org") == "t34-23535_0_0");
}
SECTION("fully read marker should be updated") {
auto readMarker = +r.readMarker();
REQUIRE(readMarker == eventId);
}
SECTION("toDevice should be updated") {
auto toDevice = +client.toDevice();
REQUIRE(toDevice.size() == 1);
REQUIRE(toDevice[0].sender() == "@alice:example.com");
}
SECTION("emits account data changes") {
auto nextTriggers = store.reader().get().nextTriggers;
auto triggerContains = [=](auto p) {
return std::any_of(
nextTriggers.begin(),
nextTriggers.end(),
[=](const KazvTrigger &t) {
if (!std::holds_alternative<ReceivingRoomAccountDataEvent>(t)) {
return false;
}
auto e = std::get<ReceivingRoomAccountDataEvent>(t);
return p(e);
});
};
REQUIRE(triggerContains([](const auto &e) {
return e.event.type() == "m.tag" && e.roomId == "!726s6s6q:example.com";
}));
REQUIRE(triggerContains([](const auto &e) {
return e.event.type() == "m.fully_read" && e.roomId == "!726s6s6q:example.com";
}));
REQUIRE(triggerContains([](const auto &e) {
return e.event.type() == "org.example.custom.room.config" && e.roomId == "!726s6s6q:example.com";
}));
}
}
TEST_CASE("Sync should record state events in timeline", "[client][sync]")
{
using namespace Kazv::CursorOp;
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
auto store = createTestClientStore(ph);
auto resp = makeResponse(
"Sync",
withResponseJsonBody(stateInTimelineResponseJson)
| withResponseDataKV("is", "initial")
);
auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), store,
std::nullopt);
store.dispatch(ProcessResponseAction{resp});
io.run();
auto r = client.room("!exampleroomid:example.com");
auto stateOpt = +r.stateOpt(KeyOfState{"moe.kazv.mxc.custom.state.type", ""});
REQUIRE(stateOpt.has_value());
REQUIRE(stateOpt.value().content().get().at("example") == "foo");
}
TEST_CASE("Sync should remove already sent local echo", "[client][sync]")
{
using namespace Kazv::CursorOp;
boost::asio::io_context io;
AsioPromiseHandler ph{io.get_executor()};
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!exampleroomid:example.com")
| withAttr(&RoomModel::localEchoes, {
{"some-example-txnid", json{
{"type", "m.room.message"},
{"content", {{"example", "foo"}}}
}},
{"some-other-txnid", json{
{"type", "m.room.message"},
{"content", {{"example", "foo"}}}
}},
})
))
);
auto store = createTestClientStoreFrom(m, ph);
auto resp = makeResponse(
"Sync",
withResponseJsonBody(txnIdResponseJson)
| withResponseDataKV("is", "initial")
);
auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }), store,
std::nullopt);
store.dispatch(ProcessResponseAction{resp});
io.run();
auto r = client.room("!exampleroomid:example.com");
auto localEchoes = +r.localEchoes();
REQUIRE(localEchoes.size() == 1);
REQUIRE(localEchoes[0].txnId == "some-other-txnid");
}
TEST_CASE("updating local notifications", "[client][sync]")
{
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!exampleroomid:example.com"))));
WHEN("the receipt for the current user did not change") {
auto resp = makeResponse(
"Sync",
withResponseJsonBody(addNotificationsJson)
| withResponseDataKV("is", "incremental")
);
auto [next, _] = processResponse(m, SyncResponse{resp});
auto room = next.roomList.rooms.at("!exampleroomid:example.com");
REQUIRE(room.unreadNotificationEventIds
== immer::flex_vector<std::string>{
"$example:example.com",
"$example2:example.com"
});
THEN("it changed later") {
auto resp = makeResponse(
"Sync",
withResponseJsonBody(removeNotificationsJson)
| withResponseDataKV("is", "incremental")
);
auto [nextNext, _] = processResponse(next, SyncResponse{resp});
auto room = nextNext.roomList.rooms.at("!exampleroomid:example.com");
REQUIRE(room.unreadNotificationEventIds
== immer::flex_vector<std::string>{
"$example2:example.com"
});
}
}
WHEN("the receipt for the current user changed") {
auto resp = makeResponse(
"Sync",
withResponseJsonBody(addAndRemoveNotificationsJson)
| withResponseDataKV("is", "incremental")
);
auto [next, _] = processResponse(m, SyncResponse{resp});
auto room = next.roomList.rooms.at("!exampleroomid:example.com");
REQUIRE(room.unreadNotificationEventIds
== immer::flex_vector<std::string>{
"$example2:example.com"
});
}
}
TEST_CASE("it does not add a gap when the limited field in the timeline is not present (conduwuit)", "[client][sync]")
{
auto body = R"({
"device_one_time_keys_count": {
"signed_curve25519": 721
},
"device_unused_fallback_key_types": null,
"next_batch": "some",
"rooms": {
"join": {
"!foo:example.com": {
"ephemeral": {
"events": []
},
"timeline": {
"events": [
{
"content": {
},
"event_id": "$1",
"origin_server_ts": 1723379000000,
"sender": "@foo:example.com",
"type": "m.room.message",
"unsigned": {
"age": 1,
"transaction_id": "xxxxxx"
}
}
],
"prev_batch": "prev-batch"
},
"unread_notifications": {
"highlight_count": 0,
"notification_count": 0
}
}
}
}
})"_json;
auto resp = makeResponse(
"Sync",
withResponseJsonBody(body)
| withResponseDataKV("is", "incremental")
);
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!foo:example.com"))));
auto [next, _] = processResponse(m, SyncResponse{resp});
auto room = next.roomList.rooms.at("!foo:example.com");
REQUIRE(room.timelineGaps.size() == 0);
REQUIRE(room.messages.count("$1") != 0);
}
TEST_CASE("it does not crash when receiving a malformed encrypted event")
{
auto crypto = makeCrypto();
auto ogs = OutboundGroupSession(
RandomTag{},
genRandomData(crypto.rotateMegOlmSessionRandomSize()),
0);
REQUIRE(ogs.valid());
crypto.createInboundGroupSession(
KeyOfGroupSession{"!foo:example.com", ogs.sessionId()},
ogs.sessionKey(),
crypto.ed25519IdentityKey()
);
ClientModel m = makeClient(
withCrypto(std::move(crypto))
| withRoom(makeRoom(
withRoomId("!foo:example.com")
| withRoomEncrypted(true))));
auto [validEvent, ignore] = m.megOlmEncrypt(Event(json{
{"content", {{"body", "foo"}}},
{"type", "m.room.message"},
{"sender", "@foo:example.com"},
{"origin_server_ts", 0},
}), "!foo:example.com", 0, genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto validEventJson = validEvent.originalJson().get();
validEventJson["event_id"] = "$0";
auto [plainText, exceptedErrorCode] = GENERATE(
table<std::string, std::string>({
{"not-json", "M_NOT_JSON"},
{"null", "M_BAD_JSON"},
{"{}", "M_BAD_JSON"},
{R"_({"room_id": "!other:example.com"})_", "MOE.KAZV.MXC_BAD_ROOM_ID"},
}));
auto encrypted = ogs.encrypt(plainText);
auto encryptedEventJson = json{
{"content", {
{"algorithm", CryptoConstants::megOlmAlgo},
{"ciphertext", encrypted},
{"session_id", ogs.sessionId()},
}},
{"event_id", "$1"},
{"sender", "@foo:example.com"},
{"type", "m.room.encrypted"},
{"origin_server_ts", 0},
};
auto body = json{
{"device_one_time_keys_count", {
{"signed_curve25519", 721},
}},
{"device_unused_fallback_key_types", nullptr},
{"next_batch", "some"},
{"rooms", {
{"join", {
{"!foo:example.com", {
{"timeline", {
{"events", {
validEventJson,
encryptedEventJson,
}},
{"prev_batch", "prev-batch"},
}},
}},
}},
}},
};
auto resp = makeResponse(
"Sync",
withResponseJsonBody(body)
| withResponseDataKV("is", "incremental")
);
auto [next, _] = processResponse(m, SyncResponse{resp});
auto messagesMap = next.roomList.rooms.at("!foo:example.com").messages;
REQUIRE(messagesMap.at("$0").decrypted());
REQUIRE(messagesMap.count("$1"));
auto event = messagesMap.at("$1");
REQUIRE(!event.decrypted());
REQUIRE(event.content().get().at("moe.kazv.mxc.errcode") == exceptedErrorCode);
}
TEST_CASE("emit SaveEventsRequested", "[client][sync]")
{
ClientModel m = makeClient(
withRoom(makeRoom(
withRoomId("!exampleroomid:example.com"))));
auto resp = makeResponse(
"Sync",
withResponseJsonBody(syncResponseJson)
| withResponseDataKV("is", "incremental")
);
auto [next, _] = ClientModel::update(m, ProcessResponseAction{SyncResponse{resp}});
auto it = std::find_if(next.nextTriggers.begin(), next.nextTriggers.end(), [](const auto &trigger) {
return std::holds_alternative<SaveEventsRequested>(trigger);
});
REQUIRE(it != next.nextTriggers.end());
auto t = std::get<SaveEventsRequested>(*it);
REQUIRE(t.timelineEvents["!726s6s6q:example.com"].size() == 2);
}
+
+TEST_CASE("Sync pops verification events", "[client][sync][encryption]")
+{
+ auto u = makeMockSdkUtil(makeClient(withCrypto(makeCrypto())));
+ auto md = u.getMockDispatcher(passDown<ProcessResponseAction>());
+ auto ctx = getMockContext(u.ph, md);
+ auto resp = makeResponse("Sync", withResponseJsonBody(R"({
+ "next_batch": "something",
+ "to_device": {"events": [{
+ "content": {
+ "from_device": "AliceDevice2",
+ "methods": [
+ "m.sas.v1"
+ ],
+ "timestamp": 1559598944869,
+ "transaction_id": "S0meUniqueAndOpaqueString"
+ },
+ "sender": "@alice:example.com",
+ "type": "m.key.verification.request"
+ }]}
+})"_json) | withResponseDataKV("is", "incremental"));
+ ctx.dispatch(ProcessResponseAction{resp})
+ .then([&u](const EffectStatus &s) {
+ REQUIRE(s.success());
+ REQUIRE(s.dataJson("verificationEvents").at("toDevice").size() == 1);
+ REQUIRE(u.sdk.client().toDevice().make().get().empty());
+ u.io.stop();
+ });
+ u.io.run();
+}
diff --git a/src/tests/client/verification-processing-test.cpp b/src/tests/client/verification-processing-test.cpp
new file mode 100644
index 0000000..a206ae5
--- /dev/null
+++ b/src/tests/client/verification-processing-test.cpp
@@ -0,0 +1,590 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#include <libkazv-config.hpp>
+
+#include <catch2/catch_test_macros.hpp>
+#include <boost/asio.hpp>
+
+#include <client/client.hpp>
+#include <client/client-model.hpp>
+#include <client/actions/encryption.hpp>
+#include <crypto/verification-tracker.hpp>
+#include <crypto/verification-utils.hpp>
+#include <crypto.hpp>
+
+#include "client-test-util.hpp"
+#include "action-mock-utils.hpp"
+#include "factory.hpp"
+
+using namespace Kazv;
+using namespace Kazv::Factory;
+namespace VCC = Kazv::VerificationCancelCodes;
+namespace VPS = Kazv::VerificationProcessStates;
+using namespace Kazv::VerificationEventTypes;
+using VTM = VerificationTrackerModel;
+
+namespace
+{
+ static Event withSender(Event e, std::string userId)
+ {
+ auto j = e.originalJson().get();
+ j["sender"] = userId;
+ return Event(j);
+ }
+
+ static ComposedModifier<ClientModel> withClientInfo(std::string userId, std::string deviceId)
+ {
+ return [userId, deviceId](ClientModel &m) {
+ m.userId = userId;
+ m.deviceId = deviceId;
+ };
+ }
+
+ static json makeDeviceInfo(const ClientModel &client)
+ {
+ auto [next, _] = updateClient(client, UploadIdentityKeysAction{});
+ return json::parse(std::get<Bytes>(next.nextJobs[0].requestBody()))["device_keys"];
+ }
+
+ struct VerificationTestSetup
+ {
+ std::string userIdA;
+ std::string userIdB;
+ std::string deviceIdA;
+ std::string deviceIdB;
+ MockSdkUtil utilA;
+ MockSdkUtil utilB;
+ json deviceInfoA;
+ json deviceInfoB;
+
+ VerificationTestSetup(boost::asio::io_context::executor_type ex)
+ : userIdA("@alice:example.com")
+ , userIdB("@bob:example.com")
+ , deviceIdA("AliceDevice")
+ , deviceIdB("BobDevice")
+ , utilA(ex, makeClient(withCrypto(makeCrypto()) | withClientInfo(userIdA, deviceIdA)))
+ , utilB(ex, makeClient(withCrypto(makeCrypto()) | withClientInfo(userIdB, deviceIdB)))
+ , deviceInfoA(makeDeviceInfo(lager::get<SdkModelCursorKey>(utilA.sdk.context())->get().client))
+ , deviceInfoB(makeDeviceInfo(lager::get<SdkModelCursorKey>(utilB.sdk.context())->get().client))
+ {
+ }
+
+ template<class PH, class Ctx>
+ auto handleKeyRequest([[maybe_unused]] PH &ph, Ctx &ctx, EnsureKeysFromDevicesAction a) -> typename MockSdkUtil::ContextT::PromiseT
+ {
+ if (!a.userIdToDeviceIdsMap.size()) {
+ return ctx.createResolvedPromise({});
+ }
+ auto userId = a.userIdToDeviceIdsMap.begin()->first;
+ auto deviceId = userId == userIdA ? deviceIdA : deviceIdB;
+ auto deviceInfo = userId == userIdA ? deviceInfoA : deviceInfoB;
+ auto body = json::object({
+ {"device_keys", {{userId, {
+ {deviceId, deviceInfo},
+ }}}},
+ });
+ auto resp = makeResponse(
+ "QueryKeys",
+ withResponseJsonBody(body)
+ | withResponseDataKV("deviceKeys", json(a.userIdToDeviceIdsMap)));
+ return ctx.dispatch(ProcessResponseAction{resp});
+ }
+ };
+}
+
+TEST_CASE("Client verification processing - complete verification flow", "[client][verification]")
+{
+ boost::asio::io_context io;
+
+ VerificationTestSetup setup(io.get_executor());
+
+ auto dispatcherA = setup.utilA.getMockDispatcher(
+ makeHandler<EnsureKeysFromDevicesAction>([&setup](auto &ph, auto &ctx, EnsureKeysFromDevicesAction action) mutable {
+ return setup.handleKeyRequest(ph, ctx, action);
+ }),
+ returnEmpty<SendToDeviceMessageAction>(),
+ passDown<NotifyVerificationTrackerModelAction>(),
+ passDown<SetDevicesTrustLevelsAction>()
+ );
+ auto dispatcherB = setup.utilB.getMockDispatcher(
+ makeHandler<EnsureKeysFromDevicesAction>([&setup](auto &ph, auto &ctx, EnsureKeysFromDevicesAction action) mutable {
+ return setup.handleKeyRequest(ph, ctx, action);
+ }),
+ returnEmpty<SendToDeviceMessageAction>(),
+ passDown<NotifyVerificationTrackerModelAction>(),
+ passDown<SetDevicesTrustLevelsAction>()
+ );
+
+ auto clientA = setup.utilA.getClient(dispatcherA);
+ auto clientB = setup.utilB.getClient(dispatcherB);
+
+ SECTION("Alice initiates, Bob accepts, both confirm")
+ {
+ // Alice initiates verification
+ clientA.requestOutgoingToDeviceVerification(setup.userIdB, setup.deviceIdB)
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 1);
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ auto requestEvent = withSender(sentEvents.at(0).event, setup.userIdA);
+ dispatcherA.clear();
+
+ // After Alice initiates the verification, she should
+ // have queried Bob's keys and the device should be the default
+ // Unseen trust level.
+ auto deviceB = clientA.devicesOfUser(setup.userIdB).make().get().at(0);
+ REQUIRE(deviceB.deviceId == setup.deviceIdB);
+ REQUIRE(deviceB.trustLevel == DeviceTrustLevel::Unseen);
+
+ // Bob receives the request
+ return clientB.processVerificationEventsFromSync({requestEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+ dispatcherB.clear();
+
+ // Bob signals ready
+ return clientB.readyForVerification(setup.userIdA, setup.deviceIdA);
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<SendToDeviceMessageAction>() == 1);
+ REQUIRE(dispatcherB.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ auto readyEvent = withSender(sentEvents.at(0).event, setup.userIdB);
+ dispatcherB.clear();
+
+ // By the time Bob signals ready for verification, he
+ // should have queried Alice's keys and marked it
+ // as the default Unseen trust level
+ auto deviceA = clientB.devicesOfUser(setup.userIdA).make().get().at(0);
+ REQUIRE(deviceA.deviceId == setup.deviceIdA);
+ REQUIRE(deviceA.trustLevel == DeviceTrustLevel::Unseen);
+
+ // Alice receives the ready event
+ return clientA.processVerificationEventsFromSync({readyEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ // Alice should have sent a start event
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ Event startEvent = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(startEvent.type() == tStart);
+ dispatcherA.clear();
+
+ // Bob receives the start event
+ return clientB.processVerificationEventsFromSync({startEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ // Bob should have sent an accept event
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ Event acceptEvent = withSender(sentEvents.at(0).event, setup.userIdB);
+ REQUIRE(acceptEvent.type() == tAccept);
+ dispatcherB.clear();
+
+ // Alice receives accept event
+ return clientA.processVerificationEventsFromSync({acceptEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ // Alice should have sent a key event
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ // Find key event from Alice
+ Event keyEventA = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(keyEventA.type() == tKey);
+ dispatcherA.clear();
+
+ // Bob receives Alice's key event
+ return clientB.processVerificationEventsFromSync({keyEventA});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ // Bob should have sent a key event
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ // Find key event from Bob
+ Event keyEventB = withSender(sentEvents.at(0).event, setup.userIdB);
+ REQUIRE(keyEventB.type() == tKey);
+ dispatcherB.clear();
+
+ // Alice receives Bob's key event
+ return clientA.processVerificationEventsFromSync({keyEventB});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 0);
+ dispatcherA.clear();
+
+ auto stateA = lager::get<VerificationTracker>(setup.utilA.sdk.context()).model.processes.at(0).state;
+ auto stateB = lager::get<VerificationTracker>(setup.utilB.sdk.context()).model.processes.at(0).state;
+ REQUIRE(std::holds_alternative<VTM::ProcessCodeDisplayed>(stateA));
+ REQUIRE(std::holds_alternative<VTM::ProcessCodeDisplayed>(stateB));
+ REQUIRE(std::get<VTM::ProcessCodeDisplayed>(stateA).emojiIndices == std::get<VTM::ProcessCodeDisplayed>(stateB).emojiIndices);
+
+ // Alice confirms the SAS matches
+ return clientA.confirmVerificationSasMatch(setup.userIdB, setup.deviceIdB);
+ }).then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ auto macEventA = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(macEventA.type() == tMac);
+ dispatcherA.clear();
+
+ // Bob receives Alice's MAC
+ return clientB.processVerificationEventsFromSync({macEventA});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ dispatcherB.clear();
+
+ // Bob confirms the SAS match
+ return clientB.confirmVerificationSasMatch(setup.userIdA, setup.deviceIdA);
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<SendToDeviceMessageAction>() == 2);
+
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+
+ // Find MAC and done events from Bob
+ Event macEventB;
+ Event doneEventB;
+ for (auto se : sentEvents) {
+ if (se.event.type() == tMac) {
+ macEventB = withSender(se.event, setup.userIdB);
+ }
+ if (se.event.type() == tDone) {
+ doneEventB = withSender(se.event, setup.userIdB);
+ }
+ }
+ REQUIRE(macEventB.type() == tMac);
+ REQUIRE(doneEventB.type() == tDone);
+ dispatcherB.clear();
+
+ // By the time Bob wants to send a done event, he should
+ // have marked Alice's device as verified
+ auto deviceA = clientB.devicesOfUser(setup.userIdA).make().get().at(0);
+ REQUIRE(deviceA.deviceId == setup.deviceIdA);
+ REQUIRE(deviceA.trustLevel == DeviceTrustLevel::Verified);
+
+ // Alice receives Bob's MAC and done events
+ return clientA.processVerificationEventsFromSync({macEventB, doneEventB});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ // Alice should send a done event
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ Event doneEventA = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(doneEventA.type() == tDone);
+ dispatcherA.clear();
+
+ // By the time Alice wants to send a done event, she should
+ // have marked Bob's device as verified
+ auto deviceB = clientA.devicesOfUser(setup.userIdB).make().get().at(0);
+ REQUIRE(deviceB.deviceId == setup.deviceIdB);
+ REQUIRE(deviceB.trustLevel == DeviceTrustLevel::Verified);
+
+ // Bob receives Alice's done event
+ return clientB.processVerificationEventsFromSync({doneEventA});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ io.stop();
+ });
+ io.run();
+ }
+
+ SECTION("Alice initiates, Bob cancels")
+ {
+ // Alice initiates verification
+ clientA.requestOutgoingToDeviceVerification(setup.userIdB, setup.deviceIdB)
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ auto requestEvent = withSender(sentEvents.at(0).event, setup.userIdA);
+ dispatcherA.clear();
+
+ // Bob receives the request
+ return clientB.processVerificationEventsFromSync({requestEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ dispatcherB.clear();
+
+ // Bob cancels
+ return clientB.cancelVerification(setup.userIdA, setup.deviceIdA);
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<SendToDeviceMessageAction>() == 1);
+ REQUIRE(dispatcherB.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ auto cancelEvent = withSender(sentEvents.at(0).event, setup.userIdB);
+ REQUIRE(cancelEvent.type() == tCancel);
+ REQUIRE(cancelEvent.content().get().at("code") == VCC::userCancel);
+ dispatcherB.clear();
+
+ // Alice receives the cancel event
+ return clientA.processVerificationEventsFromSync({cancelEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ // After receiving a cancel, Alice should not send another cancel
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 0);
+ io.stop();
+ });
+ io.run();
+ }
+
+ SECTION("Alice initiates, Bob accepts, both deny")
+ {
+ // Alice initiates verification
+ clientA.requestOutgoingToDeviceVerification(setup.userIdB, setup.deviceIdB)
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 1);
+
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ auto requestEvent = withSender(sentEvents.at(0).event, setup.userIdA);
+ dispatcherA.clear();
+
+ // Bob receives the request
+ return clientB.processVerificationEventsFromSync({requestEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+ dispatcherB.clear();
+
+ // Bob signals ready
+ return clientB.readyForVerification(setup.userIdA, setup.deviceIdA);
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<SendToDeviceMessageAction>() == 1);
+
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ auto readyEvent = withSender(sentEvents.at(0).event, setup.userIdB);
+ dispatcherB.clear();
+
+ // Alice receives the ready event
+ return clientA.processVerificationEventsFromSync({readyEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+
+ // Alice should have sent a start event
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ Event startEvent = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(startEvent.type() == tStart);
+ dispatcherA.clear();
+
+ // Bob receives the start event
+ return clientB.processVerificationEventsFromSync({startEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ // Bob should have sent an accept event
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ Event acceptEvent = withSender(sentEvents.at(0).event, setup.userIdB);
+ REQUIRE(acceptEvent.type() == tAccept);
+ dispatcherB.clear();
+
+ // Alice receives accept event
+ return clientA.processVerificationEventsFromSync({acceptEvent});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+
+ // Alice should have sent a key event
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ // Find key event from Alice
+ Event keyEventA = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(keyEventA.type() == tKey);
+ dispatcherA.clear();
+
+ // Bob receives Alice's key event
+ return clientB.processVerificationEventsFromSync({keyEventA});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ // Bob should have sent a key event
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ REQUIRE(sentEvents.size() == 1);
+
+ // Find key event from Bob
+ Event keyEventB = withSender(sentEvents.at(0).event, setup.userIdB);
+ REQUIRE(keyEventB.type() == tKey);
+ dispatcherB.clear();
+
+ // Alice receives Bob's key event
+ return clientA.processVerificationEventsFromSync({keyEventB});
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 0);
+ dispatcherA.clear();
+
+ // Alice denies the SAS matches
+ return clientA.denyVerificationSasMatch(setup.userIdB, setup.deviceIdB);
+ }).then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+ auto sentEvents = dispatcherA.of<SendToDeviceMessageAction>();
+
+ auto cancelEventA = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(cancelEventA.type() == tCancel);
+ dispatcherA.clear();
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ dispatcherB.clear();
+
+ // Bob denies the SAS match
+ return clientB.denyVerificationSasMatch(setup.userIdA, setup.deviceIdA);
+ })
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherB.calledTimes<SendToDeviceMessageAction>() >= 1);
+
+ auto sentEvents = dispatcherB.of<SendToDeviceMessageAction>();
+ auto cancelEventB = withSender(sentEvents.at(0).event, setup.userIdA);
+ REQUIRE(cancelEventB.type() == tCancel);
+ dispatcherB.clear();
+ io.stop();
+ });
+ io.run();
+ }
+}
+
+TEST_CASE("Client verification processing - tracker model updates", "[client][verification]")
+{
+ boost::asio::io_context io;
+
+ VerificationTestSetup setup(io.get_executor());
+
+ auto dispatcherA = setup.utilA.getMockDispatcher(
+ returnEmpty<EnsureKeysFromDevicesAction>(),
+ returnEmpty<SendToDeviceMessageAction>(),
+ returnEmpty<NotifyVerificationTrackerModelAction>()
+ );
+ auto clientA = setup.utilA.getClient(dispatcherA);
+
+ // Process a verification request
+ Event requestEvent = R"({
+ "content": {
+ "from_device": "BobDevice",
+ "methods": ["m.sas.v1"],
+ "timestamp": 0,
+ "transaction_id": "testTxnId"
+ },
+ "type": "m.key.verification.request",
+ "sender": "@bob:example.com"
+ })"_json;
+
+ clientA.processVerificationEventsFromSync({requestEvent})
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ REQUIRE(dispatcherA.calledTimes<NotifyVerificationTrackerModelAction>() == 1);
+ io.stop();
+ });
+ io.run();
+}
+
+TEST_CASE("Client verification processing - edge cases", "[client][verification]")
+{
+ boost::asio::io_context io;
+ VerificationTestSetup setup(io.get_executor());
+
+ SECTION("Multiple verification events processed together")
+ {
+ auto dispatcherA = setup.utilA.getMockDispatcher(
+ returnEmpty<SendToDeviceMessageAction>(),
+ returnEmpty<NotifyVerificationTrackerModelAction>()
+ );
+ auto clientA = setup.utilA.getClient(dispatcherA);
+
+ Event requestEvent1 = R"({
+ "content": {
+ "from_device": "BobDevice",
+ "methods": ["m.sas.v1"],
+ "timestamp": 0,
+ "transaction_id": "txn1"
+ },
+ "type": "m.key.verification.request",
+ "sender": "@bob:example.com"
+ })"_json;
+
+ Event requestEvent2 = R"({
+ "content": {
+ "from_device": "BobDevice2",
+ "methods": ["m.sas.v1"],
+ "timestamp": 0,
+ "transaction_id": "txn1"
+ },
+ "type": "m.key.verification.request",
+ "sender": "@bob:example.com"
+ })"_json;
+
+ clientA.processVerificationEventsFromSync({requestEvent1, requestEvent2})
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ io.stop();
+ });
+ io.run();
+ }
+
+ SECTION("Cancel non-existent verification process")
+ {
+ auto dispatcherA = setup.utilA.getMockDispatcher(
+ returnEmpty<SendToDeviceMessageAction>(),
+ passDown<NotifyVerificationTrackerModelAction>()
+ );
+ auto clientA = setup.utilA.getClient(dispatcherA);
+
+ // Try to cancel a verification that doesn't exist
+ clientA.cancelVerification(setup.userIdB, setup.deviceIdB)
+ .then([&](auto stat) {
+ REQUIRE(stat.success());
+ // Should still succeed, but no events sent
+ REQUIRE(dispatcherA.calledTimes<SendToDeviceMessageAction>() == 0);
+ io.stop();
+ });
+ io.run();
+ }
+}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Aug 8, 10:51 AM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1723101
Default Alt Text
(242 KB)

Event Timeline