CertifiEd

Протокол лицензирования

Лицензия CertifiEd — самодостаточный подписанный токен. Всё, что нужно приложению для оффлайн-проверки (сроки, feature flags, лимиты), лежит внутри подписанного payload'а. Сервер при проверке не нужен.

Реализация: TokenSerializer, TokenHeader, TokenPayload, HeartbeatMarker (CertifiEd.Application/Licensing); подпись — EncryptedFileSigner (CertifiEd.Infrastructure/Signing); проверка на клиенте — CertifiEdLicenseClient (CertifiEd.Client).

Формат токена

text
base64url(header) . base64url(payload) . base64url(signature)

Три сегмента, разделённые точками. Кодировка сегментов — base64url без padding. JSON внутри сегментов сериализуется в camelCase, поля со значением null опускаются.

JSON
{  "alg": "Ed25519",  "kid": "019f6b40-0fbb-7a69-adbf-fb5f476ad4bd",  "typ": "certified-license",  "ver": 1}
ПолеТипНазначение
algstringАлгоритм подписи, всегда Ed25519
kidGUIDId подписавшего signing_key — по нему выбирается публичный ключ для проверки
typstringДискриминатор типа, certified-license
verintВерсия формата токена (сейчас 1)

Payload

JSON
{  "lic": "019f6b40-89c4-7d41-be2a-4a151a3c0063",  "key": "CFED-S7FN-RJFA-KAEY-8KZC",  "tpl": "019f6b40-0fb8-78f0-9af2-e3589de54069",  "tplV": 1,  "cfgV": 1,  "iss": "certified-api",  "sub": "019f6b40-0f9c-7efd-996b-0dadd2ed1077",  "iat": 1784210819,  "nbf": 1784210819,  "exp": 1815746819,  "maxOfflineDays": 7,  "cfg": "{\"features\":[\"export\",\"api\"],\"limits\":{\"maxSeats\":25}}"}
ПолеТипНазначение
licGUIDId лицензии
keystringЧеловекочитаемый ключ CFED-XXXX-XXXX-XXXX-XXXX
tplGUIDId шаблона
tplVintНомер версии шаблона
cfgVintВерсия конфигурации лицензии
issstringИздатель, по умолчанию certified-api
subGUIDСубъект — id компании-лицензиата
iatlongIssued at, Unix-секунды
nbflongNot valid before, Unix-секунды
explongЖёсткое истечение, Unix-секунды (license.expires_at)
maxOfflineDaysintСколько дней клиент живёт без сети — не более 30
hwfpstring?HW fingerprint, если лицензия привязана к железу; иначе поле опущено
cfgstringJSON-строка конфигурации (features / limits) по схеме версии шаблона

Как строится подпись

  1. Сериализуются header и payload, каждый кодируется в base64url → headerB64, payloadB64.
  2. Формируется сообщение для подписи: ASCII-байты строки "{headerB64}.{payloadB64}" — всё до последней точки.
  3. Сообщение подписывается приватным ключом Ed25519 шаблона (ключ выбирается по активной signing_key). Приватные ключи хранятся на диске зашифрованными AES-256-GCM под CERTIFIED_MASTER_KEY.
  4. Подпись кодируется в base64url и приклеивается третьим сегментом.

Проверка на клиенте (CertifiEdLicenseClient.VerifySignature) зеркальна: берётся всё до последней точки как ASCII, из последнего сегмента декодируется подпись, дальше Ed25519.Verify(publicKey, message, signature). Публичный ключ — «сырые» 32 байта; API отдаёт его в hex, на клиенте — Convert.FromHexString.

Файл .ced

Эндпоинт GET /api/v1/panel/licenses/{id}/download отдаёт JSON-конверт:

JSON
{  "licenseKey": "CFED-S7FN-RJFA-KAEY-8KZC",  "token": "eyJhbGciOiJFZDI1NTE5...<three segments>...RFbKNFNZCiMLL4MlNwOrsDg",  "publicKey": "7bac1e3ce8578ad859bfe001a4f4d72702d00e3d1eebdd735ebdb7145d74f35b"}

SDK при чтении .ced принимает и такой конверт (берёт поле token), и «голый» токен одной строкой — файл можно сохранять как есть.

Offline-маркер heartbeat

Отдельный, более короткий подписанный объект — две секции вместо трёх:

text
base64url(payload) . base64url(signature)

Payload (typ = certified-heartbeat):

JSON
{  "typ": "certified-heartbeat",  "lic": "019f6b40-89c4-7d41-be2a-4a151a3c0063",  "key": "CFED-S7FN-RJFA-KAEY-8KZC",  "iat": 1784210819,  "exp": 1784815619,  "mid": "64d519a09298303a4ac813c33b663f3e859e6b5122d0210143a6c593819cf2cf"}
ПолеНазначение
typДискриминатор, certified-heartbeat
licId лицензии
keyКлюч лицензии
iatВыдан, Unix-секунды
expДействует до = iat + maxOfflineDays, Unix-секунды
midHW fingerprint машины (machine id), если привязка есть

Маркер выдаётся сервером при активации и при каждом успешном heartbeat (HeartbeatMarker.IssueAsync, подписывается тем же ключом шаблона). SDK кэширует его на диск рядом с файлом лицензии как <license>.ced.hb — атомарная запись через .tmp + File.Move. При рестарте без сети маркер загружается с диска и держит лицензию «живой» до exp.

Grace-период и вычисление Status

CertifiEdLicenseStatus выводится клиентом локально — из exp токена и маркера:

text
Invalid   — токен не загруженExpired   — now > token.exp (жёсткое истечение)
если маркер есть:    Active      — now <= marker.exp    GracePeriod — marker.exp < now <= marker.exp + 24 часа    Expired     — позже
если маркера нет (сети не было с самого старта) —отсчёт от последней локальной валидации (lastValidatedAt):    Active      — now <= lastValidatedAt + maxOfflineDays    GracePeriod — ещё +1 сутки    Expired     — позже

Итог: пока приложение периодически достаёт свежий маркер (heartbeat раз в heartbeatIntervalSeconds), оно Active. Пропала сеть — маркер держит Active до maxOfflineDays, затем сутки GracePeriod, затем Expired. Жёсткий exp токена перекрывает всё: после него всегда Expired.

HW fingerprint

HwFingerprint.Get() (CertifiEd.Client) считает стабильный SHA-256 hex по имени машины, RuntimeInformation.OSDescription, первому стабильному MAC-адресу и platform machine id: на Windows — MachineGuid из реестра, на Linux — /etc/machine-id, на macOS — IOPlatformSerialNumber. Значение кэшируется на процесс.

  • Лицензия несёт режим привязки hwBindingnone / fixed / firstActivation. В режиме fixed и в закреплённом firstActivation сервер при активации сверяет фингерпринт и отклоняет чужой (activation.fingerprint_mismatch).
  • firstActivation не имеет отпечатка при выпуске: его записывает первая успешная активация, после чего лицензия ведёт себя как fixed.
  • Замена железа делается на платформе — POST /api/v1/panel/licenses/{id}/rebind; лицензия переподписывается, и .ced нужно скачать заново.
  • Свой фингерпринт передаётся в SDK через CertifiEdClientOptions.HwFingerprint; по умолчанию берётся HwFingerprint.Get().

Подробно — в разделе Привязка к железу.

См. также