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:
- Root key (
root.pem): a long-lived RSA key pair. The public half ships with the SDK. Never rotates. - Signing cert (
signing-cert.json): contains the current signing public key, akid(key ID), and arootSignature(the root private key signs the signing public key). - Token signing: the server signs JWTs with the current signing private key. The
kidis embedded in the JWT header. - Client verification: the SDK fetches the signing cert from
/getSigningKey, verifiesrootSignatureagainst the baked-in root public key, caches the verified signing key locally, and uses it to verify JWT tokens.
Rotation flow:
- Generate a new signing key pair.
- Sign the new public key with the root private key to produce a new
rootSignature. - Update
signing-cert.jsonon the server (new kid, new public key, new rootSignature). - Deploy. New tokens are signed with the new key and carry the new
kid. - Clients automatically fetch the new signing cert on next
/activateor/validatecall. - 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.
- Webhook arrives with
purchaseIdin the payload. - Server queries
licensescollection for a document wherepurchaseIdmatches. - If found: returns
200 OKwithout creating a duplicate license. - If not found: creates the license and stores
purchaseIdon 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.
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.