# Цепочка лицензий и делегирование

Rediacc использует защищённую от подделки хеш-цепочку для выдачи лицензий и модель сертификата делегирования для on-premise развёртываний. На этой странице объясняется, как система защищает от подделки, атак воспроизведения и совместного использования лицензий.

## Зачем нужна цепочка?

Каждая лицензия, выданная сервером аккаунтов, записывается в реестр с дозаписью. Каждая запись связана с предыдущей через хеш SHA-256, образуя цепочку. Цепочка обладает тремя свойствами, делающими подделку обнаруживаемой:

1. **Порядковые номера** глобальны и монотонно возрастают для каждой подписки. Пропуск или изменение порядка записей разрывает цепочку.
2. **Хеши цепочки** привязывают каждую запись ко всем предыдущим. Изменение любой прошлой записи делает недействительными все последующие.
3. **Renet хранит максимальный виденный порядковый номер** для каждой подписки. Сервер, откатывающий свой порядковый номер, обнаруживается немедленно.

## Как выдаётся лицензия

Когда CLI запрашивает активацию машины или лицензию репозитория, сервер аккаунтов:

1. Считывает текущую вершину цепочки (последний порядковый номер + хеш) для подписки.
2. Формирует полезную нагрузку лицензии со следующим порядковым номером и предыдущим хешем цепочки.
3. Подписывает полезную нагрузку с помощью Ed25519.
4. Вычисляет `chainHash = SHA256(prevChainHash + ":" + signedPayload)`.
5. Атомарно добавляет запись в реестр выдачи. Если два одновременных запроса конкурируют за один порядковый номер, проигравший повторно получает следующий и переподписывает.
6. Возвращает подписанный блоб с хешем цепочки в CLI.

`sequence` и `prevChainHash` находятся внутри подписанной полезной нагрузки (поэтому их нельзя изменить без аннулирования подписи). `chainHash` находится на конверте (вычисляется после подписания во избежание циклической зависимости).

## Как Renet выполняет проверку

Каждая машина, работающая с Renet, хранит последнее известное состояние цепочки в `{licenseDir}/chain-state.json`. При каждой проверке лицензии Renet выполняет:

| Проверка | Неудача означает |
|---|---|
| Подпись Ed25519 действительна | Лицензия подделана или изменена |
| `sequence > lastKnownSequence` | Сервер откатил цепочку (атака воспроизведения) |
| `chainHash == SHA256(prevChainHash + ":" + payload)` | Запись цепочки изменена |
| `issuedAt >= lastKnownIssuedAt` | Манипуляция с часами (часы сервера переведены назад) |

При неудаче любой проверки лицензия отклоняется, и причина сообщается.

## Сертификаты делегирования (on-premise)

Для изолированных или самостоятельно размещённых развёртываний вышестоящий сервер аккаунтов выдаёт **сертификат делегирования**, разрешающий on-premise серверу подписывать лицензии собственным ключом Ed25519. Сертификат ограничивает возможности on-premise сервера.

### Структура сертификата

Сертификат делегирования содержит:

- `subscriptionId` - к какой подписке относится этот сертификат
- `planCode`, `maxMachines`, `maxRepositorySizeGb`, `maxRepoLicenseIssuancesPerMonth` - лимиты плана, зафиксированные в сертификате
- `maxTotalIssuances` - верхняя граница порядкового номера цепочки
- `delegatedPublicKey` - открытый ключ Ed25519 on-premise сервера (SPKI base64)
- `genesisHash` - начальная точка цепочки (продолжение от предыдущего сертификата или "genesis")
- `genesisSequence` - порядковый номер цепочки на момент выдачи. Используется `/onprem/cert-upload` для проверки связи нового сертификата с известной записью в локальном реестре при продвижении цепочки во время транзита. Необязателен для обратной совместимости (трактуется как 0 при отсутствии).
- `validFrom`, `validUntil` - окно валидности (определяется политикой валидности, описанной ниже)
- Подписан вышестоящим мастер-ключом Ed25519

### Как работает делегирование

1. Admin Enterprise генерирует пару ключей Ed25519 на on-premise сервере.
2. Admin запрашивает сертификат делегирования у вышестоящего сервера:
   ```
   POST /admin/delegation-certs
   { subscriptionId, validDays: 90, delegatedPublicKey: "MCowBQYDK2VwAyEA..." }
   ```
3. Вышестоящий сервер подписывает сертификат своим мастер-ключом и возвращает его.
4. On-premise сервер сохраняет сертификат и свой закрытый ключ, готовый к подписанию лицензий.
5. Когда CLI запрашивает лицензию у on-premise сервера, тот подписывает её делегированным ключом и включает ссылку на сертификат.
6. Renet выполняет **двухуровневую проверку**:
   - Проверяет подпись сертификата по встроенному мастер-ключу вышестоящего сервера.
   - Проверяет подпись блоба по делегированному ключу из сертификата.
   - Проверяет `blob.sequence <= cert.maxTotalIssuances`.
   - Применяет все стандартные проверки цепочки.

On-premise сервер не может:
- Подделать лицензию за пределами лимитов плана сертификата делегирования (renet отклонит её).
- Выдать более `maxTotalIssuances` операций суммарно (renet отклонит переполнение порядкового номера).
- Изменить сертификат (подпись вышестоящего сервера будет нарушена).

## Политика валидности

Окно валидности сертификата делегирования вычисляется вспомогательной функцией общей политики (`computeDelegationCertValidity()`), которая выполняется как на бэкенде вышестоящего сервера, так и на фронтенде клиентского портала. Одинаковые входные данные всегда дают одинаковый `validUntil`, поэтому клиенты могут просмотреть эффективный срок действия в модальном окне создания до отправки.

### Значения по умолчанию и потолки для каждого плана

| План | Срок по умолчанию | Потолок плана |
|---|---|---|
| COMMUNITY | 15 дней | 30 дней |
| PROFESSIONAL | 60 дней | 120 дней |
| BUSINESS | 90 дней | 180 дней |
| ENTERPRISE | 120 дней | 365 дней |

Значение по умолчанию - то, что выбирает эндпоинт создания, когда вызывающий не указывает `validDays`. Потолок - верхняя граница, которую может запросить вызывающий.

### Переопределение для конкретной подписки

Администраторы могут установить пользовательское значение `delegationCertDefaultDays` для конкретной подписки на странице Admin Subscription Detail. **Переопределение заменяет как значение по умолчанию, так и потолок для этой подписки** - это аварийный клапан для особых клиентов (например, корпоративный контракт, которому нужен 200-дневный сертификат на плане COMMUNITY). Схема Zod по-прежнему обеспечивает абсолютный диапазон `1..365`.

### Жёсткий потолок: дата окончания подписки + 3 дня льготного периода

Независимо от потолка плана и переопределения, каждый сертификат жёстко ограничен значением `subscription.expiresAt + 3 дня` (существующий `SUBSCRIPTION_CONFIG.gracePeriodDays`). Это означает:

- Для бессрочных подписок (`expiresAt = null`) потолок по сроку не применяется - действует только потолок плана.
- Для ежемесячных подписок Stripe потолок примерно равен следующей дате выставления счёта + 3 дня. Когда Stripe каждый месяц продвигает `expiresAt` вперёд, потолок смещается вместе с ним.
- Для пробных подписок потолок равен дате окончания пробного периода + 3 дня.

### Эффективные дни + причина

Каждый ответ на создание/продление включает `effectiveDays` и `reason`, чтобы вызывающий мог видеть, почему сертификат получил именно такой срок действия:

| Причина | Значение |
|---|---|
| `plan_default` | Запрос не указан, переопределения нет - использовано значение по умолчанию для плана |
| `subscription_override` | Запрос не указан - использовано переопределение подписки как значение по умолчанию |
| `requested` | Запрос вызывающего принят в рамках всех ограничений |
| `plan_max_clamp` | Запрос вызывающего превысил потолок плана - ограничен |
| `override_max_clamp` | Запрос вызывающего превысил переопределение подписки - ограничен |
| `subscription_cap_clamp` | Иначе допустимый срок превышал бы `expiresAt + 3 дня` подписки |

Модальное окно создания на клиентском портале использует эти причины для отображения предварительного просмотра в реальном времени ("Вы получите сертификат на 18 дней. Ограничен, так как срок сертификата не может превышать дату окончания подписки более чем на 3 дня."), чтобы клиенты не отправляли заявку вслепую.

### Адаптивный порог продления

Цикл автообновления on-premise использует адаптивный порог по образцу Let's Encrypt:

```
effectiveThresholdDays = min(env.RENEW_THRESHOLD_DAYS, ceil(certValidityDays / 3))
```

15-дневный COMMUNITY сертификат продлевается при 5 оставшихся днях. 90-дневный BUSINESS сертификат продлевается при 14 оставшихся днях (применяется потолок из переменной окружения). 120-дневный ENTERPRISE сертификат продлевается при 14 оставшихся днях. Это предотвращает немедленное срабатывание продления для краткосрочных сертификатов, при этом давая долгосрочным сертификатам достаточный буфер.

## Принуждение единственного активного сертификата

Подписка может иметь **не более одного активного сертификата делегирования одновременно** (`MAX_ACTIVE_DELEGATION_CERTS_PER_SUBSCRIPTION = 1`).

### Почему только один?

Каждая on-premise установка соблюдает `maxRepoLicenseIssuancesPerMonth`, `maxActivations` и целостность цепочки согласно собственному локальному реестру выдачи. On-premise сервер не синхронизирует счётчики использования с вышестоящим - в этом и состоит смысл делегирования с поддержкой офлайн-режима.

Если бы подписка имела несколько активных сертификатов (по одному на каждую установку), каждая установка соблюдала бы лимит независимо:

- Подписка на 500 выдач/месяц с 3 активными сертификатами позволяет до **1 500 выдач/месяц** на практике.
- Три параллельные цепочки, каждая привязанная к genesis, без возможности аудиторского согласования.

Вышестоящий сервер не может обнаружить этот обход, поскольку on-premise серверы спроектированы для работы в офлайн-режиме. **Единственный активный сертификат - единственная применимая на практике модель.** Клиентам с несколькими установками (production + staging + DR) необходимо приобретать по одной подписке на каждую установку.

### Поведение при коллизии

`POST /admin/delegation-certs` и `POST /portal/delegation-certs` отклоняют повторное создание ответом:

```json
HTTP/1.1 409 Conflict
{
  "code": "DELEGATION_CERT_ALREADY_ACTIVE",
  "existingCertId": "...",
  "actions": {
    "renew": "POST /portal/delegation-certs/process-renewal-request (preserves chain)",
    "revokeAndCreate": "POST /portal/delegation-certs/{existingCertId}/revoke then retry create"
  }
}
```

Клиентский портал отображает это в специальном диалоговом окне с объяснением последствий:

- **Продлить (рекомендуется)** - расширяет существующую цепочку. Все ранее выданные лицензии репозиториев продолжают работать.
- **Отозвать и создать** - отбрасывает существующую цепочку и начинает заново с genesis. Ранее выданные лицензии репозиториев становятся непроверяемыми после истечения `validUntil` СТАРОГО сертификата. Используйте только при переходе на новый on-premise с другим ключом подписи или при восстановлении после скомпрометированного ключа.

`renew()` - это атомарная замена, сохраняющая принцип единственного активного сертификата и **не** подпадающая под проверку коллизии 409.

### Ограничение частоты запросов

Даже при наличии единственного активного сертификата злоумышленник может циклически выполнять `revoke -> create -> revoke -> create`, расходуя циклы подписи мастер-ключом вышестоящего сервера. Оба эндпоинта создания ограничивают частоту до **10 попыток за скользящие 24 часа** для каждой подписки через таблицу `rateLimits`:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 78234
{ "code": "DELEGATION_CERT_RATE_LIMITED", "retryAfterSec": 78234 }
```

Счётчик увеличивается при каждой попытке независимо от результата (циклы спама при коллизиях также ограничиваются).

## Обнаружение форков

Если клиент делится своим сертификатом делегирования с другой стороной (или запускает два on-premise сервера с одним сертификатом), цепочки расходятся. Вышестоящий сервер обнаруживает это при продлении.

### Поток продления

1. Admin on-premise вызывает `POST /admin/delegation-certs/renew` с текущей вершиной цепочки:
   ```
   { subscriptionId, currentChainHash, currentSequence, delegatedPublicKey }
   ```
2. Вышестоящий сервер сверяет записи цепочки с собственным реестром.
3. Если `currentChainHash` не совпадает с записью вышестоящего сервера при `currentSequence`, обнаружен форк:
   ```
   409 { code: 'CHAIN_FORK_DETECTED', divergedAtSequence: N }
   ```
4. `genesisHash` нового сертификата устанавливается равным текущему хешу цепочки, чтобы машины со старым состоянием цепочки могли продолжить с того места, где остановились.

Если сертификат передан лицу, не являющемуся клиентом:
- Они могут использовать его в течение срока действия сертификата.
- При первом продлении вышестоящий сервер видит только одну цепочку (легитимную).
- `genesisHash` нового сертификата совпадает только с легитимной цепочкой.
- Машины на общей цепочке немедленно отклонят новые лицензии, так как их сохранённый `chainHash` не связан с `genesisHash` нового сертификата.

## Продление в изолированном окружении

Для on-premise установок без исходящего HTTPS-доступа к вышестоящему серверу поток продления полностью офлайновый. Для замыкания цикла предусмотрены три новых эндпоинта:

**На on-premise (`auth, root, requireElevated()`):**
- `GET /onprem/cert-current` - загрузить текущий подписанный сертификат (резервная копия, аудит, повторный импорт)
- `GET /onprem/renewal-request` - сгенерировать подписанный манифест с текущей вершиной локальной цепочки + делегированным открытым ключом, подписанный закрытым ключом on-premise

**На вышестоящем сервере (admin или портал с областью org):**
- `POST /admin/delegation-certs/process-renewal-request` (системный root для работы с несколькими клиентами)
- `POST /portal/delegation-certs/process-renewal-request` (владелец/Admin организации)

### Манифест запроса на продление

Запрос на продление - небольшой JSON-документ:

```json
{
  "manifest": {
    "schemaVersion": 1,
    "generatedAt": "2026-04-15T12:00:00.000Z",
    "subscriptionId": "...",
    "currentChainHash": "...",
    "currentSequence": 42,
    "delegatedPublicKey": "MCowBQYDK2VwAyEA...",
    "currentCertValidUntil": "...",
    "currentCertPublicKeyId": "...",
    "currentCertId": null
  },
  "signature": "<base64 Ed25519>",
  "publicKeyId": "..."
}
```

Подпись вычисляется по каноническому кодированию манифеста (ключи отсортированы по алфавиту, затем `JSON.stringify`) с использованием закрытого ключа on-premise. Это гарантирует, что обе стороны вычисляют идентичные байты независимо от порядка построения объекта.

### Проверка на вышестоящем сервере

`processRenewalManifest()` выполняет пять проверок:

1. **Активный сертификат существует** для подписки манифеста. Иначе возвращает `404 NO_ACTIVE_CERT` - клиент должен использовать поток создания, а не продления.
2. **Делегированный открытый ключ совпадает** с активным сертификатом. Иначе возвращает `400 DELEGATED_KEY_MISMATCH` - защита от воспроизведения с другого on-premise сервера.
3. **Подпись манифеста проходит проверку** по `delegatedPublicKey` активного сертификата. Иначе возвращает `400 MANIFEST_SIGNATURE_INVALID` - доказывает, что манифест исходит от владельца закрытого ключа on-premise.
4. **Возраст манифеста** не превышает 7 дней (`RENEWAL_MANIFEST_MAX_AGE_MS`). Иначе возвращает `400 MANIFEST_EXPIRED` - защита от воспроизведения.
5. **Связь хешей цепочки** при `currentSequence` манифеста совпадает с реестром вышестоящего сервера. Иначе возвращает `409 CHAIN_FORK_DETECTED` - защита от форкнутых цепочек.

Если все проверки пройдены, `processRenewalManifest` вызывает существующий поток `renew()`, который атомарно истекает старый сертификат и вставляет новый. **Не** подпадает под проверку коллизии 409 на стороне создания, поскольку является атомарной заменой, а не двухшаговой операцией отзыва+создания.

### Продвижение порядкового номера во время транзита

Манифест запроса на продление фиксирует вершину цепочки на момент генерации. Пока манифест находится в пути (доставка через USB, зашифрованная почта), on-premise сервер может продолжать выдавать лицензии репозиториев, продвигая свою локальную цепочку.

Когда новый сертификат загружается обратно на on-premise, `/onprem/cert-upload` проверяет, что `genesisSequence` нового сертификата по-прежнему связывается с известной записью в локальном реестре:

- Если `cert.genesisSequence > localHead.sequence` - возвращает `409 CHAIN_HEAD_BEHIND` (вышестоящий сервер находится на форкнутой цепочке).
- Если `cert.genesisSequence > 0` и запись в локальном реестре при данном порядковом номере имеет `chainHash`, отличный от `cert.genesisHash` - возвращает `409 CHAIN_FORK_ON_UPLOAD` (локальная цепочка разошлась).
- В противном случае сертификат принимается. Будущие выдачи продолжаются с `localHead.sequence + 1`.

Это означает, что **заморозка записей во время транзита не требуется**. Цепочка расширяется естественным образом с обеих сторон. Аналогично тому, как продление X.509 обрабатывает серийные номера в процессе выдачи.

## Периодический аудит

Вышестоящий сервер предоставляет эндпоинт аудита для проверки целостности цепочки без продления сертификата:

```
POST /admin/delegation-certs/audit
{ subscriptionId, chainEntries: [{ sequence, chainHash }, ...] }
```

Вышестоящий сервер обходит записи и возвращает `{ valid: true }` или `{ valid: false, divergedAtSequence: N, expected, actual }`.

On-premise серверы должны периодически вызывать этот эндпоинт (по умолчанию: еженедельно через переменную окружения `UPSTREAM_AUDIT_URL`) для раннего обнаружения форков.

### Доказательства аудита на стороне машины

Renet может локально проверить непрерывность цепочки с помощью `VerifyAuditProof`. Когда машина продлевает лицензию после долгого перерыва, сервер может вернуть промежуточные записи цепочки в качестве доказательства. Машина обходит доказательство, проверяя, что каждый `chainHash` получается из предыдущего `prevHash + blobHash` через SHA-256, обнаруживая любую подделку без обращения к вышестоящему серверу.

## Безопасность конкурентного доступа

D1 (база данных Cloudflare) не поддерживает интерактивные транзакции. Одновременная выдача лицензий для одной подписки может привести к коллизии порядкового номера. Сервер аккаунтов обрабатывает это следующим образом:

1. Считывает следующий порядковый номер + предыдущий хеш цепочки.
2. Формирует и подписывает блоб с зафиксированным порядковым номером.
3. Вставляет запись в реестр с `onConflictDoNothing`.
4. Если вставка возвращает 0 изменённых строк, порядковый номер занят другим запросом - повторно получает порядковый номер, повторно формирует, **переподписывает** и повторяет попытку.
5. После 10 неудачных попыток завершается с ошибкой.

Ключевая деталь: при повторной попытке блоб **переподписывается**. Наивная повторная попытка, только обновляющая запись в реестре, оставила бы подписанный блоб с устаревшим порядковым номером, нарушая цепочку.

## Транспорт электронной почты

Сервер аккаунтов может отправлять транзакционные email (магические ссылки, сброс пароля, уведомления безопасности) через два подключаемых транспорта:

| Транспорт | Конфигурация |
|---|---|
| `ses` (по умолчанию) | `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `AWS_SES_REGION`, `AWS_SES_FROM` |
| `smtp` | `EMAIL_TRANSPORT=smtp`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_SECURE`, `SMTP_FROM` |

Оба транспорта работают для облачных и on-premise развёртываний. Выберите тот, что подходит вашей инфраструктуре: AWS SES с вашим собственным аккаунтом AWS или любой SMTP-сервер (Microsoft Exchange, Postfix, SendGrid, Mailgun и другие).

Транспорт выбирается при запуске через переменную окружения `EMAIL_TRANSPORT`. SMTP использует пул соединений и ленивую загрузку, поэтому библиотека SMTP-клиента инициализируется только при выборе SMTP.

Все шаблоны email и публичный API email идентичны для всех транспортов.

## Связанная документация

- [Установка On-Premise](/ru/docs/on-premise) - как развернуть on-premise сервер
- [Подписка и лицензирование](/ru/docs/subscription-licensing) - лимиты плана и слоты машин
- [Каналы релизов](/ru/docs/release-channels) - каналы edge и stable
- [Регионы данных](/ru/docs/data-regions) - региональное хранение данных