@nnsi/sdk-electron 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.
Files changed (3) hide show
  1. package/README.md +432 -0
  2. package/index.js +41 -0
  3. package/package.json +9 -0
package/README.md ADDED
@@ -0,0 +1,432 @@
1
+ # @nnsi/sdk-electron
2
+
3
+ Официальный SDK для Windows-приложений на Electron, которые распространяются и запускаются через **NNSI App**.
4
+
5
+ SDK проверяет короткоживущий подписанный `launch ticket`. Лаунчер получает ticket после проверки пользователя и лицензии в Supabase, а приложение проверяет его локально по публичному RSA-ключу.
6
+
7
+ > Важно: SDK не заменяет code signing, DRM и серверную авторизацию критичных операций. Клиентское приложение работает на компьютере пользователя, поэтому особо важную логику не следует полностью доверять клиенту.
8
+
9
+ ## Архитектура
10
+
11
+ ```text
12
+ Вход в NNSI App
13
+ -> Edge Function получает access token, app_id, machine_id
14
+ -> проверяет app_licenses
15
+ -> подписывает RS256 launch ticket
16
+ -> лаунчер передаёт ticket приложению через environment
17
+ -> приложение проверяет ticket через SDK
18
+ ```
19
+
20
+ Приватный RSA-ключ хранится только в Edge Function. В приложении и лаунчере находится только public key: им можно проверять подпись, но нельзя выпускать tickets.
21
+
22
+ ## Установка
23
+
24
+ Для локальной разработки добавьте в `package.json` приложения:
25
+
26
+ ```json
27
+ {
28
+ "dependencies": {
29
+ "@nnsi/sdk-electron": "file:../sdk-electron"
30
+ }
31
+ }
32
+ ```
33
+
34
+ Путь считается относительно `package.json`. Затем:
35
+
36
+ ```powershell
37
+ npm install
38
+ ```
39
+
40
+ После публикации в npm-реестре:
41
+
42
+ ```powershell
43
+ npm install @nnsi/sdk-electron
44
+ ```
45
+
46
+ В production фиксируйте точную версию пакета.
47
+
48
+ ## Быстрое подключение
49
+
50
+ Проверку нужно выполнять в главном процессе Electron, до создания основных окон:
51
+
52
+ ```js
53
+ const { app, dialog } = require('electron');
54
+ const { requireLauncher } = require('@nnsi/sdk-electron');
55
+
56
+ let nnsiContext;
57
+ try {
58
+ nnsiContext = requireLauncher({
59
+ appId: 'myapp',
60
+ publicKey: process.env.NNSI_TICKET_PUBLIC_KEY
61
+ });
62
+ } catch (error) {
63
+ dialog.showErrorBox(
64
+ 'My App',
65
+ error instanceof Error ? error.message : 'Запуск через NNSI App не подтверждён.'
66
+ );
67
+ app.quit();
68
+ return;
69
+ }
70
+
71
+ console.log('NNSI user:', nnsiContext.user_id);
72
+ // Только после этого создаём BrowserWindow.
73
+ ```
74
+
75
+ Проверки только в renderer недостаточно: основной процесс должен завершиться до создания интерфейса.
76
+
77
+ ## API
78
+
79
+ ```js
80
+ const {
81
+ requireLauncher,
82
+ getLaunchContext,
83
+ verifyTicket
84
+ } = require('@nnsi/sdk-electron');
85
+ ```
86
+
87
+ ### `requireLauncher(options)`
88
+
89
+ Читает ticket из environment и проверяет его. При ошибке выбрасывает `Error`.
90
+
91
+ ```js
92
+ const context = requireLauncher({
93
+ appId: 'tiktimer',
94
+ publicKey: process.env.NNSI_TICKET_PUBLIC_KEY
95
+ });
96
+ ```
97
+
98
+ Параметры:
99
+
100
+ | Параметр | Тип | Назначение |
101
+ |---|---|---|
102
+ | `appId` | string | ID приложения; ticket другого приложения будет отклонён |
103
+ | `publicKey` | string | RSA public key в PEM-формате |
104
+ | `issuer` | string | Издатель; по умолчанию `nnsi-app` |
105
+ | `ticketEnv` | string | Имя переменной ticket; по умолчанию `NNSI_LAUNCH_TICKET` |
106
+
107
+ ### `getLaunchContext(options)`
108
+
109
+ Псевдоним `requireLauncher`, возвращает payload проверенного ticket:
110
+
111
+ ```js
112
+ const context = getLaunchContext({
113
+ appId: 'myapp',
114
+ publicKey: PUBLIC_KEY
115
+ });
116
+ ```
117
+
118
+ ### `verifyTicket(ticket, options)`
119
+
120
+ Проверяет уже прочитанную JWT-строку. Полезно для тестов:
121
+
122
+ ```js
123
+ const context = verifyTicket(ticket, {
124
+ appId: 'myapp',
125
+ publicKey: PUBLIC_KEY
126
+ });
127
+ ```
128
+
129
+ Проверяются формат JWT, алгоритм `RS256`, подпись RSA-SHA256, `iss`, `app_id`, `exp` и `nbf`. Payload возвращается замороженным через `Object.freeze()`.
130
+
131
+ ## Environment-переменные
132
+
133
+ Лаунчер передаёт:
134
+
135
+ | Переменная | Значение |
136
+ |---|---|
137
+ | `NNSI_LAUNCH_TICKET` | Подписанный короткоживущий JWT |
138
+ | `NNSI_TICKET_PUBLIC_KEY` | Публичный RSA-ключ в PEM |
139
+
140
+ Для локального теста:
141
+
142
+ ```powershell
143
+ $env:NNSI_LAUNCH_TICKET = 'test-ticket'
144
+ $env:NNSI_TICKET_PUBLIC_KEY = (Get-Content .\keys\nnsi-public.pem -Raw)
145
+ npm start
146
+ ```
147
+
148
+ Не храните production ticket, private key или service role key в `.env`, Git, EXE или ZIP.
149
+
150
+ ## Формат launch ticket
151
+
152
+ Ticket — JWT с подписью `RS256`:
153
+
154
+ ```json
155
+ {
156
+ "iss": "nnsi-app",
157
+ "sub": "supabase-user-uuid",
158
+ "user_id": "supabase-user-uuid",
159
+ "app_id": "tiktimer",
160
+ "machine_id": "stable-machine-id",
161
+ "jti": "unique-ticket-id",
162
+ "iat": 1770000000,
163
+ "nbf": 1769999995,
164
+ "exp": 1770000300
165
+ }
166
+ ```
167
+
168
+ - `iss` — издатель;
169
+ - `sub`, `user_id` — пользователь Supabase;
170
+ - `app_id` — разрешённое приложение;
171
+ - `machine_id` — компьютер лицензии;
172
+ - `jti` — уникальный ticket;
173
+ - `iat`, `nbf`, `exp` — времена выпуска, начала и окончания действия в Unix seconds.
174
+
175
+ Текущая Edge Function выдаёт ticket на 5 минут. Перед каждым запуском лаунчер должен получать новый ticket.
176
+
177
+ ## Генерация RSA-ключей
178
+
179
+ ```powershell
180
+ New-Item -ItemType Directory -Force .\keys | Out-Null
181
+ openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out .\keys\nnsi-private.pem
182
+ openssl rsa -pubout -in .\keys\nnsi-private.pem -out .\keys\nnsi-public.pem
183
+ ```
184
+
185
+ Private key должен начинаться с:
186
+
187
+ ```text
188
+ -----BEGIN PRIVATE KEY-----
189
+ ```
190
+
191
+ Добавьте в `.gitignore`:
192
+
193
+ ```gitignore
194
+ keys/
195
+ *.pem
196
+ .env
197
+ ```
198
+
199
+ ## Supabase Edge Function
200
+
201
+ Функция находится в:
202
+
203
+ ```text
204
+ supabase/functions/issue-launch-ticket/index.ts
205
+ ```
206
+
207
+ Она:
208
+
209
+ 1. принимает Bearer access token;
210
+ 2. получает пользователя через `auth.getUser()`;
211
+ 3. принимает `app_id` и `machine_id`;
212
+ 4. ищет активную запись `public.app_licenses`;
213
+ 5. проверяет `expires_at`;
214
+ 6. подписывает ticket;
215
+ 7. пишет аудит в `public.license_launches`;
216
+ 8. возвращает ticket.
217
+
218
+ Тело запроса:
219
+
220
+ ```json
221
+ {
222
+ "app_id": "tiktimer",
223
+ "machine_id": "computer-identifier"
224
+ }
225
+ ```
226
+
227
+ Успех:
228
+
229
+ ```json
230
+ {
231
+ "ticket": "eyJ...",
232
+ "expires_at": "2026-09-05T12:00:00.000Z"
233
+ }
234
+ ```
235
+
236
+ Если лицензия не найдена, функция возвращает HTTP 403.
237
+
238
+ ### Схема и лицензия
239
+
240
+ Выполните актуальный `launcher/supabase-schema.sql`. Для теста добавьте лицензию:
241
+
242
+ ```sql
243
+ insert into public.app_licenses
244
+ (user_id, app_id, machine_id, is_active, expires_at)
245
+ values
246
+ ('USER_UUID', 'tiktimer', 'computer-identifier', true, null);
247
+ ```
248
+
249
+ `user_id` должен быть UUID текущего пользователя, а `machine_id` — тем же значением, которое отправляет лаунчер.
250
+
251
+ ### Deploy и secrets
252
+
253
+ ```powershell
254
+ supabase login
255
+ supabase link --project-ref YOUR_PROJECT_REF
256
+ supabase secrets set SUPABASE_SERVICE_ROLE_KEY="YOUR_SERVICE_ROLE_KEY"
257
+ supabase secrets set NNSI_TICKET_PRIVATE_KEY="YOUR_PRIVATE_RSA_PEM"
258
+ supabase functions deploy issue-launch-ticket
259
+ ```
260
+
261
+ Private key и service role key задаются только как secrets Edge Function. Если CLI некорректно передаёт многострочный PEM, задайте его через Supabase Dashboard.
262
+
263
+ ## Подключение лаунчера
264
+
265
+ Лаунчер вызывает Edge Function с access token текущей Supabase-сессии:
266
+
267
+ ```js
268
+ async function issueLaunchTicket({ supabaseUrl, anonKey, accessToken, appId, machineId }) {
269
+ const response = await fetch(
270
+ `${supabaseUrl}/functions/v1/issue-launch-ticket`,
271
+ {
272
+ method: 'POST',
273
+ headers: {
274
+ Authorization: `Bearer ${accessToken}`,
275
+ apikey: anonKey,
276
+ 'Content-Type': 'application/json'
277
+ },
278
+ body: JSON.stringify({ app_id: appId, machine_id: machineId })
279
+ }
280
+ );
281
+
282
+ const data = await response.json();
283
+ if (!response.ok) throw new Error(data.error || `Ticket request failed: ${response.status}`);
284
+ return data;
285
+ }
286
+ ```
287
+
288
+ Запуск дочернего процесса:
289
+
290
+ ```js
291
+ const childEnv = {
292
+ ...process.env,
293
+ NNSI_LAUNCH_TICKET: ticket,
294
+ NNSI_TICKET_PUBLIC_KEY: publicKey
295
+ };
296
+
297
+ delete childEnv.ELECTRON_RUN_AS_NODE;
298
+
299
+ spawn(executablePath, [], {
300
+ cwd: appDirectory,
301
+ env: childEnv,
302
+ windowsHide: false
303
+ });
304
+ ```
305
+
306
+ Public key лучше хранить в самом лаунчере или подписанной конфигурации, а не брать из непроверенного каталога приложения.
307
+
308
+ ## Модель лицензий
309
+
310
+ Пользователь не должен иметь публичные INSERT/UPDATE policies на `app_licenses`. Иначе он сможет выдать себе лицензию.
311
+
312
+ Рекомендуется:
313
+
314
+ - публичный доступ только к опубликованному каталогу;
315
+ - чтение лицензии через Edge Function с service role;
316
+ - изменение `is_active`, `expires_at`, `app_id` и `machine_id` только через админ-панель или защищённую серверную функцию;
317
+ - аудит запусков в `license_launches`.
318
+
319
+ Старую клиентскую логику TikTimer, которая напрямую читает или меняет `app_licenses`, нужно заменить серверным API. Не ослабляйте RLS ради совместимости.
320
+
321
+ ## Пример для TikTimer
322
+
323
+ ```js
324
+ const { app, dialog } = require('electron');
325
+ const { getLaunchContext } = require('@nnsi/sdk-electron');
326
+
327
+ let nnsiContext;
328
+ try {
329
+ nnsiContext = getLaunchContext({
330
+ appId: 'tiktimer',
331
+ publicKey: process.env.NNSI_TICKET_PUBLIC_KEY
332
+ });
333
+ } catch (error) {
334
+ dialog.showErrorBox(
335
+ 'TikTimer',
336
+ 'TikTimer можно запустить только через NNSI App.\n\n' +
337
+ (error instanceof Error ? error.message : 'Проверка не пройдена.')
338
+ );
339
+ app.quit();
340
+ return;
341
+ }
342
+
343
+ console.log('Authorized user:', nnsiContext.user_id);
344
+ // Создание BrowserWindow только здесь, после проверки.
345
+ ```
346
+
347
+ После добавления зависимости:
348
+
349
+ ```powershell
350
+ npm install
351
+ npm run build
352
+ ```
353
+
354
+ Проверьте, что SDK попал в `resources/app.asar` и включён в `build.files`.
355
+
356
+ ## Тестовый чек-лист
357
+
358
+ Проверьте все сценарии:
359
+
360
+ 1. Запуск EXE напрямую без env — отказ и отсутствие главного окна.
361
+ 2. Запуск через лаунчер с активной лицензией — успех.
362
+ 3. Неверная пара RSA-ключей — отказ.
363
+ 4. Ticket с другим `app_id` — отказ.
364
+ 5. Просроченный ticket — отказ.
365
+ 6. Удалённая или истёкшая лицензия — Edge Function возвращает 403.
366
+ 7. Перезапуск после истечения ticket — лаунчер получает новый ticket.
367
+ 8. Сборка на чистом ПК — модуль SDK находится внутри приложения.
368
+
369
+ ## Ошибки
370
+
371
+ | Сообщение | Причина |
372
+ |---|---|
373
+ | `This application can only be launched through NNSI App` | Нет `NNSI_LAUNCH_TICKET` |
374
+ | `NNSI ticket public key is not configured` | Не передан public key |
375
+ | `Invalid NNSI launch ticket signature` | Public key не соответствует private key или ticket повреждён |
376
+ | `NNSI ticket belongs to another application` | Не совпали `appId` и `app_id` |
377
+ | `NNSI launch ticket has expired` | Нужно запросить новый ticket |
378
+ | `Active license not found` | Неверны user, app, machine или состояние лицензии |
379
+ | `Cannot find module '@nnsi/sdk-electron'` | SDK не установлен или не попал в сборку |
380
+
381
+ В логах не записывайте полный ticket:
382
+
383
+ ```js
384
+ console.log({
385
+ hasTicket: Boolean(process.env.NNSI_LAUNCH_TICKET),
386
+ hasPublicKey: Boolean(process.env.NNSI_TICKET_PUBLIC_KEY)
387
+ });
388
+ ```
389
+
390
+ ## Безопасность
391
+
392
+ 1. Никогда не помещайте private key и service role key в Electron, GitHub, EXE, ZIP или renderer.
393
+ 2. Используйте HTTPS и актуальный Electron.
394
+ 3. Выдавайте короткоживущий ticket перед каждым запуском.
395
+ 4. Проверяйте `app_id` и на сервере, и в приложении.
396
+ 5. Подписывайте Windows-сборки code-signing сертификатом.
397
+ 6. После утечки private key создайте новую RSA-пару, смените secret и выпустите приложения с новым public key.
398
+ 7. Ранее опубликованные Supabase secret/service-role ключи считайте скомпрометированными и ротируйте.
399
+
400
+ SDK не может сделать локальный EXE абсолютно не взламываемым: пользователь контролирует файлы на своём компьютере. Его задача — связать официальный запуск с серверной лицензией и повысить стоимость обхода.
401
+
402
+ ## Публикация SDK
403
+
404
+ Перед публикацией:
405
+
406
+ ```powershell
407
+ node --check .\sdk-electron\index.js
408
+ node -e "require('./sdk-electron')"
409
+ ```
410
+
411
+ Увеличьте `version` в `sdk-electron/package.json`, затем:
412
+
413
+ ```powershell
414
+ cd sdk-electron
415
+ npm publish --access restricted
416
+ ```
417
+
418
+ В приложении зафиксируйте новую версию и обновите lock-файл:
419
+
420
+ ```powershell
421
+ npm install
422
+ ```
423
+
424
+ ## Файлы проекта
425
+
426
+ ```text
427
+ sdk-electron/index.js SDK и проверка RS256
428
+ sdk-electron/package.json метаданные npm-пакета
429
+ supabase/functions/issue-launch-ticket/index.ts выдача tickets
430
+ launcher/supabase-schema.sql таблицы лицензий и аудита
431
+ ```
432
+
package/index.js ADDED
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function decodeBase64Url(value) {
6
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
7
+ return Buffer.from(normalized, 'base64');
8
+ }
9
+
10
+ function readJsonPart(value) {
11
+ return JSON.parse(decodeBase64Url(value).toString('utf8'));
12
+ }
13
+
14
+ function verifyTicket(ticket, options) {
15
+ if (typeof ticket !== 'string' || ticket.split('.').length !== 3) throw new Error('NNSI launch ticket is missing or malformed');
16
+ const [encodedHeader, encodedPayload, encodedSignature] = ticket.split('.');
17
+ const header = readJsonPart(encodedHeader);
18
+ const payload = readJsonPart(encodedPayload);
19
+ if (header.alg !== 'RS256') throw new Error('Unsupported NNSI ticket algorithm');
20
+ if (!options.publicKey) throw new Error('NNSI ticket public key is not configured');
21
+ const valid = crypto.verify('RSA-SHA256', Buffer.from(`${encodedHeader}.${encodedPayload}`), options.publicKey, decodeBase64Url(encodedSignature));
22
+ if (!valid) throw new Error('Invalid NNSI launch ticket signature');
23
+ const now = Math.floor(Date.now() / 1000);
24
+ if (payload.iss !== (options.issuer || 'nnsi-app')) throw new Error('Invalid NNSI ticket issuer');
25
+ if (options.appId && payload.app_id !== options.appId) throw new Error('NNSI ticket belongs to another application');
26
+ if (!payload.exp || payload.exp <= now) throw new Error('NNSI launch ticket has expired');
27
+ if (payload.nbf && payload.nbf > now + 30) throw new Error('NNSI launch ticket is not active yet');
28
+ return Object.freeze(payload);
29
+ }
30
+
31
+ function requireLauncher(options = {}) {
32
+ const ticket = process.env[options.ticketEnv || 'NNSI_LAUNCH_TICKET'];
33
+ if (!ticket) throw new Error('This application can only be launched through NNSI App');
34
+ return verifyTicket(ticket, options);
35
+ }
36
+
37
+ function getLaunchContext(options = {}) {
38
+ return requireLauncher(options);
39
+ }
40
+
41
+ module.exports = { requireLauncher, getLaunchContext, verifyTicket };
package/package.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@nnsi/sdk-electron",
3
+ "version": "0.1.0",
4
+ "description": "Official SDK for applications launched by NNSI App",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "license": "UNLICENSED",
8
+ "engines": { "node": ">=18" }
9
+ }