@carchalabs/seal-electron 0.7.0 → 0.8.1
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 +13 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +166 -5
- package/dist/seat.d.ts +118 -0
- package/dist/seat.js +225 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -186,8 +212,44 @@ export declare class Seal {
|
|
|
186
212
|
private deactivateOfflineNow;
|
|
187
213
|
deactivate(): Promise<void>;
|
|
188
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;
|
|
189
233
|
static hasFeature(state: LicenseState, feature: string): boolean;
|
|
190
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;
|
|
191
253
|
/** The SDK class under its earlier name (LicenseKit). Same class; use Seal in new code. */
|
|
192
254
|
export { Seal as LicenseKit };
|
|
193
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {}) {
|
|
@@ -714,9 +735,149 @@ class Seal {
|
|
|
714
735
|
}
|
|
715
736
|
await this.clear();
|
|
716
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
|
+
}
|
|
717
831
|
static hasFeature(state, feature) {
|
|
718
832
|
return (state.status === 'valid' || state.status === 'grace') && state.features.includes(feature);
|
|
719
833
|
}
|
|
720
834
|
}
|
|
721
835
|
exports.Seal = Seal;
|
|
722
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.
|
|
4
|
-
"description": "Carcha Seal
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
18
|
+
"@carchalabs/seal-core": "0.6.0"
|
|
19
19
|
},
|
|
20
20
|
"optionalDependencies": {
|
|
21
|
-
"@carchalabs/seal-hwid": "0.
|
|
21
|
+
"@carchalabs/seal-hwid": "0.6.1"
|
|
22
22
|
},
|
|
23
23
|
"engines": { "node": ">=22.13" },
|
|
24
24
|
"publishConfig": { "access": "public" },
|