@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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/client.ts
2
2
  import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
3
- import { createReadStream } from "fs";
3
+ import { createReadStream, readFileSync as readFileSync3 } from "fs";
4
4
  import { open, rename, rm } from "fs/promises";
5
5
  import os3 from "os";
6
6
  import "crypto";
@@ -189,7 +189,13 @@ var StateStore = class {
189
189
  if (pending && typeof pending["version"] === "string" && typeof pending["releaseSeq"] === "number") {
190
190
  b.pendingRelease = { version: pending["version"], releaseSeq: pending["releaseSeq"] };
191
191
  }
192
- return b.activationSecret ? b : null;
192
+ if (typeof o["machineKey"] === "string") b.machineKey = o["machineKey"];
193
+ if (typeof o["machinePublicKey"] === "string") {
194
+ b.machinePublicKey = o["machinePublicKey"];
195
+ }
196
+ if (typeof o["offlineToken"] === "string") b.offlineToken = o["offlineToken"];
197
+ if (typeof o["offlineSeen"] === "number") b.offlineSeen = o["offlineSeen"];
198
+ return b.activationSecret || b.offlineToken || b.machineKey ? b : null;
193
199
  } catch {
194
200
  return null;
195
201
  }
@@ -230,9 +236,51 @@ var StateStore = class {
230
236
  };
231
237
 
232
238
  // src/version.ts
233
- var SDK_VERSION = "0.1.0";
239
+ var SDK_VERSION = "0.2.1";
234
240
  var SDK_ID = `node/${SDK_VERSION}`;
235
241
 
242
+ // src/diagnostics.ts
243
+ var decisions = /* @__PURE__ */ new Set(["valid", "expired", "revoked", "suspended", "deactivated", "machine_revoked", "invalid", "grace_expired", "clock_tampered"]);
244
+ 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"]);
245
+ function diagnosticReport(activated, anchorPinned, state, error) {
246
+ const code = error instanceof TalosApiError ? errors.has(error.errorCode ?? "") ? error.errorCode : "api_error" : error instanceof TalosSignatureError ? "signature_verification_failed" : error != null ? "client_error" : null;
247
+ const decision = state == null ? null : decisions.has(state.decision) ? state.decision : "unknown";
248
+ const advice = {
249
+ signature_verification_failed: "Check the pinned anchor, product configuration and system time. Keep signature verification enabled.",
250
+ activation_limit_reached: "Open the license's devices in the portal. Release an unused device or review its activation limit.",
251
+ seat_limit_reached: "Close an unused app session or review the concurrent seat limit.",
252
+ license_expired: "Renew the license in the portal, then validate again.",
253
+ expired: "Renew the license in the portal, then validate again.",
254
+ license_revoked: "Revocation is permanent. Ask the vendor for a replacement license.",
255
+ revoked: "Revocation is permanent. Ask the vendor for a replacement license.",
256
+ license_suspended: "Ask the vendor to review the suspension before activating a new device.",
257
+ suspended: "Ask the vendor to review the suspension.",
258
+ machine_revoked: "Ask the vendor to review this device in the portal.",
259
+ grace_expired: "Reconnect to the configured Talos server and validate. Do not extend the local grace deadline.",
260
+ clock_tampered: "Correct the system clock and validate online.",
261
+ client_error: "Check connectivity, server configuration and local state access. The exception text is intentionally excluded."
262
+ };
263
+ 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.");
264
+ const timestamp = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
265
+ return JSON.stringify({
266
+ schema_version: 1,
267
+ sdk: SDK_ID,
268
+ generated_at: Math.floor(Date.now() / 1e3),
269
+ activated,
270
+ anchor_pinned: anchorPinned,
271
+ decision,
272
+ is_offline: state?.isOffline ?? null,
273
+ license_expires_at: timestamp(state?.licenseExpiresAt),
274
+ grace_until: timestamp(state?.graceUntil),
275
+ error_code: code,
276
+ http_status: error instanceof TalosApiError && Number.isInteger(error.statusCode) && error.statusCode >= 100 && error.statusCode <= 599 ? error.statusCode : null,
277
+ next_action: action
278
+ }, null, 2);
279
+ }
280
+
281
+ // src/offline.ts
282
+ import { createPrivateKey, generateKeyPairSync, sign as cryptoSign } from "crypto";
283
+
236
284
  // src/talos-compact.ts
237
285
  import { createPublicKey, verify as cryptoVerify } from "crypto";
238
286
  var SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
@@ -312,6 +360,36 @@ function verifyKeyset(anchorKey, expectedAud, token) {
312
360
  return claims;
313
361
  }
314
362
 
363
+ // src/offline.ts
364
+ var REQUEST_DOMAIN = "talos-offline-request";
365
+ var RECEIPT_DOMAIN = "talos-offline-deactivation";
366
+ var LICENSE_FILE_KIND = "talos-offline-license";
367
+ var DOC_VERSION = 1;
368
+ function generateMachineKey() {
369
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
370
+ return {
371
+ privateKey: privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"),
372
+ // The raw key is the last 32 bytes of the SPKI DER; every other Ed25519
373
+ // implementation in this project speaks raw-32, including the server's.
374
+ publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(-32).toString("base64")
375
+ };
376
+ }
377
+ function privateKeyObject(pkcs8Base64) {
378
+ return createPrivateKey({
379
+ key: Buffer.from(pkcs8Base64, "base64"),
380
+ format: "der",
381
+ type: "pkcs8"
382
+ });
383
+ }
384
+ function signedMessage(domain, payloadB64) {
385
+ return `${domain}.${DOC_VERSION}.${payloadB64}`;
386
+ }
387
+ function signMachineDoc(key, domain, payload) {
388
+ const req = b64urlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
389
+ const sig = cryptoSign(null, Buffer.from(signedMessage(domain, req), "ascii"), privateKeyObject(key.privateKey));
390
+ return { v: DOC_VERSION, kind: domain, req, sig: b64urlEncode(sig) };
391
+ }
392
+
315
393
  // src/client.ts
316
394
  var CLOCK_SKEW_SECONDS2 = 60;
317
395
  var DEFAULT_HEARTBEAT_SECONDS = 600;
@@ -398,6 +476,14 @@ var TalosClient = class {
398
476
  /** Default instance id for floating leases: one per client object. */
399
477
  instanceId = b64urlEncode(randomBytes2(9));
400
478
  current = null;
479
+ /**
480
+ * Return a local JSON support report without credentials, identifiers or
481
+ * exception text. Pass a caught error for classification. Makes no requests
482
+ * and does not change licensing state; `decision` is the last known result.
483
+ */
484
+ createDiagnosticReport(error) {
485
+ return diagnosticReport(this.isActivated, this.options.anchorPublicKey != null && this.options.anchorPublicKey !== "", this.current, error);
486
+ }
401
487
  listeners = /* @__PURE__ */ new Map();
402
488
  /** The newest release this client has already announced. */
403
489
  announcedReleaseSeq = 0;
@@ -423,9 +509,9 @@ var TalosClient = class {
423
509
  const stored = this.store?.load() ?? null;
424
510
  if (stored) {
425
511
  this.binding = stored;
426
- this.licenseKeyHash = stored.licenseKeyHash;
427
- this.activationSecret = stored.activationSecret;
428
- this.machineId = stored.machineId;
512
+ this.licenseKeyHash = stored.licenseKeyHash || null;
513
+ this.activationSecret = stored.activationSecret || null;
514
+ this.machineId = stored.machineId || null;
429
515
  if (stored.keyset) {
430
516
  try {
431
517
  this.adoptKeyset(stored.keyset);
@@ -473,8 +559,17 @@ var TalosClient = class {
473
559
  * construction, so this is the startup question: prompt for a licence key, or
474
560
  * go straight to {@link validate}?
475
561
  */
562
+ /**
563
+ * Whether this install holds a licence for this machine.
564
+ *
565
+ * True for an online activation (the activation secret is held) **and** for an
566
+ * air-gapped install that has loaded a licence file. Widened rather than
567
+ * split, because what a host does with this is decide whether to ask the user
568
+ * for a licence key — and an air-gapped machine that already holds a valid
569
+ * file must not be asked for one it has no way to redeem.
570
+ */
476
571
  get isActivated() {
477
- return this.activationSecret !== null;
572
+ return this.activationSecret !== null || this.binding?.offlineToken != null;
478
573
  }
479
574
  /** The server's id for this machine, once activated. */
480
575
  get currentMachineId() {
@@ -631,12 +726,221 @@ var TalosClient = class {
631
726
  // A fresh activation invalidates any cached verdict for the previous
632
727
  // binding; keep only the keyset, which is per product, not per machine.
633
728
  keyset: typeof body["keyset"] === "string" ? body["keyset"] : this.binding?.keyset,
634
- timeHighWater: this.binding?.timeHighWater
729
+ timeHighWater: this.binding?.timeHighWater,
730
+ // The machine's own identity survives an online activation, because it is
731
+ // an identity and not a verdict: an install that reaches the network once
732
+ // must not have to repeat the whole air-gapped ceremony with its vendor
733
+ // the next time it is unplugged. The clock mark survives for the same
734
+ // reason a mark exists at all — it only moves forward.
735
+ machineKey: this.binding?.machineKey,
736
+ machinePublicKey: this.binding?.machinePublicKey,
737
+ offlineSeen: this.binding?.offlineSeen
738
+ // The offline *file* does not: a live activation supersedes it, and
739
+ // keeping it would leave two answers to "what licenses this machine".
635
740
  };
636
741
  this.advanceClock(claims);
637
742
  this.store?.save(this.binding);
638
743
  return this.adopt(stateFromClaims(claims, "valid", false));
639
744
  }
745
+ /**
746
+ * This machine's own keypair, generated once and kept in the state file.
747
+ *
748
+ * The air-gapped counterpart of the activation secret: a machine that cannot
749
+ * reach a server is never issued one, so it makes an identity of its own and
750
+ * the vendor binds the file to it.
751
+ */
752
+ machineKey() {
753
+ const held = this.binding?.machineKey;
754
+ const pub = this.binding?.machinePublicKey;
755
+ if (held && pub) return { privateKey: held, publicKey: pub };
756
+ const key = generateMachineKey();
757
+ this.binding = {
758
+ ...this.bindingBase(),
759
+ machineKey: key.privateKey,
760
+ machinePublicKey: key.publicKey
761
+ };
762
+ this.store?.save(this.binding);
763
+ return key;
764
+ }
765
+ /**
766
+ * The binding as it stands, or an empty one to build on.
767
+ *
768
+ * An air-gapped install reaches its first write with nothing stored — no
769
+ * licence key, no activation secret, no machine id — so there has to be
770
+ * something for the offline fields to be added to.
771
+ */
772
+ bindingBase() {
773
+ return this.binding ?? {
774
+ licenseKeyHash: "",
775
+ activationSecret: "",
776
+ machineId: "",
777
+ fingerprint: this.fingerprint
778
+ };
779
+ }
780
+ /**
781
+ * Write the request an air-gapped machine hands to its vendor.
782
+ *
783
+ * Save the returned object as `talos-request.json`, carry it to whoever sold
784
+ * the licence, and they upload it in the portal. What comes back is a
785
+ * `license.talos` for {@link loadOfflineLicense} — and only for *this*
786
+ * machine: the request carries the fingerprint and the public half of a
787
+ * keypair generated here, and the file is bound to both.
788
+ *
789
+ * Calling it twice produces two requests under the same identity. The vendor
790
+ * can issue against either; the second file supersedes the first without
791
+ * spending a second seat.
792
+ *
793
+ * ```ts
794
+ * writeFileSync("talos-request.json", JSON.stringify(talos.createOfflineRequest(), null, 2));
795
+ * ```
796
+ *
797
+ * @param licenseKey Optional. The operator picks the licence in the portal, so
798
+ * it is not needed — but when it is present the server checks it against the
799
+ * licence being issued against, which catches issuing from the wrong tab.
800
+ */
801
+ createOfflineRequest(licenseKey) {
802
+ const key = this.machineKey();
803
+ return signMachineDoc(key, REQUEST_DOMAIN, {
804
+ product_token: this.options.productToken,
805
+ license_key: licenseKey,
806
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
807
+ machine_pubkey: key.publicKey,
808
+ nonce: newNonce(),
809
+ hostname: this.reportedHostname(),
810
+ os: `${os3.type()} ${os3.release()}`,
811
+ arch: os3.arch(),
812
+ app_version: this.options.appVersion,
813
+ sdk_version: SDK_ID,
814
+ created_at: nowSeconds()
815
+ });
816
+ }
817
+ /**
818
+ * Adopt an air-gapped licence file issued for this machine.
819
+ *
820
+ * Everything is verified locally, because there is nothing to ask: the
821
+ * anchor-signed keyset travels inside the file, the verdict is signed by a key
822
+ * that keyset lists, and the file names both halves of this machine's identity
823
+ * — its fingerprint and the public key it generated. The verdict is then kept,
824
+ * so later starts are licensed with no further ceremony.
825
+ *
826
+ * **Throws** only for a file that is not a licence: unreadable, not one of
827
+ * ours, addressed to another product, or a broken signature. A file that *is*
828
+ * one but cannot license this machine right now comes back as a verdict —
829
+ * `machine_mismatch` for another machine's file, `offline_file_expired` for
830
+ * one past its horizon, `expired` for a licence that has ended, and
831
+ * `clock_tampered` for a clock wound back behind what this install has already
832
+ * seen. Those are things the user can be told and can act on, and turning
833
+ * them into exceptions would make the launch path throw for a reason nobody
834
+ * can catch usefully.
835
+ */
836
+ async loadOfflineLicense(pathOrBuffer) {
837
+ const raw = typeof pathOrBuffer === "string" ? readFileSync3(pathOrBuffer) : Buffer.from(pathOrBuffer);
838
+ let doc;
839
+ try {
840
+ doc = JSON.parse(raw.toString("utf8"));
841
+ } catch {
842
+ throw new TalosError("not a Talos offline licence file");
843
+ }
844
+ if (doc["kind"] !== LICENSE_FILE_KIND || typeof doc["token"] !== "string") {
845
+ throw new TalosError("not a Talos offline licence file");
846
+ }
847
+ if (typeof doc["keyset"] === "string") this.adoptKeyset(doc["keyset"]);
848
+ return this.adoptOfflineToken(doc["token"], doc["keyset"]);
849
+ }
850
+ /**
851
+ * Hand this machine's air-gapped seat back.
852
+ *
853
+ * Save the returned object as `talos-deactivation.json` and send it to the
854
+ * vendor; they upload it in the portal and the seat returns to the pool.
855
+ * Signed by the same key the licence file is bound to, which is what makes it
856
+ * worth anything to a server that has never spoken to this machine.
857
+ *
858
+ * **The licence is dropped here, before the document is returned** — so an
859
+ * application that writes the file, or loses it, or never sends it has still
860
+ * stopped using the seat it is giving up. A receipt proves intent, not
861
+ * destruction: the vendor's copy of the file cannot be recalled, and if this
862
+ * machine is later restored from a snapshot it keeps working until that file
863
+ * expires. That is the accepted cost, and it is why the horizon is short.
864
+ *
865
+ * Throws when this install holds no offline licence — there is nothing to hand
866
+ * back, and a document saying otherwise would be a lie the vendor acts on.
867
+ */
868
+ createOfflineDeactivation() {
869
+ const token = this.binding?.offlineToken;
870
+ const key = this.binding?.machineKey;
871
+ const pub = this.binding?.machinePublicKey;
872
+ if (!token || !key || !pub) {
873
+ throw new TalosError("no offline licence to hand back");
874
+ }
875
+ let claims;
876
+ try {
877
+ claims = verifyCachedToken(
878
+ this.licenseVerifyKeys(),
879
+ "offline",
880
+ this.options.productToken,
881
+ token
882
+ );
883
+ } catch {
884
+ throw new TalosError("the stored offline licence no longer verifies");
885
+ }
886
+ this.binding = { ...this.bindingBase(), offlineToken: void 0 };
887
+ this.current = null;
888
+ this.store?.save(this.binding);
889
+ return signMachineDoc({ privateKey: key, publicKey: pub }, RECEIPT_DOMAIN, {
890
+ fid: claims["fid"],
891
+ mid: claims["mid"],
892
+ fph: this.fingerprint,
893
+ local_time: nowSeconds()
894
+ });
895
+ }
896
+ /**
897
+ * Verify the offline file against this machine and this clock, and adopt the
898
+ * verdict. Runs on load and again on every {@link validate}, because the two
899
+ * things that can change afterwards — the clock and the calendar — are
900
+ * exactly what it checks.
901
+ */
902
+ adoptOfflineToken(token, keyset) {
903
+ const claims = verifyCachedToken(
904
+ this.licenseVerifyKeys(),
905
+ "offline",
906
+ this.options.productToken,
907
+ token
908
+ );
909
+ const mismatch = () => this.adopt(new LicenseState("machine_mismatch", {}, null, true, null, null));
910
+ if (typeof claims["mpk"] !== "string" || claims["mpk"] !== this.binding?.machinePublicKey) {
911
+ return mismatch();
912
+ }
913
+ if (claims["fph"] !== this.fingerprint) return mismatch();
914
+ const now = nowSeconds();
915
+ const mark = Math.max(this.binding?.timeHighWater ?? 0, this.binding?.offlineSeen ?? 0);
916
+ if (mark > 0 && now + CLOCK_SKEW_SECONDS2 < mark) {
917
+ return this.adopt(new LicenseState("clock_tampered", {}, null, true, null, null));
918
+ }
919
+ const lic = claims["lic"] ?? {};
920
+ const features = lic["features"] ?? {};
921
+ const licExp = num(lic["lic_exp"]);
922
+ const exp = num(claims["exp"]);
923
+ this.machineId = typeof claims["mid"] === "string" ? claims["mid"] : this.machineId;
924
+ const base = this.bindingBase();
925
+ this.binding = {
926
+ ...base,
927
+ machineId: this.machineId ?? base.machineId,
928
+ offlineToken: token,
929
+ offlineSeen: Math.max(base.offlineSeen ?? 0, now),
930
+ ...typeof keyset === "string" ? { keyset } : {}
931
+ };
932
+ this.advanceClock(claims);
933
+ this.store?.save(this.binding);
934
+ if (exp === null || now > exp) {
935
+ return this.adopt(
936
+ new LicenseState("offline_file_expired", features, licExp, true, null, exp)
937
+ );
938
+ }
939
+ if (licExp !== null && now > licExp) {
940
+ return this.adopt(new LicenseState("expired", features, licExp, true, null, exp));
941
+ }
942
+ return this.adopt(new LicenseState("valid", features, licExp, true, null, exp));
943
+ }
640
944
  /**
641
945
  * The heartbeat cadence the server's policy asks for, in seconds, taken from
642
946
  * the last heartbeat's **signed** claims — so it cannot be lengthened in
@@ -776,6 +1080,9 @@ var TalosClient = class {
776
1080
  }
777
1081
  }
778
1082
  async validate() {
1083
+ if (!this.activationSecret && this.binding?.offlineToken) {
1084
+ return this.adoptOfflineToken(this.binding.offlineToken);
1085
+ }
779
1086
  this.requireActivated();
780
1087
  try {
781
1088
  await this.ensureKeyset();
@@ -1444,7 +1751,10 @@ async function verifyAppliedUpdate(filePath, delta) {
1444
1751
  }
1445
1752
  }
1446
1753
  export {
1754
+ LICENSE_FILE_KIND,
1447
1755
  LicenseState,
1756
+ RECEIPT_DOMAIN,
1757
+ REQUEST_DOMAIN,
1448
1758
  StateStore,
1449
1759
  TalosApiError,
1450
1760
  TalosClient,