Skip to content
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

Add Proton Pass importer #11197

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions COPYING
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Files: share/icons/badges/2_Expired.svg
share/icons/database/C46_Help.svg
share/icons/database/C53_Apply.svg
share/icons/database/C61_Services.svg
share/icons/application/scalable/actions/proton.svg
Copyright: 2022 KeePassXC Team <[email protected]>
License: MIT

Expand Down
1 change: 1 addition & 0 deletions share/icons/application/scalable/actions/proton.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions share/icons/icons.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
<file>application/scalable/actions/password-generator.svg</file>
<file>application/scalable/actions/password-show-off.svg</file>
<file>application/scalable/actions/password-show-on.svg</file>
<file>application/scalable/actions/proton.svg</file>
<file>application/scalable/actions/qrcode.svg</file>
<file>application/scalable/actions/refresh.svg</file>
<file>application/scalable/actions/remote-sync.svg</file>
Expand Down
24 changes: 20 additions & 4 deletions share/translations/keepassxc_en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4635,6 +4635,14 @@ You can enable the DuckDuckGo website icon service in the security section of th
<source>KeePass1 Database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Proton Pass (.json)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Proton Pass JSON Export</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Temporary Database</source>
<translation type="unfinished"></translation>
Expand All @@ -4651,10 +4659,6 @@ You can enable the DuckDuckGo website icon service in the security section of th
<source>Input:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Remote Database (.kdbx)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>e.g.:
get DatabaseOnRemote.kdbx {TEMP_DATABASE}
Expand All @@ -4665,6 +4669,10 @@ The command has to exit. In case of `sftp` as last commend `exit` has to be sent
</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Remote Database (.kdbx)</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>KMessageWidget</name>
Expand Down Expand Up @@ -9002,6 +9010,14 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Cannot generate valid passphrases because the wordlist is too short</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Encrypted files are not supported.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Proton Pass Import</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Delete plugin data?</source>
<translation type="unfinished"></translation>
Expand Down
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ set(core_SOURCES
format/OpVaultReaderAttachments.cpp
format/OpVaultReaderBandEntry.cpp
format/OpVaultReaderSections.cpp
format/ProtonPassReader.cpp
keys/CompositeKey.cpp
keys/FileKey.cpp
keys/PasswordKey.cpp
Expand Down
221 changes: 221 additions & 0 deletions src/format/ProtonPassReader.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* 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
* 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 "ProtonPassReader.h"

#include "core/Database.h"
#include "core/Entry.h"
#include "core/Group.h"
#include "core/Metadata.h"
#include "core/Tools.h"
#include "core/Totp.h"
#include "crypto/CryptoHash.h"

#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QMap>
#include <QScopedPointer>
#include <QUrl>

namespace
{
Entry* readItem(const QJsonObject& item)
{
const auto itemMap = item.toVariantMap();
const auto dataMap = itemMap.value("data").toMap();
const auto metadataMap = dataMap.value("metadata").toMap();

// Create entry and assign basic values
QScopedPointer<Entry> entry(new Entry());
entry->setUuid(QUuid::createUuid());
entry->setTitle(metadataMap.value("name").toString());
entry->setNotes(metadataMap.value("note").toString());

if (itemMap.value("pinned").toBool()) {
entry->addTag(QObject::tr("Favorite", "Tag for favorite entries"));
}

// Handle specific item types
auto type = dataMap.value("type").toString();

// Login
if (type.compare("login", Qt::CaseInsensitive) == 0) {
const auto loginMap = dataMap.value("content").toMap();
entry->setUsername(loginMap.value("itemUsername").toString());
entry->setPassword(loginMap.value("password").toString());
if (loginMap.contains("totpUri")) {
auto totp = loginMap.value("totpUri").toString();
if (!totp.startsWith("otpauth://")) {
QUrl url(QString("otpauth://totp/%1:%2?secret=%3")
.arg(QString(QUrl::toPercentEncoding(entry->title())),
QString(QUrl::toPercentEncoding(entry->username())),
QString(QUrl::toPercentEncoding(totp))));
totp = url.toString(QUrl::FullyEncoded);
}
entry->setTotp(Totp::parseSettings(totp));
}

if (loginMap.contains("itemEmail")) {
entry->attributes()->set("login_email", loginMap.value("itemEmail").toString());
}

// Set the entry url(s)
int i = 1;
for (const auto& urlObj : loginMap.value("urls").toList()) {
const auto url = urlObj.toString();
if (entry->url().isEmpty()) {
// First url encountered is set as the primary url
entry->setUrl(url);
} else {
// Subsequent urls
entry->attributes()->set(
QString("%1_%2").arg(EntryAttributes::AdditionalUrlAttribute, QString::number(i)), url);
++i;
}
}
}
// Credit Card
else if (type.compare("creditCard", Qt::CaseInsensitive) == 0) {
const auto cardMap = dataMap.value("content").toMap();
entry->setUsername(cardMap.value("number").toString());
entry->setPassword(cardMap.value("verificationNumber").toString());
const QStringList attrs({"cardholderName", "pin", "expirationDate"});
const QStringList sensitive({"pin"});
for (const auto& attr : attrs) {
auto value = cardMap.value(attr).toString();
if (!value.isEmpty()) {
entry->attributes()->set("card_" + attr, value, sensitive.contains(attr));
}
}
}

// Parse extra fields
for (const auto& field : dataMap.value("extraFields").toList()) {
// Derive a prefix for attribute names using the title or uuid if missing
const auto fieldMap = field.toMap();
auto name = fieldMap.value("fieldName").toString();
if (entry->attributes()->hasKey(name)) {
name = QString("%1_%2").arg(name, QUuid::createUuid().toString().mid(1, 5));
}

QString value;
const auto fieldType = fieldMap.value("type").toString();
if (fieldType.compare("totp", Qt::CaseInsensitive) == 0) {
value = fieldMap.value("data").toJsonObject().value("totpUri").toString();
} else {
value = fieldMap.value("data").toJsonObject().value("content").toString();
}

entry->attributes()->set(name, value, fieldType.compare("hidden", Qt::CaseInsensitive) == 0);
}

// Checked expired/deleted state
if (itemMap.value("state").toInt() == 2) {
entry->setExpires(true);
entry->setExpiryTime(QDateTime::currentDateTimeUtc());
}

// Collapse any accumulated history
entry->removeHistoryItems(entry->historyItems());

// Adjust the created and modified times
auto timeInfo = entry->timeInfo();
const auto createdTime = QDateTime::fromSecsSinceEpoch(itemMap.value("createTime").toULongLong(), Qt::UTC);
const auto modifiedTime = QDateTime::fromSecsSinceEpoch(itemMap.value("modifyTime").toULongLong(), Qt::UTC);
timeInfo.setCreationTime(createdTime);
timeInfo.setLastModificationTime(modifiedTime);
timeInfo.setLastAccessTime(modifiedTime);
entry->setTimeInfo(timeInfo);

return entry.take();
}

void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db)
{
// Create groups from vaults and store a temporary map of id -> uuid
const auto vaults = vault.value("vaults").toObject().toVariantMap();
for (const auto& vaultId : vaults.keys()) {
auto vaultObj = vaults.value(vaultId).toJsonObject();
auto group = new Group();
group->setUuid(QUuid::createUuid());
group->setName(vaultObj.value("name").toString());
group->setNotes(vaultObj.value("description").toString());
group->setParent(db->rootGroup());

const auto items = vaultObj.value("items").toArray();
for (const auto& item : items) {
auto entry = readItem(item.toObject());
if (entry) {
entry->setGroup(group, false);
}
}
}
}
} // namespace

bool ProtonPassReader::hasError()
{
return !m_error.isEmpty();
}

QString ProtonPassReader::errorString()
{
return m_error;
}

QSharedPointer<Database> ProtonPassReader::convert(const QString& path)
{
m_error.clear();

QFileInfo fileinfo(path);
if (!fileinfo.exists()) {
m_error = QObject::tr("File does not exist.").arg(path);
return {};
}

// Bitwarden uses a json file format
QFile file(fileinfo.absoluteFilePath());
if (!file.open(QFile::ReadOnly)) {
m_error = QObject::tr("Cannot open file: %1").arg(file.errorString());
return {};
}

QJsonParseError error;
auto json = QJsonDocument::fromJson(file.readAll(), &error).object();
if (error.error != QJsonParseError::NoError) {
m_error =
QObject::tr("Cannot parse file: %1 at position %2").arg(error.errorString(), QString::number(error.offset));
return {};
}

file.close();

if (json.value("encrypted").toBool()) {
m_error = QObject::tr("Encrypted files are not supported.");
return {};
}

auto db = QSharedPointer<Database>::create();
db->rootGroup()->setName(QObject::tr("Proton Pass Import"));

writeVaultToDatabase(json, db);

return db;
}
43 changes: 43 additions & 0 deletions src/format/ProtonPassReader.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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
* 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 PROTONPASS_READER_H
#define PROTONPASS_READER_H

#include <QSharedPointer>

class Database;

/*!
* Imports a Proton Pass vault in JSON format: https://proton.me/support/pass-export
*/
class ProtonPassReader
{
public:
explicit ProtonPassReader() = default;
~ProtonPassReader() = default;

QSharedPointer<Database> convert(const QString& path);

bool hasError();
QString errorString();

private:
QString m_error;
};

#endif // PROTONPASS_READER_H
3 changes: 3 additions & 0 deletions src/gui/DatabaseTabWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,9 @@ void DatabaseTabWidget::importFile()
Merger merger(db.data(), newDb.data());
merger.setSkipDatabaseCustomData(true);
merger.merge();
// Transfer the root group data
newDb->rootGroup()->setName(db->rootGroup()->name());
newDb->rootGroup()->setNotes(db->rootGroup()->notes());
// Show the new database
auto dbWidget = new DatabaseWidget(newDb, this);
addDatabaseTab(dbWidget);
Expand Down
1 change: 1 addition & 0 deletions src/gui/wizard/ImportWizard.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class ImportWizard : public QWizard
IMPORT_OPVAULT,
IMPORT_OPUX,
IMPORT_BITWARDEN,
IMPORT_PROTONPASS,
IMPORT_KEEPASS1,
IMPORT_REMOTE,
};
Expand Down
Loading