@antondziuin/talos-sdk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,46 @@
1
1
  import { KeyObject } from 'node:crypto';
2
2
 
3
+ /**
4
+ * The air-gapped exchange, and the identity that makes it possible.
5
+ *
6
+ * Online, a machine proves who it is with the `activation_secret` the server
7
+ * hands back from `activate`. A machine that cannot reach a server never gets
8
+ * one — so it makes its own: an Ed25519 pair at the first offline request,
9
+ * public half in `talos-request.json`, private half never leaving this disk. The
10
+ * file the portal issues is bound to that key as well as to the fingerprint, and
11
+ * anything this machine sends back afterwards is signed by it.
12
+ *
13
+ * The document format deliberately is **not** Talos-compact. That format's `typ`
14
+ * set is the closed list of things the *platform* signs, and putting a machine's
15
+ * key into the same namespace as a product's is exactly the confusion the tag
16
+ * set exists to prevent. What is copied is the part that matters: the payload
17
+ * travels base64url-encoded and is verified as received, never re-serialised, so
18
+ * three implementations cannot disagree about JSON key order and turn that into
19
+ * a signature one of them rejects.
20
+ */
21
+ /** Domain tag of `talos-request.json`; also part of what is signed. */
22
+ declare const REQUEST_DOMAIN = "talos-offline-request";
23
+ /**
24
+ * Domain tag of `talos-deactivation.json`, the document that hands a seat back.
25
+ *
26
+ * The second thing a machine key signs, and the reason the domain is inside the
27
+ * signed bytes rather than beside them: without it a captured request would be a
28
+ * valid receipt for the same machine, and returning a seat is not what its owner
29
+ * asked for when they asked for a licence.
30
+ */
31
+ declare const RECEIPT_DOMAIN = "talos-offline-deactivation";
32
+ /** `kind` of the file the portal issues back. */
33
+ declare const LICENSE_FILE_KIND = "talos-offline-license";
34
+ /** A document this machine signed: what gets written to disk and carried. */
35
+ interface SignedDoc {
36
+ v: number;
37
+ kind: string;
38
+ /** base64url of the payload JSON. */
39
+ req: string;
40
+ /** base64url Ed25519 signature over `"<kind>.1.<req>"`. */
41
+ sig: string;
42
+ }
43
+
3
44
  /**
4
45
  * What a response has to offer this SDK.
5
46
  *
@@ -428,6 +469,12 @@ declare class TalosClient {
428
469
  /** Default instance id for floating leases: one per client object. */
429
470
  private readonly instanceId;
430
471
  current: LicenseState | null;
472
+ /**
473
+ * Return a local JSON support report without credentials, identifiers or
474
+ * exception text. Pass a caught error for classification. Makes no requests
475
+ * and does not change licensing state; `decision` is the last known result.
476
+ */
477
+ createDiagnosticReport(error?: unknown): string;
431
478
  private readonly listeners;
432
479
  /** The newest release this client has already announced. */
433
480
  private announcedReleaseSeq;
@@ -462,6 +509,15 @@ declare class TalosClient {
462
509
  * construction, so this is the startup question: prompt for a licence key, or
463
510
  * go straight to {@link validate}?
464
511
  */
512
+ /**
513
+ * Whether this install holds a licence for this machine.
514
+ *
515
+ * True for an online activation (the activation secret is held) **and** for an
516
+ * air-gapped install that has loaded a licence file. Widened rather than
517
+ * split, because what a host does with this is decide whether to ask the user
518
+ * for a licence key — and an air-gapped machine that already holds a valid
519
+ * file must not be asked for one it has no way to redeem.
520
+ */
465
521
  get isActivated(): boolean;
466
522
  /** The server's id for this machine, once activated. */
467
523
  get currentMachineId(): string | null;
@@ -528,6 +584,90 @@ declare class TalosClient {
528
584
  * elsewhere.
529
585
  */
530
586
  activate(licenseKey: string): Promise<LicenseState>;
587
+ /**
588
+ * This machine's own keypair, generated once and kept in the state file.
589
+ *
590
+ * The air-gapped counterpart of the activation secret: a machine that cannot
591
+ * reach a server is never issued one, so it makes an identity of its own and
592
+ * the vendor binds the file to it.
593
+ */
594
+ private machineKey;
595
+ /**
596
+ * The binding as it stands, or an empty one to build on.
597
+ *
598
+ * An air-gapped install reaches its first write with nothing stored — no
599
+ * licence key, no activation secret, no machine id — so there has to be
600
+ * something for the offline fields to be added to.
601
+ */
602
+ private bindingBase;
603
+ /**
604
+ * Write the request an air-gapped machine hands to its vendor.
605
+ *
606
+ * Save the returned object as `talos-request.json`, carry it to whoever sold
607
+ * the licence, and they upload it in the portal. What comes back is a
608
+ * `license.talos` for {@link loadOfflineLicense} — and only for *this*
609
+ * machine: the request carries the fingerprint and the public half of a
610
+ * keypair generated here, and the file is bound to both.
611
+ *
612
+ * Calling it twice produces two requests under the same identity. The vendor
613
+ * can issue against either; the second file supersedes the first without
614
+ * spending a second seat.
615
+ *
616
+ * ```ts
617
+ * writeFileSync("talos-request.json", JSON.stringify(talos.createOfflineRequest(), null, 2));
618
+ * ```
619
+ *
620
+ * @param licenseKey Optional. The operator picks the licence in the portal, so
621
+ * it is not needed — but when it is present the server checks it against the
622
+ * licence being issued against, which catches issuing from the wrong tab.
623
+ */
624
+ createOfflineRequest(licenseKey?: string): SignedDoc;
625
+ /**
626
+ * Adopt an air-gapped licence file issued for this machine.
627
+ *
628
+ * Everything is verified locally, because there is nothing to ask: the
629
+ * anchor-signed keyset travels inside the file, the verdict is signed by a key
630
+ * that keyset lists, and the file names both halves of this machine's identity
631
+ * — its fingerprint and the public key it generated. The verdict is then kept,
632
+ * so later starts are licensed with no further ceremony.
633
+ *
634
+ * **Throws** only for a file that is not a licence: unreadable, not one of
635
+ * ours, addressed to another product, or a broken signature. A file that *is*
636
+ * one but cannot license this machine right now comes back as a verdict —
637
+ * `machine_mismatch` for another machine's file, `offline_file_expired` for
638
+ * one past its horizon, `expired` for a licence that has ended, and
639
+ * `clock_tampered` for a clock wound back behind what this install has already
640
+ * seen. Those are things the user can be told and can act on, and turning
641
+ * them into exceptions would make the launch path throw for a reason nobody
642
+ * can catch usefully.
643
+ */
644
+ loadOfflineLicense(pathOrBuffer: string | Buffer | Uint8Array): Promise<LicenseState>;
645
+ /**
646
+ * Hand this machine's air-gapped seat back.
647
+ *
648
+ * Save the returned object as `talos-deactivation.json` and send it to the
649
+ * vendor; they upload it in the portal and the seat returns to the pool.
650
+ * Signed by the same key the licence file is bound to, which is what makes it
651
+ * worth anything to a server that has never spoken to this machine.
652
+ *
653
+ * **The licence is dropped here, before the document is returned** — so an
654
+ * application that writes the file, or loses it, or never sends it has still
655
+ * stopped using the seat it is giving up. A receipt proves intent, not
656
+ * destruction: the vendor's copy of the file cannot be recalled, and if this
657
+ * machine is later restored from a snapshot it keeps working until that file
658
+ * expires. That is the accepted cost, and it is why the horizon is short.
659
+ *
660
+ * Throws when this install holds no offline licence — there is nothing to hand
661
+ * back, and a document saying otherwise would be a lie the vendor acts on.
662
+ */
663
+ createOfflineDeactivation(): SignedDoc;
664
+ /**
665
+ * Verify the offline file against this machine and this clock, and adopt the
666
+ * verdict. Runs on load and again on every {@link validate}, because the two
667
+ * things that can change afterwards — the clock and the calendar — are
668
+ * exactly what it checks.
669
+ */
670
+ private adoptOfflineToken;
531
671
  /**
532
672
  * The heartbeat cadence the server's policy asks for, in seconds, taken from
533
673
  * the last heartbeat's **signed** claims — so it cannot be lengthened in
@@ -912,6 +1052,42 @@ interface StoredBinding {
912
1052
  version: string;
913
1053
  releaseSeq: number;
914
1054
  };
1055
+ /**
1056
+ * This machine's own Ed25519 private key (PKCS#8 DER, base64), generated at
1057
+ * its first offline request.
1058
+ *
1059
+ * The air-gapped counterpart of `activationSecret`, and it exists for the same
1060
+ * reason that one does: an install has to be able to prove it is the install a
1061
+ * decision was made for. It never leaves this file — only the public half
1062
+ * travels, inside `talos-request.json` — and losing the file means repeating
1063
+ * the ceremony with the vendor, because the identity has exactly one copy by
1064
+ * construction.
1065
+ */
1066
+ machineKey?: string;
1067
+ /** The raw public half, standard base64. Checked against the file's `mpk`. */
1068
+ machinePublicKey?: string;
1069
+ /** The last verified `typ=offline` licence file. */
1070
+ offlineToken?: string;
1071
+ /**
1072
+ * The highest local clock ever seen while running on an offline file, in unix
1073
+ * seconds.
1074
+ *
1075
+ * Deliberately separate from {@link timeHighWater}, which holds *server*
1076
+ * times taken from signed claims. An air-gapped install sees exactly one
1077
+ * server time in its life — the `iat` of its file — so the server mark stops
1078
+ * moving, and nothing would notice a clock wound back to the day after
1079
+ * issuance to keep a year-long file alive forever.
1080
+ *
1081
+ * Mixing the two would be worse than useless: a machine whose clock ran fast
1082
+ * would inflate the server mark, and correcting the clock afterwards would
1083
+ * then read as tampering on an install that had done nothing wrong.
1084
+ *
1085
+ * What this bounds is **rollback, not freeze**. A user who stops their clock
1086
+ * keeps a file past its expiry; a user who winds it back is told
1087
+ * `clock_tampered`. That is the honest limit of enforcing a deadline against
1088
+ * a clock its owner controls, and it is the price of offline.
1089
+ */
1090
+ offlineSeen?: number;
915
1091
  }
916
1092
  /**
917
1093
  * Where the binding lives, per product, following each OS's convention.
@@ -982,4 +1158,4 @@ declare function verifyManifest(releaseKeys: Keys, expectedAud: string, token: s
982
1158
  */
983
1159
  declare function verifyKeyset(anchorKey: KeyObject, expectedAud: string, token: string): Record<string, unknown>;
984
1160
 
985
- export { type AcquireLeaseOptions, type CheckForUpdateOptions, type CheckInOptions, type DeltaOptions, type DownloadProgress, type DownloadProgressCallback, type FetchLike, type FetchResponseLike, type FingerprintData, type Lease, type LicenseListener, LicenseState, StateStore, type StoredBinding, TalosApiError, TalosClient, TalosError, type TalosEventMap, type TalosEventName, type TalosOptions, TalosSignatureError, type UpdateArtifact, type UpdateDelta, type UpdateDeltaTarget, type UpdateInfo, collectFingerprint, defaultStatePath, hashLicenseKey, publicKeyFromBase64, verifyAppliedUpdate, verifyKeyset, verifyManifest, verifyToken };
1161
+ export { type AcquireLeaseOptions, type CheckForUpdateOptions, type CheckInOptions, type DeltaOptions, type DownloadProgress, type DownloadProgressCallback, type FetchLike, type FetchResponseLike, type FingerprintData, LICENSE_FILE_KIND, type Lease, type LicenseListener, LicenseState, RECEIPT_DOMAIN, REQUEST_DOMAIN, type SignedDoc, StateStore, type StoredBinding, TalosApiError, TalosClient, TalosError, type TalosEventMap, type TalosEventName, type TalosOptions, TalosSignatureError, type UpdateArtifact, type UpdateDelta, type UpdateDeltaTarget, type UpdateInfo, collectFingerprint, defaultStatePath, hashLicenseKey, publicKeyFromBase64, verifyAppliedUpdate, verifyKeyset, verifyManifest, verifyToken };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,46 @@
1
1
  import { KeyObject } from 'node:crypto';
2
2
 
3
+ /**
4
+ * The air-gapped exchange, and the identity that makes it possible.
5
+ *
6
+ * Online, a machine proves who it is with the `activation_secret` the server
7
+ * hands back from `activate`. A machine that cannot reach a server never gets
8
+ * one — so it makes its own: an Ed25519 pair at the first offline request,
9
+ * public half in `talos-request.json`, private half never leaving this disk. The
10
+ * file the portal issues is bound to that key as well as to the fingerprint, and
11
+ * anything this machine sends back afterwards is signed by it.
12
+ *
13
+ * The document format deliberately is **not** Talos-compact. That format's `typ`
14
+ * set is the closed list of things the *platform* signs, and putting a machine's
15
+ * key into the same namespace as a product's is exactly the confusion the tag
16
+ * set exists to prevent. What is copied is the part that matters: the payload
17
+ * travels base64url-encoded and is verified as received, never re-serialised, so
18
+ * three implementations cannot disagree about JSON key order and turn that into
19
+ * a signature one of them rejects.
20
+ */
21
+ /** Domain tag of `talos-request.json`; also part of what is signed. */
22
+ declare const REQUEST_DOMAIN = "talos-offline-request";
23
+ /**
24
+ * Domain tag of `talos-deactivation.json`, the document that hands a seat back.
25
+ *
26
+ * The second thing a machine key signs, and the reason the domain is inside the
27
+ * signed bytes rather than beside them: without it a captured request would be a
28
+ * valid receipt for the same machine, and returning a seat is not what its owner
29
+ * asked for when they asked for a licence.
30
+ */
31
+ declare const RECEIPT_DOMAIN = "talos-offline-deactivation";
32
+ /** `kind` of the file the portal issues back. */
33
+ declare const LICENSE_FILE_KIND = "talos-offline-license";
34
+ /** A document this machine signed: what gets written to disk and carried. */
35
+ interface SignedDoc {
36
+ v: number;
37
+ kind: string;
38
+ /** base64url of the payload JSON. */
39
+ req: string;
40
+ /** base64url Ed25519 signature over `"<kind>.1.<req>"`. */
41
+ sig: string;
42
+ }
43
+
3
44
  /**
4
45
  * What a response has to offer this SDK.
5
46
  *
@@ -428,6 +469,12 @@ declare class TalosClient {
428
469
  /** Default instance id for floating leases: one per client object. */
429
470
  private readonly instanceId;
430
471
  current: LicenseState | null;
472
+ /**
473
+ * Return a local JSON support report without credentials, identifiers or
474
+ * exception text. Pass a caught error for classification. Makes no requests
475
+ * and does not change licensing state; `decision` is the last known result.
476
+ */
477
+ createDiagnosticReport(error?: unknown): string;
431
478
  private readonly listeners;
432
479
  /** The newest release this client has already announced. */
433
480
  private announcedReleaseSeq;
@@ -462,6 +509,15 @@ declare class TalosClient {
462
509
  * construction, so this is the startup question: prompt for a licence key, or
463
510
  * go straight to {@link validate}?
464
511
  */
512
+ /**
513
+ * Whether this install holds a licence for this machine.
514
+ *
515
+ * True for an online activation (the activation secret is held) **and** for an
516
+ * air-gapped install that has loaded a licence file. Widened rather than
517
+ * split, because what a host does with this is decide whether to ask the user
518
+ * for a licence key — and an air-gapped machine that already holds a valid
519
+ * file must not be asked for one it has no way to redeem.
520
+ */
465
521
  get isActivated(): boolean;
466
522
  /** The server's id for this machine, once activated. */
467
523
  get currentMachineId(): string | null;
@@ -528,6 +584,90 @@ declare class TalosClient {
528
584
  * elsewhere.
529
585
  */
530
586
  activate(licenseKey: string): Promise<LicenseState>;
587
+ /**
588
+ * This machine's own keypair, generated once and kept in the state file.
589
+ *
590
+ * The air-gapped counterpart of the activation secret: a machine that cannot
591
+ * reach a server is never issued one, so it makes an identity of its own and
592
+ * the vendor binds the file to it.
593
+ */
594
+ private machineKey;
595
+ /**
596
+ * The binding as it stands, or an empty one to build on.
597
+ *
598
+ * An air-gapped install reaches its first write with nothing stored — no
599
+ * licence key, no activation secret, no machine id — so there has to be
600
+ * something for the offline fields to be added to.
601
+ */
602
+ private bindingBase;
603
+ /**
604
+ * Write the request an air-gapped machine hands to its vendor.
605
+ *
606
+ * Save the returned object as `talos-request.json`, carry it to whoever sold
607
+ * the licence, and they upload it in the portal. What comes back is a
608
+ * `license.talos` for {@link loadOfflineLicense} — and only for *this*
609
+ * machine: the request carries the fingerprint and the public half of a
610
+ * keypair generated here, and the file is bound to both.
611
+ *
612
+ * Calling it twice produces two requests under the same identity. The vendor
613
+ * can issue against either; the second file supersedes the first without
614
+ * spending a second seat.
615
+ *
616
+ * ```ts
617
+ * writeFileSync("talos-request.json", JSON.stringify(talos.createOfflineRequest(), null, 2));
618
+ * ```
619
+ *
620
+ * @param licenseKey Optional. The operator picks the licence in the portal, so
621
+ * it is not needed — but when it is present the server checks it against the
622
+ * licence being issued against, which catches issuing from the wrong tab.
623
+ */
624
+ createOfflineRequest(licenseKey?: string): SignedDoc;
625
+ /**
626
+ * Adopt an air-gapped licence file issued for this machine.
627
+ *
628
+ * Everything is verified locally, because there is nothing to ask: the
629
+ * anchor-signed keyset travels inside the file, the verdict is signed by a key
630
+ * that keyset lists, and the file names both halves of this machine's identity
631
+ * — its fingerprint and the public key it generated. The verdict is then kept,
632
+ * so later starts are licensed with no further ceremony.
633
+ *
634
+ * **Throws** only for a file that is not a licence: unreadable, not one of
635
+ * ours, addressed to another product, or a broken signature. A file that *is*
636
+ * one but cannot license this machine right now comes back as a verdict —
637
+ * `machine_mismatch` for another machine's file, `offline_file_expired` for
638
+ * one past its horizon, `expired` for a licence that has ended, and
639
+ * `clock_tampered` for a clock wound back behind what this install has already
640
+ * seen. Those are things the user can be told and can act on, and turning
641
+ * them into exceptions would make the launch path throw for a reason nobody
642
+ * can catch usefully.
643
+ */
644
+ loadOfflineLicense(pathOrBuffer: string | Buffer | Uint8Array): Promise<LicenseState>;
645
+ /**
646
+ * Hand this machine's air-gapped seat back.
647
+ *
648
+ * Save the returned object as `talos-deactivation.json` and send it to the
649
+ * vendor; they upload it in the portal and the seat returns to the pool.
650
+ * Signed by the same key the licence file is bound to, which is what makes it
651
+ * worth anything to a server that has never spoken to this machine.
652
+ *
653
+ * **The licence is dropped here, before the document is returned** — so an
654
+ * application that writes the file, or loses it, or never sends it has still
655
+ * stopped using the seat it is giving up. A receipt proves intent, not
656
+ * destruction: the vendor's copy of the file cannot be recalled, and if this
657
+ * machine is later restored from a snapshot it keeps working until that file
658
+ * expires. That is the accepted cost, and it is why the horizon is short.
659
+ *
660
+ * Throws when this install holds no offline licence — there is nothing to hand
661
+ * back, and a document saying otherwise would be a lie the vendor acts on.
662
+ */
663
+ createOfflineDeactivation(): SignedDoc;
664
+ /**
665
+ * Verify the offline file against this machine and this clock, and adopt the
666
+ * verdict. Runs on load and again on every {@link validate}, because the two
667
+ * things that can change afterwards — the clock and the calendar — are
668
+ * exactly what it checks.
669
+ */
670
+ private adoptOfflineToken;
531
671
  /**
532
672
  * The heartbeat cadence the server's policy asks for, in seconds, taken from
533
673
  * the last heartbeat's **signed** claims — so it cannot be lengthened in
@@ -912,6 +1052,42 @@ interface StoredBinding {
912
1052
  version: string;
913
1053
  releaseSeq: number;
914
1054
  };
1055
+ /**
1056
+ * This machine's own Ed25519 private key (PKCS#8 DER, base64), generated at
1057
+ * its first offline request.
1058
+ *
1059
+ * The air-gapped counterpart of `activationSecret`, and it exists for the same
1060
+ * reason that one does: an install has to be able to prove it is the install a
1061
+ * decision was made for. It never leaves this file — only the public half
1062
+ * travels, inside `talos-request.json` — and losing the file means repeating
1063
+ * the ceremony with the vendor, because the identity has exactly one copy by
1064
+ * construction.
1065
+ */
1066
+ machineKey?: string;
1067
+ /** The raw public half, standard base64. Checked against the file's `mpk`. */
1068
+ machinePublicKey?: string;
1069
+ /** The last verified `typ=offline` licence file. */
1070
+ offlineToken?: string;
1071
+ /**
1072
+ * The highest local clock ever seen while running on an offline file, in unix
1073
+ * seconds.
1074
+ *
1075
+ * Deliberately separate from {@link timeHighWater}, which holds *server*
1076
+ * times taken from signed claims. An air-gapped install sees exactly one
1077
+ * server time in its life — the `iat` of its file — so the server mark stops
1078
+ * moving, and nothing would notice a clock wound back to the day after
1079
+ * issuance to keep a year-long file alive forever.
1080
+ *
1081
+ * Mixing the two would be worse than useless: a machine whose clock ran fast
1082
+ * would inflate the server mark, and correcting the clock afterwards would
1083
+ * then read as tampering on an install that had done nothing wrong.
1084
+ *
1085
+ * What this bounds is **rollback, not freeze**. A user who stops their clock
1086
+ * keeps a file past its expiry; a user who winds it back is told
1087
+ * `clock_tampered`. That is the honest limit of enforcing a deadline against
1088
+ * a clock its owner controls, and it is the price of offline.
1089
+ */
1090
+ offlineSeen?: number;
915
1091
  }
916
1092
  /**
917
1093
  * Where the binding lives, per product, following each OS's convention.
@@ -982,4 +1158,4 @@ declare function verifyManifest(releaseKeys: Keys, expectedAud: string, token: s
982
1158
  */
983
1159
  declare function verifyKeyset(anchorKey: KeyObject, expectedAud: string, token: string): Record<string, unknown>;
984
1160
 
985
- export { type AcquireLeaseOptions, type CheckForUpdateOptions, type CheckInOptions, type DeltaOptions, type DownloadProgress, type DownloadProgressCallback, type FetchLike, type FetchResponseLike, type FingerprintData, type Lease, type LicenseListener, LicenseState, StateStore, type StoredBinding, TalosApiError, TalosClient, TalosError, type TalosEventMap, type TalosEventName, type TalosOptions, TalosSignatureError, type UpdateArtifact, type UpdateDelta, type UpdateDeltaTarget, type UpdateInfo, collectFingerprint, defaultStatePath, hashLicenseKey, publicKeyFromBase64, verifyAppliedUpdate, verifyKeyset, verifyManifest, verifyToken };
1161
+ export { type AcquireLeaseOptions, type CheckForUpdateOptions, type CheckInOptions, type DeltaOptions, type DownloadProgress, type DownloadProgressCallback, type FetchLike, type FetchResponseLike, type FingerprintData, LICENSE_FILE_KIND, type Lease, type LicenseListener, LicenseState, RECEIPT_DOMAIN, REQUEST_DOMAIN, type SignedDoc, StateStore, type StoredBinding, TalosApiError, TalosClient, TalosError, type TalosEventMap, type TalosEventName, type TalosOptions, TalosSignatureError, type UpdateArtifact, type UpdateDelta, type UpdateDeltaTarget, type UpdateInfo, collectFingerprint, defaultStatePath, hashLicenseKey, publicKeyFromBase64, verifyAppliedUpdate, verifyKeyset, verifyManifest, verifyToken };