@antondziuin/talos-sdk 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1462 @@
1
+ // src/client.ts
2
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "crypto";
3
+ import { createReadStream } from "fs";
4
+ import { open, rename, rm } from "fs/promises";
5
+ import os3 from "os";
6
+ import "crypto";
7
+
8
+ // src/errors.ts
9
+ var TalosError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "TalosError";
13
+ }
14
+ };
15
+ var TalosSignatureError = class extends TalosError {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "TalosSignatureError";
19
+ }
20
+ };
21
+ var TalosApiError = class extends TalosError {
22
+ statusCode;
23
+ errorCode;
24
+ constructor(statusCode, errorCode) {
25
+ super(`talos api error ${statusCode}${errorCode ? `: ${errorCode}` : ""}`);
26
+ this.name = "TalosApiError";
27
+ this.statusCode = statusCode;
28
+ this.errorCode = errorCode;
29
+ }
30
+ };
31
+
32
+ // src/fingerprint.ts
33
+ import { createHash, createHmac } from "crypto";
34
+ import { readFileSync, statSync } from "fs";
35
+ import os from "os";
36
+ function collectFingerprint(productToken, extra) {
37
+ const key = createHash("sha256").update("talos-fp|" + productToken).digest();
38
+ const raw = {
39
+ machine_guid: machineGuid(),
40
+ smbios_uuid: readFile("/sys/class/dmi/id/product_uuid"),
41
+ disk_serial: readFile("/sys/class/dmi/id/board_serial"),
42
+ mac_set: macSet(),
43
+ cpu: cpu(),
44
+ mem_bucket: memBucket(),
45
+ os_install: osInstall(),
46
+ hostname: safe(() => os.hostname()),
47
+ ...extra
48
+ };
49
+ const components = {};
50
+ for (const id of Object.keys(raw).sort()) {
51
+ const value = raw[id];
52
+ if (value === void 0 || value === null || value.trim() === "") continue;
53
+ components[id] = createHmac("sha256", key).update(id + "=" + value.trim()).digest("hex").slice(0, 32);
54
+ }
55
+ const canonical = Object.entries(components).map(([k, v]) => `${k}=${v}`).join("\n");
56
+ return {
57
+ composite: "fp2:" + createHash("sha256").update(canonical).digest("hex"),
58
+ components
59
+ };
60
+ }
61
+ function machineGuid() {
62
+ return readFile("/etc/machine-id") ?? readFile("/var/lib/dbus/machine-id");
63
+ }
64
+ function macSet() {
65
+ return safe(() => {
66
+ const macs = Object.values(os.networkInterfaces()).flat().filter((i) => Boolean(i) && !i.internal).map((i) => i.mac).filter((m) => m && m !== "00:00:00:00:00:00").filter((m, i, all) => all.indexOf(m) === i).sort();
67
+ return macs.length === 0 ? void 0 : macs.join(",");
68
+ });
69
+ }
70
+ function cpu() {
71
+ return safe(() => {
72
+ const cpus = os.cpus();
73
+ if (!cpus || cpus.length === 0) return void 0;
74
+ return `${cpus[0].model}|${cpus.length}`;
75
+ });
76
+ }
77
+ function memBucket() {
78
+ return safe(() => {
79
+ const gib = Math.floor(os.totalmem() / 1024 ** 3);
80
+ if (gib < 1) return void 0;
81
+ let bucket = 1;
82
+ while (bucket * 2 <= gib) bucket *= 2;
83
+ return String(bucket);
84
+ });
85
+ }
86
+ function osInstall() {
87
+ const path = os.platform() === "win32" ? process.env.SystemRoot ?? "C:\\Windows" : "/etc";
88
+ return safe(() => {
89
+ const birth = statSync(path).birthtimeMs;
90
+ return birth > 0 ? String(Math.floor(birth / 1e3)) : void 0;
91
+ });
92
+ }
93
+ function readFile(path) {
94
+ return safe(() => {
95
+ const text = readFileSync(path, "utf8").trim();
96
+ return text.length === 0 ? void 0 : text;
97
+ });
98
+ }
99
+ function safe(f) {
100
+ try {
101
+ return f();
102
+ } catch {
103
+ return void 0;
104
+ }
105
+ }
106
+
107
+ // src/state.ts
108
+ import { createCipheriv, createDecipheriv, createHash as createHash2, hkdfSync } from "crypto";
109
+ import { randomBytes } from "crypto";
110
+ import { chmodSync, mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync } from "fs";
111
+ import { dirname, join } from "path";
112
+ import os2 from "os";
113
+ var MAGIC = Buffer.from("TLS1");
114
+ var IV_LEN = 12;
115
+ var TAG_LEN = 16;
116
+ var SALT = Buffer.from("talos-state-v1");
117
+ function defaultStatePath(productToken) {
118
+ return statePathFor(productToken, os2.platform(), os2.homedir(), process.env);
119
+ }
120
+ function statePathFor(productToken, platform, home, env) {
121
+ const slot = createHash2("sha256").update(productToken).digest("hex").slice(0, 16);
122
+ switch (platform) {
123
+ case "win32":
124
+ return join(env["LOCALAPPDATA"] || join(home, "AppData", "Local"), "Talos", slot, "state.bin");
125
+ case "darwin":
126
+ return join(home, "Library", "Application Support", "Talos", slot, "state.bin");
127
+ default:
128
+ return join(env["XDG_DATA_HOME"] || join(home, ".local", "share"), "talos", slot, "state.bin");
129
+ }
130
+ }
131
+ function stateKeyMaterial(fp) {
132
+ return fp.components["machine_guid"] ?? fp.components["smbios_uuid"] ?? fp.components["os_install"] ?? "talos-unbound";
133
+ }
134
+ function hashLicenseKey(key) {
135
+ return createHash2("sha256").update(key, "utf8").digest("hex");
136
+ }
137
+ var StateStore = class {
138
+ constructor(path, productToken, keyMaterial) {
139
+ this.path = path;
140
+ this.productToken = productToken;
141
+ this.key = Buffer.from(
142
+ hkdfSync("sha256", Buffer.from(keyMaterial), SALT, Buffer.from(productToken), 32)
143
+ );
144
+ }
145
+ path;
146
+ productToken;
147
+ key;
148
+ /**
149
+ * Load the binding, or null if there is none.
150
+ *
151
+ * Never throws. A state file that is missing, truncated, from another
152
+ * product, written by a future version, or simply corrupt must degrade to
153
+ * "not activated yet" — the app then activates again. Throwing here would
154
+ * turn a damaged cache into a launch failure.
155
+ */
156
+ load() {
157
+ let raw;
158
+ try {
159
+ raw = readFileSync2(this.path);
160
+ } catch {
161
+ return null;
162
+ }
163
+ try {
164
+ if (raw.length < MAGIC.length + IV_LEN + TAG_LEN) return null;
165
+ if (!raw.subarray(0, MAGIC.length).equals(MAGIC)) return null;
166
+ const iv = raw.subarray(MAGIC.length, MAGIC.length + IV_LEN);
167
+ const tag = raw.subarray(MAGIC.length + IV_LEN, MAGIC.length + IV_LEN + TAG_LEN);
168
+ const body = raw.subarray(MAGIC.length + IV_LEN + TAG_LEN);
169
+ const d = createDecipheriv("aes-256-gcm", this.key, iv);
170
+ d.setAAD(Buffer.from(this.productToken));
171
+ d.setAuthTag(tag);
172
+ const plain = Buffer.concat([d.update(body), d.final()]).toString("utf8");
173
+ const o = JSON.parse(plain);
174
+ if (o["v"] !== 1) return null;
175
+ const legacyKey = typeof o["licenseKey"] === "string" ? o["licenseKey"] : "";
176
+ const b = {
177
+ licenseKeyHash: typeof o["licenseKeyHash"] === "string" && o["licenseKeyHash"] ? o["licenseKeyHash"] : legacyKey ? hashLicenseKey(legacyKey) : "",
178
+ activationSecret: String(o["activationSecret"] ?? ""),
179
+ machineId: String(o["machineId"] ?? ""),
180
+ fingerprint: String(o["fingerprint"] ?? "")
181
+ };
182
+ if (typeof o["keyset"] === "string") b.keyset = o["keyset"];
183
+ if (typeof o["validation"] === "string") b.validation = o["validation"];
184
+ if (typeof o["timeHighWater"] === "number") b.timeHighWater = o["timeHighWater"];
185
+ if (typeof o["releaseSeqHighWater"] === "number") {
186
+ b.releaseSeqHighWater = o["releaseSeqHighWater"];
187
+ }
188
+ const pending = o["pendingRelease"];
189
+ if (pending && typeof pending["version"] === "string" && typeof pending["releaseSeq"] === "number") {
190
+ b.pendingRelease = { version: pending["version"], releaseSeq: pending["releaseSeq"] };
191
+ }
192
+ return b.activationSecret ? b : null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ }
197
+ /**
198
+ * Persist the binding. Best-effort by design: a read-only or full disk costs
199
+ * the user a re-activation next launch, and must not fail the activation
200
+ * that just succeeded.
201
+ */
202
+ save(b) {
203
+ try {
204
+ const plain = Buffer.from(JSON.stringify({ v: 1, ...b }), "utf8");
205
+ const iv = randomBytes(IV_LEN);
206
+ const c = createCipheriv("aes-256-gcm", this.key, iv);
207
+ c.setAAD(Buffer.from(this.productToken));
208
+ const body = Buffer.concat([c.update(plain), c.final()]);
209
+ const out = Buffer.concat([MAGIC, iv, c.getAuthTag(), body]);
210
+ mkdirSync(dirname(this.path), { recursive: true, mode: 448 });
211
+ const tmp = `${this.path}.tmp`;
212
+ writeFileSync(tmp, out, { mode: 384 });
213
+ try {
214
+ chmodSync(tmp, 384);
215
+ } catch {
216
+ }
217
+ renameSync(tmp, this.path);
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ /** Forget the binding (deactivation). */
224
+ clear() {
225
+ try {
226
+ rmSync(this.path, { force: true });
227
+ } catch {
228
+ }
229
+ }
230
+ };
231
+
232
+ // src/version.ts
233
+ var SDK_VERSION = "0.1.0";
234
+ var SDK_ID = `node/${SDK_VERSION}`;
235
+
236
+ // src/talos-compact.ts
237
+ import { createPublicKey, verify as cryptoVerify } from "crypto";
238
+ var SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
239
+ var CLOCK_SKEW_SECONDS = 60;
240
+ function publicKeyFromBase64(b64) {
241
+ const raw = Buffer.from(b64, "base64");
242
+ if (raw.length !== 32) {
243
+ throw new Error("public key must decode to 32 bytes");
244
+ }
245
+ const der = Buffer.concat([SPKI_PREFIX, raw]);
246
+ return createPublicKey({ key: der, format: "der", type: "spki" });
247
+ }
248
+ function b64urlDecode(s) {
249
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64");
250
+ }
251
+ function b64urlEncode(buf) {
252
+ return buf.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
253
+ }
254
+ function parseAndVerify(keys, token, expectedTyp) {
255
+ const list = Array.isArray(keys) ? keys : [keys];
256
+ const parts = token.split(".");
257
+ if (parts.length !== 5) throw new TalosSignatureError("malformed token");
258
+ if (parts[0] !== "talos") throw new TalosSignatureError("unexpected scheme");
259
+ if (parts[1] !== "1") throw new TalosSignatureError("unsupported wire version");
260
+ if (parts[2] !== expectedTyp) throw new TalosSignatureError("token type mismatch");
261
+ if (list.length === 0) throw new TalosSignatureError("no verification key");
262
+ const lastDot = token.lastIndexOf(".");
263
+ const prefix = Buffer.from(token.slice(0, lastDot), "ascii");
264
+ const sig = b64urlDecode(parts[4]);
265
+ const ok = list.some((k) => cryptoVerify(null, prefix, k, sig));
266
+ if (!ok) throw new TalosSignatureError("signature verification failed");
267
+ return JSON.parse(b64urlDecode(parts[3]).toString("utf8"));
268
+ }
269
+ function verifyToken(keys, expectedTyp, expectedAud, expectedNonce, token) {
270
+ const claims = parseAndVerify(keys, token, expectedTyp);
271
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
272
+ if (claims["non"] !== expectedNonce) {
273
+ throw new TalosSignatureError("nonce mismatch (possible replay)");
274
+ }
275
+ const now = Math.floor(Date.now() / 1e3);
276
+ const nbf = claims["nbf"];
277
+ if (typeof nbf !== "number") throw new TalosSignatureError("token has no nbf");
278
+ if (now + CLOCK_SKEW_SECONDS < nbf) {
279
+ throw new TalosSignatureError("token not yet valid");
280
+ }
281
+ const exp = claims["exp"];
282
+ if (typeof exp !== "number") throw new TalosSignatureError("token has no exp");
283
+ if (now - CLOCK_SKEW_SECONDS > exp) {
284
+ throw new TalosSignatureError("token expired");
285
+ }
286
+ return claims;
287
+ }
288
+ function verifyCachedToken(keys, expectedTyp, expectedAud, token) {
289
+ const claims = parseAndVerify(keys, token, expectedTyp);
290
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
291
+ const nbf = claims["nbf"];
292
+ if (typeof nbf === "number" && Math.floor(Date.now() / 1e3) + CLOCK_SKEW_SECONDS < nbf) {
293
+ throw new TalosSignatureError("cached token not yet valid");
294
+ }
295
+ return claims;
296
+ }
297
+ function verifyManifest(releaseKeys, expectedAud, token) {
298
+ const claims = parseAndVerify(releaseKeys, token, "manifest");
299
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
300
+ const notBefore = claims["not_before"];
301
+ if (typeof notBefore === "number") {
302
+ const now = Math.floor(Date.now() / 1e3);
303
+ if (now + CLOCK_SKEW_SECONDS < notBefore) {
304
+ throw new TalosSignatureError("manifest not yet released");
305
+ }
306
+ }
307
+ return claims;
308
+ }
309
+ function verifyKeyset(anchorKey, expectedAud, token) {
310
+ const claims = parseAndVerify(anchorKey, token, "keyset");
311
+ if (claims["aud"] !== expectedAud) throw new TalosSignatureError("audience mismatch");
312
+ return claims;
313
+ }
314
+
315
+ // src/client.ts
316
+ var CLOCK_SKEW_SECONDS2 = 60;
317
+ var DEFAULT_HEARTBEAT_SECONDS = 600;
318
+ var DEFAULT_HTTP_TIMEOUT_MS = 1e4;
319
+ var LicenseState = class {
320
+ constructor(decision, features, licenseExpiresAt, isOffline = false, revalidateAfter = null, graceUntil = null) {
321
+ this.decision = decision;
322
+ this.features = features;
323
+ this.licenseExpiresAt = licenseExpiresAt;
324
+ this.isOffline = isOffline;
325
+ this.revalidateAfter = revalidateAfter;
326
+ this.graceUntil = graceUntil;
327
+ }
328
+ decision;
329
+ features;
330
+ licenseExpiresAt;
331
+ isOffline;
332
+ revalidateAfter;
333
+ graceUntil;
334
+ get isValid() {
335
+ return this.decision === "valid";
336
+ }
337
+ isFeatureEnabled(name) {
338
+ const v = this.features[name];
339
+ if (typeof v === "boolean") return v;
340
+ if (typeof v === "number") return v !== 0;
341
+ if (typeof v === "string") return v.length > 0;
342
+ return false;
343
+ }
344
+ getEntitlement(name) {
345
+ return this.features[name];
346
+ }
347
+ };
348
+ var KEYSET_MAX_AGE_SECONDS = 24 * 60 * 60;
349
+ var TalosClient = class {
350
+ options;
351
+ anchorKey;
352
+ publicKey;
353
+ releaseKey;
354
+ fingerprint;
355
+ fp;
356
+ doFetch;
357
+ // Learned from the anchor-signed keyset; take precedence over the direct pins.
358
+ keysetLicenseKeys = [];
359
+ keysetReleaseKeys = [];
360
+ /**
361
+ * SHA-256 of the key this machine activated with, or null.
362
+ *
363
+ * The key itself is deliberately not held: after activation the server
364
+ * resolves the licence from the activation secret, so keeping the key would
365
+ * mean a plaintext credential in memory and on disk for the sake of one
366
+ * string comparison in `activate`.
367
+ */
368
+ licenseKeyHash = null;
369
+ activationSecret = null;
370
+ machineId = null;
371
+ store;
372
+ /** Everything persisted for this product; written back as one file. */
373
+ binding = null;
374
+ /**
375
+ * True while the adopted keyset came off disk. One refresh per process
376
+ * upgrades it, so a rotation is picked up, but a client that cannot reach the
377
+ * server keeps verifying with what it already trusts.
378
+ */
379
+ keysetFromCache = false;
380
+ /**
381
+ * The `iat` of the adopted keyset, in unix seconds, or 0 if unknown.
382
+ *
383
+ * Kept so a keyset can go stale. Without it the refresh happens once per
384
+ * process, which is fine for a desktop app that restarts and useless for the
385
+ * process this SDK is most often *in* — a server or an Electron main that runs
386
+ * for weeks. A key revoked in the portal would never reach it.
387
+ */
388
+ keysetIssuedAt = 0;
389
+ /** The policy's heartbeat cadence, from the last heartbeat's signed claims. */
390
+ heartbeatInterval = null;
391
+ checkInTimer = null;
392
+ /** Bumped on stop so an in-flight tick cannot re-arm a cancelled loop. */
393
+ checkInGeneration = 0;
394
+ lease = null;
395
+ renewTimer = null;
396
+ /** Bumped on release so an in-flight renewal cannot resurrect the seat. */
397
+ leaseGeneration = 0;
398
+ /** Default instance id for floating leases: one per client object. */
399
+ instanceId = b64urlEncode(randomBytes2(9));
400
+ current = null;
401
+ listeners = /* @__PURE__ */ new Map();
402
+ /** The newest release this client has already announced. */
403
+ announcedReleaseSeq = 0;
404
+ constructor(options) {
405
+ if (!options.productToken) throw new TalosError("productToken required");
406
+ if (!options.serverUrl) throw new TalosError("serverUrl required");
407
+ if (!options.anchorPublicKey && !options.publicKey) {
408
+ throw new TalosError("anchorPublicKey or publicKey required");
409
+ }
410
+ this.options = options;
411
+ this.anchorKey = options.anchorPublicKey ? publicKeyFromBase64(options.anchorPublicKey) : null;
412
+ this.publicKey = options.publicKey ? publicKeyFromBase64(options.publicKey) : null;
413
+ this.releaseKey = options.releasePublicKey ? publicKeyFromBase64(options.releasePublicKey) : this.publicKey;
414
+ this.fp = collectFingerprint(options.productToken, options.fingerprintComponents);
415
+ this.fingerprint = this.fp.composite;
416
+ this.doFetch = options.fetch ?? globalThis.fetch;
417
+ if (!this.doFetch) throw new TalosError("no fetch available (need Node 18+ or options.fetch)");
418
+ this.store = options.persistState === false ? null : new StateStore(
419
+ options.statePath ?? defaultStatePath(options.productToken),
420
+ options.productToken,
421
+ stateKeyMaterial(this.fp)
422
+ );
423
+ const stored = this.store?.load() ?? null;
424
+ if (stored) {
425
+ this.binding = stored;
426
+ this.licenseKeyHash = stored.licenseKeyHash;
427
+ this.activationSecret = stored.activationSecret;
428
+ this.machineId = stored.machineId;
429
+ if (stored.keyset) {
430
+ try {
431
+ this.adoptKeyset(stored.keyset);
432
+ this.keysetFromCache = this.keysetLicenseKeys.length > 0;
433
+ } catch {
434
+ }
435
+ }
436
+ this.promoteDownloadedRelease();
437
+ }
438
+ }
439
+ /**
440
+ * A release that was downloaded and is now the version running: promote its
441
+ * `release_seq` to the anti-downgrade mark.
442
+ *
443
+ * This is the whole of "the SDK knows what is installed". The SDK downloads
444
+ * and verifies bytes; the host installs them, its own way, usually across a
445
+ * restart — so installation is not an event the SDK can observe. What it can
446
+ * observe is the result, because `appVersion` is the host saying which
447
+ * version it is. When that equals the version whose bytes were fetched, the
448
+ * download became an install.
449
+ *
450
+ * Only forward, and that is the point: yanking a release or lowering its
451
+ * rollout stops it being *offered*, and must not walk an install that already
452
+ * applied it back onto an older build.
453
+ */
454
+ promoteDownloadedRelease() {
455
+ const pending = this.binding?.pendingRelease;
456
+ if (!pending || pending.version !== this.options.appVersion) return;
457
+ const mark = Math.max(this.binding?.releaseSeqHighWater ?? 0, pending.releaseSeq);
458
+ this.persist({ releaseSeqHighWater: mark, pendingRelease: void 0 });
459
+ }
460
+ /**
461
+ * The `release_seq` no update may go at or below.
462
+ *
463
+ * The host's explicit value wins when it gives one — it knows things the SDK
464
+ * cannot, such as a build installed by a package manager — and the persisted
465
+ * mark is the default that makes anti-downgrade work for the hosts that give
466
+ * nothing, which is every example and most applications.
467
+ */
468
+ releaseFloor(explicit) {
469
+ return explicit ?? this.binding?.releaseSeqHighWater;
470
+ }
471
+ /**
472
+ * Whether this machine already holds an activation — restored from disk on
473
+ * construction, so this is the startup question: prompt for a licence key, or
474
+ * go straight to {@link validate}?
475
+ */
476
+ get isActivated() {
477
+ return this.activationSecret !== null;
478
+ }
479
+ /** The server's id for this machine, once activated. */
480
+ get currentMachineId() {
481
+ return this.machineId;
482
+ }
483
+ isFeatureEnabled(name) {
484
+ return this.current?.isFeatureEnabled(name) ?? false;
485
+ }
486
+ /**
487
+ * Fetch and verify the product's keyset (signed by the pinned anchor) and
488
+ * adopt its current license/release keys. Called automatically during
489
+ * {@link activate}; call it again after a server-side key rotation to keep
490
+ * verifying without any config change. No-op if no `anchorPublicKey` is pinned.
491
+ */
492
+ async refreshKeyset() {
493
+ if (!this.anchorKey) return;
494
+ const body = await this.post("/v1/client/keyset", {
495
+ product_token: this.options.productToken
496
+ });
497
+ this.adoptKeyset(body["keyset"]);
498
+ this.keysetFromCache = false;
499
+ if (typeof body["keyset"] === "string") this.persist({ keyset: body["keyset"] });
500
+ }
501
+ /**
502
+ * Make sure a verification key exists before a response has to be verified.
503
+ *
504
+ * A restored binding goes straight to `validate`, skipping `activate` — which
505
+ * is the only place the keyset used to be adopted. An anchor-only client (the
506
+ * recommended pinning, and the one that survives key rotation) therefore had
507
+ * nothing to verify with on the app's *second* launch, and every call failed
508
+ * with "keyset unavailable". Only persistence made that reachable: before it,
509
+ * `activate` always ran first.
510
+ *
511
+ * The keyset is also cached on disk, so the *first* call of a launch with no
512
+ * network can still verify a cached decision. A cached one is refreshed once
513
+ * per process so a rotation is picked up, but a client that cannot reach the
514
+ * server keeps verifying with what it already trusts.
515
+ */
516
+ async ensureKeyset() {
517
+ if (!this.anchorKey) return;
518
+ if (this.keysetLicenseKeys.length > 0 && !this.keysetFromCache && !this.keysetIsStale()) {
519
+ return;
520
+ }
521
+ try {
522
+ await this.refreshKeyset();
523
+ } catch (e) {
524
+ if (this.keysetLicenseKeys.length === 0) throw e;
525
+ }
526
+ }
527
+ /**
528
+ * Whether the adopted keyset is old enough to be worth re-fetching.
529
+ *
530
+ * This is what makes revoking a compromised signing key mean anything to an
531
+ * install: a revoked key is omitted from the keyset, so a client only stops
532
+ * trusting it once it fetches a newer one. Refreshing only on the first call
533
+ * of a process bounds that by "next restart", which for a long-running host is
534
+ * not a bound at all.
535
+ *
536
+ * A day, not an hour: the endpoint is public and unauthenticated, and every
537
+ * install on earth hitting it hourly is a self-inflicted load pattern for a
538
+ * document that changes about once a year. The honest description of the
539
+ * guarantee is in `docs/SECURITY.md` — revocation is bounded, not instant.
540
+ */
541
+ keysetIsStale() {
542
+ if (!this.keysetIssuedAt) return true;
543
+ return Date.now() / 1e3 - this.keysetIssuedAt > KEYSET_MAX_AGE_SECONDS;
544
+ }
545
+ /** Verify a keyset token against the anchor and adopt its keys. */
546
+ adoptKeyset(token) {
547
+ if (!this.anchorKey || typeof token !== "string") return;
548
+ const claims = verifyKeyset(this.anchorKey, this.options.productToken, token);
549
+ const iat = claims["iat"];
550
+ this.keysetIssuedAt = typeof iat === "number" ? iat : 0;
551
+ const licenseKeys = [];
552
+ const releaseKeys = [];
553
+ for (const entry of asArray(claims["keys"])) {
554
+ const o = entry;
555
+ if (typeof o["pk"] !== "string") continue;
556
+ const key = publicKeyFromBase64(o["pk"]);
557
+ if (o["purpose"] === "license") licenseKeys.push(key);
558
+ else if (o["purpose"] === "release") releaseKeys.push(key);
559
+ }
560
+ this.keysetLicenseKeys = licenseKeys;
561
+ this.keysetReleaseKeys = releaseKeys;
562
+ }
563
+ /** Keys to verify license tokens with: the keyset if loaded, else the direct pin. */
564
+ licenseVerifyKeys() {
565
+ if (this.keysetLicenseKeys.length) return this.keysetLicenseKeys;
566
+ if (this.publicKey) return [this.publicKey];
567
+ throw new TalosSignatureError("no license verification key (keyset unavailable)");
568
+ }
569
+ /** Keys to verify manifests with: the keyset if loaded, else the direct pin. */
570
+ releaseVerifyKeys() {
571
+ if (this.keysetReleaseKeys.length) return this.keysetReleaseKeys;
572
+ if (this.releaseKey) return [this.releaseKey];
573
+ throw new TalosSignatureError("no release verification key (keyset unavailable)");
574
+ }
575
+ /**
576
+ * Ensure this machine is activated on `licenseKey`, and return the verified
577
+ * state. Safe to call on every start.
578
+ *
579
+ * When a binding for the same key is already held, this **re-validates**
580
+ * instead of re-activating. Same one round trip, three different outcomes:
581
+ * the activation secret is not rotated (so a second copy of the app on the
582
+ * same machine does not knock the first one out), no seat accounting is
583
+ * touched, and — because `activate` is the only client route refused for a
584
+ * suspended tenant — the developer's own paying users keep working while
585
+ * their vendor's account is suspended, which is what "graduated suspension"
586
+ * was supposed to mean.
587
+ *
588
+ * It falls back to a real activation when the stored binding is no longer
589
+ * usable: the seat was released from the portal, or the secret was rotated
590
+ * elsewhere.
591
+ */
592
+ async activate(licenseKey) {
593
+ if (this.isActivated && this.licenseKeyHash === hashLicenseKey(licenseKey)) {
594
+ try {
595
+ const state = await this.validate();
596
+ if (state.decision !== "deactivated") return state;
597
+ } catch (e) {
598
+ const stale = e instanceof TalosApiError && (e.statusCode === 401 || e.statusCode === 404);
599
+ if (!stale) throw e;
600
+ }
601
+ this.forgetBinding();
602
+ }
603
+ const nonce = newNonce();
604
+ const body = await this.post("/v1/client/activate", {
605
+ product_token: this.options.productToken,
606
+ license_key: licenseKey,
607
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
608
+ nonce,
609
+ hostname: this.reportedHostname(),
610
+ os: `${os3.type()} ${os3.release()}`,
611
+ arch: os3.arch(),
612
+ app_version: this.options.appVersion,
613
+ sdk_version: SDK_ID
614
+ });
615
+ this.adoptKeyset(body["keyset"]);
616
+ const claims = verifyToken(
617
+ this.licenseVerifyKeys(),
618
+ "activation",
619
+ this.options.productToken,
620
+ nonce,
621
+ body["activation_token"]
622
+ );
623
+ this.licenseKeyHash = hashLicenseKey(licenseKey);
624
+ this.activationSecret = body["activation_secret"];
625
+ this.machineId = body["machine_id"] ?? null;
626
+ this.binding = {
627
+ licenseKeyHash: this.licenseKeyHash,
628
+ activationSecret: this.activationSecret,
629
+ machineId: this.machineId ?? "",
630
+ fingerprint: this.fingerprint,
631
+ // A fresh activation invalidates any cached verdict for the previous
632
+ // binding; keep only the keyset, which is per product, not per machine.
633
+ keyset: typeof body["keyset"] === "string" ? body["keyset"] : this.binding?.keyset,
634
+ timeHighWater: this.binding?.timeHighWater
635
+ };
636
+ this.advanceClock(claims);
637
+ this.store?.save(this.binding);
638
+ return this.adopt(stateFromClaims(claims, "valid", false));
639
+ }
640
+ /**
641
+ * The heartbeat cadence the server's policy asks for, in seconds, taken from
642
+ * the last heartbeat's **signed** claims — so it cannot be lengthened in
643
+ * transit by anyone wanting a fleet to check in less often.
644
+ *
645
+ * `null` until the first heartbeat of this process.
646
+ */
647
+ get heartbeatIntervalSeconds() {
648
+ return this.heartbeatInterval;
649
+ }
650
+ /**
651
+ * Subscribe to a licence transition. Returns a function that unsubscribes.
652
+ *
653
+ * ```ts
654
+ * const off = talos.on("gracePeriodStarted", (s) => showOfflineBanner(s.graceUntil));
655
+ * ```
656
+ *
657
+ * Fires from every path that adopts a verdict — `activate`, `validate`,
658
+ * `heartbeat`, and the offline fallback — not only from the background loop,
659
+ * so a host that runs its own timer gets the same transitions. The listener
660
+ * runs synchronously inside that call; a listener that throws is ignored (see
661
+ * {@link emit}).
662
+ *
663
+ * `updateAvailable` is the one event that is not a licence transition: it
664
+ * carries an {@link UpdateInfo}, and subscribing to it is what makes the
665
+ * heartbeat ask the server whether a release is waiting.
666
+ */
667
+ on(event, listener) {
668
+ let set = this.listeners.get(event);
669
+ if (!set) {
670
+ set = /* @__PURE__ */ new Set();
671
+ this.listeners.set(event, set);
672
+ }
673
+ const fn = listener;
674
+ set.add(fn);
675
+ return () => {
676
+ set.delete(fn);
677
+ };
678
+ }
679
+ /**
680
+ * Adopt a verdict as the current one, announcing the transition it makes.
681
+ *
682
+ * The single funnel every path goes through, which is what makes the events
683
+ * trustworthy: a verdict reached anywhere else would be a transition nobody
684
+ * hears about.
685
+ */
686
+ adopt(state) {
687
+ const event = transitionEvent(this.current, state);
688
+ this.current = state;
689
+ if (event) this.emit(event, state);
690
+ return state;
691
+ }
692
+ emit(event, payload) {
693
+ for (const listener of this.listeners.get(event) ?? []) {
694
+ try {
695
+ listener(payload);
696
+ } catch {
697
+ }
698
+ }
699
+ }
700
+ /**
701
+ * Run the check-ins the server's policy asks for, in the background.
702
+ *
703
+ * Two knobs have existed on the wire since Phase 1 and reached no client:
704
+ * `heartbeat_interval_s` (how often an install reports in — the telemetry
705
+ * rate, and the upper bound on how long a revocation takes to arrive) and
706
+ * `reval_after` (when the entitlement bag and the offline cache should be
707
+ * refreshed). Both are signed. This loop is what makes them a lever the
708
+ * developer can actually pull: change the policy, and deployed apps follow.
709
+ *
710
+ * One timer serves both. Each tick re-validates if the signed `reval_after`
711
+ * has passed, and otherwise sends a heartbeat — which is also what renews the
712
+ * cached verdict that {@link validate} falls back on, so an app that runs
713
+ * this stays inside its grace window without the host writing any scheduling
714
+ * of its own.
715
+ *
716
+ * Opt-in, and idempotent: calling it again replaces the running loop.
717
+ */
718
+ startCheckIns(opts = {}) {
719
+ this.stopCheckIns();
720
+ const generation = ++this.checkInGeneration;
721
+ const tick = async () => {
722
+ try {
723
+ const due = this.current?.revalidateAfter;
724
+ const state = due === null || due === void 0 || nowSeconds() >= due ? await this.validate() : await this.heartbeat();
725
+ if (generation !== this.checkInGeneration) return;
726
+ opts.onState?.(state);
727
+ } catch (e) {
728
+ if (generation !== this.checkInGeneration) return;
729
+ opts.onError?.(e);
730
+ }
731
+ if (generation === this.checkInGeneration) schedule();
732
+ };
733
+ const schedule = () => {
734
+ const secs = Math.max(60, this.heartbeatInterval ?? DEFAULT_HEARTBEAT_SECONDS);
735
+ this.checkInTimer = setTimeout(() => void tick(), secs * 1e3);
736
+ this.checkInTimer.unref?.();
737
+ };
738
+ schedule();
739
+ }
740
+ /** Stop the background check-ins started by {@link startCheckIns}. */
741
+ stopCheckIns() {
742
+ this.checkInGeneration += 1;
743
+ if (this.checkInTimer) {
744
+ clearTimeout(this.checkInTimer);
745
+ this.checkInTimer = null;
746
+ }
747
+ }
748
+ /** Drop the machine binding, in memory and on disk. */
749
+ forgetBinding() {
750
+ this.licenseKeyHash = null;
751
+ this.activationSecret = null;
752
+ this.machineId = null;
753
+ this.current = null;
754
+ this.binding = null;
755
+ this.store?.clear();
756
+ }
757
+ /** Merge fields into the persisted binding and write it back. */
758
+ persist(patch) {
759
+ if (!this.store || !this.binding) return;
760
+ this.binding = { ...this.binding, ...patch };
761
+ this.store.save(this.binding);
762
+ }
763
+ /**
764
+ * Advance the server-time high-water mark from a signed claim.
765
+ *
766
+ * Only ever forward: this is the mark that keeps offline grace bounded when
767
+ * the user owns the clock. `iat` is used when a token carries no explicit
768
+ * `server_time` (activation does not) — both are the server's clock, signed.
769
+ */
770
+ advanceClock(claims) {
771
+ const seen = num(claims["server_time"]) ?? num(claims["iat"]);
772
+ if (seen === null) return;
773
+ const current = this.binding?.timeHighWater ?? 0;
774
+ if (seen > current) {
775
+ if (this.binding) this.binding.timeHighWater = seen;
776
+ }
777
+ }
778
+ async validate() {
779
+ this.requireActivated();
780
+ try {
781
+ await this.ensureKeyset();
782
+ const nonce = newNonce();
783
+ const body = await this.post("/v1/client/validate", {
784
+ product_token: this.options.productToken,
785
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
786
+ activation_secret: this.activationSecret,
787
+ nonce,
788
+ // Sent here as well as on activation and heartbeat, because check-ins
789
+ // are opt-in and this call is not: an application that validates on
790
+ // launch and never calls `startCheckIns` would otherwise show, forever,
791
+ // whatever version it activated on.
792
+ app_version: this.options.appVersion,
793
+ sdk_version: SDK_ID
794
+ });
795
+ const token = body["validation_token"];
796
+ const claims = verifyToken(
797
+ this.licenseVerifyKeys(),
798
+ "validation",
799
+ this.options.productToken,
800
+ nonce,
801
+ token
802
+ );
803
+ const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
804
+ this.advanceClock(claims);
805
+ this.persist({ validation: token, timeHighWater: this.binding?.timeHighWater });
806
+ return this.adopt(stateFromClaims(claims, decision, false));
807
+ } catch (e) {
808
+ if (e instanceof TalosApiError) throw e;
809
+ const offline = this.offlineDecision();
810
+ if (!offline) throw e;
811
+ return this.adopt(offline);
812
+ }
813
+ }
814
+ /**
815
+ * The verdict from the local cache, or null when there is nothing usable.
816
+ *
817
+ * This is the whole offline story, and every check here is load-bearing:
818
+ *
819
+ * - the token is re-verified under the pinned key, so a cache edited on disk
820
+ * is worthless;
821
+ * - `fph` must be this machine, so copying a state file to a second computer
822
+ * and pulling the network cable does not licence it;
823
+ * - `grace_until` — the policy's `offline_grace_hours`, signed — bounds how
824
+ * long a verdict may outlive its issuance;
825
+ * - the licence's own expiry is re-checked, because a cached "valid" must not
826
+ * outlive the subscription it was issued under;
827
+ * - and the clock is checked against a mark that only moves forward, since
828
+ * every deadline above is evaluated against a clock the user controls.
829
+ */
830
+ offlineDecision() {
831
+ const token = this.binding?.validation;
832
+ if (!token) return null;
833
+ const now = nowSeconds();
834
+ const highWater = this.binding?.timeHighWater ?? 0;
835
+ if (highWater > 0 && now + CLOCK_SKEW_SECONDS2 < highWater) {
836
+ return new LicenseState("clock_tampered", {}, null, true, null, null);
837
+ }
838
+ let claims;
839
+ try {
840
+ claims = verifyCachedToken(
841
+ this.licenseVerifyKeys(),
842
+ "validation",
843
+ this.options.productToken,
844
+ token
845
+ );
846
+ } catch {
847
+ return null;
848
+ }
849
+ if (claims["fph"] !== this.fingerprint) return null;
850
+ const graceUntil = num(claims["grace_until"]);
851
+ const cached = stateFromClaims(
852
+ claims,
853
+ typeof claims["decision"] === "string" ? claims["decision"] : "invalid",
854
+ true
855
+ );
856
+ if (graceUntil === null || now > graceUntil) {
857
+ return new LicenseState(
858
+ "grace_expired",
859
+ cached.features,
860
+ cached.licenseExpiresAt,
861
+ true,
862
+ cached.revalidateAfter,
863
+ graceUntil
864
+ );
865
+ }
866
+ if (cached.licenseExpiresAt !== null && now > cached.licenseExpiresAt) {
867
+ return new LicenseState(
868
+ "expired",
869
+ cached.features,
870
+ cached.licenseExpiresAt,
871
+ true,
872
+ cached.revalidateAfter,
873
+ graceUntil
874
+ );
875
+ }
876
+ return cached;
877
+ }
878
+ async deactivate() {
879
+ this.requireActivated();
880
+ await this.post("/v1/client/deactivate", {
881
+ product_token: this.options.productToken,
882
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
883
+ activation_secret: this.activationSecret
884
+ });
885
+ this.forgetBinding();
886
+ }
887
+ /**
888
+ * Send a heartbeat: refreshes this machine's last-seen state, feeds the
889
+ * developer's monitoring (version adoption / active installs), and returns the
890
+ * current signed decision — so a revocation reaches the app within one
891
+ * heartbeat interval. Call it periodically while the app runs.
892
+ *
893
+ * The response is a signed `typ=heartbeat` token, so a MITM cannot forge the
894
+ * status or lengthen the interval.
895
+ */
896
+ /**
897
+ * The machine name to report, or `undefined` to report none.
898
+ *
899
+ * An explicit `""` means "do not send": omitting the field leaves whatever
900
+ * the server already holds, because the server coalesces, so sending an empty
901
+ * string would not clear it either — suppression has to happen here.
902
+ */
903
+ reportedHostname() {
904
+ const h = this.options.hostname ?? os3.hostname();
905
+ return h === "" ? void 0 : h;
906
+ }
907
+ async heartbeat() {
908
+ this.requireActivated();
909
+ await this.ensureKeyset();
910
+ const nonce = newNonce();
911
+ const body = await this.post("/v1/client/heartbeat", {
912
+ product_token: this.options.productToken,
913
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
914
+ activation_secret: this.activationSecret,
915
+ nonce,
916
+ app_version: this.options.appVersion,
917
+ os: `${os3.type()} ${os3.release()}`,
918
+ arch: os3.arch(),
919
+ sdk_version: SDK_ID,
920
+ // Naming the channel is what asks the server for the update hint, and it
921
+ // costs two queries there — so it is asked for only when somebody is
922
+ // listening for the answer.
923
+ update_channel: this.listeners.get("updateAvailable")?.size ? this.updateChannel() : void 0
924
+ });
925
+ const claims = verifyToken(
926
+ this.licenseVerifyKeys(),
927
+ "heartbeat",
928
+ this.options.productToken,
929
+ nonce,
930
+ body["heartbeat_token"]
931
+ );
932
+ const decision = typeof claims["decision"] === "string" ? claims["decision"] : "invalid";
933
+ const interval = num(claims["heartbeat_interval_s"]);
934
+ if (interval !== null && interval > 0) this.heartbeatInterval = interval;
935
+ this.advanceClock(claims);
936
+ this.persist({ timeHighWater: this.binding?.timeHighWater });
937
+ const state = this.adopt(
938
+ new LicenseState(
939
+ decision,
940
+ this.current?.features ?? {},
941
+ this.current?.licenseExpiresAt ?? null,
942
+ false,
943
+ this.current?.revalidateAfter ?? null,
944
+ this.current?.graceUntil ?? null
945
+ )
946
+ );
947
+ await this.announceUpdate(claims);
948
+ return state;
949
+ }
950
+ /** The channel this install follows; `stable` unless set. */
951
+ updateChannel() {
952
+ return this.options.updateChannel || "stable";
953
+ }
954
+ /**
955
+ * Act on the heartbeat's `upd` hint: confirm it with a real update check and
956
+ * emit `updateAvailable` for a release not announced yet.
957
+ *
958
+ * The hint is read from the **signed** claims, not the response envelope, and
959
+ * it is only ever a prompt to go and look: the check applies this install's
960
+ * anti-downgrade floor and verifies a manifest, neither of which the hint
961
+ * does. A hint that turns out to be wrong costs one request and emits
962
+ * nothing.
963
+ *
964
+ * A failing check is swallowed. The heartbeat it rode in on has already
965
+ * succeeded, and a licensing call must not start failing because the update
966
+ * service is having a bad day.
967
+ */
968
+ async announceUpdate(claims) {
969
+ if (!this.listeners.get("updateAvailable")?.size) return;
970
+ if (claims["upd"] !== true) return;
971
+ let info;
972
+ try {
973
+ info = await this.checkForUpdate();
974
+ } catch {
975
+ return;
976
+ }
977
+ if (!info || info.releaseSeq <= this.announcedReleaseSeq) return;
978
+ this.announcedReleaseSeq = info.releaseSeq;
979
+ this.emit("updateAvailable", info);
980
+ }
981
+ /**
982
+ * Acquire a floating seat (concurrent-use license). The server arbitrates the
983
+ * cap atomically, so this either returns a seat or throws
984
+ * {@link TalosApiError} with `seat_limit_reached` (HTTP 409).
985
+ *
986
+ * By default the seat is auto-renewed in the background at the server-chosen
987
+ * interval; a lost renewal invokes `onLeaseLost`. Always
988
+ * {@link releaseLease} on clean exit — a crash simply lets the seat expire.
989
+ */
990
+ async acquireLease(opts = {}) {
991
+ this.requireActivated();
992
+ await this.ensureKeyset();
993
+ const instanceId = opts.instanceId ?? this.instanceId;
994
+ const generation = this.leaseGeneration;
995
+ const nonce = newNonce();
996
+ const body = await this.post("/v1/client/leases", {
997
+ product_token: this.options.productToken,
998
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
999
+ activation_secret: this.activationSecret,
1000
+ instance_id: instanceId,
1001
+ nonce
1002
+ });
1003
+ const claims = verifyToken(
1004
+ this.licenseVerifyKeys(),
1005
+ "lease",
1006
+ this.options.productToken,
1007
+ nonce,
1008
+ body["lease_token"]
1009
+ );
1010
+ if (claims["inst"] !== instanceId || claims["fph"] !== this.fingerprint) {
1011
+ throw new TalosSignatureError("lease token does not match this instance");
1012
+ }
1013
+ const expiresAt = typeof claims["exp"] === "number" ? claims["exp"] : 0;
1014
+ const serverNow = typeof claims["server_time"] === "number" ? claims["server_time"] : Math.floor(Date.now() / 1e3);
1015
+ const budget = Math.max(1, expiresAt - serverNow);
1016
+ const hinted = Number(body["renew_after_s"] ?? 0);
1017
+ const lease = {
1018
+ id: String(claims["lease"] ?? ""),
1019
+ instanceId,
1020
+ expiresAt,
1021
+ renewAfterSeconds: hinted > 0 && hinted <= budget ? hinted : Math.max(1, Math.floor(budget / 3)),
1022
+ seatsInUse: Number(claims["seats_in_use"] ?? 0),
1023
+ maxSeats: Number(claims["max_seats"] ?? 0)
1024
+ };
1025
+ if (generation !== this.leaseGeneration) {
1026
+ void this.releaseInstance(instanceId).catch(() => {
1027
+ });
1028
+ return lease;
1029
+ }
1030
+ this.lease = lease;
1031
+ if (opts.autoRenew !== false) this.scheduleRenew(opts);
1032
+ return lease;
1033
+ }
1034
+ /** Best-effort DELETE of one instance's seat (used for stale-renewal cleanup). */
1035
+ async releaseInstance(instanceId) {
1036
+ if (!this.activationSecret) return;
1037
+ await this.request("DELETE", "/v1/client/leases", {
1038
+ product_token: this.options.productToken,
1039
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1040
+ activation_secret: this.activationSecret,
1041
+ instance_id: instanceId
1042
+ });
1043
+ }
1044
+ /** The currently held floating seat, if any. */
1045
+ get currentLease() {
1046
+ return this.lease;
1047
+ }
1048
+ /** Release the floating seat (clean exit) and stop auto-renewal. */
1049
+ async releaseLease() {
1050
+ this.leaseGeneration += 1;
1051
+ this.stopRenew();
1052
+ const lease = this.lease;
1053
+ this.lease = null;
1054
+ if (!lease) return;
1055
+ await this.releaseInstance(lease.instanceId);
1056
+ }
1057
+ scheduleRenew(opts) {
1058
+ this.stopRenew();
1059
+ const generation = this.leaseGeneration;
1060
+ const instanceId = this.lease?.instanceId;
1061
+ const delay = Math.max(1, this.lease?.renewAfterSeconds ?? 200) * 1e3;
1062
+ this.renewTimer = setTimeout(() => {
1063
+ void (async () => {
1064
+ try {
1065
+ await this.acquireLease({ ...opts, instanceId, autoRenew: false });
1066
+ if (generation !== this.leaseGeneration) return;
1067
+ this.scheduleRenew(opts);
1068
+ } catch (e) {
1069
+ if (generation !== this.leaseGeneration) return;
1070
+ this.lease = null;
1071
+ opts.onLeaseLost?.(e instanceof Error ? e.message : "lease renewal failed");
1072
+ }
1073
+ })();
1074
+ }, delay);
1075
+ this.renewTimer.unref?.();
1076
+ }
1077
+ stopRenew() {
1078
+ if (this.renewTimer) {
1079
+ clearTimeout(this.renewTimer);
1080
+ this.renewTimer = null;
1081
+ }
1082
+ }
1083
+ /**
1084
+ * Ask whether a newer release is available. Returns a verified {@link UpdateInfo}
1085
+ * (all metadata comes from the manifest signed by the pinned release key), or
1086
+ * `null` when up to date.
1087
+ *
1088
+ * Enforces anti-downgrade: a manifest whose `release_seq` is at or below the
1089
+ * floor is rejected as no update. The floor is the persisted high-water mark
1090
+ * — the highest `release_seq` this install has been seen running — unless
1091
+ * {@link CheckForUpdateOptions.currentReleaseSeq} overrides it.
1092
+ */
1093
+ async checkForUpdate(opts = {}) {
1094
+ this.requireActivated();
1095
+ await this.ensureKeyset();
1096
+ const body = await this.post("/v1/client/updates/check", {
1097
+ product_token: this.options.productToken,
1098
+ fingerprint: { composite: this.fingerprint, components: this.fp.components },
1099
+ activation_secret: this.activationSecret,
1100
+ current_version: opts.currentVersion ?? this.options.appVersion,
1101
+ current_release_seq: this.releaseFloor(opts.currentReleaseSeq),
1102
+ channel: opts.channel ?? this.updateChannel()
1103
+ });
1104
+ if (body["update_available"] !== true) return null;
1105
+ const manifestToken = body["manifest"];
1106
+ if (typeof manifestToken !== "string") {
1107
+ throw new TalosSignatureError("update available but no manifest");
1108
+ }
1109
+ const m = verifyManifest(this.releaseVerifyKeys(), this.options.productToken, manifestToken);
1110
+ const releaseSeq = typeof m["release_seq"] === "number" ? m["release_seq"] : 0;
1111
+ const floor = this.releaseFloor(opts.currentReleaseSeq);
1112
+ if (floor !== void 0 && releaseSeq <= floor) {
1113
+ return null;
1114
+ }
1115
+ const downloads = /* @__PURE__ */ new Map();
1116
+ for (const d of asArray(body["artifacts"])) {
1117
+ const o = d;
1118
+ if (typeof o["download_path"] === "string") {
1119
+ downloads.set(selKey(o["platform"], o["arch"], o["kind"]), o["download_path"]);
1120
+ }
1121
+ }
1122
+ const artifacts = asArray(m["artifacts"]).map((a) => {
1123
+ const o = a;
1124
+ const platform = String(o["platform"] ?? "");
1125
+ const arch = String(o["arch"] ?? "");
1126
+ const kind = String(o["kind"] ?? "");
1127
+ return {
1128
+ platform,
1129
+ arch,
1130
+ kind,
1131
+ filename: String(o["filename"] ?? ""),
1132
+ size: typeof o["size"] === "number" ? o["size"] : 0,
1133
+ sha256: String(o["sha256"] ?? ""),
1134
+ downloadPath: downloads.get(selKey(platform, arch, kind))
1135
+ };
1136
+ });
1137
+ const deltaPaths = /* @__PURE__ */ new Map();
1138
+ for (const d of asArray(body["deltas"])) {
1139
+ const o = d;
1140
+ if (typeof o["download_path"] === "string") {
1141
+ deltaPaths.set(
1142
+ deltaKey(o["platform"], o["arch"], o["from"], o["target_kind"]),
1143
+ o["download_path"]
1144
+ );
1145
+ }
1146
+ }
1147
+ const deltas = asArray(m["deltas"]).map((d) => {
1148
+ const o = d;
1149
+ const t = o["target"] ?? {};
1150
+ const platform = String(o["platform"] ?? "");
1151
+ const arch = String(o["arch"] ?? "");
1152
+ const from = String(o["from"] ?? "");
1153
+ const targetKind = String(t["kind"] ?? "");
1154
+ return {
1155
+ platform,
1156
+ arch,
1157
+ from,
1158
+ fromSeq: typeof o["from_seq"] === "number" ? o["from_seq"] : 0,
1159
+ filename: String(o["filename"] ?? ""),
1160
+ size: typeof o["size"] === "number" ? o["size"] : 0,
1161
+ sha256: String(o["sha256"] ?? ""),
1162
+ target: {
1163
+ kind: targetKind,
1164
+ filename: String(t["filename"] ?? ""),
1165
+ size: typeof t["size"] === "number" ? t["size"] : 0,
1166
+ sha256: String(t["sha256"] ?? "")
1167
+ },
1168
+ downloadPath: deltaPaths.get(deltaKey(platform, arch, from, targetKind))
1169
+ };
1170
+ });
1171
+ return {
1172
+ version: String(m["version"] ?? ""),
1173
+ releaseSeq,
1174
+ mandatory: m["mandatory"] === true,
1175
+ notesUrl: typeof m["notes_url"] === "string" ? m["notes_url"] : null,
1176
+ artifacts,
1177
+ deltas
1178
+ };
1179
+ }
1180
+ /**
1181
+ * The patch that upgrades this machine, or `null` if there is none.
1182
+ *
1183
+ * Matches on the *exact* published version string, not on version ordering: a
1184
+ * patch applies to the bytes it was built against, and "close enough" produces
1185
+ * a corrupt file rather than an error. So a machine whose installed version is
1186
+ * not one the release ships a patch from simply downloads the full build.
1187
+ */
1188
+ selectDelta(info, opts = {}) {
1189
+ const from = opts.from ?? this.options.appVersion;
1190
+ if (!from) return null;
1191
+ const { platform, arch } = hostTarget();
1192
+ const matches = info.deltas.filter(
1193
+ (d) => d.platform === platform && d.arch === arch && d.from === from
1194
+ );
1195
+ if (opts.targetKind) return matches.find((d) => d.target.kind === opts.targetKind) ?? null;
1196
+ return matches.length === 1 ? matches[0] : null;
1197
+ }
1198
+ /**
1199
+ * Download a patch and verify its bytes against the manifest's SHA-256, then
1200
+ * write it to `destPath`. Returns the patch, whose `target.sha256` is what the
1201
+ * rebuilt file must hash to.
1202
+ *
1203
+ * Does NOT apply it. Applying is the host's job — and so is checking the
1204
+ * result, which {@link verifyAppliedUpdate} does. A patch verified on the way
1205
+ * in and a result nobody checks is a signed chain that stops one step short of
1206
+ * the binary that runs.
1207
+ */
1208
+ async downloadDelta(info, destPath, opts = {}) {
1209
+ const chosen = opts.delta ?? this.selectDelta(info, opts);
1210
+ if (!chosen) throw new TalosError("no patch applies to this install");
1211
+ await this.fetchVerified(
1212
+ chosen.downloadPath,
1213
+ chosen.size,
1214
+ chosen.sha256,
1215
+ destPath,
1216
+ opts.onProgress
1217
+ );
1218
+ this.rememberDownloaded(info);
1219
+ return chosen;
1220
+ }
1221
+ /**
1222
+ * Download one artifact of an update and verify its bytes against the manifest
1223
+ * SHA-256, then write it to `destPath`. Returns the chosen artifact. Does NOT
1224
+ * apply or execute anything — verification and installation are the host app's
1225
+ * responsibility.
1226
+ *
1227
+ * `onProgress` is called as the bytes arrive, which is the only way a host can
1228
+ * show a bar: an installer takes minutes on a domestic connection, and a
1229
+ * promise that says nothing until it settles is indistinguishable from one that
1230
+ * has hung. Totals come from the signed manifest — see {@link DownloadProgress}.
1231
+ */
1232
+ async downloadUpdate(info, destPath, artifact, onProgress) {
1233
+ this.requireActivated();
1234
+ const chosen = artifact ?? pickArtifact(info.artifacts);
1235
+ if (!chosen) throw new TalosError("no artifact matches this platform");
1236
+ await this.fetchVerified(chosen.downloadPath, chosen.size, chosen.sha256, destPath, onProgress);
1237
+ this.rememberDownloaded(info);
1238
+ return chosen;
1239
+ }
1240
+ /**
1241
+ * Note which release's bytes are now on disk, so the next start can tell
1242
+ * whether they were installed.
1243
+ *
1244
+ * Written after the hash check, never before: a download that failed
1245
+ * verification is not a release this install is about to be running, and
1246
+ * recording it would let a corrupt or substituted file raise the
1247
+ * anti-downgrade floor past a good build.
1248
+ */
1249
+ rememberDownloaded(info) {
1250
+ if (info.releaseSeq <= (this.binding?.releaseSeqHighWater ?? 0)) return;
1251
+ this.persist({ pendingRelease: { version: info.version, releaseSeq: info.releaseSeq } });
1252
+ }
1253
+ /**
1254
+ * Fetch one file over the entitled download route and put it at `destPath`
1255
+ * only once its bytes match the size and hash the signed manifest names.
1256
+ * Shared by full builds and patches, which differ in what they are for and not
1257
+ * at all in how their bytes are checked.
1258
+ *
1259
+ * The bytes go straight to a `.talos-part` file beside the destination, hashed
1260
+ * as they arrive, and are renamed into place only once the hash matches. Two
1261
+ * things follow. A multi-gigabyte installer is never held in memory — the whole
1262
+ * body used to be buffered before it was checked, so the largest artifact a
1263
+ * product could ship was bounded by the smallest machine it ran on. And
1264
+ * `destPath` never holds unverified bytes, not even briefly: a download that
1265
+ * fails leaves whatever was there before rather than a truncated file a host
1266
+ * might run.
1267
+ */
1268
+ async fetchVerified(downloadPath, size, sha256, destPath, onProgress) {
1269
+ if (!downloadPath) throw new TalosError("artifact has no download path");
1270
+ const url = this.options.serverUrl.replace(/\/+$/, "") + downloadPath;
1271
+ const res = await this.doFetch(url, {
1272
+ method: "GET",
1273
+ headers: {
1274
+ "x-talos-product-token": this.options.productToken,
1275
+ "x-talos-activation-secret": this.activationSecret,
1276
+ "x-talos-fingerprint": this.fingerprint
1277
+ }
1278
+ });
1279
+ if (res.status < 200 || res.status >= 300) throw new TalosApiError(res.status, void 0);
1280
+ const contentLength = res.headers?.get("content-length");
1281
+ if (contentLength && Number(contentLength) > size) {
1282
+ throw new TalosSignatureError("artifact exceeds manifest size");
1283
+ }
1284
+ const partPath = destPath + ".talos-part";
1285
+ const handle = await open(partPath, "w");
1286
+ const hash = createHash3("sha256");
1287
+ let received = 0;
1288
+ let closed = false;
1289
+ const take = async (chunk) => {
1290
+ received += chunk.byteLength;
1291
+ if (received > size) throw new TalosSignatureError("artifact exceeds manifest size");
1292
+ hash.update(chunk);
1293
+ await handle.write(chunk);
1294
+ onProgress?.({ bytesReceived: received, totalBytes: size });
1295
+ };
1296
+ try {
1297
+ const body = res.body;
1298
+ if (body) {
1299
+ const reader = body.getReader();
1300
+ try {
1301
+ for (; ; ) {
1302
+ const { done, value } = await reader.read();
1303
+ if (done || !value) break;
1304
+ await take(value);
1305
+ }
1306
+ } catch (e) {
1307
+ await reader.cancel().catch(() => {
1308
+ });
1309
+ throw e;
1310
+ }
1311
+ } else {
1312
+ await take(new Uint8Array(await res.arrayBuffer()));
1313
+ }
1314
+ if (hash.digest("hex") !== sha256) {
1315
+ throw new TalosSignatureError("artifact hash mismatch (tampered or wrong bytes)");
1316
+ }
1317
+ await handle.close();
1318
+ closed = true;
1319
+ await rename(partPath, destPath);
1320
+ } catch (e) {
1321
+ if (!closed) await handle.close().catch(() => {
1322
+ });
1323
+ await rm(partPath, { force: true }).catch(() => {
1324
+ });
1325
+ throw e;
1326
+ }
1327
+ }
1328
+ requireActivated() {
1329
+ if (!this.activationSecret) {
1330
+ throw new TalosError("not activated: call activate() first");
1331
+ }
1332
+ }
1333
+ post(path, body) {
1334
+ return this.request("POST", path, body);
1335
+ }
1336
+ /**
1337
+ * The abort signal for one licensing call, or `undefined` when disabled.
1338
+ *
1339
+ * A fresh one per request rather than one shared: `AbortSignal.timeout`
1340
+ * starts counting when it is created, so a signal made once in the
1341
+ * constructor would already have fired by the second call.
1342
+ */
1343
+ timeoutSignal() {
1344
+ const ms = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1345
+ if (!ms || ms <= 0) return void 0;
1346
+ return typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : void 0;
1347
+ }
1348
+ async request(method, path, body) {
1349
+ let res;
1350
+ try {
1351
+ res = await this.doFetch(this.options.serverUrl.replace(/\/+$/, "") + path, {
1352
+ method,
1353
+ headers: { "content-type": "application/json" },
1354
+ body: JSON.stringify(body),
1355
+ signal: this.timeoutSignal()
1356
+ });
1357
+ } catch (e) {
1358
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
1359
+ throw new TalosError(
1360
+ `request timed out after ${this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS}ms`
1361
+ );
1362
+ }
1363
+ throw e;
1364
+ }
1365
+ const text = await res.text();
1366
+ if (res.status < 200 || res.status >= 300) {
1367
+ let errorCode;
1368
+ try {
1369
+ errorCode = JSON.parse(text).error;
1370
+ } catch {
1371
+ errorCode = void 0;
1372
+ }
1373
+ throw new TalosApiError(res.status, errorCode);
1374
+ }
1375
+ return text ? JSON.parse(text) : {};
1376
+ }
1377
+ };
1378
+ function stateFromClaims(claims, decision, isOffline) {
1379
+ const lic = claims["lic"] ?? {};
1380
+ const features = lic["features"] ?? {};
1381
+ const exp = num(lic["lic_exp"]);
1382
+ const iat = num(claims["iat"]) ?? nowSeconds();
1383
+ const revalAfter = num(claims["reval_after"]) ?? hoursAfter(iat, lic["reval_h"]);
1384
+ const graceUntil = num(claims["grace_until"]) ?? hoursAfter(iat, lic["grace_h"]);
1385
+ return new LicenseState(decision, features, exp, isOffline, revalAfter, graceUntil);
1386
+ }
1387
+ function hoursAfter(base, hours) {
1388
+ const h = num(hours);
1389
+ return h === null ? null : base + h * 3600;
1390
+ }
1391
+ function num(v) {
1392
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
1393
+ }
1394
+ function nowSeconds() {
1395
+ return Math.floor(Date.now() / 1e3);
1396
+ }
1397
+ function transitionEvent(prev, next) {
1398
+ if (next.decision === "clock_tampered") {
1399
+ return prev?.decision === "clock_tampered" ? null : "clockTamperDetected";
1400
+ }
1401
+ if (next.decision === "grace_expired") {
1402
+ return prev?.decision === "grace_expired" ? null : "gracePeriodExpired";
1403
+ }
1404
+ if (next.isValid) {
1405
+ if (!next.isOffline) return null;
1406
+ return prev?.isOffline && prev.isValid ? null : "gracePeriodStarted";
1407
+ }
1408
+ return prev?.decision === next.decision ? null : "licenseInvalid";
1409
+ }
1410
+ function newNonce() {
1411
+ return b64urlEncode(randomBytes2(16));
1412
+ }
1413
+ function asArray(v) {
1414
+ return Array.isArray(v) ? v : [];
1415
+ }
1416
+ function selKey(platform, arch, kind) {
1417
+ return `${String(platform)}/${String(arch)}/${String(kind)}`;
1418
+ }
1419
+ function deltaKey(platform, arch, from, targetKind) {
1420
+ return `${String(platform)}/${String(arch)}/${String(from)}/${String(targetKind)}`;
1421
+ }
1422
+ function hostTarget() {
1423
+ const map = { win32: "windows", darwin: "macos", linux: "linux" };
1424
+ return { platform: map[os3.platform()] ?? os3.platform(), arch: os3.arch() };
1425
+ }
1426
+ function pickArtifact(artifacts) {
1427
+ const { platform, arch } = hostTarget();
1428
+ const matches = artifacts.filter((a) => a.platform === platform && a.arch === arch);
1429
+ return matches.find((a) => a.kind === "installer") ?? matches[0];
1430
+ }
1431
+ async function verifyAppliedUpdate(filePath, delta) {
1432
+ const hash = createHash3("sha256");
1433
+ const stream = createReadStream(filePath);
1434
+ await new Promise((resolve, reject) => {
1435
+ stream.on("data", (chunk) => hash.update(chunk));
1436
+ stream.on("error", reject);
1437
+ stream.on("end", () => resolve());
1438
+ });
1439
+ const actual = hash.digest("hex");
1440
+ if (actual !== delta.target.sha256) {
1441
+ throw new TalosSignatureError(
1442
+ `applied update does not match the signed manifest: expected ${delta.target.sha256}, got ${actual}`
1443
+ );
1444
+ }
1445
+ }
1446
+ export {
1447
+ LicenseState,
1448
+ StateStore,
1449
+ TalosApiError,
1450
+ TalosClient,
1451
+ TalosError,
1452
+ TalosSignatureError,
1453
+ collectFingerprint,
1454
+ defaultStatePath,
1455
+ hashLicenseKey,
1456
+ publicKeyFromBase64,
1457
+ verifyAppliedUpdate,
1458
+ verifyKeyset,
1459
+ verifyManifest,
1460
+ verifyToken
1461
+ };
1462
+ //# sourceMappingURL=index.js.map