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