Скопировали пароль от прода и синхронизировали его между ноутбуком и телефоном. Где он теперь лежит и кто может его прочитать? Я сделал сервис, где честный ответ — «нигде в открытом виде и никто, включая меня». И сейчас покажу строку из живой базы, чтобы это доказать.
Это первая статья про Copy Sync — приватный кроссплатформенный обмен буфером обмена. Я не собираюсь его вам продавать. Я хочу разобрать одну инженерную задачу: как построить сервер, которому физически нечего у вас украсть, даже если им завладеет кто‑то злой — включая меня самого. Весь крипто‑код открыт, и проверить меня можно по ~70 строкам, а не по обещаниям.
TL;DR. Сервер хранит только зашифрованный blob и метаданные маршрутизации (кому доставить, когда протухнет). Шифрование и расшифровка живут на клиенте, в Web Crypto API: X25519 (ECDH) → HKDF‑SHA256 (соль = IV клипа) → AES-256-GCM. Сервер никогда не видит ни приватного ключа, ни байта открытого текста. Realtime — на SSE, хранения нет: клипы удаляются по TTL раз в минуту. Чего пока нет — подписи отправителя и сверки отпечатков ключей; честно разбираю это в конце.
Почему я вообще полез это писать
История банальная. Я скопировал на телефоне токен из СМС, хотел вставить в терминал на Маке — и в очередной раз отправил его себе в Telegram «Избранное». Скопировал ссылку на ноутбуке, хочу открыть на телефоне — снова «Избранное». Это раздражало, и я пошёл искать готовый clipboard‑sync.
Их хватает: Pushbullet, разные облачные менеджеры буфера. Я поставил один, попользовался неделю — и поймал себя на неприятной мысли: я только что прогнал через этот буфер пароль от продакшен‑базы, и он улетел на чей‑то сервер. Дальше я полез читать, как эти штуки устроены, и почти везде увидел одну архитектуру: клиент шлёт содержимое буфера на сервер, сервер хранит и пересылает его другим вашим устройствам. TLS‑шифрование «в транзите» есть почти у всех — но это шифрование канала. На самом сервере данные лежат открытыми.
То есть вопрос «читает ли провайдер мой буфер» сводится к «верю ли я провайдеру». А я не хотел верить на слово — даже себе. Я хотел инструмент, про который можно сказать строго: сервер не читает мои данные не потому, что обещал, а потому, что у него нет ключа. Не нашёл такого под свои требования — и сел писать свой.
Сначала — доказательство, а не слова
Зайдём с конца. Вот что реально оказывается в базе, когда одно моё устройство отправляет клип другому. Это не упрощённая схема для статьи — это определение таблицы clips из исходника, как есть:
@Entity('clips')export class Clip { @PrimaryColumn('uuid') id: string @Column({ name: 'sender_device_id', type: 'uuid' }) senderDeviceId: string @Column({ name: 'recipient_device_id', type: 'uuid' }) recipientDeviceId: string @Column({ name: 'recipient_user_id', type: 'uuid' }) recipientUserId: string // AES-256-GCM ciphertext + 16-байтный auth-тег, склеенные вместе. @Column({ type: 'bytea' }) ciphertext: Buffer // GCM-nonce, ровно 12 байт (проверяется на уровне DTO). @Column({ type: 'bytea' }) iv: Buffer @CreateDateColumn() createdAt: Date @Column({ name: 'ttl_expires_at', type: 'timestamptz' }) ttlExpiresAt: Date}
Прочитайте этот список колонок глазами того, кто захватил сервер и сделал SELECT * FROM clips. Что он получит?
— ciphertext — шифротекст AES-256-GCM. Без ключа это случайный шум.
— iv — публичный одноразовый nonce. Сам по себе бесполезен.
— три *_device_id / user_id — метаданные маршрутизации: кому и от кого доставить.
— ttl_expires_at — когда удалить.
Здесь нет колонки plaintext. И нет ни одной колонки с ключом. Не потому что я её «забыл показать» — её нет в схеме. Сервер по своей конструкции не может записать сюда открытый текст, потому что он его никогда не видит: к моменту, когда данные доходят до сервера, они уже зашифрованы на клиенте. Всё остальное в статье — объяснение, как так вышло.
Главное решение: сервер с нулевым знанием
Copy Sync — это pnpm‑монорепозиторий: сервер на NestJS + PostgreSQL, веб‑клиент на Vue 3, и набор общих пакетов. Дальше будут расширение для браузера, десктоп и мобайл — но сердце всего одно: крошечный крипто‑пакет @copy-sync/crypto, вокруг которого выстроено остальное.
Вся идея держится на одном: сервер не должен ничего знать о содержимом. Остальное — следствия:
— Ключи генерируются на устройстве и не уезжают с него. У каждого устройства своя пара ключей. Сервер получает только публичные.
— Шифрование адресное: один отправитель → один получатель. Отправка «на все мои устройства» — это N независимых шифрований и N строк в таблице. Сервер видит N разных blob’ов и не знает, что это «одно и то же».
— Сервер — труба, а не архив. Доставка идёт через SSE, у клипов есть TTL, протухшие удаляет крон. Долговременного хранилища ваших данных просто нет.
Разберём по очереди — с кодом.
Как клиенты договариваются о ключе, ни разу его не передав
При первом запуске устройство генерирует пару X25519. Публичный ключ уходит на сервер (он на то и публичный), приватный остаётся на устройстве. Вот реальная генерация из keypair.ts:
export async function generateDeviceKeyPair(): Promise<SerializedKeyPair> { const kp = (await crypto.subtle.generateKey({ name: 'X25519' }, true, [ 'deriveBits', ])) as CryptoKeyPair const pubRaw = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey)) const privPkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', kp.privateKey)) return { privateKeyPkcs8: bytesToBase64(privPkcs8), // лежит в keystore устройства publicKeyHex: bytesToHex(pubRaw), // уходит на сервер }}
X25519 — это Diffie‑Hellman на кривой Curve25519. Его единственная задача — дать двум устройствам вычислить общий секрет, ни разу не передав его по сети. Магия ECDH в симметрии: ECDH(priv_A, pub_B) == ECDH(priv_B, pub_A). Каждая сторона берёт свой приватный ключ и чужой публичный — и обе получают один и тот же секрет. Сервер, у которого есть только публичные ключи, вычислить его не может: для этого нужен хотя бы один приватный, а приватных у сервера нет.
Из общего секрета — отдельный ключ на каждый клип
Использовать сырой результат ECDH прямо как ключ шифрования — плохая практика. Правильно прогнать его через KDF. Я беру HKDF‑SHA256 — и вот тут есть приём, которым я доволен: солью для HKDF служит IV конкретного клипа. Вот целиком функция вывода ключа из clip.ts:
const HKDF_INFO = new TextEncoder().encode('copy-sync v1 clip')async function deriveAesKey(myPrivate, peerPubHex, iv, usage) { const peerPub = await crypto.subtle.importKey('raw', hexToBytes(peerPubHex), { name: 'X25519' }, false, []) const sharedBits = await crypto.subtle.deriveBits({ name: 'X25519', public: peerPub }, myPrivate, 256) const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, ['deriveKey']) return crypto.subtle.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: iv, info: HKDF_INFO }, hkdfKey, { name: 'AES-GCM', length: 256 }, false, [usage], )}
IV у каждого клипа случайный — 12 байт из crypto.getRandomValues. Значит, и AES‑ключ получается уникальным на каждое сообщение, хотя общий ECDH‑секрет между парой устройств один. Из этого выпадает мелочь, которая мне нравится: один и тот же текст, отправленный одному и тому же получателю дважды, даёт два совершенно разных шифротекста. Сервер не может даже понять, что вы скопировали одно и то же.
Дальше — симметричное шифрование. AES-256-GCM выбран потому, что это AEAD: он даёт не только конфиденциальность, но и аутентификацию. При расшифровке проверяется 16-байтный тег целостности — подменённый или битый шифротекст просто не расшифруется, а не вернёт мусор.
export async function encryptClip(plaintext, recipientPubHex, myPrivateKey) { const iv = crypto.getRandomValues(new Uint8Array(12)) const aesKey = await deriveAesKey(myPrivateKey, recipientPubHex, iv, 'encrypt') const ct = new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, aesKey, new TextEncoder().encode(plaintext))) return { ciphertext: bytesToBase64(ct), iv: bytesToBase64(iv) }}
На сервер уходит ровно { ciphertext, iv }. Получатель делает зеркальную операцию: тот же ECDH со своей стороны, тот же HKDF с тем же IV, AES-GCM-decrypt. Всё.
Сервер как труба: realtime без хранения
Раз сервер ничего не понимает в данных, логично, чтобы он их и не копил. Доставка идёт через Server‑Sent Events — по одному потоку на устройство, с heartbeat’ом, чтобы прокси не рвал соединение clips.sse.controller.ts:
@Sse()stream(@CurrentDevice() device: Device): Observable<SseMessage> { return merge( this.events.subscribe(device.id).pipe(map((ev) => ({ type: ev.type, data: ev }))), interval(25_000).pipe(map(() => ({ type: 'ping', data: { t: Date.now() } }))), )}
А у каждого клипа есть ttl_expires_at, и крон раз в минуту выносит протухшее, попутно уведомляя получателя, что клип исчез clips.ttl.task.ts:
@Cron(CronExpression.EVERY_MINUTE)async cleanup(): Promise<void> { const deleted = await this.clips.deleteExpired() for (const { id, recipientDeviceId } of deleted) { this.events.publish(recipientDeviceId, { type: 'clip.expired', clipId: id }) }}
Смысл простой: даже зашифрованный blob не должен лежать вечно. Доставили — забыли.
Что это значит на практике
Если убрать крипто‑жаргон, всё сводится к трём бытовым гарантиям:
1. Дамп базы бесполезен. Утечёт хранилище — наружу уйдут шифротексты без ключей. Расшифровать нечем: приватные ключи только на ваших устройствах.
2. Запрос «отдайте данные пользователя X» нечем удовлетворить. У сервера нет открытого текста ни сейчас, ни в истории — он его не видел никогда.
3. Сервер не видит даже повторов и долго ничего не хранит. Случайный IV делает каждый шифротекст уникальным, а TTL удаляет клипы вскоре после доставки.
Это не «мы обещаем не читать». Это «нам физически нечем прочитать».
Честно о границах
Я обещал разбирать, а не продавать, поэтому про слабые места — прямо, до того как их найдут в комментариях:
1. Это v1. Метка copy-sync v1 clip в info HKDF — задел на версионирование протокола, а не справка о зрелости. Код молодой.
2. Подписи отправителя пока нет. Отдельной Ed25519-подписи в текущей версии нет: аутентичность держится на том, что AES‑GCM‑тег проверяется на ключе, выведенном из ECDH именно с публичным ключом конкретного отправителя. Полноценная защита от активного MITM на этапе обмена ключами (verification fingerprints) — в бэклоге.
3. Доверие к публичным ключам. Сейчас вы доверяете тому, что сервер отдал правильные публичные ключи ваших устройств. Сравнение отпечатков ключей между устройствами — следующий приоритет.
4. Приватный ключ хранится сериализованным в keystore устройства (в вебе это localStorage). In‑memory CryptoKey после реимпорта помечен неэкспортируемым, но сам PKCS8-байт лежит в платформенном хранилище — значит, в веб‑модели угроз есть XSS. Это честная цена браузерного клиента, и расширение здесь будет строже веба.
Если я где‑то ошибаюсь в крипто — это лучшее, что может случиться со статьёй. Код открыт, поправим вместе.
Как проверить каждое моё слово
Главное свойство zero‑knowledge архитектуры — её проверяемость: вам не нужно мне верить. Крипто‑ядро — это три маленьких файла, на которые уйдёт минут пятнадцать чтения, не больше:
-
packages/crypto/src/keypair.ts — генерация и (де)сериализация X25519-пары, ~38 строк.
-
packages/crypto/src/clip.ts — шифрование/расшифровка клипа, ~74 строки.
-
packages/crypto/src/encoding.ts — hex/base64-хелперы, без магии.
Что я предлагаю проверить ревьюеру в первую очередь:
1. Что из encryptClip на сервер уходит только { ciphertext, iv } — и ничего больше.
2. Что IV случайный на каждый вызов и используется как соль HKDF.
3. Что приватный ключ реимпортируется неэкспортируемым (importDevicePrivateKey).
4. Что в серверном коде нет ни одного пути, где появлялся бы приватный ключ или открытый текст (начните с entities/clips.entity.ts — там просто негде).
Репозиторий открыт: [gitlab.com/razgiva/copy‑sync](https://gitlab.com/razgiva/copy‑sync), смотреть packages/crypto Нашли дыру — это ценнее любого лайка.
Попробовать и что дальше
Веб‑клиент уже работает: [copysync.ru/app](https://copysync.ru/app). Заводите устройства, копируйте между ними — и помните, что сервер‑посредник не может прочитать ни одного вашего символа.
Дальше по плану — расширение для браузера (MV3, фоновый SSE), синхронизация ключей между устройствами и сверка отпечатков, потом десктоп и мобайл. Про каждый этап буду писать так же: с кодом и с честным разделом про то, чего пока нет.
Если вы безопасник — буду рад разносу: код для того и открыт. И мне правда интересно — где, по‑вашему, в этой схеме самое слабое место?
Это первая статья серии про разработку Copy Sync. Дальше — про MV3-расширение, сверку ключей между устройствами и про то, почему realtime здесь на SSE, а не на WebSocket.
ссылка на оригинал статьи https://habr.com/ru/articles/1044494/