Skip to content

Commit 26a18e6

Browse files
committed
#3598 encryption: add new note encryption with portable versioned encryption envelope
Signed-off-by: Patrizio Bekerle <patrizio@bekerle.com>
1 parent 5d8ef09 commit 26a18e6

6 files changed

Lines changed: 335 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 26.5.3
44

5+
- New note encryption now writes a portable versioned encryption envelope with
6+
per-note salt and nonce metadata, derives keys with Botan PBKDF2-HMAC-SHA1,
7+
and encrypts with AES-256-CBC plus HMAC authentication while keeping legacy
8+
encrypted notes decryptable (for [#3598](https://github.com/pbek/QOwnNotes/issues/3598))
59
- Secrets such as cloud connection passwords, proxy passwords, API keys, and
610
scripting secret settings are now stored in the operating system keychain via
711
qtkeychain instead of being obfuscated with SimpleCrypt in the settings file;

src/entities/note.cpp

Lines changed: 132 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ struct ParsedWikiLink {
7171
WikiLinkParts parts;
7272
};
7373

74+
struct NoteEncryptionEnvelope {
75+
bool isV2 = false;
76+
QString cipherText;
77+
QString kdf;
78+
QString cipher;
79+
QString salt;
80+
QString nonce;
81+
QString mac;
82+
size_t iterations = 0;
83+
};
84+
85+
constexpr int NoteEncryptionVersion = 2;
86+
constexpr int NoteEncryptionSaltBytes = 32;
87+
constexpr int NoteEncryptionNonceBytes = 16;
88+
constexpr size_t NoteEncryptionKdfIterations = 300000;
89+
const auto NoteEncryptionKdf = QStringLiteral("PBKDF2-HMAC-SHA1");
90+
const auto NoteEncryptionCipher = QStringLiteral("AES-256-CBC-PKCS7-HMAC-SHA1");
91+
7492
static const QRegularExpression &wikiLinkRegex() {
7593
static const QRegularExpression regex(QStringLiteral(R"(\[\[([^\[\]]+?)\]\])"));
7694
return regex;
@@ -317,6 +335,79 @@ static QString postProcessWikiLinksHtml(QString html, int currentNoteSubFolderId
317335

318336
return html;
319337
}
338+
339+
static NoteEncryptionEnvelope parseNoteEncryptionEnvelope(const QString &payload) {
340+
NoteEncryptionEnvelope envelope;
341+
envelope.cipherText = payload.trimmed();
342+
343+
const QStringList lines = payload.trimmed().split(QChar('\n'));
344+
if (lines.isEmpty() || lines.constFirst().trimmed() != QStringLiteral("qon-crypto: 2")) {
345+
return envelope;
346+
}
347+
348+
QHash<QString, QString> metadata;
349+
int cipherTextLine = -1;
350+
for (int i = 0; i < lines.count(); ++i) {
351+
const QString line = lines.at(i).trimmed();
352+
if (line.isEmpty()) {
353+
cipherTextLine = i + 1;
354+
break;
355+
}
356+
357+
const int separatorIndex = line.indexOf(QChar(':'));
358+
if (separatorIndex <= 0) {
359+
return envelope;
360+
}
361+
362+
metadata.insert(line.left(separatorIndex).trimmed(),
363+
line.mid(separatorIndex + 1).trimmed());
364+
}
365+
366+
if (cipherTextLine < 0 || cipherTextLine >= lines.count()) {
367+
return envelope;
368+
}
369+
370+
bool iterationsOk = false;
371+
const size_t iterations =
372+
metadata.value(QStringLiteral("kdf-iterations")).toULongLong(&iterationsOk);
373+
const QString cipherText = lines.mid(cipherTextLine).join(QChar('\n')).trimmed();
374+
375+
if (!iterationsOk ||
376+
metadata.value(QStringLiteral("qon-crypto")) != QString::number(NoteEncryptionVersion) ||
377+
metadata.value(QStringLiteral("kdf")) != NoteEncryptionKdf ||
378+
metadata.value(QStringLiteral("cipher")) != NoteEncryptionCipher ||
379+
metadata.value(QStringLiteral("salt")).isEmpty() ||
380+
metadata.value(QStringLiteral("nonce")).isEmpty() ||
381+
metadata.value(QStringLiteral("mac")).isEmpty() || cipherText.isEmpty()) {
382+
return envelope;
383+
}
384+
385+
envelope.isV2 = true;
386+
envelope.cipherText = cipherText;
387+
envelope.kdf = metadata.value(QStringLiteral("kdf"));
388+
envelope.cipher = metadata.value(QStringLiteral("cipher"));
389+
envelope.salt = metadata.value(QStringLiteral("salt"));
390+
envelope.nonce = metadata.value(QStringLiteral("nonce"));
391+
envelope.mac = metadata.value(QStringLiteral("mac"));
392+
envelope.iterations = iterations;
393+
return envelope;
394+
}
395+
396+
static QString buildNoteEncryptionEnvelope(const QString &cipherText, const QString &salt,
397+
const QString &nonce, const QString &mac) {
398+
return QStringLiteral(
399+
"qon-crypto: %1\n"
400+
"kdf: %2\n"
401+
"kdf-iterations: %3\n"
402+
"salt: %4\n"
403+
"cipher: %5\n"
404+
"nonce: %6\n"
405+
"mac: %7\n\n"
406+
"%8")
407+
.arg(QString::number(NoteEncryptionVersion), NoteEncryptionKdf,
408+
QString::number(NoteEncryptionKdfIterations), salt, NoteEncryptionCipher, nonce, mac,
409+
cipherText);
410+
}
320411
} // namespace
321412

322413
int Note::getId() const { return this->_id; }
@@ -4211,8 +4302,14 @@ QString Note::encryptNoteText() {
42114302
// encrypt the text
42124303
BotanWrapper botanWrapper;
42134304
botanWrapper.setPassword(_cryptoPassword);
4214-
botanWrapper.setSalt(QStringLiteral(BOTAN_SALT));
4215-
encryptedText = botanWrapper.Encrypt(text);
4305+
const QString salt = BotanWrapper::randomBytesBase64(NoteEncryptionSaltBytes);
4306+
const QString nonce = BotanWrapper::randomBytesBase64(NoteEncryptionNonceBytes);
4307+
QString mac;
4308+
encryptedText =
4309+
botanWrapper.EncryptV2(text, salt, nonce, NoteEncryptionKdfIterations, &mac);
4310+
if (!encryptedText.isEmpty()) {
4311+
encryptedText = buildNoteEncryptionEnvelope(encryptedText, salt, nonce, mac);
4312+
}
42164313

42174314
// SimpleCrypt *crypto = new
42184315
// SimpleCrypt(static_cast<quint64>(cryptoKey)); QString
@@ -4292,27 +4389,7 @@ bool Note::canDecryptNoteText() const {
42924389
return false;
42934390
}
42944391

4295-
// check if we have an external decryption method
4296-
QString decryptedNoteText =
4297-
ScriptingService::instance()->callEncryptionHook(encryptedNoteText, _cryptoPassword, true);
4298-
4299-
// check if a hook changed the text
4300-
if (decryptedNoteText.isEmpty()) {
4301-
// decrypt the note text with Botan
4302-
BotanWrapper botanWrapper;
4303-
botanWrapper.setPassword(_cryptoPassword);
4304-
botanWrapper.setSalt(QStringLiteral(BOTAN_SALT));
4305-
decryptedNoteText = botanWrapper.Decrypt(encryptedNoteText);
4306-
4307-
// fallback to SimpleCrypt
4308-
if (decryptedNoteText.isEmpty()) {
4309-
auto *crypto = new SimpleCrypt(static_cast<quint64>(_cryptoKey));
4310-
decryptedNoteText = crypto->decryptToString(encryptedNoteText);
4311-
delete crypto;
4312-
}
4313-
}
4314-
4315-
return !decryptedNoteText.isEmpty();
4392+
return !decryptEncryptedNoteText(encryptedNoteText).isEmpty();
43164393
}
43174394

43184395
/**
@@ -4354,24 +4431,7 @@ QString Note::getDecryptedNoteText(
43544431
const QString &encryptedNoteText) const { // check if we have an external decryption method
43554432
QString noteText = getNoteText();
43564433

4357-
QString decryptedNoteText =
4358-
ScriptingService::instance()->callEncryptionHook(encryptedNoteText, _cryptoPassword, true);
4359-
4360-
// Check if a hook changed the text
4361-
if (decryptedNoteText.isEmpty()) {
4362-
// Decrypt the note text
4363-
BotanWrapper botanWrapper;
4364-
botanWrapper.setPassword(_cryptoPassword);
4365-
botanWrapper.setSalt(QStringLiteral(BOTAN_SALT));
4366-
decryptedNoteText = botanWrapper.Decrypt(encryptedNoteText);
4367-
4368-
// Fallback to SimpleCrypt
4369-
if (decryptedNoteText.isEmpty()) {
4370-
auto *crypto = new SimpleCrypt(static_cast<quint64>(_cryptoKey));
4371-
decryptedNoteText = crypto->decryptToString(encryptedNoteText);
4372-
delete crypto;
4373-
}
4374-
}
4434+
const QString decryptedNoteText = decryptEncryptedNoteText(encryptedNoteText);
43754435

43764436
if (decryptedNoteText.isEmpty()) {
43774437
return noteText;
@@ -4385,6 +4445,37 @@ QString Note::getDecryptedNoteText(
43854445
return noteText;
43864446
}
43874447

4448+
QString Note::decryptEncryptedNoteText(const QString &encryptedNoteText) const {
4449+
QString decryptedNoteText =
4450+
ScriptingService::instance()->callEncryptionHook(encryptedNoteText, _cryptoPassword, true);
4451+
4452+
if (!decryptedNoteText.isEmpty()) {
4453+
return decryptedNoteText;
4454+
}
4455+
4456+
const NoteEncryptionEnvelope envelope = parseNoteEncryptionEnvelope(encryptedNoteText);
4457+
BotanWrapper botanWrapper;
4458+
botanWrapper.setPassword(_cryptoPassword);
4459+
4460+
if (envelope.isV2) {
4461+
return botanWrapper.DecryptV2(envelope.cipherText, envelope.salt, envelope.nonce,
4462+
envelope.mac, envelope.iterations);
4463+
}
4464+
4465+
botanWrapper.setSalt(QStringLiteral(BOTAN_SALT));
4466+
decryptedNoteText = botanWrapper.Decrypt(encryptedNoteText);
4467+
4468+
if (!decryptedNoteText.isEmpty()) {
4469+
return decryptedNoteText;
4470+
}
4471+
4472+
auto *crypto = new SimpleCrypt(static_cast<quint64>(_cryptoKey));
4473+
decryptedNoteText = crypto->decryptToString(encryptedNoteText);
4474+
delete crypto;
4475+
4476+
return decryptedNoteText;
4477+
}
4478+
43884479
/**
43894480
* Expire crypto keys in the database after 10min
43904481
*/

src/entities/note.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ class Note {
427427
const QString &connectionName = QStringLiteral("memory"));
428428

429429
[[nodiscard]] QString getDecryptedNoteText(const QString &encryptedNoteText) const;
430+
[[nodiscard]] QString decryptEncryptedNoteText(const QString &encryptedNoteText) const;
430431

431432
static QString parseEncryptedNoteText(const QString &noteText);
432433
static bool isEncryptedTextBegin(const QString &text);

0 commit comments

Comments
 (0)