Перейти к основному содержанию Перейти к навигации Перейти к нижнему колонтитулу

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

Защищённая от подделки выдача лицензий, делегированная подпись для on-premise и обнаружение форков.

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

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 находится на конверте (вычисляется после подписания во избежание циклической зависимости).

Как продлевается лицензия

Выдача идёт с вашей рабочей станции от вашего имени. Продление идёт с машины, у которой вообще нет учётных данных аккаунта, поэтому ей нужна отдельная дверь:

POST /licenses/renew
{ license: <the installed signed blob>, machineId, clusterId? }

Предъявленная лицензия и есть удостоверение. На этом эндпоинте нет никакого API-токена. Сервер проверяет подпись Ed25519 блоба, а если блоб несёт сертификат делегирования, то и через него, ровно так же, как это делает Renet на машине. Наличие корректно подписанной лицензии на репозиторий само по себе доказывает право на него: машина, у которой такая лицензия есть, уже получила это право раньше.

Полный URL этого эндпоинта передаётся внутри каждой лицензии, которую сервер выдаёт или продлевает, в поле renewalUrl. Машина считывает адрес своего сервера аккаунтов из собственной лицензии, а не из настроек, и именно поэтому машина, обслуживающая две учётные вселенные, может продлевать лицензии в обеих.

Продлённый блоб сохраняет GUID репозитория, GUID grand-репозитория и вид предъявленного блоба, берёт следующий порядковый номер из того же реестра вместе с вытекающим из него хешем цепочки и получает заново вычисленные окна валидности. ID машины заново привязывается к той машине, которая предъявила блоб, и за счёт этого миграция ВМ лечится сама внутри 40-дневной отсрочки. Идентичность хранилища (UUID LUKS или отпечаток хранилища) переносится без изменений, потому что сетевой вызов не может заново просканировать диск, который он описывает.

Отказы

КодСтатусЗначение
INVALID_LICENSE_SIGNATURE403Подпись блоба не прошла проверку либо его хеш цепочки не совпадает с реестром сервера на этом порядковом номере
INVALID_LICENSE_PAYLOAD400Блоб повреждён или имеет неверный формат
DELEGATION_CERT_INVALID403Приложенный сертификат нарушил ограничение или его подпись мастер-ключом недействительна
DELEGATION_CERT_EXPIRED403Приложенный сертификат находится вне своего окна валидности
DELEGATION_CERT_REVOKED403Приложенный сертификат отозван вышестоящим сервером
LICENSE_IDENTITY_MISMATCH403Предъявляющая машина не является лицензированной, и 40-дневная отсрочка уже истекла
SUBSCRIPTION_LAPSED403Подписка истекла или приостановлена
GRACE_PERIOD_ENDED403Льготный период подписки закончился
TRIAL_REQUIRED403Подписке нужен активный пробный период или план
REPO_GUID_OWNERSHIP_CONFLICT403GUID репозитория принадлежит другой подписке
LICENSE_RENEWAL_FAILED500Всё, что не удалось классифицировать

Отказ не затрагивает установленную лицензию. Машина продолжает работать на том, что у неё есть, пока эта лицензия не истечёт жёстко.

Продление и слот машины

Успешное продление затрагивает запись об активации машины: занимает слот, если у машины его не было, и обновляет, если был. В отличие от выдачи, в продлении никогда не отказывают из-за превышения лимита. В ответе приходит overLimit, а сама активация помечается, чтобы портал, эндпоинт статуса лицензий и rdc subscription status могли это показать. Выдача действительно новой лицензии по-прежнему жёстко блокируется на потолке. Причины описаны в разделе Подписка и лицензирование - Слоты машин.

Порядок развёртывания

Серверы аккаунтов разворачиваются раньше сборок CLI и Renet Agent, которые зависят от описанных выше полей. Renet Agent, не нашедший renewalUrl в лицензии, пропускает такой репозиторий и сообщает об этом, а не падает, поэтому старая лицензия продолжает работать, пока обновление с рабочей станции не заполнит это поле. Обратный порядок даёт машины, которые стучатся в эндпоинт, которого ещё нет.

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

Каждая машина, работающая с Renet, хранит последнее известное состояние цепочки в {licenseDir}/chain-state.json (то есть /var/lib/rediacc/license/chain-state.json, рядом с директорией repos/ для каждого репозитория). Состояние цепочки привязано к ключу подписи, подписке, репозиторию и хранилищу данных и индексируется как "<keyId>:<subscriptionId>:<repositoryGuid>:<datastoreId>" (часть с хранилищем данных остаётся пустой для репозитория на хранилище данных машины по умолчанию).

Части ключа с репозиторием и хранилищем данных здесь несущие. Порядковые номера на сервере выдаются на подписку, поэтому на машине с несколькими репозиториями под одной подпиской вершина цепочки, записанная для репозитория B, окажется впереди лицензии, которую вот-вот предъявит репозиторий A, и репозиторий A будет отклонён как воспроизведение, к которому он не имеет никакого отношения. Форк хранилища данных обостряет ту же проблему: клон сохраняет GUID репозитория, а заново выпускается только идентификатор его хранилища данных, поэтому без части с хранилищем данных более свежая лицензия форка продвинула бы вершину, против которой всё ещё проверяется оригинал. Привязка сохранённой вершины к тому репозиторию и хранилищу данных, которые названы в лицензии, снимает обе проблемы. Записи, сделанные более старым Renet Agent в более коротком формате ключа, отбрасываются при первом же сохранении состояния, а следующая проверка заново заполняет вершину.

Состояние цепочки читается и продвигается только на тех операциях, которые проверяются на полном уровне. Уровень эксплуатации его не читает и не продвигает, поэтому запуск репозитория никогда не сдвигает вершину.

При каждой проверке лицензии Renet выполняет:

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

Повторная проверка уже установленной лицензии не считается откатом: тот же порядковый номер с тем же хешем цепочки принимается, и именно это позволяет машине проверять один и тот же файл при каждом запуске репозитория.

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

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

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

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

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

  • subscriptionId - к какой подписке относится этот сертификат
  • planCode, maxMachines, maxRepositorySizeGb, maxRepoLicenseIssuancesPerMonth - лимиты плана, зафиксированные в сертификате
  • maxTotalIssuances - верхняя граница порядкового номера цепочки
  • delegatedPublicKey - открытый ключ Ed25519 on-premise сервера (SPKI base64). Его 16-символьный шестнадцатеричный отпечаток (первые 8 байт SHA-256 от исходного ключа) становится publicKeyId, который записывается в каждый блоб лицензии, подписанный этим ключом. publicKeyId всегда представляет собой реальный отпечаток ключа и никогда не бывает заглушкой вроде "default".
  • 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 сервера, тот подписывает её делегированным ключом и встраивает полный сертификат делегирования прямо в блоб лицензии (поле delegationCert). Сертификат не запрашивается отдельно: он передаётся вместе с каждой лицензией репозитория.
  6. Renet выполняет двухуровневую проверку в следующем порядке:
    • Проверяет подпись сертификата по встроенному мастер-ключу вышестоящего сервера.
    • Обеспечивает соблюдение окна валидности сертификата (validFrom / validUntil) для операций роста (repo create, fork, resize, expand) и для передачи резервных копий (repo push, pull, backups). Уровень эксплуатации (repo up, up all, autostart) пропускает только эту проверку окна, так же как он уже пропускает проверку срока действия лицензии: работающие нагрузки никогда не останавливаются из-за истёкшего сертификата, но истёкший сертификат не может разрешить ничего нового. Подпись, привязка к ключу и все прочие ограничения сертификата по-прежнему соблюдаются на всех уровнях.
    • Требует выполнения fingerprint(cert.delegatedPublicKey) == blob.publicKeyId (16-символьный шестнадцатеричный отпечаток ключа), поэтому сертификат может подтверждать только лицензии, подписанные именно тем ключом, которому он делегирован.
    • Проверяет подпись блоба по делегированному ключу из сертификата.
    • Обеспечивает соблюдение ограничений сертификата: совпадение подписки, совпадение плана, предельный размер (maxRepositorySizeGb) и blob.sequence <= cert.maxTotalIssuances.
    • Применяет все стандартные проверки цепочки.

Ошибка сертификата приводит к быстрому отказу с отдельной причиной: cert_expired (вне окна валидности) или cert_invalid (недействительная подпись мастер-ключа или нарушенное ограничение). Они проверяются раньше, чем invalid_signature, поэтому причина, связанная с сертификатом, имеет приоритет.

On-premise сервер не может:

  • Подделать лицензию за пределами лимитов плана сертификата делегирования (renet отклонит её).
  • Выдать более maxTotalIssuances операций суммарно (renet отклонит переполнение порядкового номера).
  • Продолжать подписывать работоспособные лицензии после validUntil сертификата (это окно соблюдается даже в операциях, где проверка срока действия пропускается).
  • Изменить сертификат (подпись вышестоящего сервера будет нарушена).

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

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

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

ПланСрок по умолчаниюПотолок плана
COMMUNITY15 дней30 дней
PROFESSIONAL60 дней120 дней
BUSINESS90 дней180 дней
ENTERPRISE120 дней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 отклоняют повторное создание ответом:

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-документ:

{
  "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
smtpEMAIL_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 идентичны для всех транспортов.