Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
163 changes: 163 additions & 0 deletions src/format/CredentialExchangeReader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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 each item.
// Credentials under item can contain multple objects. Only passkey credentials are supported for now.
QList<Entry*> readAccount(const QJsonObject& account)
{
QList<Entry*> entries;

// Account info
const auto username = account["username"].toString();
const auto email = account["email"].toString();

// Parse Item entities
const auto items = account["items"].toArray();
for (const auto& i : items) {
const auto item = i.toObject();

QScopedPointer<Entry> entry(new Entry());
entry->setEmitModified(false);
entry->setUuid(QUuid::createUuid());
// KeePassXC does not have a separate email field. Use email as username if username is not set.
entry->setUsername(username.isEmpty() ? email : username);

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);
entries.push_back(entry.take());
}

return entries;
}
} // 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 < CE_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 accountEntries = readAccount(account.toObject());
if (!accountEntries.isEmpty()) {
entries << accountEntries;
}
}

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 CE_MAJOR_VERSION 1
#define CE_MINOR_VERSION 0
Comment thread
varjolintu marked this conversation as resolved.
Outdated

#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", entry->uuidToHex()},
Comment thread
varjolintu marked this conversation as resolved.
Outdated
{"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", CE_MAJOR_VERSION},
{"minor", CE_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