Skip to content

Iris VMS — спецификация лицензирования (выпуск, проверка, опциональный heartbeat)

Внутренний инженерный документ. Описывает дизайн подсистемы лицензирования аппаратного продукта Iris VMS и должен точно соответствовать коду в app/licensing/. При расхождении кода и этого документа — исправляется тот, кто неправ; спека не должна «опережать» реализацию без явной пометки (планируется).

1. Контекст и принципы

Iris поставляется как аппаратный модуль (железо + ПО) заказчикам на рынках РФ/СНГ и международных. Подсистема лицензирования — это часть защиты продукта (WS3 плана аппаратной защиты). Базовые принципы:

  • Offline-first. Лицензия — это самодостаточный подписанный JSON-файл. Для проверки приставке не нужен доступ в интернет и не нужна асимметричная криптобиблиотека: проверка подписи — это pow() (stdlib) плюс детерминированная проверка PKCS#1 v1.5. Это сохраняет runtime-образ без лишних зависимостей и совместимо с air-gap-площадками.
  • Fail-open по умолчанию, opt-in принуждение. Пакет app/licensing/ сегодня поставляется инертным: настройка license_enforce по умолчанию False, поэтому неактивированный/dev-бокс работает как раньше — ничего не гейтится, бут не блокируется. Принуждение включается осознанно, для конкретной поставки.
  • Приватный ключ — вне приставки. Подпись лицензий выполняется только нашим инструментарием выпуска нашим приватным RSA-ключом. На приставке живёт только публичный ключ и только проверка.
  • Никогда не падать на плохой лицензии. Слой проверки не бросает исключений — он возвращает невалидный LicenseStatus; вызывающий код решает, принуждать ли.

2. Формат лицензии (подписанный JSON)

Файл лицензии (по умолчанию data/license.json, настройка license_path):

{
  "payload": {
    "license_id": "…",
    "customer": "…",
    "issued":  "2026-06-30T00:00:00",
    "expires": "2027-06-30T00:00:00",
    "max_cameras": 16,
    "modules": ["faces", "reid", "audit", "audio"],
    "hardware": "<sha256 hex>",
    "hardware_fields": ["gpu", "machine_id", "product_uuid"],
    "heartbeat": {"required": false, "grace_days": 90, "url": null}
  },
  "sig": "<base64 RSA-PKCS1v15-SHA256 над канонической формой payload>"
}

Поля payload:

Поле Тип Семантика
license_id строка Идентификатор лицензии (для учёта/отзыва).
customer строка Заказчик; показывается в UI/summary.
issued ISO-8601 Дата выпуска (информативно).
expires ISO-8601 / отсутствует Срок действия. Отсутствие/пусто = бессрочно.
max_cameras int / null Лимит одновременных камер. null = без лимита.
modules список строк Лицензированные модули. Пустой список = все модули.
hardware sha256 hex / null Привязка к железу. null/пусто = непривязанная (dev/eval) лицензия.
hardware_fields список строк Какие поля fingerprint участвуют в привязке. По умолчанию gpu, machine_id, product_uuid.
heartbeat объект Опциональный онлайн-heartbeat (см. §7).

sig — base64 от RSA-PKCS#1 v1.5 (SHA-256) подписи над канонической сериализацией payload.

2.1 Каноническая форма payload

То, что подписывается и проверяется, — байты, возвращаемые license.canonical_payload():

json.dumps(payload, sort_keys=True, separators=(",", ":"),
           ensure_ascii=False).encode("utf-8")

То есть: сортировка ключей, без пробелов (","/":"), ensure_ascii=False, кодировка UTF-8. Инструмент выпуска и приставка обязаны строить эти байты идентично, иначе подпись не сойдётся.

3. Схема подписи (RSA-PKCS#1 v1.5, SHA-256)

Реализация проверки — app/licensing/rsa_verify.py, чистый stdlib:

  • DER-префикс DigestInfo для SHA-256 зафиксирован: 3031300d060960864801650304020105000420 (RFC 8017 §9.2).
  • pkcs1_encode(message, k) строит EM: EM = 0x00 || 0x01 || PS(0xFF…) || 0x00 || DigestInfo || H, где H = SHA256(message), k = размер модуля в байтах. Возвращает None, если модуля не хватает на обязательные ≥8 байт паддинга.
  • verify(message, signature, n, e=65537):
  • k = (n.bit_length() + 7) // 8; отвергает, если len(signature) != k;
  • s = int.from_bytes(signature, "big"); отвергает, если s >= n;
  • em = pow(s, e, n).to_bytes(k, "big");
  • сравнение с pkcs1_encode(message, k) через hmac.compare_digest (константное время на финальном сравнении).

Подпись (off-box) выполняется настоящей библиотекой в нашем инструментарии выпуска; pkcs1_encode намеренно общий с тестовым подписантом, чтобы обе стороны согласовали точную раскладку EM-байтов.

3.1 Где живёт публичный ключ вендора

Резолвинг ключа — license._load_pubkey(). Приоритет: явный аргумент pubkey, затем окружение:

  • IRIS_LICENSE_PUBKEY_N — модуль n (десятичный или 0x-hex; парсится int(n_raw, 0));
  • IRIS_LICENSE_PUBKEY_E — экспонента e, по умолчанию 65537.

Если IRIS_LICENSE_PUBKEY_N пуст или не парсится — ключа нет, и verify_license возвращает LicenseStatus(False, "no_vendor_key") (нет ключа → неактивировано/ пермиссивно на уровне гейта, см. §5).

4. Привязка к железу (hardware fingerprint)

Реализация — app/licensing/fingerprint.py, тоже только stdlib. Fingerprint — это SHA-256 над детерминированной строкой из выбранного упорядоченного подмножества стабильных идентификаторов железа. Лицензия с непустым hardware не запустится на другом железе (например, на склонированном диске/образе).

Доступные компоненты (collect()), все best-effort, по отдельности мокаемые, ничего не бросает на отсутствии железа:

Поле Источник Примечание
gpu nvidia-smi --query-gpu=uuid Список UUID GPU (GPU-…), отсортирован; пусто без GPU/драйвера. Subprocess с жёстким timeout (по умолч. 5 с) — не вешает бут.
machine_id /etc/machine-id или /var/lib/dbus/machine-id systemd/D-Bus machine-id, неизменен на инсталляцию ОС.
product_uuid /sys/class/dmi/id/product_uuid DMI/SMBIOS UUID; часто читается только root.
board_serial /sys/class/dmi/id/board_serial DMI serial платы; обычно root-only; вторичный сигнал.
mac uuid.getnode() Отвергается, если выглядит рандомизированным/locally-administered (бит (node>>40)&1).

Поля по умолчанию (DEFAULT_FIELDS): ("gpu", "machine_id", "product_uuid") — стабильны через перезагрузки и обновления ОС, не ломаются добавлением второй сетевой карты (в отличие от единственного MAC). Приставка без дискретного GPU может привязываться только к machine_id/product_uuid.

4.1 Вычисление digest

fingerprint(fields=DEFAULT_FIELDS, components=None) -> sha256_hex

Каноническая строка (_canonical): для каждого поля в порядке fields строится "<field>=<value>" (списки/кортежи склеиваются через ,; отсутствующее значение → пустая строка), части соединяются через |. Затем SHA-256, hex. components можно передать (из collect()), чтобы не перепроверять железо или чтобы вычислить digest для другого бокса при выпуске лицензии.

matches(expected, fields, components)True, если expected непустой и равен fingerprint(fields, components).

5. Семантика проверки и feature-gate

5.1 Порядок проверки лицензии (license.verify_license)

  1. Ключ вендора есть? Нет → no_vendor_key.
  2. Структура: dict с payload и sig? Нет → malformed. Base64 sig декодируется (validate=True)? Нет → bad_signature_encoding.
  3. Подпись над каноническим payload валидна (rsa_verify.verify)? Нет → bad_signature.
  4. Срок: если expires задан и не парсится → bad_expires; если now > expiresexpired. now инъектируем (по умолчанию timeutil.utcnow()).
  5. Привязка к железу: hardware пусто/null → непривязанная (dev/eval), bound=False. Иначе, при enforce_hardware=True, берутся hardware_fields (или DEFAULT_FIELDS) и проверяется _fp.matches(...); несовпадение → hardware_mismatch.

Успех → LicenseStatus(valid=True, reason="ok", modules=frozenset(modules), max_cameras=…, expires=…, hardware_bound=bound).

Всё инъектируемо (pubkey, now, components, enforce_hardware) — модуль полностью юнит-тестируется без реального ключа и железа.

5.2 Entitlements / гейт (app/licensing/gate.py)

Entitlements(enforce, status) — fail-open по умолчанию:

  • module_enabled(name):
  • enforce=FalseTrue (всё включено);
  • иначе при невалидной лицензии → False;
  • иначе modules пуст → True (полная лицензия), либо name in modules.
  • camera_allowed(current_count):
  • enforce=False или невалидная лицензия → True;
  • иначе max_cameras is NoneTrue, иначе current_count <= max_cameras.

Известные лицензируемые модули (KNOWN_MODULES, соответствуют тарифным уровням): faces, reid, audit, audio, subtitles, ptz, analytics.

load_entitlements(license_path, enforce, **verify_kw) загружает и проверяет файл (никогда не бросает). Важно: при enforce=False лицензия всё равно парсится — для отображения статуса/summary в UI — но гейт остаётся пермиссивным.

summary() возвращает: enforce, licensed, reason, customer, modules (отсорти- рованные или "all"), max_cameras, expires (ISO или null), hardware_bound.

5.3 Включение принуждения (opt-in)

Настройки (app/config.py):

  • license_enforce: bool = False — главный выключатель принуждения;
  • license_path: str = "data/license.json" — путь к файлу лицензии.

Поставка по умолчанию инертна. Боевая приставка устанавливает license_enforce= True, кладёт подписанный hardware-bound файл по license_path, прописывает IRIS_LICENSE_PUBKEY_N/_E в окружение.

6. Модель Offline-first

Базовый и единственный реализованный сегодня путь проверки — полностью офлайновый: подпись → срок → железо. Этого достаточно для air-gap-площадок:

  • Бессрочно-офлайновый (air-gap) тариф. heartbeat.required=false (или heartbeat отсутствует/null-url). Лицензия проверяется локально неограниченно долго; «свежесть» ограничивается только полем expires (которое тоже может отсутствовать → бессрочная). Это рекомендуемый профиль для изолированных объектов.

Никаких телеметрии/phone-home в runtime нет; публичный интернет нужен только двум onboarding-функциям (Shodan-поиск камер, yt-dlp web-link ingest), которые отключаются настройкой offline_mode и к лицензированию отношения не имеют.

7. Опциональный heartbeat (протокол — планируется; поле уже в схеме)

Поле heartbeat уже присутствует в подписанном payload, но логика обращения к heartbeat сегодня в коде проверки не реализована (verify_license выполняет только подпись/срок/железо). Ниже — целевой дизайн; реализуется отдельной задачей и должен оставаться fail-open относительно сетевых сбоев.

Объект heartbeat:

  • required (bool) — обязателен ли периодический онлайн-чек;
  • grace_days (int) — окно толерантности к офлайну (для платной лицензии типично 30–90 дней, пример из схемы — 90; для триала — 0, см. ниже);
  • url (строка/null) — endpoint лицензионного сервера.

Триал (демо-режим). POST /trial авто-выпускает подписанную, привязанную к железу лицензию на 7 дней (стандартный недельный оценочный период; env IRIS_TRIAL_DAYS, дефолт 7 — синхронизирован с legal/EULA.md §1a и кодовым дефолтом; не поднимать без правки EULA). Триал: полный функционал (modules пуст = все), trial=true, heartbeat.required=true и grace_days=0строго онлайн: без свежего успешного heartbeat гейт закрывается сразу (grace_days=0), а до первого чек-ина — awaiting_first_heartbeat; один триал на машину (ключ — GPU UUID). После 7 дней → expired.

Целевой протокол:

  1. Тариф «бессрочно офлайн». required=false → heartbeat не выполняется; приставка остаётся валидной локально (см. §6). Для air-gap.
  2. Тариф с heartbeat. required=true: приставка периодически (например, раз в сутки) обращается к url, предъявляя license_id и текущий hardware fingerprint, и получает подписанный ответ (та же RSA-PKCS1v15-SHA256-схема, тот же публичный ключ) с актуальным статусом (active / revoked) и, опционально, обновлённым expires.
  3. Grace period. Сетевая недоступность сервера не ломает работу: пока с момента последнего успешного heartbeat прошло ≤ grace_days, приставка работает по последнему известному валидному статусу (fail-open к сети).
  4. Time-bomb / отзыв. Если связь восстановлена и сервер вернул revoked, либо grace period истёк без единого успешного heartbeat, гейт переходит в невалидное состояние (поведение определяется license_enforce). Это даёт онлайновый отзыв, не теряя офлайновой устойчивости.

Состояние heartbeat (timestamp последнего успеха) хранится локально; оно не часть подписанного payload и не должно влиять на офлайновую проверку подписи.

8. Процесс выпуска лицензии

Приватный ключ не покидает наш off-box инструментарий выпуска. Поток:

  1. Снять fingerprint приставки. На целевом железе (или из присланного заказчиком вывода) собрать fingerprint.collect() и вычислить digest по выбранным hardware_fields (fingerprint.fingerprint(fields, components)).
  2. Сформировать payloadlicense_id, customer, issued, expires, max_cameras, modules (тариф), hardware (digest из шага 1), hardware_fields, heartbeat.
  3. Подписать каноническую форму payload (§2.1) приватным RSA-ключом, схема PKCS#1 v1.5 / SHA-256; результат — base64 в поле sig.
  4. Доставить license.json на приставку (license_path), убедиться, что в окружении прописан соответствующий публичный ключ (IRIS_LICENSE_PUBKEY_N/E), включить license_enforce=True.
  5. Проверить на приставке: verify_license_file(license_path)valid=True, summary() показывает ожидаемые customer/modules/max_cameras/expires.

Тарификация по модулям (modules) и лимиту камер (max_cameras) — основной рычаг монетизации (ср. конкурентов: per-channel/per-camera модели Macroscop, Trassir, Milestone, Verkada). Пустой modules = полная лицензия.

9. Анти-тампер и подписанные обновления

  • Доверенный корень — только публичный ключ. Подделать лицензию без приватного ключа нельзя: любое изменение payload ломает подпись (bad_signature). Приватный ключ off-box.
  • Привязка к железу (§4) не даёт перенести лицензию на другой бокс; fingerprint включает иммутабельный GPU UUID + machine-id + DMI UUID.
  • Защита кода. Поставляемое ПО проходит нативную компиляцию (см. план аппаратной защиты), что повышает стоимость обхода логики гейта; верификатор намеренно мал и аудируем.
  • Секреты на приставке. Чувствительные поля шифруются at-rest (app/crypto.py, stdlib HMAC-CTR, encrypt-then-MAC; ключ из IRIS_SECRET_KEY или data/secret.key с правами 0600, никогда не в БД). Первичный контроль at-rest — полное шифрование диска LUKS.
  • Изоляция runtime. Контейнер non-root (uid 1000), cap_drop: ALL, no-new-privileges, исходники смонтированы read-only, bind только на 127.0.0.1:8120 за nginx. Источник лицензии не пишется самим контейнером.
  • Подписанные обновления ПО (планируется): та же доверенная корневая модель (RSA-подпись, проверка на приставке) — естественное расширение для образов обновлений; на текущий момент в app/licensing/ не реализовано.

Связанный риск (release-gate, отложен): лицензии моделей (YOLOv8 AGPL, InsightFace buffalo_l non-commercial, OSNet/MSMT17 research-only) при on-prem-поставке — путь ремедиации в docs/LICENSING.md. К механизму лицензирования приставки это не относится, но должно быть закрыто до коммерческого выпуска.