@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/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from "./src/canonical-json.js";
|
|
2
|
+
export * from "./src/client.js";
|
|
3
|
+
export * from "./src/device.js";
|
|
4
|
+
export * from "./src/file-storage.js";
|
|
5
|
+
export * from "./src/installation.js";
|
|
6
|
+
export * from "./src/offline-code.js";
|
|
7
|
+
export * from "./src/offline-code-limits.js";
|
|
8
|
+
export * from "./src/bounded-json-body.js";
|
|
9
|
+
export * from "./src/payload.js";
|
|
10
|
+
export * from "./src/storage.js";
|
|
11
|
+
export * from "./src/v1-test-vector.js";
|
|
12
|
+
export * from "./src/validation.js";
|
|
13
|
+
export * from "./src/verification.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@southwind-ai/license",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"bun": ">=1.3.0"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"src/**/*.ts",
|
|
12
|
+
"!src/**/*.test.ts"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit --project tsconfig.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@southwind-ai/shared": "workspace:*",
|
|
19
|
+
"fflate": "^0.8.3"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.ts",
|
|
23
|
+
"./offline-code": "./src/offline-code.ts",
|
|
24
|
+
"./bounded-json-body": "./src/bounded-json-body.ts"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { OFFLINE_ACTIVATION_CODE_LIMITS } from "./offline-code-limits.js";
|
|
2
|
+
|
|
3
|
+
export type BoundedJsonBodyErrorCode =
|
|
4
|
+
| "body-too-large"
|
|
5
|
+
| "invalid-content-length"
|
|
6
|
+
| "invalid-json"
|
|
7
|
+
| "invalid-object";
|
|
8
|
+
|
|
9
|
+
export class BoundedJsonBodyError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
readonly code: BoundedJsonBodyErrorCode,
|
|
12
|
+
message: string,
|
|
13
|
+
readonly status: 400 | 413,
|
|
14
|
+
) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "BoundedJsonBodyError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readBoundedJsonObject(
|
|
21
|
+
request: Request,
|
|
22
|
+
maximumBytes = OFFLINE_ACTIVATION_CODE_LIMITS.requestBytes,
|
|
23
|
+
): Promise<Record<string, unknown>> {
|
|
24
|
+
const declaredLength = parseContentLength(
|
|
25
|
+
request.headers.get("content-length"),
|
|
26
|
+
);
|
|
27
|
+
if (declaredLength !== undefined && declaredLength > maximumBytes) {
|
|
28
|
+
throw bodyTooLarge();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bytes = await readBoundedBytes(request, maximumBytes);
|
|
32
|
+
if (declaredLength !== undefined && declaredLength !== bytes.byteLength) {
|
|
33
|
+
throw new BoundedJsonBodyError(
|
|
34
|
+
"invalid-content-length",
|
|
35
|
+
"请求体长度与 Content-Length 不一致",
|
|
36
|
+
400,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let value: unknown;
|
|
41
|
+
try {
|
|
42
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
43
|
+
value = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new BoundedJsonBodyError(
|
|
46
|
+
"invalid-json",
|
|
47
|
+
"请求体必须是有效的 JSON",
|
|
48
|
+
400,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
throw new BoundedJsonBodyError(
|
|
53
|
+
"invalid-object",
|
|
54
|
+
"请求体必须是 JSON 对象",
|
|
55
|
+
400,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return value as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function boundedJsonBodyErrorResponse(
|
|
62
|
+
error: unknown,
|
|
63
|
+
): Response | undefined {
|
|
64
|
+
if (!(error instanceof BoundedJsonBodyError)) return undefined;
|
|
65
|
+
return Response.json(
|
|
66
|
+
{
|
|
67
|
+
error:
|
|
68
|
+
error.code === "body-too-large"
|
|
69
|
+
? "LICENSE_REQUEST_BODY_TOO_LARGE"
|
|
70
|
+
: "LICENSE_REQUEST_BODY_INVALID",
|
|
71
|
+
reason: error.message,
|
|
72
|
+
},
|
|
73
|
+
{ status: error.status },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseContentLength(value: string | null): number | undefined {
|
|
78
|
+
if (value === null) return undefined;
|
|
79
|
+
if (!/^(0|[1-9]\d*)$/.test(value)) {
|
|
80
|
+
throw new BoundedJsonBodyError(
|
|
81
|
+
"invalid-content-length",
|
|
82
|
+
"Content-Length 必须是有效的非负整数",
|
|
83
|
+
400,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const parsed = Number(value);
|
|
87
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
88
|
+
throw new BoundedJsonBodyError(
|
|
89
|
+
"invalid-content-length",
|
|
90
|
+
"Content-Length 超出安全范围",
|
|
91
|
+
400,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readBoundedBytes(
|
|
98
|
+
request: Request,
|
|
99
|
+
maximumBytes: number,
|
|
100
|
+
): Promise<Uint8Array> {
|
|
101
|
+
if (!request.body) return new Uint8Array();
|
|
102
|
+
const reader = request.body.getReader();
|
|
103
|
+
const chunks: Uint8Array[] = [];
|
|
104
|
+
let total = 0;
|
|
105
|
+
try {
|
|
106
|
+
while (true) {
|
|
107
|
+
const { value, done } = await reader.read();
|
|
108
|
+
if (done) break;
|
|
109
|
+
if (value.byteLength > maximumBytes - total) {
|
|
110
|
+
await reader.cancel();
|
|
111
|
+
throw bodyTooLarge();
|
|
112
|
+
}
|
|
113
|
+
chunks.push(value);
|
|
114
|
+
total += value.byteLength;
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
reader.releaseLock();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const joined = new Uint8Array(total);
|
|
121
|
+
let offset = 0;
|
|
122
|
+
for (const chunk of chunks) {
|
|
123
|
+
joined.set(chunk, offset);
|
|
124
|
+
offset += chunk.byteLength;
|
|
125
|
+
}
|
|
126
|
+
return joined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function bodyTooLarge(): BoundedJsonBodyError {
|
|
130
|
+
return new BoundedJsonBodyError(
|
|
131
|
+
"body-too-large",
|
|
132
|
+
"请求体过大,离线激活请求最多允许 64 KiB",
|
|
133
|
+
413,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type CanonicalJsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type CanonicalJsonValue =
|
|
3
|
+
| CanonicalJsonPrimitive
|
|
4
|
+
| CanonicalJsonValue[]
|
|
5
|
+
| { [key: string]: CanonicalJsonValue };
|
|
6
|
+
|
|
7
|
+
export type CanonicalJsonErrorCode =
|
|
8
|
+
| "CANONICAL_JSON_INVALID_TYPE"
|
|
9
|
+
| "CANONICAL_JSON_INVALID_NUMBER"
|
|
10
|
+
| "CANONICAL_JSON_INVALID_UNICODE"
|
|
11
|
+
| "CANONICAL_JSON_CYCLE";
|
|
12
|
+
|
|
13
|
+
export class CanonicalJsonError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
readonly code: CanonicalJsonErrorCode,
|
|
16
|
+
message: string,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CanonicalJsonError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* RFC 8785 JSON Canonicalization Scheme (JCS) serializer.
|
|
25
|
+
*
|
|
26
|
+
* V1 intentionally accepts JSON values only: no undefined, BigInt, custom
|
|
27
|
+
* toJSON conversion, non-finite numbers, or unpaired UTF-16 surrogates.
|
|
28
|
+
*/
|
|
29
|
+
export function canonicalizeJson(value: unknown): string {
|
|
30
|
+
return serialize(value, new Set<object>());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function serialize(value: unknown, ancestors: Set<object>): string {
|
|
34
|
+
if (value === null) return "null";
|
|
35
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
36
|
+
if (typeof value === "string") return serializeString(value);
|
|
37
|
+
if (typeof value === "number") return serializeNumber(value);
|
|
38
|
+
if (typeof value !== "object") {
|
|
39
|
+
throw new CanonicalJsonError(
|
|
40
|
+
"CANONICAL_JSON_INVALID_TYPE",
|
|
41
|
+
`JCS 不支持 ${typeof value} 值`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (ancestors.has(value)) {
|
|
45
|
+
throw new CanonicalJsonError("CANONICAL_JSON_CYCLE", "JCS 不支持循环引用");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ancestors.add(value);
|
|
49
|
+
try {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return `[${value.map((item) => serialize(item, ancestors)).join(",")}]`;
|
|
52
|
+
}
|
|
53
|
+
const object = value as Record<string, unknown>;
|
|
54
|
+
const members = Object.keys(object)
|
|
55
|
+
.sort(compareUtf16)
|
|
56
|
+
.map(
|
|
57
|
+
(key) => `${serializeString(key)}:${serialize(object[key], ancestors)}`,
|
|
58
|
+
);
|
|
59
|
+
return `{${members.join(",")}}`;
|
|
60
|
+
} finally {
|
|
61
|
+
ancestors.delete(value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function serializeNumber(value: number): string {
|
|
66
|
+
if (!Number.isFinite(value)) {
|
|
67
|
+
throw new CanonicalJsonError(
|
|
68
|
+
"CANONICAL_JSON_INVALID_NUMBER",
|
|
69
|
+
"JCS 不支持 NaN 或无穷大",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return JSON.stringify(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serializeString(value: string): string {
|
|
76
|
+
assertUnicodeScalarSequence(value);
|
|
77
|
+
return JSON.stringify(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function assertUnicodeScalarSequence(value: string): void {
|
|
81
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
82
|
+
const code = value.charCodeAt(index);
|
|
83
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
84
|
+
const next = value.charCodeAt(index + 1);
|
|
85
|
+
if (!Number.isFinite(next) || next < 0xdc00 || next > 0xdfff) {
|
|
86
|
+
invalidUnicode();
|
|
87
|
+
}
|
|
88
|
+
index += 1;
|
|
89
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
90
|
+
invalidUnicode();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function invalidUnicode(): never {
|
|
96
|
+
throw new CanonicalJsonError(
|
|
97
|
+
"CANONICAL_JSON_INVALID_UNICODE",
|
|
98
|
+
"JCS 不支持未配对的 UTF-16 surrogate",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function compareUtf16(left: string, right: string): number {
|
|
103
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
104
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LicenseFailureReason,
|
|
3
|
+
LicenseUsageRecord,
|
|
4
|
+
SignedLicense,
|
|
5
|
+
} from "@southwind-ai/shared";
|
|
6
|
+
import type { LicenseSource, LicenseStorage } from "./storage.js";
|
|
7
|
+
import type { LicensePublicKeySource } from "./verification.js";
|
|
8
|
+
import { parseSignedLicense, validateLicenseDocument } from "./validation.js";
|
|
9
|
+
|
|
10
|
+
export interface LicenseClientOptions {
|
|
11
|
+
licenseSource: LicenseSource;
|
|
12
|
+
stateStorage: LicenseStorage;
|
|
13
|
+
productCode: string;
|
|
14
|
+
machineCode: string;
|
|
15
|
+
publicKeys: LicensePublicKeySource;
|
|
16
|
+
now?: () => Date;
|
|
17
|
+
clockToleranceMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LicenseOpenResult {
|
|
21
|
+
valid: boolean;
|
|
22
|
+
license?: SignedLicense;
|
|
23
|
+
usage: LicenseUsageRecord;
|
|
24
|
+
failures: LicenseFailureReason[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LAST_TRUSTED_AT_KEY = "last-trusted-at";
|
|
28
|
+
|
|
29
|
+
export class WindyLicenseClient {
|
|
30
|
+
private readonly now: () => Date;
|
|
31
|
+
|
|
32
|
+
constructor(private readonly options: LicenseClientOptions) {
|
|
33
|
+
this.now = options.now ?? (() => new Date());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async readLicense(): Promise<SignedLicense | undefined> {
|
|
37
|
+
const rawLicense = await this.options.licenseSource.readCurrent();
|
|
38
|
+
return rawLicense ? parseSignedLicense(rawLicense) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async recordOpen(): Promise<LicenseOpenResult> {
|
|
42
|
+
const openedAt = this.now();
|
|
43
|
+
const rawLicense = await this.options.licenseSource.readCurrent();
|
|
44
|
+
const previousOpenedAt =
|
|
45
|
+
await this.options.stateStorage.read(LAST_TRUSTED_AT_KEY);
|
|
46
|
+
|
|
47
|
+
if (!rawLicense) {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
failures: ["invalid-format"],
|
|
51
|
+
usage: {
|
|
52
|
+
machineCode: this.options.machineCode,
|
|
53
|
+
openedAt: openedAt.toISOString(),
|
|
54
|
+
previousOpenedAt,
|
|
55
|
+
status: "invalid",
|
|
56
|
+
reason: "invalid-format",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const result = await validateLicenseDocument(rawLicense, {
|
|
62
|
+
productCode: this.options.productCode,
|
|
63
|
+
machineCode: this.options.machineCode,
|
|
64
|
+
publicKeys: this.options.publicKeys,
|
|
65
|
+
now: openedAt,
|
|
66
|
+
lastTrustedAt: previousOpenedAt,
|
|
67
|
+
clockToleranceMs: this.options.clockToleranceMs,
|
|
68
|
+
});
|
|
69
|
+
if (
|
|
70
|
+
!result.failures.includes("clock-rollback") &&
|
|
71
|
+
result.nextTrustedAt &&
|
|
72
|
+
result.nextTrustedAt !== previousOpenedAt
|
|
73
|
+
) {
|
|
74
|
+
await this.options.stateStorage.write(
|
|
75
|
+
LAST_TRUSTED_AT_KEY,
|
|
76
|
+
result.nextTrustedAt,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
valid: result.valid,
|
|
81
|
+
license: result.license,
|
|
82
|
+
failures: result.failures,
|
|
83
|
+
usage: result.usage,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/device.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface DeviceCodeParts {
|
|
2
|
+
hostname?: string;
|
|
3
|
+
platform?: string;
|
|
4
|
+
arch?: string;
|
|
5
|
+
tenant?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createDeviceCode(parts: DeviceCodeParts): string {
|
|
9
|
+
const stableParts = [
|
|
10
|
+
parts.hostname || "unknown-host",
|
|
11
|
+
parts.platform || "unknown-platform",
|
|
12
|
+
parts.arch || "unknown-arch",
|
|
13
|
+
parts.tenant || "default",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
return btoaSafe(stableParts.join("|"));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDeviceQrPayload(machineCode: string): string {
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
type: "windy-device-code",
|
|
22
|
+
version: 1,
|
|
23
|
+
machineCode,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readDeviceCodeFromQrPayload(
|
|
28
|
+
payload: string,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(payload) as { machineCode?: unknown };
|
|
32
|
+
return typeof parsed.machineCode === "string"
|
|
33
|
+
? parsed.machineCode
|
|
34
|
+
: undefined;
|
|
35
|
+
} catch {
|
|
36
|
+
return payload.trim() || undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function btoaSafe(value: string): string {
|
|
41
|
+
if (typeof btoa === "function") {
|
|
42
|
+
return btoa(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import type { AtomicLicenseStore, LicenseSource } from "./storage.js";
|
|
5
|
+
|
|
6
|
+
export class FixedPathLicenseSource implements LicenseSource {
|
|
7
|
+
constructor(protected readonly licensePath: string) {}
|
|
8
|
+
|
|
9
|
+
async readCurrent(): Promise<string | undefined> {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(this.licensePath, "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (isMissingFile(error)) return undefined;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Atomic filesystem adapter. Temporary data is synced before the same-directory
|
|
21
|
+
* rename; the directory is synced afterwards so a crash cannot expose a partial file.
|
|
22
|
+
*/
|
|
23
|
+
export class FileSystemAtomicLicenseStore
|
|
24
|
+
extends FixedPathLicenseSource
|
|
25
|
+
implements AtomicLicenseStore
|
|
26
|
+
{
|
|
27
|
+
constructor(
|
|
28
|
+
licensePath: string,
|
|
29
|
+
private readonly fileMode = 0o600,
|
|
30
|
+
) {
|
|
31
|
+
super(licensePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async replaceCurrentAtomically(value: string): Promise<void> {
|
|
35
|
+
const directory = dirname(this.licensePath);
|
|
36
|
+
const temporaryPath = join(
|
|
37
|
+
directory,
|
|
38
|
+
`.${basename(this.licensePath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
39
|
+
);
|
|
40
|
+
await mkdir(directory, { recursive: true });
|
|
41
|
+
|
|
42
|
+
let temporaryFile: Awaited<ReturnType<typeof open>> | undefined;
|
|
43
|
+
try {
|
|
44
|
+
temporaryFile = await open(temporaryPath, "wx", this.fileMode);
|
|
45
|
+
await temporaryFile.writeFile(value, "utf8");
|
|
46
|
+
await temporaryFile.sync();
|
|
47
|
+
await temporaryFile.close();
|
|
48
|
+
temporaryFile = undefined;
|
|
49
|
+
await this.beforeCommit();
|
|
50
|
+
await rename(temporaryPath, this.licensePath);
|
|
51
|
+
await syncDirectory(directory);
|
|
52
|
+
} finally {
|
|
53
|
+
await temporaryFile?.close().catch(() => undefined);
|
|
54
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Test seam for simulating a process failure before the atomic commit. */
|
|
59
|
+
protected async beforeCommit(): Promise<void> {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function syncDirectory(directory: string): Promise<void> {
|
|
63
|
+
const handle = await open(directory, "r");
|
|
64
|
+
try {
|
|
65
|
+
await handle.sync();
|
|
66
|
+
} finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isMissingFile(error: unknown): boolean {
|
|
72
|
+
return (
|
|
73
|
+
error instanceof Error &&
|
|
74
|
+
"code" in error &&
|
|
75
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { LicenseFailureReason, SignedLicense } from "@southwind-ai/shared";
|
|
2
|
+
import { canonicalizeJson } from "./canonical-json.js";
|
|
3
|
+
import type { AtomicLicenseStore } from "./storage.js";
|
|
4
|
+
import type { LicenseValidationOptions } from "./validation.js";
|
|
5
|
+
import {
|
|
6
|
+
validateLicenseDocument,
|
|
7
|
+
verifyLicenseDocument,
|
|
8
|
+
} from "./validation.js";
|
|
9
|
+
|
|
10
|
+
export type LicenseInstallFailure =
|
|
11
|
+
| LicenseFailureReason
|
|
12
|
+
| "current-license-invalid"
|
|
13
|
+
| "license-not-newer"
|
|
14
|
+
| "storage-failure";
|
|
15
|
+
|
|
16
|
+
export interface LicenseInstallResult {
|
|
17
|
+
status: "installed" | "unchanged" | "rejected";
|
|
18
|
+
license?: SignedLicense;
|
|
19
|
+
failures: LicenseInstallFailure[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TrustedReplacementAuthority = (input: {
|
|
23
|
+
current: SignedLicense;
|
|
24
|
+
candidate: SignedLicense;
|
|
25
|
+
}) => boolean | Promise<boolean>;
|
|
26
|
+
|
|
27
|
+
export type TrustedInvalidCurrentRecoveryAuthority = (input: {
|
|
28
|
+
rawCurrent: string;
|
|
29
|
+
candidate: SignedLicense;
|
|
30
|
+
}) => boolean | Promise<boolean>;
|
|
31
|
+
|
|
32
|
+
export interface LicenseInstallerOptions extends Omit<
|
|
33
|
+
LicenseValidationOptions,
|
|
34
|
+
"lastTrustedAt"
|
|
35
|
+
> {
|
|
36
|
+
store: AtomicLicenseStore;
|
|
37
|
+
/** Must resolve from authenticated, locally trusted replacement metadata. */
|
|
38
|
+
trustedReplacementAuthority?: TrustedReplacementAuthority;
|
|
39
|
+
/** Must only be enabled by an authenticated, audited local recovery flow. */
|
|
40
|
+
trustedInvalidCurrentRecoveryAuthority?: TrustedInvalidCurrentRecoveryAuthority;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class LicenseInstaller {
|
|
44
|
+
constructor(private readonly options: LicenseInstallerOptions) {}
|
|
45
|
+
|
|
46
|
+
async install(rawCandidate: string): Promise<LicenseInstallResult> {
|
|
47
|
+
const candidateResult = await validateLicenseDocument(rawCandidate, {
|
|
48
|
+
productCode: this.options.productCode,
|
|
49
|
+
machineCode: this.options.machineCode,
|
|
50
|
+
publicKeys: this.options.publicKeys,
|
|
51
|
+
now: this.options.now,
|
|
52
|
+
clockToleranceMs: this.options.clockToleranceMs,
|
|
53
|
+
});
|
|
54
|
+
const candidate = candidateResult.license;
|
|
55
|
+
if (!candidateResult.valid || !candidate) {
|
|
56
|
+
return {
|
|
57
|
+
status: "rejected",
|
|
58
|
+
failures: candidateResult.failures,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let rawCurrent: string | undefined;
|
|
63
|
+
try {
|
|
64
|
+
rawCurrent = await this.options.store.readCurrent();
|
|
65
|
+
} catch {
|
|
66
|
+
return { status: "rejected", failures: ["storage-failure"] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (rawCurrent) {
|
|
70
|
+
const currentResult = await verifyLicenseDocument(
|
|
71
|
+
rawCurrent,
|
|
72
|
+
this.options.publicKeys,
|
|
73
|
+
);
|
|
74
|
+
const current = currentResult.license;
|
|
75
|
+
if (!currentResult.valid || !current) {
|
|
76
|
+
const recoveryAllowed = Boolean(
|
|
77
|
+
await this.options.trustedInvalidCurrentRecoveryAuthority?.({
|
|
78
|
+
rawCurrent,
|
|
79
|
+
candidate,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
if (!recoveryAllowed) {
|
|
83
|
+
return {
|
|
84
|
+
status: "rejected",
|
|
85
|
+
failures: ["current-license-invalid"],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
if (sameLicense(current, candidate)) {
|
|
90
|
+
return { status: "unchanged", license: current, failures: [] };
|
|
91
|
+
}
|
|
92
|
+
if (!(await this.isNewerOrReplacement(current, candidate))) {
|
|
93
|
+
return {
|
|
94
|
+
status: "rejected",
|
|
95
|
+
license: current,
|
|
96
|
+
failures: ["license-not-newer"],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const serializedCandidate = canonicalizeJson(candidate);
|
|
103
|
+
try {
|
|
104
|
+
await this.options.store.replaceCurrentAtomically(serializedCandidate);
|
|
105
|
+
return { status: "installed", license: candidate, failures: [] };
|
|
106
|
+
} catch {
|
|
107
|
+
return this.resolveUncertainCommit(candidate);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async isNewerOrReplacement(
|
|
112
|
+
current: SignedLicense,
|
|
113
|
+
candidate: SignedLicense,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
if (
|
|
116
|
+
Date.parse(candidate.claims.issuedAt) >
|
|
117
|
+
Date.parse(current.claims.issuedAt)
|
|
118
|
+
) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
return Boolean(
|
|
122
|
+
await this.options.trustedReplacementAuthority?.({ current, candidate }),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async resolveUncertainCommit(
|
|
127
|
+
candidate: SignedLicense,
|
|
128
|
+
): Promise<LicenseInstallResult> {
|
|
129
|
+
try {
|
|
130
|
+
const committedRaw = await this.options.store.readCurrent();
|
|
131
|
+
const committed = committedRaw
|
|
132
|
+
? await verifyLicenseDocument(committedRaw, this.options.publicKeys)
|
|
133
|
+
: undefined;
|
|
134
|
+
if (committed?.license && sameLicense(committed.license, candidate)) {
|
|
135
|
+
return { status: "installed", license: candidate, failures: [] };
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// The adapter could not prove which complete value survived.
|
|
139
|
+
}
|
|
140
|
+
return { status: "rejected", failures: ["storage-failure"] };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sameLicense(left: SignedLicense, right: SignedLicense): boolean {
|
|
145
|
+
return (
|
|
146
|
+
left.claims.licenseId === right.claims.licenseId &&
|
|
147
|
+
left.publicKeyId === right.publicKeyId &&
|
|
148
|
+
left.signature === right.signature
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SignedLicense, SignedLicenseV1 } from "@southwind-ai/shared";
|
|
2
|
+
import { createLicenseSigningPayload } from "./payload.js";
|
|
3
|
+
|
|
4
|
+
export function parseSignedLicenseV1(
|
|
5
|
+
rawLicense: string,
|
|
6
|
+
): SignedLicenseV1 | undefined {
|
|
7
|
+
try {
|
|
8
|
+
const value = JSON.parse(rawLicense) as unknown;
|
|
9
|
+
if (!isRecord(value)) return undefined;
|
|
10
|
+
if (
|
|
11
|
+
!hasExactKeys(value, [
|
|
12
|
+
"algorithm",
|
|
13
|
+
"claims",
|
|
14
|
+
"formatVersion",
|
|
15
|
+
"publicKeyId",
|
|
16
|
+
"signature",
|
|
17
|
+
])
|
|
18
|
+
) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (typeof value.signature !== "string" || !value.signature.length) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const license = value as unknown as SignedLicenseV1;
|
|
25
|
+
createLicenseSigningPayload({
|
|
26
|
+
formatVersion: license.formatVersion,
|
|
27
|
+
algorithm: license.algorithm,
|
|
28
|
+
publicKeyId: license.publicKeyId,
|
|
29
|
+
claims: license.claims,
|
|
30
|
+
});
|
|
31
|
+
return license;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseSignedLicense(
|
|
38
|
+
rawLicense: string,
|
|
39
|
+
): SignedLicense | undefined {
|
|
40
|
+
return parseSignedLicenseV1(rawLicense);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasExactKeys(
|
|
48
|
+
value: Record<string, unknown>,
|
|
49
|
+
expected: readonly string[],
|
|
50
|
+
): boolean {
|
|
51
|
+
const keys = Object.keys(value);
|
|
52
|
+
return (
|
|
53
|
+
keys.length === expected.length &&
|
|
54
|
+
keys.every((key) => expected.includes(key))
|
|
55
|
+
);
|
|
56
|
+
}
|