Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions share/translations/keepassxc_en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9424,6 +9424,26 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Confirm Replace Entry References</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Version information not found.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Major version %1 is not supported.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No exporter data or timestamp provided.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No accounts to read.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No accounts were succesfully read.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>
Expand Down
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 164 additions & 0 deletions src/format/CredentialExchangeReader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/

#include "CredentialExchangeReader.h"

#include "core/Entry.h"

#include <QFileInfo>
#include <QJsonArray>
#include <QJsonObject>
#include <QScopedPointer>
#include <QTimeZone>

static const auto CREDENTIAL_PASSKEY = QStringLiteral("passkey");

namespace
{
// Parse created and modified timestamps
void setTimeInfo(const QScopedPointer<Entry>& 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>& 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> 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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general the CXF data model is designed with the following potential mappings for different providers. Use this as you wish, it's not really prescriptive in any way.

  • CXF_Account: A user's account in a credential provider, may contain one or many entries and or entry organizational constructs. A credential provider may permit Multiple user accounts to be signed in at the same time, hence a CXF output was modelled to contain multiple accounts.
  • CXF_Collection: A construct used to organize entries in an account. They may be shared across accounts if that's supported by the provider, so it also contains a pointer to the owning account in case the current account is not the owner. This may be a first class construct, as in all entries are always organized into at least one of these, or it may be an additional construct where entries may be in a organizational construct or not. This is why they are only pointers in CXF and all items are flattened out under accounts. Examples of these constructs across different providers can be: Vaults, Folders, Collections, etc. I don't know if such a construct exists in KeePassXC.
  • CXF_Items: A collection of credentials that are associated to the same scope or context. For a login for a website for example, it may contain a totp, password and/or a passkey. Not all credential providers support the same combinations of credentials, so one CXF_item may be translated into multiple KeePass entries if there are credentials that can't be combined into one. As an example if a CXF_Item has multiple passkeys in its credentials, as far as I understand a KeePassXC entry, you would create one entry per passkey credential in that item, and clone the item's metadata for all of those entries. As you can see I my understanding is that a cxf_item would be a Entry here.
  • CXF_Credentials: This would be a dedicated credential that may be a field (or many) on an entry.

Now that I write all this out, I'm thinking there might be a need for an implementation considerations section or more details in the format overview. Would you find that sort of information helpful in the spec?

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<Entry*> 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<Entry*> 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;
}
43 changes: 43 additions & 0 deletions src/format/CredentialExchangeReader.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/

#ifndef CREDENTIAL_EXCHANGE_READER_H
#define CREDENTIAL_EXCHANGE_READER_H

#define CXF_MAJOR_VERSION 1
#define CXF_MINOR_VERSION 0

#include <QJsonObject>

class Entry;

class CredentialExchangeReader
{
public:
explicit CredentialExchangeReader() = default;
~CredentialExchangeReader() = default;

QList<Entry*> readEntries(const QJsonObject& data);

bool hasError();
QString errorString();

private:
QString m_error;
};

#endif // CREDENTIAL_EXCHANGE_READER_H
102 changes: 102 additions & 0 deletions src/format/CredentialExchangeWriter.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/

#include "CredentialExchangeWriter.h"
#include "CredentialExchangeReader.h"

#include "core/Clock.h"
#include "core/Entry.h"

#include <QJsonArray>

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},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Given the entry attribute's name of EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM We've had issues in previous interoperability tests with PEM, they're not all equal. Which is why the spec defines PKCS#8 ASN.1 DER. Sometimes the PEM format uses that, sometimes not. One thing PEM usually always includes is the guards though, and those are (unfortunately implicitly) excluded from the format.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something I also put to my TODO list. For example, when doing import from Bitwarden the format differs from our entry attribute's value. I need to make sure this is compatible.

};
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Am I correct in understanding that this will create an Account entry for each entry in a user's KeepassXC instance? The goal of this data type wasn't regarding their account for a specific website, but the account in their password manager/credential provider. I'm not really sure how well that translate's to KeepassXC's data model though.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about this too, and it probably needs some adjusting.

return entryObject;
}
} // namespace

QJsonObject CredentialExchangeWriter::writeEntries(const QList<Entry*>& 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<qint64>(Clock::currentSecondsSinceEpoch());
return QJsonObject{{"version", version},
{"exporterRpId", EXPORTER_RPID},
{"exporterDisplayName", EXPORTER_DISPLAY_NAME},
{"timestamp", timestamp},
{"accounts", accounts}};
}
35 changes: 35 additions & 0 deletions src/format/CredentialExchangeWriter.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
*
* 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 <http://www.gnu.org/licenses/>.
*/

#ifndef CREDENTIAL_EXCHANGE_WRITER_H
#define CREDENTIAL_EXCHANGE_WRITER_H

#include <QJsonObject>
#include <QList>

class Entry;

class CredentialExchangeWriter
{
public:
explicit CredentialExchangeWriter() = default;
~CredentialExchangeWriter() = default;

QJsonObject writeEntries(const QList<Entry*>& entries);
};

#endif // CREDENTIAL_EXCHANGE_WRITER_H
3 changes: 3 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down
Loading
Loading