@carchalabs/seal-electron 0.6.1 → 0.7.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');
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,18 +91,31 @@ 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
  /**
99
108
  * The device's public key, sent with activation and check-ins so the server wraps feature keys for
100
- * this machine only. Only when the native module verifies for this app (it then also decrypts, with
101
- * the matching secret it derives itself) and hardware is not turned off. Never throws.
109
+ * this machine only. Sent when the native module can also decrypt what is wrapped for it, with the
110
+ * matching secret it derives itself: through the compiled-in keys when they verify for this app, or,
111
+ * with seal-hwid 0.5+, through this app's own keys (every hosted vendor, a self-hosted server). Not
112
+ * with hardware turned off. Never throws.
102
113
  */
103
114
  private deviceKey;
115
+ /** The native module's decryption with this app's own keys (seal-hwid 0.5+), or null: older module, none, or hardware off. */
116
+ private deviceDecrypter;
117
+ /** This app's public keys as SPKI PEM, for the native module. */
118
+ private keyPems;
104
119
  /**
105
120
  * The native verifier, used only when its compiled-in keys include every key this app was given:
106
121
  * an app that trusts other keys (a self-hosted server) is checked by the JavaScript verifier alone.
@@ -115,15 +130,18 @@ export declare class Seal {
115
130
  private stateFromLease;
116
131
  /** 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
132
  activate(licenseKey: string, machineName?: string): Promise<LicenseState>;
133
+ private activateNow;
118
134
  check(): Promise<LicenseState>;
135
+ private checkNow;
119
136
  /** The state from the saved record when the server gave no usable answer; a usable lease moves lastSeen forward. */
120
137
  private offlineCheck;
121
138
  /**
122
139
  * Decrypts data you encrypted for a feature (encryptForFeature in @carchalabs/seal-core, with the
123
140
  * feature's content key from the console). Works offline: it uses the saved lease, which must be valid
124
141
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
125
- * The native module does the work when it trusts this app's keys, so the content key never reaches
126
- * JavaScript; without it the JavaScript implementation is used. Throws a LicenseError:
142
+ * When the lease is wrapped for this machine's device key, the native module does the work (with its
143
+ * compiled-in keys or, seal-hwid 0.5+, this app's keys), so the device secret and the content key never
144
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript. Throws a LicenseError:
127
145
  * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
128
146
  */
129
147
  decrypt(feature: string, data: Uint8Array): Promise<Buffer>;
@@ -158,13 +176,16 @@ export declare class Seal {
158
176
  * LicenseError: `invalid_file`, `wrong_product`, or a lease failure reason such as `wrong_device`.
159
177
  */
160
178
  activateOffline(licenseFile: string): Promise<LicenseState>;
179
+ private activateOfflineNow;
161
180
  /**
162
181
  * Gives an offline machine's seat back: removes the license from this device and returns the
163
182
  * deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
164
183
  * Throws `unlicensed` when there is no license to remove.
165
184
  */
166
185
  deactivateOffline(): Promise<string>;
186
+ private deactivateOfflineNow;
167
187
  deactivate(): Promise<void>;
188
+ private deactivateNow;
168
189
  static hasFeature(state: LicenseState, feature: string): boolean;
169
190
  }
170
191
  /** 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;
@@ -235,17 +311,20 @@ class Seal {
235
311
  }
236
312
  /**
237
313
  * The device's public key, sent with activation and check-ins so the server wraps feature keys for
238
- * this machine only. Only when the native module verifies for this app (it then also decrypts, with
239
- * the matching secret it derives itself) and hardware is not turned off. Never throws.
314
+ * this machine only. Sent when the native module can also decrypt what is wrapped for it, with the
315
+ * matching secret it derives itself: through the compiled-in keys when they verify for this app, or,
316
+ * with seal-hwid 0.5+, through this app's own keys (every hosted vendor, a self-hosted server). Not
317
+ * with hardware turned off. Never throws.
240
318
  */
241
319
  deviceKey() {
242
320
  if (this.deviceKeyCache === undefined) {
243
321
  let key = null;
244
322
  try {
323
+ const hwid = this.opts.hardware === false ? null : loadHwid();
245
324
  const native = this.opts.hardware === false ? null : this.native();
246
- if (native && typeof native.devicePublicKey === 'function' && typeof native.decryptFeature === 'function') {
247
- key = native.devicePublicKey(`licensekit:${this.opts.product}`);
248
- }
325
+ const decrypts = (native && typeof native.decryptFeature === 'function') || this.deviceDecrypter() !== null;
326
+ if (hwid && typeof hwid.devicePublicKey === 'function' && decrypts)
327
+ key = hwid.devicePublicKey(`licensekit:${this.opts.product}`);
249
328
  }
250
329
  catch {
251
330
  key = null;
@@ -254,6 +333,17 @@ class Seal {
254
333
  }
255
334
  return this.deviceKeyCache ? { devicePublicKey: this.deviceKeyCache } : {};
256
335
  }
336
+ /** The native module's decryption with this app's own keys (seal-hwid 0.5+), or null: older module, none, or hardware off. */
337
+ deviceDecrypter() {
338
+ if (this.opts.hardware === false)
339
+ return null;
340
+ const hwid = loadHwid();
341
+ return hwid && typeof hwid.decryptFeatureWithKeys === 'function' ? hwid : null;
342
+ }
343
+ /** This app's public keys as SPKI PEM, for the native module. */
344
+ keyPems() {
345
+ return [...this.keys.values()].map((key) => key.export({ type: 'spki', format: 'pem' }).toString());
346
+ }
257
347
  /**
258
348
  * The native verifier, used only when its compiled-in keys include every key this app was given:
259
349
  * an app that trusts other keys (a self-hosted server) is checked by the JavaScript verifier alone.
@@ -334,7 +424,10 @@ class Seal {
334
424
  return this.emptyState('invalid', offline, { reason: strict.reason, message: REASON_TEXT[strict.reason], keyHint });
335
425
  }
336
426
  /** 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) {
427
+ activate(licenseKey, machineName) {
428
+ return this.serial(() => this.activateNow(licenseKey, machineName));
429
+ }
430
+ async activateNow(licenseKey, machineName) {
338
431
  const fingerprint = await this.fingerprint();
339
432
  const key = licenseKey.trim().toUpperCase();
340
433
  const response = await this.post('/v1/activate', {
@@ -355,7 +448,10 @@ class Seal {
355
448
  await this.save(stored);
356
449
  return state;
357
450
  }
358
- async check() {
451
+ check() {
452
+ return this.serial(() => this.checkNow());
453
+ }
454
+ async checkNow() {
359
455
  try {
360
456
  await this.fingerprint();
361
457
  }
@@ -390,11 +486,11 @@ class Seal {
390
486
  }
391
487
  catch (e) {
392
488
  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() } });
489
+ // Persist the refusal: drop the signed lease so going offline cannot bring the license back. The key is kept, also
490
+ // for invalid_key and license_revoked: a refusal is not signed, so anything that answers on the connection (a
491
+ // TLS-inspecting proxy) could send one, and the next check-in the server answers with a lease restores the
492
+ // license. A refusal that is real stays one: the server refuses the kept key again.
493
+ await this.save({ licenseKey: stored.licenseKey, lastSeen: stored.lastSeen, refused: { code: e.code, message: e.message, at: this.nowSec() } });
398
494
  const keyHint = `${stored.licenseKey.slice(0, 7)}…${stored.licenseKey.slice(-4)}`;
399
495
  return this.emptyState('invalid', false, { reason: e.code, message: e.message, keyHint });
400
496
  }
@@ -428,8 +524,9 @@ class Seal {
428
524
  * Decrypts data you encrypted for a feature (encryptForFeature in @carchalabs/seal-core, with the
429
525
  * feature's content key from the console). Works offline: it uses the saved lease, which must be valid
430
526
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
431
- * The native module does the work when it trusts this app's keys, so the content key never reaches
432
- * JavaScript; without it the JavaScript implementation is used. Throws a LicenseError:
527
+ * When the lease is wrapped for this machine's device key, the native module does the work (with its
528
+ * compiled-in keys or, seal-hwid 0.5+, this app's keys), so the device secret and the content key never
529
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript. Throws a LicenseError:
433
530
  * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
434
531
  */
435
532
  async decrypt(feature, data) {
@@ -499,10 +596,27 @@ class Seal {
499
596
  const graceSeconds = state.status === 'grace' ? (claims?.grc ?? (this.opts.graceDays ?? DEFAULT_GRACE_DAYS) * 86400) : 0;
500
597
  const check = { product: this.opts.product, fingerprintHash, now: this.nowSec(), ...(graceSeconds ? { graceSeconds } : {}) };
501
598
  const native = this.native();
599
+ // A lease wrapped for this machine's device key (epk) opens only in the native module, which holds the device
600
+ // secret: through the compiled-in keys when they verify for this app, else through this app's keys (seal-hwid 0.5+).
601
+ const deviceBound = claims?.epk !== undefined;
602
+ const withKeys = deviceBound && !(native && typeof native.decryptFeature === 'function') ? this.deviceDecrypter() : null;
603
+ const pems = withKeys ? this.keyPems() : [];
502
604
  return (data) => {
503
- const result = native && typeof native.decryptFeature === 'function'
504
- ? native.decryptFeature(leased.token, check, leased.licenseKey, feature, Buffer.from(data))
505
- : (0, seal_core_1.decryptFeature)(leased.token, { keys: this.keys, ...check }, leased.licenseKey, feature, Buffer.from(data));
605
+ let result;
606
+ if (native && typeof native.decryptFeature === 'function') {
607
+ result = native.decryptFeature(leased.token, check, leased.licenseKey, feature, Buffer.from(data));
608
+ }
609
+ else if (withKeys) {
610
+ try {
611
+ result = withKeys.decryptFeatureWithKeys(leased.token, check, pems, leased.licenseKey, feature, Buffer.from(data));
612
+ }
613
+ catch {
614
+ result = { ok: false, reason: 'decrypt_failed' };
615
+ }
616
+ }
617
+ else {
618
+ result = (0, seal_core_1.decryptFeature)(leased.token, { keys: this.keys, ...check }, leased.licenseKey, feature, Buffer.from(data));
619
+ }
506
620
  if (result.ok && result.data)
507
621
  return Buffer.from(result.data);
508
622
  if (result.reason === 'not_licensed')
@@ -539,7 +653,10 @@ class Seal {
539
653
  * check() works without the server until the lease's renewal date plus the offline grace. Throws a
540
654
  * LicenseError: `invalid_file`, `wrong_product`, or a lease failure reason such as `wrong_device`.
541
655
  */
542
- async activateOffline(licenseFile) {
656
+ activateOffline(licenseFile) {
657
+ return this.serial(() => this.activateOfflineNow(licenseFile));
658
+ }
659
+ async activateOfflineNow(licenseFile) {
543
660
  await this.fingerprint();
544
661
  let file;
545
662
  try {
@@ -566,14 +683,20 @@ class Seal {
566
683
  * deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
567
684
  * Throws `unlicensed` when there is no license to remove.
568
685
  */
569
- async deactivateOffline() {
686
+ deactivateOffline() {
687
+ return this.serial(() => this.deactivateOfflineNow());
688
+ }
689
+ async deactivateOfflineNow() {
570
690
  const stored = await this.load();
571
691
  if (!stored?.token)
572
692
  throw new LicenseError('unlicensed', 'This device has no license.');
573
693
  await this.clear();
574
694
  return JSON.stringify({ format: OFFLINE_DEACTIVATION_FORMAT, version: 1, product: this.opts.product, token: stored.token }, null, 2);
575
695
  }
576
- async deactivate() {
696
+ deactivate() {
697
+ return this.serial(() => this.deactivateNow());
698
+ }
699
+ async deactivateNow() {
577
700
  const stored = await this.load();
578
701
  if (!stored)
579
702
  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.7.0",
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/",
@@ -18,7 +18,7 @@
18
18
  "@carchalabs/seal-core": "0.5.1"
19
19
  },
20
20
  "optionalDependencies": {
21
- "@carchalabs/seal-hwid": "0.4.1"
21
+ "@carchalabs/seal-hwid": "0.5.0"
22
22
  },
23
23
  "engines": { "node": ">=22.13" },
24
24
  "publishConfig": { "access": "public" },