DEV Community

Cover image for Building DotScramble Part 2: Ed25519 License Signing, Machine Fingerprinting, and a 7-Day Offline Grace Period
freerave
freerave

Posted on

Building DotScramble Part 2: Ed25519 License Signing, Machine Fingerprinting, and a 7-Day Offline Grace Period

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)>
Enter fullscreen mode Exit fullscreen mode

The payload looks like this:

{
  "mid": "a3f1b2c9d4e5f6a7b8c9d0e1f2a3b4c5",
  "name": "FreeRave",
  "plan": "max",
  "exp": 1782000000
}
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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="
Enter fullscreen mode Exit fullscreen mode

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, {}
Enter fullscreen mode Exit fullscreen mode

Three checks, in order:

  1. Signature validEd25519PublicKey.verify() raises an exception on any tampered payload
  2. Not expired — server controls token lifetime via exp
  3. Machine matches — the mid in 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 _b64dec padding 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Validate the CSRF state token (prevents any other page from activating on behalf of the user)
  2. Cap the payload size at 4096 bytes (prevents local OOM DoS)
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

Build command:

python setup_license.py build_ext --inplace
# → src/managers/license_manager.cpython-313-x86_64-linux-gnu.so
Enter fullscreen mode Exit fullscreen mode

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 .so is platform-specific — cpython-313-x86_64-linux-gnu.so only 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
Enter fullscreen mode Exit fullscreen mode

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.

GitHub · OpenDesktop · Ko-fi · Buy Me a Coffee

Top comments (0)