Skip to content

Threat model

This page covers what Keylight defends against, what it explicitly does not, and the boundaries of each mitigation. Read it before shipping a Keylight-backed app so you understand what the system does and doesn’t protect.

Keylight is a commercial licensing system, not a DRM / anti-tamper system. What it does:

  • Enforce server-side entitlement (activation caps, license state, lifecycle)
  • Isolate accounts so one account cannot observe or mutate another
  • Issue signed offline leases so an app can verify entitlement without a network round-trip on every launch
  • Keep private signing keys off the client - only public keys ship in apps

What it explicitly does not do:

  • Hide your app’s gating logic - a patched binary can ignore LicenseManager.isEntitled
  • Prevent reverse engineering of the SDK or of your app - the SDK source is public and the compiled binary is inspectable
  • Provide cryptographic trial protection - the XOR file is obfuscation, not encryption (see Local storage model)
  • Provide strong clock-rollback protection - see Clock manipulation for what the 1-hour heuristic does and does not cover

If your product needs DRM-grade protection, layer an obfuscation/anti-tamper toolchain on top. Keylight stops casual abuse and enforces commercial limits; it is not the last line of defense against a skilled attacker.

Keylight is designed to raise the economic floor for license abuse in commercial desktop/mobile apps, not to defeat a determined reverse engineer. The expected adversaries are:

  1. Casual users who reinstall, delete stored state, or edit files to reset a trial. (Mitigated — see Server-side trial memory.) Cloning a VM is the deliberate exception: a shared machine id is far more often an imaged laptop fleet than a cheat, so it grants and alerts rather than denying.
  2. Key sharers who buy one license and try to activate it on many devices. (Mitigated via per-key activation caps enforced server-side.)
  3. Reverse engineers with a disassembler and time. (Not mitigated. If this is your threat model, you need a different product.)

The SDK persists two pieces of local state - the trial start timestamp and the activated license (key + instance id + lease). Since 0.6.0 the default backend (storage: .encryptedFile()) is:

  1. Device-bound encrypted file (authoritative). A KL01 magic followed by an AES-256-GCM sealed box at <Application Support>/.keylight/<tenantId>/.kl. The key is derived with HKDF-SHA256 from the device identity (IOPlatformUUID on macOS), with tenantId and productId folded into the HKDF info. The Keychain is not read or written, so there is no first-launch permission prompt.
  2. Keychain (opt-in). storage: .encryptedFile(keychainMirror: true) keeps a synchronized recovery copy scoped by configuration.keychainServicePrefix; storage: .keychain restores the pre-0.6.0 arrangement where the Keychain is authoritative. The SDK also falls back to the Keychain automatically where no Application Support directory or stable device ID exists, so a user is never locked out by a storage failure.

What the encryption does and does not buy you. The GCM authentication tag means a hand-edited blob fails to open rather than decoding to attacker-chosen content, and the device-bound derivation means a blob copied to a second Mac does not open there at all. It is not a defense against a reverse engineer running inside the process, who can read the plaintext after the SDK opens it. Note that pre-0.6.0 builds used an XOR-obfuscated file whose key was fixed in the SDK source and recoverable with strings; that format is still readable for migration and is re-sealed into the current format on first load.

Tampering is ultimately caught by a mechanism that does not depend on the storage layer at all: every lease is Ed25519-signed by Keylight and re-verified on every read (LeaseVerifier.verify). A user who edits expiration dates, instance IDs, or status flags will fail signature verification and flip to .invalid even if they get past the file’s encryption. Copying state to another machine is possible in principle but the server’s activation cap will reject the second activation when validation runs.

What this means in practice:

  • Casual reset of the trial (deleting the stored blob) still works locally, but since the trial ledger it no longer earns a second trial. The server remembers that this device started one — see Server-side trial memory below.
  • Forging an entitlement by editing the stored license is not possible without the Ed25519 private key.
  • Copying a license file between machines does not work on the default backend, because the file key is derived from the device identity. On a Keychain-authoritative install, moving the item is possible in principle, but the lease inside is bound to a specific instanceId; on next validation, the server will reject the second device once the activation cap is exceeded.
  • High-value functionality should require an activated license, not a trial file. Activated state is backed by a server-signed lease with cryptographic integrity.

The trial clock is local state, which makes it free to reset. Since platform 0.25.0 the server also keeps a record: one row per (tenant, product, machine_hash) noting when that device first started a trial. A device that wipes its local store and starts over is recognised on its next beacon, so “delete the app’s data and get a fresh trial” no longer works on an unmodified client.

machine_hash is already on the wire for every SDK, so this needed no client change and protects all of them at once.

A wrongly granted second trial costs one trial’s usage. A wrongly denied one costs a customer who never converts, and denials land on legitimate users in bulk rather than on attackers. Every rule leans the same way:

  • Keyed on the full (tenant, product, machine_hash) triple — trialling one of your apps never denies a trial for another.
  • Entries expire after ~18 months. Someone who evaluated two years ago and came back is a new prospect, not a cheat.
  • A device in the middle of its genuine first trial is never counted as having spent one.
  • A missing machine_hash always grants. The client omits the field rather than faking one, so absence is honest.
  • A shared hash grants. Cloned VM images and imaged laptop fleets share a machine id. An implausible number of installs behind one hash raises an operator alert and grants the trial — it never locks out. Image 200 laptops and all 200 get their trial.

It does not stop a patched client. The device state and machine hash arrive in the request body: a modified binary that omits the hash, reports itself as free-tier, or sends a fresh random hash per install is never recorded, and nothing here will catch it. This raises the cost of casual trial farming by unmodified clients — the common case — and that is the whole claim.

Anything of real value should sit behind an activated license, which is backed by a server-signed lease, not behind a trial.

The trial length and free-tier switch are served from your dashboard, which makes the response worth attacking: something on the network path answering {"trial_duration_days": 3650} grants an unlimited trial without touching the binary.

GET /config therefore returns an Ed25519 signature over the settings, bound to your tenant, your product, and a 24-hour window, verifiable against the public keyset. If you are writing your own client, verify it — and reject a trial_duration_days that arrives without a valid signature, or stripping the signature becomes the attack.

License keys are generated once at mint time, returned in the Stripe webhook response, and never persisted by Keylight in raw form. Storage holds only:

  • A SHA-256 hash of the full key, used for lookup and activation checks
  • A masked display form like TEST-A1B2-****-**** for dashboard display
  • Your app’s 4-char prefix

A storage compromise therefore cannot leak activatable keys. An attacker could learn which keys exist (by hash) and how many activations each has, but cannot activate anything without the original raw key.

The tradeoff: in manual Stripe mode you handle email delivery yourself, and if you miss the webhook response the key is unrecoverable and must be reissued.

Keylight hard-enforces two per-account quotas:

  • maxActiveInstances - the total number of licensed instances active at any one time, across all of your apps. An account on the Free plan has 5 active instances total, not 5 per app. Enforced atomically on every /activate call; a breach returns HTTP 402.
  • maxMonthlyApiCalls - a monthly rolling window of hot-path API calls (/activate + /validate). A breach returns HTTP 429 with a Retry-After header set to the seconds remaining until the next calendar-month (UTC) reset.

Fields that are not gated:

  • maxLicenses - this field is a safety ceiling (100,000 on every plan today) and is not checked at mint time. License creation itself is not rate-limited; the meaningful commercial quota is maxActiveInstances, since an unused key does not consume any resources.

A canceled account retains quota access during the 7-day grace window. A suspended account retains /validate access so already-active instances can keep rotating leases, but /activate is blocked.

CORS is scoped only to the public API routes (/:tenantId/:productId/activate, /validate, /deactivate). The admin dashboard is same-origin and does not set CORS headers - it is not intended to be called cross-origin.