DEV Community

Cover image for I Built a Clipboard Manager for Linux with AES-256 Encryption — DotGhostBoard v1.4.0 Eclipse
freerave
freerave

Posted on

I Built a Clipboard Manager for Linux with AES-256 Encryption — DotGhostBoard v1.4.0 Eclipse

A deep dive into building Eclipse — the security layer of DotGhostBoard: AES-256-GCM encryption, master password lock screen, stealth mode, secure delete, and app filtering. Full code walkthrough.


Watch the 1-minute overview:

👻 DotGhostBoard v1.4.0 — Eclipse

TL;DR — I built a clipboard manager for Linux (PyQt6 + SQLite) and just shipped its security layer: AES-256-GCM encryption, a master password lock screen, per-item secret toggle, stealth mode, secure file deletion, and app-level capture filtering. Zero telemetry. Zero Electron. 100% Python.


Background — Why Another Clipboard Manager?

Every clipboard manager I tried on Kali was either too heavy (Electron), too basic, or required a network connection. I wanted something that:

  • Runs lean on Kali Linux with no browser runtime
  • Captures text, images, and video paths automatically
  • Has a proper security layer — not just a toggle

So I built DotGhostBoard as part of my DotSuite toolkit. It's been through four versions:

Version Codename Focus
v1.0 Ghost Core capture + SQLite + dark UI
v1.1 Phantom Global hotkey + autostart
v1.2 Specter Image thumbnails + video preview
v1.3 Wraith Tags, collections, multi-select
v1.4 Eclipse Security — encryption, lock, stealth

What's in Eclipse

v1.4.0 Eclipse
├── 🔐 AES-256-GCM encryption engine
├── 🔑 Master password (PBKDF2-SHA256, 600K iterations)
├── 🔒 Lock screen — frameless, Escape-proof
├── 👁 Per-item secret toggle (right-click → Mark as Secret)
├── ⏱ Auto-lock after N minutes of inactivity
├── 👁 Stealth mode — hide from taskbar + Alt+Tab
├── 🗑 Secure delete — multi-pass byte overwrite
└── 🛡 App filter — whitelist / blacklist by process name
Enter fullscreen mode Exit fullscreen mode

Animated GIF showing the DotGhostBoard main dashboard on Kali Linux, featuring a dark neon UI with various clipboard items like text, images, and pinned cards


The Crypto Engine

The hardest design decision was key management. I didn't want to store the password anywhere — only a verifier that proves the password is correct.

# core/crypto.py

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os, base64

_NONCE_SIZE = 12     # GCM standard (96-bit)
_KDF_ITER   = 600_000


def derive_key(password: str) -> bytes:
    """PBKDF2-HMAC-SHA256 — same password + same salt = same key."""
    salt = _load_or_create_salt()   # 256-bit, stored at ~/.config/dotghostboard/eclipse.salt
    kdf  = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=_KDF_ITER,
    )
    return kdf.derive(password.encode("utf-8"))


def encrypt(plaintext: str, key: bytes) -> str:
    """AES-256-GCM → base64url token safe for SQLite TEXT columns."""
    nonce      = os.urandom(_NONCE_SIZE)
    ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
    return base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")


def decrypt(token: str, key: bytes) -> str:
    """Raises ValueError on wrong key or tampered data."""
    raw   = base64.urlsafe_b64decode(token.encode("ascii"))
    nonce = raw[:_NONCE_SIZE]
    ct    = raw[_NONCE_SIZE:]
    return AESGCM(key).decrypt(nonce, ct, None).decode("utf-8")
Enter fullscreen mode Exit fullscreen mode

Wire format: [ 12-byte nonce ][ ciphertext ][ 16-byte GCM auth tag ] — all base64url-encoded. The GCM tag means any tampering is detected on decryption, not silently ignored.

Password Verification Without Storing the Password

_VERIFY_TOKEN = "DOTGHOST_ECLIPSE_OK"

def save_master_password(password: str) -> None:
    key   = derive_key(password)
    token = encrypt(_VERIFY_TOKEN, key)   # store encrypted sentinel
    with open(_VERIFY_FILE, "w") as f:
        f.write(token)

def verify_password(password: str) -> bool:
    with open(_VERIFY_FILE) as f:
        stored = f.read().strip()
    try:
        return decrypt(stored, derive_key(password)) == _VERIFY_TOKEN
    except Exception:
        return False
Enter fullscreen mode Exit fullscreen mode

The raw password is never stored — not even hashed. Only an encrypted sentinel string lives on disk. Delete eclipse.salt and the ciphertext becomes permanently unrecoverable.


The Lock Screen

I wanted it to feel like a real lock screen — not just a dialog you can dismiss.

# ui/lock_screen.py (key parts)

class LockScreen(QDialog):
    MAX_ATTEMPTS = 5

    def __init__(self, parent=None, *, setup: bool = False):
        super().__init__(parent)
        self.setWindowFlags(
            Qt.WindowType.Dialog |
            Qt.WindowType.WindowStaysOnTopHint |
            Qt.WindowType.FramelessWindowHint   # no title bar
        )
        self.setModal(True)

    def keyPressEvent(self, event):
        # Escape cannot close a lock screen
        if event.key() != Qt.Key.Key_Escape:
            super().keyPressEvent(event)

    def closeEvent(self, event):
        # Window manager X button is also blocked
        if self._key is None:
            event.ignore()
        else:
            super().closeEvent(event)
Enter fullscreen mode Exit fullscreen mode

Three layers of protection:

  1. FramelessWindowHint — no title bar, no close button
  2. keyPressEvent — Escape key swallowed
  3. closeEvent — WM close ignored until password accepted

Screenshot of the DotGhostBoard Master Password lock screen, showing a sleek frameless dialog with a blurred background and a secure password input field.


Per-Item Secret Toggle

This was the UX challenge. I didn't want to encrypt everything automatically — that would be aggressive and confusing. Instead, the user right-clicks any text card:

# ui/dashboard.py — context menu

def _on_card_context_menu(self, pos, card: ItemCard):
    menu = QMenu(self)

    # Only show encryption options if master password is set
    # and item is a text card
    if has_master_password() and card.item_type == "text":
        menu.addSeparator()
        if card.is_secret:
            action = QAction("🔓  Remove Encryption", self)
            action.triggered.connect(lambda: self._decrypt_card(card.item_id))
        else:
            action = QAction("🔐  Mark as Secret", self)
            action.triggered.connect(lambda: self._encrypt_card(card.item_id))
        menu.addAction(action)

    menu.exec(card.mapToGlobal(pos))
Enter fullscreen mode Exit fullscreen mode

When encrypted, the card shows an amber overlay — content is completely hidden:

A demonstration of the Eclipse encryption feature where a user right-clicks a clipboard item to mark it as secret, instantly hiding its content behind an amber encrypted overlay.

# ui/widgets.py — the encrypted card face

def _build_secret_overlay(self) -> QWidget:
    overlay = QFrame()
    overlay.setObjectName("SecretOverlay")
    overlay.setStyleSheet("""
        QFrame#SecretOverlay {
            background: rgba(255, 153, 0, 0.05);
            border: 1px dashed #ff990055;
            border-radius: 6px;
        }
    """)
    overlay.setFixedHeight(56)
    # ... lock icon + hint label
    return overlay
Enter fullscreen mode Exit fullscreen mode

Clicking 👁 Reveal fires a signal up to Dashboard, which decrypts using the session key and pushes the plaintext back down:

# Dashboard receives the reveal request:
def _on_reveal_requested(self, item_id: int) -> None:
    if self._active_key is None:
        self.statusBar().showMessage("⚠ Session is locked — unlock first.")
        return
    plaintext = storage.decrypt_item(item_id, self._active_key)
    if plaintext:
        self._cards[item_id].reveal_content(plaintext)
Enter fullscreen mode Exit fullscreen mode
# ItemCard shows the plaintext:
def reveal_content(self, plaintext: str):
    self._revealed_label.setText(plaintext[:120] + "" if len(plaintext) > 120 else plaintext)
    self._overlay_widget.hide()
    self._revealed_label.show()
    self._is_revealed = True
    self._secret_btn.setText("🔒 Lock")
Enter fullscreen mode Exit fullscreen mode

When the session locks, all revealed cards re-hide automatically:

# Dashboard._lock():
for card in self._cards.values():
    card.on_session_locked()   # re-hides revealed content

# ItemCard.on_session_locked():
def on_session_locked(self):
    if self.is_secret and self._is_revealed:
        self._revealed_label.hide()
        self._revealed_label.setText("")   # clear plaintext from memory
        self._overlay_widget.show()
Enter fullscreen mode Exit fullscreen mode

Auto-Lock Inactivity Timer

# Dashboard.__init__:
self._auto_lock_timer = QTimer(self)
self._auto_lock_timer.setSingleShot(True)
self._auto_lock_timer.timeout.connect(self._lock)

# Reset on any user interaction:
def mousePressEvent(self, event):
    self._reset_auto_lock()
    super().mousePressEvent(event)

def _reset_auto_lock(self):
    minutes = self._settings.get("auto_lock_minutes", 0)
    if minutes > 0 and has_master_password():
        self._auto_lock_timer.start(minutes * 60 * 1000)
    else:
        self._auto_lock_timer.stop()
Enter fullscreen mode Exit fullscreen mode

Simple and reliable — a single-shot QTimer that resets on every mouse/keyboard event.


Stealth Mode

Hide from taskbar and Alt+Tab without breaking the UI:

def _set_stealth(self, enable: bool) -> None:
    import subprocess
    try:
        wid = hex(int(self.winId()))
        if enable:
            subprocess.run([
                "xprop", "-id", wid,
                "-f", "_NET_WM_STATE", "32a",
                "-set", "_NET_WM_STATE",
                "_NET_WM_STATE_SKIP_TASKBAR,_NET_WM_STATE_SKIP_PAGER",
            ], check=False, timeout=2, capture_output=True)
        else:
            subprocess.run(
                ["xprop", "-id", wid, "-remove", "_NET_WM_STATE"],
                check=False, timeout=2, capture_output=True
            )
    except Exception:
        pass
Enter fullscreen mode Exit fullscreen mode

This sets X11 _NET_WM_STATE hints directly via xprop — works on all EWMH-compliant window managers (GNOME, KDE, XFCE, i3 with gaps, etc.).


Secure Delete

A plain os.remove() doesn't zero out disk sectors. Eclipse overwrites first:

# core/secure_delete.py

def secure_delete(path: str, passes: int = 3) -> bool:
    if not os.path.isfile(path):
        return False

    size = os.path.getsize(path)
    if size > 0:
        with open(path, "r+b") as fh:
            for pass_num in range(passes):
                fh.seek(0)
                # Alternate: random → zeros → random
                if pass_num % 2 == 1:
                    fh.write(b"\x00" * size)
                else:
                    fh.write(os.urandom(size))
                fh.flush()
                os.fsync(fh.fileno())   # force kernel flush

    os.remove(path)
    return True
Enter fullscreen mode Exit fullscreen mode

⚠️ Note on SSDs: Wear-levelling on SSDs means software overwrite can't guarantee forensic destruction. For maximum security, combine this with full-disk encryption (LUKS).


App Filter — Whitelist / Blacklist

Don't capture from KeePassXC? Easy:

# core/app_filter.py

class AppFilter:
    def should_capture(self) -> bool:
        if not self.app_list:
            return True   # no filter = capture everything

        identifiers = get_active_app_identifiers()
        if not identifiers:
            return True   # fail-open if xdotool unavailable

        matched = self._matches(identifiers)
        return not matched if self.mode == "blacklist" else matched

    def _matches(self, identifiers: set[str]) -> bool:
        # Substring match: "keepass" matches "org.keepassxc.keepassxc"
        return any(
            app in ident
            for app in self.app_list
            for ident in identifiers
        )
Enter fullscreen mode Exit fullscreen mode

Process detection uses xdotool + /proc/<pid>/comm:

def get_active_app_identifiers() -> set[str]:
    win_id = _run(["xdotool", "getactivewindow"])
    if not win_id:
        return set()

    pid = _run(["xdotool", "getwindowpid", win_id])
    identifiers = set()

    # Read process name from /proc
    try:
        with open(f"/proc/{pid}/comm") as f:
            identifiers.add(f.read().strip().lower())
    except OSError:
        pass

    # Also check WM_CLASS for apps with wrappers
    wm_class = _run(["xprop", "-id", win_id, "WM_CLASS"])
    if wm_class:
        match = re.search(r'"([^"]+)"', wm_class)
        if match:
            identifiers.add(match.group(1).lower())

    return identifiers
Enter fullscreen mode Exit fullscreen mode

SQLite Schema Migration

Eclipse adds one column to the existing DB — backward compatible:

# core/storage.py — init_db()

# Migration: add is_secret for Eclipse v1.4.0
try:
    conn.execute(
        "ALTER TABLE clipboard_items ADD COLUMN is_secret INTEGER DEFAULT 0"
    )
except Exception:
    pass  # column already exists — safe to ignore
Enter fullscreen mode Exit fullscreen mode

And the encrypt/decrypt helpers work at the storage layer:

def encrypt_item(item_id: int, key: bytes) -> bool:
    """Encrypt content in-place. Returns False if already encrypted."""
    from core.crypto import encrypt as _encrypt
    item = get_item_by_id(item_id)
    if not item or item.get("is_secret") or item["type"] != "text":
        return False
    ciphertext = _encrypt(item["content"], key)
    with _db() as conn:
        conn.execute(
            "UPDATE clipboard_items SET content = ?, is_secret = 1 WHERE id = ?",
            (ciphertext, item_id)
        )
    return True

def decrypt_item(item_id: int, key: bytes) -> str | None:
    """Decrypt and return plaintext — does NOT modify DB."""
    from core.crypto import decrypt as _decrypt
    item = get_item_by_id(item_id)
    if not item:
        return None
    if not item.get("is_secret"):
        return item["content"]   # not encrypted, pass through
    try:
        return _decrypt(item["content"], key)
    except ValueError:
        return None   # wrong key or corrupted
Enter fullscreen mode Exit fullscreen mode

The Settings UI

Eclipse gets its own tab in the Settings dialog:

Screenshot of the Eclipse Security settings tab in DotGhostBoard, showing options for Master Password management, auto-lock inactivity timer, and the application-level capture filter configuration.

Three sections:

  • 🔑 Master Password — set / change / remove with current-password verification
  • ⏱ Auto-Lock — spinner 0–480 min
  • 🛡 App Filter — mode combo + editable process name list

About Tab

Screenshot of the DotGhostBoard About tab, displaying version 1.4.0 Eclipse, developer credits for FreeRave, system environment details, and links to the project's GitHub and social channels.

The About tab shows version info, system info (live Python/Qt versions), MIT license, and all social/community links.


Test Coverage

Eclipse ships with 28 unit tests:

pytest tests/test_eclipse.py -v
Enter fullscreen mode Exit fullscreen mode
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_decrypt_roundtrip
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_produces_different_tokens_each_call
PASSED tests/test_eclipse.py::TestCrypto::test_wrong_key_raises_value_error
PASSED tests/test_eclipse.py::TestCrypto::test_tampered_ciphertext_raises
PASSED tests/test_eclipse.py::TestCrypto::test_encrypt_unicode_characters
PASSED tests/test_eclipse.py::TestCrypto::test_master_password_flow
PASSED tests/test_eclipse.py::TestCrypto::test_same_password_derives_same_key
PASSED tests/test_eclipse.py::TestSecureDelete::test_file_is_gone_after_secure_delete
PASSED tests/test_eclipse.py::TestSecureDelete::test_original_content_overwritten
PASSED tests/test_eclipse.py::TestAppFilter::test_blacklist_blocks_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_whitelist_allows_matched_app
PASSED tests/test_eclipse.py::TestAppFilter::test_detection_failure_fails_open
PASSED tests/test_eclipse.py::TestStorageEclipse::test_encrypt_item_stores_ciphertext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_item_returns_plaintext
PASSED tests/test_eclipse.py::TestStorageEclipse::test_decrypt_wrong_key_returns_none
... 13 more ...
28 passed in 1.84s
Enter fullscreen mode Exit fullscreen mode

Terminal output showing 162 passed unit tests for DotGhostBoard, confirming the stability of core features including the encryption engine, media processing, and storage logic.


Install & Run

Option 1 — Download a Release (Recommended)

DotGhostBoard ships pre-built binaries via GitHub Actions CI — every push to main automatically builds for 4 platforms:

Screenshot of the GitHub Actions CI/CD pipeline for DotGhostBoard, showing all four build jobs (AppImage, DEB, Windows EXE, macOS DMG) completed successfully with green checkmarks and downloadable artifacts.

Platform Format Size
🐧 Linux .AppImage ~76 MB
🐧 Linux .deb ~60 MB
🪟 Windows .exe ~46 MB
🍎 macOS .dmg ~125 MB

Download from the Releases page or grab the latest build artifacts directly from GitHub Actions.

Linux AppImage (quickest):

chmod +x DotGhostBoard-1.4.0-x86_64.AppImage
./DotGhostBoard-1.4.0-x86_64.AppImage
Enter fullscreen mode Exit fullscreen mode

Linux .deb:

sudo dpkg -i dotghostboard_1.4.0_amd64.deb
Enter fullscreen mode Exit fullscreen mode

Option 2 — OpenDesktop.org

DotGhostBoard is also published on OpenDesktop.org — the Linux app store used by KDE, GNOME, and OCS-compatible installers.

Screenshot of the DotGhostBoard application page on OpenDesktop.org, detailing the native clipboard manager for Linux with the MIT license and the neon ghost app icon.

You can install it directly from the OpenDesktop page using the OCS-Install button if your distro supports it.


Option 3 — From Source

git clone https://github.com/kareem2099/DotGhostBoard
cd DotGhostBoard
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 main.py
Enter fullscreen mode Exit fullscreen mode

Requirements: Python 3.11+, PyQt6, Pillow, cryptography, xdotool

sudo apt install xdotool   # Kali / Debian / Ubuntu
Enter fullscreen mode Exit fullscreen mode

What's Next — v1.5.0 Nexus

v1.5.0 Nexus (planned)
├── 📡 Local network sync (same WiFi)
├── ☁  Optional cloud backup (S3 / Rclone)
├── 📱 QR code share — scan from phone
├── 🔌 REST API mode — localhost for scripts
├── 💻 CLI companion — dotghost push / pop
└── 🧩 Plugin system
Enter fullscreen mode Exit fullscreen mode

Links


Built with PyQt6, SQLite, and the cryptography library. Part of the DotSuite toolkit by FreeRave.

Star the repo if you find it useful ⭐

Top comments (0)