How DotScramble protects its Pro tier using asymmetric cryptography — public key verification, a weighted hardware fingerprint, server-side activation limits, and a background recheck thread.
Series recap: DotScramble is a desktop privacy tool that blurs, pixelates, and redacts sensitive regions in images. Part 1 covered the core image processing pipeline and metadata spoofing. This post covers the licensing system — specifically, how I built something that actually works without being trivially crackable.
The Threat Model
Before writing a single line of licensing code, I had to define what I'm actually protecting against.
| Attack | Realistic? | What I'm defending against |
|---|---|---|
Copy/paste the .py file to another machine |
Very likely | Machine fingerprinting |
| Share one key across unlimited machines | Likely | Server-side activation limits |
Modify is_max_activated = True in source |
Trivial in Python | Cython binary compilation |
| Forge a license token offline | Requires private key | Ed25519 asymmetric signing |
| Use the app without internet forever | Easy | 7-day offline grace period + background recheck |
| Roll back the system clock to extend a token | Clever | Clock rollback detection |
The goal wasn't to make DotScramble uncrackable — determined attackers can always get around software licensing. The goal was to make it not worth the effort for casual use, while keeping it completely frictionless for legitimate users.
The Token Format
Every activated machine gets a signed license token — a compact, self-contained credential that the app can verify locally without any network call.
The format is two Base64URL segments joined by a dot:
<base64url(JSON payload)>.<base64url(Ed25519 signature)>
The payload looks like this:
{
"mid": "a3f1b2c9d4e5f6a7b8c9d0e1f2a3b4c5",
"name": "FreeRave",
"plan": "max",
"exp": 1782000000
}
-
mid— the machine ID (more on this below) -
name— shown in the welcome message on activation -
plan—"max"for Pro tier -
exp— Unix timestamp when the token expires
The server signs this payload with its Ed25519 private key and sends the complete token to the client. The client verifies it using a hardcoded public key — which can only verify, never forge.
Why Ed25519 Over HMAC?
The classic alternative is HMAC-SHA256: generate a secret key, share it with the client, use it to sign tokens. The problem is obvious — if the key is in the client binary, it can be extracted and used to generate unlimited fake tokens offline.
Ed25519 solves this by design:
Private key → only on the server → signs tokens
Public key → hardcoded in the app → verifies tokens, cannot forge
Forging a valid signature without the private key requires breaking Ed25519 — computationally infeasible. The worst a reverse engineer can do with the public key is verify tokens, which is the same thing the app does.
The public key in license_manager.py:
_ED25519_PUBLIC_KEY_B64 = "DQ0zJAi1S0c+NUhOP3050au9k5/fYwLU45ayTZIFVuI="
32 bytes. That's the entire security boundary between the app and unlimited offline activation.
The Verification Function
The full verification runs locally on every startup and every activation:
def _verify_token(self, token: str) -> Tuple[bool, dict]:
try:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
parts = token.split(".")
if len(parts) != 2:
return False, {}
payload_b64, sig_b64 = parts
# Safe dynamic padding — prevents decoder crashes on tokens
# with non-multiple-of-4 base64 lengths
def _b64dec(s: str) -> bytes:
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
payload_bytes = _b64dec(payload_b64)
sig_bytes = _b64dec(sig_b64)
pub_raw = base64.b64decode(_ED25519_PUBLIC_KEY_B64)
public_key = Ed25519PublicKey.from_public_bytes(pub_raw)
public_key.verify(sig_bytes, payload_bytes) # raises on invalid sig
payload = json.loads(payload_bytes.decode())
# Check expiry
if payload.get("exp", 0) < time.time():
return False, {}
# Check machine binding
if payload.get("mid", "") != self.generate_machine_id():
return False, {}
return True, payload
except Exception:
return False, {}
Three checks, in order:
-
Signature valid —
Ed25519PublicKey.verify()raises an exception on any tampered payload -
Not expired — server controls token lifetime via
exp -
Machine matches — the
midin the token must equal this machine's fingerprint
All three must pass. Fail any one, the token is rejected and the local activation is cleared.
The
_b64decpadding trick: Standard Base64 requires padding to a multiple of 4 characters. URL-safe Base64 (used in JWTs and similar) often strips it. The expression'=' * (-len(s) % 4)adds exactly the right number of=characters — 0 if already padded, 1, 2, or 3 otherwise. It's a modular arithmetic trick that avoids an if/else chain.
Machine Fingerprinting
The machine ID is a 32-character hex string derived from hardware factors. It needs to be:
- Stable — survive reboots, VPN changes, hostname changes
- Unique enough — distinguish different machines
- Cross-platform — work on Linux, Windows, macOS
@staticmethod
def generate_machine_id() -> str:
factors: dict[str, str] = {}
# Platform-specific hardware ID — the most stable identifier
try:
if platform.system() == "Linux":
mid_path = "/etc/machine-id"
if os.path.isfile(mid_path):
with open(mid_path) as f:
factors["mid"] = f.read().strip()
elif platform.system() == "Windows":
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography"
)
factors["mid"] = winreg.QueryValueEx(key, "MachineGuid")[0]
elif platform.system() == "Darwin":
import subprocess
out = subprocess.check_output(
["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
stderr=subprocess.DEVNULL
)
for line in out.decode().splitlines():
if "IOPlatformUUID" in line:
factors["mid"] = line.split('"')[-2]
break
except:
pass
# Secondary factors — used if primary is unavailable
try:
factors["cpu"] = str(os.cpu_count() or 0)
except:
pass
factors["os"] = platform.system()
# MAC address — only if it's real hardware, not randomized
try:
mac_int = _uuid.getnode()
if not (mac_int >> 40) & 1: # bit 40 = 1 means locally administered (randomized)
factors["mac"] = hex(mac_int)[2:].upper().zfill(12)
except:
pass
# Sort keys for determinism, hash the concatenation
factor_str = "|".join(f"{k}:{v}" for k, v in sorted(factors.items()))
return hashlib.sha256(factor_str.encode()).hexdigest()[:32]
The key design choices:
/etc/machine-id on Linux is written once during OS installation by systemd. It survives everything short of a full OS reinstall. It's the most stable machine identifier available on Linux.
MachineGuid on Windows is equivalent — written during OS installation, stored in the registry under HKLM\SOFTWARE\Microsoft\Cryptography.
The MAC address randomization check: Modern Linux kernels and privacy-focused configurations use MAC address randomization. Bit 40 of the MAC address is the "locally administered" bit — if it's set, the MAC was generated randomly and changes on every boot. Including a random MAC in the fingerprint would break activation after every reboot. The check (mac_int >> 40) & 1 skips it when it's randomized.
sorted(factors.items()) — dict ordering in Python 3.7+ is insertion-ordered, but explicitly sorting by key makes the fingerprint deterministic regardless of which factors are available. If /etc/machine-id is missing (e.g., in a container), the hash still produces the same result each run as long as the same secondary factors are present.
The final hash is truncated to 32 hex characters — 128 bits of machine identity, which is more than enough to distinguish machines while keeping the payload compact.
The Activation Flow
User clicks "Activate Pro"
│
▼
LocalAuthManager starts an ephemeral HTTP server on 127.0.0.1:0
(port 0 = OS assigns a free port atomically — no TOCTOU race)
│
▼
Browser opens: https://dotsuite.vercel.app/en/dashboard/dotscramble/auth?port=PORT&state=STATE_TOKEN
│
▼
User logs in and clicks "Activate"
│
▼
Web dashboard POSTs { key, state } to http://127.0.0.1:PORT/callback
│
▼
AuthHandler verifies state token (CSRF protection)
│
▼
LicenseManager.verify_and_activate(key) called
│
▼
Server receives: POST /v1/license/activate
Authorization: Bearer <api_key>
Body: { "machine_id": "a3f1b2..." }
│
▼
Server signs token, returns { "license_token": "...", "name": "FreeRave" }
│
▼
Client verifies token locally (_verify_token)
│
▼
Token + API key saved to SQLite
Background recheck thread started
The local HTTP server is a security boundary. The web dashboard can't directly call Python functions — it goes through an HTTP POST, which lets us:
- Validate the CSRF state token (prevents any other page from activating on behalf of the user)
- Cap the payload size at 4096 bytes (prevents local OOM DoS)
- Use
secrets.compare_digest()for the state token comparison (constant-time, prevents timing attacks)
def verify_state(self, state):
if not state or not self.state_token:
return False
return secrets.compare_digest(state, self.state_token)
secrets.compare_digest() is not paranoia here — the state token arrives over localhost HTTP, and comparing strings with == leaks timing information about how many characters match before the first mismatch. For a 32-character random token, this isn't a practical attack, but it's the correct primitive and costs nothing.
Storing the Token
Once verified, three values are written to SQLite:
self.db_manager.save_setting("dotsuite_api_key", api_key, "license")
self.db_manager.save_setting("license_token", token, "license")
self.db_manager.save_setting("is_max_activated", True, "license")
self.db_manager.save_setting("last_license_check_time", time.time(), "license")
The database uses INSERT OR REPLACE (upsert) with a setting_type column so license settings can be queried or cleared as a group.
On the next startup, the manager reads the cached token and re-verifies it locally — no network required:
if self._license_token:
last_verified = float(self.db_manager.get_setting("last_license_check_time", 0.0))
current_time = time.time()
# Clock rollback detection
if current_time < last_verified:
self.logger.error("System clock rollback detected on startup!")
self._clear_local()
else:
valid, _ = self._verify_token(self._license_token)
if valid:
self._is_max = True
self.db_manager.save_setting("last_license_check_time", current_time, "license")
self._schedule_background_recheck()
else:
self._clear_local()
The clock rollback check is subtle. If a user sets their system clock back before the token expiry date, local verification would still pass (the token isn't expired from the perspective of the past timestamp). Storing last_license_check_time and comparing against time.time() catches this — if the current time is before the last check time, something is wrong.
The Background Recheck Thread
Local verification is fast and works offline, but it can't detect revoked keys. A key that was refunded, chargebacked, or explicitly revoked would still pass local verification until its token expires.
The solution: a daemon thread that re-checks with the server every 24 hours.
def _schedule_background_recheck(self):
self.stop_recheck_event.clear()
t = threading.Thread(
target=self._recheck_loop,
daemon=True,
name="license-recheck"
)
t.start()
def _recheck_loop(self):
while not self.stop_recheck_event.is_set():
# Wait 24 hours, or until stopped
is_stopped = self.stop_recheck_event.wait(_RECHECK_HOURS * 3600)
if is_stopped or self.stop_recheck_event.is_set():
break
self._silent_recheck()
Using Event.wait(timeout) instead of time.sleep() means the thread can be cleanly interrupted on deactivation or app close — stop_recheck_event.set() wakes it immediately.
The recheck itself is silent:
def _silent_recheck(self):
with self.lock:
if not self._is_max:
return
current_key = self._api_key
try:
machine_id = self.generate_machine_id()
payload_bytes = json.dumps({"machine_id": machine_id}).encode()
req = urllib.request.Request(
_RECHECK_URL, data=payload_bytes,
headers={
"Authorization": f"Bearer {current_key}",
"Content-Type": "application/json"
}
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
new_token = data.get("license_token", "")
valid, _ = self._verify_token(new_token)
if valid:
# Refresh the cached token
with self.lock:
self._license_token = new_token
self.db_manager.save_setting("license_token", new_token, "license")
self.db_manager.save_setting("last_license_check_time", time.time(), "license")
else:
self._clear_local() # Token failed — deactivate silently
except urllib.error.HTTPError as e:
if e.code in (401, 403, 404):
self._clear_local() # Key revoked — deactivate silently
# Other errors (500, network timeout) — do nothing, try again next cycle
except:
pass # Offline — grace period continues
The error handling is intentional:
- 401/403/404 — the server explicitly rejected the key (revoked, deleted, or not found). Deactivate immediately.
- 5xx / timeout / no network — server might be temporarily down. Don't deactivate. The user has 7 days of offline grace before the token expires.
- Any other exception — same as above, fail open.
This gives legitimate users a 7-day offline window while still detecting revoked keys as soon as connectivity is restored.
The is_max_activated Property
Every feature gate in the UI calls this single property:
@property
def is_max_activated(self) -> bool:
# Extremely fast in-memory query
# prevents frame drops in preview sliders
with self.lock:
return self._is_max
This is intentionally the simplest possible thing. No token re-verification, no database reads, no disk I/O — just a boolean behind a mutex. The reasoning: this property is called on every frame of the real-time preview slider. Token re-verification takes 0.5–2ms (cryptographic operation). At 60fps, that's up to 120ms/second of crypto overhead — enough to cause visible stuttering.
The heavy verification happens once on startup and once per recheck cycle, then the result is cached in memory.
Where it's used in main_window.py:
# Feature gating at detection mode switch
def on_detection_change(self):
mode = self.detection_mode.get()
pro_only_modes = ['target_text', 'text', 'body', 'license_plate']
if mode in pro_only_modes and not self.license_manager.is_max_activated:
QMessageBox.information(
self,
"Pro Feature",
f"The '{mode_display}' detection mode is a Pro feature.\n\n"
f"Upgrade to DotScramble Pro for advanced AI models."
)
self.detection_mode.set("face") # Reset to free tier
# Feature gating at save time
is_max = self.license_manager.is_max_activated
should_scrub = is_max and self.scrub_exif.get()
should_spoof = is_max and self.spoof_metadata.get() and not should_scrub
Protecting the Source with Cython
Python source is trivially readable. is_max_activated can be patched in two keystrokes:
# Original
return self._is_max
# Cracked version
return True
The defense: compile license_manager.py to a native .so extension using Cython. The setup_license.py script does this:
from setuptools import setup, Extension
from Cython.Build import cythonize
extensions = [
Extension(
"src.managers.license_manager",
sources=["src/managers/license_manager.py"],
extra_compile_args=["-O2"], # Strip debug symbols
)
]
setup(
name="dotscramble_license",
ext_modules=cythonize(
extensions,
compiler_directives={
"embedsignature": False, # No readable function signatures
"emit_code_comments": False, # No source hints in the binary
"language_level": "3",
"boundscheck": False,
"wraparound": True,
},
),
zip_safe=False,
)
Build command:
python setup_license.py build_ext --inplace
# → src/managers/license_manager.cpython-313-x86_64-linux-gnu.so
After building, the .py source is removed from the distribution. The app imports from the .so exactly the same way — Python's import system finds the compiled extension automatically.
The resulting binary is a real shared library. Patching it requires disassembling machine code and finding the return instruction — orders of magnitude harder than editing a .py file. Not impossible, but the effort-to-reward ratio is high enough that most people will just buy a license.
Note: The compiled
.sois platform-specific —cpython-313-x86_64-linux-gnu.soonly runs on Linux x86_64 with CPython 3.13. Targeting Windows and macOS requires separate build steps on each platform, which is a solved problem for any CI pipeline.
The Full Lifecycle
First run (no license)
├── _is_max = False
├── Free tier features available
└── "Activate Pro" triggers browser flow
Activation
├── LocalAuthManager starts ephemeral HTTP server
├── Browser → dotsuite.vercel.app/auth → user logs in
├── Dashboard POSTs { key, state } to 127.0.0.1:PORT/callback
├── CSRF state verified (secrets.compare_digest)
├── verify_and_activate(key) sends machine_id to API server
├── Server signs token with Ed25519 private key
├── Client verifies token locally (signature + expiry + machine)
├── Token cached in SQLite
└── Background recheck thread started
Every startup (with cached token)
├── Clock rollback check
├── Local token verification (signature + expiry + machine)
├── _is_max = True if valid
└── Recheck thread started
Every 24 hours (background thread)
├── POST machine_id to /v1/license/recheck
├── Server returns fresh token (extends expiry)
├── Local verification of new token
├── Cache updated
└── On HTTP 401/403/404 → _clear_local() → instant deactivation
Deactivation
├── stop_recheck_event.set() → thread wakes and exits
├── DELETE /v1/license/deactivate (frees the activation slot)
└── _clear_local() → all local state wiped
What Doesn't Work (By Design)
I explicitly chose not to implement:
Online-only mode — requiring a network connection on every launch would be a poor user experience. Planes, trains, conferences with bad WiFi, corporate proxies. The 7-day grace period handles all of these.
Hardware lock to MAC address only — MAC addresses are trivially spoofed. Using /etc/machine-id and MachineGuid as the primary identifier is more robust.
Obfuscating the public key — pointless. The public key is designed to be public. Hiding it in a string scrambler doesn't add security — it just makes the code messier.
Aggressive telemetry — the only data sent to the server is the machine ID and the API key. No usage statistics, no file names, no behavioral data. DotScramble is a privacy tool — phoning home with analytics would be self-contradictory.
Key Takeaways
On Ed25519 vs HMAC: Use asymmetric signing whenever the verifier (client) is untrusted. If the client holds a shared secret, it can forge. If it only holds a public key, it can only verify.
On machine fingerprinting: Prefer OS-level identifiers (/etc/machine-id, MachineGuid) over network-layer identifiers (MAC, IP). The latter change too often to be reliable anchors.
On background threads: Event.wait(timeout) is better than time.sleep() for long-polling threads. It wakes immediately on event.set(), so shutdown is clean and instantaneous.
On feature gates: Keep the hot path — the boolean check called per-frame — as fast as possible. Cache everything. Do the expensive work (crypto, network) once in background.
On source protection: Cython is not DRM. It raises the bar significantly against casual tampering, but a determined reverse engineer can still disassemble the .so. The actual security comes from the Ed25519 signature — you can read every byte of the binary and still can't forge a valid token.
Part 3 covers what happened when I tried to make the UI actually good — a PyQt6 → PySide6 migration forced by a GPL license conflict, three separate Wayland window management bugs, and building a custom animated file picker from scratch.
Top comments (0)