@carchalabs/seal-electron 0.6.1 → 0.6.2
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/dist/index.d.ts +16 -2
- package/dist/index.js +115 -24
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -80,6 +80,8 @@ export declare class Seal {
|
|
|
80
80
|
/** The native lease verifier, when it trusts every key this app was given (undefined = not looked up yet). */
|
|
81
81
|
private nativeLease?;
|
|
82
82
|
private deviceKeyCache?;
|
|
83
|
+
/** The end of the operations waiting to run; see serial(). */
|
|
84
|
+
private queue;
|
|
83
85
|
constructor(opts: SealOptions);
|
|
84
86
|
private get fetch();
|
|
85
87
|
private nowSec;
|
|
@@ -89,10 +91,17 @@ export declare class Seal {
|
|
|
89
91
|
private hardware;
|
|
90
92
|
/** The saved record; null when there is none, or it cannot be read or is not a record (as no license). */
|
|
91
93
|
private load;
|
|
92
|
-
/** Replaces license.dat whole: written to
|
|
93
|
-
* leaves the previous record. */
|
|
94
|
+
/** Replaces license.dat whole: written to a temporary file of this save, then renamed over it, so a crash or full disk
|
|
95
|
+
* mid-write leaves the previous record. */
|
|
94
96
|
private save;
|
|
95
97
|
private clear;
|
|
98
|
+
/**
|
|
99
|
+
* Runs the operations that change license.dat one at a time, in the order they were called, as the native SDKs' lock
|
|
100
|
+
* does. Node.js calls writing one file from calls that overlap unsafe, and the answer to an earlier call (a slow
|
|
101
|
+
* check-in for the old key) must not replace what a later one saved (an activation with a new key). Reads
|
|
102
|
+
* (decrypt, requireEncrypted) do not wait: the file is replaced whole, so a read sees one record or the other.
|
|
103
|
+
*/
|
|
104
|
+
private serial;
|
|
96
105
|
private post;
|
|
97
106
|
private emptyState;
|
|
98
107
|
/**
|
|
@@ -115,7 +124,9 @@ export declare class Seal {
|
|
|
115
124
|
private stateFromLease;
|
|
116
125
|
/** machineName: the name to show the vendor for this device. Nothing is sent when the app gives none (host names often hold a person's name). */
|
|
117
126
|
activate(licenseKey: string, machineName?: string): Promise<LicenseState>;
|
|
127
|
+
private activateNow;
|
|
118
128
|
check(): Promise<LicenseState>;
|
|
129
|
+
private checkNow;
|
|
119
130
|
/** The state from the saved record when the server gave no usable answer; a usable lease moves lastSeen forward. */
|
|
120
131
|
private offlineCheck;
|
|
121
132
|
/**
|
|
@@ -158,13 +169,16 @@ export declare class Seal {
|
|
|
158
169
|
* LicenseError: `invalid_file`, `wrong_product`, or a lease failure reason such as `wrong_device`.
|
|
159
170
|
*/
|
|
160
171
|
activateOffline(licenseFile: string): Promise<LicenseState>;
|
|
172
|
+
private activateOfflineNow;
|
|
161
173
|
/**
|
|
162
174
|
* Gives an offline machine's seat back: removes the license from this device and returns the
|
|
163
175
|
* deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
|
|
164
176
|
* Throws `unlicensed` when there is no license to remove.
|
|
165
177
|
*/
|
|
166
178
|
deactivateOffline(): Promise<string>;
|
|
179
|
+
private deactivateOfflineNow;
|
|
167
180
|
deactivate(): Promise<void>;
|
|
181
|
+
private deactivateNow;
|
|
168
182
|
static hasFeature(state: LicenseState, feature: string): boolean;
|
|
169
183
|
}
|
|
170
184
|
/** The SDK class under its earlier name (LicenseKit). Same class; use Seal in new code. */
|
package/dist/index.js
CHANGED
|
@@ -35,11 +35,44 @@ function loadHwid() {
|
|
|
35
35
|
}
|
|
36
36
|
return hwidModule;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 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
|
+
* answer, so a broken or hostile server cannot make the app hold and parse hundreds of megabytes.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_ANSWER_BYTES = 1 << 20;
|
|
43
|
+
/** The answer's body as text; null when it is longer than MAX_ANSWER_BYTES or could not be read to its end. */
|
|
44
|
+
async function readLimited(res) {
|
|
45
|
+
const declared = Number(res.headers.get('content-length') ?? NaN);
|
|
46
|
+
if (declared > MAX_ANSWER_BYTES || !res.body) {
|
|
47
|
+
await res.body?.cancel().catch(() => { });
|
|
48
|
+
return res.body ? null : '';
|
|
49
|
+
}
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let length = 0;
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done)
|
|
57
|
+
break;
|
|
58
|
+
length += value.byteLength;
|
|
59
|
+
if (length > MAX_ANSWER_BYTES) {
|
|
60
|
+
await reader.cancel().catch(() => { });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
chunks.push(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// UTF-8 as response.json() decodes it (a byte order mark dropped).
|
|
70
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
71
|
+
}
|
|
72
|
+
/** Numbers this process's temporary files (see Seal.save). */
|
|
73
|
+
let tempCounter = 0;
|
|
38
74
|
/** The server accepts at most this many components. */
|
|
39
75
|
const MAX_COMPONENTS = 32;
|
|
40
|
-
/** The license itself is gone: forget the key too. The other refusals can be undone on the server (resume, renew,
|
|
41
|
-
* re-activate), so the key is kept and the next successful online check restores the license on its own. */
|
|
42
|
-
const FORGET_KEY = new Set(['invalid_key', 'license_revoked']);
|
|
43
76
|
const CLOCK_TOLERANCE_SECONDS = 24 * 3600;
|
|
44
77
|
/**
|
|
45
78
|
* A check-in answered 2xx whose lease fails for one of these reasons did not come from this app's license server for
|
|
@@ -128,6 +161,8 @@ class Seal {
|
|
|
128
161
|
/** The native lease verifier, when it trusts every key this app was given (undefined = not looked up yet). */
|
|
129
162
|
nativeLease;
|
|
130
163
|
deviceKeyCache;
|
|
164
|
+
/** The end of the operations waiting to run; see serial(). */
|
|
165
|
+
queue = Promise.resolve();
|
|
131
166
|
constructor(opts) {
|
|
132
167
|
this.opts = opts;
|
|
133
168
|
checkServerUrl(opts.serverUrl);
|
|
@@ -191,24 +226,53 @@ class Seal {
|
|
|
191
226
|
return null;
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
|
-
/** Replaces license.dat whole: written to
|
|
195
|
-
* leaves the previous record. */
|
|
229
|
+
/** Replaces license.dat whole: written to a temporary file of this save, then renamed over it, so a crash or full disk
|
|
230
|
+
* mid-write leaves the previous record. */
|
|
196
231
|
async save(stored) {
|
|
197
232
|
await (0, promises_1.mkdir)(this.opts.storageDir, { recursive: true });
|
|
198
233
|
const text = JSON.stringify(stored);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
234
|
+
const data = this.opts.secureStore ? await this.opts.secureStore.encrypt(text) : text;
|
|
235
|
+
// license.dat.<pid>.<n>.tmp, created only if it does not exist (flag wx; SEI CERT FIO21-C: a unique name, opened
|
|
236
|
+
// exclusively, owner-only), so two app instances saving at the same time each write and rename their own file.
|
|
237
|
+
// A name left by a process that crashed is skipped.
|
|
238
|
+
for (let attempt = 0;; attempt++) {
|
|
239
|
+
const temp = `${this.file}.${process.pid}.${tempCounter++}.tmp`;
|
|
240
|
+
try {
|
|
241
|
+
await (0, promises_1.writeFile)(temp, data, { mode: 0o600, flush: true, flag: 'wx' });
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
if (e.code === 'EEXIST') {
|
|
245
|
+
if (attempt < 100)
|
|
246
|
+
continue; // another file's name: not ours to remove
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
await (0, promises_1.rm)(temp, { force: true }).catch(() => { }); // ours, cut off mid-write
|
|
250
|
+
throw e;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await (0, promises_1.rename)(temp, this.file);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
await (0, promises_1.rm)(temp, { force: true }).catch(() => { });
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
207
260
|
}
|
|
208
261
|
}
|
|
209
262
|
async clear() {
|
|
210
263
|
await (0, promises_1.rm)(this.file, { force: true });
|
|
211
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Runs the operations that change license.dat one at a time, in the order they were called, as the native SDKs' lock
|
|
267
|
+
* does. Node.js calls writing one file from calls that overlap unsafe, and the answer to an earlier call (a slow
|
|
268
|
+
* check-in for the old key) must not replace what a later one saved (an activation with a new key). Reads
|
|
269
|
+
* (decrypt, requireEncrypted) do not wait: the file is replaced whole, so a read sees one record or the other.
|
|
270
|
+
*/
|
|
271
|
+
serial(operation) {
|
|
272
|
+
const run = this.queue.then(operation);
|
|
273
|
+
this.queue = run.catch(() => undefined);
|
|
274
|
+
return run;
|
|
275
|
+
}
|
|
212
276
|
async post(path, body) {
|
|
213
277
|
let res;
|
|
214
278
|
try {
|
|
@@ -225,7 +289,19 @@ class Seal {
|
|
|
225
289
|
catch {
|
|
226
290
|
throw new LicenseError('network', 'Could not reach the license server.');
|
|
227
291
|
}
|
|
228
|
-
const
|
|
292
|
+
const text = await readLimited(res);
|
|
293
|
+
// Longer than the limit, or cut off (the timeout, a broken connection): no answer, as in the native SDKs.
|
|
294
|
+
if (text === null)
|
|
295
|
+
throw new LicenseError('network', 'Could not reach the license server.');
|
|
296
|
+
let json;
|
|
297
|
+
try {
|
|
298
|
+
json = JSON.parse(text);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
json = {};
|
|
302
|
+
}
|
|
303
|
+
if (typeof json !== 'object' || json === null)
|
|
304
|
+
json = {};
|
|
229
305
|
if (!res.ok)
|
|
230
306
|
throw new LicenseError(json.error ?? 'server_error', json.message ?? `License server error (${res.status})`, res.status);
|
|
231
307
|
return json;
|
|
@@ -334,7 +410,10 @@ class Seal {
|
|
|
334
410
|
return this.emptyState('invalid', offline, { reason: strict.reason, message: REASON_TEXT[strict.reason], keyHint });
|
|
335
411
|
}
|
|
336
412
|
/** machineName: the name to show the vendor for this device. Nothing is sent when the app gives none (host names often hold a person's name). */
|
|
337
|
-
|
|
413
|
+
activate(licenseKey, machineName) {
|
|
414
|
+
return this.serial(() => this.activateNow(licenseKey, machineName));
|
|
415
|
+
}
|
|
416
|
+
async activateNow(licenseKey, machineName) {
|
|
338
417
|
const fingerprint = await this.fingerprint();
|
|
339
418
|
const key = licenseKey.trim().toUpperCase();
|
|
340
419
|
const response = await this.post('/v1/activate', {
|
|
@@ -355,7 +434,10 @@ class Seal {
|
|
|
355
434
|
await this.save(stored);
|
|
356
435
|
return state;
|
|
357
436
|
}
|
|
358
|
-
|
|
437
|
+
check() {
|
|
438
|
+
return this.serial(() => this.checkNow());
|
|
439
|
+
}
|
|
440
|
+
async checkNow() {
|
|
359
441
|
try {
|
|
360
442
|
await this.fingerprint();
|
|
361
443
|
}
|
|
@@ -390,11 +472,11 @@ class Seal {
|
|
|
390
472
|
}
|
|
391
473
|
catch (e) {
|
|
392
474
|
if (e instanceof LicenseError && DEFINITIVE.has(e.code)) {
|
|
393
|
-
// Persist the refusal: drop the signed lease so going offline cannot bring the license back.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
475
|
+
// Persist the refusal: drop the signed lease so going offline cannot bring the license back. The key is kept, also
|
|
476
|
+
// for invalid_key and license_revoked: a refusal is not signed, so anything that answers on the connection (a
|
|
477
|
+
// TLS-inspecting proxy) could send one, and the next check-in the server answers with a lease restores the
|
|
478
|
+
// license. A refusal that is real stays one: the server refuses the kept key again.
|
|
479
|
+
await this.save({ licenseKey: stored.licenseKey, lastSeen: stored.lastSeen, refused: { code: e.code, message: e.message, at: this.nowSec() } });
|
|
398
480
|
const keyHint = `${stored.licenseKey.slice(0, 7)}…${stored.licenseKey.slice(-4)}`;
|
|
399
481
|
return this.emptyState('invalid', false, { reason: e.code, message: e.message, keyHint });
|
|
400
482
|
}
|
|
@@ -539,7 +621,10 @@ class Seal {
|
|
|
539
621
|
* check() works without the server until the lease's renewal date plus the offline grace. Throws a
|
|
540
622
|
* LicenseError: `invalid_file`, `wrong_product`, or a lease failure reason such as `wrong_device`.
|
|
541
623
|
*/
|
|
542
|
-
|
|
624
|
+
activateOffline(licenseFile) {
|
|
625
|
+
return this.serial(() => this.activateOfflineNow(licenseFile));
|
|
626
|
+
}
|
|
627
|
+
async activateOfflineNow(licenseFile) {
|
|
543
628
|
await this.fingerprint();
|
|
544
629
|
let file;
|
|
545
630
|
try {
|
|
@@ -566,14 +651,20 @@ class Seal {
|
|
|
566
651
|
* deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
|
|
567
652
|
* Throws `unlicensed` when there is no license to remove.
|
|
568
653
|
*/
|
|
569
|
-
|
|
654
|
+
deactivateOffline() {
|
|
655
|
+
return this.serial(() => this.deactivateOfflineNow());
|
|
656
|
+
}
|
|
657
|
+
async deactivateOfflineNow() {
|
|
570
658
|
const stored = await this.load();
|
|
571
659
|
if (!stored?.token)
|
|
572
660
|
throw new LicenseError('unlicensed', 'This device has no license.');
|
|
573
661
|
await this.clear();
|
|
574
662
|
return JSON.stringify({ format: OFFLINE_DEACTIVATION_FORMAT, version: 1, product: this.opts.product, token: stored.token }, null, 2);
|
|
575
663
|
}
|
|
576
|
-
|
|
664
|
+
deactivate() {
|
|
665
|
+
return this.serial(() => this.deactivateNow());
|
|
666
|
+
}
|
|
667
|
+
async deactivateNow() {
|
|
577
668
|
const stored = await this.load();
|
|
578
669
|
if (!stored)
|
|
579
670
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carchalabs/seal-electron",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Carcha Seal (formerly LicenseKit) 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/",
|