@carchalabs/seal-electron 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @carchalabs/seal-electron
2
2
 
3
- The [Carcha Seal](https://seal.carchalabs.com) (formerly LicenseKit) SDK. It runs in the Electron main process or in plain Node. It activates a license key on this device, saves the signed lease, and verifies it offline with your public keys.
3
+ The [Carcha Seal](https://seal.carchalabs.com) SDK. It runs in the Electron main process or in plain Node. It activates a license key on this device, saves the signed lease, and verifies it offline with your public keys.
4
4
 
5
5
  ```js
6
6
  const { Seal } = require('@carchalabs/seal-electron');
@@ -25,9 +25,21 @@ if (Seal.hasFeature(state, 'export-pdf')) exportPdf();
25
25
 
26
26
  Code can be protected the same way (Envelope): encrypt a CommonJS module or a folder of them with `seal-encrypt --feature pro dist/premium dist/premium`, and load it with `await kit.requireEncrypted('pro', path.join(__dirname, 'premium/index.js.lke'))`. Without the feature in the license the code cannot be read; a licensed user can still read it from memory.
27
27
 
28
+ **Floating licenses.** For a license whose policy is floating (a pool of seats in use at the same time), take a seat when the app starts and give it back when it ends:
29
+
30
+ ```js
31
+ const seat = await kit.checkout(userEnteredKey); // later: kit.checkout() uses the saved key
32
+ seat.on('lost', ({ reason, message }) => showLicenseScreen(message));
33
+ if (seat.has('export-pdf')) exportPdf();
34
+ // … and on your own sign-out or shutdown path:
35
+ await seat.release();
36
+ ```
37
+
38
+ The seat renews itself before its token's renewal time and emits `renewed`. When renewals get no answer it keeps trying until the token's lifetime has passed on a monotonic clock (changing the computer's clock changes nothing), then emits `lost` with `expired`; a refusal (`license_revoked`, `seats_exhausted`, …) emits `lost` with the server's reason at once. The SDK does nothing else on `lost`: your app decides. If the server ended the session or another copy renewed it, the SDK checks out again on the same seat. `checkout()` throws `seats_exhausted` with `details.inUse`, `details.total` and `details.retryAfter` (seconds) when every seat is in use, and `not_floating` for a node-locked license. Pass `{ user }` for a license that counts users (only a hash is sent) and `{ machineName }` to name the computer. In Electron the SDK releases held seats when the app quits (at most 2 seconds; `releaseOnQuit: false` turns it off); a seat that is never released frees itself when its token expires (10 minutes by default). Seat tokens live in memory only. With `@carchalabs/seal-hwid` 0.6 the seat is bound to the device key and every heartbeat carries a proof made in the native module.
39
+
28
40
  Computers that never reach the server use offline activation: `offlineRequest(key)` returns a request file, the license server turns it into a license file (its `/offline` page, `POST /v1/offline/activate` or the console), and `activateOffline(file)` loads it; it works only on that computer, for the policy's offline days. `deactivateOffline()` removes the license and returns a deactivation file the vendor uploads to free the seat. See the [documentation](https://seal.carchalabs.com/docs/#concepts).
29
41
 
30
- The SDK sends the license key, a per-product SHA-256 hash of the operating system's machine ID, the device name, the platform, your app version and its own version. The raw machine ID does not leave the computer.
42
+ The SDK sends the license key, a per-product SHA-256 hash of the operating system's machine ID, the device name you pass, the platform, your app version and its own version; a seat checkout also sends a random per-run instance id hash. The raw machine ID does not leave the computer. Everything sent and kept is listed in [PRIVACY.md](../../PRIVACY.md).
31
43
 
32
44
  **Feature data.** `await kit.decrypt('pro', data)` returns data you encrypted for a feature (`encryptForFeature` in `@carchalabs/seal-core`, with the feature's content key from the console). It works offline from the stored lease and only on a device whose license includes the feature; without a license the data stays encrypted, so removing a license check from your app does not unlock it. With `@carchalabs/seal-hwid` 0.2 the native module does the decryption and the key never reaches JavaScript.
33
45
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { MachineIdUnavailableError, readMachineId } from './fingerprint';
2
+ import { Seat } from './seat';
2
3
  export { MachineIdUnavailableError, readMachineId };
4
+ export { Seat };
5
+ export type { SeatLoss, SeatStatus } from './seat';
3
6
  /** This SDK's version, sent to the server so vendors can see (and require) which SDK each device runs. */
4
7
  export declare const SDK_VERSION: string;
5
8
  export type LicenseStatus = 'valid' | 'grace' | 'invalid' | 'unlicensed' | 'clock_suspect';
@@ -65,11 +68,34 @@ export interface SealOptions {
65
68
  */
66
69
  hardware?: false | (() => HardwareReport | null | Promise<HardwareReport | null>);
67
70
  now?: () => number;
71
+ /** Milliseconds on a monotonic clock, for how long a seat lasts (default performance.now). For tests. */
72
+ monotonic?: () => number;
73
+ /**
74
+ * Floating licenses, in Electron: when the app quits, give every seat it holds back first (at most 2 seconds), then
75
+ * exit. Default true. Without it, or in plain Node, a seat the app does not release frees itself when its token
76
+ * expires.
77
+ */
78
+ releaseOnQuit?: boolean;
79
+ }
80
+ /** seal.checkout()'s options. */
81
+ export interface CheckoutOptions {
82
+ /** The user's name, for a license that counts by user. Only its hash is sent (SHA-256 with the product). */
83
+ user?: string;
84
+ /** The name to show the vendor for this computer, as for activate. */
85
+ machineName?: string;
86
+ /** Renew before the token's renewal time without being asked (default true). false: call seat.renew() yourself. */
87
+ renewAutomatically?: boolean;
68
88
  }
89
+ /** @internal for tests: the native module to use instead of the installed one (null: none). */
90
+ export declare function useHwidModule(module: unknown): void;
69
91
  export declare class LicenseError extends Error {
70
92
  readonly code: string;
71
93
  readonly status?: number | undefined;
72
- constructor(code: string, message: string, status?: number | undefined);
94
+ /** The refusal's other fields: for seats_exhausted, inUse, total and retryAfter (seconds); for rate_limited, retryAfter. */
95
+ readonly details?: Record<string, unknown> | undefined;
96
+ constructor(code: string, message: string, status?: number | undefined,
97
+ /** The refusal's other fields: for seats_exhausted, inUse, total and retryAfter (seconds); for rate_limited, retryAfter. */
98
+ details?: Record<string, unknown> | undefined);
73
99
  }
74
100
  export declare class Seal {
75
101
  private readonly opts;
@@ -106,10 +132,16 @@ export declare class Seal {
106
132
  private emptyState;
107
133
  /**
108
134
  * The device's public key, sent with activation and check-ins so the server wraps feature keys for
109
- * this machine only. Only when the native module verifies for this app (it then also decrypts, with
110
- * the matching secret it derives itself) and hardware is not turned off. Never throws.
135
+ * this machine only. Sent when the native module can also decrypt what is wrapped for it, with the
136
+ * matching secret it derives itself: through the compiled-in keys when they verify for this app, or,
137
+ * with seal-hwid 0.5+, through this app's own keys (every hosted vendor, a self-hosted server). Not
138
+ * with hardware turned off. Never throws.
111
139
  */
112
140
  private deviceKey;
141
+ /** The native module's decryption with this app's own keys (seal-hwid 0.5+), or null: older module, none, or hardware off. */
142
+ private deviceDecrypter;
143
+ /** This app's public keys as SPKI PEM, for the native module. */
144
+ private keyPems;
113
145
  /**
114
146
  * The native verifier, used only when its compiled-in keys include every key this app was given:
115
147
  * an app that trusts other keys (a self-hosted server) is checked by the JavaScript verifier alone.
@@ -133,8 +165,9 @@ export declare class Seal {
133
165
  * Decrypts data you encrypted for a feature (encryptForFeature in @carchalabs/seal-core, with the
134
166
  * feature's content key from the console). Works offline: it uses the saved lease, which must be valid
135
167
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
136
- * The native module does the work when it trusts this app's keys, so the content key never reaches
137
- * JavaScript; without it the JavaScript implementation is used. Throws a LicenseError:
168
+ * When the lease is wrapped for this machine's device key, the native module does the work (with its
169
+ * compiled-in keys or, seal-hwid 0.5+, this app's keys), so the device secret and the content key never
170
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript. Throws a LicenseError:
138
171
  * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
139
172
  */
140
173
  decrypt(feature: string, data: Uint8Array): Promise<Buffer>;
@@ -179,8 +212,44 @@ export declare class Seal {
179
212
  private deactivateOfflineNow;
180
213
  deactivate(): Promise<void>;
181
214
  private deactivateNow;
215
+ /**
216
+ * Floating licenses: takes a seat of the license's pool for as long as the app runs (server docs/floating.md). With a
217
+ * license key, uses it and saves it for the next checkout; without, uses the saved one. The Seat renews itself, emits
218
+ * 'renewed' and, when the seat cannot be kept, 'lost' with the reason; give it back with seat.release() (in
219
+ * Electron the SDK also does on quit, see releaseOnQuit). There is no offline grace: a seat needs the server. Throws a
220
+ * LicenseError: `unlicensed`, the server's refusal (seats_exhausted with details inUse, total and retryAfter,
221
+ * not_floating for a node-locked license, license_revoked, …), `network`, or a seat token failure reason.
222
+ */
223
+ checkout(licenseKey?: string, options?: CheckoutOptions): Promise<Seat>;
224
+ private checkoutNow;
225
+ private seatHost;
226
+ /** The native module's seat proof (seal-hwid 0.6+), or null: older module, none, or hardware off. */
227
+ private seatProver;
228
+ /**
229
+ * The device key a seat is bound to, only when the native module can prove it in every heartbeat: a seat bound to a
230
+ * key the app cannot prove could not be renewed. Never throws.
231
+ */
232
+ private seatDeviceKey;
182
233
  static hasFeature(state: LicenseState, feature: string): boolean;
183
234
  }
235
+ /** The part of Electron's app this uses. */
236
+ export interface QuitApp {
237
+ on(event: 'will-quit', listener: (event: {
238
+ preventDefault(): void;
239
+ defaultPrevented?: boolean;
240
+ }) => void): unknown;
241
+ exit(code?: number): void;
242
+ }
243
+ /**
244
+ * In Electron, gives held seats back when the app quits: on will-quit (all windows closed, so no window can still cancel
245
+ * the quit) the quit is held, the seats are released (at most 2 seconds), and the app exits with app.exit(0), which does
246
+ * not emit before-quit or will-quit again (Electron's app API). Not when another listener already prevented the quit, nor
247
+ * when no seat is held. On Windows, Electron emits no quit event at shutdown or logout; seats then free themselves when
248
+ * their tokens expire. @internal exported for tests; `app` is Electron's when not given.
249
+ */
250
+ export declare function installQuitRelease(app?: QuitApp): boolean;
251
+ /** @internal for tests: forget the hook and the held seats. */
252
+ export declare function resetQuitRelease(): void;
184
253
  /** The SDK class under its earlier name (LicenseKit). Same class; use Seal in new code. */
185
254
  export { Seal as LicenseKit };
186
255
  /** @deprecated The options under the class's earlier name; use SealOptions. */
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LicenseKit = exports.Seal = exports.LicenseError = exports.SDK_VERSION = exports.readMachineId = exports.MachineIdUnavailableError = void 0;
3
+ exports.LicenseKit = exports.Seal = exports.LicenseError = exports.SDK_VERSION = exports.Seat = exports.readMachineId = exports.MachineIdUnavailableError = void 0;
4
+ exports.useHwidModule = useHwidModule;
5
+ exports.installQuitRelease = installQuitRelease;
6
+ exports.resetQuitRelease = resetQuitRelease;
7
+ const node_crypto_1 = require("node:crypto");
4
8
  const node_fs_1 = require("node:fs");
5
9
  const promises_1 = require("node:fs/promises");
6
10
  const node_module_1 = require("node:module");
@@ -11,6 +15,8 @@ const seal_core_1 = require("@carchalabs/seal-core");
11
15
  const fingerprint_1 = require("./fingerprint");
12
16
  Object.defineProperty(exports, "MachineIdUnavailableError", { enumerable: true, get: function () { return fingerprint_1.MachineIdUnavailableError; } });
13
17
  Object.defineProperty(exports, "readMachineId", { enumerable: true, get: function () { return fingerprint_1.readMachineId; } });
18
+ const seat_1 = require("./seat");
19
+ Object.defineProperty(exports, "Seat", { enumerable: true, get: function () { return seat_1.Seat; } });
14
20
  /** This SDK's version, sent to the server so vendors can see (and require) which SDK each device runs. */
15
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
22
  exports.SDK_VERSION = require('../package.json').version;
@@ -18,7 +24,9 @@ exports.SDK_VERSION = require('../package.json').version;
18
24
  const OFFLINE_REQUEST_FORMAT = 'licensekit-offline-request';
19
25
  const OFFLINE_LICENSE_FORMAT = 'licensekit-offline-license';
20
26
  const OFFLINE_DEACTIVATION_FORMAT = 'licensekit-offline-deactivation';
21
- const DEFINITIVE = new Set(['invalid_key', 'not_activated', 'license_suspended', 'license_revoked', 'license_expired', 'sdk_update_required', 'app_update_required', 'vm_not_allowed']);
27
+ /** Refusals kept as the license's state: offline, the app keeps being told them. floating_license: the policy is floating, so the
28
+ * app must check out a seat (checkout) instead of activating. */
29
+ const DEFINITIVE = new Set(['invalid_key', 'not_activated', 'license_suspended', 'license_revoked', 'license_expired', 'sdk_update_required', 'app_update_required', 'vm_not_allowed', 'floating_license']);
22
30
  /** The native hardware module. A non-literal require: bundlers leave it to Node, which must load the .node file from disk. */
23
31
  const HWID_PACKAGE = '@carchalabs/seal-hwid';
24
32
  /** Loads the native module once per process; null when it is not installed or has no build for this platform. */
@@ -35,6 +43,10 @@ function loadHwid() {
35
43
  }
36
44
  return hwidModule;
37
45
  }
46
+ /** @internal for tests: the native module to use instead of the installed one (null: none). */
47
+ function useHwidModule(module) {
48
+ hwidModule = module;
49
+ }
38
50
  /**
39
51
  * The largest answer read, as in the native SDKs: a lease and its notices are a few kilobytes. A longer one is read as no
40
52
  * answer, so a broken or hostile server cannot make the app hold and parse hundreds of megabytes.
@@ -85,10 +97,14 @@ const CLOCK_MESSAGE = 'The date and time on this computer look wrong. Correct th
85
97
  class LicenseError extends Error {
86
98
  code;
87
99
  status;
88
- constructor(code, message, status) {
100
+ details;
101
+ constructor(code, message, status,
102
+ /** The refusal's other fields: for seats_exhausted, inUse, total and retryAfter (seconds); for rate_limited, retryAfter. */
103
+ details) {
89
104
  super(message);
90
105
  this.code = code;
91
106
  this.status = status;
107
+ this.details = details;
92
108
  this.name = 'LicenseError';
93
109
  }
94
110
  }
@@ -302,8 +318,13 @@ class Seal {
302
318
  }
303
319
  if (typeof json !== 'object' || json === null)
304
320
  json = {};
305
- if (!res.ok)
306
- throw new LicenseError(json.error ?? 'server_error', json.message ?? `License server error (${res.status})`, res.status);
321
+ if (!res.ok) {
322
+ const details = {};
323
+ for (const name of ['inUse', 'total', 'retryAfter', 'reason'])
324
+ if (json[name] !== undefined)
325
+ details[name] = json[name];
326
+ throw new LicenseError(json.error ?? 'server_error', json.message ?? `License server error (${res.status})`, res.status, Object.keys(details).length ? details : undefined);
327
+ }
307
328
  return json;
308
329
  }
309
330
  emptyState(status, offline, extra = {}) {
@@ -311,17 +332,20 @@ class Seal {
311
332
  }
312
333
  /**
313
334
  * The device's public key, sent with activation and check-ins so the server wraps feature keys for
314
- * this machine only. Only when the native module verifies for this app (it then also decrypts, with
315
- * the matching secret it derives itself) and hardware is not turned off. Never throws.
335
+ * this machine only. Sent when the native module can also decrypt what is wrapped for it, with the
336
+ * matching secret it derives itself: through the compiled-in keys when they verify for this app, or,
337
+ * with seal-hwid 0.5+, through this app's own keys (every hosted vendor, a self-hosted server). Not
338
+ * with hardware turned off. Never throws.
316
339
  */
317
340
  deviceKey() {
318
341
  if (this.deviceKeyCache === undefined) {
319
342
  let key = null;
320
343
  try {
344
+ const hwid = this.opts.hardware === false ? null : loadHwid();
321
345
  const native = this.opts.hardware === false ? null : this.native();
322
- if (native && typeof native.devicePublicKey === 'function' && typeof native.decryptFeature === 'function') {
323
- key = native.devicePublicKey(`licensekit:${this.opts.product}`);
324
- }
346
+ const decrypts = (native && typeof native.decryptFeature === 'function') || this.deviceDecrypter() !== null;
347
+ if (hwid && typeof hwid.devicePublicKey === 'function' && decrypts)
348
+ key = hwid.devicePublicKey(`licensekit:${this.opts.product}`);
325
349
  }
326
350
  catch {
327
351
  key = null;
@@ -330,6 +354,17 @@ class Seal {
330
354
  }
331
355
  return this.deviceKeyCache ? { devicePublicKey: this.deviceKeyCache } : {};
332
356
  }
357
+ /** The native module's decryption with this app's own keys (seal-hwid 0.5+), or null: older module, none, or hardware off. */
358
+ deviceDecrypter() {
359
+ if (this.opts.hardware === false)
360
+ return null;
361
+ const hwid = loadHwid();
362
+ return hwid && typeof hwid.decryptFeatureWithKeys === 'function' ? hwid : null;
363
+ }
364
+ /** This app's public keys as SPKI PEM, for the native module. */
365
+ keyPems() {
366
+ return [...this.keys.values()].map((key) => key.export({ type: 'spki', format: 'pem' }).toString());
367
+ }
333
368
  /**
334
369
  * The native verifier, used only when its compiled-in keys include every key this app was given:
335
370
  * an app that trusts other keys (a self-hosted server) is checked by the JavaScript verifier alone.
@@ -510,8 +545,9 @@ class Seal {
510
545
  * Decrypts data you encrypted for a feature (encryptForFeature in @carchalabs/seal-core, with the
511
546
  * feature's content key from the console). Works offline: it uses the saved lease, which must be valid
512
547
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
513
- * The native module does the work when it trusts this app's keys, so the content key never reaches
514
- * JavaScript; without it the JavaScript implementation is used. Throws a LicenseError:
548
+ * When the lease is wrapped for this machine's device key, the native module does the work (with its
549
+ * compiled-in keys or, seal-hwid 0.5+, this app's keys), so the device secret and the content key never
550
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript. Throws a LicenseError:
515
551
  * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
516
552
  */
517
553
  async decrypt(feature, data) {
@@ -581,10 +617,27 @@ class Seal {
581
617
  const graceSeconds = state.status === 'grace' ? (claims?.grc ?? (this.opts.graceDays ?? DEFAULT_GRACE_DAYS) * 86400) : 0;
582
618
  const check = { product: this.opts.product, fingerprintHash, now: this.nowSec(), ...(graceSeconds ? { graceSeconds } : {}) };
583
619
  const native = this.native();
620
+ // A lease wrapped for this machine's device key (epk) opens only in the native module, which holds the device
621
+ // secret: through the compiled-in keys when they verify for this app, else through this app's keys (seal-hwid 0.5+).
622
+ const deviceBound = claims?.epk !== undefined;
623
+ const withKeys = deviceBound && !(native && typeof native.decryptFeature === 'function') ? this.deviceDecrypter() : null;
624
+ const pems = withKeys ? this.keyPems() : [];
584
625
  return (data) => {
585
- const result = native && typeof native.decryptFeature === 'function'
586
- ? native.decryptFeature(leased.token, check, leased.licenseKey, feature, Buffer.from(data))
587
- : (0, seal_core_1.decryptFeature)(leased.token, { keys: this.keys, ...check }, leased.licenseKey, feature, Buffer.from(data));
626
+ let result;
627
+ if (native && typeof native.decryptFeature === 'function') {
628
+ result = native.decryptFeature(leased.token, check, leased.licenseKey, feature, Buffer.from(data));
629
+ }
630
+ else if (withKeys) {
631
+ try {
632
+ result = withKeys.decryptFeatureWithKeys(leased.token, check, pems, leased.licenseKey, feature, Buffer.from(data));
633
+ }
634
+ catch {
635
+ result = { ok: false, reason: 'decrypt_failed' };
636
+ }
637
+ }
638
+ else {
639
+ result = (0, seal_core_1.decryptFeature)(leased.token, { keys: this.keys, ...check }, leased.licenseKey, feature, Buffer.from(data));
640
+ }
588
641
  if (result.ok && result.data)
589
642
  return Buffer.from(result.data);
590
643
  if (result.reason === 'not_licensed')
@@ -682,9 +735,149 @@ class Seal {
682
735
  }
683
736
  await this.clear();
684
737
  }
738
+ /**
739
+ * Floating licenses: takes a seat of the license's pool for as long as the app runs (server docs/floating.md). With a
740
+ * license key, uses it and saves it for the next checkout; without, uses the saved one. The Seat renews itself, emits
741
+ * 'renewed' and, when the seat cannot be kept, 'lost' with the reason; give it back with seat.release() (in
742
+ * Electron the SDK also does on quit, see releaseOnQuit). There is no offline grace: a seat needs the server. Throws a
743
+ * LicenseError: `unlicensed`, the server's refusal (seats_exhausted with details inUse, total and retryAfter,
744
+ * not_floating for a node-locked license, license_revoked, …), `network`, or a seat token failure reason.
745
+ */
746
+ checkout(licenseKey, options = {}) {
747
+ return this.checkoutNow(licenseKey, options);
748
+ }
749
+ async checkoutNow(licenseKey, options) {
750
+ const fingerprintHash = await this.fingerprint();
751
+ const key = licenseKey?.trim().toUpperCase() ?? (await this.load())?.licenseKey;
752
+ if (!key)
753
+ throw new LicenseError('unlicensed', 'Enter a license key to check out a seat.');
754
+ const host = this.seatHost(key, fingerprintHash, options);
755
+ const answer = await this.post('/v1/seats/checkout', await host.checkoutBody());
756
+ const held = (0, seat_1.acceptSeat)(host, answer, undefined, undefined);
757
+ if (typeof held === 'string')
758
+ throw new LicenseError(held, REASON_TEXT[held] ?? 'The license server sent an unusable seat.');
759
+ // The key is kept for the next checkout. A seat token never is: it lives in memory only.
760
+ if (licenseKey !== undefined) {
761
+ await this.serial(async () => {
762
+ if ((await this.load())?.licenseKey !== key)
763
+ await this.save({ licenseKey: key });
764
+ });
765
+ }
766
+ const seat = new seat_1.Seat(host, held, options.renewAutomatically !== false, (s) => heldSeats.delete(s));
767
+ if (this.opts.releaseOnQuit !== false) {
768
+ heldSeats.add(seat);
769
+ installQuitRelease();
770
+ }
771
+ return seat;
772
+ }
773
+ seatHost(licenseKey, fingerprintHash, options) {
774
+ const monotonic = this.opts.monotonic ?? (() => performance.now());
775
+ return {
776
+ product: this.opts.product,
777
+ keys: this.keys,
778
+ fingerprintHash,
779
+ licenseKey,
780
+ post: (path, body) => this.post(path, body),
781
+ checkoutBody: async () => ({
782
+ licenseKey,
783
+ product: this.opts.product,
784
+ fingerprint: fingerprintHash,
785
+ machineName: options.machineName?.slice(0, 80),
786
+ platform: (0, node_os_1.platform)(),
787
+ appVersion: this.opts.appVersion,
788
+ sdkVersion: exports.SDK_VERSION,
789
+ ...(await this.hardware()),
790
+ ...this.seatDeviceKey(),
791
+ // Always sent: only a license that counts by process uses it.
792
+ instance: INSTANCE,
793
+ ...(options.user !== undefined ? { user: (0, node_crypto_1.createHash)('sha256').update(`licensekit-user:${this.opts.product}:${options.user}`, 'utf8').digest('base64url') } : {}),
794
+ }),
795
+ proof: (token, license) => {
796
+ const hwid = this.seatProver();
797
+ if (!hwid)
798
+ return null;
799
+ try {
800
+ return hwid.seatProof(token, { product: this.opts.product, fingerprintHash, license }, this.keyPems());
801
+ }
802
+ catch {
803
+ return null;
804
+ }
805
+ },
806
+ monotonic,
807
+ wallMs: () => this.opts.now?.() ?? Date.now(),
808
+ isLicenseError: (e) => e instanceof LicenseError,
809
+ };
810
+ }
811
+ /** The native module's seat proof (seal-hwid 0.6+), or null: older module, none, or hardware off. */
812
+ seatProver() {
813
+ if (this.opts.hardware === false)
814
+ return null;
815
+ const hwid = loadHwid();
816
+ return hwid && typeof hwid.seatProof === 'function' && typeof hwid.devicePublicKey === 'function' ? hwid : null;
817
+ }
818
+ /**
819
+ * The device key a seat is bound to, only when the native module can prove it in every heartbeat: a seat bound to a
820
+ * key the app cannot prove could not be renewed. Never throws.
821
+ */
822
+ seatDeviceKey() {
823
+ try {
824
+ const key = this.seatProver()?.devicePublicKey(`licensekit:${this.opts.product}`);
825
+ return typeof key === 'string' ? { devicePublicKey: key } : {};
826
+ }
827
+ catch {
828
+ return {};
829
+ }
830
+ }
685
831
  static hasFeature(state, feature) {
686
832
  return (state.status === 'valid' || state.status === 'grace') && state.features.includes(feature);
687
833
  }
688
834
  }
689
835
  exports.Seal = Seal;
690
836
  exports.LicenseKit = Seal;
837
+ /** This process's instance id for counting by process: the SHA-256 of 128 random bits, made once (spec section 4). */
838
+ const INSTANCE = (0, node_crypto_1.createHash)('sha256').update((0, node_crypto_1.randomBytes)(16)).digest('base64url');
839
+ /** Seats held by this process whose Seal releases them on quit. */
840
+ const heldSeats = new Set();
841
+ /** How long quitting waits for the releases. */
842
+ const QUIT_RELEASE_MS = 2000;
843
+ let quitHookInstalled = false;
844
+ /**
845
+ * In Electron, gives held seats back when the app quits: on will-quit (all windows closed, so no window can still cancel
846
+ * the quit) the quit is held, the seats are released (at most 2 seconds), and the app exits with app.exit(0), which does
847
+ * not emit before-quit or will-quit again (Electron's app API). Not when another listener already prevented the quit, nor
848
+ * when no seat is held. On Windows, Electron emits no quit event at shutdown or logout; seats then free themselves when
849
+ * their tokens expire. @internal exported for tests; `app` is Electron's when not given.
850
+ */
851
+ function installQuitRelease(app) {
852
+ if (quitHookInstalled)
853
+ return true;
854
+ if (!app) {
855
+ if (!process.versions.electron)
856
+ return false;
857
+ try {
858
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
859
+ app = require('electron').app;
860
+ }
861
+ catch {
862
+ return false;
863
+ }
864
+ if (!app)
865
+ return false;
866
+ }
867
+ quitHookInstalled = true;
868
+ const quitting = app;
869
+ quitting.on('will-quit', (event) => {
870
+ if (event.defaultPrevented || heldSeats.size === 0)
871
+ return;
872
+ event.preventDefault();
873
+ const releases = Promise.allSettled([...heldSeats].map((seat) => seat.release()));
874
+ const cap = new Promise((done) => setTimeout(done, QUIT_RELEASE_MS).unref?.());
875
+ void Promise.race([releases, cap]).finally(() => quitting.exit(0));
876
+ });
877
+ return true;
878
+ }
879
+ /** @internal for tests: forget the hook and the held seats. */
880
+ function resetQuitRelease() {
881
+ quitHookInstalled = false;
882
+ heldSeats.clear();
883
+ }
package/dist/seat.d.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { KeyObject } from 'node:crypto';
3
+ import { LeaseFailure, SeatClaims } from '@carchalabs/seal-core';
4
+ /** Raised for a refusal or a failure; the server's error code and message. */
5
+ export interface SeatErrorLike {
6
+ code: string;
7
+ message: string;
8
+ status?: number;
9
+ details?: Record<string, unknown>;
10
+ }
11
+ /** What the Seal gives a Seat: its requests and its checks. Internal. */
12
+ export interface SeatHost {
13
+ product: string;
14
+ keys: Map<string, KeyObject>;
15
+ fingerprintHash: string;
16
+ /** POST to the license server; throws a LicenseError. */
17
+ post(path: string, body: Record<string, unknown>): Promise<any>;
18
+ /** The checkout body (license key, fingerprint, hardware, device key, instance, user). */
19
+ checkoutBody(): Promise<Record<string, unknown>>;
20
+ licenseKey: string;
21
+ /** The proof for the next heartbeat or release of a device-bound token, from the native module; null when it has none. */
22
+ proof(token: string, license: string): {
23
+ ok: true;
24
+ seq: number;
25
+ proof: string;
26
+ } | {
27
+ ok: false;
28
+ reason: string;
29
+ } | null;
30
+ /** Milliseconds on a monotonic clock. */
31
+ monotonic(): number;
32
+ /** Milliseconds since the Unix epoch, for the times the Seat shows. */
33
+ wallMs(): number;
34
+ isLicenseError(e: unknown): e is SeatErrorLike;
35
+ }
36
+ export type SeatStatus = 'held' | 'lost' | 'released';
37
+ /** Why a seat was lost: the server's refusal (license_revoked, seats_exhausted, …) or `expired` (no renewal in time). */
38
+ export interface SeatLoss {
39
+ reason: string;
40
+ message: string;
41
+ }
42
+ /** A checked-out answer, verified. */
43
+ interface Held {
44
+ token: string;
45
+ claims: SeatClaims;
46
+ /** Monotonic times (ms): when to renew, and when the seat ends without a renewal. */
47
+ renewAt: number;
48
+ deadline: number;
49
+ seats: {
50
+ inUse: number;
51
+ total: number | null;
52
+ };
53
+ license: {
54
+ id: string;
55
+ expiresAt: string | null;
56
+ };
57
+ }
58
+ /**
59
+ * Verifies a seat token from a checkout or heartbeat answer: the lease's checks with the seat type, then the license,
60
+ * the seat held and the next counter (spec section 2). Returns the held state, or the failure reason.
61
+ */
62
+ export declare function acceptSeat(host: SeatHost, answer: any, license: string | undefined, held: {
63
+ sid: string;
64
+ seq: number;
65
+ } | undefined): Held | LeaseFailure;
66
+ export declare class Seat extends EventEmitter {
67
+ private readonly host;
68
+ private readonly automatic;
69
+ private readonly onEnd;
70
+ private current;
71
+ private state;
72
+ private loss?;
73
+ private timer?;
74
+ private retries;
75
+ /** The session is gone (seat_expired, seat_conflict, seat_not_found): the next attempt checks out again. */
76
+ private checkOutAgain;
77
+ /** Renewals and the release one at a time, in order. */
78
+ private queue;
79
+ /** @internal Made by Seal.checkout(). */
80
+ constructor(host: SeatHost, held: Held, automatic: boolean, onEnd: (seat: Seat) => void);
81
+ /** held, lost or released. */
82
+ get status(): SeatStatus;
83
+ /** Why the seat was lost; set when status is lost. */
84
+ get lostReason(): SeatLoss | undefined;
85
+ /** The session's id; a new one after the SDK checked out again on the same seat. */
86
+ get id(): string;
87
+ get features(): string[];
88
+ has(feature: string): boolean;
89
+ /** Seats in use and the pool's size (null: unlimited), as of the last answer. */
90
+ get seats(): {
91
+ inUse: number;
92
+ total: number | null;
93
+ };
94
+ get license(): {
95
+ id: string;
96
+ expiresAt: string | null;
97
+ };
98
+ /** When the seat ends if no renewal succeeds before (from the monotonic deadline, shown in this computer's time). */
99
+ get expiresAt(): string;
100
+ /** Renews now. The Seat does this itself before the token's renewal time, unless checked out with renewAutomatically: false. */
101
+ renew(): Promise<SeatStatus>;
102
+ /**
103
+ * Gives the seat back; the server frees it at once. Resolves true when the server confirmed it, false when it could
104
+ * not be reached (the seat then frees itself when its token expires). The Seat is released either way.
105
+ */
106
+ release(): Promise<boolean>;
107
+ private serial;
108
+ private schedule;
109
+ private end;
110
+ private lose;
111
+ /** The next heartbeat's or release's body: the counter, the current token's id and, for a device-bound seat, the proof. */
112
+ private next;
113
+ private renewNow;
114
+ /** Tries again later, never past the deadline (the timer then ends the seat). retryAfter: the server's, in seconds. */
115
+ private retry;
116
+ private releaseNow;
117
+ }
118
+ export {};
package/dist/seat.js ADDED
@@ -0,0 +1,225 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Seat = void 0;
4
+ exports.acceptSeat = acceptSeat;
5
+ // Seats for floating licenses (server docs/floating.md): a seat is held only while the app runs. seal.checkout()
6
+ // takes one; the Seat renews itself before its token's renewal time and gives it back with release(). The seat token
7
+ // lives in memory only. There is no offline grace: when renewals fail, the seat ends at its deadline, measured on a
8
+ // monotonic clock from when the token arrived, so the device's clock cannot extend it. When the seat is lost the Seat
9
+ // emits 'lost' with the reason and does nothing else (the app decides what to do).
10
+ const node_events_1 = require("node:events");
11
+ const seal_core_1 = require("@carchalabs/seal-core");
12
+ /** Retries of a renewal that got no answer: 15 s, then 30 s, then every 60 s, never past the deadline. */
13
+ const RETRY_MS = [15_000, 30_000, 60_000];
14
+ /** Answers that mean the session is gone or was taken over: check out again (spec sections 3 and 6). */
15
+ const CHECK_OUT_AGAIN = new Set(['seat_expired', 'seat_conflict', 'seat_not_found']);
16
+ /** No usable answer: try again before the deadline. Every other refusal ends the seat. */
17
+ const TRANSIENT = new Set(['network', 'server_error', 'rate_limited']);
18
+ /** A 2xx whose token fails for one of these did not come from this app's server for this device (as for leases). */
19
+ const NOT_A_TOKEN_FOR_THIS_DEVICE = new Set(['malformed', 'unsupported_algorithm', 'wrong_type', 'unknown_key', 'bad_signature', 'wrong_issuer', 'wrong_product', 'wrong_device']);
20
+ const SEAT_TEXT = {
21
+ expired: 'The license server could not be reached in time, so the seat was given back. Connect to the internet and check out again.',
22
+ device_proof_unavailable: "This seat is bound to this computer's device key, which could not be read. Check out again.",
23
+ };
24
+ /**
25
+ * Verifies a seat token from a checkout or heartbeat answer: the lease's checks with the seat type, then the license,
26
+ * the seat held and the next counter (spec section 2). Returns the held state, or the failure reason.
27
+ */
28
+ function acceptSeat(host, answer, license, held) {
29
+ const token = typeof answer?.token === 'string' ? answer.token : '';
30
+ const licenseId = license ?? (typeof answer?.license?.id === 'string' ? answer.license.id : '');
31
+ const r = (0, seal_core_1.verifySeat)(token, { keys: host.keys, product: host.product, fingerprintHash: host.fingerprintHash, license: licenseId, ...(held ? { held } : {}) });
32
+ if (!r.ok)
33
+ return r.reason;
34
+ const arrived = host.monotonic();
35
+ const { iat, rev, exp } = r.claims;
36
+ const total = answer?.seats?.total;
37
+ return {
38
+ token,
39
+ claims: r.claims,
40
+ renewAt: arrived + (rev - iat) * 1000,
41
+ deadline: arrived + (exp - iat) * 1000,
42
+ seats: { inUse: Number.isInteger(answer?.seats?.inUse) ? answer.seats.inUse : 0, total: Number.isInteger(total) ? total : null },
43
+ license: { id: r.claims.sub, expiresAt: r.claims.lxp === undefined ? null : new Date(r.claims.lxp * 1000).toISOString() },
44
+ };
45
+ }
46
+ class Seat extends node_events_1.EventEmitter {
47
+ host;
48
+ automatic;
49
+ onEnd;
50
+ current;
51
+ state = 'held';
52
+ loss;
53
+ timer;
54
+ retries = 0;
55
+ /** The session is gone (seat_expired, seat_conflict, seat_not_found): the next attempt checks out again. */
56
+ checkOutAgain = false;
57
+ /** Renewals and the release one at a time, in order. */
58
+ queue = Promise.resolve();
59
+ /** @internal Made by Seal.checkout(). */
60
+ constructor(host, held, automatic, onEnd) {
61
+ super();
62
+ this.host = host;
63
+ this.automatic = automatic;
64
+ this.onEnd = onEnd;
65
+ this.current = held;
66
+ this.schedule();
67
+ }
68
+ /** held, lost or released. */
69
+ get status() {
70
+ return this.state;
71
+ }
72
+ /** Why the seat was lost; set when status is lost. */
73
+ get lostReason() {
74
+ return this.loss;
75
+ }
76
+ /** The session's id; a new one after the SDK checked out again on the same seat. */
77
+ get id() {
78
+ return this.current.claims.sid;
79
+ }
80
+ get features() {
81
+ return [...this.current.claims.feat];
82
+ }
83
+ has(feature) {
84
+ return this.state === 'held' && this.current.claims.feat.includes(feature);
85
+ }
86
+ /** Seats in use and the pool's size (null: unlimited), as of the last answer. */
87
+ get seats() {
88
+ return { ...this.current.seats };
89
+ }
90
+ get license() {
91
+ return { ...this.current.license };
92
+ }
93
+ /** When the seat ends if no renewal succeeds before (from the monotonic deadline, shown in this computer's time). */
94
+ get expiresAt() {
95
+ return new Date(this.host.wallMs() + Math.max(0, this.current.deadline - this.host.monotonic())).toISOString();
96
+ }
97
+ /** Renews now. The Seat does this itself before the token's renewal time, unless checked out with renewAutomatically: false. */
98
+ renew() {
99
+ return this.serial(() => this.renewNow());
100
+ }
101
+ /**
102
+ * Gives the seat back; the server frees it at once. Resolves true when the server confirmed it, false when it could
103
+ * not be reached (the seat then frees itself when its token expires). The Seat is released either way.
104
+ */
105
+ release() {
106
+ return this.serial(() => this.releaseNow());
107
+ }
108
+ serial(operation) {
109
+ const run = this.queue.then(operation);
110
+ this.queue = run.catch(() => undefined);
111
+ return run;
112
+ }
113
+ schedule(at) {
114
+ clearTimeout(this.timer);
115
+ this.timer = undefined;
116
+ if (this.state !== 'held' || !this.automatic)
117
+ return;
118
+ const when = Math.min(at ?? this.current.renewAt, this.current.deadline);
119
+ // A held seat never keeps the process alive: when it ends without release(), the seat frees itself at its expiry.
120
+ this.timer = setTimeout(() => void this.renew(), Math.max(0, when - this.host.monotonic()));
121
+ this.timer.unref?.();
122
+ }
123
+ end(state, loss) {
124
+ clearTimeout(this.timer);
125
+ this.timer = undefined;
126
+ this.state = state;
127
+ this.onEnd(this);
128
+ if (loss) {
129
+ this.loss = loss;
130
+ this.emit('lost', loss);
131
+ }
132
+ }
133
+ lose(reason, message) {
134
+ this.end('lost', { reason, message: message ?? SEAT_TEXT[reason] ?? 'The seat was lost.' });
135
+ return this.state;
136
+ }
137
+ /** The next heartbeat's or release's body: the counter, the current token's id and, for a device-bound seat, the proof. */
138
+ next() {
139
+ const { claims, token } = this.current;
140
+ const body = { licenseKey: this.host.licenseKey, product: this.host.product, seq: claims.seq + 1, tokenId: claims.jti };
141
+ if (claims.sep === undefined)
142
+ return body;
143
+ const proof = this.host.proof(token, claims.sub);
144
+ if (!proof || !proof.ok || proof.seq !== claims.seq + 1)
145
+ return null;
146
+ return { ...body, proof: proof.proof };
147
+ }
148
+ async renewNow() {
149
+ if (this.state !== 'held')
150
+ return this.state;
151
+ if (this.host.monotonic() >= this.current.deadline)
152
+ return this.lose('expired');
153
+ let answer;
154
+ let accepted;
155
+ try {
156
+ if (this.checkOutAgain) {
157
+ answer = await this.host.post('/v1/seats/checkout', await this.host.checkoutBody());
158
+ accepted = acceptSeat(this.host, answer, this.current.claims.sub, undefined);
159
+ }
160
+ else {
161
+ const body = this.next();
162
+ if (!body)
163
+ return this.lose('device_proof_unavailable');
164
+ answer = await this.host.post(`/v1/seats/${encodeURIComponent(this.current.claims.sid)}/heartbeat`, body);
165
+ accepted = acceptSeat(this.host, answer, this.current.claims.sub, { sid: this.current.claims.sid, seq: this.current.claims.seq });
166
+ }
167
+ }
168
+ catch (e) {
169
+ if (!this.host.isLicenseError(e))
170
+ throw e;
171
+ if (this.state !== 'held')
172
+ return this.state;
173
+ if (CHECK_OUT_AGAIN.has(e.code) && !this.checkOutAgain) {
174
+ // The session ended or another copy renewed it (a lost answer, spec section 3): the same holder checks out
175
+ // again and keeps its seat.
176
+ this.checkOutAgain = true;
177
+ return this.renewNow();
178
+ }
179
+ if (TRANSIENT.has(e.code) || (e.status !== undefined && e.status >= 500))
180
+ return this.retry(e.code === 'rate_limited' ? Number(e.details?.retryAfter) : undefined);
181
+ return this.lose(e.code, e.message);
182
+ }
183
+ if (this.state !== 'held')
184
+ return this.state;
185
+ if (typeof accepted === 'string') {
186
+ // A 2xx without a seat token from this server for this device (a proxy's page): as no answer.
187
+ if (NOT_A_TOKEN_FOR_THIS_DEVICE.has(accepted))
188
+ return this.retry();
189
+ return this.lose(accepted);
190
+ }
191
+ this.current = accepted;
192
+ this.checkOutAgain = false;
193
+ this.retries = 0;
194
+ this.schedule();
195
+ this.emit('renewed', this);
196
+ return this.state;
197
+ }
198
+ /** Tries again later, never past the deadline (the timer then ends the seat). retryAfter: the server's, in seconds. */
199
+ retry(retryAfterSeconds) {
200
+ const wait = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : RETRY_MS[Math.min(this.retries, RETRY_MS.length - 1)];
201
+ this.retries++;
202
+ this.schedule(this.host.monotonic() + wait);
203
+ return this.state;
204
+ }
205
+ async releaseNow() {
206
+ if (this.state !== 'held')
207
+ return this.state === 'released';
208
+ const body = this.checkOutAgain ? null : this.next();
209
+ // Ended here first: a renewal timer must not run while the release is on its way.
210
+ this.end('released');
211
+ if (!body)
212
+ return false;
213
+ try {
214
+ await this.host.post(`/v1/seats/${encodeURIComponent(this.current.claims.sid)}/release`, body);
215
+ return true;
216
+ }
217
+ catch (e) {
218
+ if (!this.host.isLicenseError(e))
219
+ throw e;
220
+ // Already gone: nothing is held on the server.
221
+ return e.code === 'seat_expired' || e.code === 'seat_not_found';
222
+ }
223
+ }
224
+ }
225
+ exports.Seat = Seat;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@carchalabs/seal-electron",
3
- "version": "0.6.2",
4
- "description": "Carcha Seal (formerly LicenseKit) SDK for the Electron main process and Node: activation, offline verification, grace period",
3
+ "version": "0.8.0",
4
+ "description": "Carcha Seal SDK for the Electron main process and Node: activation, offline verification, grace period",
5
5
  "keywords": ["electron", "licensing", "license-key", "software-licensing", "offline-licensing", "node-locked", "ed25519", "jws", "carcha-seal"],
6
6
  "homepage": "https://seal.carchalabs.com/docs/",
7
7
  "repository": { "type": "git", "url": "git+https://github.com/carcha-labs/carcha-seal-sdk.git", "directory": "packages/sdk-electron" },
@@ -15,10 +15,10 @@
15
15
  "test": "node --no-warnings=ExperimentalWarning --test dist/*.test.js"
16
16
  },
17
17
  "dependencies": {
18
- "@carchalabs/seal-core": "0.5.1"
18
+ "@carchalabs/seal-core": "0.6.0"
19
19
  },
20
20
  "optionalDependencies": {
21
- "@carchalabs/seal-hwid": "0.4.1"
21
+ "@carchalabs/seal-hwid": "0.6.0"
22
22
  },
23
23
  "engines": { "node": ">=22.13" },
24
24
  "publishConfig": { "access": "public" },