aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/aoapplication.h3
-rw-r--r--src/keyring.cpp467
-rw-r--r--src/keyring.h50
-rw-r--r--src/main.cpp10
-rw-r--r--src/widgets/aooptionsdialog.cpp1
5 files changed, 531 insertions, 0 deletions
diff --git a/src/aoapplication.h b/src/aoapplication.h
index 6fc41d9..548d4d0 100644
--- a/src/aoapplication.h
+++ b/src/aoapplication.h
@@ -5,6 +5,7 @@
#include "demoserver.h"
#include "discord_rich_presence.h"
#include "ext_packet.h"
+#include "keyring.h"
#include "serverdata.h"
#include "widgets/aooptionsdialog.h"
@@ -343,6 +344,8 @@ public:
ma_decoding_backend_vtable *mus_decoders[2];
ma_decoder_config mus_decoder_config;
+ KeyringModel keyring_model;
+
private:
QVector<ServerInfo> server_list;
QHash<size_t, QString> asset_lookup_cache;
diff --git a/src/keyring.cpp b/src/keyring.cpp
new file mode 100644
index 0000000..8f49dfa
--- /dev/null
+++ b/src/keyring.cpp
@@ -0,0 +1,467 @@
+// Copyright 2026 Osmium Sorcerer
+// SPDX-License-Identifier: MIT
+
+#include <QByteArray>
+#include <QCborMap>
+#include <QCborStreamReader>
+#include <QCborValue>
+#include <QDataStream>
+#include <QDir>
+#include <QIODevice>
+#include <QLockFile>
+#include <QSaveFile>
+
+#include <sodium.h>
+
+#include "file_functions.h"
+#include "keyring.h"
+
+// Encrypted secret key serialized format version.
+//
+// Version 1: Secret key encrypted with ChaCha20-Poly1305 using password-based
+// Argon2-derived wrapping key.
+//
+// Serialization: Argon2 parameters for key derivation, encrypted key, Poly1305
+// MAC over the encrypted payload with the whole header as additional
+// authenticated data.
+//
+// Fields: version (u8), salt[16], opslimit (u32be), memlimit (u32be),
+// alg (u8), ciphertext[32], mac[16].
+static constexpr quint8 key_format_version = 1;
+
+// Magic that is prepended to a byte sequence to identify it as a 32-byte
+// X25519 public key. Don't expect new tags to ever be introduced unless you
+// want certified post-quantum-secure lawyer simulator authentication with
+// ML-KEM. Simplicity over flexibility.
+static constexpr quint8 cert_x25519_tag = 0x26;
+
+// All-zero nonce for use in key wrapping. Nonces are provided to ensure
+// uniqueness of multiple keystreams under the same key. In our case, we only
+// ever encrypt one message with one derived key, the uniqueness of which is
+// provided by the salt that is randomly generated for each new key wrapping.
+static constexpr quint8 all_zero_nonce[crypto_aead_chacha20poly1305_NPUBBYTES] = {};
+
+// Domain separator string to isolate generic BLAKE2b specifically to this
+// authentication mechanism.
+static const auto domain_sep = QByteArrayLiteral("Einsof-Auth-DHCR");
+
+static QString get_keyring_path()
+{
+ return QDir(get_app_path()).filePath("keyring.cbor");
+}
+
+int keyring_initialize()
+{
+ if (sodium_init() < 0)
+ {
+ return 1;
+ }
+
+ QFile index(get_keyring_path());
+ if (!index.exists())
+ {
+ if (!index.open(QIODevice::NewOnly, QFileDevice::ReadOwner | QFileDevice::WriteOwner))
+ {
+ return 3;
+ }
+ index.close();
+ }
+
+ return 0;
+}
+
+int generate_key(QStringView name, const QByteArray &password)
+{
+ // If we let users specify arbitrary parameters, additional constraints must
+ // be enforced: min/max opslimit, type of memlimit (uint32_t vs size_t).
+ // This flexibility is counterproductive. Instead, a simple high/moderate
+ // toggle should be used if Argon2 parameter tuning is required.
+ if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
+ {
+ return 1;
+ }
+
+ quint8 public_key[crypto_kx_PUBLICKEYBYTES];
+ quint8 secret_key[crypto_kx_SECRETKEYBYTES];
+ sodium_mlock(secret_key, sizeof(secret_key));
+ if (crypto_kx_keypair(public_key, secret_key))
+ {
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return 2;
+ }
+
+ // Key wrapping. The crypto_pwhash derives the key from password with the
+ // specified parameters (3 iterations over 1 gigabyte of memory). This
+ // operation is slow and should ideally be handled asynchronously without
+ // blocking the GUI thread.
+ quint8 wrap_key[crypto_aead_chacha20poly1305_KEYBYTES];
+ sodium_mlock(wrap_key, sizeof(wrap_key));
+ quint8 salt[crypto_pwhash_SALTBYTES];
+ randombytes_buf(salt, sizeof(salt));
+ quint32 pwhash_opslimit = crypto_pwhash_OPSLIMIT_MODERATE;
+ quint32 pwhash_memlimit = crypto_pwhash_MEMLIMIT_SENSITIVE;
+ quint8 pwhash_alg = crypto_pwhash_ALG_DEFAULT;
+ if (crypto_pwhash(wrap_key, sizeof(wrap_key), password.constData(), password.size(), salt, pwhash_opslimit, pwhash_memlimit, pwhash_alg))
+ {
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return 3;
+ }
+
+ // Payload: the wrapped secret key. Authenticated together with its header
+ // (version tag and Argon2 parameters).
+ quint8 payload[sizeof(secret_key) + crypto_aead_chacha20poly1305_ABYTES];
+ QByteArray packed_header;
+ packed_header.reserve(sizeof(quint8) + sizeof(salt) + sizeof(quint32) + sizeof(quint32) + sizeof(quint8));
+ QDataStream hdr_out(&packed_header, QIODevice::WriteOnly);
+ hdr_out << key_format_version;
+ hdr_out.writeRawData((const char *)salt, sizeof(salt));
+ hdr_out << pwhash_opslimit;
+ hdr_out << pwhash_memlimit;
+ hdr_out << pwhash_alg;
+ if (crypto_aead_chacha20poly1305_encrypt(payload, nullptr, secret_key, sizeof(secret_key), (const quint8 *)packed_header.constData(), packed_header.size(), nullptr, all_zero_nonce, wrap_key))
+ {
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return 4;
+ }
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+ sodium_munlock(secret_key, sizeof(secret_key));
+ QByteArray public_key_array((const char *)public_key, (qsizetype)sizeof(public_key));
+ quint8 tag = cert_x25519_tag;
+
+ // Key fingerprint is used as its unique identifier.
+ QByteArray fingerprint;
+ fingerprint.append(tag);
+ fingerprint.append(public_key_array);
+ uchar fpr_hash[crypto_generichash_BYTES];
+ crypto_generichash(fpr_hash, sizeof(fpr_hash), (const uchar *)fingerprint.constData(), fingerprint.size(), nullptr, 0);
+
+ QByteArray encrypted_secret(packed_header);
+ encrypted_secret.append((const char *)payload, sizeof(payload));
+
+ // Prepare the key CBOR entry and atomically insert it into the keyring.
+ // To ensure durability, file locking and atomic overwrite are used as
+ // provided by Qt. Frankly, I don't see the keyring being prone to corruption
+ // as there's no concurrent access, save for a contrived edge case of a user
+ // generating/deleting keys simultaneously from two client instances. It's
+ // done regardless for a good measure.
+ QCborMap entry;
+ entry.insert(0, QCborValue(tag));
+ entry.insert(1, QCborValue(name));
+ entry.insert(2, QCborValue(public_key_array));
+ entry.insert(3, QCborValue(encrypted_secret));
+ QFile index_file(get_keyring_path());
+ QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
+ if (!index_lock.lock())
+ {
+ return 9;
+ }
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return 6;
+ }
+ QCborStreamReader idx_in(&index_file);
+ QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ if (!index.isMap())
+ {
+ index = QCborMap();
+ }
+ QCborMap index_map = index.toMap();
+ index_map.insert(QCborValue(QByteArray((const char *)fpr_hash, (qsizetype)sizeof(fpr_hash))), QCborValue(entry));
+ QSaveFile index_file_w(get_keyring_path());
+ if (!index_file_w.open(QIODevice::WriteOnly))
+ {
+ return 8;
+ }
+ index_file_w.write(QCborValue(index_map).toCbor());
+ index_file_w.commit();
+ index_lock.unlock();
+
+ return 0;
+}
+
+// Look up the key_id in the keyring, unlock it with supplied password using
+// parameters from its header, perform a Diffie-Hellman key exchange with the
+// server's ephemeral key to derive a shared secret, and hash it along with
+// additional data to prove your authenticity to the server.
+ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out)
+{
+ if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
+ {
+ return ResponseResult::invalid_password;
+ }
+
+ QFile index_file(get_keyring_path());
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return ResponseResult::inaccessible_keyring;
+ }
+ QCborStreamReader idx_in(&index_file);
+ QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ QCborMap tmp_map = index.toMap().value(QCborValue(QByteArray(key_id))).toMap();
+ QCborValue val = tmp_map.value(3);
+ if (!val.isByteArray())
+ {
+ return ResponseResult::corrupted_entry;
+ }
+ QByteArray encrypted_secret = val.toByteArray();
+
+ val = tmp_map.value(2);
+ if (!val.isByteArray())
+ {
+ return ResponseResult::corrupted_entry;
+ }
+ QByteArray public_key = val.toByteArray();
+
+ // After retrieving the key from the keyring, the process is the same as
+ // in key generation, but in the opposite direction.
+ quint8 secret_key[crypto_kx_SECRETKEYBYTES];
+ sodium_mlock(secret_key, sizeof(secret_key));
+
+ quint8 sk_version_tag;
+ quint8 salt[crypto_pwhash_SALTBYTES];
+ quint32 pwhash_opslimit;
+ quint32 pwhash_memlimit;
+ quint8 pwhash_alg;
+ quint8 payload[sizeof(secret_key) + crypto_aead_chacha20poly1305_ABYTES];
+
+ QDataStream key_in(encrypted_secret);
+ key_in >> sk_version_tag;
+ quint8 supported_version = key_format_version;
+ if (sk_version_tag != supported_version)
+ {
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return ResponseResult::unsupported_version;
+ }
+ key_in.readRawData((char *)salt, sizeof(salt));
+ key_in >> pwhash_opslimit;
+ key_in >> pwhash_memlimit;
+ key_in >> pwhash_alg;
+ key_in.readRawData((char *)payload, sizeof(payload));
+
+ quint8 wrap_key[crypto_aead_chacha20poly1305_KEYBYTES];
+ sodium_mlock(wrap_key, sizeof(wrap_key));
+ if (crypto_pwhash(wrap_key, sizeof(wrap_key), password.constData(), password.size(), salt, pwhash_opslimit, pwhash_memlimit, pwhash_alg))
+ {
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return ResponseResult::derivation_failed;
+ }
+
+ const quint8 *packed_header = (quint8 *)encrypted_secret.constData();
+ const size_t packed_header_len = sizeof(quint8) + sizeof(salt) + sizeof(quint32) + sizeof(quint32) + sizeof(quint8);
+
+ if (crypto_aead_chacha20poly1305_decrypt(secret_key, nullptr, nullptr, payload, sizeof(payload), packed_header, packed_header_len, all_zero_nonce, wrap_key))
+ {
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return ResponseResult::decryption_failed;
+ }
+ sodium_munlock(wrap_key, sizeof(wrap_key));
+
+ // Now we've unlocked the key, and ready to perform DH with the server's
+ // ephemeral key to prove ourselves.
+ quint8 shared_secret[crypto_scalarmult_BYTES];
+ if (crypto_scalarmult(shared_secret, secret_key, (const uchar *)ephemeral_key.constData()))
+ {
+ // We ended up with the point at infinity, something stupid must've
+ // happened. Reject.
+ sodium_munlock(secret_key, sizeof(secret_key));
+ return ResponseResult::bad_curve_point;
+ }
+ sodium_munlock(secret_key, sizeof(secret_key));
+
+ // The proof is a BLAKE2b-256 hash over the following:
+ // 1. Domain-separating constant.
+ // 2. Shared DH secret, serving as a proof of posession of the secret key
+ // that the server can verify using our public key.
+ // 3. Server's random ephemeral key used specifically in this attempt.
+ // 4. Our public key, binding our identity.
+ // 5. Our username, binding the transcript to the secret and the rest of the
+ // session context.
+ //
+ // Since the server possesses our certificate (public key) and its private
+ // ephemeral key, it'll end up getting the same DH secret as we did. Assuming
+ // everything else matches up in the transcript, it'll independently
+ // calculate the same hash and successfully authenticate us.
+ //
+ // Note that BLAKE2 is resistant to length-extension attacks, and all fields
+ // but last are fixed-length, eliminating encoding ambiguity and
+ // canonicalization issues.
+ quint8 proof[crypto_generichash_BYTES];
+ crypto_generichash_state state;
+ crypto_generichash_init(&state, nullptr, 0, sizeof(proof));
+ crypto_generichash_update(&state, (const uchar *)domain_sep.constData(), domain_sep.size());
+ crypto_generichash_update(&state, shared_secret, sizeof(shared_secret));
+ crypto_generichash_update(&state, (const uchar *)ephemeral_key.constData(), ephemeral_key.size());
+ crypto_generichash_update(&state, (const uchar *)public_key.constData(), public_key.size());
+ crypto_generichash_update(&state, (const uchar *)username.constData(), username.size());
+ crypto_generichash_final(&state, proof, sizeof(proof));
+
+ out.response = QByteArray((const char *)proof, (qsizetype)sizeof(proof));
+ return ResponseResult::success;
+}
+
+// The errors aren't handled carefully, there's a hazard of stale locks being
+// created. Fortunately, Qt seems to automatically detect these.
+void delete_key(const QByteArray &key_id)
+{
+ QFile index_file(get_keyring_path());
+ QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
+ if (!index_lock.lock())
+ {
+ return;
+ }
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return;
+ }
+ QCborStreamReader idx_in(&index_file);
+ QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ if (!index.isMap())
+ {
+ return;
+ }
+ QCborMap index_map = index.toMap();
+ index_map.remove(QCborValue(key_id));
+ QSaveFile index_file_w(get_keyring_path());
+ if (!index_file_w.open(QIODevice::WriteOnly))
+ {
+ return;
+ }
+ index_file_w.write(QCborValue(index_map).toCbor());
+ index_file_w.commit();
+ index_lock.unlock();
+}
+
+// Keyring table model methods (for UI).
+
+KeyringModel::KeyringModel(QObject *parent) : QAbstractTableModel(parent)
+{}
+
+int KeyringModel::rowCount(const QModelIndex &) const
+{
+ return m_keys.size();
+}
+
+int KeyringModel::columnCount(const QModelIndex &) const
+{
+ return 2;
+}
+
+QVariant KeyringModel::data(const QModelIndex &index, int role) const
+{
+ if (role == Qt::DisplayRole)
+ {
+ const KeyInfo entry = m_keys.at(index.row());
+ switch (index.column())
+ {
+ case 0:
+ return entry.name;
+ case 1:
+ return entry.bytes;
+ default:
+ return {};
+ }
+ }
+ else if (role == Qt::FontRole && index.column() == 1)
+ {
+ // There's QFontDatabase::systemFont(QFontDatabase::FixedFont)
+ QFont font;
+ font.setFamily("Monospace");
+ return font;
+ }
+ // "Get key ID" user role. Magic. There ought to be a more ergonomic way to
+ // do this.
+ else if (role == KeyIDRole)
+ {
+ return m_keys.at(index.row()).id;
+ }
+ return {};
+}
+
+// I have removed key ID from the table view, I believe it's better to have
+// users create unique names, and if not, they still have certificates to
+// differentiate. Key ID is purely internal. Make sure it can still be
+// accessed by the key unlock dialog.
+QVariant KeyringModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+ if (role != Qt::DisplayRole || orientation != Qt::Horizontal)
+ {
+ return {};
+ }
+ switch (section)
+ {
+ case 0:
+ return "Note";
+ case 1:
+ return "Certificate";
+ default:
+ return {};
+ }
+}
+
+// Load the keyring from the file into the table to be displayed in the Keyring
+// tab. Called whenever the keyring is modified.
+void KeyringModel::load_keys()
+{
+ QFile index_file(get_keyring_path());
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return;
+ }
+ QCborStreamReader idx_in(&index_file);
+ const QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ if (!index.isMap())
+ {
+ return;
+ }
+ const QCborMap index_map = index.toMap();
+ QVector<KeyInfo> infos;
+ infos.reserve(index_map.size());
+ for (QCborMap::const_iterator i = index_map.cbegin(); i != index_map.cend(); ++i)
+ {
+ KeyInfo entry;
+
+ const QCborValue key = i.key();
+ if (key.isByteArray())
+ {
+ entry.id = key.toByteArray();
+ }
+ else
+ {
+ entry.id.clear();
+ }
+
+ const QCborValue val = i.value();
+ if (val.isMap())
+ {
+ const QCborMap map = val.toMap();
+ entry.name = map.value(1).toString();
+ quint8 key_tag = (quint8)map.value(0).toInteger();
+ if (map.value(2).isByteArray() && key_tag != 0)
+ {
+ key_tag = key_tag == 0x1 ? 0x56 : key_tag; // TODO: legacy
+ const QByteArray pk = map.value(2).toByteArray();
+ QByteArray tagged;
+ tagged.reserve(sizeof(key_tag) + pk.size());
+ tagged.append(key_tag);
+ tagged.append(pk);
+ entry.bytes = QString::fromLatin1(tagged.toBase64(QByteArray::OmitTrailingEquals));
+ }
+ else
+ {
+ entry.bytes.clear();
+ }
+ }
+ infos.append(entry);
+ }
+ beginResetModel();
+ m_keys = infos;
+ endResetModel();
+}
diff --git a/src/keyring.h b/src/keyring.h
new file mode 100644
index 0000000..81d97fc
--- /dev/null
+++ b/src/keyring.h
@@ -0,0 +1,50 @@
+#pragma once
+
+#include <QAbstractTableModel>
+#include <QFont>
+#include <QString>
+
+#include "ext_packet.h"
+
+// The sole reason I use a class here is because Qt demands it. At least, that's
+// my impression after reading the documentation.
+class KeyringModel : public QAbstractTableModel
+{
+ Q_OBJECT
+public:
+ explicit KeyringModel(QObject *parent = nullptr);
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
+
+ void load_keys(void);
+ static constexpr int KeyIDRole = 0x0100;
+
+private:
+ struct KeyInfo
+ {
+ QByteArray id;
+ QString name;
+ QString bytes;
+ };
+
+ QVector<KeyInfo> m_keys;
+};
+
+enum class ResponseResult
+{
+ success,
+ invalid_password,
+ inaccessible_keyring,
+ corrupted_entry,
+ unsupported_version,
+ derivation_failed,
+ decryption_failed,
+ bad_curve_point,
+};
+
+int keyring_initialize(void);
+int generate_key(QStringView name, const QByteArray &password);
+void delete_key(const QByteArray &key_id);
+ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out);
diff --git a/src/main.cpp b/src/main.cpp
index 2743eeb..e998eed 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -95,6 +95,16 @@ int main(int argc, char *argv[])
qDebug() << ":/data/translations/ao_" + p_language;
}
+ // Instead of quitting before even starting, we should notify the user
+ // and block the keyring (and cryptography in general). Or, at least, show
+ // the popup window before exiting.
+ if (keyring_initialize())
+ {
+ qCritical() << "libsodium failed to initialize. Catastrophic failure.";
+ return EXIT_FAILURE;
+ }
+ main_app.keyring_model.load_keys();
+
main_app.construct_lobby();
main_app.net_manager->get_server_list();
main_app.net_manager->send_heartbeat();
diff --git a/src/widgets/aooptionsdialog.cpp b/src/widgets/aooptionsdialog.cpp
index e1648da..760dccf 100644
--- a/src/widgets/aooptionsdialog.cpp
+++ b/src/widgets/aooptionsdialog.cpp
@@ -4,6 +4,7 @@
#include "aoapplication.h"
#include "file_functions.h"
#include "gui_utils.h"
+#include "keyring.h"
#include "networkmanager.h"
#include "options.h"