Compare commits

...

8 Commits

Author SHA1 Message Date
85541e1db4 retry on weak password, Vault::create 2026-04-25 13:12:46 +02:00
47f1f7dd74 manage nonces in crypto.h 2026-04-25 13:06:54 +02:00
0f4d4f96d6 add macos build instructions 2026-03-18 17:36:24 +01:00
1ad1571273 LICENSE 2026-03-08 23:12:45 +01:00
7873020e72 delete in chunks 2026-03-08 23:08:53 +01:00
53a222a7da handle invalid passwords 2025-11-11 12:20:35 +01:00
f00b5221c1 extract all 2025-11-11 11:59:51 +01:00
30ca8b6262 bit of ux 2025-11-04 21:21:03 +01:00
14 changed files with 236 additions and 133 deletions

View File

@@ -4,6 +4,11 @@ project(dull)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# fix clang
foreach(flag_var CMAKE_CXX_FLAGS CMAKE_CXX_FLAGS_RELEASE CMAKE_CXX_FLAGS_DEBUG)
string(REPLACE "-mno-direct-extern-access" "" ${flag_var} "${${flag_var}}")
endforeach()
find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
if(NOT WIN32)
find_package(PkgConfig REQUIRED)

28
LICENSE Normal file
View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025-2026, Antoni Piasecki
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,22 +0,0 @@
pkgname=dull
pkgver=0.1
pkgrel=1
pkgdesc="Desktop app for securely storing sensitive files"
arch=('x86_64')
url="https://github.com/antpiasecki/dull"
depends=('qt6-base' 'botan')
makedepends=('cmake')
source=("${url}/archive/refs/tags/${pkgver}.tar.gz")
sha256sums=('SKIP')
DEBUGPKG=()
build() {
cd "${pkgname}-${pkgver}"
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr
cmake --build build -j$(nproc)
}
package() {
cd "${pkgname}-${pkgver}/build"
make DESTDIR="${pkgdir}" install
}

View File

@@ -4,32 +4,44 @@ Desktop app for securely storing sensitive files
## Features
* **Pretty usable UI**
* **Overkill encryption:** XChaCha20-Poly1305 + Argon2id(m=1GB,t=6,p=4) key derivation
* **Cross-platform-ish:** Builds on Linux, Windows and macOS
* **Overkill encryption:** XChaCha20-Poly1305 + Argon2id key derivation
* **Cross-platform:** Tested on Linux, Windows and macOS
* **Drag and Drop support**
## Building
### Debian/Ubuntu
### Debian
```
sudo apt update && sudo apt install build-essential qt6-base-dev libbotan-3-dev cmake
cmake -S . -B build
cmake -B build
cmake --build build -j $(nproc)
./build/dull
```
### Arch
```
sudo pacman -S base-devel
makepkg -si
```
sudo pacman -Syu base-devel cmake qt6-base botan
### Windows / MSYS2
```
pacman -S --needed mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja mingw-w64-x86_64-qt6-base mingw-w64-x86_64-qt6-tools mingw-w64-x86_64-libbotan
cmake -S . -B build -DBOTAN_INCLUDE_DIRS=/mingw64/include/botan-3 -DBOTAN_LIBRARIES=/mingw64/lib/libbotan-3.a
cmake -B build
cmake --build build -j $(nproc)
./build/dull
```
### macOS
```
brew install qtbase cmake pkg-config botan
cmake -B build
cmake --build build -j $(sysctl -n hw.physicalcpu)
./build/dull
```
### Windows (MSYS2)
```
pacman -S --needed mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja mingw-w64-x86_64-qt6-base mingw-w64-x86_64-qt6-tools mingw-w64-x86_64-libbotan
cmake -B build -DBOTAN_INCLUDE_DIRS=/mingw64/include/botan-3 -DBOTAN_LIBRARIES=/mingw64/lib/libbotan-3.a
cmake --build build -j $(nproc)
./build/dull
```

View File

@@ -9,12 +9,12 @@
if (!(cond)) { \
std::cerr << "ASSERTION FAILED at " << __FILE__ << ":" << __LINE__ \
<< "\n" \
<< #cond << std::endl; \
<< #cond << '\n'; \
abort(); \
} \
} while (0)
using u8 = unsigned char;
using u8 = uint8_t;
using i16 = int16_t;
using u16 = uint16_t;
using i32 = int32_t;
@@ -26,11 +26,6 @@ using f32 = float;
static_assert(sizeof(double) * 8 == 64);
using f64 = double;
inline std::string path_to_filename(const std::string &path) {
u64 pos = path.find_last_of("/\\");
return (pos == std::string::npos) ? path : path.substr(pos + 1);
}
template <typename T> constexpr char *to_char_ptr(T *ptr) noexcept {
return reinterpret_cast<char *>(ptr);
}

View File

@@ -1,17 +1,18 @@
#pragma once
#include "common.h"
#include <botan/aead.h>
#include <botan/hex.h>
#include <botan/auto_rng.h>
#include <botan/pwdhash.h>
namespace Crypto {
inline Botan::secure_vector<u8>
encrypt_xchacha20_poly1305(const Botan::secure_vector<u8> &plaintext,
const Botan::secure_vector<u8> &key,
const std::array<u8, 24> &nonce) {
encrypt(const Botan::secure_vector<u8> &key,
const Botan::secure_vector<u8> &plaintext) {
ASSERT(key.size() == 32);
ASSERT(nonce.size() == 24);
static Botan::AutoSeeded_RNG rng;
auto nonce = rng.random_array<24>();
// XChaCha20 is selected automatically based on the nonce size
// https://github.com/randombit/botan/blob/master/src/lib/stream/chacha/chacha.cpp#L375
@@ -24,16 +25,20 @@ encrypt_xchacha20_poly1305(const Botan::secure_vector<u8> &plaintext,
Botan::secure_vector<u8> ciphertext = plaintext;
cipher->finish(ciphertext);
return ciphertext;
Botan::secure_vector<u8> res;
res.insert(res.end(), nonce.begin(), nonce.end());
res.insert(res.end(), ciphertext.begin(), ciphertext.end());
return res;
}
inline Botan::secure_vector<u8>
decrypt_xchacha20_poly1305(const Botan::secure_vector<u8> &ciphertext,
const Botan::secure_vector<u8> &key,
const std::array<u8, 24> &nonce) {
ASSERT(ciphertext.size() >= 16);
decrypt(const Botan::secure_vector<u8> &key,
const Botan::secure_vector<u8> &ciphertext_with_nonce) {
ASSERT(ciphertext_with_nonce.size() >= 40);
ASSERT(key.size() == 32);
ASSERT(nonce.size() == 24);
std::array<u8, 24> nonce{};
std::copy_n(ciphertext_with_nonce.begin(), 24, nonce.begin());
auto cipher = Botan::AEAD_Mode::create_or_throw(
"ChaCha20Poly1305", Botan::Cipher_Dir::Decryption);
@@ -41,18 +46,17 @@ decrypt_xchacha20_poly1305(const Botan::secure_vector<u8> &ciphertext,
cipher->set_key(key);
cipher->start(nonce);
Botan::secure_vector<u8> plaintext = ciphertext;
cipher->finish(plaintext);
Botan::secure_vector<u8> ciphertext(ciphertext_with_nonce.begin() + 24,
ciphertext_with_nonce.end());
cipher->finish(ciphertext);
return plaintext;
return ciphertext;
}
inline Botan::secure_vector<u8>
derive_key_argon2id(const std::string &password,
inline Botan::secure_vector<u8> derive_key(const std::string &password,
const std::array<u8, 16> &salt) {
// thousands of years to crack a random 8 char password on a 100 GPUs
auto pwdhash = Botan::PasswordHashFamily::create_or_throw("Argon2id")
->from_params(static_cast<u64>(1024 * 1024), 8, 4);
->from_params(static_cast<u64>(512 * 1024), 8, 4);
Botan::secure_vector<u8> key(32);
pwdhash->derive_key(key.data(), key.size(), password.data(), password.size(),

View File

@@ -1,5 +1,4 @@
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);

View File

@@ -1,14 +1,22 @@
// TODO: actual fs
#include "mainwindow.h"
#include <QDesktopServices>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFileDialog>
#include <QInputDialog>
#include <QMessageBox>
#include <QMimeData>
#include <QTemporaryDir>
#include <botan/auto_rng.h>
#include <botan/hex.h>
#include <botan/exceptn.h>
namespace {
std::string basename(const std::string &path) {
u64 pos = path.find_last_of("/\\");
return (pos == std::string::npos) ? path : path.substr(pos + 1);
}
} // namespace
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), ui(std::make_unique<Ui::MainWindow>()) {
@@ -21,35 +29,36 @@ MainWindow::MainWindow(QWidget *parent)
connect(ui->actionNew, &QAction::triggered, this, [this]() {
QString path = QFileDialog::getSaveFileName(this, "Choose vault location",
QDir::currentPath(),
"Dull Vaults (*.dull)");
"Dull vaults (*.dull)");
if (path.isEmpty()) {
return;
}
if (!path.contains(".dull")) {
if (!path.endsWith(".dull")) {
path += ".dull";
}
QString password = QInputDialog::getText(
QString password;
while (true) {
password = QInputDialog::getText(
this, "Choose a password", "Choose a password", QLineEdit::Password);
if (password.length() < 8) {
QMessageBox::critical(this, "Error",
"Password must be at least 8 characters long.");
return;
} else {
break;
}
}
static Botan::AutoSeeded_RNG rng;
auto salt_sv = rng.random_vec(16);
std::vector<u8> salt(salt_sv.begin(), salt_sv.end());
ui->statusbar->showMessage("Creating the vault...");
QCoreApplication::processEvents();
std::ofstream create(path.toStdString(), std::ios::binary);
create.write("DULL", 4);
create.write(to_char_ptr(&VERSION), sizeof(VERSION));
create.write(to_char_ptr(salt.data()), 16);
create.close();
Vault::create(path.toStdString(), password.toStdString());
m_vault =
std::make_unique<Vault>(path.toStdString(), password.toStdString());
ui->statusbar->showMessage("Opened " + path);
reload_fs_tree();
});
@@ -63,12 +72,23 @@ MainWindow::MainWindow(QWidget *parent)
QString password = QInputDialog::getText(
this, "Unlock the vault", "Enter vault password", QLineEdit::Password);
if (password.isEmpty()) {
return;
}
// TODO: check if password valid
ui->statusbar->showMessage("Opening the vault...");
QCoreApplication::processEvents();
try {
m_vault =
std::make_unique<Vault>(path.toStdString(), password.toStdString());
reload_fs_tree();
ui->statusbar->showMessage("Opened " + path);
} catch (const Botan::Invalid_Authentication_Tag &e) {
QMessageBox::critical(this, "Error", "Invalid password.");
ui->statusbar->clearMessage();
return;
}
});
connect(
@@ -91,20 +111,47 @@ MainWindow::MainWindow(QWidget *parent)
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
m_vault->create_file(path_to_filename(path.toStdString()), content);
m_vault->create_file(basename(path.toStdString()), content);
}
reload_fs_tree();
ui->statusbar->showMessage("Added " + QString::number(paths.size()) +
" files");
});
connect(ui->actionExtract_All, &QAction::triggered, this, [this]() {
if (!m_vault) {
return;
}
QString path =
QFileDialog::getExistingDirectory(this, "Choose location to extract");
if (path.isEmpty()) {
return;
}
auto headers = m_vault->read_file_headers();
for (const auto &header : headers) {
auto content = m_vault->read_file(header.name);
if (content) {
std::ofstream file(path.toStdString() + "/" + header.name,
std::ios::binary);
file.write(content->data(), static_cast<i64>(content->size()));
}
}
ui->statusbar->showMessage("Extracted all files to " + path);
});
}
void MainWindow::reload_fs_tree() {
setWindowTitle(QString::fromStdString(m_vault->path()) + " - dull");
ui->menuFiles->setEnabled(true);
ui->fsTreeWidget->clear();
auto headers = m_vault->read_file_headers();
for (const auto &header : headers) {
auto *item = new QTreeWidgetItem(ui->fsTreeWidget);
item->setIcon(0, style()->standardIcon(QStyle::SP_FileIcon));
item->setText(0, QString::fromStdString(header.name));
item->setText(1, QString::number(header.content_ciphertext_size));
}
@@ -136,6 +183,8 @@ void MainWindow::extract_file(const std::string &filename) {
std::ofstream file(path.toStdString(), std::ios::binary);
file.write(content->data(), static_cast<i64>(content->size()));
ui->statusbar->showMessage("Extracted to " + path);
} else {
qWarning() << "File to extract not found";
}
@@ -166,6 +215,7 @@ void MainWindow::edit_file(const std::string &filename) {
m_vault->update_file(filename, new_content);
reload_fs_tree();
ui->statusbar->showMessage("File updated");
// QTemporaryDir gets deleted when it goes out of scope
} else {
qWarning() << "File to edit not found";
@@ -237,9 +287,10 @@ void MainWindow::dropEvent(QDropEvent *event) {
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
m_vault->create_file(path_to_filename(u.toLocalFile().toStdString()),
content);
m_vault->create_file(basename(u.toLocalFile().toStdString()), content);
}
ui->statusbar->showMessage(
"Added " + QString::number(event->mimeData()->urls().size()) + " files");
reload_fs_tree();
event->acceptProposedAction();

View File

@@ -2,8 +2,6 @@
#include "ui_mainwindow.h"
#include "vault.h"
#include <QMainWindow>
#include <memory>
class MainWindow : public QMainWindow {
Q_OBJECT

View File

@@ -40,13 +40,14 @@
</item>
</layout>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>32</height>
<width>900</width>
<height>30</height>
</rect>
</property>
<widget class="QMenu" name="menuVault">
@@ -64,25 +65,43 @@
<string>Files</string>
</property>
<addaction name="actionAddFiles"/>
<addaction name="actionExtract_All"/>
</widget>
<addaction name="menuVault"/>
<addaction name="menuFiles"/>
</widget>
<action name="actionNew">
<property name="icon">
<iconset theme="document-new"/>
</property>
<property name="text">
<string>New</string>
</property>
</action>
<action name="actionOpen">
<property name="icon">
<iconset theme="document-open"/>
</property>
<property name="text">
<string>Open</string>
</property>
</action>
<action name="actionAddFiles">
<property name="icon">
<iconset theme="list-add"/>
</property>
<property name="text">
<string>Add</string>
</property>
</action>
<action name="actionExtract_All">
<property name="icon">
<iconset theme="document-save-as"/>
</property>
<property name="text">
<string>Extract All</string>
</property>
</action>
</widget>
<resources/>
<connections/>

View File

@@ -1,7 +1,6 @@
#include "vault.h"
#include "common.h"
#include "crypto.h"
#include <array>
#include <botan/auto_rng.h>
#include <filesystem>
@@ -21,7 +20,32 @@ Vault::Vault(std::string path, const std::string &password)
std::array<u8, 16> salt{};
ASSERT(m_file.read(to_char_ptr(salt.data()), 16));
m_key = Crypto::derive_key_argon2id(password, salt);
m_key = Crypto::derive_key(password, salt);
Botan::secure_vector<u8> check_ciphertext;
check_ciphertext.resize(46);
ASSERT(m_file.read(to_char_ptr(check_ciphertext.data()), 46));
Crypto::decrypt(m_key, check_ciphertext);
}
void Vault::create(const std::string &path, const std::string &password) {
static Botan::AutoSeeded_RNG rng;
auto salt = rng.random_array<16>();
auto key = Crypto::derive_key(password, salt);
const std::string_view content = "LETSGO";
Botan::secure_vector<u8> content_sv(content.begin(), content.end());
auto check_ciphertext = Crypto::encrypt(key, content_sv);
std::ofstream create(path, std::ios::binary);
create.write("DULL", 4);
create.write(to_char_ptr(&VERSION), sizeof(VERSION));
create.write(to_char_ptr(salt.data()), 16);
create.write(to_char_ptr(check_ciphertext.data()), 46);
create.close();
}
std::vector<FileHeader> Vault::read_file_headers() {
@@ -62,8 +86,7 @@ std::optional<std::string> Vault::read_file(const std::string &filename) {
break;
}
auto plaintext = Crypto::decrypt_xchacha20_poly1305(
ciphertext, m_key, header->content_nonce);
auto plaintext = Crypto::decrypt(m_key, ciphertext);
return std::string(to_char_ptr(plaintext.data()), plaintext.size());
}
@@ -79,25 +102,17 @@ void Vault::create_file(const std::string &filename,
m_file.clear();
m_file.seekp(0, std::ios::end);
static Botan::AutoSeeded_RNG rng;
auto name_nonce = rng.random_array<24>();
auto content_nonce = rng.random_array<24>();
Botan::secure_vector<u8> filename_sv(filename.begin(), filename.end());
auto filename_ciphertext =
Crypto::encrypt_xchacha20_poly1305(filename_sv, m_key, name_nonce);
auto filename_ciphertext = Crypto::encrypt(m_key, filename_sv);
u64 filename_ciphertext_size = filename_ciphertext.size();
Botan::secure_vector<u8> content_sv(content.begin(), content.end());
auto ciphertext =
Crypto::encrypt_xchacha20_poly1305(content_sv, m_key, content_nonce);
auto ciphertext = Crypto::encrypt(m_key, content_sv);
u64 ciphertext_size = ciphertext.size();
ASSERT(m_file.write(to_char_ptr(name_nonce.data()), name_nonce.size()));
ASSERT(m_file.write(to_char_ptr(&filename_ciphertext_size), sizeof(u64)));
ASSERT(m_file.write(to_char_ptr(filename_ciphertext.data()),
static_cast<i64>(filename_ciphertext_size)));
ASSERT(m_file.write(to_char_ptr(content_nonce.data()), content_nonce.size()));
ASSERT(m_file.write(to_char_ptr(&ciphertext_size), sizeof(u64)));
ASSERT(m_file.write(to_char_ptr(ciphertext.data()),
static_cast<i64>(ciphertext_size)));
@@ -121,7 +136,7 @@ void Vault::delete_file(const std::string &filename) {
if (header->name == filename) {
entry_start = current_pos;
entry_total_size = 24 + sizeof(u64) + header->name_ciphertext_size + 24 +
entry_total_size = sizeof(u64) + header->name_ciphertext_size +
sizeof(u64) + header->content_ciphertext_size;
m_file.seekg(static_cast<i64>(header->content_ciphertext_size),
std::ios::cur);
@@ -135,21 +150,29 @@ void Vault::delete_file(const std::string &filename) {
if (entry_start != -1) {
i64 entry_end = entry_start + static_cast<i64>(entry_total_size);
i64 read_pos = entry_end;
i64 write_pos = entry_start;
std::array<u8, 65536> buffer{};
while (true) {
m_file.clear();
m_file.seekg(entry_end, std::ios::beg);
std::string remaining;
remaining.assign(std::istreambuf_iterator<char>(m_file),
std::istreambuf_iterator<char>());
m_file.seekg(read_pos);
m_file.read(to_char_ptr(buffer.data()), buffer.size());
auto bytes = m_file.gcount();
if (bytes == 0) {
break;
}
m_file.clear();
m_file.seekp(entry_start, std::ios::beg);
ASSERT(m_file.write(remaining.data(), static_cast<i64>(remaining.size())));
m_file.seekp(write_pos);
ASSERT(m_file.write(to_char_ptr(buffer.data()), bytes));
read_pos += bytes;
write_pos += bytes;
}
i64 new_size = entry_start + static_cast<i64>(remaining.size());
m_file.flush();
m_file.close();
std::filesystem::resize_file(m_path, new_size);
std::filesystem::resize_file(m_path, write_pos);
m_file.open(m_path, std::ios::in | std::ios::out | std::ios::binary);
ASSERT(m_file.good());
@@ -165,10 +188,6 @@ void Vault::update_file(const std::string &filename,
std::optional<FileHeader> Vault::read_file_header() {
FileHeader header{};
if (!m_file.read(to_char_ptr(header.name_nonce.data()), 24)) {
return std::nullopt;
}
if (!m_file.read(to_char_ptr(&header.name_ciphertext_size), sizeof(u64))) {
return std::nullopt;
}
@@ -182,14 +201,8 @@ std::optional<FileHeader> Vault::read_file_header() {
return std::nullopt;
}
auto name = Crypto::decrypt_xchacha20_poly1305(name_ciphertext, m_key,
header.name_nonce);
auto name = Crypto::decrypt(m_key, name_ciphertext);
header.name = std::string(name.begin(), name.end());
if (!m_file.read(to_char_ptr(header.content_nonce.data()), 24)) {
return std::nullopt;
}
if (!m_file.read(to_char_ptr(&header.content_ciphertext_size), sizeof(u64))) {
return std::nullopt;
}

View File

@@ -1,20 +1,17 @@
#pragma once
#include "common.h"
#include <array>
#include <botan/secmem.h>
#include <fstream>
#include <optional>
constexpr i16 VERSION = 1;
constexpr u64 AFTER_HEADER_OFFSET = 22;
constexpr u64 AFTER_HEADER_OFFSET = 68;
// !!! REMEMBER TO UPDATE entry_total_size IN Vault::delete_file
struct FileHeader {
std::array<u8, 24> name_nonce;
u64 name_ciphertext_size;
std::string name;
std::array<u8, 24> content_nonce;
u64 content_ciphertext_size;
};
@@ -22,12 +19,16 @@ class Vault {
public:
explicit Vault(std::string path, const std::string &password);
static void create(const std::string &path, const std::string &password);
std::vector<FileHeader> read_file_headers();
std::optional<std::string> read_file(const std::string &name);
void create_file(const std::string &name, const std::string &content);
void delete_file(const std::string &name);
void update_file(const std::string &name, const std::string &content);
const std::string &path() const { return m_path; }
private:
std::string m_path;
std::fstream m_file;