Интеграция продукта
на главнуюПорядок действий для разработчика продукта, который должен проверять лицензию этого issuer. Базовый URL: https://<сервер>
Шаги
- 1. Получить ключ активации
Вендор выпускает лицензию в админке и передаёт одноразовый ключ активации или файл .lic (офлайн-проверка без heartbeat).
- 2. Сформировать идентификаторы
instance_id — стабильный UUID установки. fingerprint — хэш стабильных признаков окружения (hostname + machine-id + путь). Оба сохраняются на диск и не меняются при рестарте.
- 3. Активировать
POST /api/public/license-activate. В ответ: license_jws, hmac_secret (выдаётся один раз), heartbeat_interval_hours.
- 4. Сохранить локально
Хранить license_jws и sha256(hmac_secret) в hex — это ключ подписи запросов. Вне репозитория, вне логов, с ограниченными правами доступа.
- 5. Проверять локально при старте
Подпись Ed25519 по ключу из JWKS, nbf/exp с допуском ±24 ч, status, binding.fingerprint, jti против списка отозванных, защита от отката часов. Сеть не нужна.
- 6. Heartbeat по расписанию
POST /api/public/license-heartbeat каждые heartbeat_interval_hours с джиттером ±10 %. В ответ — свежий JWS, revoked_jtis, warnings. При ошибке — backoff 60 с … 1 ч и состояние stale, без мгновенной блокировки.
- 7. Отчёт о потреблении
POST /api/public/license-usage-report с period (YYYY-MM или YYYY-MM-DD) и metrics. Сервер хранит максимум по метрике — ретрай безопасен.
- 8. Применять права
Модули и лимиты — только из подписанного payload: hasModule(module) и checkLimit(metric, value). Состояния: valid, grace, stale, expired, invalid.
- 9. Эксплуатация
Перенос окружения: сброс привязки + новый ключ. Приостановка/отзыв приходят следующим heartbeat. Ротация ключей подписи прозрачна — клиент только обновляет JWKS.
Кто формирует идентификаторы и когда их узнаёт сервер
Сервер не знает заранее, на какой машине будет работать продукт. При выпуске лицензии привязка пуста; её закрепляет первая успешная активация (trust on first use).
| выпуск лицензии | issuer: права, срок, режим привязки, одноразовый ключ активации |
| передача заказчику | только ключ LIC-XXXX-… — окружение ещё неизвестно |
| установка продукта | продукт: instance_id (UUID установки) + хэши компонентов |
| активация | сервер впервые видит хэши и закрепляет их за лицензией |
| heartbeat | сервер сверяет хэши по весам: ok / drift / mismatch |
Отпечаток составной: каждый компонент хэшируется отдельно и имеет вес — machine_id 40, os 15, cpu 15, hostname 15, install_path 15, порог 60 из 100. Сырые значения на сервер не уходят. Совпало ≥ 60, но что-то изменилось → drift: heartbeat проходит, приходит warning BINDING_DRIFT, администратор видит событие. Меньше 60 при strict → BINDING_MISMATCH (403) и экран повторной активации: перенос на другую машину меняет machine_id (40) и минимум один компонент. Легальный переезд — кнопка «сбросить привязку» в «Инсталляциях».
Сбор instance_id и компонентов окружения
// Собирается ПРИ КАЖДОМ старте продукта, не кэшируется.
import { createHash, randomUUID } from "node:crypto";
import { hostname, platform, release, cpus } from "node:os";
import { readFileSync } from "node:fs";
const h = (name: string, value: string) =>
createHash("sha256").update(name + ":" + value.trim().toLowerCase()).digest("hex").slice(0, 32);
function machineId(): string {
if (process.platform === "linux") return readFileSync("/etc/machine-id", "utf8");
if (process.platform === "darwin") return runIoreg(); // IOPlatformUUID
return readRegistry(); // HKLM\...\Cryptography\MachineGuid
}
export function collectComponents() {
return {
machine_id: h("machine_id", machineId()),
os: h("os", process.platform + "-" + release()),
cpu: h("cpu", cpus()[0].model + "x" + cpus().length),
hostname: h("hostname", hostname()),
install_path: h("install_path", process.cwd()),
};
}
// instance_id — один раз при установке, дальше только читаем из своего конфига
export const instanceId = config.instanceId ?? (config.instanceId = randomUUID());Порядок действий в продукте
Три слоя: экран активации (единственный экран без лицензии), локальное хранилище состояния и фоновый планировщик heartbeat. Ниже — референсная реализация.
запуск продукта
│
├─ нет локальной лицензии ──► ЭКРАН АКТИВАЦИИ (ключ / файл .lic)
│ │ успех: сохранить JWS + hmac-ключ
│ ▼
└─ лицензия есть ──► ЛОКАЛЬНАЯ ПРОВЕРКА (подпись, nbf/exp, fingerprint, отзыв)
│
valid / grace ───┴─── stale ──► продукт работает + баннер
│ (мягкая деградация)
▼
ПРОДУКТ РАБОТАЕТ ──► планировщик heartbeat (интервал политики
│ + джиттер ±10 %, backoff при ошибках)
▼
expired / invalid ──► экран блокировки + повторная активация1. Хранилище лицензии
// license-store.ts — единственное место, где лежит состояние лицензии
type LicenseState = {
instance_id: string; // стабильный UUID установки
fingerprint: string; // sha256(hostname + machine-id + путь)
license_jws: string; // обновляется ответом heartbeat
hmac_key: string; // sha256(hmac_secret), выдаётся один раз
last_heartbeat_at: number | null;
revoked_jtis: string[];
clock_watermark: number; // защита от отката часов
};
export function readState(): LicenseState | null { /* файл в каталоге установки */ }
export function writeState(next: LicenseState): void { /* права 600, вне логов */ }
export function clearState(): void { /* «сбросить активацию» */ }2. Экран активации
// экран активации: единственный доступный экран без лицензии
async function onActivate(key: string) {
const instance_id = ensureInstanceId(); // сохраняется один раз
const fingerprint = computeFingerprint();
const res = await fetch("https://<сервер>/api/public/license-activate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ activation_key: key.trim().toUpperCase(), instance_id, fingerprint, app_version: APP_VERSION }),
});
const payload = await res.json();
if (!payload.ok) return showError(payload.error.code); // INVALID_KEY, KEY_ALREADY_USED…
writeState({
instance_id,
fingerprint,
license_jws: payload.data.license_jws,
hmac_key: sha256Hex(payload.data.hmac_secret), // секрет больше не выдадут
last_heartbeat_at: null,
revoked_jtis: [],
clock_watermark: Math.floor(Date.now() / 1000),
});
await restartApp(); // дальше — обычный путь bootstrapLicense()
}3. Старт продукта: bootstrapLicense()
// вызывается один раз при старте продукта, до отрисовки основного интерфейса
export async function bootstrapLicense() {
const state = readState();
if (!state) return { screen: "activation" as const };
const client = await LicenseClient.init({
license: state.license_jws,
instanceId: state.instance_id,
hmacKey: state.hmac_key,
fingerprint: state.fingerprint, // сверяется с binding.fingerprint
baseUrl: "https://<сервер>",
});
const status = client.getStatus(); // проверка полностью локальная, сеть не нужна
startHeartbeatLoop(client); // планировщик — уже после старта UI
if (status.state === "invalid" || status.state === "expired") {
return { screen: "blocked" as const, client, status };
}
return { screen: "app" as const, client, status }; // valid | grace | stale
}4. Планировщик heartbeat
// планировщик: интервал из политики + джиттер ±10 %, backoff при ошибках
function startHeartbeatLoop(client: LicenseClient) {
let backoffMs = 0;
const tick = async () => {
const status = await client.forceHeartbeat(collectMetrics()); // { seats: 12, ... }
backoffMs = status.state === "stale" ? Math.min(Math.max(backoffMs * 2, 60_000), 3_600_000) : 0;
schedule();
};
const schedule = () => {
const hours = client.getStatus().payload?.policy?.heartbeat_interval_hours ?? 24;
const base = backoffMs || hours * 3_600_000;
const delay = Math.max(60_000, base + base * (Math.random() * 0.2 - 0.1));
setTimeout(() => void tick(), delay);
};
schedule();
// дополнительно: при появлении сети, выходе из сна и по кнопке «Проверить лицензию»
}5. Guard: активация / блокировка / продукт
// guard решает, что монтировать; ниже него код продукта не знает про лицензии
function LicenseGuard({ children }) {
const [boot, setBoot] = useState(null);
useEffect(() => { void bootstrapLicense().then(setBoot); }, []);
if (!boot) return <Splash />;
if (boot.screen === "activation") return <ActivationScreen />;
if (boot.screen === "blocked") return <BlockedScreen reason={boot.status.reason} />;
return (
<LicenseProvider client={boot.client}>
{boot.status.state === "grace" && <Banner>Срок лицензии истёк, идёт льготный период</Banner>}
{boot.status.state === "stale" && <Banner>Нет связи с сервером лицензий</Banner>}
{children}
</LicenseProvider>
);
}Активация: запрос
const res = await fetch("https://<сервер>/api/public/license-activate", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
activation_key: key, // от вендора
instance_id: instanceId, // стабильный UUID установки
fingerprint, // sha256(hostname + machine-id + path)
app_version: "1.4.2",
}),
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error.code);
// сохранить: payload.data.license_jws
// сохранить: sha256(payload.data.hmac_secret) — ключ подписиПодпись и heartbeat
import { createHash, createHmac, randomBytes } from "node:crypto";
const hmacKey = createHash("sha256").update(hmacSecret).digest("hex");
function headersFor(body: string) {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString("hex");
const signature = createHmac("sha256", hmacKey)
.update(`${timestamp}.${nonce}.${body}`)
.digest("hex");
return {
"content-type": "application/json",
"x-instance-id": instanceId,
"x-timestamp": timestamp,
"x-nonce": nonce,
"x-signature": signature,
};
}
const body = JSON.stringify({ app_version: "1.4.2", metrics: { seats: 12 } });
const res = await fetch("https://<сервер>/api/public/license-heartbeat", {
method: "POST",
headers: headersFor(body),
body, // подписывается ровно эта строка
});Локальная проверка JWS
import { ed25519 } from "@noble/curves/ed25519.js";
const { keys } = await (await fetch("https://<сервер>/api/public/license-jwks")).json();
const [h, p, s] = jws.split(".");
const header = JSON.parse(atob(h.replace(/-/g, "+").replace(/_/g, "/")));
const payload = JSON.parse(atob(p.replace(/-/g, "+").replace(/_/g, "/")));
const key = keys.find((k) => k.kid === header.kid); // неизвестный kid → invalid
const ok = ed25519.verify(b64urlToBytes(s), utf8(`${h}.${p}`), b64urlToBytes(key.x));
// далее: nbf/exp (±24 ч), payload.status, binding.fingerprint,
// jti не в revoked_jtis, водяной знак времени против отката часов
// exp < now < exp + grace_days*86400 → состояние graceПрименение прав
const status = client.getStatus(); // valid | grace | stale | expired | invalid
if (!client.hasModule("reports")) hideReports();
const { allowed, limit } = client.checkLimit("seats", activeUsers);
if (!allowed) blockNewUser(`Лимит мест: ${limit}`);Изменение прав на лету
Ключ активации расходуется один раз — он привязывает установку. Расширение и урезание прав вендор делает на сервере: выпускается новая версия лицензии, предыдущий jti отзывается как superseded, продукт получает свежий подписанный payload на heartbeat. Повторно вводить ключ не нужно.
| срок действия | новый ключ: нет | на следующем heartbeat |
| модули | новый ключ: нет | на следующем heartbeat |
| лимиты (больше или меньше) | новый ключ: нет | на следующем heartbeat |
| план / edition | новый ключ: нет | на следующем heartbeat |
| приостановка, возобновление, отзыв | новый ключ: нет | на следующем heartbeat |
| переезд на другое окружение | новый ключ: да, после сброса привязки | после повторной активации |
Немедленное обновление прав и понижение лимитов
// кнопка «Обновить лицензию»: внеплановый heartbeat, не чаще 1 раза в минуту
await client.refreshNow();
const status = client.getStatus();
// status.payload.ver — версия, status.payload.modules / limits — новые права
// понижение лимитов: данные не удаляем, запрещаем рост
const over = client.exceededLimits({ seats: currentSeats, projects: currentProjects });
if (over.length) {
showWarning(over.map((o) => `${o.metric}: ${o.value} из ${o.limit}`).join(", "));
disableCreation(over.map((o) => o.metric));
}
// нет сети → состояние stale, действуют прежние права до конца grace_daysЗаявка на изменение лимитов с утверждением
Когда изменение прав должно пройти согласование, в админке используется заявка: черновик → на рассмотрении → утверждена и применена как v(N+1) → heartbeat → права у клиента. Отклонённая заявка ничего не меняет и остаётся в истории решений.
- Карточка лицензии → «Заявка на изменение лимитов»: модули, лимиты, план, edition, срок, обоснование.
- Предпросмотр помечает каждое изменение как расширение или урезание; лицензия пока не меняется.
- «Утвердить и применить» выпускает новую версию и отзывает прежний jti как superseded; «Отклонить» фиксирует причину.
- Блок «Проверить на клиенте» прогоняет живой heartbeat выбранной инсталляции: версия до/после, новые модули и лимиты, предупреждения, метрики поверх нового лимита.
Как защищается лицензия: ключи простым языком
Лицензия — это подписанный текст, а не шифровка. Payload (модули, лимиты, сроки) читается любым, кто откроет файл; защита в том, что любая правка payload ломает подпись. Поэтому в лицензии не должно быть секретов — только права, идентификаторы и сроки.
закрытый ключ (seed Ed25519) открытый ключ
только на сервере лицензирования вшит в продукт + отдаётся в JWKS
в серверных секретах K1 / K2 может видеть кто угодно
| подписывает | проверяет
+---------------> license_jws <-------------+Что где лежит
| закрытый seed (K1 / K2) | серверные секреты issuer | никто, кроме сервера | можно выпускать поддельные лицензии → срочная ротация |
| открытый ключ (kid + значение) | вшит в сборку продукта, дубль в JWKS | любой | ничего, он публичный по замыслу |
| license_jws | файл/хранилище установки | владелец машины | ничего: подделать нельзя, чужой fingerprint не подойдёт |
| hmac_secret | защищённое хранилище установки | только эта установка | подделка heartbeat от её имени → сброс привязки |
| instance_id | конфиг установки | не секрет | ничего |
Почему ключ не нужно «расшифровывать»
Подпись — не шифрование. Продукт ничего не расшифровывает: он берёт открытый ключ и проверяет, что подпись под этим payload сделана владельцем закрытого ключа. Из открытого ключа нельзя получить закрытый, поэтому его публичность защиту не ослабляет.
Куда в продукте вписать ключ
- Открытый ключ вшивается в сборку как pinned-значение вместе с его
kid— обычно два: текущий и предыдущий, на период ротации. - JWKS — источник новых
kid, но не единственный источник доверия: подмена DNS или TLS-перехват дадут злоумышленнику подставить свой ключ и провести поддельную лицензию. - Лицензия с неизвестным
kid— невалидна; не «докачивайте ключ на ходу». - Ключ из JWKS кэшируйте только если он совпал с pinned-набором или пришёл обновлением продукта.
hmac_secret — это другой секрет
Выдаётся один раз при активации, уникален для установки и в проверке лицензии не участвует. Его роль — подпись запросов heartbeat и usage (доказательство «это та же установка»). Сервер хранит только производное значение и повторно секрет не выдаёт: потеря или утечка лечится сбросом привязки и повторной активацией.
Как усложнить подмену прав в продукте
- Единственный источник правды — проверенный payload. Ни одного пути, где лимиты берутся из конфига, localStorage или БД продукта в обход подписи.
- Никакого отдельного «модуля лицензии», который можно вырезать: проверка вызывается там же, где бизнес-логика, — отключение должно ломать функциональность, а не снимать лимиты.
- Проверка не только на старте, но и на границах действий: создание объектов, добавление пользователей, запуск интеграций.
- Watermark времени: храните максимум наблюдавшегося времени; откат часов назад — подозрительное состояние, а не «свежая лицензия».
- Целостность локального состояния: кэш лицензии, счётчики и last_heartbeat_at подписывайте HMAC от hmac_secret — ручная правка станет заметной.
- Серверная правда сильнее локальной: heartbeat приносит свежий JWS и revoked_jtis; ограничивайте максимальный офлайн (grace → stale → блокировка).
- Что можно — считайте на своей стороне: операции в вашем облаке не «разлочить» правкой клиента.
- Честная граница: код на машине клиента разбирается. Обфускация и проверка целостности сборки повышают стоимость взлома, но не заменяют серверные проверки.
Чего не делать
- Не кладите закрытый seed или «мастер-ключ» в продукт — ни в бинарник, ни в env клиента.
- Не передавайте hmac_secret в теле запросов и не пишите его в логи.
- Не доверяйте exp только по системным часам без watermark.
- Не принимайте лицензию с неизвестным kid и не доверяйте JWKS вслепую.
- Не дублируйте права в настройках, доступных пользователю на редактирование.
Реакция продукта на состояния
| valid | полный доступ |
| grace | доступ + баннер «лицензия истекла, продлите» |
| stale | доступ + баннер «нет связи с сервером лицензий» |
| expired | только просмотр / экран продления |
| invalid | блок, показать reason (BAD_SIGNATURE, BINDING_MISMATCH, REVOKED…) |
Коды ошибок
INVALID_REQUEST, INVALID_KEY, KEY_ALREADY_USED, BINDING_MISMATCH, LICENSE_NOT_ACTIVE, LICENSE_EXPIRED, UNKNOWN_INSTANCE, BAD_SIGNATURE, CLOCK_SKEW, REPLAY_DETECTED, RATE_LIMITED, SERVER_ERROR. Формат: { ok: false, error: { code, message, details? } }. Отказ по лицензии (403/409) — показать причину; недоступность issuer (сеть, 5xx) — уйти в stale и повторить позже.