work-license-server 1.0.3 → 1.0.4

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/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
- * Рассчитать стоимость заказа (покупка базового комплекса ODANT-WORK и/или докупка годовых лицензий со скидкой от количества).
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 baseAmountRub = includeBasePak ? PAK_PRODUCT.basePriceRub : 0;
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 (includeBasePak) {
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: !!includeBasePak,
260
- basePakPriceRub: baseAmountRub,
261
- baseSeats: includeBasePak ? PAK_PRODUCT.baseSeats : 0,
262
- includedSupportYears: includeBasePak ? PAK_PRODUCT.includedSupportYears : 0,
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,70 @@ import { PAK_PRODUCT } from './catalog.js';
11
11
  export const LICENSE_PROTOCOL = 'WORK-LIC-v1';
12
12
 
13
13
  /**
14
- * Загрузить или сгенерировать ключевую пару Ed25519 центра лицензирования.
14
+ * Официальные публичные ключи центра сертификации Правообладателя ODANT (Ed25519 SPKI DER base64).
15
+ * Вшиты в клиентский код odant-work; закрытые ключи хранятся исключительно на боевом сервере work.odant.org
16
+ * и холодном резервном носителе Правообладателя (никогда не публикуются в npm и репозиториях).
15
17
  */
16
- export function loadOrCreateAuthorityKeys(keyFilePath) {
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.NODE_ENV === 'production' && !allowGenerateInProd) {
72
+ throw new Error(
73
+ 'Безопасность: в production-режиме запрещена автогенерация случайных ключей лицензирования. '
74
+ + 'Задайте секретный ключ правообладателя в переменной окружения WORK_AUTHORITY_PRIVATE_KEY.'
75
+ );
24
76
  }
77
+
25
78
  const pair = crypto.generateKeyPairSync('ed25519');
26
79
  const keys = {
27
80
  publicKey: pair.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
@@ -92,27 +145,206 @@ export function signCertificate(lic, authorityKeys) {
92
145
  }
93
146
 
94
147
  /**
95
- * Проверить подпись сертификата лицензии.
148
+ * Проверить подпись сертификата лицензии строго по доверенным ключам центра сертификации ODANT
149
+ * (поле lic.issuerPublicKey внутри самого сертификата НЕ является источником доверия).
96
150
  */
97
151
  export function verifyCertificate(lic, publicKeyB64 = '') {
98
152
  if (!lic || typeof lic !== 'object' || !lic.signature)
99
153
  return false;
100
- const pubKey = publicKeyB64 || lic.issuerPublicKey;
101
- if (!pubKey)
154
+ const trustedKeys = resolveTrustedAuthorityKeys(publicKeyB64);
155
+ const payload = canonicalLicenseString(lic);
156
+ const sigBuf = Buffer.from(String(lic.signature), 'base64');
157
+ for (const keyB64 of trustedKeys) {
158
+ try {
159
+ const pub = crypto.createPublicKey({
160
+ key: Buffer.from(String(keyB64), 'base64'),
161
+ format: 'der',
162
+ type: 'spki',
163
+ });
164
+ if (crypto.verify(null, Buffer.from(payload, 'utf8'), pub, sigBuf))
165
+ return true;
166
+ }
167
+ catch { /* try next trusted key */ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ export const LICENSE_FILE_PROTOCOL = 'WORK-LIC-FILE-v1';
173
+ export const OWNER_TOKEN_PREFIX = 'owk1';
174
+
175
+ /**
176
+ * Каноническая сериализация отдельного файла лицензии (для размещения в #system).
177
+ * kind:
178
+ * - 'base' — первичная постоянная лицензия (10 вечных пользователей + снятие блокировки агентской системы);
179
+ * - 'addon' — дополнительная временная лицензия (N конкурентных пользователей на определённый период).
180
+ */
181
+ export function canonicalLicenseFileString(fileObj = {}) {
182
+ const clean = {
183
+ protocol: fileObj.protocol || LICENSE_FILE_PROTOCOL,
184
+ fileId: String(fileObj.fileId || ''),
185
+ kind: String(fileObj.kind || 'base'), // 'base' | 'addon'
186
+ licenseId: String(fileObj.licenseId || ''),
187
+ ownerAccountId: String(fileObj.ownerAccountId || ''),
188
+ ownerEmail: String(fileObj.ownerEmail || '').toLowerCase(),
189
+ issuer: String(fileObj.issuer || 'work.odant.org'),
190
+ issuedAt: Number(fileObj.issuedAt || 0),
191
+ expiresAt: fileObj.perpetual ? null : Number(fileObj.expiresAt || 0),
192
+ perpetual: Boolean(fileObj.perpetual),
193
+ seats: Number(fileObj.seats || 0),
194
+ years: Number(fileObj.years || (fileObj.perpetual ? 0 : 1)),
195
+ tier: String(fileObj.tier || 'enterprise').toLowerCase(),
196
+ aiAgentsEnabled: fileObj.aiAgentsEnabled !== false,
197
+ orderId: String(fileObj.orderId || ''),
198
+ hardware: {
199
+ hwid: String(fileObj.hardware?.hwid || ''),
200
+ nodeFingerprint: String(fileObj.hardware?.nodeFingerprint || ''),
201
+ sku: String(fileObj.hardware?.sku || PAK_PRODUCT.sku),
202
+ },
203
+ customer: {
204
+ name: String(fileObj.customer?.name || ''),
205
+ inn: String(fileObj.customer?.inn || ''),
206
+ email: String(fileObj.customer?.email || ''),
207
+ },
208
+ };
209
+ return JSON.stringify(clean);
210
+ }
211
+
212
+ /**
213
+ * Подписать отдельный файл лицензии (base или addon) закрытым ключом Ed25519 центра сертификации.
214
+ */
215
+ export function signLicenseFile(fileObj, authorityKeys) {
216
+ const priv = crypto.createPrivateKey({
217
+ key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
218
+ format: 'der',
219
+ type: 'pkcs8',
220
+ });
221
+ const payload = canonicalLicenseFileString(fileObj);
222
+ const signature = crypto.sign(null, Buffer.from(payload, 'utf8'), priv).toString('base64');
223
+ return {
224
+ ...fileObj,
225
+ protocol: fileObj.protocol || LICENSE_FILE_PROTOCOL,
226
+ issuer: fileObj.issuer || 'work.odant.org',
227
+ issuerPublicKey: authorityKeys.publicKey,
228
+ signature,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Проверить подпись отдельного файла лицензии Ed25519 строго по доверенным ключам центра сертификации ODANT
234
+ * (поле fileObj.issuerPublicKey внутри файла НЕ является источником доверия).
235
+ */
236
+ export function verifyLicenseFile(fileObj, publicKeyB64 = '') {
237
+ if (!fileObj || typeof fileObj !== 'object' || !fileObj.signature)
102
238
  return false;
103
- try {
104
- const pub = crypto.createPublicKey({
105
- key: Buffer.from(String(pubKey), 'base64'),
239
+ const trustedKeys = resolveTrustedAuthorityKeys(publicKeyB64);
240
+ const payload = canonicalLicenseFileString(fileObj);
241
+ const sigBuf = Buffer.from(String(fileObj.signature), 'base64');
242
+ for (const keyB64 of trustedKeys) {
243
+ try {
244
+ const pub = crypto.createPublicKey({
245
+ key: Buffer.from(String(keyB64), 'base64'),
246
+ format: 'der',
247
+ type: 'spki',
248
+ });
249
+ if (crypto.verify(null, Buffer.from(payload, 'utf8'), pub, sigBuf))
250
+ return true;
251
+ }
252
+ catch { /* try next trusted key */ }
253
+ }
254
+ return false;
255
+ }
256
+
257
+ /**
258
+ * Сгенерировать подписанный токен владельца (owk1.<payload_b64url>.<sig_b64url>),
259
+ * содержащий URL сервера поддержки (hubUrl), ID аккаунта владельца и секретный ключ для доступа локальной системы к ЛК.
260
+ */
261
+ export function createOwnerAccessToken({
262
+ hubUrl = 'https://work.odant.org',
263
+ accountId = '',
264
+ email = '',
265
+ tokenId = '',
266
+ secret = '',
267
+ issuedAt = Date.now(),
268
+ } = {}, authorityKeys) {
269
+ const payloadObj = {
270
+ v: 1,
271
+ hubUrl: String(hubUrl || 'https://work.odant.org').replace(/\/+$/, ''),
272
+ accountId: String(accountId || ''),
273
+ email: String(email || '').toLowerCase(),
274
+ tokenId: String(tokenId || ('TOK-' + crypto.randomBytes(4).toString('hex').toUpperCase())),
275
+ secret: String(secret || crypto.randomBytes(16).toString('hex')),
276
+ issuedAt: Number(issuedAt || Date.now()),
277
+ issuerPublicKey: authorityKeys?.publicKey || '',
278
+ };
279
+ const payloadB64 = Buffer.from(JSON.stringify(payloadObj), 'utf8').toString('base64url');
280
+ let sigB64 = '';
281
+ if (authorityKeys?.privateKey) {
282
+ const priv = crypto.createPrivateKey({
283
+ key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
106
284
  format: 'der',
107
- type: 'spki',
285
+ type: 'pkcs8',
108
286
  });
109
- const payload = canonicalLicenseString(lic);
110
- return crypto.verify(
111
- null,
112
- Buffer.from(payload, 'utf8'),
113
- pub,
114
- Buffer.from(String(lic.signature), 'base64')
115
- );
287
+ sigB64 = crypto.sign(null, Buffer.from(`${OWNER_TOKEN_PREFIX}.${payloadB64}`, 'utf8'), priv).toString('base64url');
288
+ }
289
+ return {
290
+ token: `${OWNER_TOKEN_PREFIX}.${payloadB64}.${sigB64}`,
291
+ payload: payloadObj,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Распарсить токен владельца (owk1.<payload_b64url>.<sig_b64url>) и извлечь зашитый URL сервера поддержки и реквизиты.
297
+ */
298
+ export function parseOwnerAccessToken(tokenStr = '') {
299
+ const raw = String(tokenStr || '').trim();
300
+ const parts = raw.split('.');
301
+ if (parts.length !== 3 || parts[0] !== OWNER_TOKEN_PREFIX) {
302
+ throw new Error('Некорректный формат токена владельца (ожидается owk1.<payload>.<signature>)');
303
+ }
304
+ try {
305
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
306
+ const payload = JSON.parse(json);
307
+ if (!payload?.hubUrl || !payload?.accountId || !payload?.tokenId) {
308
+ throw new Error('В токене отсутствуют обязательные поля hubUrl / accountId / tokenId');
309
+ }
310
+ return {
311
+ token: raw,
312
+ prefix: parts[0],
313
+ payloadB64: parts[1],
314
+ signatureB64: parts[2],
315
+ payload,
316
+ };
317
+ }
318
+ catch (err) {
319
+ throw new Error(`Не удалось декодировать токен владельца: ${err?.message || err}`);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Проверить криптографическую подпись Ed25519 токена владельца строго по доверенным ключам центра сертификации ODANT
325
+ * (поле payload.issuerPublicKey внутри токена НЕ является источником доверия).
326
+ */
327
+ export function verifyOwnerAccessToken(tokenStr = '', trustedPublicKeyB64 = '') {
328
+ try {
329
+ const parsed = parseOwnerAccessToken(tokenStr);
330
+ if (!parsed.signatureB64)
331
+ return false;
332
+ const trustedKeys = resolveTrustedAuthorityKeys(trustedPublicKeyB64);
333
+ const signedData = Buffer.from(`${OWNER_TOKEN_PREFIX}.${parsed.payloadB64}`, 'utf8');
334
+ const sigBuf = Buffer.from(parsed.signatureB64, 'base64url');
335
+ for (const keyB64 of trustedKeys) {
336
+ try {
337
+ const pub = crypto.createPublicKey({
338
+ key: Buffer.from(String(keyB64), 'base64'),
339
+ format: 'der',
340
+ type: 'spki',
341
+ });
342
+ if (crypto.verify(null, signedData, pub, sigBuf))
343
+ return true;
344
+ }
345
+ catch { /* try next trusted key */ }
346
+ }
347
+ return false;
116
348
  }
117
349
  catch {
118
350
  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 { LICENSE_PROTOCOL, loadOrCreateAuthorityKeys, canonicalLicenseString, signCertificate, verifyCertificate } from './crypto.js';
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';