Asymmetric Signing, Machine Fingerprinting, and Offline Grace Periods: Building a License System…
Press enter or click to view image in full sizeHow DotScramble protects its Pro tier using Ed25519 c 2026-7-1 10:14:43 Author: infosecwriteups.com(查看原文) 阅读量:4 收藏

freerave

Press enter or click to view image in full size

How DotScramble protects its Pro tier using Ed25519 cryptography — without phoning home on every launch

A technical deep-dive into license system design for desktop applications — threat modelling, Ed25519 token verification, weighted hardware fingerprinting, and background revocation detection.

The Problem With Most Desktop License Systems

Most desktop software license systems fall into one of two failure modes.

The naive implementation: a hardcoded or obfuscated license key string that the app compares against. Crack once, share forever. A single keygen posted to any forum defeats it permanently.

The over-engineered implementation: online-only validation that calls home on every launch. Legitimate users can’t use the software on a plane, at a conference, or when the license server has a bad day. The result is user experience indistinguishable from DRM — with all the goodwill cost that implies.

DotScramble needed something in between: cryptographically sound, functional offline for days at a time, with server-enforced revocation when internet is available. This post covers how that system works.

The Threat Model

Before any implementation decisions, a clear adversary model:

Attack vectorRealistic?DefenceCopy .py source to another machineTrivialMachine fingerprinting + Cython binaryShare one API key across N machinesLikelyServer-side activation limit (max 2)Patch is_max_activated = True in sourceOne lineCython .so compilationForge a license token offlineRequires Ed25519 private keyAsymmetric signing — public key only in clientUse indefinitely without networkEasy7-day token TTL + background recheckRoll back system clock to extend tokenCleverMonotonic timestamp stored in SQLiteRevoked key continues workingSilentBackground 24h server recheck

The objective was not unbreakable DRM — that doesn’t exist for software running on user-controlled hardware. The objective was to make casual circumvention more expensive than purchasing a license, while making legitimate use completely frictionless.

Why Ed25519

The classical alternative is HMAC-SHA256 with a shared secret. The fundamental problem: if the client holds a shared secret, it can forge. You can obfuscate the secret, compile it into a binary, XOR it with a magic constant — but it’s still in there, and extraction is a solved problem.

Ed25519 eliminates the forgery surface entirely:

Private key  →  lives only on the license server  →  signs tokens
Public key → hardcoded in the client binary → verifies tokens, cannot forge

The mathematics of elliptic curve cryptography guarantee that knowledge of the public key reveals nothing useful about the private key. An attacker who fully reverses the client binary, extracts the public key, and understands the entire verification flow still cannot produce a valid signature. The only path to a forged token is compromising the server.

The public key in license_manager.py is 32 bytes:

_ED25519_PUBLIC_KEY_B64 = "DQ0zJAi1S0c+NUhOP3050au9k5/fYwLU45ayTZIFVuI="

This is the entire cryptographic boundary between the client and unlimited offline activation.

Token Structure

Every activated machine receives a signed license token — a compact, self-contained credential the app can verify locally without any network call.

Format: two Base64URL strings joined by .

<base64url(JSON payload)>.<base64url(Ed25519 signature)>

Payload structure:

{
"mid": "a3f1b2c9d4e5f6a7b8c9d0e1f2a3b4c5",
"name": "FreeRave",
"plan": "max",
"exp": 1782000000
}
  • mid — the machine fingerprint (32 hex chars, SHA-256 of hardware identifiers)
  • name — displayed to the user on activation
  • plan — tier identifier ("max" = Pro)
  • exp — Unix timestamp expiry; server controls token lifetime

The server signs the raw JSON bytes with its Ed25519 private key. The client verifies using the hardcoded public key, then checks expiry and machine binding. All three must pass.

The Verification Function

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
# Dynamic Base64 padding - JWT/URL-safe Base64 strips trailing '='
# The expression (-len(s) % 4) gives: 0 if already padded, else 1-3
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 InvalidSignature on tamper
payload = json.loads(payload_bytes.decode())
# Gate 1: expiry
if payload.get("exp", 0) < time.time():
return False, {}
# Gate 2: machine binding
if payload.get("mid", "") != self.generate_machine_id():
return False, {}
return True, payload
except Exception:
return False, {}

Three gates in sequence. Fail any one, the token is rejected and local activation is cleared. The except Exception: return False, {} catch-all is intentional — any unexpected error (missing field, malformed base64, truncated token) is treated as a verification failure, not as a crash.

On the padding trick: URL-safe Base64 used in JWTs often strips trailing = padding. (-len(s) % 4) is a modular arithmetic shorthand that adds exactly the right number: if len(s) % 4 == 0, adds 0; otherwise adds 4 - (len(s) % 4). This avoids an if/else chain and handles all cases correctly.

Machine Fingerprinting

The machine ID is a 32-character hex string that must be stable across reboots, unique enough to distinguish machines, and cross-platform. The approach is a weighted combination of hardware identifiers, hashed to a fixed-length output:

@staticmethod
def generate_machine_id() -> str:
factors: dict[str, str] = {}

# Platform-specific hardware ID - primary anchor
try:
if platform.system() == "Linux":
# /etc/machine-id: written once at OS install by systemd
# Survives: reboots, kernel updates, VPN, hostname changes
# Does not survive: full OS reinstall
with open("/etc/machine-id") as f:
factors["mid"] = f.read().strip()
elif platform.system() == "Windows":
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography"
)
# MachineGuid: equivalent to /etc/machine-id on Windows
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 - supplement if primary unavailable (e.g., container)
try:
factors["cpu"] = str(os.cpu_count() or 0)
except:
pass
factors["os"] = platform.system()
# MAC address - only if real hardware, not randomized
try:
mac_int = _uuid.getnode()
# Bit 40 (the "locally administered" bit) is 1 for randomized MACs
# Randomized MACs change on every boot - useless as a stable anchor
if not (mac_int >> 40) & 1:
factors["mac"] = hex(mac_int)[2:].upper().zfill(12)
except:
pass
# Sort for determinism regardless of which factors are available
factor_str = "|".join(f"{k}:{v}" for k, v in sorted(factors.items()))
return hashlib.sha256(factor_str.encode()).hexdigest()[:32]

Why sorted(factors.items())? Dict insertion order in Python 3.7+ is deterministic, but only if the same keys are always inserted in the same order. If, for example, the MAC address is unavailable on one run (kernel randomization), the dict has fewer keys and the order changes. Sorting by key ensures the concatenated string — and therefore the hash — is identical regardless of which optional factors are present.

The MAC randomization filter: Modern Linux privacy kernels and NetworkManager configurations use MAC address randomization. Bit 40 of the MAC integer is the “locally administered” bit — set to 1 for locally generated (randomized) addresses. Including a randomized MAC in the fingerprint would cause activation to break after every reboot. The bitmask check (mac_int >> 40) & 1 filters these out.

Container environments: In Docker or LXC, /etc/machine-id may not exist. The try/except blocks ensure graceful degradation — if the primary identifier is missing, the fingerprint falls back to CPU count + OS string + MAC (if available). This is a weaker fingerprint but still functional.

The Activation Flow

User clicks "Activate Pro"


LocalAuthManager.start()
→ Binds to 127.0.0.1:0 (port 0 = OS assigns ephemeral port atomically)
→ Generates 32-byte CSRF state token via secrets.token_urlsafe(32)


webbrowser.open(
"https://dotsuite.vercel.app/en/dashboard/dotscramble/auth?port=PORT&state=STATE"
)


User authenticates in browser, clicks "Activate"


Dashboard: POST http://127.0.0.1:PORT/callback
Body: { "key": "<api_key>", "state": "<state_token>" }


AuthHandler.do_POST()
→ Content-Length check: reject if > 4096 or ≤ 0 (OOM DoS prevention)
→ secrets.compare_digest(received_state, expected_state) (CSRF check)
→ on_key_received(api_key) called


LicenseManager.verify_and_activate(api_key)
→ POST https://dotsuite-core-production.up.railway.app/v1/license/activate
Authorization: Bearer <api_key>
Body: { "machine_id": "a3f1b2..." }


Server validates key, checks activation count (≤ 2 machines), signs token
Returns: { "license_token": "<payload>.<sig>", "name": "FreeRave" }


Client: _verify_token(token)
→ Signature valid (Ed25519)
→ Not expired (exp > time.time())
→ Machine matches (mid == generate_machine_id())


Token + API key saved to SQLite
Background recheck thread started (24h cycle)

The Local HTTP Server as a Security Boundary

The browser-to-desktop callback is not a direct function call — it goes through a local HTTP server. This is not over-engineering. The HTTP boundary enforces:

  1. CSRF protection: The state token is generated fresh on each activation attempt and compared using secrets.compare_digest() (constant-time, timing-attack resistant). Any page other than the DotSuite dashboard that tries to POST to the local server will fail state verification.
  2. Payload size cap: The server rejects any POST body over 4096 bytes with HTTP 413. An API key is at most a few hundred bytes — there’s no legitimate reason for a larger payload.
  3. Port 0 binding: Binding to port 0 lets the OS assign an available ephemeral port atomically. The alternative — picking a fixed port and checking if it’s free — is a TOCTOU race condition. Port 0 eliminates the race.
# Constant-time comparison — prevents timing oracle on state token
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() matters here because the comparison happens over localhost HTTP. A timing oracle on a 32-character token over loopback is a marginal attack in practice, but it costs nothing to use the correct primitive.

Offline Grace and Startup Verification

After activation, the token is cached in SQLite. Every subsequent startup re-verifies locally — no network call required:

def __init__(self, db_manager):
# ...
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, the clock was moved backward
# This could be used to prevent token expiry - reject it
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()

Clock rollback attack: If a user manually sets their system clock backward, time.time() returns a value earlier than the stored last_license_check_time. The check current_time < last_verified detects this and immediately deactivates. The stored timestamp acts as a ratchet — it can only move forward.

Get freerave’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

The 7-day grace period is implicit in the token’s exp field. The server sets expiry 7 days from activation (or last successful recheck). An offline machine can use the software freely for 7 days before the token expires and the local check fails.

Background Revocation Detection

Local verification is fast and works offline, but it cannot detect revoked keys. A key that was refunded, chargebacked, or administratively revoked would continue to pass local Ed25519 verification until its token expires.

The background recheck thread addresses this:

def _schedule_background_recheck(self):
self.stop_recheck_event.clear()
t = threading.Thread(
target=self._recheck_loop,
daemon=True, # dies with the main process
name="license-recheck"
)
t.start()

def _recheck_loop(self):
while not self.stop_recheck_event.is_set():
# Event.wait(timeout) instead of time.sleep():
# wakes immediately on stop_recheck_event.set(), enabling clean shutdown
is_stopped = self.stop_recheck_event.wait(_RECHECK_HOURS * 3600)
if is_stopped or self.stop_recheck_event.is_set():
break
self._silent_recheck()

The recheck itself is silent — no UI notification, no interruption:

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:
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() # Server returned invalid token - deactivate
except urllib.error.HTTPError as e:
if e.code in (401, 403, 404):
self._clear_local() # Explicit server rejection - deactivate immediately
# 5xx, connection timeout: server down or network unavailable
# Do NOT deactivate - user gets full 7-day grace period
except Exception:
pass # Any other error: fail open, try again next cycle

The error handling is the most important part of this function. Three distinct outcomes:

HTTP 401/403/404 — the server explicitly rejected the request. The API key is invalid, revoked, or deleted. Deactivate immediately, clear all local credentials.

HTTP 5xx / connection error / timeout — the server is temporarily unavailable. Do nothing. The local token is still valid (Ed25519 + expiry check passed on startup). The user retains full access for the remainder of the token’s TTL. This is the correct behaviour: a server outage should never disrupt legitimate users.

Valid fresh token returned — refresh the cache. The new token extends the TTL another 7 days, so an online user’s activation effectively never expires.

The Feature Gate

Every Pro-only code path in the UI calls one property:

@property
def is_max_activated(self) -> bool:
# In-memory boolean — no I/O, no crypto
with self.lock:
return self._is_max

This is intentionally the simplest possible check. The reasoning is performance: is_max_activated is evaluated on every frame of the real-time preview slider. Token verification using Ed25519 takes 0.5–2ms per call. At 60fps, continuous verification would consume up to 120ms/second on crypto alone — enough to cause visible frame drops.

The expensive verification happens once on startup and once per 24-hour background cycle. The in-memory boolean is the hot path, protected by a standard mutex for thread safety (the background recheck thread writes to _is_max from a different thread).

Feature gating at detection mode selection:

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"'{mode.replace('_', ' ').title()}' detection requires DotScramble Pro."
)
self.detection_mode.set("face") # Reset to free tier default

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

Metadata operations (EXIF spoofing, scrubbing) are Pro-only features. The gate is evaluated at save time, not at button click — preventing race conditions where the UI state and license state could diverge.

Source Protection via Cython

Python source is trivially patchable. Given _is_max:

# Original
return self._is_max

# Patched in 3 seconds
return True

Mitigation: compile license_manager.py to a native shared library using Cython. The build script:

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"],
)
]
setup(
name="dotscramble_license",
ext_modules=cythonize(
extensions,
compiler_directives={
"embedsignature": False, # Strip readable function signatures
"emit_code_comments": False, # No source hints in binary
"language_level": "3",
"boundscheck": False,
"wraparound": True,
},
),
)

Build:

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

The original .py is removed from the distribution. Python's import system finds the .so automatically — no import path changes required.

The compiled binary is a real ELF shared library. Patching it requires disassembling x86_64 machine code, locating the boolean return instruction in the JIT-compiled method, and modifying it in a hex editor or with a binary patcher. This is not impossible, but the skill floor is substantially higher than editing a Python file. Combined with the Ed25519 enforcement — which cannot be bypassed by patching the client — the effort/reward ratio for cracking exceeds the cost of a legitimate license for most users.

Platform note: Cython produces platform-specific binaries. cpython-313-x86_64-linux-gnu.so runs only on Linux x86_64 with CPython 3.13. Cross-platform distribution requires separate build steps on Linux, Windows, and macOS — standard practice for any CI pipeline with multi-platform targets.

Known Limitations and Attack Surfaces

The public key is in the binary. This is by design — the public key is meant to be public. It cannot be used to forge tokens. An attacker who extracts it gains nothing.

Container and VM bypass. In environments where /etc/machine-id can be freely set (Docker, LXC, VMs with snapshot/rollback), the machine fingerprint can be cloned. Mitigation: the server-side 2-machine activation limit constrains this — each cloned ID counts as a new activation.

Binary patching still possible. Cython raises the bar but doesn’t eliminate it. A sufficiently motivated attacker with a disassembler can still find and patch the boolean return in the .so. The actual security comes from Ed25519 — even a patched client that claims is_max = True can't forge a valid token, so server-enforced features (recheck, machine limit) remain intact.

Clock rollback detection has a window. The check compares time.time() against a stored SQLite timestamp. An attacker who modifies the SQLite database before running the app could plant a fake last_license_check_time far in the future, then roll the clock back while keeping time.time() > stored_time. Mitigation: the SQLite database is in the user's home directory and not encrypted — for high-value deployments, a tamper-evident store (e.g. macOS Keychain, Linux Secret Service) would be more appropriate.

No mutual TLS. The /v1/license/activate and /v1/license/recheck calls use standard HTTPS. The server's certificate is verified by the system CA store. A machine with a compromised CA store (corporate MITM proxy, malware-installed root) could intercept and modify these responses. This is a general HTTPS limitation, not specific to this design.

Design Decisions — What Was Intentionally Excluded

Online-only enforcement. Requiring a network check on every launch would break offline use. Planes, hotel Wi-Fi captive portals, corporate proxies, server downtime — all of these would result in failed launches for paying customers. The 7-day grace period with background recheck is the correct trade-off.

Aggressive telemetry. The only data sent to the server is the machine ID and API key. No usage statistics, no feature telemetry, no file names, no behavioral data. DotScramble is a privacy tool. Telemetry would be self-contradicting.

Obfuscating the public key. Pointless security theatre. The public key is designed to be known. Wrapping it in XOR or base64-of-base64 adds no security and significant code smell.

Binding to MAC address only. MAC addresses are trivially spoofed on Linux (ip link set dev eth0 address XX:XX:XX:XX:XX:XX) and may be randomized by default. Using /etc/machine-id as the primary anchor is more stable and harder to spoof without root access.

Summary

DotScramble’s license system implements a minimal but sound cryptographic design:

  • Ed25519 asymmetric signing ensures tokens cannot be forged without the server’s private key, regardless of how thoroughly the client binary is reversed.
  • Weighted machine fingerprinting using OS-level hardware identifiers provides stable, cross-platform machine binding that survives network changes and VPN usage.
  • SQLite-cached token with startup re-verification enables offline use without any network dependency on launch.
  • 7-day TTL with 24-hour background recheck gives legitimate offline users a comfortable grace window while ensuring revoked keys are detected within a day of the machine coming online.
  • Cython compilation raises the practical bar against source patching, complementing but not replacing the cryptographic enforcement.

The design is appropriate for a solo-developed privacy tool. It is not appropriate for enterprise software protecting high-value IP — that use case warrants hardware dongles, TPM attestation, or a full code-signing/attestation pipeline.


文章来源: https://infosecwriteups.com/asymmetric-signing-machine-fingerprinting-and-offline-grace-periods-building-a-license-system-d8dd5678e1cb?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh