Carcha Seal documentation

Carcha Seal was previously named LicenseKit. The npm packages are now @carchalabs/seal-* (the @carchalabs/licensekit-* ones are deprecated) and the SDK class is Seal; LicenseKit stays as an alias. License keys (LK-), leases and offline files keep their format, so existing apps and license keys keep working.

Concepts

Product
The app you license. Its code, for example example-studio, is what the SDK sends as product.
Policy
The defaults for new licenses of a product: seats, duration in days or perpetual, how long updates are included, how often the app checks in, how long it works offline after a missed check-in, whether offline activation is allowed and how long its license file lasts, and features. Every product has a default policy.
License
What a customer buys. It has a key, a policy, seats, an optional expiry, an optional end of updates, features and a status: active, suspended or revoked. Values given when the license is created override the policy.
License key
What the customer types, for example LK-7Q2M-9XPA-K4TD-W8NE. It uses the Crockford Base32 alphabet, so I, L, O and U never appear; input ignores case, spaces and hyphens and reads I and L as 1 and O as 0. The server stores only a SHA-256 hash, so a key is shown once.
Device and seat
Activating a license on a computer takes one seat. The SDK identifies the computer by a SHA-256 hash of the operating system's machine ID and the product code; the raw ID never leaves the computer. Activating again on the same computer reuses its seat.
Lease
The signed statement the server returns on every activation and check-in. The app stores it and verifies it offline.
Check-in and offline grace
Each lease has a check-in deadline set by the policy. After the deadline the app keeps working for the policy's offline grace period, but never past the license expiry. Then it must reach the server.
Revocation window
Suspending or revoking a license takes effect at the device's next check-in. A device that stays offline keeps working until its check-in deadline plus the grace period.
Offline activation
For a computer that never reaches the server. The app writes a request file; on a connected device the customer uploads it at /offline on the license server (or the vendor in the console, Offline files) and gets a license file that works only on that computer, for the policy's offline days and never past the license expiry. Seat limits apply as online. To give the seat back, the app writes a deactivation file and the vendor uploads it in the console. Off unless the policy sets offline days.
Clock check
The SDK remembers the latest time it saw. If the clock is more than a day earlier than that, or a lease looks issued in the future, the state is clock_suspect.

The lease

A lease is a compact JSON Web Signature (RFC 7515) with the Ed25519 algorithm (RFC 9864). The header has alg, typ set to licensekit-lease+jwt, and kid, the RFC 7638 thumbprint of the signing key. Verifiers accept only Ed25519, check the type, and choose the key by kid.

Lease claims
ClaimMeaning
issIssuer, licensekit
subLicense ID
audProduct code
jtiID of this lease
iat, nbfIssued at, not before (Unix seconds; 5 minutes of clock skew allowed)
expLicense expiry; absent for perpetual licenses
revCheck-in deadline
grcOffline grace in seconds
mntEnd of updates, if the policy tracks it
fpDevice fingerprint hash
featFeatures
cusCustomer name

Verification fails with one of these reasons, checked in this order:

malformed unsupported_algorithm wrong_type unknown_key bad_signature wrong_issuer wrong_product wrong_device not_yet_valid expired revalidation_required

SDK

The SDK runs in the Electron main process or in plain Node.js 22.13 or later.

npm install @carchalabs/seal-electron
const kit = new Seal({
  serverUrl: 'https://licenses.example.com',
  product: 'example-studio',
  publicKey: [currentKeyPem, nextKeyPem],
  storageDir: app.getPath('userData'),
  appVersion: app.getVersion(),
  secureStore: {
    encrypt: (s) => safeStorage.encryptString(s),
    decrypt: (b) => safeStorage.decryptString(b),
    isProtected: () => process.platform !== 'linux' || safeStorage.getSelectedStorageBackend() !== 'basic_text',
  },
});

Options

OptionMeaning
serverUrlLicense server URL
productProduct code
publicKeyOne PEM public key or a list; a lease is accepted if its kid matches one of them. An unusable key throws when the SDK is created.
storageDirWhere license.dat is kept
appVersionSent to the server on activation and check-in
secureStoreEncrypts license.dat; encrypt and decrypt may return promises; isProtected reports whether the storage is really protected
graceDaysUsed only when a lease carries no grc claim; default 7
requestTimeoutMsDefault 8000
allowFallbackFingerprintDefault false. When the operating system's machine id cannot be read (after 3 attempts), use hostname, CPU and network card instead. Without it the state is invalid with reason machine_id_unavailable, so the same computer never takes a second seat under a different fingerprint.

Methods

activate(licenseKey, machineName?)
Takes a seat for this computer and stores the lease. Throws LicenseError with the server's error code, network, clock_suspect, or a lease failure reason if the returned lease cannot be used.
check()
Checks in with the server and stores the new lease. If the server cannot be reached, decides from the stored lease. Call it at startup and every few hours.
deactivate()
Frees this computer's seat and deletes the stored lease.
offlineRequest(licenseKey, machineName?)
Offline activation, step 1: returns the request file (JSON text) for a computer that cannot reach the server. It holds what activate would send: the license key, the fingerprint and hashed hardware parts, never raw serial numbers.
activateOffline(licenseFile)
Step 2: loads the license file issued for that request. It is verified like an online lease and saved; from then on check() decides from it without calling the server. Throws LicenseError: invalid_file, wrong_product, clock_suspect or a lease failure reason such as wrong_device. A new license file renews it; activate() switches the computer to online check-ins.
deactivateOffline()
Removes the license from this computer and returns the deactivation file (JSON text). The seat is freed when the vendor uploads it in the console. Save the file before telling the user the license is removed.
decrypt(feature, data)
Decrypts data you encrypted for a feature with its content key (encryptForFeature in @carchalabs/seal-core; the key is under the product's feature keys). Works offline from the stored lease, which must be valid or in its grace period, include the feature and carry its key. With @carchalabs/seal-hwid 0.2 the native module decrypts, so the key never reaches JavaScript. Throws LicenseError: unlicensed, a lease failure reason, clock_suspect, feature_not_licensed or decrypt_failed.
requireEncrypted(feature, file)
Envelope: loads a CommonJS module you encrypted for a feature and returns its exports, like require. It is decrypted with the lease's feature key (as decrypt) and compiled from memory, so the code never exists as a plain file. Relative requires inside it load encrypted neighbours: ./x finds x.lke, x.js.lke or x/index.js.lke; anything else goes to Node. Loaded once per process. Throws the same errors as decrypt.
Seal.hasFeature(state, feature)
True only for valid and grace states that include the feature.

Encrypting data and code for a feature

A check in your code can be patched out; data your app cannot use without the license cannot. Create a feature key in the console (Products, Feature keys), encrypt part of your app with it at build time, and open it at run time with decrypt:

SEAL_FEATURE_KEY=<key from the console> \
  npx -p @carchalabs/seal-core seal-encrypt --feature pro dist/pro.js dist/pro.js.lke

// in the app
const data = await kit.decrypt('pro', await readFile(join(__dirname, 'pro.json.lke')));

For code (Envelope), bundle the paid part of your main process into CommonJS, encrypt the file or its folder, and load it instead of requiring it:

SEAL_FEATURE_KEY=<key> npx -p @carchalabs/seal-core seal-encrypt --feature pro dist/premium dist/premium

// in the main process
const premium = await kit.requireEncrypted('pro', join(__dirname, 'premium', 'index.js.lke'));
premium.exportReport(...);

Ship only the .lke files. Code for the renderer can be decrypted the same way in the main process and served to the page from your app's protocol handler.

The key is read from SEAL_FEATURE_KEY or --key-file, never from the command line. Data is LKE1, a nonce and AES-256-GCM with the feature as associated data. A lease carries the content key of each licensed feature wrapped for that license key and device, so the data opens only there, also offline. With @carchalabs/seal-hwid 0.2 the device also sends an X25519 public key derived from its hardware (system UUID, motherboard, system disk and OS install id), and the keys are wrapped for it: a copied license file opens nothing on another machine. After one of those parts changes, the data opens again from the next successful check-in. A licensed user can still copy what they have decrypted, including code from memory: this does not hide code from its licensed users, it stops an app with the check removed from having the feature without a license.

States

StatusMeaning
validThe lease is valid now.
graceThe check-in deadline has passed and the computer is offline; the app works until revalidateBy.
invalidThe license cannot be used; reason is a lease failure or the server's error code. A refusal from the server (suspended, expired, device removed, sdk_update_required) is kept while offline, and the next successful check-in clears it without the key.
unlicensedNo license is stored on this computer.
clock_suspectThe clock looks wrong; reason is clock_rollback or lease_from_future.

Every state also has offline, storageProtected, features and expiresAt, and, when known, customer, maintenanceUntil, revalidateBy, keyHint and a message for the user.

valid and grace states can also have notices from the last check-in, kept while offline, e.g. { "code": "sdk_update_recommended", "minimum": "0.2.0", "requiredFrom": "2026-11-01T00:00:00.000Z" }: this app's Carcha Seal SDK must be updated by that date, after which the server refuses it with sdk_update_required. The SDK shows nothing itself; your app decides how to tell its users.

Integration guide

A license check in your own code can be found and removed. These steps make that harder and make it pay off less, following what established licensing vendors document for their own SDKs.

  1. Activate once, check at every start. Call activate when the user enters a key, not on every launch. Call check() at startup and every few hours; it works offline from the signed lease and only needs the network to renew it.
  2. Put real feature code or data behind decrypt. hasFeature answers a question; decrypt returns something your app cannot run without. Encrypt a module, model, template or asset per feature at build time (Encrypting data for a feature) so an app with its checks removed still has nothing to run.
  3. Check in more than one place. Ask for the state or decrypt where the feature is used, not once at startup into a single flag that one change can flip.
  4. Handle every state and reason. Branch on status, show message to the user and log reason; grace and clock_suspect need their own wording.
  5. Ship the native module. Install @carchalabs/seal-hwid (it comes with the SDK), keep it out of your bundler and let electron-builder unpack it. With it the SDK sends hardware components, verifies every lease a second time with keys compiled into the module, and decrypts with a key bound to the machine. Check a packaged build on each platform you ship.
  6. Protect the stored license. Pass secureStore backed by Electron's safeStorage, so license.dat is encrypted by the operating system.
  7. Ship only public keys. Embed the current and next public key. The admin API key, the signing key and feature keys never go into the app; feature keys belong in your build pipeline (SEAL_FEATURE_KEY).
  8. Harden the app. Turn off the RunAsNode, NODE_OPTIONS and inspect fuses, turn on ASAR integrity (macOS and Windows) and OnlyLoadAppFromAsar, and code-sign and notarize your installers (Electron fuses, ASAR integrity).

No client-side measure stops a determined person who controls the computer; what they get from removing a check is what matters, and decrypt is what takes that away.

Quickstart

@carchalabs/seal-quickstart runs the SDK from a terminal against the public demo server, with a trial key from the Try it section. It sends the device name Carcha Seal quickstart and saves the lease unencrypted in the per-user application data folder.

npx @carchalabs/seal-quickstart activate LK-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
npx @carchalabs/seal-quickstart status
npx @carchalabs/seal-quickstart deactivate

The exit code is 0 when the license can be used (valid or grace), 1 when it cannot or a request failed, and 2 for a wrong command.

Client API

Called by the SDK and authenticated by the license key. Bodies are JSON; unknown fields are rejected. fingerprint is a 64-character lowercase SHA-256 hex digest. Each client address may call activate, validate and deactivate 30 times a minute; the key endpoints are not limited.

All three also take optional components, a list of up to 32 { "kind", "hash" } (kind uuid, board, disk, mac, os, cpu or gpu; hash a SHA-256 hex digest keyed per product on the device, never the raw value), and vm, true when the device runs as a virtual machine. With components, a computer keeps its seat after some parts change: a new disk, network card or operating system install is the same computer, a new motherboard needs a transfer, and a copy of an installation on other hardware is refused. Requests without them are matched on the exact fingerprint.

POST /v1/activate

Body: licenseKey, product, fingerprint, optional machineName, platform, appVersion, sdkVersion, components (up to 32 { kind, hash }), vm, devicePublicKey (32-byte X25519 key, base64url; the lease's feature keys are then wrapped for it).

Response: token (the lease), reused, license with id, customer, features, expiresAt, and revalidateBy. When the app's SDK is older than the recommended or required version, the response also has notices with the code sdk_update_recommended (or app_update_recommended for the product's minimum app version), e.g. { "code": "sdk_update_recommended", "minimum": "0.2.0", "requiredFrom": "2026-11-01T00:00:00.000Z" }.

POST /v1/validate

Body: licenseKey, product, fingerprint, optional appVersion, sdkVersion, components, vm, devicePublicKey. Response: valid, token, revalidateBy, and notices as for activation.

POST /v1/deactivate

Body: licenseKey, product, fingerprint. Response: deactivated: true. A seat can be released this way 3 times per seat in any 12 months; the vendor can release it from the console at any time.

POST /v1/offline/activate

Body: an offline request file as written by offlineRequest: { "format": "licensekit-offline-request", "version": 1, "activation": { ... } }, where activation is the body of /v1/activate. Response: the offline license file, format licensekit-offline-license, version, product, licenseKey, token, customer, revalidateBy. The lease lasts the policy's offline days. Refused with offline_not_allowed (403) when the policy does not allow it. 10 requests a minute per address. The page GET /offline on the same server does this for customers in a browser.

GET /v1/keys

The verification keys as a JSON Web Key Set (RFC 7517): the current signing key first, then the next one if configured.

GET /v1/public-key

The current public key as PEM, for embedding in an app.

Errors

Every error has the body { "error": "...", "message": "..." }.

CodeHTTPMeaning
invalid_key404The key does not exist for this product.
license_suspended403The license is suspended.
license_revoked403The license is revoked.
license_expired403The license has expired.
activation_limit409All seats are in use; the body also has maxActivations.
not_activated404This computer holds no seat on the license.
device_conflict409This installation is already activated on a different computer (a copied disk or system image).
transfer_limit403The seat has been released too often this year; the body also has limit and availableAt.
vm_not_allowed403The license's policy does not allow virtual machines.
offline_not_allowed403Offline activation: the license's policy does not allow it.
validation_failed400The body is not valid.
rate_limited429Too many requests from this address.
app_update_required426This app version is older than the product's required minimum; the body also has minimum.
sdk_update_required426The app's Carcha Seal SDK is older than the required version; the body also has minimum. Ship an app update with a newer SDK.

Admin API

Send the admin key as x-admin-key or as Authorization: Bearer.

EndpointPurpose
GET /api/overviewCounts and recent events
GET /api/productsProducts
POST /api/productscode, name, optional defaultFeatures; also creates the default policy
PATCH /api/products/:idMinimum app version: appRecommendedVersion, appRequiredVersion, appRequiredFrom (ISO date); null removes a value. The rule uses the appVersion your app sends; a missing version, or one not in 1.2.3 form, counts as older.
GET /api/products/:id/feature-keysThe product's feature content keys (feature, key as base64url). Secret: they open the data you encrypted for each feature.
POST /api/products/:id/feature-keysfeature. Creates that feature's 256-bit content key, or returns the existing one; a key is never replaced. At most 50 per product. Every lease for a license with the feature then carries the key wrapped for that license key and device.
GET /api/policiesPolicies; optional ?productId=
POST /api/policiesproductId, code, name, optional maxActivations, durationDays (null for perpetual), maintenanceDays, revalidateHours, graceDays, features, vmPolicy (allow, block), offlineDays (days an offline license file lasts; 0, the default, means offline activation is not allowed)
GET /api/licensesLicenses; optional ?q= (customer, email or key ending) and ?productId=
POST /api/licensesproductId, customerName, optional policyId, customerEmail, maxActivations, expiresAt (YYYY-MM-DD or null), features. Returns the key once.
GET /api/licenses/:idA license with its devices and history
PATCH /api/licenses/:idOptional maxActivations, expiresAt, features, status (active, suspended, revoked)
DELETE /api/activations/:idRemoves a device and frees its seat
POST /api/offline/deactivateBody: an offline deactivation file (format licensekit-offline-deactivation, version, product, token). Frees that computer's seat and returns the license. Only the seat's newest lease frees it (stale_deactivation otherwise); a file not signed by this server is invalid_file.

Order webhook

POST /api/webhooks/order

Creates a license from an order. Enabled when WEBHOOK_SECRET is set. The header X-Signature: sha256=<hex> must be the HMAC-SHA256 of the raw body with that secret.

Body: orderId, productCode, customerName, optional customerEmail, seats, durationDays. Response: duplicate: false, licenseId and licenseKey. The same orderId again answers duplicate: true without a key.

Signing keys

The server signs with LICENSE_SIGNING_KEY. Apps embed its public half and the public half of the next key, which the server also publishes from LICENSE_NEXT_PUBLIC_KEY or LICENSE_NEXT_PUBLIC_KEY_FILE. The next key's private half is kept offline. To rotate, the next key becomes the signing key; installed apps already trust it. A new next key is then created for the following release. NIST SP 800-57 Part 1 suggests a cryptoperiod of about one to three years for a private signature key.