@antondziuin/talos-sdk 0.1.0 → 0.2.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 CHANGED
@@ -1,15 +1,18 @@
1
1
  # @antondziuin/talos-sdk (Node.js)
2
2
 
3
- Online-first client SDK for the Talos licensing platform. Node ≥18, **zero
4
- runtime dependencies** (`node:crypto` Ed25519, built-in `fetch`), dual ESM + CJS.
3
+ Talos SDK for Node.js and Electron applications. Activate and validate licenses,
4
+ read feature entitlements, manage floating seats, and download signed software
5
+ updates. Supports offline grace periods and license files for air-gapped machines.
6
+
7
+ Requires Node.js 18 or later. Includes TypeScript declarations, ESM and CommonJS
8
+ builds, and **zero runtime dependencies**. Uses `node:crypto` for Ed25519
9
+ verification and the built-in `fetch` for HTTP requests.
5
10
 
6
11
  ```sh
7
12
  npm install @antondziuin/talos-sdk
8
13
  ```
9
14
 
10
- MIT-licensed, from `0.1.0` onward. The platform it talks to is not — the split is
11
- deliberate: a library you install into your own application should not be
12
- something your legal review has to read.
15
+ MIT-licensed.
13
16
 
14
17
  ## Quickstart
15
18
 
@@ -19,7 +22,7 @@ import { TalosClient } from "@antondziuin/talos-sdk";
19
22
  const talos = new TalosClient({
20
23
  productToken: "tpt_…", // from the portal's Integration page
21
24
  anchorPublicKey: "base64-anchor-key", // pin THIS — the rotation-safe root
22
- serverUrl: "https://api.talos.dev",
25
+ serverUrl: "https://talos.dziuin.dev", // Talos Cloud, or your self-hosted node
23
26
  appVersion: "1.4.2",
24
27
  });
25
28
 
@@ -34,6 +37,33 @@ if (talos.isFeatureEnabled("pdf-export")) enablePdfMenu();
34
37
  const seats = Number(talos.current?.getEntitlement("seats") ?? 1);
35
38
  ```
36
39
 
40
+ ## Local diagnostic reports
41
+
42
+ After a licensing call, `talos.createDiagnosticReport()` returns JSON describing
43
+ the last decision, offline grace deadline, SDK version and whether an activation
44
+ is held. If the call throws, pass the caught error to classify it and get a next
45
+ action:
46
+
47
+ ```ts
48
+ try {
49
+ await talos.validate();
50
+ console.log(talos.createDiagnosticReport());
51
+ } catch (error) {
52
+ console.log(talos.createDiagnosticReport(error));
53
+ }
54
+ ```
55
+
56
+ The report is a local snapshot: it makes no requests, writes no files and changes
57
+ no licensing state. It excludes keys, tokens, server URLs, machine identifiers,
58
+ entitlements and exception messages/stacks. Unknown error codes are replaced with
59
+ `api_error`. An HTTP error is not a signed license decision. An offline result
60
+ alone cannot identify the original transport or verification failure.
61
+
62
+ In the portal, select the same license on **Integration → Connection progress**
63
+ to see its devices, recorded validation, seat usage and recent activation refusals.
64
+ Use the report for failures that the server cannot see. Do not use this report
65
+ as authorization; continue to gate access on the SDK's verified license state.
66
+
37
67
  ## Supplying your own `fetch`
38
68
 
39
69
  `options.fetch` replaces the global one for **every** network call, artifact
@@ -184,6 +214,51 @@ pins, but they break on the first rotation — prefer the anchor.)
184
214
  Anti-tamper honesty: JS is patchable — gate premium value on server-returned
185
215
  *data* (entitlements/config), not client booleans.
186
216
 
217
+ ## Air-gapped machines
218
+
219
+ Grace above is for an install that has been online. This is for one that never
220
+ will be — an isolated network, no route out at all.
221
+
222
+ ```ts
223
+ // On the machine. Write this out and carry it to your vendor:
224
+ writeFileSync("talos-request.json", JSON.stringify(talos.createOfflineRequest(), null, 2));
225
+
226
+ // They upload it in the portal and hand you back license.talos:
227
+ const state = await talos.loadOfflineLicense("license.talos");
228
+ state.decision; // "valid"
229
+ ```
230
+
231
+ The first call generates an **Ed25519 keypair for this machine**, keeps the
232
+ private half in the same encrypted state file as everything else, and puts the
233
+ public half in the request beside the fingerprint. The file you get back is bound
234
+ to both, so a file copied off another machine does not license this one and this
235
+ machine's state directory does not license another. Calling it twice reuses the
236
+ same identity — only the nonce changes.
237
+
238
+ Everything is verified locally: the anchor-signed keyset travels inside the file,
239
+ so an anchor-only pin still works with no server to fetch one from. The verdict
240
+ is kept, so later starts need nothing — `validate()` re-checks the stored file
241
+ instead of reaching for the network, and `isActivated` is true.
242
+
243
+ `loadOfflineLicense` **throws** only for a file that is not a licence: unreadable,
244
+ not one of ours, addressed to another product, or a signature that does not
245
+ verify. A file that is one but cannot license this machine comes back as a
246
+ verdict you can show the user — `machine_mismatch`, `offline_file_expired` (get a
247
+ new file), `expired` (renew the licence), `clock_tampered`.
248
+
249
+ Retiring the machine? `talos.createOfflineDeactivation()` writes a
250
+ `talos-deactivation.json` for the vendor to upload, and **drops the licence
251
+ before returning it** — so an app that loses the file has still stopped using the
252
+ seat. Credited once per file, so a machine restored from a snapshot replaying its
253
+ receipt changes nothing.
254
+
255
+ Two things to know before you offer this to customers. A file **cannot be
256
+ recalled** — revoking it in the portal frees the seat so you can issue to a
257
+ replacement machine, but the machine holding it keeps working until the file's
258
+ own expiry, which is why the policy's horizon is short. And hardware changes are
259
+ not forgiven offline: the drift matcher lives on the server, so a replaced disk
260
+ means `machine_mismatch` and a new request.
261
+
187
262
  ## Heartbeats, monitoring, and cadence
188
263
 
189
264
  ```ts
@@ -283,26 +358,12 @@ given.
283
358
  The same code arrives on the artifact download, for the same reason: the gate is
284
359
  the licence's `updates` entitlement, checked on both routes.
285
360
 
286
- ## Hardening the shipped bundle
287
-
288
- The renderer, the bundle and the disk all belong to the person running your
289
- application, so nothing here is a licence check — the checks are on the server,
290
- and the entitlement is the thing worth gating on (see the portal's
291
- "Entitlements" page). What client-side hardening buys is time.
292
-
293
- - **Bundle first**, with esbuild or webpack: one file, no module boundaries, no
294
- helpfully-named source files to open.
295
- - **Then `bytenode`**, which compiles that bundle to V8 bytecode — the practical
296
- option for Node and Electron applications, and the one this SDK's C# sibling
297
- reaches for with NativeAOT.
298
- - **Keep the client in the main process** in Electron. The renderer is the part
299
- an attacker already controls; it should be told a verdict and never hold the
300
- activation secret (see `examples/electron-main`).
361
+ ## Electron integration
301
362
 
302
- Honestly labelled: bytecode can be decompiled and every obfuscator has a
303
- deobfuscator. These buy an attacker's afternoon, not their ability — which is
304
- why the server decides seats, expiry and revocation, and why a feature that only
305
- exists on the server cannot be patched into existence on the client.
363
+ Keep `TalosClient` in the main process and expose the license state to the
364
+ renderer through IPC. Keep the activation secret in the main process. Use
365
+ server-verified entitlements to control licensed features; client-side code
366
+ can be modified by the person running the application.
306
367
 
307
368
  ## Hardware changes
308
369
 
@@ -321,15 +382,14 @@ an id you already hold — pass it in and the server will use it:
321
382
  const talos = new TalosClient({
322
383
  productToken: "tpt_…",
323
384
  anchorPublicKey: "…",
324
- serverUrl: "https://api.talos.dev",
385
+ serverUrl: "https://talos.dziuin.dev",
325
386
  fingerprintComponents: { machine_guid: myNativeModule.machineId() },
326
387
  });
327
388
  ```
328
389
 
329
390
  The value must be stable for the life of the install — one that changes per
330
- launch reports a hardware change on every launch. See
331
- [docs/FINGERPRINT.md](../../docs/FINGERPRINT.md) for the recognised ids, the
332
- per-platform coverage table, and the drift budget that bounds all of this.
391
+ launch reports a hardware change on every launch. The product's fingerprint
392
+ policy controls which hardware changes are tolerated.
333
393
 
334
394
  ## Floating (concurrent) licenses
335
395
 
@@ -360,7 +420,7 @@ const talos = new TalosClient({
360
420
  productToken: "tpt_…",
361
421
  publicKey: "…", // license key (activation/validation tokens)
362
422
  releasePublicKey: "…", // release key (update manifests) — separate custody
363
- serverUrl: "https://api.talos.dev",
423
+ serverUrl: "https://talos.dziuin.dev",
364
424
  });
365
425
  await talos.activate(licenseKey);
366
426
 
@@ -398,9 +458,3 @@ that is already updated.
398
458
 
399
459
  Downloaded bytes are verified against the signed manifest's SHA-256, so the
400
460
  CDN/mirror serving them is untrusted infrastructure.
401
-
402
- ## Tests
403
-
404
- - `npm test` runs the client-side verification tests (no server needed).
405
- - The cross-language **contract test** (`test/contract.test.ts`) runs the SDK
406
- against a live Rust server; see [`../csharp/contract.sh`](../csharp/contract.sh).
package/dist/index.cjs CHANGED
@@ -30,7 +30,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ LICENSE_FILE_KIND: () => LICENSE_FILE_KIND,
33
34
  LicenseState: () => LicenseState,
35
+ RECEIPT_DOMAIN: () => RECEIPT_DOMAIN,
36
+ REQUEST_DOMAIN: () => REQUEST_DOMAIN,
34
37
  StateStore: () => StateStore,
35
38
  TalosApiError: () => TalosApiError,
36
39
  TalosClient: () => TalosClient,
@@ -48,11 +51,11 @@ __export(index_exports, {
48
51
  module.exports = __toCommonJS(index_exports);
49
52
 
50
53
  // src/client.ts
51
- var import_node_crypto5 = require("crypto");
54
+ var import_node_crypto6 = require("crypto");
52
55
  var import_node_fs3 = require("fs");
53
56
  var import_promises = require("fs/promises");
54
57
  var import_node_os3 = __toESM(require("os"), 1);
55
- var import_node_crypto6 = require("crypto");
58
+ var import_node_crypto7 = require("crypto");
56
59
 
57
60
  // src/errors.ts
58
61
  var TalosError = class extends Error {
@@ -238,7 +241,13 @@ var StateStore = class {
238
241
  if (pending && typeof pending["version"] === "string" && typeof pending["releaseSeq"] === "number") {
239
242
  b.pendingRelease = { version: pending["version"], releaseSeq: pending["releaseSeq"] };
240
243
  }
241
- return b.activationSecret ? b : null;
244
+ if (typeof o["machineKey"] === "string") b.machineKey = o["machineKey"];
245
+ if (typeof o["machinePublicKey"] === "string") {
246
+ b.machinePublicKey = o["machinePublicKey"];
247
+ }
248
+ if (typeof o["offlineToken"] === "string") b.offlineToken = o["offlineToken"];
249
+ if (typeof o["offlineSeen"] === "number") b.offlineSeen = o["offlineSeen"];
250
+ return b.activationSecret || b.offlineToken || b.machineKey ? b : null;
242
251
  } catch {
243
252
  return null;
244
253
  }
@@ -279,9 +288,51 @@ var StateStore = class {
279
288
  };
280
289
 
281
290
  // src/version.ts
282
- var SDK_VERSION = "0.1.0";
291
+ var SDK_VERSION = "0.2.1";
283
292
  var SDK_ID = `node/${SDK_VERSION}`;
284
293
 
294
+ // src/diagnostics.ts
295
+ var decisions = /* @__PURE__ */ new Set(["valid", "expired", "revoked", "suspended", "deactivated", "machine_revoked", "invalid", "grace_expired", "clock_tampered"]);
296
+ var errors = /* @__PURE__ */ new Set(["product_not_found", "license_not_found", "license_expired", "license_revoked", "license_suspended", "machine_revoked", "activation_limit_reached", "seat_limit_reached", "deactivation_limit_reached", "tenant_suspended", "tenant_closed", "invalid_activation_secret", "rate_limited"]);
297
+ function diagnosticReport(activated, anchorPinned, state, error) {
298
+ const code = error instanceof TalosApiError ? errors.has(error.errorCode ?? "") ? error.errorCode : "api_error" : error instanceof TalosSignatureError ? "signature_verification_failed" : error != null ? "client_error" : null;
299
+ const decision = state == null ? null : decisions.has(state.decision) ? state.decision : "unknown";
300
+ const advice = {
301
+ signature_verification_failed: "Check the pinned anchor, product configuration and system time. Keep signature verification enabled.",
302
+ activation_limit_reached: "Open the license's devices in the portal. Release an unused device or review its activation limit.",
303
+ seat_limit_reached: "Close an unused app session or review the concurrent seat limit.",
304
+ license_expired: "Renew the license in the portal, then validate again.",
305
+ expired: "Renew the license in the portal, then validate again.",
306
+ license_revoked: "Revocation is permanent. Ask the vendor for a replacement license.",
307
+ revoked: "Revocation is permanent. Ask the vendor for a replacement license.",
308
+ license_suspended: "Ask the vendor to review the suspension before activating a new device.",
309
+ suspended: "Ask the vendor to review the suspension.",
310
+ machine_revoked: "Ask the vendor to review this device in the portal.",
311
+ grace_expired: "Reconnect to the configured Talos server and validate. Do not extend the local grace deadline.",
312
+ clock_tampered: "Correct the system clock and validate online.",
313
+ client_error: "Check connectivity, server configuration and local state access. The exception text is intentionally excluded."
314
+ };
315
+ const action = advice[code ?? decision ?? ""] ?? (code ? "Review the licensing call and configuration with the vendor. HTTP errors are not signed license decisions." : state?.isOffline ? "The last decision was offline. Check connectivity and validate online; this snapshot cannot identify the transport failure." : !activated ? "Activate with a license key, then validate the saved activation." : decision === "valid" ? "The last licensing call returned valid. Check the portal for this device's recorded validation." : "Run a licensing check and generate a new report. Ask the vendor to review an invalid decision.");
316
+ const timestamp = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
317
+ return JSON.stringify({
318
+ schema_version: 1,
319
+ sdk: SDK_ID,
320
+ generated_at: Math.floor(Date.now() / 1e3),
321
+ activated,
322
+ anchor_pinned: anchorPinned,
323
+ decision,
324
+ is_offline: state?.isOffline ?? null,
325
+ license_expires_at: timestamp(state?.licenseExpiresAt),
326
+ grace_until: timestamp(state?.graceUntil),
327
+ error_code: code,
328
+ http_status: error instanceof TalosApiError && Number.isInteger(error.statusCode) && error.statusCode >= 100 && error.statusCode <= 599 ? error.statusCode : null,
329
+ next_action: action
330
+ }, null, 2);
331
+ }
332
+
333
+ // src/offline.ts
334
+ var import_node_crypto5 = require("crypto");
335
+
285
336
  // src/talos-compact.ts
286
337
  var import_node_crypto4 = require("crypto");
287
338
  var SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
@@ -361,6 +412,36 @@ function verifyKeyset(anchorKey, expectedAud, token) {
361
412
  return claims;
362
413
  }
363
414
 
415
+ // src/offline.ts
416
+ var REQUEST_DOMAIN = "talos-offline-request";
417
+ var RECEIPT_DOMAIN = "talos-offline-deactivation";
418
+ var LICENSE_FILE_KIND = "talos-offline-license";
419
+ var DOC_VERSION = 1;
420
+ function generateMachineKey() {
421
+ const { privateKey, publicKey } = (0, import_node_crypto5.generateKeyPairSync)("ed25519");
422
+ return {
423
+ privateKey: privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
424
+ // The raw key is the last 32 bytes of the SPKI DER; every other Ed25519
425
+ // implementation in this project speaks raw-32, including the server's.
426
+ publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64")
427
+ };
428
+ }
429
+ function privateKeyObject(pkcs8Base64) {
430
+ return (0, import_node_crypto5.createPrivateKey)({
431
+ key: Buffer.from(pkcs8Base64, "base64"),
432
+ format: "der",
433
+ type: "pkcs8"
434
+ });
435
+ }
436
+ function signedMessage(domain, payloadB64) {
437
+ return `${domain}.${DOC_VERSION}.${payloadB64}`;
438
+ }
439
+ function signMachineDoc(key, domain, payload) {
440
+ const req = b64urlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
441
+ const sig = (0, import_node_crypto5.sign)(null, Buffer.from(signedMessage(domain, req), "ascii"), privateKeyObject(key.privateKey));
442
+ return { v: DOC_VERSION, kind: domain, req, sig: b64urlEncode(sig) };
443
+ }
444
+
364
445
  // src/client.ts
365
446
  var CLOCK_SKEW_SECONDS2 = 60;
366
447
  var DEFAULT_HEARTBEAT_SECONDS = 600;
@@ -445,8 +526,16 @@ var TalosClient = class {
445
526
  /** Bumped on release so an in-flight renewal cannot resurrect the seat. */
446
527
  leaseGeneration = 0;
447
528
  /** Default instance id for floating leases: one per client object. */
448
- instanceId = b64urlEncode((0, import_node_crypto5.randomBytes)(9));
529
+ instanceId = b64urlEncode((0, import_node_crypto6.randomBytes)(9));
449
530
  current = null;
531
+ /**
532
+ * Return a local JSON support report without credentials, identifiers or
533
+ * exception text. Pass a caught error for classification. Makes no requests
534
+ * and does not change licensing state; `decision` is the last known result.
535
+ */
536
+ createDiagnosticReport(error) {
537
+ return diagnosticReport(this.isActivated, this.options.anchorPublicKey != null && this.options.anchorPublicKey !== "", this.current, error);
538
+ }
450
539
  listeners = /* @__PURE__ */ new Map();
451
540
  /** The newest release this client has already announced. */
452
541
  announcedReleaseSeq = 0;
@@ -472,9 +561,9 @@ var TalosClient = class {
472
561
  const stored = this.store?.load() ?? null;
473
562
  if (stored) {
474
563
  this.binding = stored;
475
- this.licenseKeyHash = stored.licenseKeyHash;
476
- this.activationSecret = stored.activationSecret;
477
- this.machineId = stored.machineId;
564
+ this.licenseKeyHash = stored.licenseKeyHash || null;
565
+ this.activationSecret = stored.activationSecret || null;
566
+ this.machineId = stored.machineId || null;
478
567
  if (stored.keyset) {
479
568
  try {
480
569
  this.adoptKeyset(stored.keyset);
@@ -522,8 +611,17 @@ var TalosClient = class {
522
611
  * construction, so this is the startup question: prompt for a licence key, or
523
612
  * go straight to {@link validate}?
524
613
  */
614
+ /**
615
+ * Whether this install holds a licence for this machine.
616
+ *
617
+ * True for an online activation (the activation secret is held) **and** for an
618
+ * air-gapped install that has loaded a licence file. Widened rather than
619
+ * split, because what a host does with this is decide whether to ask the user
620
+ * for a licence key — and an air-gapped machine that already holds a valid
621
+ * file must not be asked for one it has no way to redeem.
622
+ */
525
623
  get isActivated() {
526
- return this.activationSecret !== null;
624
+ return this.activationSecret !== null || this.binding?.offlineToken != null;
527
625
  }
528
626
  /** The server's id for this machine, once activated. */
529
627
  get currentMachineId() {
@@ -680,12 +778,221 @@ var TalosClient = class {
680
778
  // A fresh activation invalidates any cached verdict for the previous
681
779
  // binding; keep only the keyset, which is per product, not per machine.
682
780
  keyset: typeof body["keyset"] === "string" ? body["keyset"] : this.binding?.keyset,
683
- timeHighWater: this.binding?.timeHighWater
781
+ timeHighWater: this.binding?.timeHighWater,
782
+ // The machine's own identity survives an online activation, because it is
783
+ // an identity and not a verdict: an install that reaches the network once
784
+ // must not have to repeat the whole air-gapped ceremony with its vendor
785
+ // the next time it is unplugged. The clock mark survives for the same
786
+ // reason a mark exists at all — it only moves forward.
787
+ machineKey: this.binding?.machineKey,
788
+ machinePublicKey: this.binding?.machinePublicKey,
789
+ offlineSeen: this.binding?.offlineSeen
790
+ // The offline *file* does not: a live activation supersedes it, and
791
+ // keeping it would leave two answers to "what licenses this machine".
684
792
  };
685
793
  this.advanceClock(claims);
686
794
  this.store?.save(this.binding);
687
795
  return this.adopt(stateFromClaims(claims, "valid", false));
688
796
  }
797
+ /**
798
+ * This machine's own keypair, generated once and kept in the state file.
799
+ *
800
+ * The air-gapped counterpart of the activation secret: a machine that cannot
801
+ * reach a server is never issued one, so it makes an identity of its own and
802
+ * the vendor binds the file to it.
803
+ */
804
+ machineKey() {
805
+ const held = this.binding?.machineKey;
806
+ const pub = this.binding?.machinePublicKey;
807
+ if (held && pub) return { privateKey: held, publicKey: pub };
808
+ const key = generateMachineKey();
809
+ this.binding = {
810
+ ...this.bindingBase(),
811
+ machineKey: key.privateKey,
812
+ machinePublicKey: key.publicKey
813
+ };
814
+ this.store?.save(this.binding);
815
+ return key;
816
+ }
817
+ /**
818
+ * The binding as it stands, or an empty one to build on.
819
+ *
820
+ * An air-gapped install reaches its first write with nothing stored — no
821
+ * licence key, no activation secret, no machine id — so there has to be
822
+ * something for the offline fields to be added to.
823
+ */
824
+ bindingBase() {
825
+ return this.binding ?? {
826
+ licenseKeyHash: "",
827
+ activationSecret: "",
828
+ machineId: "",
829
+ fingerprint: this.fingerprint
830
+ };
831
+ }
832
+ /**
833
+ * Write the request an air-gapped machine hands to its vendor.
834
+ *
835
+ * Save the returned object as `talos-request.json`, carry it to whoever sold
836
+ * the licence, and they upload it in the portal. What comes back is a
837
+ * `license.talos` for {@link loadOfflineLicense} — and only for *this*
838
+ * machine: the request carries the fingerprint and the public half of a
839
+ * keypair generated here, and the file is bound to both.
840
+ *
841
+ * Calling it twice produces two requests under the same identity. The vendor
842
+ * can issue against either; the second file supersedes the first without
843
+ * spending a second seat.
844
+ *
845
+ * ```ts
846
+ * writeFileSync("talos-request.json", JSON.stringify(talos.createOfflineRequest(), null, 2));
847
+ * ```
848
+ *
849
+ * @param licenseKey Optional. The operator picks the licence in the portal, so
850
+ * it is not needed — but when it is present the server checks it against the
851
+ * licence being issued against, which catches issuing from the wrong tab.
852
+ */
853
+ createOfflineRequest(licenseKey) {
854
+ const key = this.machineKey();
855
+ return signMachineDoc(key, REQUEST_DOMAIN, {
856
+ product_token: this.options.productToken,
857
+ license_key: licenseKey,
858
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
859
+ machine_pubkey: key.publicKey,
860
+ nonce: newNonce(),
861
+ hostname: this.reportedHostname(),
862
+ os: `${import_node_os3.default.type()} ${import_node_os3.default.release()}`,
863
+ arch: import_node_os3.default.arch(),
864
+ app_version: this.options.appVersion,
865
+ sdk_version: SDK_ID,
866
+ created_at: nowSeconds()
867
+ });
868
+ }
869
+ /**
870
+ * Adopt an air-gapped licence file issued for this machine.
871
+ *
872
+ * Everything is verified locally, because there is nothing to ask: the
873
+ * anchor-signed keyset travels inside the file, the verdict is signed by a key
874
+ * that keyset lists, and the file names both halves of this machine's identity
875
+ * — its fingerprint and the public key it generated. The verdict is then kept,
876
+ * so later starts are licensed with no further ceremony.
877
+ *
878
+ * **Throws** only for a file that is not a licence: unreadable, not one of
879
+ * ours, addressed to another product, or a broken signature. A file that *is*
880
+ * one but cannot license this machine right now comes back as a verdict —
881
+ * `machine_mismatch` for another machine's file, `offline_file_expired` for
882
+ * one past its horizon, `expired` for a licence that has ended, and
883
+ * `clock_tampered` for a clock wound back behind what this install has already
884
+ * seen. Those are things the user can be told and can act on, and turning
885
+ * them into exceptions would make the launch path throw for a reason nobody
886
+ * can catch usefully.
887
+ */
888
+ async loadOfflineLicense(pathOrBuffer) {
889
+ const raw = typeof pathOrBuffer === "string" ? (0, import_node_fs3.readFileSync)(pathOrBuffer) : Buffer.from(pathOrBuffer);
890
+ let doc;
891
+ try {
892
+ doc = JSON.parse(raw.toString("utf8"));
893
+ } catch {
894
+ throw new TalosError("not a Talos offline licence file");
895
+ }
896
+ if (doc["kind"] !== LICENSE_FILE_KIND || typeof doc["token"] !== "string") {
897
+ throw new TalosError("not a Talos offline licence file");
898
+ }
899
+ if (typeof doc["keyset"] === "string") this.adoptKeyset(doc["keyset"]);
900
+ return this.adoptOfflineToken(doc["token"], doc["keyset"]);
901
+ }
902
+ /**
903
+ * Hand this machine's air-gapped seat back.
904
+ *
905
+ * Save the returned object as `talos-deactivation.json` and send it to the
906
+ * vendor; they upload it in the portal and the seat returns to the pool.
907
+ * Signed by the same key the licence file is bound to, which is what makes it
908
+ * worth anything to a server that has never spoken to this machine.
909
+ *
910
+ * **The licence is dropped here, before the document is returned** — so an
911
+ * application that writes the file, or loses it, or never sends it has still
912
+ * stopped using the seat it is giving up. A receipt proves intent, not
913
+ * destruction: the vendor's copy of the file cannot be recalled, and if this
914
+ * machine is later restored from a snapshot it keeps working until that file
915
+ * expires. That is the accepted cost, and it is why the horizon is short.
916
+ *
917
+ * Throws when this install holds no offline licence — there is nothing to hand
918
+ * back, and a document saying otherwise would be a lie the vendor acts on.
919
+ */
920
+ createOfflineDeactivation() {
921
+ const token = this.binding?.offlineToken;
922
+ const key = this.binding?.machineKey;
923
+ const pub = this.binding?.machinePublicKey;
924
+ if (!token || !key || !pub) {
925
+ throw new TalosError("no offline licence to hand back");
926
+ }
927
+ let claims;
928
+ try {
929
+ claims = verifyCachedToken(
930
+ this.licenseVerifyKeys(),
931
+ "offline",
932
+ this.options.productToken,
933
+ token
934
+ );
935
+ } catch {
936
+ throw new TalosError("the stored offline licence no longer verifies");
937
+ }
938
+ this.binding = { ...this.bindingBase(), offlineToken: void 0 };
939
+ this.current = null;
940
+ this.store?.save(this.binding);
941
+ return signMachineDoc({ privateKey: key, publicKey: pub }, RECEIPT_DOMAIN, {
942
+ fid: claims["fid"],
943
+ mid: claims["mid"],
944
+ fph: this.fingerprint,
945
+ local_time: nowSeconds()
946
+ });
947
+ }
948
+ /**
949
+ * Verify the offline file against this machine and this clock, and adopt the
950
+ * verdict. Runs on load and again on every {@link validate}, because the two
951
+ * things that can change afterwards — the clock and the calendar — are
952
+ * exactly what it checks.
953
+ */
954
+ adoptOfflineToken(token, keyset) {
955
+ const claims = verifyCachedToken(
956
+ this.licenseVerifyKeys(),
957
+ "offline",
958
+ this.options.productToken,
959
+ token
960
+ );
961
+ const mismatch = () => this.adopt(new LicenseState("machine_mismatch", {}, null, true, null, null));
962
+ if (typeof claims["mpk"] !== "string" || claims["mpk"] !== this.binding?.machinePublicKey) {
963
+ return mismatch();
964
+ }
965
+ if (claims["fph"] !== this.fingerprint) return mismatch();
966
+ const now = nowSeconds();
967
+ const mark = Math.max(this.binding?.timeHighWater ?? 0, this.binding?.offlineSeen ?? 0);
968
+ if (mark > 0 && now + CLOCK_SKEW_SECONDS2 < mark) {
969
+ return this.adopt(new LicenseState("clock_tampered", {}, null, true, null, null));
970
+ }
971
+ const lic = claims["lic"] ?? {};
972
+ const features = lic["features"] ?? {};
973
+ const licExp = num(lic["lic_exp"]);
974
+ const exp = num(claims["exp"]);
975
+ this.machineId = typeof claims["mid"] === "string" ? claims["mid"] : this.machineId;
976
+ const base = this.bindingBase();
977
+ this.binding = {
978
+ ...base,
979
+ machineId: this.machineId ?? base.machineId,
980
+ offlineToken: token,
981
+ offlineSeen: Math.max(base.offlineSeen ?? 0, now),
982
+ ...typeof keyset === "string" ? { keyset } : {}
983
+ };
984
+ this.advanceClock(claims);
985
+ this.store?.save(this.binding);
986
+ if (exp === null || now > exp) {
987
+ return this.adopt(
988
+ new LicenseState("offline_file_expired", features, licExp, true, null, exp)
989
+ );
990
+ }
991
+ if (licExp !== null && now > licExp) {
992
+ return this.adopt(new LicenseState("expired", features, licExp, true, null, exp));
993
+ }
994
+ return this.adopt(new LicenseState("valid", features, licExp, true, null, exp));
995
+ }
689
996
  /**
690
997
  * The heartbeat cadence the server's policy asks for, in seconds, taken from
691
998
  * the last heartbeat's **signed** claims — so it cannot be lengthened in
@@ -825,6 +1132,9 @@ var TalosClient = class {
825
1132
  }
826
1133
  }
827
1134
  async validate() {
1135
+ if (!this.activationSecret && this.binding?.offlineToken) {
1136
+ return this.adoptOfflineToken(this.binding.offlineToken);
1137
+ }
828
1138
  this.requireActivated();
829
1139
  try {
830
1140
  await this.ensureKeyset();
@@ -1332,7 +1642,7 @@ var TalosClient = class {
1332
1642
  }
1333
1643
  const partPath = destPath + ".talos-part";
1334
1644
  const handle = await (0, import_promises.open)(partPath, "w");
1335
- const hash = (0, import_node_crypto5.createHash)("sha256");
1645
+ const hash = (0, import_node_crypto6.createHash)("sha256");
1336
1646
  let received = 0;
1337
1647
  let closed = false;
1338
1648
  const take = async (chunk) => {
@@ -1457,7 +1767,7 @@ function transitionEvent(prev, next) {
1457
1767
  return prev?.decision === next.decision ? null : "licenseInvalid";
1458
1768
  }
1459
1769
  function newNonce() {
1460
- return b64urlEncode((0, import_node_crypto5.randomBytes)(16));
1770
+ return b64urlEncode((0, import_node_crypto6.randomBytes)(16));
1461
1771
  }
1462
1772
  function asArray(v) {
1463
1773
  return Array.isArray(v) ? v : [];
@@ -1478,7 +1788,7 @@ function pickArtifact(artifacts) {
1478
1788
  return matches.find((a) => a.kind === "installer") ?? matches[0];
1479
1789
  }
1480
1790
  async function verifyAppliedUpdate(filePath, delta) {
1481
- const hash = (0, import_node_crypto5.createHash)("sha256");
1791
+ const hash = (0, import_node_crypto6.createHash)("sha256");
1482
1792
  const stream = (0, import_node_fs3.createReadStream)(filePath);
1483
1793
  await new Promise((resolve, reject) => {
1484
1794
  stream.on("data", (chunk) => hash.update(chunk));
@@ -1494,7 +1804,10 @@ async function verifyAppliedUpdate(filePath, delta) {
1494
1804
  }
1495
1805
  // Annotate the CommonJS export names for ESM import in node:
1496
1806
  0 && (module.exports = {
1807
+ LICENSE_FILE_KIND,
1497
1808
  LicenseState,
1809
+ RECEIPT_DOMAIN,
1810
+ REQUEST_DOMAIN,
1498
1811
  StateStore,
1499
1812
  TalosApiError,
1500
1813
  TalosClient,