ultimate-media-tracker/src/sync/CryptoEnvelope.cpp
Tronax de353f0218
feat: add Android app and end-to-end encrypted library sync
Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 00:35:54 +02:00

158 lines
5.7 KiB
C++

#include "sync/CryptoEnvelope.h"
#include <QtEndian>
#include <QtGlobal>
#include <sodium.h>
namespace umt {
namespace CryptoEnvelope {
namespace {
const char MAGIC[4] = { 'U', 'M', 'T', 'S' };
const quint8 VERSION = 1;
constexpr int SALT_BYTES = 16; // crypto_pwhash_SALTBYTES
constexpr int NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
constexpr int KEY_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES
// libsodium INTERACTIVE limits — a balance of security and CPU cost. Must match
// the Android client so snapshots are interchangeable.
constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
void setError(QString *error, const QString &msg) {
if (error) *error = msg;
}
bool ensureInit(QString *error) {
static const int rc = sodium_init();
if (rc < 0) {
setError(error, QStringLiteral("Krypto-Bibliothek konnte nicht initialisiert werden"));
return false;
}
return true;
}
QByteArray deriveKey(const QString &passphrase, const QByteArray &salt,
quint64 ops, quint64 mem, QString *error) {
const QByteArray pw = passphrase.toUtf8();
QByteArray key(KEY_BYTES, Qt::Uninitialized);
const int rc = crypto_pwhash(
reinterpret_cast<unsigned char *>(key.data()), KEY_BYTES,
pw.constData(), static_cast<unsigned long long>(pw.size()),
reinterpret_cast<const unsigned char *>(salt.constData()),
ops, static_cast<size_t>(mem),
crypto_pwhash_ALG_ARGON2ID13);
if (rc != 0) {
setError(error, QStringLiteral("Schlüsselableitung fehlgeschlagen"));
return {};
}
return key;
}
} // namespace
QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
QString *error) {
if (!ensureInit(error)) return {};
QByteArray salt(SALT_BYTES, Qt::Uninitialized);
randombytes_buf(salt.data(), SALT_BYTES);
QByteArray nonce(NONCE_BYTES, Qt::Uninitialized);
randombytes_buf(nonce.data(), NONCE_BYTES);
const QByteArray key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT, error);
if (key.isEmpty()) return {};
QByteArray cipher(plaintext.size() + ABYTES, Qt::Uninitialized);
unsigned long long cipherLen = 0;
const int rc = crypto_aead_xchacha20poly1305_ietf_encrypt(
reinterpret_cast<unsigned char *>(cipher.data()), &cipherLen,
reinterpret_cast<const unsigned char *>(plaintext.constData()),
static_cast<unsigned long long>(plaintext.size()),
nullptr, 0, // no associated data
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Verschlüsselung fehlgeschlagen"));
return {};
}
cipher.resize(static_cast<int>(cipherLen));
QByteArray out;
out.reserve(HEADER_BYTES + cipher.size());
out.append(MAGIC, 4);
out.append(static_cast<char>(VERSION));
quint32 opsBE = qToBigEndian<quint32>(static_cast<quint32>(OPSLIMIT));
quint32 memBE = qToBigEndian<quint32>(static_cast<quint32>(MEMLIMIT));
out.append(reinterpret_cast<const char *>(&opsBE), 4);
out.append(reinterpret_cast<const char *>(&memBE), 4);
out.append(salt);
out.append(nonce);
out.append(cipher);
return out;
}
QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
QString *error) {
if (!ensureInit(error)) return {};
if (envelope.size() < HEADER_BYTES) {
setError(error, QStringLiteral("Daten unvollständig"));
return {};
}
const char *p = envelope.constData();
if (qstrncmp(p, MAGIC, 4) != 0) {
setError(error, QStringLiteral("Unbekanntes Format"));
return {};
}
int off = 4;
const quint8 version = static_cast<quint8>(p[off]); off += 1;
if (version != VERSION) {
setError(error, QStringLiteral("Nicht unterstützte Version: %1").arg(version));
return {};
}
quint32 opsBE, memBE;
memcpy(&opsBE, p + off, 4); off += 4;
memcpy(&memBE, p + off, 4); off += 4;
const quint64 ops = qFromBigEndian<quint32>(opsBE);
const quint64 mem = qFromBigEndian<quint32>(memBE);
const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
const QByteArray cipher = envelope.mid(off);
if (cipher.size() < ABYTES) {
setError(error, QStringLiteral("Daten unvollständig"));
return {};
}
const QByteArray key = deriveKey(passphrase, salt, ops, mem, error);
if (key.isEmpty()) return {};
QByteArray plain(cipher.size() - ABYTES, Qt::Uninitialized);
unsigned long long plainLen = 0;
const int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
reinterpret_cast<unsigned char *>(plain.data()), &plainLen,
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(cipher.constData()),
static_cast<unsigned long long>(cipher.size()),
nullptr, 0, // no associated data
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Entschlüsselung fehlgeschlagen (falsche Passphrase?)"));
return {};
}
plain.resize(static_cast<int>(plainLen));
return plain;
}
} // namespace CryptoEnvelope
} // namespace umt