@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 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 license.dat.tmp, then renamed over it, so a crash or full disk mid-write
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 license.dat.tmp, then renamed over it, so a crash or full disk mid-write
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 temp = this.file + '.tmp';
200
- try {
201
- await (0, promises_1.writeFile)(temp, this.opts.secureStore ? await this.opts.secureStore.encrypt(text) : text, { mode: 0o600, flush: true });
202
- await (0, promises_1.rename)(temp, this.file);
203
- }
204
- catch (e) {
205
- await (0, promises_1.rm)(temp, { force: true }).catch(() => { });
206
- throw e;
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 json = await res.json().catch(() => ({}));
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
- async activate(licenseKey, machineName) {
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
- async check() {
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
- if (FORGET_KEY.has(e.code))
395
- await this.clear();
396
- else
397
- await this.save({ licenseKey: stored.licenseKey, lastSeen: stored.lastSeen, refused: { code: e.code, message: e.message, at: this.nowSec() } });
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
- async activateOffline(licenseFile) {
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
- async deactivateOffline() {
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
- async deactivate() {
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.1",
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/",