Files
keepassxc/src/format/OpVaultReaderBandEntry.cpp
Jonathan White 612f8d2e5b Improve OPVault handling and replace test opvault
* Fix various bugs in opvault parsing to include: TOTP parsing, date handling, naming convention, attachments, and multiple url's.

* Remove category groups that don't have any entries.

* Simplify tests by focusing on the resulting database instead of the parsing mechanics.

* Remove proprietary "freddy" opvault in favor of self-made "keepassxc" opvault.

* Fix #4069, select opvault file on macOS
2020-05-14 15:17:28 -04:00

264 lines
9.3 KiB
C++

/*
* 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 <QScopedPointer>
#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;
}
QScopedPointer<Entry> entry(new Entry());
if (bandEntry.contains("trashed") && bandEntry["trashed"].toBool()) {
// Send this entry to the recycle bin
rootGroup->database()->recycleEntry(entry.data());
} else 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.data(), bandEntry)) {
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.data(), section);
}
fillAttachments(entry.data(), attachmentDir, entryKey, entryHmacKey);
return entry.take();
}
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;
}
auto overviewJsonBytes = entOver01.getClearText();
auto overviewDoc = QJsonDocument::fromJson(overviewJsonBytes);
auto 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()) {
const auto& urlObj = urlV.toObject();
if (urlObj.contains("u")) {
auto newUrl = urlObj["u"].toString();
if (newUrl != url) {
// Add this url if it isn't the base one
entry->attributes()->set(QString("KP2A_URL_%1").arg(i), newUrl);
++i;
}
}
}
QStringList tagsList;
for (const auto tagV : overviewJson["tags"].toArray()) {
if (tagV.isString()) {
tagsList << tagV.toString();
}
}
entry->setTags(tagsList.join(','));
return true;
}