Skip to content

Security


JWT Signing: RS256 with Key Chain

All tokens are signed using RS256 (RSA + SHA-256). The server never shares private keys with clients; clients only need the public key to verify tokens offline.

Key Chain (Zero-Downtime Rotation)

The system uses a two-level trust chain for key rotation without downtime:

Root Key (baked into SDK, never rotates)
    |
    ├── verifies --> Signing Cert A (current)
    |                   └── signs --> JWT tokens
    |
    └── verifies --> Signing Cert B (next rotation)
                        └── signs --> JWT tokens

How it works:

  1. Root key (root.pem): a long-lived RSA key pair. The public half ships with the SDK. Never rotates.
  2. Signing cert (signing-cert.json): contains the current signing public key, a kid (key ID), and a rootSignature (the root private key signs the signing public key).
  3. Token signing: the server signs JWTs with the current signing private key. The kid is embedded in the JWT header.
  4. Client verification: the SDK fetches the signing cert from /getSigningKey, verifies rootSignature against the baked-in root public key, caches the verified signing key locally, and uses it to verify JWT tokens.

Rotation flow:

  1. Generate a new signing key pair.
  2. Sign the new public key with the root private key to produce a new rootSignature.
  3. Update signing-cert.json on the server (new kid, new public key, new rootSignature).
  4. Deploy. New tokens are signed with the new key and carry the new kid.
  5. Clients automatically fetch the new signing cert on next /activate or /validate call.
  6. Old tokens (signed with the previous key) expire naturally within 30 days.

No downtime: clients that already have a cached signing key continue verifying old tokens. On their next online check, they fetch the new cert and cache it.

Server-side vs client-side

Server-side (verifyToken in utils.ts): currently uses a single public.pem. Does not look up keys by kid. This means the server's public.pem must be updated alongside rotation.

Client-side (Python SDK): full chain verification is implemented. The SDK fetches /getSigningKey, verifies the cert's rootSignature against the baked-in root key, and caches the result to disk. Falls back to a bundled public.pem if the server is unreachable.

Public Endpoint: /getSigningKey

Returns the current signing certificate. Cached for 1 hour (Cache-Control: public, max-age=3600).

{
  "kid": "key-2026-04",
  "publicKey": "-----BEGIN PUBLIC KEY-----\n...",
  "rootSignature": "base64-encoded-signature",
  "algorithm": "RS256",
  "createdAt": "2026-04-01T00:00:00Z"
}

Machine Fingerprinting

Tokens are hardware-bound. The machine fingerprint is a SHA-256 hash of:

  • MAC address (primary interface)
  • CPU identifier (platform.processor())
  • Hostname
  • Platform info (OS + architecture)
  • Disk serial number
  • Product-specific salt

The fingerprint is embedded in the JWT machineFingerprint claim and verified server-side on /activate and /validate. On the client side, the cached token file is XOR-obfuscated using the fingerprint as the key, so copying the file to a different machine renders it unreadable.

This prevents:

  • Token file copying between machines (obfuscation key differs).
  • VM cloning attacks (cloned VMs may share hardware IDs, but disk serial and hostname often differ).
  • Sharing tokens via cloud sync (different machine = different obfuscation key).

Fingerprint stability

Hardware changes (new NIC, new disk, hostname change) will change the fingerprint and invalidate the cached token. Users must re-activate after significant hardware changes.


Anti-Piracy Detection

The server runs three active detectors on every activation and validation event. Three additional violation types are defined in the type system for future use. Detectors operate over rolling time windows and flag licenses without blocking them immediately.

Active detectors: geo_spread, machine_churn, concurrent_anomaly

Defined but not yet active: fingerprint_instability, velocity_spike, known_bad_actor

Geo Spread Detection

Trigger: 3 or more distinct countries in a 7-day rolling window.

Legitimate single-user licenses do not activate from three different countries in one week. This pattern indicates credential sharing or resale.

Day 1: Activation from Germany     (1 country)
Day 3: Activation from USA         (2 countries)
Day 5: Activation from Brazil      (3 countries) -> FLAG

Site licenses are exempt

Site licenses skip geo detection entirely, since a large studio may legitimately have offices in multiple countries.

Machine Churn Detection

Trigger: 5 or more new machine activations in a 7-day rolling window.

A license being activated on many new machines in quick succession indicates key sharing.

Day 1: Machine A activated
Day 2: Machine B activated
Day 3: Machine C activated
Day 4: Machine D activated
Day 5: Machine E activated  -> FLAG (5th new machine in 7 days)

Concurrent Anomaly Detection

Trigger: more simultaneous active IPs than max_machines allows.

Checked on every validation. If a per-machine license (max 2 seats) is validating from 5 different IPs at the same time, the license is flagged.


Threat Escalation

Flags do not result in immediate revocation. The server uses a staged escalation model:

Level Status User Experience Recovery
clean active Normal operation N/A
warning active No change for user; admin notified Automatic after investigation window
degraded degraded Nag dialog on every launch; tool still runs Admin clears flag
suspended suspended Hard block after grace period; must contact support Admin unsuspends
revoked revoked Permanent hard block Admin re-issues new license

Escalation thresholds (based on unresolved violations):

totalSeverity >= 1                          -> warning  (level 1, status: active)
totalSeverity >= 3  OR  recentCount >= 2    -> degraded (level 2, status: degraded)
totalSeverity >= 6  OR  recentCount >= 3    -> suspended (level 3, status: suspended)

recentCount is the number of unresolved violations detected in the last 30 days.

Escalation path:

clean
  |
  | (severity sum >= 1)
  v
warning  (no user impact; admin can investigate)
  |
  | (severity sum >= 3 OR 2+ violations in 30 days)
  v
degraded  (nag dialog, tool still usable)
  |
  | (severity sum >= 6 OR 3+ violations in 30 days)
  v
suspended  (hard block after offline grace window)
  |
  | (manual admin review)
  v
revoked  (permanent, new license required)

False positives

VPN users and travelers may trigger geo spread. Admins can clear flags and exempt specific licenses from geo detection via the admin API.


Rate Limiting

Only the /activate endpoint has rate limiting. All other endpoints have no rate limiting applied.

Endpoint Limit
/activate 15 attempts per hour per license key (in-memory)
All other endpoints No rate limiting

Rate limiting on /activate is keyed by the licenseKey field in the request body, not by IP. The limit is tracked in-memory (not persisted), so it resets on function cold starts. Clients that exceed the limit receive 429 Too Many Requests.


Webhook Deduplication

Deduplication applies to purchase.completed webhook events only. The server checks whether a license with the same purchaseId already exists in Firestore before creating a new one.

  1. Webhook arrives with purchaseId in the payload.
  2. Server queries licenses collection for a document where purchaseId matches.
  3. If found: returns 200 OK without creating a duplicate license.
  4. If not found: creates the license and stores purchaseId on it.

There is no TTL and no event ID hashing. The dedup key is the purchaseId value itself, persisted indefinitely on the license document. Only purchase.completed events go through this path; other event types (refund, chargeback, etc.) do not perform a dedup check.


SDK Version Tracking

The /activate endpoint reads the x-sdk-version request header and stores it on the activation record in Firestore. This allows tracking which SDK version each machine is running.

x-sdk-version: python/2.0.0

The header is optional. If absent, no version is recorded. min_sdk_version enforcement is not implemented: the server does not block or warn based on SDK version. /validate and other endpoints do not read or record the SDK version header.