Skip to content

Commit

Permalink
Support passkeys with Bitwarden import (#11401)
Browse files Browse the repository at this point in the history
  • Loading branch information
varjolintu authored Oct 25, 2024
1 parent 95bae83 commit 6e0baf9
Show file tree
Hide file tree
Showing 14 changed files with 180 additions and 47 deletions.
4 changes: 4 additions & 0 deletions share/translations/keepassxc_en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9061,6 +9061,10 @@ This option is deprecated, use --set-key-file instead.</source>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Passkey</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>
Expand Down
11 changes: 0 additions & 11 deletions src/browser/BrowserPasskeys.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,6 @@ const QString BrowserPasskeys::REQUIREMENT_REQUIRED = QStringLiteral("required")
const QString BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT = QStringLiteral("direct");
const QString BrowserPasskeys::PASSKEYS_ATTESTATION_NONE = QStringLiteral("none");

const QString BrowserPasskeys::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME");
const QString BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID = QStringLiteral("KPEX_PASSKEY_CREDENTIAL_ID");

// For compatibility with StrongBox
const QString BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID");
const QString BrowserPasskeys::KPXC_PASSKEY_USERNAME = QStringLiteral("KPXC_PASSKEY_USERNAME");

const QString BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM");
const QString BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY");
const QString BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE");

BrowserPasskeys* BrowserPasskeys::instance()
{
return s_browserPasskeys;
Expand Down
8 changes: 0 additions & 8 deletions src/browser/BrowserPasskeys.h
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,6 @@ class BrowserPasskeys : public QObject
static const QString PASSKEYS_ATTESTATION_DIRECT;
static const QString PASSKEYS_ATTESTATION_NONE;

static const QString KPXC_PASSKEY_USERNAME;
static const QString KPEX_PASSKEY_USERNAME;
static const QString KPEX_PASSKEY_CREDENTIAL_ID;
static const QString KPEX_PASSKEY_GENERATED_USER_ID;
static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM;
static const QString KPEX_PASSKEY_RELYING_PARTY;
static const QString KPEX_PASSKEY_USER_HANDLE;

private:
QByteArray buildAttestationObject(const QJsonObject& credentialCreationOptions,
const QString& extensions,
Expand Down
25 changes: 13 additions & 12 deletions src/browser/BrowserService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
#include "BrowserHost.h"
#include "BrowserMessageBuilder.h"
#include "BrowserSettings.h"
#include "core/EntryAttributes.h"
#include "core/Tools.h"
#include "gui/MainWindow.h"
#include "gui/MessageBox.h"
Expand Down Expand Up @@ -763,9 +764,9 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
}

const auto privateKeyPem = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
const auto privateKeyPem = selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM);
const auto credentialId = passkeyUtils()->getCredentialIdFromEntry(selectedEntry);
const auto userHandle = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
const auto userHandle = selectedEntry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE);

auto publicKeyCredential =
browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, credentialId, userHandle, privateKeyPem);
Expand Down Expand Up @@ -843,11 +844,11 @@ void BrowserService::addPasskeyToEntry(Entry* entry,

entry->beginUpdate();

entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USERNAME, username);
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID, credentialId, true);
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true);
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY, rpId);
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE, userHandle, true);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USERNAME, username);
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->addTag(tr("Passkey"));

entry->endUpdate();
Expand Down Expand Up @@ -1042,7 +1043,7 @@ QList<Entry*> BrowserService::searchEntries(const QSharedPointer<Database>& db,

#ifdef WITH_XC_BROWSER_PASSKEYS
// With Passkeys, check for the Relying Party instead of URL
if (passkey && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) != siteUrl) {
if (passkey && entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY) != siteUrl) {
continue;
}
#endif
Expand Down Expand Up @@ -1384,7 +1385,7 @@ QList<Entry*> BrowserService::getPasskeyEntries(const QString& rpId, const Strin
{
QList<Entry*> entries;
for (const auto& entry : searchEntries(rpId, "", keyList, true)) {
if (entry->hasPasskey() && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId) {
if (entry->hasPasskey() && entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY) == rpId) {
entries << entry;
}
}
Expand All @@ -1399,8 +1400,8 @@ QList<Entry*> BrowserService::getPasskeyEntriesWithUserHandle(const QString& rpI
{
QList<Entry*> entries;
for (const auto& entry : searchEntries(rpId, "", keyList, true)) {
if (entry->hasPasskey() && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId
&& entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE) == userId) {
if (entry->hasPasskey() && entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY) == rpId
&& entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE) == userId) {
entries << entry;
}
}
Expand All @@ -1425,7 +1426,7 @@ QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& assert
// See: https://w3c.github.io/webauthn/#dom-authenticatorassertionresponse-userhandle
if (allowedCredentials.contains(passkeyUtils()->getCredentialIdFromEntry(entry))
|| (allowedCredentials.isEmpty()
&& entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE))) {
&& entry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_USER_HANDLE))) {
entries << entry;
}
}
Expand Down
13 changes: 7 additions & 6 deletions src/browser/PasskeyUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include "PasskeyUtils.h"
#include "BrowserMessageBuilder.h"
#include "BrowserPasskeys.h"
#include "core/EntryAttributes.h"
#include "core/Tools.h"
#include "gui/UrlTools.h"

Expand Down Expand Up @@ -389,9 +390,9 @@ QString PasskeyUtils::getCredentialIdFromEntry(const Entry* entry) const
return {};
}

return entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)
? entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)
: entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID);
return entry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID)
? entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID)
: entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID);
}

// For compatibility with StrongBox (and other possible clients in the future)
Expand All @@ -401,7 +402,7 @@ QString PasskeyUtils::getUsernameFromEntry(const Entry* entry) const
return {};
}

return entry->attributes()->hasKey(BrowserPasskeys::KPXC_PASSKEY_USERNAME)
? entry->attributes()->value(BrowserPasskeys::KPXC_PASSKEY_USERNAME)
: entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME);
return entry->attributes()->hasKey(EntryAttributes::KPXC_PASSKEY_USERNAME)
? entry->attributes()->value(EntryAttributes::KPXC_PASSKEY_USERNAME)
: entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USERNAME);
}
13 changes: 13 additions & 0 deletions src/core/EntryAttributes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ const QString EntryAttributes::SearchTextGroupName = "SearchText";

const QString EntryAttributes::RememberCmdExecAttr = "_EXEC_CMD";
const QString EntryAttributes::AdditionalUrlAttribute = "KP2A_URL";

// Passkey related attributes
const QString EntryAttributes::PasskeyAttribute = "KPEX_PASSKEY";
const QString EntryAttributes::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME");
const QString EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID = QStringLiteral("KPEX_PASSKEY_CREDENTIAL_ID");
const QString EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM");
const QString EntryAttributes::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY");
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-----");

// For compatibility with StrongBox
const QString EntryAttributes::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID");
const QString EntryAttributes::KPXC_PASSKEY_USERNAME = QStringLiteral("KPXC_PASSKEY_USERNAME");

EntryAttributes::EntryAttributes(QObject* parent)
: ModifiableObject(parent)
Expand Down
11 changes: 11 additions & 0 deletions src/core/EntryAttributes.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,18 @@ class EntryAttributes : public ModifiableObject
static const QStringList DefaultAttributes;
static const QString RememberCmdExecAttr;
static const QString AdditionalUrlAttribute;

static const QString PasskeyAttribute;
static const QString KPXC_PASSKEY_USERNAME;
static const QString KPEX_PASSKEY_USERNAME;
static const QString KPEX_PASSKEY_CREDENTIAL_ID;
static const QString KPEX_PASSKEY_GENERATED_USER_ID;
static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM;
static const QString KPEX_PASSKEY_RELYING_PARTY;
static const QString KPEX_PASSKEY_USER_HANDLE;
static const QString KPEX_PASSKEY_PRIVATE_KEY_START;
static const QString KPEX_PASSKEY_PRIVATE_KEY_END;

static bool isDefaultAttribute(const QString& key);
static bool isPasskeyAttribute(const QString& key);

Expand Down
39 changes: 38 additions & 1 deletion src/format/BitwardenReader.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <[email protected]>
* Copyright (C) 2024 KeePassXC Team <[email protected]>
*
* 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
Expand Down Expand Up @@ -81,6 +81,43 @@ namespace
entry->setTotp(Totp::parseSettings(totp));
}

// Parse passkey
if (loginMap.contains("fido2Credentials")) {
const auto fido2CredentialsMap = loginMap.value("fido2Credentials").toList();
for (const auto& fido2Credentials : fido2CredentialsMap) {
const auto passkey = fido2Credentials.toMap();

// Change from UUID to base64 byte array
const auto credentialIdValue = passkey.value("credentialId").toString();
if (!credentialIdValue.isEmpty()) {
const auto credentialUuid = Tools::uuidToHex(credentialIdValue);
const auto credentialIdArray = QByteArray::fromHex(credentialUuid.toUtf8());
const auto credentialId =
credentialIdArray.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID, credentialId, true);
}

// Base64 needs to be changed from URL encoding back to normal, and the result as PEM string
const auto keyValue = passkey.value("keyValue").toString();
if (!keyValue.isEmpty()) {
const auto keyValueArray =
QByteArray::fromBase64(keyValue.toUtf8(), QByteArray::Base64UrlEncoding);
auto privateKey = keyValueArray.toBase64(QByteArray::Base64Encoding);
privateKey.insert(0, EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_START.toUtf8());
privateKey.append(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_END.toUtf8());
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true);
}

entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_USERNAME,
passkey.value("userName").toString());
entry->attributes()->set(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY,
passkey.value("rpId").toString());
entry->attributes()->set(
EntryAttributes::KPEX_PASSKEY_USER_HANDLE, passkey.value("userHandle").toString(), true);
entry->addTag(QObject::tr("Passkey"));
}
}

// Set the entry url(s)
int i = 1;
for (const auto& urlObj : loginMap.value("uris").toList()) {
Expand Down
7 changes: 4 additions & 3 deletions src/gui/passkeys/PasskeyExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "browser/BrowserPasskeys.h"
#include "browser/PasskeyUtils.h"
#include "core/Entry.h"
#include "core/EntryAttributes.h"
#include "core/Tools.h"
#include "gui/MessageBox.h"
#include <QFile>
Expand Down Expand Up @@ -94,12 +95,12 @@ void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& fol
}

QJsonObject passkeyObject;
passkeyObject["relyingParty"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY);
passkeyObject["relyingParty"] = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY);
passkeyObject["url"] = entry->url();
passkeyObject["username"] = passkeyUtils()->getUsernameFromEntry(entry);
passkeyObject["credentialId"] = passkeyUtils()->getCredentialIdFromEntry(entry);
passkeyObject["userHandle"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
passkeyObject["privateKey"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
passkeyObject["userHandle"] = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE);
passkeyObject["privateKey"] = entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM);

QJsonDocument document(passkeyObject);
if (passkeyFile.write(document.toJson()) < 0) {
Expand Down
4 changes: 2 additions & 2 deletions src/gui/passkeys/PasskeyImporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer<Database>&
tr("Cannot import passkey"),
tr("Cannot import passkey file \"%1\".\nThe following data is missing:\n%2")
.arg(file.fileName(), missingKeys.join(", ")));
} else if (!privateKey.startsWith("-----BEGIN PRIVATE KEY-----")
|| !privateKey.trimmed().endsWith("-----END PRIVATE KEY-----")) {
} else if (!privateKey.startsWith(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_START)
|| !privateKey.trimmed().endsWith(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_END)) {
MessageBox::information(
m_parent,
tr("Cannot import passkey"),
Expand Down
5 changes: 3 additions & 2 deletions src/gui/reports/ReportsWidgetPasskeys.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "browser/BrowserPasskeys.h"
#include "browser/PasskeyUtils.h"
#include "core/AsyncTask.h"
#include "core/EntryAttributes.h"
#include "core/Group.h"
#include "core/Metadata.h"
#include "gui/GuiTools.h"
Expand Down Expand Up @@ -75,7 +76,7 @@ PasskeyList::PasskeyList(const QSharedPointer<Database>& db)
}

for (auto entry : group->entries()) {
if (entry->isRecycled() || !entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM)) {
if (entry->isRecycled() || !entry->attributes()->hasKey(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM)) {
continue;
}

Expand Down Expand Up @@ -134,7 +135,7 @@ void ReportsWidgetPasskeys::addPasskeyRow(Group* group, Entry* entry)
row << new QStandardItem(Icons::entryIconPixmap(entry), title);
row << new QStandardItem(Icons::groupIconPixmap(group), group->hierarchy().join("/"));
row << new QStandardItem(passkeyUtils()->getUsernameFromEntry(entry));
row << new QStandardItem(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY));
row << new QStandardItem(entry->attributes()->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY));
row << new QStandardItem(urlList.join('\n'));

// Set tooltips
Expand Down
35 changes: 34 additions & 1 deletion tests/TestImports.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 KeePassXC Team <[email protected]>
* Copyright (C) 2024 KeePassXC Team <[email protected]>
*
* 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
Expand Down Expand Up @@ -282,3 +282,36 @@ void TestImports::testBitwardenEncrypted()
}
QVERIFY(db);
}

void TestImports::testBitwardenPasskey()
{
auto bitwardenPath =
QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_passkey_export.json"));

BitwardenReader reader;
auto db = reader.convert(bitwardenPath);
QVERIFY2(!reader.hasError(), qPrintable(reader.errorString()));
QVERIFY(db);

// Confirm Login fields
auto entry = db->rootGroup()->findEntryByPath("/webauthn.io");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("webauthn.io"));
QCOMPARE(entry->username(), QStringLiteral("KPXC_BITWARDEN"));
QCOMPARE(entry->url(), QStringLiteral("https://webauthn.io/"));

// Confirm passkey attributes
auto attr = entry->attributes();
QCOMPARE(attr->value(EntryAttributes::KPEX_PASSKEY_CREDENTIAL_ID), QStringLiteral("o-FfiyfBQq6Qz6YVrYeFTw"));
QCOMPARE(
attr->value(EntryAttributes::KPEX_PASSKEY_PRIVATE_KEY_PEM),
QStringLiteral(
"-----BEGIN PRIVATE "
"KEY-----"
"MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgmr4GQQjerojFuf0ZouOuUllMvAwxZSZAfB6gwDYcLiehRANCAAT0WR5zVS"
"p6ieusvjkLkzaGc7fjGBmwpiuLPxR/d+ZjqMI9L2DKh+takp6wGt2x0n4jzr1KA352NZg0vjZX9CHh-----END PRIVATE KEY-----"));
QCOMPARE(attr->value(EntryAttributes::KPEX_PASSKEY_USERNAME), QStringLiteral("KPXC_BITWARDEN"));
QCOMPARE(attr->value(EntryAttributes::KPEX_PASSKEY_RELYING_PARTY), QStringLiteral("webauthn.io"));
QCOMPARE(attr->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE),
QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw"));
}
3 changes: 2 additions & 1 deletion tests/TestImports.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 KeePassXC Team <[email protected]>
* Copyright (C) 2024 KeePassXC Team <[email protected]>
*
* 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
Expand Down Expand Up @@ -30,6 +30,7 @@ private slots:
void testOPVault();
void testBitwarden();
void testBitwardenEncrypted();
void testBitwardenPasskey();
};

#endif /* TEST_IMPORTS_H */
Loading

0 comments on commit 6e0baf9

Please sign in to comment.