-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Add support for Credential Exchange Format with passkeys #13343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
7fcdd7f
287b30d
db1be0f
4abf46f
3a6af9f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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? | ||
| 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; | ||
| } | ||
| 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 |
| 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}, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Given the entry attribute's name of
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}}; | ||
| } | ||
| 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 |
There was a problem hiding this comment.
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.
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?