Skip to content

Commit

Permalink
Add Proton Pass importer
Browse files Browse the repository at this point in the history
* Closes #10465
  • Loading branch information
droidmonkey committed Dec 1, 2024
1 parent 9a63e80 commit 9704699
Show file tree
Hide file tree
Showing 15 changed files with 562 additions and 16 deletions.
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 @@ -72,6 +72,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 @@ -4683,6 +4683,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 @@ -4699,10 +4707,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 @@ -4713,6 +4717,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 @@ -9058,6 +9066,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

0 comments on commit 9704699

Please sign in to comment.