@carchalabs/seal-electron 0.8.0 → 0.9.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
@@ -23,6 +23,8 @@ if (Seal.hasFeature(state, 'export-pdf')) exportPdf();
23
23
 
24
24
  `check()` returns one of `valid`, `grace`, `invalid`, `unlicensed` or `clock_suspect`, with `offline` and `storageProtected` flags. A refusal from the server (suspended, expired, device removed, update required) is kept while offline and cleared by the next successful check-in. `notices` carries what the server asks your app to act on, such as `sdk_update_recommended` or `app_update_recommended` with a `minimum` version and a `requiredFrom` date; the SDK shows nothing itself. `activate(key, machineName)` sends the device name you pass, and none when you pass nothing (a computer's host name often holds its user's name). `deactivate()` releases the seat.
25
25
 
26
+ A saved license that is there and cannot be read (your `secureStore` cannot decrypt it, the file is damaged, or it is not a license record) is not `unlicensed`: `check()` answers `invalid` with `reason: 'storage_unreadable'`, and `deactivate()`, `deactivateOffline()`, `decrypt()` and a `checkout()` without a key throw that code. Nothing is removed. Ask for the license key again (`activate`), or have the offline license file loaded again (`activateOffline`): either replaces the record. A save is read back through your `secureStore` before it replaces the previous record; if what was written cannot be read back, the call throws `storage_error` and the previous record stays.
27
+
26
28
  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
29
 
28
30
  **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:
@@ -41,7 +43,7 @@ Computers that never reach the server use offline activation: `offlineRequest(ke
41
43
 
42
44
  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).
43
45
 
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.
46
+ **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` the SDK sends the computer's device key, the server wraps the feature keys for it, and the native module does the decryption: the content key never reaches JavaScript, and a copied `license.dat` opens nothing on another computer. A lease wrapped for the license key alone (no `seal-hwid` build for the platform, `hardware: false`, or a lease from before the device key was sent) is opened in JavaScript, where the content key can be read. `requireNativeDecrypt: true` refuses that case: `decrypt` and `requireEncrypted` then throw `hardware_required`. On the server, a policy with `featureKeyDeviceProof: required` sends no feature keys to a device without a device key.
45
47
 
46
48
  **Hardware components.** When `@carchalabs/seal-hwid` is installed, the SDK also sends one keyed hash per hardware part (motherboard, disk, network card, OS install, CPU and graphics models) and whether the computer is a virtual machine. The license server then recognises the computer after a new disk, network card or operating system install, and refuses a copied disk or system image on other hardware. Raw serial numbers never leave the computer. Without the package, or on a platform it has no module for, nothing extra is sent. `hardware: false` turns it off. Bundlers must leave the package to Node (mark it external); electron-builder unpacks its native module from the asar archive.
47
49
 
package/dist/index.d.ts CHANGED
@@ -20,6 +20,10 @@ export interface LicenseState {
20
20
  status: LicenseStatus;
21
21
  offline: boolean;
22
22
  storageProtected: boolean;
23
+ /**
24
+ * Why the status is what it is: a lease failure, the server's refusal, or `storage_unreadable` (status `invalid`):
25
+ * a saved license is there and cannot be read, so ask for the key again or load the offline license file again.
26
+ */
23
27
  reason?: string;
24
28
  message?: string;
25
29
  customer?: string;
@@ -67,6 +71,15 @@ export interface SealOptions {
67
71
  * sent when it is not (or cannot load on this platform). false: never send them. A function: your own.
68
72
  */
69
73
  hardware?: false | (() => HardwareReport | null | Promise<HardwareReport | null>);
74
+ /**
75
+ * Decrypt feature data only when the lease's feature keys are wrapped for this machine's device key and the native
76
+ * module opens them, so neither the device secret nor the content key reaches JavaScript and a copied license.dat
77
+ * opens nothing. Default false: a lease wrapped for the license key alone (no native module for this platform,
78
+ * `hardware: false`, or a server that issued it before the device key was sent) is opened in JavaScript, as before.
79
+ * true: decrypt and requireEncrypted throw `hardware_required` instead. The server's policy setting
80
+ * featureKeyDeviceProof: required is what keeps such keys off devices without a device key.
81
+ */
82
+ requireNativeDecrypt?: boolean;
70
83
  now?: () => number;
71
84
  /** Milliseconds on a monotonic clock, for how long a seat lasts (default performance.now). For tests. */
72
85
  monotonic?: () => number;
@@ -115,10 +128,16 @@ export declare class Seal {
115
128
  fingerprint(): Promise<string>;
116
129
  /** Read once per instance: hardware does not change while the app runs. Never throws. */
117
130
  private hardware;
118
- /** The saved record; null when there is none, or it cannot be read or is not a record (as no license). */
131
+ /**
132
+ * The saved record; null when there is none. A license.dat that is there and cannot be read (the secure store cannot
133
+ * decrypt it, it is damaged, or it is not a record) is not "no license": this throws `storage_unreadable`, so a
134
+ * licensed user is not shown a first-run screen. A new activation replaces the file.
135
+ */
119
136
  private load;
120
- /** Replaces license.dat whole: written to a temporary file of this save, then renamed over it, so a crash or full disk
121
- * mid-write leaves the previous record. */
137
+ /** The saved record, or null when there is none or it cannot be read: for a step that is about to replace it. */
138
+ private loadIfReadable;
139
+ /** Replaces license.dat whole: written to a temporary file of this save, read back, then renamed over it, so a crash,
140
+ * a full disk mid-write or a secure store that cannot decrypt what it encrypted leaves the previous record. */
122
141
  private save;
123
142
  private clear;
124
143
  /**
@@ -167,8 +186,9 @@ export declare class Seal {
167
186
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
168
187
  * When the lease is wrapped for this machine's device key, the native module does the work (with its
169
188
  * 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:
171
- * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
189
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript, or, with requireNativeDecrypt,
190
+ * refused (`hardware_required`). Throws a LicenseError: `unlicensed`, `storage_unreadable`, a lease failure reason, `clock_suspect`,
191
+ * `feature_not_licensed`, `hardware_required` or `decrypt_failed`.
172
192
  */
173
193
  decrypt(feature: string, data: Uint8Array): Promise<Buffer>;
174
194
  /**
@@ -188,6 +208,8 @@ export declare class Seal {
188
208
  * uses it once, requireEncrypted for a module and the encrypted modules it requires.
189
209
  */
190
210
  private opener;
211
+ /** What keeps this app from sending a device key. */
212
+ private deviceKeyHint;
191
213
  /**
192
214
  * Offline activation, step 1 on a machine that cannot reach the server: the request file (JSON text)
193
215
  * the user takes to a connected device. The license server turns it into a license file at
@@ -206,7 +228,8 @@ export declare class Seal {
206
228
  /**
207
229
  * Gives an offline machine's seat back: removes the license from this device and returns the
208
230
  * deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
209
- * Throws `unlicensed` when there is no license to remove.
231
+ * Throws `unlicensed` when there is no license to remove, and `storage_unreadable` when the saved one cannot be read
232
+ * (nothing is removed).
210
233
  */
211
234
  deactivateOffline(): Promise<string>;
212
235
  private deactivateOfflineNow;
@@ -217,7 +240,7 @@ export declare class Seal {
217
240
  * license key, uses it and saves it for the next checkout; without, uses the saved one. The Seat renews itself, emits
218
241
  * 'renewed' and, when the seat cannot be kept, 'lost' with the reason; give it back with seat.release() (in
219
242
  * 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,
243
+ * LicenseError: `unlicensed`, `storage_unreadable` (no key given and the saved one cannot be read), the server's refusal (seats_exhausted with details inUse, total and retryAfter,
221
244
  * not_floating for a node-locked license, license_revoked, …), `network`, or a seat token failure reason.
222
245
  */
223
246
  checkout(licenseKey?: string, options?: CheckoutOptions): Promise<Seat>;
package/dist/index.js CHANGED
@@ -129,6 +129,8 @@ function checkServerUrl(serverUrl) {
129
129
  return;
130
130
  throw new LicenseError('insecure_server_url', 'serverUrl must start with https:// (http:// only for localhost).');
131
131
  }
132
+ /** A saved license that is there and cannot be read (storage_unreadable): what the user can do about it. */
133
+ const STORAGE_UNREADABLE_MESSAGE = 'The saved license on this computer cannot be read. Enter your license key again, or load your offline license file again.';
132
134
  const REASON_TEXT = {
133
135
  malformed: 'The saved license is damaged. Please activate again.',
134
136
  bad_signature: 'The saved license was modified. Please activate again.',
@@ -231,19 +233,46 @@ class Seal {
231
233
  })();
232
234
  return this.hardwareCache.then((report) => report && report.components.length ? { components: report.components.slice(0, MAX_COMPONENTS), vm: report.vm === true } : {});
233
235
  }
234
- /** The saved record; null when there is none, or it cannot be read or is not a record (as no license). */
236
+ /**
237
+ * The saved record; null when there is none. A license.dat that is there and cannot be read (the secure store cannot
238
+ * decrypt it, it is damaged, or it is not a record) is not "no license": this throws `storage_unreadable`, so a
239
+ * licensed user is not shown a first-run screen. A new activation replaces the file.
240
+ */
235
241
  async load() {
242
+ let buf;
243
+ try {
244
+ buf = await (0, promises_1.readFile)(this.file);
245
+ }
246
+ catch (e) {
247
+ if (e.code === 'ENOENT')
248
+ return null;
249
+ throw new LicenseError('storage_unreadable', STORAGE_UNREADABLE_MESSAGE);
250
+ }
251
+ let stored = null;
236
252
  try {
237
- const buf = await (0, promises_1.readFile)(this.file);
238
253
  const text = this.opts.secureStore ? await this.opts.secureStore.decrypt(buf) : buf.toString('utf8');
239
- return parseStored(JSON.parse(text));
254
+ stored = parseStored(JSON.parse(text));
240
255
  }
241
256
  catch {
242
- return null;
257
+ stored = null;
258
+ }
259
+ if (!stored)
260
+ throw new LicenseError('storage_unreadable', STORAGE_UNREADABLE_MESSAGE);
261
+ return stored;
262
+ }
263
+ /** The saved record, or null when there is none or it cannot be read: for a step that is about to replace it. */
264
+ async loadIfReadable() {
265
+ try {
266
+ return await this.load();
267
+ }
268
+ catch (e) {
269
+ if (e instanceof LicenseError && e.code === 'storage_unreadable')
270
+ return null;
271
+ throw e;
243
272
  }
244
273
  }
245
- /** Replaces license.dat whole: written to a temporary file of this save, then renamed over it, so a crash or full disk
246
- * mid-write leaves the previous record. */
274
+ /** Replaces license.dat whole: written to a temporary file of this save, read back, then renamed over it, so a crash,
275
+ * a full disk mid-write or a secure store that cannot decrypt what it encrypted leaves the previous record. */
247
276
  async save(stored) {
248
277
  await (0, promises_1.mkdir)(this.opts.storageDir, { recursive: true });
249
278
  const text = JSON.stringify(stored);
@@ -265,6 +294,17 @@ class Seal {
265
294
  await (0, promises_1.rm)(temp, { force: true }).catch(() => { }); // ours, cut off mid-write
266
295
  throw e;
267
296
  }
297
+ try {
298
+ // Read back through the same path load() takes, before the previous record is replaced.
299
+ const back = await (0, promises_1.readFile)(temp);
300
+ const readBack = this.opts.secureStore ? await this.opts.secureStore.decrypt(back) : back.toString('utf8');
301
+ if (readBack !== text)
302
+ throw new Error('read back differs');
303
+ }
304
+ catch {
305
+ await (0, promises_1.rm)(temp, { force: true }).catch(() => { });
306
+ throw new LicenseError('storage_error', 'The license could not be saved: what was written could not be read back.');
307
+ }
268
308
  try {
269
309
  await (0, promises_1.rename)(temp, this.file);
270
310
  return;
@@ -482,7 +522,16 @@ class Seal {
482
522
  return this.emptyState('invalid', false, { reason: e.code, message: e.message });
483
523
  throw e;
484
524
  }
485
- const stored = await this.load();
525
+ let stored;
526
+ try {
527
+ stored = await this.load();
528
+ }
529
+ catch (e) {
530
+ // Not "unlicensed": the app shows this to a customer who has a license, and nothing is changed on disk.
531
+ if (e instanceof LicenseError && e.code === 'storage_unreadable')
532
+ return this.emptyState('invalid', false, { reason: e.code, message: e.message });
533
+ throw e;
534
+ }
486
535
  if (!stored)
487
536
  return this.emptyState('unlicensed', false);
488
537
  if (stored.offline && stored.token) {
@@ -547,8 +596,9 @@ class Seal {
547
596
  * (or in its offline grace period), grant the feature and carry its key, and the saved license key.
548
597
  * When the lease is wrapped for this machine's device key, the native module does the work (with its
549
598
  * 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:
551
- * `unlicensed`, a lease failure reason, `clock_suspect`, `feature_not_licensed` or `decrypt_failed`.
599
+ * reach JavaScript; a lease wrapped for the license key alone is opened in JavaScript, or, with requireNativeDecrypt,
600
+ * refused (`hardware_required`). Throws a LicenseError: `unlicensed`, `storage_unreadable`, a lease failure reason, `clock_suspect`,
601
+ * `feature_not_licensed`, `hardware_required` or `decrypt_failed`.
552
602
  */
553
603
  async decrypt(feature, data) {
554
604
  return (await this.opener(feature))(data);
@@ -622,7 +672,15 @@ class Seal {
622
672
  const deviceBound = claims?.epk !== undefined;
623
673
  const withKeys = deviceBound && !(native && typeof native.decryptFeature === 'function') ? this.deviceDecrypter() : null;
624
674
  const pems = withKeys ? this.keyPems() : [];
675
+ const nativeDecrypts = !!(native && typeof native.decryptFeature === 'function') || withKeys !== null;
625
676
  return (data) => {
677
+ // A feature the lease grants and carries a key for, which this app would open in JavaScript.
678
+ const carried = !!claims?.feat.includes(feature) && typeof claims.fk?.[feature] === 'string';
679
+ if (this.opts.requireNativeDecrypt && carried && !(deviceBound && nativeDecrypts)) {
680
+ throw new LicenseError('hardware_required', deviceBound
681
+ ? `"${feature}" is wrapped for this computer's device key, but the native module that opens it (@carchalabs/seal-hwid) is not available here.`
682
+ : `"${feature}" was sent for the license key alone, not for this computer's device key, and this app decrypts only device-bound keys (requireNativeDecrypt). ${this.deviceKeyHint()}`);
683
+ }
626
684
  let result;
627
685
  if (native && typeof native.decryptFeature === 'function') {
628
686
  result = native.decryptFeature(leased.token, check, leased.licenseKey, feature, Buffer.from(data));
@@ -647,6 +705,16 @@ class Seal {
647
705
  throw new LicenseError(result.reason ?? 'invalid', REASON_TEXT[result.reason] ?? 'The license is not valid.');
648
706
  };
649
707
  }
708
+ /** What keeps this app from sending a device key. */
709
+ deviceKeyHint() {
710
+ if (this.deviceKey().devicePublicKey)
711
+ return 'Check online so the server sends the key for this computer.';
712
+ if (this.opts.hardware === false)
713
+ return 'The app turned hardware off (hardware: false), so it sends none.';
714
+ return loadHwid()
715
+ ? 'The installed @carchalabs/seal-hwid is too old to open device-bound keys with this app\'s keys (0.5 or later).'
716
+ : 'It needs the native module @carchalabs/seal-hwid, which is not installed or has no build for this platform.';
717
+ }
650
718
  /**
651
719
  * Offline activation, step 1 on a machine that cannot reach the server: the request file (JSON text)
652
720
  * the user takes to a connected device. The license server turns it into a license file at
@@ -702,7 +770,8 @@ class Seal {
702
770
  /**
703
771
  * Gives an offline machine's seat back: removes the license from this device and returns the
704
772
  * deactivation file (JSON text) for the vendor, who frees the seat by uploading it in the console.
705
- * Throws `unlicensed` when there is no license to remove.
773
+ * Throws `unlicensed` when there is no license to remove, and `storage_unreadable` when the saved one cannot be read
774
+ * (nothing is removed).
706
775
  */
707
776
  deactivateOffline() {
708
777
  return this.serial(() => this.deactivateOfflineNow());
@@ -740,7 +809,7 @@ class Seal {
740
809
  * license key, uses it and saves it for the next checkout; without, uses the saved one. The Seat renews itself, emits
741
810
  * 'renewed' and, when the seat cannot be kept, 'lost' with the reason; give it back with seat.release() (in
742
811
  * 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,
812
+ * LicenseError: `unlicensed`, `storage_unreadable` (no key given and the saved one cannot be read), the server's refusal (seats_exhausted with details inUse, total and retryAfter,
744
813
  * not_floating for a node-locked license, license_revoked, …), `network`, or a seat token failure reason.
745
814
  */
746
815
  checkout(licenseKey, options = {}) {
@@ -759,7 +828,7 @@ class Seal {
759
828
  // The key is kept for the next checkout. A seat token never is: it lives in memory only.
760
829
  if (licenseKey !== undefined) {
761
830
  await this.serial(async () => {
762
- if ((await this.load())?.licenseKey !== key)
831
+ if ((await this.loadIfReadable())?.licenseKey !== key)
763
832
  await this.save({ licenseKey: key });
764
833
  });
765
834
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carchalabs/seal-electron",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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/",
@@ -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.6.0"
18
+ "@carchalabs/seal-core": "0.7.0"
19
19
  },
20
20
  "optionalDependencies": {
21
- "@carchalabs/seal-hwid": "0.6.0"
21
+ "@carchalabs/seal-hwid": "0.6.1"
22
22
  },
23
23
  "engines": { "node": ">=22.13" },
24
24
  "publishConfig": { "access": "public" },