Compare commits

..

6 Commits

Author SHA1 Message Date
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
12 changed files with 174 additions and 64 deletions

View File

@@ -1 +1 @@
Checks: '*,clang-analyzer-*,-llvmlibc-*,-fuchsia-*,-altera-*,-abseil-*,-android-*,-modernize-use-trailing-return-type,-readability-identifier-length,-*-readability-todo,-*-magic-numbers,-readability-function-cognitive-complexity,-*-easily-swappable-parameters,-*-pro-type-reinterpret-cast,-*-owning-memory,-concurrency-mt-unsafe,-cert-env33-c,-*-avoid-do-while'
Checks: '*,clang-analyzer-*,-llvmlibc-*,-fuchsia-*,-altera-*,-abseil-*,-android-*,-modernize-use-trailing-return-type,-readability-identifier-length,-*-readability-todo,-*-magic-numbers,-readability-function-cognitive-complexity,-*-easily-swappable-parameters,-*-pro-type-reinterpret-cast,-*-owning-memory,-concurrency-mt-unsafe,-cert-env33-c,-*-avoid-do-while'

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
/.cache
/build
*.py
*.dull
*.dull

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(m=1GB,t=8,p=4) 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
cmake -B build
cmake --build build -j $(nproc)
./build/dull
```
### Windows / MSYS2
### 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 -S . -B build -DBOTAN_INCLUDE_DIRS=/mingw64/include/botan-3 -DBOTAN_LIBRARIES=/mingw64/lib/libbotan-3.a
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

@@ -1,7 +1,6 @@
#pragma once
#include "common.h"
#include <botan/aead.h>
#include <botan/hex.h>
#include <botan/pwdhash.h>
namespace Crypto {
@@ -60,4 +59,4 @@ derive_key_argon2id(const std::string &password,
return key;
}
}; // namespace Crypto
}; // namespace Crypto

View File

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

View File

@@ -1,6 +1,7 @@
// TODO: actual fs
#include "mainwindow.h"
#include "crypto.h"
#include <QDesktopServices>
#include <QDragEnterEvent>
#include <QDropEvent>
#include <QFileDialog>
#include <QInputDialog>
@@ -8,7 +9,7 @@
#include <QMimeData>
#include <QTemporaryDir>
#include <botan/auto_rng.h>
#include <botan/hex.h>
#include <botan/exceptn.h>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), ui(std::make_unique<Ui::MainWindow>()) {
@@ -21,12 +22,12 @@ 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";
}
@@ -38,19 +39,34 @@ MainWindow::MainWindow(QWidget *parent)
return;
}
ui->statusbar->showMessage("Creating the vault...");
QCoreApplication::processEvents();
static Botan::AutoSeeded_RNG rng;
auto salt_sv = rng.random_vec(16);
std::vector<u8> salt(salt_sv.begin(), salt_sv.end());
auto salt = rng.random_array<16>();
auto key = Crypto::derive_key_argon2id(password.toStdString(), salt);
auto check_nonce = rng.random_array<24>();
const std::string content = "LETSGO";
Botan::secure_vector<u8> content_sv(content.begin(), content.end());
auto check_ciphertext =
Crypto::encrypt_xchacha20_poly1305(content_sv, key, check_nonce);
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.write(to_char_ptr(check_nonce.data()), 24);
create.write(to_char_ptr(check_ciphertext.data()), 22);
create.close();
m_vault =
std::make_unique<Vault>(path.toStdString(), password.toStdString());
reload_fs_tree();
ui->statusbar->clearMessage();
});
connect(ui->actionOpen, &QAction::triggered, this, [this]() {
@@ -63,12 +79,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();
m_vault =
std::make_unique<Vault>(path.toStdString(), password.toStdString());
reload_fs_tree();
try {
m_vault =
std::make_unique<Vault>(path.toStdString(), password.toStdString());
reload_fs_tree();
ui->statusbar->clearMessage();
} catch (const Botan::Invalid_Authentication_Tag &e) {
QMessageBox::critical(this, "Error", "Invalid password.");
ui->statusbar->clearMessage();
return;
}
});
connect(
@@ -95,16 +122,43 @@ MainWindow::MainWindow(QWidget *parent)
}
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 +190,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 +222,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";
@@ -240,7 +297,9 @@ void MainWindow::dropEvent(QDropEvent *event) {
m_vault->create_file(path_to_filename(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>
@@ -22,6 +21,15 @@ Vault::Vault(std::string path, const std::string &password)
ASSERT(m_file.read(to_char_ptr(salt.data()), 16));
m_key = Crypto::derive_key_argon2id(password, salt);
std::array<u8, 24> check_nonce{};
ASSERT(m_file.read(to_char_ptr(check_nonce.data()), 24));
Botan::secure_vector<u8> check_ciphertext;
check_ciphertext.resize(22);
ASSERT(m_file.read(to_char_ptr(check_ciphertext.data()), 22));
Crypto::decrypt_xchacha20_poly1305(check_ciphertext, m_key, check_nonce);
}
std::vector<FileHeader> Vault::read_file_headers() {
@@ -135,21 +143,29 @@ void Vault::delete_file(const std::string &filename) {
if (entry_start != -1) {
i64 entry_end = entry_start + static_cast<i64>(entry_total_size);
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>());
i64 read_pos = entry_end;
i64 write_pos = entry_start;
std::array<u8, 65536> buffer{};
while (true) {
m_file.clear();
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());
@@ -194,4 +210,4 @@ std::optional<FileHeader> Vault::read_file_header() {
return std::nullopt;
}
return header;
}
}

View File

@@ -7,7 +7,7 @@
#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 {
@@ -28,6 +28,8 @@ public:
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;