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>
158 lines
5.7 KiB
C++
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
|