Хочу рассказать про недавний кейс из своей практики и своих трудовых будней.
Заказчик пришёл с задачей: «Хочу QR-код на сертификатах, чтобы можно было проверить подлинность». У него в голове это выглядело так: он дает мне гугл таблицу с перечнем фамилий, датой, и номером сертификата, скрипт генерирует QR единый для всех сертификатов и как бы этот вопрос закрывается по его мнению. Но при этом должна закрываться самая главная боль - защита от подделки путем переноса QR на другой сертификат. Фактически получалась фикция. С моими возражениями заказчик согласился и я расскажу как мы пришли к криптографическому решению без бэкенда, и покажу конкретный код.
Что не так со схемой «QR - lookup в базе»
Простой lookup проверяет только одно,что такой номер в базе есть. Но он не проверяет, что именно этот документ соответствует этому номеру. Два вектора подделки, которые он не закрывает:
Пересадка QR. Берём реальный валидный сертификат, копируем его QR на поддельный документ с другим именем. Сканируем - база говорит «валиден». Имя на бумаге и имя в базе никто не сравнил. Можно лепить что угодно на сертификате, хоть даже что его сам Сэм Альтман выдал.
Правка содержимого. Меняем «базовый курс» на «продвинутый» в PDF-редакторе, QR оставляем. Проверка проходит, потому что номер в базе есть. Защита держится только если страница верификации показывает канонические данные из базы - имя, курс, дату - и человек их сверяет с бумагой. Это уже лучше, но требует живого сервера с базой, которую надо хостить, оплачивать и поддерживать. Тут я задумался: а зачем вообще сервер?
И я пошел по пути пары ключей
Если данные сертификата подписаны закрытым ключом, то подделать подпись без ключа математически невозможно, если изменить любое поле - подпись сломается, скопировать чужой QR - страница все равно покажет данные реального владельца.
Никакой базы. Никакого сервера. Весь контент верификации - статический HTML с зашитым публичным ключом.
Почему Ed25519, а не RSA или ECDSA
- Компактность подписи: 64 байта против 256–512 у RSA. Важно, потому что всё это уйдёт в QR-код.
- Скорость: генерация 50 подписей - миллисекунды.
- Совместимость: PyNaCl в Python и tweetnacl.js в браузере реализуют одну и ту же криптолинию. Подпись, сделанная питоном, верифицируется JS-библиотекой без конвертаций.
Почему данные в #-фрагменте URL, а не в query
https://verify.domain/#<token> ← фрагмент не уходит на сервер https://verify.domain/?t=<token> ← query уходит в логи GitHub
Фрагмент браузер не отправляет на сервер при запросе страницы. Персональные данные владельцев сертификата (ФИО, дата) живут только в QR-коде и в браузере при проверке. Никакой централизованной базы физически не существует — сливать нечего.
Архитектура
[Генератор, Python] ←── таблица xlsx + шаблон PDF │ ├─ Ed25519 подпись данных (PyNaCl) ├─ Упаковка в URL: https://domain/#<payload_b64>.<sig_b64> ├─ Генерация QR (segno, ECC=M) └─ Штамп текста и QR на PDF (PyMuPDF) │ ▼ папка с именными PDF [Верификатор, статика] ── GitHub Pages ── домен заказчика │ ├─ Читает location.hash ├─ Декодирует base64url ├─ Проверяет подпись (tweetnacl.js, публичный ключ зашит константой) └─ Показывает данные + статус
Закрытый ключ хранится только локально у того, кто выпускает сертификаты. Публичный ключ зашит в index.html - он безопасен.
Контракт токена
Это единственное место, где генератор и верификатор должны совпадать точно.
fields = [cert_num, fio, course, date_iso] payload = "\x1f".join(fields).encode("utf-8") sig = sk.sign(payload).signature token = b64url_nopad(payload) + "." + b64url_nopad(sig) url = f"https://domain/#{token}"
UnitSeparator /x1f выбран потому что не встречается в именах и названиях курсов. Скрипт явно проверяет каждое поле перед подписью.
base64url без паддинга (=) - чтобы URL был чище и QR по итогу плотнее.
Генерация ключей
import nacl.signing, os sk = nacl.signing.SigningKey.generate() with open("keys/ed25519_private.key", "wb") as f: f.write(sk.encode()) os.chmod("keys/ed25519_private.key", 0o600) with open("keys/ed25519_public.key", "wb") as f: f.write(sk.verify_key.encode())
Приватный ключ - только локально c chmod 600, публичный - пойдет в верификатор
Подпись и сборка
import nacl.signing, segno, base64, io, fitz def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def sign_cert(sk, cert_num, fio, course, date_iso) -> str: fields = [cert_num, fio, course, date_iso] payload = "\x1f".join(fields).encode("utf-8") sig = sk.sign(payload).signature return b64url(payload) + "." + b64url(sig) def stamp_pdf(template_path, output_path, fio, token, domain, cfg): doc = fitz.open(template_path) page = doc[0] page.insert_textbox( fitz.Rect(*cfg["fio"]["rect"]), fio, fontsize=cfg["fio"]["fontsize"], fontfile=cfg["font_path"], fontname="custom", color=cfg["fio"]["color"], align=fitz.TEXT_ALIGN_CENTER, ) url = f"https://{domain}/#{token}" qr = segno.make(url, error="m") buf = io.BytesIO() qr.save(buf, kind="png", scale=10, border=1) sz = cfg["qr"]["size"] x, y = cfg["qr"]["x"], cfg["qr"]["y"] page.insert_image(fitz.Rect(x, y, x+sz, y+sz), stream=buf.getvalue()) if qr.version > 10: print(f"⚠ QR версии {qr.version} (>10) - проверьте читаемость с печати") doc.save(output_path)
Верификатор
Весь верификатор - один index.html. CDN для двух зависимостей:
<script src="https://cdnjs.cloudflare.com/ajax/libs/tweetnacl/1.0.3/nacl-fast.min.js"></script> <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap" rel="stylesheet">
montserrat - для поддержки русского шрифта.
Ядро верификации:
const PUBLIC_KEY_B64 = "..."; function b64urlDecode(str) { str = str.replace(/-/g, '+').replace(/_/g, '/'); while (str.length % 4) str += '='; const bin = atob(str); return Uint8Array.from(bin, c => c.charCodeAt(0)); } function verify(token) { const parts = token.split('.'); if (parts.length !== 2) return { ok: false, reason: 'parse' }; try { const payload = b64urlDecode(parts[0]); const sig = b64urlDecode(parts[1]); const pubkey = b64urlDecode(PUBLIC_KEY_B64); const valid = nacl.sign.detached.verify(payload, sig, pubkey); if (!valid) return { ok: false, reason: 'signature' }; const fields = new TextDecoder().decode(payload).split('\x1f'); if (fields.length !== 4) return { ok: false, reason: 'parse' }; const [cert_num, fio, course, date_iso] = fields; const [y, m, d] = date_iso.split('-'); const date_fmt = `${d}.${m}.${y}`; return { ok: true, cert_num, fio, course, date: date_fmt }; } catch { return { ok: false, reason: 'parse' }; } } window.addEventListener('hashchange', run); window.addEventListener('load', run); function run() { const token = location.hash.slice(1); if (!token) { showNoToken(); return; } const result = verify(token); result.ok ? showValid(result) : showInvalid(result.reason); }
Четыре состояния - valid, invalid, parse_error и no_token. Все обрабатываются явно
Плотность QR: практические наблюдения
Плата за отсутствие базы - данные идут прямо в QR.
С полным кириллическим названием курса в моем случае токен вырастает до ~280 символов, это QR версии 11–12.
base64url без паддинга вместо hex, разделитель \x1f (1 байт) вместо JSON с именами полей (была сначала такая мысль), уровень коррекции ошибок M вместо H (H раздувает QR без практической пользы для сертификата), физический размер на печати не менее 3 см - при 180pt (~6.3 см) версия 12 читается уверенно даже бюджетным телефоном - проверено.
Все это помогает держать размер
Если название курса короткое (латиница, аббревиатура) - реально укладывается в версию 8–9. В моем случае название курса было довольно длинное, хоть и латиницей, пришлось ужиматься.
Корень доверия и его граница
Что подпись не гарантирует.
Подпись защищает целостность конкретного сертификата: поменять имя или курс без закрытого ключа невозможно. Но она не мешает мошеннику поднять свою страницу с своими ключами и своим «валиден» по сути скопировав схему.
Это не дыра в схеме - это фундаментальное свойство криптографии без PKI. В нашем случае корень доверия - это конкретный URL проверки.
Защита строится на двух вещах: Домен заказчика, а не бесплатный поддомен. Зарегистрировать под чужую компанию - уже подделка документов, а не просто технический трюк.. Второе — то что URL напечатан на сертификате рядом с QR читаемым текстом. Проверяющий машинально сверяет «куда вёл QR» с тем, что написано на бумаге.
Для сертификатов образовательных программ этот уровень защиты избыточен против большинства реальных сценариев мошенничества. Но так захотел заказчик, ок...
Результат
50 именных сертификатов с криптографической защитой - за 15 секунд. В облачную папку копировалось дольше.
Стоимость всей инфраструктуры - дешевый домен за ~15$ в год. GitHub Pages бесплатно.
Обслуживание - ноль. Нет сервера, нет БД, нет мониторинга.
Следующий поток обучения - новя таблица и одна команда в терминале. Проект воспроизводим на 100%.
Закрытый ключ хранится только у того, кто выпускает сертификаты. Это одновременно и защита от подделки, и рабочая бизнес-модель: хочешь новые сертификаты - приходишь к держателю ключа.
Все спасибо, что дочитали до конца.
Если делали что-то похожее или видите способ сжать QR ещё сильнее - пишите в комментарии.