Implement 1Password Vault Import
* Support importing 1Password vaults (.opvault folders) into KDBX database * Entry attributes are filled based on section and field name * Expiration dates are set for entries * Entry URL's are set from a wider array of fields
This commit is contained in:
266
src/format/OpVaultReaderBandEntry.cpp
Normal file
266
src/format/OpVaultReaderBandEntry.cpp
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright (C) 2019 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 "OpData01.h"
|
||||
#include "OpVaultReader.h"
|
||||
|
||||
#include "core/Group.h"
|
||||
#include "core/Tools.h"
|
||||
#include "crypto/CryptoHash.h"
|
||||
#include "crypto/SymmetricCipher.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUuid>
|
||||
|
||||
bool OpVaultReader::decryptBandEntry(const QJsonObject& bandEntry,
|
||||
QJsonObject& data,
|
||||
QByteArray& key,
|
||||
QByteArray& hmacKey)
|
||||
{
|
||||
if (!bandEntry.contains("d")) {
|
||||
qWarning() << "Band entries must contain a \"d\" key: " << bandEntry.keys();
|
||||
return false;
|
||||
}
|
||||
if (!bandEntry.contains("k")) {
|
||||
qWarning() << "Band entries must contain a \"k\" key: " << bandEntry.keys();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString uuid = bandEntry.value("uuid").toString();
|
||||
|
||||
/*!
|
||||
* This is the encrypted item and MAC keys.
|
||||
* It is encrypted with the master encryption key and authenticated with the master MAC key.
|
||||
*
|
||||
* The last 32 bytes comprise the HMAC-SHA256 of the IV and the encrypted data.
|
||||
* The MAC is computed with the master MAC key.
|
||||
* The data before the MAC is the AES-CBC encrypted item keys using unique random 16-byte IV.
|
||||
* \code
|
||||
* uint8_t crypto_key[32];
|
||||
* uint8_t mac_key[32];
|
||||
* \endcode
|
||||
* \sa https://support.1password.com/opvault-design/#k
|
||||
*/
|
||||
const QString& entKStr = bandEntry["k"].toString();
|
||||
QByteArray kBA = QByteArray::fromBase64(entKStr.toUtf8());
|
||||
const int wantKsize = 16 + 32 + 32 + 32;
|
||||
if (kBA.size() != wantKsize) {
|
||||
qCritical("Malformed \"k\" size; expected %d got %d\n", wantKsize, kBA.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray hmacSig = kBA.mid(kBA.size() - 32, 32);
|
||||
const QByteArray& realHmacSig =
|
||||
CryptoHash::hmac(kBA.mid(0, kBA.size() - hmacSig.size()), m_masterHmacKey, CryptoHash::Sha256);
|
||||
if (realHmacSig != hmacSig) {
|
||||
qCritical() << QString(R"(Entry "k" failed its HMAC in UUID "%1", wanted "%2" got "%3")")
|
||||
.arg(uuid)
|
||||
.arg(QString::fromUtf8(hmacSig.toHex()))
|
||||
.arg(QString::fromUtf8(realHmacSig));
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray iv = kBA.mid(0, 16);
|
||||
QByteArray keyAndMacKey = kBA.mid(iv.size(), 64);
|
||||
SymmetricCipher cipher(SymmetricCipher::Aes256, SymmetricCipher::Cbc, SymmetricCipher::Decrypt);
|
||||
if (!cipher.init(m_masterKey, iv)) {
|
||||
qCritical() << "Unable to init cipher using masterKey in UUID " << uuid;
|
||||
return false;
|
||||
}
|
||||
if (!cipher.processInPlace(keyAndMacKey)) {
|
||||
qCritical() << "Unable to decipher \"k\"(key+hmac) in UUID " << uuid;
|
||||
return false;
|
||||
}
|
||||
|
||||
key = keyAndMacKey.mid(0, 32);
|
||||
hmacKey = keyAndMacKey.mid(32);
|
||||
|
||||
QString dKeyB64 = bandEntry.value("d").toString();
|
||||
OpData01 entD01;
|
||||
if (!entD01.decodeBase64(dKeyB64, key, hmacKey)) {
|
||||
qCritical() << R"(Unable to decipher "d" in UUID ")" << uuid << "\": " << entD01.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto clearText = entD01.getClearText();
|
||||
data = QJsonDocument::fromJson(clearText).object();
|
||||
return true;
|
||||
}
|
||||
|
||||
Entry* OpVaultReader::processBandEntry(const QJsonObject& bandEntry, const QDir& attachmentDir, Group* rootGroup)
|
||||
{
|
||||
const QString uuid = bandEntry.value("uuid").toString();
|
||||
if (!(uuid.size() == 32 || uuid.size() == 36)) {
|
||||
qWarning() << QString("Skipping suspicious band UUID <<%1>> with length %2").arg(uuid).arg(uuid.size());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto entry = new Entry();
|
||||
|
||||
if (bandEntry.contains("category")) {
|
||||
const QJsonValue& categoryValue = bandEntry["category"];
|
||||
if (categoryValue.isString()) {
|
||||
bool found = false;
|
||||
const QString category = categoryValue.toString();
|
||||
for (Group* group : rootGroup->children()) {
|
||||
const QVariant& groupCode = group->property("code");
|
||||
if (category == groupCode.toString()) {
|
||||
entry->setGroup(group);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
qWarning() << QString("Unable to place Entry.Category \"%1\" so using the Root instead").arg(category);
|
||||
entry->setGroup(rootGroup);
|
||||
}
|
||||
} else {
|
||||
qWarning() << QString(R"(Skipping non-String Category type "%1" in UUID "%2")")
|
||||
.arg(categoryValue.type())
|
||||
.arg(uuid);
|
||||
entry->setGroup(rootGroup);
|
||||
}
|
||||
} else {
|
||||
qWarning() << "Using the root group because the entry is category-less: <<\n"
|
||||
<< bandEntry << "\n>> in UUID " << uuid;
|
||||
entry->setGroup(rootGroup);
|
||||
}
|
||||
|
||||
entry->setUpdateTimeinfo(false);
|
||||
TimeInfo ti;
|
||||
bool timeInfoOk = false;
|
||||
if (bandEntry.contains("created")) {
|
||||
auto createdTime = static_cast<uint>(bandEntry["created"].toInt());
|
||||
ti.setCreationTime(QDateTime::fromTime_t(createdTime, Qt::UTC));
|
||||
timeInfoOk = true;
|
||||
}
|
||||
if (bandEntry.contains("updated")) {
|
||||
auto updateTime = static_cast<uint>(bandEntry["updated"].toInt());
|
||||
ti.setLastModificationTime(QDateTime::fromTime_t(updateTime, Qt::UTC));
|
||||
timeInfoOk = true;
|
||||
}
|
||||
// "tx" is modified by sync, not by user; maybe a custom attribute?
|
||||
if (timeInfoOk) {
|
||||
entry->setTimeInfo(ti);
|
||||
}
|
||||
entry->setUuid(Tools::hexToUuid(uuid));
|
||||
|
||||
if (!fillAttributes(entry, bandEntry)) {
|
||||
delete entry;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QJsonObject data;
|
||||
QByteArray entryKey;
|
||||
QByteArray entryHmacKey;
|
||||
|
||||
if (!decryptBandEntry(bandEntry, data, entryKey, entryHmacKey)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
if (data.contains("notesPlain")) {
|
||||
entry->setNotes(data.value("notesPlain").toString());
|
||||
}
|
||||
|
||||
// it seems sometimes the password is a top-level field, and not in "fields" themselves
|
||||
if (data.contains("password")) {
|
||||
entry->setPassword(data.value("password").toString());
|
||||
}
|
||||
|
||||
for (const auto& fieldValue : data.value("fields").toArray()) {
|
||||
if (!fieldValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto field = fieldValue.toObject();
|
||||
auto designation = field["designation"].toString();
|
||||
auto value = field["value"].toString();
|
||||
if (designation == "password") {
|
||||
entry->setPassword(value);
|
||||
} else if (designation == "username") {
|
||||
entry->setUsername(value);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray& sectionsArray = data["sections"].toArray();
|
||||
for (const QJsonValue& sectionValue : sectionsArray) {
|
||||
if (!sectionValue.isObject()) {
|
||||
qWarning() << R"(Skipping non-Object in "sections" for UUID ")" << uuid << "\" << " << sectionsArray
|
||||
<< ">>";
|
||||
continue;
|
||||
}
|
||||
const QJsonObject& section = sectionValue.toObject();
|
||||
|
||||
fillFromSection(entry, section);
|
||||
}
|
||||
|
||||
fillAttachments(entry, attachmentDir, entryKey, entryHmacKey);
|
||||
return entry;
|
||||
}
|
||||
|
||||
bool OpVaultReader::fillAttributes(Entry* entry, const QJsonObject& bandEntry)
|
||||
{
|
||||
const QString overviewStr = bandEntry.value("o").toString();
|
||||
OpData01 entOver01;
|
||||
if (!entOver01.decodeBase64(overviewStr, m_overviewKey, m_overviewHmacKey)) {
|
||||
qCritical() << "Unable to decipher 'o' in UUID \"" << entry->uuid() << "\"\n"
|
||||
<< ": " << entOver01.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray overviewJsonBytes = entOver01.getClearText();
|
||||
QJsonDocument overviewDoc = QJsonDocument::fromJson(overviewJsonBytes);
|
||||
QJsonObject overviewJson = overviewDoc.object();
|
||||
|
||||
QString title = overviewJson.value("title").toString();
|
||||
entry->setTitle(title);
|
||||
|
||||
QString url = overviewJson["url"].toString();
|
||||
entry->setUrl(url);
|
||||
|
||||
int i = 1;
|
||||
for (const auto& urlV : overviewJson["URLs"].toArray()) {
|
||||
auto urlName = QString("URL_%1").arg(i);
|
||||
auto urlValue = urlV.toString();
|
||||
if (urlV.isObject()) {
|
||||
const auto& urlObj = urlV.toObject();
|
||||
if (urlObj["l"].isString() && urlObj["u"].isString()) {
|
||||
urlName = urlObj["l"].toString();
|
||||
urlValue = urlObj["u"].toString();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!urlValue.isEmpty() && urlValue != url) {
|
||||
entry->attributes()->set(urlName, urlValue);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
QStringList tagsList;
|
||||
for (const auto& tagV : overviewJson["tags"].toArray()) {
|
||||
if (tagV.isString()) {
|
||||
tagsList << tagV.toString();
|
||||
}
|
||||
}
|
||||
entry->setTags(tagsList.join(','));
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user