diff --git a/COPYING b/COPYING index 3436ec3e47..35e8c52e9b 100644 --- a/COPYING +++ b/COPYING @@ -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 License: MIT diff --git a/share/icons/application/scalable/actions/proton.svg b/share/icons/application/scalable/actions/proton.svg new file mode 100644 index 0000000000..89515ddec9 --- /dev/null +++ b/share/icons/application/scalable/actions/proton.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 40e0d5416a..d842fcde89 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -70,6 +70,7 @@ application/scalable/actions/password-generator.svg application/scalable/actions/password-show-off.svg application/scalable/actions/password-show-on.svg + application/scalable/actions/proton.svg application/scalable/actions/qrcode.svg application/scalable/actions/refresh.svg application/scalable/actions/remote-sync.svg diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 67aada93fe..551db9cbfc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/format/ProtonPassReader.cpp b/src/format/ProtonPassReader.cpp new file mode 100644 index 0000000000..105adde79f --- /dev/null +++ b/src/format/ProtonPassReader.cpp @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * 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 . + */ + +#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 +#include +#include +#include +#include +#include +#include +#include + +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(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 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 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::create(); + db->rootGroup()->setName(QObject::tr("Proton Pass Import")); + + writeVaultToDatabase(json, db); + + return db; +} diff --git a/src/format/ProtonPassReader.h b/src/format/ProtonPassReader.h new file mode 100644 index 0000000000..74764b8900 --- /dev/null +++ b/src/format/ProtonPassReader.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * 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 . + */ + +#ifndef PROTONPASS_READER_H +#define PROTONPASS_READER_H + +#include + +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 convert(const QString& path); + + bool hasError(); + QString errorString(); + +private: + QString m_error; +}; + +#endif // PROTONPASS_READER_H diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index b9b26d3775..2b58d20fe6 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -278,6 +278,9 @@ DatabaseWidget* 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); diff --git a/src/gui/wizard/ImportWizard.h b/src/gui/wizard/ImportWizard.h index b7e9de68d3..8747f42388 100644 --- a/src/gui/wizard/ImportWizard.h +++ b/src/gui/wizard/ImportWizard.h @@ -48,6 +48,7 @@ class ImportWizard : public QWizard IMPORT_OPVAULT, IMPORT_OPUX, IMPORT_BITWARDEN, + IMPORT_PROTONPASS, IMPORT_KEEPASS1 }; diff --git a/src/gui/wizard/ImportWizardPageReview.cpp b/src/gui/wizard/ImportWizardPageReview.cpp index 2cb56791b6..b63cf7b4d3 100644 --- a/src/gui/wizard/ImportWizardPageReview.cpp +++ b/src/gui/wizard/ImportWizardPageReview.cpp @@ -24,6 +24,7 @@ #include "format/KeePass1Reader.h" #include "format/OPUXReader.h" #include "format/OpVaultReader.h" +#include "format/ProtonPassReader.h" #include "gui/csvImport/CsvImportWidget.h" #include "gui/wizard/ImportWizard.h" @@ -66,28 +67,29 @@ void ImportWizardPageReview::initializePage() break; case ImportWizard::IMPORT_OPVAULT: m_db = importOPVault(filename, field("ImportPassword").toString()); - setupDatabasePreview(); break; case ImportWizard::IMPORT_OPUX: m_db = importOPUX(filename); - setupDatabasePreview(); break; case ImportWizard::IMPORT_KEEPASS1: m_db = importKeePass1(filename, field("ImportPassword").toString(), field("ImportKeyFile").toString()); - setupDatabasePreview(); break; case ImportWizard::IMPORT_BITWARDEN: m_db = importBitwarden(filename, field("ImportPassword").toString()); - setupDatabasePreview(); + break; + case ImportWizard::IMPORT_PROTONPASS: + m_db = importProtonPass(filename); break; default: break; } + + setupDatabasePreview(); } bool ImportWizardPageReview::validatePage() { - if (m_csvWidget && field("ImportType").toInt() == ImportWizard::IMPORT_CSV) { + if (isCsvImport()) { m_db = m_csvWidget->buildDatabase(); } return !m_db.isNull(); @@ -109,14 +111,18 @@ void ImportWizardPageReview::setupCsvImport(const QString& filename) }); m_csvWidget->load(filename); - - // Qt does not automatically resize a QScrollWidget in a QWizard... - m_ui->scrollAreaContents->layout()->addWidget(m_csvWidget); - m_ui->scrollArea->setMinimumSize(m_csvWidget->width() + 50, m_csvWidget->height() + 100); } void ImportWizardPageReview::setupDatabasePreview() { + // CSV preview is handled by the import widget + if (isCsvImport()) { + // Qt does not automatically resize a QScrollWidget in a QWizard... + m_ui->scrollAreaContents->layout()->addWidget(m_csvWidget); + m_ui->scrollArea->setMinimumSize(m_csvWidget->width() + 50, m_csvWidget->height() + 100); + return; + } + if (!m_db) { m_ui->scrollArea->setVisible(false); return; @@ -200,3 +206,18 @@ ImportWizardPageReview::importKeePass1(const QString& filename, const QString& p return db; } + +QSharedPointer ImportWizardPageReview::importProtonPass(const QString& filename) +{ + ProtonPassReader reader; + auto db = reader.convert(filename); + if (reader.hasError()) { + m_ui->messageWidget->showMessage(reader.errorString(), KMessageWidget::Error, -1); + } + return db; +} + +bool ImportWizardPageReview::isCsvImport() const +{ + return m_csvWidget && field("ImportType").toInt() == ImportWizard::IMPORT_CSV; +} diff --git a/src/gui/wizard/ImportWizardPageReview.h b/src/gui/wizard/ImportWizardPageReview.h index 87f501c85c..862482a1d5 100644 --- a/src/gui/wizard/ImportWizardPageReview.h +++ b/src/gui/wizard/ImportWizardPageReview.h @@ -43,11 +43,13 @@ class ImportWizardPageReview : public QWizardPage QSharedPointer database(); private: + bool isCsvImport() const; void setupCsvImport(const QString& filename); QSharedPointer importOPUX(const QString& filename); QSharedPointer importBitwarden(const QString& filename, const QString& password); QSharedPointer importOPVault(const QString& filename, const QString& password); QSharedPointer importKeePass1(const QString& filename, const QString& password, const QString& keyfile); + QSharedPointer importProtonPass(const QString& filename); void setupDatabasePreview(); diff --git a/src/gui/wizard/ImportWizardPageSelect.cpp b/src/gui/wizard/ImportWizardPageSelect.cpp index 43b0e8f2aa..47e5021830 100644 --- a/src/gui/wizard/ImportWizardPageSelect.cpp +++ b/src/gui/wizard/ImportWizardPageSelect.cpp @@ -35,13 +35,15 @@ ImportWizardPageSelect::ImportWizardPageSelect(QWidget* parent) new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Export (.1pux)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Vault (.opvault)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("bitwarden"), tr("Bitwarden (.json)"), m_ui->importTypeList); + new QListWidgetItem(icons()->icon("proton"), tr("Proton Pass (.json)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("object-locked"), tr("KeePass 1 Database (.kdb)"), m_ui->importTypeList); m_ui->importTypeList->item(0)->setData(Qt::UserRole, ImportWizard::IMPORT_CSV); m_ui->importTypeList->item(1)->setData(Qt::UserRole, ImportWizard::IMPORT_OPUX); m_ui->importTypeList->item(2)->setData(Qt::UserRole, ImportWizard::IMPORT_OPVAULT); m_ui->importTypeList->item(3)->setData(Qt::UserRole, ImportWizard::IMPORT_BITWARDEN); - m_ui->importTypeList->item(4)->setData(Qt::UserRole, ImportWizard::IMPORT_KEEPASS1); + m_ui->importTypeList->item(4)->setData(Qt::UserRole, ImportWizard::IMPORT_PROTONPASS); + m_ui->importTypeList->item(5)->setData(Qt::UserRole, ImportWizard::IMPORT_KEEPASS1); connect(m_ui->importTypeList, &QListWidget::currentItemChanged, this, &ImportWizardPageSelect::itemSelected); m_ui->importTypeList->setCurrentRow(0); @@ -104,6 +106,7 @@ void ImportWizardPageSelect::itemSelected(QListWidgetItem* current, QListWidgetI // Unencrypted types case ImportWizard::IMPORT_CSV: case ImportWizard::IMPORT_OPUX: + case ImportWizard::IMPORT_PROTONPASS: setCredentialState(false); break; // Password may be required @@ -237,6 +240,8 @@ QString ImportWizardPageSelect::importFileFilter() return QString("%1 (*.1pux)").arg(tr("1Password Export")); case ImportWizard::IMPORT_BITWARDEN: return QString("%1 (*.json)").arg(tr("Bitwarden JSON Export")); + case ImportWizard::IMPORT_PROTONPASS: + return QString("%1 (*.json)").arg(tr("Proton Pass JSON Export")); case ImportWizard::IMPORT_OPVAULT: return QString("%1 (*.opvault)").arg(tr("1Password Vault")); case ImportWizard::IMPORT_KEEPASS1: diff --git a/tests/TestImports.cpp b/tests/TestImports.cpp index 84ef26ccee..bfed90759b 100644 --- a/tests/TestImports.cpp +++ b/tests/TestImports.cpp @@ -25,6 +25,7 @@ #include "format/BitwardenReader.h" #include "format/OPUXReader.h" #include "format/OpVaultReader.h" +#include "format/ProtonPassReader.h" #include #include @@ -277,3 +278,58 @@ void TestImports::testBitwardenEncrypted() } QVERIFY(db); } + +void TestImports::testProtonPass() +{ + auto protonPassPath = + QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/protonpass_export.json")); + + ProtonPassReader reader; + auto db = reader.convert(protonPassPath); + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + QVERIFY(db); + + // Confirm Login fields + auto entry = db->rootGroup()->findEntryByPath("/Personal/Test Login"); + QVERIFY(entry); + QCOMPARE(entry->title(), QStringLiteral("Test Login")); + QCOMPARE(entry->username(), QStringLiteral("Username")); + QCOMPARE(entry->password(), QStringLiteral("Password")); + QCOMPARE(entry->url(), QStringLiteral("https://example.com/")); + QCOMPARE(entry->notes(), QStringLiteral("My login secure note.")); + // Check extra URL's + QCOMPARE(entry->attribute("KP2A_URL_1"), QStringLiteral("https://example2.com/")); + // Check TOTP + QVERIFY(entry->hasTotp()); + // Check attributes + auto attr = entry->attributes(); + QVERIFY(attr->isProtected("hidden field")); + QCOMPARE(attr->value("second 2fa secret"), QStringLiteral("TOTPCODE")); + // NOTE: Proton Pass does not export attachments + // NOTE: Proton Pass does not export expiration dates + + // Confirm Secure Note + entry = db->rootGroup()->findEntryByPath("/Personal/My Secure Note"); + QVERIFY(entry); + QCOMPARE(entry->notes(), QStringLiteral("Secure note contents.")); + + // Confirm Credit Card + entry = db->rootGroup()->findEntryByPath("/Personal/Test Card"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("1234222233334444")); + QCOMPARE(entry->password(), QStringLiteral("333")); + attr = entry->attributes(); + QCOMPARE(attr->value("card_cardholderName"), QStringLiteral("Test name")); + QCOMPARE(attr->value("card_expirationDate"), QStringLiteral("2025-01")); + QCOMPARE(attr->value("card_pin"), QStringLiteral("1234")); + QVERIFY(attr->isProtected("card_pin")); + + // Confirm Expired (deleted) entry + entry = db->rootGroup()->findEntryByPath("/Personal/My Deleted Note"); + QVERIFY(entry); + QVERIFY(entry->isExpired()); + + // Confirm second group (vault) + entry = db->rootGroup()->findEntryByPath("/Test/Other vault login"); + QVERIFY(entry); +} diff --git a/tests/TestImports.h b/tests/TestImports.h index 2e00de9a6a..fa128d1040 100644 --- a/tests/TestImports.h +++ b/tests/TestImports.h @@ -30,6 +30,7 @@ private slots: void testOPVault(); void testBitwarden(); void testBitwardenEncrypted(); + void testProtonPass(); }; #endif /* TEST_IMPORTS_H */ diff --git a/tests/data/protonpass_export.json b/tests/data/protonpass_export.json new file mode 100644 index 0000000000..ef82352180 --- /dev/null +++ b/tests/data/protonpass_export.json @@ -0,0 +1,173 @@ +{ + "version": "1.21.2", + "userId": "USER_ID", + "encrypted": false, + "vaults": { + "VAULT_A": { + "name": "Personal", + "description": "Personal vault", + "display": { + "color": 0, + "icon": 0 + }, + "items": [ + { + "itemId": "yZENmDjtmZGODNy3Q_CZiPAF_IgINq8w-R-qazrOh-Nt9YJeVF3gu07ovzDS4jhYHoMdOebTw5JkYPGgIL1mwQ==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "Test Login", + "note": "My login secure note.", + "itemUuid": "e8ee1a0c" + }, + "extraFields": [ + { + "fieldName": "non-hidden field", + "type": "text", + "data": { + "content": "non-hidden field content" + } + }, + { + "fieldName": "hidden field", + "type": "hidden", + "data": { + "content": "hidden field content" + } + }, + { + "fieldName": "second 2fa secret", + "type": "totp", + "data": { + "totpUri": "TOTPCODE" + } + } + ], + "type": "login", + "content": { + "itemEmail": "Email", + "password": "Password", + "urls": [ + "https://example.com/", + "https://example2.com/" + ], + "totpUri": "otpauth://totp/Test%20Login%20-%20Personal%20Vault:Username?issuer=Test%20Login%20-%20Personal%20Vault&secret=TOTPCODE&algorithm=SHA1&digits=6&period=30", + "passkeys": [], + "itemUsername": "Username" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182868, + "modifyTime": 1689182868, + "pinned": true + }, + { + "itemId": "xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "My Secure Note", + "note": "Secure note contents.", + "itemUuid": "ad618070" + }, + "extraFields": [], + "type": "note", + "content": {} + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182908, + "modifyTime": 1689182908, + "pinned": false + }, + { + "itemId": "ZmGzd-HNQYTr6wmfWlSfiStXQLqGic_PYB2Q2T_hmuRM2JIA4pKAPJcmFafxJrDpXxLZ2EPjgD6Noc9a0U6AVQ==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "Test Card", + "note": "Credit Card Note", + "itemUuid": "d8f45370" + }, + "extraFields": [], + "type": "creditCard", + "content": { + "cardholderName": "Test name", + "cardType": 0, + "number": "1234222233334444", + "verificationNumber": "333", + "expirationDate": "2025-01", + "pin": "1234" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1691001643, + "modifyTime": 1691001643, + "pinned": true + }, + { + "itemId": "xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "My Deleted Note", + "note": "Secure note contents.", + "itemUuid": "ad618070" + }, + "extraFields": [], + "type": "note", + "content": {} + }, + "state": 2, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182908, + "modifyTime": 1689182908, + "pinned": false + } + ] + }, + "VAULT_B": { + "name": "Test", + "description": "", + "display": { + "color": 4, + "icon": 2 + }, + "items": [ + { + "itemId": "U_J8-eUR15sC-PjUhjVcixDcayhjGuoerUZCr560RlAi0ZjBNkSaSKAytVzZn4E0hiFX1_y4qZbUetl6jO3aJw==", + "shareId": "OJz-4MnPqAuYnyemhctcGDlSLJrzsTnf2FnFSwxh1QP_oth9xyGDc2ZAqCv5FnqkVgTNHT5aPj62zcekNemfNw==", + "data": { + "metadata": { + "name": "Other vault login", + "note": "", + "itemUuid": "f3429d44" + }, + "extraFields": [], + "type": "login", + "content": { + "itemEmail": "other vault username", + "password": "other vault password", + "urls": [], + "totpUri": "JBSWY3DPEHPK3PXP", + "passkeys": [], + "itemUsername": "" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182949, + "modifyTime": 1689182949, + "pinned": false + } + ] + } + } +} \ No newline at end of file