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
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")
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
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)
Three layers of protection:
-
FramelessWindowHint— no title bar, no close button -
keyPressEvent— Escape key swallowed -
closeEvent— WM close ignored until password accepted
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))
When encrypted, the card shows an amber overlay — content is completely hidden:
# 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
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)
# 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")
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()
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()
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
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
⚠️ 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
)
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
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
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
The Settings UI
Eclipse gets its own tab in the Settings dialog:
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
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
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
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:
| 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
Linux .deb:
sudo dpkg -i dotghostboard_1.4.0_amd64.deb
Option 2 — OpenDesktop.org
DotGhostBoard is also published on OpenDesktop.org — the Linux app store used by KDE, GNOME, and OCS-compatible installers.
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
Requirements: Python 3.11+, PyQt6, Pillow, cryptography, xdotool
sudo apt install xdotool # Kali / Debian / Ubuntu
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
Links
- 🐙 GitHub: https://github.com/kareem2099/DotGhostBoard
- 🐛 Issues: https://github.com/kareem2099/DotGhostBoard/issues
- 🖥 OpenDesktop: https://opendesktop.org/p/2353623/
- ⚙️ CI Builds: https://github.com/kareem2099/DotGhostBoard/actions
- 📝 medium: https://medium.com/@freerave
- 💼 LinkedIn: https://www.linkedin.com/in/freerave/
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)