Хочу рассказать про недавний кейс из своей практики и своих трудовых будней.
Заказчик пришёл с задачей: «Хочу QR-код на сертификатах, чтобы можно было проверить подлинность». У него в голове это выглядело так: он дает мне гугл таблицу с перечнем фамилий, датой, и номером сертификата, скрипт генерирует QR единый для всех сертификатов и как бы этот вопрос закрывается по его мнению. Но при этом должна закрываться самая главная боль - защита от подделки путем переноса QR на другой сертификат. Фактически получалась фикция. С моими возражениями заказчик согласился и я расскажу как мы пришли к криптографическому решению без бэкенда, и покажу конкретный код.


Что не так со схемой «QR - lookup в базе»

Простой lookup проверяет только одно,что такой номер в базе есть. Но он не проверяет, что именно этот документ соответствует этому номеру. Два вектора подделки, которые он не закрывает:

  1. Пересадка QR. Берём реальный валидный сертификат, копируем его QR на поддельный документ с другим именем. Сканируем - база говорит «валиден». Имя на бумаге и имя в базе никто не сравнил. Можно лепить что угодно на сертификате, хоть даже что его сам Сэм Альтман выдал.

  2. Правка содержимого. Меняем «базовый курс» на «продвинутый» в 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 ещё сильнее - пишите в комментарии.

Комментарии (0)