Передача подписки на ТВ¶
Перенос подписки на Apple TV / Android TV с телефона — из любой сети, даже если устройства находятся на разных концах города.
Ключевое свойство: релей не может прочитать то, что через него передаётся. Ссылка на подписку шифруется на телефоне и расшифровывается только на телевизоре. Сервер видит лишь два непрозрачных блока байтов.
- Адрес:
https://check.incytv.com - Исходный код: INCY-DEV/incy-tv-relay
- Образ: ghcr.io/incy-dev/incy-tv-relay
Код открыт целиком — можно убедиться, что сервер действительно ничего не расшифровывает, и при желании поднять свой релей.
Как это работает¶
На телевизоре появляется 8-значный код. Вы вводите его на телефоне (или сканируете QR) — и подписка переезжает.
Телевизор Релей Телефон
│ │ │
│ занимает код │ │
│───────────────────────>│ │
│ │ │
│ показывает │ читает параметры │
│ K7M2XQ4P + QR │<───────────────────────│
│ │ │
│ │ шифрует подписку │
│ │ ключом из кода │
│ │ │
│ │<─── шифротекст ────────│
│<─── шифротекст ────────│ │
│ │ │
│ расшифровывает │ запись удаляется │
Обе стороны выводят общий ключ шифрования из одного лишь кода — по протоколу CPace. Релей, наблюдая весь обмен целиком, этот ключ вывести не может: он видит только публичные значения, из которых секрет не восстанавливается.
Почему 8 знаков — это достаточно
Обычно короткий код означает слабую защиту. Здесь иначе: перехватив трафик, злоумышленник не может подбирать код у себя на компьютере. Каждая попытка требует обращения к серверу, а там лимит — 5 попыток, после чего код уничтожается.
Поэтому ручной ввод кода так же надёжен, как QR — в обоих случаях передаётся только код, а ключ вычисляется на устройствах.
Что видит сервер¶
| Данные | Хранит | Комментарий |
|---|---|---|
| Ссылка на подписку | нет | зашифрована, ключа у сервера нет |
| Название провайдера | нет | внутри того же шифротекста |
| IP-адреса устройств | нет | не пишутся ни в базу, ни в логи |
| История переносов | нет | запись удаляется сразу после доставки |
| Два блока байтов | 5 минут | затем исчезают автоматически |
Тела запросов не логируются — там шифротекст, но логи имеют свойство утекать, поэтому их просто нет.
Локальный перенос без интернета¶
Если телефон и телевизор в одной сети Wi-Fi, приложение использует прямое
соединение (Bonjour, _incy-tv._tcp) — данные вообще не покидают домашнюю
сеть и не идут через сервер.
Релей включается только тогда, когда прямое соединение невозможно. Разница пользователю не показывается — работает и так, и так.
API для интеграции¶
Своим ботам и панелям можно передавать подписку на телевизор пользователя тем же способом. Все тела — JSON, бинарные поля — base64url без паддинга.
Занять код¶
Вызывает телевизор.
POST /pair/init
Content-Type: application/json
{ "code": "K7M2XQ4P", "sid": "<16 байт>", "ya": "<32 байта>" }
| Ответ | Значение |
|---|---|
204 |
принято |
409 |
код занят — сгенерировать новый и повторить |
400 |
неверный формат кода или точки |
Прочитать параметры¶
Вызывает отправитель.
| Ответ | Значение |
|---|---|
200 |
параметры получены |
404 |
кода нет или истёк |
429 |
превышен лимит попыток, код уничтожен |
Каждый вызов считается попыткой
Это и есть защита от перебора. После 5 обращений код сгорает — не опрашивайте эндпоинт в цикле.
Отправить подписку¶
POST /pair/K7M2XQ4P/send
Content-Type: application/json
{ "yb": "<32 байта>", "ct": "<nonce+шифротекст+тег>" }
| Ответ | Значение |
|---|---|
204 |
принято |
404 |
кода нет или истёк |
409 |
подписка уже отправлена (одноразово) |
413 |
тело больше 64 КиБ |
Забрать подписку¶
Вызывает телевизор. Запрос висит до 30 секунд, ожидая отправителя.
| Ответ | Значение |
|---|---|
200 |
{ "yb": …, "ct": … }, запись сразу удаляется |
204 |
пока ничего — повторить запрос |
404 |
код истёк |
Служебные¶
GET /healthz — жив ли сервис. GET /readyz — готов ли принимать запросы.
Ограничения¶
| Что | Лимит |
|---|---|
| Попыток на один код | 5, дальше код уничтожается |
| Запросов с одного IP | 300 в минуту |
| Размер тела | 64 КиБ |
| Время жизни кода | 5 минут |
Лимит на код важнее лимита на IP: смена адреса от него не спасает, поэтому перебор бессмыслен.
Отдельного лимита на создание кодов нет: за одним внешним адресом (CGNAT оператора, общежитие) могут быть сотни устройств, и почасовой лимит отрезал бы часть пользователей.
Что передавать¶
Внутри шифротекста — JSON:
Поле ct — это nonce ‖ шифротекст ‖ тег одним куском: раскладка combined
у CryptoKit, которую libsodium понимает напрямую. Отдельного поля nonce нет.
Передавайте ссылку на подписку, а не развёрнутый конфиг: телевизор сам её загрузит и получит актуальный список серверов. Так объём на порядки меньше, и конфигурация всегда свежая.
Криптография¶
Для совместимости с приложением параметры должны совпадать байт в байт.
| Параметр | Значение |
|---|---|
| Группа | Ristretto255 |
| Протокол | CPace |
| Шифрование | ChaCha20-Poly1305 |
| Вывод ключа | HKDF-SHA256 |
| Алфавит кода | ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (без I, O, 0, 1) |
generator = ristretto255_from_hash( SHA-512(dsi ‖ code ‖ sid) )
scalar = ristretto255_scalar_reduce( 64 случайных байта )
Y = ristretto255_scalarmult( scalar, generator )
K = ristretto255_scalarmult( scalar, Y_peer )
key = HKDF-SHA256( K ‖ min(Ya,Yb) ‖ max(Ya,Yb), salt=sid, info=dsi, 32 )
dsi = "CPace255-INCY-TVRELAY-v1"
Точки сортируются лексикографически — чтобы обе стороны хешировали их в одном порядке, не договариваясь, кто «первый».
Контрольный вектор для проверки реализации:
generator("TESTCODE", sid = 000102…0f) =
def51453cb5cfdb7d78e667cf7575060841474e063f5e39ea28f14fd9340042f
Библиотеки: libsodium (iOS/tvOS — swift-sodium), lazysodium
(Android — com.goterl:lazysodium-android). Ristretto255 доступен в обеих
без рукописных биндингов.
Проверяйте совместимость тестом
Расхождение ключей между платформами означает, что перенос просто не сработает. Сверьте контрольный вектор выше — это быстрее, чем отлаживать «не расшифровывается».
Пример: бот на Python¶
Отправка подписки на телевизор пользователя по введённому коду.
import base64, json, secrets, httpx
from nacl.bindings import (
crypto_core_ristretto255_from_hash,
crypto_core_ristretto255_scalar_random,
crypto_scalarmult_ristretto255,
)
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
import hashlib
BASE = "https://check.incytv.com"
DSI = b"CPace255-INCY-TVRELAY-v1"
b64d = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
b64e = lambda b: base64.urlsafe_b64encode(b).decode().rstrip("=")
def send_subscription(code: str, url: str, name: str) -> None:
# 1. Читаем параметры телевизора. Это считается попыткой —
# повторять при неудаче нельзя, после 5 раз код сгорит.
r = httpx.get(f"{BASE}/pair/{code}", timeout=10)
if r.status_code == 404:
raise ValueError("код не найден или истёк")
if r.status_code == 429:
raise ValueError("код заблокирован из-за неверных попыток")
r.raise_for_status()
sid, ya = b64d(r.json()["sid"]), b64d(r.json()["ya"])
# 2. Выводим общий ключ из кода — сервер этого сделать не может.
generator = crypto_core_ristretto255_from_hash(
hashlib.sha512(DSI + code.encode() + sid).digest()
)
scalar = crypto_core_ristretto255_scalar_random()
yb = crypto_scalarmult_ristretto255(scalar, generator)
shared = crypto_scalarmult_ristretto255(scalar, ya)
lo, hi = sorted([ya, yb]) # порядок точек одинаков у обеих сторон
key = HKDF(hashes.SHA256(), 32, sid, DSI).derive(shared + lo + hi)
# 3. Шифруем и отправляем.
payload = json.dumps(
{"v": 1, "type": "subscription", "url": url, "name": name}
).encode()
nonce = secrets.token_bytes(12)
# nonce идёт первыми 12 байтами ct — отдельного поля нет.
ct = nonce + ChaCha20Poly1305(key).encrypt(nonce, payload, None)
resp = httpx.post(
f"{BASE}/pair/{code}/send",
json={"yb": b64e(yb), "ct": b64e(ct)},
timeout=10,
)
if resp.status_code == 409:
raise ValueError("на этот код уже отправляли подписку")
resp.raise_for_status()
Зависимости: pynacl, cryptography, httpx.
Пример: бот на Node.js¶
import sodium from 'libsodium-wrappers-sumo';
import { createHash, hkdfSync, randomBytes, createCipheriv } from 'node:crypto';
const BASE = 'https://check.incytv.com';
const DSI = Buffer.from('CPace255-INCY-TVRELAY-v1');
const b64e = (b) => Buffer.from(b).toString('base64url');
const b64d = (s) => Buffer.from(s, 'base64url');
export async function sendSubscription(code, url, name) {
await sodium.ready;
// 1. Параметры телевизора (одна попытка — лимит 5 на код).
const r = await fetch(`${BASE}/pair/${code}`);
if (r.status === 404) throw new Error('код не найден или истёк');
if (r.status === 429) throw new Error('код заблокирован');
if (!r.ok) throw new Error(`релей вернул ${r.status}`);
const { sid: sidB64, ya: yaB64 } = await r.json();
const sid = b64d(sidB64); const ya = b64d(yaB64);
// 2. Общий ключ выводится из кода на устройствах, не на сервере.
const generator = sodium.crypto_core_ristretto255_from_hash(
createHash('sha512').update(Buffer.concat([DSI, Buffer.from(code), sid])).digest(),
);
const scalar = sodium.crypto_core_ristretto255_scalar_random();
const yb = sodium.crypto_scalarmult_ristretto255(scalar, generator);
const shared = sodium.crypto_scalarmult_ristretto255(scalar, ya);
// Лексикографический порядок точек — одинаковый у обеих сторон.
const [lo, hi] = [Buffer.from(ya), Buffer.from(yb)].sort(Buffer.compare);
const key = Buffer.from(hkdfSync(
'sha256', Buffer.concat([Buffer.from(shared), lo, hi]), sid, DSI, 32,
));
// 3. Шифруем и отправляем.
const payload = Buffer.from(JSON.stringify(
{ v: 1, type: 'subscription', url, name },
));
const nonce = randomBytes(12);
const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 });
// nonce первыми 12 байтами ct — отдельного поля нет.
const ct = Buffer.concat([
nonce, cipher.update(payload), cipher.final(), cipher.getAuthTag(),
]);
const resp = await fetch(`${BASE}/pair/${code}/send`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ yb: b64e(yb), ct: b64e(ct) }),
});
if (resp.status === 409) throw new Error('на этот код уже отправляли');
if (!resp.ok) throw new Error(`релей вернул ${resp.status}`);
}
Зависимость: libsodium-wrappers-sumo (обычная сборка libsodium-wrappers
не содержит функций Ristretto255).
Типичные ошибки¶
404 на все запросы. Код истёк — он живёт 5 минут. Попросите пользователя
открыть экран добавления на телевизоре заново.
429 при первом же обращении. Кто-то уже перебирал этот код, и он
уничтожен. Нужен новый код с телевизора.
Телевизор пишет «неверный код». MAC не сошёлся: либо код введён с ошибкой, либо реализация криптографии расходится. Проверьте контрольный вектор.
409 при отправке. На этот код уже отправляли подписку — она одноразовая.
Пустой ответ 204 от /result. Это норма: отправитель ещё не прислал
данные. Повторите запрос, соединение держится до 30 секунд.
Свой релей¶
Сервер не хранит ничего постоянного, поэтому его легко поднять самостоятельно:
git clone https://github.com/INCY-DEV/incy-tv-relay
cd incy-tv-relay
npm install
REDIS_URL=redis://localhost:6379 npm start
Нужен только Redis. В репозитории есть готовые манифесты Kubernetes и
Dockerfile.
Приложение по умолчанию использует check.incytv.com; адрес своего релея
задаётся в настройках сборки.