Press enter or click to view image in full size
A clipboard manager is one of the most dangerous pieces of software running on your machine. It silently captures everything — passwords, API keys, JWTs, private keys, credit card numbers. Most clipboard managers sync to the cloud. Some log everything to a remote server you don’t control.
DotGhostBoard takes the opposite position: no telemetry, no cloud, no central server. With v1.5.0 (Nexus), I extended this to multi-device sync — devices on the same LAN can now discover each other, pair securely, and synchronize clipboard data using end-to-end encryption. Here’s the full threat model and implementation.
The Threat Model
Before writing a line of code, the attack surface had to be clearly defined.
Threats we defend against:
- A passive attacker capturing LAN traffic (packet sniffing)
- An active MITM attacker intercepting the pairing handshake
- A rogue device on the same network attempting to inject clipboard items
- A brute-force attack against the pairing PIN
- A malicious app on a trusted device replaying captured ciphertext
Threats we explicitly do not defend against:
- A fully compromised OS on either device (if the OS is owned, the clipboard is owned regardless)
- Physical access attacks
- A malicious actor who already has the shared secret
The design goal: zero plaintext on the wire, zero trust in the network, zero central infrastructure.
Layer 1 — Device Discovery (mDNS)
The first challenge is how devices find each other without a central rendezvous server and without requiring users to type IP addresses.
The solution is mDNS — each instance broadcasts itself on the LAN using a custom service type _dotghost._tcp.local. via the zeroconf library. Other instances listen and populate the peer list automatically.
# core/network_discovery.py
import socket
from zeroconf import ServiceBrowser, Zeroconf, ServiceInfo, IPVersion
from PyQt6.QtCore import pyqtSignal, QThread_SERVICE_TYPE = "_dotghost._tcp.local."
class DotGhostDiscovery(QThread):
peer_found = pyqtSignal(str, str, str, int) # node_id, name, ip, port
peer_lost = pyqtSignal(str)
def run(self):
self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
self.info = ServiceInfo(
type_=_SERVICE_TYPE,
name=f"{self.node_id}.{_SERVICE_TYPE}",
addresses=[socket.inet_aton(get_local_ip())],
port=self.port,
properties={
b'node_id': self.node_id.encode(),
b'device_name': self.device_name.encode(),
b'version': b'1',
},
server=f"{self.node_id}.local."
)
self.zeroconf.register_service(self.info)
self.browser = ServiceBrowser(self.zeroconf, _SERVICE_TYPE, self)
self.exec()
def add_service(self, zc, type_, name):
info = zc.get_service_info(type_, name)
if not info:
return
node_id = info.properties.get(b'node_id', b'').decode()
if node_id == self.node_id: # never trust self
return
ip = socket.inet_ntoa(info.addresses[0])
self.peer_found.emit(node_id, info.properties.get(b'device_name', b'Unknown').decode(), ip, info.port)
Security note on mDNS: Discovery is not authentication. Knowing a device is on the LAN proves nothing about whether it should be trusted. The discovery layer does zero verification — that’s entirely the job of the pairing layer below.
Layer 2 — The Pairing Handshake (X25519 + PBKDF2 + AES-GCM)
This is the core of the security model. The handshake establishes a shared secret between two devices without ever transmitting that secret — and without trusting the network at any point.
Why the network can’t be trusted
Consider a coffee shop scenario: both your laptop and phone are on the same Wi-Fi. An attacker on the same network can see all your traffic. If the pairing protocol sent public keys in plaintext, an attacker could intercept them, substitute their own keys (MITM), and read every clipboard item you sync.
Get freerave’s stories in your inbox
Join Medium for free to get updates from this writer.
The defense is a PIN-wrapped key exchange:
Device A Device B
│ │
│ 1. Generate ephemeral X25519 key │
│ 2. Display 6-digit PIN to user │ 2. Display same PIN to user
│ (out-of-band verification) │ (user confirms they match)
│ 3. Derive wrap key: PBKDF2(PIN, salt, 100k iterations)
│ 4. Encrypt pubkey with wrap key │
│ 5. Send: {salt, encrypted_pubkey} ►│
│ │ 6. Derive same wrap key
│ │ 7. Decrypt pubkey
│ │ 8. Generate ephemeral X25519 key
│◄──────────── {encrypted_pubkey} ────│ 9. ECDH → shared secret
│ 10. ECDH → shared secret │
│ 11. Discard ephemeral keys │ 11. Discard ephemeral keys
│ │
│ shared_secret stored in DB │The PIN is shown on both screens simultaneously. The user confirms they match — this is the out-of-band channel that breaks any MITM. An attacker intercepting the handshake traffic would have to also intercept the physical screen of both devices to substitute the PIN.
Implementation
# core/pairing.py
import os, base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import x25519
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers.aead import AESGCM_KDF_ITERATIONS = 100_000 # OWASP minimum for PBKDF2-SHA256
def derive_handshake_key(pin: str, salt: bytes) -> bytes:
"""
Derive a 256-bit wrapping key from the PIN and a per-session salt.
The salt is transmitted in plaintext - its job is to prevent
precomputed PIN rainbow tables, not to be secret itself.
With a 6-digit PIN space (10^6), PBKDF2 at 100k iterations
makes brute-force infeasible in the pairing window.
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=_KDF_ITERATIONS,
)
return kdf.derive(pin.encode("utf-8"))
def generate_pairing_keys() -> tuple[x25519.X25519PrivateKey, bytes]:
"""
Ephemeral X25519 key pair - lives only for the duration of the handshake.
Discarded immediately after the shared secret is derived.
"""
private_key = x25519.X25519PrivateKey.generate()
public_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
return private_key, public_bytes
def encrypt_pairing_payload(pubkey: bytes, wrap_key: bytes) -> str:
"""
AES-256-GCM encrypt the public key using the PIN-derived wrapping key.
Wire format: base64( nonce[12] || ciphertext || tag[16] )
"""
aesgcm = AESGCM(wrap_key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, pubkey, None)
return base64.b64encode(nonce + ct).decode()
def decrypt_pairing_payload(payload: str, wrap_key: bytes) -> bytes:
"""
Raises cryptography.exceptions.InvalidTag on wrong PIN or tampered payload.
This is the cryptographic proof of PIN knowledge - no PIN, no decryption.
"""
raw = base64.b64decode(payload)
nonce, ct = raw[:12], raw[12:]
return AESGCM(wrap_key).decrypt(nonce, ct, None)
def derive_shared_secret(
private_key: x25519.X25519PrivateKey,
peer_pubkey_bytes: bytes
) -> bytes:
"""
Complete the ECDH exchange.
Both devices independently compute the same 32-byte secret.
It is never transmitted - only the encrypted public keys are.
"""
peer_pub = x25519.X25519PublicKey.from_public_bytes(peer_pubkey_bytes)
return private_key.exchange(peer_pub)
Why X25519 over RSA or P-256?
X25519 provides several security advantages relevant to this protocol:
- Immune to invalid-curve attacks by design — the curve has cofactor 8, and the implementation clamps the scalar, making point validation unnecessary
- Constant-time by construction — the Montgomery ladder used in X25519 has no secret-dependent branches
- 32-byte keys — smaller attack surface in the wire format
- Default in TLS 1.3 — well-audited, widely implemented, no patent issues
The shared secret is stored in SQLite as a raw 32-byte BLOB — not base64, not hex, not re-encoded in any way that could introduce bugs:
CREATE TABLE trusted_peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id TEXT UNIQUE NOT NULL,
device_name TEXT NOT NULL,
shared_secret BLOB NOT NULL, -- 32 raw bytes, never leaves this DB
paired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);Layer 3 — Sync Transport (AES-256-GCM + Rate Limiting)
With pairing complete, the sync transport is deliberately minimal: a lightweight HTTPServer running on a local port. It's bound to 0.0.0.0 but has two hard security gates on every request.
Gate 1: Peer Identity
peer_node_id = data.get("node_id")
peer = storage.get_trusted_peer(peer_node_id)if not peer:
self._send_response(403, {"status": "error", "message": "Untrusted peer"})
return
Unknown node_id → immediate 403. No further processing, no error details leaked.
Gate 2: Cryptographic Authentication
try:
plaintext = decrypt_from_peer(data.get("payload"), peer["shared_secret"])
except Exception:
# InvalidTag: wrong key, tampered payload, or replayed ciphertext
self._send_response(403, {"status": "error", "message": "Decryption failed"})
returnAES-GCM’s authentication tag serves as the cryptographic proof of identity. An attacker who knows a valid node_id but doesn't have the shared secret cannot forge a valid ciphertext — InvalidTag is raised and the request is dropped. Replayed ciphertext from a previous session would also fail if nonces are handled correctly (each encryption uses os.urandom(12)).
Rate Limiting the Pairing Endpoint
A 6-digit PIN has 1⁰⁶ combinations. Without rate limiting, an attacker on the LAN could brute-force it in under a second. The pairing endpoint uses a sliding window limiter:
class _RateLimiter:
"""3 pairing attempts per 60 seconds per IP — makes brute-force infeasible."""def __init__(self, max_attempts: int = 3, window: int = 60):
self._attempts = defaultdict(list)
self._lock = Lock() # shared state across HTTP handler threads
self.max = max_attempts
self.window = window
def is_allowed(self, ip: str) -> bool:
now = time.time()
with self._lock:
self._attempts[ip] = [
t for t in self._attempts[ip] if now - t < self.window
]
if len(self._attempts[ip]) >= self.max:
return False
self._attempts[ip].append(now)
return True
At 3 attempts per 60 seconds, exhausting a 6-digit PIN space would take ~5.5 years. Combined with the 100,000-iteration PBKDF2 on the server side (adding ~300ms per attempt), online brute-force is completely infeasible.
The Lock is not optional. Python's BaseHTTPRequestHandler spawns a new handler instance per request, but they share the server's state. Without a threading.Lock, two simultaneous pairing requests race on the _attempts dict — a classic TOCTOU on the rate limiter itself.
The Full Data Flow
Copy on Device A Appears on Device B
───────────────── ────────────────────
1. ClipboardMonitor detects change
2. Retrieve shared_secret from DB
3. nonce = os.urandom(12)
4. ct = AES-256-GCM(shared_secret, nonce, plaintext)
5. POST /api/sync
{
"node_id": "abc123",
"payload": base64(nonce + ct + tag)
}
────────────────────────────────────► 6. Lookup peer by node_id
7. AES-256-GCM decrypt
→ InvalidTag? Drop.
→ OK? Continue.
8. storage.add_item(plaintext)
9. sync_received.emit()
10. UI updates
◄────────────────────── 201 { "status": "synced" }Every byte that crosses the network is ciphertext. The key never crosses the network. The plaintext never crosses the network.
What This Does Not Protect Against
Being explicit about limitations is part of responsible security engineering:
Compromised OS: If an attacker has code execution on either device, the clipboard contents are accessible in memory before encryption and after decryption. Encryption only protects data in transit — not at rest on a compromised system.
Network-level DoS: The sync server can be flooded. Rate limiting is applied to the pairing endpoint only. A volumetric attack against /api/sync from a LAN device would succeed. This is a known limitation acceptable for the current threat model (LAN-only, single user, private network).
Short PIN window brute-force on slow networks: The rate limiter operates per-IP. On a network where an attacker can spoof IPs, the 3-attempts-per-60s window could be bypassed. X25519 + AES-GCM still hold — the attacker would need to guess the PIN to decrypt the key exchange — but the rate limiter alone wouldn’t stop them.
What’s Next — v2.0.0 Cerberus
The sync layer is stable. Next is the Password Vault — a Zero-Knowledge secret store built on the same AES-256 foundation.
The key design decision that separates it from naive implementations: detection of secrets in the clipboard happens on pattern shape, not keywords. A 1,500-word blog post mentioning “password” doesn’t trigger anything. A 40-character base64 string matching AKIA[0-9A-Z]{16} (AWS access key pattern) does.
PATTERNS = {
"jwt": re.compile(r'eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+'),
"aws_key": re.compile(r'AKIA[0-9A-Z]{16}'),
"gh_token": re.compile(r'gh[ps]_[A-Za-z0-9]{36}'),
"hex_secret": re.compile(r'[0-9a-f]{64}'),
}The vault lives in a completely separate vault.db file with its own connection, locked when not in use, protected by a Master Password with its own PBKDF2-derived key — never sharing state with the main ghost.db.