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():
То есть: сортировка ключей, без пробелов (","/":"), 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¶
Каноническая строка (_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)¶
- Ключ вендора есть? Нет →
no_vendor_key. - Структура: dict с
payloadиsig? Нет →malformed. Base64sigдекодируется (validate=True)? Нет →bad_signature_encoding. - Подпись над каноническим payload валидна (
rsa_verify.verify)? Нет →bad_signature. - Срок: если
expiresзадан и не парсится →bad_expires; еслиnow > expires→expired.nowинъектируем (по умолчаниюtimeutil.utcnow()). - Привязка к железу:
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=False→True(всё включено);- иначе при невалидной лицензии →
False; - иначе
modulesпуст →True(полная лицензия), либоname in modules. camera_allowed(current_count):enforce=Falseили невалидная лицензия →True;- иначе
max_cameras is None→True, иначе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.
Целевой протокол:
- Тариф «бессрочно офлайн».
required=false→ heartbeat не выполняется; приставка остаётся валидной локально (см. §6). Для air-gap. - Тариф с heartbeat.
required=true: приставка периодически (например, раз в сутки) обращается кurl, предъявляяlicense_idи текущий hardware fingerprint, и получает подписанный ответ (та же RSA-PKCS1v15-SHA256-схема, тот же публичный ключ) с актуальным статусом (active / revoked) и, опционально, обновлённымexpires. - Grace period. Сетевая недоступность сервера не ломает работу: пока с
момента последнего успешного heartbeat прошло ≤
grace_days, приставка работает по последнему известному валидному статусу (fail-open к сети). - Time-bomb / отзыв. Если связь восстановлена и сервер вернул
revoked, либо grace period истёк без единого успешного heartbeat, гейт переходит в невалидное состояние (поведение определяетсяlicense_enforce). Это даёт онлайновый отзыв, не теряя офлайновой устойчивости.
Состояние heartbeat (timestamp последнего успеха) хранится локально; оно не часть подписанного payload и не должно влиять на офлайновую проверку подписи.
8. Процесс выпуска лицензии¶
Приватный ключ не покидает наш off-box инструментарий выпуска. Поток:
- Снять fingerprint приставки. На целевом железе (или из присланного
заказчиком вывода) собрать
fingerprint.collect()и вычислить digest по выбраннымhardware_fields(fingerprint.fingerprint(fields, components)). - Сформировать
payload—license_id,customer,issued,expires,max_cameras,modules(тариф),hardware(digest из шага 1),hardware_fields,heartbeat. - Подписать каноническую форму payload (§2.1) приватным RSA-ключом, схема
PKCS#1 v1.5 / SHA-256; результат — base64 в поле
sig. - Доставить
license.jsonна приставку (license_path), убедиться, что в окружении прописан соответствующий публичный ключ (IRIS_LICENSE_PUBKEY_N/E), включитьlicense_enforce=True. - Проверить на приставке:
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. К механизму лицензирования приставки это не относится, но должно быть закрыто до коммерческого выпуска.