2019 lines
68 KiB
C++
2019 lines
68 KiB
C++
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "e2e_cloud/storage/aes_gcm_local_record_protector.h"
|
|
#include "e2e_cloud/storage/file_atomic_blob_store.h"
|
|
#include "e2e_cloud/storage/local_record_key_derivation.h"
|
|
#include "e2e_cloud/storage/persistent_conversation_metadata.h"
|
|
#include "e2e_cloud/storage/persistent_content_store.h"
|
|
#include "e2e_cloud/storage/persistent_content_sync_state.h"
|
|
#include "e2e_cloud/storage/persistent_control_observation_state.h"
|
|
#include "e2e_cloud/storage/persistent_freshness_trust.h"
|
|
#include "e2e_cloud/files/persistent_file_transfer.h"
|
|
#include "e2e_cloud/files/private_file_manifest.h"
|
|
#include "e2e_cloud/core/envelope_codec.h"
|
|
#include "e2e_cloud/group/signed_group_transition.h"
|
|
#include "e2e_cloud/identity/openssl_account_crypto.h"
|
|
#include "e2e_cloud/mls/client_key_package.h"
|
|
#include "e2e_cloud/mls/mls_outbox_reconciler.h"
|
|
#include "e2e_cloud/mls/observed_key_package.h"
|
|
#include "e2e_cloud/protocol/freshness_protocol.h"
|
|
#include "e2e_cloud/storage/persistent_inbound_journal.h"
|
|
#include "e2e_cloud/storage/persistent_key_package_pool.h"
|
|
#include "e2e_cloud/storage/persistent_mls_state.h"
|
|
#include "e2e_cloud/storage/persistent_outbox.h"
|
|
|
|
#include <QtCore/QFile>
|
|
#include <QtCore/QTemporaryDir>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstdio>
|
|
#include <optional>
|
|
#include <string_view>
|
|
#include <utility>
|
|
|
|
namespace {
|
|
|
|
using namespace E2ECloud;
|
|
|
|
template <typename Id>
|
|
[[nodiscard]] Id FilledId(std::uint8_t value) {
|
|
auto result = Id();
|
|
result.bytes.fill(value);
|
|
return result;
|
|
}
|
|
|
|
[[nodiscard]] int Fail(const char *message) {
|
|
std::fprintf(stderr, "%s\n", message);
|
|
return 1;
|
|
}
|
|
|
|
void AppendTestUint16(QByteArray &result, std::uint16_t value) {
|
|
result.append(char(value >> 8));
|
|
result.append(char(value));
|
|
}
|
|
|
|
void AppendTestUint32(QByteArray &result, std::uint32_t value) {
|
|
result.append(char(value >> 24));
|
|
result.append(char(value >> 16));
|
|
result.append(char(value >> 8));
|
|
result.append(char(value));
|
|
}
|
|
|
|
void AppendTestUint64(QByteArray &result, std::uint64_t value) {
|
|
for (auto shift = 56; shift >= 0; shift -= 8) {
|
|
result.append(char(value >> shift));
|
|
}
|
|
}
|
|
|
|
template <typename Array>
|
|
void AppendTestArray(QByteArray &result, const Array &value) {
|
|
result.append(
|
|
reinterpret_cast<const char*>(value.data()),
|
|
int(value.size()));
|
|
}
|
|
|
|
class MemoryBlobStore final : public AtomicBlobStore {
|
|
public:
|
|
[[nodiscard]] BlobReadResult read() const override {
|
|
if (readError) {
|
|
return {
|
|
.status = BlobReadStatus::Error,
|
|
.bytes = {},
|
|
};
|
|
} else if (!bytes) {
|
|
return {
|
|
.status = BlobReadStatus::Missing,
|
|
.bytes = {},
|
|
};
|
|
}
|
|
return {
|
|
.status = BlobReadStatus::Found,
|
|
.bytes = *bytes,
|
|
};
|
|
}
|
|
|
|
bool writeAtomic(const QByteArray &value) override {
|
|
if (writeError) {
|
|
return false;
|
|
}
|
|
bytes = value;
|
|
return true;
|
|
}
|
|
|
|
std::optional<QByteArray> bytes;
|
|
bool readError = false;
|
|
bool writeError = false;
|
|
|
|
};
|
|
|
|
class CountingLocalRecordProtector final : public LocalRecordProtector {
|
|
public:
|
|
explicit CountingLocalRecordProtector(LocalRecordKey &&key);
|
|
|
|
[[nodiscard]] std::optional<QByteArray> seal(
|
|
const QByteArray &purpose,
|
|
const QByteArray &plaintext) const override;
|
|
|
|
[[nodiscard]] std::optional<QByteArray> open(
|
|
const QByteArray &purpose,
|
|
const QByteArray &ciphertext) const override;
|
|
|
|
void resetOpenCalls() const;
|
|
|
|
[[nodiscard]] int openCalls() const;
|
|
|
|
private:
|
|
AesGcmLocalRecordProtector _protector;
|
|
mutable int _openCalls = 0;
|
|
|
|
};
|
|
|
|
CountingLocalRecordProtector::CountingLocalRecordProtector(
|
|
LocalRecordKey &&key)
|
|
: _protector(std::move(key)) {
|
|
}
|
|
|
|
std::optional<QByteArray> CountingLocalRecordProtector::seal(
|
|
const QByteArray &purpose,
|
|
const QByteArray &plaintext) const {
|
|
return _protector.seal(purpose, plaintext);
|
|
}
|
|
|
|
std::optional<QByteArray> CountingLocalRecordProtector::open(
|
|
const QByteArray &purpose,
|
|
const QByteArray &ciphertext) const {
|
|
++_openCalls;
|
|
return _protector.open(purpose, ciphertext);
|
|
}
|
|
|
|
void CountingLocalRecordProtector::resetOpenCalls() const {
|
|
_openCalls = 0;
|
|
}
|
|
|
|
int CountingLocalRecordProtector::openCalls() const {
|
|
return _openCalls;
|
|
}
|
|
|
|
[[nodiscard]] PendingMessage Message() {
|
|
return {
|
|
.conversationId = FilledId<ConversationId>(1),
|
|
.objectId = FilledId<ObjectId>(2),
|
|
.plaintext = QByteArray("private draft"),
|
|
.authenticatedData = QByteArray("metadata"),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] TransportEnvelope Envelope() {
|
|
return {
|
|
.conversationId = FilledId<ConversationId>(1),
|
|
.objectKind = ObjectKind::MlsApplication,
|
|
.senderAccountId = FilledId<AccountId>(2),
|
|
.senderClientId = FilledId<ClientId>(3),
|
|
.telegramPeerIdBinding = 42,
|
|
.epochOrGeneration = 7,
|
|
.objectId = FilledId<ObjectId>(4),
|
|
.payloadHash = FilledId<Digest>(5),
|
|
.payload = QByteArray("payload"),
|
|
.authenticationData = QByteArray("authentication"),
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] MlsOperationReceipt MlsReceipt() {
|
|
return {
|
|
.objectId = FilledId<ObjectId>(13),
|
|
.requestHash = FilledId<Digest>(14),
|
|
.envelope = {
|
|
.conversationId = FilledId<ConversationId>(12),
|
|
.objectId = FilledId<ObjectId>(13),
|
|
.bytes = QByteArray("exact MLS ciphertext"),
|
|
},
|
|
};
|
|
}
|
|
|
|
[[nodiscard]] bool Contains(
|
|
const QByteArray &haystack,
|
|
std::string_view needle) {
|
|
const auto first = haystack.constData();
|
|
const auto last = first + haystack.size();
|
|
return std::search(
|
|
first,
|
|
last,
|
|
begin(needle),
|
|
end(needle)) != last;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioAeadProtection() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(7);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto purpose = QByteArray("test-purpose");
|
|
const auto plaintext = QByteArray("protected value");
|
|
const auto first = protector.seal(purpose, plaintext);
|
|
const auto second = protector.seal(purpose, plaintext);
|
|
if (std::any_of(begin(key), end(key), [](std::uint8_t byte) {
|
|
return byte != 0;
|
|
})
|
|
|| !first
|
|
|| !second
|
|
|| first == second
|
|
|| Contains(*first, "protected value")
|
|
|| protector.open(purpose, *first) != plaintext
|
|
|| protector.open(QByteArray("wrong-purpose"), *first)) {
|
|
return Fail("local AEAD did not bind nonce, purpose, and plaintext");
|
|
}
|
|
auto tampered = *first;
|
|
tampered[tampered.size() - 1] ^= 1;
|
|
if (protector.open(purpose, tampered)) {
|
|
return Fail("local AEAD accepted a modified authentication tag");
|
|
}
|
|
auto emptyKey = LocalRecordKey();
|
|
const auto invalid = AesGcmLocalRecordProtector(std::move(emptyKey));
|
|
if (invalid.seal(purpose, plaintext)) {
|
|
return Fail("local AEAD accepted an empty record key");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioAtomicBlobRejectsOversizedFile() {
|
|
auto directory = QTemporaryDir();
|
|
if (!directory.isValid()) {
|
|
return Fail("atomic blob temporary directory was unavailable");
|
|
}
|
|
const auto path = directory.filePath("oversized.blob");
|
|
auto file = QFile(path);
|
|
constexpr auto kMaximumProtectedBlobSize = qint64(
|
|
128 * 1024 * 1024 + 42);
|
|
if (!file.open(QIODevice::WriteOnly)
|
|
|| !file.seek(kMaximumProtectedBlobSize)
|
|
|| file.write("x", 1) != 1) {
|
|
return Fail("oversized atomic blob fixture could not be created");
|
|
}
|
|
file.close();
|
|
const auto stored = FileAtomicBlobStore(path).read();
|
|
if (stored.status != BlobReadStatus::Error || !stored.bytes.isEmpty()) {
|
|
return Fail("atomic blob read allocated an oversized local record");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioConversationRecordKeyDerivation() {
|
|
auto masterBytes = std::array<std::uint8_t, 32>();
|
|
masterBytes.fill(11);
|
|
const auto masterKey = SecureKey32(std::move(masterBytes));
|
|
const auto first = DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
42,
|
|
FilledId<ConversationId>(1));
|
|
const auto retry = DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
42,
|
|
FilledId<ConversationId>(1));
|
|
const auto otherConversation = DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
42,
|
|
FilledId<ConversationId>(2));
|
|
const auto otherAccount = DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
43,
|
|
FilledId<ConversationId>(1));
|
|
if (!first
|
|
|| first != retry
|
|
|| first == otherConversation
|
|
|| first == otherAccount
|
|
|| DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
0,
|
|
FilledId<ConversationId>(1))
|
|
|| DeriveConversationLocalRecordKey(
|
|
masterKey,
|
|
42,
|
|
ConversationId())) {
|
|
return Fail("local record keys were not domain-separated");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioContentSyncBoundarySurvivesRestart() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(31);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto state = PersistentContentSyncState(blob, protector);
|
|
const auto conversationId = FilledId<ConversationId>(32);
|
|
if (state.load(conversationId) != ContentSyncStateLoadResult::Missing
|
|
|| state.advance(100) != ContentSyncStateCommitResult::Committed
|
|
|| state.advance(99)
|
|
!= ContentSyncStateCommitResult::InvalidBoundary
|
|
|| state.advance(100)
|
|
!= ContentSyncStateCommitResult::AlreadyCommitted) {
|
|
return Fail("content synchronization boundary was not monotonic");
|
|
}
|
|
auto restored = PersistentContentSyncState(blob, protector);
|
|
if (restored.load(conversationId)
|
|
!= ContentSyncStateLoadResult::Loaded
|
|
|| restored.newestObservedMessageId() != 100
|
|
|| restored.revision() != 1
|
|
|| restored.advance(150)
|
|
!= ContentSyncStateCommitResult::Committed) {
|
|
return Fail("content synchronization boundary did not survive restart");
|
|
}
|
|
auto wrongStream = PersistentContentSyncState(
|
|
blob,
|
|
protector,
|
|
ObservedSyncStream::Control);
|
|
if (wrongStream.load(conversationId)
|
|
!= ContentSyncStateLoadResult::AuthenticationFailed
|
|
|| wrongStream.loaded()) {
|
|
return Fail("content and control boundaries were not domain-separated");
|
|
}
|
|
auto tampered = *blob.bytes;
|
|
tampered[tampered.size() - 1] ^= 1;
|
|
blob.bytes = tampered;
|
|
if (restored.load(conversationId)
|
|
!= ContentSyncStateLoadResult::AuthenticationFailed
|
|
|| restored.loaded()
|
|
|| restored.newestObservedMessageId()
|
|
|| restored.revision()
|
|
|| restored.advance(200)
|
|
!= ContentSyncStateCommitResult::InvalidBoundary) {
|
|
return Fail("content synchronization boundary accepted tampering");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioControlObservationStateSurvivesRestart() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(33);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto conversationId = FilledId<ConversationId>(34);
|
|
const auto localAccountId = FilledId<AccountId>(35);
|
|
const auto remoteAccountId = FilledId<AccountId>(36);
|
|
const auto checkpoint = Checkpoint{
|
|
.conversationId = conversationId,
|
|
.generation = 7,
|
|
.stateHash = FilledId<Digest>(37),
|
|
};
|
|
const auto witnesses = std::set<AccountId>{
|
|
localAccountId,
|
|
remoteAccountId,
|
|
};
|
|
auto blob = MemoryBlobStore();
|
|
auto state = PersistentControlObservationState(blob, protector);
|
|
if (state.load(conversationId)
|
|
!= ControlObservationStateLoadResult::Missing
|
|
|| state.advance(100, checkpoint, witnesses, true)
|
|
!= ControlObservationStateCommitResult::Committed
|
|
|| state.advance(99, checkpoint, witnesses, true)
|
|
!= ControlObservationStateCommitResult::InvalidState
|
|
|| state.advance(100, checkpoint, witnesses, true)
|
|
!= ControlObservationStateCommitResult::AlreadyCommitted) {
|
|
return Fail("control observation state was not monotonic");
|
|
}
|
|
auto restored = PersistentControlObservationState(blob, protector);
|
|
if (restored.load(conversationId)
|
|
!= ControlObservationStateLoadResult::Loaded
|
|
|| restored.newestObservedMessageId() != 100
|
|
|| restored.checkpoint() != checkpoint
|
|
|| restored.safetyWitnesses() != witnesses
|
|
|| !restored.ownSafetyGossipObserved()
|
|
|| restored.revision() != 1
|
|
|| restored.legacy()) {
|
|
return Fail("control witnesses did not survive restart");
|
|
}
|
|
blob.writeError = true;
|
|
const auto nextWitnesses = std::set<AccountId>{ localAccountId };
|
|
if (restored.advance(150, checkpoint, nextWitnesses, false)
|
|
!= ControlObservationStateCommitResult::PersistenceFailed
|
|
|| restored.newestObservedMessageId() != 100
|
|
|| restored.safetyWitnesses() != witnesses
|
|
|| !restored.ownSafetyGossipObserved()) {
|
|
return Fail("failed control observation write changed live state");
|
|
}
|
|
blob.writeError = false;
|
|
auto contentState = PersistentContentSyncState(blob, protector);
|
|
if (contentState.load(conversationId)
|
|
!= ContentSyncStateLoadResult::AuthenticationFailed) {
|
|
return Fail("control observation state was not domain-separated");
|
|
}
|
|
auto tampered = *blob.bytes;
|
|
tampered[tampered.size() - 1] ^= 1;
|
|
blob.bytes = tampered;
|
|
if (restored.load(conversationId)
|
|
!= ControlObservationStateLoadResult::AuthenticationFailed
|
|
|| restored.loaded()
|
|
|| restored.newestObservedMessageId()
|
|
|| restored.revision()
|
|
|| !restored.safetyWitnesses().empty()) {
|
|
return Fail("control observation state accepted tampering");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioLegacyControlBoundaryMigrates() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(38);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto conversationId = FilledId<ConversationId>(39);
|
|
const auto accountId = FilledId<AccountId>(40);
|
|
const auto checkpoint = Checkpoint{
|
|
.conversationId = conversationId,
|
|
.generation = 8,
|
|
.stateHash = FilledId<Digest>(41),
|
|
};
|
|
auto blob = MemoryBlobStore();
|
|
auto legacy = PersistentContentSyncState(
|
|
blob,
|
|
protector,
|
|
ObservedSyncStream::Control);
|
|
if (legacy.load(conversationId) != ContentSyncStateLoadResult::Missing
|
|
|| legacy.advance(75) != ContentSyncStateCommitResult::Committed) {
|
|
return Fail("legacy control boundary setup failed");
|
|
}
|
|
auto migrated = PersistentControlObservationState(blob, protector);
|
|
if (migrated.load(conversationId)
|
|
!= ControlObservationStateLoadResult::LegacyLoaded
|
|
|| !migrated.legacy()
|
|
|| migrated.newestObservedMessageId() != 75
|
|
|| migrated.revision() != 1
|
|
|| migrated.advance(75, checkpoint, { accountId }, false)
|
|
!= ControlObservationStateCommitResult::Committed
|
|
|| migrated.legacy()
|
|
|| migrated.revision() != 2) {
|
|
return Fail("legacy control boundary was not migrated");
|
|
}
|
|
auto restored = PersistentControlObservationState(blob, protector);
|
|
if (restored.load(conversationId)
|
|
!= ControlObservationStateLoadResult::Loaded
|
|
|| restored.checkpoint() != checkpoint
|
|
|| restored.safetyWitnesses()
|
|
!= std::set<AccountId>{ accountId }) {
|
|
return Fail("migrated control observation state did not reload");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioContentStoreReloadFailureClearsPlaintext() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(51);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto sha256 = OpenSslSha256Provider();
|
|
const auto conversationId = FilledId<ConversationId>(52);
|
|
const auto eventObjectId = FilledId<ObjectId>(53);
|
|
auto indexBlob = MemoryBlobStore();
|
|
auto directory = QTemporaryDir();
|
|
if (!directory.isValid()) {
|
|
return Fail("content store temporary directory was unavailable");
|
|
}
|
|
auto store = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
if (store.load() != ContentStoreLoadResult::Missing
|
|
|| store.append({
|
|
.conversationId = conversationId,
|
|
.eventObjectId = eventObjectId,
|
|
.contentObjectId = FilledId<ObjectId>(54),
|
|
.objectKind = ObjectKind::EncryptedMessageBody,
|
|
.groupGeneration = 7,
|
|
.senderAccountId = FilledId<AccountId>(55),
|
|
.senderClientId = FilledId<ClientId>(56),
|
|
.unixTime = 1'725'000'000,
|
|
.observedTelegramMessageId = 99,
|
|
.plaintext = QByteArray("decrypted protected message"),
|
|
}) != ContentStoreAppendResult::Stored
|
|
|| !store.record(eventObjectId)) {
|
|
return Fail("content store reload fixture could not be persisted");
|
|
}
|
|
(*indexBlob.bytes)[indexBlob.bytes->size() - 1] ^= 1;
|
|
if (store.load() != ContentStoreLoadResult::AuthenticationFailed
|
|
|| store.loaded()
|
|
|| store.revision()
|
|
|| store.size()
|
|
|| !store.records(0, 1).empty()
|
|
|| store.record(eventObjectId)) {
|
|
return Fail("failed content store reload retained plaintext state");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioContentStoreAcceptsCarrierDuplicates() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(57);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto sha256 = OpenSslSha256Provider();
|
|
const auto conversationId = FilledId<ConversationId>(58);
|
|
auto indexBlob = MemoryBlobStore();
|
|
auto directory = QTemporaryDir();
|
|
if (!directory.isValid()) {
|
|
return Fail("content duplicate temporary directory was unavailable");
|
|
}
|
|
auto store = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
auto record = ProtectedContentRecord{
|
|
.conversationId = conversationId,
|
|
.eventObjectId = FilledId<ObjectId>(59),
|
|
.contentObjectId = FilledId<ObjectId>(60),
|
|
.objectKind = ObjectKind::EncryptedMessageBody,
|
|
.groupGeneration = 7,
|
|
.senderAccountId = FilledId<AccountId>(61),
|
|
.senderClientId = FilledId<ClientId>(62),
|
|
.unixTime = 1'725'000'000,
|
|
.observedTelegramMessageId = 100,
|
|
.plaintext = QByteArray("same protected message"),
|
|
};
|
|
if (store.load() != ContentStoreLoadResult::Missing
|
|
|| store.append(record) != ContentStoreAppendResult::Stored) {
|
|
return Fail("content duplicate fixture could not be persisted");
|
|
}
|
|
record.observedTelegramMessageId = 101;
|
|
const auto newerDuplicate = store.append(record);
|
|
const auto afterNewerDuplicate = store.record(record.eventObjectId);
|
|
if (newerDuplicate != ContentStoreAppendResult::AlreadyStored
|
|
|| store.revision() != 1
|
|
|| store.size() != 1
|
|
|| !afterNewerDuplicate
|
|
|| afterNewerDuplicate->observedTelegramMessageId != 100) {
|
|
return Fail("same content under another carrier id was a conflict");
|
|
}
|
|
record.observedTelegramMessageId = 99;
|
|
indexBlob.writeError = true;
|
|
const auto failed = store.append(record);
|
|
const auto afterFailedWrite = store.record(record.eventObjectId);
|
|
if (failed != ContentStoreAppendResult::PersistenceFailed
|
|
|| store.revision() != 1
|
|
|| !afterFailedWrite
|
|
|| afterFailedWrite->observedTelegramMessageId != 100) {
|
|
return Fail("failed duplicate boundary write changed live content");
|
|
}
|
|
indexBlob.writeError = false;
|
|
const auto retried = store.append(record);
|
|
const auto afterOlderDuplicate = store.record(record.eventObjectId);
|
|
if (retried != ContentStoreAppendResult::AlreadyStored
|
|
|| store.revision() != 2
|
|
|| !afterOlderDuplicate
|
|
|| afterOlderDuplicate->observedTelegramMessageId != 99) {
|
|
return Fail("older carrier duplicate did not lower its search boundary");
|
|
}
|
|
record.plaintext.append('!');
|
|
if (store.append(std::move(record)) != ContentStoreAppendResult::Conflict) {
|
|
return Fail("changed protected content escaped conflict detection");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioContentStorePagesAndMigratesLegacyIndex() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(63);
|
|
const auto protector = CountingLocalRecordProtector(std::move(key));
|
|
const auto sha256 = OpenSslSha256Provider();
|
|
const auto conversationId = FilledId<ConversationId>(64);
|
|
auto indexBlob = MemoryBlobStore();
|
|
auto directory = QTemporaryDir();
|
|
if (!directory.isValid()) {
|
|
return Fail("content paging temporary directory was unavailable");
|
|
}
|
|
auto store = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
if (store.load() != ContentStoreLoadResult::Missing) {
|
|
return Fail("content paging store did not start empty");
|
|
}
|
|
const auto times = std::array<std::uint64_t, 3>{ 300, 100, 200 };
|
|
for (auto index = std::size_t(); index != times.size(); ++index) {
|
|
if (store.append({
|
|
.conversationId = conversationId,
|
|
.eventObjectId = FilledId<ObjectId>(
|
|
std::uint8_t(65 + index)),
|
|
.contentObjectId = FilledId<ObjectId>(
|
|
std::uint8_t(68 + index)),
|
|
.objectKind = (index == 2)
|
|
? ObjectKind::EncryptedFileManifest
|
|
: ObjectKind::EncryptedMessageBody,
|
|
.groupGeneration = 7,
|
|
.senderAccountId = FilledId<AccountId>(71),
|
|
.senderClientId = FilledId<ClientId>(72),
|
|
.unixTime = times[index],
|
|
.observedTelegramMessageId = 100 + std::int64_t(index),
|
|
.plaintext = QByteArray("paged protected content"),
|
|
}) != ContentStoreAppendResult::Stored) {
|
|
return Fail("content paging fixture could not be persisted");
|
|
}
|
|
}
|
|
const auto purpose = QByteArray("e2e-cloud-content-index-v1");
|
|
auto versionThree = indexBlob.bytes
|
|
? protector.open(purpose, *indexBlob.bytes)
|
|
: std::nullopt;
|
|
if (!versionThree || versionThree->size() != 54 + 3 * 82) {
|
|
return Fail("content paging index was not written as version three");
|
|
}
|
|
auto versionTwo = QByteArray(versionThree->constData(), 54);
|
|
versionTwo[8] = 0;
|
|
versionTwo[9] = 2;
|
|
for (auto index = 0; index != 3; ++index) {
|
|
versionTwo.append(
|
|
versionThree->constData() + 54 + index * 82,
|
|
74);
|
|
}
|
|
indexBlob.bytes = protector.seal(purpose, versionTwo);
|
|
auto restoredVersionTwo = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
if (!indexBlob.bytes
|
|
|| restoredVersionTwo.load() != ContentStoreLoadResult::Loaded) {
|
|
return Fail("version-two content index did not migrate");
|
|
}
|
|
const auto versionTwoFiles = restoredVersionTwo.records(
|
|
0,
|
|
1,
|
|
ObjectKind::EncryptedFileManifest);
|
|
const auto migratedVersionTwo = indexBlob.bytes
|
|
? protector.open(purpose, *indexBlob.bytes)
|
|
: std::nullopt;
|
|
if (versionTwoFiles.size() != 1
|
|
|| versionTwoFiles[0].observedTelegramMessageId != 1
|
|
|| !migratedVersionTwo
|
|
|| migratedVersionTwo->size() != 54 + 3 * 82
|
|
|| std::uint8_t((*migratedVersionTwo)[9]) != 3) {
|
|
return Fail("version-two manifest boundary was not made conservative");
|
|
}
|
|
auto versionOne = QByteArray(versionThree->constData(), 54);
|
|
versionOne[8] = 0;
|
|
versionOne[9] = 1;
|
|
for (auto index = 0; index != 3; ++index) {
|
|
versionOne.append(versionThree->constData() + 54 + index * 82, 64);
|
|
}
|
|
indexBlob.bytes = protector.seal(purpose, versionOne);
|
|
if (!indexBlob.bytes) {
|
|
return Fail("legacy content index fixture could not be protected");
|
|
}
|
|
auto restored = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
if (restored.load() != ContentStoreLoadResult::Loaded
|
|
|| restored.size() != 3
|
|
|| restored.size(ObjectKind::EncryptedFileManifest) != 1) {
|
|
return Fail("legacy content index did not migrate");
|
|
}
|
|
const auto firstPage = restored.records(0, 2);
|
|
const auto secondPage = restored.records(2, 1);
|
|
const auto files = restored.records(
|
|
0,
|
|
1,
|
|
ObjectKind::EncryptedFileManifest);
|
|
if (firstPage.size() != 2
|
|
|| firstPage[0].unixTime != 100
|
|
|| firstPage[1].unixTime != 200
|
|
|| secondPage.size() != 1
|
|
|| secondPage[0].unixTime != 300
|
|
|| files.size() != 1
|
|
|| files[0].objectKind != ObjectKind::EncryptedFileManifest
|
|
|| files[0].unixTime != 200
|
|
|| files[0].observedTelegramMessageId != 1) {
|
|
return Fail("content store did not load only the requested sorted page");
|
|
}
|
|
const auto migrated = indexBlob.bytes
|
|
? protector.open(purpose, *indexBlob.bytes)
|
|
: std::nullopt;
|
|
if (!migrated
|
|
|| migrated->size() != 54 + 3 * 82
|
|
|| std::uint8_t((*migrated)[8]) != 0
|
|
|| std::uint8_t((*migrated)[9]) != 3) {
|
|
return Fail("legacy content index was not upgraded atomically");
|
|
}
|
|
protector.resetOpenCalls();
|
|
auto reloaded = PersistentContentStore(
|
|
conversationId,
|
|
directory.path(),
|
|
indexBlob,
|
|
protector,
|
|
sha256);
|
|
if (reloaded.load() != ContentStoreLoadResult::Loaded
|
|
|| protector.openCalls() != 1) {
|
|
return Fail("version-three content load decrypted every message");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFileTransferSurvivesRestart() {
|
|
auto localKey = LocalRecordKey();
|
|
localKey.fill(33);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(localKey));
|
|
auto fileKey = std::array<std::uint8_t, 32>();
|
|
fileKey.fill(34);
|
|
auto context = FileChunkContext{
|
|
.conversationId = FilledId<ConversationId>(35),
|
|
.fileId = FilledId<FileId>(36),
|
|
.plaintextSize = 13,
|
|
.chunkSize = 64 * 1024,
|
|
.chunkCount = 1,
|
|
.noncePrefix = {},
|
|
};
|
|
context.noncePrefix.fill(37);
|
|
const auto manifest = PrivateFileManifestCodecV1().encodePlaintext({
|
|
.context = context,
|
|
.key = FileEncryptionKey(std::move(fileKey)),
|
|
.plaintextHash = FilledId<Digest>(38),
|
|
.unixTime = 1'725'000'000,
|
|
.filenameUtf8 = QByteArray("backup.any"),
|
|
.mimeTypeUtf8 = QByteArray("application/octet-stream"),
|
|
.preview = PrivateFilePreview{
|
|
.width = 320,
|
|
.height = 180,
|
|
.jpegBytes = QByteArray(70 * 1024, 'p'),
|
|
},
|
|
});
|
|
auto blob = MemoryBlobStore();
|
|
auto transfer = PersistentFileTransfer(blob, protector);
|
|
if (!manifest) {
|
|
return Fail("resumable file transfer manifest was not encoded");
|
|
}
|
|
if (transfer.load(context.conversationId)
|
|
!= FileTransferLoadResult::Empty) {
|
|
return Fail("resumable file transfer was not initially empty");
|
|
}
|
|
if (transfer.begin({
|
|
.conversationId = context.conversationId,
|
|
.eventObjectId = FilledId<ObjectId>(39),
|
|
.contentObjectId = FilledId<ObjectId>(40),
|
|
.groupGeneration = 7,
|
|
.archiveEpochGeneration = 3,
|
|
.manifestPublished = false,
|
|
.nextChunkIndex = 0,
|
|
.sourcePathUtf8 = QByteArray("/private/source.any"),
|
|
.manifestPlaintext = manifest.value_or(QByteArray()),
|
|
}) != FileTransferCommitResult::Committed) {
|
|
return Fail("resumable file transfer was not started");
|
|
}
|
|
if (transfer.advance(0) != FileTransferCommitResult::InvalidMutation) {
|
|
return Fail("resumable file transfer advanced before publication");
|
|
}
|
|
if (transfer.markManifestPublished(3)
|
|
!= FileTransferCommitResult::Committed) {
|
|
return Fail("resumable file transfer publication was not persisted");
|
|
}
|
|
if (transfer.advance(0) != FileTransferCommitResult::Committed) {
|
|
return Fail("resumable file transfer progress was not persisted");
|
|
}
|
|
auto restored = PersistentFileTransfer(blob, protector);
|
|
const auto restoredLoad = restored.load(context.conversationId);
|
|
if (restoredLoad == FileTransferLoadResult::AuthenticationFailed) {
|
|
return Fail("resumable file transfer snapshot authentication failed");
|
|
} else if (restoredLoad == FileTransferLoadResult::InvalidSnapshot) {
|
|
return Fail("resumable file transfer snapshot was invalid");
|
|
} else if (restoredLoad != FileTransferLoadResult::Loaded) {
|
|
return Fail("resumable file transfer snapshot did not load");
|
|
} else if (!restored.pending()
|
|
|| !restored.pending()->manifestPublished
|
|
|| restored.pending()->archiveEpochGeneration != 3
|
|
|| restored.pending()->nextChunkIndex != 1) {
|
|
return Fail("resumable file transfer did not restore its chunk cursor");
|
|
}
|
|
auto replacementContext = context;
|
|
replacementContext.fileId = FilledId<FileId>(41);
|
|
auto replacementKey = std::array<std::uint8_t, 32>();
|
|
replacementKey.fill(42);
|
|
const auto replacementManifest = PrivateFileManifestCodecV1()
|
|
.encodePlaintext({
|
|
.context = replacementContext,
|
|
.key = FileEncryptionKey(std::move(replacementKey)),
|
|
.plaintextHash = FilledId<Digest>(43),
|
|
.unixTime = 1'725'000'001,
|
|
.filenameUtf8 = QByteArray("replacement.any"),
|
|
.mimeTypeUtf8 = QByteArray("application/octet-stream"),
|
|
});
|
|
const auto replacement = PendingFileTransfer{
|
|
.conversationId = context.conversationId,
|
|
.eventObjectId = FilledId<ObjectId>(44),
|
|
.contentObjectId = FilledId<ObjectId>(45),
|
|
.groupGeneration = 8,
|
|
.archiveEpochGeneration = 4,
|
|
.manifestPublished = false,
|
|
.nextChunkIndex = 0,
|
|
.sourcePathUtf8 = QByteArray("/private/replacement.any"),
|
|
.manifestPlaintext = replacementManifest.value_or(QByteArray()),
|
|
};
|
|
blob.writeError = true;
|
|
if (!replacementManifest
|
|
|| restored.replace(replacement)
|
|
!= FileTransferCommitResult::PersistenceFailed
|
|
|| !restored.pending()
|
|
|| restored.pending()->eventObjectId != FilledId<ObjectId>(39)) {
|
|
return Fail("failed file transfer replacement changed pending state");
|
|
}
|
|
blob.writeError = false;
|
|
if (restored.replace(replacement) != FileTransferCommitResult::Committed
|
|
|| !restored.pending()
|
|
|| restored.pending()->eventObjectId != replacement.eventObjectId
|
|
|| restored.revision() != 4) {
|
|
return Fail("file transfer could not be replaced atomically");
|
|
}
|
|
auto replaced = PersistentFileTransfer(blob, protector);
|
|
if (replaced.load(context.conversationId)
|
|
!= FileTransferLoadResult::Loaded
|
|
|| !replaced.pending()
|
|
|| replaced.pending()->sourcePathUtf8
|
|
!= replacement.sourcePathUtf8) {
|
|
return Fail("resumable file transfer replacement did not reload");
|
|
}
|
|
const auto replacementSnapshot = *blob.bytes;
|
|
(*blob.bytes)[blob.bytes->size() - 1] ^= 1;
|
|
if (replaced.load(context.conversationId)
|
|
!= FileTransferLoadResult::AuthenticationFailed
|
|
|| replaced.loaded()
|
|
|| replaced.pending()
|
|
|| replaced.revision()) {
|
|
return Fail("failed file transfer reload retained pending plaintext");
|
|
}
|
|
blob.bytes = replacementSnapshot;
|
|
if (replaced.load(context.conversationId)
|
|
!= FileTransferLoadResult::Loaded) {
|
|
return Fail("file transfer cancellation fixture did not reload");
|
|
}
|
|
blob.writeError = true;
|
|
if (replaced.requestCancel()
|
|
!= FileTransferCommitResult::PersistenceFailed
|
|
|| !replaced.pending()
|
|
|| replaced.pending()->cancelRequested) {
|
|
return Fail("failed cancellation changed pending file transfer");
|
|
}
|
|
blob.writeError = false;
|
|
if (replaced.requestCancel()
|
|
!= FileTransferCommitResult::Committed
|
|
|| !replaced.pending()
|
|
|| !replaced.pending()->cancelRequested
|
|
|| replaced.markManifestPublished(4)
|
|
!= FileTransferCommitResult::Committed
|
|
|| !replaced.pending()
|
|
|| !replaced.pending()->cancelRequested
|
|
|| !replaced.pending()->manifestPublished
|
|
|| replaced.revision() != 6) {
|
|
return Fail("file transfer cancellation was not durable");
|
|
}
|
|
auto cancelling = PersistentFileTransfer(blob, protector);
|
|
if (cancelling.load(context.conversationId)
|
|
!= FileTransferLoadResult::Loaded
|
|
|| !cancelling.pending()
|
|
|| !cancelling.pending()->cancelRequested
|
|
|| cancelling.requestCancel()
|
|
!= FileTransferCommitResult::AlreadyCommitted
|
|
|| cancelling.clear() != FileTransferCommitResult::Committed) {
|
|
return Fail("resumable file transfer could not be cleared");
|
|
}
|
|
auto completed = PersistentFileTransfer(blob, protector);
|
|
if (completed.load(context.conversationId)
|
|
!= FileTransferLoadResult::Empty
|
|
|| completed.pending()) {
|
|
return Fail("completed file transfer reappeared after restart");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioLegacyFileTransferPublishesManifestFirst() {
|
|
auto localKey = LocalRecordKey();
|
|
localKey.fill(50);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(localKey));
|
|
auto fileKey = std::array<std::uint8_t, 32>();
|
|
fileKey.fill(51);
|
|
auto context = FileChunkContext{
|
|
.conversationId = FilledId<ConversationId>(52),
|
|
.fileId = FilledId<FileId>(53),
|
|
.plaintextSize = 13,
|
|
.chunkSize = 64 * 1024,
|
|
.chunkCount = 1,
|
|
.noncePrefix = {},
|
|
};
|
|
context.noncePrefix.fill(54);
|
|
const auto manifest = PrivateFileManifestCodecV1().encodePlaintext({
|
|
.context = context,
|
|
.key = FileEncryptionKey(std::move(fileKey)),
|
|
.plaintextHash = FilledId<Digest>(55),
|
|
.unixTime = 1'725'000'002,
|
|
.filenameUtf8 = QByteArray("legacy.any"),
|
|
.mimeTypeUtf8 = QByteArray("application/octet-stream"),
|
|
});
|
|
if (!manifest) {
|
|
return Fail("legacy file transfer manifest could not be encoded");
|
|
}
|
|
const auto eventObjectId = FilledId<ObjectId>(56);
|
|
const auto contentObjectId = FilledId<ObjectId>(57);
|
|
const auto sourcePath = QByteArray("/private/legacy.any");
|
|
auto plaintext = QByteArray("TDE2EFTR", 8);
|
|
AppendTestUint16(plaintext, 1);
|
|
AppendTestArray(plaintext, context.conversationId.bytes);
|
|
AppendTestUint64(plaintext, 5);
|
|
plaintext.append(char(1));
|
|
AppendTestArray(plaintext, eventObjectId.bytes);
|
|
AppendTestArray(plaintext, contentObjectId.bytes);
|
|
AppendTestUint64(plaintext, 7);
|
|
AppendTestUint32(plaintext, 0);
|
|
AppendTestUint32(plaintext, std::uint32_t(sourcePath.size()));
|
|
plaintext.append(sourcePath);
|
|
AppendTestUint32(plaintext, std::uint32_t(manifest->size()));
|
|
plaintext.append(*manifest);
|
|
auto blob = MemoryBlobStore();
|
|
blob.bytes = protector.seal(
|
|
QByteArray("e2e-cloud-file-transfer-v1"),
|
|
plaintext);
|
|
auto transfer = PersistentFileTransfer(blob, protector);
|
|
if (!blob.bytes
|
|
|| transfer.load(context.conversationId)
|
|
!= FileTransferLoadResult::Loaded
|
|
|| !transfer.pending()
|
|
|| transfer.pending()->manifestPublished
|
|
|| transfer.pending()->archiveEpochGeneration
|
|
|| transfer.advance(0)
|
|
!= FileTransferCommitResult::InvalidMutation
|
|
|| transfer.markManifestPublished(3)
|
|
!= FileTransferCommitResult::Committed
|
|
|| !transfer.pending()
|
|
|| !transfer.pending()->manifestPublished
|
|
|| transfer.pending()->archiveEpochGeneration != 3
|
|
|| transfer.revision() != 6) {
|
|
return Fail("legacy file transfer bypassed manifest-first migration");
|
|
}
|
|
auto restored = PersistentFileTransfer(blob, protector);
|
|
if (restored.load(context.conversationId)
|
|
!= FileTransferLoadResult::Loaded
|
|
|| !restored.pending()
|
|
|| !restored.pending()->manifestPublished
|
|
|| restored.pending()->cancelRequested
|
|
|| restored.pending()->archiveEpochGeneration != 3) {
|
|
return Fail("migrated file transfer did not persist version three");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioPersistentDraftRoundTrip() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(8);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto store = PersistentOutboxStore(blob, protector);
|
|
if (store.load() != PersistentOutboxLoadResult::Empty
|
|
|| !store.loaded()
|
|
|| store.size()
|
|
|| store.revision()) {
|
|
return Fail("empty protected outbox did not initialize cleanly");
|
|
}
|
|
blob.writeError = true;
|
|
if (store.append(Message()) || store.size() || store.revision()) {
|
|
return Fail("failed atomic write changed the in-memory outbox");
|
|
}
|
|
blob.writeError = false;
|
|
if (!store.append(Message())
|
|
|| store.size() != 1
|
|
|| store.revision() != 1
|
|
|| !blob.bytes
|
|
|| Contains(*blob.bytes, "private draft")) {
|
|
return Fail("protected draft was not atomically encrypted at rest");
|
|
}
|
|
auto restored = PersistentOutboxStore(blob, protector);
|
|
const auto loaded = restored.load();
|
|
const auto item = restored.front(FilledId<ConversationId>(1));
|
|
if (loaded != PersistentOutboxLoadResult::Loaded
|
|
|| !item
|
|
|| item->stage != OutboxItemStage::Draft
|
|
|| item->draft.plaintext != QByteArray("private draft")
|
|
|| restored.revision() != 1) {
|
|
return Fail("protected draft did not survive authenticated reload");
|
|
}
|
|
auto second = Message();
|
|
second.objectId = FilledId<ObjectId>(3);
|
|
if (!restored.append(std::move(second)) || restored.size() != 2) {
|
|
return Fail("protected outbox pair setup failed");
|
|
}
|
|
blob.writeError = true;
|
|
if (restored.removePair(
|
|
FilledId<ObjectId>(2),
|
|
FilledId<ObjectId>(3))
|
|
|| restored.size() != 2) {
|
|
return Fail("failed outbox pair removal changed live state");
|
|
}
|
|
blob.writeError = false;
|
|
if (!restored.removePair(
|
|
FilledId<ObjectId>(2),
|
|
FilledId<ObjectId>(3))
|
|
|| restored.size()
|
|
|| restored.revision() != 3
|
|
|| !restored.removePair(
|
|
FilledId<ObjectId>(2),
|
|
FilledId<ObjectId>(3))
|
|
|| restored.revision() != 3) {
|
|
return Fail("protected outbox pair was not removed atomically");
|
|
}
|
|
auto remaining = Message();
|
|
remaining.objectId = FilledId<ObjectId>(4);
|
|
if (!restored.append(std::move(remaining)) || restored.size() != 1) {
|
|
return Fail("protected outbox clear setup failed");
|
|
}
|
|
blob.writeError = true;
|
|
if (restored.clear() || restored.size() != 1) {
|
|
return Fail("failed outbox clear changed live state");
|
|
}
|
|
blob.writeError = false;
|
|
if (!restored.clear()
|
|
|| restored.size()
|
|
|| restored.revision() != 5
|
|
|| !restored.clear()
|
|
|| restored.revision() != 5) {
|
|
return Fail("protected outbox was not cleared atomically");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioOutboxPlaintextCleanup() {
|
|
auto message = Message();
|
|
CleansePendingMessage(message);
|
|
if (!message.plaintext.isEmpty()
|
|
|| !message.authenticatedData.isEmpty()) {
|
|
return Fail("pending outbox message was not cleansed");
|
|
}
|
|
auto item = OutboxItem{
|
|
.draft = Message(),
|
|
.stage = OutboxItemStage::Draft,
|
|
.sealed = std::nullopt,
|
|
};
|
|
CleanseOutboxItem(item);
|
|
if (!item.draft.plaintext.isEmpty()
|
|
|| !item.draft.authenticatedData.isEmpty()) {
|
|
return Fail("outbox item plaintext was not cleansed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioConversationMetadataRoundTrip() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(12);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto store = PersistentConversationMetadata(blob, protector);
|
|
const auto metadata = ConversationLocalMetadata{
|
|
.conversationId = FilledId<ConversationId>(31),
|
|
.telegramPeerIdBinding = 32,
|
|
.accountId = FilledId<AccountId>(33),
|
|
.clientId = FilledId<ClientId>(34),
|
|
};
|
|
if (store.load(metadata.conversationId)
|
|
!= ConversationMetadataLoadResult::Missing
|
|
|| store.initialize(metadata)
|
|
!= ConversationMetadataCommitResult::Committed
|
|
|| !blob.bytes) {
|
|
return Fail("conversation metadata did not persist");
|
|
}
|
|
auto restored = PersistentConversationMetadata(blob, protector);
|
|
if (restored.load(metadata.conversationId)
|
|
!= ConversationMetadataLoadResult::Loaded
|
|
|| !restored.metadata()
|
|
|| *restored.metadata() != metadata
|
|
|| restored.revision() != 1
|
|
|| restored.initialize(metadata)
|
|
!= ConversationMetadataCommitResult::AlreadyCommitted) {
|
|
return Fail("conversation metadata did not round-trip exactly");
|
|
}
|
|
auto conflicting = metadata;
|
|
conflicting.clientId = FilledId<ClientId>(35);
|
|
if (restored.initialize(conflicting)
|
|
!= ConversationMetadataCommitResult::Conflict) {
|
|
return Fail("conversation metadata accepted a client identity conflict");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFreshnessTrustSurvivesRestart() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(36);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
const auto conversationId = FilledId<ConversationId>(37);
|
|
auto store = PersistentFreshnessTrust(blob, protector);
|
|
if (store.load(conversationId) != FreshnessTrustLoadResult::Missing
|
|
|| store.initialize(false) != FreshnessTrustCommitResult::Committed
|
|
|| store.trusted()
|
|
|| store.confirm() != FreshnessTrustCommitResult::Committed
|
|
|| !store.trusted()) {
|
|
return Fail("freshness trust did not commit monotonically");
|
|
}
|
|
auto restored = PersistentFreshnessTrust(blob, protector);
|
|
if (restored.load(conversationId) != FreshnessTrustLoadResult::Loaded
|
|
|| !restored.trusted()
|
|
|| restored.confirm()
|
|
!= FreshnessTrustCommitResult::AlreadyCommitted) {
|
|
return Fail("freshness trust did not survive restart");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioFreshnessResponseReplayIsPersistent() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(54);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto outboxBlob = MemoryBlobStore();
|
|
auto journalBlob = MemoryBlobStore();
|
|
auto outbox = PersistentOutboxStore(outboxBlob, protector);
|
|
auto journal = PersistentInboundJournal(
|
|
journalBlob,
|
|
protector,
|
|
InboundJournalDomain::Control);
|
|
const auto conversationId = FilledId<ConversationId>(55);
|
|
const auto challengeObjectId = FilledId<ObjectId>(56);
|
|
const auto challengeHash = FilledId<Digest>(57);
|
|
const auto response = EncodedEnvelope{
|
|
.conversationId = conversationId,
|
|
.objectId = FilledId<ObjectId>(58),
|
|
.bytes = QByteArray("exact signed freshness response"),
|
|
};
|
|
const auto queue = [&](bool alreadyPublished = false) {
|
|
return QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = challengeHash,
|
|
.responseEnvelope = response,
|
|
.responseAlreadyPublished = alreadyPublished,
|
|
}, outbox, journal);
|
|
};
|
|
if (outbox.load() != PersistentOutboxLoadResult::Empty
|
|
|| journal.load() != InboundJournalLoadResult::Missing
|
|
|| queue() != FreshnessResponseQueueResult::Queued
|
|
|| outbox.size() != 1
|
|
|| journal.lookup(
|
|
conversationId,
|
|
challengeObjectId,
|
|
challengeHash) != InboundJournalLookup::Accepted
|
|
|| queue() != FreshnessResponseQueueResult::AlreadyResponded
|
|
|| !outbox.remove(response.objectId)) {
|
|
return Fail("freshness response replay setup was not durable");
|
|
}
|
|
auto wrongDomain = PersistentInboundJournal(journalBlob, protector);
|
|
if (wrongDomain.load() != InboundJournalLoadResult::AuthenticationFailed) {
|
|
return Fail("control replay journal was swappable with content state");
|
|
}
|
|
auto restoredOutbox = PersistentOutboxStore(outboxBlob, protector);
|
|
auto restoredJournal = PersistentInboundJournal(
|
|
journalBlob,
|
|
protector,
|
|
InboundJournalDomain::Control);
|
|
if (restoredOutbox.load() != PersistentOutboxLoadResult::Loaded
|
|
|| restoredJournal.load() != InboundJournalLoadResult::Loaded
|
|
|| QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = challengeHash,
|
|
.responseEnvelope = response,
|
|
}, restoredOutbox, restoredJournal)
|
|
!= FreshnessResponseQueueResult::AlreadyResponded
|
|
|| restoredOutbox.size()
|
|
|| QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = FilledId<Digest>(59),
|
|
.responseEnvelope = response,
|
|
}, restoredOutbox, restoredJournal)
|
|
!= FreshnessResponseQueueResult::ObjectIdConflict) {
|
|
return Fail("freshness response replay survived a restart");
|
|
}
|
|
auto publishedOutboxBlob = MemoryBlobStore();
|
|
auto publishedJournalBlob = MemoryBlobStore();
|
|
auto publishedOutbox = PersistentOutboxStore(
|
|
publishedOutboxBlob,
|
|
protector);
|
|
auto publishedJournal = PersistentInboundJournal(
|
|
publishedJournalBlob,
|
|
protector,
|
|
InboundJournalDomain::Control);
|
|
if (publishedOutbox.load() != PersistentOutboxLoadResult::Empty
|
|
|| publishedJournal.load() != InboundJournalLoadResult::Missing
|
|
|| !publishedOutbox.appendSealed(response)
|
|
|| QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = challengeHash,
|
|
.responseEnvelope = response,
|
|
.responseAlreadyPublished = true,
|
|
}, publishedOutbox, publishedJournal)
|
|
!= FreshnessResponseQueueResult::AlreadyPublished
|
|
|| publishedOutbox.size()
|
|
|| publishedOutbox.revision() != 2
|
|
|| publishedJournal.lookup(
|
|
conversationId,
|
|
challengeObjectId,
|
|
challengeHash) != InboundJournalLookup::Accepted) {
|
|
return Fail("published freshness response was queued again");
|
|
}
|
|
auto failedOutboxBlob = MemoryBlobStore();
|
|
auto failedJournalBlob = MemoryBlobStore();
|
|
auto failedOutbox = PersistentOutboxStore(failedOutboxBlob, protector);
|
|
auto failedJournal = PersistentInboundJournal(
|
|
failedJournalBlob,
|
|
protector,
|
|
InboundJournalDomain::Control);
|
|
if (failedOutbox.load() != PersistentOutboxLoadResult::Empty
|
|
|| failedJournal.load() != InboundJournalLoadResult::Missing) {
|
|
return Fail("freshness replay failure fixture did not load");
|
|
}
|
|
failedJournalBlob.writeError = true;
|
|
if (QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = challengeHash,
|
|
.responseEnvelope = response,
|
|
}, failedOutbox, failedJournal)
|
|
!= FreshnessResponseQueueResult::PersistenceFailed
|
|
|| !failedOutbox.contains(response.objectId)) {
|
|
return Fail("freshness replay journal failure lost the response");
|
|
}
|
|
failedJournalBlob.writeError = false;
|
|
if (QueueFreshnessResponseOnce({
|
|
.conversationId = conversationId,
|
|
.challengeObjectId = challengeObjectId,
|
|
.challengePayloadHash = challengeHash,
|
|
.responseEnvelope = response,
|
|
}, failedOutbox, failedJournal)
|
|
!= FreshnessResponseQueueResult::AlreadyQueued
|
|
|| failedJournal.lookup(
|
|
conversationId,
|
|
challengeObjectId,
|
|
challengeHash) != InboundJournalLookup::Accepted) {
|
|
return Fail("freshness replay journal did not recover atomically");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioSealedRetrySurvivesRestart() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(9);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto store = PersistentOutboxStore(blob, protector);
|
|
if (store.load() != PersistentOutboxLoadResult::Empty
|
|
|| !store.append(Message())) {
|
|
return Fail("protected outbox setup failed");
|
|
}
|
|
const auto sealed = EncodedEnvelope{
|
|
.conversationId = FilledId<ConversationId>(1),
|
|
.objectId = FilledId<ObjectId>(2),
|
|
.bytes = QByteArray("opaque ciphertext"),
|
|
};
|
|
if (!store.replaceWithSealed(sealed.objectId, sealed)
|
|
|| store.revision() != 2) {
|
|
return Fail("sealed envelope was not persisted for exact retry");
|
|
}
|
|
auto restored = PersistentOutboxStore(blob, protector);
|
|
const auto loaded = restored.load();
|
|
const auto item = restored.front(sealed.conversationId);
|
|
if (loaded != PersistentOutboxLoadResult::Loaded
|
|
|| !item
|
|
|| item->stage != OutboxItemStage::Sealed
|
|
|| item->sealed != sealed
|
|
|| !item->draft.plaintext.isEmpty()
|
|
|| !item->draft.authenticatedData.isEmpty()) {
|
|
return Fail("sealed retry regenerated or retained plaintext after restart");
|
|
}
|
|
if (!restored.remove(sealed.objectId)
|
|
|| restored.size()
|
|
|| restored.revision() != 3) {
|
|
return Fail("uploaded protected outbox item was not atomically removed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioCorruptionFailsClosed() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(10);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto store = PersistentOutboxStore(blob, protector);
|
|
if (store.load() != PersistentOutboxLoadResult::Empty
|
|
|| !store.append(Message())
|
|
|| !blob.bytes) {
|
|
return Fail("protected outbox setup failed");
|
|
}
|
|
(*blob.bytes)[blob.bytes->size() - 1] ^= 1;
|
|
if (store.load()
|
|
!= PersistentOutboxLoadResult::AuthenticationFailed
|
|
|| store.loaded()
|
|
|| store.size()
|
|
|| store.revision()) {
|
|
return Fail(
|
|
"corrupted outbox reload retained pending plaintext");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioMlsStateTransaction() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(15);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto store = PersistentMlsStateStore(blob, protector);
|
|
const auto conversationId = FilledId<ConversationId>(12);
|
|
if (store.load(conversationId) != MlsStateLoadResult::Missing
|
|
|| !store.loaded()
|
|
|| store.revision()) {
|
|
return Fail("missing MLS state did not initialize cleanly");
|
|
}
|
|
blob.writeError = true;
|
|
if (store.initialize(
|
|
QByteArray("openmls-0.8.1-0e99bc88-rustcrypto-v1"),
|
|
QByteArray("initial secret state"))
|
|
!= MlsStateCommitResult::PersistenceFailed
|
|
|| store.revision()
|
|
|| !store.engineState().isEmpty()) {
|
|
return Fail("failed MLS initialization changed live state");
|
|
}
|
|
blob.writeError = false;
|
|
if (store.initialize(
|
|
QByteArray("openmls-0.8.1-0e99bc88-rustcrypto-v1"),
|
|
QByteArray("initial secret state"))
|
|
!= MlsStateCommitResult::Committed
|
|
|| store.revision() != 1
|
|
|| !blob.bytes
|
|
|| Contains(*blob.bytes, "initial secret state")) {
|
|
return Fail("MLS state was not atomically protected");
|
|
}
|
|
auto receipt = MlsReceipt();
|
|
receipt.envelope.conversationId = conversationId;
|
|
if (store.commit({
|
|
.baseRevision = 1,
|
|
.engineState = QByteArray("advanced secret state"),
|
|
.receipt = receipt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::Committed
|
|
|| store.revision() != 2
|
|
|| store.engineState() != QByteArray("advanced secret state")
|
|
|| store.receipt(receipt.objectId) != receipt) {
|
|
return Fail("MLS state and ciphertext receipt were not one commit");
|
|
}
|
|
if (store.commit({
|
|
.baseRevision = 1,
|
|
.engineState = QByteArray("discarded retry state"),
|
|
.receipt = receipt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::AlreadyCommitted
|
|
|| store.revision() != 2
|
|
|| store.engineState() != QByteArray("advanced secret state")) {
|
|
return Fail("MLS exact retry was not idempotent");
|
|
}
|
|
auto conflicting = receipt;
|
|
conflicting.envelope.bytes = QByteArray("different ciphertext");
|
|
if (store.commit({
|
|
.baseRevision = 2,
|
|
.engineState = QByteArray("conflicting state"),
|
|
.receipt = conflicting,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::InvalidMutation
|
|
|| store.revision() != 2) {
|
|
return Fail("MLS operation identifier accepted conflicting ciphertext");
|
|
}
|
|
blob.writeError = true;
|
|
if (store.commit({
|
|
.baseRevision = 2,
|
|
.engineState = QByteArray("uncommitted secret state"),
|
|
.receipt = std::nullopt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::PersistenceFailed
|
|
|| store.engineState() != QByteArray("advanced secret state")
|
|
|| store.revision() != 2) {
|
|
return Fail("failed MLS state write changed live ratchet state");
|
|
}
|
|
blob.writeError = false;
|
|
auto restored = PersistentMlsStateStore(blob, protector);
|
|
if (restored.load(conversationId) != MlsStateLoadResult::Loaded
|
|
|| restored.engineId()
|
|
!= QByteArray("openmls-0.8.1-0e99bc88-rustcrypto-v1")
|
|
|| restored.engineState() != QByteArray("advanced secret state")
|
|
|| restored.receipt(receipt.objectId) != receipt
|
|
|| !restored.acknowledgeReceipt(receipt.objectId)
|
|
|| restored.receiptCount()
|
|
|| restored.revision() != 3) {
|
|
return Fail("MLS transaction did not survive restart and acknowledgement");
|
|
}
|
|
const auto tombstone = MlsRemovalTombstone{
|
|
.transitionId = FilledId<ObjectId>(61),
|
|
.generation = 9,
|
|
.resultingStateHash = FilledId<Digest>(62),
|
|
.mlsCommitHash = FilledId<Digest>(63),
|
|
.removedAccountId = FilledId<AccountId>(64),
|
|
.removedClientId = FilledId<ClientId>(65),
|
|
};
|
|
if (restored.commit({
|
|
.baseRevision = restored.revision(),
|
|
.engineState = {},
|
|
.receipt = std::nullopt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = tombstone,
|
|
}) != MlsStateCommitResult::Committed
|
|
|| !restored.removed()
|
|
|| restored.removalTombstone() != tombstone
|
|
|| !restored.engineState().isEmpty()
|
|
|| restored.receiptCount()
|
|
|| restored.inboundApplicationCount()) {
|
|
return Fail("MLS removal tombstone did not replace active state");
|
|
}
|
|
auto removed = PersistentMlsStateStore(blob, protector);
|
|
if (removed.load(conversationId) != MlsStateLoadResult::Loaded
|
|
|| !removed.removed()
|
|
|| removed.removalTombstone() != tombstone
|
|
|| removed.commit({
|
|
.baseRevision = removed.revision(),
|
|
.engineState = QByteArray("must not reactivate implicitly"),
|
|
.receipt = std::nullopt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::InvalidMutation
|
|
|| removed.replaceRemovedWithKeyPackage(
|
|
removed.revision(),
|
|
QByteArray("fresh KeyPackage state"))
|
|
!= MlsStateCommitResult::Committed
|
|
|| removed.removed()
|
|
|| removed.engineState() != QByteArray("fresh KeyPackage state")) {
|
|
return Fail("MLS tombstone re-admission path failed closed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioMlsStateBindingAndCorruption() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(16);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
const auto conversationId = FilledId<ConversationId>(17);
|
|
auto store = PersistentMlsStateStore(blob, protector);
|
|
if (store.load(conversationId) != MlsStateLoadResult::Missing
|
|
|| store.initialize(
|
|
QByteArray("openmls-0.8.1-0e99bc88-rustcrypto-v1"),
|
|
QByteArray("bound state"))
|
|
!= MlsStateCommitResult::Committed
|
|
|| !blob.bytes) {
|
|
return Fail("MLS binding test setup failed");
|
|
}
|
|
auto swapped = PersistentMlsStateStore(blob, protector);
|
|
if (swapped.load(FilledId<ConversationId>(18))
|
|
!= MlsStateLoadResult::AuthenticationFailed) {
|
|
return Fail("MLS state could be swapped between conversations");
|
|
}
|
|
(*blob.bytes)[blob.bytes->size() - 1] ^= 1;
|
|
auto corrupted = PersistentMlsStateStore(blob, protector);
|
|
if (corrupted.load(conversationId)
|
|
!= MlsStateLoadResult::AuthenticationFailed
|
|
|| corrupted.loaded()
|
|
|| corrupted.revision()) {
|
|
return Fail("corrupted MLS state did not fail closed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioMlsReceiptOutboxReconciliation() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(19);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto mlsBlob = MemoryBlobStore();
|
|
auto outboxBlob = MemoryBlobStore();
|
|
auto journalBlob = MemoryBlobStore();
|
|
auto mls = PersistentMlsStateStore(mlsBlob, protector);
|
|
auto outbox = PersistentOutboxStore(outboxBlob, protector);
|
|
auto journal = PersistentInboundJournal(journalBlob, protector);
|
|
const auto envelopeCodec = EnvelopeCodecV1();
|
|
const auto envelope = Envelope();
|
|
const auto encoded = envelopeCodec.encode(envelope);
|
|
const auto conversationId = envelope.conversationId;
|
|
const auto receipt = encoded
|
|
? std::optional<MlsOperationReceipt>(MlsOperationReceipt{
|
|
.objectId = envelope.objectId,
|
|
.requestHash = FilledId<Digest>(20),
|
|
.envelope = *encoded,
|
|
})
|
|
: std::nullopt;
|
|
if (mls.load(conversationId) != MlsStateLoadResult::Missing
|
|
|| !receipt
|
|
|| mls.initialize(
|
|
QByteArray("openmls-0.8.1-0e99bc88-rustcrypto-v1"),
|
|
QByteArray("reconcile state"))
|
|
!= MlsStateCommitResult::Committed
|
|
|| mls.commit({
|
|
.baseRevision = mls.revision(),
|
|
.engineState = QByteArray("reconcile advanced state"),
|
|
.receipt = *receipt,
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::Committed
|
|
|| outbox.load() != PersistentOutboxLoadResult::Empty
|
|
|| journal.load() != InboundJournalLoadResult::Missing
|
|
|| !outbox.append({
|
|
.conversationId = conversationId,
|
|
.objectId = receipt->objectId,
|
|
.plaintext = QByteArray("descriptor"),
|
|
.authenticatedData = {},
|
|
})) {
|
|
return Fail("MLS receipt reconciliation setup failed");
|
|
}
|
|
if (ReconcileMlsOutboxReceipts(
|
|
mls,
|
|
outbox,
|
|
envelopeCodec,
|
|
journal)
|
|
!= MlsReceiptReconcileResult::NothingToDo
|
|
|| mls.receiptCount() != 1
|
|
|| !outbox.remove(receipt->objectId)
|
|
|| ReconcileMlsOutboxReceipts(
|
|
mls,
|
|
outbox,
|
|
envelopeCodec,
|
|
journal)
|
|
!= MlsReceiptReconcileResult::Reconciled
|
|
|| mls.receiptCount()
|
|
|| journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Accepted) {
|
|
return Fail("MLS receipt was not tied to durable outbox delivery");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioObservedMlsReceiptCrashRecovery() {
|
|
enum class JournalPhase {
|
|
Missing,
|
|
Pending,
|
|
Accepted,
|
|
};
|
|
enum class Failure {
|
|
None,
|
|
Journal,
|
|
MlsState,
|
|
Substitution,
|
|
};
|
|
const auto run = [&](JournalPhase phase, Failure failure) {
|
|
auto key = LocalRecordKey();
|
|
key.fill(21);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto envelopeCodec = EnvelopeCodecV1();
|
|
const auto envelope = Envelope();
|
|
const auto encoded = envelopeCodec.encode(envelope);
|
|
auto mlsBlob = MemoryBlobStore();
|
|
auto journalBlob = MemoryBlobStore();
|
|
auto mlsState = PersistentMlsStateStore(mlsBlob, protector);
|
|
auto journal = PersistentInboundJournal(journalBlob, protector);
|
|
if (!encoded
|
|
|| mlsState.load(envelope.conversationId)
|
|
!= MlsStateLoadResult::Missing
|
|
|| mlsState.initialize(
|
|
QByteArray("test-engine"),
|
|
QByteArray("initial state"))
|
|
!= MlsStateCommitResult::Committed
|
|
|| mlsState.commit({
|
|
.baseRevision = mlsState.revision(),
|
|
.engineState = QByteArray("state after local seal"),
|
|
.receipt = MlsOperationReceipt{
|
|
.objectId = envelope.objectId,
|
|
.requestHash = FilledId<Digest>(22),
|
|
.envelope = *encoded,
|
|
},
|
|
.inboundApplication = std::nullopt,
|
|
.removalTombstone = std::nullopt,
|
|
}) != MlsStateCommitResult::Committed
|
|
|| journal.load() != InboundJournalLoadResult::Missing) {
|
|
return false;
|
|
}
|
|
if (phase != JournalPhase::Missing && !journal.begin(envelope)) {
|
|
return false;
|
|
}
|
|
if (phase == JournalPhase::Accepted
|
|
&& !journal.accept(
|
|
envelope.conversationId,
|
|
envelope.objectId)) {
|
|
return false;
|
|
}
|
|
auto observed = envelope;
|
|
if (failure == Failure::Journal) {
|
|
journalBlob.writeError = true;
|
|
} else if (failure == Failure::MlsState) {
|
|
mlsBlob.writeError = true;
|
|
} else if (failure == Failure::Substitution) {
|
|
observed.authenticationData.append('x');
|
|
}
|
|
const auto first = ReconcileObservedMlsReceipt(
|
|
observed,
|
|
envelopeCodec,
|
|
mlsState,
|
|
journal);
|
|
if (failure == Failure::Substitution) {
|
|
return first
|
|
== ObservedMlsReceiptReconcileResult::ObjectIdConflict
|
|
&& mlsState.receiptCount() == 1
|
|
&& journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) == InboundJournalLookup::Missing;
|
|
}
|
|
const auto failed = failure == Failure::Journal
|
|
? ObservedMlsReceiptReconcileResult::JournalFailure
|
|
: ObservedMlsReceiptReconcileResult::MlsStateFailure;
|
|
if (failure != Failure::None) {
|
|
if (first != failed || mlsState.receiptCount() != 1) {
|
|
return false;
|
|
}
|
|
journalBlob.writeError = false;
|
|
mlsBlob.writeError = false;
|
|
if (ReconcileObservedMlsReceipt(
|
|
envelope,
|
|
envelopeCodec,
|
|
mlsState,
|
|
journal)
|
|
!= ObservedMlsReceiptReconcileResult::Reconciled) {
|
|
return false;
|
|
}
|
|
} else if (first
|
|
!= ObservedMlsReceiptReconcileResult::Reconciled) {
|
|
return false;
|
|
}
|
|
return !mlsState.receiptCount()
|
|
&& journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) == InboundJournalLookup::Accepted;
|
|
};
|
|
if (!run(JournalPhase::Missing, Failure::None)
|
|
|| !run(JournalPhase::Pending, Failure::None)
|
|
|| !run(JournalPhase::Accepted, Failure::None)
|
|
|| !run(JournalPhase::Pending, Failure::Journal)
|
|
|| !run(JournalPhase::Accepted, Failure::MlsState)
|
|
|| !run(JournalPhase::Missing, Failure::Substitution)) {
|
|
return Fail("observed MLS receipt did not close every crash window");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioInboundJournalSurvivesRestart() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(11);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto journal = PersistentInboundJournal(blob, protector);
|
|
const auto envelope = Envelope();
|
|
if (journal.load() != InboundJournalLoadResult::Missing
|
|
|| journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Missing
|
|
|| !journal.begin(envelope)
|
|
|| journal.revision() != 1
|
|
|| journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Pending) {
|
|
return Fail("inbound journal did not persist its pending phase");
|
|
}
|
|
auto concurrent = envelope;
|
|
concurrent.objectId = FilledId<ObjectId>(6);
|
|
concurrent.payloadHash = FilledId<Digest>(7);
|
|
if (!journal.begin(concurrent)
|
|
|| journal.lookup(
|
|
concurrent.conversationId,
|
|
concurrent.objectId,
|
|
concurrent.payloadHash) != InboundJournalLookup::Pending
|
|
|| !journal.abort(
|
|
concurrent.conversationId,
|
|
concurrent.objectId)
|
|
|| journal.lookup(
|
|
concurrent.conversationId,
|
|
concurrent.objectId,
|
|
concurrent.payloadHash) != InboundJournalLookup::Missing) {
|
|
return Fail("inbound journal could not recover independent operations");
|
|
}
|
|
auto pending = PersistentInboundJournal(blob, protector);
|
|
if (pending.load() != InboundJournalLoadResult::Loaded
|
|
|| pending.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Pending
|
|
|| !pending.accept(envelope.conversationId, envelope.objectId)
|
|
|| pending.revision() != 4) {
|
|
return Fail("inbound journal lost pending crash recovery state");
|
|
}
|
|
auto accepted = PersistentInboundJournal(blob, protector);
|
|
if (accepted.load() != InboundJournalLoadResult::Loaded
|
|
|| accepted.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Accepted
|
|
|| accepted.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
FilledId<Digest>(9))
|
|
!= InboundJournalLookup::ObjectIdConflict) {
|
|
return Fail("inbound journal did not retain accepted replay state");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioInboundJournalWriteFailureIsTransactional() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(12);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto journal = PersistentInboundJournal(blob, protector);
|
|
const auto envelope = Envelope();
|
|
if (journal.load() != InboundJournalLoadResult::Missing) {
|
|
return Fail("inbound journal setup failed");
|
|
}
|
|
blob.writeError = true;
|
|
if (journal.begin(envelope)
|
|
|| journal.size()
|
|
|| journal.revision()
|
|
|| journal.lookup(
|
|
envelope.conversationId,
|
|
envelope.objectId,
|
|
envelope.payloadHash) != InboundJournalLookup::Missing) {
|
|
return Fail("failed inbound journal write changed live state");
|
|
}
|
|
blob.writeError = false;
|
|
if (!journal.begin(envelope)) {
|
|
return Fail("transient inbound journal write failure poisoned storage");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioInboundJournalCorruptionFailsClosed() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(13);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
auto blob = MemoryBlobStore();
|
|
auto journal = PersistentInboundJournal(blob, protector);
|
|
if (journal.load() != InboundJournalLoadResult::Missing
|
|
|| !journal.begin(Envelope())
|
|
|| !blob.bytes) {
|
|
return Fail("inbound journal corruption setup failed");
|
|
}
|
|
(*blob.bytes)[blob.bytes->size() - 1] ^= 1;
|
|
if (journal.load()
|
|
!= InboundJournalLoadResult::AuthenticationFailed
|
|
|| journal.size()
|
|
|| journal.revision()
|
|
|| journal.lookup(
|
|
FilledId<ConversationId>(1),
|
|
FilledId<ObjectId>(4),
|
|
FilledId<Digest>(5)) != InboundJournalLookup::StorageError) {
|
|
return Fail("corrupted inbound journal did not fail closed");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
[[nodiscard]] int ScenarioKeyPackagePoolRetainsReplacementSecrets() {
|
|
auto key = LocalRecordKey();
|
|
key.fill(15);
|
|
const auto protector = AesGcmLocalRecordProtector(std::move(key));
|
|
const auto envelopeCodec = EnvelopeCodecV1();
|
|
const auto sha256 = OpenSslSha256Provider();
|
|
const auto conversationId = FilledId<ConversationId>(61);
|
|
const auto clientId = FilledId<ClientId>(62);
|
|
const auto peerBinding = std::uint64_t(63);
|
|
const auto generation = std::uint64_t(7);
|
|
const auto createdAt = std::uint64_t(1'000'000);
|
|
const auto telegramUserIdBinding = std::uint64_t(7001);
|
|
auto identity = GenerateAccountPrivateIdentity();
|
|
const auto accountId = identity
|
|
? DeriveAccountId(identity->credential, sha256)
|
|
: std::nullopt;
|
|
if (!identity || !accountId) {
|
|
return Fail("KeyPackage pool identity setup failed");
|
|
}
|
|
const auto makeEntry = [&](const char *package) {
|
|
const auto keyPackage = QByteArray(package);
|
|
const auto objectId = DeriveClientKeyPackageObjectId(
|
|
conversationId,
|
|
*accountId,
|
|
clientId,
|
|
generation,
|
|
telegramUserIdBinding,
|
|
identity->credential,
|
|
keyPackage,
|
|
sha256);
|
|
const auto authorization = objectId
|
|
? CreateClientAuthorizationProof({
|
|
.conversationId = conversationId,
|
|
.authorizationId = *objectId,
|
|
.accountId = *accountId,
|
|
.clientId = clientId,
|
|
.requestedAfterGeneration = generation,
|
|
.createdAt = createdAt,
|
|
.accountCredential = &identity->credential,
|
|
.accountSigningPrivateKey = &identity->signingPrivateKey,
|
|
.keyPackage = keyPackage,
|
|
}, sha256)
|
|
: std::nullopt;
|
|
const auto payload = authorization
|
|
? ClientKeyPackagePublicationCodecV1().encode({
|
|
.accountCredential = identity->credential,
|
|
.authorization = *authorization,
|
|
.keyPackage = keyPackage,
|
|
})
|
|
: std::nullopt;
|
|
const auto signature = authorization
|
|
? QByteArray(
|
|
reinterpret_cast<const char*>(
|
|
authorization->signature.data()),
|
|
int(authorization->signature.size()))
|
|
: QByteArray();
|
|
const auto envelope = payload
|
|
? envelopeCodec.encode({
|
|
.conversationId = conversationId,
|
|
.objectKind = ObjectKind::ClientKeyPackage,
|
|
.senderAccountId = *accountId,
|
|
.senderClientId = clientId,
|
|
.telegramPeerIdBinding = peerBinding,
|
|
.epochOrGeneration = generation,
|
|
.objectId = *objectId,
|
|
.payloadHash = sha256.digest(*payload),
|
|
.payload = *payload,
|
|
.authenticationData = signature,
|
|
})
|
|
: std::nullopt;
|
|
return envelope
|
|
? std::optional<StoredClientKeyPackage>({
|
|
.keyPackageHash = sha256.digest(keyPackage),
|
|
.createdAt = createdAt,
|
|
.expiresAt = createdAt
|
|
+ kOpenMlsKeyPackageLifetimeSeconds,
|
|
.privateEngineState = QByteArray("private-") + keyPackage,
|
|
.publicationEnvelope = *envelope,
|
|
.queued = false,
|
|
})
|
|
: std::nullopt;
|
|
};
|
|
auto first = makeEntry("first-public-package");
|
|
auto second = makeEntry("replacement-public-package");
|
|
const auto observed = first
|
|
? VerifyObservedClientKeyPackage({
|
|
.bytes = first->publicationEnvelope.bytes,
|
|
.observedTelegramPeerIdBinding = peerBinding,
|
|
.observedSenderTelegramUserIdBinding = telegramUserIdBinding,
|
|
.observedMessageId = 91,
|
|
},
|
|
conversationId,
|
|
peerBinding,
|
|
generation,
|
|
createdAt,
|
|
envelopeCodec,
|
|
sha256)
|
|
: VerifyObservedClientKeyPackageOutcome();
|
|
const auto substitutedAuthor = first
|
|
? VerifyObservedClientKeyPackage({
|
|
.bytes = first->publicationEnvelope.bytes,
|
|
.observedTelegramPeerIdBinding = peerBinding,
|
|
.observedSenderTelegramUserIdBinding
|
|
= telegramUserIdBinding + 1,
|
|
.observedMessageId = 91,
|
|
},
|
|
conversationId,
|
|
peerBinding,
|
|
generation,
|
|
createdAt,
|
|
envelopeCodec,
|
|
sha256)
|
|
: VerifyObservedClientKeyPackageOutcome();
|
|
const auto wrongCarrier = first
|
|
? VerifyObservedClientKeyPackage({
|
|
.bytes = first->publicationEnvelope.bytes,
|
|
.observedTelegramPeerIdBinding = peerBinding + 1,
|
|
.observedSenderTelegramUserIdBinding = telegramUserIdBinding,
|
|
.observedMessageId = 91,
|
|
},
|
|
conversationId,
|
|
peerBinding,
|
|
generation,
|
|
createdAt,
|
|
envelopeCodec,
|
|
sha256)
|
|
: VerifyObservedClientKeyPackageOutcome();
|
|
if (observed.status != ObservedClientKeyPackageStatus::Verified
|
|
|| !observed.verified
|
|
|| observed.verified->telegramUserIdBinding
|
|
!= telegramUserIdBinding
|
|
|| observed.verified->publication.authorization.clientId != clientId
|
|
|| substitutedAuthor.status
|
|
!= ObservedClientKeyPackageStatus::InvalidTelegramAuthorBinding
|
|
|| wrongCarrier.status
|
|
!= ObservedClientKeyPackageStatus::InvalidTransportMetadata) {
|
|
return Fail("KeyPackage did not bind observed Telegram authorship");
|
|
}
|
|
auto poolBlob = MemoryBlobStore();
|
|
auto pool = PersistentKeyPackagePool(
|
|
poolBlob,
|
|
protector,
|
|
envelopeCodec,
|
|
sha256);
|
|
if (!first
|
|
|| !second
|
|
|| pool.load(conversationId, peerBinding)
|
|
!= KeyPackagePoolLoadResult::Empty
|
|
|| pool.add(*first) != KeyPackagePoolMutationResult::Committed
|
|
|| pool.add(*second) != KeyPackagePoolMutationResult::Committed
|
|
|| pool.entries().size() != 2
|
|
|| pool.needsRefresh(createdAt, generation)
|
|
|| !pool.find(first->keyPackageHash, createdAt)) {
|
|
return Fail("KeyPackage pool did not retain valid replacements");
|
|
}
|
|
auto outboxBlob = MemoryBlobStore();
|
|
auto outbox = PersistentOutboxStore(outboxBlob, protector);
|
|
if (outbox.load() != PersistentOutboxLoadResult::Empty
|
|
|| pool.enqueuePending(outbox, createdAt)
|
|
!= KeyPackagePoolEnqueueResult::Queued
|
|
|| outbox.size() != 2
|
|
|| std::any_of(
|
|
begin(pool.entries()),
|
|
end(pool.entries()),
|
|
[](const StoredClientKeyPackage &entry) {
|
|
return !entry.queued;
|
|
})) {
|
|
return Fail("KeyPackage pool did not reconcile its exact publications");
|
|
}
|
|
auto restored = PersistentKeyPackagePool(
|
|
poolBlob,
|
|
protector,
|
|
envelopeCodec,
|
|
sha256);
|
|
if (restored.load(conversationId, peerBinding)
|
|
!= KeyPackagePoolLoadResult::Loaded
|
|
|| restored.entries().size() != 2
|
|
|| !restored.find(second->keyPackageHash, createdAt)
|
|
|| restored.consume(second->keyPackageHash)
|
|
!= KeyPackagePoolMutationResult::Committed
|
|
|| !restored.entries().empty()) {
|
|
return Fail("KeyPackage pool did not consume all obsolete secrets");
|
|
}
|
|
auto consumed = PersistentKeyPackagePool(
|
|
poolBlob,
|
|
protector,
|
|
envelopeCodec,
|
|
sha256);
|
|
if (consumed.load(conversationId, peerBinding)
|
|
!= KeyPackagePoolLoadResult::Loaded
|
|
|| !consumed.entries().empty()
|
|
|| !consumed.needsRefresh(createdAt, generation)) {
|
|
return Fail("consumed KeyPackage secrets survived restart");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int, char *[]) {
|
|
for (const auto scenario : {
|
|
ScenarioAeadProtection,
|
|
ScenarioAtomicBlobRejectsOversizedFile,
|
|
ScenarioConversationRecordKeyDerivation,
|
|
ScenarioContentSyncBoundarySurvivesRestart,
|
|
ScenarioControlObservationStateSurvivesRestart,
|
|
ScenarioLegacyControlBoundaryMigrates,
|
|
ScenarioContentStoreReloadFailureClearsPlaintext,
|
|
ScenarioContentStoreAcceptsCarrierDuplicates,
|
|
ScenarioContentStorePagesAndMigratesLegacyIndex,
|
|
ScenarioFileTransferSurvivesRestart,
|
|
ScenarioLegacyFileTransferPublishesManifestFirst,
|
|
ScenarioConversationMetadataRoundTrip,
|
|
ScenarioFreshnessTrustSurvivesRestart,
|
|
ScenarioFreshnessResponseReplayIsPersistent,
|
|
ScenarioPersistentDraftRoundTrip,
|
|
ScenarioOutboxPlaintextCleanup,
|
|
ScenarioSealedRetrySurvivesRestart,
|
|
ScenarioCorruptionFailsClosed,
|
|
ScenarioMlsStateTransaction,
|
|
ScenarioMlsStateBindingAndCorruption,
|
|
ScenarioMlsReceiptOutboxReconciliation,
|
|
ScenarioObservedMlsReceiptCrashRecovery,
|
|
ScenarioInboundJournalSurvivesRestart,
|
|
ScenarioInboundJournalWriteFailureIsTransactional,
|
|
ScenarioInboundJournalCorruptionFailsClosed,
|
|
ScenarioKeyPackagePoolRetainsReplacementSecrets,
|
|
}) {
|
|
if (const auto result = scenario()) {
|
|
return result;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|