@southwind-ai/license 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/index.ts +13 -0
- package/package.json +29 -0
- package/src/bounded-json-body.ts +135 -0
- package/src/canonical-json.ts +104 -0
- package/src/client.ts +86 -0
- package/src/device.ts +46 -0
- package/src/file-storage.ts +77 -0
- package/src/installation.ts +150 -0
- package/src/license-parser.ts +56 -0
- package/src/offline-code-base32.ts +57 -0
- package/src/offline-code-decompression.ts +55 -0
- package/src/offline-code-limits.ts +10 -0
- package/src/offline-code-wire.ts +72 -0
- package/src/offline-code.ts +319 -0
- package/src/payload.ts +201 -0
- package/src/storage.ts +64 -0
- package/src/test-fixtures.ts +59 -0
- package/src/v1-test-vector.ts +49 -0
- package/src/validation.ts +143 -0
- package/src/verification.ts +199 -0
package/src/payload.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LicenseFormatVersion,
|
|
3
|
+
LicenseV1Claims,
|
|
4
|
+
SignedLicenseV1,
|
|
5
|
+
} from "@southwind-ai/shared";
|
|
6
|
+
import { canonicalizeJson } from "./canonical-json.js";
|
|
7
|
+
|
|
8
|
+
export type LicensePayloadErrorCode =
|
|
9
|
+
| "INVALID_LICENSE_PAYLOAD"
|
|
10
|
+
| "UNSUPPORTED_LICENSE_FORMAT";
|
|
11
|
+
|
|
12
|
+
export class LicensePayloadError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
readonly code: LicensePayloadErrorCode,
|
|
15
|
+
message: string,
|
|
16
|
+
options?: ErrorOptions,
|
|
17
|
+
) {
|
|
18
|
+
super(message, options);
|
|
19
|
+
this.name = "LicensePayloadError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LicenseV1SigningDocument {
|
|
24
|
+
formatVersion: 1;
|
|
25
|
+
algorithm: "ED25519";
|
|
26
|
+
publicKeyId: string;
|
|
27
|
+
claims: LicenseV1Claims;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LicensePayloadHandler<
|
|
31
|
+
TVersion extends LicenseFormatVersion,
|
|
32
|
+
TDocument,
|
|
33
|
+
> {
|
|
34
|
+
readonly formatVersion: TVersion;
|
|
35
|
+
serialize(document: TDocument): string;
|
|
36
|
+
parse(payload: string): TDocument;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const licenseV1PayloadHandler: LicensePayloadHandler<
|
|
40
|
+
1,
|
|
41
|
+
LicenseV1SigningDocument
|
|
42
|
+
> = {
|
|
43
|
+
formatVersion: 1,
|
|
44
|
+
serialize(document) {
|
|
45
|
+
try {
|
|
46
|
+
assertV1Document(document);
|
|
47
|
+
return canonicalizeJson(document);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw normalizePayloadError(error);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
parse(payload) {
|
|
53
|
+
try {
|
|
54
|
+
const document = parseJson(payload);
|
|
55
|
+
assertV1Document(document);
|
|
56
|
+
if (canonicalizeJson(document) !== payload) {
|
|
57
|
+
invalidPayload("V1 signed payload 必须使用 RFC 8785 canonical JSON");
|
|
58
|
+
}
|
|
59
|
+
return document;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw normalizePayloadError(error);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function getLicensePayloadHandler(
|
|
67
|
+
formatVersion: unknown,
|
|
68
|
+
): LicensePayloadHandler<1, LicenseV1SigningDocument> {
|
|
69
|
+
if (formatVersion === 1) return licenseV1PayloadHandler;
|
|
70
|
+
throw unsupported(formatVersion);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createLicenseSigningPayload(
|
|
74
|
+
license: Omit<SignedLicenseV1, "signature">,
|
|
75
|
+
): string {
|
|
76
|
+
return licenseV1PayloadHandler.serialize(license);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseLicenseSigningPayload(
|
|
80
|
+
formatVersion: unknown,
|
|
81
|
+
payload: string,
|
|
82
|
+
): LicenseV1SigningDocument {
|
|
83
|
+
return getLicensePayloadHandler(formatVersion).parse(payload);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const parseLicenseSigningDocument = parseLicenseSigningPayload;
|
|
87
|
+
|
|
88
|
+
function assertV1Document(
|
|
89
|
+
value: unknown,
|
|
90
|
+
): asserts value is LicenseV1SigningDocument {
|
|
91
|
+
const document = object(value, "V1 signing document");
|
|
92
|
+
exactKeys(document, ["algorithm", "claims", "formatVersion", "publicKeyId"]);
|
|
93
|
+
if (document.formatVersion !== 1) invalidPayload("formatVersion 必须为 1");
|
|
94
|
+
if (document.algorithm !== "ED25519") {
|
|
95
|
+
invalidPayload("algorithm 必须为 ED25519");
|
|
96
|
+
}
|
|
97
|
+
text(document.publicKeyId, "publicKeyId");
|
|
98
|
+
assertV1Claims(document.claims);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function assertV1Claims(value: unknown): asserts value is LicenseV1Claims {
|
|
102
|
+
const claims = object(value, "claims");
|
|
103
|
+
exactKeys(claims, [
|
|
104
|
+
"contract",
|
|
105
|
+
"customer",
|
|
106
|
+
"expiresAt",
|
|
107
|
+
"issuedAt",
|
|
108
|
+
"licenseId",
|
|
109
|
+
"licenseVersion",
|
|
110
|
+
"machineCode",
|
|
111
|
+
"productCode",
|
|
112
|
+
"project",
|
|
113
|
+
]);
|
|
114
|
+
for (const key of [
|
|
115
|
+
"licenseId",
|
|
116
|
+
"productCode",
|
|
117
|
+
"machineCode",
|
|
118
|
+
"issuedAt",
|
|
119
|
+
"expiresAt",
|
|
120
|
+
] as const) {
|
|
121
|
+
text(claims[key], key);
|
|
122
|
+
}
|
|
123
|
+
reference(claims.customer, "customer");
|
|
124
|
+
reference(claims.project, "project");
|
|
125
|
+
const contract = object(claims.contract, "contract");
|
|
126
|
+
exactKeys(contract, ["contractNumber", "id", "version"]);
|
|
127
|
+
for (const key of ["id", "contractNumber", "version"] as const) {
|
|
128
|
+
text(contract[key], `contract.${key}`);
|
|
129
|
+
}
|
|
130
|
+
const version = object(claims.licenseVersion, "licenseVersion");
|
|
131
|
+
exactKeys(version, ["key", "name"]);
|
|
132
|
+
text(version.key, "licenseVersion.key");
|
|
133
|
+
text(version.name, "licenseVersion.name");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function reference(value: unknown, label: string): void {
|
|
137
|
+
const ref = object(value, label);
|
|
138
|
+
exactKeys(ref, ["code", "id", "name"]);
|
|
139
|
+
for (const key of ["id", "code", "name"] as const) {
|
|
140
|
+
text(ref[key], `${label}.${key}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
145
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
146
|
+
invalidPayload(`${label} 必须为 JSON 对象`);
|
|
147
|
+
}
|
|
148
|
+
return value as Record<string, unknown>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function exactKeys(
|
|
152
|
+
value: Record<string, unknown>,
|
|
153
|
+
allowed: readonly string[],
|
|
154
|
+
): void {
|
|
155
|
+
const actual = Object.keys(value);
|
|
156
|
+
const unexpected = actual.filter((key) => !allowed.includes(key));
|
|
157
|
+
const missing = allowed.filter((key) => !Object.hasOwn(value, key));
|
|
158
|
+
if (unexpected.length || missing.length) {
|
|
159
|
+
invalidPayload(
|
|
160
|
+
`字段不匹配(缺少:${missing.join(",") || "无"};未知:${unexpected.join(",") || "无"})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function text(value: unknown, label: string): void {
|
|
166
|
+
if (typeof value !== "string" || !value.length) {
|
|
167
|
+
invalidPayload(`${label} 必须为非空字符串`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseJson(payload: string): unknown {
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(payload);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw new LicensePayloadError(
|
|
176
|
+
"INVALID_LICENSE_PAYLOAD",
|
|
177
|
+
"signed payload 不是有效 JSON",
|
|
178
|
+
{ cause: error },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function invalidPayload(message: string): never {
|
|
184
|
+
throw new LicensePayloadError("INVALID_LICENSE_PAYLOAD", message);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unsupported(formatVersion: unknown): never {
|
|
188
|
+
throw new LicensePayloadError(
|
|
189
|
+
"UNSUPPORTED_LICENSE_FORMAT",
|
|
190
|
+
`不支持的 License 格式版本:${String(formatVersion)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizePayloadError(error: unknown): LicensePayloadError {
|
|
195
|
+
if (error instanceof LicensePayloadError) return error;
|
|
196
|
+
return new LicensePayloadError(
|
|
197
|
+
"INVALID_LICENSE_PAYLOAD",
|
|
198
|
+
"License payload 不是有效的 canonical JSON",
|
|
199
|
+
{ cause: error },
|
|
200
|
+
);
|
|
201
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface LicenseStorage {
|
|
2
|
+
read(key: string): string | undefined | Promise<string | undefined>;
|
|
3
|
+
write(key: string, value: string): void | Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface LicenseSource {
|
|
7
|
+
readCurrent(): string | undefined | Promise<string | undefined>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adapter boundary for a single-current-License store.
|
|
12
|
+
* Implementations must leave either the old or the new complete document readable.
|
|
13
|
+
*/
|
|
14
|
+
export interface AtomicLicenseStore extends LicenseSource {
|
|
15
|
+
replaceCurrentAtomically(value: string): void | Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class MemoryLicenseStorage implements LicenseStorage {
|
|
19
|
+
private readonly values = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
read(key: string): string | undefined {
|
|
22
|
+
return this.values.get(key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
write(key: string, value: string): void {
|
|
26
|
+
this.values.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class MemoryAtomicLicenseStore implements AtomicLicenseStore {
|
|
31
|
+
constructor(private current?: string) {}
|
|
32
|
+
|
|
33
|
+
readCurrent(): string | undefined {
|
|
34
|
+
return this.current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
replaceCurrentAtomically(value: string): void {
|
|
38
|
+
this.current = value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class BrowserLicenseStorage implements LicenseStorage {
|
|
43
|
+
constructor(private readonly prefix = "windy-license") {}
|
|
44
|
+
|
|
45
|
+
read(key: string): string | undefined {
|
|
46
|
+
if (typeof window === "undefined") {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return window.localStorage.getItem(this.storageKey(key)) || undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
write(key: string, value: string): void {
|
|
54
|
+
if (typeof window === "undefined") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
window.localStorage.setItem(this.storageKey(key), value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private storageKey(key: string): string {
|
|
62
|
+
return `${this.prefix}:${key}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
LegacyPrototypeLicenseClaims as LicenseClaims,
|
|
4
|
+
LegacyPrototypeSignedLicense as SignedLicense,
|
|
5
|
+
LicenseV1Claims,
|
|
6
|
+
SignedLicenseV1,
|
|
7
|
+
} from "@southwind-ai/shared";
|
|
8
|
+
import { createLicenseSigningPayload } from "./payload.js";
|
|
9
|
+
import { legacyPrototypeLicensePayloadReader } from "./verification.js";
|
|
10
|
+
|
|
11
|
+
export function createSignedLicense(inputClaims: LicenseClaims): {
|
|
12
|
+
license: SignedLicense;
|
|
13
|
+
publicKeyPem: string;
|
|
14
|
+
} {
|
|
15
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
16
|
+
const signature = sign(
|
|
17
|
+
null,
|
|
18
|
+
Buffer.from(
|
|
19
|
+
legacyPrototypeLicensePayloadReader.serializeForVerification(inputClaims),
|
|
20
|
+
),
|
|
21
|
+
privateKey,
|
|
22
|
+
).toString("base64");
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
26
|
+
license: {
|
|
27
|
+
claims: inputClaims,
|
|
28
|
+
signature,
|
|
29
|
+
publicKeyId: "key_1",
|
|
30
|
+
algorithm: "ED25519",
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createSignedLicenseV1(
|
|
36
|
+
claims: LicenseV1Claims,
|
|
37
|
+
keyId = "key_v1",
|
|
38
|
+
): {
|
|
39
|
+
license: SignedLicenseV1;
|
|
40
|
+
publicKeyPem: string;
|
|
41
|
+
} {
|
|
42
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
43
|
+
const unsigned = {
|
|
44
|
+
formatVersion: 1,
|
|
45
|
+
algorithm: "ED25519",
|
|
46
|
+
publicKeyId: keyId,
|
|
47
|
+
claims,
|
|
48
|
+
} as const;
|
|
49
|
+
const signature = sign(
|
|
50
|
+
null,
|
|
51
|
+
Buffer.from(createLicenseSigningPayload(unsigned)),
|
|
52
|
+
privateKey,
|
|
53
|
+
).toString("base64");
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
57
|
+
license: { ...unsigned, signature },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { licenseVersionKey, type LicenseV1Claims } from "@southwind-ai/shared";
|
|
2
|
+
|
|
3
|
+
/** Public, non-secret interoperability vector shared by SDK and issuer tests. */
|
|
4
|
+
export const LICENSE_V1_TEST_VECTOR: {
|
|
5
|
+
claims: LicenseV1Claims;
|
|
6
|
+
keyId: string;
|
|
7
|
+
publicKeyPem: string;
|
|
8
|
+
signedPayload: string;
|
|
9
|
+
payloadDigest: string;
|
|
10
|
+
signature: string;
|
|
11
|
+
} = {
|
|
12
|
+
claims: {
|
|
13
|
+
licenseId: "license_vector_v1",
|
|
14
|
+
productCode: "windy-platform",
|
|
15
|
+
customer: {
|
|
16
|
+
id: "customer_vector",
|
|
17
|
+
code: "C-001",
|
|
18
|
+
name: "固定向量客户",
|
|
19
|
+
},
|
|
20
|
+
project: {
|
|
21
|
+
id: "project_vector",
|
|
22
|
+
code: "P-001",
|
|
23
|
+
name: "固定向量项目",
|
|
24
|
+
},
|
|
25
|
+
contract: {
|
|
26
|
+
id: "contract_vector",
|
|
27
|
+
contractNumber: "HT-2026-001",
|
|
28
|
+
version: "1",
|
|
29
|
+
},
|
|
30
|
+
machineCode: "machine-vector-001",
|
|
31
|
+
issuedAt: "2026-07-10T00:00:00.000Z",
|
|
32
|
+
expiresAt: "2030-07-10T00:00:00.000Z",
|
|
33
|
+
licenseVersion: {
|
|
34
|
+
key: licenseVersionKey("advanced"),
|
|
35
|
+
name: "高级付费版",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
keyId: "development-version-model-v1",
|
|
39
|
+
publicKeyPem:
|
|
40
|
+
"-----BEGIN PUBLIC KEY-----\n" +
|
|
41
|
+
"MCowBQYDK2VwAyEAbYwI4vgDmeP9rj+Vu5QkqW83LZsKJJCs1athjcE5J5s=\n" +
|
|
42
|
+
"-----END PUBLIC KEY-----\n",
|
|
43
|
+
signedPayload:
|
|
44
|
+
'{"algorithm":"ED25519","claims":{"contract":{"contractNumber":"HT-2026-001","id":"contract_vector","version":"1"},"customer":{"code":"C-001","id":"customer_vector","name":"固定向量客户"},"expiresAt":"2030-07-10T00:00:00.000Z","issuedAt":"2026-07-10T00:00:00.000Z","licenseId":"license_vector_v1","licenseVersion":{"key":"advanced","name":"高级付费版"},"machineCode":"machine-vector-001","productCode":"windy-platform","project":{"code":"P-001","id":"project_vector","name":"固定向量项目"}},"formatVersion":1,"publicKeyId":"development-version-model-v1"}',
|
|
45
|
+
payloadDigest:
|
|
46
|
+
"1cbe2a782b74cb79ba03bc987b0736155db56742a9bc619f612af2a162ec535b",
|
|
47
|
+
signature:
|
|
48
|
+
"y1tMY82+iYlAfzRvC+IdZoLlufZ3b7EW0TysskwZHi7fXye07K6vQ9cTSvkg3dn+vhMeLeyt+3VjpOLhxFTQBQ==",
|
|
49
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LicenseFailureReason,
|
|
3
|
+
LicenseUsageRecord,
|
|
4
|
+
SignedLicense,
|
|
5
|
+
} from "@southwind-ai/shared";
|
|
6
|
+
import { parseSignedLicense } from "./license-parser.js";
|
|
7
|
+
import type { LicensePublicKeySource } from "./verification.js";
|
|
8
|
+
import { verifySignedLicense } from "./verification.js";
|
|
9
|
+
|
|
10
|
+
export { parseSignedLicense, parseSignedLicenseV1 } from "./license-parser.js";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CLOCK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
13
|
+
const RFC_3339 =
|
|
14
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
15
|
+
|
|
16
|
+
export interface LicenseValidationOptions {
|
|
17
|
+
productCode: string;
|
|
18
|
+
machineCode: string;
|
|
19
|
+
publicKeys: LicensePublicKeySource;
|
|
20
|
+
now?: Date;
|
|
21
|
+
lastTrustedAt?: string;
|
|
22
|
+
clockToleranceMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LicenseDocumentVerificationResult {
|
|
26
|
+
valid: boolean;
|
|
27
|
+
license?: SignedLicense;
|
|
28
|
+
failures: LicenseFailureReason[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LicenseRuntimeValidationResult extends LicenseDocumentVerificationResult {
|
|
32
|
+
usage: LicenseUsageRecord;
|
|
33
|
+
nextTrustedAt?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function verifyLicenseDocument(
|
|
37
|
+
rawLicense: string,
|
|
38
|
+
publicKeys: LicensePublicKeySource,
|
|
39
|
+
): Promise<LicenseDocumentVerificationResult> {
|
|
40
|
+
const license = parseSignedLicense(rawLicense);
|
|
41
|
+
if (!license) return { valid: false, failures: ["invalid-format"] };
|
|
42
|
+
|
|
43
|
+
const result = await verifySignedLicense(license, publicKeys);
|
|
44
|
+
return result.valid
|
|
45
|
+
? { valid: true, license, failures: [] }
|
|
46
|
+
: { valid: false, failures: result.failures };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function validateLicenseDocument(
|
|
50
|
+
rawLicense: string,
|
|
51
|
+
options: LicenseValidationOptions,
|
|
52
|
+
): Promise<LicenseRuntimeValidationResult> {
|
|
53
|
+
const now = options.now ?? new Date();
|
|
54
|
+
const openedAt = validNow(now);
|
|
55
|
+
const verified = await verifyLicenseDocument(rawLicense, options.publicKeys);
|
|
56
|
+
const failures = [...verified.failures];
|
|
57
|
+
const license = verified.license;
|
|
58
|
+
|
|
59
|
+
if (license) {
|
|
60
|
+
const issuedAt = parseDateTime(license.claims.issuedAt);
|
|
61
|
+
const expiresAt = parseDateTime(license.claims.expiresAt);
|
|
62
|
+
if (
|
|
63
|
+
issuedAt === undefined ||
|
|
64
|
+
expiresAt === undefined ||
|
|
65
|
+
expiresAt <= issuedAt
|
|
66
|
+
) {
|
|
67
|
+
failures.push("invalid-format");
|
|
68
|
+
} else if (now.getTime() > expiresAt) {
|
|
69
|
+
failures.push("expired");
|
|
70
|
+
}
|
|
71
|
+
if (license.claims.productCode !== options.productCode) {
|
|
72
|
+
failures.push("product-mismatch");
|
|
73
|
+
}
|
|
74
|
+
if (license.claims.machineCode !== options.machineCode) {
|
|
75
|
+
failures.push("machine-mismatch");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const clock = evaluateClock(
|
|
80
|
+
now.getTime(),
|
|
81
|
+
options.lastTrustedAt,
|
|
82
|
+
options.clockToleranceMs ?? DEFAULT_CLOCK_TOLERANCE_MS,
|
|
83
|
+
);
|
|
84
|
+
if (clock.rollback) failures.push("clock-rollback");
|
|
85
|
+
|
|
86
|
+
const uniqueFailures = [...new Set(failures)];
|
|
87
|
+
const valid = Boolean(license) && uniqueFailures.length === 0;
|
|
88
|
+
return {
|
|
89
|
+
valid,
|
|
90
|
+
license,
|
|
91
|
+
failures: uniqueFailures,
|
|
92
|
+
nextTrustedAt: clock.nextTrustedAt,
|
|
93
|
+
usage: {
|
|
94
|
+
machineCode: options.machineCode,
|
|
95
|
+
openedAt,
|
|
96
|
+
previousOpenedAt: options.lastTrustedAt,
|
|
97
|
+
status: uniqueFailures.includes("clock-rollback")
|
|
98
|
+
? "clock-rollback"
|
|
99
|
+
: valid
|
|
100
|
+
? "valid"
|
|
101
|
+
: uniqueFailures.includes("expired")
|
|
102
|
+
? "expired"
|
|
103
|
+
: "invalid",
|
|
104
|
+
reason: uniqueFailures[0],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function evaluateClock(
|
|
110
|
+
now: number,
|
|
111
|
+
lastTrustedAt: string | undefined,
|
|
112
|
+
toleranceMs: number,
|
|
113
|
+
): { rollback: boolean; nextTrustedAt?: string } {
|
|
114
|
+
if (!Number.isFinite(toleranceMs) || toleranceMs < 0) {
|
|
115
|
+
throw new RangeError("clockToleranceMs 必须为非负有限数");
|
|
116
|
+
}
|
|
117
|
+
if (!lastTrustedAt) {
|
|
118
|
+
return { rollback: false, nextTrustedAt: new Date(now).toISOString() };
|
|
119
|
+
}
|
|
120
|
+
const previous = parseDateTime(lastTrustedAt);
|
|
121
|
+
if (previous === undefined) {
|
|
122
|
+
return { rollback: true };
|
|
123
|
+
}
|
|
124
|
+
if (now + toleranceMs < previous) {
|
|
125
|
+
return { rollback: true, nextTrustedAt: lastTrustedAt };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
rollback: false,
|
|
129
|
+
nextTrustedAt: new Date(Math.max(now, previous)).toISOString(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseDateTime(value: string): number | undefined {
|
|
134
|
+
if (!RFC_3339.test(value)) return undefined;
|
|
135
|
+
const timestamp = Date.parse(value);
|
|
136
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validNow(now: Date): string {
|
|
140
|
+
if (!Number.isFinite(now.getTime()))
|
|
141
|
+
throw new RangeError("now 必须是有效时间");
|
|
142
|
+
return now.toISOString();
|
|
143
|
+
}
|