Page MenuHomePhorge

No OneTemporary

Size
165 KB
Referenced Files
None
Subscribers
None
diff --git a/src/client/actions/encryption.cpp b/src/client/actions/encryption.cpp
index be1c37a..b493766 100644
--- a/src/client/actions/encryption.cpp
+++ b/src/client/actions/encryption.cpp
@@ -1,826 +1,817 @@
/*
* 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 k = m.makeSelfDeviceKeys();
auto job = m.job<UploadKeysJob>()
.make(k)
.withData(json{{"is", "identityKeys"}});
kzo.client.dbg() << "Uploading identity keys" << std::endl;
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, GenerateAndUploadOneTimeKeysAction a)
{
if (! m.crypto) {
kzo.client.warn() << "Client::crypto is invalid, ignoring it." << std::endl;
return { std::move(m), simpleFail };
}
kzo.client.dbg() << "Generating " << a.numToGen << " one-time keys..." << std::endl;
auto maxNumKeys = m.constCrypto().maxNumberOfOneTimeKeys();
auto numLocalKeys = m.constCrypto().numUnpublishedOneTimeKeys();
auto numStoredKeys = m.constCrypto().uploadedOneTimeKeysCount(signedCurve25519) + numLocalKeys;
auto numKeysToGenerate = a.numToGen;
auto genKeysLimit = maxNumKeys - numStoredKeys;
if (numKeysToGenerate > genKeysLimit) {
numKeysToGenerate = genKeysLimit;
}
if (numLocalKeys <= 0 && numKeysToGenerate <= 0) { // we have enough already
kzo.client.dbg() << "We have enough one-time keys. Ignoring this." << std::endl;
return { std::move(m), lager::noop };
}
if (numKeysToGenerate > 0) {
m.withCrypto([&](auto &c) { c.genOneTimeKeysWithRandom(a.random, numKeysToGenerate); });
}
kzo.client.dbg() << "Generating done." << std::endl;
auto keys = m.constCrypto().unpublishedOneTimeKeys();
auto cv25519Keys = keys.at(curve25519);
json oneTimeKeys = json::object();
for (auto [id, keyStr] : cv25519Keys.items()) {
json keyObject = json::object();
keyObject["key"] = keyStr;
- keyObject["signatures"] = convertSignature(m, m.withCrypto([&](auto &c) { return c.sign(keyObject); }));
+ keyObject["signatures"] = m.convertSignature(m.withCrypto([&](auto &c) { return c.sign(keyObject); }));
oneTimeKeys[signedCurve25519 + ":" + id] = keyObject;
}
auto job = m.job<UploadKeysJob>()
.make(
std::nullopt, // deviceKeys
oneTimeKeys)
.withData(json{{"is", "oneTimeKeys"}});
kzo.client.dbg() << "Uploading one time keys" << std::endl;
m.addJob(std::move(job));
return { std::move(m), lager::noop };
};
ClientResult processResponse(ClientModel m, UploadKeysResponse r)
{
if (! m.crypto) {
kzo.client.warn() << "Client::crypto is invalid, ignoring it." << std::endl;
return { std::move(m), lager::noop };
}
auto is = r.dataStr("is");
if (is == "identityKeys") {
if (! r.success()) {
kzo.client.dbg() << "Uploading identity keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Uploading identity keys successful" << std::endl;
m.identityKeysUploaded = true;
} else {
if (! r.success()) {
kzo.client.dbg() << "Uploading one-time keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "Uploading one-time keys successful" << std::endl;
m.withCrypto([&](auto &c) { c.markOneTimeKeysAsPublished(); });
}
m.withCrypto([&](auto &c) { c.setUploadedOneTimeKeysCount(r.oneTimeKeyCounts()); });
return { std::move(m), lager::noop };
}
static JsonWrap cannotDecryptEvent(
const std::string &reason,
const std::string &errcode,
const json &raw)
{
return json{
{"type", "m.room.message"},
{"content", {
{"msgtype","moe.kazv.mxc.cannot.decrypt"},
{"body", "**This message cannot be decrypted due to " + reason + ".**"},
{"moe.kazv.mxc.error", reason},
{"moe.kazv.mxc.errcode", errcode},
{"moe.kazv.mxc.raw", raw},
}},
};
}
// returns std::nullopt on success, and an error event on failure
static std::optional<JsonWrap> verifyEvent(ClientModel &m, Event e, const json &plainJson)
{
try {
std::string algo = e.originalJson().get().at("content").at("algorithm");
if (algo == olmAlgo) {
- std::string senderCurve25519Key = e.originalJson().get()
- .at("content").at("sender_key");
+ // Perform checks described in
+ // https://spec.matrix.org/v1.18/client-server-api/#validation-of-incoming-decrypted-events
- auto deviceInfoOpt = m.deviceLists.findByCurve25519Key(e.sender(), senderCurve25519Key);
+ // (5) Where sender_device_keys is present in the decrypted content:
+ auto [status, deviceInfo] = m.deviceLists.findByOlmEvent(e.setDecryptedJson(plainJson, Event::Decrypted));
- if (! deviceInfoOpt) {
- kzo.client.dbg() << "Device key " << senderCurve25519Key
- << " unknown, thus invalid" << std::endl;
+ if (status == DeviceListTracker::NotFound) {
+ kzo.client.dbg() << "Device key unknown, thus invalid" << std::endl;
return cannotDecryptEvent(
"device key unknown",
"MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN",
plainJson
);
}
- auto deviceInfo = deviceInfoOpt.value();
-
+ // (1) The sender property in the decrypted content must match the sender of the event.
if (! (plainJson.at("sender") == e.sender())) {
kzo.client.dbg() << "Sender does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"sender does not match",
"MOE.KAZV.MXC_BAD_SENDER",
plainJson
);
}
+
+ // (3) The recipient property in the decrypted content must match the user ID of the local user.
if (! (plainJson.at("recipient") == m.userId)) {
kzo.client.dbg() << "Recipient does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"recipient does not match",
"MOE.KAZV.MXC_BAD_RECIPIENT",
plainJson
);
}
+
+ // (4) The recipient_keys.ed25519 property in the decrypted content must match the client
+ // device's Ed25519 signing key.
if (! (plainJson.at("recipient_keys").at(ed25519) == m.constCrypto().ed25519IdentityKey())) {
kzo.client.dbg() << "Recipient key does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"recipient keys do not match",
"MOE.KAZV.MXC_BAD_RECIPIENT_KEYS",
plainJson
);
}
+
+ // (2) The keys.ed25519 property in the decrypted content must match the
+ // [CORRECTED: ed25519 identity key of the sending device]
auto thisEd25519Key = plainJson.at("keys").at(ed25519).get<std::string>();
+ // If `sender_device_keys` is present, this also checks:
+ // (3) [CORRECTED: sender_device_keys.keys.ed25519:<device_id> must be the same as the `keys.ed25519` property in the decrypted content.]
if (thisEd25519Key != deviceInfo.ed25519Key) {
kzo.client.dbg() << "Sender ed25519 key does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"sender keys do not match",
"MOE.KAZV.MXC_BAD_SENDER_KEYS",
plainJson
);
}
+
+ // if everything looks good, add the deviceInfo if it is not yet in tracker
+ if (status == DeviceListTracker::InEvent) {
+ m.deviceLists.addVerifiedDeviceKeyInfo(
+ e.sender(),
+ deviceInfo.deviceId,
+ deviceInfo
+ );
+ }
} else if (algo == megOlmAlgo) {
auto roomId = plainJson.at("room_id").get<std::string>();
if (roomId.empty() ||
roomId != e.originalJson().get().at("room_id").template get<std::string>()) {
kzo.client.dbg() << "Room id does not match, thus invalid" << std::endl;
return cannotDecryptEvent(
"room id does not match",
"MOE.KAZV.MXC_BAD_ROOM_ID",
plainJson
);
}
} else {
kzo.client.dbg() << "Unknown algorithm, thus invalid" << std::endl;
return cannotDecryptEvent(
"unknown algorithm",
"MOE.KAZV.MXC_UNKNOWN_ALGORITHM",
plainJson
);
}
} catch (const std::exception &exception) {
kzo.client.dbg() << "json format is not correct, thus invalid" << std::endl;
return cannotDecryptEvent(
exception.what(),
"M_BAD_JSON",
plainJson
);
}
return std::nullopt;
}
- static Event decryptEvent(ClientModel &m, Event e)
+ 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());
+ kzo.client.dbg() << "plain:" << plainJson << std::endl;
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(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.addDevice(userId, deviceId, deviceInfo);
}
if (wantedToFetchAllForUser(userId)) {
m.deviceLists.markUpToDate(userId);
}
}
return { std::move(m), detail::ReturnEffectStatusT{
EffectStatus{/* succ = */ true, json::object({
{"unsatisfied", unsatisfied}
})}
} };
}
ClientResult updateClient(ClientModel m, ClaimKeysAction a)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return { std::move(m), lager::noop };
}
kzo.client.dbg() << "claim keys for: " << json(a.devicesToSend).dump() << std::endl;
auto keyMap = immer::map<std::string, immer::map<std::string /* deviceId */,
std::string /* curve25519IdentityKey */>>{};
for (auto [userId, devices] : a.devicesToSend) {
kzo.client.dbg() << "Iterating through user " << userId << std::endl;
auto deviceToKey = immer::map<std::string, std::string>{};
for (auto deviceId : devices) {
kzo.client.dbg() << "Device: " << deviceId << std::endl;
auto infoOpt = m.deviceLists.get(userId, deviceId);
if (infoOpt) {
kzo.client.dbg() << "Got device info, curve25519 key is: " << infoOpt.value().curve25519Key << std::endl;
deviceToKey = std::move(deviceToKey)
.set(deviceId, infoOpt.value().curve25519Key);
} else {
kzo.client.dbg() << "Did not get device info" << std::endl;
}
}
keyMap = std::move(keyMap).set(userId, deviceToKey);
}
auto devicesToClaimKeys = m.withCrypto([&](auto &c) { return c.devicesMissingOutboundSessionKey(keyMap); });
kzo.client.dbg() << "Really claim keys for: " << json(devicesToClaimKeys).dump() << std::endl;
auto oneTimeKeys = immer::map<std::string, immer::map<std::string, std::string>>{};
for (auto [userId, devices] : devicesToClaimKeys) {
auto devKeys = immer::map<std::string, std::string>{};
for (auto deviceId: devices) {
devKeys = std::move(devKeys).set(deviceId, signedCurve25519);
}
oneTimeKeys = std::move(oneTimeKeys).set(userId, devKeys);
}
auto job = m.job<ClaimKeysJob>()
.make(std::move(oneTimeKeys))
.withData(json{
{"roomId", a.roomId},
{"sessionId", a.sessionId},
{"sessionKey", a.sessionKey},
{"devicesToSend", a.devicesToSend},
{"random", a.random}
});
m.addJob(std::move(job));
return { std::move(m), lager::noop };
}
ClientResult processResponse(ClientModel m, ClaimKeysResponse r)
{
if (! m.crypto) {
kzo.client.dbg() << "We have no encryption enabled--ignoring this" << std::endl;
return { std::move(m), simpleFail };
}
if (! r.success()) {
kzo.client.dbg() << "claim keys failed" << std::endl;
return { std::move(m), failWithResponse(r) };
}
kzo.client.dbg() << "claim keys successful" << std::endl;
kzo.client.dbg() << "Json body: " << r.jsonBody().get().dump() << std::endl;
auto roomId = r.dataStr("roomId");
auto sessionKey = r.dataStr("sessionKey");
auto sessionId = r.dataStr("sessionId");
auto devicesToSend =
immer::map<std::string, immer::flex_vector<std::string>>(r.dataJson("devicesToSend"));
auto random = r.dataJson("random").template get<RandomData>();
// create outbound sessions for those devices
auto oneTimeKeys = r.oneTimeKeys();
for (auto [userId, deviceMap] : oneTimeKeys) {
for (auto [deviceId, keyVar] : deviceMap) {
auto keys = keyVar.get();
for (auto [keyId, key] : keys.items()) {
auto deviceInfoOpt = m.deviceLists.get(userId, deviceId);
if (deviceInfoOpt) {
auto deviceInfo = deviceInfoOpt.value();
kzo.client.dbg() << "Verifying key for " << userId
<< "/" << deviceId
<< key.dump()
<< " with ed25519 key "
<< deviceInfo.ed25519Key << std::endl;
auto verified = m.withCrypto([&](auto &c) { return c.verify(key, userId, deviceId, deviceInfo.ed25519Key); });
kzo.client.dbg() << (verified ? "passed" : "did not pass") << std::endl;
if (verified && key.contains("key")) {
auto theirOneTimeKey = key.at("key");
kzo.client.dbg() << "creating outbound session for it" << std::endl;
m.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(random, deviceInfo.curve25519Key, theirOneTimeKey); });
random.erase(0, Crypto::createOutboundSessionRandomSize());
kzo.client.dbg() << "done" << std::endl;
}
}
}
}
}
auto eventJson = json{
{"content", {{"algorithm", megOlmAlgo},
{"room_id", roomId},
{"session_id", sessionId},
{"session_key", sessionKey}}},
{"type", "m.room_key"}
};
auto event = Event(JsonWrap(eventJson));
return {
std::move(m),
[event](auto &&) { return EffectStatus{ /* success = */ true, json{{ "keyEvent", event.originalJson() }} }; }
};
}
ClientResult updateClient(ClientModel m, EncryptMegOlmEventAction a)
{
auto [encryptedEvent, maybeKey] = m.megOlmEncrypt(a.e, a.roomId, a.timeMs, a.random);
return {
std::move(m),
[=](auto && /* ctx */) {
auto retJson = json::object({
{"encrypted", encryptedEvent.originalJson()},
});
if (maybeKey.has_value()) {
retJson["key"] = maybeKey.value();
}
return EffectStatus(/* succ = */ true, retJson);
}
};
}
ClientResult updateClient(ClientModel m, SetDeviceTrustLevelAction a)
{
auto maybeOldInfo = m.deviceLists.get(a.userId, a.deviceId);
if (!maybeOldInfo) {
return {
std::move(m),
[=](auto && /* ctx */) {
auto retJson = json::object({
{"error", "No such device"},
{"errorCode", "MOE_KAZV_MXC_KAZV_NO_SUCH_DEVICE"},
});
return EffectStatus(/* succ = */ false, retJson);
}
};
}
m.deviceLists.deviceLists = updateIn(
std::move(m.deviceLists.deviceLists),
[a](auto device) {
device.trustLevel = a.trustLevel;
return device;
},
a.userId,
a.deviceId
);
return { m, lager::noop };
}
ClientResult updateClient(ClientModel m, SetDevicesTrustLevelsAction a)
{
auto res = json::object({
{"notFound", json::array()},
});
auto succ = true;
for (const auto &[userId, devicesMap] : a.trustLevelMap) {
for (const auto &[deviceId, trustLevel] : devicesMap) {
kzo.client.dbg() << "SetDevicesTrustLevelsAction: user " << userId << ", device " << deviceId << ", trust level " << trustLevel << std::endl;
auto maybeOldInfo = m.deviceLists.get(userId, deviceId);
if (!maybeOldInfo) {
res.at("notFound").push_back(json::array({userId, deviceId}));
succ = false;
continue;
}
m.deviceLists.deviceLists = updateIn(
std::move(m.deviceLists.deviceLists),
[trustLevel](auto device) {
device.trustLevel = trustLevel;
return device;
},
userId,
deviceId
);
}
}
return {std::move(m), detail::ReturnEffectStatusT{{succ, res}}};
}
ClientResult updateClient(ClientModel m, SetTrustLevelNeededToSendKeysAction a)
{
m.trustLevelNeededToSendKeys = a.trustLevel;
return { std::move(m), lager::noop };
}
ClientResult updateClient(ClientModel m, PrepareForSharingRoomKeyAction a)
{
auto messages = m.olmEncryptSplit(a.e, a.devices, a.random);
auto txnId = getTxnId(Event(), m);
m.roomList = RoomListModel::update(
std::move(m.roomList),
UpdateRoomAction{
a.roomId,
AddPendingRoomKeyAction{
PendingRoomKeyEvent{txnId, messages}
}
}
);
return { std::move(m), [txnId](auto &&) {
return EffectStatus(/* succ = */ true, json::object({{"txnId", txnId}}));
} };
}
ClientResult updateClient(ClientModel m, ImportFromKeyBackupFileAction a)
{
auto maybeExportFile = decryptKeyExport(std::move(a.fileContent), std::move(a.password));
if (!maybeExportFile) {
return {std::move(m), Kazv::detail::ReturnEffectStatusT{{
/* succ = */ false,
json{
{"errorCode", maybeExportFile.reason()},
{"error", maybeExportFile.reason()},
},
}}};
}
std::size_t imported = 0;
m.withCrypto([&maybeExportFile, &imported](Crypto &c) {
imported = c.importInboundGroupSessions(std::move(maybeExportFile).value());
});
return {std::move(m), Kazv::detail::ReturnEffectStatusT{{
/* succ = */ true,
json{
{"imported", imported},
},
}}};
};
ClientResult updateClient(ClientModel m, [[maybe_unused]] NotifyVerificationTrackerModelAction a)
{
m.addTrigger(VerificationTrackerModelChanged{});
return {std::move(m), lager::noop};
}
}
diff --git a/src/client/actions/encryption.hpp b/src/client/actions/encryption.hpp
index dcff335..18c0563 100644
--- a/src/client/actions/encryption.hpp
+++ b/src/client/actions/encryption.hpp
@@ -1,43 +1,44 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2026 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include "client-model.hpp"
#include "csapi/keys.hpp"
namespace Kazv
{
ClientResult updateClient(ClientModel m, UploadIdentityKeysAction a);
ClientResult updateClient(ClientModel m, GenerateAndUploadOneTimeKeysAction a);
ClientResult processResponse(ClientModel m, UploadKeysResponse r);
ClientModel tryDecryptEvents(ClientModel m);
+ Event decryptEvent(ClientModel &m, Event e);
std::optional<BaseJob> clientPerform(ClientModel m, QueryKeysAction a);
ClientResult updateClient(ClientModel m, QueryKeysAction a);
ClientResult updateClient(ClientModel m, EnsureKeysFromDevicesAction a);
ClientResult processResponse(ClientModel m, QueryKeysResponse r);
ClientResult updateClient(ClientModel m, ClaimKeysAction a);
ClientResult processResponse(ClientModel m, ClaimKeysResponse r);
ClientResult updateClient(ClientModel m, EncryptMegOlmEventAction a);
ClientResult updateClient(ClientModel m, SetDeviceTrustLevelAction a);
ClientResult updateClient(ClientModel m, SetDevicesTrustLevelsAction a);
ClientResult updateClient(ClientModel m, SetTrustLevelNeededToSendKeysAction a);
ClientResult updateClient(ClientModel m, PrepareForSharingRoomKeyAction a);
ClientResult updateClient(ClientModel m, ImportFromKeyBackupFileAction a);
ClientResult updateClient(ClientModel m, NotifyVerificationTrackerModelAction a);
}
diff --git a/src/client/client-model.cpp b/src/client/client-model.cpp
index 497d18a..abbbdbe 100644
--- a/src/client/client-model.cpp
+++ b/src/client/client-model.cpp
@@ -1,494 +1,538 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <immer/algorithm.hpp>
#include <lager/util.hpp>
#include <lager/context.hpp>
#include <functional>
#include <zug/transducer/filter.hpp>
#include <immer/flex_vector_transient.hpp>
#include "debug.hpp"
#include "immer-utils.hpp"
#include "json-utils.hpp"
#include "client-model.hpp"
#include "actions/states.hpp"
#include "actions/auth.hpp"
#include "actions/membership.hpp"
#include "actions/paginate.hpp"
#include "actions/send.hpp"
#include "actions/states.hpp"
#include "actions/account-data.hpp"
#include "actions/sync.hpp"
#include "actions/ephemeral.hpp"
#include "actions/content.hpp"
#include "actions/encryption.hpp"
#include "actions/profile.hpp"
#include "actions/storage.hpp"
namespace Kazv
{
auto ClientModel::update(ClientModel m, Action a) -> Result
{
auto oldClient = m;
auto oldDeviceLists = m.deviceLists;
auto actionIsStorage = std::holds_alternative<LoadEventsFromStorageAction>(a) || std::holds_alternative<PurgeRoomTimelineAction>(a);
auto [newClient, effect] = lager::match(std::move(a))(
[&](RoomListAction a) -> Result {
m.roomList = RoomListModel::update(std::move(m.roomList), a);
return {std::move(m), lager::noop};
},
[&](ResubmitJobAction a) -> Result {
m.addJob(std::move(a.job));
return { std::move(m), lager::noop };
},
[&](auto a) -> decltype(updateClient(m, a)) {
return updateClient(m, a);
},
#define RESPONSE_FOR(_jobId) \
if (r.jobId() == #_jobId) { \
return processResponse(m, _jobId##Response{std::move(r)}); \
}
[&](ProcessResponseAction a) -> Result {
auto r = std::move(a.response);
// auth
RESPONSE_FOR(Login);
RESPONSE_FOR(GetWellknown);
RESPONSE_FOR(GetVersions);
RESPONSE_FOR(Logout);
// paginate
RESPONSE_FOR(GetRoomEvents);
// sync
RESPONSE_FOR(Sync);
RESPONSE_FOR(DefineFilter);
// membership
RESPONSE_FOR(CreateRoom);
RESPONSE_FOR(InviteUser);
RESPONSE_FOR(JoinRoomById);
RESPONSE_FOR(JoinRoom);
RESPONSE_FOR(LeaveRoom);
RESPONSE_FOR(ForgetRoom);
RESPONSE_FOR(Kick);
RESPONSE_FOR(Ban);
RESPONSE_FOR(Unban);
// send
RESPONSE_FOR(SendMessage);
RESPONSE_FOR(SendToDevice);
RESPONSE_FOR(RedactEvent);
// states
RESPONSE_FOR(GetRoomState);
RESPONSE_FOR(SetRoomStateWithKey);
RESPONSE_FOR(GetRoomStateWithKey);
// account data
RESPONSE_FOR(SetAccountData);
RESPONSE_FOR(SetAccountDataPerRoom);
// ephemeral
RESPONSE_FOR(SetTyping);
RESPONSE_FOR(PostReceipt);
RESPONSE_FOR(SetReadMarker);
// content
RESPONSE_FOR(UploadContent);
RESPONSE_FOR(GetContent);
RESPONSE_FOR(GetContentThumbnail);
// encryption
RESPONSE_FOR(UploadKeys);
RESPONSE_FOR(QueryKeys);
RESPONSE_FOR(ClaimKeys);
// profile
RESPONSE_FOR(GetUserProfile);
RESPONSE_FOR(SetAvatarUrl);
RESPONSE_FOR(SetDisplayName);
m.addTrigger(UnrecognizedResponse{std::move(r)});
return { std::move(m), lager::noop };
}
#undef RESPONSE_FOR
);
newClient.maybeRotateSessions(oldClient);
// if it is an storage action, do not add it back because the things
// should be the same as the ones in storage
if (!actionIsStorage) {
newClient.maybeAddSaveEventsTrigger(oldClient);
}
return { std::move(newClient), std::move(effect) };
}
std::pair<Event, std::optional<std::string>> ClientModel::megOlmEncrypt(
Event e, std::string roomId, Timestamp timeMs, RandomData random)
{
if (!crypto) {
kzo.client.dbg() << "We do not have e2ee, so do not encrypt events" << std::endl;
return { e, std::nullopt };
}
if (e.encrypted()) {
kzo.client.dbg() << "The event is already encrypted. Ignoring it." << std::endl;
return { e, std::nullopt };
}
auto j = e.originalJson().get();
auto r = roomList[roomId];
if (! r.encrypted) {
kzo.client.dbg() << "The room " << roomId
<< " is not encrypted, so do not encrypt events" << std::endl;
return { e, std::nullopt };
}
auto desc = r.sessionRotateDesc();
auto keyOpt = std::optional<std::string>{};
if (r.shouldRotateSessionKey) {
kzo.client.dbg() << "We should rotate this session." << std::endl;
keyOpt = withCrypto([&](auto &c) { return c.rotateMegOlmSessionWithRandom(random, timeMs, roomId); });
} else {
keyOpt = withCrypto([&](auto &c) { return c.rotateMegOlmSessionWithRandomIfNeeded(random, timeMs, roomId, desc); });
}
// we no longer need to rotate session
// until next time a device change happens
roomList.rooms = std::move(roomList.rooms)
.update(roomId, [](auto r) { r.shouldRotateSessionKey = false; return r; });
auto relation = hasAtThat(j["content"], "m.relates_to", &json::is_object) ? j["content"]["m.relates_to"] : json(nullptr);
// so that Crypto::encryptMegOlm() can find room id
j["room_id"] = roomId;
auto content = withCrypto([&](auto &c) { return c.encryptMegOlm(j); });
j["type"] = "m.room.encrypted";
j["content"] = std::move(content);
j["content"]["device_id"] = deviceId;
// add relationship to plaintext
if (relation.is_object()) {
j["content"]["m.relates_to"] = relation;
}
return { Event(JsonWrap(j)), keyOpt };
}
immer::map<std::string, immer::map<std::string, Event>> ClientModel::olmEncryptSplit(
Event e,
- immer::map<std::string, immer::flex_vector<std::string>> userIdToDeviceIdMap, RandomData random)
+ immer::map<std::string, immer::flex_vector<std::string>> userIdToDeviceIdMap,
+ RandomData random,
+ bool attachSenderDeviceKeys
+ )
{
using ResT = immer::map<std::string, immer::map<std::string, Event>>;
if (!crypto) {
kzo.client.dbg() << "We do not have e2ee, so do not encrypt events" << std::endl;
return ResT{};
}
if (e.encrypted()) {
kzo.client.dbg() << "The event is already encrypted. Ignoring it." << std::endl;
return ResT{};
}
auto origJson = e.originalJson().get();
auto encJson = json::object();
encJson["content"] = json{
{"algorithm", CryptoConstants::olmAlgo},
{"ciphertext", json::object()},
{"sender_key", constCrypto().curve25519IdentityKey()},
};
encJson["type"] = "m.room.encrypted";
+ if (attachSenderDeviceKeys) {
+ auto senderDeviceKeys = makeSelfDeviceKeys();
+ origJson["sender_device_keys"] = senderDeviceKeys;
+ }
+
ResT messages;
for (auto [userId, devices] : userIdToDeviceIdMap) {
messages = std::move(messages).set(userId, immer::map<std::string, Event>());
for (auto dev : devices) {
auto devInfoOpt = deviceLists.get(userId, dev);
if (! devInfoOpt) {
+ kzo.client.warn() << "no device info for " << userId << " / " << dev << std::endl;
continue;
}
auto devInfo = devInfoOpt.value();
auto jsonForThisDevice = origJson;
jsonForThisDevice["sender"] = this->userId;
jsonForThisDevice["recipient"] = userId;
jsonForThisDevice["recipient_keys"] = json{
{CryptoConstants::ed25519, devInfo.ed25519Key}
};
jsonForThisDevice["keys"] = json{
{CryptoConstants::ed25519, constCrypto().ed25519IdentityKey()}
};
auto thisEventJson = encJson;
thisEventJson["content"]["ciphertext"]
.merge_patch(withCrypto([&](auto &c) { return c.encryptOlmWithRandom(random, jsonForThisDevice, devInfo.curve25519Key); }));
random.erase(0, Crypto::encryptOlmMaxRandomSize());
messages = setIn(std::move(messages), Event(thisEventJson), userId, dev);
}
}
return messages;
}
+ DeviceKeys ClientModel::makeSelfDeviceKeys()
+ {
+ using namespace CryptoConstants;
+
+ auto keys =
+ immer::map<std::string, std::string>{}
+ .set(ed25519 + ":" + deviceId, constCrypto().ed25519IdentityKey())
+ .set(curve25519 + ":" + deviceId, constCrypto().curve25519IdentityKey());
+
+ DeviceKeys k {
+ userId,
+ deviceId,
+ {olmAlgo, megOlmAlgo},
+ keys,
+ {} // signatures to be added soon
+ };
+
+ auto j = json(k);
+
+ auto sig = withCrypto([&](auto &crypto) { return crypto.sign(j); });
+
+ k.signatures = convertSignature(sig);
+
+ return k;
+ }
+
+ json ClientModel::convertSignature(std::string signature) const
+ {
+ auto j = json::object();
+ j[userId] = json::object();
+ j[userId][CryptoConstants::ed25519 + ":" + deviceId] = signature;
+
+ return j;
+ };
+
immer::flex_vector<std::string /* deviceId */> ClientModel::devicesToSendKeys(std::string userId) const
{
auto trustLevelNeeded = this->trustLevelNeededToSendKeys;
// XXX: preliminary approach
auto shouldSendP = [=](auto deviceInfo, auto /* deviceMap */) {
return deviceInfo.trustLevel >= trustLevelNeeded;
};
auto devices = deviceLists.devicesFor(userId);
return intoImmer(
immer::flex_vector<std::string>{},
zug::filter([=](auto n) {
auto [id, dev] = n;
return shouldSendP(dev, devices);
})
| zug::map([=](auto n) {
return n.first;
}),
devices);
}
std::size_t ClientModel::numOneTimeKeysNeeded() const
{
const auto &crypto = constCrypto();
// Keep half of max supported number of keys
int numUploadedKeys = crypto.uploadedOneTimeKeysCount(CryptoConstants::signedCurve25519);
int numKeysNeeded = crypto.maxNumberOfOneTimeKeys() / 2
- numUploadedKeys;
// Subtract the number of existing one-time keys, in case
// the previous upload was not successful.
int numKeysToGenerate = numKeysNeeded - crypto.numUnpublishedOneTimeKeys();
if (numKeysToGenerate < 0) {
numKeysToGenerate = 0;
}
return numKeysToGenerate;
}
std::size_t EncryptMegOlmEventAction::maxRandomSize()
{
return Crypto::rotateMegOlmSessionRandomSize();
}
std::size_t EncryptMegOlmEventAction::minRandomSize()
{
return 0;
}
std::size_t PrepareForSharingRoomKeyAction::randomSize(PrepareForSharingRoomKeyAction::UserIdToDeviceIdMap devices)
{
auto singleRandomSize = Crypto::encryptOlmMaxRandomSize();
auto deviceNum = accumulate(devices, std::size_t{},
[](auto counter, auto pair) { return counter + pair.second.size(); });
return deviceNum * singleRandomSize;
}
std::size_t GenerateAndUploadOneTimeKeysAction::randomSize(std::size_t numToGen)
{
return Crypto::genOneTimeKeysRandomSize(numToGen);
}
std::size_t ClaimKeysAction::randomSize(immer::map<std::string, immer::flex_vector<std::string>> devicesToSend)
{
auto singleRandomSize = Crypto::createOutboundSessionRandomSize();
auto deviceNum = accumulate(devicesToSend, std::size_t{},
[](auto counter, auto pair) { return counter + pair.second.size(); });
return deviceNum * singleRandomSize;
}
void ClientModel::maybeRotateSessions(ClientModel oldClient)
{
auto roomIds = intoImmer(
immer::flex_vector<std::string>{},
zug::filter([](const auto &pair) {
return pair.second.encrypted && !pair.second.shouldRotateSessionKey;
})
| zug::map([](const auto &pair) { return pair.first; }),
roomList.rooms
);
auto markRotate = [this](const auto &roomId) {
roomList.rooms =
std::move(roomList.rooms)
.update(roomId, [](auto room) {
room.shouldRotateSessionKey = true;
return room;
});
};
// Rotate megolm keys for rooms whose users' device list has changed
auto changedUsers = deviceLists.diff(oldClient.deviceLists);
if (! changedUsers.empty()) {
for (auto roomId : roomIds) {
auto it = std::find_if(changedUsers.begin(), changedUsers.end(),
[=](auto userId) { return roomList.rooms[roomId].hasUser(userId); });
if (it != changedUsers.end()) {
kzo.client.dbg() << "rotate keys for room " << roomId << std::endl;
markRotate(roomId);
}
}
}
roomIds = intoImmer(
immer::flex_vector<std::string>{},
zug::filter([](const auto &pair) {
return pair.second.encrypted && !pair.second.shouldRotateSessionKey;
})
| zug::map([](const auto &pair) { return pair.first; }),
roomList.rooms
);
for (auto roomId : roomIds) {
auto userIds = roomList.rooms[roomId].joinedMemberIds();
auto devicesNotChanged = [oldClient, this](const auto &userId) {
return oldClient.devicesToSendKeys(userId) == devicesToSendKeys(userId);
};
// if any user has the device changes
if (!immer::all_of(userIds, devicesNotChanged)) {
kzo.client.dbg() << "rotate keys for room " << roomId << std::endl;
markRotate(roomId);
}
}
}
auto ClientModel::directRoomMap() const -> immer::map<std::string, std::string>
{
auto directs = accountData["m.direct"].content().get();
auto directItems = directs.items();
return std::accumulate(directItems.begin(), directItems.end(), immer::map<std::string, std::string>(),
[](auto acc, const auto &cur) {
auto [userId, roomIds] = cur;
if (!roomIds.is_array()) {
return acc;
}
for (auto roomId : roomIds) {
if (roomId.is_string()) {
acc = std::move(acc).set(roomId.template get<std::string>(), userId);
}
}
return acc;
}
);
}
auto ClientModel::roomIdsUnderTag(std::string tagId) const -> immer::map<std::string, double>
{
return std::accumulate(
roomList.rooms.begin(), roomList.rooms.end(),
immer::map<std::string, double>{},
[tagId](auto acc, auto cur) {
auto [roomId, room] = cur;
auto tags = room.tags();
if (tags.count(tagId)) {
acc = std::move(acc).set(roomId, tags[tagId]);
}
return acc;
}
);
}
auto ClientModel::roomIdsByTagId() const -> immer::map<std::string, immer::map<std::string, double>>
{
return std::accumulate(
roomList.rooms.begin(), roomList.rooms.end(),
immer::map<std::string, immer::map<std::string, double>>{},
[](auto acc, auto cur) {
auto [roomId, room] = cur;
auto tags = room.tags();
if (tags.empty()) {
acc = setIn(std::move(acc), ROOM_TAG_DEFAULT_ORDER, "", roomId);
} else {
for (const auto &[tagId, order] : tags) {
acc = setIn(std::move(acc), order, tagId, roomId);
}
}
return acc;
}
);
}
const Crypto &ClientModel::constCrypto() const
{
return crypto.value().get();
}
void ClientModel::maybeAddSaveEventsTrigger(const ClientModel &old)
{
SaveEventsRequested trigger;
using ELT = immer::flex_vector_transient<Event>;
auto addToTrigger = [&trigger](const std::string &roomId, ELT tl, ELT nonTl) {
if (!tl.empty()) {
trigger.timelineEvents = std::move(trigger.timelineEvents).set(roomId, std::move(tl).persistent());
}
if (!nonTl.empty()) {
trigger.nonTimelineEvents = std::move(trigger.nonTimelineEvents).set(roomId, std::move(nonTl).persistent());
}
};
immer::diff(old.roomList.rooms, roomList.rooms, immer::make_differ(
/* addedFn = */ [&addToTrigger](const auto &p) {
const auto &[roomId, room] = p;
ELT tl;
ELT nonTl;
for (const auto &[id, e] : room.messages) {
if (room.isInTimeline(id)) {
tl.push_back(e);
} else {
nonTl.push_back(e);
}
}
addToTrigger(roomId, std::move(tl), std::move(nonTl));
},
/* removedFn = */ [](auto &&) {},
/* changedFn = */ [&addToTrigger](const auto &p1, const auto &p2) {
const auto &roomId = p2.first;
const auto &newRoom = p2.second;
const auto &oldRoom = p1.second;
ELT tl;
ELT nonTl;
auto addEvent = [&tl, &nonTl, &newRoom](const auto &newPair) {
const auto &[eventId, event] = newPair;
if (newRoom.isInTimeline(eventId)) {
tl.push_back(event);
} else {
nonTl.push_back(event);
}
};
immer::diff(oldRoom.messages, newRoom.messages, immer::make_differ(
/* addedFn = */ addEvent,
/* removedFn = */ [](auto &&) {},
/* changedFn = */ [&addEvent](auto &&, auto &&newPair) {
addEvent(std::forward<decltype(newPair)>(newPair));
}
));
addToTrigger(roomId, std::move(tl), std::move(nonTl));
}
));
if (!trigger.timelineEvents.empty() || !trigger.nonTimelineEvents.empty()) {
nextTriggers = std::move(nextTriggers).push_back(trigger);
}
}
}
diff --git a/src/client/client-model.hpp b/src/client/client-model.hpp
index 61ff83d..c3d46b2 100644
--- a/src/client/client-model.hpp
+++ b/src/client/client-model.hpp
@@ -1,746 +1,756 @@
/*
* 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);
+ immer::map<std::string, immer::map<std::string, Event>> olmEncryptSplit(Event e, immer::map<std::string, immer::flex_vector<std::string>> userIdToDeviceIdMap, RandomData random, bool attachSenderDeviceKeys = false);
+
+ /// Make a struct of DeviceKeys for identity keys of the current device.
+ DeviceKeys makeSelfDeviceKeys();
+
+ /**
+ * Convert a signature from Crypto into a json object.
+ *
+ * The returned object is a map from user id to key id to signature.
+ */
+ json convertSignature(std::string signature) const;
/// @return number of one-time keys we need to generate
std::size_t numOneTimeKeysNeeded() const;
/// @return the mapping from room id to user id of direct rooms
auto directRoomMap() const -> immer::map<std::string, std::string>;
auto roomIdsUnderTag(std::string tagId) const -> immer::map<std::string, double>;
auto roomIdsByTagId() const -> immer::map<std::string, immer::map<std::string, double>>;
/// Get the const reference of crypto of this client.
///
/// `crypto.has_value()` must be true.
const Crypto &constCrypto() const;
/// Do func with crypto, returning its return value.
///
/// `crypto.has_value()` must be true.
template<class Func>
auto withCrypto(Func &&func) -> std::decay_t<std::invoke_result_t<Func &&, Crypto &>>
{
using ResT = std::decay_t<std::invoke_result_t<Func &&, Crypto &>>;
if constexpr (std::is_same_v<ResT, void>) {
crypto = std::move(crypto).value()
.update([f=std::forward<Func>(func)](Crypto c) mutable {
std::forward<Func>(f)(c);
return c;
});
} else {
std::optional<ResT> res;
crypto = std::move(crypto).value()
.update([f=std::forward<Func>(func), &res](Crypto c) mutable {
res = std::forward<Func>(f)(c);
return c;
});
return std::move(res).value();
}
}
// helpers
template<class Job>
struct MakeJobT
{
template<class ...Args>
constexpr auto make(Args &&...args) const {
if constexpr (Job::needsAuth()) {
return Job(
serverUrl,
token,
std::forward<Args>(args)...);
} else {
return Job(
serverUrl,
std::forward<Args>(args)...);
}
}
std::string serverUrl;
std::string token;
};
template<class Job>
constexpr auto job() const {
return MakeJobT<Job>{serverUrl, token};
}
inline void addJob(BaseJob j) {
nextJobs = std::move(nextJobs).push_back(std::move(j));
}
inline auto popAllJobs() {
auto jobs = std::move(nextJobs);
nextJobs = DEFVAL;
return jobs;
};
inline void addTrigger(KazvTrigger t) {
addTriggers({t});
}
inline void addTriggers(immer::flex_vector<KazvTrigger> c) {
nextTriggers = std::move(nextTriggers) + c;
}
inline auto popAllTriggers() {
auto triggers = std::move(nextTriggers);
nextTriggers = DEFVAL;
return triggers;
}
void maybeAddSaveEventsTrigger(const ClientModel &old);
using Action = ClientAction;
using Effect = ClientEffect;
using Result = ClientResult;
static Result update(ClientModel m, Action a);
};
// actions:
struct LoginAction {
std::string serverUrl;
std::string username;
std::string password;
std::optional<std::string> deviceName;
};
struct TokenLoginAction
{
std::string serverUrl;
std::string username;
std::string token;
std::string deviceId;
};
/**
* Login using the m.token.login flow.
*/
struct MLoginTokenLoginAction
{
std::string serverUrl;
std::string loginToken;
std::optional<std::string> deviceName;
};
struct LogoutAction {};
struct HardLogoutAction {};
struct GetWellknownAction
{
std::string userId;
};
struct GetVersionsAction
{
std::string serverUrl;
};
struct SyncAction {};
struct SetShouldSyncAction
{
bool shouldSync;
};
struct PaginateTimelineAction
{
std::string roomId;
/// Must be where the Gap is
std::string fromEventId;
std::optional<int> limit;
};
struct SendMessageAction
{
std::string roomId;
Event event;
std::optional<std::string> txnId{std::nullopt};
};
struct SendStateEventAction
{
std::string roomId;
Event event;
};
/**
* Saves an local echo.
*
* After dispatching this action, the result should be such that
* `result.dataStr("txnId")` contains the transaction id to be used
* in SendMessageAction.
*/
struct SaveLocalEchoAction
{
/// The room id
std::string roomId;
/// The event to send
Event event;
/// The chosen txnId for this event. If not specified, generate from the current ClientModel.
std::optional<std::string> txnId{std::nullopt};
};
/**
* Updates the status of an local echo.
*
* After dispatching this action, the local echo's status will be
* set to the one described in the action.
*/
struct UpdateLocalEchoStatusAction
{
/// The room id.
std::string roomId;
/// The chosen txnId for this event.
std::string txnId;
/// The updated status of this local echo.
LocalEchoDesc::Status status;
};
struct RedactEventAction
{
std::string roomId;
std::string eventId;
std::optional<std::string> reason;
};
struct CreateRoomAction
{
using Visibility = RoomVisibility;
using Preset = CreateRoomPreset;
Visibility visibility;
std::optional<std::string> roomAliasName;
std::optional<std::string> name;
std::optional<std::string> topic;
immer::array<std::string> invite;
//immer::array<Invite3pid> invite3pid;
std::optional<std::string> roomVersion;
JsonWrap creationContent;
immer::array<Event> initialState;
std::optional<Preset> preset;
std::optional<bool> isDirect;
JsonWrap powerLevelContentOverride;
};
struct GetRoomStatesAction
{
std::string roomId;
};
struct GetStateEventAction
{
std::string roomId;
std::string type;
std::string stateKey;
};
struct InviteToRoomAction
{
std::string roomId;
std::string userId;
};
struct JoinRoomByIdAction
{
std::string roomId;
};
struct JoinRoomAction
{
std::string roomIdOrAlias;
immer::array<std::string> serverName;
};
struct LeaveRoomAction
{
std::string roomId;
};
struct ForgetRoomAction
{
std::string roomId;
};
struct KickAction
{
std::string roomId;
std::string userId;
std::optional<std::string> reason;
};
struct BanAction
{
std::string roomId;
std::string userId;
std::optional<std::string> reason;
};
struct UnbanAction
{
std::string roomId;
std::string userId;
};
struct SetAccountDataPerRoomAction
{
std::string roomId;
Event accountDataEvent;
};
struct SetTypingAction
{
std::string roomId;
bool typing;
std::optional<int> timeoutMs;
};
struct PostReceiptAction
{
std::string roomId;
std::string eventId;
};
struct SetReadMarkerAction
{
std::string roomId;
std::string eventId;
};
struct UploadContentAction
{
FileDesc content;
std::optional<std::string> filename;
std::optional<std::string> contentType;
std::string uploadId; // to be used by library users
};
struct DownloadContentAction
{
std::string mxcUri;
std::optional<FileDesc> downloadTo;
};
struct DownloadThumbnailAction
{
std::string mxcUri;
int width;
int height;
std::optional<ThumbnailResizingMethod> method;
std::optional<bool> allowRemote;
std::optional<FileDesc> downloadTo;
};
struct ResubmitJobAction
{
BaseJob job;
};
struct ProcessResponseAction
{
Response response;
};
struct PostInitialFiltersAction
{
};
struct SetAccountDataAction
{
Event accountDataEvent;
};
struct SendToDeviceMessageAction
{
Event event;
immer::map<std::string, immer::flex_vector<std::string>> devicesToSend;
std::optional<std::string> txnId{std::nullopt};
};
/**
* Send multiple to device messages.
*
* Due to limitations of the spec, the type of the Events must be the same.
*/
struct SendMultipleToDeviceMessagesAction
{
/// A map from user id to device id to the event.
immer::map<std::string, immer::map<std::string, Event>> userToDeviceToEventMap;
/// An optional transaction id. Will be generated if not provided.
std::optional<std::string> txnId{std::nullopt};
};
struct UploadIdentityKeysAction
{
};
/**
* The action to generate one-time keys.
*
* `random.size()` must be at least `randomSize(numToGen)`.
*
* This action will not generate keys exceeding the local limit of olm.
*/
struct GenerateAndUploadOneTimeKeysAction
{
/// @return The size of random needed to generate
/// `numToGen` one-time keys
static std::size_t randomSize(std::size_t numToGen);
/// The number of keys to generate
std::size_t numToGen;
/// The random data used to generate keys
RandomData random;
};
struct QueryKeysAction
{
bool isInitialSync;
};
/**
* Ensure keys from devices of a user.
*
* After the reducer for this action completes,
* the ClientModel will contain information about the devices'
* keys, in the DeviceListTracker (ClientModel::deviceLists).
*
* The after receiving a response, the resulting EffectStatus will contain
* a data property `unsatisfied`. It is in the format:
*
* ```
* {
* "users": [userIds...], "devices": [[userId, deviceId]...]
* }
* ```
*
* If we requested all devices of a user `@foo:example.org` and the user is
* not available, then `data.at("unsatisfied").at("users")` will contain
* `@foo:example.org`.
*
* If we requested a device `Device1` of a user `@foo:example.org` and the
* device is not available, then `data.at("unsatisfied").at("devices")` will
* contain `["@foo:example.org", "Device1"]`.
*/
struct EnsureKeysFromDevicesAction
{
/**
* The map detailing the devices of which the keys to be fetched.
*
* This follows the same semantics as the query keys endpoint
* (/_matrix/client/v3/keys/query): if the device id list is
* empty, it will query all keys of that user. In this case,
* after receiving the response, we will also mark the device lists
* for that user as up-to-date.
*/
immer::map<
std::string /* userId */,
immer::flex_vector<std::string /* deviceId */>> userIdToDeviceIdsMap;
};
struct ClaimKeysAction
{
static std::size_t randomSize(immer::map<std::string, immer::flex_vector<std::string>> devicesToSend);
std::string roomId;
std::string sessionId;
std::string sessionKey;
immer::map<std::string, immer::flex_vector<std::string>> devicesToSend;
RandomData random;
};
/**
* The action to encrypt an megolm event for a room.
*
* If the action is successful, the result `r` will
* be such that `r.dataJson("encrypted")` contains the encrypted event *json*.
*
* If the megolm session is rotated, `r.dataStr("key")` will contain the key
* of the megolm session. Otherwise, `r.data().contains("key")` will be false.
*
* The Action may fail due to insufficient random data,
* when the megolm session needs to be rotated.
* In this case, the reducer for the Action will fail,
* and its result `r` will be such that
* `r.dataStr("reason") == "NotEnoughRandom"`.
* The user needs to provide random data of
* at least size `maxRandomSize()`.
*
*/
struct EncryptMegOlmEventAction
{
static std::size_t maxRandomSize();
static std::size_t minRandomSize();
/// The id of the room to encrypt for.
std::string roomId;
/// The event to encrypt.
Event e;
/// The timestamp, to determine whether the session should expire.
Timestamp timeMs;
/// Random data for the operation. Must be of at least size
/// `minRandomSize()`. If this is a retry of the previous operation
/// due to NotEnoughRandom, it must be of at least size `maxRandomSize()`.
RandomData random;
};
struct SetDeviceTrustLevelAction
{
std::string userId;
std::string deviceId;
DeviceTrustLevel trustLevel;
};
/**
* Set the trust levels of devices.
*
* If any of the devices is not found, this will return a
* failed EffectStatus with data
* `{"notFound": [[<userId>, <deviceId>]...]}`.
* Otherwise, it returns a successful EffectStatus.
*/
struct SetDevicesTrustLevelsAction
{
/// A map from userId to deviceId to the trust level to set.
immer::map<
std::string /* userId */,
immer::map<std::string /* deviceId */, DeviceTrustLevel>> trustLevelMap;
};
struct SetTrustLevelNeededToSendKeysAction
{
DeviceTrustLevel trustLevel;
};
/// Encrypt room key as olm and add it to the room's
/// pending keyshare slots.
/// This is to ensure atomicity and that we do not lose an olm-encrypted event.
struct PrepareForSharingRoomKeyAction
{
using UserIdToDeviceIdMap = immer::map<std::string, immer::flex_vector<std::string>>;
static std::size_t randomSize(UserIdToDeviceIdMap devices);
/// The room to share the key event in.
std::string roomId;
/// Devices to encrypt for.
UserIdToDeviceIdMap devices;
/// The key event to encrypt.
Event e;
/// The random data for the encryption. Must be of at least
/// size `randomSize(devices)`.
RandomData random;
};
/**
* Import keys from key backup file.
*
* On success, the reducer returns data with `imported` property
* being the number of keys imported. On failure, it returns data with
* `errorCode` and `error` properties set to the error in the process.
*/
struct ImportFromKeyBackupFileAction
{
/// The content of the key backup file.
std::string fileContent;
/// The password.
std::string password;
};
/**
* Notify that the verification tracker model has been changed.
*/
struct NotifyVerificationTrackerModelAction {};
struct 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/device-list-tracker.cpp b/src/client/device-list-tracker.cpp
index c896b2d..eff7d6f 100644
--- a/src/client/device-list-tracker.cpp
+++ b/src/client/device-list-tracker.cpp
@@ -1,187 +1,261 @@
/*
* 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
{
+ 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::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)
+ std::optional<DeviceKeyInfo> DeviceListTracker::verifyDeviceInfo(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo) const
{
using namespace CryptoConstants;
if (userId != deviceInfo.userId
|| deviceId != deviceInfo.deviceId) {
- return false;
+ return std::nullopt;
}
- // if the ed25519 key changed, reject
- auto curEd25519Key = deviceInfo.keys[ed25519 + ":" + deviceId];
DeviceTrustLevel trustLevel{Unseen};
+ std::optional<std::string> displayName;
+
+ auto curEd25519Key = deviceInfo.keys[ed25519 + ":" + deviceId];
if (deviceLists[userId].find(deviceId)) {
+ // if the ed25519 key changed, reject
if (curEd25519Key != deviceLists[userId][deviceId].ed25519Key) {
- return false;
+ return std::nullopt;
}
- // keep device trust level when adding device
+ // keep device trust level and display name when adding device
trustLevel = deviceLists[userId][deviceId].trustLevel;
+ displayName = deviceLists[userId][deviceId].displayName;
+ }
+
+ // reject if there is another device with the same keys
+ if (auto d = findByCurve25519Key(userId, deviceInfo.keys[curve25519 + ":" + deviceId]);
+ d.has_value()
+ && d.value().deviceId != deviceId) {
+ return std::nullopt;
}
kzo.client.dbg() << "verifying device info" << std::endl;
- if (crypto.verify(deviceInfo, userId, deviceId, curEd25519Key)) {
+ 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 : displayName,
trustLevel,
};
+ return info;
+ } else {
+ kzo.client.dbg() << "did not pass verification" << std::endl;
+ return std::nullopt;
+ }
+ }
- deviceLists = std::move(deviceLists)
- .update(userId, [=](auto deviceMap) {
- return std::move(deviceMap).set(deviceId, info);
- });
- return true;
+ bool DeviceListTracker::addDevice(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo)
+ {
+ using namespace CryptoConstants;
+ auto res = verifyDeviceInfo(userId, deviceId, deviceInfo);
+ if (!res.has_value()) {
+ return false;
}
- kzo.client.dbg() << "did not pass verification" << std::endl;
- return false;
+ auto info = res.value();
+ addVerifiedDeviceKeyInfo(userId, deviceId, info);
+
+ return true;
+ }
+
+ void DeviceListTracker::addVerifiedDeviceKeyInfo(std::string userId, std::string deviceId, DeviceKeyInfo info)
+ {
+ deviceLists = std::move(deviceLists)
+ .update(userId, [=](auto deviceMap) {
+ return std::move(deviceMap).set(deviceId, info);
+ });
}
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)
+ auto DeviceListTracker::findByOlmEvent(Event e) const
+ -> std::pair<FindByOlmEventStatus, DeviceKeyInfo>
{
- a.displayName = std::nullopt;
- b.displayName = std::nullopt;
- a.trustLevel = Unseen;
- b.trustLevel = Unseen;
- return std::move(a) == std::move(b);
- }
+ if (!(e.encrypted() && e.decrypted())) {
+ return {NotFound, {}};
+ }
- 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;
+ auto senderCurve25519Key =
+ e.originalJson().get().contains("/content/sender_key"_json_pointer)
+ && e.originalJson().get().at("/content/sender_key"_json_pointer).is_string()
+ ? e.originalJson().get().at("/content/sender_key"_json_pointer).template get<std::string>()
+ : std::string();
+ if (senderCurve25519Key.empty()) {
+ return {NotFound, {}};
+ }
+ // Perform checks described in
+ // https://spec.matrix.org/v1.18/client-server-api/#validation-of-incoming-decrypted-events
+ // (5) Where sender_device_keys is present in the decrypted content:
+ if (e.raw().get().contains("sender_device_keys")) {
+ auto deviceKeys = e.raw().get().at("sender_device_keys").template get<Api::QueryKeysJob::DeviceInformation>();
+ // The following statement checks:
+ // (1) sender_device_keys.user_id must also match the sender of the event.
+ // (2) [CORRECTED: sender_device_keys.keys.curve25519:<device_id>] must also match the sender_key property in the cleartext m.room.encrypted event body.
+ // (3) ed25519 keys not checked (the check is performed in client/actions/encryption.cpp )
+ // (4) The sender_device_keys structure must have a valid signature from the key with ID ed25519:<device_id> (i.e., the sending device's Ed25519 key).
+ auto verifiedInfo = verifyDeviceInfo(e.sender(), deviceKeys.deviceId, deviceKeys);
+ if (verifiedInfo.has_value()) {
+ auto existingDeviceOpt = get(e.sender(), deviceKeys.deviceId);
+ if (existingDeviceOpt.has_value()) {
+ return {InTracker, existingDeviceOpt.value()};
+ } else {
+ return {InEvent, verifiedInfo.value()};
}
+ } else {
+ kzo.client.dbg() << "sender_device_keys present but invalid, rejecting" << std::endl;
+ return {NotFound, {}};
}
- ));
- return !changed;
+ } else {
+
+ auto existingDeviceOpt = findByCurve25519Key(e.sender(), senderCurve25519Key);
+ if (existingDeviceOpt.has_value()) {
+ return {InTracker, existingDeviceOpt.value()};
+ } else {
+ return {NotFound, {}};
+ }
+ }
}
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/device-list-tracker.hpp b/src/client/device-list-tracker.hpp
index 6edbd55..0699ec3 100644
--- a/src/client/device-list-tracker.hpp
+++ b/src/client/device-list-tracker.hpp
@@ -1,106 +1,122 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021 Tusooa Zhu <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#pragma once
#include <libkazv-config.hpp>
#include <string>
#include <immer/map.hpp>
#include <immer/flex_vector.hpp>
#include <boost/serialization/string.hpp>
#include <serialization/std-optional.hpp>
#include <serialization/immer-map.hpp>
#include <crypto.hpp>
#include <csapi/keys.hpp>
#include "cursorutil.hpp"
namespace Kazv
{
enum DeviceTrustLevel
{
Blocked,
Unseen,
Seen,
Verified,
};
struct DeviceKeyInfo
{
std::string deviceId;
std::string ed25519Key;
std::string curve25519Key;
std::optional<std::string> displayName;
DeviceTrustLevel trustLevel{Unseen};
friend bool operator==(const DeviceKeyInfo &a, const DeviceKeyInfo &b) = default;
friend bool operator!=(const DeviceKeyInfo &a, const DeviceKeyInfo &b) = default;
};
template<class Archive>
void serialize(Archive &ar, DeviceKeyInfo &i, std::uint32_t const /*version*/)
{
ar
& i.deviceId
& i.ed25519Key
& i.curve25519Key
& i.displayName
& i.trustLevel
;
}
struct DeviceListTracker
{
using DeviceMapT = immer::map<std::string /* deviceId */, DeviceKeyInfo>;
immer::map<std::string /* userId */, bool /* outdated */> usersToTrackDeviceLists;
immer::map<std::string /* userId */, DeviceMapT> deviceLists;
template<class RangeT>
void track(RangeT &&userIds) {
for (auto userId : std::forward<RangeT>(userIds)) {
usersToTrackDeviceLists = std::move(usersToTrackDeviceLists)
.set(userId, true);
}
}
template<class RangeT>
void untrack(RangeT &&userIds) {
for (auto userId : std::forward<RangeT>(userIds)) {
usersToTrackDeviceLists = std::move(usersToTrackDeviceLists).erase(userId);
}
}
immer::flex_vector<std::string> outdatedUsers() const;
- bool addDevice(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo, Crypto &crypto);
+ std::optional<DeviceKeyInfo> verifyDeviceInfo(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo) const;
+
+ bool addDevice(std::string userId, std::string deviceId, Api::QueryKeysJob::DeviceInformation deviceInfo);
+
+ void addVerifiedDeviceKeyInfo(std::string userId, std::string deviceId, DeviceKeyInfo info);
void markUpToDate(std::string userId);
DeviceMapT devicesFor(std::string userId) const;
std::optional<DeviceKeyInfo> get(std::string userId, std::string deviceId) const;
std::optional<DeviceKeyInfo> findByEd25519Key(std::string userId, std::string ed25519Key) const;
std::optional<DeviceKeyInfo> findByCurve25519Key(std::string userId, std::string curve25519Key) const;
+ enum FindByOlmEventStatus
+ {
+ /// The sending device info cannot be found.
+ NotFound,
+ /// The sending device info is already in tracker.
+ InTracker,
+ /// The sending device info is not in tracker, but it is in the event
+ /// and ready to be added to the tracker.
+ InEvent,
+ };
+ std::pair<FindByOlmEventStatus, DeviceKeyInfo> findByOlmEvent(Event e) const;
+
/// returns a list of users whose device list has changed
immer::flex_vector<std::string> diff(DeviceListTracker that) const;
};
template<class Archive>
void serialize(Archive &ar, DeviceListTracker &t, std::uint32_t const /*version*/)
{
ar
& t.usersToTrackDeviceLists
& t.deviceLists
;
}
}
BOOST_CLASS_VERSION(Kazv::DeviceKeyInfo, 0)
BOOST_CLASS_VERSION(Kazv::DeviceListTracker, 0)
diff --git a/src/crypto/crypto.hpp b/src/crypto/crypto.hpp
index 11344cd..b6911e6 100644
--- a/src/crypto/crypto.hpp
+++ b/src/crypto/crypto.hpp
@@ -1,283 +1,283 @@
/*
* 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 <memory>
#include <nlohmann/json.hpp>
#include <immer/map.hpp>
#include <immer/flex_vector.hpp>
#include <maybe.hpp>
#include "crypto-util.hpp"
#include "time-util.hpp"
namespace Kazv
{
class Session;
struct MegOlmSessionRotateDesc
{
/// The time limit of the megolm session, in milliseconds.
Timestamp ms{};
/// The message limit of the megolm session.
int messages{};
};
struct CryptoPrivate;
class Crypto
{
public:
/**
* Construct an invalid Crypto.
*/
explicit Crypto();
/**
* @return The size of random data needed to construct a Crypto.
*/
static std::size_t constructRandomSize();
/**
* Constructs a Crypto using user-provided random data.
*
* @param data Random data of size at least `constructRandomSize()`.
*/
Crypto(RandomTag, RandomData data);
Crypto(const Crypto &that);
Crypto(Crypto &&that);
Crypto &operator=(const Crypto &that);
Crypto &operator=(Crypto &&that);
~Crypto();
bool operator==(const Crypto &that) const;
/**
* @return whether this Crypto is valid.
*/
bool valid() const;
std::string ed25519IdentityKey() const;
std::string curve25519IdentityKey() const;
std::string sign(nlohmann::json j);
void setUploadedOneTimeKeysCount(immer::map<std::string /* algorithm */, int> uploadedOneTimeKeysCount);
int uploadedOneTimeKeysCount(std::string algorithm) const;
std::size_t maxNumberOfOneTimeKeys() const;
/**
* Get the size of random data needed to generate `num`
* one-time keys.
*
* @param num The number of one-time keys to generate.
*
* @return The size of random data needed to generate
* `num` one-time keys.
*/
static std::size_t genOneTimeKeysRandomSize(int num);
/**
* Generate `num` one-time keys with user-provided random data.
*
* @param random The random data. Must be of at least size
* `genOneTimeKeysRandomSize(num)`.
* @param num The number of one-time keys to generate.
*/
void genOneTimeKeysWithRandom(RandomData random, int num);
/**
* According to olm.h, this returns an object like
*
* {
* curve25519: {
* "AAAAAA": "wo76WcYtb0Vk/pBOdmduiGJ0wIEjW4IBMbbQn7aSnTo",
* "AAAAAB": "LRvjo46L1X2vx69sS9QNFD29HWulxrmW11Up5AfAjgU"
* }
* }
*/
nlohmann::json unpublishedOneTimeKeys() const;
int numUnpublishedOneTimeKeys() const;
void markOneTimeKeysAsPublished();
/// Returns decrypted message if we can decrypt it
/// otherwise returns the error
MaybeString decrypt(nlohmann::json eventJson);
/**
* @return The size of random data needed to encrypt a message
* for the session identified with `theirCurve25519IdentityKey`.
*/
std::size_t encryptOlmRandomSize(std::string theirCurve25519IdentityKey) const;
/**
* @return The maximum size of random data needed to encrypt a message
* for the session identified with `theirCurve25519IdentityKey`.
*/
static std::size_t encryptOlmMaxRandomSize();
/**
* Encrypt `eventJson` with olm, for the recipient identified with `theirCurve25519IdentityKey`.
*
* @param random The random data to use for encryption. Must be of
* at least size `encryptOlmRandomSize(theirCurve25519IdentityKey)`.
* @param eventJson The event json to encrypt.
* @param theirCurve25519IdentityKey The curve25519 identity key of the recipient.
*
* @return A json object that looks like
* ```
* {
* "<their identity key>": {
* "type": <number>,
* "body": "<body>"
* }
* }
* ```
*/
nlohmann::json encryptOlmWithRandom(
RandomData random, nlohmann::json eventJson, std::string theirCurve25519IdentityKey);
/// returns the content template with everything but deviceId
/// eventJson should contain type, room_id and content
nlohmann::json encryptMegOlm(nlohmann::json eventJson);
/**
* Create or update an inbound group session.
*
* @param k The key of the group session.
* @param sessionKey The session key.
* @param ed25519Key The ed25519 identity key of the sender of the m.room_key event.
*
* @return true iff the session is created or updated.
*/
bool createInboundGroupSession(KeyOfGroupSession k, std::string sessionKey, std::string ed25519Key);
/**
* Import group sessions from json.
*
* This function first validates the format. If a key passes
* format validation, this calls createInboundGroupSession.
*
* @param keys The json array of group session keys. In the
* key-export format specified in the spec.
* @return The number of keys successfully imported.
*/
std::size_t importInboundGroupSessions(const nlohmann::json &keys);
bool hasInboundGroupSession(KeyOfGroupSession k) const;
std::string outboundGroupSessionInitialKey(std::string roomId);
std::string outboundGroupSessionCurrentKey(std::string roomId);
/// Check whether the signature of userId/deviceId is valid in object
- bool verify(nlohmann::json object, std::string userId, std::string deviceId, std::string ed25519Key);
+ static bool verify(nlohmann::json object, std::string userId, std::string deviceId, std::string ed25519Key);
MaybeString getInboundGroupSessionEd25519KeyFromEvent(const nlohmann::json &eventJson) const;
/**
* @return The size of random data needed for `rotateMegOlmSessionWithRandom()`
* and `rotateMegOlmSessionWithRandomIfNeeded()`.
*/
static std::size_t rotateMegOlmSessionRandomSize();
/**
* Rotate the megolm session using user-provided random data.
*
* @param random The random data. Must be of at least size
* `rotateMegOlmSessionRandomSize()`.
* @param timeMs The creation time of the new megolm session.
* @param roomId The room id of the megolm session to rotate.
*
* @return The new session key.
*/
std::string rotateMegOlmSessionWithRandom(RandomData random, Timestamp timeMs, std::string roomId);
/**
* Rotate the megolm session using user-provided random data,
* if we need to rotate it.
*
* The session will be rotated if and only if
* - The difference between `timeMs` and the creation time of
* the megolm session has reached the time limit in `desc`, OR;
* - The number of messages this megolm session has encrypted has
* reached the message limit in `desc`.
*
* @param random The random data. Must be of at least size
* `rotateMegOlmSessionRandomSize()`.
* @param timeMs The timestamp to judge whether the session
* has reached its time limit. If the megolm session is rotated,
* this will also be the creation time of the new megolm session.
* @param roomId The room id of the megolm session to rotate.
* @param desc The rotation specification of this room.
*
* @return The session key if the session is rotated,
* `std::nullopt` otherwise.
*/
std::optional<std::string> rotateMegOlmSessionWithRandomIfNeeded(
RandomData random,
Timestamp timeMs,
std::string roomId,
MegOlmSessionRotateDesc desc);
using UserIdToDeviceIdMap = immer::map<std::string, immer::flex_vector<std::string>>;
UserIdToDeviceIdMap devicesMissingOutboundSessionKey(
immer::map<std::string, immer::map<std::string /* deviceId */,
std::string /* curve25519IdentityKey */>> keyMap) const;
/**
* @return The size of random data needed for `createOutboundSessionWithRandom()`.
*/
static std::size_t createOutboundSessionRandomSize();
/**
* Create an outbound session using user-provided random data.
*
* @param random The random data to use. It must be at least of
* size `createOutboundSessionRandomSize()`.
* @param theirIdeneityKey The identity key of the recipient.
* @param theirOneTimeKey The one-time key of the recipient.
*/
void createOutboundSessionWithRandom(
RandomData random,
std::string theirIdentityKey,
std::string theirOneTimeKey);
template<class Archive>
void save(Archive & ar, const unsigned int /* version */) const {
ar << toJson().dump();
}
template<class Archive>
void load(Archive &ar, const unsigned int /* version */) {
std::string j;
ar >> j;
loadJson(nlohmann::json::parse(std::move(j)));
}
BOOST_SERIALIZATION_SPLIT_MEMBER()
nlohmann::json toJson() const;
void loadJson(const nlohmann::json &j);
private:
friend class Session;
friend struct SessionPrivate;
std::unique_ptr<CryptoPrivate> m_d;
};
}
BOOST_CLASS_VERSION(Kazv::Crypto, 0)
diff --git a/src/tests/client/device-list-tracker-test.cpp b/src/tests/client/device-list-tracker-test.cpp
index b64dcf0..7618847 100644
--- a/src/tests/client/device-list-tracker-test.cpp
+++ b/src/tests/client/device-list-tracker-test.cpp
@@ -1,22 +1,96 @@
/*
* 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 "encryption-test-utils.hpp"
#include <catch2/catch_test_macros.hpp>
#include <client-model.hpp>
+#include <actions/encryption.hpp>
#include <factory.hpp>
+#include <debug.hpp>
using namespace Kazv;
using namespace Kazv::Factory;
TEST_CASE("DeviceListTracker::findByCurve25519Key")
{
auto info = makeDeviceKeyInfo(withDeviceId("device1"));
auto client = makeClient(withDevice("@example:example.com", info));
REQUIRE(client.deviceLists.findByCurve25519Key("@example:example.com", info.curve25519Key) == info);
REQUIRE(client.deviceLists.findByCurve25519Key("@someone:example.com", info.curve25519Key) == std::nullopt);
}
+
+TEST_CASE("DeviceListTracker::findByOlmEvent")
+{
+ auto s = OlmFirstTimeDecryptTestSetup();
+
+ auto plainText = json{
+ {"content", json::object()},
+ {"type", "moe.kazv.mxc.xxx"},
+ };
+
+ SECTION("without sender_device_keys") {
+ auto encrypted = s.encrypt(plainText);
+ auto e = s.decryptOnly(encrypted);
+ auto [st, ki] = s.clientWithDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::InTracker);
+ REQUIRE(ki.deviceId == "device1");
+ REQUIRE(ki.ed25519Key == s.clientA.constCrypto().ed25519IdentityKey());
+
+ std::tie(st, ki) = s.clientNoDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::NotFound);
+ }
+
+ SECTION("with valid sender_device_keys") {
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ true);
+ auto e = s.decryptOnly(encrypted);
+ auto [st, ki] = s.clientWithDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::InTracker);
+ REQUIRE(ki.deviceId == "device1");
+ REQUIRE(ki.ed25519Key == s.clientA.constCrypto().ed25519IdentityKey());
+
+ std::tie(st, ki) = s.clientNoDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::InEvent);
+ REQUIRE(ki.deviceId == "device1");
+ REQUIRE(ki.ed25519Key == s.clientA.constCrypto().ed25519IdentityKey());
+ }
+
+ SECTION("with invalid sender_device_keys (bad signature)") {
+ // the following creates a bad signature
+ auto fakeClientA = s.clientA;
+ fakeClientA.deviceId = "device3";
+ plainText["sender_device_keys"] = fakeClientA.makeSelfDeviceKeys();
+ plainText["sender_device_keys"]["device_id"] = s.senderDeviceId;
+
+ auto encrypted = s.encrypt(plainText);
+ auto e = s.decryptOnly(encrypted);
+ auto [st, ki] = s.clientWithDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::NotFound);
+
+ std::tie(st, ki) = s.clientNoDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::NotFound);
+ }
+
+ SECTION("with invalid sender_device_keys (device id mismatch)") {
+ // the following creates a good signature, but device id is fake
+ // findByOlmEvent() does not rule out this, but it should be ruled out in
+ // verifyEvent().
+ auto fakeClientA = s.clientA;
+ fakeClientA.deviceId = "device3";
+ plainText["sender_device_keys"] = fakeClientA.makeSelfDeviceKeys();
+
+ auto encrypted = s.encrypt(plainText);
+ auto e = s.decryptOnly(encrypted);
+ auto [st, ki] = s.clientWithDevice.deviceLists.findByOlmEvent(e);
+ // It should reject because there is already another device with the same
+ // key
+ REQUIRE(st == DeviceListTracker::NotFound);
+
+ std::tie(st, ki) = s.clientNoDevice.deviceLists.findByOlmEvent(e);
+ REQUIRE(st == DeviceListTracker::InEvent);
+ }
+}
diff --git a/src/tests/client/encryption-test.cpp b/src/tests/client/encryption-test.cpp
index 8597b5d..1328942 100644
--- a/src/tests/client/encryption-test.cpp
+++ b/src/tests/client/encryption-test.cpp
@@ -1,511 +1,718 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2021-2023 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <catch2/catch_all.hpp>
#include <client/actions/encryption.hpp>
#include <client-model.hpp>
#include "key-export.hpp"
#include "client-test-util.hpp"
#include "action-mock-utils.hpp"
+#include "encryption-test-utils.hpp"
#include "factory.hpp"
using namespace Kazv::Factory;
namespace
{
struct CreateE2EESessionResult
{
ClientModel receiver1;
ClientModel receiver2;
ClientModel sender;
};
struct CreateMegOlmSessionResult
{
Event encryptedRoomEvent;
Event encryptedKeyEvent;
Event unencryptedKeyEvent;
};
}
static json makeDeviceInfo(const ClientModel &client)
{
auto [next, _] = updateClient(client, UploadIdentityKeysAction{});
return json::parse(std::get<Bytes>(next.nextJobs[0].requestBody()))["device_keys"];
}
static CreateE2EESessionResult createE2EESession()
{
auto r1Crypto = makeCrypto();
r1Crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto r1 = makeClient(withCrypto(r1Crypto));
r1.userId = "@receiver:example.com";
r1.deviceId = "device1";
auto r2Crypto = makeCrypto();
r2Crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto r2 = makeClient(withCrypto(r2Crypto));
r2.userId = "@receiver:example.com";
r2.deviceId = "device2";
auto oneTimeKeys1 = r1Crypto.unpublishedOneTimeKeys();
auto cv25519Key1 = oneTimeKeys1["curve25519"].items().begin().value().template get<std::string>();
auto oneTimeKeys2 = r2Crypto.unpublishedOneTimeKeys();
auto cv25519Key2 = oneTimeKeys2["curve25519"].items().begin().value().template get<std::string>();
auto queryKeysRespJsonSender = json{
{"device_keys", {{"@receiver:example.com", {
{"device1", makeDeviceInfo(r1)},
{"device2", makeDeviceInfo(r2)},
}}}},
};
// Query keys
auto client = makeClient(withCrypto(makeCrypto()));
client.userId = "@sender:example.com";
client.deviceId = "device1";
auto queryKeysRespJsonReceiver = json{
{"device_keys", {{"@sender:example.com", {
{"device1", makeDeviceInfo(client)}
}}}},
};
std::tie(client, std::ignore) = processResponse(client, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonSender)
| withResponseDataKV("deviceKeys", json::object({{"@receiver:example.com", json::array()}})))
));
std::tie(r1, std::ignore) = processResponse(r1, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonReceiver)
| withResponseDataKV("deviceKeys", json::object({{"@sender:example.com", json::array()}})))
));
std::tie(r2, std::ignore) = processResponse(r2, QueryKeysResponse(
makeResponse("QueryKeys", withResponseJsonBody(queryKeysRespJsonReceiver)
| withResponseDataKV("deviceKeys", json::object({{"@sender:example.com", json::array()}})))
));
// Claim keys
client.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), r1Crypto.curve25519IdentityKey(), cv25519Key1); });
client.withCrypto([&](auto &c) { c.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), r2Crypto.curve25519IdentityKey(), cv25519Key2); });
return {
r1,
r2,
client,
};
}
static CreateMegOlmSessionResult createMegOlmSession(ClientModel &sender, ClientModel &receiver, std::string roomId, Event plainText)
{
auto sessionKey = sender.withCrypto([&](auto &c) {
return c.rotateMegOlmSessionWithRandom(genRandomData(Crypto::rotateMegOlmSessionRandomSize()), 0, roomId);
});
auto mod = withRoom(makeRoom(withRoomId(roomId) | withRoomEncrypted(true)));
mod(sender);
mod(receiver);
auto [encrypted, _noSessionKey] = sender.megOlmEncrypt(plainText, roomId, 0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto sessionId = encrypted.originalJson().get()["content"]["session_id"].template get<std::string>();
auto keyEventJson = json{
{"content", {{"algorithm", CryptoConstants::megOlmAlgo},
{"room_id", roomId},
{"session_id", sessionId},
{"session_key", sessionKey}}},
{"type", "m.room_key"}
};
auto res = sender.olmEncryptSplit(Event(keyEventJson),
{{receiver.userId, {receiver.deviceId}}},
genRandomData(Crypto::encryptOlmMaxRandomSize() * 2));
auto encryptedKeyEventJson = res[receiver.userId][receiver.deviceId].originalJson().get();
encryptedKeyEventJson["sender"] = sender.userId;
auto encryptedKeyEvent = Event(encryptedKeyEventJson);
std::cerr << "room event" << encrypted.originalJson().get().dump() << std::endl;
std::cerr << "key event" << encryptedKeyEvent.originalJson().get().dump() << std::endl;
keyEventJson["keys"] = json{
{CryptoConstants::ed25519, sender.constCrypto().ed25519IdentityKey()},
};
keyEventJson["sender"] = sender.userId;
return {encrypted, encryptedKeyEvent, Event(keyEventJson)};
}
static Response syncResponseFromToDevice(Event toDevice)
{
auto j = json{
{"next_batch", "something"},
{"to_device", {
{"events", json::array({toDevice.originalJson().get()})},
}},
};
return makeResponse(
"Sync",
withResponseJsonBody(j)
| withResponseDataKV("is", "incremental"));
}
TEST_CASE("PrepareForSharingRoomKeyAction: adds the encrypted event to pending events", "[client][encryption]")
{
ClientModel m;
m.crypto = Crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
RoomModel room;
room.encrypted = true;
room.roomId = "!exampleroomid:example.com";
m.roomList.rooms = m.roomList.rooms.set("!exampleroomid:example.com", room);
auto event = Event{json{
{"type", "m.room_key"},
{"content", {{"some", "thing"}}},
}};
auto [next, dontCareEffect] = ClientModel::update(m, PrepareForSharingRoomKeyAction{"!exampleroomid:example.com", {}, event, {}});
auto nextRoom = next.roomList.rooms.at("!exampleroomid:example.com");
REQUIRE(nextRoom.pendingRoomKeyEvents.size() == 1);
}
TEST_CASE("encrypted event will keep a copy of m.relates_to in plaintext", "[client][encryption]")
{
auto room = makeRoom(withRoomEncrypted(true));
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto eventToEncrypt = makeEvent(
withEventType("m.room.message")
| withEventRelationship("moe.kazv.mxc.custom-rel-type", "$some-event-id")
);
auto [encryptedEvent, maybeKey] = client.megOlmEncrypt(
eventToEncrypt,
room.roomId,
0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize())
);
// because we do not have session key yet, it should always be rotated
REQUIRE(maybeKey.has_value());
// check we can still access relationship
REQUIRE(encryptedEvent.relationship() == std::pair<std::string, std::string>{"moe.kazv.mxc.custom-rel-type", "$some-event-id"});
// check that the relationship is also in plaintext
REQUIRE(encryptedEvent.originalJson().get()["content"]["m.relates_to"] == json{
{"rel_type", "moe.kazv.mxc.custom-rel-type"},
{"event_id", "$some-event-id"},
});
}
TEST_CASE("encrypting event without relationship should not put m.relates_to key in plaintext", "[client][encryption]")
{
auto room = makeRoom(withRoomEncrypted(true));
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto eventToEncrypt = makeEvent(
withEventType("m.room.message")
);
auto [encryptedEvent, maybeKey] = client.megOlmEncrypt(
eventToEncrypt,
room.roomId,
0,
genRandomData(EncryptMegOlmEventAction::maxRandomSize())
);
REQUIRE(maybeKey.has_value());
REQUIRE(!encryptedEvent.originalJson().get()["content"].contains("m.relates_to"));
}
TEST_CASE("ClientModel::olmEncryptSplit()", "[client][encryption]")
{
auto r = createE2EESession();
auto client = r.sender;
auto receiver1 = r.receiver1.constCrypto();
auto receiver2 = r.receiver2.constCrypto();
// encrypt
auto res = client.olmEncryptSplit(Event(json::object()),
{{"@receiver:example.com", {"device1", "device2"}}},
genRandomData(Crypto::encryptOlmMaxRandomSize() * 2));
REQUIRE(res["@receiver:example.com"]["device1"].originalJson().get().at("content").at("ciphertext").size() == 1);
REQUIRE(res["@receiver:example.com"]["device1"].originalJson().get().at("content").at("ciphertext").contains(receiver1.curve25519IdentityKey()));
REQUIRE(res["@receiver:example.com"]["device2"].originalJson().get().at("content").at("ciphertext").size() == 1);
REQUIRE(res["@receiver:example.com"]["device2"].originalJson().get().at("content").at("ciphertext").contains(receiver2.curve25519IdentityKey()));
}
TEST_CASE("tryDecryptEvents()", "[client][encryption]")
{
auto roomId = "!someroom:example.com";
auto room = makeRoom(
withRoomEncrypted(true)
| withRoomId(roomId)
);
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto plainText = makeEvent();
auto [encrypted, sessionId] = client.megOlmEncrypt(plainText, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto plainText2 = makeEvent();
// verify that we can decrypt events without sender_key or device_id
auto [encrypted2, sessionId2] = client.megOlmEncrypt(plainText2, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto j = encrypted2.originalJson().get();
j["content"].erase("sender_key");
j["content"].erase("device_id");
encrypted2 = Event(j);
auto events = EventList{
makeEvent(),
makeEvent(),
encrypted,
encrypted2,
};
withRoomTimeline(events)(room);
withRoom(room)(client);
auto nextClient = tryDecryptEvents(client);
auto decryptedEvent = nextClient.roomList.rooms[roomId].messages[encrypted.id()];
REQUIRE(decryptedEvent.encrypted());
REQUIRE(decryptedEvent.decrypted());
REQUIRE(decryptedEvent.type() == plainText.type());
REQUIRE(decryptedEvent.content() == plainText.content());
auto decryptedEvent2 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent2.encrypted());
REQUIRE(decryptedEvent2.decrypted());
REQUIRE(decryptedEvent2.type() == plainText2.type());
REQUIRE(decryptedEvent2.content() == plainText2.content());
REQUIRE(nextClient.roomList.rooms[roomId].undecryptedEvents
==
immer::map<std::string, immer::flex_vector<std::string>>{});
}
TEST_CASE("tryDecryptEvents() will decrypt to-device events and add group session key", "[client][encryption]")
{
auto r = createE2EESession();
auto sender = r.sender;
auto receiver = r.receiver1;
std::string roomId = "!someroom:example.com";
Event plainText = json{
{"type", "m.room.message"},
{"content", {
{"body", "mew"},
}},
{"room_id", roomId},
};
auto [encryptedRoomEvent, encryptedKeyEvent, unencryptedKeyEvent] = createMegOlmSession(sender, receiver, roomId, plainText);
auto sessionId = encryptedRoomEvent.originalJson().get()["content"]["session_id"].template get<std::string>();
SECTION("Process key event") {
auto resp = syncResponseFromToDevice(encryptedKeyEvent);
auto [next, _dontCareEffect] = ClientModel::update(receiver, ProcessResponseAction{resp});
REQUIRE(next.constCrypto().hasInboundGroupSession(KeyOfGroupSession{roomId, sessionId}));
REQUIRE(next.toDevice.size() == 0);
}
SECTION("Reject unencrypted key event") {
auto resp = syncResponseFromToDevice(unencryptedKeyEvent);
auto [next, _dontCareEffect] = ClientModel::update(receiver, ProcessResponseAction{resp});
REQUIRE(!next.constCrypto().hasInboundGroupSession(KeyOfGroupSession{roomId, sessionId}));
REQUIRE(next.toDevice.size() == 0);
}
}
TEST_CASE("tryDecryptEvents() will update room.undecryptedEvents", "[client][encryption]")
{
auto roomId = "!someroom:example.com";
auto room = makeRoom(
withRoomEncrypted(true)
| withRoomId(roomId)
);
auto client = makeClient(
withCrypto(makeCrypto())
| withRoom(room)
);
auto plainText = makeEvent();
auto [encrypted, sessionId] = client.megOlmEncrypt(plainText, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto plainText2 = makeEvent();
auto [encrypted2, sessionId2] = client.megOlmEncrypt(plainText2, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
auto j = encrypted2.originalJson().get();
// simulate an undecryptable event with a known session id
j["content"]["/////"];
encrypted2 = Event(j);
// simulate an undecryptable event with an unknown session id
auto plainText3 = makeEvent();
auto [encrypted3, sessionId3] = client.megOlmEncrypt(plainText3, roomId, 1719196953000,
genRandomData(EncryptMegOlmEventAction::maxRandomSize()));
j["content"]["session_id"] = "some-session-id";
encrypted3 = Event(j);
auto events = EventList{
makeEvent(),
makeEvent(),
encrypted,
encrypted2,
encrypted3,
};
withRoomTimeline(events)(room);
withRoom(room)(client);
auto nextClient = tryDecryptEvents(client);
auto decryptedEvent = nextClient.roomList.rooms[roomId].messages[encrypted.id()];
REQUIRE(decryptedEvent.encrypted());
REQUIRE(decryptedEvent.decrypted());
REQUIRE(decryptedEvent.type() == plainText.type());
REQUIRE(decryptedEvent.content() == plainText.content());
auto decryptedEvent2 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent2.encrypted());
REQUIRE(!decryptedEvent2.decrypted());
auto decryptedEvent3 = nextClient.roomList.rooms[roomId].messages[encrypted2.id()];
REQUIRE(decryptedEvent3.encrypted());
REQUIRE(!decryptedEvent3.decrypted());
REQUIRE(nextClient.roomList.rooms[roomId].undecryptedEvents
==
immer::map<std::string, immer::flex_vector<std::string>>{
{encrypted.originalJson().get()["content"]["session_id"], {encrypted2.id()}},
{encrypted3.originalJson().get()["content"]["session_id"], {encrypted3.id()}},
});
}
static const std::string password = "test";
static const std::string backupFile = R"(-----BEGIN MEGOLM SESSION DATA-----
AV6R43XMe68Ekf7jB4lYHLgAAAAAAAAAAAAAAAAAAAAAAA9CQG628kByLP7LApTtbvhnpgFnUUJ
+tRMkpw4zcGoTOJya9/lawfRWKjd8LZeuHKdNLkEhfIAE16Xmqv+uU8oEASPxjLDOMjsgBKLMRx
/iwUR7Aoe4wjuwEcdEEOW+T6ffjUz5LmEJcI14qZ1wXUPk1pnNmz+4nX8+a9UxgEpAN7vsmilwz
P4PXNubhvGsqtZpy44pP6Td0alYgwVfTXqWB1KokMjuQE+2q6/Jb6U/z5D5nv8ArcJL04cD0U6r
ySsRWI9Jra2OcKFQxgLeVpRAiP6/sRyl9k1n6eiSOfmGkZ+qnvOfZsQh7Wupgh6zRe8LNEtrYZh
FpSaCE+0U8I5hZJrWNBDFfHg+rtzB4BEk0YwpD3rVcWEsk8kKqHmEulEqIXckd1SbSG7y7H1ADB
7mjAY7qWetMizPXD+I8MDUnU1TF3Jv3CIZfZY7BHh2WukmiORlpN4H5s/Wwq2oCIk7qXhCHFvaF
uj+XytIz6TmkEVZfXK9zqUCwCU+VYSGl9GVAezO8CZ6aEJes95yYqRxfADdJG2Vtd0oXwrpR0xV
1GO+0JJ3xKicVX6U77iMtJbL1Lge32QvbAcv8o6mcaW28xeeYPrccMIRa3vLtuSDDqKC79S9bIP
2U5F+MHn+5dqMeXcG9K2hS91gsBQMAvX6
-----END MEGOLM SESSION DATA-----)";
TEST_CASE("import keys", "[client][encryption]")
{
auto u = makeMockSdkUtil(makeClient(
withCrypto(makeCrypto())
));
auto c = u.sdk.client();
SECTION("success")
{
c.importFromKeyBackupFile(backupFile, password)
.then([&u](auto stat) {
REQUIRE(stat);
// the example json in the spec is not a valid key
REQUIRE(stat.dataJson("imported") == 0);
u.io.stop();
});
u.io.run();
}
SECTION("failure")
{
c.importFromKeyBackupFile(backupFile, "wrongpass")
.then([&u](auto stat) {
REQUIRE(!stat);
REQUIRE(stat.dataStr("error") == DecryptKeyExportErrorCodes::HMAC_FAILED);
u.io.stop();
});
u.io.run();
}
}
+TEST_CASE("tryDecryptEvents() rejects Olm-encrypted to-device event from unknown device", "[client][encryption][olm]")
+{
+ // Bob: the current user, with crypto enabled
+ auto bobCrypto = makeCrypto();
+ bobCrypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
+ auto bobOneTimeKeys = bobCrypto.unpublishedOneTimeKeys();
+ bobCrypto.markOneTimeKeysAsPublished();
+
+ auto bobClient = makeClient(withCrypto(bobCrypto));
+ bobClient.userId = "@bob:example.com";
+ bobClient.deviceId = "bobdevice";
+
+ auto bobIdentityKey = bobCrypto.curve25519IdentityKey();
+ auto bobOneTimeKey = std::string{};
+ for (auto [id, key] : bobOneTimeKeys[CryptoConstants::curve25519].items()) {
+ bobOneTimeKey = key;
+ }
+
+ // Alice: a device NOT known to Bob (not in Bob's device list)
+ auto aliceCrypto = makeCrypto();
+ auto aliceIdentityKey = aliceCrypto.curve25519IdentityKey();
+ auto aliceEdKey = aliceCrypto.ed25519IdentityKey();
+
+ // Alice creates an outbound session to Bob (using Bob's published one-time key)
+ aliceCrypto.createOutboundSessionWithRandom(
+ genRandomData(Crypto::createOutboundSessionRandomSize()),
+ bobIdentityKey, bobOneTimeKey);
+
+ // Alice encrypts an m.room_key event for Bob
+ auto plainJson = json{
+ {"content", {
+ {"algorithm", CryptoConstants::megOlmAlgo},
+ {"room_id", "!someroom:example.com"},
+ {"session_id", "somesessionid"},
+ {"session_key", "somesessionkey"},
+ }},
+ {"keys", {
+ {CryptoConstants::ed25519, aliceEdKey},
+ }},
+ {"sender", "@alice:example.com"},
+ {"recipient", "@bob:example.com"},
+ {"recipient_keys", {
+ {CryptoConstants::ed25519, bobCrypto.ed25519IdentityKey()},
+ }},
+ {"type", "m.room_key"},
+ };
+
+ auto encryptedCiphertext = aliceCrypto.encryptOlmWithRandom(
+ genRandomData(Crypto::encryptOlmMaxRandomSize()),
+ plainJson, bobIdentityKey);
+
+ auto toDeviceJson = json{
+ {"sender", "@alice:example.com"},
+ {"type", "m.room.encrypted"},
+ {"content", {
+ {"algorithm", CryptoConstants::olmAlgo},
+ {"sender_key", aliceIdentityKey},
+ {"ciphertext", encryptedCiphertext},
+ }},
+ };
+
+ auto toDeviceEvent = Event(toDeviceJson);
+
+ // Feed the to-device event to Bob's client via a sync response
+ auto resp = syncResponseFromToDevice(toDeviceEvent);
+ auto [next, _] = ClientModel::update(bobClient, ProcessResponseAction{resp});
+
+ // The event should be marked as NOT decrypted because Alice's device
+ // is not in Bob's device list
+ REQUIRE(next.toDevice.size() == 1);
+ auto processedEvent = next.toDevice[0];
+ REQUIRE(processedEvent.encrypted());
+ REQUIRE(!processedEvent.decrypted());
+
+ // Verify the error indicates the device key is unknown
+ auto decryptedContent = processedEvent.content().get();
+ REQUIRE(decryptedContent.contains("moe.kazv.mxc.error"));
+ REQUIRE(decryptedContent["moe.kazv.mxc.error"] == "device key unknown");
+ REQUIRE(decryptedContent["moe.kazv.mxc.errcode"] == "MOE.KAZV.MXC_DEVICE_KEY_UNKNOWN");
+}
+
+TEST_CASE("decryptEvent() handles Olm-encrypted to-device event", "[client][encryption][olm]")
+{
+ auto s = OlmFirstTimeDecryptTestSetup();
+
+ auto plainText = json{
+ {"content", json::object()},
+ {"type", "moe.kazv.mxc.xxx"},
+ };
+
+ SECTION("good keys, with sender_device_keys") {
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ true);
+ auto ev = decryptEvent(s.clientNoDevice, encrypted);
+ REQUIRE(ev.decrypted());
+ // Then, after the decryption, device info should be added to the tracker
+ auto devOpt = s.clientNoDevice.deviceLists.findByCurve25519Key(
+ ev.sender(),
+ ev.originalJson().get().at("/content/sender_key"_json_pointer).template get<std::string>()
+ );
+ REQUIRE(devOpt.has_value());
+ }
+
+ SECTION("good keys, without sender_device_keys") {
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
+ auto ev = decryptEvent(s.clientWithDevice, encrypted);
+ REQUIRE(ev.decrypted());
+ }
+
+ SECTION("sender mismatch") {
+ s.clientA.userId = "@bad:example.com";
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
+ s.clientA.userId = s.sender;
+ withEventKV("/sender"_json_pointer, s.sender)(encrypted);
+ auto ev = decryptEvent(s.clientWithDevice, encrypted);
+ REQUIRE(!ev.decrypted());
+ REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_SENDER");
+ }
+
+ SECTION("recipient mismatch") {
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ false);
+ auto clientRec = s.clientWithDevice;
+ clientRec.userId = "@bad:example.com";
+ auto ev = decryptEvent(clientRec, encrypted);
+ REQUIRE(!ev.decrypted());
+ REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT");
+ }
+
+ SECTION("recipient keys mismatch") {
+ using namespace CryptoConstants;
+ auto encJson = json::object();
+ encJson["content"] = json{
+ {"algorithm", CryptoConstants::olmAlgo},
+ {"ciphertext", json::object()},
+ {"sender_key", s.clientA.constCrypto().curve25519IdentityKey()},
+ };
+ encJson["type"] = "m.room.encrypted";
+ auto c = makeCrypto();
+ auto toEncrypt = plainText;
+ toEncrypt["sender"] = s.sender;
+ toEncrypt["recipient"] = s.recipient;
+ toEncrypt["recipient_keys"] = json{
+ {ed25519, c.ed25519IdentityKey()},
+ };
+ toEncrypt["keys"] = json{
+ {ed25519, s.clientA.constCrypto().ed25519IdentityKey()},
+ };
+ encJson["content"]["ciphertext"] = s.clientA.withCrypto([&toEncrypt, &s](auto &c) {
+ auto key = s.clientNoDevice.constCrypto().curve25519IdentityKey();
+ return c.encryptOlmWithRandom(
+ genRandomData(c.encryptOlmRandomSize(key)),
+ toEncrypt,
+ key
+ );
+ });
+ encJson["sender"] = s.sender;
+ auto encrypted = Event(encJson);
+ auto ev = decryptEvent(s.clientWithDevice, encrypted);
+ REQUIRE(!ev.decrypted());
+ REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT_KEYS");
+ }
+
+ SECTION("bad sender ed25519 keys") {
+ using namespace CryptoConstants;
+ auto encJson = json::object();
+ encJson["content"] = json{
+ {"algorithm", CryptoConstants::olmAlgo},
+ {"ciphertext", json::object()},
+ {"sender_key", s.clientA.constCrypto().curve25519IdentityKey()},
+ };
+ encJson["type"] = "m.room.encrypted";
+ auto c = makeCrypto();
+ auto toEncrypt = plainText;
+ toEncrypt["sender"] = s.sender;
+ toEncrypt["recipient"] = s.recipient;
+ toEncrypt["recipient_keys"] = json{
+ {ed25519, s.clientNoDevice.constCrypto().ed25519IdentityKey()},
+ };
+ toEncrypt["keys"] = json{
+ {ed25519, c.ed25519IdentityKey()},
+ };
+ encJson["content"]["ciphertext"] = s.clientA.withCrypto([&toEncrypt, &s](auto &c) {
+ auto key = s.clientNoDevice.constCrypto().curve25519IdentityKey();
+ return c.encryptOlmWithRandom(
+ genRandomData(c.encryptOlmRandomSize(key)),
+ toEncrypt,
+ key
+ );
+ });
+ encJson["sender"] = s.sender;
+ auto encrypted = Event(encJson);
+ auto ev = decryptEvent(s.clientWithDevice, encrypted);
+ REQUIRE(!ev.decrypted());
+ REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_SENDER_KEYS");
+ }
+
+ SECTION("if verify event fails, device info is not added to tracker") {
+ auto encrypted = s.encrypt(plainText, /* attachSenderDeviceKeys = */ true);
+ auto clientRec = s.clientNoDevice;
+ clientRec.userId = "@bad:example.com";
+ auto ev = decryptEvent(clientRec, encrypted);
+ REQUIRE(!ev.decrypted());
+ REQUIRE(ev.content().get().at("moe.kazv.mxc.errcode") == "MOE.KAZV.MXC_BAD_RECIPIENT");
+ REQUIRE(s.clientNoDevice.deviceLists.deviceLists.empty());
+ }
+}
+
TEST_CASE("EnsureKeysFromDevicesAction", "[client][encryption]")
{
auto client = makeClient(
withCrypto(makeCrypto())
);
auto u1Client = makeClient(withCrypto(makeCrypto()));
u1Client.userId = "@user:example.com";
u1Client.deviceId = "U1Device1";
auto [next, _] = updateClient(client, EnsureKeysFromDevicesAction{
{{"@user:example.com", {"U1Device1"}}},
});
assert1Job(next);
auto job = next.nextJobs.front();
next.nextJobs = {};
REQUIRE(job.jobId() == "QueryKeys");
auto body = json::parse(std::get<BytesBody>(job.requestBody()));
REQUIRE(body.at("device_keys") == json::object({
{"@user:example.com", json::array({"U1Device1"})},
}));
auto u = makeMockSdkUtil(next);
auto md = u.getMockDispatcher(passDown<ProcessResponseAction>());
auto ctx = getMockContext(u.ph, md);
WHEN("good response") {
auto resp = makeResponse("QueryKeys", withResponseJsonBody(json::object({
{"device_keys", {
{"@user:example.com", {
{"U1Device1", makeDeviceInfo(u1Client)},
}},
}},
})) | withResponseDataKV("deviceKeys", job.dataJson("deviceKeys")));
ctx.dispatch(ProcessResponseAction{resp})
.then([&u](const EffectStatus &s) {
REQUIRE(s.success());
REQUIRE(s.dataJson("unsatisfied") == json::object({
{"users", json::array()},
{"devices", json::array()},
}));
u.io.stop();
});
u.io.run();
}
WHEN("missing device") {
auto resp = makeResponse("QueryKeys", withResponseJsonBody(json::object({
{"device_keys", {
{"@user:example.com", json::object()},
}},
})) | withResponseDataKV("deviceKeys", job.dataJson("deviceKeys")));
ctx.dispatch(ProcessResponseAction{resp})
.then([&u](const EffectStatus &s) {
REQUIRE(s.success());
REQUIRE(s.dataJson("unsatisfied") == json::object({
{"users", json::array()},
{"devices", json::array({{"@user:example.com", "U1Device1"}})},
}));
u.io.stop();
});
u.io.run();
}
}
diff --git a/src/tests/crypto-test.cpp b/src/tests/crypto-test.cpp
index 541126a..2bfd84f 100644
--- a/src/tests/crypto-test.cpp
+++ b/src/tests/crypto-test.cpp
@@ -1,730 +1,812 @@
/*
* This file is part of libkazv.
* SPDX-FileCopyrightText: 2020-2024 tusooa <tusooa@kazv.moe>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#include <libkazv-config.hpp>
#include <catch2/catch_all.hpp>
#include <sstream>
#include <boost/archive/text_iarchive.hpp>
#include <boost/archive/text_oarchive.hpp>
#include <crypto/crypto.hpp>
#include <aes-256-ctr.hpp>
#include <base64.hpp>
#include <sha256.hpp>
#include "crypto/crypto-test-resource.hpp"
using namespace Kazv;
using namespace Kazv::CryptoConstants;
using IAr = boost::archive::text_iarchive;
using OAr = boost::archive::text_oarchive;
static const auto resource = cryptoDumpResource();
json makeEncryptedJson(json ciphertext, std::string senderKey)
{
return json{{"content", {
{"algorithm", olmAlgo},
{"ciphertext", std::move(ciphertext)},
{"sender_key", std::move(senderKey)},
}}};
}
template<class T>
static void serializeDup(const T &in, T &out)
{
std::stringstream stream;
{
auto ar = OAr(stream);
ar << in;
}
{
auto ar = IAr(stream);
ar >> out;
}
}
static bool doesDecryptTo(Crypto &crypto, const nlohmann::json &encryptedEvent, const nlohmann::json &plainText)
{
auto res = crypto.decrypt(encryptedEvent);
if (!res) {
return false;
}
auto decrypted = json::parse(res.value());
return decrypted == plainText;
}
TEST_CASE("Crypto constructors", "[crypto]")
{
Crypto crypto;
REQUIRE(!crypto.valid());
Crypto crypto2(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
REQUIRE(crypto2.valid());
}
TEST_CASE("Crypto conversion from libolm to vodozemac", "[crypto]")
{
Crypto a;
a.loadJson(resource["a"]);
Crypto b;
b.loadJson(resource["b"]);
// encrypt with existing sessions
auto aIdKey = a.curve25519IdentityKey();
auto origJson = json{{"test", "mew"}};
{
auto encryptedMsg = b.encryptOlmWithRandom(genRandomData(Crypto::encryptOlmMaxRandomSize()), origJson, aIdKey);
auto decryptedOpt = a.decrypt(makeEncryptedJson(encryptedMsg, b.curve25519IdentityKey()));
REQUIRE(decryptedOpt);
}
// encrypt/decrypt with new sessions
auto k = a.unpublishedOneTimeKeys();
a.markOneTimeKeysAsPublished();
auto oneTimeKey = std::string{};
for (auto [id, key] : k[curve25519].items()) {
oneTimeKey = key;
}
{
Crypto c(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
c.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), aIdKey, oneTimeKey);
auto encryptedMsg = c.encryptOlmWithRandom(genRandomData(Crypto::encryptOlmMaxRandomSize()), origJson, aIdKey);
auto decryptedOpt = a.decrypt(makeEncryptedJson(encryptedMsg, c.curve25519IdentityKey()));
REQUIRE(decryptedOpt.reason() == "");
REQUIRE(decryptedOpt);
REQUIRE(decryptedOpt.value() == origJson.dump());
}
}
TEST_CASE("Crypto should be copyable", "[crypto]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto oneTimeKeys = crypto.unpublishedOneTimeKeys();
Crypto cryptoClone(crypto);
REQUIRE(crypto.ed25519IdentityKey() == cryptoClone.ed25519IdentityKey());
REQUIRE(crypto.curve25519IdentityKey() == cryptoClone.curve25519IdentityKey());
auto oneTimeKeys2 = cryptoClone.unpublishedOneTimeKeys();
REQUIRE(oneTimeKeys == oneTimeKeys2);
REQUIRE(crypto.numUnpublishedOneTimeKeys() == cryptoClone.numUnpublishedOneTimeKeys());
}
TEST_CASE("Crypto should be serializable", "[crypto]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
auto oneTimeKeys = crypto.unpublishedOneTimeKeys();
Crypto cryptoClone;
serializeDup(crypto, cryptoClone);
REQUIRE(crypto.ed25519IdentityKey() == cryptoClone.ed25519IdentityKey());
REQUIRE(crypto.curve25519IdentityKey() == cryptoClone.curve25519IdentityKey());
auto oneTimeKeys2 = cryptoClone.unpublishedOneTimeKeys();
REQUIRE(oneTimeKeys == oneTimeKeys2);
REQUIRE(crypto.numUnpublishedOneTimeKeys() == cryptoClone.numUnpublishedOneTimeKeys());
}
TEST_CASE("Invalid Crypto should be serializable", "[crypto]")
{
Crypto crypto;
Crypto cryptoClone;
serializeDup(crypto, cryptoClone);
REQUIRE(!cryptoClone.valid());
}
TEST_CASE("Serialize Crypto with an OutboundGroupSession", "[crypto]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
std::string roomId = "!example:example.org";
auto desc = MegOlmSessionRotateDesc{500000 /* ms */, 100 /* messages */};
crypto.rotateMegOlmSessionWithRandom(genRandomData(Crypto::rotateMegOlmSessionRandomSize()), currentTimeMs(), roomId);
Crypto cryptoClone;
serializeDup(crypto, cryptoClone);
REQUIRE(! cryptoClone.rotateMegOlmSessionWithRandomIfNeeded(
genRandomData(Crypto::rotateMegOlmSessionRandomSize()), currentTimeMs(),
roomId, desc).has_value());
}
+TEST_CASE("Should handle unknown device sending Olm pre-key message", "[crypto][olm]")
+{
+ // Bob: the current user, generates and publishes one-time keys
+ Crypto bob(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
+ bob.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
+ auto bobKeys = bob.unpublishedOneTimeKeys();
+ bob.markOneTimeKeysAsPublished();
+
+ auto bobIdentityKey = bob.curve25519IdentityKey();
+ auto bobOneTimeKey = std::string{};
+ for (auto [id, key] : bobKeys[curve25519].items()) {
+ bobOneTimeKey = key;
+ }
+
+ // Alice: a device that Bob does not know about
+ // (Bob has never queried her keys or sent her a message)
+ Crypto alice(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
+ auto aliceIdentityKey = alice.curve25519IdentityKey();
+
+ // Alice creates an outbound session to Bob and encrypts a message
+ alice.createOutboundSessionWithRandom(
+ genRandomData(Crypto::createOutboundSessionRandomSize()),
+ bobIdentityKey, bobOneTimeKey);
+
+ auto plainText = json{{"test", "mew"}};
+ auto encryptedMsg = alice.encryptOlmWithRandom(
+ genRandomData(Crypto::encryptOlmMaxRandomSize()),
+ plainText, bobIdentityKey);
+
+ // Bob receives the Olm message from Alice
+ auto encJson = makeEncryptedJson(encryptedMsg, aliceIdentityKey);
+
+ auto decryptedOpt = bob.decrypt(encJson);
+ REQUIRE(decryptedOpt);
+
+ auto decryptedJson = json::parse(decryptedOpt.value());
+ REQUIRE(decryptedJson == plainText);
+}
+
+TEST_CASE("Should reuse existing inbound session to encrypt after receiving from unknown device", "[crypto][olm]")
+{
+ Crypto a(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
+ Crypto b(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
+
+ a.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
+ auto k = a.unpublishedOneTimeKeys();
+ a.markOneTimeKeysAsPublished();
+
+ auto oneTimeKey = std::string{};
+ for (auto [id, key] : k[curve25519].items()) {
+ oneTimeKey = key;
+ }
+
+ auto aIdKey = a.curve25519IdentityKey();
+
+ // b (unknown to a) creates outbound session and sends a pre-key message
+ b.createOutboundSessionWithRandom(
+ genRandomData(Crypto::createOutboundSessionRandomSize()), aIdKey, oneTimeKey);
+
+ auto origJson = json{{"hello", "world"}};
+ auto encryptedMsg = b.encryptOlmWithRandom(
+ genRandomData(Crypto::encryptOlmMaxRandomSize()), origJson, aIdKey);
+
+ auto encJson = makeEncryptedJson(encryptedMsg, b.curve25519IdentityKey());
+
+ // a decrypts - this creates an inbound session from the pre-key message
+ auto decryptedOpt = a.decrypt(encJson);
+ REQUIRE(decryptedOpt);
+ REQUIRE(json::parse(decryptedOpt.value()) == origJson);
+
+ // Now a can use the inbound session to encrypt back to b
+ using StrMap = immer::map<std::string, std::string>;
+ auto devMap = immer::map<std::string, StrMap>()
+ .set("b", StrMap().set("dev", b.curve25519IdentityKey()));
+
+ auto devices = a.devicesMissingOutboundSessionKey(devMap);
+ // The inbound session should be usable as an outbound session as well,
+ // so no device should be missing an olm session
+ auto expected = immer::map<std::string, immer::flex_vector<std::string>>();
+ REQUIRE(devices == expected);
+}
+
TEST_CASE("Generating and publishing keys should work", "[crypto]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
REQUIRE(crypto.numUnpublishedOneTimeKeys() == 1);
crypto.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
REQUIRE(crypto.numUnpublishedOneTimeKeys() == 2);
crypto.markOneTimeKeysAsPublished();
REQUIRE(crypto.numUnpublishedOneTimeKeys() == 0);
}
TEST_CASE("Should reuse existing inbound session to encrypt", "[crypto]")
{
Crypto a(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
Crypto b(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
a.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
// Get A publish the key and send to B
auto k = a.unpublishedOneTimeKeys();
a.markOneTimeKeysAsPublished();
auto oneTimeKey = std::string{};
for (auto [id, key] : k[curve25519].items()) {
oneTimeKey = key;
}
auto aIdKey = a.curve25519IdentityKey();
b.createOutboundSessionWithRandom(genRandomData(Crypto::createOutboundSessionRandomSize()), aIdKey, oneTimeKey);
auto origJson = json{{"test", "mew"}};
auto encryptedMsg = b.encryptOlmWithRandom(genRandomData(Crypto::encryptOlmMaxRandomSize()), origJson, aIdKey);
auto encJson = json{
{"content",
{
{"algorithm", olmAlgo},
{"ciphertext", encryptedMsg},
{"sender_key", b.curve25519IdentityKey()}
}
}
};
auto decryptedOpt = a.decrypt(encJson);
REQUIRE(decryptedOpt);
auto decryptedJson = json::parse(decryptedOpt.value());
REQUIRE(decryptedJson == origJson);
using StrMap = immer::map<std::string, std::string>;
auto devMap = immer::map<std::string, StrMap>()
.set("b", StrMap().set("dev", b.curve25519IdentityKey()));
Crypto aClone{a};
auto devices = a.devicesMissingOutboundSessionKey(devMap);
auto devicesAClone = aClone.devicesMissingOutboundSessionKey(devMap);
// No device should be missing an olm session, as A has received an
// inbound olm session before.
auto expected = immer::map<std::string, immer::flex_vector<std::string>>();
REQUIRE(devices == expected);
REQUIRE(devicesAClone == expected);
}
TEST_CASE("Encrypt and decrypt AES-256-CTR", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto desc2 = desc;
std::string original = "test for aes-256-ctr";
auto encrypted = desc.processInPlace(original);
auto decrypted = desc2.processInPlace(encrypted);
REQUIRE(original == decrypted);
}
TEST_CASE("Encrypt and decrypt AES-256-CTR with any sequence type", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto desc2 = desc;
std::string oStr = "test for aes-256-ctr";
std::vector<unsigned char> original(oStr.begin(), oStr.end());
auto encrypted = desc.processInPlace(original);
auto decrypted = desc2.processInPlace(encrypted);
REQUIRE(original == decrypted);
}
TEST_CASE("Encrypt and decrypt AES-256-CTR in a non-destructive way", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
std::string original = "test for aes-256-ctr";
auto [next, encrypted] = desc.process(original);
auto [next2, encrypted2] = desc.process(original);
REQUIRE(encrypted == encrypted2);
auto [next3, decrypted] = desc.process(encrypted);
REQUIRE(original == decrypted);
}
TEST_CASE("Encrypt and decrypt AES-256-CTR in batches", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
std::string original = "test for aes-256-ctr";
std::string orig2 = "another test string...";
auto [next, encrypted] = desc.process(original);
auto [next2, encrypted2] = next.process(orig2);
auto [next3, decrypted] = desc.process(encrypted + encrypted2);
REQUIRE(original + orig2 == decrypted);
}
TEST_CASE("AES-256-CTR should be movable", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto desc2 = std::move(desc);
REQUIRE(desc2.valid());
REQUIRE(! desc.valid());
std::string original = "test for aes-256-ctr";
std::string encrypted;
// Can be moved from itself
desc2 = std::move(desc2);
REQUIRE(desc2.valid());
std::tie(desc2, encrypted) = std::move(desc2).process(original);
REQUIRE(desc2.valid());
}
TEST_CASE("AES-256-CTR should be copyable", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto desc2 = desc;
REQUIRE(desc2.valid());
REQUIRE(desc.valid());
desc = AES256CTRDesc::fromRandom(RandomData{});
std::string original = "test for aes-256-ctr";
std::string encrypted;
REQUIRE(desc2.valid());
std::tie(desc2, encrypted) = desc2.process(original);
REQUIRE(desc2.valid());
}
TEST_CASE("Construct AES-256-CTR from known key and iv", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto desc2 = AES256CTRDesc(desc.key(), desc.iv());
REQUIRE(desc2.valid());
REQUIRE(desc.key() == desc2.key());
REQUIRE(desc.iv() == desc2.iv());
}
TEST_CASE("AES-256-CTR validity check", "[crypto][aes256ctr]")
{
SECTION("Not enough random, should reject") {
ByteArray random = genRandom(AES256CTRDesc::randomSize - 1);
auto desc = AES256CTRDesc::fromRandom(random);
REQUIRE(! desc.valid());
}
SECTION("More than enough random, should accept") {
ByteArray random = genRandom(AES256CTRDesc::randomSize + 1);
auto desc = AES256CTRDesc::fromRandom(random);
REQUIRE(desc.valid());
}
}
TEST_CASE("AES256CTRDesc::fromRandom() should leave the lower 8 bytes as 0 for the counter", "[crypto][aes256ctr]")
{
auto r = genRandom(AES256CTRDesc::randomSize);
auto desc = AES256CTRDesc::fromRandom(r);
auto iv = decodeBase64(desc.iv());
REQUIRE(iv.size() == AES256CTRDesc::ivSize);
REQUIRE(std::all_of(iv.begin() + AES256CTRDesc::ivSizeInit, iv.end(), [](auto ch) { return ch == 0; }));
auto desc2 = desc;
std::string original = "test for aes-256-ctr";
auto encrypted = desc.processInPlace(original);
auto decrypted = desc2.processInPlace(encrypted);
REQUIRE(original == decrypted);
}
TEST_CASE("Base64 encoder and decoder", "[crypto][base64]")
{
std::string orig = "The Quick Brown Fox Jumps Over the Lazy Dog";
// no padding
std::string expected = "VGhlIFF1aWNrIEJyb3duIEZveCBKdW1wcyBPdmVyIHRoZSBMYXp5IERvZw";
std::string encoded = encodeBase64(orig);
REQUIRE(encoded == expected);
std::string decoded = decodeBase64(encoded);
REQUIRE(decoded == orig);
}
TEST_CASE("Urlsafe base64 encoder and decoder", "[crypto][base64]")
{
std::string orig = "The Quick Brown Fox Jumps Over the Lazy Dog";
// no padding
std::string expected = "VGhlIFF1aWNrIEJyb3duIEZveCBKdW1wcyBPdmVyIHRoZSBMYXp5IERvZw";
std::string encoded = encodeBase64(orig, Base64Opts::urlSafe);
REQUIRE(encoded == expected);
std::string decoded = decodeBase64(encoded, Base64Opts::urlSafe);
REQUIRE(decoded == orig);
}
TEST_CASE("Base64 encoder and decoder, example from Matrix specs", "[crypto][base64]")
{
std::string orig = "JGLn/yafz74HB2AbPLYJWIVGnKAtqECOBf11yyXac2Y";
std::string decoded = decodeBase64(orig);
std::string encoded = encodeBase64(decoded);
REQUIRE(encoded == orig);
}
TEST_CASE("Urlsafe base64 encoder and decoder, example from Matrix specs", "[crypto][base64]")
{
std::string orig = "JGLn_yafz74HB2AbPLYJWIVGnKAtqECOBf11yyXac2Y";
std::string decoded = decodeBase64(orig, Base64Opts::urlSafe);
std::string encoded = encodeBase64(decoded, Base64Opts::urlSafe);
REQUIRE(encoded == orig);
}
TEST_CASE("SHA256 hashing support", "[crypto][sha256]")
{
auto hash = SHA256Desc{};
auto message1 = std::string("12345678910");
hash.processInPlace(message1);
auto res = hash.get();
auto expected = std::string("Y2QCZISah8kDVhKdmeoWXjeqX6vB/qRpBt8afKUNtJI");
REQUIRE(res == expected);
}
TEST_CASE("SHA256 hashing streaming", "[crypto][sha256]")
{
auto hash = SHA256Desc{};
auto message1 = std::string("12345678910");
auto message2 = std::string("abcdefghijklmn");
hash.processInPlace(message1);
hash.processInPlace(message2);
auto res = hash.get();
auto hash2 = SHA256Desc{};
hash2.processInPlace(message1 + message2);
auto expected = hash2.get();
REQUIRE(res == expected);
}
TEST_CASE("SHA256Desc should be copyable", "[crypto][sha256]")
{
auto hash = SHA256Desc{};
auto message1 = std::string("12345678910");
auto message2 = std::string("abcdefghijklmn");
hash.processInPlace(message1);
auto hash2 = hash;
hash.processInPlace(message2);
hash2.processInPlace(message2);
auto res = hash.get();
auto res2 = hash2.get();
REQUIRE(res == res2);
}
TEST_CASE("SHA256Desc should be self-copyable and -movable", "[crypto][sha256]")
{
auto hash = SHA256Desc{};
auto message1 = std::string("12345678910");
hash = hash.process(message1);
auto message2 = std::string("abcdefghijklmn");
hash = std::move(hash).process(message2);
auto hash2 = SHA256Desc{};
hash2.processInPlace(message1 + message2);
auto res = hash.get();
auto res2 = hash2.get();
REQUIRE(res == res2);
}
TEST_CASE("SHA256 should accept any range type", "[crypto][sha256]")
{
std::string msg = "12345678910";
std::vector<char> arr(msg.begin(), msg.end());
auto hash = SHA256Desc{};
auto res1 = hash.process(arr).get();
auto res2 = std::move(hash).process(arr).get();
// after moving, hash is no longer valid, reset it here
hash = SHA256Desc{};
hash.processInPlace(arr);
auto res3 = hash.get();
hash = SHA256Desc{};
auto reference = hash.process(msg).get();
REQUIRE(res1 == res2);
REQUIRE(res1 == res3);
REQUIRE(res1 == reference);
}
TEST_CASE("Crypto::createInboundGroupSession should not allow session key replacement attacks", "[crypto][group-session]")
{
std::string roomId = "!someroom:example.com";
Crypto a(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
// creating a outbound group session will add it to inbound group sessions
auto initialKey = a.rotateMegOlmSessionWithRandom(
genRandomData(Crypto::rotateMegOlmSessionRandomSize()),
0,
roomId
);
// encrypt to get the session id
auto plainText = R"({
"content": {},
"type": "m.room.message",
"room_id": "!someroom:example.com"
})"_json;
auto encryptedContent = a.encryptMegOlm(plainText);
auto encryptedEvent = json{
{"event_id", "$some-event-id1"},
{"origin_server_ts", 1719196953000},
{"content", encryptedContent},
{"type", "m.room.encrypted"},
{"room_id", roomId},
};
// message index is currently at 1
auto currentKey = a.outboundGroupSessionCurrentKey(roomId);
auto sessionId = encryptedContent["session_id"].template get<std::string>();
auto plainText2 = R"({
"content": {"a": "b"},
"type": "m.room.message",
"room_id": "!someroom:example.com"
})"_json;
auto encryptedContent2 = a.encryptMegOlm(plainText2);
Crypto b(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
Crypto malice(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
auto encryptedEvent2 = json{
{"event_id", "$some-event-id"},
{"origin_server_ts", 1719196953000},
{"content", encryptedContent2},
{"type", "m.room.encrypted"},
{"room_id", roomId},
};
auto keyOfSession = KeyOfGroupSession{roomId, sessionId};
auto created = b.createInboundGroupSession(keyOfSession, currentKey, a.ed25519IdentityKey());
REQUIRE(created);
REQUIRE(doesDecryptTo(b, encryptedEvent2, plainText2));
SECTION("it should reject if the identity key is not the same") {
auto updated = b.createInboundGroupSession(keyOfSession, initialKey, malice.ed25519IdentityKey());
REQUIRE(!updated);
}
SECTION("it should reject if the given session key does not belong to the same session") {
auto anotherSessionKey = a.rotateMegOlmSessionWithRandom(
genRandomData(Crypto::rotateMegOlmSessionRandomSize()),
0,
roomId
);
auto updated = b.createInboundGroupSession(keyOfSession, anotherSessionKey, a.ed25519IdentityKey());
REQUIRE(!updated);
}
SECTION("it should prevent replay attack if merging with itself") {
auto updated = b.createInboundGroupSession(keyOfSession, currentKey, a.ed25519IdentityKey());
REQUIRE(updated);
REQUIRE(doesDecryptTo(b, encryptedEvent2, plainText2));
auto replay = encryptedEvent2;
replay["event_id"] = "$some-other-id";
REQUIRE(!b.decrypt(replay).has_value());
}
SECTION("it should prevent replay attack if session is updated") {
auto updated = b.createInboundGroupSession(keyOfSession, initialKey, a.ed25519IdentityKey());
REQUIRE(updated);
REQUIRE(doesDecryptTo(b, encryptedEvent2, plainText2));
REQUIRE(doesDecryptTo(b, encryptedEvent, plainText));
auto replay = encryptedEvent2;
replay["event_id"] = "$some-other-id";
REQUIRE(!b.decrypt(replay).has_value());
}
}
TEST_CASE("Crypto::hasInboundGroupSession", "[crypto][group-session]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
// creating a outbound group session will add it to inbound group sessions
crypto.rotateMegOlmSessionWithRandom(
genRandomData(Crypto::rotateMegOlmSessionRandomSize()),
0,
"!someroom:example.com"
);
// encrypt to get the session id
auto encryptedContent = crypto.encryptMegOlm(R"({
"content": {},
"type": "m.room.message",
"room_id": "!someroom:example.com"
})"_json);
auto sessionId = encryptedContent["session_id"].template get<std::string>();
REQUIRE(crypto.hasInboundGroupSession(KeyOfGroupSession{
"!someroom:example.com",
sessionId,
}));
REQUIRE(!crypto.hasInboundGroupSession(KeyOfGroupSession{
"!someroom:example.com",
sessionId + "something something",
}));
}
TEST_CASE("Crypto::decrypt(MegOlmEvent)", "[crypto][group-session]")
{
Crypto crypto(RandomTag{}, genRandomData(Crypto::constructRandomSize()));
crypto.rotateMegOlmSessionWithRandom(
genRandomData(Crypto::rotateMegOlmSessionRandomSize()),
0,
"!someroom:example.com"
);
auto plainText = R"({
"content": {"body": "something"},
"type": "m.room.message",
"room_id": "!someroom:example.com"
})"_json;
// encrypt to get the session id
auto encryptedContent = crypto.encryptMegOlm(plainText);
auto encryptedEvent = json{
{"event_id", "$some-event-id"},
{"origin_server_ts", 1719196953000},
{"content", encryptedContent},
{"type", "m.room.encrypted"},
{"room_id", "!someroom:example.com"},
};
REQUIRE(doesDecryptTo(crypto, encryptedEvent, plainText));
}
TEST_CASE("KeyOfGroupSession serialization", "[crypto][group-session]")
{
{
auto k = KeyOfGroupSession{"!someroom:example.com", "some-session-id"};
json j = k;
REQUIRE(j == json{
{"roomId", "!someroom:example.com"},
{"sessionId", "some-session-id"},
});
}
{
json j{
{"roomId", "!someroom:example.com"},
{"senderKey", "some-key"}, // legacy version
{"sessionId", "some-session-id"},
};
auto k = j.template get<KeyOfGroupSession>();
REQUIRE(k == KeyOfGroupSession{"!someroom:example.com", "some-session-id"});
}
}
diff --git a/src/tests/encryption-test-utils.hpp b/src/tests/encryption-test-utils.hpp
new file mode 100644
index 0000000..8017cf1
--- /dev/null
+++ b/src/tests/encryption-test-utils.hpp
@@ -0,0 +1,101 @@
+/*
+ * This file is part of libkazv.
+ * SPDX-FileCopyrightText: 2026 tusooa <tusooa@kazv.moe>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+#pragma once
+#include <libkazv-config.hpp>
+#include "factory.hpp"
+#include <catch2/catch_test_macros.hpp>
+
+struct OlmFirstTimeDecryptTestSetup
+{
+ inline OlmFirstTimeDecryptTestSetup()
+ : sender("@example:example.com")
+ , recipient("@foo:example.com")
+ , senderDeviceId("device1")
+ , recipientDeviceId("device2")
+ {
+ using namespace Kazv;
+ using namespace Kazv::Factory;
+
+ auto cryptoA = makeCrypto();
+ auto cryptoB = makeCrypto();
+ // A creates an outbound olm session with B
+ cryptoB.genOneTimeKeysWithRandom(genRandomData(Crypto::genOneTimeKeysRandomSize(1)), 1);
+ auto otk = *(cryptoB.unpublishedOneTimeKeys()[CryptoConstants::curve25519].begin());
+ cryptoB.markOneTimeKeysAsPublished();
+ cryptoA.createOutboundSessionWithRandom(
+ genRandomData(Crypto::createOutboundSessionRandomSize()),
+ cryptoB.curve25519IdentityKey(), otk);
+
+ infoA = DeviceKeyInfo{
+ senderDeviceId,
+ cryptoA.ed25519IdentityKey(),
+ cryptoA.curve25519IdentityKey(),
+ "Device-1",
+ DeviceTrustLevel::Verified,
+ };
+ infoB = DeviceKeyInfo{
+ recipientDeviceId,
+ cryptoB.ed25519IdentityKey(),
+ cryptoB.curve25519IdentityKey(),
+ "Device-2",
+ DeviceTrustLevel::Verified,
+ };
+
+ clientA = makeClient(
+ withAttr(&ClientModel::userId, sender)
+ | withAttr(&ClientModel::deviceId, senderDeviceId)
+ | withDevice(recipient, infoB)
+ | withCrypto(cryptoA)
+ );
+ clientWithDevice = makeClient(
+ withAttr(&ClientModel::userId, recipient)
+ | withAttr(&ClientModel::deviceId, recipientDeviceId)
+ | withDevice(sender, infoA)
+ | withCrypto(cryptoB)
+ );
+ clientNoDevice = makeClient(
+ withAttr(&ClientModel::userId, recipient)
+ | withAttr(&ClientModel::deviceId, recipientDeviceId)
+ | withCrypto(cryptoB)
+ );
+ }
+
+ inline Kazv::Event encrypt(Kazv::json pt, bool attachSenderDeviceKeys = false)
+ {
+ using namespace Kazv;
+ auto encrypted = clientA.olmEncryptSplit(
+ Event(pt),
+ {{recipient, {recipientDeviceId}}},
+ {},
+ attachSenderDeviceKeys
+ );
+ auto j = encrypted.at(recipient).at(recipientDeviceId).originalJson().get();
+ j["sender"] = sender;
+ return Event(j);
+ };
+
+ inline Kazv::Event decryptOnly(Kazv::Event e)
+ {
+ using namespace Kazv;
+ auto decryptedText = clientWithDevice.withCrypto([&](Crypto &c) {
+ return c.decrypt(e.originalJson().get());
+ });
+ REQUIRE(decryptedText.has_value());
+ auto decryptedJson = json::parse(decryptedText.value());
+ return e.setDecryptedJson(decryptedJson, Event::Decrypted);
+ };
+
+ std::string sender;
+ std::string recipient;
+ std::string senderDeviceId;
+ std::string recipientDeviceId;
+ Kazv::DeviceKeyInfo infoA;
+ Kazv::DeviceKeyInfo infoB;
+ Kazv::ClientModel clientA;
+ Kazv::ClientModel clientWithDevice;
+ Kazv::ClientModel clientNoDevice;
+};

File Metadata

Mime Type
text/x-diff
Expires
Sun, Oct 11, 12:49 PM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785778
Default Alt Text
(165 KB)

Event Timeline