@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.
@@ -0,0 +1,57 @@
1
+ const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2
+ const VALUES = new Map(
3
+ [...ALPHABET].map((character, index) => [character, index]),
4
+ );
5
+
6
+ export function encodeBase32(bytes: Uint8Array): string {
7
+ let output = "";
8
+ let buffer = 0;
9
+ let bits = 0;
10
+ for (const byte of bytes) {
11
+ buffer = (buffer << 8) | byte;
12
+ bits += 8;
13
+ while (bits >= 5) {
14
+ bits -= 5;
15
+ output += ALPHABET[(buffer >>> bits) & 31];
16
+ buffer &= (1 << bits) - 1;
17
+ }
18
+ }
19
+ if (bits > 0) output += ALPHABET[(buffer << (5 - bits)) & 31];
20
+ return output;
21
+ }
22
+
23
+ export function decodeBase32(value: string): Uint8Array {
24
+ const output: number[] = [];
25
+ let buffer = 0;
26
+ let bits = 0;
27
+ for (const character of value) {
28
+ const decoded = VALUES.get(character);
29
+ if (decoded === undefined)
30
+ throw new Error(`非法 Base32 字符:${character}`);
31
+ buffer = (buffer << 5) | decoded;
32
+ bits += 5;
33
+ if (bits >= 8) {
34
+ bits -= 8;
35
+ output.push((buffer >>> bits) & 255);
36
+ buffer &= (1 << bits) - 1;
37
+ }
38
+ }
39
+ if (bits >= 5 || buffer !== 0) throw new Error("Base32 末尾填充位无效");
40
+ return Uint8Array.from(output);
41
+ }
42
+
43
+ export function segmentChecksum(data: string, segmentIndex: number): string {
44
+ let primary = (segmentIndex * 11 + 7) & 31;
45
+ let secondary = (segmentIndex * 17 + 19) & 31;
46
+ for (let index = 0; index < data.length; index += 1) {
47
+ const value = VALUES.get(data[index] || "");
48
+ if (value === undefined) throw new Error("分段包含非法字符");
49
+ primary = (primary + value * (index * 2 + 1)) & 31;
50
+ secondary = (secondary + value * ((index * 6 + 5) & 31)) & 31;
51
+ }
52
+ return (ALPHABET[primary] || "0") + (ALPHABET[secondary] || "0");
53
+ }
54
+
55
+ export function isBase32Character(character: string): boolean {
56
+ return VALUES.has(character);
57
+ }
@@ -0,0 +1,55 @@
1
+ import { Unzlib } from "fflate";
2
+ import { OFFLINE_ACTIVATION_CODE_LIMITS } from "./offline-code-limits.js";
3
+
4
+ const COMPRESSED_CHUNK_BYTES = 64;
5
+
6
+ export class OfflineCodeDecompressionLimitError extends Error {
7
+ constructor() {
8
+ super("离线激活码解压结果超过安全上限");
9
+ this.name = "OfflineCodeDecompressionLimitError";
10
+ }
11
+ }
12
+
13
+ /**
14
+ * 分块驱动解压器,并在累计输出越过信封声明或协议上限时立即中断。
15
+ */
16
+ export function decompressOfflineCode(
17
+ compressed: Uint8Array,
18
+ expectedLength: number,
19
+ ): Uint8Array {
20
+ if (
21
+ compressed.byteLength === 0 ||
22
+ compressed.byteLength > OFFLINE_ACTIVATION_CODE_LIMITS.compressedBytes
23
+ ) {
24
+ throw new OfflineCodeDecompressionLimitError();
25
+ }
26
+
27
+ const output = new Uint8Array(expectedLength);
28
+ let offset = 0;
29
+ const stream = new Unzlib((chunk) => {
30
+ const remaining = expectedLength - offset;
31
+ if (
32
+ chunk.byteLength > remaining ||
33
+ offset + chunk.byteLength >
34
+ OFFLINE_ACTIVATION_CODE_LIMITS.decompressedBytes
35
+ ) {
36
+ throw new OfflineCodeDecompressionLimitError();
37
+ }
38
+ output.set(chunk, offset);
39
+ offset += chunk.byteLength;
40
+ });
41
+
42
+ for (
43
+ let start = 0;
44
+ start < compressed.byteLength;
45
+ start += COMPRESSED_CHUNK_BYTES
46
+ ) {
47
+ const end = Math.min(start + COMPRESSED_CHUNK_BYTES, compressed.byteLength);
48
+ stream.push(compressed.subarray(start, end), end === compressed.byteLength);
49
+ }
50
+
51
+ if (offset !== expectedLength) {
52
+ throw new Error("离线激活码解压结果长度不一致");
53
+ }
54
+ return output;
55
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * W1 安全上限。常见激活码不足 1 KiB;打印与请求上限保留了数十倍余量。
3
+ */
4
+ export const OFFLINE_ACTIVATION_CODE_LIMITS = {
5
+ printedCharacters: 32 * 1024,
6
+ normalizedCharacters: 28 * 1024,
7
+ compressedBytes: 16 * 1024,
8
+ decompressedBytes: 64 * 1024,
9
+ requestBytes: 64 * 1024,
10
+ } as const;
@@ -0,0 +1,72 @@
1
+ import type { SignedLicense } from "@southwind-ai/shared";
2
+
3
+ export const ED25519_SIGNATURE_BYTES = 64;
4
+
5
+ export function toCompactTuple(license: SignedLicense): unknown[] {
6
+ const { claims } = license;
7
+ return [
8
+ claims.licenseId,
9
+ claims.productCode,
10
+ claims.customer.id,
11
+ claims.customer.code,
12
+ claims.customer.name,
13
+ claims.project.id,
14
+ claims.project.code,
15
+ claims.project.name,
16
+ claims.contract.id,
17
+ claims.contract.contractNumber,
18
+ claims.contract.version,
19
+ claims.machineCode,
20
+ claims.issuedAt,
21
+ claims.expiresAt,
22
+ claims.licenseVersion.key,
23
+ claims.licenseVersion.name,
24
+ license.publicKeyId,
25
+ ];
26
+ }
27
+
28
+ export function fromCompactTuple(value: unknown, signature: string): unknown {
29
+ if (
30
+ !Array.isArray(value) ||
31
+ value.length !== 17 ||
32
+ value.some((item) => typeof item !== "string")
33
+ ) {
34
+ throw new Error("激活码字段类型或数量无效");
35
+ }
36
+ return {
37
+ formatVersion: 1,
38
+ algorithm: "ED25519",
39
+ publicKeyId: value[16],
40
+ signature,
41
+ claims: {
42
+ licenseId: value[0],
43
+ productCode: value[1],
44
+ customer: { id: value[2], code: value[3], name: value[4] },
45
+ project: { id: value[5], code: value[6], name: value[7] },
46
+ contract: { id: value[8], contractNumber: value[9], version: value[10] },
47
+ machineCode: value[11],
48
+ issuedAt: value[12],
49
+ expiresAt: value[13],
50
+ licenseVersion: { key: value[14], name: value[15] },
51
+ },
52
+ };
53
+ }
54
+
55
+ export function decodeEd25519Signature(value: string): Uint8Array {
56
+ if (!/^[A-Za-z0-9+/]{86}==$/.test(value)) {
57
+ throw new Error("License ED25519 签名编码无效");
58
+ }
59
+ const bytes = Uint8Array.from(atob(value), (character) =>
60
+ character.charCodeAt(0),
61
+ );
62
+ if (bytes.byteLength !== ED25519_SIGNATURE_BYTES) {
63
+ throw new Error("License ED25519 签名长度无效");
64
+ }
65
+ return bytes;
66
+ }
67
+
68
+ export function encodeEd25519Signature(bytes: Uint8Array): string {
69
+ let binary = "";
70
+ for (const byte of bytes) binary += String.fromCharCode(byte);
71
+ return btoa(binary);
72
+ }
@@ -0,0 +1,319 @@
1
+ import { strFromU8, strToU8, zlibSync } from "fflate";
2
+ import type { SignedLicense } from "@southwind-ai/shared";
3
+ import { canonicalizeJson } from "./canonical-json.js";
4
+ import { parseSignedLicense } from "./license-parser.js";
5
+ import {
6
+ decodeBase32,
7
+ encodeBase32,
8
+ isBase32Character,
9
+ segmentChecksum,
10
+ } from "./offline-code-base32.js";
11
+ import {
12
+ decodeEd25519Signature,
13
+ ED25519_SIGNATURE_BYTES,
14
+ encodeEd25519Signature,
15
+ fromCompactTuple,
16
+ toCompactTuple,
17
+ } from "./offline-code-wire.js";
18
+ import { OFFLINE_ACTIVATION_CODE_LIMITS } from "./offline-code-limits.js";
19
+ import {
20
+ decompressOfflineCode,
21
+ OfflineCodeDecompressionLimitError,
22
+ } from "./offline-code-decompression.js";
23
+
24
+ const PREFIX = "W1";
25
+ const DATA_CHARACTERS_PER_SEGMENT = 25;
26
+ const CHECKSUM_CHARACTERS = 2;
27
+ const MAX_CANONICAL_BYTES = OFFLINE_ACTIVATION_CODE_LIMITS.decompressedBytes;
28
+
29
+ export type OfflineActivationCodeErrorCode =
30
+ | "empty"
31
+ | "invalid-prefix"
32
+ | "unsupported-version"
33
+ | "invalid-character"
34
+ | "invalid-length"
35
+ | "input-too-long"
36
+ | "decompression-limit"
37
+ | "group-checksum"
38
+ | "payload-checksum"
39
+ | "damaged-payload"
40
+ | "invalid-license";
41
+
42
+ export class OfflineActivationCodeError extends Error {
43
+ constructor(
44
+ readonly code: OfflineActivationCodeErrorCode,
45
+ message: string,
46
+ readonly groupIndex?: number,
47
+ ) {
48
+ super(message);
49
+ this.name = "OfflineActivationCodeError";
50
+ }
51
+ }
52
+
53
+ export interface DecodedOfflineActivationCode {
54
+ license: SignedLicense;
55
+ canonicalLicense: string;
56
+ }
57
+
58
+ export function encodeOfflineActivationCode(license: SignedLicense): string {
59
+ const canonicalLicense = canonicalizeJson(license);
60
+ const validated = parseSignedLicense(canonicalLicense);
61
+ if (!validated) {
62
+ throw new OfflineActivationCodeError("invalid-license", "License 格式无效");
63
+ }
64
+ const raw = strToU8(JSON.stringify(toCompactTuple(validated)));
65
+ if (raw.byteLength > MAX_CANONICAL_BYTES) {
66
+ throw new OfflineActivationCodeError("invalid-license", "License 内容过长");
67
+ }
68
+ const compressed = zlibSync(raw, { level: 9 });
69
+ if (compressed.byteLength > OFFLINE_ACTIVATION_CODE_LIMITS.compressedBytes) {
70
+ throw new OfflineActivationCodeError(
71
+ "invalid-license",
72
+ "License 压缩内容过长",
73
+ );
74
+ }
75
+ let signature: Uint8Array;
76
+ try {
77
+ signature = decodeEd25519Signature(validated.signature);
78
+ } catch (error) {
79
+ throw new OfflineActivationCodeError(
80
+ "invalid-license",
81
+ error instanceof Error ? error.message : "License ED25519 签名无效",
82
+ );
83
+ }
84
+ const envelope = new Uint8Array(
85
+ 4 + compressed.byteLength + ED25519_SIGNATURE_BYTES + 4,
86
+ );
87
+ writeUint32(envelope, 0, raw.byteLength);
88
+ envelope.set(compressed, 4);
89
+ envelope.set(signature, 4 + compressed.byteLength);
90
+ writeUint32(
91
+ envelope,
92
+ envelope.byteLength - 4,
93
+ crc32(strToU8(canonicalLicense)),
94
+ );
95
+ return formatGroups(encodeBase32(envelope));
96
+ }
97
+
98
+ export function decodeOfflineActivationCode(
99
+ input: string,
100
+ ): DecodedOfflineActivationCode {
101
+ const normalized = normalizeInput(input);
102
+ const encoded = verifyAndJoinGroups(normalized.slice(PREFIX.length));
103
+ let envelope: Uint8Array;
104
+ try {
105
+ envelope = decodeBase32(encoded);
106
+ } catch {
107
+ throw new OfflineActivationCodeError(
108
+ "damaged-payload",
109
+ "激活码末尾数据不完整或已损坏",
110
+ );
111
+ }
112
+ if (envelope.byteLength < 10) {
113
+ throw new OfflineActivationCodeError(
114
+ "invalid-length",
115
+ "激活码数据长度不足",
116
+ );
117
+ }
118
+ const expectedLength = readUint32(envelope, 0);
119
+ if (expectedLength === 0 || expectedLength > MAX_CANONICAL_BYTES) {
120
+ throw new OfflineActivationCodeError(
121
+ "damaged-payload",
122
+ "激活码声明的内容长度无效",
123
+ );
124
+ }
125
+ let raw: Uint8Array;
126
+ try {
127
+ raw = decompressOfflineCode(
128
+ envelope.subarray(4, -(ED25519_SIGNATURE_BYTES + 4)),
129
+ expectedLength,
130
+ );
131
+ } catch (error) {
132
+ if (error instanceof OfflineCodeDecompressionLimitError) {
133
+ throw new OfflineActivationCodeError(
134
+ "decompression-limit",
135
+ "离线激活码内容超过安全上限",
136
+ );
137
+ }
138
+ throw new OfflineActivationCodeError(
139
+ "damaged-payload",
140
+ "激活码压缩内容已损坏",
141
+ );
142
+ }
143
+ if (raw.byteLength !== expectedLength) {
144
+ throw new OfflineActivationCodeError(
145
+ "damaged-payload",
146
+ "激活码内容长度校验失败",
147
+ );
148
+ }
149
+ let compact: unknown;
150
+ try {
151
+ compact = JSON.parse(strFromU8(raw));
152
+ } catch {
153
+ throw new OfflineActivationCodeError(
154
+ "damaged-payload",
155
+ "激活码紧凑内容已损坏",
156
+ );
157
+ }
158
+ const signature = encodeEd25519Signature(
159
+ envelope.subarray(-(ED25519_SIGNATURE_BYTES + 4), -4),
160
+ );
161
+ let canonicalLicense: string;
162
+ try {
163
+ canonicalLicense = canonicalizeJson(fromCompactTuple(compact, signature));
164
+ } catch (error) {
165
+ throw new OfflineActivationCodeError(
166
+ "damaged-payload",
167
+ error instanceof Error ? error.message : "激活码紧凑字段无效",
168
+ );
169
+ }
170
+ if (
171
+ crc32(strToU8(canonicalLicense)) !==
172
+ readUint32(envelope, envelope.byteLength - 4)
173
+ ) {
174
+ throw new OfflineActivationCodeError(
175
+ "payload-checksum",
176
+ "激活码整体校验失败",
177
+ );
178
+ }
179
+ const license = parseSignedLicense(canonicalLicense);
180
+ if (!license || canonicalizeJson(license) !== canonicalLicense) {
181
+ throw new OfflineActivationCodeError(
182
+ "invalid-license",
183
+ "激活码不是规范的 License",
184
+ );
185
+ }
186
+ return { license, canonicalLicense };
187
+ }
188
+
189
+ export function splitOfflineActivationCode(code: string): string[] {
190
+ const normalized = normalizeInput(code);
191
+ const body = normalized.slice(PREFIX.length);
192
+ return [PREFIX, ...chunkGroups(body)];
193
+ }
194
+
195
+ export function offlineActivationCodeErrorMessage(error: unknown): string {
196
+ if (!(error instanceof OfflineActivationCodeError))
197
+ return "离线激活码无法识别";
198
+ if (error.code === "group-checksum" && error.groupIndex) {
199
+ return `第 ${error.groupIndex} 段输入有误,请对照签发页面重新输入`;
200
+ }
201
+ return error.message;
202
+ }
203
+
204
+ function normalizeInput(input: string): string {
205
+ if (input.length > OFFLINE_ACTIVATION_CODE_LIMITS.printedCharacters) {
206
+ throw new OfflineActivationCodeError("input-too-long", "离线激活码过长");
207
+ }
208
+ const compact = input.toUpperCase().replace(/[\s-]/g, "");
209
+ if (compact.length > OFFLINE_ACTIVATION_CODE_LIMITS.normalizedCharacters) {
210
+ throw new OfflineActivationCodeError("input-too-long", "离线激活码过长");
211
+ }
212
+ if (!compact)
213
+ throw new OfflineActivationCodeError("empty", "请输入离线激活码");
214
+ const normalized = compact.replace(/O/g, "0").replace(/[IL]/g, "1");
215
+ if (!normalized.startsWith("W")) {
216
+ throw new OfflineActivationCodeError(
217
+ "invalid-prefix",
218
+ "激活码必须以 W1 开头",
219
+ );
220
+ }
221
+ if (!normalized.startsWith(PREFIX)) {
222
+ throw new OfflineActivationCodeError(
223
+ "unsupported-version",
224
+ "不支持该激活码版本",
225
+ );
226
+ }
227
+ for (const character of normalized.slice(PREFIX.length)) {
228
+ if (!isBase32Character(character)) {
229
+ throw new OfflineActivationCodeError(
230
+ "invalid-character",
231
+ `激活码包含无法识别的字符:${character}`,
232
+ );
233
+ }
234
+ }
235
+ return normalized;
236
+ }
237
+
238
+ function formatGroups(encoded: string): string {
239
+ const segments: string[] = [];
240
+ for (
241
+ let offset = 0;
242
+ offset < encoded.length;
243
+ offset += DATA_CHARACTERS_PER_SEGMENT
244
+ ) {
245
+ const data = encoded.slice(offset, offset + DATA_CHARACTERS_PER_SEGMENT);
246
+ segments.push(data + segmentChecksum(data, segments.length));
247
+ }
248
+ return `${PREFIX}-${segments.join("-")}`;
249
+ }
250
+
251
+ function verifyAndJoinGroups(body: string): string {
252
+ if (body.length < 2) {
253
+ throw new OfflineActivationCodeError(
254
+ "invalid-length",
255
+ "激活码缺少数据分组",
256
+ );
257
+ }
258
+ const segments = chunkGroups(body);
259
+ return segments
260
+ .map((segment, index) => {
261
+ if (
262
+ segment.length <= CHECKSUM_CHARACTERS ||
263
+ segment.length > DATA_CHARACTERS_PER_SEGMENT + CHECKSUM_CHARACTERS
264
+ ) {
265
+ throw new OfflineActivationCodeError(
266
+ "invalid-length",
267
+ "激活码分段长度无效",
268
+ index + 1,
269
+ );
270
+ }
271
+ const data = segment.slice(0, -CHECKSUM_CHARACTERS);
272
+ if (
273
+ segment.slice(-CHECKSUM_CHARACTERS) !== segmentChecksum(data, index)
274
+ ) {
275
+ throw new OfflineActivationCodeError(
276
+ "group-checksum",
277
+ `第 ${index + 1} 段校验失败`,
278
+ index + 1,
279
+ );
280
+ }
281
+ return data;
282
+ })
283
+ .join("");
284
+ }
285
+
286
+ function chunkGroups(body: string): string[] {
287
+ const size = DATA_CHARACTERS_PER_SEGMENT + CHECKSUM_CHARACTERS;
288
+ const groups: string[] = [];
289
+ for (let offset = 0; offset < body.length; offset += size) {
290
+ groups.push(body.slice(offset, offset + size));
291
+ }
292
+ return groups;
293
+ }
294
+
295
+ function crc32(bytes: Uint8Array): number {
296
+ let crc = 0xffffffff;
297
+ for (const byte of bytes) {
298
+ crc ^= byte;
299
+ for (let bit = 0; bit < 8; bit += 1) {
300
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
301
+ }
302
+ }
303
+ return (crc ^ 0xffffffff) >>> 0;
304
+ }
305
+
306
+ function readUint32(bytes: Uint8Array, offset: number): number {
307
+ return new DataView(
308
+ bytes.buffer,
309
+ bytes.byteOffset,
310
+ bytes.byteLength,
311
+ ).getUint32(offset);
312
+ }
313
+
314
+ function writeUint32(bytes: Uint8Array, offset: number, value: number): void {
315
+ new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
316
+ offset,
317
+ value,
318
+ );
319
+ }