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
This commit is contained in:
@@ -66,6 +66,8 @@ Database* OpVaultReader::readDatabase(QDir& opdataDir, const QString& password)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto vaultName = opdataDir.dirName();
|
||||
|
||||
auto key = QSharedPointer<CompositeKey>::create();
|
||||
key->addKey(QSharedPointer<PasswordKey>::create(password));
|
||||
|
||||
@@ -73,12 +75,12 @@ Database* OpVaultReader::readDatabase(QDir& opdataDir, const QString& password)
|
||||
db->setKdf(KeePass2::uuidToKdf(KeePass2::KDF_ARGON2));
|
||||
db->setCipher(KeePass2::CIPHER_AES256);
|
||||
db->setKey(key, true, false);
|
||||
db->metadata()->setName(opdataDir.dirName());
|
||||
db->metadata()->setName(vaultName);
|
||||
|
||||
auto rootGroup = db->rootGroup();
|
||||
rootGroup->setTimeInfo({});
|
||||
rootGroup->setUpdateTimeinfo(false);
|
||||
rootGroup->setName("OPVault Root Group");
|
||||
rootGroup->setName(vaultName.remove(".opvault"));
|
||||
rootGroup->setUuid(QUuid::createUuid());
|
||||
|
||||
populateCategoryGroups(rootGroup);
|
||||
@@ -110,7 +112,6 @@ Database* OpVaultReader::readDatabase(QDir& opdataDir, const QString& password)
|
||||
for (QChar ch : bandChars) {
|
||||
QFile bandFile(defaultDir.filePath(bandPattern.arg(ch)));
|
||||
if (!bandFile.exists()) {
|
||||
qWarning() << "Skipping missing file \"" << bandFile.fileName() << "\"";
|
||||
continue;
|
||||
}
|
||||
// https://support.1password.com/opvault-design/#band-files
|
||||
@@ -137,13 +138,20 @@ Database* OpVaultReader::readDatabase(QDir& opdataDir, const QString& password)
|
||||
continue;
|
||||
}
|
||||
// https://support.1password.com/opvault-design/#items
|
||||
Entry* entry = processBandEntry(bandEnt, defaultDir, rootGroup);
|
||||
auto entry = processBandEntry(bandEnt, defaultDir, rootGroup);
|
||||
if (!entry) {
|
||||
qWarning() << "Unable to process Band Entry " << uuid;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty categories (groups)
|
||||
for (auto group : rootGroup->children()) {
|
||||
if (group->isEmpty()) {
|
||||
delete group;
|
||||
}
|
||||
}
|
||||
|
||||
zeroKeys();
|
||||
return db.take();
|
||||
}
|
||||
|
||||
@@ -125,22 +125,8 @@ bool OpVaultReader::readAttachment(const QString& filePath,
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata.contains("contentsSize")) {
|
||||
qWarning() << "Expected attachment metadata to contain \"contentsSize\" but nope: " << metadata;
|
||||
return false;
|
||||
} else if (!metadata["contentsSize"].isDouble()) {
|
||||
qWarning() << "Expected attachment metadata to contain numeric \"contentsSize\" but nope: " << metadata;
|
||||
return false;
|
||||
}
|
||||
int bytesLen = metadata["contentsSize"].toInt();
|
||||
const QByteArray encData = file.readAll();
|
||||
if (encData.size() < bytesLen) {
|
||||
qCritical() << "Unable to read all of the attachment payload; wanted " << bytesLen << "but got"
|
||||
<< encData.size();
|
||||
return false;
|
||||
}
|
||||
|
||||
OpData01 att01;
|
||||
const QByteArray encData = file.readAll();
|
||||
if (!att01.decode(encData, itemKey, itemHmacKey)) {
|
||||
qCritical() << "Unable to decipher attachment payload: " << att01.errorString();
|
||||
return false;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QScopedPointer>
|
||||
#include <QUuid>
|
||||
|
||||
bool OpVaultReader::decryptBandEntry(const QJsonObject& bandEntry,
|
||||
@@ -112,9 +113,12 @@ Entry* OpVaultReader::processBandEntry(const QJsonObject& bandEntry, const QDir&
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto entry = new Entry();
|
||||
QScopedPointer<Entry> entry(new Entry());
|
||||
|
||||
if (bandEntry.contains("category")) {
|
||||
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;
|
||||
@@ -162,8 +166,7 @@ Entry* OpVaultReader::processBandEntry(const QJsonObject& bandEntry, const QDir&
|
||||
}
|
||||
entry->setUuid(Tools::hexToUuid(uuid));
|
||||
|
||||
if (!fillAttributes(entry, bandEntry)) {
|
||||
delete entry;
|
||||
if (!fillAttributes(entry.data(), bandEntry)) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -184,7 +187,7 @@ Entry* OpVaultReader::processBandEntry(const QJsonObject& bandEntry, const QDir&
|
||||
entry->setPassword(data.value("password").toString());
|
||||
}
|
||||
|
||||
for (const auto& fieldValue : data.value("fields").toArray()) {
|
||||
for (const auto fieldValue : data.value("fields").toArray()) {
|
||||
if (!fieldValue.isObject()) {
|
||||
continue;
|
||||
}
|
||||
@@ -208,11 +211,11 @@ Entry* OpVaultReader::processBandEntry(const QJsonObject& bandEntry, const QDir&
|
||||
}
|
||||
const QJsonObject& section = sectionValue.toObject();
|
||||
|
||||
fillFromSection(entry, section);
|
||||
fillFromSection(entry.data(), section);
|
||||
}
|
||||
|
||||
fillAttachments(entry, attachmentDir, entryKey, entryHmacKey);
|
||||
return entry;
|
||||
fillAttachments(entry.data(), attachmentDir, entryKey, entryHmacKey);
|
||||
return entry.take();
|
||||
}
|
||||
|
||||
bool OpVaultReader::fillAttributes(Entry* entry, const QJsonObject& bandEntry)
|
||||
@@ -225,9 +228,9 @@ bool OpVaultReader::fillAttributes(Entry* entry, const QJsonObject& bandEntry)
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray overviewJsonBytes = entOver01.getClearText();
|
||||
QJsonDocument overviewDoc = QJsonDocument::fromJson(overviewJsonBytes);
|
||||
QJsonObject overviewJson = overviewDoc.object();
|
||||
auto overviewJsonBytes = entOver01.getClearText();
|
||||
auto overviewDoc = QJsonDocument::fromJson(overviewJsonBytes);
|
||||
auto overviewJson = overviewDoc.object();
|
||||
|
||||
QString title = overviewJson.value("title").toString();
|
||||
entry->setTitle(title);
|
||||
@@ -236,26 +239,20 @@ bool OpVaultReader::fillAttributes(Entry* entry, const QJsonObject& bandEntry)
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!urlValue.isEmpty() && urlValue != url) {
|
||||
entry->attributes()->set(urlName, urlValue);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
QStringList tagsList;
|
||||
for (const auto& tagV : overviewJson["tags"].toArray()) {
|
||||
for (const auto tagV : overviewJson["tags"].toArray()) {
|
||||
if (tagV.isString()) {
|
||||
tagsList << tagV.toString();
|
||||
}
|
||||
|
||||
@@ -31,10 +31,29 @@
|
||||
#include <QUrlQuery>
|
||||
#include <QUuid>
|
||||
|
||||
namespace
|
||||
{
|
||||
QDateTime resolveDate(const QString& kind, const QJsonValue& value)
|
||||
{
|
||||
QDateTime date;
|
||||
if (kind == "monthYear") {
|
||||
// 1Password programmers are sadistic...
|
||||
auto dateValue = QString::number(value.toInt());
|
||||
date = QDateTime::fromString(dateValue, "yyyyMM");
|
||||
date.setTimeSpec(Qt::UTC);
|
||||
} else if (value.isString()) {
|
||||
date = QDateTime::fromTime_t(value.toString().toUInt(), Qt::UTC);
|
||||
} else {
|
||||
date = QDateTime::fromTime_t(value.toInt(), Qt::UTC);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OpVaultReader::fillFromSection(Entry* entry, const QJsonObject& section)
|
||||
{
|
||||
const auto uuid = entry->uuid();
|
||||
const QString& sectionName = section["name"].toString();
|
||||
QString sectionName = section["name"].toString();
|
||||
|
||||
if (!section.contains("fields")) {
|
||||
auto sectionNameLC = sectionName.toLower();
|
||||
@@ -47,6 +66,12 @@ void OpVaultReader::fillFromSection(Entry* entry, const QJsonObject& section)
|
||||
qWarning() << R"(Skipping non-Array "fields" in UUID ")" << uuid << "\"\n";
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a default section name then replace with the section title if not empty
|
||||
if (sectionName.startsWith("Section_") && !section["title"].toString().isEmpty()) {
|
||||
sectionName = section["title"].toString();
|
||||
}
|
||||
|
||||
QJsonArray sectionFields = section["fields"].toArray();
|
||||
for (const QJsonValue sectionField : sectionFields) {
|
||||
if (!sectionField.isObject()) {
|
||||
@@ -68,7 +93,7 @@ void OpVaultReader::fillFromSectionField(Entry* entry, const QString& sectionNam
|
||||
// Ignore "a" and "inputTraits" fields, they don't apply to KPXC
|
||||
|
||||
auto attrName = resolveAttributeName(sectionName, field["n"].toString(), field["t"].toString());
|
||||
auto attrValue = field.value("v").toVariant().toString();
|
||||
auto attrValue = field.value("v").toString();
|
||||
auto kind = field["k"].toString();
|
||||
|
||||
if (attrName.startsWith("TOTP_")) {
|
||||
@@ -82,30 +107,37 @@ void OpVaultReader::fillFromSectionField(Entry* entry, const QString& sectionNam
|
||||
query.addQueryItem("period", QString("%1").arg(Totp::DEFAULT_STEP));
|
||||
}
|
||||
attrValue = query.toString(QUrl::FullyEncoded);
|
||||
}
|
||||
entry->setTotp(Totp::parseSettings(attrValue));
|
||||
} else if (attrName.startsWith("expir", Qt::CaseInsensitive)) {
|
||||
QDateTime expiry;
|
||||
if (kind == "date") {
|
||||
expiry = QDateTime::fromTime_t(attrValue.toUInt(), Qt::UTC);
|
||||
entry->setTotp(Totp::parseSettings(attrValue));
|
||||
} else {
|
||||
expiry = QDateTime::fromString(attrValue, "yyyyMM");
|
||||
expiry.setTimeSpec(Qt::UTC);
|
||||
entry->setTotp(Totp::parseSettings({}, attrValue));
|
||||
}
|
||||
|
||||
} else if (attrName.startsWith("expir", Qt::CaseInsensitive)) {
|
||||
QDateTime expiry = resolveDate(kind, field.value("v"));
|
||||
if (expiry.isValid()) {
|
||||
entry->setExpiryTime(expiry);
|
||||
entry->setExpires(true);
|
||||
} else {
|
||||
qWarning() << QString("[%1] Invalid expiration date found: %2").arg(entry->title(), attrValue);
|
||||
}
|
||||
} else {
|
||||
if (kind == "date") {
|
||||
auto date = QDateTime::fromTime_t(attrValue.toUInt(), Qt::UTC);
|
||||
if (kind == "date" || kind == "monthYear") {
|
||||
QDateTime date = resolveDate(kind, field.value("v"));
|
||||
if (date.isValid()) {
|
||||
attrValue = date.toString();
|
||||
entry->attributes()->set(attrName, date.toString(Qt::SystemLocaleShortDate));
|
||||
} else {
|
||||
qWarning()
|
||||
<< QString("[%1] Invalid date attribute found: %2 = %3").arg(entry->title(), attrName, attrValue);
|
||||
}
|
||||
} else if (kind == "address") {
|
||||
// Expand address into multiple attributes
|
||||
auto addrFields = field.value("v").toObject().toVariantMap();
|
||||
for (auto part : addrFields.keys()) {
|
||||
entry->attributes()->set(attrName + QString("_%1").arg(part), addrFields.value(part).toString());
|
||||
}
|
||||
} else {
|
||||
entry->attributes()->set(attrName, attrValue, (kind == "password" || kind == "concealed"));
|
||||
}
|
||||
|
||||
entry->attributes()->set(attrName, attrValue, (kind == "password" || kind == "concealed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +150,7 @@ QString OpVaultReader::resolveAttributeName(const QString& section, const QStrin
|
||||
|
||||
auto lowName = name.toLower();
|
||||
auto lowText = text.toLower();
|
||||
if (section.isEmpty()) {
|
||||
if (section.isEmpty() || name.startsWith("address")) {
|
||||
// Empty section implies these are core attributes
|
||||
// try to find username, password, url
|
||||
if (lowName == "password" || lowText == "password") {
|
||||
|
||||
Reference in New Issue
Block a user