У меня есть платформа для работы с метафорическими ассоциативными картами. Это инструмент психологов, коучей: колода картинок, вопросы, разговор. Звучит нишево, но суть задачи универсальна – авторский визуальный контент в вебе, который надо защитить от массового скачивания и пиратства. При этом контент загружают сами пользователи.
Если вы делаете галерею, маркетплейс иллюстраций, образовательную платформу с визуалами или любой сервис, где картинки – это ценность, а не декорация, эта статья для вас. Я расскажу, как выстроил многослойную защиту изображений, не превращая при этом продукт в крепость, из которой неудобно пользоваться.
Исходная платформа: app.makcards.online (открытая бета)
С чего всё начиналось: карты как открытые файлы
В первой версии платформы карты раздавались как обычная статика. Nginx, публичная папка, прямые URL. Работало так: клиент запрашивает манифест колоды, получает список путей к картинкам, а дальше стандартный <img src="...">.
Проблем тут сразу несколько. Пути предсказуемые – base_001.jpg, base_002.jpg, ... base_080.jpg. Манифест доступен без авторизации. Кеш – public, immutable, TTL 30 дней. Правый клик – «Сохранить изображение». Любой скрипт из трёх строчек на curl скачает всю колоду за секунды.
Для встроенных демо-колод это терпимо. Но я строю платформу, на которой авторы будут загружать и в перспективе продавать свои колоды. Если автор знает, что его работу можно утащить одной командой в терминале, он не будет доверять площадке свой контент. А без контента нет платформы.
Модель угроз: от кого защищаемся
Прежде чем проектировать защиту, стоит определить, от кого именно мы защищаемся. Любой контент, который показывается на экране, можно сфотографировать. Это аксиома, и бороться с ней бессмысленно.
Зато есть три реалистичные угрозы, с которыми можно и нужно работать.
Первая – casual piracy (бытовое пиратство). Пользователь видит красивую колоду, нажимает «Сохранить» и кидает в чат. Или ставит расширение для скачивания картинок. Это самый массовый вектор, и его стоимость должна быть выше нуля.
Вторая – массовое скачивание. Скрипт, который перебирает URL и складывает всё в папку. Сканы чужих наборов, залитые «бесплатно» на торренты или в Telegram-каналы. Здесь нужна не столько невозможность, сколько заметное усложнение и трекинг.
Третья – атрибуция. Если утечка произошла, хочется знать, откуда контент ушёл. Не для судебных разбирательств (хотя и для них тоже), а для оперативного реагирования.
Скриншоты сознательно оставлены за скобками. Если человек скриншотит по одной карте – это неудобно, потеря качества заметна, а 80 скриншотов подряд – это уже усилие, которое отсекает большинство casual-сценариев.
Архитектура защиты: четыре слоя
Защита строится как эшелонированная оборона. Ни один слой не является непробиваемым, но в совокупности они создают достаточный барьер. Каждый слой работает независимо – если один обойдён, остальные продолжают работать.

А вот как выглядит полный путь от первого запуска до отображения карты:

Разберём каждый слой подробнее.
Слой 1: Криптографическая идентификация устройства
Это фундамент всей системы. Без понимания, кто запрашивает контент, защищать его бессмысленно. Но у платформы есть особенность: она работает без регистрации. Человек открыл ссылку – и он уже в сессии. Нет аккаунтов, нет «подтвердите почту». Это принципиальное UX-решение, и ломать его ради безопасности не хотелось.
Решение – анонимная идентификация через WebCrypto API. При первом запуске браузер генерирует ECDSA-ключ (кривая P-256), и приватная часть помечена как extractable: false.
const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, // extractable = false ['sign', 'verify'] );
Это принципиальный момент. Флаг extractable: false означает, что приватный ключ невозможно прочитать, скопировать или экспортировать. Даже сам JavaScript-код страницы не может получить к нему доступ в виде байтов. Единственное, что можно делать – подписывать данные через crypto.subtle.sign(). Ключ живёт внутри криптографической подсистемы браузера и привязан к конкретному origin.
Ключевая пара хранится в IndexedDB. Не в localStorage (который доступен синхронно и легко читается), а именно в IndexedDB – потому что это единственное хранилище, которое поддерживает объекты CryptoKey.
Публичный ключ экспортируется один раз – при регистрации устройства на сервере. Сервер вычисляет SHA-256 от публичного ключа (fingerprint), сохраняет его как уникальный идентификатор и выдаёт JWT-токен для дальнейшей работы. Регистрация идемпотентна: один и тот же публичный ключ всегда возвращает один и тот же device_id.
Подписанные запросы
Каждый запрос к API подписывается приватным ключом. Не только авторизационные – каждый. Клиент формирует каноническую строку из метода, пути, таймстемпа, одноразового nonce и хеша тела запроса. Подписывает ECDSA. Сервер проверяет подпись, используя публичный ключ устройства из базы.
Каноническая строка: GET /api/tiles/nature/a7f3b2c1.jpg 1706011200 YWJjMTIzZGVmNDU2 e3b0c44298fc1c14...
Сервер проверяет пять вещей: валидность JWT, попадание таймстемпа в окно ±5 минут, уникальность nonce (через Redis с TTL 5 минут – защита от replay-атак), целостность тела запроса и, наконец, криптографическую подпись.
Зачем и JWT, и подпись? Разве токена не достаточно? Коротко: JWT подтверждает «кто», а подпись – «он ли это на самом деле». Токен – строка, её можно перехватить (логи, расширение браузера, утечка через Referer). Классическая атака token replay: перехватил JWT → отправил те же запросы от своего имени. С подписью это не работает: каждый запрос содержит уникальный nonce (одноразовый, хранится в Redis 5 минут) и таймстемп (окно ±5 минут). Даже имея валидный JWT, без приватного ключа подпись не создать, а ключ extractable: false – он физически не покидает CryptoKey-объект браузера.
Rate limiting
На уровне устройства работает двухоконный rate limiting – краткосрочный (поминутный) и долгосрочный (часовой). Отдельно ограничены регистрации новых устройств – по IP и по ASN (номеру автономной системы). Это не позволяет обойти ограничения простым фармом новых идентификаторов. Каждая очистка хранилища браузера – это новое устройство и новая регистрация, которая тоже лимитирована.
Слой 2: Тайлинг – карты как мозаика
Это, пожалуй, самое нестандартное решение. На сервере нет цельных изображений карт. Совсем. Каждая карта на этапе сборки разрезается на сетку 3×3 – девять фрагментов по 300×450 пикселей. Каждый фрагмент сохраняется с рандомным UUID-именем.
Было: nature_042.jpg (900×1350, полная карта) Стало: a7f3b2c1.jpg (300×450, фрагмент 0,0) e9d4f8a2.jpg (300×450, фрагмент 0,1) c1b5d7e3.jpg (300×450, фрагмент 0,2) f2a8c9d4.jpg (300×450, фрагмент 1,0) b6e1a3f5.jpg (300×450, фрагмент 1,1) d8c2b4a6.jpg (300×450, фрагмент 1,2) e4f6d1c7.jpg (300×450, фрагмент 2,0) a9b3e5f8.jpg (300×450, фрагмент 2,1) c7d2a6b9.jpg (300×450, фрагмент 2,2)
Оригиналы после нарезки удаляются из деплоя. Исходники высокого разрешения хранятся в отдельной директории, которая никогда не попадает на сервер.
Маппинг «карта → её тайлы» живёт в манифесте, который доступен только авторизованным устройствам (слой 1). Без манифеста у атакующего есть набор UUID-файлов без контекста – он не знает, какие фрагменты принадлежат какой карте и в каком порядке их собирать.
Для быстрого просмотра в режиме веера генерируются крошечные превью – 150×225 пикселей, качество 60%. Они достаточны для выбора карты, но бесполезны как «скачанный контент» – слишком маленькие.
Nginx: tiles не раздаются напрямую
Отдельная важная деталь: тайлы не лежат в публичной папке Nginx. Директория с тайлами закрыта правилом deny all. Все запросы к тайлам проходят через Go API, который проверяет авторизацию, подпись и rate limit.
location ~* /static/decks/[^/]+/tiles/ { deny all; } location ~* /static/decks/[^/]+/cards/ { deny all; } location ~* \.(yaml|yml|json)$ { deny all; }
Заблокированы три вещи: прямой доступ к тайлам, прямой доступ к оригинальным картам (на случай если файлы случайно остались) и доступ к метаданным (манифесты, конфигурации колод). Кеширование установлено на private – промежуточные прокси и CDN не кешируют тайлы. Дополнительно стоит проверка Referer, но это best-effort сигнал, а не фактор безопасности – реальная защита держится на подписи запросов и rate limiting.
Слой 3: Canvas-рендеринг вместо
Классическая проблема: даже если изображение защищено на уровне загрузки, в момент показа оно оказывается в DOM как <img src="...">. Правый клик – «Сохранить изображение». DevTools → Network – все URL как на ладони.
Решение: карты никогда не рендерятся через <img> с реальными серверными URL.
Для основного рабочего поля (стола) используется Pixi.js – это WebGL-движок, который рисует всё на <canvas>. Карта – это спрайт с текстурой, которая создаётся из собранных тайлов прямо в памяти. В DOM нет ни URL, ни <img> – только canvas с пикселями.
Для полноэкранного просмотра карты используется OffscreenCanvas. Девять тайлов загружаются параллельно, собираются на скрытом canvas, результат конвертируется в blob: URL через canvas.toBlob(). Именно этот blob-url попадает в <img src="blob:...">. При закрытии превью blob URL отзывается через URL.revokeObjectURL().
Почему blob URL – это лучше, чем прямой URL? Потому что blob-адрес эфемерный. Он привязан к конкретному Document, не пережившему перезагрузку страницы. Его нельзя открыть в другой вкладке или вставить в curl. Если пользователь нажмёт «Сохранить» – он сохранит уже собранное изображение с водяными знаками (о них ниже), а не оригинальный тайл.
Для режима веера (выбор карты из колоды) используются те самые крошечные превью-тамбнейлы. Они тоже загружаются через авторизованный API и показываются как blob URL.
На всех элементах с картами отключено контекстное меню, выделение и перетаскивание:
onContextMenu={(e) => e.preventDefault()} style={{ userSelect: 'none' }} draggable={false}
Да, пиксели с canvas можно снять через toDataURL() или getImageData() – от этого не спрятаться. Но к моменту, когда данные попадают на canvas, в них уже вшит per-device водяной знак. То есть даже «вытащенная» таким способом картинка несёт в себе fingerprint устройства.
Отключённое контекстное меню и draggable={false} – это не защита от программиста с DevTools. Это барьер для 95% обычных пользователей, которые привыкли скачивать картинки правым кликом.
Слой 4: Водяные знаки – два уровня
Здесь работают две независимые системы. Одна видимая, вторая нет.
Видимый водяной знак: домен в паддинге
У каждой карты на столе есть тонкая полоска паддинга сверху и снизу. В этих полосках отображается название домена – «makcards.online» – с прозрачностью 6%. Это практически незаметно при нормальной работе, но достаточно для атрибуции, если карта «уплыла».

Знак рендерится в двух местах: на Pixi.js-канвасе (для стола) и в DOM (для превью и редактора). Текст, размер шрифта и прозрачность вынесены в конфигурацию, чтобы при необходимости можно было быстро поменять.
Невидимый водяной знак: пиксельный сдвиг
А вот это уже интереснее. Каждый раз, когда TileCompositor собирает карту из тайлов, он вжигает в неё невидимый per-device водяной знак. Технология основана на минимальном изменении яркости пикселей.
Как это работает. Из device_id вычисляется короткий fingerprint (первые 4 байта SHA-256, как hex). Формируется текст: домен платформы и fingerprint устройства. Этот текст рендерится на вспомогательном canvas крупным шрифтом, под углом −30°, повторяясь по диагонали через каждые 300 пикселей.
Дальше алгоритм проходит по каждому пикселю основного изображения. Там, где маска содержит текст, значение яркости пикселя сдвигается на 5 единиц. Светлые пиксели становятся чуть темнее, тёмные – чуть светлее. Направление сдвига зависит от текущей яркости, что гарантирует контраст на любом фоне.
Сдвиг в 5 единиц – это примерно 2% интенсивности канала. Человеческий глаз не различает такие изменения. Но при целенаправленном анализе (нормализация контраста, гистограмма яркости) паттерн отчётливо виден. Мы проверили экспериментально на наборе из нескольких колод при quality 60/75/85 – паттерн сохраняется в подавляющем большинстве случаев. Это ожидаемо: стандартная матрица квантизации JPEG при quality 75 даёт шаг 2–3 единицы для низкочастотных коэффициентов, а наш сдвиг в 5 единиц стабильно выше этого порога.
Вот упрощённая версия алгоритма:
function applyWatermark(ctx, width, height, deviceFingerprint) { // 1. Рендерим маску: текст с fingerprint по диагонали const mask = createMaskCanvas(width, height); const maskCtx = mask.getContext('2d'); maskCtx.font = '70px sans-serif'; maskCtx.rotate(-30 * Math.PI / 180); // Текст повторяется по диагональной сетке с шагом 300px const text = `app.makcards.online · ${deviceFingerprint} · `; for (let y = -diag; y < diag; y += 300) { for (let x = -diag; x < diag; x += textWidth) { maskCtx.fillText(text, x, y); } } // 2. Попиксельный сдвиг яркости const imageData = ctx.getImageData(0, 0, width, height); const maskData = maskCtx.getImageData(0, 0, width, height); for (let i = 0; i < imageData.data.length; i += 4) { if (maskData.data[i + 3] === 0) continue; // маска пуста – пропускаем const shift = 5 * maskData.data[i + 3] / 255; // плавность на краях текста for (let c = 0; c < 3; c++) { const val = imageData.data[i + c]; // Светлые пиксели темнеют, тёмные светлеют imageData.data[i + c] = val > 128 ? Math.max(0, Math.round(val - shift)) : Math.min(255, Math.round(val + shift)); } } ctx.putImageData(imageData, 0, 0); }
На выходе:
Оригинальный пиксель: RGB(142, 87, 203) После водяного знака: RGB(137, 82, 198) // сдвиг -5 в маскированной области
Если изображение утекло – можно определить, с какого устройства. Не для того чтобы наказать пользователя (хотя и это возможно), а для того чтобы заблокировать устройство и отреагировать на утечку.
Что видит атакующий: до и после
Раньше путь к скачиванию всей колоды выглядел так: открыть DevTools, посмотреть URL первой карты, заметить паттерн нумерации, написать for i in $(seq 1 80); do curl ... done. Тридцать секунд работы.

Теперь для того же результата нужно: зарегистрировать устройство (ECDSA-ключ + подпись), получить JWT, запросить манифест (подписанный запрос), распарсить маппинг тайлов для каждой карты, загрузить по 9 фрагментов на карту (каждый запрос подписан, с nonce), уложиться в rate limit, собрать фрагменты в правильном порядке. Для колоды из 80 карт – это 720 подписанных запросов. При лимите в 1000 тайлов в час это займёт время, оставит чёткий след в логах и приведёт к блокировке устройства.
Причём результатом будет не оригинальный контент, а изображения с вжженным водяным знаком, привязанным к конкретному устройству.
Чего защита НЕ делает
Хочу быть честным про ограничения, потому что security theater – это хуже, чем его отсутствие.
Скриншоты работают. Это неизбежно для любого контента, который показывается на экране. Но скриншотить по одной карте – муторно, качество падает, а водяной знак (видимый) попадает в кадр.
Headless-браузер пройдёт весь флоу. Целенаправленный атакующий с навыками разработки может автоматизировать Puppeteer/Playwright, зарегистрировать устройство и пройти все шаги. Rate limiting по устройству и ASN делает фарм дороже, но slow scraping (по одной карте раз в несколько минут) остаётся возможным. Защита значительно поднимает стоимость атаки, но не делает её невозможной. Задача – сделать так, чтобы проще было купить колоду, чем воровать.
XSS и вредоносные расширения компрометируют origin. Важно понимать: extractable: false защищает от экспорта ключа, но не от злоупотребления подписью в скомпрометированной среде. Если атакующий получает выполнение произвольного JS на нашем домене (через XSS), или если установлено вредоносное расширение с доступом к страни��е – они могут вызывать crypto.subtle.sign() и подписывать запросы тем же ключом. Поэтому для нас критичны строгий CSP (script-src 'self', без inline-скриптов), SRI на бандлы и жёсткие security-заголовки. Это угрозы другого уровня, и закрываются они другими средствами.
Невидимый водяной знак не переживает сильную обработку. Масштабирование, поворот и пережатие с низким качеством могут разрушить паттерн. Но для типичного сценария (скачал → залил в чат → переслал) он работает.
Что происходит, когда утечка обнаружена
Раз уж зашла речь про ограничения, стоит рассказать, что происходит на другом конце – когда утечка уже случилась.
Процесс простой. Берём «утёкшее» изображение, нормализуем контраст, извлекаем паттерн водяного знака – из него получаем 8-символьный hex fingerprint устройства. По fingerprint находим device_id в базе. Дальше видим всю историю: когда устройство зарегистрировано, с какого IP и ASN, какие колоды запрашивало, сколько тайлов загрузило. Если устройство привязано к аккаунту (OAuth) – знаем и пользователя.
Меры реагирования: блокировка устройства (статус blocked – все подписанные запросы начинают возвращать 403), при необходимости блокировка аккаунта, уведомление автора колоды. Заблокированное устройство не может ни запрашивать манифесты, ни загружать тайлы – даже если токен ещё валиден, middleware проверяет статус при каждом запросе. Если невидимый fingerprint не извлекается (изображение сильно пережато или обработано), остаётся видимый домен-водяной знак как минимальная атрибуция.
Технические решения, которые оказались неочевидными
Почему IndexedDB, а не localStorage
CryptoKey с extractable: false можно сохранить только в IndexedDB. Это не вопрос предпочтений – localStorage принимает только строки, а неэкстрагируемый ключ нельзя сериализовать в строку по определению. IndexedDB работает с structured clone algorithm, который умеет хранить CryptoKey напрямую.
Почему ECDSA, а не HMAC
HMAC – это симметричная подпись. Секрет знают обе стороны. Если сервер скомпрометирован – утекает возможность подписывать за любое устройство. С ECDSA на сервере хранится только публичный ключ. Даже при утечке базы данных злоумышленник не сможет подписывать запросы от имени устройств.
Почему не зашифровать тайлы
Был соблазн добавить шифрование – передавать ключ шифрования в манифесте и дешифровать на клиенте. Но это security theater: если ключ передаётся тому же клиенту, который потом дешифрует – это не шифрование, а обфускация. А обфускация добавляет сложность без реальной безопасности. Тайлинг + авторизация + подпись запросов дают более честную защиту.
Почему водяной знак на клиенте, а не на сервере
На сервере пришлось бы хранить оригиналы и генерировать водяные знаки на лету для каждого запроса. Это нагрузка на CPU, необходимость кеширования (теперь уже per-device) и усложнение инфраструктуры. На клиенте водяной знак вжигается в момент сборки тайлов на canvas – это одна операция с пиксельным буфером, которая занимает миллисекунды и не стоит ничего серверу.
Как это ощущается для пользователя
Всё это работает прозрачно. Пользователь открывает ссылку, видит колоду, вытягивает карту. Он не знает ни про ключи, ни про подписи, ни про тайлы.
Немного цифр из реального использования. Типичная сессия – 5–15 карт, это 45–135 тайлов. Лимит в 1000 тайлов в час покрывает ~111 полных карт – хватает с огромным запасом даже для самых интенсивных групповых сессий. Первый запуск приложения: генерация ECDSA-ключа через WebCrypto занимает десятки миллисекунд (зависит от устройства, на среднем Android – порядка 50 мс), регистрация устройства – обычный HTTP round-trip. Загрузка одной карты: 9 тайлов по ~25 КБ каждый, параллельно по HTTP/2 на одном соединении – суммарно ~225 КБ, сравнимо с одним полноразмерным JPEG. Сборка на OffscreenCanvas и вжигание водяного знака делается один раз на карту, результат кешируется как Pixi.js Texture. На десктопе getImageData + проход по пиксельному буферу 900×1350 занимает единицы миллисекунд; на слабых мобилках может быть заметнее, но повторная отрисовка той же карты уже бесплатна. Где OffscreenCanvas недоступен (привет, старые версии Safari), используется обычный canvas на главном потоке. Превью-тамбнейлы (~10–15 КБ) показываются практически мгновенно, пока полноразмерная карта собирается в фоне.
Отдельно пришлось подумать про мобильные устройства. В сессии обычно 5–15 карт, и там полноразмерная сборка из 9 тайлов работает нормально. Но есть режимы, где на экране одновременно десятки или сотни карточек – редактор колод, веер выбора. Собирать полноразмерные текстуры для всех – это убить и память, и FPS. Поэтому на мобильных устройствах редактор показывает только превью (150×225, один HTTP-запрос вместо девяти), а полноразмерная карта собирается из тайлов только когда пользователь реально открывает её. В веере выбора та��бнейлы загружаются лениво – только для видимых карт плюс небольшой буфер, а не для всей колоды сразу. Это одновременно и про производительность, и про то, чтобы не раздавать «ценный» full-res контент без необходимости.
На медленном мобильном соединении задержка между вытягиванием карты и её появлением заметна чуть больше. Но тут помогает UX-приём: карта сначала появляется рубашкой вверх (рубашка – обычный статический файл, загружается моментально), а тайлы лица подгружаются в фоне. К моменту переворачивания карты изображение уже собрано.
Trade-offs: что даёт каждый слой и чего стоит
Слой | Что даёт | Цена | Что НЕ закрывает |
|---|---|---|---|
Подписанные запросы | Нельзя загрузить тайлы без зарегистрированного устройства, replay невозможен | Субмиллисекундная подпись, но +сложность (nonce, Redis roundtrip, верификация) | XSS / вредоносное расширение на том же origin может подписывать запросы |
Тайлинг 3×3 | Нет цельных файлов, пути непредсказуемые | 9 запросов вместо 1, сборка на canvas | Имея манифест, можно собрать скриптом |
Canvas-рендеринг | Нет | Зависимость от Pixi.js / OffscreenCanvas |
|
Per-device watermark | Forensic-трекинг утечки до устройства | Единицы мс CPU на клиенте (один раз, потом кеш) | Не переживает сильную обработку (ресайз + поворот + low quality) |
Rate limiting | Блокирует массовое скачивание | Может задеть легитимного пользователя при edge-case | Headless-браузер с паузами уложится в лимит |
Nginx deny + private cache | Тайлы только через API, нет CDN-утечки | Вся нагрузка на Go API, нет CDN-кеша | – |
Стек
Если кому-то интересно, на чём всё это работает.
Backend: Go, Chi router, PostgreSQL, Redis. Криптографическая верификация подписей – стандартная библиотека crypto/ecdsa. Нарезка тайлов – Node.js скрипт с sharp (запускается на этапе сборки, не в рантайме).
Frontend: React, TypeScript, Pixi.js для canvas-рендеринга. WebCrypto API для генерации ключей и подписей. OffscreenCanvas для сборки тайлов. Состояние на Zustand.
Инфраструктура: Docker Compose, Nginx с hardening-конфигурацией. Деплой – bash-скрипты.
Итого: многослойная защита работает не потому, что каждый слой непробиваемый
Ни один из этих слоёв в отдельности не остановит мотивированного атакующего. Контекстное меню обходится за секунду. Тайлы можно собрать скриптом. Подписи можно автоматизировать через headless-браузер.
Но в совокупности они создают систему, где стоимость атаки растёт мультипликативно. Casual piracy блокируется первыми тремя слоями. Массовое скачивание упирается в rate limiting и подписанные запросы. А если утечка всё-таки произошла – водяной знак позволяет отследить источник.
Это прагматичный подход. Не «абсолютная защита» (которой не существует), а разумный баланс между безопасностью, удобством пользователя и сложностью реализации. Вся система заняла несколько дней разработки – и большая часть времени ушла на тайлинг и водяные знаки, а не на криптографию (которая уже была в платформе для идентификации устройств).
Вопросы к сообществу
Мне правда интересен опыт тех, кто решал похожие задачи.
Если вы защищали изображения на UGC-платформе – что сработало, а что оказалось театром безопасности? Есть ли что-то, что я упустил, или наоборот – добавил зря?
И отдельный вопрос: стоит ли добавлять canary-ассеты (специально размеченные «ловушечные» изображения, по обнаружению которых можно вычислять утечки)? Идея красивая, но я пока не уверен в соотношении затрат и пользы.
Платформа в открытой бете: app.makcards.online
Чат: t.me/makcards_chat
