Интеграция продукта

на главную

Порядок действий для разработчика продукта, который должен проверять лицензию этого issuer. Базовый URL: https://<сервер>

Шаги

  1. 1. Получить ключ активации

    Вендор выпускает лицензию в админке и передаёт одноразовый ключ активации или файл .lic (офлайн-проверка без heartbeat).

  2. 2. Сформировать идентификаторы

    instance_id — стабильный UUID установки. fingerprint — хэш стабильных признаков окружения (hostname + machine-id + путь). Оба сохраняются на диск и не меняются при рестарте.

  3. 3. Активировать

    POST /api/public/license-activate. В ответ: license_jws, hmac_secret (выдаётся один раз), heartbeat_interval_hours.

  4. 4. Сохранить локально

    Хранить license_jws и sha256(hmac_secret) в hex — это ключ подписи запросов. Вне репозитория, вне логов, с ограниченными правами доступа.

  5. 5. Проверять локально при старте

    Подпись Ed25519 по ключу из JWKS, nbf/exp с допуском ±24 ч, status, binding.fingerprint, jti против списка отозванных, защита от отката часов. Сеть не нужна.

  6. 6. Heartbeat по расписанию

    POST /api/public/license-heartbeat каждые heartbeat_interval_hours с джиттером ±10 %. В ответ — свежий JWS, revoked_jtis, warnings. При ошибке — backoff 60 с … 1 ч и состояние stale, без мгновенной блокировки.

  7. 7. Отчёт о потреблении

    POST /api/public/license-usage-report с period (YYYY-MM или YYYY-MM-DD) и metrics. Сервер хранит максимум по метрике — ретрай безопасен.

  8. 8. Применять права

    Модули и лимиты — только из подписанного payload: hasModule(module) и checkLimit(metric, value). Состояния: valid, grace, stale, expired, invalid.

  9. 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 → права у клиента. Отклонённая заявка ничего не меняет и остаётся в истории решений.

  1. Карточка лицензии → «Заявка на изменение лимитов»: модули, лимиты, план, edition, срок, обоснование.
  2. Предпросмотр помечает каждое изменение как расширение или урезание; лицензия пока не меняется.
  3. «Утвердить и применить» выпускает новую версию и отзывает прежний jti как superseded; «Отклонить» фиксирует причину.
  4. Блок «Проверить на клиенте» прогоняет живой 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 сделана владельцем закрытого ключа. Из открытого ключа нельзя получить закрытый, поэтому его публичность защиту не ослабляет.

Куда в продукте вписать ключ

hmac_secret — это другой секрет

Выдаётся один раз при активации, уникален для установки и в проверке лицензии не участвует. Его роль — подпись запросов heartbeat и usage (доказательство «это та же установка»). Сервер хранит только производное значение и повторно секрет не выдаёт: потеря или утечка лечится сбросом привязки и повторной активацией.

Как усложнить подмену прав в продукте

  1. Единственный источник правды — проверенный payload. Ни одного пути, где лимиты берутся из конфига, localStorage или БД продукта в обход подписи.
  2. Никакого отдельного «модуля лицензии», который можно вырезать: проверка вызывается там же, где бизнес-логика, — отключение должно ломать функциональность, а не снимать лимиты.
  3. Проверка не только на старте, но и на границах действий: создание объектов, добавление пользователей, запуск интеграций.
  4. Watermark времени: храните максимум наблюдавшегося времени; откат часов назад — подозрительное состояние, а не «свежая лицензия».
  5. Целостность локального состояния: кэш лицензии, счётчики и last_heartbeat_at подписывайте HMAC от hmac_secret — ручная правка станет заметной.
  6. Серверная правда сильнее локальной: heartbeat приносит свежий JWS и revoked_jtis; ограничивайте максимальный офлайн (grace → stale → блокировка).
  7. Что можно — считайте на своей стороне: операции в вашем облаке не «разлочить» правкой клиента.
  8. Честная граница: код на машине клиента разбирается. Обфускация и проверка целостности сборки повышают стоимость взлома, но не заменяют серверные проверки.

Чего не делать

Реакция продукта на состояния

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 и повторить позже.

Живой пример: демо-инстансJWKS