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