Защита QR сертификатов без сервера: Ed25519, GitHub Pages и ноль (почти) ежемесячных затрат

от автора


Хочу рассказать про недавний кейс из своей практики и своих трудовых будней.
Заказчик пришёл с задачей: «Хочу 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, ossk = 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, fitzdef 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 ещё сильнее — пишите в комментарии.

ссылка на оригинал статьи https://habr.com/ru/articles/1054826/