mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-07-25 15:55:38 -04:00
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:
parent
e121f4bc28
commit
125a81f2ed
45 changed files with 2578 additions and 0 deletions
137
src/format/OpData01.cpp
Normal file
137
src/format/OpData01.cpp
Normal file
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* 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 "crypto/CryptoHash.h"
|
||||
#include "crypto/SymmetricCipher.h"
|
||||
|
||||
#include <QDataStream>
|
||||
#include <QDebug>
|
||||
|
||||
OpData01::OpData01(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
OpData01::~OpData01()
|
||||
{
|
||||
}
|
||||
|
||||
bool OpData01::decodeBase64(QString const& b64String, const QByteArray& key, const QByteArray& hmacKey)
|
||||
{
|
||||
const QByteArray b64Bytes = QByteArray::fromBase64(b64String.toUtf8());
|
||||
return decode(b64Bytes, key, hmacKey);
|
||||
}
|
||||
|
||||
bool OpData01::decode(const QByteArray& data, const QByteArray& key, const QByteArray& hmacKey)
|
||||
{
|
||||
/*!
|
||||
* The first 8 bytes of the data are the string “opdata01”.
|
||||
*/
|
||||
const QByteArray header("opdata01");
|
||||
if (!data.startsWith(header)) {
|
||||
m_errorStr = tr("Invalid OpData01, does not contain header");
|
||||
return false;
|
||||
}
|
||||
|
||||
QDataStream in(data);
|
||||
in.setByteOrder(QDataStream::LittleEndian);
|
||||
in.skipRawData(header.size());
|
||||
|
||||
/*!
|
||||
* The next 8 bytes contain the length in bytes of the plaintext as a little endian unsigned 64 bit integer.
|
||||
*/
|
||||
qlonglong len;
|
||||
in >> len;
|
||||
|
||||
/*!
|
||||
* The next 16 bytes are the randomly chosen initialization vector.
|
||||
*/
|
||||
QByteArray iv(16, '\0');
|
||||
int read = in.readRawData(iv.data(), 16);
|
||||
if (read != 16) {
|
||||
m_errorStr = tr("Unable to read all IV bytes, wanted 16 but got %1").arg(iv.size());
|
||||
return false;
|
||||
}
|
||||
|
||||
SymmetricCipher cipher(SymmetricCipher::Aes256, SymmetricCipher::Cbc, SymmetricCipher::Decrypt);
|
||||
if (!cipher.init(key, iv)) {
|
||||
m_errorStr = tr("Unable to init cipher for opdata01: %1").arg(cipher.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
/*!
|
||||
* The plaintext is padded using the following scheme.
|
||||
*
|
||||
* If the size of the plaintext is an even multiple of the block size then 1 block of random data is prepended
|
||||
* to the plaintext. Otherwise, between 1 and 15 (inclusive) bytes of random data are prepended to the plaintext
|
||||
* to achieve an even multiple of blocks.
|
||||
*/
|
||||
const int blockSize = cipher.blockSize();
|
||||
int randomBytes = blockSize - (len % blockSize);
|
||||
if (randomBytes == 0) {
|
||||
// add random block
|
||||
randomBytes = blockSize;
|
||||
}
|
||||
qlonglong clear_len = len + randomBytes;
|
||||
QByteArray qbaCT(clear_len, '\0');
|
||||
in.readRawData(qbaCT.data(), clear_len);
|
||||
|
||||
/*!
|
||||
* The HMAC-SHA256 is computed over the entirety of the opdata including header, length, IV and ciphertext
|
||||
* using a 256-bit MAC key. The 256-bit MAC is not truncated. It is appended to the ciphertext.
|
||||
*/
|
||||
const int hmacLen = 256 / 8;
|
||||
QByteArray hmacSig(hmacLen, '\0'); // 256 / 8, '\0');
|
||||
in.readRawData(hmacSig.data(), hmacLen);
|
||||
if (hmacSig.size() != hmacLen) {
|
||||
m_errorStr = tr("Unable to read all HMAC signature bytes");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray hmacData = data.mid(0, data.size() - hmacSig.size());
|
||||
const QByteArray actualHmac = CryptoHash::hmac(hmacData, hmacKey, CryptoHash::Algorithm::Sha256);
|
||||
if (actualHmac != hmacSig) {
|
||||
m_errorStr = tr("Malformed OpData01 due to a failed HMAC");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!cipher.processInPlace(qbaCT)) {
|
||||
m_errorStr = tr("Unable to process clearText in place");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove random bytes
|
||||
const QByteArray& clearText = qbaCT.mid(randomBytes);
|
||||
if (clearText.size() != len) {
|
||||
m_errorStr = tr("Expected %1 bytes of clear-text, found %2").arg(len, clearText.size());
|
||||
return false;
|
||||
}
|
||||
m_clearText = clearText;
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray OpData01::getClearText()
|
||||
{
|
||||
return m_clearText;
|
||||
}
|
||||
|
||||
QString OpData01::errorString()
|
||||
{
|
||||
return m_errorStr;
|
||||
}
|
57
src/format/OpData01.h
Normal file
57
src/format/OpData01.h
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
#ifndef KEEPASSXC_OPDATA01_H
|
||||
#define KEEPASSXC_OPDATA01_H
|
||||
|
||||
#include <QObject>
|
||||
|
||||
/*!
|
||||
* Packages and transports the AgileBits data structure called \c OpData01
|
||||
* used to encypt and provide HMAC for encrypted data.
|
||||
* \sa https://support.1password.com/opvault-design/#opdata01
|
||||
*/
|
||||
class OpData01 : public QObject
|
||||
{
|
||||
public:
|
||||
explicit OpData01(QObject* parent = nullptr);
|
||||
~OpData01() override;
|
||||
|
||||
/*!
|
||||
* The convenience equivalent of decode01(OpData01,const QByteArray,const QByteArray,const QByteArray) that simply
|
||||
* decodes the provided base64 string into its underlying \c QByteArray.
|
||||
*/
|
||||
bool decodeBase64(QString const& b64String, const QByteArray& key, const QByteArray& hmacKey);
|
||||
|
||||
/*!
|
||||
* Populates the given \code OpData01 structure by decoding the provided blob of data,
|
||||
* using the given key and then verifies using the given HMAC key.
|
||||
* \returns true if things went well and \code m_clearText is usable, false and \code m_errorStr will contain
|
||||
* details.
|
||||
*/
|
||||
bool decode(const QByteArray& data, const QByteArray& key, const QByteArray& hmacKey);
|
||||
|
||||
QByteArray getClearText();
|
||||
|
||||
QString errorString();
|
||||
|
||||
private:
|
||||
QByteArray m_clearText;
|
||||
QString m_errorStr;
|
||||
};
|
||||
|
||||
#endif // KEEPASSXC_OPDATA01_H
|
478
src/format/OpVaultReader.cpp
Normal file
478
src/format/OpVaultReader.cpp
Normal file
|
@ -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');
|
||||
}
|
119
src/format/OpVaultReader.h
Normal file
119
src/format/OpVaultReader.h
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
#ifndef OPVAULT_READER_H_
|
||||
#define OPVAULT_READER_H_
|
||||
|
||||
#include <QDir>
|
||||
|
||||
#include "core/Database.h"
|
||||
#include "core/Metadata.h"
|
||||
|
||||
/*!
|
||||
* Imports a directory in the 1Password \c opvault format into a \c Database.
|
||||
* \sa https://support.1password.com/opvault-overview/
|
||||
* \sa https://support.1password.com/opvault-design/
|
||||
* \sa https://cache.agilebits.com/security-kb/freddy-2013-12-04.tar.gz is the sample data used to test this class,
|
||||
* and its password is \c freddy
|
||||
*/
|
||||
class OpVaultReader : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit OpVaultReader(QObject* parent = nullptr);
|
||||
~OpVaultReader() override;
|
||||
|
||||
Database* readDatabase(QDir& opdataDir, const QString& password);
|
||||
|
||||
bool hasError();
|
||||
QString errorString();
|
||||
|
||||
private:
|
||||
struct DerivedKeyHMAC
|
||||
{
|
||||
QByteArray encrypt;
|
||||
QByteArray hmac;
|
||||
bool error;
|
||||
QString errorStr;
|
||||
};
|
||||
|
||||
QJsonObject readAndAssertJsonFile(QFile& file, const QString& stripLeading, const QString& stripTrailing);
|
||||
|
||||
DerivedKeyHMAC* deriveKeysFromPassPhrase(QByteArray& salt, const QString& password, unsigned long iterations);
|
||||
DerivedKeyHMAC* decodeB64CompositeKeys(const QString& b64, const QByteArray& encKey, const QByteArray& hmacKey);
|
||||
DerivedKeyHMAC* decodeCompositeKeys(const QByteArray& keyKey);
|
||||
|
||||
/*!
|
||||
* \sa https://support.1password.com/opvault-design/#profile-js
|
||||
* @param profileJson the contents of \c profile.js
|
||||
* @return \c true if the profile data was decrypted successfully, \c false otherwise
|
||||
*/
|
||||
bool processProfileJson(QJsonObject& profileJson, const QString& password, Group* rootGroup);
|
||||
|
||||
/*!
|
||||
* \sa https://support.1password.com/opvault-design/#folders-js
|
||||
* @param foldersJson the map from a folder UUID to its data (name and any smart query)
|
||||
* @return \c true if the folder data was decrypted successfully, \c false otherwise
|
||||
*/
|
||||
bool processFolderJson(QJsonObject& foldersJson, Group* rootGroup);
|
||||
|
||||
/*!
|
||||
* Decrypts the provided band object into its interior structure,
|
||||
* as well as the encryption key and HMAC key declared therein,
|
||||
* which are used to decrypt the attachments, also.
|
||||
* @returns \c nullptr if unable to do the decryption, otherwise the interior object and its keys
|
||||
*/
|
||||
bool decryptBandEntry(const QJsonObject& bandEntry, QJsonObject& data, QByteArray& key, QByteArray& hmacKey);
|
||||
Entry* processBandEntry(const QJsonObject& bandEntry, const QDir& attachmentDir, Group* rootGroup);
|
||||
|
||||
bool readAttachment(const QString& filePath,
|
||||
const QByteArray& itemKey,
|
||||
const QByteArray& itemHmacKey,
|
||||
QJsonObject& metadata,
|
||||
QByteArray& payload);
|
||||
void fillAttachment(Entry* entry,
|
||||
const QFileInfo& attachmentFileInfo,
|
||||
const QByteArray& entryKey,
|
||||
const QByteArray& entryHmacKey);
|
||||
void fillAttachments(Entry* entry,
|
||||
const QDir& attachmentDir,
|
||||
const QByteArray& entryKey,
|
||||
const QByteArray& entryHmacKey);
|
||||
|
||||
bool fillAttributes(Entry* entry, const QJsonObject& bandEntry);
|
||||
|
||||
void fillFromSection(Entry* entry, const QJsonObject& section);
|
||||
void fillFromSectionField(Entry* entry, const QString& sectionName, QJsonObject& field);
|
||||
QString resolveAttributeName(const QString& section, const QString& name, const QString& text);
|
||||
|
||||
void populateCategoryGroups(Group* rootGroup);
|
||||
/*! Used to blank the memory after the keys have been used. */
|
||||
void zeroKeys();
|
||||
|
||||
bool m_error;
|
||||
QString m_errorStr;
|
||||
QByteArray m_masterKey;
|
||||
QByteArray m_masterHmacKey;
|
||||
/*! Used to decrypt overview text, such as folder names. */
|
||||
QByteArray m_overviewKey;
|
||||
QByteArray m_overviewHmacKey;
|
||||
|
||||
friend class TestOpVaultReader;
|
||||
};
|
||||
|
||||
#endif /* OPVAULT_READER_H_ */
|
250
src/format/OpVaultReaderAttachments.cpp
Normal file
250
src/format/OpVaultReaderAttachments.cpp
Normal file
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* 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 <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUuid>
|
||||
|
||||
/*!
|
||||
* This will \c qCritical() if unable to open the file for reading.
|
||||
* @param file the \c .attachment file to decode
|
||||
* @return \c nullptr if unable to take action, else a pair of metadata and the actual attachment bits
|
||||
* \sa https://support.1password.com/opvault-design/#attachments
|
||||
*/
|
||||
bool OpVaultReader::readAttachment(const QString& filePath,
|
||||
const QByteArray& itemKey,
|
||||
const QByteArray& itemHmacKey,
|
||||
QJsonObject& metadata,
|
||||
QByteArray& payload)
|
||||
{
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
qCritical() << QString("Unable to open \"%s\" for reading").arg(file.fileName());
|
||||
return false;
|
||||
}
|
||||
|
||||
QString magic("OPCLDAT");
|
||||
QByteArray magicBytes = file.read(7);
|
||||
if (magicBytes != magic.toUtf8()) {
|
||||
qCritical() << "Expected OPCLDAT but found <<" << magicBytes.toHex() << ">>";
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray version = file.read(1);
|
||||
if (version[0] != '\001' && version[0] != '\002') {
|
||||
qCritical() << "Unexpected version number; wanted 1 or 2, got <<" << version << ">>";
|
||||
return false;
|
||||
}
|
||||
const QByteArray& metadataLenBytes = file.read(2);
|
||||
if (metadataLenBytes.size() != 2) {
|
||||
qCritical() << "Unable to read all metadata length bytes; wanted 2 bytes, got " << metadataLenBytes.size()
|
||||
<< ": <<" << metadataLenBytes.toHex() << ">>";
|
||||
return false;
|
||||
}
|
||||
const auto b0 = static_cast<unsigned char>(metadataLenBytes[0]);
|
||||
const auto b1 = static_cast<unsigned char>(metadataLenBytes[1]);
|
||||
int metadataLen = ((0xFF & b1) << 8) | (0xFF & b0);
|
||||
|
||||
// no really: it's labeled "Junk" in the spec
|
||||
int junkBytesRead = file.read(2).size();
|
||||
if (junkBytesRead != 2) {
|
||||
qCritical() << "Unable to read all \"junk\" bytes; wanted 2 bytes, got " << junkBytesRead;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray& iconLenBytes = file.read(4);
|
||||
if (iconLenBytes.size() != 4) {
|
||||
qCritical() << "Unable to read all \"iconLen\" bytes; wanted 4 bytes, got " << iconLenBytes.size();
|
||||
return false;
|
||||
}
|
||||
|
||||
int iconLen = 0;
|
||||
for (int i = 0, len = iconLenBytes.size(); i < len; ++i) {
|
||||
char ch = iconLenBytes[i];
|
||||
auto b = static_cast<unsigned char>(ch & 0xFF);
|
||||
iconLen = (b << (i * 8)) | iconLen;
|
||||
}
|
||||
|
||||
QByteArray metadataJsonBytes = file.read(metadataLen);
|
||||
if (metadataJsonBytes.size() != metadataLen) {
|
||||
qCritical() << "Unable to read all bytes of metadata JSON; wanted " << metadataLen << "but read "
|
||||
<< metadataJsonBytes.size();
|
||||
return false;
|
||||
}
|
||||
QByteArray iconBytes = file.read(iconLen);
|
||||
if (iconBytes.size() != iconLen) {
|
||||
qCritical() << "Unable to read all icon bytes; wanted " << iconLen << "but read " << iconBytes.size();
|
||||
// apologies for the icon being fatal, but it would take some gear-turning
|
||||
// to re-sync where in the attach header we are
|
||||
return false;
|
||||
}
|
||||
|
||||
// we don't actually _care_ what the icon bytes are,
|
||||
// but they damn well better be valid opdata01 and pass its HMAC
|
||||
OpData01 icon01;
|
||||
if (!icon01.decode(iconBytes, itemKey, itemHmacKey)) {
|
||||
qCritical() << "Unable to decipher attachment icon in " << filePath << ": " << icon01.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError jsError;
|
||||
QJsonDocument jDoc = QJsonDocument::fromJson(metadataJsonBytes, &jsError);
|
||||
if (jsError.error != QJsonParseError::ParseError::NoError) {
|
||||
qCritical() << "Found invalid attachment metadata JSON at offset " << jsError.offset << ": error("
|
||||
<< jsError.error << "): " << jsError.errorString() << "\n<<" << metadataJsonBytes << ">>";
|
||||
return false;
|
||||
}
|
||||
if (!jDoc.isObject()) {
|
||||
qCritical() << "Expected " << metadataJsonBytes << "to be a JSON Object";
|
||||
return false;
|
||||
}
|
||||
|
||||
metadata = jDoc.object();
|
||||
if (metadata.contains("trashed") && metadata["trashed"].toBool()) {
|
||||
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;
|
||||
if (!att01.decode(encData, itemKey, itemHmacKey)) {
|
||||
qCritical() << "Unable to decipher attachment payload: " << att01.errorString();
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = att01.getClearText();
|
||||
return true;
|
||||
}
|
||||
|
||||
/*!
|
||||
* \sa https://support.1password.com/opvault-design/#attachments
|
||||
*/
|
||||
void OpVaultReader::fillAttachments(Entry* entry,
|
||||
const QDir& attachmentDir,
|
||||
const QByteArray& entryKey,
|
||||
const QByteArray& entryHmacKey)
|
||||
{
|
||||
/*!
|
||||
* Attachment files are named with the UUID of the item that they are attached to followed by an underscore
|
||||
* and then followed by the UUID of the attachment itself. The file is then given the extension .attachment.
|
||||
*/
|
||||
auto fileFilter = QString("%1_*.attachment").arg(entry->uuidToHex().toUpper());
|
||||
const auto& attachInfoList = attachmentDir.entryInfoList(QStringList() << fileFilter, QDir::Files);
|
||||
int attachmentCount = attachInfoList.size();
|
||||
if (attachmentCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& info : attachInfoList) {
|
||||
if (!info.isReadable()) {
|
||||
qCritical() << QString("Attachment file \"%1\" is not readable").arg(info.absoluteFilePath());
|
||||
continue;
|
||||
}
|
||||
fillAttachment(entry, info, entryKey, entryHmacKey);
|
||||
}
|
||||
}
|
||||
|
||||
void OpVaultReader::fillAttachment(Entry* entry,
|
||||
const QFileInfo& info,
|
||||
const QByteArray& entryKey,
|
||||
const QByteArray& entryHmacKey)
|
||||
{
|
||||
QJsonObject attachMetadata;
|
||||
QByteArray attachPayload;
|
||||
if (!readAttachment(info.absoluteFilePath(), entryKey, entryHmacKey, attachMetadata, attachPayload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attachMetadata.contains("overview")) {
|
||||
qWarning() << "Expected \"overview\" in attachment metadata";
|
||||
return;
|
||||
}
|
||||
|
||||
const QString& overB64 = attachMetadata["overview"].toString();
|
||||
OpData01 over01;
|
||||
|
||||
if (over01.decodeBase64(overB64, m_overviewKey, m_overviewHmacKey)) {
|
||||
QByteArray overviewJson = over01.getClearText();
|
||||
QJsonDocument overDoc = QJsonDocument::fromJson(overviewJson);
|
||||
if (overDoc.isObject()) {
|
||||
QJsonObject overObj = overDoc.object();
|
||||
attachMetadata.remove("overview");
|
||||
for (QString& key : overObj.keys()) {
|
||||
const QJsonValueRef& value = overObj[key];
|
||||
QString insertAs = key;
|
||||
for (int aa = 0; attachMetadata.contains(insertAs) && aa < 5; ++aa) {
|
||||
insertAs = QString("%1_%2").arg(key, aa);
|
||||
}
|
||||
attachMetadata[insertAs] = value;
|
||||
}
|
||||
} else {
|
||||
qWarning() << "Expected JSON Object in \"overview\" but nope: " << overDoc;
|
||||
}
|
||||
} else {
|
||||
qCritical()
|
||||
<< QString("Unable to decode attach.overview for \"%1\": %2").arg(info.fileName(), over01.errorString());
|
||||
}
|
||||
|
||||
QByteArray payload;
|
||||
payload.append(QString("attachment file is actually %1 bytes\n").arg(info.size()).toUtf8());
|
||||
for (QString& key : attachMetadata.keys()) {
|
||||
const QJsonValueRef& value = attachMetadata[key];
|
||||
QByteArray valueBytes;
|
||||
if (value.isString()) {
|
||||
valueBytes = value.toString().toUtf8();
|
||||
} else if (value.isDouble()) {
|
||||
valueBytes = QString("%1").arg(value.toInt()).toUtf8();
|
||||
} else if (value.isBool()) {
|
||||
valueBytes = value.toBool() ? "true" : "false";
|
||||
} else {
|
||||
valueBytes = QString("Unexpected metadata type in attachment: %1").arg(value.type()).toUtf8();
|
||||
}
|
||||
payload.append(key.toUtf8()).append(":=").append(valueBytes).append("\n");
|
||||
}
|
||||
|
||||
QString attachKey = info.baseName();
|
||||
if (attachMetadata.contains("filename")) {
|
||||
QJsonValueRef attFilename = attachMetadata["filename"];
|
||||
if (attFilename.isString()) {
|
||||
attachKey = attFilename.toString();
|
||||
} else {
|
||||
qWarning() << QString("Unexpected type of attachment \"filename\": %1").arg(attFilename.type());
|
||||
}
|
||||
}
|
||||
|
||||
entry->attachments()->set(attachKey, attachPayload);
|
||||
}
|
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;
|
||||
}
|
136
src/format/OpVaultReaderSections.cpp
Normal file
136
src/format/OpVaultReaderSections.cpp
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 "totp/totp.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QUrlQuery>
|
||||
#include <QUuid>
|
||||
|
||||
void OpVaultReader::fillFromSection(Entry* entry, const QJsonObject& section)
|
||||
{
|
||||
const auto uuid = entry->uuid();
|
||||
const QString& sectionName = section["name"].toString();
|
||||
|
||||
if (!section.contains("fields")) {
|
||||
auto sectionNameLC = sectionName.toLower();
|
||||
auto sectionTitleLC = section["title"].toString("").toLower();
|
||||
if (!(sectionNameLC == "linked items" && sectionTitleLC == "related items")) {
|
||||
qWarning() << R"(Skipping "fields"-less Section in UUID ")" << uuid << "\": <<" << section << ">>";
|
||||
}
|
||||
return;
|
||||
} else if (!section["fields"].isArray()) {
|
||||
qWarning() << R"(Skipping non-Array "fields" in UUID ")" << uuid << "\"\n";
|
||||
return;
|
||||
}
|
||||
QJsonArray sectionFields = section["fields"].toArray();
|
||||
for (const QJsonValue sectionField : sectionFields) {
|
||||
if (!sectionField.isObject()) {
|
||||
qWarning() << R"(Skipping non-Object "fields" in UUID ")" << uuid << "\": << " << sectionField << ">>";
|
||||
continue;
|
||||
}
|
||||
QJsonObject field = sectionField.toObject();
|
||||
fillFromSectionField(entry, sectionName, field);
|
||||
}
|
||||
}
|
||||
|
||||
void OpVaultReader::fillFromSectionField(Entry* entry, const QString& sectionName, QJsonObject& field)
|
||||
{
|
||||
if (!field.contains("v")) {
|
||||
// for our purposes, we don't care if there isn't a value in the field
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 kind = field["k"].toString();
|
||||
|
||||
if (attrName.startsWith("TOTP_")) {
|
||||
if (attrValue.startsWith("otpauth://")) {
|
||||
QUrlQuery query(attrValue);
|
||||
// at least as of 1Password 7, they don't append the digits= and period= which totp.cpp requires
|
||||
if (!query.hasQueryItem("digits")) {
|
||||
query.addQueryItem("digits", QString("%1").arg(Totp::DEFAULT_DIGITS));
|
||||
}
|
||||
if (!query.hasQueryItem("period")) {
|
||||
query.addQueryItem("period", QString("%1").arg(Totp::DEFAULT_STEP));
|
||||
}
|
||||
attrValue = query.toString(QUrl::FullyEncoded);
|
||||
}
|
||||
entry->attributes()->set(Totp::ATTRIBUTE_SETTINGS, attrValue, true);
|
||||
} else if (attrName.startsWith("expir", Qt::CaseInsensitive)) {
|
||||
QDateTime expiry;
|
||||
if (kind == "date") {
|
||||
expiry = QDateTime::fromTime_t(attrValue.toUInt(), Qt::UTC);
|
||||
} else {
|
||||
expiry = QDateTime::fromString(attrValue, "yyyyMM");
|
||||
expiry.setTimeSpec(Qt::UTC);
|
||||
}
|
||||
|
||||
if (expiry.isValid()) {
|
||||
entry->setExpiryTime(expiry);
|
||||
entry->setExpires(true);
|
||||
}
|
||||
} else {
|
||||
if (kind == "date") {
|
||||
auto date = QDateTime::fromTime_t(attrValue.toUInt(), Qt::UTC);
|
||||
if (date.isValid()) {
|
||||
attrValue = date.toString();
|
||||
}
|
||||
}
|
||||
|
||||
entry->attributes()->set(attrName, attrValue, (kind == "password" || kind == "concealed"));
|
||||
}
|
||||
}
|
||||
|
||||
QString OpVaultReader::resolveAttributeName(const QString& section, const QString& name, const QString& text)
|
||||
{
|
||||
// Special case for TOTP
|
||||
if (name.startsWith("TOTP_")) {
|
||||
return name;
|
||||
}
|
||||
|
||||
auto lowName = name.toLower();
|
||||
auto lowText = text.toLower();
|
||||
if (section.isEmpty()) {
|
||||
// Empty section implies these are core attributes
|
||||
// try to find username, password, url
|
||||
if (lowName == "password" || lowText == "password") {
|
||||
return EntryAttributes::PasswordKey;
|
||||
} else if (lowName == "username" || lowText == "username") {
|
||||
return EntryAttributes::UserNameKey;
|
||||
} else if (lowName == "url" || lowText == "url" || lowName == "hostname" || lowText == "server"
|
||||
|| lowName == "website") {
|
||||
return EntryAttributes::URLKey;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
return QString("%1_%2").arg(section, name);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue