diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 574dc39671..f8f78d13d0 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -9424,6 +9424,26 @@ This option is deprecated, use --set-key-file instead. Confirm Replace Entry References + + Version information not found. + + + + Major version %1 is not supported. + + + + No exporter data or timestamp provided. + + + + No accounts to read. + + + + No accounts were succesfully read. + + QtIOCompressor diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1bf2a795ec..89af67649b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -57,6 +57,8 @@ set(core_SOURCES crypto/kdf/AesKdf.cpp crypto/kdf/Argon2Kdf.cpp format/BitwardenReader.cpp + format/CredentialExchangeReader.cpp + format/CredentialExchangeWriter.cpp format/CsvExporter.cpp format/CsvParser.cpp format/HtmlExporter.cpp diff --git a/src/format/CredentialExchangeReader.cpp b/src/format/CredentialExchangeReader.cpp new file mode 100644 index 0000000000..91338a73e6 --- /dev/null +++ b/src/format/CredentialExchangeReader.cpp @@ -0,0 +1,164 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "CredentialExchangeReader.h" + +#include "core/Entry.h" + +#include +#include +#include +#include +#include + +static const auto CREDENTIAL_PASSKEY = QStringLiteral("passkey"); + +namespace +{ + // Parse created and modified timestamps + void setTimeInfo(const QScopedPointer& entry, const QJsonObject& item) + { + auto timeInfo = entry->timeInfo(); + const auto creationAt = item["creationAt"].toInteger(); + const auto modifiedAt = item["modifiedAt"].toInteger(); + const auto creationTimestamp = QDateTime::fromSecsSinceEpoch(creationAt).toUTC(); + const auto modifiedTimestamp = QDateTime::fromSecsSinceEpoch(modifiedAt).toUTC(); + timeInfo.setCreationTime(creationTimestamp); + timeInfo.setLastModificationTime(modifiedTimestamp); + entry->setTimeInfo(timeInfo); + } + + // Parse credential of "passkey" type + void setPasskeyCredential(const QScopedPointer& entry, const QJsonObject& credential) + { + const auto credentialId = credential["credentialId"].toString(); + const auto rpId = credential["rpId"].toString(); + const auto passkeyUsername = credential["username"].toString(); + const auto userDisplayName = credential["userDisplayName"].toString(); + const auto userHandle = credential["userHandle"].toString(); + // The value MUST be PKCS#8 ASN.1 DER formatted byte string which is then Base64url encoded. + const auto privateKey = credential["key"].toString(); + + // TODO: fido2Extensions.hmacCredentials should be handled after PRF support has been made. + entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USERNAME, passkeyUsername); + entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID, credentialId, true); + 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, "1"); + entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_FLAG_BS, "1"); + } + + // Account can has multiple items. Create a new entry for an Account, and parse Items to it. + // Credentials under Item can contain multple objects. Only passkey credentials are supported for now. + // TODO: It needs to be decided if each Item needs a separate entry, or is one Account one entry, and each + // Item is added to it. The Account doesn't have a common title, but title can vary per each Item. + Entry* readAccount(const QJsonObject& account) + { + QScopedPointer entry(new Entry()); + + // Account info + const auto username = account["username"].toString(); + const auto email = account["email"].toString(); + // KeePassXC does not have a separate email field. Use email as username if username is not set. + entry->setUsername(username.isEmpty() ? email : username); + + // Parse Item entities + const auto items = account["items"].toArray(); + for (const auto& i : items) { + const auto item = i.toObject(); + + entry->setEmitModified(false); + entry->setUuid(QUuid::createUuid()); + + // TODO: How to handle titles if there are multiple items? + entry->setTitle(item["title"].toString()); + setTimeInfo(entry, item); + + // Parse item credentials + const auto credentials = item["credentials"].toArray(); + for (const auto& cred : credentials) { + const auto credential = cred.toObject(); + const auto credentialType = credential["type"].toString(); + + // Only support passkeys for now + if (credentialType == CREDENTIAL_PASSKEY) { + setPasskeyCredential(entry, credential); + } + } + + entry->setEmitModified(true); + } + + return entry.take(); + } +} // namespace + +bool CredentialExchangeReader::hasError() +{ + return !m_error.isEmpty(); +} + +QString CredentialExchangeReader::errorString() +{ + return m_error; +} + +QList CredentialExchangeReader::readEntries(const QJsonObject& data) +{ + // Verify version + const auto version = data["version"].toObject(); + if (version.isEmpty()) { + m_error = QObject::tr("Version information not found."); + return {}; + } + + // Minor versions should be compatible + const auto majorVersion = version["major"].toInt(); + if (majorVersion < CXF_MAJOR_VERSION) { + m_error = QObject::tr("Major version %1 is not supported.").arg(majorVersion); + return {}; + } + + // Check exporter and timestamp exists + if (data["exporterRpId"].toString().isEmpty() || data["exporterDisplayName"].toString().isEmpty() + || data["timestamp"].toInteger() == 0) { + m_error = QObject::tr("No exporter data or timestamp provided."); + return {}; + } + + // Parse accounts + const auto accounts = data["accounts"].toArray(); + if (accounts.isEmpty()) { + // No accounts to import + m_error = QObject::tr("No accounts to read."); + return {}; + } + + QList entries; + for (const auto& account : accounts) { + const auto accountEntry = readAccount(account.toObject()); + if (accountEntry) { + entries << accountEntry; + } + } + + if (entries.isEmpty()) { + m_error = QObject::tr("No accounts were succesfully read."); + } + return entries; +} diff --git a/src/format/CredentialExchangeReader.h b/src/format/CredentialExchangeReader.h new file mode 100644 index 0000000000..f382b0f19b --- /dev/null +++ b/src/format/CredentialExchangeReader.h @@ -0,0 +1,43 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef CREDENTIAL_EXCHANGE_READER_H +#define CREDENTIAL_EXCHANGE_READER_H + +#define CXF_MAJOR_VERSION 1 +#define CXF_MINOR_VERSION 0 + +#include + +class Entry; + +class CredentialExchangeReader +{ +public: + explicit CredentialExchangeReader() = default; + ~CredentialExchangeReader() = default; + + QList readEntries(const QJsonObject& data); + + bool hasError(); + QString errorString(); + +private: + QString m_error; +}; + +#endif // CREDENTIAL_EXCHANGE_READER_H diff --git a/src/format/CredentialExchangeWriter.cpp b/src/format/CredentialExchangeWriter.cpp new file mode 100644 index 0000000000..7f20138be3 --- /dev/null +++ b/src/format/CredentialExchangeWriter.cpp @@ -0,0 +1,102 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "CredentialExchangeWriter.h" +#include "CredentialExchangeReader.h" + +#include "core/Clock.h" +#include "core/Entry.h" + +#include + +static const auto EXPORTER_RPID = QStringLiteral("keepassxc.org"); +static const auto EXPORTER_DISPLAY_NAME = QStringLiteral("KeePassXC"); + +namespace +{ + QJsonObject writeEntry(const Entry* entry) + { + // Only passkey entries are supported at the moment + if (!entry->hasPasskey()) { + return {}; + } + + QJsonObject entryObject{ + {"id", QString(entry->uuid().toByteArray().toBase64())}, + {"username", entry->username()}, + {"email", QString()}, // There is no email in KeePassXC + }; + + QJsonArray items; + const auto passkeyUsername = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USERNAME); + const auto credentialId = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID); + const auto privateKey = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM); + const auto rpId = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY); + const auto userHandle = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE); + + QJsonArray credentials; + credentials << QJsonObject{ + {"type", "passkey"}, + {"credentialId", credentialId}, + {"rpId", rpId}, + {"username", passkeyUsername}, + {"userDisplayName", QString()}, // KeePassXC does not store this + {"userHandle", userHandle}, + {"key", privateKey}, + }; + // TODO: Write fido2Extensions + + const auto& timeInfo = entry->timeInfo(); + const auto creationAt = timeInfo.creationTime().toUTC().toSecsSinceEpoch(); + const auto modifiedAt = timeInfo.lastModificationTime().toUTC().toSecsSinceEpoch(); + items << QJsonObject{{"id", credentialId}, + {"creationAt", creationAt}, + {"modifiedAt", modifiedAt}, + {"title", entry->title()}, + {"credentials", credentials}}; + + entryObject["items"] = items; + return entryObject; + } +} // namespace + +QJsonObject CredentialExchangeWriter::writeEntries(const QList& entries) +{ + if (entries.isEmpty()) { + // No entries provided + return {}; + } + + QJsonObject version{ + {"major", CXF_MAJOR_VERSION}, + {"minor", CXF_MINOR_VERSION}, + }; + + QJsonArray accounts; + for (const auto& entry : entries) { + if (const auto entryObject = writeEntry(entry); !entryObject.isEmpty()) { + accounts << entryObject; + } + } + + const auto timestamp = static_cast(Clock::currentSecondsSinceEpoch()); + return QJsonObject{{"version", version}, + {"exporterRpId", EXPORTER_RPID}, + {"exporterDisplayName", EXPORTER_DISPLAY_NAME}, + {"timestamp", timestamp}, + {"accounts", accounts}}; +} diff --git a/src/format/CredentialExchangeWriter.h b/src/format/CredentialExchangeWriter.h new file mode 100644 index 0000000000..819c25d304 --- /dev/null +++ b/src/format/CredentialExchangeWriter.h @@ -0,0 +1,35 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef CREDENTIAL_EXCHANGE_WRITER_H +#define CREDENTIAL_EXCHANGE_WRITER_H + +#include +#include + +class Entry; + +class CredentialExchangeWriter +{ +public: + explicit CredentialExchangeWriter() = default; + ~CredentialExchangeWriter() = default; + + QJsonObject writeEntries(const QList& entries); +}; + +#endif // CREDENTIAL_EXCHANGE_WRITER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d11e5660b5..554b784181 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -85,6 +85,9 @@ add_unit_test(NAME testdeletedobjects SOURCES TestDeletedObjects.cpp add_unit_test(NAME testkeepass1reader SOURCES TestKeePass1Reader.cpp LIBS ${TEST_LIBRARIES}) +add_unit_test(NAME testcredentialexchange SOURCES TestCredentialExchange.cpp + LIBS ${TEST_LIBRARIES}) + add_unit_test(NAME testimports SOURCES TestImports.cpp LIBS ${TEST_LIBRARIES}) diff --git a/tests/TestCredentialExchange.cpp b/tests/TestCredentialExchange.cpp new file mode 100644 index 0000000000..53c153e5fc --- /dev/null +++ b/tests/TestCredentialExchange.cpp @@ -0,0 +1,149 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "TestCredentialExchange.h" + +#include "config-keepassx-tests.h" +#include "core/Entry.h" +#include "format/CredentialExchangeReader.h" +#include "format/CredentialExchangeWriter.h" + +#include +#include +#include +#include + +QTEST_GUILESS_MAIN(TestCredentialExchange) + +// clang-format off +const QString CredentialExchangeData = R"( + { + "version": { + "major": 1, + "minor": 0 + }, + "exporterRpId": "exporter.example.com", + "exporterDisplayName": "Exporter app", + "timestamp": 1705228800, + "accounts": [ + { + "id": "DZSXp7iBQY-Fg-OofakQtQ", + "username": "jane_smith", + "email": "jane.smith@example.com", + "fullName": "Jane Smith", + "items": [ + { + "id": "akKA3Y0jQRuK7sKplB0Y9w", + "creationAt": 1705142400, + "modifiedAt": 1705228800, + "title": "WebAuthn.io", + "subtitle": "johndoe", + "credentials": [ + { + "type": "passkey", + "credentialId": "Y3JlZGVudGlhbElkRXhhbXBsZQ", + "rpId": "webauthn.io", + "username": "johndoe", + "userDisplayName": "John Doe", + "userHandle": "cnEzaNHWcYK3coWZjvoaV1Hj9gnI12mKe2dL2HZVFlY", + "key": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgARu_0sCt20EpgVxb4Puq3Ga5VVLpuTY75ngvZlyq3X6hRANCAASmdk1xLsK0oOlhxIPp0d1ZuS0sT9nf6BZtSelhqvLBW0fOL33l_bXgsr_STUHjCLn8l6gcRJwe7OQvbQubZ1dY", + "fido2Extensions": { + "hmacCredentials": { + "algorithm": "hmac-sha256", + "credWithUV": "j3N5T9qLpWz2rYf4vS6lDn1KpQx8E0fRc2a7Bm5nUsw", + "credWithoutUV": "y2R8tL3eWf5qBz0sK4hHn9rVgX7pD1cQm6uTj2aP8Fs" + }, + "credBlob": "eyJ1c2VyTmFtZSI6ICJKb2huIERvZSIsICJ1c2VySWQiOiAiamRvZS0wMDEiLCAiZW1haWwiOiAiamRvZUBleGFtcGxlLmNvbSJ9", + "largeBlob": { + "uncompressedSize": 129, + "data": "HYxBCoUwDESvMgcQT-Hqr71ATIMtlKQkkY-3twrDLIb3Zq8tMEMKUfZ7pBR08lNwdDtAEcaN3vXfsuJnVbGZrNhf82PYrl5ma1JTXCGOQkkLaAxETnmBOb53O51GbYwQdslYHw" + }, + "payments": true + } + } + ] + } + ] + } + ] + } +)"; + +// clang-format on + +void TestCredentialExchange::initTestCase() +{ + QLocale::setDefault(QLocale::c()); +} + +void TestCredentialExchange::testCredentialExchangeReader() +{ + const QJsonDocument doc(QJsonDocument::fromJson(CredentialExchangeData.toUtf8())); + const auto importData = doc.object(); + + CredentialExchangeReader reader; + const auto entries = reader.readEntries(importData); + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + + QVERIFY(!entries.isEmpty()); + const auto firstEntry = entries.first(); + QCOMPARE(firstEntry->username(), QString("jane_smith")); + QCOMPARE(firstEntry->title(), QString("WebAuthn.io")); + QCOMPARE(firstEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USERNAME), QString("johndoe")); + QCOMPARE(firstEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID), + QString("Y3JlZGVudGlhbElkRXhhbXBsZQ")); + QCOMPARE(firstEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM), + QString("MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgARu_" + "0sCt20EpgVxb4Puq3Ga5VVLpuTY75ngvZlyq3X6hRANCAASmdk1xLsK0oOlhxIPp0d1ZuS0sT9nf6BZtSelhqvLBW0fOL33l_" + "bXgsr_STUHjCLn8l6gcRJwe7OQvbQubZ1dY")); + QCOMPARE(firstEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY), QString("webauthn.io")); + QCOMPARE(firstEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE), + QString("cnEzaNHWcYK3coWZjvoaV1Hj9gnI12mKe2dL2HZVFlY")); + QCOMPARE(firstEntry->timeInfo().creationTime().toString(Qt::ISODate), QString("2024-01-13T10:40:00Z")); + QCOMPARE(firstEntry->timeInfo().lastModificationTime().toString(Qt::ISODate), QString("2024-01-14T10:40:00Z")); +} + +void TestCredentialExchange::testCredentialExchangeWriter() +{ + const auto firstEntry = new Entry(); + firstEntry->setUuid(QUuid::createUuid()); + firstEntry->setUsername("John Doe"); + firstEntry->setTitle("Title for John Doe"); + firstEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USERNAME, "johndoe"); + firstEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID, "Y3JlZGVudGlhbElkRXhhbXBsZQ"); + firstEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, "MIGHAgEAMBMGByqGS..."); + firstEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY, "webauthn.io"); + firstEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USER_HANDLE, + "cnEzaNHWcYK3coWZjvoaV1Hj9gnI12mKe2dL2HZVFlY"); + + const auto secondEntry = new Entry(); + secondEntry->setUuid(QUuid::createUuid()); + secondEntry->setUsername("Jane Doe"); + secondEntry->setTitle("Title for Jane Doe"); + secondEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USERNAME, "janedoe"); + secondEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID, "GVuElkRXhhbXBsZdGlhY3JlZb"); + secondEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, "XIGHAgEAMBMGByqGS..."); + secondEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY, "webauthn.io"); + secondEntry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USER_HANDLE, + "K3coWgnI12mZjvoaV1Hj92HZVFlYKecnEzaNHWcY2dL"); + + QList entries; + entries << firstEntry << secondEntry; + + CredentialExchangeWriter writer; + const auto result = writer.writeEntries(entries); + QVERIFY(result["accounts"].toArray().size() == 2); +} diff --git a/tests/TestCredentialExchange.h b/tests/TestCredentialExchange.h new file mode 100644 index 0000000000..af0476a924 --- /dev/null +++ b/tests/TestCredentialExchange.h @@ -0,0 +1,33 @@ +/* + * 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 + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTCREDENTIALEXCHANGE_H +#define KEEPASSXC_TESTCREDENTIALEXCHANGE_H + +#include + +class TestCredentialExchange : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void testCredentialExchangeReader(); + void testCredentialExchangeWriter(); +}; + +#endif // KEEPASSXC_TESTCREDENTIALEXCHANGE_H