From b124f57dd9979fb7d34c424e1fa835aa29c75646 Mon Sep 17 00:00:00 2001 From: Janek Bevendorff Date: Sat, 17 Jan 2026 14:19:53 +0100 Subject: [PATCH 01/14] Advance vcpkg baseline --- vcpkg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vcpkg.json b/vcpkg.json index c3cbf96c3..684cb977f 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,7 +1,7 @@ { "name": "keepassxc", "version-string": "2.7.11", - "builtin-baseline": "dfb72f61c5a066ab75cd0bdcb2e007228bfc3270", + "builtin-baseline": "66c0373dc7fca549e5803087b9487edfe3aca0a1", "dependencies": [ { "name": "argon2", From be1efffde7b6bcce8abbea4ee09fb18ad06d623d Mon Sep 17 00:00:00 2001 From: Janek Bevendorff Date: Fri, 6 Mar 2026 23:59:02 +0100 Subject: [PATCH 02/14] Fix Linux test failures (#13113) * Fix test failure introduced by ab31a748fad4ab448bc9c3ca9571bb129cc6fda1 (#10993) * Fix tray hiding test failure Introduced by 43904d87b7ae7339e393ce9de465556e3da52d3c (#10928), but somehow hasn't posed an issue until now. --------- Co-authored-by: Jonathan White --- tests/gui/TestGui.cpp | 14 ++++++- .../TestImageAttachmentsWidget.cpp | 40 ++++--------------- 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index 1d1b11ad2..c8bf7c987 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -346,8 +346,8 @@ void TestGui::testMergeDatabase() fileDialog()->setNextFileName(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx")); triggerAction("actionDatabaseMerge"); - QTRY_COMPARE(QApplication::focusWidget()->objectName(), QString("passwordEdit")); - auto* editPasswordMerge = QApplication::focusWidget(); + QWidget* editPasswordMerge; + QTRY_VERIFY((editPasswordMerge = QApplication::focusWidget()) && editPasswordMerge->objectName() == "passwordEdit"); QVERIFY(editPasswordMerge->isVisible()); QTest::keyClicks(editPasswordMerge, "a"); @@ -1931,18 +1931,28 @@ void TestGui::testTrayRestoreHide() trayIcon->activated(QSystemTrayIcon::Trigger); QTRY_VERIFY(m_mainWindow->isVisible()); + // Wait out window hide grace period before triggering tray icon again + int gracePeriod = 250; +#ifdef Q_OS_WIN + // Windows requires a shorter grace period + gracePeriod = 50; +#endif + + Tools::wait(gracePeriod); trayIcon->activated(QSystemTrayIcon::Trigger); QTRY_VERIFY(!m_mainWindow->isVisible()); trayIcon->activated(QSystemTrayIcon::MiddleClick); QTRY_VERIFY(m_mainWindow->isVisible()); + Tools::wait(gracePeriod); trayIcon->activated(QSystemTrayIcon::MiddleClick); QTRY_VERIFY(!m_mainWindow->isVisible()); trayIcon->activated(QSystemTrayIcon::DoubleClick); QTRY_VERIFY(m_mainWindow->isVisible()); + Tools::wait(gracePeriod); trayIcon->activated(QSystemTrayIcon::DoubleClick); QTRY_VERIFY(!m_mainWindow->isVisible()); diff --git a/tests/gui/attachments/TestImageAttachmentsWidget.cpp b/tests/gui/attachments/TestImageAttachmentsWidget.cpp index fc2d14e2d..44800a907 100644 --- a/tests/gui/attachments/TestImageAttachmentsWidget.cpp +++ b/tests/gui/attachments/TestImageAttachmentsWidget.cpp @@ -43,10 +43,7 @@ void TestImageAttachmentsWidget::testFitInView() auto zoomFactor = m_imageAttachmentsView->transform(); m_widget->setMinimumSize(m_widget->size() + QSize{100, 100}); - - QCoreApplication::processEvents(); - - QVERIFY(zoomFactor != m_imageAttachmentsView->transform()); + QTRY_VERIFY(zoomFactor != m_imageAttachmentsView->transform()); } void TestImageAttachmentsWidget::testZoomCombobox() @@ -56,10 +53,7 @@ void TestImageAttachmentsWidget::testZoomCombobox() QVERIFY(index != -1); m_zoomCombobox->setCurrentIndex(index); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(zoom, zoom)); + QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(zoom, zoom)); } } @@ -67,10 +61,7 @@ void TestImageAttachmentsWidget::testEditZoomCombobox() { for (double i = 0.25; i < 5; i += 0.25) { m_zoomCombobox->setCurrentText(QString::number(i * 100)); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); + QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); } } @@ -79,19 +70,13 @@ void TestImageAttachmentsWidget::testEditWithPercentZoomCombobox() // Example 100 % for (double i = 0.25; i < 5; i += 0.25) { m_zoomCombobox->setCurrentText(QString("%1 %").arg(i * 100)); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); + QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); } // Example 100% for (double i = 0.25; i < 5; i += 0.25) { m_zoomCombobox->setCurrentText(QString("%1%").arg(i * 100)); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); + QTRY_COMPARE(m_imageAttachmentsView->transform(), QTransform::fromScale(i, i)); } } @@ -108,10 +93,7 @@ void TestImageAttachmentsWidget::testInvalidValueZoomCombobox() for (const auto& invalidValue : {"Help", "3,4", "", ".", "% 100"}) { m_zoomCombobox->setCurrentText(invalidValue); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), expectedTransform); + QTRY_COMPARE(m_imageAttachmentsView->transform(), expectedTransform); } } @@ -208,10 +190,7 @@ void TestImageAttachmentsWidget::testZoomLowerBound() true); QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), expectTransform); + QTRY_COMPARE(m_imageAttachmentsView->transform(), expectTransform); } void TestImageAttachmentsWidget::testZoomUpperBound() @@ -237,8 +216,5 @@ void TestImageAttachmentsWidget::testZoomUpperBound() true); QCoreApplication::sendEvent(m_imageAttachmentsView->viewport(), &event); - - QCoreApplication::processEvents(); - - QCOMPARE(m_imageAttachmentsView->transform(), expectTransform); + QTRY_COMPARE(m_imageAttachmentsView->transform(), expectTransform); } From 4cb1d8d8ea637ea07a8326d6cbcc215f3b63c415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sat, 17 Jan 2026 15:01:13 +0200 Subject: [PATCH 03/14] Passkeys: Add publicKey to register response (#12757) --- src/browser/BrowserPasskeys.cpp | 50 ++++++++++++++++++++++++--------- src/browser/BrowserPasskeys.h | 10 +++---- tests/TestPasskeys.cpp | 24 ++++++++-------- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/src/browser/BrowserPasskeys.cpp b/src/browser/BrowserPasskeys.cpp index 287b42cca..2560b9428 100644 --- a/src/browser/BrowserPasskeys.cpp +++ b/src/browser/BrowserPasskeys.cpp @@ -79,7 +79,7 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso // Credential private key const auto alg = getAlgorithmFromPublicKey(credentialCreationOptions); - const auto privateKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second); + const auto privateKey = buildCredentialPrivateKey(alg, testingVariables); if (privateKey.cborEncodedPublicKey.isEmpty() && privateKey.privateKeyPem.isEmpty()) { // Key creation failed return {}; @@ -103,6 +103,9 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso // Additions for extension side functions responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData); + + // PublicKey + responseObject["publicKey"] = browserMessageBuilder()->getBase64FromArray(privateKey.spkiPublicKey); responseObject["publicKeyAlgorithm"] = alg; // PublicKeyCredential @@ -224,8 +227,7 @@ QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QS } // See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples -AttestationKeyPair -BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond) +AttestationKeyPair BrowserPasskeys::buildCredentialPrivateKey(int alg, const TestingVariables& testingVariables) { // Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now if (alg != WebAuthnAlgorithms::ES256 && alg != WebAuthnAlgorithms::RS256 && alg != WebAuthnAlgorithms::EDDSA) { @@ -234,21 +236,31 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir QByteArray firstPart; QByteArray secondPart; + QByteArray spki; QByteArray pem; - if (!predefinedFirst.isEmpty() && !predefinedSecond.isEmpty()) { - firstPart = browserMessageBuilder()->getArrayFromBase64(predefinedFirst); - secondPart = browserMessageBuilder()->getArrayFromBase64(predefinedSecond); + if (!testingVariables.first.isEmpty() && !testingVariables.second.isEmpty()) { + firstPart = browserMessageBuilder()->getArrayFromBase64(testingVariables.first); + secondPart = browserMessageBuilder()->getArrayFromBase64(testingVariables.second); } else { if (alg == WebAuthnAlgorithms::ES256) { try { - Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1")); + // Use predefined data if found (only for testing private key creation) + const auto keyData = !testingVariables.data.isEmpty() + ? Botan::BigInt(testingVariables.data.toStdString()) + : Botan::BigInt(0); + Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1"), keyData); const auto& publicPoint = privateKey.public_point(); auto x = publicPoint.get_affine_x(); auto y = publicPoint.get_affine_y(); firstPart = bigIntToQByteArray(x); secondPart = bigIntToQByteArray(y); + auto publicKey = + Botan::ECDSA_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits()); + auto publicKeySpki = publicKey.subject_public_key(); + spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size()); + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); pem = QByteArray::fromStdString(privateKeyPem); } catch (std::exception& e) { @@ -263,6 +275,10 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir firstPart = bigIntToQByteArray(modulus); secondPart = bigIntToQByteArray(exponent); + auto publicKey = Botan::RSA_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits()); + auto publicKeySpki = publicKey.subject_public_key(); + spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size()); + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); pem = QByteArray::fromStdString(privateKeyPem); } catch (std::exception& e) { @@ -271,17 +287,22 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir } } else if (alg == WebAuthnAlgorithms::EDDSA) { try { - Botan::Ed25519_PrivateKey key(*randomGen()->getRng()); - auto publicKey = key.get_public_key(); + Botan::Ed25519_PrivateKey privateKey(*randomGen()->getRng()); + auto publicKeyBits = privateKey.get_public_key(); #ifdef WITH_XC_BOTAN3 - auto privateKey = key.raw_private_key_bits(); + auto privateKeyBits = privateKey.raw_private_key_bits(); #else - auto privateKey = key.get_private_key(); + auto privateKeyBits = privateKey.get_private_key(); #endif - firstPart = browserMessageBuilder()->getQByteArray(publicKey.data(), publicKey.size()); - secondPart = browserMessageBuilder()->getQByteArray(privateKey.data(), privateKey.size()); + firstPart = browserMessageBuilder()->getQByteArray(publicKeyBits.data(), publicKeyBits.size()); + secondPart = browserMessageBuilder()->getQByteArray(privateKeyBits.data(), privateKeyBits.size()); - auto privateKeyPem = Botan::PKCS8::PEM_encode(key); + auto publicKey = + Botan::Ed25519_PublicKey(privateKey.algorithm_identifier(), privateKey.public_key_bits()); + auto publicKeySpki = publicKey.subject_public_key(); + spki = browserMessageBuilder()->getQByteArray(publicKeySpki.data(), publicKeySpki.size()); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); pem = QByteArray::fromStdString(privateKeyPem); } catch (std::exception& e) { qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EdDSA private key: %s", @@ -299,6 +320,7 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir AttestationKeyPair attestationKeyPair; attestationKeyPair.cborEncodedPublicKey = result; attestationKeyPair.privateKeyPem = pem; + attestationKeyPair.spkiPublicKey = spki; return attestationKeyPair; } diff --git a/src/browser/BrowserPasskeys.h b/src/browser/BrowserPasskeys.h index 4955654c9..230dee63f 100644 --- a/src/browser/BrowserPasskeys.h +++ b/src/browser/BrowserPasskeys.h @@ -61,6 +61,7 @@ struct AttestationKeyPair { QByteArray cborEncodedPublicKey; QByteArray privateKeyPem; + QByteArray spkiPublicKey; }; // Predefined variables used for testing the class @@ -69,6 +70,7 @@ struct TestingVariables QString credentialId; QString first; QString second; + QString data; }; class BrowserPasskeys : public QObject @@ -81,7 +83,7 @@ public: static BrowserPasskeys* instance(); PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions, - const TestingVariables& predefinedVariables = {}); + const TestingVariables& testingVariables = {}); QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions, const QString& credentialId, const QString& userHandle, @@ -110,11 +112,9 @@ private: const QString& extensions, const QString& credentialId, const QByteArray& cborEncodedPublicKey, - const TestingVariables& predefinedVariables = {}); + const TestingVariables& testingVariables = {}); QByteArray buildAuthenticatorData(const QString& rpId, const QString& extensions); - AttestationKeyPair buildCredentialPrivateKey(int alg, - const QString& predefinedFirst = QString(), - const QString& predefinedSecond = QString()); + AttestationKeyPair buildCredentialPrivateKey(int alg, const TestingVariables& testingVariables = {}); QByteArray buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem); QJsonObject parseAuthData(const QByteArray& authData) const; diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp index fd25c2baf..878732e3e 100644 --- a/tests/TestPasskeys.cpp +++ b/tests/TestPasskeys.cpp @@ -77,7 +77,7 @@ const QString PublicKeyCredential = R"( "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", "rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef", "response": { - "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M", + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIHK1iVimeR02UYipyiEKrKhhfhJRMew8EbDWGKtMZ2wUIlggbtZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" }, "type": "public-key" @@ -188,8 +188,8 @@ void TestPasskeys::testDecodeResponseData() QCOMPARE(publicKey["1"], 2); QCOMPARE(publicKey["3"], -7); QCOMPARE(publicKey["-1"], 1); - QCOMPARE(publicKey["-2"], QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0")); - QCOMPARE(publicKey["-3"], QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M")); + QCOMPARE(publicKey["-2"], QString("crWJWKZ5HTZRiKnKIQqsqGF-ElEx7DwRsNYYq0xnbBQ")); + QCOMPARE(publicKey["-3"], QString("btZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU")); } void TestPasskeys::testLoadingECPrivateKeyFromPem() @@ -276,10 +276,9 @@ void TestPasskeys::testCreatingAttestationObjectWithEC() auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); - TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond}; + TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond, QString()}; const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions); - const auto credentialPrivateKey = - browserPasskeys()->buildCredentialPrivateKey(alg, predefinedFirst, predefinedSecond); + const auto credentialPrivateKey = browserPasskeys()->buildCredentialPrivateKey(alg, testingVariables); auto result = browserPasskeys()->buildAttestationObject( credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables); QCOMPARE( @@ -344,10 +343,9 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA() auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); - TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent}; + TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent, QString()}; const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions); - auto credentialPrivateKey = - browserPasskeys()->buildCredentialPrivateKey(alg, predefinedModulus, predefinedExponent); + auto credentialPrivateKey = browserPasskeys()->buildCredentialPrivateKey(alg, testingVariables); auto result = browserPasskeys()->buildAttestationObject( credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables); @@ -380,8 +378,7 @@ void TestPasskeys::testRegister() { // Predefined values for a desired outcome const auto predefinedId = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); - const auto predefinedX = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); - const auto predefinedY = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M"); + const auto predefinedData = QString("0x4B0E8AB07B1E62CCD4CB7B9D5BC9DE7B6EED7A3C8A3D466DB12897755E3D7E6D"); const auto origin = QString("https://webauthn.io"); const auto testDataPublicKey = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); const auto testDataResponse = testDataPublicKey["response"]; @@ -392,7 +389,7 @@ void TestPasskeys::testRegister() publicKeyCredentialOptions, origin, &credentialCreationOptions); QVERIFY(creationResult == 0); - TestingVariables testingVariables = {predefinedId, predefinedX, predefinedY}; + TestingVariables testingVariables = {predefinedId, QString(), QString(), predefinedData}; auto result = browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions, testingVariables); auto publicKeyCredential = result.response; QCOMPARE(publicKeyCredential["type"], QString("public-key")); @@ -402,6 +399,9 @@ void TestPasskeys::testRegister() auto response = publicKeyCredential["response"].toObject(); auto attestationObject = response["attestationObject"].toString(); auto clientDataJson = response["clientDataJSON"].toString(); + QCOMPARE(response["publicKey"], + QString("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcrWJWKZ5HTZRiKnKIQqsqGF-" + "ElEx7DwRsNYYq0xnbBRu1nvRfXVIvHXtAUNZUBHf-qqTk6qtFL75aFzuHLD1hQ")); QCOMPARE(attestationObject, testDataResponse["attestationObject"].toString()); // Parse clientDataJSON From d97e69b9a8664ddb528104199383fe5e7b07e86d Mon Sep 17 00:00:00 2001 From: Anton Bobov Date: Sun, 11 Jan 2026 16:25:22 +0500 Subject: [PATCH 04/14] fix(gui): enable Auto-Type help button when feature is active The 'openHelpButton' in the Auto-Type configuration widget is now enabled whenever the main 'Enable Auto-Type for this entry' checkbox is checked, regardless of whether a custom sequence is defined. Previously, the help button's state depended on the custom sequence being enabled, which was inconsistent and confusing. The help is now available whenever the main Auto-Type feature is enabled, providing assistance for both default and window-specific sequences. --- src/gui/entry/EditEntryWidget.cpp | 2 +- src/gui/entry/EditEntryWidgetAutoType.ui | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index 3074d5818..b5b95dd66 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -1534,7 +1534,7 @@ void EditEntryWidget::updateAutoTypeEnabled() m_autoTypeUi->inheritSequenceButton->setEnabled(!m_history && autoTypeEnabled); m_autoTypeUi->customSequenceButton->setEnabled(!m_history && autoTypeEnabled); m_autoTypeUi->sequenceEdit->setEnabled(autoTypeEnabled && m_autoTypeUi->customSequenceButton->isChecked()); - m_autoTypeUi->openHelpButton->setEnabled(autoTypeEnabled && m_autoTypeUi->customSequenceButton->isChecked()); + m_autoTypeUi->openHelpButton->setEnabled(autoTypeEnabled); m_autoTypeUi->assocView->setEnabled(autoTypeEnabled); m_autoTypeUi->assocAddButton->setEnabled(!m_history); diff --git a/src/gui/entry/EditEntryWidgetAutoType.ui b/src/gui/entry/EditEntryWidgetAutoType.ui index 7bda4c38e..be76abfa0 100644 --- a/src/gui/entry/EditEntryWidgetAutoType.ui +++ b/src/gui/entry/EditEntryWidgetAutoType.ui @@ -90,9 +90,6 @@ - - false - Open Auto-Type help webpage From 75ba31cea10c7c2d6040d4e391722065d9a54091 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Tue, 6 Jan 2026 11:32:20 +0200 Subject: [PATCH 05/14] Fix showing URls in browser access dialog --- src/browser/BrowserAccessControlDialog.cpp | 57 ++++++++++++++-------- src/browser/BrowserAccessControlDialog.h | 4 +- src/browser/BrowserAccessControlDialog.ui | 2 +- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/browser/BrowserAccessControlDialog.cpp b/src/browser/BrowserAccessControlDialog.cpp index d62e92056..f0eb9a4c5 100644 --- a/src/browser/BrowserAccessControlDialog.cpp +++ b/src/browser/BrowserAccessControlDialog.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2013 Francois Ferrand * * This program is free software: you can redistribute it and/or modify @@ -53,55 +53,72 @@ void BrowserAccessControlDialog::setEntries(const QList& entriesToConfir QUrl url(urlString); m_ui->siteLabel->setText(m_ui->siteLabel->text().arg( url.toDisplayString(QUrl::RemoveUserInfo | QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment))); + m_ui->siteLabel->setToolTip(urlString); m_ui->rememberDecisionCheckBox->setVisible(!httpAuth); m_ui->rememberDecisionCheckBox->setChecked(false); m_ui->itemsTable->setRowCount(entriesToConfirm.count()); - m_ui->itemsTable->setColumnCount(2); + m_ui->itemsTable->setColumnCount(3); int row = 0; for (const auto& entry : entriesToConfirm) { addEntryToList(entry, row); ++row; } - m_ui->itemsTable->resizeColumnsToContents(); + m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); m_ui->itemsTable->selectAll(); m_ui->allowButton->setFocus(); } -void BrowserAccessControlDialog::addEntryToList(Entry* entry, int row) +void BrowserAccessControlDialog::addEntryToList(const Entry* entry, int row) { - auto item = new QTableWidgetItem(); - item->setText(entry->resolveMultiplePlaceholders(entry->title()) + " - " - + entry->resolveMultiplePlaceholders(entry->username())); - item->setData(Qt::UserRole, row); - item->setFlags(item->flags() | Qt::ItemIsSelectable); - m_ui->itemsTable->setItem(row, 0, item); + const auto titleItem = new QTableWidgetItem(); + const auto entryTitle = entry->resolveMultiplePlaceholders(entry->title()); + const auto entryUrl = entry->resolveMultiplePlaceholders(entry->url()); + titleItem->setText(entryTitle); + titleItem->setToolTip(entryUrl); + titleItem->setData(Qt::UserRole, row); + titleItem->setFlags(titleItem->flags() | Qt::ItemIsSelectable); + m_ui->itemsTable->setItem(row, 0, titleItem); + + const auto usernameItem = new QTableWidgetItem(); + const auto entryUsername = entry->resolveMultiplePlaceholders(entry->username()); + usernameItem->setText(entryUsername); + usernameItem->setData(Qt::UserRole, row); + m_ui->itemsTable->setItem(row, 1, usernameItem); auto disableButton = new QPushButton(); disableButton->setIcon(icons()->icon("entry-delete")); disableButton->setToolTip(tr("Disable for this site")); - connect(disableButton, &QAbstractButton::pressed, [&, item, disableButton] { - auto font = item->font(); - if (item->flags() == Qt::NoItemFlags) { - item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - item->setSelected(true); + connect(disableButton, &QAbstractButton::pressed, [&, titleItem, usernameItem, disableButton] { + auto font = titleItem->font(); + if (titleItem->flags() == Qt::NoItemFlags) { + titleItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + usernameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + titleItem->setSelected(true); + usernameItem->setSelected(true); font.setStrikeOut(false); - item->setFont(font); + titleItem->setFont(font); + usernameItem->setFont(font); disableButton->setIcon(icons()->icon("entry-delete")); disableButton->setToolTip(tr("Disable for this site")); m_ui->rememberDecisionCheckBox->setEnabled(true); } else { - item->setFlags(Qt::NoItemFlags); - item->setSelected(false); + titleItem->setFlags(Qt::NoItemFlags); + usernameItem->setFlags(Qt::NoItemFlags); + titleItem->setSelected(false); + usernameItem->setSelected(false); font.setStrikeOut(true); - item->setFont(font); + titleItem->setFont(font); + usernameItem->setFont(font); disableButton->setIcon(icons()->icon("entry-restore")); disableButton->setToolTip(tr("Undo")); @@ -112,7 +129,7 @@ void BrowserAccessControlDialog::addEntryToList(Entry* entry, int row) } }); - m_ui->itemsTable->setCellWidget(row, 1, disableButton); + m_ui->itemsTable->setCellWidget(row, 2, disableButton); } bool BrowserAccessControlDialog::remember() const diff --git a/src/browser/BrowserAccessControlDialog.h b/src/browser/BrowserAccessControlDialog.h index 3ecf5b506..39acd0ce3 100644 --- a/src/browser/BrowserAccessControlDialog.h +++ b/src/browser/BrowserAccessControlDialog.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2013 Francois Ferrand * * This program is free software: you can redistribute it and/or modify @@ -55,7 +55,7 @@ private slots: void selectionChanged(); private: - void addEntryToList(Entry* entry, int row); + void addEntryToList(const Entry* entry, int row); bool areAllDisabled() const; QList getAllItems() const; diff --git a/src/browser/BrowserAccessControlDialog.ui b/src/browser/BrowserAccessControlDialog.ui index 63f264311..69d61fcee 100755 --- a/src/browser/BrowserAccessControlDialog.ui +++ b/src/browser/BrowserAccessControlDialog.ui @@ -26,7 +26,7 @@ %1 is requesting access to the following entries: - Qt::AlignCenter + Qt::AlignLeft From 34945d917257cdf79e1f067eb2c581ce311fb60c Mon Sep 17 00:00:00 2001 From: xboxones1 <91512529+xboxones1@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:39:02 +0000 Subject: [PATCH 06/14] Fix minor font and theme issues (#12814) * Fix font size for all platforms * Fix font size for TOTP in preview panel * Styles: drop Windows-specific palette overrides * Fix encoding of EditEntryWidgetMain.ui * Fix tab width --- src/gui/Application.cpp | 3 +- src/gui/EntryPreviewWidget.cpp | 3 + src/gui/EntryPreviewWidget.ui | 4 - src/gui/MainWindow.cpp | 1 - src/gui/entry/EditEntryWidget.cpp | 3 + src/gui/entry/EditEntryWidgetMain.ui | 769 +++++++++--------- .../attachments/TextAttachmentsEditWidget.cpp | 3 + .../attachments/TextAttachmentsEditWidget.ui | 6 +- .../TextAttachmentsPreviewWidget.cpp | 3 + .../TextAttachmentsPreviewWidget.ui | 6 +- src/gui/styles/dark/DarkStyle.cpp | 2 - src/gui/styles/light/LightStyle.cpp | 2 - 12 files changed, 398 insertions(+), 407 deletions(-) diff --git a/src/gui/Application.cpp b/src/gui/Application.cpp index 4609d58fc..a5fbe1337 100644 --- a/src/gui/Application.cpp +++ b/src/gui/Application.cpp @@ -153,8 +153,6 @@ void Application::bootstrap(const QString& uiLanguage) { Bootstrap::bootstrap(uiLanguage); - applyFontSize(); - osUtils->registerNativeEventFilter(); MessageBox::initializeButtonDefs(); @@ -200,6 +198,7 @@ void Application::applyTheme() stylesheetFile.close(); } } + applyFontSize(); } void Application::applyFontSize() diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 0e9a11e43..b9bffa4be 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -392,6 +392,9 @@ void EntryPreviewWidget::updateEntryGeneralTab() m_ui->entryNotesTextEdit->setFont(Font::defaultFont()); } + m_ui->entryNotesTextEdit->setTabStopDistance( + QFontMetrics(m_ui->entryNotesTextEdit->font()).horizontalAdvance(QString(4, ' '))); + m_ui->entryUrlLabel->setRawText(m_currentEntry->displayUrl().toHtmlEscaped()); const QString url = m_currentEntry->url(); if (!url.isEmpty()) { diff --git a/src/gui/EntryPreviewWidget.ui b/src/gui/EntryPreviewWidget.ui index e44218b3e..b6d4cadcb 100644 --- a/src/gui/EntryPreviewWidget.ui +++ b/src/gui/EntryPreviewWidget.ui @@ -137,7 +137,6 @@ - 10 true @@ -434,9 +433,6 @@ true - - 10.000000000000000 - true diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 8c28179ff..f26461aad 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2031,7 +2031,6 @@ void MainWindow::initViewMenu() restartApp(tr("You must restart the application to apply this setting. Would you like to restart now?")); } else { kpxcApp->applyTheme(); - kpxcApp->applyFontSize(); } }); diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index b5b95dd66..d821296f1 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -943,6 +943,9 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) m_mainUi->notesEdit->setFont(Font::defaultFont()); } + m_mainUi->notesEdit->setTabStopDistance( + QFontMetrics(m_mainUi->notesEdit->font()).horizontalAdvance(QString(4, ' '))); + m_advancedUi->attachmentsWidget->setReadOnly(m_history); m_advancedUi->addAttributeButton->setEnabled(!m_history); m_advancedUi->editAttributeButton->setEnabled(false); diff --git a/src/gui/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui index d5b002ec2..2f785f035 100644 --- a/src/gui/entry/EditEntryWidgetMain.ui +++ b/src/gui/entry/EditEntryWidgetMain.ui @@ -1,386 +1,383 @@ - - - EditEntryWidgetMain - - - - 0 - 0 - 400 - 523 - - - - Edit Entry - - - QFrame::NoFrame - - - QFrame::Plain - - - Qt::ScrollBarAlwaysOff - - - QAbstractScrollArea::AdjustToContents - - - true - - - - - 0 - 0 - 400 - 523 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 10 - - - 8 - - - - - Title field - - - - - - - &Username: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - usernameComboBox - - - - - - - Qt::StrongFocus - - - Password field - - - - - - - - - - 0 - 1 - - - - - 0 - 100 - - - - Notes field - - - 10.000000000000000 - - - - - - - - - &Title: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - titleEdit - - - - - - - &Password: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - passwordEdit - - - - - - - Qt::StrongFocus - - - Username field - - - - - - - Qt::StrongFocus - - - Tags list - - - - - - - 8 - - - - - Toggle expiration - - - Toggle expiration - - - - - - - - - - false - - - Expiration field - - - true - - - - - - - - 0 - 0 - - - - Expiration Presets - - - Expiration presets - - - Presets - - - - - - - - - UR&L: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - urlEdit - - - - - - - 8 - - - - - Url field - - - https://example.com - - - - - - - Download favicon for URL - - - Download favicon for URL - - - - - - - - - - - &Notes: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - notesEdit - - - - - - - 6 - - - - - Qt::Horizontal - - - - 5 - 20 - - - - - - - - Toggle notes visibility - - - Toggle notes visibility - - - - 14 - 14 - - - - true - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - T&ags: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - tagsList - - - - - - - &Expires: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - expireCheck - - - - - - - - - TagsEdit - QWidget -
gui/tag/TagsEdit.h
- 1 -
- - URLEdit - QLineEdit -
gui/URLEdit.h
- 1 -
- - PasswordWidget - QWidget -
gui/PasswordWidget.h
- 1 -
-
- - titleEdit - usernameComboBox - passwordEdit - urlEdit - fetchFaviconButton - tagsList - expireCheck - expireDatePicker - expirePresets - revealNotesButton - notesEdit - - - -
+ + + EditEntryWidgetMain + + + + 0 + 0 + 400 + 523 + + + + Edit Entry + + + QFrame::NoFrame + + + QFrame::Plain + + + Qt::ScrollBarAlwaysOff + + + QAbstractScrollArea::AdjustToContents + + + true + + + + + 0 + 0 + 400 + 523 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 10 + + + 8 + + + + + Title field + + + + + + + &Username: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + usernameComboBox + + + + + + + Qt::StrongFocus + + + Password field + + + + + + + + + + 0 + 1 + + + + + 0 + 100 + + + + Notes field + + + + + + + + + &Title: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + titleEdit + + + + + + + &Password: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + passwordEdit + + + + + + + Qt::StrongFocus + + + Username field + + + + + + + Qt::StrongFocus + + + Tags list + + + + + + + 8 + + + + + Toggle expiration + + + Toggle expiration + + + + + + + + + + false + + + Expiration field + + + true + + + + + + + + 0 + 0 + + + + Expiration Presets + + + Expiration presets + + + Presets + + + + + + + + + UR&L: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + urlEdit + + + + + + + 8 + + + + + Url field + + + https://example.com + + + + + + + Download favicon for URL + + + Download favicon for URL + + + + + + + + + + + &Notes: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + notesEdit + + + + + + + 6 + + + + + Qt::Horizontal + + + + 5 + 20 + + + + + + + + Toggle notes visibility + + + Toggle notes visibility + + + + 14 + 14 + + + + true + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + T&ags: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + tagsList + + + + + + + &Expires: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + expireCheck + + + + + + + + + TagsEdit + QWidget +
gui/tag/TagsEdit.h
+ 1 +
+ + URLEdit + QLineEdit +
gui/URLEdit.h
+ 1 +
+ + PasswordWidget + QWidget +
gui/PasswordWidget.h
+ 1 +
+
+ + titleEdit + usernameComboBox + passwordEdit + urlEdit + fetchFaviconButton + tagsList + expireCheck + expireDatePicker + expirePresets + revealNotesButton + notesEdit + + + +
diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp index c67404a6f..f87f4510f 100644 --- a/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp +++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.cpp @@ -56,4 +56,7 @@ void TextAttachmentsEditWidget::updateUi() { m_ui->attachmentsTextEdit->setPlainText(m_attachment.data); m_ui->attachmentsTextEdit->setReadOnly(m_mode == attachments::OpenMode::ReadOnly); + + m_ui->attachmentsTextEdit->setTabStopDistance( + QFontMetrics(m_ui->attachmentsTextEdit->font()).horizontalAdvance(QString(4, ' '))); } diff --git a/src/gui/entry/attachments/TextAttachmentsEditWidget.ui b/src/gui/entry/attachments/TextAttachmentsEditWidget.ui index fe1fd50ba..e73894d15 100644 --- a/src/gui/entry/attachments/TextAttachmentsEditWidget.ui +++ b/src/gui/entry/attachments/TextAttachmentsEditWidget.ui @@ -57,11 +57,7 @@ - - - 10.000000000000000 - - +
diff --git a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp index 99c03ffe7..9ec105ea2 100644 --- a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp +++ b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.cpp @@ -99,6 +99,9 @@ void TextAttachmentsPreviewWidget::initTypeCombobox() // Configure text browser to open external links m_ui->previewTextBrowser->setOpenExternalLinks(true); + m_ui->previewTextBrowser->setTabStopDistance( + QFontMetrics(m_ui->previewTextBrowser->font()).horizontalAdvance(QString(4, ' '))); + m_ui->typeComboBox->setCurrentIndex(m_ui->typeComboBox->findData(PlainText)); onTypeChanged(m_ui->typeComboBox->currentIndex()); diff --git a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui index 2f2ea14a1..2ee97aa22 100644 --- a/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui +++ b/src/gui/entry/attachments/TextAttachmentsPreviewWidget.ui @@ -61,11 +61,7 @@ - - - 10.000000000000000 - - + diff --git a/src/gui/styles/dark/DarkStyle.cpp b/src/gui/styles/dark/DarkStyle.cpp index daf16aaac..3b86cfce7 100644 --- a/src/gui/styles/dark/DarkStyle.cpp +++ b/src/gui/styles/dark/DarkStyle.cpp @@ -121,8 +121,6 @@ void DarkStyle::polish(QWidget* widget) palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x2D2D2D)); palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0x2D2D2D)); } -#elif defined(Q_OS_WIN) - palette.setColor(QPalette::All, QPalette::Window, QRgb(0x2F2F30)); #else palette.setColor(QPalette::Active, QPalette::Window, QRgb(0x2F2F30)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0x313133)); diff --git a/src/gui/styles/light/LightStyle.cpp b/src/gui/styles/light/LightStyle.cpp index f73995412..8ef39868a 100644 --- a/src/gui/styles/light/LightStyle.cpp +++ b/src/gui/styles/light/LightStyle.cpp @@ -121,8 +121,6 @@ void LightStyle::polish(QWidget* widget) palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xF5F5F5)); palette.setColor(QPalette::Disabled, QPalette::Window, QRgb(0xF5F5F5)); } -#elif defined(Q_OS_WIN) - palette.setColor(QPalette::All, QPalette::Window, QRgb(0xFFFFFF)); #else palette.setColor(QPalette::Active, QPalette::Window, QRgb(0xEFF0F1)); palette.setColor(QPalette::Inactive, QPalette::Window, QRgb(0xEFF0F1)); From 407827ebf1068961ea0d5c77aaa5fe4d6170f262 Mon Sep 17 00:00:00 2001 From: Janek Bevendorff Date: Sun, 18 Jan 2026 16:45:29 +0100 Subject: [PATCH 07/14] Check version in vcpkg.json and appstream XML well-formedness, add translator listing (#12968) * Check version in vcpkg.json and appstream XML well-formedness * Move translator list utility to release-tool --- release-tool.py | 131 +++++++++++++++++++++++++++++---- utils/transifex_translators.py | 70 ------------------ 2 files changed, 116 insertions(+), 85 deletions(-) delete mode 100644 utils/transifex_translators.py diff --git a/release-tool.py b/release-tool.py index 4fdd9f567..cbab2c6d1 100755 --- a/release-tool.py +++ b/release-tool.py @@ -17,9 +17,11 @@ import argparse +from collections import defaultdict import ctypes from datetime import datetime import hashlib +import json import logging import lzma import os @@ -33,7 +35,8 @@ import subprocess import sys import tarfile import tempfile -from urllib.request import urlretrieve +from urllib import request +from xml import sax ########################################################################################### @@ -447,6 +450,7 @@ class Check(Command): if checkout: _git_checkout(git_ref, cwd=src_dir) logger.debug('Attempting to find "%s" version string in source files...', version) + cls.check_version_in_vcpkg_manifest(version, src_dir) cls.check_version_in_cmake(version, src_dir) cls.check_changelog(version, src_dir) cls.check_app_stream_info(version, src_dir) @@ -460,32 +464,32 @@ class Check(Command): raise Error(f'Source directory "{src_dir}" does not exist!') @staticmethod - def check_git_repository(cwd): + def check_git_repository(cwd=None): if _run(['git', 'rev-parse', '--is-inside-work-tree'], check=False, cwd=cwd).returncode != 0: raise Error('Not a valid Git repository: %s', cwd) @staticmethod - def check_release_exists(tag_name, cwd): + def check_release_exists(tag_name, cwd=None): if not _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: raise Error('Release tag does not exists: %s', tag_name) @staticmethod - def check_release_does_not_exist(tag_name, cwd): + def check_release_does_not_exist(tag_name, cwd=None): if _run(['git', 'tag', '--list', tag_name], check=False, cwd=cwd).stdout: raise Error('Release tag already exists: %s', tag_name) @staticmethod - def check_working_tree_clean(cwd): + def check_working_tree_clean(cwd=None): if not _git_working_dir_clean(cwd=cwd): raise Error('Current working tree is not clean! Please commit or unstage any changes.') @staticmethod - def check_branch_exists(branch, cwd): + def check_branch_exists(branch, cwd=None): if _run(['git', 'rev-parse', branch], check=False, cwd=cwd).returncode != 0: raise Error(f'Branch or tag "{branch}" does not exist!') @staticmethod - def check_version_in_cmake(version, cwd): + def check_version_in_cmake(version, cwd=None): cmakelists = Path('CMakeLists.txt') if cwd: cmakelists = Path(cwd) / cmakelists @@ -500,7 +504,17 @@ class Check(Command): raise Error(f'Version number in {cmakelists} not updated! Expected: %s, found: %s.', version, cmake_version) @staticmethod - def check_changelog(version, cwd): + def check_version_in_vcpkg_manifest(version, cwd=None): + manifest = Path('vcpkg.json') + if cwd: + manifest = Path(cwd) / manifest + manifest_json = json.load(manifest.open('r')) + if version != manifest_json['version-string']: + raise Error(f'Version number in {manifest} not updated! Expected: %s, found: %s.', + version, manifest_json['version-string']) + + @staticmethod + def check_changelog(version, cwd=None): changelog = Path('CHANGELOG.md') if cwd: changelog = Path(cwd) / changelog @@ -511,12 +525,21 @@ class Check(Command): raise Error(f'{changelog} has not been updated to the "%s" release.', version) @staticmethod - def check_app_stream_info(version, cwd): + def check_app_stream_info(version, cwd=None): appstream = Path('share/linux/org.keepassxc.KeePassXC.appdata.xml') if cwd: appstream = Path(cwd) / appstream if not appstream.is_file(): raise Error('File not found: %s', appstream) + + try: + parser = sax.make_parser() + parser.setContentHandler(sax.handler.ContentHandler()) + parser.parse(appstream) + except sax.SAXParseException as e: + raise Error(f'{appstream} is not well-formed. Error: %s at line %s, column %s', + e.getMessage(), e.getLineNumber(), e.getColumnNumber()) + regex = re.compile(rf'^\s*') with appstream.open('r', encoding='utf-8') as f: for line in f: @@ -572,8 +595,7 @@ class Tag(Command): # Update translations if not skip_translations: i18n = I18N(self._arg_parser) - i18n.run_tx_pull(src_dir, i18n.derive_resource_name(tx_resource, cwd=src_dir), tx_min_perc, - commit=True, yes=yes) + i18n.run_tx_pull(src_dir, tx_resource, tx_min_perc, commit=True, yes=yes) changelog = re.search(rf'^## ({major}\.{minor}\.{patch} \(.*?\)\n\n+.+?)\n\n+## ', (Path(src_dir) / 'CHANGELOG.md').read_text("UTF-8"), re.MULTILINE | re.DOTALL) @@ -808,7 +830,7 @@ class Build(Command): if _run(['which', toolname], cwd=None, check=False, **(docker_args or {})).returncode != 0: logger.info(f'Downloading {toolname}...') outfile = bin_dir / toolname - urlretrieve(url, outfile) + request.urlretrieve(url, outfile) outfile.chmod(outfile.stat().st_mode | stat.S_IEXEC) def build_linux(self, version, src_dir, output_dir, *, install_prefix, parallelism, cmake_opts, use_system_deps, @@ -1048,7 +1070,7 @@ class GPGSign(Command): class I18N(Command): """Update translation files and pull from or push to Transifex.""" - TRANSIFEX_RESOURCE = 'keepassxc.share-translations-keepassxc-en-ts--{}' + TRANSIFEX_RESOURCE = 'share-translations-keepassxc-en-ts--{}' TRANSIFEX_PULL_PERC = 60 @classmethod @@ -1070,6 +1092,15 @@ class I18N(Command): pull.add_argument('-y', '--yes', help='Don\'t ask before pulling translations.', action='store_true') pull.add_argument('tx_args', help='Additional arguments to pass to tx subcommand.', nargs=argparse.REMAINDER) + list_translators = subparsers.add_parser('tx-list-translators', + help='Print a HTML-formatted list of translation contributors.') + list_translators.add_argument('-o', '--org', help='Transifex org name.', default='keepassxc') + list_translators.add_argument('-p', '--project', help='Transifex project name.', default='keepassxc') + list_translators.add_argument('-r', '--resource', help='Transifex resource name.', + choices=['master', 'develop']) + list_translators.add_argument('-b', '--member-blacklist', nargs='+', help='Transifex users to ignore', + default=['phoerious', 'droidmonkey']) + lupdate = subparsers.add_parser('lupdate', help='Update source translation file from C++ sources.') lupdate.add_argument('-d', '--build-dir', help='Build directory for looking up lupdate binary.') lupdate.add_argument('-c', '--commit', help='Commit changes.', action='store_true') @@ -1112,12 +1143,15 @@ class I18N(Command): self.check_transifex_cmd_exists() self.check_transifex_config_exists(src_dir) - kwargs['resource'] = self.derive_resource_name(kwargs['resource'], cwd=src_dir) - kwargs['tx_args'] = kwargs['tx_args'][1:] + if 'tx_args' in kwargs: + kwargs['tx_args'] = kwargs['tx_args'][1:] if subcmd == 'tx-push': self.run_tx_push(src_dir, **kwargs) elif subcmd == 'tx-pull': self.run_tx_pull(src_dir, **kwargs) + elif subcmd == 'tx-list-translators': + self.run_tx_list_translators(src_dir, kwargs['org'], kwargs['project'], kwargs['resource'], + kwargs['member_blacklist']) elif subcmd == 'lupdate': kwargs['lupdate_args'] = kwargs['lupdate_args'][1:] @@ -1137,6 +1171,7 @@ class I18N(Command): # noinspection PyMethodMayBeStatic def run_tx_push(self, src_dir, resource, yes, tx_args): + resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir) sys.stderr.write('\nAbout to push the ' + fmt.bold('"en"') + ' source file from the current branch to Transifex:\n') sys.stderr.write(f' {fmt.bold(_git_get_branch(cwd=src_dir))}' @@ -1151,6 +1186,7 @@ class I18N(Command): # noinspection PyMethodMayBeStatic def run_tx_pull(self, src_dir, resource, min_perc, commit=False, yes=False, tx_args=None): + resource = 'keepassxc.' + self.derive_resource_name(resource, cwd=src_dir) sys.stderr.write('\nAbout to pull translations for ' + fmt.bold(f'"{resource}"') + '.\n') if not yes and not _yes_no_prompt('Continue?'): logger.error('Pull aborted.') @@ -1164,6 +1200,71 @@ class I18N(Command): if commit: _git_commit_files(files, 'Update translations.', cwd=src_dir) + # noinspection PyMethodMayBeStatic + def run_tx_list_translators(self, src_dir, org, project, resource, member_blacklist): + txrc = Path.home() / '.transifexrc' + if not txrc.exists(): + raise Error('No Transifex config found. Run tx init first.') + + org = f'o:{org}' + project = f'{org}:p:{project}' + resource = f'{project}:r:{self.derive_resource_name(resource, cwd=src_dir)}' + + token = [l for l in open(txrc, 'r') if l.startswith('token')][0].split('=', 1)[1].strip() + member_blacklist = [f'u:{m}' for m in member_blacklist] + + def get_url(url): + req = request.Request(url) + req.add_header('Content-Type', 'application/vnd.api+json') + req.add_header('Authorization', f'Bearer {token}') + with request.urlopen(req) as resp: + return json.load(resp) + + logger.info('Fetching languages...',) + languages_json = get_url(f'https://rest.api.transifex.com/projects/{project}/languages') + languages = {} + for lang in languages_json['data']: + languages[lang['id']] = lang['attributes']['name'] + + logger.info('Fetching language stats...') + language_stats_json = get_url('https://rest.api.transifex.com/resource_language_stats?' + f'filter[project]={project}&filter[resource]={resource}') + for s in language_stats_json['data']: + completion = s['attributes']['translated_strings'] / s['attributes']['total_strings'] + if completion < .6: + languages.pop(s['relationships']['language']['data']['id']) + + logger.info('Fetching language members...') + members_json = get_url(f'https://rest.api.transifex.com/team_memberships?filter[organization]={org}') + members = defaultdict(set) + for member in members_json['data']: + print('.', end='', file=sys.stderr) + sys.stderr.flush() + if member['relationships']['user']['data']['id'] in member_blacklist: + continue + lid = member['relationships']['language']['data']['id'] + if lid not in languages: + continue + user = get_url(member['relationships']['user']['links']['related'])['data']['attributes']['username'] + members[lid].add(user) + print(file=sys.stderr, flush=True) + + print('
    ') + for lang in sorted(languages, key=lambda x: languages[x]): + if not members[lang]: + continue + lines = [f'
  • {languages[lang]}: '] + for i, m in enumerate(sorted(members[lang], key=lambda x: x.lower())): + if len(lines[-1]) + len(m) >= 120: + lines.append(' ') + lines[-1] += m + if i < len(members[lang]) - 1: + lines[-1] += ', ' + lines[-1] += '
  • ' + print('\n'.join(lines)) + print('
') + logger.info('Done. Please add the list to the About dialog and commit the changes.') + def run_lupdate(self, src_dir, build_dir=None, commit=False, lupdate_args=None): path = _get_bin_path(build_dir) self.check_lupdate_exists(path) diff --git a/utils/transifex_translators.py b/utils/transifex_translators.py deleted file mode 100644 index 9fb51ce26..000000000 --- a/utils/transifex_translators.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -from collections import defaultdict -import json -import sys -from pathlib import Path -from urllib import request - -txrc = Path.home() / '.transifexrc' -if not txrc.exists(): - print('No Transifex config found. Run tx init first.') - sys.exit(1) - -org = 'o:keepassxc' -proj = f'{org}:p:keepassxc' -resource = f'{proj}:r:share-translations-keepassxc-en-ts--master' -token = [l for l in open(txrc, 'r') if l.startswith('token')][0].split('=', 1)[1].strip() -member_blacklist = ['u:droidmonkey', 'u:phoerious'] - - -def get_url(url): - req = request.Request(url) - req.add_header('Content-Type', 'application/vnd.api+json') - req.add_header('Authorization', f'Bearer {token}') - with request.urlopen(req) as resp: - return json.load(resp) - - -print('Fetching languages...', file=sys.stderr) -languages_json = get_url(f'https://rest.api.transifex.com/projects/{proj}/languages') -languages = {} -for lang in languages_json['data']: - languages[lang['id']] = lang['attributes']['name'] - -print('Fetching language stats...', file=sys.stderr) -language_stats_json = get_url('https://rest.api.transifex.com/resource_language_stats?' - f'filter[project]={proj}&filter[resource]={resource}') -completion = {} -for stat in language_stats_json['data']: - completion = stat['attributes']['translated_strings'] / stat['attributes']['total_strings'] - if completion < .6: - languages.pop(stat['relationships']['language']['data']['id']) - -print('Fetching language members...', end='', file=sys.stderr) -members_json = get_url(f'https://rest.api.transifex.com/team_memberships?filter[organization]={org}') -members = defaultdict(set) -for member in members_json['data']: - print('.', end='', file=sys.stderr) - if member['relationships']['user']['data']['id'] in member_blacklist: - continue - lid = member['relationships']['language']['data']['id'] - if lid not in languages: - continue - user = get_url(member['relationships']['user']['links']['related'])['data']['attributes']['username'] - members[lid].add(user) -print(file=sys.stderr) - -print('
    ') -for lang in sorted(languages, key=lambda x: languages[x]): - if not members[lang]: - continue - lines = [f'
  • {languages[lang]}: '] - for i, m in enumerate(sorted(members[lang], key=lambda x: x.lower())): - if len(lines[-1]) + len(m) >= 120: - lines.append(' ') - lines[-1] += m - if i < len(members[lang]) - 1: - lines[-1] += ', ' - lines[-1] += '
  • ' - print('\n'.join(lines)) -print('
') From 6baaec2f389c63007bebcfefd4e713626a93ed6c Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Sun, 8 Mar 2026 06:58:52 -0400 Subject: [PATCH 08/14] Revert Auto-Type change that caused race condition (#12738) * Fixes #12723 --- src/autotype/AutoType.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/autotype/AutoType.cpp b/src/autotype/AutoType.cpp index cd08eb56b..b5cc6fa03 100644 --- a/src/autotype/AutoType.cpp +++ b/src/autotype/AutoType.cpp @@ -115,6 +115,8 @@ namespace {"f14", Qt::Key_F14}, {"f15", Qt::Key_F15}, {"f16", Qt::Key_F16}}; + constexpr int s_minWaitDelay = 100; // 100 ms + constexpr int s_maxWaitDelay = 10000; // 10 seconds } // namespace AutoType* AutoType::m_instance = nullptr; @@ -312,6 +314,9 @@ void AutoType::executeAutoTypeActions(const Entry* entry, // Restore executor mode m_executor->mode = mode; + // Initial Auto-Type delay to allow window to come to foreground + Tools::wait(qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay)); + // Grab the current active window after everything settles if (window == 0) { window = m_plugin->activeWindow(); @@ -543,16 +548,16 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin } const int maxTypeDelay = 500; - const int maxWaitDelay = 10000; const int maxRepetition = 100; + int currentTypingDelay = qBound(0, config()->get(Config::AutoTypeDelay).toInt(), maxTypeDelay); - int cumulativeDelay = qBound(0, config()->get(Config::AutoTypeStartDelay).toInt(), maxWaitDelay); + // Take into account the initial delay which is added before any actions are performed + int cumulativeDelay = qBound(s_minWaitDelay, config()->get(Config::AutoTypeStartDelay).toInt(), s_maxWaitDelay); // Initial actions include start delay and initial inter-key delay QList> actions; actions << QSharedPointer::create(); actions << QSharedPointer::create(currentTypingDelay, true); - actions << QSharedPointer::create(cumulativeDelay); // Replace escaped braces with a template for easier regex QString sequence = entrySequence; @@ -631,12 +636,12 @@ AutoType::parseSequence(const QString& entrySequence, const Entry* entry, QStrin actions << QSharedPointer::create(qBound(0, delay, maxTypeDelay), true); } else if (placeholder == "delay") { // Mid typing delay (wait), repeat represents the desired delay in milliseconds - if (repeat > maxWaitDelay) { - error = tr("Very long delay detected, max is %1: %2").arg(maxWaitDelay).arg(fullPlaceholder); + if (repeat > s_maxWaitDelay) { + error = tr("Very long delay detected, max is %1: %2").arg(s_maxWaitDelay).arg(fullPlaceholder); return {}; } cumulativeDelay += repeat; - actions << QSharedPointer::create(qBound(0, repeat, maxWaitDelay)); + actions << QSharedPointer::create(qBound(0, repeat, s_maxWaitDelay)); } else if (placeholder == "clearfield") { // Platform-specific field clearing actions << QSharedPointer::create(); From 8904293a3c353560f89162510dae2c4afc576abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sun, 8 Mar 2026 13:47:29 +0200 Subject: [PATCH 09/14] Fix setting browser related values to customData (#13026) Co-authored-by: varjolintu --- src/core/CustomData.cpp | 3 +-- src/gui/entry/EditEntryWidget.cpp | 25 +++++++++++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/core/CustomData.cpp b/src/core/CustomData.cpp index 37c197c73..9e4a1631f 100644 --- a/src/core/CustomData.cpp +++ b/src/core/CustomData.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -149,7 +149,6 @@ void CustomData::copyDataFrom(const CustomData* other) m_data = other->m_data; - updateLastModified(); emit reset(); emitModified(); } diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index d821296f1..8ed279f35 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -351,25 +351,31 @@ void EditEntryWidget::updateBrowser() return; } + auto changeValue = [&](const QString& option, const bool newValue) { + // If value is false and no customData exists, make no edits + if (!m_customData->hasKey(option) && !newValue) { + return; + } + + // If customData exists, set the value + m_customData->set(option, (newValue ? TRUE_STR : FALSE_STR)); + }; + // Only update the custom data if no group level settings are used (checkbox is enabled) if (m_browserUi->hideEntryCheckbox->isEnabled()) { - auto hide = m_browserUi->hideEntryCheckbox->isChecked(); - m_customData->set(BrowserService::OPTION_HIDE_ENTRY, (hide ? TRUE_STR : FALSE_STR)); + changeValue(BrowserService::OPTION_HIDE_ENTRY, m_browserUi->hideEntryCheckbox->isChecked()); } if (m_browserUi->skipAutoSubmitCheckbox->isEnabled()) { - auto skip = m_browserUi->skipAutoSubmitCheckbox->isChecked(); - m_customData->set(BrowserService::OPTION_SKIP_AUTO_SUBMIT, (skip ? TRUE_STR : FALSE_STR)); + changeValue(BrowserService::OPTION_SKIP_AUTO_SUBMIT, m_browserUi->skipAutoSubmitCheckbox->isChecked()); } if (m_browserUi->onlyHttpAuthCheckbox->isEnabled()) { - auto onlyHttpAuth = m_browserUi->onlyHttpAuthCheckbox->isChecked(); - m_customData->set(BrowserService::OPTION_ONLY_HTTP_AUTH, (onlyHttpAuth ? TRUE_STR : FALSE_STR)); + changeValue(BrowserService::OPTION_ONLY_HTTP_AUTH, m_browserUi->onlyHttpAuthCheckbox->isChecked()); } if (m_browserUi->notHttpAuthCheckbox->isEnabled()) { - auto notHttpAuth = m_browserUi->notHttpAuthCheckbox->isChecked(); - m_customData->set(BrowserService::OPTION_NOT_HTTP_AUTH, (notHttpAuth ? TRUE_STR : FALSE_STR)); + changeValue(BrowserService::OPTION_NOT_HTTP_AUTH, m_browserUi->notHttpAuthCheckbox->isChecked()); } } @@ -803,7 +809,6 @@ void EditEntryWidget::addKeyToAgent() if (!sshAgent()->addIdentity(key, settings, m_db->uuid())) { showMessage(sshAgent()->errorString(), MessageWidget::Error); - return; } } @@ -817,7 +822,6 @@ void EditEntryWidget::removeKeyFromAgent() if (!sshAgent()->removeIdentity(key)) { showMessage(sshAgent()->errorString(), MessageWidget::Error); - return; } } @@ -1040,6 +1044,7 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) setupBrowser(); } + m_browserSettingsChanged = false; auto hideEntriesCheckBoxEnabled = true; auto skipAutoSubmitCheckBoxEnabled = true; auto onlyHttpAuthCheckBoxEnabled = true; From 7fc0c45b5612a20e6a6c125f3958fc2c025fc511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sun, 8 Mar 2026 14:08:15 +0200 Subject: [PATCH 10/14] Passkeys: Set BE and BS flags to true (#13042) Passkeys: Set BE flag to true --------- Co-authored-by: varjolintu --- src/browser/BrowserPasskeys.cpp | 25 +++++++++++++++++-------- src/browser/BrowserPasskeys.h | 13 ++++++++++--- src/browser/BrowserService.cpp | 16 +++++++++++++--- src/core/EntryAttributes.cpp | 4 +++- src/core/EntryAttributes.h | 4 +++- tests/TestPasskeys.cpp | 33 ++++++++++++++++++++++----------- 6 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/browser/BrowserPasskeys.cpp b/src/browser/BrowserPasskeys.cpp index 2560b9428..361bed7dd 100644 --- a/src/browser/BrowserPasskeys.cpp +++ b/src/browser/BrowserPasskeys.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -125,14 +125,16 @@ PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJso QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& assertionOptions, const QString& credentialId, const QString& userHandle, - const QString& privateKeyPem) + const QString& privateKeyPem, + const bool beFlag, + const bool bsFlag) { if (!passkeyUtils()->checkCredentialAssertionOptions(assertionOptions)) { return {}; } - const auto authenticatorData = - buildAuthenticatorData(assertionOptions["rpId"].toString(), assertionOptions["extensions"].toString()); + const auto authenticatorData = buildAuthenticatorData( + assertionOptions["rpId"].toString(), assertionOptions["extensions"].toString(), beFlag, bsFlag); const auto clientDataJson = assertionOptions["clientDataJson"].toString(); const auto clientDataArray = clientDataJson.toUtf8(); @@ -171,8 +173,12 @@ QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credential result.append(rpIdHash); // Use default flags - const auto flags = setFlagsFromJson(QJsonObject( - {{"ED", !extensions.isEmpty()}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}})); + const auto flags = setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()}, + {"AT", true}, + {"BS", DEFAULT_BS_FLAG}, + {"BE", DEFAULT_BE_FLAG}, + {"UV", true}, + {"UP", true}})); result.append(flags); // Signature counter (not supported, always 0 @@ -204,7 +210,10 @@ QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credential } // Build a short version of the attestation object for webauthn.get -QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QString& extensions) +QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, + const QString& extensions, + const bool beFlag, + const bool bsFlag) { QByteArray result; @@ -212,7 +221,7 @@ QByteArray BrowserPasskeys::buildAuthenticatorData(const QString& rpId, const QS result.append(rpIdHash); const auto flags = setFlagsFromJson(QJsonObject( - {{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}})); + {{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", bsFlag}, {"BE", beFlag}, {"UV", true}, {"UP", true}})); result.append(flags); // Signature counter (not supported, always 0 diff --git a/src/browser/BrowserPasskeys.h b/src/browser/BrowserPasskeys.h index 230dee63f..83ba625c3 100644 --- a/src/browser/BrowserPasskeys.h +++ b/src/browser/BrowserPasskeys.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,8 @@ #include #include +#define DEFAULT_BE_FLAG true +#define DEFAULT_BS_FLAG true #define ID_BYTES 32 #define HASH_BYTES 32 #define RSA_BITS 2048 @@ -87,7 +89,9 @@ public: QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions, const QString& credentialId, const QString& userHandle, - const QString& privateKeyPem); + const QString& privateKeyPem, + const bool beFlag = DEFAULT_BE_FLAG, + const bool bsFlag = DEFAULT_BE_FLAG); static const QString AAGUID; @@ -113,7 +117,10 @@ private: const QString& credentialId, const QByteArray& cborEncodedPublicKey, const TestingVariables& testingVariables = {}); - QByteArray buildAuthenticatorData(const QString& rpId, const QString& extensions); + QByteArray buildAuthenticatorData(const QString& rpId, + const QString& extensions, + const bool beFlag = DEFAULT_BE_FLAG, + const bool bsFlag = DEFAULT_BE_FLAG); AttestationKeyPair buildCredentialPrivateKey(int alg, const TestingVariables& testingVariables = {}); QByteArray buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem); diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index 6aac18a24..b26502808 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2017 Sami Vänttinen * Copyright (C) 2013 Francois Ferrand * @@ -774,8 +774,16 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& const auto credentialId = passkeyUtils()->getCredentialIdFromEntry(selectedEntry); const auto userHandle = selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE); - auto publicKeyCredential = - browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, credentialId, userHandle, privateKeyPem); + // Get BE and BS flags if present + const auto beFlag = selectedEntry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_FLAG_BE) + ? selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_FLAG_BE) == TRUE_STR + : DEFAULT_BE_FLAG; + const auto bsFlag = selectedEntry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_FLAG_BS) + ? selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_FLAG_BS) == TRUE_STR + : DEFAULT_BS_FLAG; + + auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential( + assertionOptions, credentialId, userHandle, privateKeyPem, beFlag, bsFlag); if (publicKeyCredential.isEmpty()) { return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR); } @@ -855,6 +863,8 @@ void BrowserService::addPasskeyToEntry(Entry* entry, entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true); entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY, rpId); entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USER_HANDLE, userHandle, true); + entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_FLAG_BE, TRUE_STR); + entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_FLAG_BS, TRUE_STR); entry->addTag(tr("Passkey")); entry->endUpdate(); diff --git a/src/core/EntryAttributes.cpp b/src/core/EntryAttributes.cpp index dba4d8962..88d32ae96 100644 --- a/src/core/EntryAttributes.cpp +++ b/src/core/EntryAttributes.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2012 Felix Geyer * * This program is free software: you can redistribute it and/or modify @@ -46,6 +46,8 @@ const QString EntryAttributes::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX const QString EntryAttributes::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE"); const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_START = QStringLiteral("-----BEGIN PRIVATE KEY-----"); const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_END = QStringLiteral("-----END PRIVATE KEY-----"); +const QString EntryAttributes::KPEX_PASSKEY_FLAG_BE = QStringLiteral("KPEX_PASSKEY_FLAG_BE"); +const QString EntryAttributes::KPEX_PASSKEY_FLAG_BS = QStringLiteral("KPEX_PASSKEY_FLAG_BS"); // For compatibility with StrongBox const QString EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID"); diff --git a/src/core/EntryAttributes.h b/src/core/EntryAttributes.h index d0767a4c1..14fd0c30e 100644 --- a/src/core/EntryAttributes.h +++ b/src/core/EntryAttributes.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2012 Felix Geyer * * This program is free software: you can redistribute it and/or modify @@ -75,6 +75,8 @@ public: static const QString KPEX_PASSKEY_USER_HANDLE; static const QString KPEX_PASSKEY_PRIVATE_KEY_START; static const QString KPEX_PASSKEY_PRIVATE_KEY_END; + static const QString KPEX_PASSKEY_FLAG_BE; + static const QString KPEX_PASSKEY_FLAG_BS; static bool isDefaultAttribute(const QString& key); static bool isPasskeyAttribute(const QString& key); diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp index 878732e3e..82f649e9c 100644 --- a/tests/TestPasskeys.cpp +++ b/tests/TestPasskeys.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -77,7 +77,7 @@ const QString PublicKeyCredential = R"( "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", "rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef", "response": { - "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIHK1iVimeR02UYipyiEKrKhhfhJRMew8EbDWGKtMZ2wUIlggbtZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU", + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBdAAAAAP2xQbJdhEQ-ijVGmMIFpQIAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIHK1iVimeR02UYipyiEKrKhhfhJRMew8EbDWGKtMZ2wUIlggbtZ70X11SLx17QFDWVAR3_qqk5OqrRS--Whc7hyw9YU", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" }, "type": "public-key" @@ -185,6 +185,8 @@ void TestPasskeys::testDecodeResponseData() QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(flags["AT"], true); QCOMPARE(flags["UP"], true); + QCOMPARE(flags["BE"], true); + QCOMPARE(flags["BS"], true); QCOMPARE(publicKey["1"], 2); QCOMPARE(publicKey["3"], -7); QCOMPARE(publicKey["-1"], 1); @@ -285,13 +287,18 @@ void TestPasskeys::testCreatingAttestationObjectWithEC() result, QString("\xA3" "cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0" - "9\x7F)%\x0B`\x84\x1E\xF0" - "E\x00\x00\x00\x01\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b\x00 \x8B\xB0\xCA" - "6\x17\xD6\xDE\x01\x11|\xEA\x94\r\xA0R\xC0\x80_\xF3r\xFBr\xB5\x02\x03:" - "\xBAr\x0Fi\x81\xFE\xA5\x01\x02\x03& \x01!X " - "e\xE2\xF2\x1F:cq\xD3G\xEA\xE0\xF7\x1F\xCF\xFA\\\xABO\xF6\x86\x88\x80\t\xAE\x81\x8BT\xB2\x9B\x15\x85~" - "\"X \\\x8E\x1E@\xDB\x97T-\xF8\x9B\xB0\xAD" - "5\xDC\x12^\xC3\x95\x05\xC6\xDF^\x03\xCB\xB4Q\x91\xFF|\xDB\x94\xB7")); + "9\x7F)%\x0B`\x84\x1E\xF0]\x00\x00\x00\x00\xFD\xB1" + "A\xB2]\x84" + "D>\x8A" + "5F\x98\xC2\x05\xA5\x02\x00 \xCA\xBC\xC5'\x99pr\x94\xF0`\xC3\x9D])\xB1\x17\x96\xF9q\x84%\xA8\x13" + "3m\xB5?w\xEA\x05,\xEF\xA5\x01\x02\x03& \x01!X \x06\xEC\xAF" + "4[b\x91" + "am\x19Y\x03\xA6P*\xCA" + "1\xC4\x95\xA8i\xE5\xF0\x87\xE5\xD4\xB8" + "2\xCD\b\x85\xDD\"X \xE2\xEE\x7F\xE9\x0F\x0E\xE9\x1D\x07\x83J\x03\t\xDB" + "B$\xB1\x0B\xD3%\xFF\x18" + "2\xE1S\x99\xB7\x1D" + "B\x04\xE7\x83")); // Double check that the result can be decoded BrowserCbor browserCbor; @@ -312,6 +319,8 @@ void TestPasskeys::testCreatingAttestationObjectWithEC() QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(flags["AT"], true); QCOMPARE(flags["UP"], true); + QCOMPARE(flags["BE"], true); + QCOMPARE(flags["BS"], true); QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::EC2); QCOMPARE(publicKey["3"], WebAuthnAlgorithms::ES256); QCOMPARE(publicKey["-1"], 1); @@ -368,6 +377,8 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA() QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); QCOMPARE(flags["AT"], true); QCOMPARE(flags["UP"], true); + QCOMPARE(flags["BE"], true); + QCOMPARE(flags["BS"], true); QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::RSA); QCOMPARE(publicKey["3"], WebAuthnAlgorithms::RS256); QCOMPARE(publicKey["-1"], predefinedModulus); @@ -438,14 +449,14 @@ void TestPasskeys::testGet() QCOMPARE(publicKeyCredential["id"].toString(), id); auto response = publicKeyCredential["response"].toObject(); - QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA")); + QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAdAAAAAA")); QCOMPARE(response["clientDataJSON"].toString(), QString("eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtML" "Ux1R1Uxem9wUm1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYm" "F1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ")); QCOMPARE( response["signature"].toString(), - QString("MEYCIQCpbDaYJ4b2ofqWBxfRNbH3XCpsyao7Iui5lVuJRU9HIQIhAPl5moNZgJu5zmurkKK_P900Ct6wd3ahVIqCEqTeeRdE")); + QString("MEUCIQCvg3nXO2fiNK9ockxscgPtoM9_u6ERaW2-F1L99YasOAIgNhYOjPJyKJ-W8roV531kC59ss1USas7jy8TfRnbJLtg")); auto clientDataJson = response["clientDataJSON"].toString(); auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); From 0ce7ed3434afd203c07e0608d7a907f01ab3936e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sun, 8 Mar 2026 17:18:22 +0200 Subject: [PATCH 11/14] Add support for nested folders with Bitwarden import (#13081) Co-authored-by: varjolintu --- src/format/BitwardenReader.cpp | 52 ++++- tests/TestImports.cpp | 102 +++++++++- tests/TestImports.h | 3 +- tests/data/bitwarden_nested_export.json | 249 ++++++++++++++++++++++++ 4 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 tests/data/bitwarden_nested_export.json diff --git a/src/format/BitwardenReader.cpp b/src/format/BitwardenReader.cpp index 43b2a34f3..37e361929 100644 --- a/src/format/BitwardenReader.cpp +++ b/src/format/BitwardenReader.cpp @@ -261,6 +261,48 @@ namespace return entry.take(); } + Group* createGroup(Group* rootGroup, const QString& folderName) + { + Group* currentParentGroup = rootGroup; + Group* result = nullptr; + const auto groups = folderName.split("/", Qt::SkipEmptyParts); + + // Returns the group name based on depth + const auto getGroupName = [&](const int depth) { + QString groupName; + for (int i = 0; i < depth + 1; ++i) { + groupName.append((i == 0 ? "" : "/") + groups[i]); + } + return groupName; + }; + + // Create new group(s) always when the path is not found + for (int i = 0; i < groups.length(); ++i) { + const auto groupName = getGroupName(i); + const auto tempGroup = rootGroup->findGroupByPath(groupName); + + if (!tempGroup) { + const auto newGroup = new Group(); + newGroup->setName(groups[i]); + newGroup->setUuid(QUuid::createUuid()); + newGroup->setParent(currentParentGroup); + currentParentGroup = newGroup; + + if (groupName == folderName) { + result = newGroup; + } + continue; + } + + if (groupName == folderName) { + result = tempGroup; + } + currentParentGroup = tempGroup; + } + + return result; + } + void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer db) { auto folderField = QString("folders"); @@ -277,12 +319,12 @@ namespace // Create groups from folders and store a temporary map of id -> uuid QMap folderMap; for (const auto& folder : vault.value(folderField).toArray()) { - auto group = new Group(); - group->setUuid(QUuid::createUuid()); - group->setName(folder.toObject().value("name").toString()); - group->setParent(db->rootGroup()); + const auto folderId = folder.toObject().value("id").toString(); + const auto folderName = folder.toObject().value("name").toString(); - folderMap.insert(folder.toObject().value("id").toString(), group); + if (const auto group = createGroup(db->rootGroup(), folderName)) { + folderMap.insert(folderId, group); + } } QString folderId; diff --git a/tests/TestImports.cpp b/tests/TestImports.cpp index d17beb389..e2db29b6e 100644 --- a/tests/TestImports.cpp +++ b/tests/TestImports.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -327,6 +327,106 @@ void TestImports::testBitwardenPasskey() QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw")); } +void TestImports::testBitwardenNestedFolders() +{ + auto bitwardenPath = + QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json")); + + BitwardenReader reader; + auto db = reader.convert(bitwardenPath); + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + QVERIFY(db); + + /* The group tree should be: + / + - Example + - Test Authentication + /SecondTest + - GMail entry + /Test + - Gmail test 2 + /Subfolder + - Webauthn.io test 2 + /Subfolder + - Test Account + /SubFolder + - WebAuthn.io test + /AnotherSubFolder + - Another test account + - Webauthn.io test 3 + */ + + // Verify groups + auto secondTestGroup = db->rootGroup()->findGroupByPath("/SecondTest"); + QVERIFY(secondTestGroup); + auto testGroup = db->rootGroup()->findGroupByPath("/Test"); + QVERIFY(testGroup); + auto testSubfolderLowercaseGroup = db->rootGroup()->findGroupByPath("/Test/Subfolder"); + QVERIFY(testSubfolderLowercaseGroup); + auto testSubFolderGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder"); + QVERIFY(testSubFolderGroup); + auto longGroup = db->rootGroup()->findGroupByPath("/Test/SubFolder/AnotherSubFolder"); + QVERIFY(longGroup); + + // Verify entries and the groups they belong to + + // GMail entry + auto entry = db->rootGroup()->findEntryByPath("/SecondTest/GMail entry"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("example@gmail.com")); + QCOMPARE(entry->group(), secondTestGroup); + + // Test Authentication + entry = db->rootGroup()->findEntryByPath("/Test Authentication"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("test@testauthentication.com")); + QCOMPARE(entry->group(), db->rootGroup()); + + // Gmail test 2 + entry = db->rootGroup()->findEntryByPath("/Test/Gmail test 2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("example2@gmail.com")); + QCOMPARE(entry->group(), testGroup); + + // Example + entry = db->rootGroup()->findEntryByPath("/Example"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("user@example.com")); + QCOMPARE(entry->group(), db->rootGroup()); + + // WebAuthn.io test + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/WebAuthn.io test"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser")); + QCOMPARE(entry->group(), testSubFolderGroup); + + // Webauthn.io test 2 + entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Webauthn.io test 2"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser2")); + QCOMPARE(entry->group(), testSubfolderLowercaseGroup); + + // Webauthn.io test 3 + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Webauthn.io test 3"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("testUser3")); + QCOMPARE(entry->group(), longGroup); + + // Test Account + // There are two groups with an identical name. The group for this entry should not be the same group with the + // Webauthn.io test 2, but we cannot distinguish these. + entry = db->rootGroup()->findEntryByPath("/Test/Subfolder/Test Account"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("test-account")); + QCOMPARE(entry->group(), testSubfolderLowercaseGroup); + + // Another test account + entry = db->rootGroup()->findEntryByPath("/Test/SubFolder/AnotherSubFolder/Another test account"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("anotherUser")); + QCOMPARE(entry->group(), longGroup); +} + void TestImports::testProtonPass() { auto protonPassPath = diff --git a/tests/TestImports.h b/tests/TestImports.h index 728fa6377..f7d37e515 100644 --- a/tests/TestImports.h +++ b/tests/TestImports.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,6 +31,7 @@ private slots: void testBitwarden(); void testBitwardenEncrypted(); void testBitwardenPasskey(); + void testBitwardenNestedFolders(); void testProtonPass(); }; diff --git a/tests/data/bitwarden_nested_export.json b/tests/data/bitwarden_nested_export.json new file mode 100644 index 000000000..315d9cf75 --- /dev/null +++ b/tests/data/bitwarden_nested_export.json @@ -0,0 +1,249 @@ +{ + "encrypted": false, + "folders": [ + { + "id": "53f3c6e7-a167-47e2-91bb-b3f900a377a3", + "name": "SecondTest" + }, + { + "id": "14f22922-b8ed-4e9c-814b-b3f900a36a92", + "name": "Test" + }, + { + "id": "da442766-39b4-4fb1-a2f3-b3f900a3a36d", + "name": "Test/Subfolder" + }, + { + "id": "0504d89b-00aa-41a5-9355-b3f900a3b43f", + "name": "Test/Subfolder" + }, + { + "id": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59", + "name": "Test/SubFolder" + }, + { + "id": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "name": "Test/SubFolder/AnotherSubFolder" + } + ], + "items": [ + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:57:23.033Z", + "creationDate": "2026-02-17T16:55:23.210Z", + "id": "6b154a7d-4b62-44aa-ae0d-b3f40116e266", + "folderId": "53f3c6e7-a167-47e2-91bb-b3f900a377a3", + "type": 1, + "reprompt": 0, + "name": "GMail entry", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://accounts.google.com" + } + ], + "fido2Credentials": [], + "username": "example@gmail.com", + "password": "examplePassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-20T17:45:32.670Z", + "creationDate": "2026-02-20T17:45:32.670Z", + "id": "6ccafb74-ecab-482f-88b2-b3f70124a91b", + "type": 1, + "reprompt": 0, + "name": "Test Authentication", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://testauthentication.com/login" + } + ], + "fido2Credentials": [], + "username": "test@testauthentication.com", + "password": "testPassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:57:15.540Z", + "creationDate": "2026-02-06T19:22:48.860Z", + "id": "a9f00893-346e-4be6-ac4e-b3e9013f6065", + "folderId": "14f22922-b8ed-4e9c-814b-b3f900a36a92", + "type": 1, + "reprompt": 0, + "name": "Gmail test 2", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://accounts.google.com" + } + ], + "fido2Credentials": [], + "username": "example2@gmail.com", + "password": "examplePassword2", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-09T13:15:16.370Z", + "creationDate": "2026-02-09T13:15:16.113Z", + "id": "b375fe89-756a-41be-bcec-b3ec00da6d55", + "type": 1, + "reprompt": 0, + "name": "Example", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://www.example.com/" + } + ], + "username": "user@example.com", + "password": "examplePassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T10:03:05.596Z", + "creationDate": "2026-02-09T13:15:45.020Z", + "id": "b42284e2-a103-4dd0-982c-b3ec00da8f36", + "folderId": "c96cf0e9-fd44-4a7e-9619-b3f900a58e59", + "type": 1, + "reprompt": 0, + "name": "WebAuthn.io test", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser", + "password": "testPassword", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:58.923Z", + "creationDate": "2024-10-23T16:38:08.606Z", + "id": "a8e579f0-98c2-4ac9-a126-b212011225f8", + "folderId": "da442766-39b4-4fb1-a2f3-b3f900a3a36d", + "type": 1, + "reprompt": 0, + "name": "Webauthn.io test 2", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser2", + "password": "testPassword2", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:46.026Z", + "creationDate": "2025-10-29T06:13:55.333Z", + "id": "a88363cf-9fea-43d8-a3dd-b3850066b36a", + "folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "type": 1, + "reprompt": 0, + "name": "Webauthn.io test 3", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://webauthn.io/" + } + ], + "username": "testUser3", + "password": "testPassword3", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2025-09-12T16:25:15.850Z", + "creationDate": "2025-09-12T16:25:15.850Z", + "id": "d2946603-1bfc-4eee-8805-b356010e9c65", + "folderId": "0504d89b-00aa-41a5-9355-b3f900a3b43f", + "type": 1, + "reprompt": 0, + "name": "Test Account", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://testsite.com/" + } + ], + "fido2Credentials": [], + "username": "test-account", + "password": "test", + "totp": null + }, + "collectionIds": null + }, + { + "passwordHistory": [], + "revisionDate": "2026-02-22T09:56:52.673Z", + "creationDate": "2024-10-23T19:17:45.433Z", + "id": "4e3b570e-3ead-4557-b5be-b212013dfcd0", + "folderId": "5d262faf-329d-4197-9e8d-b3f900a3934c", + "type": 1, + "reprompt": 0, + "name": "Another test account", + "notes": null, + "favorite": false, + "fields": [], + "login": { + "uris": [ + { + "uri": "https://anothertestsite.org/" + } + ], + "username": "anotherUser", + "password": "anotherPassword", + "totp": null + }, + "collectionIds": null + } + ] +} \ No newline at end of file From d54277a5710a656b778bb83cad8241dc6c855273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Sun, 8 Mar 2026 17:17:27 +0200 Subject: [PATCH 12/14] Fix showing correct checkbox value in entry Browser Integration settings (#12980) Co-authored-by: varjolintu --- src/browser/BrowserService.cpp | 7 ++-- src/gui/entry/EditEntryWidget.cpp | 64 +++++++++---------------------- tests/TestBrowser.cpp | 55 +++++++++++++++++++++++++- tests/TestBrowser.h | 3 +- 4 files changed, 78 insertions(+), 51 deletions(-) diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index b26502808..8dbac3cf1 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -1022,8 +1022,8 @@ QList BrowserService::searchEntries(const QSharedPointer& db, } for (const auto& group : rootGroup->groupsRecursive(true)) { - if (group->isRecycled() - || group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Enable) { + const auto groupOptionHideEntry = group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY); + if (group->isRecycled() || groupOptionHideEntry == Group::Enable) { continue; } @@ -1038,7 +1038,8 @@ QList BrowserService::searchEntries(const QSharedPointer& db, for (auto* entry : group->entries()) { if (entry->isRecycled() - || (entry->customData()->contains(BrowserService::OPTION_HIDE_ENTRY) + || (groupOptionHideEntry == Group::Inherit + && entry->customData()->contains(BrowserService::OPTION_HIDE_ENTRY) && entry->customData()->value(BrowserService::OPTION_HIDE_ENTRY) == TRUE_STR)) { continue; } diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index 8ed279f35..c456069d3 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -809,6 +809,7 @@ void EditEntryWidget::addKeyToAgent() if (!sshAgent()->addIdentity(key, settings, m_db->uuid())) { showMessage(sshAgent()->errorString(), MessageWidget::Error); + return; } } @@ -822,6 +823,7 @@ void EditEntryWidget::removeKeyFromAgent() if (!sshAgent()->removeIdentity(key)) { showMessage(sshAgent()->errorString(), MessageWidget::Error); + return; } } @@ -1044,56 +1046,26 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) setupBrowser(); } - m_browserSettingsChanged = false; - auto hideEntriesCheckBoxEnabled = true; - auto skipAutoSubmitCheckBoxEnabled = true; - auto onlyHttpAuthCheckBoxEnabled = true; - auto notHttpAuthCheckBoxEnabled = true; - auto hideEntries = false; - auto skipAutoSubmit = false; - auto onlyHttpAuth = false; - auto notHttpAuth = false; - const auto group = m_entry->group(); - if (group) { - hideEntries = group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Enable; - skipAutoSubmit = group->resolveCustomDataTriState(BrowserService::OPTION_SKIP_AUTO_SUBMIT) == Group::Enable; - onlyHttpAuth = group->resolveCustomDataTriState(BrowserService::OPTION_ONLY_HTTP_AUTH) == Group::Enable; - notHttpAuth = group->resolveCustomDataTriState(BrowserService::OPTION_NOT_HTTP_AUTH) == Group::Enable; + m_browserUi->messageWidget->showMessage( + tr("Some Browser Integration settings are overridden by group settings."), MessageWidget::Information); + m_browserUi->messageWidget->setVisible(false); - hideEntriesCheckBoxEnabled = - group->resolveCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY) == Group::Inherit; - skipAutoSubmitCheckBoxEnabled = - group->resolveCustomDataTriState(BrowserService::OPTION_SKIP_AUTO_SUBMIT) == Group::Inherit; - onlyHttpAuthCheckBoxEnabled = - group->resolveCustomDataTriState(BrowserService::OPTION_ONLY_HTTP_AUTH) == Group::Inherit; - notHttpAuthCheckBoxEnabled = - group->resolveCustomDataTriState(BrowserService::OPTION_NOT_HTTP_AUTH) == Group::Inherit; - } + auto updateCheckBoxValue = [&](QCheckBox* checkBox, const QString& option) { + const auto optionEnabledInGroup = group ? group->resolveBrowserOptionEnabled(option) : false; + const auto optionInherited = group ? group->resolveCustomDataTriState(option) == Group::Inherit : true; - // Show information about group level settings - if (!hideEntriesCheckBoxEnabled || !skipAutoSubmitCheckBoxEnabled || !onlyHttpAuthCheckBoxEnabled - || !notHttpAuthCheckBoxEnabled) { - m_browserUi->messageWidget->showMessage( - tr("Some Browser Integration settings are overridden by group settings."), MessageWidget::Information); - m_browserUi->messageWidget->setVisible(true); - } + if (!optionInherited) { + m_browserUi->messageWidget->setVisible(true); + } - // Disable checkboxes based on group level settings - updateBrowserIntegrationCheckbox( - m_browserUi->hideEntryCheckbox, hideEntriesCheckBoxEnabled, hideEntries, BrowserService::OPTION_HIDE_ENTRY); - updateBrowserIntegrationCheckbox(m_browserUi->skipAutoSubmitCheckbox, - skipAutoSubmitCheckBoxEnabled, - skipAutoSubmit, - BrowserService::OPTION_SKIP_AUTO_SUBMIT); - updateBrowserIntegrationCheckbox(m_browserUi->onlyHttpAuthCheckbox, - onlyHttpAuthCheckBoxEnabled, - onlyHttpAuth, - BrowserService::OPTION_ONLY_HTTP_AUTH); - updateBrowserIntegrationCheckbox(m_browserUi->notHttpAuthCheckbox, - notHttpAuthCheckBoxEnabled, - notHttpAuth, - BrowserService::OPTION_NOT_HTTP_AUTH); + updateBrowserIntegrationCheckbox(checkBox, optionInherited, optionEnabledInGroup, option); + }; + + updateCheckBoxValue(m_browserUi->hideEntryCheckbox, BrowserService::OPTION_HIDE_ENTRY); + updateCheckBoxValue(m_browserUi->skipAutoSubmitCheckbox, BrowserService::OPTION_SKIP_AUTO_SUBMIT); + updateCheckBoxValue(m_browserUi->onlyHttpAuthCheckbox, BrowserService::OPTION_ONLY_HTTP_AUTH); + updateCheckBoxValue(m_browserUi->notHttpAuthCheckbox, BrowserService::OPTION_NOT_HTTP_AUTH); m_browserUi->addURLButton->setEnabled(!m_history); m_browserUi->removeURLButton->setEnabled(false); diff --git a/tests/TestBrowser.cpp b/tests/TestBrowser.cpp index a2610748a..5b7f56cc6 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -854,3 +854,56 @@ void TestBrowser::testRestrictBrowserKey() QCOMPARE(sorted[2]->url(), QString("https://example.com/2")); QCOMPARE(sorted[3]->url(), QString("https://example.com/0")); } + +void TestBrowser::testHideEntry() +{ + const auto db = QSharedPointer::create(); + auto* root = db->rootGroup(); + + const auto entry = new Entry(); + entry->setGroup(root); + entry->beginUpdate(); + entry->setUrl(QString("https://github.com/")); + entry->setUsername(QString("User 1")); + entry->setUuid(QUuid::createUuid()); + entry->setTitle(QString("Name_ 1")); + entry->endUpdate(); + + // Entry should be found normally + auto result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 1); + QCOMPARE(result[0]->url(), QString("https://github.com/")); + + // Hide entry from entry settings, group setting is inherited + entry->customData()->set(BrowserService::OPTION_HIDE_ENTRY, TRUE_STR); + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 0); + + // Disable hide from group settings, entry should be found + root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Disable); + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 1); + + // Enable hide from group setting, entry should not be found + root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Enable); + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 0); + + // Remove the hide settings from entry, return group setting to inherit + entry->customData()->set(BrowserService::OPTION_HIDE_ENTRY, FALSE_STR); + root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Inherit); + + // Entry should be found again + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 1); + + // Enable hide from group setting, entry should not be found + root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Enable); + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 0); + + // Disable hide from group settings, entry should be found + root->setCustomDataTriState(BrowserService::OPTION_HIDE_ENTRY, Group::Disable); + result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session"); + QCOMPARE(result.length(), 1); +} diff --git a/tests/TestBrowser.h b/tests/TestBrowser.h index 6a99e085d..171389e51 100644 --- a/tests/TestBrowser.h +++ b/tests/TestBrowser.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -51,6 +51,7 @@ private slots: void testBestMatchingCredentials(); void testBestMatchingWithAdditionalURLs(); void testRestrictBrowserKey(); + void testHideEntry(); private: QList createEntries(QStringList& urls, Group* root, bool additionalUrl = false) const; From 63836c2a2928339bf711c8e2d2b3884c09e2ab2e Mon Sep 17 00:00:00 2001 From: Janek Bevendorff Date: Sun, 8 Mar 2026 17:50:48 +0100 Subject: [PATCH 13/14] Sanitise attachment file names before saving (#13114) Reported by @yuki-matsuhashi --- src/gui/entry/EntryAttachmentsWidget.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/gui/entry/EntryAttachmentsWidget.cpp b/src/gui/entry/EntryAttachmentsWidget.cpp index 523850010..0cd45d157 100644 --- a/src/gui/entry/EntryAttachmentsWidget.cpp +++ b/src/gui/entry/EntryAttachmentsWidget.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -368,8 +369,9 @@ void EntryAttachmentsWidget::saveSelectedAttachments() QStringList errors; for (const QModelIndex& index : indexes) { - const QString filename = m_attachmentsModel->keyByIndex(index); - const QString attachmentPath = saveDir.absoluteFilePath(filename); + QString attachmentKey = m_attachmentsModel->keyByIndex(index); + const QString fileNameSanitized = attachmentKey.replace(QRegExp("[/\\\\]"), ""); + const QString attachmentPath = saveDir.absoluteFilePath(fileNameSanitized); if (QFileInfo::exists(attachmentPath)) { @@ -382,7 +384,7 @@ void EntryAttachmentsWidget::saveSelectedAttachments() tr("Are you sure you want to overwrite the existing file \"%1\" with the attachment?")); auto result = MessageBox::question( - this, tr("Confirm overwrite"), questionText.arg(filename), buttons, MessageBox::Cancel); + this, tr("Confirm overwrite"), questionText.arg(fileNameSanitized), buttons, MessageBox::Cancel); if (result == MessageBox::Skip) { continue; @@ -392,11 +394,11 @@ void EntryAttachmentsWidget::saveSelectedAttachments() } QFile file(attachmentPath); - const QByteArray attachmentData = m_entryAttachments->value(filename); + const QByteArray attachmentData = m_entryAttachments->value(attachmentKey); const bool saveOk = file.open(QIODevice::WriteOnly) && file.setPermissions(QFile::ReadUser | QFile::WriteUser) && file.write(attachmentData) == attachmentData.size(); if (!saveOk) { - errors.append(QString("%1 - %2").arg(filename, file.errorString())); + errors.append(QString("%1 - %2").arg(fileNameSanitized, file.errorString())); } } From bc5875f10165cce1d7ed003c4e87be9271b1b23c Mon Sep 17 00:00:00 2001 From: Janek Bevendorff Date: Sun, 8 Mar 2026 22:26:10 +0100 Subject: [PATCH 14/14] Mock datetime to avoid test failures on second lapse (#13115) Fixes #13059 --- tests/CMakeLists.txt | 2 +- tests/TestTools.cpp | 16 ++++++++++++++++ tests/TestTools.h | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2ad662609..2219088b9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -216,7 +216,7 @@ add_unit_test(NAME testdatabase SOURCES TestDatabase.cpp LIBS testsupport ${TEST_LIBRARIES}) add_unit_test(NAME testtools SOURCES TestTools.cpp - LIBS ${TEST_LIBRARIES}) + LIBS testsupport ${TEST_LIBRARIES}) add_unit_test(NAME testconfig SOURCES TestConfig.cpp LIBS testsupport ${TEST_LIBRARIES}) diff --git a/tests/TestTools.cpp b/tests/TestTools.cpp index 3455e97c8..3d59399e0 100644 --- a/tests/TestTools.cpp +++ b/tests/TestTools.cpp @@ -19,6 +19,7 @@ #include "core/Clock.h" #include "core/Tools.h" +#include "mock/MockClock.h" #include #include @@ -33,8 +34,23 @@ namespace { return wholes + QLocale().decimalPoint() + fractions + " " + unit; } + + MockClock* s_clock = nullptr; } // namespace +void TestTools::initTestCase() +{ + Q_ASSERT(s_clock == nullptr); + s_clock = new MockClock(2026, 3, 8, 21, 45, 05); + MockClock::setup(s_clock); +} + +void TestTools::cleanupTestCase() +{ + MockClock::teardown(); + s_clock = nullptr; +} + void TestTools::testHumanReadableFileSize() { constexpr auto kibibyte = 1024u; diff --git a/tests/TestTools.h b/tests/TestTools.h index dc17f5b13..a540fd499 100644 --- a/tests/TestTools.h +++ b/tests/TestTools.h @@ -24,6 +24,8 @@ class TestTools : public QObject { Q_OBJECT private slots: + void initTestCase(); + void cleanupTestCase(); void testHumanReadableFileSize(); void testIsHex(); void testIsBase64();