work-license-server 1.0.3 → 1.0.5
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 +8 -2
- package/assets/admin.js +572 -0
- package/assets/landing.css +72 -1
- package/assets/landing.js +315 -332
- package/bin/server.mjs +5 -2
- package/package.json +1 -1
- package/src/admin.js +319 -0
- package/src/agent.js +71 -5
- package/src/catalog.js +141 -8
- package/src/crypto.js +251 -17
- package/src/index.js +22 -1
- package/src/landing.js +94 -171
- package/src/server.js +229 -24
- package/src/store.js +693 -47
- package/src/yookassa.js +22 -8
package/src/catalog.js
CHANGED
|
@@ -6,10 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
export const PAK_PRODUCT = Object.freeze({
|
|
8
8
|
sku: 'WORK-PAK-495-192-4T',
|
|
9
|
+
baseLicenseSku: 'WORK-LIC-BASE-10',
|
|
9
10
|
name: 'ODANT-WORK',
|
|
10
11
|
domain: 'work.odant.org',
|
|
11
12
|
basePriceRub: 5_000_000,
|
|
13
|
+
baseLicensePriceRub: 4_000_000,
|
|
12
14
|
baseSeats: 10,
|
|
15
|
+
unlicensedBossSeats: 1,
|
|
13
16
|
perpetualBase: true,
|
|
14
17
|
includedSupportYears: 1,
|
|
15
18
|
includedSupportTier: 'enterprise',
|
|
@@ -213,13 +216,60 @@ export function calculateSeatVolumeDiscount(seats = 1) {
|
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
/**
|
|
216
|
-
*
|
|
219
|
+
* Отдельная базовая постоянная лицензия на 10 вечных пользователей (без оборудования) —
|
|
220
|
+
* для клиентов, самостоятельно приобретших автономную машину AMD Ryzen™ AI Max+ PRO 495 (192 ГБ)
|
|
221
|
+
* и установивших систему через npm.
|
|
222
|
+
*/
|
|
223
|
+
export const BASE_LICENSE_PRODUCT = Object.freeze({
|
|
224
|
+
sku: 'WORK-LIC-BASE-10',
|
|
225
|
+
name: 'Базовая постоянная лицензия ODANT-WORK (10 вечных пользователей, без железа)',
|
|
226
|
+
priceRub: 4_000_000,
|
|
227
|
+
baseSeats: 10,
|
|
228
|
+
perpetualBase: true,
|
|
229
|
+
aiAgentsEnabled: true,
|
|
230
|
+
includedSupportYears: 1,
|
|
231
|
+
includedSupportTier: 'enterprise',
|
|
232
|
+
description: 'Снимает стартовые ограничения: активирует агентскую ИИ-систему, открывает 10 вечных конкурентных пользовательских мест и включает 1 год поддержки Enterprise.',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Дополнительные услуги и товары, доступные в личном кабинете владельца.
|
|
237
|
+
*/
|
|
238
|
+
export const ADDITIONAL_PRODUCTS = Object.freeze([
|
|
239
|
+
{
|
|
240
|
+
id: 'ai-finetune-pack',
|
|
241
|
+
sku: 'WORK-SRV-FINETUNE',
|
|
242
|
+
title: 'Пакет дообучения локальных моделей ИИ (LoRA) и разработки навыков под предприятие',
|
|
243
|
+
priceRub: 600_000,
|
|
244
|
+
category: 'service',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'nas-backup-kit',
|
|
248
|
+
sku: 'WORK-HW-NAS',
|
|
249
|
+
title: 'Комплект резервного копирования и архивации баз организаций (NAS)',
|
|
250
|
+
priceRub: 350_000,
|
|
251
|
+
category: 'hardware',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: 'audit-migration-pack',
|
|
255
|
+
sku: 'WORK-SRV-MIGRATION',
|
|
256
|
+
title: 'Пакет переноса исторических учётных баз, справочников и остатков',
|
|
257
|
+
priceRub: 450_000,
|
|
258
|
+
category: 'service',
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Рассчитать стоимость заказа (покупка базового комплекса с машиной 5 млн ₽, отдельно базовой лицензии 4 млн ₽,
|
|
264
|
+
* докупка срочных годовых лицензий со скидкой от количества и прочих товаров/услуг).
|
|
217
265
|
*/
|
|
218
266
|
export function calculateOrderQuote({
|
|
219
267
|
includeBasePak = false,
|
|
268
|
+
includeBaseLicenseOnly = false,
|
|
220
269
|
tier = 'business',
|
|
221
270
|
addonSeats = 0,
|
|
222
271
|
years = 1,
|
|
272
|
+
extraProductIds = [],
|
|
223
273
|
} = {}) {
|
|
224
274
|
const tierKey = String(tier || 'business').toLowerCase();
|
|
225
275
|
const tierSpec = SLA_TIERS[tierKey] || SLA_TIERS.business;
|
|
@@ -229,25 +279,42 @@ export function calculateOrderQuote({
|
|
|
229
279
|
const volumeDiscount = calculateSeatVolumeDiscount(seats);
|
|
230
280
|
const volumeDiscountPercent = volumeDiscount.discountPercent;
|
|
231
281
|
|
|
232
|
-
const
|
|
282
|
+
const hasFullPak = Boolean(includeBasePak);
|
|
283
|
+
const hasBaseLicOnly = !hasFullPak && Boolean(includeBaseLicenseOnly);
|
|
284
|
+
|
|
285
|
+
const baseAmountRub = hasFullPak
|
|
286
|
+
? PAK_PRODUCT.basePriceRub
|
|
287
|
+
: (hasBaseLicOnly ? BASE_LICENSE_PRODUCT.priceRub : 0);
|
|
288
|
+
|
|
233
289
|
const addonsGrossAmountRub = seats * tierSpec.pricePerUserYearRub * periodYears;
|
|
234
290
|
const addonsDiscountRub = Math.round(addonsGrossAmountRub * (volumeDiscountPercent / 100));
|
|
235
291
|
const addonsAmountRub = addonsGrossAmountRub - addonsDiscountRub;
|
|
236
292
|
const effectiveUnitPriceRub = seats > 0 ? Math.round(addonsAmountRub / seats) : tierSpec.pricePerUserYearRub * periodYears;
|
|
237
|
-
const totalAmountRub = baseAmountRub + addonsAmountRub;
|
|
238
293
|
|
|
239
294
|
const items = [];
|
|
240
|
-
if (
|
|
295
|
+
if (hasFullPak) {
|
|
241
296
|
items.push({
|
|
297
|
+
sku: PAK_PRODUCT.sku,
|
|
242
298
|
title: `${PAK_PRODUCT.name} (${PAK_PRODUCT.hardware.cpu}, ${PAK_PRODUCT.hardware.ramGb} ГБ RAM, SSD ${PAK_PRODUCT.hardware.ssdTb} ТБ, вечная лицензия на ${PAK_PRODUCT.baseSeats} мест + 1 год поддержки Enterprise)`,
|
|
243
299
|
quantity: 1,
|
|
244
300
|
unitPriceRub: PAK_PRODUCT.basePriceRub,
|
|
245
301
|
totalRub: PAK_PRODUCT.basePriceRub,
|
|
246
302
|
});
|
|
247
303
|
}
|
|
304
|
+
else if (hasBaseLicOnly) {
|
|
305
|
+
items.push({
|
|
306
|
+
sku: BASE_LICENSE_PRODUCT.sku,
|
|
307
|
+
title: BASE_LICENSE_PRODUCT.name,
|
|
308
|
+
quantity: 1,
|
|
309
|
+
unitPriceRub: BASE_LICENSE_PRODUCT.priceRub,
|
|
310
|
+
totalRub: BASE_LICENSE_PRODUCT.priceRub,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
248
314
|
if (seats > 0) {
|
|
249
315
|
const discSuffix = volumeDiscountPercent > 0 ? `, скидка −${volumeDiscountPercent}% за объём` : '';
|
|
250
316
|
items.push({
|
|
317
|
+
sku: `WORK-LIC-ADDON-${tierSpec.id.toUpperCase()}`,
|
|
251
318
|
title: `Годовая лицензия ODANT-WORK на доп. рабочее место (уровень ${tierSpec.name}, ${periodYears} г.${discSuffix})`,
|
|
252
319
|
quantity: seats,
|
|
253
320
|
unitPriceRub: effectiveUnitPriceRub,
|
|
@@ -255,11 +322,31 @@ export function calculateOrderQuote({
|
|
|
255
322
|
});
|
|
256
323
|
}
|
|
257
324
|
|
|
325
|
+
let extraAmountRub = 0;
|
|
326
|
+
const extraIds = Array.isArray(extraProductIds) ? extraProductIds : [];
|
|
327
|
+
for (const pid of extraIds) {
|
|
328
|
+
const prod = ADDITIONAL_PRODUCTS.find(p => p.id === pid || p.sku === pid);
|
|
329
|
+
if (prod) {
|
|
330
|
+
extraAmountRub += prod.priceRub;
|
|
331
|
+
items.push({
|
|
332
|
+
sku: prod.sku,
|
|
333
|
+
title: prod.title,
|
|
334
|
+
quantity: 1,
|
|
335
|
+
unitPriceRub: prod.priceRub,
|
|
336
|
+
totalRub: prod.priceRub,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const totalAmountRub = baseAmountRub + addonsAmountRub + extraAmountRub;
|
|
342
|
+
|
|
258
343
|
return {
|
|
259
|
-
includeBasePak:
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
344
|
+
includeBasePak: hasFullPak,
|
|
345
|
+
includeBaseLicenseOnly: hasBaseLicOnly,
|
|
346
|
+
basePakPriceRub: hasFullPak ? PAK_PRODUCT.basePriceRub : 0,
|
|
347
|
+
baseLicenseOnlyPriceRub: hasBaseLicOnly ? BASE_LICENSE_PRODUCT.priceRub : 0,
|
|
348
|
+
baseSeats: (hasFullPak || hasBaseLicOnly) ? PAK_PRODUCT.baseSeats : 0,
|
|
349
|
+
includedSupportYears: (hasFullPak || hasBaseLicOnly) ? PAK_PRODUCT.includedSupportYears : 0,
|
|
263
350
|
tier: tierSpec.id,
|
|
264
351
|
tierName: tierSpec.name,
|
|
265
352
|
pricePerUserYearRub: tierSpec.pricePerUserYearRub,
|
|
@@ -270,6 +357,7 @@ export function calculateOrderQuote({
|
|
|
270
357
|
addonsGrossAmountRub,
|
|
271
358
|
addonsDiscountRub,
|
|
272
359
|
addonsAmountRub,
|
|
360
|
+
extraAmountRub,
|
|
273
361
|
totalAmountRub,
|
|
274
362
|
reactionMinutes: tierSpec.reactionMinutes,
|
|
275
363
|
reactionLabel: tierSpec.reactionLabel,
|
|
@@ -678,3 +766,48 @@ export function calculatePartnerEconomics({
|
|
|
678
766
|
operationsModel: FRANCHISE_PROGRAM.partnerOperationsModel,
|
|
679
767
|
};
|
|
680
768
|
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Публичный договор-оферта ODANT-WORK (ст. 435, 437, 1286 ГК РФ и ФЗ № 63-ФЗ «Об электронной подписи»).
|
|
772
|
+
* Подписание оферты простой электронной подписью (ПЭП) в личном кабинете обязательно перед оформлением
|
|
773
|
+
* любого заказа комплекса, докупкой годовых лицензий, оплатой или резервированием региональной франшизы.
|
|
774
|
+
*/
|
|
775
|
+
export const PUBLIC_OFERTA = Object.freeze({
|
|
776
|
+
version: 'ODANT-WORK-OFERTA-2026.1',
|
|
777
|
+
updatedAt: '01.11.2026',
|
|
778
|
+
title: 'Публичный договор-оферта на поставку ПАК ODANT-WORK, предоставление неисключительных лицензий, техническую поддержку (SLA) и резервирование региональной франшизы',
|
|
779
|
+
vendor: VENDOR_CONTACTS,
|
|
780
|
+
sections: Object.freeze([
|
|
781
|
+
{
|
|
782
|
+
number: '1',
|
|
783
|
+
title: 'Общие положения и предмет Договора-оферты',
|
|
784
|
+
text: 'Настоящий документ является официальным публичным предложением (офертой) ООО «Бизнес Интер Софт» (ИНН 6234049193, г. Рязань, ул. Ленина, 47), именуемого далее «Правообладатель», заключить договор на поставку программно-аппаратного комплекса ODANT-WORK, предоставление права использования ПО (простой неисключительной лицензии), сопровождение по уровню SLA и/или резервирование региональной партнёрской франшизы с любым юридическим лицом или индивидуальным предпринимателем («Заказчик / Партнёр»).',
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
number: '2',
|
|
788
|
+
title: 'Комплектация ПАК ODANT-WORK и вечная базовая лицензия',
|
|
789
|
+
text: 'Базовый программно-аппаратный комплекс ODANT-WORK (AMD Ryzen™ AI Max+ PRO 495, Radeon™ 8065S, 192 ГБ Unified RAM, SSD 4 ТБ) стоимостью 5 000 000 ₽ включает бессрочную (вечную) лицензию на 10 одновременных активных пользовательских сессий (привязанную к HWID оборудования и криптографическому ключу узла #system/node-key.json) и 1-й год технической поддержки максимального уровня Enterprise (24/7, время реакции до 15 минут). По истечении первого года продление поддержки и докупка мест осуществляются по желанию Заказчика; 10 базовых вечных лицензий продолжают работать бессрочно.',
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
number: '3',
|
|
793
|
+
title: 'Годовые пользовательские лицензии и уровни поддержки (SLA)',
|
|
794
|
+
text: 'Дополнительные конкурентные рабочие места предоставляются сроком от 1 до 5 лет по выбранному тарифу SLA: Start (10 000 ₽/год за место, до 12 обращений, реакция до 8 раб. ч.), Business (15 000 ₽/год, до 48 обращений, реакция до 2 ч.), Enterprise (20 000 ₽/год, безлимит обращений, реакция до 15 мин 24/7). При единовременном заказе от 5 мест применяется объёмная скидка от 5% до 25%. Выпуск и продление лицензий удостоверяются цифровым сертификатом Ed25519 (протокол WORK-LIC-v1).',
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
number: '4',
|
|
798
|
+
title: 'Региональная франшиза и условия резервирования квоты',
|
|
799
|
+
text: 'Квота региональных партнёров-франчайзи в каждом субъекте РФ пропорциональна численности населения региона. Фиксированный паушальный взнос равен 500 000 ₽, умноженным на экономический индекс выбранного субъекта РФ. При оформлении заявки на резервирование франшизы Заказчик оплачивает невозвратный обеспечительный залог в размере 100 000 ₽, который закрепляет за ним региональную квоту и засчитывается в счёт паушального взноса. Оставшаяся часть паушального взноса подлежит полной оплате в течение 12 (двенадцати) месяцев с даты резервирования.',
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
number: '5',
|
|
803
|
+
title: 'Порядок расчётов и фискализация (ЮKassa · 54-ФЗ)',
|
|
804
|
+
text: 'Все расчёты по настоящему Договору производятся в рублях РФ через авторизованный платёжный шлюз ЮKassa (API v3) с формированием кассового чека в соответствии с 54-ФЗ либо по безналичному счёту для юридических лиц. Исполнение обязательств по выпуску/обновлению сертификата лицензии или закреплению квоты франшизы происходит автоматически в момент подтверждения оплаты.',
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
number: '6',
|
|
808
|
+
title: 'Акцепт оферты простой электронной подписью (ПЭП)',
|
|
809
|
+
text: 'В соответствии со ст. 438 ГК РФ и ст. 6 ФЗ № 63-ФЗ «Об электронной подписи», регистрация Заказчика в личном кабинете на сайте work.odant.org с указанием реквизитов организации (наименование, ИНН, корпоративный email, ФИО представителя) и установка отметки о согласии с условиями настоящей Оферты признаются полным и безоговорочным акцептом Договора-оферты простой электронной подписью (ПЭП), равнозначным договору, подписанному собственноручно.',
|
|
810
|
+
},
|
|
811
|
+
]),
|
|
812
|
+
});
|
|
813
|
+
|
package/src/crypto.js
CHANGED
|
@@ -11,17 +11,72 @@ import { PAK_PRODUCT } from './catalog.js';
|
|
|
11
11
|
export const LICENSE_PROTOCOL = 'WORK-LIC-v1';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Официальные публичные ключи центра сертификации Правообладателя ODANT (Ed25519 SPKI DER base64).
|
|
15
|
+
* Вшиты в клиентский код odant-work; закрытые ключи хранятся исключительно на боевом сервере work.odant.org
|
|
16
|
+
* и холодном резервном носителе Правообладателя (никогда не публикуются в npm и репозиториях).
|
|
15
17
|
*/
|
|
16
|
-
export
|
|
18
|
+
export const ODANT_AUTHORITY_PUBLIC_KEY = 'MCowBQYDK2VwAyEAdlQSj+pC3IOt60vo3lXkzpd0DFU4ZmADqEYQsW1+mRA=';
|
|
19
|
+
export const ODANT_BACKUP_PUBLIC_KEY = 'MCowBQYDK2VwAyEA8xJ9r7V4kM2pN6qL1wZ5tB3cF0yH8gD4sE7uA9vK2mQ=';
|
|
20
|
+
export const TRUSTED_AUTHORITY_PUBLIC_KEYS = Object.freeze([
|
|
21
|
+
ODANT_AUTHORITY_PUBLIC_KEY,
|
|
22
|
+
ODANT_BACKUP_PUBLIC_KEY,
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Получить список доверенных публичных ключей центра сертификации (без доверия к полям внутри самого сертификата!).
|
|
27
|
+
*/
|
|
28
|
+
export function resolveTrustedAuthorityKeys(trustedPublicKeyB64 = '') {
|
|
29
|
+
const keys = [];
|
|
30
|
+
if (trustedPublicKeyB64)
|
|
31
|
+
keys.push(String(trustedPublicKeyB64).trim());
|
|
32
|
+
if (process.env.WORK_LICENSE_PUBLIC_KEY)
|
|
33
|
+
keys.push(String(process.env.WORK_LICENSE_PUBLIC_KEY).trim());
|
|
34
|
+
for (const k of TRUSTED_AUTHORITY_PUBLIC_KEYS) {
|
|
35
|
+
if (!keys.includes(k))
|
|
36
|
+
keys.push(k);
|
|
37
|
+
}
|
|
38
|
+
return keys.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Загрузить ключевую пару Ed25519 центра лицензирования из защищённого окружения (WORK_AUTHORITY_PRIVATE_KEY)
|
|
43
|
+
* или локального хранилища (в dev/test режиме).
|
|
44
|
+
*/
|
|
45
|
+
export function loadOrCreateAuthorityKeys(keyFilePath, { allowGenerateInProd = false } = {}) {
|
|
46
|
+
const envPriv = String(process.env.WORK_AUTHORITY_PRIVATE_KEY || '').trim();
|
|
47
|
+
if (envPriv) {
|
|
48
|
+
const privObj = crypto.createPrivateKey({
|
|
49
|
+
key: Buffer.from(envPriv, 'base64'),
|
|
50
|
+
format: 'der',
|
|
51
|
+
type: 'pkcs8',
|
|
52
|
+
});
|
|
53
|
+
const derivedPub = crypto.createPublicKey(privObj).export({ type: 'spki', format: 'der' }).toString('base64');
|
|
54
|
+
const pubB64 = String(process.env.WORK_AUTHORITY_PUBLIC_KEY || derivedPub).trim();
|
|
55
|
+
return {
|
|
56
|
+
publicKey: pubB64,
|
|
57
|
+
privateKey: envPriv,
|
|
58
|
+
source: 'env',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
if (keyFilePath && fs.existsSync(keyFilePath)) {
|
|
18
63
|
try {
|
|
19
64
|
const raw = JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
|
|
20
65
|
if (raw?.publicKey && raw?.privateKey)
|
|
21
66
|
return raw;
|
|
22
67
|
}
|
|
23
|
-
catch { /* создадим заново */ }
|
|
68
|
+
catch { /* создадим заново ниже */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.env.WORK_REQUIRE_AUTHORITY_ENV === '1' && !allowGenerateInProd) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Безопасность: требуется явно заданный секретный ключ правообладателя в переменной окружения WORK_AUTHORITY_PRIVATE_KEY.'
|
|
74
|
+
);
|
|
24
75
|
}
|
|
76
|
+
if (process.env.NODE_ENV === 'production') {
|
|
77
|
+
console.warn('[work.odant.org] WORK_AUTHORITY_PRIVATE_KEY не задан в окружении — используется локальный ключ контейнера.');
|
|
78
|
+
}
|
|
79
|
+
|
|
25
80
|
const pair = crypto.generateKeyPairSync('ed25519');
|
|
26
81
|
const keys = {
|
|
27
82
|
publicKey: pair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
@@ -92,27 +147,206 @@ export function signCertificate(lic, authorityKeys) {
|
|
|
92
147
|
}
|
|
93
148
|
|
|
94
149
|
/**
|
|
95
|
-
* Проверить подпись сертификата
|
|
150
|
+
* Проверить подпись сертификата лицензии строго по доверенным ключам центра сертификации ODANT
|
|
151
|
+
* (поле lic.issuerPublicKey внутри самого сертификата НЕ является источником доверия).
|
|
96
152
|
*/
|
|
97
153
|
export function verifyCertificate(lic, publicKeyB64 = '') {
|
|
98
154
|
if (!lic || typeof lic !== 'object' || !lic.signature)
|
|
99
155
|
return false;
|
|
100
|
-
const
|
|
101
|
-
|
|
156
|
+
const trustedKeys = resolveTrustedAuthorityKeys(publicKeyB64);
|
|
157
|
+
const payload = canonicalLicenseString(lic);
|
|
158
|
+
const sigBuf = Buffer.from(String(lic.signature), 'base64');
|
|
159
|
+
for (const keyB64 of trustedKeys) {
|
|
160
|
+
try {
|
|
161
|
+
const pub = crypto.createPublicKey({
|
|
162
|
+
key: Buffer.from(String(keyB64), 'base64'),
|
|
163
|
+
format: 'der',
|
|
164
|
+
type: 'spki',
|
|
165
|
+
});
|
|
166
|
+
if (crypto.verify(null, Buffer.from(payload, 'utf8'), pub, sigBuf))
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
catch { /* try next trusted key */ }
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const LICENSE_FILE_PROTOCOL = 'WORK-LIC-FILE-v1';
|
|
175
|
+
export const OWNER_TOKEN_PREFIX = 'owk1';
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Каноническая сериализация отдельного файла лицензии (для размещения в #system).
|
|
179
|
+
* kind:
|
|
180
|
+
* - 'base' — первичная постоянная лицензия (10 вечных пользователей + снятие блокировки агентской системы);
|
|
181
|
+
* - 'addon' — дополнительная временная лицензия (N конкурентных пользователей на определённый период).
|
|
182
|
+
*/
|
|
183
|
+
export function canonicalLicenseFileString(fileObj = {}) {
|
|
184
|
+
const clean = {
|
|
185
|
+
protocol: fileObj.protocol || LICENSE_FILE_PROTOCOL,
|
|
186
|
+
fileId: String(fileObj.fileId || ''),
|
|
187
|
+
kind: String(fileObj.kind || 'base'), // 'base' | 'addon'
|
|
188
|
+
licenseId: String(fileObj.licenseId || ''),
|
|
189
|
+
ownerAccountId: String(fileObj.ownerAccountId || ''),
|
|
190
|
+
ownerEmail: String(fileObj.ownerEmail || '').toLowerCase(),
|
|
191
|
+
issuer: String(fileObj.issuer || 'work.odant.org'),
|
|
192
|
+
issuedAt: Number(fileObj.issuedAt || 0),
|
|
193
|
+
expiresAt: fileObj.perpetual ? null : Number(fileObj.expiresAt || 0),
|
|
194
|
+
perpetual: Boolean(fileObj.perpetual),
|
|
195
|
+
seats: Number(fileObj.seats || 0),
|
|
196
|
+
years: Number(fileObj.years || (fileObj.perpetual ? 0 : 1)),
|
|
197
|
+
tier: String(fileObj.tier || 'enterprise').toLowerCase(),
|
|
198
|
+
aiAgentsEnabled: fileObj.aiAgentsEnabled !== false,
|
|
199
|
+
orderId: String(fileObj.orderId || ''),
|
|
200
|
+
hardware: {
|
|
201
|
+
hwid: String(fileObj.hardware?.hwid || ''),
|
|
202
|
+
nodeFingerprint: String(fileObj.hardware?.nodeFingerprint || ''),
|
|
203
|
+
sku: String(fileObj.hardware?.sku || PAK_PRODUCT.sku),
|
|
204
|
+
},
|
|
205
|
+
customer: {
|
|
206
|
+
name: String(fileObj.customer?.name || ''),
|
|
207
|
+
inn: String(fileObj.customer?.inn || ''),
|
|
208
|
+
email: String(fileObj.customer?.email || ''),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
return JSON.stringify(clean);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Подписать отдельный файл лицензии (base или addon) закрытым ключом Ed25519 центра сертификации.
|
|
216
|
+
*/
|
|
217
|
+
export function signLicenseFile(fileObj, authorityKeys) {
|
|
218
|
+
const priv = crypto.createPrivateKey({
|
|
219
|
+
key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
|
|
220
|
+
format: 'der',
|
|
221
|
+
type: 'pkcs8',
|
|
222
|
+
});
|
|
223
|
+
const payload = canonicalLicenseFileString(fileObj);
|
|
224
|
+
const signature = crypto.sign(null, Buffer.from(payload, 'utf8'), priv).toString('base64');
|
|
225
|
+
return {
|
|
226
|
+
...fileObj,
|
|
227
|
+
protocol: fileObj.protocol || LICENSE_FILE_PROTOCOL,
|
|
228
|
+
issuer: fileObj.issuer || 'work.odant.org',
|
|
229
|
+
issuerPublicKey: authorityKeys.publicKey,
|
|
230
|
+
signature,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Проверить подпись отдельного файла лицензии Ed25519 строго по доверенным ключам центра сертификации ODANT
|
|
236
|
+
* (поле fileObj.issuerPublicKey внутри файла НЕ является источником доверия).
|
|
237
|
+
*/
|
|
238
|
+
export function verifyLicenseFile(fileObj, publicKeyB64 = '') {
|
|
239
|
+
if (!fileObj || typeof fileObj !== 'object' || !fileObj.signature)
|
|
102
240
|
return false;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
241
|
+
const trustedKeys = resolveTrustedAuthorityKeys(publicKeyB64);
|
|
242
|
+
const payload = canonicalLicenseFileString(fileObj);
|
|
243
|
+
const sigBuf = Buffer.from(String(fileObj.signature), 'base64');
|
|
244
|
+
for (const keyB64 of trustedKeys) {
|
|
245
|
+
try {
|
|
246
|
+
const pub = crypto.createPublicKey({
|
|
247
|
+
key: Buffer.from(String(keyB64), 'base64'),
|
|
248
|
+
format: 'der',
|
|
249
|
+
type: 'spki',
|
|
250
|
+
});
|
|
251
|
+
if (crypto.verify(null, Buffer.from(payload, 'utf8'), pub, sigBuf))
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch { /* try next trusted key */ }
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Сгенерировать подписанный токен владельца (owk1.<payload_b64url>.<sig_b64url>),
|
|
261
|
+
* содержащий URL сервера поддержки (hubUrl), ID аккаунта владельца и секретный ключ для доступа локальной системы к ЛК.
|
|
262
|
+
*/
|
|
263
|
+
export function createOwnerAccessToken({
|
|
264
|
+
hubUrl = 'https://work.odant.org',
|
|
265
|
+
accountId = '',
|
|
266
|
+
email = '',
|
|
267
|
+
tokenId = '',
|
|
268
|
+
secret = '',
|
|
269
|
+
issuedAt = Date.now(),
|
|
270
|
+
} = {}, authorityKeys) {
|
|
271
|
+
const payloadObj = {
|
|
272
|
+
v: 1,
|
|
273
|
+
hubUrl: String(hubUrl || 'https://work.odant.org').replace(/\/+$/, ''),
|
|
274
|
+
accountId: String(accountId || ''),
|
|
275
|
+
email: String(email || '').toLowerCase(),
|
|
276
|
+
tokenId: String(tokenId || ('TOK-' + crypto.randomBytes(4).toString('hex').toUpperCase())),
|
|
277
|
+
secret: String(secret || crypto.randomBytes(16).toString('hex')),
|
|
278
|
+
issuedAt: Number(issuedAt || Date.now()),
|
|
279
|
+
issuerPublicKey: authorityKeys?.publicKey || '',
|
|
280
|
+
};
|
|
281
|
+
const payloadB64 = Buffer.from(JSON.stringify(payloadObj), 'utf8').toString('base64url');
|
|
282
|
+
let sigB64 = '';
|
|
283
|
+
if (authorityKeys?.privateKey) {
|
|
284
|
+
const priv = crypto.createPrivateKey({
|
|
285
|
+
key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
|
|
106
286
|
format: 'der',
|
|
107
|
-
type: '
|
|
287
|
+
type: 'pkcs8',
|
|
108
288
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
289
|
+
sigB64 = crypto.sign(null, Buffer.from(`${OWNER_TOKEN_PREFIX}.${payloadB64}`, 'utf8'), priv).toString('base64url');
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
token: `${OWNER_TOKEN_PREFIX}.${payloadB64}.${sigB64}`,
|
|
293
|
+
payload: payloadObj,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Распарсить токен владельца (owk1.<payload_b64url>.<sig_b64url>) и извлечь зашитый URL сервера поддержки и реквизиты.
|
|
299
|
+
*/
|
|
300
|
+
export function parseOwnerAccessToken(tokenStr = '') {
|
|
301
|
+
const raw = String(tokenStr || '').trim();
|
|
302
|
+
const parts = raw.split('.');
|
|
303
|
+
if (parts.length !== 3 || parts[0] !== OWNER_TOKEN_PREFIX) {
|
|
304
|
+
throw new Error('Некорректный формат токена владельца (ожидается owk1.<payload>.<signature>)');
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
|
308
|
+
const payload = JSON.parse(json);
|
|
309
|
+
if (!payload?.hubUrl || !payload?.accountId || !payload?.tokenId) {
|
|
310
|
+
throw new Error('В токене отсутствуют обязательные поля hubUrl / accountId / tokenId');
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
token: raw,
|
|
314
|
+
prefix: parts[0],
|
|
315
|
+
payloadB64: parts[1],
|
|
316
|
+
signatureB64: parts[2],
|
|
317
|
+
payload,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
throw new Error(`Не удалось декодировать токен владельца: ${err?.message || err}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Проверить криптографическую подпись Ed25519 токена владельца строго по доверенным ключам центра сертификации ODANT
|
|
327
|
+
* (поле payload.issuerPublicKey внутри токена НЕ является источником доверия).
|
|
328
|
+
*/
|
|
329
|
+
export function verifyOwnerAccessToken(tokenStr = '', trustedPublicKeyB64 = '') {
|
|
330
|
+
try {
|
|
331
|
+
const parsed = parseOwnerAccessToken(tokenStr);
|
|
332
|
+
if (!parsed.signatureB64)
|
|
333
|
+
return false;
|
|
334
|
+
const trustedKeys = resolveTrustedAuthorityKeys(trustedPublicKeyB64);
|
|
335
|
+
const signedData = Buffer.from(`${OWNER_TOKEN_PREFIX}.${parsed.payloadB64}`, 'utf8');
|
|
336
|
+
const sigBuf = Buffer.from(parsed.signatureB64, 'base64url');
|
|
337
|
+
for (const keyB64 of trustedKeys) {
|
|
338
|
+
try {
|
|
339
|
+
const pub = crypto.createPublicKey({
|
|
340
|
+
key: Buffer.from(String(keyB64), 'base64'),
|
|
341
|
+
format: 'der',
|
|
342
|
+
type: 'spki',
|
|
343
|
+
});
|
|
344
|
+
if (crypto.verify(null, signedData, pub, sigBuf))
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
catch { /* try next trusted key */ }
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
116
350
|
}
|
|
117
351
|
catch {
|
|
118
352
|
return false;
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export {
|
|
2
2
|
PAK_PRODUCT,
|
|
3
|
+
BASE_LICENSE_PRODUCT,
|
|
4
|
+
ADDITIONAL_PRODUCTS,
|
|
3
5
|
AI_PLATFORM,
|
|
4
6
|
VENDOR_CONTACTS,
|
|
5
7
|
FEDERAL_DISTRICTS,
|
|
@@ -14,8 +16,27 @@ export {
|
|
|
14
16
|
calculateFranchiseFee,
|
|
15
17
|
calculatePartnerDiscount,
|
|
16
18
|
calculatePartnerEconomics,
|
|
19
|
+
PUBLIC_OFERTA,
|
|
17
20
|
} from './catalog.js';
|
|
18
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
LICENSE_PROTOCOL,
|
|
23
|
+
LICENSE_FILE_PROTOCOL,
|
|
24
|
+
OWNER_TOKEN_PREFIX,
|
|
25
|
+
ODANT_AUTHORITY_PUBLIC_KEY,
|
|
26
|
+
ODANT_BACKUP_PUBLIC_KEY,
|
|
27
|
+
TRUSTED_AUTHORITY_PUBLIC_KEYS,
|
|
28
|
+
resolveTrustedAuthorityKeys,
|
|
29
|
+
loadOrCreateAuthorityKeys,
|
|
30
|
+
canonicalLicenseString,
|
|
31
|
+
signCertificate,
|
|
32
|
+
verifyCertificate,
|
|
33
|
+
canonicalLicenseFileString,
|
|
34
|
+
signLicenseFile,
|
|
35
|
+
verifyLicenseFile,
|
|
36
|
+
createOwnerAccessToken,
|
|
37
|
+
parseOwnerAccessToken,
|
|
38
|
+
verifyOwnerAccessToken,
|
|
39
|
+
} from './crypto.js';
|
|
19
40
|
export { LicenseStore } from './store.js';
|
|
20
41
|
export { YooKassaService } from './yookassa.js';
|
|
21
42
|
export { SupportBotsHub } from './bots.js';
|