Нужно отдать клиенту непрозрачный параметр — id файла в ссылке, состояние в cookie, код в hidden-поле — и при возврате понять, не подменили ли строку. Шифровать ради этого весь payload часто избыточно: достаточно HMAC-подписи. В D7 для этого есть Bitrix\Main\Security\Sign\Signer и вариант со сроком жизни — TimeSigner.
Рядом по теме: Config\Option и Configuration (откуда берётся ключ по умолчанию), Result и Error, Web\Uri, Application, Context, Request.
Если нужна помощь с модулем или разбором токенов на проде — услуги и контакты на karoche.ru.
Зачем нужна подпись, если это не шифрование?
Подпись отвечает на вопрос «строку меняли после выдачи?». Содержимое при этом остаётся читаемым: в URL вида id=42.абц... число 42 видно любому. Это не AES и не «спрятать секрет от пользователя».
Берите Signer, когда:
- Параметр уходит наружу (query, cookie, post) и должен вернуться без правок.
- Секрет (ключ) остаётся на сервере; в токене его быть не должно.
- Для временных ссылок нужен явный TTL — тогда
TimeSigner.
Не берите Signer как замену авторизации сессии или ACL: подпись id=5 не даёт права читать чужой заказ, если вы сами не проверите владельца после unsign.
Минимальный Signer::sign и unsign
sign принимает строку и опциональную соль, возвращает {message}{separator}{signature} (разделитель по умолчанию — точка). unsign проверяет подпись и возвращает исходное сообщение либо кидает \Bitrix\Main\Security\Sign\BadSignatureException.
<?php
use Bitrix\Main\Security\Sign\Signer;
use Bitrix\Main\Security\Sign\BadSignatureException;
$signer = new Signer();
$signed = $signer->sign('42', 'file_download');
// отдали клиенту $signed, потом получили обратно:
try {
$id = $signer->unsign($signed, 'file_download');
// $id === '42'
} catch (BadSignatureException $e) {
// подменили значение, соль или формат строки
}
Без try/catch любое битое значение валит весь хит исключением. На границе HTTP (query/cookie) ловите исключение и отвечайте 400/403 по своей политике — не «глушите» его пустым catch без лога.
validate($value, $signature, $salt) проверяет пару значение+подпись и возвращает bool, не бросая исключение при неверных типах аргументов (вернёт false). Для целой упакованной строки обычно удобнее unsign.
Какая соль и какой ключ должны совпасть при проверке?
Соль задаёт пространство имён подписи. Это не пароль конечного пользователя. Одна и та же строка 42 с солью file_download и с солью order_view даёт разные подписи. При unsign соль должна быть той же.
Ограничение из реализации getKey: если соль не null, она обязана совпадать с регулярным выражением ^[a-zA-Z0-9_.-]{3,50}$. Иначе сразу BadSignatureException с текстом про malformed salt. Кириллица, пробел, слишком короткая соль (ab) или слишком длинная — частая причина «у меня на стенде работало», когда соль собрали из человекочитаемого заголовка.
Ключ подписи:
- Явно:
$signer->setKey('...')— одна и та же строка на sign и unsign (и на всех серверах кластера). - По умолчанию: опция
main/signer_default_keyчерезConfig\Option; если пусто — ядро генерирует значение черезSecurity\Randomи сохраняет в Option. К ключу дописываетсяcrypto_keyиз секцииcryptoв.settings.php/ Configuration, если он задан.
Практический вывод: смена crypto_key или сброс signer_default_key делает недействительными старые подписанные ссылки. Перед ротацией ключа заложите окно, когда принимаются оба ключа, либо смиритесь с массовым «Signature does not match».
Когда брать TimeSigner вместо Signer?
TimeSigner::sign($value, $time, $salt = null) добавляет метку истечения. Формат: {message}{separator}{expired timestamp}{separator}{signature}. Второй аргумент — unix-timestamp или строка в духе strtotime (+1 hour, +1 day).
<?php
use Bitrix\Main\Security\Sign\TimeSigner;
use Bitrix\Main\Security\Sign\BadSignatureException;
$signer = new TimeSigner();
$signed = $signer->sign('42', '+15 minutes', 'file_download');
try {
$id = $signer->unsign($signed, 'file_download');
} catch (BadSignatureException $e) {
// битая подпись ИЛИ срок вышел - смотрите текст $e->getMessage()
}
unsign проверяет и подпись, и время. Просрочка даёт сообщение вида Signature timestamp expired (...). Это всё ещё BadSignatureException, не отдельный класс — ветвить логику можно по тексту/префиксу сообщения или логировать целиком.
Когда нужен только контроль «строку не подменили» без срока — берите Signer. Ссылки на download, invite и reset-код с коротким окном удобнее закрывать через TimeSigner.
Чем отличаются Signer и TimeSigner
| Критерий | Signer |
TimeSigner |
|---|---|---|
| Назначение | Целостность строки | Целостность + срок жизни |
sign |
sign($value, $salt = null) |
sign($value, $time, $salt = null) |
| Формат | message + sep + signature | message + sep + expiry + sep + signature |
| Просрочка | Нет | Да, через unsign |
| Тип ошибки | BadSignatureException |
Тот же класс (+ текст про timestamp) |
| Типичный сценарий | Signed cookie / anti-tamper id | Временная ссылка, OTP-окно |
Оба класса умеют pack / unpack, setSeparator, setKey, getSignature, validate.
unpack, separator и формат строки
Разделитель по умолчанию — .. Его можно сменить через setSeparator, но тогда и sign, и unsign должны жить с одним разделителем. Если в самом сообщении есть тот же символ, unpack режет справа: при limit = 2 (по умолчанию) последняя точка отделяет подпись, всё левее остаётся сообщением. Для TimeSigner частей больше — сначала отрезается подпись, затем timestamp.
unpack($value, 0) разбирает все сегменты. Если разделителя нет — снова BadSignatureException («Separator not found in value»).
Практические ловушки:
- Двойное
urlencode/ обрезка query на прокси — подпись ломается. - Смена separator «для красоты» только на одной стороне.
- Подпись ожидают в hex; ручная склейка «своим» base64 без
signне пройдётunsign.
Сборку URL удобно делать через Web\Uri, чтобы не потерять кодирование параметра.
Типичные BadSignatureException и как их ловить
| Симптом / текст | Вероятная причина | Что проверить |
|---|---|---|
| Separator not found in value | Пришла неподписанная строка или обрезали хвост | Цепочка передачи, логи proxy, длина query |
| Signature does not match | Другая соль, другой ключ, подмена message | Соль 1:1; setKey/Option/crypto_key на всех нодах |
| Malformed salt… | Соль вне [a-zA-Z0-9_.-]{3,50} |
Длина 3-50, латиница/цифры/_/./- |
| Хвост не hex (Signature must be hexadecimal string) | Повреждён хвост подписи | Кодирование URL, копипаст из почты |
| Просрочка (Signature timestamp expired) | Окно TimeSigner вышло | TTL при sign, часы серверов (NTP) |
Шаблон на границе запроса:
<?php
use Bitrix\Main\Application;
use Bitrix\Main\Security\Sign\TimeSigner;
use Bitrix\Main\Security\Sign\BadSignatureException;
$request = Application::getInstance()->getContext()->getRequest();
$raw = (string)$request->get('t');
$signer = new TimeSigner();
try {
$payload = $signer->unsign($raw, 'file_download');
} catch (BadSignatureException $e) {
// залогировать класс + message без полного токена, если в message есть ПДн
CHTTP::SetStatus('403 Forbidden');
return;
}
// дальше - свои проверки ACL по $payload
Не логируйте сырой подписанный токен целиком в публичные каналы, если внутри есть персональные данные: подпись не шифрует содержимое.
Пример: одноразовая ссылка с TTL
Сценарий: отдать ссылку на скачивание файла на 20 минут. Id файла не секрет, но подмена id на чужой номер должна отсекаться подписью; поздно пришедшая ссылка — сроком.
<?php
use Bitrix\Main\Security\Sign\TimeSigner;
use Bitrix\Main\Web\Uri;
function buildDownloadUrl(int $fileId): string
{
$signer = new TimeSigner();
$token = $signer->sign((string)$fileId, '+20 minutes', 'file_download');
$uri = new Uri('/local/download.php');
$uri->addParams(['t' => $token]);
return $uri->getUri();
}
function resolveDownloadToken(string $token): int
{
$signer = new TimeSigner();
$id = $signer->unsign($token, 'file_download');
return (int)$id;
}
После успешного unsign всё равно проверьте права текущего пользователя на файл. Подпись подтверждает только то, что строку выписали мы и срок ещё действует. Право читать заказ проверяйте отдельно.
Для связки с настройками ключа см. Config\Option. Если нужна единая обработка успеха/ошибок в сервисах модуля — Result и Error (сам Signer бросает исключение, Result — соседний контракт).
Нужен разбор конкретного модуля или аудит токенов на проде — напишите через karoche.ru.
Чеклист перед выкладкой на прод
- Соль одинакова на sign и unsign, формат
[a-zA-Z0-9_.-]{3,50}. - Ключ: либо общий
setKey, либо стабильные Option +crypto_keyна всех серверах. - Для TTL выбран
TimeSigner, срок адекватен сценарию, часы серверов синхронизированы. - На входе HTTP —
try/catchнаBadSignatureException, без падения хита. - После
unsign— отдельная проверка прав на сущность. - Токен в URL не ломается кодированием; для сборки используйте
Uri. - Ротация ключа спланирована: старые ссылки перестанут проходить
unsign. - В логах — причина исключения, не обязательно полный токен с ПДн.
Источники
- Документация класса Signer
- Описание sign
- Разбор unsign
- Параметр ключа в setKey
- Подпись со сроком: TimeSigner::sign
- Проверка срока: TimeSigner::unsign
- Зеркало исходника signer.php
- Класс BadSignatureException
Полный набор заметок по Битрикс и смежному стеку — на karoche.ru.
