diff options
Diffstat (limited to 'src/format/OpVaultReader.cpp')
-rw-r--r-- | src/format/OpVaultReader.cpp | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/src/format/OpVaultReader.cpp b/src/format/OpVaultReader.cpp new file mode 100644 index 000000000..49d62b624 --- /dev/null +++ b/src/format/OpVaultReader.cpp @@ -0,0 +1,478 @@ +/* + * 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 "OpVaultReader.h" +#include "OpData01.h" + +#include "core/Group.h" +#include "core/Tools.h" +#include "crypto/CryptoHash.h" +#include "crypto/SymmetricCipher.h" +#include "keys/PasswordKey.h" + +#include <QDebug> +#include <QJsonDocument> +#include <QJsonObject> +#include <QUuid> +#include <gcrypt.h> + +OpVaultReader::OpVaultReader(QObject* parent) + : QObject(parent) + , m_error(false) +{ +} + +OpVaultReader::~OpVaultReader() +{ +} + +Database* OpVaultReader::readDatabase(QDir& opdataDir, const QString& password) +{ + if (!opdataDir.exists()) { + m_error = true; + m_errorStr = tr("Directory .opvault must exist"); + return nullptr; + } + if (!opdataDir.isReadable()) { + m_error = true; + m_errorStr = tr("Directory .opvault must be readable"); + return nullptr; + } + + // https://support.1password.com/opvault-design/#directory-layout + QDir defaultDir = QDir(opdataDir); + if (!defaultDir.cd("default")) { + m_error = true; + m_errorStr = tr("Directory .opvault/default must exist"); + return nullptr; + } + if (!defaultDir.isReadable()) { + m_error = true; + m_errorStr = tr("Directory .opvault/default must be readable"); + return nullptr; + } + + auto key = QSharedPointer<CompositeKey>::create(); + key->addKey(QSharedPointer<PasswordKey>::create(password)); + + QScopedPointer<Database> db(new Database()); + db->setKdf(KeePass2::uuidToKdf(KeePass2::KDF_ARGON2)); + db->setCipher(KeePass2::CIPHER_AES256); + db->setKey(key, true, false); + db->metadata()->setName(opdataDir.dirName()); + + auto rootGroup = db->rootGroup(); + rootGroup->setTimeInfo({}); + rootGroup->setUpdateTimeinfo(false); + rootGroup->setName("OPVault Root Group"); + rootGroup->setUuid(QUuid::createUuid()); + + populateCategoryGroups(rootGroup); + + QFile profileJsFile(defaultDir.absoluteFilePath("profile.js")); + QJsonObject profileJson = readAndAssertJsonFile(profileJsFile, "var profile=", ";"); + if (profileJson.isEmpty()) { + return nullptr; + } + if (!processProfileJson(profileJson, password, rootGroup)) { + zeroKeys(); + return nullptr; + } + if (profileJson.contains("uuid") and profileJson["uuid"].isString()) { + rootGroup->setUuid(Tools::hexToUuid(profileJson["uuid"].toString())); + } + + QFile foldersJsFile(defaultDir.filePath("folders.js")); + if (foldersJsFile.exists()) { + QJsonObject foldersJs = readAndAssertJsonFile(foldersJsFile, "loadFolders(", ");"); + if (!processFolderJson(foldersJs, rootGroup)) { + zeroKeys(); + return nullptr; + } + } + + const QString bandChars("0123456789ABCDEF"); + QString bandPattern("band_%1.js"); + 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 + QJsonObject bandJs = readAndAssertJsonFile(bandFile, "ld(", ");"); + const QStringList keys = bandJs.keys(); + for (const QString& entryKey : keys) { + const QJsonObject bandEnt = bandJs[entryKey].toObject(); + const QString uuid = bandEnt["uuid"].toString(); + if (entryKey != uuid) { + qWarning() << QString("Mismatched Entry UUID, its JSON key <<%1>> and its UUID <<%2>>") + .arg(entryKey) + .arg(uuid); + } + QStringList requiredKeys({"d", "k", "hmac"}); + bool ok = true; + for (const QString& requiredKey : asConst(requiredKeys)) { + if (!bandEnt.contains(requiredKey)) { + qCritical() << "Skipping malformed Entry UUID " << uuid << " without key " << requiredKey; + ok = false; + break; + } + } + if (!ok) { + continue; + } + // https://support.1password.com/opvault-design/#items + Entry* entry = processBandEntry(bandEnt, defaultDir, rootGroup); + if (!entry) { + qWarning() << "Unable to process Band Entry " << uuid; + } + } + } + + zeroKeys(); + return db.take(); +} + +bool OpVaultReader::hasError() +{ + return m_error; +} + +QString OpVaultReader::errorString() +{ + return m_errorStr; +} + +bool OpVaultReader::processProfileJson(QJsonObject& profileJson, const QString& password, Group* rootGroup) +{ + unsigned long iterations = profileJson["iterations"].toInt(); + // QString lastUpdatedBy = profileJson["lastUpdatedBy"].toString(); + QString masterKeyB64 = profileJson["masterKey"].toString(); + QString overviewKeyB64 = profileJson["overviewKey"].toString(); + // QString profileName = profileJs["profileName"].toString(); + QByteArray salt; + { + QString saltB64 = profileJson["salt"].toString(); + salt = QByteArray::fromBase64(saltB64.toUtf8()); + } + auto rootGroupTime = rootGroup->timeInfo(); + auto createdAt = static_cast<uint>(profileJson["createdAt"].toInt()); + rootGroupTime.setCreationTime(QDateTime::fromTime_t(createdAt, Qt::UTC)); + auto updatedAt = static_cast<uint>(profileJson["updatedAt"].toInt()); + rootGroupTime.setLastModificationTime(QDateTime::fromTime_t(updatedAt, Qt::UTC)); + rootGroup->setUuid(Tools::hexToUuid(profileJson["uuid"].toString())); + + const auto derivedKeys = deriveKeysFromPassPhrase(salt, password, iterations); + if (derivedKeys->error) { + m_error = true; + m_errorStr = derivedKeys->errorStr; + delete derivedKeys; + return false; + } + + QByteArray encKey = derivedKeys->encrypt; + QByteArray hmacKey = derivedKeys->hmac; + delete derivedKeys; + + auto masterKeys = decodeB64CompositeKeys(masterKeyB64, encKey, hmacKey); + if (masterKeys->error) { + m_error = true; + m_errorStr = masterKeys->errorStr; + delete masterKeys; + return false; + } + m_masterKey = masterKeys->encrypt; + m_masterHmacKey = masterKeys->hmac; + delete masterKeys; + auto overviewKeys = decodeB64CompositeKeys(overviewKeyB64, encKey, hmacKey); + if (overviewKeys->error) { + m_error = true; + m_errorStr = overviewKeys->errorStr; + delete overviewKeys; + return false; + } + m_overviewKey = overviewKeys->encrypt; + m_overviewHmacKey = overviewKeys->hmac; + delete overviewKeys; + + return true; +} + +bool OpVaultReader::processFolderJson(QJsonObject& foldersJson, Group* rootGroup) +{ + const QStringList keys = foldersJson.keys(); + + bool result = true; + for (const QString& key : keys) { + const QJsonValueRef& folderValue = foldersJson[key]; + if (!folderValue.isObject()) { + qWarning() << "Found non-Object folder with key \"" << key << "\""; + continue; + } + const QJsonObject folder = folderValue.toObject(); + QJsonObject overviewJs; + const QString overviewStr = folder.value("overview").toString(); + OpData01 foldOverview01; + if (!foldOverview01.decodeBase64(overviewStr, m_overviewKey, m_overviewHmacKey)) { + qCritical() << "Unable to decipher folder UUID \"" << key << "\": " << foldOverview01.errorString(); + result = false; + continue; + } + auto foldOverview = foldOverview01.getClearText(); + QJsonDocument fOverJSON = QJsonDocument::fromJson(foldOverview); + overviewJs = fOverJSON.object(); + + const QString& folderTitle = overviewJs["title"].toString(); + auto myGroup = new Group(); + myGroup->setParent(rootGroup); + myGroup->setName(folderTitle); + if (folder.contains("uuid")) { + myGroup->setUuid(Tools::hexToUuid(folder["uuid"].toString())); + } + + if (overviewJs.contains("smart") && overviewJs["smart"].toBool()) { + if (!overviewJs.contains("predicate_b64")) { + const QString& errMsg = + QString(R"(Expected a predicate in smart folder[uuid="%1"; title="%2"]))").arg(key, folderTitle); + qWarning() << errMsg; + myGroup->setNotes(errMsg); + } else { + QByteArray pB64 = QByteArray::fromBase64(overviewJs["predicate_b64"].toString().toUtf8()); + myGroup->setNotes(pB64.toHex()); + } + } + + TimeInfo ti; + bool timeInfoOk = false; + if (folder.contains("created")) { + auto createdTime = static_cast<uint>(folder["created"].toInt()); + ti.setCreationTime(QDateTime::fromTime_t(createdTime, Qt::UTC)); + timeInfoOk = true; + } + if (folder.contains("updated")) { + auto updateTime = static_cast<uint>(folder["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) { + myGroup->setTimeInfo(ti); + } + } + return result; +} + +/* + * Asserts that the given file is an existing file, able to be read, contains JSON, and that + * the payload is a JSON object. Currently it just returns an empty QJsonObject as a means of + * indicating the error, although it will qCritical() if unable to actually open the file for reading. + * + * @param file the path containing the JSON file + * @param stripLeading any leading characters that might be present in file which should be removed + * @param stripTrailing the trailing characters that might be present in file which should be removed + * @return + */ +QJsonObject OpVaultReader::readAndAssertJsonFile(QFile& file, const QString& stripLeading, const QString& stripTrailing) +{ + QByteArray filePayload; + const QFileInfo& fileInfo = QFileInfo(file); + auto absFilePath = fileInfo.absoluteFilePath(); + if (!fileInfo.exists()) { + qCritical() << QString("File \"%1\" must exist").arg(absFilePath); + return QJsonObject(); + } + if (!fileInfo.isReadable()) { + qCritical() << QString("File \"%1\" must be readable").arg(absFilePath); + return QJsonObject(); + } + + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qCritical() << QString("Unable to open \"%1\" readonly+text").arg(absFilePath); + } + filePayload = file.readAll(); + file.close(); + if (!stripLeading.isEmpty()) { + QByteArray prefix = stripLeading.toUtf8(); + if (filePayload.startsWith(prefix)) { + filePayload = filePayload.remove(0, prefix.size()); + } + } + if (!stripTrailing.isEmpty()) { + QByteArray suffix = stripTrailing.toUtf8(); + if (filePayload.endsWith(suffix)) { + const int delBytes = suffix.size(); + filePayload = filePayload.remove(filePayload.length() - delBytes, delBytes); + } + } + + QJsonParseError* error = Q_NULLPTR; + QJsonDocument jDoc = QJsonDocument::fromJson(filePayload, error); + if (!jDoc.isObject()) { + qCritical() << "Expected " << filePayload << "to be a JSON Object"; + return QJsonObject(); + } + return jDoc.object(); +} + +/* Convenience method for calling decodeCompositeKeys when you have a base64 encrypted composite key. */ +OpVaultReader::DerivedKeyHMAC* +OpVaultReader::decodeB64CompositeKeys(const QString& b64, const QByteArray& encKey, const QByteArray& hmacKey) +{ + auto result = new DerivedKeyHMAC(); + + OpData01 keyKey01; + if (!keyKey01.decodeBase64(b64, encKey, hmacKey)) { + result->error = true; + result->errorStr = tr("Unable to decode masterKey: %1").arg(keyKey01.errorString()); + return result; + } + const QByteArray keyKey = keyKey01.getClearText(); + + return decodeCompositeKeys(keyKey); +} + +/* + * Given a string of bytes, decompose it into its constituent parts, an encryption key and a HMAC key. + * The plaintext of the masterKey is 256 bytes of data selected randomly when the keychain was first created. + * + * The 256 byte (2048 bit) plaintext content of the masterKey is then hashed with SHA-512. + * The first 32 bytes (256-bits) of the resulting hash are the master encryption key, + * and the second 32 bytes are the master hmac key. + */ +OpVaultReader::DerivedKeyHMAC* OpVaultReader::decodeCompositeKeys(const QByteArray& keyKey) +{ + const int encKeySize = 256 / 8; + const int hmacKeySize = 256 / 8; + const int digestSize = encKeySize + hmacKeySize; + + auto result = new DerivedKeyHMAC; + result->error = false; + + result->encrypt = QByteArray(encKeySize, '\0'); + result->hmac = QByteArray(hmacKeySize, '\0'); + + const char* buffer_vp = keyKey.data(); + auto buf_len = size_t(keyKey.size()); + + const int algo = GCRY_MD_SHA512; + unsigned char digest[digestSize]; + gcry_md_hash_buffer(algo, digest, buffer_vp, buf_len); + + unsigned char* cp = digest; + for (int i = 0, len = encKeySize; i < len; ++i) { + result->encrypt[i] = *(cp++); + } + for (int i = 0, len = hmacKeySize; i < len; ++i) { + result->hmac[i] = *(cp++); + } + + return result; +} + +/* + * Translates the provided salt and passphrase into a derived set of keys, one for encryption + * and one for use as a HMAC key. See https://support.1password.com/opvault-design/#key-derivation + * @param iterations the number of rounds to apply the derivation formula + * @return a non-null structure containing either the error or the two password-derived keys + */ +OpVaultReader::DerivedKeyHMAC* +OpVaultReader::deriveKeysFromPassPhrase(QByteArray& salt, const QString& password, unsigned long iterations) +{ + const int derivedEncKeySize = 256 / 8; + const int derivedMACSize = 256 / 8; + const int keysize = derivedEncKeySize + derivedMACSize; + + auto result = new DerivedKeyHMAC; + result->error = false; + + QByteArray keybuffer(keysize, '\0'); + auto err = gcry_kdf_derive(password.toUtf8().constData(), + password.size(), + GCRY_KDF_PBKDF2, + GCRY_MD_SHA512, + salt.constData(), + salt.size(), + iterations, + keysize, + keybuffer.data()); + if (err != 0) { + result->error = true; + result->errorStr = tr("Unable to derive master key: %1").arg(gcry_strerror(err)); + return result; + } + if (keysize != keybuffer.size()) { + qWarning() << "Calling PBKDF2(keysize=" << keysize << "yielded" << keybuffer.size() << "bytes"; + } + + QByteArray::const_iterator it = keybuffer.cbegin(); + + result->encrypt = QByteArray(derivedEncKeySize, '\0'); + for (int i = 0, len = derivedEncKeySize; i < len && it != keybuffer.cend(); ++i, ++it) { + result->encrypt[i] = *it; + } + + result->hmac = QByteArray(derivedMACSize, '\0'); + for (int i = 0; i < derivedMACSize && it != keybuffer.cend(); ++i, ++it) { + result->hmac[i] = *it; + } + return result; +} + +/*! + * \sa https://support.1password.com/opvault-design/#category + */ +void OpVaultReader::populateCategoryGroups(Group* rootGroup) +{ + QMap<QString, QString> categoryMap; + categoryMap.insert("001", "Login"); + categoryMap.insert("002", "Credit Card"); + categoryMap.insert("003", "Secure Note"); + categoryMap.insert("004", "Identity"); + categoryMap.insert("005", "Password"); + categoryMap.insert("099", "Tombstone"); + categoryMap.insert("100", "Software License"); + categoryMap.insert("101", "Bank Account"); + categoryMap.insert("102", "Database"); + categoryMap.insert("103", "Driver License"); + categoryMap.insert("104", "Outdoor License"); + categoryMap.insert("105", "Membership"); + categoryMap.insert("106", "Passport"); + categoryMap.insert("107", "Rewards"); + categoryMap.insert("108", "SSN"); + categoryMap.insert("109", "Router"); + categoryMap.insert("110", "Server"); + categoryMap.insert("111", "Email"); + for (const QString& catNum : categoryMap.keys()) { + const QString& category = categoryMap[catNum]; + auto g = new Group(); + g->setName(category); + g->setProperty("code", catNum); + g->setUpdateTimeinfo(false); + // maybe make these stable, so folks can depend on them? + g->setUuid(QUuid::createUuid()); + g->setParent(rootGroup); + } +} + +void OpVaultReader::zeroKeys() +{ + m_masterKey.fill('\0'); + m_masterHmacKey.fill('\0'); + m_overviewKey.fill('\0'); + m_overviewHmacKey.fill('\0'); +} |