A deep-dive into DotGhostBoard 2.1's security layer: AES-256-GCM .vault backup packages with AAD header authentication, CSPRNG password generation with keyspace entropy, secret expiration tracking, and encrypted version history with safe revert.
Most local password managers have a backup problem: export to unencrypted CSV and you have a plaintext credential dump sitting on your disk. Use proprietary cloud sync and you've handed your secrets to a third party. Neither is acceptable.
In Part 1, we covered the productivity layer of DotGhostBoard 2.1 — smart auto-tagging and contextual clipboard actions running locally in under 1ms for typical payloads (< 5KB).
This part is about security architecture: what happens when you need to safely move, store, expire, and rotate credentials.
Here's what we're covering:
- Standalone
.vaultEncrypted Backup Packages (with AAD header authentication) - CSPRNG Password & Token Generator with Live Keyspace Entropy
- Secret Expiry Tracking & Dynamic Warning Badges
- Encrypted Password Version History with Safe Revert
- Vault-to-Dashboard Plaintext Sweep & Its Practical Boundaries
- Unified UX Polish & Ergonomics
1. Encrypted Vault Backups: The .vault Package Format
The goal was simple: export your entire Vault as a single portable file that contains zero unencrypted secret bytes — safe to copy to a USB drive, upload to a personal server, or store offsite.
The result is the .vault format (core/security/vault/backup.py), a compact binary package:
+---------------+---------------+---------------+---------------+---------------+
| Magic Header | Version | Salt | Nonce | Payload + Tag |
| "DGBV" | 0x01 | 16 bytes | 12 bytes | Ciphertext + |
| (4 bytes) | (1 byte) | os.urandom | AES-GCM | 16B GCM Tag |
+---------------+---------------+---------------+---------------+---------------+
The Cryptographic Stack
| Layer | Implementation |
|---|---|
| Cipher | AES-256-GCM (authenticated encryption with AAD) |
| Key derivation | PBKDF2-HMAC-SHA256, 100,000 rounds |
| Salt | 16-byte random, per-export (os.urandom(16)) |
| Nonce | 12-byte random, per-export (os.urandom(12)) |
| Associated Data (AAD) | 5-byte header (MAGIC + VERSION) authenticated into the GCM tag |
| Authentication | 128-bit GCM tag — tampering with ciphertext, nonce, or header fails cleanly |
Here's the core export logic, lightly abbreviated:
# core/security/vault/backup.py (simplified)
import os, json
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
MAGIC = b"DGBV"
VERSION = b"\x01"
KDF_ITER = 100_000
def export_vault(secrets: list[dict], passphrase: str) -> bytes:
salt = os.urandom(16)
nonce = os.urandom(12)
header_aad = MAGIC + VERSION
# Derive 256-bit key from passphrase
kdf = PBKDF2HMAC(hashes.SHA256(), length=32, salt=salt, iterations=KDF_ITER)
key = kdf.derive(passphrase.encode())
# Serialize payload entirely in-memory — never touches disk
plaintext = json.dumps(secrets, ensure_ascii=False).encode()
# AES-256-GCM: encrypt payload and authenticate format header via AAD
ciphertext = AESGCM(key).encrypt(nonce, plaintext, header_aad)
# Return complete package (16-byte GCM tag is appended to ciphertext by AESGCM)
return header_aad + salt + nonce + ciphertext
And importing reverses the process — with AEAD integrity verification happening before any payload parsing or database operations:
def import_vault(data: bytes, passphrase: str) -> list[dict]:
if not data.startswith(MAGIC):
raise ValueError("Not a valid .vault file")
if len(data) < 5 or data[4] != 1:
raise ValueError("Unsupported .vault backup version")
header_aad = data[:5]
salt, nonce, ciphertext = data[5:21], data[21:33], data[33:]
kdf = PBKDF2HMAC(hashes.SHA256(), length=32, salt=salt, iterations=KDF_ITER)
key = kdf.derive(passphrase.encode())
# Decrypt and authenticate: raises InvalidTag if ciphertext, passphrase,
# or header bytes (MAGIC/VERSION) were tampered with
plaintext = AESGCM(key).decrypt(nonce, ciphertext, header_aad)
return json.loads(plaintext)
Why Associated Authenticated Data (AAD)?
Passing MAGIC + VERSION as AAD cryptographically binds the cleartext header to the payload's 128-bit authentication tag. Without AAD, an attacker could flip the version byte undetected by the cipher until application-level parsing. With AAD, modifying even a single bit in the header or ciphertext triggers an immediate InvalidTag rejection by the cipher.
What Gets Exported
Everything: active secrets, categories, creation/update timestamps, expiration dates, and the last 3 encrypted version history entries. Full Vault state in one file.
Built-in duplicate skipping (overwrite_existing=False) on import means re-importing an old backup won't create duplicates in your live Vault.
On PBKDF2 iteration count: 100,000 rounds is a responsive baseline across diverse Linux hardware, including low-power ARM boards. Argon2id support and user-configurable iteration scaling are on the v2.2 roadmap.

Figure 1: Standalone .vault backup export dialog showing PBKDF2 passphrase derivation, AES-256-GCM encryption parameters, and live Vault drawer state.
2. CSPRNG Password & Token Generator
Generating credentials inside a clipboard manager means you stay in one tool. No tab-switching to a web generator, no pasting between windows.
The generator is embedded directly inside the Add/Edit Secret dialog (ui/vault/secret_dialog.py):
[ ⚡ Generate Password / Token ]
├── CSPRNG Source: Python secrets module (/dev/urandom)
├── Character Pools: [✓] a-z [✓] A-Z [✓] 0-9 [✓] Symbols
├── Length Slider: 8 ──────────●─────── 64 (default: 20)
├── Token Presets: Hex | UUIDv4 | Base64 | Bearer | URL-Safe
└── Entropy Meter: [████████████████████] 131.1 bits — Fort Knox
The Entropy Calculation
Instead of an arbitrary "password strength checklist," we display theoretical keyspace entropy:
H = L × log₂(N)
Where:
-
L= character length -
N= active pool size (e.g., a-z + A-Z + 0-9 + symbols = 94 characters)
# ui/vault/secret_dialog.py (simplified)
import math
def keyspace_entropy(length: int, pool_size: int) -> float:
"""Theoretical bits of entropy for a uniformly random credential."""
if pool_size < 2:
return 0.0
return length * math.log2(pool_size)
def entropy_label(bits: float) -> str:
if bits < 40: return "Weak"
if bits < 60: return "Moderate"
if bits < 80: return "Strong"
return "Fort Knox"
The meter updates live as you drag the slider or toggle character pools. A 20-character password using the full 94-character pool yields 131.1 bits of keyspace entropy (20 × log₂(94)) — comfortably above the 80–128-bit range generally considered strong for random credentials.
Keyspace entropy across the stack: Both the auto-tagger in Part 1 and the generator here use the same keyspace entropy formula (H = L × log₂(N)). The difference is in application: the auto-tagger estimates the character pool of existing clipboard text to detect high-entropy tokens, while the generator computes the exact strength of newly generated passwords from user-selected pools.

Figure 2: Built-in CSPRNG Password & Token Generator showing character pool toggles, 20-character length slider, and live keyspace entropy evaluation (Strong ~131 bits).
3. Secret Expiry Tracking & Dynamic Badges
Temporary credentials — staging tokens, contractor access keys, short-lived API secrets — have an expiration date. Most password managers ignore this.
DotGhostBoard 2.1 tracks expiry directly in the Vault model (core/security/vault/models.py) and surfaces it visually on each secret card (ui/vault/secret_card.py).
Setting an Expiry Date
In the Add/Edit dialog, you can:
- Pick a preset duration: 30 days, 90 days, 180 days, 1 year.
- Or pick an exact date via a
QDateEditcalendar picker.
The date is stored as an ISO 8601 timestamp string in SQLite. The expiry logic lives in a shared mixin:
# core/security/vault/models.py
from datetime import datetime
from typing import Optional
class _ExpiryMixin:
expires_at: Optional[str]
@property
def is_expired(self) -> bool:
if not self.expires_at:
return False
try:
exp = datetime.fromisoformat(self.expires_at)
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
return now > exp
except Exception:
# Corrupted timestamp: treated as non-expiring rather than raising in the UI
return False
@property
def days_remaining(self) -> Optional[int]:
if not self.expires_at:
return None
try:
exp = datetime.fromisoformat(self.expires_at)
now = datetime.now(exp.tzinfo) if exp.tzinfo else datetime.now()
return (exp - now).days
except Exception:
return None
@dataclass
class VaultItem(_ExpiryMixin): ...
@dataclass(frozen=True)
class VaultSummary(_ExpiryMixin): ...
Both VaultItem (full DB record) and VaultSummary (UI listing) inherit the same expiry logic through the mixin — written once, tested once.
Visual Status Badges
The secret card renders the appropriate badge at render time:
| Badge | Condition | Appearance |
|---|---|---|
⛔ EXPIRED |
is_expired == True |
Red badge |
⚠️ 0d–7d left |
days_remaining <= 7 (e.g., ⚠️ 3d left, or ⚠️ 0d left when less than 24 hours remain) |
Amber badge |
⏳ 2027-03-01 |
Active, > 7 days remaining | Blue pill |
| (none) | No expiry set | — |
Figure 3: Secret cards displaying dynamic status badges including expired credentials and upcoming renewal countdowns.
4. Encrypted Password Version History & Safe Revert
Rotating a production credential is risky. If the new key fails, you need the old one — immediately. DotGhostBoard 2.1 solves this with Encrypted Version History.
How It Works
Every time you update an existing secret, the current ciphertext is archived before the new one is written:
# core/security/vault/service.py (simplified)
def update_secret(self, item_id: int, secret_text: str, save_history: bool = True) -> bool:
key = self._require_unlocked()
if save_history:
# 1. Fetch current encrypted record before replacing
existing = self._repo.get_item(item_id)
if existing and existing.ciphertext:
# 2. Archive previous ciphertext to history (capped at 3 newest)
self._repo.add_history_entry(item_id, existing.ciphertext, max_entries=3)
# 3. Encrypt and store the new secret version
new_ciphertext = encrypt(secret_text, key)
return self._repo.update_item(item_id, ciphertext=new_ciphertext)
Key points:
- Max 3 versions — capped in SQLite, avoiding unbounded storage growth.
- Encrypted at rest — history entries use the same DEK as the live secret. Plaintext never hits disk.
- Decrypted in memory only — when you open the History Viewer, decryption happens in RAM and is discarded on close.
The History Viewer (📜) & Safe Revert
Clicking the 📜 icon on any secret card opens the modal:
- Version index and creation timestamp for each entry.
-
👁️ Reveal/🙈 Hidetoggle with monospace masking. - Copy button — protected by DotGhostBoard's 30-second automatic clipboard scrub.
- ↺ Revert to this — restores the selected version as the active secret.
Behind the scenes, revert calls:
self.update_secret(item_id, secret_text=plaintext, save_history=True)
This neatly preserves the active secret you just replaced into history, allowing you to toggle back and forth safely without data loss. When a secret is deleted, SQLite cascades and purges all historical entries in the same transaction.

Figure 4: Password version history modal showing archived credential states, reveal toggle, and one-click atomic revert.
5. Vault-to-Dashboard Plaintext Sweep
Here's a subtle security gap:
You add a secret to the Vault. But before you did, you copied it to your clipboard — meaning an unencrypted copy was recorded in your general clipboard history.
Later, you delete the secret from the Vault. If the clipboard history remains untouched, your secret is gone from the encrypted vault but still sitting in plaintext in the general history database.
DotGhostBoard 2.1 closes this gap with a Coordinated Plaintext Sweep:
# ui/vault/vault_controller.py (simplified)
def delete_secret(self, item_id: int) -> bool:
# 1. Temporarily decrypt secret in-memory to discover history traces
try:
plaintext = self._service.reveal_secret(item_id)
except Exception:
plaintext = None
# 2. Delete from encrypted Vault database
success = self._service.delete_secret(item_id)
# 3. If deleted, sweep matching plaintext entries from clipboard history
if success and plaintext:
history_matches = self._history_repo.find_by_content(plaintext)
for entry in history_matches:
self._history_repo.delete(entry["id"])
self.history_item_removed.emit(entry["id"]) # removes card from UI
self.secrets_changed.emit()
return success
The history_item_removed signal coordinates the UI: the Vault controller emits it, and the HistoryController receives it to cleanly remove the card from the UI. No tight coupling, no shared database transactions — just Qt signals.
Practical Boundary: Exact Match vs. Substrings
The sweep performs an exact match (find_by_content(plaintext)). If you copied the credential with trailing whitespace, a trailing newline, or embedded inside a larger terminal command line (e.g. curl -H "Authorization: Bearer <key>"), the exact sweep will not catch the enclosing entry.
We deliberately chose this conservative exact match: running broad substring searches across the entire clipboard history could easily result in destructive false-positive deletions of unrelated notes or commands.
6. Unified Design System & UX Polish
Security tools only work if developers actually use them. Friction kills adoption. In 2.1, we focused on tightening common interactions:
-
Component Unification (
PasswordInputWidget): We extracted password input handling and the visibility toggle into a single reusable widget. Both the sessionLockScreenand theVaultUnlockDialognow share identical keyboard behavior, input styling, and state management rather than maintaining divergent Qt implementations. -
Drawer Ergonomics: The slide-out Vault drawer (
ui/vault/vault_panel.py) was restructured into a clear two-row toolbar (title/status row + action controls row) with an expanded 380px width for better readability. First-time users see an empty-state onboarding card outlining shortcuts and clipboard auto-scrub policies rather than a blank panel.
Figure 5: Expanded 380px Vault drawer featuring a two-row action toolbar and structured onboarding empty-state.
Figure 6: Unified PasswordInputWidget on the Session Lock screen with integrated eye toggle and focus handling.
Testing & Code Quality
523 automated tests. 100% passing.
======================== 523 passed in ~100s ========================
The test matrix covers:
- Cryptographic roundtrips with AAD tampering verification
- SQLite transaction isolation for version history and cascade deletes
- UI event loops via headless Xvfb (tested against both Openbox and Qtile 0.36.0)
- Expiry logic edge cases (timezone-aware vs. naive datetimes, malformed strings)
- Plaintext sweep signal propagation across the Vault ↔ Dashboard boundary
Zero Flake8 errors. ui/dashboard.py enforces a hard <= 500 LOC limit — all specialized logic delegates to controllers and mixins.
What's Next & Known Limitations
A few honest limitations, on the record:
-
Wayland compatibility: DotGhostBoard relies on X11/EWMH for clipboard capture and hotkeys. We're investigating
wlr-data-controlprotocol support for Wayland. No ETA yet, but it's our most-requested architecture evolution. - KDF hardening: 100,000 PBKDF2 rounds is our current baseline. User-configurable iteration counts (targeting OWASP's 600,000 recommendation) and Argon2id evaluation are on the v2.2 roadmap.
- Backup format version negotiation: v2.1 introduces the single-byte version header; upcoming releases will support forward-compatible schema migrations.
- Substring Plaintext Sweeps: Exploring optional flagged heuristics for wiping shell history snippets containing expired tokens.
-
CLI Vault queries: Scriptable, authenticated
dotghost vault listanddotghost vault get <title>commands are planned for v2.2.
📦 Try DotGhostBoard 2.1
100% free and open-source — Apache-2.0 License.
- GitHub: kareem2099/DotGhostBoard
- OpenDesktop / KDE Store: DotGhostBoard on OpenDesktop
git clone https://github.com/kareem2099/DotGhostBoard.git
cd DotGhostBoard
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
python3 main.py
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl+Alt+V |
Summon / hide Dashboard (migrates to active workspace) |
Ctrl+Alt+Space |
Floating Spotlight Quick Search |
Ctrl+Shift+V |
Open / close The Vault encrypted drawer |
Ctrl+F |
Focus search bar inside Dashboard |
Questions about the vault format, the key derivation choices, or the history revert logic? Drop them below — happy to go deeper on any of it. 👻🔒



Top comments (0)